# URnetwork SDK 导览

这是[入门指南](/docs/getting-started-sdk)背后的深度导览：每个绑定层如何构成、它出厂的默认值，以及我们已知的锋利边角。一切都跑在 URnetwork 的托管平台上。你带来的是凭证，不是基础设施。对象模型（设备、API、视图控制器）在 [/docs/overview](/docs/overview) 和 [/docs/api](/docs/api)。

一个 Go 核心实现整个客户端：传输、合同、数据包路径。每个产物（AAR、xcframework、wasm、c-shared 库）都是同一个核心的绑定，这就是行为跨平台一致的原因，也是产物偏大的原因（每个都带着 Go 运行时）。核心承载的还有产品规则，不只是数据包。Solana Pay 住在这里（`CreatePaymentReference`、`BuildSolanaPaymentUrl`），因为它曾经不在：Web 应用把支付引用铸成了十六进制 uuid，而 Solana Pay 要求 base58 的 32 字节公钥，于是客户可能付了钱却永远匹配不回账户；Android 则硬编码了金额和商户地址。重新实现一条应用们已经遵守的规则，正是这一层存在要防止的那类 bug。

## 移动端

### DeviceLocal，以及 Apple 上的应用进程拆分

`DeviceLocal` 是真身，运行中的客户端引擎：它拥有传输、合同状态（URnetwork 对一个账户可传输字节的记账），以及数据包路径。`DeviceRemote` 是住在另一个进程或平台上的 `DeviceLocal` 的客户端，API 表面相同：真设备的遥控器。在 Android 上一个进程同时承载 UI 和 `VpnService`，所以应用直接持有 `DeviceLocal`。

Apple 把 VPN 数据包处理放在它自己的沙盒化 NetworkExtension 进程里（每个 iOS/macOS VPN 都这样拆），所以 SDK 跟着拆：

- `NEPacketTunnelProvider` 扩展拥有 `SdkDeviceLocal`，并在它上面调用 `setRpcServer(serverPem, clientCertPem, hostPort)` 启动监听。
- 应用进程创建一个 `SdkDeviceRemote`，经**环回 mTLS 设备 RPC** 连接挂接：镜像的 `setRpcServer(clientPem, serverCertPem, hostPort)`，每一侧先报自己的身份、再报对端的证书。设备 RPC 是 SDK 自己的协议：方法调用加订阅流，走 Go 的 net/rpc，不是 gRPC。环回上的双向 TLS 不是仪式。localhost 每个本地进程和用户都够得着，只有持匹配 PEM 的一方才能挂接。
- **铸密钥材料的是应用，不是扩展。** `SdkGenerateDeviceRpcKeyMaterial()` 每个 VPN 会话返回一对新的自签名服务器和客户端密钥对；应用把两份 PEM 和 host:port 放进 `NETunnelProviderProtocol` 的 **provider configuration**，隧道启动时扩展就是从那里拿到它们的。不涉及任何 App Group 容器。
- 盯住 `RemoteChangeListener`：`remoteConnected` 第一次变成 true，那份材料就被证明有效，把它作为"最后已知可用"持久化，下次启动时重新应用。这就是应用能挂接到已在运行的扩展的原因。

重建是家常便饭：隧道每次重启，扩展就重建它的设备——用户切换 VPN、系统重启扩展，或 iOS 因超出扩展内存上限把它杀掉。第一天就把重挂接接好线（铸材料、启动隧道、`setRpcServer`、重新应用你自己的 UI 状态）。在 `DeviceLocal` 上能做的一切在 `DeviceRemote` 上都能做；remote 代理调用，并在重连之间重放订阅。两个层级：普通的**重连**（同一个设备，RPC 链路断了又回来）会替你重建订阅；**重建**（底下换了一个新设备实例）意味着再跑一遍那套序列。一个陷阱：`DeviceRecreatedListener` 在*设备代数*变化时触发，而只有平台托管的 RPC 路径会盖这个代数戳，所以在环回上，要从你自己的隧道生命周期和 `RemoteChangeListener` 驱动重挂接。

