feat(ios): P1-B — W7 UI wave: deep links, timeline, quick-reply, diff, projects, session switcher, thumbnails, lock-screen push
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
This commit is contained in:
234
ios/App/WebTerm/DiffFetcher.swift
Normal file
234
ios/App/WebTerm/DiffFetcher.swift
Normal file
@@ -0,0 +1,234 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user