Skip to content

About

A lightweight, cross-platform JNI wrapper that embeds the native TypeBit BitTorrent engine for Android and desktop JVM environments

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Repository files navigation

TypeBitTorrent

A BitTorrent client where the UI is a thin Kotlin/Compose shell and the brain is a no_std + alloc Rust engine. Windows desktop + Android from one codebase. The UI thread has never touched a socket; the engine thread has never touched a composable.

flowchart LR
    subgraph UI["Kotlin / Compose Multiplatform"]
        A[AppRoot · Routing] --> B[AppStore]
        B --> C[AppState]
        D[Screens: Main · Detail · Settings · Search · RSS · Add · Create]
        E[Settings UI · Repositories]
    end
    subgraph BRIDGE["native/ · JNI cdylib"]
        G[Cmd channel mpsc]
        H[Event channel]
        I[Snapshot JSON · one per tick]
    end
    subgraph ENGINE["typebit 0.1.8 engine"]
        J[tick loop]
        K[TorrentSession · Swarm]
        L[DHT · LSD · PEX]
        M[Trackers · WebSeeds]
        N[DiskCache · VerifyPool]
    end
    subgraph HOST["NativeHost (std)"]
        O[TCP/UDP sockets]
        P[.part staging · files]
        Q[HTTP worker · DNS resolver]
        R[Firewall · UPnP · NAT-PMP]
    end
    A --> G; I --> B
    G --> J; J --> K; J --> L; J --> M; J --> N
    K --> O; N --> P; M --> Q; L --> O; R --> J
Loading

index

why

qBittorrent is great and also a giant C++ monolith you cannot embed. libtorrent is a library but the learning curve is a cliff. I wanted an engine that is (a) actually embeddable, (b) small enough to read in an afternoon, and (c) honest about what it does. So typebit exists: a Rust engine with a clean Host trait and zero opinions about pixels.

the stack

composeApp/
  src/commonMain/    UI, store, models, settings, engine facade
  src/jvmShared/     JNI external declarations + JVM helpers
  src/androidMain/   Android entry, platform actuals, manifest
  src/desktopMain/   Desktop entry, AWT, resource loader
native/              Rust JNI bridge (Host impl + worker threads)
scripts/             build-desktop.ps1 · build-android.ps1
docs/                architecture.md · settings.md

user guide

This section is the manual. Read it once, then just use it.

first launch

step desktop android
1 Unzip the portable folder (or run the installer) and start TypeBitTorrent.exe Install the APK, grant network permission when asked
2 The engine spawns on a Rust thread in the background — you'll see live DHT/Tracker counters in the status bar The engine spawns at process start; the status row shows DHT / Tracker / ↓↑ rates
3 Open Settings → Connection and confirm the listen port (default 6881) is forwarded in your router if you want inbound peers Same port; Android holds a WiFi multicast lock automatically for LAN discovery
4 Add your first torrent (below) Add your first torrent (below)

On Windows, run Settings → BitTorrent → Windows 防火墙与共享 → 自动配置 once (needs admin). It opens inbound TCP+UDP for your listen port and UDP 6771 so LSD (BEP-14) LAN discovery works on the same router.

adding a torrent

Hit the + button (desktop toolbar / Android bottom bar 添加). The add dialog is two-phase and qBittorrent-shaped:

flowchart TD
    A[Add dialog] --> B{Source?}
    B -->|.torrent file| C[Parse via engine → preview files]
    B -->|magnet link| D[addMagnetResolve → engine fetches metadata<br/>hold gate ON → nothing downloads yet]
    C --> E[File tree: tri-state checkboxes<br/>per-file priority Skip / Normal / High]
    D --> E
    E --> F[Save path · category · tags · start-now toggle]
    F --> G{start-now?}
    G -->|yes| H[Start immediately]
    G -->|no| I[Add paused]
Loading
  • .torrent file — pick it, the file list appears instantly (parsed by the engine, not guessed).
  • Magnet — paste magnet:?xt=urn:btih:…. The task is created immediately (it's a real task now, closing the dialog won't kill it), metadata is fetched in the background, and the file tree appears as soon as the info arrives. The hold gate keeps it from downloading a single byte until you pick files.
  • File tree — folders collapse/expand, tri-state checkboxes (unchecked = Skip, that file never touches disk, not even a .part), per-file priority dropdown. Uncheck everything you don't want; only your selection is downloaded.
  • Save path / category / tags / 添加后开始下载 — the usual. Categories map to folders if you enable Automatic Torrent Management.
  • Two-phase magnet: if you close the dialog before the metadata resolves, the task stays (paused-ish); when metadata lands it starts. You can also release it to download everything (qBittorrent behavior).

