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

@@ -0,0 +1,131 @@
import APIClient
import SwiftUI
import WireProtocol
/// T-iOS-32 · Worktree`POST /projects/worktree`G
///
/// + ref = HEAD
/// canonical /" Worktree "
/// "spin up a worktree, land the winner, delete the rest"
///
/// `WorktreeBranchRule`//
/// `Text(verbatim:)`
struct WorktreeSheet: View {
@State private var viewModel: WorktreeViewModel
/// worktree
let onCreated: () -> Void
/// " worktree " canonical
let onOpenSession: (String) -> Void
@Environment(\.dismiss) private var dismiss
init(
viewModel: WorktreeViewModel,
onCreated: @escaping () -> Void,
onOpenSession: @escaping (String) -> Void
) {
_viewModel = State(initialValue: viewModel)
self.onCreated = onCreated
self.onOpenSession = onOpenSession
}
var body: some View {
@Bindable var bindable = viewModel
return NavigationStack {
Form {
if case .created(let path, let branch) = viewModel.createPhase {
createdSection(path: path, branch: branch)
} else {
formSection(
branch: $bindable.branchText, base: $bindable.baseText
)
}
}
.navigationTitle(WorktreeCopy.sheetTitle)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(WorktreeCopy.cancel) { dismiss() }
}
}
}
}
@ViewBuilder private func formSection(
branch: Binding<String>, base: Binding<String>
) -> some View {
Section {
TextField(WorktreeCopy.branchField, text: branch)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.submitLabel(.done)
.onSubmit { submit() }
TextField(WorktreeCopy.baseField, text: base)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
} footer: {
if let message = viewModel.branchValidationMessage {
Text(message)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.statusStuck)
}
}
Section {
Button {
submit()
} label: {
if viewModel.createPhase == .creating {
ProgressView()
.frame(maxWidth: .infinity, minHeight: DS.Layout.minHitTarget)
} else {
Label(WorktreeCopy.submit, systemImage: "plus.rectangle.on.folder")
}
}
.buttonStyle(DSButtonStyle(kind: .primary))
.disabled(!viewModel.canSubmitCreate)
.listRowBackground(Color.clear)
if case .failed(let message) = viewModel.createPhase {
InlineMessage(text: message, tone: .error)
}
}
}
@ViewBuilder private func createdSection(path: String, branch: String) -> some View {
Section(WorktreeCopy.createdTitle) {
VStack(alignment: .leading, spacing: DS.Space.xs4) {
Text(verbatim: branch)
.font(DS.Typography.callout)
.foregroundStyle(DS.Palette.textPrimary)
Text(verbatim: path)
.font(DS.Typography.mono(.caption))
.foregroundStyle(DS.Palette.textSecondary)
.lineLimit(2)
.truncationMode(.middle)
}
}
Section {
Button {
DS.Haptics.selection()
onOpenSession(path)
dismiss()
} label: {
Label(WorktreeCopy.openInNewWorktree, systemImage: "terminal.fill")
}
.buttonStyle(DSButtonStyle(kind: .primary))
.disabled(path.isEmpty)
.listRowBackground(Color.clear)
Button(WorktreeCopy.cancel) { dismiss() }
.buttonStyle(DSButtonStyle(kind: .secondary))
.listRowBackground(Color.clear)
}
}
private func submit() {
Task {
await viewModel.create()
if case .created = viewModel.createPhase {
DS.Haptics.success()
onCreated() // worktree
}
}
}
}