-
-
Notifications
You must be signed in to change notification settings - Fork 15
Expand file tree
/
Copy pathVocaMacApp.swift
More file actions
469 lines (412 loc) · 17.8 KB
/
Copy pathVocaMacApp.swift
File metadata and controls
469 lines (412 loc) · 17.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
// VocaMacApp.swift
// VocaMac
//
// Main entry point for the VocaMac application.
// Configures the app as a menu bar-only application (no Dock icon).
import SwiftUI
/// Manages the settings window for menu-bar-only apps
@MainActor
final class SettingsWindowManager: ObservableObject {
private var settingsWindow: NSWindow?
private var closeObserver: NSObjectProtocol?
/// 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)
NSApp.activate(ignoringOtherApps: true)
return
}
// Create the settings view
let settingsView = SettingsView(initialPage: requestedPage ?? .dictation)
.environmentObject(appState)
.environmentObject(self)
// Create a new window
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 760, height: 560),
styleMask: [.titled, .closable, .miniaturizable, .resizable],
backing: .buffered,
defer: false
)
window.title = "VocaMac Settings"
window.contentView = NSHostingView(rootView: settingsView)
window.center()
window.isReleasedWhenClosed = false
window.makeKeyAndOrderFront(nil)
self.settingsWindow = window
// Show in the Dock so the window can take focus.
DockVisibilityCoordinator.shared.windowDidOpen()
NSApp.activate(ignoringOtherApps: true)
// Held so it can be removed on close — a block-based observer lives
// until its token is released, so opening repeatedly would otherwise
// stack up observers.
closeObserver = NotificationCenter.default.addObserver(
forName: NSWindow.willCloseNotification,
object: window,
queue: .main
) { [weak self] _ in
MainActor.assumeIsolated {
guard let self else { return }
self.settingsWindow = nil
if let observer = self.closeObserver {
NotificationCenter.default.removeObserver(observer)
self.closeObserver = nil
}
DockVisibilityCoordinator.shared.windowDidClose()
}
}
}
/// 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.
/// Update details open in their own window rather than as a sheet inside the
/// MenuBarExtra popover: sheets there detach, fight the popover for focus,
/// and pull it down along with themselves when dismissed.
@MainActor
final class UpdateWindowManager: ObservableObject {
private var updateWindow: NSWindow?
private var closeObserver: NSObjectProtocol?
/// The release currently on screen, so a newer one can replace it.
private var presentedInfo: UpdateInfo?
func open(appState: AppState, info: UpdateInfo) {
if let window = updateWindow, window.isVisible {
// Already showing this release — just bring it forward. If a
// newer one arrived while the window was open, swap the contents
// rather than leaving the stale release on screen.
if presentedInfo != info {
window.contentView = NSHostingView(rootView: detailView(appState: appState, info: info))
presentedInfo = info
}
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
return
}
let updateView = detailView(appState: appState, info: info)
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 480, height: 420),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
window.title = "VocaMac Update"
window.contentView = NSHostingView(rootView: updateView)
window.center()
window.isReleasedWhenClosed = false
window.makeKeyAndOrderFront(nil)
self.updateWindow = window
self.presentedInfo = info
// Show in the Dock so the window can take focus.
DockVisibilityCoordinator.shared.windowDidOpen()
NSApp.activate(ignoringOtherApps: true)
closeObserver = NotificationCenter.default.addObserver(
forName: NSWindow.willCloseNotification,
object: window,
queue: .main
) { [weak self] _ in
MainActor.assumeIsolated {
guard let self else { return }
self.updateWindow = nil
self.presentedInfo = nil
if let observer = self.closeObserver {
NotificationCenter.default.removeObserver(observer)
self.closeObserver = nil
}
DockVisibilityCoordinator.shared.windowDidClose()
}
}
}
private func detailView(appState: AppState, info: UpdateInfo) -> some View {
UpdateDetailView(info: info, isPresented: Binding(
get: { true },
set: { [weak self] stillPresented in
if !stillPresented { self?.updateWindow?.close() }
}
))
.environmentObject(appState)
}
}
/// Manages the onboarding window
@MainActor
final class OnboardingWindowManager: ObservableObject {
private var onboardingWindow: NSWindow?
private var closeObserver: NSObjectProtocol?
var onCompletion: (() -> Void)?
func open(appState: AppState, force: Bool = false) {
// If window already exists, just bring it to front
if let window = onboardingWindow, window.isVisible {
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
return
}
// When manually re-triggered, reset completion flag so the
// monitor doesn't immediately close the window
if force {
appState.hasCompletedOnboarding = false
}
// Create the onboarding view
let onboardingView = OnboardingView()
.environmentObject(appState)
// Create a new window
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 600, height: 580),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
window.title = "Welcome to VocaMac"
window.contentView = NSHostingView(rootView: onboardingView)
window.center()
window.isReleasedWhenClosed = false
window.makeKeyAndOrderFront(nil)
self.onboardingWindow = window
// Show in the Dock so the window can take focus.
DockVisibilityCoordinator.shared.windowDidOpen()
NSApp.activate(ignoringOtherApps: true)
closeObserver = NotificationCenter.default.addObserver(
forName: NSWindow.willCloseNotification,
object: window,
queue: .main
) { [weak self] _ in
MainActor.assumeIsolated {
guard let self else { return }
self.onboardingWindow = nil
if let observer = self.closeObserver {
NotificationCenter.default.removeObserver(observer)
self.closeObserver = nil
}
DockVisibilityCoordinator.shared.windowDidClose()
}
}
// Monitor app state for onboarding completion on main thread
DispatchQueue.main.async {
self.monitorOnboardingCompletion(appState: appState)
}
}
private func monitorOnboardingCompletion(appState: AppState) {
Task {
while self.onboardingWindow?.isVisible == true {
await MainActor.run {
if appState.hasCompletedOnboarding {
self.onboardingWindow?.close()
}
}
try? await Task.sleep(nanoseconds: 100_000_000) // Check every 100ms
}
}
}
}
struct VocaMacApp: App {
@StateObject private var appState = AppState.production()
@StateObject private var settingsManager = SettingsWindowManager()
@StateObject private var updateWindowManager = UpdateWindowManager()
@StateObject private var onboardingManager = OnboardingWindowManager()
var body: some Scene {
// Menu bar presence — the primary UI for VocaMac
MenuBarExtra {
MenuBarView(settingsManager: settingsManager, updateWindowManager: updateWindowManager)
.environmentObject(appState)
} label: {
MenuBarIcon(appStatus: appState.appStatus)
.onAppear {
// Trigger startup from the SwiftUI lifecycle so it only runs
// on the AppState instance that SwiftUI actually retains.
// Previously, startup ran in AppState.init() which caused
// double initialization (and double event taps) because
// SwiftUI may instantiate the App struct more than once.
appState.triggerStartupIfNeeded()
}
}
.menuBarExtraStyle(.window)
}
@MainActor init() {
// Ensure only one instance of VocaMac is running
Self.ensureSingleInstance()
// For .app bundles, Dock hiding is handled by LSUIElement=true in Info.plist.
// For direct binary execution, we set it programmatically.
DispatchQueue.main.async {
NSApp?.setActivationPolicy(.accessory)
}
// Listen for "Show Setup Wizard" requests from Settings / Menu Bar
NotificationCenter.default.addObserver(
forName: .showOnboarding,
object: nil,
queue: .main
) { [self] _ in
Task { @MainActor [self] in
self.onboardingManager.open(appState: self.appState, force: true)
}
}
// Show onboarding on first launch
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [self] in
if !self.appState.hasCompletedOnboarding {
self.onboardingManager.open(appState: self.appState)
}
}
}
/// Terminate any other running instances of VocaMac
private static func ensureSingleInstance() {
let currentPID = ProcessInfo.processInfo.processIdentifier
let runningApps = NSRunningApplication.runningApplications(withBundleIdentifier: "com.vocamac.app")
for app in runningApps where app.processIdentifier != currentPID {
VocaLogger.info(.general, "Terminating previous instance (PID \(app.processIdentifier))")
app.terminate()
}
// Also kill by process name for direct binary execution (no bundle ID).
// Match against the full command line (pid + args) so a running
// headless CLI job (e.g. `VocaMac --transcribe-file ... --json`) is
// never mistaken for another GUI instance and killed mid-job.
let task = Process()
task.executableURL = URL(fileURLWithPath: "/usr/bin/pgrep")
task.arguments = ["-fl", "VocaMac"]
let pipe = Pipe()
task.standardOutput = pipe
do {
try task.run()
task.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
if let output = String(data: data, encoding: .utf8) {
for line in output.split(separator: "\n") {
let components = line.split(separator: " ", maxSplits: 1)
guard let pidField = components.first, let pid = Int32(pidField),
pid != currentPID else { continue }
let commandLine = components.count > 1 ? components[1] : ""
guard !Self.isHeadlessCLICommandLine(commandLine) else { continue }
VocaLogger.info(.general, "Killing previous VocaMac process (PID \(pid))")
kill(pid, SIGTERM)
}
}
} catch {
// pgrep not found or failed — not critical
}
}
/// Recognizes the headless CLI flags from `CLICommand.cliFlags` so the GUI
/// leaves an in-flight one-shot transcription running instead of killing it.
private static func isHeadlessCLICommandLine(_ commandLine: some StringProtocol) -> Bool {
CLICommand.cliFlags.contains { commandLine.contains($0) }
}
}
// MARK: - Menu Bar Icon
/// Renders the Voca mark in the menu bar with color changes based on app status.
///
/// Idle uses a template silhouette so macOS follows the menu bar appearance.
/// Recording tints that same mark brand teal. Processing and error keep SF Symbols.
///
/// MenuBarExtra strips SwiftUI `.foregroundStyle()` colors, so status colors
/// are applied via `NSImage` + `sourceAtop` with `isTemplate = false`.
///
/// States:
/// • idle → Voca mark (template, adapts to menu bar)
/// • recording → Voca mark in brand teal (mic hot)
/// • processing → yellow ellipsis (non-template, colored)
/// • error → orange warning (non-template, colored)
struct MenuBarIcon: View {
let appStatus: AppStatus
var body: some View {
Image(nsImage: makeMenuBarIcon())
}
private func makeMenuBarIcon() -> NSImage {
switch MenuBarIconStyle.style(for: appStatus) {
case .brandMarkTemplate:
if let mark = sizedMark() {
mark.isTemplate = true
return mark
}
return fallbackSymbol(named: "mic.fill", tint: nil)
case .brandMarkTinted:
if let mark = sizedMark() {
return tintedImage(base: mark, color: BrandAssets.brandGreen)
}
return fallbackSymbol(named: "mic.fill", tint: BrandAssets.brandGreen)
case .systemSymbol(let name):
return fallbackSymbol(named: name, tint: statusColor)
}
}
/// Menu-bar point size for the brand mark.
/// Slightly above the 16pt SF Symbol default so the line-art mic reads at a
/// similar visual weight to neighboring status items.
private static let markPointSize: CGFloat = 20
/// Sized copy of the bundled mic mark, or `nil` if the asset is missing.
///
/// The mark is taller than it is wide, so it is scaled to fit the square
/// slot (not stretched) and centered — that uses the full slot height.
private func sizedMark() -> NSImage? {
guard let mark = BrandAssets.mark else { return nil }
let slot = Self.markPointSize
let size = NSSize(width: slot, height: slot)
return NSImage(size: size, flipped: false) { rect in
NSGraphicsContext.current?.imageInterpolation = .high
let markSize = mark.size
guard markSize.width > 0, markSize.height > 0 else { return false }
let scale = min(rect.width / markSize.width, rect.height / markSize.height)
let drawSize = NSSize(width: markSize.width * scale, height: markSize.height * scale)
let drawRect = NSRect(
x: rect.midX - drawSize.width / 2,
y: rect.midY - drawSize.height / 2,
width: drawSize.width,
height: drawSize.height
)
mark.draw(in: drawRect)
return true
}
}
private func fallbackSymbol(named name: String, tint: NSColor?) -> NSImage {
let config = NSImage.SymbolConfiguration(pointSize: Self.markPointSize, weight: .regular)
guard let baseImage = NSImage(systemSymbolName: name, accessibilityDescription: "VocaMac")?
.withSymbolConfiguration(config) else {
return NSImage(systemSymbolName: "mic", accessibilityDescription: "VocaMac") ?? NSImage()
}
guard let tint else {
let copy = baseImage.copy() as? NSImage ?? baseImage
copy.isTemplate = true
return copy
}
return tintedImage(base: baseImage, color: tint)
}
private func tintedImage(base: NSImage, color: NSColor) -> NSImage {
let size = base.size
let tinted = NSImage(size: size, flipped: false) { rect in
base.draw(in: rect)
color.set()
rect.fill(using: .sourceAtop)
return true
}
tinted.isTemplate = false
return tinted
}
private var statusColor: NSColor {
switch appStatus {
case .idle: return BrandAssets.brandGreen
case .recording: return BrandAssets.brandGreen
case .processing: return .systemYellow
case .error: return .systemOrange
}
}
}