the main screen

Desktop — permanent sidebar with 13 filters + a search box, a toolbar (search / stats / list-grid toggle / settings / create torrent / add), and the torrent table. The detail panel slides in at the bottom when you select a torrent.

Android — bottom navigation: 种子 / 添加 / 搜索 / RSS / 设置. The torrent list shows cards (switch to grid with the view toggle); long-press a card for the action sheet.

Filters (sidebar / chips): 全部 · 下载中 · 做种中 · 已完成 · 正在执行 · 已暂停 · 活跃 · 不活跃 · 已停止 · 停止上传 · 停止下载 · 正在检查 · 出错.

Sort — by name / availability / download speed / upload speed / share ratio (desktop and mobile both have a sort menu).

Row actions (table row context menu / long-press): 重命名, 分享 (magnet / info hash), 暂停 / 继续, 删除 (with confirmation, removes .part staging files too).

Batch — desktop has 全部开始 / 全部暂停 above the list.

the detail panel (5 tabs)

tab what you get
信息 size, progress, piece stats, created-by/at, comment, save dir, ratio, speeds. Unfinished files honestly say "暂存为 .part,校验通过后自动重命名"
文件 full file tree; per-file priority; rename a file while downloading (.part is promoted to the new name at completion); ▶ play on any video file
Tracker every announce URL with tier, live status (工作/更新/异常), seeds & leechers; add / remove trackers at runtime
Peers real connected peers — address, client fingerprint, country flag, progress, ↑↓ rates, in-flight requests; refreshes every 2 s
分块 piece bitfield rendered live — see exactly which pieces are verified

watch while you download

Video files (ts / avi / rmvb / wmv / mp4 / mkv / …) are downloaded head-first, then strictly in file order — banded sequential piece picking; rarity never overrides order. In the 文件 tab hit ▶ on a video:

  • Android — hands the live .part to the system player via a FileProvider content-URI grant.
  • Desktop — Desktop.open() on the .part; your default player starts as soon as the head lands and streams the body as it arrives.
  • The tail (e.g. an MP4 moov atom) is fetched last, so seeking near the end works once it lands.

search

搜索 queries six engines in the background with human-like pacing and returns real magnet links (no browser tabs): Nyaa, 1337x (with mirror fallback), The Pirate Bay (with mirror fallback), plus the Chinese magnet indexes 黑马磁力 / 磁力多 / 搜番. Each engine shows a live status (RUNNING / DONE / BLOCKED / FAILED). Every result has a one-click 添加 that feeds addMagnetEx — straight into the add flow.

The search client sends a browser UA, keeps cookies, and paces requests so it doesn't get you (or the site) rate-limited. Online-video / cloud-drive titles are filtered out of results.

RSS

RSS is a reader, not an auto-downloader: paste any RSS/Atom URL, hit 订阅, it fetches and parses (title, link, description) every refresh, entries open in your browser. Remove a feed with the trash icon. Feeds persist across restarts.

create a torrent

Create (desktop toolbar or Android top-bar icon) → pick files, or a whole folder (recursive walk, empty/zero-byte files skipped, duplicates rejected), then set piece length, name, announce URLs, comment, source tag (BEP-10 style, for private-tracker uploads) and the private flag (BEP-27).

Piece size defaults to whatever lands near 2000 pieces, which is what private trackers expect; 16 KiB … 256 MiB are all selectable. The engine streams SHA-1 across file boundaries in 1 MiB chunks, reports live progress (piece count, bytes, percentage) with a working cancel, and then hands you the real infohash plus buttons to save the .torrent and/or start seeding it. The infohash is computed from the exact bencode that gets written, so what you copy is what the swarm will ask for.

The same creation path backs POST /api/create in the WebUI, so a NAS build produces byte-identical torrents.

"加入并做种" verifies the data it just hashed. The engine starts every session with an empty piece bitfield — it only knows what it downloaded itself — so the files a torrent was built from have to be checked once before the client counts them as "have". Without that pass it would re-download its own source data and never upload anything.

The same verification is available for any data you already own: the per-torrent 校验 button in the WebUI, or 校验本地数据 in the desktop toolbar. It hashes the files under the save path against the metainfo piece hashes, hands the verified set to the engine and the torrent becomes a seed; pieces that do not match (corrupted, half-written) are left out, so only the damaged pieces are re-downloaded.

