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]>
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user