Skip to content

Commit f378dc7

Browse files
committed
Fix Gateway start timeout and Pair phone navigation
On start timeout, terminate the child process so Stop is not required to clear a stranded gateway. Open Settings Pair phone through durable page and pairing flags instead of delayed notifications.
1 parent 25cdb5e commit f378dc7

6 files changed

Lines changed: 152 additions & 64 deletions

File tree

Sources/VocaMac/App/VocaMacApp.swift

Lines changed: 37 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,24 +12,25 @@ final class SettingsWindowManager: ObservableObject {
1212
private var settingsWindow: NSWindow?
1313
private var closeObserver: NSObjectProtocol?
1414

15-
func open(appState: AppState, page: SettingsPage? = nil) {
15+
/// Sidebar page to apply when Settings appears. Survives first-open timing.
16+
@Published private(set) var requestedPage: SettingsPage?
17+
/// Pair-phone sheet to present when Gateway settings appears.
18+
@Published private(set) var pendingPairingPresentation = false
19+
20+
func open(appState: AppState, page: SettingsPage? = nil, showPairing: Bool = false) {
21+
recordOpenRequest(page: page, showPairing: showPairing)
22+
1623
// If window already exists, just bring it to front
1724
if let window = settingsWindow, window.isVisible {
1825
window.makeKeyAndOrderFront(nil)
1926
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-
}
2727
return
2828
}
2929

3030
// Create the settings view
31-
let settingsView = SettingsView()
31+
let settingsView = SettingsView(initialPage: requestedPage ?? .dictation)
3232
.environmentObject(appState)
33+
.environmentObject(self)
3334

3435
// Create a new window
3536
let window = NSWindow(
@@ -50,14 +51,6 @@ final class SettingsWindowManager: ObservableObject {
5051
DockVisibilityCoordinator.shared.windowDidOpen()
5152
NSApp.activate(ignoringOtherApps: true)
5253

53-
if let page {
54-
NotificationCenter.default.post(
55-
name: .selectSettingsPage,
56-
object: nil,
57-
userInfo: ["page": page.rawValue]
58-
)
59-
}
60-
6154
// Held so it can be removed on close — a block-based observer lives
6255
// until its token is released, so opening repeatedly would otherwise
6356
// stack up observers.
@@ -77,6 +70,33 @@ final class SettingsWindowManager: ObservableObject {
7770
}
7871
}
7972
}
73+
74+
/// Stores a sidebar page and/or pair-phone request until Settings consumes it.
75+
func recordOpenRequest(page: SettingsPage? = nil, showPairing: Bool = false) {
76+
if let page {
77+
requestedPage = page
78+
}
79+
if showPairing {
80+
pendingPairingPresentation = true
81+
if requestedPage == nil {
82+
requestedPage = .gateway
83+
}
84+
}
85+
}
86+
87+
/// Returns and clears the requested sidebar page, if any.
88+
func consumeRequestedPage() -> SettingsPage? {
89+
guard let page = requestedPage else { return nil }
90+
requestedPage = nil
91+
return page
92+
}
93+
94+
/// Returns whether a pair-phone sheet was requested, then clears the flag.
95+
func consumePendingPairingPresentation() -> Bool {
96+
guard pendingPairingPresentation else { return false }
97+
pendingPairingPresentation = false
98+
return true
99+
}
80100
}
81101

82102
/// Manages the standalone update details window.

Sources/VocaMac/Services/GatewayEmbedController.swift

Lines changed: 32 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -223,44 +223,33 @@ final class GatewayEmbedController: ObservableObject {
223223
do {
224224
try spawnNativeProcess(executable: binaryPath)
225225
} catch {
226-
status = .error(error.localizedDescription)
227-
lastErrorMessage = error.localizedDescription
226+
let message = error.localizedDescription
227+
status = .error(message)
228+
lastErrorMessage = message
229+
await terminateSpawnedProcess()
228230
return
229231
}
230232

231233
for _ in 0..<40 {
232234
try? await Task.sleep(nanoseconds: 250_000_000)
233235
await refreshStatus()
234236
if isLive { return }
235-
if case .error = status { return }
237+
if case .error = status {
238+
await terminateSpawnedProcess()
239+
return
240+
}
236241
}
237242

238243
if !isLive {
239244
let message = "Gateway did not become reachable on port \(GatewayPaths.defaultPort)."
240245
status = .error(message)
241246
lastErrorMessage = message
247+
await terminateSpawnedProcess()
242248
}
243249
}
244250

245251
func stop() async {
246-
if let process, process.isRunning {
247-
process.terminate()
248-
let deadline = Date().addingTimeInterval(2)
249-
while process.isRunning, Date() < deadline {
250-
try? await Task.sleep(nanoseconds: 50_000_000)
251-
}
252-
if process.isRunning {
253-
process.interrupt()
254-
}
255-
}
256-
stdoutPipe?.fileHandleForReading.readabilityHandler = nil
257-
self.process = nil
258-
self.stdoutPipe = nil
259-
260-
isLive = false
261-
isReady = false
262-
pairingPayload = nil
263-
pairingPayloadRaw = nil
252+
await terminateSpawnedProcess()
264253
status = .stopped
265254
lastErrorMessage = nil
266255
}
@@ -427,6 +416,28 @@ final class GatewayEmbedController: ObservableObject {
427416

428417
// MARK: - Private
429418

419+
/// SIGTERM/SIGINT the spawned child and drop the retained `Process`.
420+
/// Does not change `status` — callers record `.stopped` or `.error`.
421+
private func terminateSpawnedProcess() async {
422+
if let process, process.isRunning {
423+
process.terminate()
424+
let deadline = Date().addingTimeInterval(2)
425+
while process.isRunning, Date() < deadline {
426+
try? await Task.sleep(nanoseconds: 50_000_000)
427+
}
428+
if process.isRunning {
429+
process.interrupt()
430+
}
431+
}
432+
stdoutPipe?.fileHandleForReading.readabilityHandler = nil
433+
self.process = nil
434+
self.stdoutPipe = nil
435+
isLive = false
436+
isReady = false
437+
pairingPayload = nil
438+
pairingPayloadRaw = nil
439+
}
440+
430441
private func spawnNativeProcess(executable: String) throws {
431442
#if os(macOS)
432443
let process = Process()

Sources/VocaMac/Views/GatewaySettingsTab.swift

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,9 @@ import AppKit
99
import CoreImage
1010
import CoreImage.CIFilterBuiltins
1111

12-
extension Notification.Name {
13-
/// Menu bar asks Settings to select Gateway and show the pair sheet.
14-
static let showGatewayPairing = Notification.Name("com.vocamac.showGatewayPairing")
15-
/// Select a settings sidebar page (`userInfo["page"]` = SettingsPage.rawValue).
16-
static let selectSettingsPage = Notification.Name("com.vocamac.selectSettingsPage")
17-
}
18-
1912
struct GatewaySettingsTab: View {
2013
@ObservedObject private var gateway = GatewayEmbedController.shared
14+
@EnvironmentObject private var settingsWindowManager: SettingsWindowManager
2115
@State private var showingPairSheet = false
2216
@State private var copiedURL = false
2317

@@ -208,10 +202,12 @@ struct GatewaySettingsTab: View {
208202
.task {
209203
await gateway.refreshStatus()
210204
}
211-
.onReceive(NotificationCenter.default.publisher(for: .showGatewayPairing)) { _ in
212-
if gateway.status.allowsPairing {
213-
showingPairSheet = true
214-
}
205+
.onAppear {
206+
presentPendingPairingIfNeeded()
207+
}
208+
.onChange(of: settingsWindowManager.pendingPairingPresentation) { _, pending in
209+
guard pending else { return }
210+
presentPendingPairingIfNeeded()
215211
}
216212
.sheet(isPresented: $showingPairSheet) {
217213
GatewayPairPhoneSheet(
@@ -226,6 +222,13 @@ struct GatewaySettingsTab: View {
226222
}
227223
}
228224

225+
private func presentPendingPairingIfNeeded() {
226+
guard settingsWindowManager.consumePendingPairingPresentation() else { return }
227+
if gateway.status.allowsPairing {
228+
showingPairSheet = true
229+
}
230+
}
231+
229232
private var isStopDisabled: Bool {
230233
switch gateway.status {
231234
case .stopped:

Sources/VocaMac/Views/MenuBarView.swift

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -572,15 +572,7 @@ struct MenuBarView: View {
572572

573573
if gateway.status.allowsPairing {
574574
Button {
575-
settingsManager.open(appState: appState)
576-
NotificationCenter.default.post(
577-
name: .selectSettingsPage,
578-
object: nil,
579-
userInfo: ["page": SettingsPage.gateway.rawValue]
580-
)
581-
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
582-
NotificationCenter.default.post(name: .showGatewayPairing, object: nil)
583-
}
575+
settingsManager.open(appState: appState, page: .gateway, showPairing: true)
584576
} label: {
585577
HStack {
586578
Image(systemName: "qrcode")

Sources/VocaMac/Views/SettingsView.swift

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,19 @@ extension Notification.Name {
1313

1414
struct SettingsView: View {
1515
@EnvironmentObject var appState: AppState
16+
@EnvironmentObject var settingsWindowManager: SettingsWindowManager
1617

17-
@State private var selectedPage: SettingsPage? = .dictation
18+
@State private var selectedPage: SettingsPage?
1819
@State private var searchText = ""
1920
@State private var pageBeforeSearch: SettingsPage = .dictation
2021
/// Manual sidebar visibility. Avoids NavigationSplitView relocating system toggles.
2122
@State private var isSidebarVisible = true
2223

24+
init(initialPage: SettingsPage = .dictation) {
25+
_selectedPage = State(initialValue: initialPage)
26+
_pageBeforeSearch = State(initialValue: initialPage)
27+
}
28+
2329
private var matchCounts: [SettingsPage: Int] {
2430
SettingsSearchIndex.matchCounts(query: searchText)
2531
}
@@ -81,12 +87,18 @@ struct SettingsView: View {
8187
}
8288
}
8389
.frame(minWidth: 720, minHeight: 520)
84-
.onReceive(NotificationCenter.default.publisher(for: .selectSettingsPage)) { note in
85-
if let raw = note.userInfo?["page"] as? String,
86-
let page = SettingsPage(rawValue: raw) {
87-
selectedPage = page
88-
}
90+
.onAppear {
91+
applyRequestedSettingsPage()
8992
}
93+
.onChange(of: settingsWindowManager.requestedPage) { _, page in
94+
guard page != nil else { return }
95+
applyRequestedSettingsPage()
96+
}
97+
}
98+
99+
private func applyRequestedSettingsPage() {
100+
guard let page = settingsWindowManager.consumeRequestedPage() else { return }
101+
selectedPage = page
90102
}
91103

92104
private var settingsSidebar: some View {
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// SettingsWindowManagerTests.swift
2+
// VocaMac Tests
3+
//
4+
// Durable Settings navigation: requested page and pair-phone presentation
5+
// must survive until SettingsView / GatewaySettingsTab consume them.
6+
7+
import XCTest
8+
@testable import VocaMac
9+
10+
@MainActor
11+
final class SettingsWindowManagerTests: XCTestCase {
12+
13+
func testRequestedPageIsDurableUntilConsumed() {
14+
let manager = SettingsWindowManager()
15+
manager.recordOpenRequest(page: .gateway)
16+
17+
XCTAssertEqual(manager.requestedPage, .gateway)
18+
XCTAssertEqual(manager.consumeRequestedPage(), .gateway)
19+
XCTAssertNil(manager.requestedPage)
20+
XCTAssertNil(manager.consumeRequestedPage())
21+
}
22+
23+
func testPairingPresentationIsDurableUntilConsumed() {
24+
let manager = SettingsWindowManager()
25+
manager.recordOpenRequest(page: .gateway, showPairing: true)
26+
27+
XCTAssertTrue(manager.pendingPairingPresentation)
28+
XCTAssertTrue(manager.consumePendingPairingPresentation())
29+
XCTAssertFalse(manager.pendingPairingPresentation)
30+
XCTAssertFalse(manager.consumePendingPairingPresentation())
31+
}
32+
33+
func testPairingRequestDefaultsToGatewayPage() {
34+
let manager = SettingsWindowManager()
35+
manager.recordOpenRequest(showPairing: true)
36+
37+
XCTAssertEqual(manager.requestedPage, .gateway)
38+
XCTAssertTrue(manager.pendingPairingPresentation)
39+
XCTAssertEqual(manager.consumeRequestedPage(), .gateway)
40+
XCTAssertTrue(manager.consumePendingPairingPresentation())
41+
}
42+
43+
func testPageRequestDoesNotImplyPairing() {
44+
let manager = SettingsWindowManager()
45+
manager.recordOpenRequest(page: .audio)
46+
47+
XCTAssertEqual(manager.requestedPage, .audio)
48+
XCTAssertFalse(manager.pendingPairingPresentation)
49+
}
50+
}

0 commit comments

Comments
 (0)