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:
116
ios/App/WebTerm/Screens/ResumeHistorySheet.swift
Normal file
116
ios/App/WebTerm/Screens/ResumeHistorySheet.swift
Normal file
@@ -0,0 +1,116 @@
|
||||
import APIClient
|
||||
import SwiftUI
|
||||
import WireProtocol
|
||||
|
||||
/// T-iOS-32 · `claude --resume <id>` 历史选择器(`GET /sessions`)。
|
||||
///
|
||||
/// 只列出 cwd 落在本仓库内的历史会话(在别的仓库跑过的会话,用本仓库的 cwd 去
|
||||
/// resume 是错的)。选中一条 → 在**该会话自己的 cwd** 里开新会话并注入
|
||||
/// `claude --resume <id>\r`(worktree 会话因此回到那个 worktree)。
|
||||
///
|
||||
/// 安全:id/cwd/preview 全是不可信服务器字节 —— 一律 `Text(verbatim:)`;
|
||||
/// id 未过 `ProjectResumeCommand` 白名单的行**不提供恢复按钮**(那串东西会被送进
|
||||
/// 一条真的 PTY 命令行)。
|
||||
struct ResumeHistorySheet: View {
|
||||
@State private var viewModel: ResumeHistoryViewModel
|
||||
/// (cwd, sessionId) —— 由详情屏转交给"在此仓库开会话"的同一条缝。
|
||||
let onResume: (String, String) -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
private enum Metrics {
|
||||
/// 首条提示的展示行数上限(服务器已截断到 120 字符)。
|
||||
static let previewLineLimit = 2
|
||||
}
|
||||
|
||||
init(viewModel: ResumeHistoryViewModel, onResume: @escaping (String, String) -> Void) {
|
||||
_viewModel = State(initialValue: viewModel)
|
||||
self.onResume = onResume
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
content
|
||||
.navigationTitle(ResumeCopy.sheetTitle)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(WorktreeCopy.cancel) { dismiss() }
|
||||
}
|
||||
}
|
||||
.task { await viewModel.load() }
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var content: some View {
|
||||
switch viewModel.phase {
|
||||
case .loading:
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
case .empty:
|
||||
ContentUnavailableView {
|
||||
Label(ResumeCopy.emptyTitle, systemImage: "clock.arrow.circlepath")
|
||||
} description: {
|
||||
Text(ResumeCopy.emptyDetail)
|
||||
}
|
||||
case .failed(let message):
|
||||
ContentUnavailableView {
|
||||
Label(ResumeCopy.loadFailed, systemImage: "exclamationmark.triangle")
|
||||
} description: {
|
||||
Text(verbatim: message)
|
||||
} actions: {
|
||||
Button(ResumeCopy.retry) {
|
||||
Task { await viewModel.load() }
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(DS.Palette.accent)
|
||||
}
|
||||
case .loaded(let candidates):
|
||||
List(candidates) { candidate in
|
||||
row(candidate)
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
}
|
||||
}
|
||||
|
||||
private func row(_ candidate: ResumeHistoryViewModel.ResumeCandidate) -> some View {
|
||||
HStack(spacing: DS.Space.sm8) {
|
||||
VStack(alignment: .leading, spacing: DS.Space.xs2) {
|
||||
// 首条提示(服务器已折叠空白并截断)→ verbatim。
|
||||
Text(verbatim: candidate.session.preview.isEmpty
|
||||
? ResumeCopy.noPreview : candidate.session.preview)
|
||||
.font(DS.Typography.callout)
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
.lineLimit(Metrics.previewLineLimit)
|
||||
Text(verbatim: candidate.cwd)
|
||||
.font(DS.Typography.mono(.caption2))
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
Text(GitTimeFormat.relative(
|
||||
fromMs: candidate.session.mtimeMs,
|
||||
nowMs: Date().timeIntervalSince1970 * 1_000
|
||||
))
|
||||
.dsMetaText()
|
||||
if !candidate.canResume {
|
||||
Text(ResumeCopy.notResumable)
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.statusWaiting)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: DS.Space.sm8)
|
||||
if candidate.canResume {
|
||||
Button(ResumeCopy.resume) {
|
||||
DS.Haptics.selection()
|
||||
onResume(candidate.cwd, candidate.session.id)
|
||||
dismiss()
|
||||
}
|
||||
.font(DS.Typography.body.weight(.semibold))
|
||||
.foregroundStyle(DS.Palette.accent)
|
||||
.frame(minWidth: DS.Layout.minHitTarget, minHeight: DS.Layout.minHitTarget)
|
||||
.accessibilityLabel(
|
||||
ResumeCopy.resumeAccessibilityLabel(candidate.session.project)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user