Files
nucleic-remote-ios/NucleicRemote/NucleicRemote/Views/PairingScannerView.swift
T
abkslmandClaude Opus 4.8 6f24b42004 M4: NucleicRemote iPhone app — SwiftUI client over the shared protocol
A real iOS Xcode app (ios/NucleicRemote) linking the NucleicProtocol SwiftPM
library as a local package. Builds for the iOS 27 simulator and launches to the
pairing screen.

- Transport: NWFrameChannel (NWConnection) + LANDiscovery (Bonjour _nucleic._tcp).
- Engine: drives NucleicProtocol.SyncClient (Noise XXpsk0 pair / IK reconnect,
  hello/welcome, HostMsg→Event stream).
- State: RemoteStore (ObservableObject) — the single on-device projection of host
  state; IdentityStore persists the device identity (Keychain) + pinned host.
- UI (UX_IOS): attention-first SessionsView, SessionDetailView (transcript/diff +
  status-driven action area / composer), ApprovalCardView with Face ID gate on
  high-risk approvals + allow-always menu, PairingScannerView (AVFoundation QR),
  SettingsView, connection chip. Same status glyphs/semantics as the Mac.

Add-iPhone QR display + server start live on the macOS side (follow-up); push /
Live Activity are M5 (needs the relay). gitignore keeps this .xcodeproj despite
the blanket *.xcodeproj rule.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-13 01:12:55 -07:00

103 lines
3.7 KiB
Swift

import SwiftUI
import AVFoundation
import NucleicProtocol
/// Camera QR scanner (UX_IOS §7). Decodes the Mac's `nucleic://pair?d=…` QR into a
/// `PairingPayload` and hands it back exactly once.
struct PairingScannerView: View {
let onScanned: (PairingPayload) -> Void
@Environment(\.dismiss) private var dismiss
@State private var error: String?
var body: some View {
NavigationStack {
ZStack {
QRScannerRepresentable { value in handle(value) }
.ignoresSafeArea()
VStack {
Spacer()
RoundedRectangle(cornerRadius: 16)
.stroke(.white.opacity(0.8), lineWidth: 3)
.frame(width: 240, height: 240)
Spacer()
if let error {
Text(error).foregroundStyle(.white)
.padding().background(.red.opacity(0.8), in: Capsule())
} else {
Text("Point at the QR code on your Mac")
.foregroundStyle(.white)
.padding().background(.black.opacity(0.5), in: Capsule())
}
Spacer().frame(height: 40)
}
}
.navigationTitle("Scan to pair")
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } }
}
}
private func handle(_ value: String) {
guard let payload = try? PairingPayload(qrString: value) else {
error = "That isn't a Nucleic pairing code"; return
}
onScanned(payload)
}
}
/// UIKit bridge for the AVCaptureSession metadata scan.
struct QRScannerRepresentable: UIViewControllerRepresentable {
let onFound: (String) -> Void
func makeUIViewController(context: Context) -> QRScannerController {
let controller = QRScannerController()
controller.onFound = onFound
return controller
}
func updateUIViewController(_ controller: QRScannerController, context: Context) {}
}
final class QRScannerController: UIViewController, AVCaptureMetadataOutputObjectsDelegate {
var onFound: ((String) -> Void)?
private let session = AVCaptureSession()
private var didFind = false
override func viewDidLoad() {
super.viewDidLoad()
guard let device = AVCaptureDevice.default(for: .video),
let input = try? AVCaptureDeviceInput(device: device),
session.canAddInput(input) else { return }
session.addInput(input)
let output = AVCaptureMetadataOutput()
guard session.canAddOutput(output) else { return }
session.addOutput(output)
output.setMetadataObjectsDelegate(self, queue: .main)
output.metadataObjectTypes = [.qr]
let preview = AVCaptureVideoPreviewLayer(session: session)
preview.videoGravity = .resizeAspectFill
preview.frame = view.layer.bounds
view.layer.addSublayer(preview)
Task.detached { [session] in session.startRunning() }
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
session.stopRunning()
}
func metadataOutput(
_ output: AVCaptureMetadataOutput,
didOutput metadataObjects: [AVMetadataObject],
from connection: AVCaptureConnection
) {
guard !didFind,
let object = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
let value = object.stringValue else { return }
didFind = true
onFound?(value)
}
}