diff --git a/Sources/VocaMac/App/VocaMacApp.swift b/Sources/VocaMac/App/VocaMacApp.swift index 4761865..9ab17c2 100644 --- a/Sources/VocaMac/App/VocaMacApp.swift +++ b/Sources/VocaMac/App/VocaMacApp.swift @@ -12,7 +12,14 @@ final class SettingsWindowManager: ObservableObject { private var settingsWindow: NSWindow? private var closeObserver: NSObjectProtocol? - func open(appState: AppState) { + /// Sidebar page to apply when Settings appears. Survives first-open timing. + @Published private(set) var requestedPage: SettingsPage? + /// Pair-phone sheet to present when Gateway settings appears. + @Published private(set) var pendingPairingPresentation = false + + func open(appState: AppState, page: SettingsPage? = nil, showPairing: Bool = false) { + recordOpenRequest(page: page, showPairing: showPairing) + // If window already exists, just bring it to front if let window = settingsWindow, window.isVisible { window.makeKeyAndOrderFront(nil) @@ -21,8 +28,9 @@ final class SettingsWindowManager: ObservableObject { } // Create the settings view - let settingsView = SettingsView() + let settingsView = SettingsView(initialPage: requestedPage ?? .dictation) .environmentObject(appState) + .environmentObject(self) // Create a new window let window = NSWindow( @@ -62,6 +70,34 @@ final class SettingsWindowManager: ObservableObject { } } } + + /// Stores a sidebar page and/or pair-phone request until Settings consumes it. + func recordOpenRequest(page: SettingsPage? = nil, showPairing: Bool = false) { + if let page { + requestedPage = page + } + if showPairing { + pendingPairingPresentation = true + if requestedPage == nil { + requestedPage = .gateway + } + } + } + + /// Returns and clears the requested sidebar page, if any. + func consumeRequestedPage() -> SettingsPage? { + guard let page = requestedPage else { return nil } + requestedPage = nil + return page + } + + /// Consumes the pair-phone request only when the Gateway pane can show the sheet. + /// Leaves the flag set otherwise so a later pairable/ready status can retry. + func consumePendingPairingPresentation(canPresent: Bool) -> Bool { + guard pendingPairingPresentation, canPresent else { return false } + pendingPairingPresentation = false + return true + } } /// Manages the standalone update details window. diff --git a/Sources/VocaMac/Models/GatewayPairing.swift b/Sources/VocaMac/Models/GatewayPairing.swift new file mode 100644 index 0000000..c51b5a7 --- /dev/null +++ b/Sources/VocaMac/Models/GatewayPairing.swift @@ -0,0 +1,196 @@ +// GatewayPairing.swift +// VocaMac +// +// Pure pairing helpers for VocaGateway: payload decode and loopback rejection. +// Foundation-only so unit tests cover the contract without process APIs. + +import Foundation + +/// Decoded phone-pairing document `{v,url,token}` from Gateway admin `payload`. +struct GatewayPairingPayload: Codable, Equatable { + let v: Int + let url: String + let token: String + + enum CodingKeys: String, CodingKey { + case v + case url + case token + } + + var gatewayURL: URL? { URL(string: url) } + + /// Compact JSON string suitable for QR encoding (phones expect this shape). + var qrPayloadString: String { + let dict: [String: Any] = ["v": v, "url": url, "token": token] + guard let data = try? JSONSerialization.data(withJSONObject: dict, options: [.sortedKeys]), + let text = String(data: data, encoding: .utf8) else { + return #"{"token":"\#(token)","url":"\#(url)","v":\#(v)}"# + } + return text + } +} + +enum GatewayPairingDecodeError: Error, Equatable, LocalizedError { + case empty + case invalidJSON + case missingPayload + case unsupportedVersion(Int?) + case missingURL + case missingToken + case invalidURL(String) + case loopbackURL(String) + + var errorDescription: String? { + switch self { + case .empty: + return "Pairing payload is empty." + case .invalidJSON: + return "Pairing payload is not valid JSON." + case .missingPayload: + return "Admin pairing response is missing payload." + case .unsupportedVersion(let version): + return "Unsupported pairing version: \(version.map(String.init) ?? "missing")." + case .missingURL: + return "Pairing payload is missing a gateway URL." + case .missingToken: + return "Pairing payload is missing a bearer token." + case .invalidURL(let raw): + return "Pairing payload has an invalid gateway URL: \(raw)." + case .loopbackURL(let raw): + return "Pairing URL must not be localhost or 127.0.0.1 (got \(raw)). Set a LAN or Tailscale address." + } + } +} + +/// Decodes `/v1/admin/pairing` admin JSON whose `payload` may be an object or a JSON string. +enum GatewayPairingDecoder { + static let supportedVersion = 1 + + /// Decode from an admin JSON object that exposes `payload` (object or string). + static func decodeAdminJSON( + _ json: [String: Any], + rejectLoopback: Bool = true + ) -> Result { + guard let rawPayload = json["payload"] else { + return .failure(.missingPayload) + } + + let object: [String: Any] + if let asObject = rawPayload as? [String: Any] { + object = asObject + } else if let asString = rawPayload as? String { + let trimmed = asString.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return .failure(.empty) } + guard let data = trimmed.data(using: .utf8), + let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return .failure(.invalidJSON) + } + object = parsed + } else if let data = try? JSONSerialization.data(withJSONObject: rawPayload), + let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + object = parsed + } else { + return .failure(.invalidJSON) + } + + return decodePayloadObject(object, rejectLoopback: rejectLoopback) + } + + /// Decode a raw payload JSON string `{v,url,token}`. + static func decodePayloadString( + _ raw: String, + rejectLoopback: Bool = true + ) -> Result { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return .failure(.empty) } + guard let data = trimmed.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return .failure(.invalidJSON) + } + return decodePayloadObject(object, rejectLoopback: rejectLoopback) + } + + static func decodePayloadObject( + _ object: [String: Any], + rejectLoopback: Bool = true + ) -> Result { + let versionValue = object["v"] ?? object["version"] + let version: Int? + if let intValue = versionValue as? Int { + version = intValue + } else if let number = versionValue as? NSNumber { + version = number.intValue + } else { + version = nil + } + guard version == supportedVersion else { + return .failure(.unsupportedVersion(version)) + } + + guard let urlString = (object["url"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !urlString.isEmpty else { + return .failure(.missingURL) + } + guard let token = (object["token"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !token.isEmpty else { + return .failure(.missingToken) + } + guard let url = URL(string: urlString), url.scheme != nil, url.host != nil else { + return .failure(.invalidURL(urlString)) + } + + if rejectLoopback, GatewayPairingURL.isLoopback(url) { + return .failure(.loopbackURL(urlString)) + } + + return .success(GatewayPairingPayload(v: supportedVersion, url: urlString, token: token)) + } +} + +/// Loopback / pairability checks for Gateway URLs used in phone QR codes. +enum GatewayPairingURL { + /// True when the URL host is loopback and must not appear in a phone QR. + static func isLoopback(_ url: URL) -> Bool { + guard let host = url.host?.lowercased() else { return false } + if host == "localhost" || host == "127.0.0.1" || host == "::1" || host == "[::1]" { + return true + } + if host.hasPrefix("127.") { return true } + return false + } + + static func isLoopback(_ raw: String) -> Bool { + guard let url = URL(string: raw) else { return false } + return isLoopback(url) + } + + /// True when a URL is usable for pairing QR (has scheme+host and is not loopback). + static func isPairableURL(_ url: URL) -> Bool { + guard url.scheme != nil, url.host != nil else { return false } + return !isLoopback(url) + } + + static func isPairableURL(_ raw: String) -> Bool { + guard let url = URL(string: raw), url.scheme != nil, url.host != nil else { return false } + return isPairableURL(url) + } + + /// Normalize a user-supplied public URL override for pairing. + static func validatedPublicURL(_ raw: String) -> Result { + var trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return .failure(.missingURL) } + if !trimmed.contains("://") { + trimmed = "http://\(trimmed)" + } + guard let url = URL(string: trimmed), url.scheme != nil, url.host != nil else { + return .failure(.invalidURL(trimmed)) + } + if isLoopback(url) { + return .failure(.loopbackURL(trimmed)) + } + return .success(url) + } +} diff --git a/Sources/VocaMac/Models/SettingsPage.swift b/Sources/VocaMac/Models/SettingsPage.swift index 3bc53f4..33eeb7b 100644 --- a/Sources/VocaMac/Models/SettingsPage.swift +++ b/Sources/VocaMac/Models/SettingsPage.swift @@ -16,6 +16,7 @@ enum SettingsPage: String, CaseIterable, Identifiable, Hashable { case application case stats case advanced + case gateway case about var id: String { rawValue } @@ -30,6 +31,7 @@ enum SettingsPage: String, CaseIterable, Identifiable, Hashable { case .application: return "Application" case .stats: return "Stats" case .advanced: return "Advanced" + case .gateway: return "Gateway" case .about: return "About" } } @@ -44,6 +46,7 @@ enum SettingsPage: String, CaseIterable, Identifiable, Hashable { case .application: return "gearshape" case .stats: return "chart.xyaxis.line" case .advanced: return "ladybug" + case .gateway: return "server.rack" case .about: return "info.circle" } } diff --git a/Sources/VocaMac/Models/SettingsSearchIndex.swift b/Sources/VocaMac/Models/SettingsSearchIndex.swift index c3b779b..932988d 100644 --- a/Sources/VocaMac/Models/SettingsSearchIndex.swift +++ b/Sources/VocaMac/Models/SettingsSearchIndex.swift @@ -183,6 +183,29 @@ enum SettingsSearchIndex { keywords: ["overlay", "cursor", "indicator", "mic", "position", "style"] ), + // Gateway + SettingsSearchEntry( + id: "gateway", + page: .gateway, + title: "Gateway", + subtitle: "Optional self-hosted VocaGateway", + keywords: ["gateway", "vocagateway", "pair", "phone", "docker", "self-hosted", "qr"] + ), + SettingsSearchEntry( + id: "gateway-pair", + page: .gateway, + title: "Pair phone", + subtitle: "QR code for VocaPhone", + keywords: ["pair", "phone", "qr", "pairing"] + ), + SettingsSearchEntry( + id: "gateway-docker", + page: .gateway, + title: "Docker fallback", + subtitle: "Install Docker Desktop when native binary is missing", + keywords: ["docker", "desktop", "container", "compose"] + ), + // Stats / Advanced / About SettingsSearchEntry( id: "stats", diff --git a/Sources/VocaMac/Services/GatewayEmbedController.swift b/Sources/VocaMac/Services/GatewayEmbedController.swift new file mode 100644 index 0000000..7725648 --- /dev/null +++ b/Sources/VocaMac/Services/GatewayEmbedController.swift @@ -0,0 +1,551 @@ +// GatewayEmbedController.swift +// VocaMac +// +// Native-first start/stop and health/pairing probes for a local VocaGateway. +// Gateway owns its logs; this manager only reveals Gateway paths or tees +// session stdout — it does not write Gateway logs under VocaMac Application Support. + +import Foundation +import AppKit + +/// Paths and URLs used by the local Gateway integration. +enum GatewayPaths { + static let defaultPort = 8765 + static let loopbackBaseURL = URL(string: "http://127.0.0.1:8765")! + static let docsURL = URL(string: "https://vocagateway.vocahq.com/")! + static let githubURL = URL(string: "https://github.com/VocaHQ/vocagateway")! + static let dockerDesktopURL = URL(string: "https://www.docker.com/products/docker-desktop/")! + static let dockerInstallURL = URL(string: "https://docs.docker.com/desktop/setup/install/mac-install/")! + static let vocagatewayREADME = URL(string: "https://github.com/VocaHQ/vocagateway#readme")! + static let publicURLOverrideDefaultsKey = "vocamac.gateway.publicURLOverride" + + static var configDirectory: URL { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".config/vocagateway", isDirectory: true) + } + + static var tokenFileURL: URL { + configDirectory.appendingPathComponent("token", isDirectory: false) + } + + static var configFileURL: URL { + configDirectory.appendingPathComponent("config.json", isDirectory: false) + } + + /// LaunchAgent log directory documented by VocaGateway on macOS. + static var macOSLogDirectory: URL { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Logs/VocaGateway", isDirectory: true) + } + + static var macOSLogFileURL: URL { + macOSLogDirectory.appendingPathComponent("gateway.log", isDirectory: false) + } + + /// Common install locations checked after PATH. + static var commonBinaryCandidates: [String] { + [ + "/opt/homebrew/bin/vocagateway", + "/usr/local/bin/vocagateway", + "\(NSHomeDirectory())/.local/bin/vocagateway", + "\(NSHomeDirectory())/.cargo/bin/vocagateway", + ] + } +} + +/// Resolves a `vocagateway` executable without spawning it. +enum GatewayBinaryResolver { + /// Returns an absolute path to `vocagateway` when found on PATH or common locations. + static func resolveExecutablePath( + fileManager: FileManager = .default, + pathEnvironment: String? = ProcessInfo.processInfo.environment["PATH"] + ) -> String? { + if let fromWhich = whichViaBin(named: "vocagateway", fileManager: fileManager) { + return fromWhich + } + if let fromPath = findOnPATH( + named: "vocagateway", + pathEnvironment: pathEnvironment, + fileManager: fileManager + ) { + return fromPath + } + for candidate in GatewayPaths.commonBinaryCandidates { + if fileManager.isExecutableFile(atPath: candidate) { + return candidate + } + } + return nil + } + + static func whichViaBin(named name: String, fileManager: FileManager) -> String? { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/which") + process.arguments = [name] + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = Pipe() + do { + try process.run() + process.waitUntilExit() + } catch { + return nil + } + guard process.terminationStatus == 0 else { return nil } + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let path = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !path.isEmpty, fileManager.isExecutableFile(atPath: path) else { return nil } + return path + } + + static func findOnPATH( + named name: String, + pathEnvironment: String?, + fileManager: FileManager + ) -> String? { + let path = pathEnvironment ?? "" + for directory in path.split(separator: ":") { + let candidate = URL(fileURLWithPath: String(directory), isDirectory: true) + .appendingPathComponent(name) + .path + if fileManager.isExecutableFile(atPath: candidate) { + return candidate + } + } + return nil + } + + static func isDockerCLIAvailable( + fileManager: FileManager = .default, + pathEnvironment: String? = ProcessInfo.processInfo.environment["PATH"] + ) -> Bool { + let fixed = ["/usr/local/bin/docker", "/opt/homebrew/bin/docker"] + if fixed.contains(where: { fileManager.isExecutableFile(atPath: $0) }) { + return true + } + return findOnPATH(named: "docker", pathEnvironment: pathEnvironment, fileManager: fileManager) != nil + } +} + + + +@MainActor +final class GatewayEmbedController: ObservableObject { + static let shared = GatewayEmbedController() + + enum Status: Equatable { + case stopped + case starting + case pairable + case ready + case error(String) + + var title: String { + switch self { + case .stopped: return "Stopped" + case .starting: return "Starting" + case .pairable: return "Pairable" + case .ready: return "Ready" + case .error: return "Error" + } + } + + var detail: String? { + if case .error(let message) = self { return message } + return nil + } + + var allowsPairing: Bool { + switch self { + case .pairable, .ready: return true + default: return false + } + } + } + + + @Published private(set) var status: Status = .stopped + @Published private(set) var binaryPath: String? + @Published private(set) var isBinaryAvailable = false + @Published private(set) var isDockerAvailable = false + @Published private(set) var pairingPayload: GatewayPairingPayload? + @Published private(set) var pairingPayloadRaw: String? + @Published private(set) var pairingCandidates: [String] = [] + @Published private(set) var lastErrorMessage: String? + @Published private(set) var isLive = false + @Published private(set) var isReady = false + @Published var publicURLOverride: String { + didSet { + UserDefaults.standard.set(publicURLOverride, forKey: GatewayPaths.publicURLOverrideDefaultsKey) + } + } + + private var process: Process? + private var stdoutPipe: Pipe? + private let session: URLSession + + init(session: URLSession = .shared) { + self.session = session + self.publicURLOverride = UserDefaults.standard.string(forKey: GatewayPaths.publicURLOverrideDefaultsKey) ?? "" + refreshBinaryAvailability() + } + + // MARK: - Discovery + + func refreshBinaryAvailability() { + binaryPath = GatewayBinaryResolver.resolveExecutablePath() + isBinaryAvailable = binaryPath != nil + isDockerAvailable = GatewayBinaryResolver.isDockerCLIAvailable() + } + + // MARK: - Lifecycle + + func start() async { + refreshBinaryAvailability() + guard let binaryPath else { + let message = "VocaGateway is not installed. Install the native CLI, or use Docker as a fallback (no MLX / Apple Silicon native engines)." + status = .error(message) + lastErrorMessage = message + return + } + + if isLive { + await refreshStatus() + return + } + + // Never stack a second child on a retained process that is no longer live. + if process != nil { + await terminateSpawnedProcess() + if process != nil { + let message = "Previous Gateway process could not be stopped." + status = .error(message) + lastErrorMessage = message + return + } + } + + status = .starting + lastErrorMessage = nil + pairingPayload = nil + pairingPayloadRaw = nil + + do { + try spawnNativeProcess(executable: binaryPath) + } catch { + let message = error.localizedDescription + status = .error(message) + lastErrorMessage = message + await terminateSpawnedProcess() + return + } + + for _ in 0..<40 { + try? await Task.sleep(nanoseconds: 250_000_000) + await refreshStatus() + if isLive { return } + if case .error = status { + await terminateSpawnedProcess() + return + } + } + + if !isLive { + let message = "Gateway did not become reachable on port \(GatewayPaths.defaultPort)." + status = .error(message) + lastErrorMessage = message + await terminateSpawnedProcess() + } + } + + func stop() async { + await terminateSpawnedProcess() + // Only claim stopped after the child is gone; a surviving process stays stoppable. + guard process == nil else { return } + status = .stopped + lastErrorMessage = nil + } + + func refreshStatus() async { + refreshBinaryAvailability() + + let live = await probe(path: "/") + let ready = await probe(path: "/health/ready") + + if !live { + pairingPayload = nil + pairingPayloadRaw = nil + if process != nil, case .error = status { + // Reap a retained child so Stop is not disabled while it is still running. + await terminateSpawnedProcess() + return + } + if process?.isRunning == true { + isLive = false + isReady = false + status = .starting + return + } + isLive = false + isReady = false + if case .error = status { + return + } + status = .stopped + return + } + + isLive = true + isReady = ready + await fetchPairing() + } + + // MARK: - Pairing + + func fetchPairing() async { + guard isLive else { return } + + guard let token = readBootstrapToken() else { + let message = "Gateway is live but ~/.config/vocagateway/token is missing." + status = .error(message) + lastErrorMessage = message + return + } + + var components = URLComponents() + components.scheme = "http" + components.host = "127.0.0.1" + components.port = GatewayPaths.defaultPort + components.path = "/v1/admin/pairing" + + let override = publicURLOverride.trimmingCharacters(in: .whitespacesAndNewlines) + if !override.isEmpty { + switch GatewayPairingURL.validatedPublicURL(override) { + case .success(let url): + components.queryItems = [URLQueryItem(name: "url", value: url.absoluteString)] + case .failure(let error): + pairingPayload = nil + pairingPayloadRaw = nil + let message = error.localizedDescription + status = .error(message) + lastErrorMessage = message + return + } + } + + guard let endpoint = components.url else { return } + + var request = URLRequest(url: endpoint) + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.timeoutInterval = 5 + + do { + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { + status = .error("Invalid pairing response.") + return + } + guard http.statusCode == 200 else { + let message = "Pairing request failed (HTTP \(http.statusCode))." + status = .error(message) + lastErrorMessage = message + return + } + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + status = .error("Pairing response was not JSON.") + return + } + if let candidates = json["candidates"] as? [String] { + pairingCandidates = candidates + } + pairingPayloadRaw = json["payload"] as? String + + switch GatewayPairingDecoder.decodeAdminJSON(json, rejectLoopback: true) { + case .success(let payload): + pairingPayload = payload + status = isReady ? .ready : .pairable + lastErrorMessage = nil + case .failure(let error): + pairingPayload = nil + let message = error.localizedDescription + status = .error(message) + lastErrorMessage = message + } + } catch { + let message = "Pairing request failed: \(error.localizedDescription)" + status = .error(message) + lastErrorMessage = message + } + } + + // MARK: - Actions + + func openWebUI() { + NSWorkspace.shared.open(GatewayPaths.loopbackBaseURL) + } + + func openGatewayDocs() { + NSWorkspace.shared.open(GatewayPaths.docsURL) + } + + func openDockerDesktopInstall() { + NSWorkspace.shared.open(GatewayPaths.dockerInstallURL) + } + + func openDockerFallbackDocs() { + NSWorkspace.shared.open(GatewayPaths.dockerInstallURL) + NSWorkspace.shared.open(GatewayPaths.vocagatewayREADME) + } + + func openGatewayRepo() { + NSWorkspace.shared.open(GatewayPaths.githubURL) + } + + /// Reveal Gateway's documented log path (not VocaMac Application Support). + func openGatewayLogs() { + let configDir = GatewayPaths.configDirectory + let fm = FileManager.default + if fm.fileExists(atPath: configDir.path) { + NSWorkspace.shared.open(configDir) + return + } + let parent = configDir.deletingLastPathComponent() + if fm.fileExists(atPath: parent.path) { + NSWorkspace.shared.open(parent) + return + } + lastErrorMessage = "Gateway config folder ~/.config/vocagateway does not exist yet. It appears after the first native Gateway start." + } + + + func copyPairingURLToPasteboard() { + guard let url = pairingPayload?.url else { return } + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(url, forType: .string) + } + + func readBootstrapToken() -> String? { + let url = GatewayPaths.tokenFileURL + guard let raw = try? String(contentsOf: url, encoding: .utf8) else { return nil } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + /// True while this controller still owns a child `Process` (running or not yet reaped). + var hasManagedProcess: Bool { process != nil } + + // MARK: - Private + + /// SIGTERM, then SIGINT, then SIGKILL the spawned child; drop the retained Process only after exit. + /// Does not change `status` — callers record `.stopped` or `.error`. + private func terminateSpawnedProcess() async { + if let process, process.isRunning { + process.terminate() + await waitForProcessExit(process, seconds: 2) + if process.isRunning { + process.interrupt() + await waitForProcessExit(process, seconds: 2) + } + if process.isRunning { + kill(process.processIdentifier, SIGKILL) + await waitForProcessExit(process, seconds: 1) + } + // Do not abandon a surviving child; keep ownership so Stop can still target it. + if process.isRunning { + objectWillChange.send() + return + } + } + stdoutPipe?.fileHandleForReading.readabilityHandler = nil + self.process = nil + self.stdoutPipe = nil + isLive = false + isReady = false + pairingPayload = nil + pairingPayloadRaw = nil + } + + private func waitForProcessExit(_ process: Process, seconds: TimeInterval) async { + let deadline = Date().addingTimeInterval(seconds) + while process.isRunning, Date() < deadline { + try? await Task.sleep(nanoseconds: 50_000_000) + } + } + + private func spawnNativeProcess(executable: String) throws { +#if os(macOS) + let process = Process() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = [] + + var environment = ProcessInfo.processInfo.environment + let override = publicURLOverride.trimmingCharacters(in: .whitespacesAndNewlines) + if !override.isEmpty, case .success(let url) = GatewayPairingURL.validatedPublicURL(override) { + environment["VOCAGATEWAY_PUBLIC_URL"] = url.absoluteString + } + process.environment = environment + + // Tee stdout/stderr for this session only. Persisted logs stay under Gateway paths. + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + stdoutPipe = pipe + + process.terminationHandler = { [weak self] terminated in + Task { @MainActor in + guard let self else { return } + guard self.process === terminated else { return } + self.stdoutPipe?.fileHandleForReading.readabilityHandler = nil + self.process = nil + self.stdoutPipe = nil + self.isLive = false + self.isReady = false + if case .starting = self.status { + let message = "Gateway process exited while starting." + self.status = .error(message) + self.lastErrorMessage = message + } else if case .error = self.status { + // keep + } else { + self.status = .stopped + } + } + } + + try process.run() + self.process = process + + pipe.fileHandleForReading.readabilityHandler = { handle in + _ = handle.availableData + } +#else + throw NSError( + domain: "GatewayEmbedController", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Native Gateway process control is only available on macOS."] + ) +#endif + } + + private func probe(path: String) async -> Bool { + var components = URLComponents() + components.scheme = "http" + components.host = "127.0.0.1" + components.port = GatewayPaths.defaultPort + components.path = path + guard let url = components.url else { return false } + + var request = URLRequest(url: url) + request.timeoutInterval = 2 + request.httpMethod = "GET" + do { + let (_, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { return false } + return http.statusCode == 200 + } catch { + return false + } + } +} diff --git a/Sources/VocaMac/Views/GatewaySettingsTab.swift b/Sources/VocaMac/Views/GatewaySettingsTab.swift new file mode 100644 index 0000000..ad58388 --- /dev/null +++ b/Sources/VocaMac/Views/GatewaySettingsTab.swift @@ -0,0 +1,350 @@ +// GatewaySettingsTab.swift +// VocaMac +// +// Settings → Gateway: native-first local VocaGateway controls, phone pairing QR, +// and a Docker fallback CTA when the native binary is missing. + +import SwiftUI +import AppKit +import CoreImage +import CoreImage.CIFilterBuiltins + +struct GatewaySettingsTab: View { + @ObservedObject private var gateway = GatewayEmbedController.shared + @EnvironmentObject private var settingsWindowManager: SettingsWindowManager + @State private var showingPairSheet = false + @State private var copiedURL = false + + var body: some View { + Form { + Section { + Text("VocaGateway is an optional companion that exposes local speech APIs to phones and other clients. It is not an on-device VocaMac engine, and it does not replace the speech models you already run here.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Section("Status") { + HStack { + Circle() + .fill(statusColor) + .frame(width: 10, height: 10) + Text(gateway.status.title) + .fontWeight(.semibold) + Spacer() + if gateway.isLive { + Text(gateway.isReady ? "Model ready" : "Model may still be downloading") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + if let path = gateway.binaryPath { + LabeledContent("Binary") { + Text(path) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + } else { + Label("Native vocagateway binary not found on PATH", systemImage: "exclamationmark.triangle") + .foregroundStyle(.orange) + .font(.caption) + } + + if let message = gateway.lastErrorMessage, case .error = gateway.status { + Text(message) + .font(.caption) + .foregroundStyle(.orange) + .fixedSize(horizontal: false, vertical: true) + } + + HStack { + Button { + Task { await gateway.start() } + } label: { + Label("Start", systemImage: "play.fill") + } + .disabled(gateway.status == .starting || !gateway.isBinaryAvailable || gateway.isLive) + + Button { + Task { await gateway.stop() } + } label: { + Label("Stop", systemImage: "stop.fill") + } + .disabled(isStopDisabled) + + Button { + Task { await gateway.refreshStatus() } + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + + Spacer() + + Button { + gateway.openWebUI() + } label: { + Label("Open WebUI", systemImage: "safari") + } + .disabled(!gateway.isLive) + } + } + + Section("Phone pairing") { + Text("Show the Pair phone QR once Gateway is live and the pairing URL is a non-loopback address. You can pair while a model is still downloading.") + .font(.caption) + .foregroundStyle(.secondary) + + HStack { + TextField( + "Public URL override (LAN / Tailscale)", + text: $gateway.publicURLOverride, + prompt: Text("http://192.168.x.x:8765") + ) + .textFieldStyle(.roundedBorder) + .onSubmit { + Task { await gateway.fetchPairing() } + } + + Button("Apply") { + Task { await gateway.fetchPairing() } + } + .disabled(!gateway.isLive) + } + + Text("Required when auto-discovery returns localhost. Maps to VOCAGATEWAY_PUBLIC_URL when VocaMac starts Gateway.") + .font(.caption2) + .foregroundStyle(.tertiary) + + if gateway.pairingPayload != nil { + HStack { + Button { + showingPairSheet = true + } label: { + Label("Pair phone…", systemImage: "qrcode") + } + .buttonStyle(.borderedProminent) + + Button { + gateway.copyPairingURLToPasteboard() + copiedURL = true + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + copiedURL = false + } + } label: { + Label(copiedURL ? "Copied" : "Copy URL", systemImage: copiedURL ? "checkmark" : "doc.on.doc") + } + + if let url = gateway.pairingPayload?.url { + Text(url) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + } + } else if gateway.isLive { + Label( + "QR hidden until the pairing URL is not localhost / 127.0.0.1.", + systemImage: "qrcode.viewfinder" + ) + .font(.caption) + .foregroundStyle(.orange) + } + } + + if !gateway.isBinaryAvailable { + Section("Docker fallback") { + Text("Native `vocagateway` is the happy path. Docker Compose is a fallback only. Docker images do not include MLX or Apple Silicon native engines.") + .font(.caption) + .foregroundStyle(.secondary) + + HStack { + Button { + gateway.openDockerFallbackDocs() + } label: { + Label( + gateway.isDockerAvailable ? "Docker Desktop docs" : "Install Docker Desktop", + systemImage: "shippingbox" + ) + } + + Button { + NSWorkspace.shared.open(GatewayPaths.docsURL) + } label: { + Label("Gateway install docs", systemImage: "book") + } + + Button { + NSWorkspace.shared.open(GatewayPaths.githubURL) + } label: { + Label("GitHub", systemImage: "chevron.left.forwardslash.chevron.right") + } + } + } + } + + Section("Logs") { + Text("Gateway owns its logs. VocaMac opens ~/.config/vocagateway (Gateway config and documented log path). It does not write Gateway logs under VocaMac Application Support.") + .font(.caption) + .foregroundStyle(.secondary) + + Button { + gateway.openGatewayLogs() + } label: { + Label("Open Gateway Logs", systemImage: "doc.text.magnifyingglass") + } + } + } + .formStyle(.grouped) + .task { + await gateway.refreshStatus() + presentPendingPairingIfNeeded() + } + .onAppear { + presentPendingPairingIfNeeded() + } + .onChange(of: settingsWindowManager.pendingPairingPresentation) { _, pending in + guard pending else { return } + presentPendingPairingIfNeeded() + } + .onChange(of: gateway.status) { _, status in + guard status.allowsPairing else { return } + presentPendingPairingIfNeeded() + } + .sheet(isPresented: $showingPairSheet) { + GatewayPairPhoneSheet( + payloadRaw: gateway.pairingPayload?.qrPayloadString + ?? gateway.pairingPayload.map { encodeCompactPayload($0) }, + urlString: gateway.pairingPayload?.url, + onCopyURL: { + gateway.copyPairingURLToPasteboard() + }, + onDismiss: { showingPairSheet = false } + ) + } + } + + private func presentPendingPairingIfNeeded() { + guard settingsWindowManager.consumePendingPairingPresentation( + canPresent: gateway.status.allowsPairing + ) else { return } + showingPairSheet = true + } + + private var isStopDisabled: Bool { + switch gateway.status { + case .stopped: + return !gateway.hasManagedProcess + case .error: + return !gateway.isLive && !gateway.hasManagedProcess + case .starting, .pairable, .ready: + return false + } + } + + private var statusColor: Color { + switch gateway.status { + case .stopped: return .secondary + case .starting: return .orange + case .pairable: return .blue + case .ready: return .green + case .error: return .red + } + } + + private func encodeCompactPayload(_ payload: GatewayPairingPayload) -> String { + let object: [String: Any] = [ + "v": payload.v, + "url": payload.url, + "token": payload.token, + ] + guard let data = try? JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]), + let text = String(data: data, encoding: .utf8) else { + return "" + } + return text + } +} + +// MARK: - Pair phone sheet + +struct GatewayPairPhoneSheet: View { + let payloadRaw: String? + let urlString: String? + let onCopyURL: () -> Void + let onDismiss: () -> Void + + @State private var qrImage: NSImage? + + var body: some View { + VStack(spacing: 16) { + Text("Pair phone") + .font(.headline) + + Text("Scan with VocaPhone. The QR encodes the gateway URL and bearer token.") + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + + if let qrImage { + Image(nsImage: qrImage) + .interpolation(.none) + .resizable() + .scaledToFit() + .frame(width: 220, height: 220) + .padding(8) + .background(Color.white) + .cornerRadius(8) + } else { + ContentUnavailableView( + "QR unavailable", + systemImage: "qrcode", + description: Text("Could not build a QR from the pairing payload.") + ) + .frame(height: 220) + } + + if let urlString { + Text(urlString) + .font(.caption) + .textSelection(.enabled) + .foregroundStyle(.secondary) + } + + HStack { + Button("Copy URL", action: onCopyURL) + Spacer() + Button("Done", action: onDismiss) + .keyboardShortcut(.defaultAction) + } + } + .padding(24) + .frame(width: 360) + .onAppear { + qrImage = payloadRaw.flatMap { GatewayQRCodeImage.make(from: $0) } + } + } +} + +// MARK: - QR helper + +enum GatewayQRCodeImage { + static func make(from string: String) -> NSImage? { + let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + let context = CIContext() + let filter = CIFilter.qrCodeGenerator() + filter.message = Data(trimmed.utf8) + filter.correctionLevel = "M" + guard let output = filter.outputImage else { return nil } + + let scale: CGFloat = 12 + let scaled = output.transformed(by: CGAffineTransform(scaleX: scale, y: scale)) + guard let cgImage = context.createCGImage(scaled, from: scaled.extent) else { return nil } + return NSImage(cgImage: cgImage, size: NSSize(width: scaled.extent.width, height: scaled.extent.height)) + } +} diff --git a/Sources/VocaMac/Views/MenuBarView.swift b/Sources/VocaMac/Views/MenuBarView.swift index b19e6e8..90dab51 100644 --- a/Sources/VocaMac/Views/MenuBarView.swift +++ b/Sources/VocaMac/Views/MenuBarView.swift @@ -108,6 +108,7 @@ struct MenuBarView: View { @EnvironmentObject var appState: AppState @ObservedObject var settingsManager: SettingsWindowManager @ObservedObject var updateWindowManager: UpdateWindowManager + @ObservedObject private var gateway = GatewayEmbedController.shared @StateObject private var processMonitor = ProcessMonitor(useTimer: false) @State private var audioDevices: [AudioDevice] = [] @@ -150,6 +151,9 @@ struct MenuBarView: View { } .padding(20) .frame(width: 380) + .task { + await gateway.refreshStatus() + } .onAppear { processMonitor.start() } .onDisappear { processMonitor.stop() } } @@ -583,6 +587,29 @@ struct MenuBarView: View { } .buttonStyle(MenuRowButtonStyle()) + + if gateway.status.allowsPairing { + Button { + settingsManager.open(appState: appState, page: .gateway, showPairing: true) + } label: { + HStack { + Image(systemName: "qrcode") + Text("Pair phone…") + Spacer() + } + .font(.body) + .padding(.vertical, 6) + .padding(.horizontal, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(Color.primary.opacity(0.0001)) + ) + } + .buttonStyle(MenuRowButtonStyle()) + } + Button { NSApplication.shared.terminate(nil) } label: { diff --git a/Sources/VocaMac/Views/SettingsView.swift b/Sources/VocaMac/Views/SettingsView.swift index d939ef0..6de30c0 100644 --- a/Sources/VocaMac/Views/SettingsView.swift +++ b/Sources/VocaMac/Views/SettingsView.swift @@ -13,13 +13,19 @@ extension Notification.Name { struct SettingsView: View { @EnvironmentObject var appState: AppState + @EnvironmentObject var settingsWindowManager: SettingsWindowManager - @State private var selectedPage: SettingsPage? = .dictation + @State private var selectedPage: SettingsPage? @State private var searchText = "" @State private var pageBeforeSearch: SettingsPage = .dictation /// Manual sidebar visibility. Avoids NavigationSplitView relocating system toggles. @State private var isSidebarVisible = true + init(initialPage: SettingsPage = .dictation) { + _selectedPage = State(initialValue: initialPage) + _pageBeforeSearch = State(initialValue: initialPage) + } + private var matchCounts: [SettingsPage: Int] { SettingsSearchIndex.matchCounts(query: searchText) } @@ -81,6 +87,18 @@ struct SettingsView: View { } } .frame(minWidth: 720, minHeight: 520) + .onAppear { + applyRequestedSettingsPage() + } + .onChange(of: settingsWindowManager.requestedPage) { _, page in + guard page != nil else { return } + applyRequestedSettingsPage() + } + } + + private func applyRequestedSettingsPage() { + guard let page = settingsWindowManager.consumeRequestedPage() else { return } + selectedPage = page } private var settingsSidebar: some View { @@ -128,6 +146,8 @@ struct SettingsView: View { StatsSettingsTab() case .advanced: DebugTab() + case .gateway: + GatewaySettingsTab() case .about: AboutTab() } diff --git a/Tests/VocaMacTests/GatewayPairingTests.swift b/Tests/VocaMacTests/GatewayPairingTests.swift new file mode 100644 index 0000000..7f8a97c --- /dev/null +++ b/Tests/VocaMacTests/GatewayPairingTests.swift @@ -0,0 +1,171 @@ +// GatewayPairingTests.swift +// VocaMac Tests + +import XCTest +@testable import VocaMac + +final class GatewayPairingTests: XCTestCase { + + func testDecodePayloadSuccess() { + let raw = #"{"v":1,"url":"http://192.168.1.20:8765","token":"abcdefghijklmnopqrstuvwxyz012345"}"# + let result = GatewayPairingDecoder.decodePayloadString(raw) + switch result { + case .success(let payload): + XCTAssertEqual(payload.v, 1) + XCTAssertEqual(payload.url, "http://192.168.1.20:8765") + XCTAssertEqual(payload.token, "abcdefghijklmnopqrstuvwxyz012345") + case .failure(let error): + XCTFail("Unexpected failure: \(error)") + } + } + + func testDecodePayloadRejectsLoopbackLocalhost() { + let raw = #"{"v":1,"url":"http://localhost:8765","token":"abcdefghijklmnopqrstuvwxyz012345"}"# + let result = GatewayPairingDecoder.decodePayloadString(raw, rejectLoopback: true) + guard case .failure(.loopbackURL) = result else { + return XCTFail("Expected loopback rejection, got \(result)") + } + } + + func testDecodePayloadRejectsLoopback127() { + let raw = #"{"v":1,"url":"http://127.0.0.1:8765","token":"abcdefghijklmnopqrstuvwxyz012345"}"# + let result = GatewayPairingDecoder.decodePayloadString(raw, rejectLoopback: true) + guard case .failure(.loopbackURL) = result else { + return XCTFail("Expected loopback rejection, got \(result)") + } + } + + func testDecodePayloadRejectsLoopbackIPv6() { + let raw = #"{"v":1,"url":"http://[::1]:8765","token":"abcdefghijklmnopqrstuvwxyz012345"}"# + let result = GatewayPairingDecoder.decodePayloadString(raw, rejectLoopback: true) + guard case .failure(.loopbackURL) = result else { + return XCTFail("Expected ::1 loopback rejection, got \(result)") + } + } + + func testDecodePayloadAllowsLoopbackWhenNotRejected() { + let raw = #"{"v":1,"url":"http://127.0.0.1:8765","token":"abcdefghijklmnopqrstuvwxyz012345"}"# + let result = GatewayPairingDecoder.decodePayloadString(raw, rejectLoopback: false) + guard case .success(let payload) = result else { + return XCTFail("Expected success when loopback allowed, got \(result)") + } + XCTAssertTrue(GatewayPairingURL.isLoopback(payload.url)) + XCTAssertFalse(GatewayPairingURL.isPairableURL(payload.url)) + } + + func testDecodeAdminResponsePayloadAsString() { + let payload = #"{"v":1,"url":"http://10.0.0.5:8765","token":"only-inside-payload-token-value-32"}"# + let admin: [String: Any] = [ + "version": 1, + "url": "http://10.0.0.5:8765", + "payload": payload, + "candidates": ["http://10.0.0.5:8765"], + ] + let result = GatewayPairingDecoder.decodeAdminJSON(admin) + switch result { + case .success(let decoded): + XCTAssertEqual(decoded.token, "only-inside-payload-token-value-32") + XCTAssertEqual(decoded.url, "http://10.0.0.5:8765") + case .failure(let error): + XCTFail("Unexpected failure: \(error)") + } + } + + func testDecodeAdminResponsePayloadAsObject() { + let admin: [String: Any] = [ + "payload": [ + "v": 1, + "url": "http://10.0.0.8:8765", + "token": "object-payload-token-abcdefghijkl", + ] as [String: Any], + ] + let result = GatewayPairingDecoder.decodeAdminJSON(admin) + switch result { + case .success(let decoded): + XCTAssertEqual(decoded.v, 1) + XCTAssertEqual(decoded.url, "http://10.0.0.8:8765") + XCTAssertEqual(decoded.token, "object-payload-token-abcdefghijkl") + XCTAssertTrue(GatewayPairingURL.isPairableURL(decoded.url)) + case .failure(let error): + XCTFail("Unexpected failure: \(error)") + } + } + + func testDecodeAdminResponseFailsWithoutPayload() { + let admin: [String: Any] = [ + "version": 1, + "url": "http://10.0.0.5:8765", + "token": "should-be-ignored-even-if-present-here", + ] + let result = GatewayPairingDecoder.decodeAdminJSON(admin) + guard case .failure(.missingPayload) = result else { + return XCTFail("Expected missingPayload when payload is absent, got \(result)") + } + } + + func testValidatedPublicURLRejectsLoopback() { + let result = GatewayPairingURL.validatedPublicURL("http://127.0.0.1:8765") + guard case .failure(.loopbackURL) = result else { + return XCTFail("Expected loopback rejection") + } + } + + func testValidatedPublicURLAcceptsLAN() { + let result = GatewayPairingURL.validatedPublicURL("192.168.1.50:8765") + guard case .success(let url) = result else { + return XCTFail("Expected success, got \(result)") + } + XCTAssertEqual(url.host, "192.168.1.50") + XCTAssertEqual(url.port, 8765) + } + + func testIsLoopbackDetectsVariants() { + XCTAssertTrue(GatewayPairingURL.isLoopback(URL(string: "http://localhost:8765")!)) + XCTAssertTrue(GatewayPairingURL.isLoopback(URL(string: "http://127.0.0.1:8765")!)) + XCTAssertTrue(GatewayPairingURL.isLoopback(URL(string: "http://127.1.2.3:8765")!)) + XCTAssertTrue(GatewayPairingURL.isLoopback(URL(string: "http://[::1]:8765")!)) + XCTAssertFalse(GatewayPairingURL.isLoopback(URL(string: "http://192.168.0.10:8765")!)) + XCTAssertFalse(GatewayPairingURL.isLoopback(URL(string: "https://gateway.tailnet.ts.net")!)) + XCTAssertTrue(GatewayPairingURL.isPairableURL("http://192.168.0.10:8765")) + XCTAssertFalse(GatewayPairingURL.isPairableURL("http://127.0.0.1:8765")) + } + + func testCodableRoundTrip() throws { + let payload = GatewayPairingPayload( + v: 1, + url: "http://192.168.1.20:8765", + token: "abcdefghijklmnopqrstuvwxyz012345" + ) + let data = try JSONEncoder().encode(payload) + let decoded = try JSONDecoder().decode(GatewayPairingPayload.self, from: data) + XCTAssertEqual(decoded, payload) + XCTAssertTrue(decoded.qrPayloadString.contains("192.168.1.20")) + XCTAssertTrue(decoded.qrPayloadString.contains("token")) + } +} + +final class SettingsPageGatewayTests: XCTestCase { + func testSettingsPageIncludesGatewayBeforeAbout() { + XCTAssertTrue(SettingsPage.allCases.contains(.gateway)) + XCTAssertEqual(SettingsPage.gateway.title, "Gateway") + XCTAssertEqual(SettingsPage.gateway.systemImage, "server.rack") + + let pages = SettingsPage.allCases + guard let gatewayIndex = pages.firstIndex(of: .gateway), + let aboutIndex = pages.firstIndex(of: .about) else { + return XCTFail("gateway and about must both exist") + } + XCTAssertLessThan(gatewayIndex, aboutIndex) + } + + func testSettingsSearchIndexHitsGatewayKeywords() { + for query in ["gateway", "pair", "phone", "docker", "vocagateway"] { + let matches = SettingsSearchIndex.matches(query: query) + XCTAssertTrue( + matches.contains { $0.page == .gateway }, + "Expected gateway hit for query \(query)" + ) + } + XCTAssertEqual(SettingsSearchIndex.firstMatchingPage(query: "vocagateway"), .gateway) + } +} diff --git a/Tests/VocaMacTests/SettingsWindowManagerTests.swift b/Tests/VocaMacTests/SettingsWindowManagerTests.swift new file mode 100644 index 0000000..c51f982 --- /dev/null +++ b/Tests/VocaMacTests/SettingsWindowManagerTests.swift @@ -0,0 +1,61 @@ +// SettingsWindowManagerTests.swift +// VocaMac Tests +// +// Durable Settings navigation: requested page and pair-phone presentation +// must survive until SettingsView / GatewaySettingsTab consume them. + +import XCTest +@testable import VocaMac + +@MainActor +final class SettingsWindowManagerTests: XCTestCase { + + func testRequestedPageIsDurableUntilConsumed() { + let manager = SettingsWindowManager() + manager.recordOpenRequest(page: .gateway) + + XCTAssertEqual(manager.requestedPage, .gateway) + XCTAssertEqual(manager.consumeRequestedPage(), .gateway) + XCTAssertNil(manager.requestedPage) + XCTAssertNil(manager.consumeRequestedPage()) + } + + func testPairingPresentationIsDurableUntilConsumed() { + let manager = SettingsWindowManager() + manager.recordOpenRequest(page: .gateway, showPairing: true) + + XCTAssertTrue(manager.pendingPairingPresentation) + XCTAssertTrue(manager.consumePendingPairingPresentation(canPresent: true)) + XCTAssertFalse(manager.pendingPairingPresentation) + XCTAssertFalse(manager.consumePendingPairingPresentation(canPresent: true)) + } + + func testPairingPresentationIsNotConsumedUntilPresentable() { + let manager = SettingsWindowManager() + manager.recordOpenRequest(page: .gateway, showPairing: true) + + XCTAssertTrue(manager.pendingPairingPresentation) + XCTAssertFalse(manager.consumePendingPairingPresentation(canPresent: false)) + XCTAssertTrue(manager.pendingPairingPresentation) + XCTAssertTrue(manager.consumePendingPairingPresentation(canPresent: true)) + XCTAssertFalse(manager.pendingPairingPresentation) + } + + func testPairingRequestDefaultsToGatewayPage() { + let manager = SettingsWindowManager() + manager.recordOpenRequest(showPairing: true) + + XCTAssertEqual(manager.requestedPage, .gateway) + XCTAssertTrue(manager.pendingPairingPresentation) + XCTAssertEqual(manager.consumeRequestedPage(), .gateway) + XCTAssertTrue(manager.consumePendingPairingPresentation(canPresent: true)) + } + + func testPageRequestDoesNotImplyPairing() { + let manager = SettingsWindowManager() + manager.recordOpenRequest(page: .audio) + + XCTAssertEqual(manager.requestedPage, .audio) + XCTAssertFalse(manager.pendingPairingPresentation) + } +}