T-iOS-22: DeepLinkRouter (full-field whitelist, cold-start stash, route(from:) for push-tap reuse), 21 tests T-iOS-24: TimelineSheet mirroring web render() order; disabled→empty-state; reuses AwayDigest onExpand T-iOS-25: QuickReply chips (built-ins mirror quick-reply.ts via KeyByteMap; visible iff live gate && !readOnly) T-iOS-27: read-only DiffScreen + App-layer DiffFetcher (RO no-Origin; APIClient fold-in noted for T-iOS-38 owner) T-iOS-26: Projects list/detail — grouping byte-identical to web group keys (prefs-shared collapse state), prefs-clobber defenses (no blind PUT on empty base; adopt server echo), claude\r bootstrap T-iOS-23: UnreadLedger + TitleSanitizer (SessionCore, +15 tests), lastOutputAt decode, list-boundary re-sanitize T-iOS-29: new-in-cwd (untrusted cwd, no bootstrap re-injection) + exited-session reopen; fixes stale-controller SwiftTerm view bug via .id(controller.id) T-iOS-28: offscreen SwiftTerm thumbnail pipeline (LRU 32, concurrency gate 2, 256KiB cap, grid clamp) T-iOS-21: PushRegistrar (WEBTERM_GATE category: Allow=.authenticationRequired, no .foreground) + NotificationActionHandler (whitelisted payload, token never persisted, bg-task-wrapped POST, 403 fallback) CRITICAL fix (verify-found boot crash): @Sendable literals on UN completion closures — MainActor-inherited closures trapped Swift 6 executor check on UN's background queue; boot re-verified (no new crash reports, permission prompt reached, privacy shade correctly covering during system alert) Verified: 261 pkg + 247 app + 10 integration tests green; 7/7 semantics checks; Owns audit clean
235 lines
9.6 KiB
Swift
235 lines
9.6 KiB
Swift
import Foundation
|
||
import WireProtocol
|
||
|
||
// T-iOS-27 · App-layer fetcher for `GET /projects/diff?path=&staged=` —— the
|
||
// read-only structured git diff (server route src/server.ts:601-618, parser
|
||
// src/http/diff.ts). iOS mirrors the web split: parsing lives ONLY server-side;
|
||
// this file fetches + tolerantly decodes, the screen renders verbatim.
|
||
//
|
||
// 为什么不在 APIClient 包里:APIClient 是 T-iOS-38 单一 owner(PLAN §6 Owns
|
||
// 铁律),本任务不得改包。此 fetcher 走同一枚注入的 `HTTPTransport`,语义与
|
||
// APIClient 路由构建完全同构 —— **回收进 APIClient 是 T-iOS-38 owner 的
|
||
// follow-up**(届时本文件的路由/编码/解码可整体搬迁,测试随行)。
|
||
//
|
||
// Origin 铁律(plan §3.4/§5.1,iff-G):`/projects/diff` 是 RO 端点 —— 路由层
|
||
// 没有 requireAllowedOrigin(src/server.ts:601 注释 "no Origin guard; same
|
||
// threat model as /projects"),因此请求**绝不携带 Origin**。若服务器未来把它
|
||
// 改为 G,测试先红而不是靠巧合通过。
|
||
//
|
||
// 服务器是不可信输入源(plan §4):解码逐层宽容,镜像 web 的
|
||
// normalizeDiffResult(public/diff.ts:38-102)—— 顶层三键缺一 → 整体无效;
|
||
// 畸形 file/hunk/line 逐条丢弃;未知枚举值降级(kind → .context、status →
|
||
// .modified),绝不 crash。
|
||
|
||
// MARK: - 模型(镜像 src/types.ts:445-479;App 内部,待 T-iOS-38 回收)
|
||
|
||
/// One diff line's semantic kind. Unknown wire values degrade to `.context`
|
||
/// (the web renderer's `?? 'df-context'` fallback, public/diff.ts:192).
|
||
enum DiffLineKind: String, Sendable, Equatable {
|
||
case added, removed, context, hunk, meta
|
||
}
|
||
|
||
struct DiffLine: Sendable, Equatable {
|
||
let kind: DiffLineKind
|
||
/// Verbatim server bytes (marker already stripped server-side). UNTRUSTED
|
||
/// display input — render via `Text(verbatim:)` only, never Markdown/links.
|
||
let text: String
|
||
}
|
||
|
||
struct DiffHunk: Sendable, Equatable {
|
||
let header: String
|
||
let lines: [DiffLine]
|
||
}
|
||
|
||
/// File-level change status. Unknown wire values degrade to `.modified`
|
||
/// (display-only field; the web keeps the raw string for a CSS class).
|
||
enum DiffFileStatus: String, Sendable, Equatable {
|
||
case modified, added, deleted, renamed, binary, untracked
|
||
}
|
||
|
||
struct DiffFile: Sendable, Equatable {
|
||
let oldPath: String
|
||
let newPath: String
|
||
let status: DiffFileStatus
|
||
let added: Int
|
||
let removed: Int
|
||
let binary: Bool
|
||
let hunks: [DiffHunk]
|
||
}
|
||
|
||
struct DiffResult: Sendable, Equatable {
|
||
let files: [DiffFile]
|
||
let staged: Bool
|
||
let truncated: Bool
|
||
}
|
||
|
||
// MARK: - 宽容解码(Decodable 在 extension 里,保留成员逐一初始化器)
|
||
|
||
extension DiffLine: Decodable {
|
||
private enum CodingKeys: String, CodingKey { case kind, text }
|
||
|
||
init(from decoder: any Decoder) throws {
|
||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||
// kind/text 必须是字符串,否则整行丢弃(镜像 normalizeLine)。
|
||
let rawKind = try container.decode(String.self, forKey: .kind)
|
||
text = try container.decode(String.self, forKey: .text)
|
||
kind = DiffLineKind(rawValue: rawKind) ?? .context
|
||
}
|
||
}
|
||
|
||
extension DiffHunk: Decodable {
|
||
private enum CodingKeys: String, CodingKey { case header, lines }
|
||
|
||
init(from decoder: any Decoder) throws {
|
||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||
header = try container.decode(String.self, forKey: .header)
|
||
let boxes = try container.decode([DiffLossyBox<DiffLine>].self, forKey: .lines)
|
||
lines = boxes.compactMap(\.value)
|
||
}
|
||
}
|
||
|
||
extension DiffFile: Decodable {
|
||
private enum CodingKeys: String, CodingKey {
|
||
case oldPath, newPath, status, added, removed, binary, hunks
|
||
}
|
||
|
||
init(from decoder: any Decoder) throws {
|
||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||
oldPath = try container.decode(String.self, forKey: .oldPath)
|
||
newPath = try container.decode(String.self, forKey: .newPath)
|
||
added = try container.decode(Int.self, forKey: .added)
|
||
removed = try container.decode(Int.self, forKey: .removed)
|
||
binary = try container.decode(Bool.self, forKey: .binary)
|
||
let rawStatus = try container.decode(String.self, forKey: .status)
|
||
status = DiffFileStatus(rawValue: rawStatus) ?? .modified
|
||
let boxes = try container.decode([DiffLossyBox<DiffHunk>].self, forKey: .hunks)
|
||
hunks = boxes.compactMap(\.value)
|
||
}
|
||
}
|
||
|
||
extension DiffResult: Decodable {
|
||
private enum CodingKeys: String, CodingKey { case files, staged, truncated }
|
||
|
||
init(from decoder: any Decoder) throws {
|
||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||
staged = try container.decode(Bool.self, forKey: .staged)
|
||
truncated = try container.decode(Bool.self, forKey: .truncated)
|
||
let boxes = try container.decode([DiffLossyBox<DiffFile>].self, forKey: .files)
|
||
files = boxes.compactMap(\.value)
|
||
}
|
||
}
|
||
|
||
/// Per-element tolerance shim: a malformed element becomes nil instead of
|
||
/// failing the whole array. Local twin of APIClient's internal `LossyBox`
|
||
/// (not importable across the package boundary) — folds together with the
|
||
/// fetcher in the T-iOS-38 follow-up.
|
||
private struct DiffLossyBox<Wrapped: Decodable>: Decodable {
|
||
let value: Wrapped?
|
||
|
||
init(from decoder: any Decoder) {
|
||
value = try? Wrapped(from: decoder)
|
||
}
|
||
}
|
||
|
||
// MARK: - 错误分类学(route 层语义,src/server.ts:602-618)
|
||
|
||
enum DiffFetchError: Error, Equatable {
|
||
/// 空 path / 无法编码 / URL 构建失败 —— 客户端先拒,零网络。
|
||
case invalidRequest
|
||
/// 服务器 400:`path query parameter is required`。
|
||
case pathInvalid
|
||
/// 服务器 404:SEC-H7 三叉校验失败(绝对路径 + 目录 + 有 .git)。
|
||
case projectNotFound
|
||
/// 200 但响应体不是 DiffResult 形状(不可信输入源兜底)。
|
||
case invalidResponse
|
||
/// 其余状态(含 500 `failed to read diff`)。
|
||
case unexpectedStatus(Int)
|
||
}
|
||
|
||
// MARK: - DiffFetcher
|
||
|
||
/// Fetch a repo's structured diff over the injected transport. Immutable
|
||
/// value; one instance per (endpoint) — the screen's VM closes over it.
|
||
struct DiffFetcher: Sendable {
|
||
let endpoint: HostEndpoint
|
||
private let http: any HTTPTransport
|
||
|
||
/// Route constants(无魔法字符串)。`stagedFlag`:服务器精确匹配
|
||
/// `req.query['staged'] === '1'`(src/server.ts:613)——必须发 `1`/`0`。
|
||
/// (对照:web fetchDiff 发 `true`/`false`,永远匹配不上 `'1'` ——
|
||
/// staged 开关在 web 端静默失效,已在任务日志上报,不属本任务 Owns。)
|
||
private enum Route {
|
||
static let path = "/projects/diff"
|
||
static let pathKey = "path"
|
||
static let stagedKey = "staged"
|
||
static let stagedOn = "1"
|
||
static let stagedOff = "0"
|
||
static let methodGet = "GET"
|
||
}
|
||
|
||
private enum Status {
|
||
static let ok = 200
|
||
static let badRequest = 400
|
||
static let notFound = 404
|
||
}
|
||
|
||
/// Strict RFC 3986 unreserved set —— 与 APIClient `Endpoints.
|
||
/// unreservedCharacters` 同值同理(裸 `+` 会被 Express qs 解码成空格,
|
||
/// `&`/`=` 会拆参);该常量在包内 internal 不可 import,T-iOS-38 回收时合并。
|
||
private static let unreservedCharacters = CharacterSet(
|
||
charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
||
)
|
||
|
||
init(endpoint: HostEndpoint, http: any HTTPTransport) {
|
||
self.endpoint = endpoint
|
||
self.http = http
|
||
}
|
||
|
||
/// `GET /projects/diff?path=&staged=` → 宽容解码后的 `DiffResult`。
|
||
/// 空 path 客户端先拒(镜像服务器 400 规则),不产生网络 I/O。
|
||
func fetch(path: String, staged: Bool) async throws -> DiffResult {
|
||
guard !path.isEmpty, let request = buildRequest(path: path, staged: staged) else {
|
||
throw DiffFetchError.invalidRequest
|
||
}
|
||
let (data, response) = try await http.send(request)
|
||
switch response.statusCode {
|
||
case Status.ok:
|
||
guard let result = try? JSONDecoder().decode(DiffResult.self, from: data) else {
|
||
throw DiffFetchError.invalidResponse
|
||
}
|
||
return result
|
||
case Status.badRequest:
|
||
throw DiffFetchError.pathInvalid
|
||
case Status.notFound:
|
||
throw DiffFetchError.projectNotFound
|
||
default:
|
||
throw DiffFetchError.unexpectedStatus(response.statusCode)
|
||
}
|
||
}
|
||
|
||
/// Build the RO GET —— 与 APIClient `APIRoute.urlRequest(for:)` 同派生
|
||
/// 哲学:baseURL 只取 scheme/host/port,path/query 整体替换,fragment 与
|
||
/// 凭据丢弃。**不 stamp Origin**(iff-G,见文件头)。
|
||
private func buildRequest(path: String, staged: Bool) -> URLRequest? {
|
||
guard let encodedPath = path.addingPercentEncoding(
|
||
withAllowedCharacters: Self.unreservedCharacters
|
||
) else { return nil }
|
||
guard var components = URLComponents(
|
||
url: endpoint.baseURL, resolvingAgainstBaseURL: true
|
||
) else { return nil }
|
||
components.path = Route.path
|
||
components.query = nil
|
||
components.fragment = nil
|
||
components.user = nil
|
||
components.password = nil
|
||
let stagedValue = staged ? Route.stagedOn : Route.stagedOff
|
||
components.percentEncodedQuery =
|
||
"\(Route.pathKey)=\(encodedPath)&\(Route.stagedKey)=\(stagedValue)"
|
||
guard let url = components.url else { return nil }
|
||
|
||
var request = URLRequest(url: url)
|
||
request.httpMethod = Route.methodGet
|
||
return request
|
||
}
|
||
}
|