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:
Yaojia Wang
2026-07-05 13:34:01 +02:00
parent aa956fcbb4
commit 4871e8ac3d
20 changed files with 3319 additions and 48 deletions

View File

@@ -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,未推送/未合并) ### ✅ iOS 客户端 P0 实施(完成 — 2026-07-05,分支 `feat/ios-client`,7 commits,未推送/未合并)
按 PLAN_IOS_CLIENT §8 批次推进;编排 = 每波一个 ultracode `Workflow`(builder 按任务派发 + 独立 verify agent 复跑验收)。 按 PLAN_IOS_CLIENT §8 批次推进;编排 = 每波一个 ultracode `Workflow`(builder 按任务派发 + 独立 verify agent 复跑验收)。
- **[x] W0 基础(T-iOS-1/2/3/4,5 agents,verify 6/6 PASS)**: - **[x] W0 基础(T-iOS-1/2/3/4,5 agents,verify 6/6 PASS)**:

View File

@@ -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 { guard let request = route.urlRequest(for: endpoint) else {
throw APIClientError.invalidRequest throw APIClientError.invalidRequest
} }
@@ -115,7 +115,7 @@ public struct APIClient: Sendable {
} }
/// 200 ok; 404 `.sessionNotFound`; anything else `.unexpectedStatus`. /// 200 ok; 404 `.sessionNotFound`; anything else `.unexpectedStatus`.
private static func requireOK(_ response: HTTPURLResponse) throws { static func requireOK(_ response: HTTPURLResponse) throws {
switch response.statusCode { switch response.statusCode {
case HTTPStatus.ok: case HTTPStatus.ok:
return return
@@ -128,10 +128,12 @@ public struct APIClient: Sendable {
} }
/// Named HTTP status codes used by the client (no magic numbers, plan §4). /// Named HTTP status codes used by the client (no magic numbers, plan §4).
private enum HTTPStatus { enum HTTPStatus {
static let ok = 200 static let ok = 200
static let noContent = 204 static let noContent = 204
static let badRequest = 400
static let forbidden = 403 static let forbidden = 403
static let notFound = 404 static let notFound = 404
static let tooManyRequests = 429 static let tooManyRequests = 429
static let internalServerError = 500
} }

View 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 64160 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)
}
}
}

View File

@@ -1,10 +1,11 @@
import Foundation import Foundation
import WireProtocol 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 { enum HTTPMethod: String, Sendable {
case get = "GET" case get = "GET"
case post = "POST" case post = "POST"
case put = "PUT"
case delete = "DELETE" case delete = "DELETE"
} }
@@ -36,10 +37,29 @@ struct APIRoute: Sendable, Equatable {
let path: String let path: String
let originPolicy: OriginPolicy let originPolicy: OriginPolicy
let body: Data? 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: /// Build the `URLRequest` against `endpoint.baseURL`'s scheme/host/port:
/// the path is REPLACED and query/fragment/credentials are dropped the /// the path is REPLACED, the query is REPLACED by `percentEncodedQuery`
/// same derivation philosophy as `HostEndpoint.wsURL`. Origin stamping /// (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 /// happens HERE and only here (single point; hand-stamping elsewhere is a
/// review CRITICAL, plan §5.1). /// review CRITICAL, plan §5.1).
func urlRequest(for endpoint: HostEndpoint) -> URLRequest? { func urlRequest(for endpoint: HostEndpoint) -> URLRequest? {
@@ -51,6 +71,9 @@ struct APIRoute: Sendable, Equatable {
components.fragment = nil components.fragment = nil
components.user = nil components.user = nil
components.password = nil components.password = nil
if let percentEncodedQuery {
components.percentEncodedQuery = percentEncodedQuery
}
guard let url = components.url else { return nil } guard let url = components.url else { return nil }
var request = URLRequest(url: url) var request = URLRequest(url: url)
@@ -66,12 +89,25 @@ struct APIRoute: Sendable, Equatable {
} }
} }
/// Builders for the six frozen endpoints (plan §3.4). Route table /// Builders for the frozen endpoints (plan §3.4 + T-iOS-38 P1 ). Route
/// (verified against src/server.ts): /// table (verified against src/server.ts):
/// - RO `GET /live-sessions` (:257) · `GET /live-sessions/:id/preview` (:314) /// - RO `GET /live-sessions` (:257) · `GET /live-sessions/:id/preview` (:314)
/// · `GET /live-sessions/:id/events` (:528) · `GET /config/ui` (:609) /// · `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) /// - 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 { 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 { static func liveSessions() -> APIRoute {
APIRoute(method: .get, path: "/live-sessions", originPolicy: .readOnly, body: nil) APIRoute(method: .get, path: "/live-sessions", originPolicy: .readOnly, body: nil)
} }

