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 )
0 commit comments