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.
612 lines
26 KiB
Swift
612 lines
26 KiB
Swift
import HostRegistry
|
||
import SwiftUI
|
||
import UIKit
|
||
#if !targetEnvironment(simulator)
|
||
import VisionKit
|
||
#endif
|
||
|
||
/// T-iOS-12 · Pairing screen: QR scan (device only) + manual URL entry + probe.
|
||
/// Pure presentation over `PairingViewModel` — every rule (input validation, the
|
||
/// zero-network-before-confirm gate, §5.4 warning tiers, error copy/actions)
|
||
/// lives in the VM where it is unit-tested. Presented first-run (T-iOS-15) and
|
||
/// as an add/switch-host sheet. Scanner (`DataScannerViewController`) is compiled
|
||
/// out on the simulator, where manual entry is the pairing path.
|
||
struct PairingScreen: View {
|
||
@Bindable var viewModel: PairingViewModel
|
||
var onPaired: (HostRegistry.Host) -> Void = { _ in } // T-iOS-15 navigate hook
|
||
|
||
@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
|
||
.navigationTitle(ScreenCopy.title)
|
||
.onChange(of: viewModel.pairedHost) { _, paired in
|
||
guard let paired else { return }
|
||
onPaired(paired)
|
||
}
|
||
}
|
||
@ViewBuilder private var content: some View {
|
||
switch viewModel.phase {
|
||
case .idle:
|
||
idleView
|
||
case .confirming(let pending):
|
||
ConfirmHostView(pending: pending, viewModel: viewModel)
|
||
case .probing(let pending):
|
||
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)
|
||
}
|
||
}
|
||
|
||
// 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) {
|
||
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)
|
||
}
|
||
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)
|
||
.foregroundStyle(DS.Palette.statusStuck)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
if PairingScanAvailability.isAvailable {
|
||
Button {
|
||
scannerError = nil
|
||
isShowingScanner = true
|
||
} label: {
|
||
Label(ScreenCopy.scanButton, systemImage: "qrcode.viewfinder")
|
||
}
|
||
.buttonStyle(DSButtonStyle(kind: .secondary)) // hidden on sim
|
||
}
|
||
Text(ScreenCopy.qrHint)
|
||
.font(DS.Typography.caption)
|
||
.foregroundStyle(DS.Palette.textSecondary)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
pairedHostsSection
|
||
}
|
||
.padding(DS.Space.lg16)
|
||
}
|
||
}
|
||
|
||
// 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 {
|
||
#if targetEnvironment(simulator)
|
||
// Unreachable: the entry is hidden on the simulator. Kept total.
|
||
Text(ScreenCopy.scanUnavailable)
|
||
#else
|
||
ZStack(alignment: .bottom) {
|
||
QRScannerView(
|
||
onCode: { payload in
|
||
isShowingScanner = false
|
||
viewModel.handleScannedCode(payload)
|
||
},
|
||
onError: { message in scannerError = message }
|
||
)
|
||
if let scannerError {
|
||
Text(scannerError)
|
||
.font(DS.Typography.callout)
|
||
.foregroundStyle(DS.Palette.statusStuck)
|
||
.padding(DS.Space.md12)
|
||
.background(.thinMaterial, in: RoundedRectangle(
|
||
cornerRadius: DS.Radius.sm8
|
||
))
|
||
.padding(DS.Space.lg16)
|
||
}
|
||
}
|
||
#endif
|
||
}
|
||
private func probingView(_ pending: PairingViewModel.PendingHost) -> some View {
|
||
VStack(spacing: DS.Space.lg16) {
|
||
ProgressView()
|
||
Text(ScreenCopy.probing(pending.displayAddress))
|
||
.font(DS.Typography.callout)
|
||
.foregroundStyle(DS.Palette.textSecondary)
|
||
.multilineTextAlignment(.center)
|
||
}
|
||
.padding(DS.Space.xl20)
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
}
|
||
private func pairedView(_ host: HostRegistry.Host) -> some View {
|
||
VStack(spacing: DS.Space.md12) {
|
||
Image(systemName: "checkmark.circle.fill")
|
||
.font(DS.Typography.largeTitle)
|
||
.foregroundStyle(DS.Palette.statusWorking)
|
||
Text(ScreenCopy.paired(host.name))
|
||
.font(DS.Typography.headline)
|
||
.foregroundStyle(DS.Palette.textPrimary)
|
||
}
|
||
.padding(DS.Space.xl20)
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
.onAppear { DS.Haptics.success() }
|
||
}
|
||
}
|
||
|
||
/// Confirm page — parsed address + §5.4 warning tier + host name.
|
||
private struct ConfirmHostView: View {
|
||
let pending: PairingViewModel.PendingHost
|
||
@Bindable var viewModel: PairingViewModel
|
||
|
||
var body: some View {
|
||
ScrollView {
|
||
VStack(spacing: DS.Space.xl20) {
|
||
addressCard
|
||
warningTier
|
||
VStack(spacing: DS.Space.md12) {
|
||
Button(ScreenCopy.connect) {
|
||
DS.Haptics.selection()
|
||
Task { await viewModel.confirmConnect() }
|
||
}
|
||
.buttonStyle(DSButtonStyle(kind: .primary))
|
||
.accessibilityIdentifier("pairing.confirmButton")
|
||
Button(ScreenCopy.cancel) { viewModel.cancel() }
|
||
.buttonStyle(DSButtonStyle(kind: .secondary))
|
||
}
|
||
}
|
||
.padding(DS.Space.lg16)
|
||
}
|
||
}
|
||
private var addressCard: some View {
|
||
Card {
|
||
VStack(alignment: .leading, spacing: DS.Space.md12) {
|
||
SectionHeader(title: ScreenCopy.confirmSectionTitle)
|
||
// Single-point-derived origin (UITest asserts this exact string).
|
||
Text(pending.displayAddress)
|
||
.font(DS.Typography.mono())
|
||
.foregroundStyle(DS.Palette.textPrimary)
|
||
.textSelection(.enabled)
|
||
Divider()
|
||
TextField(ScreenCopy.namePlaceholder, text: $viewModel.hostName)
|
||
.font(DS.Typography.body)
|
||
}
|
||
}
|
||
}
|
||
|
||
// §5.4 warning tiers — LOGIC frozen (switch cases), only presentation restyled.
|
||
@ViewBuilder private var warningTier: some View {
|
||
switch pending.warning {
|
||
case .none:
|
||
EmptyView()
|
||
case .tailscaleEncrypted: // positive accent badge — encrypted transport
|
||
Card {
|
||
Label(ScreenCopy.tailscaleBadge, systemImage: "lock.shield")
|
||
.font(DS.Typography.callout)
|
||
.foregroundStyle(DS.Palette.accent)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
case .plaintextLAN: // subtle amber note
|
||
Label(ScreenCopy.plaintextNotice, systemImage: "eye")
|
||
.font(DS.Typography.caption)
|
||
.foregroundStyle(DS.Palette.statusWaiting)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
case .publicHostBlocking:
|
||
publicWarningCard
|
||
}
|
||
}
|
||
|
||
/// Prominent red card + acknowledgement gate (Toggle → `hasAcknowledgedPublicRisk`;
|
||
/// required-message on `needsPublicRiskAcknowledgement`). Logic unchanged.
|
||
private var publicWarningCard: some View {
|
||
VStack(alignment: .leading, spacing: DS.Space.md12) {
|
||
Label {
|
||
Text(ScreenCopy.publicWarning)
|
||
.font(DS.Typography.headline)
|
||
.foregroundStyle(DS.Palette.textPrimary)
|
||
} icon: {
|
||
Image(systemName: "exclamationmark.octagon.fill")
|
||
.foregroundStyle(DS.Palette.statusStuck)
|
||
}
|
||
Divider()
|
||
Toggle(ScreenCopy.publicAcknowledge, isOn: $viewModel.hasAcknowledgedPublicRisk)
|
||
.font(DS.Typography.callout)
|
||
.tint(DS.Palette.accent)
|
||
if viewModel.needsPublicRiskAcknowledgement {
|
||
Label(ScreenCopy.publicAckRequired, systemImage: "arrow.up")
|
||
.font(DS.Typography.caption)
|
||
.foregroundStyle(DS.Palette.statusStuck)
|
||
}
|
||
}
|
||
.padding(DS.Space.md12)
|
||
.background(DS.Palette.card, in: RoundedRectangle(cornerRadius: DS.Radius.md12))
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: DS.Radius.md12)
|
||
.strokeBorder(DS.Palette.statusStuck, lineWidth: DS.Stroke.hairline)
|
||
)
|
||
}
|
||
}
|
||
|
||
/// Failure page — inline copy + recovery actions (retry / settings / back).
|
||
private struct FailureView: View {
|
||
let pending: PairingViewModel.PendingHost
|
||
let failure: PairingViewModel.FailureDisplay
|
||
let viewModel: PairingViewModel
|
||
|
||
var body: some View {
|
||
ScrollView {
|
||
VStack(spacing: DS.Space.xl20) {
|
||
Card {
|
||
VStack(alignment: .leading, spacing: DS.Space.sm8) {
|
||
Text(pending.displayAddress)
|
||
.font(DS.Typography.mono(.caption))
|
||
.foregroundStyle(DS.Palette.textSecondary)
|
||
.lineLimit(1)
|
||
.truncationMode(.middle)
|
||
Label {
|
||
Text(failure.message)
|
||
.font(DS.Typography.callout)
|
||
.foregroundStyle(DS.Palette.textPrimary)
|
||
} icon: {
|
||
Image(systemName: "xmark.octagon.fill")
|
||
.foregroundStyle(DS.Palette.statusStuck)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
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 || needsToken) ? .secondary : .primary
|
||
))
|
||
Button(ScreenCopy.back) { viewModel.cancel() }
|
||
.buttonStyle(DSButtonStyle(kind: .secondary))
|
||
}
|
||
}
|
||
.padding(DS.Space.lg16)
|
||
}
|
||
}
|
||
|
||
/// The app's Settings pane hosts its 本地网络 toggle (no deep link exists).
|
||
private func openAppSettings() {
|
||
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
|
||
UIApplication.shared.open(url)
|
||
}
|
||
}
|
||
|
||
enum PairingScanAvailability {
|
||
/// Simulator: no camera → hidden, manual entry is the pairing path. Device:
|
||
/// requires VisionKit support (`isSupported` is MainActor-isolated).
|
||
@MainActor static var isAvailable: Bool {
|
||
#if targetEnvironment(simulator)
|
||
return false
|
||
#else
|
||
return DataScannerViewController.isSupported
|
||
#endif
|
||
}
|
||
}
|
||
|
||
#if !targetEnvironment(simulator)
|
||
/// Thin `DataScannerViewController` wrapper: QR only; first recognized code wins;
|
||
/// payload handed to the VM untouched (validation is the VM's job).
|
||
private struct QRScannerView: UIViewControllerRepresentable {
|
||
let onCode: (String) -> Void
|
||
let onError: (String) -> Void
|
||
|
||
func makeCoordinator() -> Coordinator {
|
||
Coordinator(onCode: onCode)
|
||
}
|
||
|
||
func makeUIViewController(context: Context) -> DataScannerViewController {
|
||
let scanner = DataScannerViewController(
|
||
recognizedDataTypes: [.barcode(symbologies: [.qr])],
|
||
qualityLevel: .balanced,
|
||
isHighlightingEnabled: true
|
||
)
|
||
scanner.delegate = context.coordinator
|
||
return scanner
|
||
}
|
||
|
||
func updateUIViewController(_ scanner: DataScannerViewController, context: Context) {
|
||
guard !scanner.isScanning else { return }
|
||
do {
|
||
try scanner.startScanning()
|
||
} catch {
|
||
// Explicit surfacing (plan §4): shows inline; manual entry remains.
|
||
onError(ScreenCopy.scannerStartFailed(error.localizedDescription))
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
final class Coordinator: NSObject, DataScannerViewControllerDelegate {
|
||
private let onCode: (String) -> Void
|
||
private var hasDelivered = false
|
||
|
||
init(onCode: @escaping (String) -> Void) {
|
||
self.onCode = onCode
|
||
}
|
||
|
||
func dataScanner(
|
||
_ dataScanner: DataScannerViewController,
|
||
didAdd addedItems: [RecognizedItem],
|
||
allItems: [RecognizedItem]
|
||
) {
|
||
guard !hasDelivered else { return }
|
||
for item in addedItems {
|
||
if case .barcode(let barcode) = item,
|
||
let payload = barcode.payloadStringValue {
|
||
hasDelivered = true
|
||
onCode(payload)
|
||
return
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
#endif
|
||
|
||
private enum ScreenCopy {
|
||
static let title = "配对主机"
|
||
static let heroTitle = "连接你的电脑"
|
||
static let heroSubtitle = "在同一网络里打开电脑上运行的终端会话——手动输入地址,或扫描它的配对二维码。"
|
||
static let manualSectionTitle = "输入地址"
|
||
static let manualPlaceholder = "http://192.168.1.5:3000"
|
||
static let manualSubmit = "连接"
|
||
static let scanButton = "扫描二维码"
|
||
static let scanUnavailable = "模拟器不支持扫码,请手输地址。"
|
||
static let qrHint = "在电脑的 web 终端工具栏点「Connect a device」可显示配对二维码;也可直接输入它的地址。"
|
||
static let confirmSectionTitle = "确认要连接的主机"
|
||
static let namePlaceholder = "主机名称"
|
||
static let connect = "连接"
|
||
static let cancel = "取消"
|
||
static let retry = "重试"
|
||
static let back = "返回"
|
||
static let openSettings = "去设置"
|
||
static let tailscaleBadge = "经 Tailscale 加密(WireGuard 网络层)"
|
||
static let plaintextNotice = "ws:// 明文连接:键击与终端输出可被同一网络内的设备嗅探。仅限可信 LAN,推荐 tailscale serve(wss)。"
|
||
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)" }
|
||
static func scannerStartFailed(_ reason: String) -> String {
|
||
"无法启动相机扫描:\(reason)。可改用手输地址。"
|
||
}
|
||
}
|