View File

@@ -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
}
}

View File

@@ -159,9 +159,26 @@ public enum APIClientError: Error, Equatable, Sendable {
/// token is missing / mismatched / STALE tokens are single-use and /// token is missing / mismatched / STALE tokens are single-use and
/// expiring by design (SEC-C1/M1). User-facing in `message`. /// expiring by design (SEC-C1/M1). User-facing in `message`.
case decisionRejected case decisionRejected
/// 429: `POST /hook/decision` is limited to 10 requests/min/IP /// 429: the endpoint is rate-limited per IP by fixed server policy
/// (src/server.ts:72,504-508 fixed server security 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 case rateLimited
/// The APNs device token is not 64160 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. /// Any other non-success status code.
case unexpectedStatus(Int) case unexpectedStatus(Int)
@@ -179,7 +196,15 @@ public enum APIClientError: Error, Equatable, Sendable {
case .decisionRejected: case .decisionRejected:
"审批令牌已过期或已被处理——请回到终端里直接批准/拒绝。" "审批令牌已过期或已被处理——请回到终端里直接批准/拒绝。"
case .rateLimited: case .rateLimited:
"操作过于频繁服务器限 10 次/分钟),请稍后再试。" "操作过于频繁服务器已限流,请稍后再试。"
case .invalidApnsToken:
"推送注册令牌格式异常,请重启 App 重新注册推送。"
case .projectPathInvalid:
"项目路径为空或不合法。"
case .projectNotFound:
"项目不存在(路径可能已移动或删除)。"
case .projectDetailUnavailable:
"读取项目详情失败,请稍后再试。"
case .unexpectedStatus(let status): case .unexpectedStatus(let status):
"服务器返回了意外状态码 \(status)" "服务器返回了意外状态码 \(status)"
} }

View File

@@ -146,42 +146,22 @@ extension PairingError {
(error as NSError).localizedDescription (error as NSError).localizedDescription
} }
/// LAN-class targets for the `localNetworkDenied` mapping: RFC1918, /// LAN-class targets for the `localNetworkDenied` mapping a VIEW over
/// link-local 169.254/16, loopback, CGNAT 100.64/10 (Tailscale), and /// `HostClassifier` (T-iOS-38 dedup; plan §5.4 tiers are the single
/// mDNS `.local` names. (T-iOS-12's warning TIERS are separate logic /// classification source):
/// this predicate only gates the Local-Network-permission diagnosis.) /// - `.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 { static func isPrivateOrLocalHost(_ host: String) -> Bool {
let lowered = host.lowercased() switch HostClassifier.classify(host: host) {
if lowered == "localhost" || lowered.hasSuffix(".local") { case .loopback, .privateLAN:
return true return true
} case .tailscale:
guard let octets = ipv4Octets(lowered) else { return HostClassifier.ipv4Octets(host.lowercased()) != nil
case .public:
return false 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:
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
} }
} }

View 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)
}
}
}

View 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)
}
}
}

View 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 token32
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)
}
}

View File

@@ -0,0 +1,83 @@
import Foundation
import Testing
import WireProtocol
import APIClient
/// T-iOS-38 · host plan §5.4 API
/// loopback / privateLAN / tailscale / publicW3 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)")
}
}
}

View File

@@ -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"
/// blobstring/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()
}
}
}

View 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/detailpercent-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)
}
}

View File

