Architecture架构
Four layers, one seam that matters, and a short list of popular libraries that are deliberately absent.四个分层、一道真正重要的接缝,以及一份简短的清单:那些被有意不用的流行库。
The shape整体形状#
The one seam that matters is ConnectivityProvider. The UI never branches on
which implementation is active; it reads a status flow and calls
start/probe/fetch. That is what makes the embedded
node genuinely optional — the default build simply has no implementation registered for
it, and the UI says so instead of crashing — and what would make a third provider (a
different mesh, a relay, a mock for tests) a local change.真正重要的那一道接缝是 ConnectivityProvider。UI 从不对当前生效的是哪个实现做分支
判断;它读一条状态流,并调用 start/probe/fetch。正因如此,
内嵌节点才是真正可选的 —— 默认构建干脆没有为它注册任何实现,UI 会如实说明而不是崩溃 ——
也正因如此,再加第三个 provider(另一种网状网络、一个中继、一个测试用的 mock)只会是一处局部改动。
| Layer分层 | Package包 | Responsibility职责 |
|---|---|---|
| UIUI | ui/setup, ui/theme |
Compose screen, one UiState, one view model. No business rules.Compose 页面、一个 UiState、一个 view model。不含业务规则。 |
| Domain领域 | domain |
What may be dialled, and the ordered test. Pure Kotlin: no Android types, so it is testable on the JVM without Robolectric.什么可以被连接,以及那套有序的测试。纯 Kotlin:不含 Android 类型,所以不用 Robolectric 也能在 JVM 上测试。 |
| Network网络 | net, net/tsnet |
Two ways to reach the target, behind one interface.两种到达目标的方式,藏在同一个接口后面。 |
| Data数据 | data/config, data/crypto |
Persistence, and the Keystore-backed vault.持久化,以及由 Keystore 支撑的保险库。 |
| Native原生 | tailnet/ (Go) |
The embedded node, and the only code that talks to the control plane.内嵌节点,也是唯一与控制面通信的代码。 |
Why the HTTP happens in Go为什么 HTTP 发生在 Go 侧#
This is the least obvious decision in the codebase, so it is worth stating.这是整个代码库里最不显然的一个决定,所以值得说清楚。
The socket has to be created by the tailnet's own network stack — that is what makes the
traffic go through the tunnel. But a Go net.Conn has no Java representation,
so no JNI binding can hand it back. The options were:套接字必须由 tailnet 自己的网络栈创建 —— 正是这一点让流量走隧道。但 Go 的
net.Conn 没有 Java 表示形式,所以任何 JNI 绑定都无法把它交回来。可选方案有:
- run a SOCKS5 proxy on loopback and have OkHttp dial through it;在回环上跑一个 SOCKS5 代理,让 OkHttp 通过它发起连接;
- give the Go side a request-shaped API and return the response bytes.给 Go 侧一个「请求形状」的 API,把响应字节返回回来。
Option 2 was chosen. It removes a listening socket from the process, removes a credential
for that proxy, and keeps the whole path in one place. The cost is that HTTP lives in two
implementations rather than one — which is a real cost, and the reason
ConnectivityProvider.fetch() takes a request object rather than exposing a
client.选了方案 2。它让进程里少了一个监听套接字,少了这个代理所需的凭据,并把整条路径收在一处。
代价是 HTTP 存在于两套实现而不是一套 —— 这是真实的代价,也是
ConnectivityProvider.fetch() 接收一个请求对象、而不是暴露一个 client 的原因。
The binding rules绑定规则#
tailnet/tailnet.go follows one rule: every exported function takes
and returns only String, Int, Bool or
error. Structured data crosses as JSON; byte payloads are base64
inside that JSON.tailnet/tailnet.go 只遵守一条规则:每个导出函数只接受并只返回
String、Int、Bool 或 error。
结构化数据以 JSON 过界;字节载荷是那份 JSON 里的 base64。
That is not stylistic. gomobile cannot express a Go interface or a
net.Conn, and multi-value returns bind differently across gomobile
revisions. Keeping the surface primitive means the generated Java is stable, reviewable,
and independent of the binding generator's mood.这不是风格问题。gomobile 无法表达 Go 的 interface 或 net.Conn,而多返回值在不同
gomobile 修订版之间绑定的方式不一样。把接口面保持为原始类型,意味着生成的 Java 是稳定的、
可审阅的,也不受绑定生成器心情的影响。
Why one provider is loaded reflectively为什么有一个 provider 用反射加载#
TsnetConnectivityProvider compiles against classes that only exist after
gomobile bind. The default build — a fresh clone, and CI without a Go
toolchain — has no such classes. A direct reference from shared code would break the
build for everyone who is not shipping the native bridge.TsnetConnectivityProvider 编译时依赖的类只在 gomobile bind 之后才存在。
默认构建 —— 刚 clone 下来的仓库,以及没有 Go 工具链的 CI —— 没有这些类。从共享代码里直接引用,
会让所有不发布原生桥接层的人的构建都挂掉。
ProviderRegistry therefore resolves the installer with
Class.forName and a single install method, only when
BuildConfig.TSNET_ENABLED is true. A separate Gradle module per provider does
not remove the problem — the consumer still has to name the class — it only moves the
failure.所以 ProviderRegistry 用 Class.forName 加一个 install
方法来解析安装器,而且只在 BuildConfig.TSNET_ENABLED 为真时这么做。给每个 provider
单独一个 Gradle 模块并不能消除这个问题 —— 使用方仍然要写出类名 —— 它只是把失败点挪了个地方。
R8 cannot see the reference, so app/proguard-rules.pro keeps
**.net.tsnet.** explicitly. Without that rule the failure is release-only
and presents as "the embedded node feature silently disappeared" — the worst kind of
bug to chase. If you add a provider, add its keep rule too.R8 看不到这个引用,所以 app/proguard-rules.pro 显式保留
**.net.tsnet.**。没有这条规则,故障只在 release 出现,表现是「内嵌节点功能悄悄消失了」
—— 最难追的那类 bug。如果你要新增 provider,也别忘了给它加 keep 规则。
What is deliberately not there有意不放在那里的东西#
No Hilt不用 Hilt
Four objects, one scope. A framework would add an annotation processor, a plugin, a
generated component, and a second way to construct every object — to remove four
lines of wiring in AppContainer.kt that a reader can follow at a glance.四个对象,一个作用域。引入框架会带来一个注解处理器、一个插件、一个生成的组件,以及构造每个
对象的第二种方式 —— 只为了省掉 AppContainer.kt 里四行接线代码,而那四行读者一眼
就能看懂。
This is a judgement, not a principle. Add a background component or a second graph and the trade flips; the wiring is in one file precisely so the swap is one file.这是判断,不是原则。加一个后台组件或第二张依赖图,这笔交易就会反过来;接线集中在一个文件里, 正是为了让替换也只需改一个文件。
No Retrofit不用 Retrofit
The app makes one kind of request to exactly one host, and it has to go through the provider seam anyway. Retrofit's value is managing a large typed API surface; there is no such surface here.这个 App 只对唯一一个主机发一种请求,而且无论如何都要经过 provider 那道接缝。Retrofit 的价值 在于管理庞大的强类型 API 表面;这里没有这样的表面。
No Room不用 Room
Eleven scalar preferences in DataStore. A relational store would be ceremony around a key-value workload.DataStore 里存放十一个标量偏好设置。关系型存储在这样一个键值型负载周围只是繁文缛节。
No navigation library不用导航库
One screen. Adding a back stack to a single destination is a graph with one node.只有一个页面。给单一目的地加一个回退栈,就是一张只有一个节点的图。
No Timber不用 Timber
One fewer dependency to audit in the code path that handles the credential.
SafeLog is forty lines and does the redaction Timber would not.在处理凭据的那条代码路径上,少一个要审计的依赖。SafeLog 只有四十行,
而且做了 Timber 不会做的脱敏。
No analytics, no crash reporter不接统计,也不接崩溃上报
Not a cost decision. A crash reporter is a network destination that sees your configuration, and this app's whole claim is that it has no such destination.这不是成本考量。崩溃上报是一个能看到你配置的网络目标,而这个 App 的全部主张就是它没有 这样的目标。
State and lifetime状态与生命周期#
Three rules, each with a reason:三条规则,每条都有理由:
-
The plaintext key is never part of
UiState. It lives inkeyDraftonly while being typed, is written to the vault on save, and is read back only for the duration of a connection test. So a state dump, a test assertion, or a screenshot cannot contain it — the state object holds a boolean,hasStoredKey.明文密钥永远不是UiState的一部分。它只在输入期间存在于keyDraft,保存时写入保险库,且只在一次连接测试的持续时间内被读回。所以状态转储、 测试断言或截图都不可能包含它 —— 状态对象里只有一个布尔值hasStoredKey。 -
The object graph is owned by
Application. The root composable reads it from the application context rather than receiving it, which makes a configuration change structurally unable to produce a second graph — and therefore a second embedded node fighting over one state directory.对象图由Application持有。根 composable 从 application context 读取它,而不是通过参数接收,这使得配置变更在结构上无法产生第二张对象图 —— 也就不会出现第二个 内嵌节点争抢同一个状态目录。 -
One provider instance per
ProviderId, created on first request. Nothing eagerly loads the native library, so a build with the bridge in which the user never enables it pays nothing at cold start.每个ProviderId一个 provider 实例,在首次请求时创建。没有任何东西 会急着加载原生库,所以一个带了桥接层、但用户从未启用它的构建,在冷启动时不付任何代价。
Repository layout仓库结构#
Why src/tsnet is a separate source set为什么 src/tsnet 是单独的 source set
app/build.gradle.kts adds src/tsnet/kotlin to the main source
set only when -PwithTsnet=true and app/libs/tailnet.aar exists.
If the property is set and the AAR is missing, the build fails immediately with the
command to produce it — rather than with the ten thousand lines of "unresolved reference:
mobile" that a naive configuration produces.app/build.gradle.kts 只在 -PwithTsnet=true 且
app/libs/tailnet.aar 存在时,才把 src/tsnet/kotlin 加入主 source set。
如果设置了该属性却缺少这个 AAR,构建会立刻失败,并给出生成它的命令 —— 而不是像天真配置那样
产出上万行「unresolved reference: mobile」。
Known gaps已知缺口
Listed so that they are not discovered by you:列在这里,是为了不让你自己去发现它们:
| Gap缺口 | Impact影响 |
|---|---|
| No instrumented tests没有 instrumentation 测试 | Anything needing a real device — Keystore behaviour, the native bridge — is verified by hand, not by CI.任何需要真机的东西 —— Keystore 行为、原生桥接层 —— 都是手工验证,不是 CI 验证。 |
| UI strings are not extractedUI 字符串没有抽取出来 | The app cannot be translated without moving copy out of the Compose sources into resources. This is a deliberate trade: each warning currently sits next to the condition that triggers it.不把文案从 Compose 源码搬进资源文件,就无法翻译这个 App。这是有意的取舍: 目前每条警告就挨着触发它的那个条件。 |
| Per-ABI AAR size is unmeasured各 ABI 的 AAR 体积没有测量 | An embedded tsnet node adds a Go runtime. It is not small, and
abiFilters is not configured.一个内嵌的 tsnet 节点会带上 Go 运行时。它不小,而
abiFilters 没有配置。 |
| No reproducible-build guarantee没有可复现构建的保证 | Release APKs are signed in CI but not verified bit-for-bit reproducible.release APK 在 CI 里签名,但没有逐比特验证其可复现性。 |
| The bridge is compiled, not audited桥接层只是被编译,没有被审计 | tsnet is a large dependency. This project neither vendors nor reviews
it.tsnet 是个大依赖。本项目既不把它 vendor 进来,也不审阅它。 |
Extending it扩展它
A third provider, in four steps:加第三个 provider,分四步:
- Add a value to
ProviderId.给ProviderId加一个值。 - Implement
ConnectivityProvider.实现ConnectivityProvider。 - Register it in
ProviderRegistry.initialise().在ProviderRegistry.initialise()里注册它。 - If it needs a heavy dependency, put it in its own source set and add a
Class.forNamehook plus a ProGuard keep rule — the patterntsnetuses.如果它需要一个重量级依赖,就把它放进自己的 source set,并加一个Class.forName钩子 以及一条 ProGuard keep 规则 —— 即tsnet用的那套模式。
No UI change is required. The provider selector is driven by
ProviderId.entries and the registry's availability list.不需要改 UI。provider 选择器由 ProviderId.entries 和注册表的可用性列表驱动。