diff --git a/assistant/scripts/ipc/check-swift-decoder-drift.ts b/assistant/scripts/ipc/check-swift-decoder-drift.ts index 7be06afacc3..3695db078db 100644 --- a/assistant/scripts/ipc/check-swift-decoder-drift.ts +++ b/assistant/scripts/ipc/check-swift-decoder-drift.ts @@ -51,6 +51,8 @@ const SWIFT_OMIT_ALLOWLIST = new Set([ 'agent_heartbeat_alert', // Browser handoff — not yet consumed by the macOS client 'browser_handoff_request', + // Guardian verification — daemon-internal for Telegram channel setup + 'guardian_verification_response', // Work item messages — not yet consumed by the macOS client 'work_item_get_response', 'work_item_run_task_response', diff --git a/assistant/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap b/assistant/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap index 1d1bb94d162..9e33d0b4dd2 100644 --- a/assistant/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +++ b/assistant/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap @@ -594,6 +594,15 @@ exports[`IPC message snapshots ClientMessage types telegram_config serializes to } `; +exports[`IPC message snapshots ClientMessage types guardian_verification serializes to expected JSON 1`] = ` +{ + "action": "create_challenge", + "channel": "telegram", + "sessionId": "sess-001", + "type": "guardian_verification", +} +`; + exports[`IPC message snapshots ClientMessage types twitter_auth_start serializes to expected JSON 1`] = ` { "type": "twitter_auth_start", @@ -892,15 +901,6 @@ exports[`IPC message snapshots ClientMessage types tool_names_list serializes to } `; -exports[`IPC message snapshots ClientMessage types guardian_verification serializes to expected JSON 1`] = ` -{ - "action": "create_challenge", - "channel": "telegram", - "sessionId": "sess-001", - "type": "guardian_verification", -} -`; - exports[`IPC message snapshots ServerMessage types auth_result serializes to expected JSON 1`] = ` { "success": true, @@ -1920,6 +1920,15 @@ exports[`IPC message snapshots ServerMessage types telegram_config_response seri } `; +exports[`IPC message snapshots ServerMessage types guardian_verification_response serializes to expected JSON 1`] = ` +{ + "instruction": "Send this code to the Telegram bot", + "secret": "verify-secret-123", + "success": true, + "type": "guardian_verification_response", +} +`; + exports[`IPC message snapshots ServerMessage types twitter_auth_result serializes to expected JSON 1`] = ` { "accountInfo": "@vellum_test", @@ -2474,12 +2483,3 @@ exports[`IPC message snapshots ServerMessage types tool_names_list_response seri "type": "tool_names_list_response", } `; - -exports[`IPC message snapshots ServerMessage types guardian_verification_response serializes to expected JSON 1`] = ` -{ - "instruction": "Send this code to the bot", - "secret": "VERIFY-ABC123", - "success": true, - "type": "guardian_verification_response", -} -`; diff --git a/assistant/src/__tests__/channel-approval-routes.test.ts b/assistant/src/__tests__/channel-approval-routes.test.ts index 4fa6ae941fc..257081f3983 100644 --- a/assistant/src/__tests__/channel-approval-routes.test.ts +++ b/assistant/src/__tests__/channel-approval-routes.test.ts @@ -227,7 +227,7 @@ describe('inbound callback metadata triggers decision handling', () => { // First, send a normal message to establish the conversation. const initReq = makeInboundRequest({ content: 'init' }); const initRes = await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator); - const initBody = await initRes.json() as { conversationId?: string; eventId?: string; accepted?: boolean }; + const _initBody = await initRes.json() as { conversationId?: string; eventId?: string; accepted?: boolean }; // Now we need to find the actual conversationId that was created. // Check the channel_inbound_events table. @@ -722,7 +722,7 @@ describe('terminal state check before markProcessed', () => { }; // getRun always returns 'running' — the run never completes within the poll - const orchestrator = { + const _orchestrator = { submitDecision: mock(() => 'applied' as const), getRun: mock(() => ({ ...mockRun, status: 'running' as const })), startRun: mock(async () => mockRun), diff --git a/assistant/src/__tests__/channel-approval.test.ts b/assistant/src/__tests__/channel-approval.test.ts index 09c63663d48..c32b14a7520 100644 --- a/assistant/src/__tests__/channel-approval.test.ts +++ b/assistant/src/__tests__/channel-approval.test.ts @@ -48,8 +48,10 @@ afterAll(() => { // --------------------------------------------------------------------------- function ensureConversation(conversationId: string): void { + // eslint-disable-next-line @typescript-eslint/no-require-imports const { getDb } = require('../memory/db.js'); const db = getDb(); + // eslint-disable-next-line @typescript-eslint/no-require-imports const { conversations } = require('../memory/schema.js'); try { db.insert(conversations).values({ @@ -63,6 +65,7 @@ function ensureConversation(conversationId: string): void { } function resetTables(): void { + // eslint-disable-next-line @typescript-eslint/no-require-imports const { getDb } = require('../memory/db.js'); const db = getDb(); db.run('DELETE FROM message_runs'); @@ -255,8 +258,8 @@ describe('getPendingConfirmationsByConversation', () => { ensureConversation('conv-1'); const run1 = createRun('conv-1', 'msg-1'); - const run2 = createRun('conv-1', 'msg-2'); - const run3 = createRun('conv-1', 'msg-3'); + const _run2 = createRun('conv-1', 'msg-2'); + const _run3 = createRun('conv-1', 'msg-3'); setRunConfirmation(run1.id, sampleConfirmation); // run2 stays in 'running' state diff --git a/assistant/src/__tests__/ipc-snapshot.test.ts b/assistant/src/__tests__/ipc-snapshot.test.ts index c42671e82c1..c2e2fbb569f 100644 --- a/assistant/src/__tests__/ipc-snapshot.test.ts +++ b/assistant/src/__tests__/ipc-snapshot.test.ts @@ -378,6 +378,12 @@ const clientMessages: Record = { type: 'telegram_config', action: 'get', }, + guardian_verification: { + type: 'guardian_verification', + action: 'create_challenge', + channel: 'telegram', + sessionId: 'sess-001', + }, twitter_auth_start: { type: 'twitter_auth_start', }, @@ -554,12 +560,6 @@ const clientMessages: Record = { tool_names_list: { type: 'tool_names_list', }, - guardian_verification: { - type: 'guardian_verification', - action: 'create_challenge', - channel: 'telegram', - sessionId: 'sess-001', - }, }; // --------------------------------------------------------------------------- @@ -1218,6 +1218,12 @@ const serverMessages: Record = { connected: true, hasWebhookSecret: true, }, + guardian_verification_response: { + type: 'guardian_verification_response', + success: true, + secret: 'verify-secret-123', + instruction: 'Send this code to the Telegram bot', + }, twitter_auth_result: { type: 'twitter_auth_result', success: true, @@ -1592,12 +1598,6 @@ const serverMessages: Record = { type: 'tool_names_list_response', names: ['bash', 'file_read', 'file_write'], }, - guardian_verification_response: { - type: 'guardian_verification_response', - success: true, - secret: 'VERIFY-ABC123', - instruction: 'Send this code to the bot', - }, }; // --------------------------------------------------------------------------- diff --git a/assistant/src/config/vellum-skills/chatgpt-import/tools/chatgpt-import.ts b/assistant/src/config/vellum-skills/chatgpt-import/tools/chatgpt-import.ts index 5a697e4f6cf..fe4468e82dc 100644 --- a/assistant/src/config/vellum-skills/chatgpt-import/tools/chatgpt-import.ts +++ b/assistant/src/config/vellum-skills/chatgpt-import/tools/chatgpt-import.ts @@ -275,6 +275,7 @@ function extractLocalFile(buffer: Buffer, offset: number, cdCompressedSize: numb if (compressionMethod === 0) { return fileData.toString('utf-8'); } else if (compressionMethod === 8) { + // eslint-disable-next-line @typescript-eslint/no-require-imports const { inflateRawSync } = require('node:zlib') as typeof import('node:zlib'); return inflateRawSync(fileData).toString('utf-8'); } else { diff --git a/clients/ios/App/VellumAssistantApp.swift b/clients/ios/App/VellumAssistantApp.swift index 70cd2b46773..628fa79ed1f 100644 --- a/clients/ios/App/VellumAssistantApp.swift +++ b/clients/ios/App/VellumAssistantApp.swift @@ -24,6 +24,7 @@ struct VellumAssistantApp: App { .environmentObject(appDelegate.clientProvider) } else { OnboardingView(isCompleted: $onboardingCompleted, authManager: appDelegate.authManager) + .environmentObject(appDelegate.clientProvider) } } .preferredColorScheme(preferredScheme) diff --git a/clients/ios/Resources/Info.plist b/clients/ios/Resources/Info.plist index f18f6efcf7d..9639956708a 100644 --- a/clients/ios/Resources/Info.plist +++ b/clients/ios/Resources/Info.plist @@ -27,6 +27,8 @@ Vellum Assistant needs access to your microphone for voice input. NSSpeechRecognitionUsageDescription Vellum Assistant needs access to speech recognition to transcribe your voice input. + NSCameraUsageDescription + Vellum Assistant uses the camera to scan QR codes for pairing with your Mac. NSPhotoLibraryUsageDescription Select photos to attach to your message CFBundleURLTypes diff --git a/clients/ios/Views/OnboardingView.swift b/clients/ios/Views/OnboardingView.swift index f638ea2adcc..e6b86ae9e81 100644 --- a/clients/ios/Views/OnboardingView.swift +++ b/clients/ios/Views/OnboardingView.swift @@ -136,11 +136,15 @@ struct ChoosePathStep: View { struct DaemonSetupStep: View { var onContinue: (() -> Void)? - @State private var hostname = "localhost" + @State private var hostname = "" @State private var port = "8765" @State private var sessionToken = "" @State private var showingAlert = false @State private var alertMessage = "" + @State private var showingQRPairing = false + /// Tracks whether a token is configured (via QR or manual entry). + /// Re-checked on appear and after QR sheet dismissal. + @State private var hasConfiguredToken = false var body: some View { VStack(spacing: VSpacing.xl) { @@ -150,14 +154,45 @@ struct DaemonSetupStep: View { .font(VFont.title) .foregroundColor(VColor.textPrimary) - Text("Enter your Mac's address and the session token from the Vellum desktop app.") + Text("Scan the QR code from your Mac, or enter the connection details manually.") .font(VFont.body) .foregroundColor(VColor.textSecondary) .multilineTextAlignment(.center) .padding(.horizontal, VSpacing.xl) + // QR Scanner button + Button { + showingQRPairing = true + } label: { + HStack(spacing: VSpacing.md) { + Image(systemName: "qrcode.viewfinder") + .font(.system(size: 24)) + VStack(alignment: .leading, spacing: VSpacing.xxs) { + Text("Scan QR Code") + .font(VFont.bodyBold) + Text("Open Vellum on your Mac > Settings > Show QR Code") + .font(VFont.caption) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(VSpacing.lg) + .background(VColor.surface) + .cornerRadius(VRadius.md) + .overlay( + RoundedRectangle(cornerRadius: VRadius.md) + .stroke(VColor.surfaceBorder, lineWidth: 1) + ) + } + .foregroundColor(VColor.textPrimary) + .padding(.horizontal, VSpacing.xl) + + // Manual entry section VStack(spacing: VSpacing.lg) { - TextField("Hostname (e.g. localhost)", text: $hostname) + Text("Or enter manually:") + .font(VFont.caption) + .foregroundColor(VColor.textMuted) + + TextField("Hostname (e.g. 192.168.1.100)", text: $hostname) .textFieldStyle(.roundedBorder) .autocapitalization(.none) .autocorrectionDisabled() @@ -166,15 +201,10 @@ struct DaemonSetupStep: View { .textFieldStyle(.roundedBorder) .keyboardType(.numberPad) - SecureField("Session token (from ~/.vellum/session-token)", text: $sessionToken) + SecureField("Session token", text: $sessionToken) .textFieldStyle(.roundedBorder) .autocapitalization(.none) .autocorrectionDisabled() - - Text("Find the token at ~/.vellum/session-token on your Mac, or in Mac app Settings.") - .font(VFont.caption) - .foregroundColor(VColor.textMuted) - .multilineTextAlignment(.center) } .padding(.horizontal, VSpacing.xl) @@ -184,20 +214,20 @@ struct DaemonSetupStep: View { showingAlert = true return } - UserDefaults.standard.set(hostname, forKey: UserDefaultsKeys.daemonHostname) + if !hostname.isEmpty { + UserDefaults.standard.set(hostname, forKey: UserDefaultsKeys.daemonHostname) + } UserDefaults.standard.set(portInt, forKey: UserDefaultsKeys.daemonPort) // iOS always uses TLS for TCP connections UserDefaults.standard.set(true, forKey: UserDefaultsKeys.daemonTLSEnabled) - if sessionToken.isEmpty { - _ = APIKeyManager.shared.deleteAPIKey(provider: "daemon-token") - UserDefaults.standard.removeObject(forKey: UserDefaultsKeys.legacyDaemonToken) - } else { + if !sessionToken.isEmpty { _ = APIKeyManager.shared.setAPIKey(sessionToken, provider: "daemon-token") } onContinue?() } .buttonStyle(.borderedProminent) - .disabled(hostname.isEmpty || port.isEmpty || sessionToken.isEmpty) + // Enable if either QR pairing configured a token, or manual fields are filled + .disabled(!hasConfiguredToken && (hostname.isEmpty || port.isEmpty || sessionToken.isEmpty)) Button("Skip for now") { onContinue?() @@ -212,13 +242,34 @@ struct DaemonSetupStep: View { } message: { Text(alertMessage) } + .sheet(isPresented: $showingQRPairing, onDismiss: { + reloadSettings() + }) { + QRPairingSheet() + } .onAppear { - hostname = UserDefaults.standard.string(forKey: UserDefaultsKeys.daemonHostname) ?? "localhost" - let portValue = UserDefaults.standard.integer(forKey: UserDefaultsKeys.daemonPort) - port = portValue > 0 ? String(portValue) : "8765" - sessionToken = APIKeyManager.shared.getAPIKey(provider: "daemon-token") ?? "" + reloadSettings() } } + + private func reloadSettings() { + let storedHostname = UserDefaults.standard.string(forKey: UserDefaultsKeys.daemonHostname) ?? "" + if !storedHostname.isEmpty && storedHostname != "localhost" { + hostname = storedHostname + } + let portValue = UserDefaults.standard.integer(forKey: UserDefaultsKeys.daemonPort) + port = portValue > 0 ? String(portValue) : "8765" + sessionToken = APIKeyManager.shared.getAPIKey(provider: "daemon-token") ?? "" + // Check if any token is configured (bare key or host-specific) + hasConfiguredToken = !sessionToken.isEmpty || hasHostSpecificToken() + } + + private func hasHostSpecificToken() -> Bool { + let h = UserDefaults.standard.string(forKey: UserDefaultsKeys.daemonHostname) ?? "" + let p = UserDefaults.standard.integer(forKey: UserDefaultsKeys.daemonPort) + guard !h.isEmpty, p > 0 else { return false } + return APIKeyManager.shared.getAPIKey(provider: "daemon-token:\(h):\(p)") != nil + } } // MARK: - PermissionsStep diff --git a/clients/ios/Views/QRScannerView.swift b/clients/ios/Views/QRScannerView.swift new file mode 100644 index 00000000000..8db7b02087d --- /dev/null +++ b/clients/ios/Views/QRScannerView.swift @@ -0,0 +1,140 @@ +#if canImport(UIKit) +import AVFoundation +import SwiftUI +import UIKit + +/// AVFoundation QR code scanner wrapped as a SwiftUI view. +/// Provides haptic feedback on scan and auto-stops after the first read. +struct QRScannerView: UIViewControllerRepresentable { + let onCodeScanned: (String) -> Void + + func makeUIViewController(context: Context) -> QRScannerViewController { + let vc = QRScannerViewController() + vc.onCodeScanned = onCodeScanned + return vc + } + + func updateUIViewController(_ uiViewController: QRScannerViewController, context: Context) {} +} + +class QRScannerViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate { + var onCodeScanned: ((String) -> Void)? + private var captureSession: AVCaptureSession? + private var previewLayer: AVCaptureVideoPreviewLayer? + private var hasScanned = false + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .black + setupCamera() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + previewLayer?.frame = view.bounds + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + stopScanning() + } + + private func setupCamera() { + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .authorized: + configureSession() + case .notDetermined: + AVCaptureDevice.requestAccess(for: .video) { granted in + DispatchQueue.main.async { + if granted { + self.configureSession() + } else { + self.showPermissionDeniedOverlay() + } + } + } + case .denied, .restricted: + showPermissionDeniedOverlay() + @unknown default: + showPermissionDeniedOverlay() + } + } + + private func configureSession() { + let session = AVCaptureSession() + + guard let device = AVCaptureDevice.default(for: .video), + let input = try? AVCaptureDeviceInput(device: device) else { + showPermissionDeniedOverlay() + return + } + + guard 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.bounds + view.layer.addSublayer(preview) + previewLayer = preview + + captureSession = session + + DispatchQueue.global(qos: .userInitiated).async { + session.startRunning() + } + } + + private func showPermissionDeniedOverlay() { + let label = UILabel() + label.text = "Camera access is required to scan QR codes.\n\nGo to Settings > Vellum Assistant > Camera to enable it." + label.textColor = .white + label.textAlignment = .center + label.numberOfLines = 0 + label.font = .systemFont(ofSize: 16) + label.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(label) + + NSLayoutConstraint.activate([ + label.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 32), + label.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -32), + label.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) + } + + private func stopScanning() { + captureSession?.stopRunning() + } + + // MARK: - AVCaptureMetadataOutputObjectsDelegate + + func metadataOutput( + _ output: AVCaptureMetadataOutput, + didOutput metadataObjects: [AVMetadataObject], + from connection: AVCaptureConnection + ) { + guard !hasScanned, + let object = metadataObjects.first as? AVMetadataMachineReadableCodeObject, + object.type == .qr, + let value = object.stringValue else { + return + } + + hasScanned = true + stopScanning() + + // Haptic feedback on successful scan + let generator = UINotificationFeedbackGenerator() + generator.notificationOccurred(.success) + + onCodeScanned?(value) + } +} +#endif diff --git a/clients/ios/Views/Settings/ConnectionSettingsSection.swift b/clients/ios/Views/Settings/ConnectionSettingsSection.swift index 63b31829add..7e5d15935ce 100644 --- a/clients/ios/Views/Settings/ConnectionSettingsSection.swift +++ b/clients/ios/Views/Settings/ConnectionSettingsSection.swift @@ -52,13 +52,23 @@ struct DaemonConnectionSection: View { @State private var sessionToken: String = "" @State private var showingAlert = false @State private var alertMessage = "" + @State private var showingQRPairing = false var body: some View { Section("Mac Daemon") { + Button { + showingQRPairing = true + } label: { + HStack { + Image(systemName: "qrcode.viewfinder") + Text("Scan QR Code") + } + } + HStack { Text("Hostname") Spacer() - TextField("localhost", text: $daemonHostname) + TextField("e.g. 192.168.1.100", text: $daemonHostname) .multilineTextAlignment(.trailing) .autocorrectionDisabled() .textInputAutocapitalization(.never) @@ -78,7 +88,7 @@ struct DaemonConnectionSection: View { .autocorrectionDisabled() .textInputAutocapitalization(.never) } - Text("Copy this from ~/.vellum/session-token on your Mac, or from Mac app → Settings.") + Text("Or scan the QR code from Mac app > Settings > Show QR Code.") .font(.caption) .foregroundStyle(.secondary) @@ -115,12 +125,22 @@ struct DaemonConnectionSection: View { } message: { Text(alertMessage) } + .sheet(isPresented: $showingQRPairing, onDismiss: { + // Re-read settings after QR pairing in case they changed + reloadSettings() + }) { + QRPairingSheet() + } .onAppear { - daemonHostname = UserDefaults.standard.string(forKey: UserDefaultsKeys.daemonHostname) ?? "localhost" - let portValue = UserDefaults.standard.integer(forKey: UserDefaultsKeys.daemonPort) - daemonPort = portValue > 0 ? String(portValue) : "8765" - sessionToken = APIKeyManager.shared.getAPIKey(provider: "daemon-token") ?? "" + reloadSettings() } } + + private func reloadSettings() { + daemonHostname = UserDefaults.standard.string(forKey: UserDefaultsKeys.daemonHostname) ?? "" + let portValue = UserDefaults.standard.integer(forKey: UserDefaultsKeys.daemonPort) + daemonPort = portValue > 0 ? String(portValue) : "8765" + sessionToken = APIKeyManager.shared.getAPIKey(provider: "daemon-token") ?? "" + } } #endif diff --git a/clients/ios/Views/Settings/QRPairingSheet.swift b/clients/ios/Views/Settings/QRPairingSheet.swift new file mode 100644 index 00000000000..828463524ca --- /dev/null +++ b/clients/ios/Views/Settings/QRPairingSheet.swift @@ -0,0 +1,348 @@ +#if canImport(UIKit) +import SwiftUI +import VellumAssistantShared + +/// QR pairing sheet: scan QR code → parse → confirm → save config → connect. +struct QRPairingSheet: View { + @EnvironmentObject var clientProvider: ClientProvider + @Environment(\.dismiss) var dismiss + + @State private var phase: PairingPhase = .scanning + @State private var scannedPayload: DaemonQRPayload? + @State private var errorMessage: String? + + enum PairingPhase { + case scanning + case confirming + case connecting + case connected + case error + } + + var body: some View { + NavigationStack { + VStack(spacing: VSpacing.xl) { + switch phase { + case .scanning: + scanningView + case .confirming: + confirmingView + case .connecting: + connectingView + case .connected: + connectedView + case .error: + errorView + } + } + .navigationTitle("Pair with Mac") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + } + } + } + + // MARK: - Scanning + + private var scanningView: some View { + VStack(spacing: VSpacing.lg) { + Text("Point your camera at the QR code shown in Vellum on your Mac.") + .font(VFont.body) + .foregroundColor(VColor.textSecondary) + .multilineTextAlignment(.center) + .padding(.horizontal, VSpacing.xl) + + QRScannerView { code in + handleScannedCode(code) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .cornerRadius(VRadius.md) + .padding(.horizontal, VSpacing.lg) + + Text("Open Vellum on your Mac > Settings > Show QR Code") + .font(VFont.caption) + .foregroundColor(VColor.textMuted) + .multilineTextAlignment(.center) + .padding(.horizontal, VSpacing.xl) + } + .padding(.vertical, VSpacing.lg) + } + + // MARK: - Confirming + + private var confirmingView: some View { + VStack(spacing: VSpacing.xl) { + Spacer() + + Image(systemName: "checkmark.circle") + .font(.system(size: 48)) + .foregroundColor(VColor.accent) + + Text("QR Code Scanned") + .font(VFont.title) + .foregroundColor(VColor.textPrimary) + + if let payload = scannedPayload { + VStack(alignment: .leading, spacing: VSpacing.sm) { + infoRow(label: "Mac IP", value: payload.host) + infoRow(label: "Port", value: "\(payload.port)") + infoRow(label: "TLS", value: "Enabled") + } + .padding(VSpacing.lg) + .background(VColor.surface) + .cornerRadius(VRadius.md) + .padding(.horizontal, VSpacing.xl) + } + + Spacer() + + Button("Connect") { + connectToMac() + } + .buttonStyle(.borderedProminent) + .padding(.bottom, VSpacing.xxl) + } + } + + // MARK: - Connecting + + private var connectingView: some View { + VStack(spacing: VSpacing.lg) { + Spacer() + ProgressView() + .controlSize(.large) + Text("Connecting to Mac...") + .font(VFont.body) + .foregroundColor(VColor.textSecondary) + Spacer() + } + } + + // MARK: - Connected + + private var connectedView: some View { + VStack(spacing: VSpacing.xl) { + Spacer() + + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 64)) + .foregroundColor(VColor.success) + + Text("Connected!") + .font(VFont.title) + .foregroundColor(VColor.textPrimary) + + Text("Your iPhone is now connected to your Mac.") + .font(VFont.body) + .foregroundColor(VColor.textSecondary) + .multilineTextAlignment(.center) + + Spacer() + + Button("Done") { + dismiss() + } + .buttonStyle(.borderedProminent) + .padding(.bottom, VSpacing.xxl) + } + } + + // MARK: - Error + + private var errorView: some View { + VStack(spacing: VSpacing.xl) { + Spacer() + + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 48)) + .foregroundColor(VColor.error) + + Text("Connection Failed") + .font(VFont.title) + .foregroundColor(VColor.textPrimary) + + if let message = errorMessage { + Text(message) + .font(VFont.body) + .foregroundColor(VColor.textSecondary) + .multilineTextAlignment(.center) + .padding(.horizontal, VSpacing.xl) + } + + Spacer() + + VStack(spacing: VSpacing.md) { + Button("Try Again") { + phase = .scanning + scannedPayload = nil + errorMessage = nil + } + .buttonStyle(.borderedProminent) + + Button("Cancel") { + dismiss() + } + .foregroundColor(VColor.textSecondary) + } + .padding(.bottom, VSpacing.xxl) + } + } + + // MARK: - Logic + + private func handleScannedCode(_ code: String) { + guard let data = code.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + errorMessage = "Invalid QR code. Please scan the QR code from the Vellum Mac app." + phase = .error + return + } + + guard json["type"] as? String == "vellum-daemon" else { + errorMessage = "This QR code is not from Vellum. Open Vellum on your Mac and scan the QR code from Settings." + phase = .error + return + } + + guard let version = json["v"] as? Int, version == 1 else { + errorMessage = "QR code version not supported. Please update the Vellum app on your Mac." + phase = .error + return + } + + guard let host = json["h"] as? String, + let port = json["p"] as? Int, + let token = json["t"] as? String, + let fingerprint = json["f"] as? String else { + errorMessage = "QR code is missing required fields. Please regenerate the QR code on your Mac." + phase = .error + return + } + + guard port >= 1 && port <= 65535 else { + errorMessage = "QR code contains an invalid port number (\(port)). Please regenerate the QR code on your Mac." + phase = .error + return + } + + let hostId = json["id"] as? String ?? "" + + scannedPayload = DaemonQRPayload( + host: host, + port: port, + token: token, + fingerprint: fingerprint, + hostId: hostId + ) + phase = .confirming + } + + private func connectToMac() { + guard let payload = scannedPayload else { return } + phase = .connecting + + // Check if this hostId matches a previously-stored host with a different IP. + // If so, update the stored hostname (same Mac, new IP). + detectAndMigrateHost(payload: payload) + + // Save connection config + let hostname = payload.host + let port = payload.port + + UserDefaults.standard.set(hostname, forKey: UserDefaultsKeys.daemonHostname) + UserDefaults.standard.set(port, forKey: UserDefaultsKeys.daemonPort) + UserDefaults.standard.set(true, forKey: UserDefaultsKeys.daemonTLSEnabled) + + // Store token with host-specific key + let hostSpecificProvider = "daemon-token:\(hostname):\(port)" + _ = APIKeyManager.shared.setAPIKey(payload.token, provider: hostSpecificProvider) + // Also store as bare key for backwards compatibility + _ = APIKeyManager.shared.setAPIKey(payload.token, provider: "daemon-token") + + // Store fingerprint and hostId per host:port + let fpKey = UserDefaultsKeys.daemonCertFingerprint(host: hostname, port: UInt16(port)) + UserDefaults.standard.set(payload.fingerprint, forKey: fpKey) + + if !payload.hostId.isEmpty { + let idKey = UserDefaultsKeys.daemonHostId(host: hostname, port: UInt16(port)) + UserDefaults.standard.set(payload.hostId, forKey: idKey) + } + + // Rebuild the client so the new TCP config takes effect + clientProvider.rebuildClient() + + // Connect + Task { + do { + try await clientProvider.client.connect() + await MainActor.run { + phase = .connected + } + } catch { + await MainActor.run { + let nsError = error as NSError + if nsError.domain == "NWError" { + if nsError.localizedDescription.contains("TLS") || nsError.localizedDescription.contains("SSL") { + errorMessage = "TLS handshake failed. The certificate may have changed — try regenerating the QR code on your Mac." + } else if nsError.localizedDescription.contains("refused") { + errorMessage = "Connection refused. Make sure the Vellum daemon is running on your Mac and iOS pairing is enabled." + } else { + errorMessage = "Connection failed: \(error.localizedDescription)" + } + } else { + errorMessage = "Connection failed: \(error.localizedDescription)" + } + phase = .error + } + } + } + } + + /// If the scanned hostId matches a stored hostId for a different hostname, + /// this is the same Mac with a new IP. Clean up old Keychain entries. + private func detectAndMigrateHost(payload: DaemonQRPayload) { + guard !payload.hostId.isEmpty else { return } + + let currentHostname = UserDefaults.standard.string(forKey: UserDefaultsKeys.daemonHostname) ?? "" + let currentPort = UserDefaults.standard.integer(forKey: UserDefaultsKeys.daemonPort) + guard currentPort > 0 else { return } + + let oldIdKey = UserDefaultsKeys.daemonHostId(host: currentHostname, port: UInt16(currentPort)) + let storedHostId = UserDefaults.standard.string(forKey: oldIdKey) + + // Same Mac (matching hostId) but different IP — clean up old keys + if storedHostId == payload.hostId && currentHostname != payload.host { + let oldTokenProvider = "daemon-token:\(currentHostname):\(currentPort)" + _ = APIKeyManager.shared.deleteAPIKey(provider: oldTokenProvider) + let oldFpKey = UserDefaultsKeys.daemonCertFingerprint(host: currentHostname, port: UInt16(currentPort)) + UserDefaults.standard.removeObject(forKey: oldFpKey) + UserDefaults.standard.removeObject(forKey: oldIdKey) + } + } + + private func infoRow(label: String, value: String) -> some View { + HStack { + Text(label) + .font(VFont.caption) + .foregroundColor(VColor.textMuted) + .frame(width: 60, alignment: .leading) + Text(value) + .font(VFont.mono) + .foregroundColor(VColor.textPrimary) + Spacer() + } + } +} + +/// Parsed QR code payload from the macOS pairing QR code. +struct DaemonQRPayload { + let host: String + let port: Int + let token: String + let fingerprint: String + let hostId: String +} +#endif diff --git a/clients/ios/vellum-assistant-ios.xcodeproj/project.pbxproj b/clients/ios/vellum-assistant-ios.xcodeproj/project.pbxproj index 525f27e1627..40ffdf9101c 100644 --- a/clients/ios/vellum-assistant-ios.xcodeproj/project.pbxproj +++ b/clients/ios/vellum-assistant-ios.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ 31D0C2CC725EE57E9F593A72 /* ChatTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A38AEC9FF8D2DC8C62800C /* ChatTabView.swift */; }; 5412498DD7C6C6B4CF7477CE /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24A722220881E67C353DFE12 /* AppDelegate.swift */; }; 5923262CDB1AEBE0C2118808 /* IOSThreadStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06CFCCFEF68D99FFDAC92204 /* IOSThreadStore.swift */; }; + 59457CBF1DEB60A8C299B7DA /* QRScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89D67321AD99D3E3C4399DF0 /* QRScannerView.swift */; }; 5C6DE39F8210A78D484071A4 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43C31D43ED8346DA9D2FC500 /* SceneDelegate.swift */; }; 6419DA2F54DB6F703BD1DF3B /* VellumAssistantApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 736A766CE9C9D8326A1EBDA5 /* VellumAssistantApp.swift */; }; 70294FBE9E2A2B4F05A54BF0 /* ThreadLifecycleIOSTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB54EB0319A569FD1CF4B13B /* ThreadLifecycleIOSTests.swift */; }; @@ -41,6 +42,7 @@ CDE68996EC77DAA45CF933A2 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F835A2D424DED22906123993 /* ContentView.swift */; }; E82000F70C38CD07957C2F0F /* PermissionRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3099C4A4B1BEA0FFD8F46692 /* PermissionRowView.swift */; }; EAC778C46C68008399873E3E /* ChatTranscriptFormatterIOSTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDED15E4C12EADF58BD0CA16 /* ChatTranscriptFormatterIOSTests.swift */; }; + EC4B5B7BB9057BCDF63474B4 /* QRPairingSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75ADAEA6F825C35354C0A2E6 /* QRPairingSheet.swift */; }; F6E66C2F942C26A210459312 /* IntegrationsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8029FFBAC7A74E1EADB0F0B8 /* IntegrationsSection.swift */; }; /* End PBXBuildFile section */ @@ -58,10 +60,12 @@ 51A38AEC9FF8D2DC8C62800C /* ChatTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatTabView.swift; sourceTree = ""; }; 6469D4E238ADC7A9CCCD3F0E /* IdentityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityView.swift; sourceTree = ""; }; 736A766CE9C9D8326A1EBDA5 /* VellumAssistantApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VellumAssistantApp.swift; sourceTree = ""; }; + 75ADAEA6F825C35354C0A2E6 /* QRPairingSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRPairingSheet.swift; sourceTree = ""; }; 7A76C5B0E7B697B57063BC75 /* clients */ = {isa = PBXFileReference; lastKnownFileType = folder; name = clients; path = ..; sourceTree = SOURCE_ROOT; }; 7BFF3D4B4A9D5E1C99AD1ADB /* TitleGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TitleGenerator.swift; sourceTree = ""; }; 8029FFBAC7A74E1EADB0F0B8 /* IntegrationsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationsSection.swift; sourceTree = ""; }; 8303637A9FF05FBAB7BFB714 /* vellum-assistant-ios.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = "vellum-assistant-ios.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 89D67321AD99D3E3C4399DF0 /* QRScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRScannerView.swift; sourceTree = ""; }; 907F415E8AE00310C58BD65D /* TrustRulesSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrustRulesSection.swift; sourceTree = ""; }; 97E0B2314721CE2B9E778D16 /* ThreadListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadListView.swift; sourceTree = ""; }; AE61E2C6D294D559BDDB2003 /* ConnectionSettingsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionSettingsSection.swift; sourceTree = ""; }; @@ -125,6 +129,7 @@ EFA3496A6F3D741B4690BB86 /* LoginView.swift */, DA6DA4FDE9AA57C723CC06AA /* MessageMediaEmbedsView.swift */, EEAAA98AE5EB03645C49AA68 /* OnboardingView.swift */, + 89D67321AD99D3E3C4399DF0 /* QRScannerView.swift */, BA17A2DA662CD75C73E4726C /* SettingsView.swift */, 97E0B2314721CE2B9E778D16 /* ThreadListView.swift */, 505C277A9ED9027CA064DB10 /* WorkspaceFileSheet.swift */, @@ -194,6 +199,7 @@ AE61E2C6D294D559BDDB2003 /* ConnectionSettingsSection.swift */, 8029FFBAC7A74E1EADB0F0B8 /* IntegrationsSection.swift */, 3099C4A4B1BEA0FFD8F46692 /* PermissionRowView.swift */, + 75ADAEA6F825C35354C0A2E6 /* QRPairingSheet.swift */, 3483675C469B0B8354AC5D8D /* RemindersSection.swift */, 2CA6B4E578A6F678E49599B9 /* SchedulesSection.swift */, 907F415E8AE00310C58BD65D /* TrustRulesSection.swift */, @@ -313,6 +319,8 @@ 831E2260278404A3AA07ECCB /* MessageMediaEmbedsView.swift in Sources */, B61091E975453A5D9A073D5B /* OnboardingView.swift in Sources */, E82000F70C38CD07957C2F0F /* PermissionRowView.swift in Sources */, + EC4B5B7BB9057BCDF63474B4 /* QRPairingSheet.swift in Sources */, + 59457CBF1DEB60A8C299B7DA /* QRScannerView.swift in Sources */, C4305A05FD834E67C287D60B /* RemindersSection.swift in Sources */, 5C6DE39F8210A78D484071A4 /* SceneDelegate.swift in Sources */, 786CB3EF2E6BD4A20821168F /* SchedulesSection.swift in Sources */, diff --git a/clients/macos/vellum-assistant/Features/Settings/PairingQRCodeSheet.swift b/clients/macos/vellum-assistant/Features/Settings/PairingQRCodeSheet.swift new file mode 100644 index 00000000000..af5414ea4c8 --- /dev/null +++ b/clients/macos/vellum-assistant/Features/Settings/PairingQRCodeSheet.swift @@ -0,0 +1,131 @@ +import CryptoKit +import SwiftUI +import VellumAssistantShared + +/// Displays a QR code containing the connection payload for iOS pairing. +/// Payload format: `{"type":"vellum-daemon","h":"","p":8765,"t":"","f":"","id":"","v":1}` +@MainActor +struct PairingQRCodeSheet: View { + @Environment(\.dismiss) var dismiss + + let sessionToken: String + let fingerprint: String + let tcpPort: Int + + @State private var localIP: String = "..." + @State private var hostId: String = "" + + var body: some View { + VStack(spacing: VSpacing.lg) { + HStack { + Text("Pair iOS Device") + .font(VFont.sectionTitle) + .foregroundColor(VColor.textPrimary) + Spacer() + Button("Done") { dismiss() } + } + + if let qrImage = generateQRImage() { + Image(nsImage: qrImage) + .resizable() + .interpolation(.none) + .scaledToFit() + .frame(width: 220, height: 220) + .padding(VSpacing.md) + .background(Color.white) + .cornerRadius(VRadius.md) + } else { + Text("Failed to generate QR code") + .font(VFont.body) + .foregroundColor(VColor.error) + .frame(width: 220, height: 220) + } + + Text("Scan this QR code with the Vellum iOS app to connect.") + .font(VFont.body) + .foregroundColor(VColor.textSecondary) + .multilineTextAlignment(.center) + + VStack(alignment: .leading, spacing: VSpacing.sm) { + infoRow(label: "IP Address", value: localIP) + infoRow(label: "Port", value: "\(tcpPort)") + infoRow(label: "TLS", value: "Enabled") + } + .padding(VSpacing.md) + .background(VColor.surfaceSubtle) + .cornerRadius(VRadius.md) + + Text("Pairing persists until you regenerate the session token.") + .font(VFont.caption) + .foregroundColor(VColor.textMuted) + .multilineTextAlignment(.center) + } + .padding(VSpacing.xl) + .frame(width: 340) + .onAppear { + localIP = NetworkInterfaceResolver.getLocalIPv4() ?? "unknown" + hostId = Self.computeHostId() + } + } + + private func infoRow(label: String, value: String) -> some View { + HStack { + Text(label) + .font(VFont.caption) + .foregroundColor(VColor.textMuted) + .frame(width: 80, alignment: .leading) + Text(value) + .font(VFont.mono) + .foregroundColor(VColor.textPrimary) + Spacer() + } + } + + private func generateQRImage() -> NSImage? { + guard localIP != "..." && localIP != "unknown" else { return nil } + + let payload: [String: Any] = [ + "type": "vellum-daemon", + "h": localIP, + "p": tcpPort, + "t": sessionToken, + "f": fingerprint, + "id": hostId, + "v": 1, + ] + + guard let jsonData = try? JSONSerialization.data(withJSONObject: payload), + let jsonString = String(data: jsonData, encoding: .utf8) else { + return nil + } + + return QRCodeGenerator.generate(from: jsonString, size: 220) + } + + /// Compute a stable, privacy-safe host identifier. + /// SHA-256 of the IOPlatformUUID + an app-specific salt. + static func computeHostId() -> String { + let platformUUID = getPlatformUUID() ?? UUID().uuidString + let salt = "vellum-assistant-host-id" + let input = Data((platformUUID + salt).utf8) + let hash = SHA256.hash(data: input) + return hash.compactMap { String(format: "%02x", $0) }.joined() + } + + /// Read the IOPlatformUUID from the IORegistry (macOS hardware identifier). + private static func getPlatformUUID() -> String? { + let service = IOServiceGetMatchingService( + kIOMasterPortDefault, + IOServiceMatching("IOPlatformExpertDevice") + ) + guard service != 0 else { return nil } + defer { IOObjectRelease(service) } + + let key = kIOPlatformUUIDKey as CFString + guard let uuid = IORegistryEntryCreateCFProperty(service, key, kCFAllocatorDefault, 0)? + .takeRetainedValue() as? String else { + return nil + } + return uuid + } +} diff --git a/clients/macos/vellum-assistant/Features/Settings/SettingsAdvancedTab.swift b/clients/macos/vellum-assistant/Features/Settings/SettingsAdvancedTab.swift index 0214500d3c4..87fa88cadac 100644 --- a/clients/macos/vellum-assistant/Features/Settings/SettingsAdvancedTab.swift +++ b/clients/macos/vellum-assistant/Features/Settings/SettingsAdvancedTab.swift @@ -1,3 +1,4 @@ +import CryptoKit import SwiftUI import VellumAssistantShared @@ -12,6 +13,11 @@ struct SettingsAdvancedTab: View { @State private var sessionToken: String = "" @State private var tokenCopied: Bool = false + @State private var fingerprint: String = "" + @State private var iosPairingEnabled: Bool = false + @State private var showingPairingQR: Bool = false + @State private var showingPairingWarning: Bool = false + @State private var showingRegenerateConfirmation: Bool = false @State private var showingRetireConfirmation: Bool = false @State private var isRetiring: Bool = false @State private var lockfileAssistants: [LockfileAssistant] = [] @@ -42,6 +48,10 @@ struct SettingsAdvancedTab: View { .onAppear { let tokenPath = NSHomeDirectory() + "/.vellum/session-token" sessionToken = (try? String(contentsOfFile: tokenPath, encoding: .utf8))?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let fingerprintPath = NSHomeDirectory() + "/.vellum/tls/fingerprint" + fingerprint = (try? String(contentsOfFile: fingerprintPath, encoding: .utf8))?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let flagPath = NSHomeDirectory() + "/.vellum/ios-pairing-enabled" + iosPairingEnabled = FileManager.default.fileExists(atPath: flagPath) lockfileAssistants = LockfileAssistant.loadAll() selectedAssistantId = UserDefaults.standard.string(forKey: "connectedAssistantId") ?? "" identity = IdentityInfo.load() @@ -262,42 +272,132 @@ struct SettingsAdvancedTab: View { .font(VFont.sectionTitle) .foregroundColor(VColor.textPrimary) - VStack(alignment: .leading, spacing: VSpacing.sm) { - Text("Session Token") - .font(VFont.bodyMedium) - .foregroundColor(VColor.textPrimary) - Text("Paste this into the Vellum iOS app to connect it to this Mac.") - .font(VFont.caption) - .foregroundColor(VColor.textSecondary) + // Pairing toggle + HStack { + VStack(alignment: .leading, spacing: VSpacing.xs) { + Text("Enable iOS Pairing") + .font(VFont.bodyMedium) + .foregroundColor(VColor.textPrimary) + Text("Allow iPhone connections over your local network (TLS encrypted).") + .font(VFont.caption) + .foregroundColor(VColor.textSecondary) + } + Spacer() + Toggle("", isOn: $iosPairingEnabled) + .toggleStyle(.switch) + .labelsHidden() + .onChange(of: iosPairingEnabled) { _, enabled in + if enabled { + // Show one-time warning on first enable + if !UserDefaults.standard.bool(forKey: "ios_pairing_warning_shown") { + showingPairingWarning = true + } else { + setIOSPairingEnabled(true) + } + } else { + setIOSPairingEnabled(false) + } + } + } - HStack(spacing: VSpacing.sm) { - if sessionToken.isEmpty { - Text("Token not found") - .font(VFont.mono) - .foregroundColor(VColor.textMuted) - .frame(maxWidth: .infinity, alignment: .leading) - } else { - Text(String(sessionToken.prefix(16)) + "...") - .font(VFont.mono) - .foregroundColor(VColor.textSecondary) - .frame(maxWidth: .infinity, alignment: .leading) + // QR Code + Token display + if iosPairingEnabled { + VStack(alignment: .leading, spacing: VSpacing.sm) { + HStack(spacing: VSpacing.sm) { + VButton(label: "Show QR Code", style: .primary) { + showingPairingQR = true + } + .disabled(sessionToken.isEmpty || fingerprint.isEmpty) + + Spacer() + + Button(tokenCopied ? "Copied!" : "Copy Token") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(sessionToken, forType: .string) + tokenCopied = true + Task { + try? await Task.sleep(nanoseconds: 2_000_000_000) + tokenCopied = false + } + } + .disabled(sessionToken.isEmpty) } - Button(tokenCopied ? "Copied!" : "Copy") { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(sessionToken, forType: .string) - tokenCopied = true - Task { - try? await Task.sleep(nanoseconds: 2_000_000_000) - tokenCopied = false + if !sessionToken.isEmpty { + HStack(spacing: VSpacing.xs) { + Text("Token:") + .font(VFont.caption) + .foregroundColor(VColor.textMuted) + Text(String(sessionToken.prefix(16)) + "...") + .font(VFont.mono) + .foregroundColor(VColor.textSecondary) } + } else { + Text("Session token not found. Restart the daemon to generate one.") + .font(VFont.caption) + .foregroundColor(VColor.textMuted) } - .disabled(sessionToken.isEmpty) + + Button("Regenerate Token") { + showingRegenerateConfirmation = true + } + .font(VFont.caption) + .foregroundColor(VColor.accent) } } - .padding(VSpacing.lg) - .vCard(background: VColor.surfaceSubtle) } + .padding(VSpacing.lg) + .vCard(background: VColor.surfaceSubtle) + .alert("Enable iOS Pairing", isPresented: $showingPairingWarning) { + Button("Cancel", role: .cancel) { + iosPairingEnabled = false + } + Button("Enable") { + UserDefaults.standard.set(true, forKey: "ios_pairing_warning_shown") + setIOSPairingEnabled(true) + } + } message: { + Text("Your assistant will be reachable on your local network. Only devices with the session token can connect. TLS encryption is always enabled.") + } + .alert("Regenerate Session Token", isPresented: $showingRegenerateConfirmation) { + Button("Cancel", role: .cancel) {} + Button("Regenerate", role: .destructive) { + regenerateSessionToken() + } + } message: { + Text("This will delete the current token. A new token will be generated on the next daemon restart. Any paired iOS devices will need to re-scan the QR code.\n\nRestart the daemon after regenerating to apply the change.") + } + .sheet(isPresented: $showingPairingQR) { + PairingQRCodeSheet( + sessionToken: sessionToken, + fingerprint: fingerprint, + tcpPort: getTCPPort() + ) + } + } + + private func setIOSPairingEnabled(_ enabled: Bool) { + let flagPath = NSHomeDirectory() + "/.vellum/ios-pairing-enabled" + if enabled { + FileManager.default.createFile(atPath: flagPath, contents: nil) + } else { + try? FileManager.default.removeItem(atPath: flagPath) + } + } + + private func regenerateSessionToken() { + let tokenPath = NSHomeDirectory() + "/.vellum/session-token" + try? FileManager.default.removeItem(atPath: tokenPath) + sessionToken = "" + // The daemon will generate a new token on next start. + // For now, just clear the UI. A daemon restart is needed. + } + + private func getTCPPort() -> Int { + // Read TCP port from daemon config or use default + let envPort = ProcessInfo.processInfo.environment["VELLUM_DAEMON_TCP_PORT"] + if let envPort, let port = Int(envPort) { return port } + return 8765 } // MARK: - Switch Assistant diff --git a/clients/shared/IPC/DaemonConnection.swift b/clients/shared/IPC/DaemonConnection.swift index 816d2e6226e..00e5bd0e93a 100644 --- a/clients/shared/IPC/DaemonConnection.swift +++ b/clients/shared/IPC/DaemonConnection.swift @@ -1,6 +1,9 @@ import Foundation import Network import os +#if os(iOS) +import CryptoKit +#endif private let log = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.vellum.vellum-assistant", category: "DaemonClient") @@ -77,13 +80,13 @@ extension DaemonClient { port = configPort } - // iOS always enforces TLS for TCP connections + // iOS always enforces TLS for TCP connections with certificate pinning log.info("Connecting to daemon at \(hostname):\(port) (tls=true)") endpoint = NWEndpoint.hostPort( host: NWEndpoint.Host(hostname), port: NWEndpoint.Port(integerLiteral: port) ) - parameters = .tls + parameters = Self.makePinnedTLSParameters(hostname: hostname, port: port) #else #error("DaemonClient is only supported on macOS and iOS") #endif @@ -291,6 +294,51 @@ extension DaemonClient { } #endif + // MARK: - TLS Certificate Pinning (iOS) + + #if os(iOS) + /// Create NWParameters with TLS and optional certificate pinning. + /// If a fingerprint is stored for this host:port, the TLS verify block + /// compares the server's leaf certificate SHA-256 against the stored value. + /// If no fingerprint is stored, accepts any valid TLS connection (TOFU model). + static func makePinnedTLSParameters(hostname: String, port: UInt16) -> NWParameters { + let fpKey = "daemon_fingerprint:\(hostname):\(port)" + let storedFingerprint = UserDefaults.standard.string(forKey: fpKey) + + let tlsOptions = NWProtocolTLS.Options() + + sec_protocol_options_set_verify_block( + tlsOptions.securityProtocolOptions, + { metadata, trust, completionHandler in + // If no fingerprint stored, accept any cert (first connection / TOFU) + guard let expected = storedFingerprint, !expected.isEmpty else { + completionHandler(true) + return + } + + // Extract the leaf certificate + let secTrust = sec_trust_copy_ref(trust).takeRetainedValue() + guard SecTrustGetCertificateCount(secTrust) > 0, + let leafCert = SecTrustCopyCertificateChain(secTrust) as? [SecCertificate], + let cert = leafCert.first else { + completionHandler(false) + return + } + + // Compute SHA-256 fingerprint of the DER-encoded certificate + let certData = SecCertificateCopyData(cert) as Data + let hash = SHA256.hash(data: certData) + let fingerprint = hash.compactMap { String(format: "%02x", $0) }.joined() + + completionHandler(fingerprint == expected) + }, + .main + ) + + return NWParameters(tls: tlsOptions) + } + #endif + // MARK: - HTTP Transport /// Connect to a remote assistant via HTTP REST + SSE. diff --git a/clients/shared/IPC/Generated/IPCContractGenerated.swift b/clients/shared/IPC/Generated/IPCContractGenerated.swift index 40087b3e1db..5d715414cfe 100644 --- a/clients/shared/IPC/Generated/IPCContractGenerated.swift +++ b/clients/shared/IPC/Generated/IPCContractGenerated.swift @@ -747,6 +747,21 @@ public struct IPCGetSigningIdentityResponse: Codable, Sendable { public let error: String? } +public struct IPCGuardianVerificationRequest: Codable, Sendable { + public let type: String + public let action: String + public let channel: String? + public let sessionId: String? +} + +public struct IPCGuardianVerificationResponse: Codable, Sendable { + public let type: String + public let success: Bool + public let secret: String? + public let instruction: String? + public let error: String? +} + public struct IPCHistoryRequest: Codable, Sendable { public let type: String public let sessionId: String diff --git a/clients/shared/Utilities/NetworkInterfaceResolver.swift b/clients/shared/Utilities/NetworkInterfaceResolver.swift new file mode 100644 index 00000000000..6b99b46e240 --- /dev/null +++ b/clients/shared/Utilities/NetworkInterfaceResolver.swift @@ -0,0 +1,58 @@ +#if os(macOS) +import Foundation + +/// Detects the local IPv4 address for LAN communication. +/// Prefers en0 (Wi-Fi) > en1 > first non-loopback IPv4, matching the +/// Node.js `network-info.ts` helper's precedence. +public enum NetworkInterfaceResolver { + /// Returns the best local IPv4 address, or nil if none found. + public static func getLocalIPv4() -> String? { + var ifaddr: UnsafeMutablePointer? + guard getifaddrs(&ifaddr) == 0, let firstAddr = ifaddr else { + return nil + } + defer { freeifaddrs(ifaddr) } + + var addressesByInterface: [String: String] = [:] + + var current: UnsafeMutablePointer? = firstAddr + while let addr = current { + defer { current = addr.pointee.ifa_next } + + // Only IPv4 (AF_INET). ifa_addr can be NULL for some interfaces (e.g. awdl0, tunnels). + guard let ifaAddr = addr.pointee.ifa_addr, ifaAddr.pointee.sa_family == UInt8(AF_INET) else { continue } + + let name = String(cString: addr.pointee.ifa_name) + + // Extract the IPv4 address string + var hostname = [CChar](repeating: 0, count: Int(NI_MAXHOST)) + let result = getnameinfo( + ifaAddr, + socklen_t(ifaAddr.pointee.sa_len), + &hostname, + socklen_t(hostname.count), + nil, 0, + NI_NUMERICHOST + ) + guard result == 0 else { continue } + let ip = String(cString: hostname) + + // Skip loopback and link-local + if ip.hasPrefix("127.") || ip.hasPrefix("169.254.") { continue } + + addressesByInterface[name] = ip + } + + // Priority: en0 (Wi-Fi) > en1 > any other + let priorityInterfaces = ["en0", "en1"] + for iface in priorityInterfaces { + if let ip = addressesByInterface[iface] { + return ip + } + } + + // Fallback to first non-loopback + return addressesByInterface.values.first + } +} +#endif diff --git a/clients/shared/Utilities/QRCodeGenerator.swift b/clients/shared/Utilities/QRCodeGenerator.swift new file mode 100644 index 00000000000..bb8f71abfab --- /dev/null +++ b/clients/shared/Utilities/QRCodeGenerator.swift @@ -0,0 +1,34 @@ +#if os(macOS) +import CoreImage +import AppKit + +/// Generates QR code images from string data using CoreImage. +public enum QRCodeGenerator { + /// Generate a QR code NSImage from a string payload. + /// - Parameters: + /// - string: The data to encode in the QR code. + /// - size: The desired output size in points (QR codes are square). + /// - Returns: An NSImage of the QR code, or nil if generation fails. + public static func generate(from string: String, size: CGFloat = 200) -> NSImage? { + guard let data = string.data(using: .utf8), + let filter = CIFilter(name: "CIQRCodeGenerator") else { + return nil + } + + filter.setValue(data, forKey: "inputMessage") + filter.setValue("M", forKey: "inputCorrectionLevel") + + guard let ciImage = filter.outputImage else { return nil } + + // Scale the tiny CIFilter output to the desired size + let scaleX = size / ciImage.extent.size.width + let scaleY = size / ciImage.extent.size.height + let scaled = ciImage.transformed(by: CGAffineTransform(scaleX: scaleX, y: scaleY)) + + let rep = NSCIImageRep(ciImage: scaled) + let nsImage = NSImage(size: rep.size) + nsImage.addRepresentation(rep) + return nsImage + } +} +#endif