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:
146
ios/App/WebTerm/ViewModels/ResumeHistoryViewModel.swift
Normal file
146
ios/App/WebTerm/ViewModels/ResumeHistoryViewModel.swift
Normal 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
|
||||
/// 会话自己的 cwd(worktree 会话会回到那个 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) 的会话" }
|
||||
}
|
||||
Reference in New Issue
Block a user