Skip to content

Commit 25cdb5e

Browse files
committed
Align Gateway settings pane with embed controller contract
Rename the local Gateway controller to GatewayEmbedController with a shared singleton for Settings and the menu bar. Decode pairing payload as object or string, reject loopback QR URLs, open ~/.config/vocagateway for logs, and surface Pair phone from the tray when Pairable.
1 parent 6692856 commit 25cdb5e

5 files changed

Lines changed: 177 additions & 102 deletions

File tree

Sources/VocaMac/App/VocaMacApp.swift

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,18 @@ final class SettingsWindowManager: ObservableObject {
1212
private var settingsWindow: NSWindow?
1313
private var closeObserver: NSObjectProtocol?
1414

15-
func open(appState: AppState) {
15+
func open(appState: AppState, page: SettingsPage? = nil) {
1616
// If window already exists, just bring it to front
1717
if let window = settingsWindow, window.isVisible {
1818
window.makeKeyAndOrderFront(nil)
1919
NSApp.activate(ignoringOtherApps: true)
20+
if let page {
21+
NotificationCenter.default.post(
22+
name: .selectSettingsPage,
23+
object: nil,
24+
userInfo: ["page": page.rawValue]
25+
)
26+
}
2027
return
2128
}
2229

@@ -43,6 +50,14 @@ final class SettingsWindowManager: ObservableObject {
4350
DockVisibilityCoordinator.shared.windowDidOpen()
4451
NSApp.activate(ignoringOtherApps: true)
4552

53+
if let page {
54+
NotificationCenter.default.post(
55+
name: .selectSettingsPage,
56+
object: nil,
57+
userInfo: ["page": page.rawValue]
58+
)
59+
}
60+
4661
// Held so it can be removed on close — a block-based observer lives
4762
// until its token is released, so opening repeatedly would otherwise
4863
// stack up observers.

Sources/VocaMac/Services/GatewayProcessManager.swift renamed to Sources/VocaMac/Services/GatewayEmbedController.swift

Lines changed: 84 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// GatewayProcessManager.swift
1+
// GatewayEmbedController.swift
22
// VocaMac
33
//
44
// Native-first start/stop and health/pairing probes for a local VocaGateway.
@@ -15,6 +15,9 @@ enum GatewayPaths {
1515
static let docsURL = URL(string: "https://vocagateway.vocahq.com/")!
1616
static let githubURL = URL(string: "https://github.com/VocaHQ/vocagateway")!
1717
static let dockerDesktopURL = URL(string: "https://www.docker.com/products/docker-desktop/")!
18+
static let dockerInstallURL = URL(string: "https://docs.docker.com/desktop/setup/install/mac-install/")!
19+
static let vocagatewayREADME = URL(string: "https://github.com/VocaHQ/vocagateway#readme")!
20+
static let publicURLOverrideDefaultsKey = "vocamac.gateway.publicURLOverride"
1821

1922
static var configDirectory: URL {
2023
FileManager.default.homeDirectoryForCurrentUser
@@ -57,6 +60,9 @@ enum GatewayBinaryResolver {
5760
fileManager: FileManager = .default,
5861
pathEnvironment: String? = ProcessInfo.processInfo.environment["PATH"]
5962
) -> String? {
63+
if let fromWhich = whichViaBin(named: "vocagateway", fileManager: fileManager) {
64+
return fromWhich
65+
}
6066
if let fromPath = findOnPATH(
6167
named: "vocagateway",
6268
pathEnvironment: pathEnvironment,
@@ -72,6 +78,27 @@ enum GatewayBinaryResolver {
7278
return nil
7379
}
7480

81+
static func whichViaBin(named name: String, fileManager: FileManager) -> String? {
82+
let process = Process()
83+
process.executableURL = URL(fileURLWithPath: "/usr/bin/which")
84+
process.arguments = [name]
85+
let pipe = Pipe()
86+
process.standardOutput = pipe
87+
process.standardError = Pipe()
88+
do {
89+
try process.run()
90+
process.waitUntilExit()
91+
} catch {
92+
return nil
93+
}
94+
guard process.terminationStatus == 0 else { return nil }
95+
let data = pipe.fileHandleForReading.readDataToEndOfFile()
96+
let path = String(data: data, encoding: .utf8)?
97+
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
98+
guard !path.isEmpty, fileManager.isExecutableFile(atPath: path) else { return nil }
99+
return path
100+
}
101+
75102
static func findOnPATH(
76103
named name: String,
77104
pathEnvironment: String?,
@@ -102,38 +129,43 @@ enum GatewayBinaryResolver {
102129
}
103130

104131

105-
/// UI / runtime state for the embedded Gateway control.
106-
enum GatewayRuntimeStatus: Equatable {
107-
case stopped
108-
case starting
109-
case pairable
110-
case ready
111-
case error(String)
112-
113-
var title: String {
114-
switch self {
115-
case .stopped: return "Stopped"
116-
case .starting: return "Starting…"
117-
case .pairable: return "Pairable"
118-
case .ready: return "Ready"
119-
case .error: return "Error"
132+
133+
@MainActor
134+
final class GatewayEmbedController: ObservableObject {
135+
static let shared = GatewayEmbedController()
136+
137+
enum Status: Equatable {
138+
case stopped
139+
case starting
140+
case pairable
141+
case ready
142+
case error(String)
143+
144+
var title: String {
145+
switch self {
146+
case .stopped: return "Stopped"
147+
case .starting: return "Starting"
148+
case .pairable: return "Pairable"
149+
case .ready: return "Ready"
150+
case .error: return "Error"
151+
}
152+
}
153+
154+
var detail: String? {
155+
if case .error(let message) = self { return message }
156+
return nil
120157
}
121-
}
122158

123-
/// True when Settings/tray may offer Pair phone (non-loopback QR ready).
124-
var allowsPairing: Bool {
125-
switch self {
126-
case .pairable, .ready: return true
127-
default: return false
159+
var allowsPairing: Bool {
160+
switch self {
161+
case .pairable, .ready: return true
162+
default: return false
163+
}
128164
}
129165
}
130-
}
131166

132-
@MainActor
133-
final class GatewayProcessManager: ObservableObject {
134-
static let shared = GatewayProcessManager()
135167

136-
@Published private(set) var status: GatewayRuntimeStatus = .stopped
168+
@Published private(set) var status: Status = .stopped
137169
@Published private(set) var binaryPath: String?
138170
@Published private(set) var isBinaryAvailable = false
139171
@Published private(set) var isDockerAvailable = false
@@ -143,14 +175,19 @@ final class GatewayProcessManager: ObservableObject {
143175
@Published private(set) var lastErrorMessage: String?
144176
@Published private(set) var isLive = false
145177
@Published private(set) var isReady = false
146-
@Published var publicURLOverride: String = ""
178+
@Published var publicURLOverride: String {
179+
didSet {
180+
UserDefaults.standard.set(publicURLOverride, forKey: GatewayPaths.publicURLOverrideDefaultsKey)
181+
}
182+
}
147183

148184
private var process: Process?
149185
private var stdoutPipe: Pipe?
150186
private let session: URLSession
151187

152188
init(session: URLSession = .shared) {
153189
self.session = session
190+
self.publicURLOverride = UserDefaults.standard.string(forKey: GatewayPaths.publicURLOverrideDefaultsKey) ?? ""
154191
refreshBinaryAvailability()
155192
}
156193

@@ -167,7 +204,7 @@ final class GatewayProcessManager: ObservableObject {
167204
func start() async {
168205
refreshBinaryAvailability()
169206
guard let binaryPath else {
170-
let message = "VocaGateway is not installed. Install the native CLI, or use Docker as a fallback."
207+
let message = "VocaGateway is not installed. Install the native CLI, or use Docker as a fallback (no MLX / Apple Silicon native engines)."
171208
status = .error(message)
172209
lastErrorMessage = message
173210
return
@@ -231,7 +268,7 @@ final class GatewayProcessManager: ObservableObject {
231268
func refreshStatus() async {
232269
refreshBinaryAvailability()
233270

234-
let live = await probe(path: "/health/live")
271+
let live = await probe(path: "/")
235272
let ready = await probe(path: "/health/ready")
236273
isLive = live
237274
isReady = ready
@@ -316,7 +353,7 @@ final class GatewayProcessManager: ObservableObject {
316353
}
317354
pairingPayloadRaw = json["payload"] as? String
318355

319-
switch GatewayPairingCodec.decodeAdminResponse(json, rejectLoopback: true) {
356+
switch GatewayPairingDecoder.decodeAdminJSON(json, rejectLoopback: true) {
320357
case .success(let payload):
321358
pairingPayload = payload
322359
status = isReady ? .ready : .pairable
@@ -345,7 +382,12 @@ final class GatewayProcessManager: ObservableObject {
345382
}
346383

347384
func openDockerDesktopInstall() {
348-
NSWorkspace.shared.open(GatewayPaths.dockerDesktopURL)
385+
NSWorkspace.shared.open(GatewayPaths.dockerInstallURL)
386+
}
387+
388+
func openDockerFallbackDocs() {
389+
NSWorkspace.shared.open(GatewayPaths.dockerInstallURL)
390+
NSWorkspace.shared.open(GatewayPaths.vocagatewayREADME)
349391
}
350392

351393
func openGatewayRepo() {
@@ -354,27 +396,23 @@ final class GatewayProcessManager: ObservableObject {
354396

355397
/// Reveal Gateway's documented log path (not VocaMac Application Support).
356398
func openGatewayLogs() {
357-
let logFile = GatewayPaths.macOSLogFileURL
358-
let logDir = GatewayPaths.macOSLogDirectory
359-
let fm = FileManager.default
360-
if fm.fileExists(atPath: logFile.path) {
361-
NSWorkspace.shared.selectFile(logFile.path, inFileViewerRootedAtPath: logDir.path)
362-
return
363-
}
364-
if fm.fileExists(atPath: logDir.path) {
365-
NSWorkspace.shared.open(logDir)
366-
return
367-
}
368399
let configDir = GatewayPaths.configDirectory
400+
let fm = FileManager.default
369401
if fm.fileExists(atPath: configDir.path) {
370402
NSWorkspace.shared.open(configDir)
371403
return
372404
}
373-
lastErrorMessage = "No Gateway log file yet. LaunchAgent installs write to ~/Library/Logs/VocaGateway/. Session starts from VocaMac tee process stdout only."
405+
let parent = configDir.deletingLastPathComponent()
406+
if fm.fileExists(atPath: parent.path) {
407+
NSWorkspace.shared.open(parent)
408+
return
409+
}
410+
lastErrorMessage = "Gateway config folder ~/.config/vocagateway does not exist yet. It appears after the first native Gateway start."
374411
}
375412

413+
376414
func copyPairingURLToPasteboard() {
377-
guard let url = pairingPayload?.url.absoluteString else { return }
415+
guard let url = pairingPayload?.url else { return }
378416
let pasteboard = NSPasteboard.general
379417
pasteboard.clearContents()
380418
pasteboard.setString(url, forType: .string)
@@ -437,7 +475,7 @@ final class GatewayProcessManager: ObservableObject {
437475
}
438476
#else
439477
throw NSError(
440-
domain: "GatewayProcessManager",
478+
domain: "GatewayEmbedController",
441479
code: 1,
442480
userInfo: [NSLocalizedDescriptionKey: "Native Gateway process control is only available on macOS."]
443481
)

Sources/VocaMac/Views/GatewaySettingsTab.swift

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,14 @@ extension Notification.Name {
1717
}
1818

1919
struct GatewaySettingsTab: View {
20-
@ObservedObject private var gateway = GatewayProcessManager.shared
20+
@ObservedObject private var gateway = GatewayEmbedController.shared
2121
@State private var showingPairSheet = false
2222
@State private var copiedURL = false
2323

2424
var body: some View {
2525
Form {
2626
Section {
27-
Text("Optional self-hosted compute for phones and other Voca clients. This is not on-device transcription; audio goes to the Gateway host you run.")
27+
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.")
2828
.font(.caption)
2929
.foregroundStyle(.secondary)
3030
.fixedSize(horizontal: false, vertical: true)
@@ -129,7 +129,7 @@ struct GatewaySettingsTab: View {
129129
Button {
130130
showingPairSheet = true
131131
} label: {
132-
Label("Show Pair Phone QR", systemImage: "qrcode")
132+
Label("Pair phone…", systemImage: "qrcode")
133133
}
134134
.buttonStyle(.borderedProminent)
135135

@@ -163,13 +163,13 @@ struct GatewaySettingsTab: View {
163163

164164
if !gateway.isBinaryAvailable {
165165
Section("Docker fallback") {
166-
Text("Native start is the happy path. Docker Desktop is only a fallback when vocagateway is missing.")
166+
Text("Native `vocagateway` is the happy path. Docker Compose is a fallback only. Docker images do not include MLX or Apple Silicon native engines.")
167167
.font(.caption)
168168
.foregroundStyle(.secondary)
169169

170170
HStack {
171171
Button {
172-
gateway.openDockerDesktopInstall()
172+
gateway.openDockerFallbackDocs()
173173
} label: {
174174
Label(
175175
gateway.isDockerAvailable ? "Docker Desktop docs" : "Install Docker Desktop",
@@ -178,13 +178,13 @@ struct GatewaySettingsTab: View {
178178
}
179179

180180
Button {
181-
gateway.openGatewayDocs()
181+
NSWorkspace.shared.open(GatewayPaths.docsURL)
182182
} label: {
183183
Label("Gateway install docs", systemImage: "book")
184184
}
185185

186186
Button {
187-
gateway.openGatewayRepo()
187+
NSWorkspace.shared.open(GatewayPaths.githubURL)
188188
} label: {
189189
Label("GitHub", systemImage: "chevron.left.forwardslash.chevron.right")
190190
}
@@ -193,7 +193,7 @@ struct GatewaySettingsTab: View {
193193
}
194194

195195
Section("Logs") {
196-
Text("Gateway owns its logs. VocaMac opens Gateway's path under ~/Library/Logs/VocaGateway or ~/.config/vocagateway — it does not store Gateway logs under VocaMac Application Support.")
196+
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.")
197197
.font(.caption)
198198
.foregroundStyle(.secondary)
199199

@@ -215,7 +215,7 @@ struct GatewaySettingsTab: View {
215215
}
216216
.sheet(isPresented: $showingPairSheet) {
217217
GatewayPairPhoneSheet(
218-
payloadRaw: gateway.pairingPayloadRaw
218+
payloadRaw: gateway.pairingPayload?.qrPayloadString
219219
?? gateway.pairingPayload.map { encodeCompactPayload($0) },
220220
urlString: gateway.pairingPayload?.url,
221221
onCopyURL: {
@@ -249,8 +249,8 @@ struct GatewaySettingsTab: View {
249249

250250
private func encodeCompactPayload(_ payload: GatewayPairingPayload) -> String {
251251
let object: [String: Any] = [
252-
"v": payload.version,
253-
"url": payload.url.absoluteString,
252+
"v": payload.v,
253+
"url": payload.url,
254254
"token": payload.token,
255255
]
256256
guard let data = try? JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]),

Sources/VocaMac/Views/MenuBarView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ struct MenuBarView: View {
9292
@EnvironmentObject var appState: AppState
9393
@ObservedObject var settingsManager: SettingsWindowManager
9494
@ObservedObject var updateWindowManager: UpdateWindowManager
95-
@ObservedObject private var gateway = GatewayProcessManager.shared
95+
@ObservedObject private var gateway = GatewayEmbedController.shared
9696
@StateObject private var processMonitor = ProcessMonitor()
9797
@State private var audioDevices: [AudioDevice] = []
9898

0 commit comments

Comments
 (0)