import APIClient import Foundation import Observation import WireProtocol /// T-iOS-32 · `claude --resume ` 历史(`GET /sessions`)。 /// /// 服务端把主机 `~/.claude/projects` 下最近修改的会话文件列给我们( /// `src/http/history.ts`),这正是 `claude --resume` 选择器的数据。恢复动作走的是 /// 已有的"在此仓库开新会话"缝:`attach(null, cwd)` + attach 后注入 /// `claude --resume \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) 的会话" } }