### IoLoop（仅 Android）

`Sdk.newIoLoop(device, detachedFd)` 是数据包泵：

- 传入一个**已分离的、非阻塞的** fd（`ParcelFileDescriptor.detachFd()`）。那次调用之后，**fd 归 Go 所有，并将由 Go 关闭**；绝不要再从 Java 包装或关闭它。两个所有者意味着双重关闭：fd 号立即回收，Java 一侧多余的一次 close 可能踩到下一个拿到这个号的无关描述符，损坏发生在离原因很远的地方。
- 循环在 Go 内部双向泵送（tun→设备和设备→tun），避免了每包一次的 JNI 穿越和缓冲区拷贝。这就是 API 收一个裸 fd 而不是暴露读写方法的原因。
- 停止时关闭 IoLoop（不是 fd）；循环退出时触发完成回调。如果它在你没要求的时候触发——因为 tun fd 遇到 EOF 或错误，或设备关停——把它当作"隧道没了"：结束 `VpnService` 会话，然后重建或显示已断开。回调到达在 SDK 线程上，Go 在退出路上关闭 fd；绝不要在处理器里碰它。

Apple 不用 IoLoop：隧道提供者经 `packetFlow` 搬运数据包，NetworkExtension 提供的唯一接口。每个 iOS VPN 都付这次穿越的成本，它的批量读取把成本摊薄。

### 共享模式，以及密钥材料为何要紧

`SetProvideMode` 控制设备是否向网络提供容量。默认关闭：新构造的设备共享模式是"none"，在你设置一个模式之前什么都不提供，所以嵌入 SDK 绝不会悄悄共享你用户的带宽。今天有实际作用的模式是两个：**public**，以及 **network**——把共享限定在同一 URnetwork 账户的其他设备。协议枚举里还有更多值，但平台把你网络之外的每个对端都归为 public，所以真实的选择是：不共享、只给我自己的设备，或者给任何人。

共享正是**密钥材料持久化**要紧的地方：你在构造时传入的 `DeviceLocalKeyMaterial` *就是*设备的提供者身份，一个客户端密钥种子加上共享 TLS 证书和密钥。把它持久化在平台安全存储里（Keystore、Keychain），每次启动传同一份材料，否则网络每次都看到一个崭新的提供者，丢掉选择机制所偏好的可靠性历史。纯客户端用临时材料（`null`）没问题。

