mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-02 23:51:34 +00:00
Part 1 — Simplified Chinese localization of the iOS app layer: - ios/Hapi/Resources/Localizable.xcstrings: hand-authored String Catalog (395 keys, zh-Hans; en implicit as source). Terminology mined from web/src/lib/locales/zh-CN.ts (会话/新建会话/权限模式/允许/拒绝/工作树/ 机器/语音输入/用量/草稿夹 …). - Mechanical edits only: merged multi-part string concatenations into single LocalizedStringKey literals, converted ternary/plain-String user copy to String(localized:), switched helper params (detailRow, DashboardCard, optionPicker, notice) to LocalizedStringKey. - LocalizedNoticeMapper (app layer): display-point translation of the 27 known HapiKit-emitted strings (ChatInteractor notices, dictation errors, window-sync warnings, files fallbacks, worktree-name validation) with verbatim passthrough for server-originated text; package stays language-free. - Deliberately untranslated (web parity / non-copy): event rows (EventPresentation — web renders presentation.ts verbatim), catalog option labels (Default/Auto/Sonnet/permission modes — web shows them verbatim), tool names, code-like titles (grep(pattern:), MCP:, Skill, Task), unit suffixes (B/KB/MB, m/h/d/w), decorative separators. - Language row wired: AppLanguage gains .system (follow system, new default); explicit picks write the AppleLanguages override, Follow system removes it; footer notes a relaunch applies it (no supported in-place SwiftUI locale swap). - project.pbxproj: zh-Hans added to knownRegions. Part 2 — #39 catalog dedupe (HapiProtocol): - NewSessionCatalogs.claudeModels/claudeEfforts now derive from ClaudeModels/ClaudeEfforts (single source shared with ModelCatalog); codexReasoningEfforts stays own data (web CODEX_REASONING_EFFORT_OPTIONS minus max); effortLabel delegates to ModelCatalog.capitalizedFirst. - New CatalogTests lock test for the derived option lists. Gate: bash ios/scripts/linux-test.sh — 459 tests green.
189 lines
6.7 KiB
Swift
189 lines
6.7 KiB
Swift
import HapiProtocol
|
|
import SwiftUI
|
|
import UIKit
|
|
import Vision
|
|
import VisionKit
|
|
|
|
/// QR scanning step: a VisionKit live scanner that feeds every recognized
|
|
/// string through ``BindLink/parse(_:)`` (so both the companion deeplink QR
|
|
/// and the web direct-access QR pair) and pushes the shared confirm step on
|
|
/// success. Falls back to guidance when scanning is unsupported (Simulator,
|
|
/// no camera) or camera access is denied.
|
|
struct QRScannerView: View {
|
|
@Environment(\.dismiss) private var dismiss
|
|
@Environment(\.openURL) private var openURL
|
|
@State private var scanned: PendingPairing?
|
|
@State private var cameraDenied = false
|
|
@State private var sawForeignCode = false
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
scannerContent
|
|
.navigationTitle("Scan to Pair")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button("Cancel") {
|
|
dismiss()
|
|
}
|
|
}
|
|
}
|
|
.navigationDestination(item: $scanned) { pending in
|
|
PairingConfirmView(pending: pending)
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var scannerContent: some View {
|
|
if DataScannerViewController.isSupported {
|
|
DataScannerRepresentable(isActive: scanned == nil, onScan: handleScannedString)
|
|
.ignoresSafeArea(edges: .bottom)
|
|
.overlay(alignment: .bottom) {
|
|
hintBar
|
|
}
|
|
.overlay {
|
|
if cameraDenied {
|
|
cameraDeniedView
|
|
}
|
|
}
|
|
.task {
|
|
// Also triggers the camera permission prompt on first use.
|
|
cameraDenied = !(await DataScannerViewController.isAvailable)
|
|
}
|
|
} else {
|
|
ContentUnavailableView {
|
|
Label("Scanning unavailable", systemImage: "camera")
|
|
} description: {
|
|
Text("This device cannot scan QR codes (for example the Simulator). Go back and use manual entry instead.")
|
|
} actions: {
|
|
Button("Close") {
|
|
dismiss()
|
|
}
|
|
.buttonStyle(.bordered)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var hintBar: some View {
|
|
Text(sawForeignCode
|
|
? String(localized: "That code is not a HAPI pairing QR — scan one printed by the hub.")
|
|
: String(localized: "Point the camera at the pairing QR from the hub terminal or the web app's Companion Pairing settings."))
|
|
.font(.footnote)
|
|
.multilineTextAlignment(.center)
|
|
.foregroundStyle(sawForeignCode ? Color.orange : Color.secondary)
|
|
.padding(12)
|
|
.frame(maxWidth: .infinity)
|
|
.background(.thinMaterial)
|
|
}
|
|
|
|
private var cameraDeniedView: some View {
|
|
ContentUnavailableView {
|
|
Label("Camera access denied", systemImage: "video.slash")
|
|
} description: {
|
|
Text("Allow camera access in Settings to scan the pairing QR, or go back and use manual entry.")
|
|
} actions: {
|
|
if let settingsURL = URL(string: UIApplication.openSettingsURLString) {
|
|
Button("Open Settings") {
|
|
openURL(settingsURL)
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
}
|
|
}
|
|
.background(.background)
|
|
}
|
|
|
|
/// Returns whether the payload was accepted (stops further scanning).
|
|
private func handleScannedString(_ raw: String) -> Bool {
|
|
guard scanned == nil else { return true }
|
|
guard let link = BindLink.parse(raw) else {
|
|
sawForeignCode = true
|
|
return false
|
|
}
|
|
UINotificationFeedbackGenerator().notificationOccurred(.success)
|
|
scanned = PendingPairing(
|
|
hubUrl: link.hubUrl,
|
|
accessToken: link.accessToken,
|
|
source: .qrScan
|
|
)
|
|
return true
|
|
}
|
|
}
|
|
|
|
/// `DataScannerViewController` wrapped for SwiftUI, restricted to QR codes.
|
|
/// `isActive` gates scanning so a successful scan freezes the camera while
|
|
/// the confirm step is up and re-arms when the user navigates back.
|
|
private struct DataScannerRepresentable: UIViewControllerRepresentable {
|
|
var isActive: Bool
|
|
var onScan: (String) -> Bool
|
|
|
|
func makeUIViewController(context: Context) -> DataScannerViewController {
|
|
let scanner = DataScannerViewController(
|
|
recognizedDataTypes: [.barcode(symbologies: [.qr])],
|
|
qualityLevel: .balanced,
|
|
recognizesMultipleItems: false,
|
|
isHighlightingEnabled: true
|
|
)
|
|
scanner.delegate = context.coordinator
|
|
return scanner
|
|
}
|
|
|
|
func updateUIViewController(_ scanner: DataScannerViewController, context: Context) {
|
|
context.coordinator.onScan = onScan
|
|
if isActive {
|
|
context.coordinator.hasAccepted = false
|
|
if !scanner.isScanning {
|
|
// Throws when unavailable (permission denied etc.); the
|
|
// SwiftUI layer surfaces that state, so failing quietly here
|
|
// is fine.
|
|
try? scanner.startScanning()
|
|
}
|
|
} else if scanner.isScanning {
|
|
scanner.stopScanning()
|
|
}
|
|
}
|
|
|
|
func makeCoordinator() -> Coordinator {
|
|
Coordinator(onScan: onScan)
|
|
}
|
|
|
|
@MainActor
|
|
final class Coordinator: NSObject, DataScannerViewControllerDelegate {
|
|
var onScan: (String) -> Bool
|
|
var hasAccepted = false
|
|
|
|
init(onScan: @escaping (String) -> Bool) {
|
|
self.onScan = onScan
|
|
}
|
|
|
|
func dataScanner(
|
|
_ dataScanner: DataScannerViewController,
|
|
didAdd addedItems: [RecognizedItem],
|
|
allItems: [RecognizedItem]
|
|
) {
|
|
process(addedItems, scanner: dataScanner)
|
|
}
|
|
|
|
func dataScanner(
|
|
_ dataScanner: DataScannerViewController,
|
|
didUpdate updatedItems: [RecognizedItem],
|
|
allItems: [RecognizedItem]
|
|
) {
|
|
process(updatedItems, scanner: dataScanner)
|
|
}
|
|
|
|
private func process(_ items: [RecognizedItem], scanner: DataScannerViewController) {
|
|
guard !hasAccepted else { return }
|
|
for item in items {
|
|
guard case .barcode(let barcode) = item,
|
|
let payload = barcode.payloadStringValue else { continue }
|
|
if onScan(payload) {
|
|
hasAccepted = true
|
|
scanner.stopScanning()
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|