feat(ios): P1-A — server touch-points (lastOutputAt, APNs sender+token endpoint) + APIClient P1 contract
T-iOS-37: LiveSessionInfo.lastOutputAt additive-optional field + manager.list() mapping (+4 tests; web consumers unaffected) T-iOS-20: src/push/apns.ts — env-gated (all-or-disabled, no crash, zero key-material logging), hand-rolled ES256 JWT (node:crypto ieee-p1363, zero new deps), NEEDS-INPUT/DONE payloads with structural minimization, token store per subscription-store conventions, combineNotifyServices parallel to web-push, POST/DELETE /push/apns-token per frozen wire shape (G-guard, 5/min/IP, 8kb); 65 tests incl. dual-channel e2e vs local fake APNs T-iOS-38: APIClient builders — apns-token (client-side hex mirror, pre-network reject), projects/detail (lossy decode, single-point percent-encoding), prefs (unknown-key byte-exact round-trip preservation), public four-tier HostNetworkTier Coordination: webterminal:// CFBundleURLTypes pre-registered in project.yml for T-iOS-22 Verified: root 1470 tests + tsc clean; APIClient 76 tests, 95.26% coverage; wire-shape cross-check zero mismatches
This commit is contained in:
@@ -24,6 +24,14 @@
|
||||
|
||||
> 新会话读到的第一块。保持准确,只描述"此刻"。
|
||||
|
||||
### 🚧 iOS 客户端 P1 实施(进行中 — 2026-07-05,分支 `feat/ios-client`)
|
||||
- **[x] P1-A(W6+W7 首发: T-iOS-37 ∥ T-iOS-20 ∥ T-iOS-38,3 builders ∥ + verify 6/6 PASS;`/push/apns-token` 线形状由 orchestrator 预冻结,两侧并行编码零失配——verify 逐项对照 method/path/body key/hex 规则/状态码/守卫/限额全 match)**:
|
||||
- **T-iOS-37 server lastOutputAt**: `LiveSessionInfo.lastOutputAt?: number` 可选增量 + `manager.list()` 一行映射 + 4 测;web 端 grep 零消费者,tsc+全量双证兼容。
|
||||
- **T-iOS-20 server APNs**: `src/push/apns.ts`(576 行)——env 三件套 all-or-disabled(缺失→路由不挂载,启动不 crash,密钥材料零日志)、token store 照抄 subscription-store 约定(幂等/上限/0600)、**手写 ES256 JWT(node:crypto ieee-p1363,零新依赖)**、NEEDS-INPUT(priority 10+WEBTERM_GATE+capability token)/DONE(priority 5)、payload 最小化结构性保证(构造函数只读 meta.id,cwd/命令内容不可能出现)、410/BadDeviceToken 逐出、`combineNotifyServices` 与 web-push 并联互不阻塞;`hasPushTarget` 扩展(仅 APNs token 也能 hold gate)。**65 测**含双 e2e(本地 h2c 假 APNs:held gate→双通道同发同 token→/hook/decision 放行)。新增 `APNS_HOST` env(sandbox 切换+测试零网络)。**npm test 53 files/1470 全绿,tsc 干净**。
|
||||
- **T-iOS-38 APIClient P1 契约**: APNs token builders(hex 校验镜像服务器、联网前拒绝、显式 ASCII 字符集防全角)、projects/detail(**证实线上无 namespace 字段**——纯 web 端分组概念;lossy 解码;percent-encode 单点+裸 + 规避)、prefs(**未知顶层键往返逐字节存活**——防 iOS 覆写 web 偏好,关键陷阱已测;JSONValue 独立 integer case 防 42→42.0)、公开四层 HostNetworkTier(W3 dedup 欠账,PairingVM 后续 pass 可切换)。76/76 绿,覆盖 95.26%。
|
||||
- 遗留(LOW/cosmetic): Swift doc comment 里 server 行号引用漂移;PairingVM 分类去重待后续 pass。
|
||||
- 待续: P1-B UI 九连链(22→24→25→27→26→23→29→28→21,串行,共享 App target)→ P1-C(T-iOS-30 验收)。**协调点**: `webterminal://` scheme 的 CFBundleURLTypes 由 orchestrator 预注册进 project.yml;T-iOS-23 获窄授权在 APIClient LiveSessionInfo 补 lastOutputAt 可选解码(38 已完工,串行无竞态)。
|
||||
|
||||
### ✅ iOS 客户端 P0 实施(完成 — 2026-07-05,分支 `feat/ios-client`,7 commits,未推送/未合并)
|
||||
按 PLAN_IOS_CLIENT §8 批次推进;编排 = 每波一个 ultracode `Workflow`(builder 按任务派发 + 独立 verify agent 复跑验收)。
|
||||
- **[x] W0 基础(T-iOS-1/2/3/4,5 agents,verify 6/6 PASS)**:
|
||||
|
||||
@@ -105,9 +105,9 @@ public struct APIClient: Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Internals
|
||||
// MARK: - Internals (shared with the P1 feature files, T-iOS-38)
|
||||
|
||||
private func perform(_ route: APIRoute) async throws -> (Data, HTTPURLResponse) {
|
||||
func perform(_ route: APIRoute) async throws -> (Data, HTTPURLResponse) {
|
||||
guard let request = route.urlRequest(for: endpoint) else {
|
||||
throw APIClientError.invalidRequest
|
||||
}
|
||||
@@ -115,7 +115,7 @@ public struct APIClient: Sendable {
|
||||
}
|
||||
|
||||
/// 200 → ok; 404 → `.sessionNotFound`; anything else → `.unexpectedStatus`.
|
||||
private static func requireOK(_ response: HTTPURLResponse) throws {
|
||||
static func requireOK(_ response: HTTPURLResponse) throws {
|
||||
switch response.statusCode {
|
||||
case HTTPStatus.ok:
|
||||
return
|
||||
@@ -128,10 +128,12 @@ public struct APIClient: Sendable {
|
||||
}
|
||||
|
||||
/// Named HTTP status codes used by the client (no magic numbers, plan §4).
|
||||
private enum HTTPStatus {
|
||||
enum HTTPStatus {
|
||||
static let ok = 200
|
||||
static let noContent = 204
|
||||
static let badRequest = 400
|
||||
static let forbidden = 403
|
||||
static let notFound = 404
|
||||
static let tooManyRequests = 429
|
||||
static let internalServerError = 500
|
||||
}
|
||||
|
||||
99
ios/Packages/APIClient/Sources/APIClient/ApnsToken.swift
Normal file
99
ios/Packages/APIClient/Sources/APIClient/ApnsToken.swift
Normal file
@@ -0,0 +1,99 @@
|
||||
import Foundation
|
||||
import WireProtocol
|
||||
|
||||
/// APNs device-token registration (T-iOS-38 · frozen wire shape, orchestrator
|
||||
/// ruling — mirrors the `POST /push/subscribe` conventions, src/server.ts:461-480):
|
||||
///
|
||||
/// - `POST /push/apns-token` body `{"token":"<64-160 hex chars, lowercase>"}`
|
||||
/// → **204** (idempotent upsert)
|
||||
/// - `DELETE /push/apns-token` body 同形 → **204** (idempotent; unknown token
|
||||
/// still 204)
|
||||
/// - Both are **G endpoints**: `Origin` guard (`requireAllowedOrigin`) → 403.
|
||||
/// - Server limits: 400 invalid token/shape · **429 rate limit 5/min/IP** ·
|
||||
/// `express.json` body limit **8 KB** (SUBSCRIBE_RATE_MAX, src/server.ts:73).
|
||||
///
|
||||
/// The hex rule is enforced CLIENT-SIDE too (validate at the boundary, plan §4):
|
||||
/// uppercase input is lowercase-normalized, anything not 64–160 hex chars is
|
||||
/// rejected with `.invalidApnsToken` BEFORE any network I/O.
|
||||
enum ApnsTokenRule {
|
||||
/// APNs device tokens are ≥ 32 bytes (64 hex chars) today; the frozen
|
||||
/// shape allows up to 160 hex chars of forward headroom.
|
||||
static let minHexLength = 64
|
||||
static let maxHexLength = 160
|
||||
/// ASCII-only hex alphabet — deliberately NOT `Character.isHexDigit`
|
||||
/// (which also accepts fullwidth digit forms the server regex rejects).
|
||||
private static let hexDigits = Set("0123456789abcdef")
|
||||
|
||||
/// Lowercase-normalize and validate; nil = not a valid wire token.
|
||||
static func normalize(_ raw: String) -> String? {
|
||||
let lowered = raw.lowercased()
|
||||
guard (minHexLength...maxHexLength).contains(lowered.count),
|
||||
lowered.allSatisfy({ hexDigits.contains($0) })
|
||||
else { return nil }
|
||||
return lowered
|
||||
}
|
||||
}
|
||||
|
||||
extension Endpoints {
|
||||
static let apnsTokenPath = "/push/apns-token"
|
||||
|
||||
/// `POST /push/apns-token` — G. `normalized` MUST come from
|
||||
/// `ApnsTokenRule.normalize` (single validation point).
|
||||
static func registerApnsToken(normalized: String) throws -> APIRoute {
|
||||
APIRoute(
|
||||
method: .post, path: apnsTokenPath, originPolicy: .guarded,
|
||||
body: try JSONEncoder().encode(ApnsTokenBody(token: normalized))
|
||||
)
|
||||
}
|
||||
|
||||
/// `DELETE /push/apns-token` — G, idempotent (unknown token still 204).
|
||||
static func unregisterApnsToken(normalized: String) throws -> APIRoute {
|
||||
APIRoute(
|
||||
method: .delete, path: apnsTokenPath, originPolicy: .guarded,
|
||||
body: try JSONEncoder().encode(ApnsTokenBody(token: normalized))
|
||||
)
|
||||
}
|
||||
|
||||
private struct ApnsTokenBody: Encodable {
|
||||
let token: String
|
||||
}
|
||||
}
|
||||
|
||||
extension APIClient {
|
||||
/// Register this device's APNs token (idempotent upsert → 204).
|
||||
/// G endpoint — Origin byte-equal to `endpoint.originHeader`.
|
||||
/// Server limits (documented, enforced server-side): body ≤ 8 KB,
|
||||
/// ≤ 5 requests/min/IP → `.rateLimited`. Invalid hex is rejected
|
||||
/// client-side (`.invalidApnsToken`) before any network I/O.
|
||||
public func registerApnsToken(_ token: String) async throws {
|
||||
try await sendApnsToken(token, build: Endpoints.registerApnsToken(normalized:))
|
||||
}
|
||||
|
||||
/// Unregister a previously registered token (idempotent → 204 even for an
|
||||
/// unknown token). Same G guard and limits as `registerApnsToken`.
|
||||
public func unregisterApnsToken(_ token: String) async throws {
|
||||
try await sendApnsToken(token, build: Endpoints.unregisterApnsToken(normalized:))
|
||||
}
|
||||
|
||||
private func sendApnsToken(
|
||||
_ token: String,
|
||||
build: (String) throws -> APIRoute
|
||||
) async throws {
|
||||
guard let normalized = ApnsTokenRule.normalize(token) else {
|
||||
throw APIClientError.invalidApnsToken
|
||||
}
|
||||
let (_, response) = try await perform(try build(normalized))
|
||||
switch response.statusCode {
|
||||
case HTTPStatus.noContent:
|
||||
return
|
||||
case HTTPStatus.badRequest:
|
||||
throw APIClientError.invalidApnsToken
|
||||
case HTTPStatus.forbidden:
|
||||
throw APIClientError.forbidden
|
||||
case HTTPStatus.tooManyRequests:
|
||||
throw APIClientError.rateLimited
|
||||
default:
|
||||
throw APIClientError.unexpectedStatus(response.statusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import Foundation
|
||||
import WireProtocol
|
||||
|
||||
/// HTTP method whitelist for the six frozen endpoints (plan §3.4).
|
||||
/// HTTP method whitelist for the frozen endpoints (plan §3.4 + T-iOS-38 P1 增量).
|
||||
enum HTTPMethod: String, Sendable {
|
||||
case get = "GET"
|
||||
case post = "POST"
|
||||
case put = "PUT"
|
||||
case delete = "DELETE"
|
||||
}
|
||||
|
||||
@@ -36,10 +37,29 @@ struct APIRoute: Sendable, Equatable {
|
||||
let path: String
|
||||
let originPolicy: OriginPolicy
|
||||
let body: Data?
|
||||
/// Pre-encoded query string (`key=<strictly-percent-encoded value>`), or
|
||||
/// nil for no query. Percent-encoding happens ONCE, in the route builder
|
||||
/// (T-iOS-38: `/projects/detail?path=`) — never at call sites.
|
||||
let percentEncodedQuery: String?
|
||||
|
||||
init(
|
||||
method: HTTPMethod,
|
||||
path: String,
|
||||
originPolicy: OriginPolicy,
|
||||
body: Data?,
|
||||
percentEncodedQuery: String? = nil
|
||||
) {
|
||||
self.method = method
|
||||
self.path = path
|
||||
self.originPolicy = originPolicy
|
||||
self.body = body
|
||||
self.percentEncodedQuery = percentEncodedQuery
|
||||
}
|
||||
|
||||
/// Build the `URLRequest` against `endpoint.baseURL`'s scheme/host/port:
|
||||
/// the path is REPLACED and query/fragment/credentials are dropped — the
|
||||
/// same derivation philosophy as `HostEndpoint.wsURL`. Origin stamping
|
||||
/// the path is REPLACED, the query is REPLACED by `percentEncodedQuery`
|
||||
/// (dropped when nil), fragment/credentials are dropped — the same
|
||||
/// derivation philosophy as `HostEndpoint.wsURL`. Origin stamping
|
||||
/// happens HERE and only here (single point; hand-stamping elsewhere is a
|
||||
/// review CRITICAL, plan §5.1).
|
||||
func urlRequest(for endpoint: HostEndpoint) -> URLRequest? {
|
||||
@@ -51,6 +71,9 @@ struct APIRoute: Sendable, Equatable {
|
||||
components.fragment = nil
|
||||
components.user = nil
|
||||
components.password = nil
|
||||
if let percentEncodedQuery {
|
||||
components.percentEncodedQuery = percentEncodedQuery
|
||||
}
|
||||
guard let url = components.url else { return nil }
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
@@ -66,12 +89,25 @@ struct APIRoute: Sendable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Builders for the six frozen endpoints (plan §3.4). Route table
|
||||
/// (verified against src/server.ts):
|
||||
/// Builders for the frozen endpoints (plan §3.4 + T-iOS-38 P1 增量). Route
|
||||
/// table (verified against src/server.ts):
|
||||
/// - RO — `GET /live-sessions` (:257) · `GET /live-sessions/:id/preview` (:314)
|
||||
/// · `GET /live-sessions/:id/events` (:528) · `GET /config/ui` (:609)
|
||||
/// · `GET /projects` (:262) · `GET /projects/detail?path=` (:293)
|
||||
/// · `GET /prefs` (:273)
|
||||
/// - G — `DELETE /live-sessions/:id` (:354) · `POST /hook/decision` (:503)
|
||||
/// · `PUT /prefs` (:278) · `POST|DELETE /push/apns-token` (frozen T-iOS-20
|
||||
/// shape, mirrors `/push/subscribe` :461-498)
|
||||
/// P1 builders live beside their feature models: `ApnsToken.swift`,
|
||||
/// `Projects.swift`, `Prefs.swift` (T-iOS-38 single owner).
|
||||
enum Endpoints {
|
||||
/// Strict RFC 3986 unreserved set — everything else gets percent-encoded.
|
||||
/// Deliberately stricter than `.urlQueryAllowed`: a bare `+` in a query is
|
||||
/// decoded as a SPACE by Express's qs parser, and `&`/`=` would split the
|
||||
/// parameter — strict encoding removes the whole ambiguity class.
|
||||
static let unreservedCharacters = CharacterSet(
|
||||
charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
||||
)
|
||||
static func liveSessions() -> APIRoute {
|
||||
APIRoute(method: .get, path: "/live-sessions", originPolicy: .readOnly, body: nil)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import Foundation
|
||||
import WireProtocol
|
||||
|
||||
/// The four network tiers of plan §5.4's pairing/warning table — PUBLIC so UI
|
||||
/// layers (PairingViewModel's scheme+address 分层提示, T-iOS-12) consume ONE
|
||||
/// classification instead of re-deriving it (the W3 dedup this type exists for).
|
||||
///
|
||||
/// | tier | 匹配 | §5.4 提示 |
|
||||
/// |---|---|---|
|
||||
/// | `loopback` | localhost · 127.0.0.0/8 · ::1 | 无警告 |
|
||||
/// | `privateLAN` | RFC1918 (10/8, 172.16/12, 192.168/16) · link-local 169.254/16 · mDNS `.local` | ws:// 非阻断明文提示 |
|
||||
/// | `tailscale` | CGNAT 100.64.0.0/10 · MagicDNS `*.ts.net` | 无明文警告(WireGuard 已加密) |
|
||||
/// | `public` | 其余一切(含越界/畸形地址——fail-safe:分不清就当公网,宁可多警告) | 最强阻断式警告 |
|
||||
public enum HostNetworkTier: Sendable, Equatable, CaseIterable {
|
||||
case loopback
|
||||
case privateLAN
|
||||
case tailscale
|
||||
case `public`
|
||||
}
|
||||
|
||||
/// Pure, stateless classifier over a URL host string (plan §5.4 tiers).
|
||||
/// IPv6 note: only loopback `::1` is tiered; other IPv6 literals (incl. ULA
|
||||
/// fd00::/8) fall to `.public` — fail-safe over-warning, matching v1 scope
|
||||
/// (the app dials IPv4 LAN / CGNAT / MagicDNS targets).
|
||||
public enum HostClassifier {
|
||||
private static let magicDNSSuffix = ".ts.net"
|
||||
private static let mdnsSuffix = ".local"
|
||||
private static let loopbackNames: Set<String> = ["localhost", "::1", "[::1]"]
|
||||
|
||||
/// Classify a bare host string (as returned by `URL.host` — no scheme,
|
||||
/// no port). Unknown/malformed input is `.public` by design (fail-safe).
|
||||
public static func classify(host: String) -> HostNetworkTier {
|
||||
let lowered = host.lowercased()
|
||||
if loopbackNames.contains(lowered) {
|
||||
return .loopback
|
||||
}
|
||||
if lowered.hasSuffix(magicDNSSuffix) {
|
||||
return .tailscale
|
||||
}
|
||||
if lowered.hasSuffix(mdnsSuffix) {
|
||||
return .privateLAN
|
||||
}
|
||||
guard let octets = ipv4Octets(lowered) else {
|
||||
return .public
|
||||
}
|
||||
return classifyIPv4(octets)
|
||||
}
|
||||
|
||||
/// Convenience for callers holding a `HostEndpoint` (the VM's shape).
|
||||
public static func classify(endpoint: HostEndpoint) -> HostNetworkTier {
|
||||
classify(host: endpoint.baseURL.host ?? "")
|
||||
}
|
||||
|
||||
// MARK: - IPv4 (shared with PairingError's Local-Network diagnosis)
|
||||
|
||||
private static func classifyIPv4(_ octets: [Int]) -> HostNetworkTier {
|
||||
switch (octets[0], octets[1]) {
|
||||
case (127, _): // loopback 127/8
|
||||
return .loopback
|
||||
case (10, _), (192, 168): // RFC1918 10/8 · 192.168/16
|
||||
return .privateLAN
|
||||
case (172, 16...31): // RFC1918 172.16/12
|
||||
return .privateLAN
|
||||
case (169, 254): // link-local
|
||||
return .privateLAN
|
||||
case (100, 64...127): // CGNAT 100.64/10 (Tailscale)
|
||||
return .tailscale
|
||||
default:
|
||||
return .public
|
||||
}
|
||||
}
|
||||
|
||||
private static let ipv4OctetCount = 4
|
||||
private static let ipv4OctetRange = 0...255
|
||||
|
||||
/// Strict dotted-quad parse; anything else (hostnames, IPv6, out-of-range
|
||||
/// octets, wrong arity) → nil.
|
||||
static func ipv4Octets(_ host: String) -> [Int]? {
|
||||
let parts = host.split(separator: ".", omittingEmptySubsequences: false)
|
||||
guard parts.count == ipv4OctetCount else { return nil }
|
||||
let octets = parts.compactMap { Int($0) }
|
||||
guard octets.count == ipv4OctetCount,
|
||||
octets.allSatisfy({ ipv4OctetRange.contains($0) })
|
||||
else { return nil }
|
||||
return octets
|
||||
}
|
||||
}
|
||||
@@ -159,9 +159,26 @@ public enum APIClientError: Error, Equatable, Sendable {
|
||||
/// token is missing / mismatched / STALE — tokens are single-use and
|
||||
/// expiring by design (SEC-C1/M1). User-facing 话术 in `message`.
|
||||
case decisionRejected
|
||||
/// 429: `POST /hook/decision` is limited to 10 requests/min/IP
|
||||
/// (src/server.ts:72,504-508 — fixed server security policy).
|
||||
/// 429: the endpoint is rate-limited per IP by fixed server policy —
|
||||
/// `POST /hook/decision` ≤ 10/min (src/server.ts:72,504-508);
|
||||
/// `POST|DELETE /push/apns-token` ≤ 5/min (mirrors `/push/subscribe`,
|
||||
/// src/server.ts:73,461-466). Per-endpoint limits live in each call's
|
||||
/// doc comment; the user copy stays limit-agnostic.
|
||||
case rateLimited
|
||||
/// The APNs device token is not 64–160 hex chars (frozen T-iOS-20 wire
|
||||
/// rule) — rejected client-side BEFORE any network I/O, or echoed by the
|
||||
/// server as a 400.
|
||||
case invalidApnsToken
|
||||
/// 400 from `GET /projects/detail` — the `path` query parameter is
|
||||
/// missing/empty (src/server.ts:295-298). Also raised client-side for an
|
||||
/// empty path, before any network I/O.
|
||||
case projectPathInvalid
|
||||
/// 404 from `GET /projects/detail` — no project at that path
|
||||
/// (src/server.ts:301-304; moved/deleted/not a directory).
|
||||
case projectNotFound
|
||||
/// 500 from `GET /projects/detail` — the server failed reading the repo
|
||||
/// (src/server.ts:306-309, body `{error}`).
|
||||
case projectDetailUnavailable
|
||||
/// Any other non-success status code.
|
||||
case unexpectedStatus(Int)
|
||||
|
||||
@@ -179,7 +196,15 @@ public enum APIClientError: Error, Equatable, Sendable {
|
||||
case .decisionRejected:
|
||||
"审批令牌已过期或已被处理——请回到终端里直接批准/拒绝。"
|
||||
case .rateLimited:
|
||||
"操作过于频繁(服务器限 10 次/分钟),请稍后再试。"
|
||||
"操作过于频繁,服务器已限流,请稍后再试。"
|
||||
case .invalidApnsToken:
|
||||
"推送注册令牌格式异常,请重启 App 重新注册推送。"
|
||||
case .projectPathInvalid:
|
||||
"项目路径为空或不合法。"
|
||||
case .projectNotFound:
|
||||
"项目不存在(路径可能已移动或删除)。"
|
||||
case .projectDetailUnavailable:
|
||||
"读取项目详情失败,请稍后再试。"
|
||||
case .unexpectedStatus(let status):
|
||||
"服务器返回了意外状态码 \(status)。"
|
||||
}
|
||||
|
||||
@@ -146,42 +146,22 @@ extension PairingError {
|
||||
(error as NSError).localizedDescription
|
||||
}
|
||||
|
||||
/// LAN-class targets for the `localNetworkDenied` mapping: RFC1918,
|
||||
/// link-local 169.254/16, loopback, CGNAT 100.64/10 (Tailscale), and
|
||||
/// mDNS `.local` names. (T-iOS-12's warning TIERS are separate logic —
|
||||
/// this predicate only gates the Local-Network-permission diagnosis.)
|
||||
/// LAN-class targets for the `localNetworkDenied` mapping — a VIEW over
|
||||
/// `HostClassifier` (T-iOS-38 dedup; plan §5.4 tiers are the single
|
||||
/// classification source):
|
||||
/// - `.loopback` / `.privateLAN` → true (Local-Network-permission gated);
|
||||
/// - `.tailscale` → true only for CGNAT 100.64/10 IPs dialed on a local
|
||||
/// interface; MagicDNS `*.ts.net` rides the WireGuard tunnel, so an
|
||||
/// ENETDOWN there is NOT the Local Network permission → false;
|
||||
/// - `.public` → false (never misdiagnose 权限被拒 for public targets).
|
||||
static func isPrivateOrLocalHost(_ host: String) -> Bool {
|
||||
let lowered = host.lowercased()
|
||||
if lowered == "localhost" || lowered.hasSuffix(".local") {
|
||||
switch HostClassifier.classify(host: host) {
|
||||
case .loopback, .privateLAN:
|
||||
return true
|
||||
}
|
||||
guard let octets = ipv4Octets(lowered) else {
|
||||
return false
|
||||
}
|
||||
switch (octets[0], octets[1]) {
|
||||
case (10, _), (127, _): // RFC1918 10/8 · loopback 127/8
|
||||
return true
|
||||
case (192, 168), (169, 254): // RFC1918 192.168/16 · link-local
|
||||
return true
|
||||
case (172, 16...31): // RFC1918 172.16/12
|
||||
return true
|
||||
case (100, 64...127): // CGNAT 100.64/10 (Tailscale)
|
||||
return true
|
||||
default:
|
||||
case .tailscale:
|
||||
return HostClassifier.ipv4Octets(host.lowercased()) != nil
|
||||
case .public:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private static let ipv4OctetCount = 4
|
||||
private static let ipv4OctetRange = 0...255
|
||||
|
||||
private static func ipv4Octets(_ host: String) -> [Int]? {
|
||||
let parts = host.split(separator: ".", omittingEmptySubsequences: false)
|
||||
guard parts.count == ipv4OctetCount else { return nil }
|
||||
let octets = parts.compactMap { Int($0) }
|
||||
guard octets.count == ipv4OctetCount,
|
||||
octets.allSatisfy({ ipv4OctetRange.contains($0) })
|
||||
else { return nil }
|
||||
return octets
|
||||
}
|
||||
}
|
||||
|
||||
204
ios/Packages/APIClient/Sources/APIClient/Prefs.swift
Normal file
204
ios/Packages/APIClient/Sources/APIClient/Prefs.swift
Normal file
@@ -0,0 +1,204 @@
|
||||
import Foundation
|
||||
import WireProtocol
|
||||
|
||||
// T-iOS-38 · cross-device Projects UI prefs (`GET /prefs` RO · `PUT /prefs` G):
|
||||
// an OPAQUE-BUT-VALIDATED JSON object round-trip. Known keys mirror the web
|
||||
// client loosely (public/prefs.ts sanitizePrefs / src/types.ts:493-496:
|
||||
// `favourites: string[]`, `collapsed: {group-key: true}`); ALL OTHER top-level
|
||||
// keys are preserved verbatim across decode → mutate → encode, so an iOS PUT
|
||||
// can never clobber prefs written by the web client or a future server.
|
||||
// (PUT replaces the WHOLE blob server-side, src/server.ts:278-288 — key
|
||||
// preservation is therefore correctness, not politeness.)
|
||||
|
||||
// MARK: - JSONValue (internal round-trip carrier)
|
||||
|
||||
/// Minimal JSON tree — the storage format that lets unknown prefs keys
|
||||
/// round-trip losslessly. Integers keep a dedicated case so `42` never
|
||||
/// re-encodes as `42.0`.
|
||||
enum JSONValue: Sendable, Equatable, Codable {
|
||||
case null
|
||||
case bool(Bool)
|
||||
case integer(Int)
|
||||
case number(Double)
|
||||
case string(String)
|
||||
case array([JSONValue])
|
||||
case object([String: JSONValue])
|
||||
|
||||
init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
if container.decodeNil() {
|
||||
self = .null
|
||||
} else if let bool = try? container.decode(Bool.self) {
|
||||
self = .bool(bool)
|
||||
} else if let int = try? container.decode(Int.self) {
|
||||
self = .integer(int)
|
||||
} else if let double = try? container.decode(Double.self) {
|
||||
self = .number(double)
|
||||
} else if let string = try? container.decode(String.self) {
|
||||
self = .string(string)
|
||||
} else if let array = try? container.decode([JSONValue].self) {
|
||||
self = .array(array)
|
||||
} else if let object = try? container.decode([String: JSONValue].self) {
|
||||
self = .object(object)
|
||||
} else {
|
||||
throw DecodingError.dataCorruptedError(
|
||||
in: container, debugDescription: "not a JSON value"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func encode(to encoder: any Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
switch self {
|
||||
case .null: try container.encodeNil()
|
||||
case .bool(let value): try container.encode(value)
|
||||
case .integer(let value): try container.encode(value)
|
||||
case .number(let value): try container.encode(value)
|
||||
case .string(let value): try container.encode(value)
|
||||
case .array(let value): try container.encode(value)
|
||||
case .object(let value): try container.encode(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UiPrefs
|
||||
|
||||
/// The prefs blob. Immutable snapshot: `withFavourites`/`withCollapsed` return
|
||||
/// NEW copies that replace exactly one known key and carry every other key
|
||||
/// through untouched (THE round-trip correctness trap — tested).
|
||||
public struct UiPrefs: Sendable, Equatable {
|
||||
/// Full decoded top-level object — known keys included, unknown keys opaque.
|
||||
private let storage: [String: JSONValue]
|
||||
|
||||
private enum Key {
|
||||
static let favourites = "favourites"
|
||||
static let collapsed = "collapsed"
|
||||
}
|
||||
|
||||
init(storage: [String: JSONValue]) {
|
||||
self.storage = storage
|
||||
}
|
||||
|
||||
/// Fresh prefs (e.g. first write from a device that never fetched).
|
||||
public init(favourites: [String] = [], collapsed: [String: Bool] = [:]) {
|
||||
self.init(storage: [
|
||||
Key.favourites: .array(favourites.map(JSONValue.string)),
|
||||
Key.collapsed: .object(collapsed.mapValues(JSONValue.bool)),
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: Known keys (sanitized views — mirror public/prefs.ts sanitizePrefs)
|
||||
|
||||
/// Favourited project paths (★): non-empty strings, de-duplicated,
|
||||
/// original order kept. Wrong-typed entries are dropped, not fatal.
|
||||
public var favourites: [String] {
|
||||
guard case .array(let items)? = storage[Key.favourites] else { return [] }
|
||||
var seen = Set<String>()
|
||||
var out: [String] = []
|
||||
for case .string(let path) in items where !path.isEmpty && seen.insert(path).inserted {
|
||||
out.append(path)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/// Namespace group-key → collapsed. Only literal `true` values count
|
||||
/// (expanded is the default; both web and server sanitizers agree).
|
||||
public var collapsed: [String: Bool] {
|
||||
guard case .object(let entries)? = storage[Key.collapsed] else { return [:] }
|
||||
var out: [String: Bool] = [:]
|
||||
for (key, value) in entries where !key.isEmpty {
|
||||
if case .bool(true) = value { out[key] = true }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MARK: Immutable mutation (unknown keys carried through verbatim)
|
||||
|
||||
/// New snapshot with `favourites` replaced; every other key untouched.
|
||||
public func withFavourites(_ favourites: [String]) -> UiPrefs {
|
||||
var next = storage
|
||||
next[Key.favourites] = .array(favourites.map(JSONValue.string))
|
||||
return UiPrefs(storage: next)
|
||||
}
|
||||
|
||||
/// New snapshot with `collapsed` replaced; every other key untouched.
|
||||
public func withCollapsed(_ collapsed: [String: Bool]) -> UiPrefs {
|
||||
var next = storage
|
||||
next[Key.collapsed] = .object(collapsed.mapValues(JSONValue.bool))
|
||||
return UiPrefs(storage: next)
|
||||
}
|
||||
|
||||
// MARK: Wire round-trip
|
||||
|
||||
/// Decode a `/prefs` body. nil = top level is not a JSON object — callers
|
||||
/// surface `.invalidResponseBody` LOUDLY instead of degrading to empty
|
||||
/// prefs (an empty-based PUT would wipe the server blob).
|
||||
public static func decode(from data: Data) -> UiPrefs? {
|
||||
guard let storage = try? JSONDecoder().decode([String: JSONValue].self, from: data) else {
|
||||
return nil
|
||||
}
|
||||
return UiPrefs(storage: storage)
|
||||
}
|
||||
|
||||
/// Encode the FULL blob (known + unknown keys) for `PUT /prefs`.
|
||||
public func encodeBody() throws -> Data {
|
||||
try JSONEncoder().encode(storage)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Routes + client calls
|
||||
|
||||
extension Endpoints {
|
||||
static let prefsPath = "/prefs"
|
||||
|
||||
/// `GET /prefs` — RO, no Origin (src/server.ts:273-275; no secrets, just
|
||||
/// paths + booleans).
|
||||
static func getPrefs() -> APIRoute {
|
||||
APIRoute(method: .get, path: prefsPath, originPolicy: .readOnly, body: nil)
|
||||
}
|
||||
|
||||
/// `PUT /prefs` — G (CSRF Origin guard). Server-enforced body limit
|
||||
/// **64 KB** (`express.json({limit:'64kb'})`, src/server.ts:278); the body
|
||||
/// is sanitized server-side and echoed back as the 200 response.
|
||||
static func putPrefs(_ prefs: UiPrefs) throws -> APIRoute {
|
||||
APIRoute(
|
||||
method: .put, path: prefsPath, originPolicy: .guarded,
|
||||
body: try prefs.encodeBody()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension APIClient {
|
||||
/// `GET /prefs` — the cross-device favourites/collapse blob. RO → no
|
||||
/// Origin. A non-object body throws `.invalidResponseBody` (never
|
||||
/// silently degrades — see `UiPrefs.decode`).
|
||||
public func prefs() async throws -> UiPrefs {
|
||||
let (data, response) = try await perform(Endpoints.getPrefs())
|
||||
guard response.statusCode == HTTPStatus.ok else {
|
||||
throw APIClientError.unexpectedStatus(response.statusCode)
|
||||
}
|
||||
guard let prefs = UiPrefs.decode(from: data) else {
|
||||
throw APIClientError.invalidResponseBody
|
||||
}
|
||||
return prefs
|
||||
}
|
||||
|
||||
/// `PUT /prefs` — replace the whole blob (body ≤ 64 KB, server rule).
|
||||
/// G → Origin byte-equal; 403 = Origin guard. Returns the server's
|
||||
/// sanitized echo — treat IT as the new source of truth, not the input.
|
||||
@discardableResult
|
||||
public func putPrefs(_ prefs: UiPrefs) async throws -> UiPrefs {
|
||||
let (data, response) = try await perform(try Endpoints.putPrefs(prefs))
|
||||
switch response.statusCode {
|
||||
case HTTPStatus.ok:
|
||||
guard let echoed = UiPrefs.decode(from: data) else {
|
||||
throw APIClientError.invalidResponseBody
|
||||
}
|
||||
return echoed
|
||||
case HTTPStatus.forbidden:
|
||||
throw APIClientError.forbidden
|
||||
default:
|
||||
throw APIClientError.unexpectedStatus(response.statusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
297
ios/Packages/APIClient/Sources/APIClient/Projects.swift
Normal file
297
ios/Packages/APIClient/Sources/APIClient/Projects.swift
Normal file
@@ -0,0 +1,297 @@
|
||||
import Foundation
|
||||
import WireProtocol
|
||||
|
||||
// T-iOS-38 · Projects contract increments (both RO — NO Origin header):
|
||||
// - `GET /projects` (src/server.ts:262-269) → `[ProjectInfo]`, mirrors
|
||||
// src/types.ts:273-281 field-for-field. The server replies `[]` even on
|
||||
// internal failure — a non-array body is therefore "not web-terminal".
|
||||
// - `GET /projects/detail?path=` (src/server.ts:293-310) → `ProjectDetail`
|
||||
// (src/types.ts:296-306). `path` percent-encoding happens ONCE, in the
|
||||
// builder. 400/404/500 `{error}` map to explicit typed errors.
|
||||
// Tolerant decoding at the untrusted server boundary (plan §4): unknown
|
||||
// fields ignored, malformed entries dropped one by one, never crash.
|
||||
|
||||
// MARK: - ProjectSessionRef
|
||||
|
||||
/// One running session belonging to a project (src/types.ts:262-269; the
|
||||
/// server mirrors LiveSessionInfo fields into this ref).
|
||||
public struct ProjectSessionRef: Sendable, Equatable {
|
||||
public let id: UUID
|
||||
/// Derived label (last cwd segment, e.g. repo dir name); optional.
|
||||
public let title: String?
|
||||
public let status: ClaudeStatus
|
||||
public let clientCount: Int
|
||||
public let createdAt: Int
|
||||
public let exited: Bool
|
||||
|
||||
public init(
|
||||
id: UUID, title: String?, status: ClaudeStatus,
|
||||
clientCount: Int, createdAt: Int, exited: Bool
|
||||
) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.status = status
|
||||
self.clientCount = clientCount
|
||||
self.createdAt = createdAt
|
||||
self.exited = exited
|
||||
}
|
||||
}
|
||||
|
||||
extension ProjectSessionRef: Decodable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, title, status, clientCount, createdAt, exited
|
||||
}
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decode(UUID.self, forKey: .id)
|
||||
clientCount = try container.decode(Int.self, forKey: .clientCount)
|
||||
createdAt = try container.decode(Int.self, forKey: .createdAt)
|
||||
exited = try container.decode(Bool.self, forKey: .exited)
|
||||
// Same tolerance as LiveSessionInfo: a future status value must not
|
||||
// drop the ref; a wrong-typed title degrades to nil.
|
||||
let rawStatus = try? container.decode(String.self, forKey: .status)
|
||||
status = rawStatus.flatMap(ClaudeStatus.init(rawValue:)) ?? .unknown
|
||||
title = try? container.decode(String.self, forKey: .title)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ProjectInfo
|
||||
|
||||
/// A discovered project (git repo or recently-used cwd) — src/types.ts:273-281.
|
||||
/// Note: there is NO `namespace` field on the wire; namespace grouping is a
|
||||
/// web-client concept that only surfaces as `UiPrefs.collapsed` group-keys.
|
||||
public struct ProjectInfo: Sendable, Equatable {
|
||||
public let name: String
|
||||
public let path: String
|
||||
public let isGit: Bool
|
||||
public let branch: String?
|
||||
/// Uncommitted changes; only present when the server runs the dirty check.
|
||||
public let dirty: Bool?
|
||||
/// Newest `~/.claude/projects` mtime for this cwd (ms) — the sort key.
|
||||
public let lastActiveMs: Int?
|
||||
public let sessions: [ProjectSessionRef]
|
||||
|
||||
public init(
|
||||
name: String, path: String, isGit: Bool, branch: String?,
|
||||
dirty: Bool?, lastActiveMs: Int?, sessions: [ProjectSessionRef]
|
||||
) {
|
||||
self.name = name
|
||||
self.path = path
|
||||
self.isGit = isGit
|
||||
self.branch = branch
|
||||
self.dirty = dirty
|
||||
self.lastActiveMs = lastActiveMs
|
||||
self.sessions = sessions
|
||||
}
|
||||
}
|
||||
|
||||
extension ProjectInfo: Decodable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case name, path, isGit, branch, dirty, lastActiveMs, sessions
|
||||
}
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
name = try container.decode(String.self, forKey: .name)
|
||||
path = try container.decode(String.self, forKey: .path)
|
||||
isGit = try container.decode(Bool.self, forKey: .isGit)
|
||||
branch = try? container.decode(String.self, forKey: .branch)
|
||||
dirty = try? container.decode(Bool.self, forKey: .dirty)
|
||||
lastActiveMs = try? container.decode(Int.self, forKey: .lastActiveMs)
|
||||
sessions = LossyList.decode(ProjectSessionRef.self, in: container, forKey: .sessions)
|
||||
}
|
||||
|
||||
/// Decode a `/projects` response body — non-array top level →
|
||||
/// `invalidResponseBody`; malformed elements dropped one by one (same
|
||||
/// pattern as `LiveSessionInfo.decodeList`).
|
||||
static func decodeList(from data: Data) throws -> [ProjectInfo] {
|
||||
guard let entries = try? JSONDecoder().decode([LossyBox<ProjectInfo>].self, from: data) else {
|
||||
throw APIClientError.invalidResponseBody
|
||||
}
|
||||
return entries.compactMap(\.value)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - WorktreeInfo / ProjectDetail
|
||||
|
||||
/// One `git worktree list --porcelain` entry (src/types.ts:284-292).
|
||||
public struct WorktreeInfo: Sendable, Equatable {
|
||||
public let path: String
|
||||
/// Branch name; nil on detached HEAD.
|
||||
public let branch: String?
|
||||
public let head: String?
|
||||
public let isMain: Bool
|
||||
public let isCurrent: Bool
|
||||
public let locked: Bool?
|
||||
public let prunable: Bool?
|
||||
|
||||
public init(
|
||||
path: String, branch: String?, head: String?,
|
||||
isMain: Bool, isCurrent: Bool, locked: Bool?, prunable: Bool?
|
||||
) {
|
||||
self.path = path
|
||||
self.branch = branch
|
||||
self.head = head
|
||||
self.isMain = isMain
|
||||
self.isCurrent = isCurrent
|
||||
self.locked = locked
|
||||
self.prunable = prunable
|
||||
}
|
||||
}
|
||||
|
||||
extension WorktreeInfo: Decodable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case path, branch, head, isMain, isCurrent, locked, prunable
|
||||
}
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
path = try container.decode(String.self, forKey: .path)
|
||||
branch = try? container.decode(String.self, forKey: .branch)
|
||||
head = try? container.decode(String.self, forKey: .head)
|
||||
isMain = (try? container.decode(Bool.self, forKey: .isMain)) ?? false
|
||||
isCurrent = (try? container.decode(Bool.self, forKey: .isCurrent)) ?? false
|
||||
locked = try? container.decode(Bool.self, forKey: .locked)
|
||||
prunable = try? container.decode(Bool.self, forKey: .prunable)
|
||||
}
|
||||
}
|
||||
|
||||
/// Detailed view of one project (src/types.ts:296-306).
|
||||
public struct ProjectDetail: Sendable, Equatable {
|
||||
public let name: String
|
||||
public let path: String
|
||||
public let isGit: Bool
|
||||
public let branch: String?
|
||||
public let dirty: Bool?
|
||||
public let worktrees: [WorktreeInfo]
|
||||
public let sessions: [ProjectSessionRef]
|
||||
public let hasClaudeMd: Bool
|
||||
/// CLAUDE.md content (server-truncated for display) when present.
|
||||
public let claudeMd: String?
|
||||
|
||||
public init(
|
||||
name: String, path: String, isGit: Bool, branch: String?, dirty: Bool?,
|
||||
worktrees: [WorktreeInfo], sessions: [ProjectSessionRef],
|
||||
hasClaudeMd: Bool, claudeMd: String?
|
||||
) {
|
||||
self.name = name
|
||||
self.path = path
|
||||
self.isGit = isGit
|
||||
self.branch = branch
|
||||
self.dirty = dirty
|
||||
self.worktrees = worktrees
|
||||
self.sessions = sessions
|
||||
self.hasClaudeMd = hasClaudeMd
|
||||
self.claudeMd = claudeMd
|
||||
}
|
||||
}
|
||||
|
||||
extension ProjectDetail: Decodable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case name, path, isGit, branch, dirty, worktrees, sessions, hasClaudeMd, claudeMd
|
||||
}
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
name = try container.decode(String.self, forKey: .name)
|
||||
path = try container.decode(String.self, forKey: .path)
|
||||
isGit = try container.decode(Bool.self, forKey: .isGit)
|
||||
branch = try? container.decode(String.self, forKey: .branch)
|
||||
dirty = try? container.decode(Bool.self, forKey: .dirty)
|
||||
hasClaudeMd = (try? container.decode(Bool.self, forKey: .hasClaudeMd)) ?? false
|
||||
claudeMd = try? container.decode(String.self, forKey: .claudeMd)
|
||||
worktrees = LossyList.decode(WorktreeInfo.self, in: container, forKey: .worktrees)
|
||||
sessions = LossyList.decode(ProjectSessionRef.self, in: container, forKey: .sessions)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Lossy decoding helpers (shared per-element tolerance)
|
||||
|
||||
/// Per-element tolerance shim: a malformed element becomes nil instead of
|
||||
/// failing the whole array (same pattern as WireProtocol's TimelineEvent).
|
||||
struct LossyBox<Wrapped: Decodable>: Decodable {
|
||||
let value: Wrapped?
|
||||
|
||||
init(from decoder: any Decoder) {
|
||||
value = try? Wrapped(from: decoder)
|
||||
}
|
||||
}
|
||||
|
||||
enum LossyList {
|
||||
/// Decode `[Element]` at `key`, dropping malformed elements; a missing or
|
||||
/// wrong-typed array degrades to `[]`.
|
||||
static func decode<Element: Decodable, Key: CodingKey>(
|
||||
_ type: Element.Type,
|
||||
in container: KeyedDecodingContainer<Key>,
|
||||
forKey key: Key
|
||||
) -> [Element] {
|
||||
let boxes = (try? container.decode([LossyBox<Element>].self, forKey: key)) ?? []
|
||||
return boxes.compactMap(\.value)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Routes + client calls
|
||||
|
||||
extension Endpoints {
|
||||
/// `GET /projects` — RO, no Origin (src/server.ts:262-269).
|
||||
static func projects() -> APIRoute {
|
||||
APIRoute(method: .get, path: "/projects", originPolicy: .readOnly, body: nil)
|
||||
}
|
||||
|
||||
/// `GET /projects/detail?path=` — RO, no Origin (src/server.ts:293-310).
|
||||
/// The ONE place `path` gets percent-encoded (strict unreserved-only set —
|
||||
/// see `unreservedCharacters` for why `.urlQueryAllowed` is not enough).
|
||||
/// nil = the path could not be encoded (surfaced as `.invalidRequest`).
|
||||
static func projectDetail(path: String) -> APIRoute? {
|
||||
guard let encoded = path.addingPercentEncoding(
|
||||
withAllowedCharacters: unreservedCharacters
|
||||
) else { return nil }
|
||||
return APIRoute(
|
||||
method: .get, path: "/projects/detail", originPolicy: .readOnly,
|
||||
body: nil, percentEncodedQuery: "path=\(encoded)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension APIClient {
|
||||
/// `GET /projects` — the host's discovered projects with their running
|
||||
/// sessions merged in. RO → no Origin.
|
||||
public func projects() async throws -> [ProjectInfo] {
|
||||
let (data, response) = try await perform(Endpoints.projects())
|
||||
guard response.statusCode == HTTPStatus.ok else {
|
||||
throw APIClientError.unexpectedStatus(response.statusCode)
|
||||
}
|
||||
return try ProjectInfo.decodeList(from: data)
|
||||
}
|
||||
|
||||
/// `GET /projects/detail?path=` — branch/worktrees/CLAUDE.md for one
|
||||
/// project. RO → no Origin. An empty path is rejected client-side
|
||||
/// (mirror of the server's 400 rule) before any network I/O;
|
||||
/// 400/404/500 map to `.projectPathInvalid` / `.projectNotFound` /
|
||||
/// `.projectDetailUnavailable`.
|
||||
public func projectDetail(path: String) async throws -> ProjectDetail {
|
||||
guard !path.isEmpty else {
|
||||
throw APIClientError.projectPathInvalid
|
||||
}
|
||||
guard let route = Endpoints.projectDetail(path: path) else {
|
||||
throw APIClientError.invalidRequest
|
||||
}
|
||||
let (data, response) = try await perform(route)
|
||||
switch response.statusCode {
|
||||
case HTTPStatus.ok:
|
||||
guard let detail = try? JSONDecoder().decode(ProjectDetail.self, from: data) else {
|
||||
throw APIClientError.invalidResponseBody
|
||||
}
|
||||
return detail
|
||||
case HTTPStatus.badRequest:
|
||||
throw APIClientError.projectPathInvalid
|
||||
case HTTPStatus.notFound:
|
||||
throw APIClientError.projectNotFound
|
||||
case HTTPStatus.internalServerError:
|
||||
throw APIClientError.projectDetailUnavailable
|
||||
default:
|
||||
throw APIClientError.unexpectedStatus(response.statusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
175
ios/Packages/APIClient/Tests/APIClientTests/ApnsTokenTests.swift
Normal file
175
ios/Packages/APIClient/Tests/APIClientTests/ApnsTokenTests.swift
Normal file
@@ -0,0 +1,175 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import TestSupport
|
||||
import WireProtocol
|
||||
import APIClient
|
||||
|
||||
/// T-iOS-38 · APNs device-token 注册端点(orchestrator 冻结 wire shape):
|
||||
/// - `POST /push/apns-token` body `{"token":"<64-160 位小写 hex>"}` → 204(幂等 upsert)
|
||||
/// - `DELETE /push/apns-token` body 同形 → 204(幂等;未知 token 仍 204)
|
||||
/// 两者均为 G 端点(Origin 逐字符等于 `endpoint.originHeader`);服务器约束:
|
||||
/// 400 非法 token/形状 · 403 Origin 守卫 · 429 限频 5 次/分/IP · body ≤ 8 KB
|
||||
///(对齐 POST /push/subscribe 约定,src/server.ts:461-480,73)。
|
||||
/// hex 校验镜像服务器规则并在**联网前**拒绝(客户端边界验证,plan §4)。
|
||||
struct ApnsTokenTests {
|
||||
private static let base = "http://192.168.1.5:3000"
|
||||
/// 64 位小写 hex —— 典型 APNs device token(32 字节)。
|
||||
private static let validToken = String(repeating: "0123456789abcdef", count: 4)
|
||||
|
||||
private struct Fixture {
|
||||
let endpoint: HostEndpoint
|
||||
let http: FakeHTTPTransport
|
||||
let client: APIClient
|
||||
let url: URL
|
||||
}
|
||||
|
||||
private func makeFixture() throws -> Fixture {
|
||||
let baseURL = try #require(URL(string: Self.base))
|
||||
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
|
||||
let http = FakeHTTPTransport()
|
||||
return Fixture(
|
||||
endpoint: endpoint,
|
||||
http: http,
|
||||
client: APIClient(endpoint: endpoint, http: http),
|
||||
url: try #require(URL(string: Self.base + "/push/apns-token"))
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 冻结 wire shape(方法/路径/Origin/body)
|
||||
|
||||
@Test("register 为 POST /push/apns-token,G 端点:Origin 逐字符等于 endpoint.originHeader,body 恰为 {token}")
|
||||
func registerBuildsFrozenWireShape() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture()
|
||||
await fixture.http.queueSuccess(method: "POST", url: fixture.url, status: 204)
|
||||
|
||||
// Act
|
||||
try await fixture.client.registerApnsToken(Self.validToken)
|
||||
|
||||
// Assert
|
||||
let request = try #require(await fixture.http.recordedRequests.first)
|
||||
#expect(request.httpMethod == "POST")
|
||||
#expect(request.url == fixture.url)
|
||||
#expect(request.value(forHTTPHeaderField: "Origin") == fixture.endpoint.originHeader)
|
||||
#expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json")
|
||||
let body = try #require(request.httpBody)
|
||||
let object = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any])
|
||||
#expect(Set(object.keys) == Set(["token"]))
|
||||
#expect(object["token"] as? String == Self.validToken)
|
||||
}
|
||||
|
||||
@Test("unregister 为 DELETE /push/apns-token,同 G 守卫同 body 形状,204 → 成功(幂等)")
|
||||
func unregisterBuildsFrozenWireShape() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture()
|
||||
await fixture.http.queueSuccess(method: "DELETE", url: fixture.url, status: 204)
|
||||
|
||||
// Act
|
||||
try await fixture.client.unregisterApnsToken(Self.validToken)
|
||||
|
||||
// Assert
|
||||
let request = try #require(await fixture.http.recordedRequests.first)
|
||||
#expect(request.httpMethod == "DELETE")
|
||||
#expect(request.url == fixture.url)
|
||||
#expect(request.value(forHTTPHeaderField: "Origin") == fixture.endpoint.originHeader)
|
||||
let body = try #require(request.httpBody)
|
||||
let object = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any])
|
||||
#expect(object["token"] as? String == Self.validToken)
|
||||
}
|
||||
|
||||
// MARK: - 客户端 hex 校验(镜像服务器规则,联网前拒绝)
|
||||
|
||||
@Test("大写 hex 输入 → 联网前归一化为小写(服务器按小写归一存储)")
|
||||
func uppercaseTokenIsLowercaseNormalizedBeforeNetwork() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture()
|
||||
await fixture.http.queueSuccess(method: "POST", url: fixture.url, status: 204)
|
||||
|
||||
// Act
|
||||
try await fixture.client.registerApnsToken(Self.validToken.uppercased())
|
||||
|
||||
// Assert
|
||||
let request = try #require(await fixture.http.recordedRequests.first)
|
||||
let body = try #require(request.httpBody)
|
||||
let object = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any])
|
||||
#expect(object["token"] as? String == Self.validToken)
|
||||
}
|
||||
|
||||
@Test("非法 token(过短/过长/非 hex/空/含空格) → invalidApnsToken 且零网络请求")
|
||||
func invalidTokensAreRejectedBeforeAnyNetworkRequest() async throws {
|
||||
// Arrange — 63 位(短 1)、161 位(长 1)、非 hex 字符、空串、内嵌空格
|
||||
let fixture = try makeFixture()
|
||||
let invalidTokens = [
|
||||
String(repeating: "a", count: 63),
|
||||
String(repeating: "a", count: 161),
|
||||
String(repeating: "g", count: 64),
|
||||
"",
|
||||
String(repeating: "a", count: 32) + " " + String(repeating: "a", count: 31),
|
||||
]
|
||||
|
||||
// Act + Assert — register 与 unregister 两条路径都在联网前拒绝
|
||||
for token in invalidTokens {
|
||||
await #expect(throws: APIClientError.invalidApnsToken) {
|
||||
try await fixture.client.registerApnsToken(token)
|
||||
}
|
||||
await #expect(throws: APIClientError.invalidApnsToken) {
|
||||
try await fixture.client.unregisterApnsToken(token)
|
||||
}
|
||||
}
|
||||
#expect(await fixture.http.recordedRequests.isEmpty)
|
||||
}
|
||||
|
||||
@Test("边界长度 64 与 160 位 hex 均接受(服务器规则 64-160)")
|
||||
func boundaryLengthTokensAreAccepted() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture()
|
||||
await fixture.http.queueSuccess(method: "POST", url: fixture.url, status: 204)
|
||||
await fixture.http.queueSuccess(method: "POST", url: fixture.url, status: 204)
|
||||
|
||||
// Act
|
||||
try await fixture.client.registerApnsToken(String(repeating: "f", count: 64))
|
||||
try await fixture.client.registerApnsToken(String(repeating: "f", count: 160))
|
||||
|
||||
// Assert
|
||||
#expect(await fixture.http.recordedRequests.count == 2)
|
||||
}
|
||||
|
||||
// MARK: - 状态码映射
|
||||
|
||||
@Test("400(服务器判 token 非法) → invalidApnsToken;403(Origin 守卫) → forbidden")
|
||||
func status400And403MapToTypedErrors() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture()
|
||||
await fixture.http.queueSuccess(method: "POST", url: fixture.url, status: 400)
|
||||
await fixture.http.queueSuccess(method: "DELETE", url: fixture.url, status: 403)
|
||||
|
||||
// Act + Assert
|
||||
await #expect(throws: APIClientError.invalidApnsToken) {
|
||||
try await fixture.client.registerApnsToken(Self.validToken)
|
||||
}
|
||||
await #expect(throws: APIClientError.forbidden) {
|
||||
try await fixture.client.unregisterApnsToken(Self.validToken)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("429 → rateLimited(≤5 次/分/IP,对齐 push/subscribe 限频);500 → unexpectedStatus")
|
||||
func status429And500MapToTypedErrors() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture()
|
||||
await fixture.http.queueSuccess(method: "POST", url: fixture.url, status: 429)
|
||||
await fixture.http.queueSuccess(method: "POST", url: fixture.url, status: 500)
|
||||
|
||||
// Act + Assert
|
||||
await #expect(throws: APIClientError.rateLimited) {
|
||||
try await fixture.client.registerApnsToken(Self.validToken)
|
||||
}
|
||||
await #expect(throws: APIClientError.unexpectedStatus(500)) {
|
||||
try await fixture.client.registerApnsToken(Self.validToken)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("invalidApnsToken 有非空 UI 话术(plan §4 错误处理全面显式)")
|
||||
func invalidApnsTokenHasNonEmptyMessage() {
|
||||
#expect(!APIClientError.invalidApnsToken.message.isEmpty)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import WireProtocol
|
||||
import APIClient
|
||||
|
||||
/// T-iOS-38 · 四层 host 分级(plan §5.4 提示分层表),公开 API:
|
||||
/// loopback / privateLAN / tailscale / public。W3 时 PairingViewModel 因
|
||||
/// `isPrivateOrLocalHost` 是 internal 单桶谓词而自行重写了分类——本类型
|
||||
/// 是给 VM 换用的单一出处(本任务不改 VM,见日志条目)。
|
||||
/// 注意本文件**不用 @testable**:证明 API 确实 public。
|
||||
struct HostClassificationTests {
|
||||
@Test("loopback 层:localhost/127.0.0.0\u{2044}8/IPv6 ::1(裸与带括号)")
|
||||
func loopbackTierMatchesLocalTargets() {
|
||||
// Arrange
|
||||
let hosts = ["localhost", "LOCALHOST", "127.0.0.1", "127.8.8.8", "::1", "[::1]"]
|
||||
|
||||
// Act + Assert
|
||||
for host in hosts {
|
||||
#expect(HostClassifier.classify(host: host) == .loopback, "\(host) 应为 loopback")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("privateLAN 层:RFC1918 三段 + link-local 169.254\u{2044}16 + mDNS .local")
|
||||
func privateLANTierMatchesRFC1918AndLinkLocal() {
|
||||
// Arrange
|
||||
let hosts = [
|
||||
"10.0.0.5", "192.168.0.9", "172.16.0.1", "172.31.255.255",
|
||||
"169.254.1.1", "mac-mini.local", "Mac-Mini.LOCAL",
|
||||
]
|
||||
|
||||
// Act + Assert
|
||||
for host in hosts {
|
||||
#expect(HostClassifier.classify(host: host) == .privateLAN, "\(host) 应为 privateLAN")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("tailscale 层:CGNAT 100.64.0.0\u{2044}10 与 MagicDNS *.ts.net")
|
||||
func tailscaleTierMatchesCGNATAndMagicDNS() {
|
||||
// Arrange
|
||||
let hosts = [
|
||||
"100.64.0.1", "100.100.1.1", "100.127.255.255",
|
||||
"mac.tailnet.ts.net", "foo.TS.NET",
|
||||
]
|
||||
|
||||
// Act + Assert
|
||||
for host in hosts {
|
||||
#expect(HostClassifier.classify(host: host) == .tailscale, "\(host) 应为 tailscale")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("public 层:公网 IP/域名/越界 CIDR/畸形 IPv4 一律最强警告层")
|
||||
func publicTierIsTheFailSafeDefault() {
|
||||
// Arrange — 越界:172.32 超出 172.16/12;100.128 与 100.63 超出 100.64/10
|
||||
let hosts = [
|
||||
"8.8.8.8", "203.0.113.7", "example.com", "tsnet.example.com",
|
||||
"172.32.0.1", "100.128.0.1", "100.63.255.255", "256.1.1.1", "1.2.3", "",
|
||||
]
|
||||
|
||||
// Act + Assert — 分不清 → public(fail-safe:宁可多警告,plan §5.4)
|
||||
for host in hosts {
|
||||
#expect(HostClassifier.classify(host: host) == .public, "\(host) 应为 public")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("classify(endpoint:) 便捷入口与 host 字符串版一致(VM 只有 HostEndpoint 可用)")
|
||||
func endpointConvenienceMatchesHostClassification() throws {
|
||||
// Arrange
|
||||
let vectors: [(url: String, tier: HostNetworkTier)] = [
|
||||
("http://127.0.0.1:3000", .loopback),
|
||||
("http://192.168.1.5:3000", .privateLAN),
|
||||
("http://100.100.1.1:3000", .tailscale),
|
||||
("https://mac.tailnet.ts.net", .tailscale),
|
||||
("https://example.com", .public),
|
||||
]
|
||||
|
||||
// Act + Assert
|
||||
for vector in vectors {
|
||||
let url = try #require(URL(string: vector.url))
|
||||
let endpoint = try #require(HostEndpoint(baseURL: url))
|
||||
#expect(HostClassifier.classify(endpoint: endpoint) == vector.tier, "\(vector.url)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import TestSupport
|
||||
import WireProtocol
|
||||
import APIClient
|
||||
|
||||
/// T-iOS-38 · `GET /prefs`(RO,无 Origin) / `PUT /prefs`(G,Origin;body ≤ 64 KB,
|
||||
/// src/server.ts:278)。prefs 是"不透明但已验证"的 JSON 对象往返:已知键
|
||||
/// favourites/collapsed 松散镜像 web 端(public/prefs.ts sanitizePrefs),
|
||||
/// **未知键在往返中原样保留** —— 关键正确性陷阱:iOS 改一个已知键后 PUT,
|
||||
/// 绝不能把 web/未来服务器写入的未知键清掉。
|
||||
struct PrefsRoundTripTests {
|
||||
private static let base = "http://192.168.1.5:3000"
|
||||
|
||||
/// 含未知键的服务器 blob:string/number/bool/null/嵌套 array/object 全类型。
|
||||
private static let blobWithUnknownKeys = """
|
||||
{"favourites":["/Users/dev/a"],"collapsed":{"grp":true},\
|
||||
"theme":"dark","pollSeconds":42,"beta":false,"legacy":null,\
|
||||
"futureList":[1,"two",{"deep":true}],"futureObj":{"x":{"y":[3]}}}
|
||||
"""
|
||||
|
||||
private struct Fixture {
|
||||
let endpoint: HostEndpoint
|
||||
let http: FakeHTTPTransport
|
||||
let client: APIClient
|
||||
let url: URL
|
||||
}
|
||||
|
||||
private func makeFixture() throws -> Fixture {
|
||||
let baseURL = try #require(URL(string: Self.base))
|
||||
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
|
||||
let http = FakeHTTPTransport()
|
||||
return Fixture(
|
||||
endpoint: endpoint,
|
||||
http: http,
|
||||
client: APIClient(endpoint: endpoint, http: http),
|
||||
url: try #require(URL(string: Self.base + "/prefs"))
|
||||
)
|
||||
}
|
||||
|
||||
private func decodePrefs(_ json: String) throws -> UiPrefs {
|
||||
try #require(UiPrefs.decode(from: Data(json.utf8)))
|
||||
}
|
||||
|
||||
/// 重新解析 encode 产物为字典(键序无关的深度比较用)。
|
||||
private func reparse(_ data: Data) throws -> NSDictionary {
|
||||
try #require(try JSONSerialization.jsonObject(with: data) as? NSDictionary)
|
||||
}
|
||||
|
||||
// MARK: - Origin iff-G
|
||||
|
||||
@Test("Origin iff-G:GET /prefs 无 Origin;PUT /prefs 带 Origin(逐字符相等)+JSON Content-Type")
|
||||
func prefsOriginAppearsIffPut() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture()
|
||||
await fixture.http.queueSuccess(url: fixture.url, body: Data(Self.blobWithUnknownKeys.utf8))
|
||||
await fixture.http.queueSuccess(
|
||||
method: "PUT", url: fixture.url, body: Data(#"{"favourites":[],"collapsed":{}}"#.utf8)
|
||||
)
|
||||
|
||||
// Act
|
||||
_ = try await fixture.client.prefs()
|
||||
_ = try await fixture.client.putPrefs(UiPrefs())
|
||||
|
||||
// Assert
|
||||
let requests = await fixture.http.recordedRequests
|
||||
#expect(requests.count == 2)
|
||||
let get = try #require(requests.first)
|
||||
#expect(get.httpMethod == "GET")
|
||||
#expect(get.value(forHTTPHeaderField: "Origin") == nil)
|
||||
let put = try #require(requests.last)
|
||||
#expect(put.httpMethod == "PUT")
|
||||
#expect(put.value(forHTTPHeaderField: "Origin") == fixture.endpoint.originHeader)
|
||||
#expect(put.value(forHTTPHeaderField: "Content-Type") == "application/json")
|
||||
}
|
||||
|
||||
// MARK: - 已知键的松散镜像(web sanitizePrefs 语义)
|
||||
|
||||
@Test("已知键解码:favourites 只留非空字符串并去重;collapsed 只留 true 项")
|
||||
func knownKeysAreSanitizedLikeWebClient() throws {
|
||||
// Arrange — 混入非字符串/空串/重复/非 true 值
|
||||
let json = """
|
||||
{"favourites":["/a",42,"","/a","/b"],\
|
||||
"collapsed":{"g1":true,"g2":false,"g3":"yes","":true}}
|
||||
"""
|
||||
|
||||
// Act
|
||||
let prefs = try decodePrefs(json)
|
||||
|
||||
// Assert
|
||||
#expect(prefs.favourites == ["/a", "/b"])
|
||||
#expect(prefs.collapsed == ["g1": true])
|
||||
}
|
||||
|
||||
@Test("已知键缺失/类型错 → 空缺省;空构造与 memberwise 构造可用")
|
||||
func missingKnownKeysDegradeToEmpty() throws {
|
||||
// Act
|
||||
let empty = try decodePrefs(#"{"favourites":"not-an-array"}"#)
|
||||
let constructed = UiPrefs(favourites: ["/x"], collapsed: ["g": true])
|
||||
|
||||
// Assert
|
||||
#expect(empty.favourites.isEmpty)
|
||||
#expect(empty.collapsed.isEmpty)
|
||||
#expect(constructed.favourites == ["/x"])
|
||||
#expect(constructed.collapsed == ["g": true])
|
||||
}
|
||||
|
||||
// MARK: - 关键正确性陷阱:未知键在往返中存活
|
||||
|
||||
@Test("关键陷阱:解码含未知键的 blob → withFavourites 改已知键 → 重编码,未知键逐字节存活")
|
||||
func unknownKeysSurviveFavouritesMutationRoundTrip() throws {
|
||||
// Arrange
|
||||
let original = try decodePrefs(Self.blobWithUnknownKeys)
|
||||
|
||||
// Act — 只动 favourites
|
||||
let mutated = original.withFavourites(["/Users/dev/a", "/Users/dev/b"])
|
||||
let reencoded = try reparse(try mutated.encodeBody())
|
||||
|
||||
// Assert — 已知键更新、另一已知键不动、未知键全类型深度相等
|
||||
#expect(mutated.favourites == ["/Users/dev/a", "/Users/dev/b"])
|
||||
#expect(reencoded["favourites"] as? [String] == ["/Users/dev/a", "/Users/dev/b"])
|
||||
#expect(reencoded["collapsed"] as? [String: Bool] == ["grp": true])
|
||||
#expect(reencoded["theme"] as? String == "dark")
|
||||
#expect(reencoded["pollSeconds"] as? Int == 42)
|
||||
#expect(reencoded["beta"] as? Bool == false)
|
||||
#expect(reencoded["legacy"] is NSNull)
|
||||
#expect(reencoded["futureList"] as? NSArray == [1, "two", ["deep": true]] as NSArray)
|
||||
#expect(reencoded["futureObj"] as? NSDictionary == ["x": ["y": [3]]] as NSDictionary)
|
||||
// 原值不可变(plan §4 不可变铁律)
|
||||
#expect(original.favourites == ["/Users/dev/a"])
|
||||
}
|
||||
|
||||
@Test("withCollapsed 同样保留未知键与 favourites;往返不放大不丢失")
|
||||
func unknownKeysSurviveCollapsedMutationRoundTrip() throws {
|
||||
// Arrange
|
||||
let original = try decodePrefs(Self.blobWithUnknownKeys)
|
||||
|
||||
// Act
|
||||
let mutated = original.withCollapsed(["grp": true, "new-group": true])
|
||||
let reencoded = try reparse(try mutated.encodeBody())
|
||||
|
||||
// Assert
|
||||
#expect(reencoded["collapsed"] as? [String: Bool] == ["grp": true, "new-group": true])
|
||||
#expect(reencoded["favourites"] as? [String] == ["/Users/dev/a"])
|
||||
#expect(reencoded["theme"] as? String == "dark")
|
||||
#expect(reencoded["futureObj"] as? NSDictionary == ["x": ["y": [3]]] as NSDictionary)
|
||||
// 未知键总数不变(8 个顶层键)
|
||||
#expect(reencoded.count == 8)
|
||||
}
|
||||
|
||||
// MARK: - 客户端调用(echo/错误映射)
|
||||
|
||||
@Test("putPrefs 200 → 返回服务器回显的(已净化)prefs;403(Origin 守卫) → forbidden")
|
||||
func putPrefsReturnsServerEchoAndMapsForbidden() async throws {
|
||||
// Arrange — 服务器回显是净化后的 blob(src/server.ts:283 res.json(prefsStore.get()))
|
||||
let fixture = try makeFixture()
|
||||
await fixture.http.queueSuccess(
|
||||
method: "PUT", url: fixture.url,
|
||||
body: Data(#"{"favourites":["/kept"],"collapsed":{}}"#.utf8)
|
||||
)
|
||||
await fixture.http.queueSuccess(method: "PUT", url: fixture.url, status: 403)
|
||||
|
||||
// Act
|
||||
let echoed = try await fixture.client.putPrefs(UiPrefs(favourites: ["/kept", ""]))
|
||||
|
||||
// Assert
|
||||
#expect(echoed.favourites == ["/kept"])
|
||||
await #expect(throws: APIClientError.forbidden) {
|
||||
_ = try await fixture.client.putPrefs(UiPrefs())
|
||||
}
|
||||
}
|
||||
|
||||
@Test("GET /prefs 顶层非对象(数组/HTML) → invalidResponseBody(绝不静默降级为空——防止后续 PUT 清空服务器 prefs)")
|
||||
func nonObjectPrefsBodyFailsLoudlyInsteadOfDegradingToEmpty() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture()
|
||||
await fixture.http.queueSuccess(url: fixture.url, body: Data("[]".utf8))
|
||||
await fixture.http.queueSuccess(url: fixture.url, body: Data("<html>".utf8))
|
||||
|
||||
// Act + Assert
|
||||
await #expect(throws: APIClientError.invalidResponseBody) {
|
||||
_ = try await fixture.client.prefs()
|
||||
}
|
||||
await #expect(throws: APIClientError.invalidResponseBody) {
|
||||
_ = try await fixture.client.prefs()
|
||||
}
|
||||
}
|
||||
}
|
||||
289
ios/Packages/APIClient/Tests/APIClientTests/ProjectsTests.swift
Normal file
289
ios/Packages/APIClient/Tests/APIClientTests/ProjectsTests.swift
Normal file
@@ -0,0 +1,289 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import TestSupport
|
||||
import WireProtocol
|
||||
import APIClient
|
||||
|
||||
/// T-iOS-38 · Projects 契约增量(RO,无 Origin):
|
||||
/// - `GET /projects`(src/server.ts:262-269)→ `[ProjectInfo]`(src/types.ts:273-281 实际形状:
|
||||
/// name/path/isGit 必填,branch/dirty/lastActiveMs 可选,sessions 为 ProjectSessionRef[];
|
||||
/// 无 namespace 字段 —— namespace 是 web 端分组概念,只出现在 prefs.collapsed 的 key 里);
|
||||
/// - `GET /projects/detail?path=`(src/server.ts:293-310)→ `ProjectDetail`;
|
||||
/// path 的 percent-encoding **只在 builder 一处**处理;400/404/500 `{error}` → 显式类型化错误。
|
||||
/// 服务器是不可信输入源:未知字段忽略、畸形条目逐条丢弃、绝不 crash。
|
||||
struct ProjectsTests {
|
||||
private static let base = "http://192.168.1.5:3000"
|
||||
private static let sessionIdString = "0f5a1b2c-3d4e-4f60-8a9b-0c1d2e3f4a5b"
|
||||
|
||||
private static let fullProjectJSON = """
|
||||
{"name":"web-terminal","path":"/Users/dev/web-terminal","isGit":true,\
|
||||
"branch":"main","dirty":true,"lastActiveMs":1720000000000,\
|
||||
"sessions":[{"id":"\(sessionIdString)","title":"web-terminal","status":"working",\
|
||||
"clientCount":2,"createdAt":1719990000000,"exited":false}]}
|
||||
"""
|
||||
|
||||
private static let minimalProjectJSON = """
|
||||
{"name":"notes","path":"/Users/dev/notes","isGit":false,"sessions":[]}
|
||||
"""
|
||||
|
||||
private struct Fixture {
|
||||
let http: FakeHTTPTransport
|
||||
let client: APIClient
|
||||
}
|
||||
|
||||
private func makeFixture() throws -> Fixture {
|
||||
let baseURL = try #require(URL(string: Self.base))
|
||||
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
|
||||
let http = FakeHTTPTransport()
|
||||
return Fixture(http: http, client: APIClient(endpoint: endpoint, http: http))
|
||||
}
|
||||
|
||||
private func routeURL(_ pathAndQuery: String) throws -> URL {
|
||||
try #require(URL(string: Self.base + pathAndQuery))
|
||||
}
|
||||
|
||||
private func fetchProjects(_ fixture: Fixture, body: String) async throws -> [ProjectInfo] {
|
||||
await fixture.http.queueSuccess(url: try routeURL("/projects"), body: Data(body.utf8))
|
||||
return try await fixture.client.projects()
|
||||
}
|
||||
|
||||
// MARK: - Origin iff-G(两个新 RO 端点都不带 Origin)
|
||||
|
||||
@Test("Origin iff-G(RO 侧):GET /projects 与 GET /projects/detail 均不带 Origin")
|
||||
func projectsEndpointsAreReadOnlyWithoutOrigin() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture()
|
||||
await fixture.http.queueSuccess(url: try routeURL("/projects"), body: Data("[]".utf8))
|
||||
await fixture.http.queueSuccess(
|
||||
url: try routeURL("/projects/detail?path=%2FUsers%2Fdev%2Fnotes"),
|
||||
body: Data(Self.detailJSON.utf8)
|
||||
)
|
||||
|
||||
// Act
|
||||
_ = try await fixture.client.projects()
|
||||
_ = try await fixture.client.projectDetail(path: "/Users/dev/notes")
|
||||
|
||||
// Assert
|
||||
let requests = await fixture.http.recordedRequests
|
||||
#expect(requests.count == 2)
|
||||
for request in requests {
|
||||
#expect(request.httpMethod == "GET")
|
||||
#expect(request.value(forHTTPHeaderField: "Origin") == nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - /projects 解码
|
||||
|
||||
@Test("ProjectInfo 全字段样本解码(src/types.ts:273-281 实际形状,含 sessions ref)")
|
||||
func projectInfoDecodesFullSample() async throws {
|
||||
// Act
|
||||
let projects = try await fetchProjects(try makeFixture(), body: "[\(Self.fullProjectJSON)]")
|
||||
|
||||
// Assert
|
||||
let expected = ProjectInfo(
|
||||
name: "web-terminal",
|
||||
path: "/Users/dev/web-terminal",
|
||||
isGit: true,
|
||||
branch: "main",
|
||||
dirty: true,
|
||||
lastActiveMs: 1_720_000_000_000,
|
||||
sessions: [ProjectSessionRef(
|
||||
id: try #require(UUID(uuidString: Self.sessionIdString)),
|
||||
title: "web-terminal",
|
||||
status: .working,
|
||||
clientCount: 2,
|
||||
createdAt: 1_719_990_000_000,
|
||||
exited: false
|
||||
)]
|
||||
)
|
||||
#expect(projects == [expected])
|
||||
}
|
||||
|
||||
@Test("可选字段缺省(branch/dirty/lastActiveMs)与未知字段(向前兼容)均容忍,不丢条目")
|
||||
func optionalFieldsDegradeAndUnknownFieldsAreIgnored() async throws {
|
||||
// Arrange — 未知字段模拟未来服务器增量
|
||||
let body = """
|
||||
[{"name":"notes","path":"/Users/dev/notes","isGit":false,"sessions":[],\
|
||||
"futureField":{"nested":true},"favouriteRank":3}]
|
||||
"""
|
||||
|
||||
// Act
|
||||
let projects = try await fetchProjects(try makeFixture(), body: body)
|
||||
|
||||
// Assert
|
||||
let project = try #require(projects.first)
|
||||
#expect(project.branch == nil)
|
||||
#expect(project.dirty == nil)
|
||||
#expect(project.lastActiveMs == nil)
|
||||
#expect(project.sessions.isEmpty)
|
||||
}
|
||||
|
||||
@Test("畸形条目(缺必填/类型错/非对象)逐条丢弃,合法条目保留,不 crash")
|
||||
func malformedProjectEntriesAreDroppedWhileValidOnesSurvive() async throws {
|
||||
// Arrange
|
||||
let body = """
|
||||
[\(Self.fullProjectJSON),42,{"name":"x"},\
|
||||
{"name":1,"path":"/p","isGit":true,"sessions":[]},\(Self.minimalProjectJSON)]
|
||||
"""
|
||||
|
||||
// Act
|
||||
let projects = try await fetchProjects(try makeFixture(), body: body)
|
||||
|
||||
// Assert
|
||||
#expect(projects.map(\.name) == ["web-terminal", "notes"])
|
||||
}
|
||||
|
||||
@Test("session ref:未知 status → .unknown;畸形 ref 丢弃但项目保留;sessions 缺失 → 空数组")
|
||||
func sessionRefsAreTolerantlyDecoded() async throws {
|
||||
// Arrange
|
||||
let body = """
|
||||
[{"name":"a","path":"/a","isGit":true,"sessions":[\
|
||||
{"id":"\(Self.sessionIdString)","status":"hyperdrive","clientCount":0,\
|
||||
"createdAt":1,"exited":false},\
|
||||
{"id":"not-a-uuid","status":"idle","clientCount":0,"createdAt":2,"exited":true}]},\
|
||||
{"name":"b","path":"/b","isGit":false}]
|
||||
"""
|
||||
|
||||
// Act
|
||||
let projects = try await fetchProjects(try makeFixture(), body: body)
|
||||
|
||||
// Assert
|
||||
#expect(projects.count == 2)
|
||||
let first = try #require(projects.first)
|
||||
#expect(first.sessions.count == 1) // 坏 UUID 的 ref 被丢弃
|
||||
#expect(first.sessions.first?.status == .unknown)
|
||||
#expect(first.sessions.first?.title == nil)
|
||||
#expect(projects.last?.sessions.isEmpty == true) // sessions 缺失 → []
|
||||
}
|
||||
|
||||
@Test("/projects 非数组 body → invalidResponseBody;非 200 → unexpectedStatus")
|
||||
func projectsRejectsNonArrayBodyAndBadStatus() async throws {
|
||||
// Act + Assert — 非数组
|
||||
await #expect(throws: APIClientError.invalidResponseBody) {
|
||||
_ = try await self.fetchProjects(try self.makeFixture(), body: #"{"error":"x"}"#)
|
||||
}
|
||||
// Act + Assert — 非 200
|
||||
let fixture = try makeFixture()
|
||||
await fixture.http.queueSuccess(url: try routeURL("/projects"), status: 500)
|
||||
await #expect(throws: APIClientError.unexpectedStatus(500)) {
|
||||
_ = try await fixture.client.projects()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - /projects/detail:percent-encoding 单点处理
|
||||
|
||||
@Test("detail 的 path percent-encoding 只在 builder 一处:空格/+/&/=/非 ASCII 全部严格编码")
|
||||
func detailPathIsStrictlyPercentEncodedOnceInBuilder() async throws {
|
||||
// Arrange — '+' 必须编码为 %2B(Express qs 会把裸 '+' 解成空格);α = %CE%B1
|
||||
let fixture = try makeFixture()
|
||||
let rawPath = "/Users/dev/my proj+α&x=1"
|
||||
let encoded = "%2FUsers%2Fdev%2Fmy%20proj%2B%CE%B1%26x%3D1"
|
||||
let expectedURL = try routeURL("/projects/detail?path=\(encoded)")
|
||||
await fixture.http.queueSuccess(url: expectedURL, body: Data(Self.detailJSON.utf8))
|
||||
|
||||
// Act
|
||||
_ = try await fixture.client.projectDetail(path: rawPath)
|
||||
|
||||
// Assert — FakeHTTPTransport 按整 URL 精确匹配,能走到这里即编码逐字节正确
|
||||
let request = try #require(await fixture.http.recordedRequests.first)
|
||||
#expect(request.url == expectedURL)
|
||||
}
|
||||
|
||||
// MARK: - /projects/detail 解码与错误映射
|
||||
|
||||
private static let detailJSON = """
|
||||
{"name":"notes","path":"/Users/dev/notes","isGit":true,"branch":"main","dirty":false,\
|
||||
"worktrees":[{"path":"/Users/dev/notes","branch":"main","head":"abc1234",\
|
||||
"isMain":true,"isCurrent":true},\
|
||||
{"path":"/Users/dev/notes-wt","isMain":false,"isCurrent":false,"locked":true}],\
|
||||
"sessions":[],"hasClaudeMd":true,"claudeMd":"# Notes"}
|
||||
"""
|
||||
|
||||
@Test("ProjectDetail 全字段解码(worktrees 含可选字段缺省;src/types.ts:296-306)")
|
||||
func projectDetailDecodesFullSample() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture()
|
||||
await fixture.http.queueSuccess(
|
||||
url: try routeURL("/projects/detail?path=%2FUsers%2Fdev%2Fnotes"),
|
||||
body: Data(Self.detailJSON.utf8)
|
||||
)
|
||||
|
||||
// Act
|
||||
let detail = try await fixture.client.projectDetail(path: "/Users/dev/notes")
|
||||
|
||||
// Assert
|
||||
#expect(detail.name == "notes")
|
||||
#expect(detail.isGit)
|
||||
#expect(detail.branch == "main")
|
||||
#expect(detail.dirty == false)
|
||||
#expect(detail.hasClaudeMd)
|
||||
#expect(detail.claudeMd == "# Notes")
|
||||
#expect(detail.worktrees.count == 2)
|
||||
let second = try #require(detail.worktrees.last)
|
||||
#expect(second.branch == nil) // detached HEAD → branch 缺省
|
||||
#expect(second.locked == true)
|
||||
#expect(second.prunable == nil)
|
||||
#expect(!second.isMain)
|
||||
}
|
||||
|
||||
@Test("detail 容忍:hasClaudeMd 缺失 → false;畸形 worktree 条目丢弃;200 但 body 畸形 → invalidResponseBody")
|
||||
func projectDetailToleratesDegradedShapes() async throws {
|
||||
// Arrange — worktrees 里混入缺 path 的畸形条目
|
||||
let fixture = try makeFixture()
|
||||
let degraded = """
|
||||
{"name":"a","path":"/a","isGit":false,\
|
||||
"worktrees":[{"isMain":true},{"path":"/a","isMain":true,"isCurrent":true}],"sessions":[]}
|
||||
"""
|
||||
let url = try routeURL("/projects/detail?path=%2Fa")
|
||||
await fixture.http.queueSuccess(url: url, body: Data(degraded.utf8))
|
||||
await fixture.http.queueSuccess(url: url, body: Data("[1,2]".utf8))
|
||||
|
||||
// Act
|
||||
let detail = try await fixture.client.projectDetail(path: "/a")
|
||||
|
||||
// Assert
|
||||
#expect(detail.hasClaudeMd == false)
|
||||
#expect(detail.claudeMd == nil)
|
||||
#expect(detail.worktrees.count == 1)
|
||||
// Act + Assert — 200 但整体形状不对
|
||||
await #expect(throws: APIClientError.invalidResponseBody) {
|
||||
_ = try await fixture.client.projectDetail(path: "/a")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("detail 400/404/500 {error} → projectPathInvalid/projectNotFound/projectDetailUnavailable,各有非空话术")
|
||||
func projectDetailMapsErrorStatusesToTypedErrors() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture()
|
||||
let url = try routeURL("/projects/detail?path=%2Fgone")
|
||||
await fixture.http.queueSuccess(url: url, status: 400, body: Data(#"{"error":"path query parameter is required"}"#.utf8))
|
||||
await fixture.http.queueSuccess(url: url, status: 404, body: Data(#"{"error":"project not found"}"#.utf8))
|
||||
await fixture.http.queueSuccess(url: url, status: 500, body: Data(#"{"error":"failed to read project detail"}"#.utf8))
|
||||
|
||||
// Act + Assert
|
||||
await #expect(throws: APIClientError.projectPathInvalid) {
|
||||
_ = try await fixture.client.projectDetail(path: "/gone")
|
||||
}
|
||||
await #expect(throws: APIClientError.projectNotFound) {
|
||||
_ = try await fixture.client.projectDetail(path: "/gone")
|
||||
}
|
||||
await #expect(throws: APIClientError.projectDetailUnavailable) {
|
||||
_ = try await fixture.client.projectDetail(path: "/gone")
|
||||
}
|
||||
for error in [APIClientError.projectPathInvalid, .projectNotFound, .projectDetailUnavailable] {
|
||||
#expect(!error.message.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("空 path → projectPathInvalid,联网前拒绝(镜像服务器 400 规则)")
|
||||
func emptyDetailPathIsRejectedBeforeNetwork() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture()
|
||||
|
||||
// Act + Assert
|
||||
await #expect(throws: APIClientError.projectPathInvalid) {
|
||||
_ = try await fixture.client.projectDetail(path: "")
|
||||
}
|
||||
#expect(await fixture.http.recordedRequests.isEmpty)
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,12 @@ targets:
|
||||
properties:
|
||||
CFBundleDisplayName: WebTerm
|
||||
UILaunchScreen: {}
|
||||
# Deep-link scheme (P1 T-iOS-22): webterminal://open?host=<id>&join=<uuid>
|
||||
# Registered here by the orchestrator (project.yml coordination point);
|
||||
# all parsing/validation lives in DeepLinkRouter (untrusted input).
|
||||
CFBundleURLTypes:
|
||||
- CFBundleURLName: com.yaojia.webterm.deeplink
|
||||
CFBundleURLSchemes: [webterminal]
|
||||
UISupportedInterfaceOrientations:
|
||||
- UIInterfaceOrientationPortrait
|
||||
- UIInterfaceOrientationLandscapeLeft
|
||||
|
||||
576
src/push/apns.ts
Normal file
576
src/push/apns.ts
Normal file
@@ -0,0 +1,576 @@
|
||||
/**
|
||||
* src/push/apns.ts (T-iOS-20) — APNs sender + device-token registry for the
|
||||
* iOS client (PLAN_IOS_CLIENT §0.3/§7 declared server touch-point).
|
||||
*
|
||||
* Sits BESIDE the web-push `push-service` behind the same `NotifyService` seam:
|
||||
* the server combines both via `combineNotifyServices` so every existing hook
|
||||
* event point (held gate → needs-input, Stop/SessionEnd → done) fans out to
|
||||
* browsers AND iPhones with zero new event paths.
|
||||
*
|
||||
* Config is env-only and all-or-disabled: APNS_KEY_PATH + APNS_KEY_ID +
|
||||
* APNS_TEAM_ID are the critical trio; any piece missing (or an unreadable /
|
||||
* non-EC key) cleanly disables the feature — one log line, never a crash,
|
||||
* never any key material in logs.
|
||||
*
|
||||
* PAYLOAD MINIMIZATION (security invariant, mirrors push-service SP1/SEC-C5):
|
||||
* the visible alert carries ONLY a session short-prefix + status word; the
|
||||
* custom payload carries ONLY what the web-push payload already carries
|
||||
* (sessionId, cls, and — needs-input only — the single-use capability token
|
||||
* for /hook/decision). Never cwd, never command content, never terminal bytes.
|
||||
*
|
||||
* Transport: hand-rolled HTTP/2 + ES256 JWT over node:http2/node:crypto (zero
|
||||
* new npm deps, repo rule). The HTTP/2 layer hides behind the `ApnsHttp2Client`
|
||||
* seam so tests never touch the network. JWT is cached ~50 min (Apple wants
|
||||
* 20–60 min); a 403 drops the cache so the next send re-signs.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { chmod, writeFile } from 'node:fs/promises';
|
||||
import { connect } from 'node:http2';
|
||||
import { createPrivateKey, sign as signBytes, type KeyObject } from 'node:crypto';
|
||||
import { homedir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { Config, NotifyClass, NotifyService, Session } from '../types.js';
|
||||
|
||||
// ── constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** 0600 — owner read/write only; the file holds device push tokens. */
|
||||
const FILE_MODE = 0o600;
|
||||
/** Default APNs topic when APNS_BUNDLE_ID is unset. */
|
||||
const DEFAULT_BUNDLE_ID = 'com.yaojia.webterm';
|
||||
/** Default token-store filename (under the home dir), mirroring pushStorePath. */
|
||||
const DEFAULT_STORE_FILENAME = '.web-terminal-apns-tokens.json';
|
||||
/** Production APNs host; APNS_HOST overrides (sandbox / tests). */
|
||||
const DEFAULT_APNS_HOST = 'https://api.push.apple.com';
|
||||
/** Refresh the provider JWT after ~50 min (Apple requires 20–60 min). */
|
||||
const JWT_MAX_AGE_MS = 50 * 60_000;
|
||||
/** Expiration window (seconds) for low-priority DONE pushes (mirrors web-push TTL). */
|
||||
const DONE_EXPIRATION_SECONDS = 600;
|
||||
/** Frozen wire shape: an APNs device token is 64–160 hex chars, lowercased. */
|
||||
const APNS_TOKEN_PATTERN = /^[0-9a-f]{64,160}$/;
|
||||
/** Notification category the iOS app registers Allow/Deny actions against. */
|
||||
const GATE_CATEGORY = 'WEBTERM_GATE';
|
||||
/** apns-priority: deliver immediately (held gate) vs. power-friendly (done). */
|
||||
const PRIORITY_IMMEDIATE = '10';
|
||||
const PRIORITY_CONSERVE = '5';
|
||||
/** Give a single HTTP/2 exchange this long before failing it (never hang). */
|
||||
const REQUEST_TIMEOUT_MS = 10_000;
|
||||
/** Chars of the session id shown in the visible alert (minimization). */
|
||||
const ALERT_ID_PREFIX_LEN = 8;
|
||||
|
||||
// ── types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Frozen, validated APNs runtime config (loaded from the APNS_* env group). */
|
||||
export interface ApnsConfig {
|
||||
readonly keyPath: string;
|
||||
readonly keyId: string;
|
||||
readonly teamId: string;
|
||||
readonly bundleId: string;
|
||||
readonly storePath: string;
|
||||
readonly host: string;
|
||||
}
|
||||
|
||||
export type ApnsConfigResult =
|
||||
| { readonly ok: true; readonly config: ApnsConfig }
|
||||
| { readonly ok: false; readonly reason: string };
|
||||
|
||||
/** One registered iPhone (device token is hex, already lowercase-normalized). */
|
||||
export interface ApnsTokenRecord {
|
||||
readonly token: string;
|
||||
readonly createdAt: number;
|
||||
}
|
||||
|
||||
export interface ApnsTokenStore {
|
||||
/** Frozen snapshot of current records (immutable to callers). */
|
||||
list(): readonly ApnsTokenRecord[];
|
||||
/** Idempotent upsert by token (FIFO-capped). Throws on invalid input. */
|
||||
add(record: ApnsTokenRecord): void;
|
||||
/** Remove by token; no-op when absent (idempotent). */
|
||||
remove(token: string): void;
|
||||
/** Remove a batch of dead tokens (410 / BadDeviceToken evictions). */
|
||||
prune(deadTokens: readonly string[]): void;
|
||||
/** Persist to disk (0600). Best-effort: logs and resolves on failure. */
|
||||
persist(): Promise<void>;
|
||||
}
|
||||
|
||||
/** One APNs HTTP/2 exchange (headers exclude pseudo-headers except via path). */
|
||||
export interface ApnsRequest {
|
||||
readonly path: string;
|
||||
readonly headers: Readonly<Record<string, string>>;
|
||||
readonly body: string;
|
||||
}
|
||||
|
||||
export interface ApnsResponse {
|
||||
readonly status: number;
|
||||
readonly body: string;
|
||||
}
|
||||
|
||||
/** Transport seam over node:http2 (injectable for tests — no network in tests). */
|
||||
export interface ApnsHttp2Client {
|
||||
request(req: ApnsRequest): Promise<ApnsResponse>;
|
||||
}
|
||||
|
||||
export interface ApnsServiceDeps {
|
||||
client?: ApnsHttp2Client;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
/** What the server wires up when the feature is enabled. */
|
||||
export interface ApnsRuntime {
|
||||
readonly service: NotifyService;
|
||||
readonly store: ApnsTokenStore;
|
||||
}
|
||||
|
||||
// ── config loading ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Read a required env var; empty string counts as missing. */
|
||||
function envOrNull(env: Record<string, string | undefined>, key: string): string | null {
|
||||
const value = env[key];
|
||||
return typeof value === 'string' && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the APNS_* env group. All-or-disabled: any missing critical piece
|
||||
* (APNS_KEY_PATH / APNS_KEY_ID / APNS_TEAM_ID) or a malformed APNS_HOST yields
|
||||
* `{ok:false, reason}` — the caller disables the feature, never crashes.
|
||||
*/
|
||||
export function loadApnsConfig(env: Record<string, string | undefined>): ApnsConfigResult {
|
||||
const missing = ['APNS_KEY_PATH', 'APNS_KEY_ID', 'APNS_TEAM_ID'].filter(
|
||||
(key) => envOrNull(env, key) === null,
|
||||
);
|
||||
if (missing.length > 0) {
|
||||
return { ok: false, reason: `${missing.join('/')} not set` };
|
||||
}
|
||||
const host = envOrNull(env, 'APNS_HOST') ?? DEFAULT_APNS_HOST;
|
||||
try {
|
||||
const parsed = new URL(host);
|
||||
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
||||
return { ok: false, reason: 'APNS_HOST must be an http(s) URL' };
|
||||
}
|
||||
} catch {
|
||||
return { ok: false, reason: 'APNS_HOST is not a valid URL' };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
config: {
|
||||
keyPath: envOrNull(env, 'APNS_KEY_PATH') as string,
|
||||
keyId: envOrNull(env, 'APNS_KEY_ID') as string,
|
||||
teamId: envOrNull(env, 'APNS_TEAM_ID') as string,
|
||||
bundleId: envOrNull(env, 'APNS_BUNDLE_ID') ?? DEFAULT_BUNDLE_ID,
|
||||
storePath: envOrNull(env, 'APNS_STORE_PATH') ?? path.join(homedir(), DEFAULT_STORE_FILENAME),
|
||||
host,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and sanity-check the .p8 signing key. Returns null (logged, no key
|
||||
* material) on any failure: missing file, unparsable PEM, non-EC key.
|
||||
*/
|
||||
export function loadApnsKey(keyPath: string): KeyObject | null {
|
||||
let pem: string;
|
||||
try {
|
||||
pem = readFileSync(keyPath, 'utf8');
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`apns: cannot read key file at ${keyPath} (${(err as NodeJS.ErrnoException)?.code ?? 'error'})`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const key = createPrivateKey(pem);
|
||||
if (key.asymmetricKeyType !== 'ec') {
|
||||
console.error('apns: key is not an EC key (APNs requires ES256 / P-256)');
|
||||
return null;
|
||||
}
|
||||
return key;
|
||||
} catch {
|
||||
// Never echo parse errors — they can quote fragments of the key material.
|
||||
console.error(`apns: key file at ${keyPath} is not a valid PKCS#8 EC key`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── token validation + store ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Boundary validation for the frozen wire shape: a device token is a string of
|
||||
* 64–160 hex chars; case-insensitive input is normalized to lowercase.
|
||||
* Anything else → null (the route answers 400).
|
||||
*/
|
||||
export function normalizeApnsToken(raw: unknown): string | null {
|
||||
if (typeof raw !== 'string') return null;
|
||||
const token = raw.toLowerCase();
|
||||
return APNS_TOKEN_PATTERN.test(token) ? token : null;
|
||||
}
|
||||
|
||||
/** Type guard: a structurally valid, persistable token record. */
|
||||
function isValidApnsTokenRecord(x: unknown): x is ApnsTokenRecord {
|
||||
if (x === null || typeof x !== 'object') return false;
|
||||
const r = x as Record<string, unknown>;
|
||||
if (typeof r['token'] !== 'string' || !APNS_TOKEN_PATTERN.test(r['token'])) return false;
|
||||
if (typeof r['createdAt'] !== 'number' || !Number.isFinite(r['createdAt'])) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Best-effort load (mirrors subscription-store): missing/malformed → empty. */
|
||||
function loadTokenRecords(filePath: string): ApnsTokenRecord[] {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(filePath, 'utf8');
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') {
|
||||
console.error('apns: token store load failed, starting empty', err);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter(isValidApnsTokenRecord);
|
||||
} catch {
|
||||
console.error('apns: token store is malformed JSON, starting empty');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load (or initialise empty) the device-token store backed by `filePath`.
|
||||
* Same conventions as the web-push subscription-store: immutable replacement
|
||||
* on every mutation, FIFO cap, 0600 persistence, tolerant load.
|
||||
* @param filePath absolute path to the JSON store (APNS_STORE_PATH)
|
||||
* @param maxTokens FIFO cap (mirrors cfg.pushMaxSubs)
|
||||
*/
|
||||
export function loadApnsTokenStore(filePath: string, maxTokens: number): ApnsTokenStore {
|
||||
let records: ApnsTokenRecord[] = loadTokenRecords(filePath);
|
||||
|
||||
return {
|
||||
list(): readonly ApnsTokenRecord[] {
|
||||
return Object.freeze(records.slice());
|
||||
},
|
||||
|
||||
add(record: ApnsTokenRecord): void {
|
||||
if (!isValidApnsTokenRecord(record)) {
|
||||
throw new TypeError('apns: invalid token record');
|
||||
}
|
||||
const deduped = records.filter((r) => r.token !== record.token);
|
||||
const next = [...deduped, record];
|
||||
records = next.length > maxTokens ? next.slice(next.length - maxTokens) : next;
|
||||
},
|
||||
|
||||
remove(token: string): void {
|
||||
records = records.filter((r) => r.token !== token);
|
||||
},
|
||||
|
||||
prune(deadTokens: readonly string[]): void {
|
||||
if (deadTokens.length === 0) return;
|
||||
const dead = new Set(deadTokens);
|
||||
records = records.filter((r) => !dead.has(r.token));
|
||||
},
|
||||
|
||||
async persist(): Promise<void> {
|
||||
try {
|
||||
await writeFile(filePath, JSON.stringify(records, null, 2), { mode: FILE_MODE });
|
||||
// writeFile's mode only applies on creation; enforce 0600 if pre-existing.
|
||||
await chmod(filePath, FILE_MODE);
|
||||
} catch (err) {
|
||||
console.error('apns: token store persist failed', err);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── ES256 provider JWT ────────────────────────────────────────────────────────
|
||||
|
||||
function base64urlJson(value: unknown): string {
|
||||
return Buffer.from(JSON.stringify(value)).toString('base64url');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the APNs provider JWT: ES256 (ECDSA P-256 / SHA-256) with the raw
|
||||
* r||s signature Apple expects (dsaEncoding 'ieee-p1363', NOT ASN.1/DER).
|
||||
* @param nowMs injectable clock (tests) — iat is floor(nowMs/1000)
|
||||
*/
|
||||
export function buildApnsJwt(key: KeyObject, keyId: string, teamId: string, nowMs: number): string {
|
||||
const header = base64urlJson({ alg: 'ES256', kid: keyId });
|
||||
const claims = base64urlJson({ iss: teamId, iat: Math.floor(nowMs / 1000) });
|
||||
const signingInput = `${header}.${claims}`;
|
||||
const signature = signBytes('sha256', Buffer.from(signingInput), {
|
||||
key,
|
||||
dsaEncoding: 'ieee-p1363',
|
||||
});
|
||||
return `${signingInput}.${signature.toString('base64url')}`;
|
||||
}
|
||||
|
||||
// ── message construction (payload minimization lives HERE) ───────────────────
|
||||
|
||||
interface ApnsMessage {
|
||||
readonly headers: Readonly<Record<string, string>>;
|
||||
readonly body: string;
|
||||
}
|
||||
|
||||
/** apns-collapse-id: session UUID sans dashes (32 chars — mirrors web-push topic). */
|
||||
function collapseId(sessionId: string): string {
|
||||
return sessionId.replace(/-/g, '').slice(0, 64);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build headers + body for one signal. Returns null for classes outside the P1
|
||||
* APNs signal list (needs-input / done). The alert is minimal by design: a
|
||||
* status word + the session id's short prefix — no cwd, no command content, no
|
||||
* full session id in the visible text. The custom payload mirrors the web-push
|
||||
* `PushPayload` minus the cwd-derived `detail`.
|
||||
*/
|
||||
function buildApnsMessage(
|
||||
cfg: Config,
|
||||
apnsCfg: ApnsConfig,
|
||||
session: Session,
|
||||
cls: NotifyClass,
|
||||
nowMs: number,
|
||||
token?: string,
|
||||
): ApnsMessage | null {
|
||||
if (cls !== 'needs-input' && cls !== 'done') return null;
|
||||
const sessionId = session.meta.id;
|
||||
const isGate = cls === 'needs-input';
|
||||
const shortId = sessionId.slice(0, ALERT_ID_PREFIX_LEN);
|
||||
const expirationSeconds = isGate
|
||||
? Math.ceil(cfg.decisionTokenTtlMs / 1000)
|
||||
: DONE_EXPIRATION_SECONDS;
|
||||
|
||||
const aps: Record<string, unknown> = {
|
||||
alert: { title: isGate ? 'Needs input' : 'Done', body: `Session ${shortId}` },
|
||||
...(isGate ? { category: GATE_CATEGORY, sound: 'default' } : {}),
|
||||
};
|
||||
const payload: Record<string, unknown> = {
|
||||
aps,
|
||||
sessionId,
|
||||
cls,
|
||||
...(isGate && token !== undefined ? { token } : {}),
|
||||
};
|
||||
|
||||
return {
|
||||
headers: {
|
||||
'apns-topic': apnsCfg.bundleId,
|
||||
'apns-push-type': 'alert',
|
||||
'apns-priority': isGate ? PRIORITY_IMMEDIATE : PRIORITY_CONSERVE,
|
||||
'apns-collapse-id': collapseId(sessionId),
|
||||
'apns-expiration': String(Math.floor(nowMs / 1000) + expirationSeconds),
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
};
|
||||
}
|
||||
|
||||
// ── default HTTP/2 client ─────────────────────────────────────────────────────
|
||||
|
||||
/** Extract APNs' error `reason` from a response body, if parseable. */
|
||||
function reasonOf(body: string): string | undefined {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(body);
|
||||
if (parsed !== null && typeof parsed === 'object') {
|
||||
const reason = (parsed as Record<string, unknown>)['reason'];
|
||||
if (typeof reason === 'string') return reason;
|
||||
}
|
||||
} catch {
|
||||
/* non-JSON body — no reason */
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal per-request node:http2 client. One connection per send (KISS): our
|
||||
* volume is a handful of pushes per gate event, so connection reuse machinery
|
||||
* (GOAWAY handling, keep-alive pings) isn't worth its failure modes here.
|
||||
* Every error path settles the promise — never an unhandled 'error' event.
|
||||
*/
|
||||
function createHttp2Client(authority: string): ApnsHttp2Client {
|
||||
return {
|
||||
request(req: ApnsRequest): Promise<ApnsResponse> {
|
||||
return new Promise<ApnsResponse>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const settle = (fn: () => void): void => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
fn();
|
||||
}
|
||||
};
|
||||
let session: ReturnType<typeof connect>;
|
||||
try {
|
||||
session = connect(authority);
|
||||
} catch (err) {
|
||||
reject(err instanceof Error ? err : new Error(String(err)));
|
||||
return;
|
||||
}
|
||||
const fail = (err: Error): void => {
|
||||
session.close();
|
||||
settle(() => reject(err));
|
||||
};
|
||||
session.on('error', fail);
|
||||
session.setTimeout(REQUEST_TIMEOUT_MS, () => fail(new Error('apns: request timed out')));
|
||||
let stream: ReturnType<typeof session.request>;
|
||||
try {
|
||||
stream = session.request({ ':method': 'POST', ':path': req.path, ...req.headers });
|
||||
} catch (err) {
|
||||
fail(err instanceof Error ? err : new Error(String(err)));
|
||||
return;
|
||||
}
|
||||
let status = 0;
|
||||
let body = '';
|
||||
stream.setEncoding('utf8');
|
||||
stream.on('response', (headers) => {
|
||||
status = Number(headers[':status'] ?? 0);
|
||||
});
|
||||
stream.on('data', (chunk: string) => {
|
||||
body += chunk;
|
||||
});
|
||||
stream.on('end', () => {
|
||||
session.close();
|
||||
settle(() => resolve({ status, body }));
|
||||
});
|
||||
stream.on('error', fail);
|
||||
stream.end(req.body);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── sender (NotifyService implementation) ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create the APNs NotifyService. Same seam as `createPushService`: the server
|
||||
* fans hook events out through `notify(session, cls, token?)`.
|
||||
* @param cfg frozen runtime config (DND/done toggles, decision-token TTL)
|
||||
* @param apnsCfg validated APNS_* env group
|
||||
* @param key the loaded .p8 EC private key (never logged)
|
||||
* @param store device-token store (read for fan-out, pruned on 410/BadDeviceToken)
|
||||
* @param deps injectable HTTP/2 client + clock (tests never touch the network)
|
||||
*/
|
||||
export function createApnsService(
|
||||
cfg: Config,
|
||||
apnsCfg: ApnsConfig,
|
||||
key: KeyObject,
|
||||
store: ApnsTokenStore,
|
||||
deps: ApnsServiceDeps = {},
|
||||
): NotifyService {
|
||||
const client = deps.client ?? createHttp2Client(apnsCfg.host);
|
||||
const now = deps.now ?? Date.now;
|
||||
let cachedJwt: { readonly value: string; readonly issuedAtMs: number } | null = null;
|
||||
|
||||
function currentJwt(nowMs: number): string {
|
||||
if (cachedJwt === null || nowMs - cachedJwt.issuedAtMs > JWT_MAX_AGE_MS) {
|
||||
cachedJwt = { value: buildApnsJwt(key, apnsCfg.keyId, apnsCfg.teamId, nowMs), issuedAtMs: nowMs };
|
||||
}
|
||||
return cachedJwt.value;
|
||||
}
|
||||
|
||||
/** Mirrors web-push shouldSend; additionally scopes APNs to its P1 signals. */
|
||||
function shouldSend(cls: NotifyClass): boolean {
|
||||
if (cls !== 'needs-input' && cls !== 'done') return false;
|
||||
if (cfg.notifyDnd) return false;
|
||||
if (cls === 'done' && !cfg.notifyDone) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
async function sendOne(record: ApnsTokenRecord, message: ApnsMessage, dead: string[]): Promise<void> {
|
||||
let res: ApnsResponse;
|
||||
try {
|
||||
res = await client.request({
|
||||
path: `/3/device/${record.token}`,
|
||||
headers: message.headers,
|
||||
body: message.body,
|
||||
});
|
||||
} catch (err) {
|
||||
// Connection-level failure (refused/reset/timeout): keep the token, log
|
||||
// without secrets, never crash the server.
|
||||
console.error(`apns: send failed (${err instanceof Error ? err.message : 'connection error'})`);
|
||||
return;
|
||||
}
|
||||
if (res.status === 200) return;
|
||||
const reason = reasonOf(res.body);
|
||||
if (res.status === 410 || (res.status === 400 && reason === 'BadDeviceToken')) {
|
||||
dead.push(record.token); // device gone / token invalid → evict
|
||||
return;
|
||||
}
|
||||
if (res.status === 403) {
|
||||
cachedJwt = null; // Expired/InvalidProviderToken → re-sign on the next send
|
||||
}
|
||||
// 429 and other transient statuses: keep the token, log (no token/key material).
|
||||
console.error(`apns: send failed (status ${res.status}${reason !== undefined ? ` ${reason}` : ''})`);
|
||||
}
|
||||
|
||||
return {
|
||||
isEnabled: () => true,
|
||||
|
||||
async notify(session: Session, cls: NotifyClass, token?: string): Promise<void> {
|
||||
if (!shouldSend(cls)) return;
|
||||
const records = store.list();
|
||||
if (records.length === 0) return;
|
||||
|
||||
const nowMs = now();
|
||||
const built = buildApnsMessage(cfg, apnsCfg, session, cls, nowMs, token);
|
||||
if (built === null) return;
|
||||
const message: ApnsMessage = {
|
||||
body: built.body,
|
||||
headers: { ...built.headers, authorization: `bearer ${currentJwt(nowMs)}` },
|
||||
};
|
||||
const dead: string[] = [];
|
||||
|
||||
await Promise.all(records.map((record) => sendOne(record, message, dead)));
|
||||
|
||||
if (dead.length > 0) {
|
||||
store.prune(dead);
|
||||
await store.persist();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── composition + server-facing init ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fan a notify out to several providers (web-push + APNs). One provider
|
||||
* failing must never block or hide the others (best-effort, logged).
|
||||
*/
|
||||
export function combineNotifyServices(...services: readonly NotifyService[]): NotifyService {
|
||||
return {
|
||||
isEnabled: () => services.some((service) => service.isEnabled()),
|
||||
|
||||
async notify(session: Session, cls: NotifyClass, token?: string): Promise<void> {
|
||||
await Promise.all(
|
||||
services.map(async (service) => {
|
||||
try {
|
||||
await service.notify(session, cls, token);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`apns: notify fan-out failed (${err instanceof Error ? err.message : 'error'})`,
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Server entry point: read the APNS_* env group and build the runtime, or
|
||||
* return null when the feature is disabled. Exactly one log line either way;
|
||||
* startup NEVER crashes on APNs problems; key material is never logged.
|
||||
* @param maxTokens FIFO cap for the token store — pass cfg.pushMaxSubs
|
||||
*/
|
||||
export function initApns(cfg: Config, env: Record<string, string | undefined>): ApnsRuntime | null {
|
||||
const result = loadApnsConfig(env);
|
||||
if (!result.ok) {
|
||||
console.error(`apns: disabled (${result.reason})`);
|
||||
return null;
|
||||
}
|
||||
const key = loadApnsKey(result.config.keyPath);
|
||||
if (key === null) {
|
||||
// loadApnsKey already logged the specific (key-material-free) cause.
|
||||
return null;
|
||||
}
|
||||
const store = loadApnsTokenStore(result.config.storePath, cfg.pushMaxSubs);
|
||||
const service = createApnsService(cfg, result.config, key, store);
|
||||
console.error(
|
||||
`apns: enabled (topic ${result.config.bundleId}, store ${result.config.storePath})`,
|
||||
);
|
||||
return { service, store };
|
||||
}
|
||||
@@ -46,8 +46,10 @@ import { detachWs, writeInput, setClientDims } from './session/session.js'
|
||||
import { loadSubscriptionStore } from './push/subscription-store.js'
|
||||
import { loadPrefsStore } from './http/prefs-store.js'
|
||||
import { createPushService } from './push/push-service.js'
|
||||
import { combineNotifyServices, initApns, normalizeApnsToken } from './push/apns.js'
|
||||
import type {
|
||||
Config,
|
||||
NotifyService,
|
||||
PermissionGate,
|
||||
PermissionMode,
|
||||
PushSubscriptionRecord,
|
||||
@@ -188,7 +190,14 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
|
||||
// A1: persistent push-subscription store + VAPID-signed push sender, injected
|
||||
// into the manager (DI, M5) so the manager never imports push-service directly.
|
||||
const subStore = loadSubscriptionStore(cfg.pushStorePath, cfg.pushMaxSubs)
|
||||
const pushService = createPushService(cfg, subStore)
|
||||
const webPushService = createPushService(cfg, subStore)
|
||||
// T-iOS-20: optional APNs sender — enabled only when the APNS_* env group is
|
||||
// complete (else null + one log line, never a crash). It fans out beside
|
||||
// web-push through the SAME NotifyService seam, so every existing hook-event
|
||||
// call site below stays untouched: `pushService` is the combined fan-out.
|
||||
const apns = initApns(cfg, process.env as Record<string, string | undefined>)
|
||||
const pushService: NotifyService =
|
||||
apns === null ? webPushService : combineNotifyServices(webPushService, apns.service)
|
||||
const manager = createSessionManager(cfg, pushService)
|
||||
|
||||
// v0.6 Projects: cross-device UI prefs (favourites + group collapse-state).
|
||||
@@ -419,7 +428,11 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
|
||||
// ≥1 subscription (so "walk away with zero tabs" still triggers lock-screen
|
||||
// approval); otherwise fall through to Claude's own prompt.
|
||||
const hasWatcher = session.clients.size > 0
|
||||
const hasPushTarget = pushService.isEnabled() && subStore.list().length > 0
|
||||
// A web-push subscription OR a registered APNs device token counts as a
|
||||
// reachable "walked-away" device (T-iOS-20).
|
||||
const hasPushTarget =
|
||||
(webPushService.isEnabled() && subStore.list().length > 0) ||
|
||||
(apns !== null && apns.store.list().length > 0)
|
||||
if (!hasWatcher && !hasPushTarget) {
|
||||
res.json({})
|
||||
return
|
||||
@@ -450,7 +463,7 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
|
||||
|
||||
// Public VAPID key for SW subscription. 503 when push is disabled (graceful).
|
||||
app.get('/push/vapid-key', (_req, res) => {
|
||||
if (!pushService.isEnabled() || cfg.vapidPublicKey === undefined) {
|
||||
if (!webPushService.isEnabled() || cfg.vapidPublicKey === undefined) {
|
||||
res.status(503).end()
|
||||
return
|
||||
}
|
||||
@@ -497,6 +510,53 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
|
||||
res.status(204).end()
|
||||
})
|
||||
|
||||
// ── T-iOS-20: APNs device-token registry (iOS client) ─────────────────────
|
||||
// Mounted only when the APNS_* env group is complete (disabled ⇒ 404).
|
||||
// Frozen wire shape: POST/DELETE /push/apns-token {token: 64–160 hex chars,
|
||||
// lowercase-normalized} → 204 idempotent upsert/remove; 400 invalid; Origin
|
||||
// guard 403; 5/min/IP 429; body ≤ 8kb — mirrors /push/subscribe above.
|
||||
if (apns !== null) {
|
||||
const apnsStore = apns.store
|
||||
const apnsTokenLimiter = createRateLimiter(SUBSCRIBE_RATE_MAX, RATE_LIMIT_WINDOW_MS)
|
||||
|
||||
app.post('/push/apns-token', express.json({ limit: '8kb' }), async (req, res) => {
|
||||
if (!requireAllowedOrigin(req, res)) return
|
||||
if (!apnsTokenLimiter(req.socket.remoteAddress ?? '', Date.now())) {
|
||||
res.status(429).end()
|
||||
return
|
||||
}
|
||||
const token = normalizeApnsToken(((req.body ?? {}) as Record<string, unknown>)['token'])
|
||||
if (token === null) {
|
||||
res.status(400).end()
|
||||
return
|
||||
}
|
||||
try {
|
||||
apnsStore.add({ token, createdAt: Date.now() })
|
||||
await apnsStore.persist()
|
||||
} catch {
|
||||
res.status(400).end()
|
||||
return
|
||||
}
|
||||
res.status(204).end()
|
||||
})
|
||||
|
||||
app.delete('/push/apns-token', express.json({ limit: '8kb' }), async (req, res) => {
|
||||
if (!requireAllowedOrigin(req, res)) return
|
||||
if (!apnsTokenLimiter(req.socket.remoteAddress ?? '', Date.now())) {
|
||||
res.status(429).end()
|
||||
return
|
||||
}
|
||||
const token = normalizeApnsToken(((req.body ?? {}) as Record<string, unknown>)['token'])
|
||||
if (token === null) {
|
||||
res.status(400).end()
|
||||
return
|
||||
}
|
||||
apnsStore.remove(token) // idempotent: unknown token still 204
|
||||
await apnsStore.persist()
|
||||
res.status(204).end()
|
||||
})
|
||||
}
|
||||
|
||||
// Remote lock-screen Allow/Deny (SEC-C1): called by a REMOTE device's SW (not
|
||||
// loopback) → guard with Origin + a per-decision capability token (stale/
|
||||
// mismatch → 403).
|
||||
|
||||
@@ -62,6 +62,8 @@ function sendIfOpen(ws: WebSocketLike | null, msg: Parameters<typeof serialize>[
|
||||
* (add, delete) rather than in-place modification of the table structure.
|
||||
* Individual Session objects do carry mutable fields (attachedWs, detachedAt,
|
||||
* lastOutputAt, exitedAt, exitCode) by design — those are runtime handles.
|
||||
* lastOutputAt is no longer list()-omitted: since T-iOS-37 it is serialized
|
||||
* into LiveSessionInfo (unread watermark for /live-sessions consumers).
|
||||
*
|
||||
* `notifyService` is injected (DI, M5) so the manager never imports push-service
|
||||
* directly (avoids a manager↔push-service cycle). sweepStuck uses it to push the
|
||||
@@ -190,6 +192,7 @@ export function createSessionManager(
|
||||
cols: s.pty.cols,
|
||||
rows: s.pty.rows,
|
||||
telemetry: s.telemetry, // B2: latest telemetry for the thumbnail wall
|
||||
lastOutputAt: s.lastOutputAt, // T-iOS-37: unread watermark (M3 record)
|
||||
}))
|
||||
.sort((a, b) => b.createdAt - a.createdAt);
|
||||
}
|
||||
|
||||
@@ -253,6 +253,12 @@ export interface LiveSessionInfo {
|
||||
cols: number; // current PTY size (for the manage page)
|
||||
rows: number;
|
||||
telemetry?: StatusTelemetry | null; // B2: latest telemetry for the thumbnail wall
|
||||
/** T-iOS-37: last pty.onData timestamp (epoch ms; = createdAt until first
|
||||
* output — createSession inits it to spawn time). Serialized from the M3
|
||||
* runtime record so clients can derive an unread watermark
|
||||
* (lastOutputAt > local last-seen). Additive OPTIONAL: older consumers
|
||||
* that build or read LiveSessionInfo without it stay valid. */
|
||||
readonly lastOutputAt?: number;
|
||||
}
|
||||
|
||||
/* ───────────────── project manager (v0.6, §4.3 FEATURE doc) ──────────────── */
|
||||
|
||||
@@ -1049,3 +1049,66 @@ describe('list — telemetry field', () => {
|
||||
expect(entry?.telemetry).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── list() carries lastOutputAt (T-iOS-37 unread watermark) ───────────────────
|
||||
// The runtime record (Session.lastOutputAt, refreshed on every pty.onData — M3)
|
||||
// is serialized into LiveSessionInfo so clients can compute an unread dot
|
||||
// (lastOutputAt > local last-seen). Additive OPTIONAL field: existing consumers
|
||||
// (preview grid, projects panel) are unaffected.
|
||||
describe('list — lastOutputAt field (T-iOS-37)', () => {
|
||||
it('includes lastOutputAt matching the session runtime record after output flows', () => {
|
||||
const mgr = createSessionManager(CFG);
|
||||
nextPty = createMockPty();
|
||||
const s = mgr.handleAttach(createMockWs(), null, DIMS, 1_000);
|
||||
|
||||
// Output flows → session.ts pty.onData refreshes the runtime record with
|
||||
// Date.now(); list() must serialize exactly that value.
|
||||
const before = Date.now();
|
||||
(s.pty as MockIPty).emitData('hello from claude');
|
||||
|
||||
const entry = mgr.list().find((e) => e.id === s.meta.id);
|
||||
expect(s.lastOutputAt).toBeGreaterThanOrEqual(before);
|
||||
expect(entry?.lastOutputAt).toBe(s.lastOutputAt);
|
||||
});
|
||||
|
||||
it('serializes a pinned runtime value verbatim (epoch ms, no transformation)', () => {
|
||||
const mgr = createSessionManager(CFG);
|
||||
nextPty = createMockPty();
|
||||
const s = mgr.handleAttach(createMockWs(), null, DIMS, 1_000);
|
||||
|
||||
// Pin the runtime record to a known cursor (same style as the M3 reap tests).
|
||||
s.lastOutputAt = 123_456;
|
||||
|
||||
const entry = mgr.list().find((e) => e.id === s.meta.id);
|
||||
expect(entry?.lastOutputAt).toBe(123_456);
|
||||
});
|
||||
|
||||
it('equals spawn time for a fresh session before any output (createSession inits lastOutputAt = now)', () => {
|
||||
const mgr = createSessionManager(CFG);
|
||||
nextPty = createMockPty();
|
||||
const s = mgr.handleAttach(createMockWs(), null, DIMS, 1_000);
|
||||
|
||||
// createSession initializes lastOutputAt: now (src/session/session.ts) —
|
||||
// a fresh session's watermark IS its spawn time, never absent/zero.
|
||||
const entry = mgr.list().find((e) => e.id === s.meta.id);
|
||||
expect(entry?.lastOutputAt).toBe(1_000);
|
||||
});
|
||||
|
||||
it('keeps the existing LiveSessionInfo shape intact (additive optional field)', () => {
|
||||
const mgr = createSessionManager(CFG);
|
||||
nextPty = createMockPty();
|
||||
const s = mgr.handleAttach(createMockWs(), null, DIMS, 1_000);
|
||||
|
||||
const entry = mgr.list().find((e) => e.id === s.meta.id);
|
||||
expect(entry).toMatchObject({
|
||||
id: s.meta.id,
|
||||
createdAt: 1_000,
|
||||
clientCount: 1,
|
||||
status: 'unknown',
|
||||
exited: false,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
telemetry: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
1084
test/push-apns.test.ts
Normal file
1084
test/push-apns.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user