feat(ios,android): P2 wave, git panel, token UX, per-host WS token, docs

App layer, four sequential slices (a shared .xcodeproj means adding files
regenerates it, so these could not run in parallel):

- token UX end to end: pairing prompts for a token when a host 401s, POST /auth
  validates it, and 204-without-Set-Cookie is correctly read as "this server has
  auth disabled" rather than "authenticated". A host paired before the token was
  turned on recovers by re-pairing in place. Remove-host now exists and finally
  gives PushRegistrar.handleHostRemoved a caller.
- project git panel + worktree lifecycle (T-iOS-32) + claude --resume history —
  the parity gap with Android and the web front end.
- terminal search (T-iOS-33) and voice PTT (T-iOS-31) with an epoch guard so a
  session switch between dictation and confirm cannot inject into the wrong
  session.
- theme + Dynamic Type (T-iOS-34) and web ?join= interop (T-iOS-35). RootView no
  longer hard-locks .preferredColorScheme(.dark).

Also unpins SwiftTerm to 1.15.0 by dropping the local hasActiveSelection that
collided with the upstream one, verified green from a fresh derivedDataPath.

Includes the two HIGH fixes the security review found:
- iOS resolved the WS token host-independently, so a token-gated host sitting
  next to an open one could never open a terminal and no on-screen remedy could
  fix it. Now one transport per host; cross-host leakage is structurally
  impossible since both read paths return only that host's own value.
- Android reported the host's own git-credential 401 (git-ops.ts:108, "Push
  authentication required on the host.") as "your access token is wrong", because
  a blanket 401 mapping ran ahead of the per-route one. Git-write routes are now
  ROUTE_DEFINED and keep the server's message.

And the doc sync: README/ios README no longer claim the client is unmerged on
feat/ios-client, the Clients section finally lists Android, and the plan
checkboxes reflect what is actually built.

iOS 534 app tests + 452 package tests; Android 687 tests.
This commit is contained in:
Yaojia Wang
2026-07-30 15:57:41 +02:00
parent 9114630c3a
commit 284cfd193a
70 changed files with 10271 additions and 358 deletions

View File

@@ -0,0 +1,146 @@
import APIClient
import Foundation
import Observation
import WireProtocol
/// T-iOS-32 · `claude --resume <id>` `GET /sessions`
///
/// `~/.claude/projects`
/// `src/http/history.ts` `claude --resume`
/// ""`attach(null, cwd)` + attach
/// `claude --resume <id>\r` web `public/tabs.ts:918 newTabForResume`
///
/// ****`id`/`cwd`/`preview`
/// `id` ** PTY ** `ProjectResumeCommand`
/// id`;` `` ` `` `$(`
/// web `claude --resume ${sessionId}\r` iOS
@MainActor
@Observable
final class ResumeHistoryViewModel {
/// `bootstrapInput == nil` id
///
struct ResumeCandidate: Equatable, Identifiable, Sendable {
let session: HistorySession
/// cwdworktree worktree
let cwd: String
let bootstrapInput: String?
var id: String { session.id }
var canResume: Bool { bootstrapInput != nil }
}
enum Phase: Equatable {
case loading
case loaded([ResumeCandidate])
///
case empty
case failed(String)
}
let projectPath: String
private(set) var phase: Phase = .loading
@ObservationIgnored
private let fetch: @Sendable () async throws -> [HistorySession]
init(projectPath: String, fetch: @escaping @Sendable () async throws -> [HistorySession]) {
self.projectPath = projectPath
self.fetch = fetch
}
///
static func forProject(
endpoint: HostEndpoint, http: any HTTPTransport, path: String
) -> ResumeHistoryViewModel {
let client = APIClient(endpoint: endpoint, http: http)
return ResumeHistoryViewModel(projectPath: path, fetch: {
try await client.claudeSessions()
})
}
/// Fetch
func load() async {
phase = .loading
do {
let candidates = Self.candidates(from: try await fetch(), projectPath: projectPath)
phase = candidates.isEmpty ? .empty : .loaded(candidates)
} catch {
phase = .failed(GitWriteFeedback.message(for: error, fallback: ResumeCopy.loadFailed))
}
}
/// + mtime ****
///
/// cwd cwd
/// resume `/` `/repos/web-terminal-old`
/// `/repos/web-terminal`
static func candidates(
from sessions: [HistorySession], projectPath: String
) -> [ResumeCandidate] {
let root = normalized(projectPath)
guard Validation.isAbsoluteCwd(root) else { return [] }
return sessions.compactMap { session in
let cwd = normalized(session.cwd)
guard Validation.isAbsoluteCwd(cwd), isInside(cwd: cwd, root: root) else { return nil }
return ResumeCandidate(
session: session,
cwd: cwd,
bootstrapInput: ProjectResumeCommand.bootstrapInput(sessionId: session.id)
)
}
}
/// `/` `/`
private static func normalized(_ path: String) -> String {
guard path.count > 1, path.hasSuffix("/") else { return path }
return String(path.dropLast())
}
private static func isInside(cwd: String, root: String) -> Bool {
cwd == root || cwd.hasPrefix(root.hasSuffix("/") ? root : root + "/")
}
}
// MARK: -
/// id PTY
///
/// id `.jsonl` UUID
/// `[A-Za-z0-9._-]` `;``|``&``` ` ```$`
/// " id" id
enum ProjectResumeCommand {
/// UUID 36
static let maxIdLength = 128
private static let allowed = Set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-")
/// nil = id `\r`0x0D Enter `\r`
/// `\n`CLAUDE.md Gotchas
static func bootstrapInput(sessionId: String) -> String? {
guard isWellFormed(sessionId) else { return nil }
return "claude --resume \(sessionId)\r"
}
static func isWellFormed(_ sessionId: String) -> Bool {
!sessionId.isEmpty
&& sessionId.count <= maxIdLength
&& sessionId.allSatisfy(allowed.contains)
}
}
// MARK: - plan §4
enum ResumeCopy {
static let entryLabel = "恢复历史会话"
static let sheetTitle = "历史会话"
static let emptyTitle = "暂无历史会话"
static let emptyDetail = "主机在此仓库下还没有 Claude Code 历史记录(`~/.claude/projects`)。"
static let loadFailed = "读取历史会话失败"
static let retry = "重试"
static let resume = "恢复"
static let notResumable = "会话标识不合法,无法恢复。"
static let noPreview = "(无首条提示)"
static func resumeAccessibilityLabel(_ project: String) -> String { "恢复 \(project) 的会话" }
}