stats dialog

The bar-chart button opens a live stats dialog (1 s refresh):

  • 用户统计 — global ↓↑ totals, share ratio, cache read-hit rate
  • 缓存统计 — used / budget, clean + dirty entries, discarded bytes
  • 性能统计 — write/read overload indicators
  • 网络统计 — DHT nodes, active trackers, external IP:port (BEP-42), and the LSD counters: lsd_sent / lsd_recv / lsd_peers

Reading the LSD counters (BEP-14 LAN discovery):

  • sent grows → you're announcing.
  • sent grows but recv = 0 on the same router → AP isolation or a firewall blocking inbound UDP 6771 (desktop: re-run the firewall button).
  • recv > 0 but peers = 0 → announces arrive but no shared torrents, or the return path is blocked.

android specifics

  • 锁屏后继续下载 (Settings → 行为): when on, an Android foreground service + wake lock keep the engine alive with the screen off. The 忽略电池优化 button walks you through the system exemption (required on some OEM ROMs for background networking).
  • Back gesture: back from a sub-screen returns to the main screen instead of quitting; back in the detail panel closes it.
  • Where downloads go: the default save path is Android/data/com.typebit.app/files/Download/TypeBitTorrent — an app-private external directory, i.e. writable without MANAGE_EXTERNAL_STORAGE, visible over USB/MTP and covered by the FileProvider, so playing a file mid-download works. Point it at a shared folder with the folder picker if you granted storage access.
  • Play while downloading: tapping a media row in the Files tab resolves the real content URI (.part files are recognised by extension + declared MIME) and opens it with FLAG_GRANT_READ_URI_PERMISSION; a chooser appears only when no app can be resolved.
  • The WiFi multicast lock is acquired in Application.onCreate, before the engine creates any socket — OEM ROMs won't enable multicast retroactively, so LSD works from the very first announce.

nas & webui

The same binary runs headless, which is the point of the Rust/Kotlin split: a NAS has no display but it does have the engine.

typebittorrent --headless --bind=0.0.0.0 --port=18881 \
               --data=/config --downloads=/downloads --password='change-me'

--headless (or TYPEBIT_HEADLESS=1) starts the engine and serves the built-in WebUI; --port / --data / --downloads / --downloads-force / --username / --password override stored settings on boot. TYPEBIT_PASSWORD is the container-friendly alternative. If no password is set anywhere, one is generated and printed once — an unauthenticated client is never served.

The browser UI is a full client, not a status page: transfers, add (magnet / .torrent / URL), per-file priorities and renames, trackers, peers, piece map, global + per-torrent speed limits, settings, engine statistics, search engines, RSS and torrent creation all drive the same AppStore calls the desktop window uses.

Ready-made packaging lives in packaging/:

target path notes
Docker (amd64/arm64) packaging/docker multi-stage, distroless-ish JRE, non-root, /config + /downloads volumes
Unraid packaging/unraid/typebittorrent.xml Container v2 template; /mnt/user/appdata/typebittorrent + your downloads share
飞牛 fnOS packaging/fnos build-fpk.sh produces both the x86_64 and the arm64 .fpk; cmd/main implements start/stop/status/restart, the install wizard sets the WebUI password, and the App Center settings form changes the download folder and password
any Linux scripts/build-linux.sh produces the app image the two above consume

Full instructions, including the reverse-proxy/TLS story and what the packages deliberately do not do, are in docs/nas.md.

the network layer

Four things in here are not "how everyone does it", and each one exists because the previous behaviour lost peers or corrupted data.

A real resolver, not a DoH client. The engine resolves names through the OS resolver, which is the weakest link on a filtered or hijacked network: a poisoned answer leaves every udp:// tracker and every BEP-5 router unreachable, and one blocked domain can hold the whole bootstrap behind it. Resolution is delegated to RecurseX — an iterative resolver that walks down from the root servers with RFC 9156 QNAME minimisation and 0x20 case randomisation, validates DNSSEC wherever a signature chain exists, keeps a multi-tier cache with serve-stale (RFC 8767), and can forward over DoT or DoH when you name an upstream. Writing that here would be strictly worse than using it.