如果你把共享做成界面，把交换讲明白：陌生人的流量从用户的 IP 出网，提供者位置看得到目的地 IP 和 TLS SNI（像 ISP 一样），但默认看不到发起用户的真实 IP。提供者安全是在引擎里做出来的。开源的 ip_security 层检查提供者自己的出口，在 DMCA 类和 CFAA 类流量离开之前把它们丢弃。判定就是丢包，任何地方都不记录目的地、域名或内容。BitTorrent 特征命中还会向运营方发出一个滥用标记，只携带对端设备 id 和一个布尔值，而运营方今天没有处理它的代码；不透明加密的丢弃是静默的。提供者参与 UR protocol；[ur.xyz](https://ur.xyz) 记录了奖励。

### 运行时笔记

- GC 节奏按操作系统自动调好：iOS 上节奏因子 10（扩展内存限制很残酷），Android 上 50，其余 100。你不用设。
- `memoryTargetByteCount` 是另一个旋钮：设备用来*切分*并确定自身缓冲区大小的字节预算，dns 2 : 客户端 14 : 提供者 4，共享关闭时提供者份额垫给客户端那对（默认 20 MB）。它既不是上限也不是 GC 设置：进程级软性占用上限是另一个 `SetMemoryLimit`，硬性击杀是操作系统的扩展上限。把目标设在它之下；让扩展几乎不含逻辑。
- gomobile 绑定在构建里设了闸，机制本身重要：gobind 会默默略过它绑不了的东西，在生成的源码里只留一行 `// skipped` 注释，所以构建会 grep 那些源码，**对任何不在显式白名单上的遗漏直接失败**。允许的跳过都是内部项（RPC gob 载荷、代理/平台表面、gomobile 表达不了的 `uint64`/`[][]byte` 形状）：是有意为之，不是漂移。

## JavaScript

### 为什么 wasm 里没有 DeviceLocal

浏览器页面不可能拥有 tun 接口，所以 wasm 的 `DeviceLocal` 将无物可泵。JS 层只带客户端那一半：API 表面、视图控制器，以及 `DeviceRemote`——一个设备住在别处的完整设备*客户端*。页面持有控制面（连接状态、位置、统计、账户），流量路径则由任何能使用托管代理的东西消费。因此有两个工厂：`createProxyDevice` 是薄模型，解析托管代理 URL，把流量留给消费它们的东西（扩展的代理配置、一个 fetch 智能体）；`createPlatformDeviceRemote` 是厚模型，一个真正的 `DeviceRemote`，带完整的监听器和视图控制器表面，架在平台替你托管的设备上。

### signedProxyId 认证

`createPlatformDeviceRemote` 向 `wss://<proxy>/device-rpc` 打开一条设备 RPC websocket。那条 websocket **不**用账户 JWT 认证（`byJwt`，URnetwork 登录发的 bearer 令牌；"by"是 BringYour 的遗留）。它用 `signedProxyId` 认证，即平台的 `/network/auth-client` 端点随代理 URL 一起返回的 `auth_token`。这是有意的权限分离：签名代理 id 恰好授权一条 websocket，不携带任何账户权限，所以宽权限的令牌从不搭乘数据面套接字。把（`proxyUrl`、`signedProxyId`）当作一份凭证。一起请求、一起传递、一起刷新，代理拒绝套接字时重新请求这一对，而不是缓存其中一半。

### 监听器模式与视图控制器

每个订阅都是同一个形状：`add*ChangeListener(fn)` 返回一个**退订函数**。拿住它，在拆除时调用。在 React 里，把它 return 出去：

```js
useEffect(() => {
  const unsub = device.addConnectChangeListener(setConnectEnabled);
  return unsub;
}, [device]);
```

这经得起 React 严格模式的双重调用效应（add、unsub、add），最后恰好剩一个存活订阅。视图控制器表面（连接、位置、设备、合同、拦截动作）也绑进了 wasm，挂在设备上，如 `device.openConnectViewController()`，所以 Web 应用复用移动应用同一套呈现逻辑，而不是从裸监听器重新推导状态。

### ur.io 如何使用它

ur.io 上的 `/app` 界面是参考消费者：wasm **懒加载**，只在用户到达连接界面时才加载，所以落地页从不付那约 43 MB 的成本，而且每个标签页只有一个 `DeviceRemote`。导出对页面是全局的，所以 `init` 藏在一个单例后面。

浏览器扩展打包了同一份 wasm，但今天并不实例化它：它经平台代理加浏览器自己的代理 API 驱动连接，消费的是 npm 包的**另一个**入口点 `@urnetwork/sdk-js/react`——纯 `fetch` 的 API hook 和生成的类型，不需要 wasm。如果你只要 REST 表面，导入它、永远不调 `init`：加载器在运行时解析 wasm URL，而不是经静态的 `new URL(..., import.meta.url)`，所以打包器不会为不用它的消费者打出它。

## cgo

### 守护进程的拆分

上架的 URnetwork Linux 和 Windows 应用就构建在这一层上，两者用同一个双进程模式——任何要碰 tun 设备的 cgo 集成都该照抄的形状。SDK 提供引擎和 C API；守护进程是你的，那两个已发布的应用是开源参考：

- 一个 **root/服务进程**（systemd 单元、Windows 服务）运行 `DeviceLocal` 并拥有 tun 接口。
- **无特权的 UI 进程**运行一个 `DeviceRemote`，经环回 mTLS 设备 RPC 挂接；SDK 的默认地址是 `127.0.0.1:12025`。
- mTLS 的 PEM 经一条由操作系统亲自授权的通道递给 UI：Linux 上是**经 `SO_PEERCRED` 检查的 unix 套接字**（在 accept 时检查，先于读任何帧），Windows 上是**命名管道**。那次握手，而不是 TCP 端口，才是真正的授权边界；环回 mTLS 只是把其他本地用户挡在端口之外。

### RAII 封装语义（`urnetwork_sdk.hpp`）

- 句柄被包在拥有型类型里，析构函数调用 `urnet_release`。但**释放不等于关闭/停止**：丢掉最后一个包装并不会停止设备或关闭连接。先调 `*_close` / `*_stop`（包装的 `.close()` / `.stop()`），再让包装释放。多个句柄可能指向同一个活对象，所以作用域退出绝不能默默杀死一条活会话。
- 订阅以 `urnet::Sub` 返回；析构函数负责退订。
- 错误以 `urnet::Error` 异常浮出，携带 `out_error` 的文本。

一条硬规则，和人们的预期相反：

```cpp
sub = device.addConnectChangeListener([&](bool enabled) { /* ... */ });
sub.close();   // returns immediately; a callback may still be running
// do NOT free what that lambda captured here
```

监听器列表是写时复制的，所以退订只是移除条目并返回。这是有意的（回调*被允许*在自己内部把自己移除），但它意味着 `Sub` 没了之后，回调可能仍在另一个线程上运行。销毁那个监听器捕获的状态才是真正的崩溃源；用 `shared_ptr` 或一个回调会检查的标志，把它养过拆除期。

泄漏纪律：在一个场景之前对 `urnet_live_handle_count()` 拍快照，跑一遍构造/使用/关闭/释放，断言它回到基线。`cgo/smoke` 干的正是这件事，是照抄的模板。

### 实际绑定了什么

C ABI 是从 Go 表面*生成*的，生成器会写出 `cgo/coverage_report.txt`：每个导出符号，以及每个没有跨过去的符号的原因（Go context、`net.Conn` 内部、函数参数、池所有权调用、RPC gob 类型）。回答"这个能从 C 调吗"，读那个文件，不要读 Go 源码。新的 Go 导出只有在生成器运行后才会到达头文件，所以头文件与切出它的库一起发布。把它们钉在一起。

### 线上协议兼容性

设备 RPC 线上协议是**版本钉住的（`DeviceRpcVersion`，当前为 1）并由本地端在每次同步时强制检查**。它刻意*不是*发布版本：托管的两半独立部署，绑在一起就会在每次服务器部署后拒绝所有浏览器。不匹配不会抛异常。remote 保持未同步并持续重试，看起来和"守护进程没在跑"一模一样。用 `GetRemoteConnected()` 加 `GetSyncError()` 区分它们：同步错误为空意味着还够不到，而 `"device rpc version mismatch: ..."` 或 `"device instance mismatch: ..."` 是重连永远修不好的拒绝。守护进程和 UI 从同一个 SDK 发布版出货。

## 横切事项

### 承载政策的设置

这些标志装的是产品决策，不是调优，它们的默认值就是你用户得到的隐私姿态。一台原装设备出厂时：

| 设置 | 默认 | 效果 | 主要代价 |
|---|---|---|---|
| `SetPerformanceProfile` | nil（自动） | 质量和速度窗口并排运行；流量同时从几个提供者出网（通常 3–8 个），带按站点固定 | 钉住一个 `WindowType` 就收窄到一个窗口 |
| `AllowDirect` | 关 | 保留匿名化跳点，没有任何提供者看到用户的真实 IP | 开：吞吐量更高，且提供者看到用户的真实 IP |
| `PostQuantumEncryption` | 开 | 密封客户端↔提供者会话，运营方中继的是自己读不了的字节 | 密封不了的提供者会被跳过，而非不密封使用 |
| `SetRouteLocal` | 允许 | 隧道断开时流量回落到本地路由 | 禁止就是断网保护：流量改为停住 |
| `SetProvideMode` | none（无） | 设备不向网络提供任何容量 | public 或 network 共享会让他人流量从用户的 IP 出网 |

随表附送的注意事项：

- `AllowDirect` 是速度设置，它暴露的 IP 恰好就是它去掉的那一跳。应用把它反相呈现为"强匿名化"（Strong Anonymization），默认开启，而且托管设备配置上无论调用方设什么都强制它关闭。绝不要把它作为免费的速度呈现给你的用户；把它作为它本来的那笔交换呈现。
- `PostQuantumEncryption` 是应用以"后量子加密"（Post Quantum Encryption）名义发布的端到端客户端↔提供者会话，启动即默认开启且提供者侧就绪：当前所有提供者构建都启用应答一侧。开启期间客户端是故障即关闭（fail-closed）的：它不会明文承载应用数据，密封不了的提供者会被跳过，而不是不密封就用。代价在可用性，不在机密性。把标志关掉，流量才又能走标准路径。
- `SetRouteLocal` 是断网保护（kill switch）的原语（"允许本地流量"）。把它关掉，并把每个 URnetwork 应用都有的那个开关交给用户，让隧道断开时流量停住而不是回落。

提供者对你用户身份的失明是无条件的；运营方对内容的失明默认也成立。两条性质，原装配置都为真，这才让"没有任何单独一方同时握有你用户的身份和活动"这句话说得准确。翻转任何一个标志，你就是把对应的性质交换出去，而不是挣到什么。

那两个标志选出的三种模式，为你所路由的用户陈述如下：

| 模式 | 运营方看到 | 提供者看到 | 如何得到 |
|---|---|---|---|
| **中继密封** | 账户/来源连接、提供者关联、密文及时序/数据量 | 目的地流量和一个设备/合同 id，**不含**用户的真实 IP | 默认：`PostQuantumEncryption` 开，`AllowDirect` 关 |
| **中继标准** | 账户/来源连接、提供者关联、内层目的地和数据包字节 | 目的地流量和一个设备/合同 id，**不含**用户的真实 IP | 仅在 `PostQuantumEncryption` 关闭时 |
| **直连** | 更少的中继参与 | **用户的真实 IP**及目的地流量 | 自行选择开启：`AllowDirect` 开（托管配置上强制关闭） |

默认已经是第一行，什么都不用设。第二行要把 `PostQuantumEncryption` 关掉才会出现：开启期间，客户端遇到密封不了的提供者会跳过，而不是落到那一行。没有逐连接的 API 报告某条会话最终落在哪一行。[威胁模型](/docs/threat-model)把这几行逐一对照具名的对手推演，并明说每一行在哪里失守。

### 线程规则

- 非视图对象（`DeviceLocal`、`DeviceRemote`、`Api`、网络空间）并发安全。从任何线程调用都行。
- **视图控制器是单线程的**，除非某个明确写了不是。每个都从一个线程驱动（通常是你的 UI 线程）。浏览器 JS 天然满足；在 Android、Apple 和 cgo 上会咬人，那里回调到达在 Go 管理的线程上。
- 回调在任意 SDK 管理的线程上触发。碰 UI 之前先封送到你的 UI 线程，而且绝不要在回调里阻塞：`DeviceRemote` 把它的回调串行化在一条带缓冲的通道里，所以一个慢监听器会反压它上面的每个订阅。

### 产物大小

围绕这些数字规划下载和打包预算：

| 产物 | 大小（标注处为压缩后） |
| --- | --- |
| Android AAR | ~37 MB |
| Apple xcframework（zip） | ~116 MB |
| JS wasm | ~43.5 MB |
| Linux c-shared（zip） | ~25 MB |
| Windows c-shared（zip） | ~21 MB |

### API 稳定性

版本基于日期（`vYYYY.M.D-<code>`）；还没有 SemVer 契约，npm 包是 beta 且每夜重发。在 1.0 之前把三个表面都当作移动中的：钉死精确版本，每次升级都读发布说明，并让成对的产物（守护进程和 UI、扩展和应用）保持在同一个发布版上。