@@ -63,6 +63,12 @@ targets:
properties: properties:
CFBundleDisplayName: WebTerm CFBundleDisplayName: WebTerm
UILaunchScreen: {} 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: UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait - UIInterfaceOrientationPortrait
- UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeLeft

576
src/push/apns.ts Normal file
View 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
* 2060 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 2060 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 64160 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
* 64160 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 };
}

View File

@@ -46,8 +46,10 @@ import { detachWs, writeInput, setClientDims } from './session/session.js'
import { loadSubscriptionStore } from './push/subscription-store.js' import { loadSubscriptionStore } from './push/subscription-store.js'
import { loadPrefsStore } from './http/prefs-store.js' import { loadPrefsStore } from './http/prefs-store.js'
import { createPushService } from './push/push-service.js' import { createPushService } from './push/push-service.js'
import { combineNotifyServices, initApns, normalizeApnsToken } from './push/apns.js'
import type { import type {
Config, Config,
NotifyService,
PermissionGate, PermissionGate,
PermissionMode, PermissionMode,
PushSubscriptionRecord, PushSubscriptionRecord,
@@ -188,7 +190,14 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
// A1: persistent push-subscription store + VAPID-signed push sender, injected // A1: persistent push-subscription store + VAPID-signed push sender, injected
// into the manager (DI, M5) so the manager never imports push-service directly. // into the manager (DI, M5) so the manager never imports push-service directly.
const subStore = loadSubscriptionStore(cfg.pushStorePath, cfg.pushMaxSubs) 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) const manager = createSessionManager(cfg, pushService)
// v0.6 Projects: cross-device UI prefs (favourites + group collapse-state). // 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 // ≥1 subscription (so "walk away with zero tabs" still triggers lock-screen
// approval); otherwise fall through to Claude's own prompt. // approval); otherwise fall through to Claude's own prompt.
const hasWatcher = session.clients.size > 0 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) { if (!hasWatcher && !hasPushTarget) {
res.json({}) res.json({})
return 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). // Public VAPID key for SW subscription. 503 when push is disabled (graceful).
app.get('/push/vapid-key', (_req, res) => { app.get('/push/vapid-key', (_req, res) => {
if (!pushService.isEnabled() || cfg.vapidPublicKey === undefined) { if (!webPushService.isEnabled() || cfg.vapidPublicKey === undefined) {
res.status(503).end() res.status(503).end()
return return
} }
@@ -497,6 +510,53 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
res.status(204).end() 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: 64160 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 // 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/ // loopback) → guard with Origin + a per-decision capability token (stale/
// mismatch → 403). // mismatch → 403).

View File

@@ -62,6 +62,8 @@ function sendIfOpen(ws: WebSocketLike | null, msg: Parameters<typeof serialize>[
* (add, delete) rather than in-place modification of the table structure. * (add, delete) rather than in-place modification of the table structure.
* Individual Session objects do carry mutable fields (attachedWs, detachedAt, * Individual Session objects do carry mutable fields (attachedWs, detachedAt,
* lastOutputAt, exitedAt, exitCode) by design — those are runtime handles. * 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 * `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 * directly (avoids a manager↔push-service cycle). sweepStuck uses it to push the
@@ -190,6 +192,7 @@ export function createSessionManager(
cols: s.pty.cols, cols: s.pty.cols,
rows: s.pty.rows, rows: s.pty.rows,
telemetry: s.telemetry, // B2: latest telemetry for the thumbnail wall 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); .sort((a, b) => b.createdAt - a.createdAt);
} }

View File

@@ -253,6 +253,12 @@ export interface LiveSessionInfo {
cols: number; // current PTY size (for the manage page) cols: number; // current PTY size (for the manage page)
rows: number; rows: number;
telemetry?: StatusTelemetry | null; // B2: latest telemetry for the thumbnail wall 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) ──────────────── */ /* ───────────────── project manager (v0.6, §4.3 FEATURE doc) ──────────────── */

View File

@@ -1049,3 +1049,66 @@ describe('list — telemetry field', () => {
expect(entry?.telemetry).toBeNull(); 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

File diff suppressed because it is too large Load Diff