What native/src/dns.rs adds is the part a resolver cannot have, because it is about this engine rather than about DNS:

  • A non-blocking edge. Resolver::resolve blocks by design, and the hooks the engine calls on its own thread (resolve_host / resolve_host_all) must not: they answer from a small memo of the resolver's answers (kept with their TTL, clamped 30 s – 1 h), fall back to the OS resolver immediately, and warm the resolver on a background worker. The next lookup gets the authoritative answer. The tick loop never waits on a name.
  • Single-flight across callers, so six BEP-5 routers bootstrapping at once are one lookup per name.
  • A guard-compatible answer. The SSRF guard's address check reads the memo, so it is decided on the same answers the engine then dials.
  • Upstream normalisation. A forwarder is addressed by IP with the TLS name after #, because a resolver cannot resolve its own name; well-known provider hostnames are mapped to their addresses, and anything else is resolved once at startup. An entry that cannot be used is reported in the log with your own text, never silently dropped or silently interpreted as something else.

It is on by default (Settings → 连接 → 域名解析). Turn it off and the resolver asks no third party anything: it starts at the root servers and walks down, which is the most private mode available here. HTTPS, DoT and plain upstreams are all acceptible in the list; encrypted ones are only used when the platform's trust anchors could be read, because a DoT/DoH forwarder with verification off is an unauthenticated server that can rewrite every answer — worse than the OS resolver it replaced. If they cannot be read, the encrypted entries are dropped, the reason is logged, and resolution continues iteratively.

A guard against torrent-supplied URLs. Every HTTP URL this client fetches comes from something untrusted — a tracker list, a web-seed list, or the .torrent file itself. A client that fetches whatever those strings say is an SSRF primitive aimed at your own machine: http://127.0.0.1:8080/admin, http://[::1]:9200/, http://169.254.169.254/latest/meta-data/. The guard classifies the URL and the address the name resolves to, and refuses loopback, the cloud metadata service and special-use ranges. Private LAN ranges stay allowed by default, because seeding from the NAS next to you is a feature, not an attack — switch that off in the same settings block if you never want a torrent to touch your local network. Redirects are capped at two, so the second half of an SSRF attempt has nowhere to go.

IPv6 that actually works. The shared UDP socket is now dual-stack (:: with IPV6_V6ONLY=0) and v4 peers are unwrapped from their mapped form, so IPv6 DHT nodes and IPv6 UDP trackers are reachable — previously every NetAddr::V6 send failed with EAFNOSUPPORT, which is why the resolver deliberately preferred IPv4. The default gateway is discovered too (Linux /proc/net/route, Windows GetBestRoute), which is what NAT-PMP needs; together with http_post for UPnP SOAP, automatic port mapping works on both kinds of router instead of neither.

HTTP that does not fight itself. Tracker announces and web-seed ranges share one pooled client:

  • HTTP/2 for https:// (one connection, many multiplexed range requests, RFC 9218 priorities per stream) and HTTP/1.1 for http:// — clear-text h2 is not something the other side is guaranteed to speak, and using it silently broke plain-HTTP trackers;
  • Two priority classes: an announce is a few hundred bytes that makes peers appear, so it preempts a queue full of range fetches;
  • Real deadlines from the engine's timeout_ms per request, plus a keep-alive pool, honest User-Agent, and one retry on transport failure;
  • Byte-range validation that cannot corrupt data: a 206 must be exactly the requested window, and a 200 (server ignored Range) is only accepted when it is aligned — the request starts at offset 0 and the declared length is the window. Anything else is refused instead of writing file offset 0 into the middle of the file.

settings reference

Every category mirrors qBittorrent's options dialog. Settings are persisted immediately (no save button), engine-relevant ones are pushed live to the engine on change.

外观 (Appearance)

setting default meaning
主题模式 SYSTEM System / Light / Dark / AMOLED (pure black)
字体 DEFAULT Inter+Noto Sans SC / Roboto / Open Sans / Noto Sans SC / System
壁纸 off blurred + dimmed wallpaper behind the whole UI
模糊半径 24 px wallpaper gaussian radius (pre-blurred once, cached)
遮罩浓度 0.45 black/white readability scrim, 0..0.85
显示模式 Crop Crop (fill) or Fit (letterbox)
垂直偏移 0 wallpaper pan in Crop mode, −1..1
种子色 auto manual Material You seed color override (wins over wallpaper)

行为 (Behavior)

setting default meaning
语言 system UI language
退出/删除确认 on confirmation dialogs
启动最小化 / 最小化到托盘 / 关闭到托盘 off desktop window behaviour
通知 on added / finished / new-version notifications
后台下载 on Android foreground service + wake lock while active
刷新间隔 500 ms UI cadence (also drives the engine poll loop)

下载 (Downloads)

