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

@@ -18,6 +18,12 @@ struct PairingScreen: View {
@State private var manualURLText = ""
@State private var isShowingScanner = false
@State private var scannerError: String?
/// C1 · Typed access token. Lives in `SecureField` view state only for as
/// long as the prompt is up, and is cleared the moment it is submitted or
/// abandoned the persisted copy belongs in the Keychain, nowhere else.
@State private var accessTokenText = ""
/// C1 · Host pending the "" confirmation dialog (nil = none).
@State private var hostPendingRemoval: HostRegistry.Host?
var body: some View {
content
@@ -37,47 +43,162 @@ struct PairingScreen: View {
probingView(pending)
case .failed(let pending, let failure):
FailureView(pending: pending, failure: failure, viewModel: viewModel)
case .awaitingToken(let pending):
tokenPrompt(pending, isValidating: false)
case .validatingToken(let pending):
tokenPrompt(pending, isValidating: true)
case .paired(let host):
pairedView(host)
}
}
private var idleView: some View {
// MARK: - Access-token prompt (C1 · §1.1)
/// The host answered 401. `SecureField` (never a plain `TextField`), no
/// autocorrect/autocapitalisation a token is not prose and the inline
/// rejection comes from the VM, which never echoes the value back.
private func tokenPrompt(
_ pending: PairingViewModel.PendingHost, isValidating: Bool
) -> some View {
ScrollView {
VStack(spacing: DS.Space.xl20) {
VStack(spacing: DS.Space.md12) { // inviting hero
Image(systemName: "desktopcomputer")
.font(DS.Typography.largeTitle)
.foregroundStyle(DS.Palette.accent)
.padding(.top, DS.Space.sm8)
Text(ScreenCopy.heroTitle)
.font(DS.Typography.title)
.foregroundStyle(DS.Palette.textPrimary)
Text(ScreenCopy.heroSubtitle)
.font(DS.Typography.callout)
.foregroundStyle(DS.Palette.textSecondary)
.multilineTextAlignment(.center)
tokenFieldCard(pending, isValidating: isValidating)
if let rejection = viewModel.tokenRejection {
Label(rejection, systemImage: "exclamationmark.circle.fill")
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.statusStuck)
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxWidth: .infinity)
Card {
VStack(alignment: .leading, spacing: DS.Space.md12) {
SectionHeader(title: ScreenCopy.manualSectionTitle)
TextField(ScreenCopy.manualPlaceholder, text: $manualURLText)
.font(DS.Typography.mono())
.keyboardType(.URL)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.submitLabel(.go)
.onSubmit { viewModel.submitManualURL(manualURLText) }
.accessibilityIdentifier("pairing.urlField")
Divider()
Button(ScreenCopy.manualSubmit) {
DS.Haptics.selection()
viewModel.submitManualURL(manualURLText)
}
.buttonStyle(DSButtonStyle(kind: .primary))
.accessibilityIdentifier("pairing.submitButton")
VStack(spacing: DS.Space.md12) {
if isValidating {
ProgressView()
}
Button(ScreenCopy.tokenSubmit) { submitAccessToken() }
.buttonStyle(DSButtonStyle(kind: .primary))
.disabled(isValidating || accessTokenText.isEmpty)
.accessibilityIdentifier("pairing.tokenSubmit")
Button(ScreenCopy.cancel) {
accessTokenText = ""
viewModel.cancelAccessTokenEntry()
}
.buttonStyle(DSButtonStyle(kind: .secondary))
.disabled(isValidating)
}
}
.padding(DS.Space.lg16)
}
}
private func tokenFieldCard(
_ pending: PairingViewModel.PendingHost, isValidating: Bool
) -> some View {
Card {
VStack(alignment: .leading, spacing: DS.Space.md12) {
SectionHeader(title: ScreenCopy.tokenSectionTitle)
Text(pending.displayAddress)
.font(DS.Typography.mono(.caption))
.foregroundStyle(DS.Palette.textSecondary)
Text(ScreenCopy.tokenHint)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textSecondary)
Divider()
SecureField(ScreenCopy.tokenPlaceholder, text: $accessTokenText)
.font(DS.Typography.mono())
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.submitLabel(.go)
.disabled(isValidating)
.onSubmit { submitAccessToken() }
.accessibilityIdentifier("pairing.tokenField")
}
}
}
/// Hand the token to the VM and drop the view's copy immediately.
private func submitAccessToken() {
let token = accessTokenText
accessTokenText = ""
Task { await viewModel.submitAccessToken(token) }
}
private var idleView: some View {
idleContent
.sheet(isPresented: $isShowingScanner) { scannerSheet }
.task { await viewModel.loadPairedHosts() }
.confirmationDialog(
ScreenCopy.removeConfirmTitle,
isPresented: removalDialogBinding,
titleVisibility: .visible,
presenting: hostPendingRemoval
) { host in
removalDialogActions(host)
} message: { _ in
Text(ScreenCopy.removeConfirmMessage)
}
}
/// Presented iff a host is staged for removal; dismissal clears the staging.
private var removalDialogBinding: Binding<Bool> {
Binding(
get: { hostPendingRemoval != nil },
set: { presented in if !presented { hostPendingRemoval = nil } }
)
}
@ViewBuilder private func removalDialogActions(
_ host: HostRegistry.Host
) -> some View {
Button(ScreenCopy.removeConfirm(host.name), role: .destructive) {
hostPendingRemoval = nil
Task { await viewModel.removeHost(id: host.id) }
}
Button(ScreenCopy.cancel, role: .cancel) { hostPendingRemoval = nil }
}
private var hero: some View {
VStack(spacing: DS.Space.md12) { // inviting hero
Image(systemName: "desktopcomputer")
.font(DS.Typography.largeTitle)
.foregroundStyle(DS.Palette.accent)
.padding(.top, DS.Space.sm8)
Text(ScreenCopy.heroTitle)
.font(DS.Typography.title)
.foregroundStyle(DS.Palette.textPrimary)
Text(ScreenCopy.heroSubtitle)
.font(DS.Typography.callout)
.foregroundStyle(DS.Palette.textSecondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
}
private var manualEntryCard: some View {
Card {
VStack(alignment: .leading, spacing: DS.Space.md12) {
SectionHeader(title: ScreenCopy.manualSectionTitle)
TextField(ScreenCopy.manualPlaceholder, text: $manualURLText)
.font(DS.Typography.mono())
.keyboardType(.URL)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.submitLabel(.go)
.onSubmit { viewModel.submitManualURL(manualURLText) }
.accessibilityIdentifier("pairing.urlField")
Divider()
Button(ScreenCopy.manualSubmit) {
DS.Haptics.selection()
viewModel.submitManualURL(manualURLText)
}
.buttonStyle(DSButtonStyle(kind: .primary))
.accessibilityIdentifier("pairing.submitButton")
}
}
}
private var idleContent: some View {
ScrollView {
VStack(spacing: DS.Space.xl20) {
hero
manualEntryCard
if let rejection = viewModel.inputRejection {
Label(rejection, systemImage: "exclamationmark.circle.fill")
.font(DS.Typography.caption)
@@ -97,10 +218,72 @@ struct PairingScreen: View {
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textSecondary)
.frame(maxWidth: .infinity, alignment: .leading)
pairedHostsSection
}
.padding(DS.Space.lg16)
}
.sheet(isPresented: $isShowingScanner) { scannerSheet }
}
// MARK: - Paired hosts (C1 · remove + "re-pair to add/update the token")
/// The app's host-management surface: re-pairing the same address updates
/// that host in place (that is how an existing host gains an access token
/// after the server enabled `WEBTERM_TOKEN`), and deletes the record
/// which takes its stored token with it and de-registers its APNs token.
@ViewBuilder private var pairedHostsSection: some View {
if !viewModel.pairedHosts.isEmpty || viewModel.hostsErrorMessage != nil {
Card {
VStack(alignment: .leading, spacing: DS.Space.md12) {
SectionHeader(title: ScreenCopy.pairedSectionTitle)
if let message = viewModel.hostsErrorMessage {
Label(message, systemImage: "exclamationmark.triangle.fill")
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.statusStuck)
}
ForEach(viewModel.pairedHosts) { host in
pairedHostRow(host)
if host.id != viewModel.pairedHosts.last?.id {
Divider()
}
}
Text(ScreenCopy.pairedSectionHint)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textSecondary)
}
}
}
}
private func pairedHostRow(_ host: HostRegistry.Host) -> some View {
HStack(spacing: DS.Space.md12) {
VStack(alignment: .leading, spacing: DS.Space.xs2) {
Text(verbatim: host.name)
.font(DS.Typography.headline)
.foregroundStyle(DS.Palette.textPrimary)
.lineLimit(1)
Text(host.endpoint.originHeader)
.dsMetaText()
.lineLimit(1)
.truncationMode(.middle)
// PRESENCE only a token value never reaches the screen.
if host.accessToken != nil {
Label(ScreenCopy.tokenStored, systemImage: "key.fill")
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.accent)
}
}
Spacer(minLength: 0)
Button(role: .destructive) {
hostPendingRemoval = host
} label: {
Label(ScreenCopy.remove, systemImage: "trash")
.labelStyle(.iconOnly)
}
.buttonStyle(.plain)
.foregroundStyle(DS.Palette.statusStuck)
.accessibilityLabel(ScreenCopy.removeAccessibility(host.name))
.accessibilityIdentifier("pairing.removeHost")
}
}
@ViewBuilder private var scannerSheet: some View {
@@ -277,12 +460,22 @@ private struct FailureView: View {
}
VStack(spacing: DS.Space.md12) {
let needsSettings = failure.action == .openLocalNetworkSettings
// C1 · 401 is ambiguous (token OR Origin), so BOTH remedies
// stay on screen: the token prompt leads, retry stays put.
let needsToken = failure.action == .enterAccessToken
if needsSettings {
Button(ScreenCopy.openSettings) { openAppSettings() }
.buttonStyle(DSButtonStyle(kind: .primary))
}
if needsToken {
Button(ScreenCopy.enterToken) { viewModel.beginAccessTokenEntry() }
.buttonStyle(DSButtonStyle(kind: .primary))
.accessibilityIdentifier("pairing.enterTokenButton")
}
Button(ScreenCopy.retry) { Task { await viewModel.retry() } }
.buttonStyle(DSButtonStyle(kind: needsSettings ? .secondary : .primary))
.buttonStyle(DSButtonStyle(
kind: (needsSettings || needsToken) ? .secondary : .primary
))
Button(ScreenCopy.back) { viewModel.cancel() }
.buttonStyle(DSButtonStyle(kind: .secondary))
}
@@ -391,6 +584,24 @@ private enum ScreenCopy {
static let publicWarning = "这是公网地址!任何能连上该端口的人都会得到你电脑的 shell。web-terminal 绝不应暴露到公网。"
static let publicAcknowledge = "我已了解风险,仍要连接"
static let publicAckRequired = "请先勾选上面的风险确认,再点连接。"
// C1 · access token + host management
static let enterToken = "输入访问令牌"
static let tokenSectionTitle = "输入访问令牌"
static let tokenPlaceholder = "WEBTERM_TOKEN"
static let tokenHint =
"这台主机设置了 WEBTERM_TOKEN。令牌只会保存在本机钥匙串里绝不出现在网址或日志中。"
static let tokenSubmit = "验证并保存"
static let tokenStored = "已保存访问令牌"
static let pairedSectionTitle = "已配对主机"
static let pairedSectionHint =
"想给已配对的主机补/换访问令牌:在上面重新输入同一个地址配对一次即可(不会重复添加)。"
static let remove = "移除"
static let removeConfirmTitle = "移除这台主机?"
static let removeConfirmMessage =
"会同时删除本机保存的访问令牌,并在该主机上注销本设备的推送。主机上正在跑的会话不受影响。"
static func removeConfirm(_ name: String) -> String { "移除「\(name)" }
static func removeAccessibility(_ name: String) -> String { "移除主机 \(name)" }
static func probing(_ address: String) -> String { "正在验证 \(address)" }
static func paired(_ name: String) -> String { "已配对:\(name)" }