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:
@@ -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)" }
|
||||
|
||||
Reference in New Issue
Block a user