setting default meaning
默认保存路径 empty where torrents land
临时路径 off stage elsewhere, move at completion
预分配磁盘 off (see BitTorrent → 磁盘分配 for the real 3-mode preallocation)
添加后暂停 off add all new torrents paused
最大活跃 下载/上传/总数 3 / 3 / 5 active-torrent limits
自动 Torrent 管理 off category → save path mapping
分享率 / 时间限制 off auto-stop a torrent at ratio / time

连接 (Connection)

setting default meaning
监听端口 6881 BT listen port (random-port mode supported)
最大连接数 500 global connections
每任务最大连接 100 per-torrent cap
上传槽 8 / 4 global / per-torrent unchoke slots
协议 TCP+UDP TCP / UDP only
代理 无 SOCKS5 only (honest: SOCKS4/HTTP settings are stored but never enable a proxy — the engine implements SOCKS5)

域名解析 (Name resolution)

setting default meaning
使用内置解析器 on resolve tracker / DHT-bootstrap names with the built-in recursive resolver (iterative from the root, QNAME minimisation, 0x20, DNSSEC where a chain exists) instead of trusting the OS resolver
上游解析器 Cloudflare, AliDNS, DNSPod one scheme://ip[/path][#tls-name] upstream per line; https://1.1.1.1/dns-query#cloudflare-dns.com (DoH), tls://1.1.1.1#one.one.one.one (DoT), or a bare 223.5.5.5 (plain). Empty = iterate from the root and tell no third party anything
启用 IPv6 on ask for AAAA and let one dual-stack UDP socket carry IPv6 DHT and UDP trackers
允许局域网 Web 种子 on allow fetches that resolve to RFC 1918 / ULA (the NAS case); loopback and the cloud metadata address are refused either way

Encrypted upstreams are verified against the platform trust store, and only used when it could be read: courierust has no implicit store, so the alternative would be an unauthenticated TLS peer. See docs/architecture.md for what that means per platform.

速度 (Speed)

setting default meaning
全局 下载/上传 限速 0 (unlimited) KiB/s hard ceiling (see the tolerance table in the deep-dive)
备用限速 10 MiB / 3 MiB alternative limits
定时切换 off schedule alternative limits by days + time window

BitTorrent

setting default meaning
DHT / PEX / LSD on discovery mechanisms (BEP-5 / BEP-14; the PEX switch is stored only — the engine exposes no PEX config)
UPnP / NAT-PMP on dual-protocol port mapping (both protocols actually mapped)
每任务最大 Peers 80 swarm cap per torrent
请求管线 32 per-peer in-flight 16 KiB blocks (≤512 KiB/peer)
请求超时 20 s per-request timeout, pipeline-aware
最大连续超时 8 consecutive empty windows before a ban
冲突分块 32 endgame duplicates
使用默认 Tracker on community fallback trackers appended to every torrent
反吸血 on fingerprint + reputation + hard bans
屏蔽吸血客户端 on Xunlei/Thunder/FlashGet etc. are never unchoked
额外 Tracker empty one URL per line, applied to running torrents on change
Tracker subscription built-in community list trackerslist URLs (one per line) + refresh interval in hours (0 = manual only); results land in subscribedTrackers and new URLs are pushed to every existing and new torrent
磁盘缓存 256 MiB write-back cache budget
磁盘分配 稀疏 OFF (grow) / SPARSE (reserve, recommended) / FULL (zero-fill)
做种/下载槽 8 / 8 unchoke slots
优化间隔 / 冷落超时 / 重新choke 30 s / 60 s / 10 s leech scheduler
调度权重 α/β/γ/δ 8/2/1/64 utility scheduler (typebit-specific)
内容布局 原始 original / subfolder / no-subfolder

WebUI

The embedded server is shipped; the desktop build starts it on demand and the headless build always serves it.

setting default meaning
启用 WebUI on start/stop the HTTP server (desktop); headless ignores it
端口 18881 listening port (rebound live)
用户名 / 密码 admin / empty credentials; password stored as PBKDF2-HMAC-SHA256
允许局域网访问 off off → 127.0.0.1 only, on → 0.0.0.0
本机免登录 on loopback callers skip authentication
最大登录失败次数 / 封禁时长 5 / 3600 s per-address throttling
会话超时 60 min session TTL
主机头校验 off validate Host (DNS-rebinding defence)
反向代理模式 off trust X-Forwarded-For / X-Forwarded-Proto
启用 HTTPS off advisory: this server is plain HTTP, put TLS in front
CSRF 保护 / 反点击劫持 on / on X-TypeBit header + SameSite=Strict; X-Frame-Options + CSP

