From 56442cfe464b2ba93d9fcc21be9fd34bb09ee318 Mon Sep 17 00:00:00 2001 From: QuintinShaw Date: Sat, 18 Jul 2026 16:21:41 +0800 Subject: [PATCH 01/11] feat(apple): redesign pairing onboarding --- apple/SitrepApp/Sitrep/JoinView.swift | 235 +++++++++++++++++++------- 1 file changed, 177 insertions(+), 58 deletions(-) diff --git a/apple/SitrepApp/Sitrep/JoinView.swift b/apple/SitrepApp/Sitrep/JoinView.swift index d55c152..3c9fefd 100644 --- a/apple/SitrepApp/Sitrep/JoinView.swift +++ b/apple/SitrepApp/Sitrep/JoinView.swift @@ -3,8 +3,8 @@ import SwiftUI import UIKit import VisionKit -/// First-run onboarding, gift-card style: point the camera at the connect -/// code shown on the Mac (live text recognition), paste it, or type it. +/// First-run pairing: scan the connect code shown on the Mac, paste it, +/// or enter it manually. /// Lets the SwiftUI layer re-arm the scanner after a failed join. @MainActor final class ScannerControl { @@ -21,68 +21,25 @@ struct JoinView: View { @State private var error: String? @State private var joining = false @State private var scannerControl = ScannerControl() + @FocusState private var codeFieldFocused: Bool var body: some View { - VStack(spacing: 16) { - Spacer() - Image(systemName: "dot.radiowaves.left.and.right") - .font(.system(size: 44)) - .foregroundStyle(.blue) - Text("连接你的电脑").font(.title2.bold()) - Text("在 Mac 的 Sitrep 菜单里点「添加设备」,\n把相机对准它显示的代码") - .font(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - - if DataScannerViewController.isSupported { - ZStack { - CodeScanner(control: scannerControl) { text in - guard !joining else { return } - Task { await join(payload: text) } - } - // The instant a code matches, the camera stops and this - // takes over — unmistakable "got it" feedback. - if joining { - Rectangle().fill(.thinMaterial) - ProgressView("正在连接…") - } - } - .frame(height: 240) - .clipShape(RoundedRectangle(cornerRadius: 16)) - .padding(.horizontal) - } + ScrollView { + VStack(spacing: 0) { + header + .padding(.top, 28) - HStack { - TextField("输入连接码", text: $manualCode) - .font(.body.monospaced()) - .textInputAutocapitalization(.characters) - .autocorrectionDisabled() - .textFieldStyle(.roundedBorder) - .onSubmit { Task { await join(payload: manualCode) } } - Button { - Task { - if let text = UIPasteboard.general.string { - await join(payload: text) - } else { - error = "剪贴板是空的" - } - } - } label: { - Image(systemName: "doc.on.clipboard") - } - .buttonStyle(.bordered) - Button("连接") { Task { await join(payload: manualCode) } } - .buttonStyle(.borderedProminent) - .disabled(ConnectCode.parse(manualCode) == nil) - } - .padding(.horizontal) + scanner + .padding(.top, 30) - if let error { - Text(error).font(.caption).foregroundStyle(.red) + manualEntry + .padding(.top, 20) } - Spacer() + .padding(.horizontal, 20) + .padding(.bottom, 32) } - .padding() + .scrollDismissesKeyboard(.interactively) + .background(Color(.systemGroupedBackground).ignoresSafeArea()) .interactiveDismissDisabled() .task(id: model.pendingJoinPayload) { guard let payload = model.pendingJoinPayload else { return } @@ -97,9 +54,171 @@ struct JoinView: View { } } + private var header: some View { + VStack(spacing: 14) { + Image(systemName: "macbook.and.iphone") + .font(.largeTitle) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(.blue) + .padding(18) + .background(.blue.opacity(0.12), in: RoundedRectangle(cornerRadius: 24)) + + VStack(spacing: 8) { + Text("连接 Mac") + .font(.largeTitle.bold()) + + Text("在 Mac 菜单栏打开 Sitrep,选择「添加设备」,\n然后扫描它显示的连接码。") + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + } + .frame(maxWidth: .infinity) + } + + @ViewBuilder private var scanner: some View { + if DataScannerViewController.isSupported { + ZStack { + CodeScanner(control: scannerControl) { text in + guard !joining else { return } + Task { await join(payload: text) } + } + + LinearGradient( + colors: [.black.opacity(0.24), .clear, .black.opacity(0.32)], + startPoint: .top, + endPoint: .bottom + ) + + RoundedRectangle(cornerRadius: 14) + .stroke( + .white.opacity(0.9), + style: StrokeStyle(lineWidth: 2, dash: [10, 7]) + ) + .frame(maxWidth: 270) + .aspectRatio(4.6, contentMode: .fit) + .shadow(color: .black.opacity(0.18), radius: 8, y: 3) + + VStack { + Spacer() + Label("将连接码放入框内", systemImage: "viewfinder") + .font(.subheadline.weight(.medium)) + .foregroundStyle(.white) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(.ultraThinMaterial, in: Capsule()) + .padding(.bottom, 14) + } + + if joining { + Rectangle() + .fill(.regularMaterial) + ProgressView("正在连接…") + .font(.headline) + } + } + .aspectRatio(1.45, contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 28)) + .overlay { + RoundedRectangle(cornerRadius: 28) + .stroke(Color(.separator).opacity(0.45), lineWidth: 0.5) + } + .accessibilityLabel("连接码扫描器") + } else { + ContentUnavailableView( + "无法使用相机扫描", + systemImage: "camera.fill", + description: Text("请在下方粘贴或输入连接码") + ) + .frame(maxWidth: .infinity) + .aspectRatio(1.45, contentMode: .fit) + .background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 28)) + } + } + + private var manualEntry: some View { + VStack(alignment: .leading, spacing: 14) { + HStack { + Label("手动输入", systemImage: "keyboard") + .font(.headline) + + Spacer() + + Button("粘贴", systemImage: "doc.on.clipboard") { + pasteCode() + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(joining) + } + + TextField("连接码或邀请链接", text: $manualCode) + .font(.body.monospaced()) + .textInputAutocapitalization(.characters) + .autocorrectionDisabled() + .focused($codeFieldFocused) + .submitLabel(.go) + .onSubmit { Task { await join(payload: manualCode) } } + .padding(.horizontal, 14) + .frame(minHeight: 52) + .background(Color(.tertiarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 14)) + .overlay { + RoundedRectangle(cornerRadius: 14) + .stroke(Color(.separator).opacity(0.35), lineWidth: 0.5) + } + .disabled(joining) + + if let error { + Label(error, systemImage: "exclamationmark.circle.fill") + .font(.footnote) + .foregroundStyle(.red) + .fixedSize(horizontal: false, vertical: true) + .transition(.move(edge: .top).combined(with: .opacity)) + } + + Button { + Task { await join(payload: manualCode) } + } label: { + if joining { + ProgressView() + .frame(maxWidth: .infinity) + } else { + Label("连接", systemImage: "link") + .frame(maxWidth: .infinity) + } + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .disabled(!canSubmit || joining) + } + .padding(16) + .background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 24)) + .animation(.snappy, value: error) + } + + private var canSubmit: Bool { + let payload = manualCode.trimmingCharacters(in: .whitespacesAndNewlines) + if ConnectCode.parse(payload) != nil { return true } + guard let components = URLComponents(string: payload) else { return false } + return components.scheme == "sitrep" && components.host == "join" + } + + private func pasteCode() { + guard let text = UIPasteboard.general.string, + !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + error = "剪贴板里没有连接码" + return + } + manualCode = text.trimmingCharacters(in: .whitespacesAndNewlines) + error = nil + codeFieldFocused = true + } + /// Accepts a connect code (official server implicit) or a full /// sitrep://join link (self-host path). private func join(payload: String) async { + guard !joining else { return } let trimmed = payload.trimmingCharacters(in: .whitespacesAndNewlines) if let code = ConnectCode.parse(trimmed) { await performJoin( From cd9146806b1370369bc60cb43ff62ff8267ed237 Mon Sep 17 00:00:00 2001 From: QuintinShaw Date: Sat, 18 Jul 2026 16:41:29 +0800 Subject: [PATCH 02/11] fix(apple): require explicit camera activation --- apple/SitrepApp/Sitrep/JoinView.swift | 375 ++++++++++++++++---------- 1 file changed, 240 insertions(+), 135 deletions(-) diff --git a/apple/SitrepApp/Sitrep/JoinView.swift b/apple/SitrepApp/Sitrep/JoinView.swift index 3c9fefd..e951120 100644 --- a/apple/SitrepApp/Sitrep/JoinView.swift +++ b/apple/SitrepApp/Sitrep/JoinView.swift @@ -16,31 +16,62 @@ final class ScannerControl { } struct JoinView: View { + private enum Destination: String, Identifiable { + case scanner + case manual + + var id: String { rawValue } + } + @Bindable var model: AppModel @State private var manualCode = "" @State private var error: String? @State private var joining = false @State private var scannerControl = ScannerControl() - @FocusState private var codeFieldFocused: Bool + @State private var destination: Destination? var body: some View { - ScrollView { - VStack(spacing: 0) { - header - .padding(.top, 28) + VStack(spacing: 0) { + Spacer(minLength: 32) + + header + + Spacer() - scanner - .padding(.top, 30) + VStack(spacing: 12) { + Label("相机仅在你选择扫描后启用", systemImage: "hand.raised.fill") + .font(.footnote) + .foregroundStyle(.secondary) + + Button { + present(.scanner) + } label: { + Label("扫描连接码", systemImage: "camera.viewfinder") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) - manualEntry - .padding(.top, 20) + Button { + present(.manual) + } label: { + Label("手动输入连接码", systemImage: "keyboard") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .controlSize(.large) } - .padding(.horizontal, 20) - .padding(.bottom, 32) + .buttonBorderShape(.roundedRectangle(radius: 18)) + .frame(maxWidth: .infinity) + .padding(.bottom, 12) } - .scrollDismissesKeyboard(.interactively) + .padding(.horizontal, 24) + .padding(.bottom, 20) .background(Color(.systemGroupedBackground).ignoresSafeArea()) .interactiveDismissDisabled() + .sheet(item: $destination) { destination in + destinationView(destination) + } .task(id: model.pendingJoinPayload) { guard let payload = model.pendingJoinPayload else { return } model.pendingJoinPayload = nil @@ -55,146 +86,49 @@ struct JoinView: View { } private var header: some View { - VStack(spacing: 14) { + VStack(spacing: 18) { Image(systemName: "macbook.and.iphone") - .font(.largeTitle) + .font(.system(size: 46)) .symbolRenderingMode(.hierarchical) .foregroundStyle(.blue) - .padding(18) - .background(.blue.opacity(0.12), in: RoundedRectangle(cornerRadius: 24)) + .padding(24) + .background(.blue.opacity(0.12), in: RoundedRectangle(cornerRadius: 28)) - VStack(spacing: 8) { + VStack(spacing: 10) { Text("连接 Mac") .font(.largeTitle.bold()) - Text("在 Mac 菜单栏打开 Sitrep,选择「添加设备」,\n然后扫描它显示的连接码。") + Text("在 Mac 菜单栏打开 Sitrep 并选择「添加设备」,再选择一种连接方式。") .font(.body) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: 340) } } .frame(maxWidth: .infinity) } - @ViewBuilder private var scanner: some View { - if DataScannerViewController.isSupported { - ZStack { - CodeScanner(control: scannerControl) { text in - guard !joining else { return } - Task { await join(payload: text) } - } - - LinearGradient( - colors: [.black.opacity(0.24), .clear, .black.opacity(0.32)], - startPoint: .top, - endPoint: .bottom - ) - - RoundedRectangle(cornerRadius: 14) - .stroke( - .white.opacity(0.9), - style: StrokeStyle(lineWidth: 2, dash: [10, 7]) - ) - .frame(maxWidth: 270) - .aspectRatio(4.6, contentMode: .fit) - .shadow(color: .black.opacity(0.18), radius: 8, y: 3) - - VStack { - Spacer() - Label("将连接码放入框内", systemImage: "viewfinder") - .font(.subheadline.weight(.medium)) - .foregroundStyle(.white) - .padding(.horizontal, 14) - .padding(.vertical, 8) - .background(.ultraThinMaterial, in: Capsule()) - .padding(.bottom, 14) - } - - if joining { - Rectangle() - .fill(.regularMaterial) - ProgressView("正在连接…") - .font(.headline) - } - } - .aspectRatio(1.45, contentMode: .fit) - .clipShape(RoundedRectangle(cornerRadius: 28)) - .overlay { - RoundedRectangle(cornerRadius: 28) - .stroke(Color(.separator).opacity(0.45), lineWidth: 0.5) - } - .accessibilityLabel("连接码扫描器") - } else { - ContentUnavailableView( - "无法使用相机扫描", - systemImage: "camera.fill", - description: Text("请在下方粘贴或输入连接码") + @ViewBuilder + private func destinationView(_ destination: Destination) -> some View { + switch destination { + case .scanner: + ScannerJoinView( + joining: joining, + error: error, + scannerControl: scannerControl, + onCode: { code in Task { await join(payload: code) } } + ) + case .manual: + ManualJoinView( + code: $manualCode, + joining: joining, + error: error, + canSubmit: canSubmit, + onPaste: pasteCode, + onSubmit: { Task { await join(payload: manualCode) } } ) - .frame(maxWidth: .infinity) - .aspectRatio(1.45, contentMode: .fit) - .background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 28)) - } - } - - private var manualEntry: some View { - VStack(alignment: .leading, spacing: 14) { - HStack { - Label("手动输入", systemImage: "keyboard") - .font(.headline) - - Spacer() - - Button("粘贴", systemImage: "doc.on.clipboard") { - pasteCode() - } - .buttonStyle(.bordered) - .controlSize(.small) - .disabled(joining) - } - - TextField("连接码或邀请链接", text: $manualCode) - .font(.body.monospaced()) - .textInputAutocapitalization(.characters) - .autocorrectionDisabled() - .focused($codeFieldFocused) - .submitLabel(.go) - .onSubmit { Task { await join(payload: manualCode) } } - .padding(.horizontal, 14) - .frame(minHeight: 52) - .background(Color(.tertiarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 14)) - .overlay { - RoundedRectangle(cornerRadius: 14) - .stroke(Color(.separator).opacity(0.35), lineWidth: 0.5) - } - .disabled(joining) - - if let error { - Label(error, systemImage: "exclamationmark.circle.fill") - .font(.footnote) - .foregroundStyle(.red) - .fixedSize(horizontal: false, vertical: true) - .transition(.move(edge: .top).combined(with: .opacity)) - } - - Button { - Task { await join(payload: manualCode) } - } label: { - if joining { - ProgressView() - .frame(maxWidth: .infinity) - } else { - Label("连接", systemImage: "link") - .frame(maxWidth: .infinity) - } - } - .buttonStyle(.borderedProminent) - .controlSize(.large) - .disabled(!canSubmit || joining) } - .padding(16) - .background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 24)) - .animation(.snappy, value: error) } private var canSubmit: Bool { @@ -212,7 +146,11 @@ struct JoinView: View { } manualCode = text.trimmingCharacters(in: .whitespacesAndNewlines) error = nil - codeFieldFocused = true + } + + private func present(_ destination: Destination) { + error = nil + self.destination = destination } /// Accepts a connect code (official server implicit) or a full @@ -263,6 +201,169 @@ struct JoinView: View { } } +private struct ScannerJoinView: View { + @Environment(\.dismiss) private var dismiss + + let joining: Bool + let error: String? + let scannerControl: ScannerControl + let onCode: (String) -> Void + + var body: some View { + NavigationStack { + Group { + if DataScannerViewController.isSupported { + ZStack { + CodeScanner(control: scannerControl) { code in + guard !joining else { return } + onCode(code) + } + + LinearGradient( + colors: [.black.opacity(0.3), .clear, .black.opacity(0.42)], + startPoint: .top, + endPoint: .bottom + ) + .allowsHitTesting(false) + + RoundedRectangle(cornerRadius: 16) + .stroke( + .white.opacity(0.92), + style: StrokeStyle(lineWidth: 2, dash: [10, 7]) + ) + .frame(maxWidth: 300) + .aspectRatio(4.6, contentMode: .fit) + .shadow(color: .black.opacity(0.22), radius: 8, y: 3) + .allowsHitTesting(false) + + VStack(spacing: 12) { + Spacer() + + if let error { + Label(error, systemImage: "exclamationmark.circle.fill") + .font(.footnote) + .foregroundStyle(.red) + .multilineTextAlignment(.center) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14)) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + + Label("将 Mac 上的连接码放入框内", systemImage: "viewfinder") + .font(.subheadline.weight(.medium)) + .foregroundStyle(.white) + .padding(.horizontal, 14) + .padding(.vertical, 9) + .background(.ultraThinMaterial, in: Capsule()) + } + .padding() + .allowsHitTesting(false) + + if joining { + Rectangle() + .fill(.regularMaterial) + ProgressView("正在连接…") + .font(.headline) + } + } + .accessibilityLabel("连接码扫描器") + .animation(.snappy, value: error) + } else { + ContentUnavailableView( + "无法使用相机扫描", + systemImage: "camera.fill", + description: Text("请返回并选择手动输入") + ) + } + } + .background(Color(.systemBackground).ignoresSafeArea()) + .navigationTitle("扫描连接码") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("关闭", systemImage: "xmark") { dismiss() } + } + } + } + .interactiveDismissDisabled(joining) + } +} + +private struct ManualJoinView: View { + @Environment(\.dismiss) private var dismiss + @Binding var code: String + @FocusState private var codeFieldFocused: Bool + + let joining: Bool + let error: String? + let canSubmit: Bool + let onPaste: () -> Void + let onSubmit: () -> Void + + var body: some View { + NavigationStack { + Form { + Section { + TextField("连接码或邀请链接", text: $code) + .font(.body.monospaced()) + .textInputAutocapitalization(.characters) + .autocorrectionDisabled() + .focused($codeFieldFocused) + .submitLabel(.go) + .onSubmit(onSubmit) + .disabled(joining) + + Button("从剪贴板粘贴", systemImage: "doc.on.clipboard") { + onPaste() + codeFieldFocused = true + } + .disabled(joining) + } header: { + Text("连接码") + } footer: { + Text("连接码由 Mac 上的 Sitrep 生成,仅用于将这台设备加入你的空间。") + } + + if let error { + Section { + Label(error, systemImage: "exclamationmark.circle.fill") + .font(.footnote) + .foregroundStyle(.red) + } + } + } + .navigationTitle("手动输入") + .navigationBarTitleDisplayMode(.inline) + .safeAreaInset(edge: .bottom) { + Button(action: onSubmit) { + if joining { + ProgressView() + .frame(maxWidth: .infinity) + } else { + Label("连接", systemImage: "link") + .frame(maxWidth: .infinity) + } + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .buttonBorderShape(.roundedRectangle(radius: 18)) + .disabled(!canSubmit || joining) + .padding() + .background(.bar) + } + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("关闭", systemImage: "xmark") { dismiss() } + } + } + } + .interactiveDismissDisabled(joining) + .onAppear { codeFieldFocused = true } + .animation(.snappy, value: error) + } +} + /// Live text scanner filtered to the connect-code pattern — random text in /// the camera view never triggers a join attempt. struct CodeScanner: UIViewControllerRepresentable { @@ -287,6 +388,10 @@ struct CodeScanner: UIViewControllerRepresentable { func updateUIViewController(_ vc: DataScannerViewController, context: Context) {} + static func dismantleUIViewController(_ vc: DataScannerViewController, coordinator: Coordinator) { + vc.stopScanning() + } + func makeCoordinator() -> Coordinator { Coordinator(onCode: onCode) } final class Coordinator: NSObject, DataScannerViewControllerDelegate { From 91ad58cd8511690f53966784e5177072d61b7199 Mon Sep 17 00:00:00 2001 From: QuintinShaw Date: Sat, 18 Jul 2026 16:46:18 +0800 Subject: [PATCH 03/11] fix(apple): simplify pairing choices --- apple/SitrepApp/Sitrep/JoinView.swift | 4 ---- 1 file changed, 4 deletions(-) diff --git a/apple/SitrepApp/Sitrep/JoinView.swift b/apple/SitrepApp/Sitrep/JoinView.swift index e951120..3ab404d 100644 --- a/apple/SitrepApp/Sitrep/JoinView.swift +++ b/apple/SitrepApp/Sitrep/JoinView.swift @@ -39,10 +39,6 @@ struct JoinView: View { Spacer() VStack(spacing: 12) { - Label("相机仅在你选择扫描后启用", systemImage: "hand.raised.fill") - .font(.footnote) - .foregroundStyle(.secondary) - Button { present(.scanner) } label: { From 62e90b4c3dadc3fb2fea5afeedc8e93fab8ff987 Mon Sep 17 00:00:00 2001 From: QuintinShaw Date: Sat, 18 Jul 2026 16:57:44 +0800 Subject: [PATCH 04/11] perf(apple): reduce pairing scan latency --- apple/SitrepApp/Sitrep/JoinView.swift | 62 +++++++++++++++++++++++---- 1 file changed, 54 insertions(+), 8 deletions(-) diff --git a/apple/SitrepApp/Sitrep/JoinView.swift b/apple/SitrepApp/Sitrep/JoinView.swift index 3ab404d..36653d1 100644 --- a/apple/SitrepApp/Sitrep/JoinView.swift +++ b/apple/SitrepApp/Sitrep/JoinView.swift @@ -113,7 +113,7 @@ struct JoinView: View { joining: joining, error: error, scannerControl: scannerControl, - onCode: { code in Task { await join(payload: code) } } + onCode: { code in beginJoin(payload: code) } ) case .manual: ManualJoinView( @@ -122,7 +122,7 @@ struct JoinView: View { error: error, canSubmit: canSubmit, onPaste: pasteCode, - onSubmit: { Task { await join(payload: manualCode) } } + onSubmit: { beginJoin(payload: manualCode) } ) } } @@ -153,6 +153,21 @@ struct JoinView: View { /// sitrep://join link (self-host path). private func join(payload: String) async { guard !joining else { return } + joining = true + error = nil + await resolveJoin(payload: payload) + } + + /// Switch the UI to its progress state in the same main-actor turn as + /// the scanner callback, before URL parsing or any network work begins. + private func beginJoin(payload: String) { + guard !joining else { return } + joining = true + error = nil + Task { await resolveJoin(payload: payload) } + } + + private func resolveJoin(payload: String) async { let trimmed = payload.trimmingCharacters(in: .whitespacesAndNewlines) if let code = ConnectCode.parse(trimmed) { await performJoin( @@ -170,12 +185,11 @@ struct JoinView: View { await performJoin(server: serverURL, space: space, code: code) return } + joining = false error = "无法识别的连接码" } private func performJoin(server: URL, space: String?, code: String) async { - joining = true - error = nil defer { joining = false } do { let joined = try await APIClient.join( @@ -371,18 +385,50 @@ struct CodeScanner: UIViewControllerRepresentable { // Latin-only OCR: skipping the CJK text model is the single // biggest speed win; the code alphabet is pure A-Z/2-9. recognizedDataTypes: [.text(languages: ["en-US"])], - qualityLevel: .balanced, + qualityLevel: .fast, recognizesMultipleItems: true, - isHighlightingEnabled: true + isHighFrameRateTrackingEnabled: true, + isPinchToZoomEnabled: true, + isGuidanceEnabled: false, + isHighlightingEnabled: false ) vc.delegate = context.coordinator context.coordinator.scanner = vc control?.coordinator = context.coordinator try? vc.startScanning() + Task { @MainActor [weak vc] in + // SwiftUI lays out the hosted controller after creation. Yield + // once so the ROI is calculated from the final camera bounds. + await Task.yield() + guard let vc else { return } + Self.updateRegionOfInterest(vc) + } return vc } - func updateUIViewController(_ vc: DataScannerViewController, context: Context) {} + func updateUIViewController(_ vc: DataScannerViewController, context: Context) { + Self.updateRegionOfInterest(vc) + } + + @MainActor + private static func updateRegionOfInterest(_ vc: DataScannerViewController) { + let bounds = vc.view.bounds + guard bounds.width > 0, bounds.height > 0 else { return } + + // Match the visible reticle while leaving enough vertical tolerance + // for OCR bounding-box drift. VisionKit ignores unrelated page text. + let width = min(max(bounds.width - 40, 0), 340) + let height = min(max(bounds.height * 0.18, 100), 150) + let region = CGRect( + x: bounds.midX - width / 2, + y: bounds.midY - height / 2, + width: width, + height: height + ) + if vc.regionOfInterest != region { + vc.regionOfInterest = region + } + } static func dismantleUIViewController(_ vc: DataScannerViewController, coordinator: Coordinator) { vc.stopScanning() @@ -420,8 +466,8 @@ struct CodeScanner: UIViewControllerRepresentable { if let match = candidate.firstMatch(of: ConnectCode.scanPattern), !rejected.contains(String(match.output)) { fired = true - scanner?.stopScanning() // one shot; also stops the camera work onCode(String(match.output)) + scanner?.stopScanning() // one shot; also stops the camera work return } } From d367f1ab8eaaa1f58b77e7d44f61aacc3954c75d Mon Sep 17 00:00:00 2001 From: QuintinShaw Date: Sun, 19 Jul 2026 08:34:30 +0800 Subject: [PATCH 05/11] fix(apple): surface deep-link join failures Deep-link and cold-start joins run with destination == nil, so the existing error/joining state only rendered inside the scanner/manual sheets and was invisible for links opened via onOpenURL or pendingJoinPayload. Add an inline progress/error status to the base JoinView shown whenever no sheet is on screen, disable the action buttons while a join is in flight, and stop present() from resetting error state while a join is still running. --- apple/SitrepApp/Sitrep/JoinView.swift | 75 +++++++++++++++++++-------- 1 file changed, 54 insertions(+), 21 deletions(-) diff --git a/apple/SitrepApp/Sitrep/JoinView.swift b/apple/SitrepApp/Sitrep/JoinView.swift index 36653d1..b13d532 100644 --- a/apple/SitrepApp/Sitrep/JoinView.swift +++ b/apple/SitrepApp/Sitrep/JoinView.swift @@ -38,28 +38,14 @@ struct JoinView: View { Spacer() - VStack(spacing: 12) { - Button { - present(.scanner) - } label: { - Label("扫描连接码", systemImage: "camera.viewfinder") - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .controlSize(.large) - - Button { - present(.manual) - } label: { - Label("手动输入连接码", systemImage: "keyboard") - .frame(maxWidth: .infinity) - } - .buttonStyle(.bordered) - .controlSize(.large) + // Deep-link and cold-start joins run with no sheet on screen + // (destination == nil), so this is the only place their + // progress or failure is ever visible. + if destination == nil { + deepLinkStatus } - .buttonBorderShape(.roundedRectangle(radius: 18)) - .frame(maxWidth: .infinity) - .padding(.bottom, 12) + + actionButtons } .padding(.horizontal, 24) .padding(.bottom, 20) @@ -105,6 +91,49 @@ struct JoinView: View { .frame(maxWidth: .infinity) } + private var actionButtons: some View { + VStack(spacing: 12) { + Button { + present(.scanner) + } label: { + Label("扫描连接码", systemImage: "camera.viewfinder") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + + Button { + present(.manual) + } label: { + Label("手动输入连接码", systemImage: "keyboard") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .controlSize(.large) + } + .buttonBorderShape(.roundedRectangle(radius: 18)) + .frame(maxWidth: .infinity) + .padding(.bottom, 12) + .disabled(joining) + } + + @ViewBuilder + private var deepLinkStatus: some View { + if joining { + ProgressView("正在连接…") + .padding(.bottom, 16) + } else if let error { + Label(error, systemImage: "exclamationmark.circle.fill") + .font(.footnote) + .foregroundStyle(.red) + .multilineTextAlignment(.center) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(.red.opacity(0.12), in: RoundedRectangle(cornerRadius: 14)) + .padding(.bottom, 16) + } + } + @ViewBuilder private func destinationView(_ destination: Destination) -> some View { switch destination { @@ -145,6 +174,10 @@ struct JoinView: View { } private func present(_ destination: Destination) { + // A join already in progress (e.g. from a deep link) owns the + // current error/joining state; don't let a stray tap reset it + // before that state has had a chance to be shown. + guard !joining else { return } error = nil self.destination = destination } From 365984dfe18e8321363838b6408648a8320c5914 Mon Sep 17 00:00:00 2001 From: QuintinShaw Date: Sun, 19 Jul 2026 08:35:26 +0800 Subject: [PATCH 06/11] fix(apple): remove ineffective scanner reentrancy guard The onCode closure's `guard !joining` could never block a second callback: UIViewControllerRepresentable.makeCoordinator runs once, so Coordinator captured this closure (and the joining value baked into it) only on the sheet's first render. Reentrancy is actually enforced synchronously by Coordinator's `fired` flag plus beginJoin's own `guard !joining`, which runs in the same actor turn as this callback. Drop the dead guard and document why it's safe. --- apple/SitrepApp/Sitrep/JoinView.swift | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/apple/SitrepApp/Sitrep/JoinView.swift b/apple/SitrepApp/Sitrep/JoinView.swift index b13d532..77203aa 100644 --- a/apple/SitrepApp/Sitrep/JoinView.swift +++ b/apple/SitrepApp/Sitrep/JoinView.swift @@ -257,10 +257,14 @@ private struct ScannerJoinView: View { Group { if DataScannerViewController.isSupported { ZStack { - CodeScanner(control: scannerControl) { code in - guard !joining else { return } - onCode(code) - } + // Reentrancy is guarded synchronously: Coordinator's + // `fired` flag stops it firing twice, and beginJoin's + // `guard !joining` runs in the same actor turn as + // this callback. (A `guard !joining` here would be + // a no-op: makeCoordinator captures this closure + // once, so it would forever see the `joining` value + // from the sheet's first render.) + CodeScanner(control: scannerControl, onCode: onCode) LinearGradient( colors: [.black.opacity(0.3), .clear, .black.opacity(0.42)], From c30573d8943dcd73be6ad96417a73341e9d830ab Mon Sep 17 00:00:00 2001 From: QuintinShaw Date: Sun, 19 Jul 2026 08:36:34 +0800 Subject: [PATCH 07/11] fix(apple): scan every candidate match, not just the first firstMatch(of:) only ever looked at one shape match per transcript candidate, so a rejected false-positive on a candidate permanently hid any real code recognized later in that same candidate. Switch to matches(of:) and walk all matches, skipping ones already rejected. --- apple/SitrepApp/Sitrep/JoinView.swift | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apple/SitrepApp/Sitrep/JoinView.swift b/apple/SitrepApp/Sitrep/JoinView.swift index 77203aa..f6bc5f1 100644 --- a/apple/SitrepApp/Sitrep/JoinView.swift +++ b/apple/SitrepApp/Sitrep/JoinView.swift @@ -499,11 +499,15 @@ struct CodeScanner: UIViewControllerRepresentable { transcripts.append(text.transcript.filter { !$0.isWhitespace }) } } + // A rejected false-positive shape shouldn't mask a real code + // that appears later in the same candidate, so walk every + // match rather than stopping at the first. for candidate in transcripts + [transcripts.joined()] { - if let match = candidate.firstMatch(of: ConnectCode.scanPattern), - !rejected.contains(String(match.output)) { + for match in candidate.matches(of: ConnectCode.scanPattern) { + let code = String(match.output) + guard !rejected.contains(code) else { continue } fired = true - onCode(String(match.output)) + onCode(code) scanner?.stopScanning() // one shot; also stops the camera work return } From 0bae18ee61d214723d91b66321d12d9dc0bf3875 Mon Sep 17 00:00:00 2001 From: QuintinShaw Date: Sun, 19 Jul 2026 08:37:35 +0800 Subject: [PATCH 08/11] fix(apple): accept case-insensitive join links in manual entry .textInputAutocapitalization(.characters) on the manual code field can uppercase a fully-typed sitrep://join link (e.g. SITREP://JOIN?...), but canSubmit and resolveJoin compared scheme/host case-sensitively via URLComponents, so such input could never validate or resolve. Lowercase only the scheme/host comparison; leave the rest of the string, and the extracted query values, in their original case. --- apple/SitrepApp/Sitrep/JoinView.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apple/SitrepApp/Sitrep/JoinView.swift b/apple/SitrepApp/Sitrep/JoinView.swift index f6bc5f1..ea88230 100644 --- a/apple/SitrepApp/Sitrep/JoinView.swift +++ b/apple/SitrepApp/Sitrep/JoinView.swift @@ -160,7 +160,11 @@ struct JoinView: View { let payload = manualCode.trimmingCharacters(in: .whitespacesAndNewlines) if ConnectCode.parse(payload) != nil { return true } guard let components = URLComponents(string: payload) else { return false } - return components.scheme == "sitrep" && components.host == "join" + // Only scheme/host are normalized for comparison; query values keep + // their original case (autocapitalized manual typing can uppercase + // the whole string, but pasted links are already correctly cased). + return components.scheme?.lowercased() == "sitrep" + && components.host?.lowercased() == "join" } private func pasteCode() { @@ -210,7 +214,7 @@ struct JoinView: View { return } if let comps = URLComponents(string: trimmed), - comps.scheme == "sitrep", comps.host == "join", + comps.scheme?.lowercased() == "sitrep", comps.host?.lowercased() == "join", let server = comps.queryItems?.first(where: { $0.name == "server" })?.value, let space = comps.queryItems?.first(where: { $0.name == "space" })?.value, let code = comps.queryItems?.first(where: { $0.name == "code" })?.value, From 41d9cebc9e195c694732e3868a755447cd9b624e Mon Sep 17 00:00:00 2001 From: QuintinShaw Date: Sun, 19 Jul 2026 08:38:54 +0800 Subject: [PATCH 09/11] fix(apple): disable sheet close button while joining ScannerJoinView and ManualJoinView already set interactiveDismissDisabled(joining) to block swipe-to-dismiss during a join, but their toolbar close buttons stayed tappable, letting the user dismiss the sheet mid-join through the button instead. --- apple/SitrepApp/Sitrep/JoinView.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apple/SitrepApp/Sitrep/JoinView.swift b/apple/SitrepApp/Sitrep/JoinView.swift index ea88230..7836254 100644 --- a/apple/SitrepApp/Sitrep/JoinView.swift +++ b/apple/SitrepApp/Sitrep/JoinView.swift @@ -334,6 +334,7 @@ private struct ScannerJoinView: View { .toolbar { ToolbarItem(placement: .cancellationAction) { Button("关闭", systemImage: "xmark") { dismiss() } + .disabled(joining) } } } @@ -406,6 +407,7 @@ private struct ManualJoinView: View { .toolbar { ToolbarItem(placement: .cancellationAction) { Button("关闭", systemImage: "xmark") { dismiss() } + .disabled(joining) } } } From 422873b0014cd4e6c7b9c94263479d6a12901417 Mon Sep 17 00:00:00 2001 From: QuintinShaw Date: Sun, 19 Jul 2026 08:40:01 +0800 Subject: [PATCH 10/11] fix(apple): distinguish network failures from invalid codes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit performJoin's blanket catch treated every failure — timeouts, offline, 5xx — the same as a server-rejected code: it showed "code invalid or expired" and permanently blacklisted the code via rejectAndResume, deadlocking a perfectly valid code against a flaky connection. Only an explicit APIError.badStatus in the 4xx range now blacklists the code; everything else (transport errors, timeouts, 5xx) shows a network-check message and resumes scanning via the new ScannerControl.resume()/Coordinator.resume(), which re-arms the scanner without adding the code to `rejected`. --- apple/SitrepApp/Sitrep/JoinView.swift | 37 ++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/apple/SitrepApp/Sitrep/JoinView.swift b/apple/SitrepApp/Sitrep/JoinView.swift index 7836254..afb09dd 100644 --- a/apple/SitrepApp/Sitrep/JoinView.swift +++ b/apple/SitrepApp/Sitrep/JoinView.swift @@ -13,6 +13,12 @@ final class ScannerControl { func rejectAndResume(_ code: String) { coordinator?.reject(code) } + + /// A transport/server failure, not a bad code: resume scanning without + /// blacklisting it so the same code can be retried. + func resume() { + coordinator?.resume() + } } struct JoinView: View { @@ -240,12 +246,27 @@ struct JoinView: View { model.needsOnboarding = false await model.enableNotifications() } catch { - self.error = "连接失败:代码无效或已过期,请重新生成后再扫" - // Re-arm the scanner, ignoring this dead code so it doesn't - // re-trigger while still on screen. - scannerControl.rejectAndResume(code) + if Self.isRejectedByServer(error) { + self.error = "连接失败:代码无效或已过期,请重新生成后再扫" + // Re-arm the scanner, ignoring this dead code so it doesn't + // re-trigger while still on screen. + scannerControl.rejectAndResume(code) + } else { + self.error = "连接失败:请检查网络后重试" + // Not the code's fault — resume scanning without + // blacklisting it so the same code can be retried. + scannerControl.resume() + } } } + + /// Only an explicit 4xx from the server means the code itself is bad. + /// Transport failures, timeouts, and 5xx responses are the network's + /// or server's fault and shouldn't blacklist an otherwise-valid code. + private static func isRejectedByServer(_ error: Error) -> Bool { + guard case APIError.badStatus(let status) = error else { return false } + return (400..<500).contains(status) + } } private struct ScannerJoinView: View { @@ -493,6 +514,14 @@ struct CodeScanner: UIViewControllerRepresentable { try? scanner?.startScanning() } + /// A join failed for reasons unrelated to the code (network/server + /// error): resume scanning without blacklisting anything, so the + /// same code can trigger another attempt. + func resume() { + fired = false + try? scanner?.startScanning() + } + // OCR refines transcripts over time (didUpdate) and may split one // code across adjacent text items — so on every change, scan each // transcript AND the concatenation of all of them, whitespace From bebc33d44a2f4863d7085853350e2f52d4c9e3e4 Mon Sep 17 00:00:00 2001 From: QuintinShaw Date: Sun, 19 Jul 2026 08:42:52 +0800 Subject: [PATCH 11/11] fix(apple): handle denied camera permission ScannerJoinView only checked DataScannerViewController.isSupported, so a denied/restricted camera permission fell through to a black camera layer with no explanation or way out, and startScanning() failures were silently swallowed with try?. Check AVCaptureDevice.authorizationStatus(for: .video) before showing the scanner: request access when .notDetermined, and show an explicit "open Settings" screen instead of the camera view when denied or restricted (refreshed on scenePhase becoming active, so returning from Settings revives the flow without reopening the sheet). Route startScanning() through a Coordinator.start() that reports failures via a new onScanningError callback instead of using try?, rendering a readable error state rather than a dead camera preview. --- apple/SitrepApp/Sitrep/JoinView.swift | 231 +++++++++++++++++--------- 1 file changed, 157 insertions(+), 74 deletions(-) diff --git a/apple/SitrepApp/Sitrep/JoinView.swift b/apple/SitrepApp/Sitrep/JoinView.swift index afb09dd..4970429 100644 --- a/apple/SitrepApp/Sitrep/JoinView.swift +++ b/apple/SitrepApp/Sitrep/JoinView.swift @@ -1,3 +1,4 @@ +import AVFoundation import SitrepKit import SwiftUI import UIKit @@ -271,83 +272,20 @@ struct JoinView: View { private struct ScannerJoinView: View { @Environment(\.dismiss) private var dismiss + @Environment(\.scenePhase) private var scenePhase let joining: Bool let error: String? let scannerControl: ScannerControl let onCode: (String) -> Void + @State private var cameraAuthorization = AVCaptureDevice.authorizationStatus(for: .video) + @State private var scanningError: String? + var body: some View { NavigationStack { Group { - if DataScannerViewController.isSupported { - ZStack { - // Reentrancy is guarded synchronously: Coordinator's - // `fired` flag stops it firing twice, and beginJoin's - // `guard !joining` runs in the same actor turn as - // this callback. (A `guard !joining` here would be - // a no-op: makeCoordinator captures this closure - // once, so it would forever see the `joining` value - // from the sheet's first render.) - CodeScanner(control: scannerControl, onCode: onCode) - - LinearGradient( - colors: [.black.opacity(0.3), .clear, .black.opacity(0.42)], - startPoint: .top, - endPoint: .bottom - ) - .allowsHitTesting(false) - - RoundedRectangle(cornerRadius: 16) - .stroke( - .white.opacity(0.92), - style: StrokeStyle(lineWidth: 2, dash: [10, 7]) - ) - .frame(maxWidth: 300) - .aspectRatio(4.6, contentMode: .fit) - .shadow(color: .black.opacity(0.22), radius: 8, y: 3) - .allowsHitTesting(false) - - VStack(spacing: 12) { - Spacer() - - if let error { - Label(error, systemImage: "exclamationmark.circle.fill") - .font(.footnote) - .foregroundStyle(.red) - .multilineTextAlignment(.center) - .padding(.horizontal, 14) - .padding(.vertical, 10) - .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14)) - .transition(.move(edge: .bottom).combined(with: .opacity)) - } - - Label("将 Mac 上的连接码放入框内", systemImage: "viewfinder") - .font(.subheadline.weight(.medium)) - .foregroundStyle(.white) - .padding(.horizontal, 14) - .padding(.vertical, 9) - .background(.ultraThinMaterial, in: Capsule()) - } - .padding() - .allowsHitTesting(false) - - if joining { - Rectangle() - .fill(.regularMaterial) - ProgressView("正在连接…") - .font(.headline) - } - } - .accessibilityLabel("连接码扫描器") - .animation(.snappy, value: error) - } else { - ContentUnavailableView( - "无法使用相机扫描", - systemImage: "camera.fill", - description: Text("请返回并选择手动输入") - ) - } + content } .background(Color(.systemBackground).ignoresSafeArea()) .navigationTitle("扫描连接码") @@ -360,6 +298,132 @@ private struct ScannerJoinView: View { } } .interactiveDismissDisabled(joining) + .task { await refreshCameraAuthorization() } + // Coming back from Settings after granting access should revive + // the scanner without requiring the sheet to be reopened. + .onChange(of: scenePhase) { _, phase in + if phase == .active { Task { await refreshCameraAuthorization() } } + } + } + + @ViewBuilder + private var content: some View { + if !DataScannerViewController.isSupported { + ContentUnavailableView( + "无法使用相机扫描", + systemImage: "camera.fill", + description: Text("请返回并选择手动输入") + ) + } else if let scanningError { + ContentUnavailableView( + "无法启动相机", + systemImage: "camera.fill.badge.ellipsis", + description: Text(scanningError) + ) + } else { + switch cameraAuthorization { + case .authorized: + scannerOverlay + case .denied, .restricted: + cameraPermissionDeniedView + case .notDetermined: + ProgressView() + @unknown default: + ProgressView() + } + } + } + + private var scannerOverlay: some View { + ZStack { + // Reentrancy is guarded synchronously: Coordinator's + // `fired` flag stops it firing twice, and beginJoin's + // `guard !joining` runs in the same actor turn as + // this callback. (A `guard !joining` here would be + // a no-op: makeCoordinator captures this closure + // once, so it would forever see the `joining` value + // from the sheet's first render.) + CodeScanner( + control: scannerControl, + onCode: onCode, + onScanningError: { scanningError = $0 } + ) + + LinearGradient( + colors: [.black.opacity(0.3), .clear, .black.opacity(0.42)], + startPoint: .top, + endPoint: .bottom + ) + .allowsHitTesting(false) + + RoundedRectangle(cornerRadius: 16) + .stroke( + .white.opacity(0.92), + style: StrokeStyle(lineWidth: 2, dash: [10, 7]) + ) + .frame(maxWidth: 300) + .aspectRatio(4.6, contentMode: .fit) + .shadow(color: .black.opacity(0.22), radius: 8, y: 3) + .allowsHitTesting(false) + + VStack(spacing: 12) { + Spacer() + + if let error { + Label(error, systemImage: "exclamationmark.circle.fill") + .font(.footnote) + .foregroundStyle(.red) + .multilineTextAlignment(.center) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14)) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + + Label("将 Mac 上的连接码放入框内", systemImage: "viewfinder") + .font(.subheadline.weight(.medium)) + .foregroundStyle(.white) + .padding(.horizontal, 14) + .padding(.vertical, 9) + .background(.ultraThinMaterial, in: Capsule()) + } + .padding() + .allowsHitTesting(false) + + if joining { + Rectangle() + .fill(.regularMaterial) + ProgressView("正在连接…") + .font(.headline) + } + } + .accessibilityLabel("连接码扫描器") + .animation(.snappy, value: error) + } + + private var cameraPermissionDeniedView: some View { + ContentUnavailableView { + Label("需要相机权限", systemImage: "camera.fill") + } description: { + Text("请在系统设置中允许 Sitrep 使用相机以扫描连接码") + } actions: { + Button("打开设置") { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + } + .buttonStyle(.borderedProminent) + } + } + + private func refreshCameraAuthorization() async { + let status = AVCaptureDevice.authorizationStatus(for: .video) + if status == .notDetermined { + let granted = await AVCaptureDevice.requestAccess(for: .video) + cameraAuthorization = granted ? .authorized : .denied + } else { + cameraAuthorization = status + } } } @@ -443,6 +507,7 @@ private struct ManualJoinView: View { struct CodeScanner: UIViewControllerRepresentable { var control: ScannerControl? var onCode: (String) -> Void + var onScanningError: ((String) -> Void)? func makeUIViewController(context: Context) -> DataScannerViewController { let vc = DataScannerViewController( @@ -459,13 +524,16 @@ struct CodeScanner: UIViewControllerRepresentable { vc.delegate = context.coordinator context.coordinator.scanner = vc control?.coordinator = context.coordinator - try? vc.startScanning() + let coordinator = context.coordinator Task { @MainActor [weak vc] in // SwiftUI lays out the hosted controller after creation. Yield - // once so the ROI is calculated from the final camera bounds. + // once so the ROI is calculated from the final camera bounds, + // and so a startScanning failure (which reports back into + // SwiftUI state) isn't raised mid view-update. await Task.yield() guard let vc else { return } Self.updateRegionOfInterest(vc) + coordinator.start() } return vc } @@ -498,20 +566,35 @@ struct CodeScanner: UIViewControllerRepresentable { vc.stopScanning() } - func makeCoordinator() -> Coordinator { Coordinator(onCode: onCode) } + func makeCoordinator() -> Coordinator { Coordinator(onCode: onCode, onScanningError: onScanningError) } final class Coordinator: NSObject, DataScannerViewControllerDelegate { let onCode: (String) -> Void + let onScanningError: ((String) -> Void)? weak var scanner: DataScannerViewController? private var fired = false private var rejected: Set = [] - init(onCode: @escaping (String) -> Void) { self.onCode = onCode } + + init(onCode: @escaping (String) -> Void, onScanningError: ((String) -> Void)?) { + self.onCode = onCode + self.onScanningError = onScanningError + } + + /// Starts (or restarts) the scanner, surfacing a failure instead of + /// silently swallowing it with `try?`. + func start() { + do { + try scanner?.startScanning() + } catch { + onScanningError?(error.localizedDescription) + } + } /// A join with this code failed: don't fire on it again, resume. func reject(_ code: String) { rejected.insert(code) fired = false - try? scanner?.startScanning() + start() } /// A join failed for reasons unrelated to the code (network/server @@ -519,7 +602,7 @@ struct CodeScanner: UIViewControllerRepresentable { /// same code can trigger another attempt. func resume() { fired = false - try? scanner?.startScanning() + start() } // OCR refines transcripts over time (didUpdate) and may split one