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.
259 lines
10 KiB
Swift
259 lines
10 KiB
Swift
import APIClient
|
||
import Foundation
|
||
import HostRegistry
|
||
import Observation
|
||
import WireProtocol
|
||
|
||
/// T-iOS-26 · Projects 列表状态(镜像 web v0.6 projects 面板的数据面):
|
||
/// `GET /projects` 的分组网格 + `GET/PUT /prefs` 的跨端收藏/折叠往返 +
|
||
/// "在此仓库开新会话" 的导航信号。
|
||
///
|
||
/// Documented decisions:
|
||
/// - **prefs 是 clobber 敏感面**:`PUT /prefs` 服务器侧整体替换 blob
|
||
/// (src/server.ts:286-288),所以一律经 `UiPrefs` 的 unknown-key-preserving
|
||
/// API 改写(web/未来服务器写入的未知顶层键原样带回);prefs GET 失败时
|
||
/// toggle 只改本地、**绝不 PUT**(空底盘上写 = 清掉服务器收藏)。
|
||
/// - **PUT 成功采纳服务器 echo 为新真相**(服务器会 sanitize,本地状态与
|
||
/// 服务器逐字节对齐);失败保留本地改动 + 显式文案,下次 toggle 自然重试。
|
||
/// - **prefs 只在首次 load 拉一次**(镜像 web mountProjects.init:刷新节奏
|
||
/// 只重取项目,绝不用旧服务器值clobber本地未同步的编辑)。
|
||
/// - 列表数据是不可信服务器输入:解码宽容(APIClient 已做),路径在铸造
|
||
/// OpenRequest 前再过 `Validation.isAbsoluteCwd`(deep-link 同款纪律)。
|
||
@MainActor
|
||
@Observable
|
||
final class ProjectsViewModel {
|
||
// MARK: - Observable state
|
||
|
||
private(set) var projects: [ProjectInfo] = []
|
||
private(set) var hasLoadedOnce = false
|
||
/// 最近一次项目刷新失败(旧列表保留 —— 一次丢包不清屏)。
|
||
private(set) var fetchErrorMessage: String?
|
||
/// prefs 加载失败:收藏/折叠只在本地生效、不回写(防 clobber)。
|
||
private(set) var prefsErrorMessage: String?
|
||
/// 最近一次 PUT /prefs 失败(本地改动已保留)。
|
||
private(set) var prefsSyncErrorMessage: String?
|
||
/// "在此仓库开新会话" 被拒(非法路径)。
|
||
private(set) var openErrorMessage: String?
|
||
/// 收藏的项目路径(保序:新收藏追加尾部)。
|
||
private(set) var favourites: [String] = []
|
||
/// 组 key → 已折叠(只存 true;展开是默认态 —— 与 web/服务器一致)。
|
||
private(set) var collapsedGroups: [String: Bool] = [:]
|
||
/// 导航信号(T-iOS-26):每次请求新 id,重复点按也能触发 onChange。
|
||
private(set) var openRequest: ProjectOpenRequest?
|
||
/// 搜索框绑定(.searchable)。
|
||
var searchText = ""
|
||
|
||
// MARK: - Dependencies & internal state
|
||
|
||
let host: HostRegistry.Host
|
||
@ObservationIgnored let http: any HTTPTransport
|
||
/// 最近一次已知的完整 prefs blob(含未知键)。nil = 从未成功加载 →
|
||
/// 绝不 PUT。
|
||
@ObservationIgnored private var prefsBase: UiPrefs?
|
||
|
||
private var client: APIClient {
|
||
APIClient(endpoint: host.endpoint, http: http)
|
||
}
|
||
|
||
init(host: HostRegistry.Host, http: any HTTPTransport) {
|
||
self.host = host
|
||
self.http = http
|
||
}
|
||
|
||
// MARK: - Derived view state
|
||
|
||
/// 分组快照:搜索过滤 → web 同款分组(收藏优先排序在组内完成)。
|
||
var groups: [ProjectGroup] {
|
||
ProjectGrouping.group(
|
||
ProjectGrouping.filter(projects, query: searchText),
|
||
favourites: Set(favourites)
|
||
)
|
||
}
|
||
|
||
var isSearching: Bool {
|
||
!searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||
}
|
||
|
||
/// 搜索中强制全展开(结果绝不藏在折叠 caret 后,镜像 web renderGrid)。
|
||
func isCollapsed(_ group: ProjectGroup) -> Bool {
|
||
group.isCollapsible && !isSearching && collapsedGroups[group.key] == true
|
||
}
|
||
|
||
func isFavourite(_ path: String) -> Bool {
|
||
favourites.contains(path)
|
||
}
|
||
|
||
/// 空态文案:无项目 vs 无搜索结果(镜像 web renderGrid 的两种 msg)。
|
||
var emptyStateMessage: String? {
|
||
guard hasLoadedOnce, fetchErrorMessage == nil else { return nil }
|
||
if projects.isEmpty { return ProjectsCopy.emptyNoProjects }
|
||
if ProjectGrouping.filter(projects, query: searchText).isEmpty {
|
||
return ProjectsCopy.emptyNoMatch
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// MARK: - Load / refresh
|
||
|
||
/// 首次进入:prefs(一次)+ 项目。prefs 之后留在内存(镜像 web init)。
|
||
func load() async {
|
||
await loadPrefsIfNeeded()
|
||
await refresh()
|
||
}
|
||
|
||
/// 只重取项目(下拉刷新与重试共用)。失败保留旧列表 + 显式文案。
|
||
func refresh() async {
|
||
do {
|
||
projects = try await client.projects()
|
||
fetchErrorMessage = nil
|
||
hasLoadedOnce = true
|
||
} catch {
|
||
fetchErrorMessage = ProjectsCopy.fetchFailed(Self.errorDetail(error))
|
||
}
|
||
}
|
||
|
||
private func loadPrefsIfNeeded() async {
|
||
guard prefsBase == nil else { return }
|
||
do {
|
||
let prefs = try await client.prefs()
|
||
adopt(prefs)
|
||
prefsErrorMessage = nil
|
||
} catch {
|
||
prefsErrorMessage = ProjectsCopy.prefsLoadFailed
|
||
}
|
||
}
|
||
|
||
// MARK: - Favourites / collapse(跨端 prefs 往返)
|
||
|
||
func toggleFavourite(path: String) async {
|
||
favourites = favourites.contains(path)
|
||
? favourites.filter { $0 != path }
|
||
: favourites + [path]
|
||
await persistPrefs()
|
||
}
|
||
|
||
func toggleCollapsed(key: String) async {
|
||
if collapsedGroups[key] == true {
|
||
collapsedGroups = collapsedGroups.filter { $0.key != key }
|
||
} else {
|
||
collapsedGroups = collapsedGroups.merging([key: true]) { _, new in new }
|
||
}
|
||
await persistPrefs()
|
||
}
|
||
|
||
/// 以最近已知的完整 blob 为底盘改写两个已知键(未知键原样带回),PUT
|
||
/// 后采纳服务器 echo。无底盘(prefs 从未加载成功)→ 本地生效、不回写。
|
||
private func persistPrefs() async {
|
||
guard let base = prefsBase else { return }
|
||
let next = base.withFavourites(favourites).withCollapsed(collapsedGroups)
|
||
prefsBase = next // 乐观:连续 toggle 在同一底盘上叠加
|
||
do {
|
||
let echoed = try await client.putPrefs(next)
|
||
adopt(echoed)
|
||
prefsSyncErrorMessage = nil
|
||
} catch {
|
||
prefsSyncErrorMessage = ProjectsCopy.prefsSyncFailed(Self.errorDetail(error))
|
||
}
|
||
}
|
||
|
||
private func adopt(_ prefs: UiPrefs) {
|
||
prefsBase = prefs
|
||
favourites = prefs.favourites
|
||
collapsedGroups = prefs.collapsed
|
||
}
|
||
|
||
// MARK: - 在此仓库开新会话(T-iOS-26 的核心动作)
|
||
|
||
/// `attach(null, cwd)` + attach 后注入 `claude\r`(帧序由 engine 的
|
||
/// attach-first 队列保证)。路径是服务器数据 → 不可信,铸造前先验证。
|
||
func requestOpenClaude(cwd: String) {
|
||
guard Validation.isAbsoluteCwd(cwd) else {
|
||
openErrorMessage = ProjectsCopy.openClaudeInvalidPath
|
||
return
|
||
}
|
||
openErrorMessage = nil
|
||
openRequest = ProjectOpenRequest(
|
||
id: UUID(), host: host, cwd: cwd,
|
||
bootstrapInput: ProjectLaunch.claudeBootstrapInput
|
||
)
|
||
}
|
||
|
||
/// T-iOS-32(C2)· 恢复一条历史会话:**同一条** `attach(null, cwd)` 缝,只把
|
||
/// bootstrap 换成 `claude --resume <id>\r`(镜像 web
|
||
/// `public/tabs.ts:918 newTabForResume`)。
|
||
///
|
||
/// 两道校验都在铸造 request 之前:cwd 必须是绝对路径(与上面同款纪律),
|
||
/// 会话 id 必须过 `ProjectResumeCommand` 白名单 —— 那串字节会被拼进一条真的送进
|
||
/// PTY 的命令行,web 侧没有这层校验,iOS 不复制这个缺口。
|
||
func requestResumeClaude(cwd: String, sessionId: String) {
|
||
guard Validation.isAbsoluteCwd(cwd) else {
|
||
openErrorMessage = ProjectsCopy.openClaudeInvalidPath
|
||
return
|
||
}
|
||
guard let bootstrap = ProjectResumeCommand.bootstrapInput(sessionId: sessionId) else {
|
||
openErrorMessage = ResumeCopy.notResumable
|
||
return
|
||
}
|
||
openErrorMessage = nil
|
||
openRequest = ProjectOpenRequest(
|
||
id: UUID(), host: host, cwd: cwd, bootstrapInput: bootstrap
|
||
)
|
||
}
|
||
|
||
// MARK: - Detail assembly(详情/差异屏的装配缝)
|
||
|
||
func makeDetailViewModel(path: String) -> ProjectDetailViewModel {
|
||
.forHost(endpoint: host.endpoint, http: http, path: path)
|
||
}
|
||
|
||
// MARK: - Helpers
|
||
|
||
private static func errorDetail(_ error: any Error) -> String {
|
||
(error as? APIClientError)?.message ?? error.localizedDescription
|
||
}
|
||
}
|
||
|
||
/// "在此仓库开新会话" 的导航信号 —— `SessionListViewModel.OpenRequest` 的
|
||
/// cwd+bootstrap 平行变体(该类型属 T-iOS-13 文件,不越界扩展;由
|
||
/// AppCoordinator.openProject 消费)。
|
||
struct ProjectOpenRequest: Equatable, Sendable, Identifiable {
|
||
let id: UUID
|
||
let host: HostRegistry.Host
|
||
/// 新会话的工作目录(已验证为绝对路径)。
|
||
let cwd: String
|
||
/// attach 后注入的首条输入(nil = 只开 shell)。
|
||
let bootstrapInput: String?
|
||
}
|
||
|
||
/// 项目内启动命令(镜像 public/tabs.ts:679 `openProject` 的默认 cmd)。
|
||
enum ProjectLaunch {
|
||
/// Enter 是 `\r`(0x0D)不是 `\n` —— 合成输入的经典坑(CLAUDE.md Gotchas)。
|
||
static let claudeBootstrapInput = "claude\r"
|
||
}
|
||
|
||
/// 用户可见文案(中文具名常量,plan §4)。
|
||
enum ProjectsCopy {
|
||
static let title = "项目"
|
||
static let searchPrompt = "筛选项目…"
|
||
static let activeGroupLabel = "活跃中"
|
||
static let otherGroupLabel = "其他"
|
||
static let allGroupLabel = "全部项目"
|
||
static let dirtyBadge = "未提交"
|
||
static let emptyNoProjects = "未发现项目。请检查主机的 PROJECT_ROOTS 配置。"
|
||
static let emptyNoMatch = "没有匹配的项目。"
|
||
static let prefsLoadFailed = "云端收藏/折叠状态加载失败,本次修改仅在本机生效。"
|
||
static let openClaudeInvalidPath = "项目路径无效,无法开新会话。"
|
||
|
||
static func fetchFailed(_ detail: String) -> String {
|
||
"刷新项目列表失败:\(detail)"
|
||
}
|
||
|
||
static func prefsSyncFailed(_ detail: String) -> String {
|
||
"收藏/折叠状态同步失败:\(detail)"
|
||
}
|
||
|
||
static func activeCountBadge(_ count: Int) -> String {
|
||
"● \(count) 活跃"
|
||
}
|
||
}
|