高级 (Advanced)

setting default meaning
磁盘缓存 256 MiB alias of BitTorrent cache
保存续传间隔 60 s resume-data write cadence
OS 缓存 on OS-level buffering
套接字积压 / 发送缓冲水位 30 / 512 KiB socket tuning
bdecode 深度/令牌上限 100 / 50M metainfo parse guardrails
最大并发 HTTP announce 50 tracker worker cap
Tracker 失败阈值 / 重试间隔 / 次数 3 / 30 s / 5 tracker backoff policy
Peer 周转间隔 5 s swap cadence

RSS

setting default meaning
刷新间隔 30 min feed refresh cadence
每源最大条目 50 items kept per feed
智能剧集过滤 on episode-aware filtering helper

engine deep-dive

This is where it gets interesting. Everything below is what typebit 0.1.8 actually does — no dashboard numbers, real behaviour.

rate limiting is a hard ceiling, not a suggestion

Global and per-torrent limits are enforced by engine-side token buckets. The upload path obeys a spec that reads like a tax table:

limit allowed overshoot
100 KiB/s 10%
200 KiB/s 9% (then −0.5% per extra 50 KiB)
1 MiB/s 1% (floor)

Burst capacity is derived from the tolerance (burst = rate × tol / 200, clamped to [4 KiB, 1 MiB]), so any one-second window stays inside limit × (1 + tol).

flowchart LR
    A[upload tick] --> B{"global_up bucket<br/>available(now)?"}
    B -->|"yes, min with per-session allowance"| C[send up to N bytes]
    C --> D[subtract from global_up AND session allowance]
    B -->|no| E[wait — no burst-stall]
    F[global_down drained each tick] --> G[shared tick_down_budget on DiskCache]
    G --> H[fill_pipeline draws from shared budget]
    H --> I[lone downloader uses the whole pipe<br/>idle torrents waste nothing]
Loading

The old code divided each tick's allowance by the number of active torrents — a lone downloader could never hit its limit, then burst 10× and go silent. Now the global buckets are the only authority and idle torrents get nothing.

downloads that don't self-destruct

The classic "seed fast, then dead; restart fixes it" failure had three engine-side causes:

  1. Strict block validation was banning healthy seeds. on_piece demanded exact 16 KiB alignment; non-conforming clients tripped a protocol violation, got dropped, got banned. Bans lived in memory, so a restart wiped them — that's why restart "fixed" it. Validation is now lenient (any in-piece window ≤ 16 KiB) and assembly is byte-exact.
  2. Flat timeouts burned deep pipelines. A peer with 32 blocks outstanding legitimately answers the last one late. The timeout is now pipeline-aware (timeout + in_flight × 250 ms), and the ban bar is high: a peer must deliver nothing across 8 full windows.
  3. An empty swarm just sat there. Losing the last ready peer now force-refreshes within 5 s (tracker announce + DHT lookup).

16 MiB pieces can finally pass integrity

Assembly is byte-exact — the session tracks the exact received byte ranges per piece:

stateDiagram-v2
    [*] --> Assembling: on_piece (byte ranges)
    Assembling --> Assembling: new bytes appended (hostile peers<br/>cannot rewrite others' bytes)
    Assembling --> DataComplete: total() >= piece length
    DataComplete --> Verified: SHA-1/SHA-256 match
    DataComplete --> Assembling: hash fail → ranges reset, redownload
    Verified --> [*]: have bit set, flushed to disk
Loading

A short, misaligned, or duplicate block can never leave a zero-filled hole that fails verification forever — which is exactly why large-piece (16 MiB) torrents used to wedge at 99%. Resume only trusts verified have bits; partial pieces re-download.

trackers with manners

  • BEP-12 tiers are preserved (TrackerState.tier) and announced in order: the torrent's own trackers first, config extras and community fallbacks after.
  • BEP-15 UDP with fast retransmit — a lost connect packet is retried every 3 s (up to 3 attempts) before the endpoint rotates and a failure counts:
sequenceDiagram
    participant S as Session
    participant T as UDP tracker
    S->>T: Connect (transaction id)
    Note over S,T: packet lost
    S->>T: Connect retry (3 s)
    S->>T: Connect retry (3 s)
    S->>T: Connect retry (3 s)
    Note over S,T: 3 attempts → rotate endpoint + count failure
    S->>T: Announce
    T-->>S: Response / Error (ACTION_ERROR parsed)
    S->>S: apply peers, reset attempts
Loading

One dropped datagram no longer costs a 15-second stall. v2/hybrid torrents announce the truncated 20-byte hash.

  • BEP-27 UTF-8 names (name.utf-8 / path.utf-8) and the BEP-10 extension handshake (ut_metadata / ut_pex) work.

multi-thread + multi-mirror

  • Multi-thread: every peer runs an independent pipeline (32 × 16 KiB in flight), bounded by a global in-flight window derived from the disk cache budget (budget / 16 KiB, ≥ 64 blocks). A torrent pulls from every peer the swarm offers.
  • Multi-mirror: url-list mirrors are used simultaneously — up to 4 blocks in flight, round-robin across mirrors, per-mirror failure ledgers:
flowchart LR
    A[drive_webseed] --> B{slots < 4?}
    B --> C[pick next mirror round-robin<br/>skip mirrors with fails >= max]
    C --> D[fire async HTTP range job]
    D --> E[on_range_job_done → apply block]
    E --> F{all mirrors failed?}
    F -->|no| B
    F -->|yes| G[backoff retry_at = now + backoff]
    G --> B
Loading

Mirror A never waits for mirror B, and a dead mirror backs off instead of wedging the piece. (An early version retried a dead mirror every tick and hammered DNS at ~2 lookups/sec. Fixed, tested, done.)

discovery — all of it

  • DHT (BEP-5) with bootstrap resilience: survives a bootstrapless first boot, re-bootstraps by real node count (placeholder nodes don't count), refreshes the table with 3-node fan-out, and persists up to 160 nodes.
  • PEX — added / added.f / dropped / added6 / p / p6 exchanged with peers; external port witnesses confirmed by ≥ 2 distinct peers and announced so you're reachable.
  • LSD (BEP-14) — announces from a dedicated port-6771 socket (the spec does this for a reason), with anti-amplification (one unicast reply per source per 10 s) and burst-announce on active-set changes:
flowchart TD
    A[LAN neighbour] -->|BT-SEARCH multicast :6771| B[dedicated 6771 socket]
    B --> C{parse OK + not our own cookie + port != 0?}
    C -->|no| D[drop]
    C -->|yes| E{rate-limited source?}
    E -->|yes| D
    E -->|no| F[unicast reply + enqueue_peer<br/>DiscoverySource::Lsd]
    F --> G[lsd_peers++]
Loading

If sent grows but recv stays 0 on the same router, that's AP isolation or a firewall — not the code. Android grabs the WiFi multicast lock at process start, before any socket exists, because OEM ROMs won't enable multicast retroactively.

  • uTP (BEP-29) with LEDBAT exists in the engine. In practice peers usually negotiate plain TCP, and the wire is plaintext. We're honest about that.

anti-leech that actually bites

Fingerprinting (-XX####- BEP-20 parsing) + reputation + hard bans. Known leech clients (Xunlei, Thunder, FlashGet, …) are never unchoked — they can still upload to us, but can never download from us. Three guardrails keep it fair: LAN/LSD neighbours are never identity-blocked (they're on your wifi), new connections get a probation grace window, and anyone who has contributed more than the free-ride floor isn't blocked regardless of fingerprint.

everything else under the hood

  • Selective download: Vec<Option<DiskId>> files — skipped files are never opened, no .part, no preallocation; runtime re-selection reopens on the next tick; in-flight requests for newly-skipped pieces are cancelled with Cancel immediately.
  • A hold gate for magnets: the session refuses to pick anything until priorities are committed — a magnet can't start hoarding GBs while you're still choosing files.
  • Parallel piece verification (VerifyPool), one batched native snapshot per poll tick instead of N JNI calls, a catch_unwind-wrapped engine tick that reports recovery to the UI instead of dying silently.
  • Windows firewall rules (incl. UDP 6771 for LSD) + ICS sharing control, UPnP + NAT-PMP dual-protocol port mapping.

war stories

  • "Restart fixes it" was an in-memory ban list. Fixing it taught me more about BitTorrent client behaviour than any spec.
  • A 16 MiB piece stuck at 99% wasn't the hash — it was a half-block marked "received" with a gap nobody would ever fill. Byte-exact ranges fixed it.
  • DHT showing 0 after a few minutes was the engine thread dying on a panic while the UI kept polling a corpse. Every engine tick is now wrapped in catch_unwind, and recovery is reported back to the UI.
  • LSD invisible on the same router: three separate causes — a missing Windows firewall rule for UDP 6771, announces sent from the wrong socket, and an Android multicast lock taken too late. All three fixed; the counters in 统计 → 网络统计 prove it live.

honest limitations

  • Not every qBittorrent counter exists. Where the engine can't report a number honestly, the UI shows — instead of inventing one.
  • uTP is implemented but most peers pick TCP; "encryption mode" settings are stored UI — the wire is plaintext.
  • The WebUI speaks plain HTTP. It is designed for a LAN or a reverse proxy that terminates TLS; there is no built-in certificate management, and 启用 HTTPS only flips the Secure cookie flag.
  • The Unraid Community Applications feed requires an OSI-approved license and this project is under PolyForm Perimeter 1.0.0, so the template is shipped for manual install (packaging/unraid) rather than submitted to CA.
  • The search engines are scraped, not API-driven — sites change their HTML and an engine can go BLOCKED until the regex catches up.

build from source

Prereqs: JDK 17, Android SDK + NDK for the .sos, Rust stable (1.95+) with aarch64-linux-android / armv7-linux-androideabi / x86_64-linux-android targets (x86 too, because emulators).

Toolchain: Gradle 9.8, AGP 9.4, Kotlin 2.4, Compose Multiplatform 1.12 (versions live in gradle/libs.versions.toml). AGP 9 no longer accepts com.android.application in a Kotlin Multiplatform module by default, so gradle.properties keeps android.builtInKotlin=false and android.newDsl=false; drop both once the KMP application plugin exists.

# 1. desktop DLL → composeApp/src/desktopMain/resources/native/typebit_native.dll
.\scripts\build-desktop.ps1

# 2. Android → composeApp/src/androidMain/jniLibs/<abi>/libtypebit_native.so
$env:ANDROID_NDK_HOME = "C:\Users\you\AppData\Local\Android\Sdk\ndk\..."
.\scripts\build-android.ps1

# 3. Kotlin
gradlew.bat :composeApp:run             # desktop
gradlew.bat :composeApp:assembleDebug   # Android APK
gradlew.bat :composeApp:createDistributable

Before you ship anything, prove the libraries match the Kotlin bridge:

.\scripts\verify-native.ps1

It compares JNI_ABI (Rust) with EXPECTED_BRIDGE_ABI (Kotlin) and checks that every expect fun native* in NativeBridge.kt is actually exported by the DLL and by all four .sos. Skipping this is how you get an app that compiles, installs, and then dies with no exception the moment it calls into the engine — JNI carries no type information, so a signature drift is not a link error, it is a SIGSEGV. Bump JNI_ABI in the same commit as any native signature or JSON contract change.

build-android.ps1 also runs Gradle through scripts/lib/gradle-env.ps1, which temporarily neutralises a loopback proxy in %USERPROFILE%\.gradle\gradle.properties (the leftover Clash/V2Ray setting that makes every Gradle build fail with "Connection refused" once the proxy is gone) and restores that file byte-for-byte afterwards. Set TYPEBIT_KEEP_GRADLE_PROXY=1 if your proxy is genuinely running.

The desktop DLL ships inside the app jar (native/typebit_native.dll); the Android .sos live in jniLibs. If you dir /s /b | findstr dll the distro and see nothing, that's because the DLL is in the jar. This has confused exactly one person per release. Every release.

Files worth knowing if you are extending this:

path what it is
native/src/make_torrent.rs the torrent builder (BEP-3/12/27) with progress, cancellation and its own tests
native/src/jni_glue.rs the JNI surface; keep it thin — parsing and defaults only
composeApp/src/commonMain/kotlin/com/typebit/engine/TorrentEngine.kt the crash-proof facade: a failing call degrades to a default instead of throwing into a coroutine
composeApp/src/desktopMain/kotlin/com/typebit/webui/WebUiServer.kt the NAS product surface (same store, HTTP instead of pixels)
packaging/ Docker / Unraid / fnOS packaging, built on scripts/build-linux.sh output (the fnOS arm64 payload is cross-assembled by packaging/fnos/build-fpk.sh; scripts/test-fnos-lifecycle.sh covers the fnOS lifecycle scripts)

docs & license

  • docs/architecture.md — the full JNI protocol, threading model, and the tick loop.
  • docs/settings.md — every setting, what drives it, what's stored-only.
  • NOTICE.md — third-party notices.

License: app + bridge + engine — PolyForm Perimeter 1.0.0 (LICENSE, NOTICE.md). Not affiliated with qBittorrent, BitComet, or libtorrent. They do their thing; we do ours.

About

A lightweight, cross-platform JNI wrapper that embeds the native TypeBit BitTorrent engine for Android and desktop JVM environments

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages