@hasna/recordings 0.1.10 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,126 @@
1
+ import Foundation
2
+
3
+ struct RecProject: Codable, Identifiable, Sendable {
4
+ let id: String
5
+ var name: String
6
+ var path: String?
7
+ var systemPrompt: String?
8
+ var appBundleIds: [String]?
9
+
10
+ init(name: String, path: String? = nil, systemPrompt: String? = nil, appBundleIds: [String]? = nil) {
11
+ self.id = UUID().uuidString
12
+ self.name = name
13
+ self.path = path
14
+ self.systemPrompt = systemPrompt
15
+ self.appBundleIds = appBundleIds
16
+ }
17
+ }
18
+
19
+ struct ProjectSettings: Codable, Sendable {
20
+ var globalSystemPrompt: String
21
+ var projects: [RecProject]
22
+ var activeProjectId: String?
23
+
24
+ init() {
25
+ globalSystemPrompt = ""
26
+ projects = []
27
+ activeProjectId = nil
28
+ }
29
+ }
30
+
31
+ @MainActor
32
+ final class ProjectStore: ObservableObject {
33
+ @Published var settings = ProjectSettings()
34
+
35
+ private let filePath: String
36
+
37
+ var activeProject: RecProject? {
38
+ settings.projects.first { $0.id == settings.activeProjectId }
39
+ }
40
+
41
+ var effectiveSystemPrompt: String {
42
+ let global = settings.globalSystemPrompt.trimmingCharacters(in: .whitespacesAndNewlines)
43
+ let project = activeProject?.systemPrompt?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
44
+ if global.isEmpty && project.isEmpty { return "" }
45
+ if global.isEmpty { return project }
46
+ if project.isEmpty { return global }
47
+ return "\(global)\n\n\(project)"
48
+ }
49
+
50
+ init() {
51
+ let home = FileManager.default.homeDirectoryForCurrentUser.path
52
+ filePath = "\(home)/.hasna/recordings/projects.json"
53
+ load()
54
+ }
55
+
56
+ func load() {
57
+ guard FileManager.default.fileExists(atPath: filePath),
58
+ let data = FileManager.default.contents(atPath: filePath) else { return }
59
+ do {
60
+ settings = try JSONDecoder().decode(ProjectSettings.self, from: data)
61
+ } catch {
62
+ fputs("[ProjectStore] Failed to load: \(error)\n", stderr)
63
+ }
64
+ }
65
+
66
+ func save() {
67
+ do {
68
+ let data = try JSONEncoder().encode(settings)
69
+ let dir = (filePath as NSString).deletingLastPathComponent
70
+ try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
71
+ try data.write(to: URL(fileURLWithPath: filePath))
72
+ } catch {
73
+ fputs("[ProjectStore] Failed to save: \(error)\n", stderr)
74
+ }
75
+ }
76
+
77
+ func addProject(name: String, path: String? = nil, systemPrompt: String? = nil) {
78
+ let project = RecProject(name: name, path: path, systemPrompt: systemPrompt)
79
+ settings.projects.append(project)
80
+ save()
81
+ }
82
+
83
+ func updateProject(_ project: RecProject) {
84
+ guard let idx = settings.projects.firstIndex(where: { $0.id == project.id }) else { return }
85
+ settings.projects[idx] = project
86
+ save()
87
+ }
88
+
89
+ func removeProject(id: String) {
90
+ settings.projects.removeAll { $0.id == id }
91
+ if settings.activeProjectId == id { settings.activeProjectId = nil }
92
+ save()
93
+ }
94
+
95
+ func setActive(_ id: String?) {
96
+ settings.activeProjectId = id
97
+ save()
98
+ }
99
+
100
+ // MARK: - Auto-detection
101
+
102
+ nonisolated static func matchProject(windowTitle: String?, bundleId: String?, projects: [RecProject]) -> RecProject? {
103
+ if let bundleId {
104
+ if let match = projects.first(where: { $0.appBundleIds?.contains(bundleId) == true }) {
105
+ return match
106
+ }
107
+ }
108
+
109
+ if let title = windowTitle {
110
+ let lower = title.lowercased()
111
+ for project in projects {
112
+ let name = project.name.lowercased()
113
+ if lower.contains(name) { return project }
114
+ let slug = name.replacingOccurrences(of: " ", with: "-")
115
+ if lower.contains(slug) { return project }
116
+ if let path = project.path {
117
+ let dirName = (path as NSString).lastPathComponent.lowercased()
118
+ if lower.contains(dirName) { return project }
119
+ }
120
+ }
121
+ }
122
+
123
+ return nil
124
+ }
125
+
126
+ }
@@ -4,7 +4,7 @@ import KeyboardShortcuts
4
4
  // MARK: - Custom shortcut (not fn — fn is handled by FnKeyMonitor)
5
5
 
6
6
  extension KeyboardShortcuts.Name {
7
- static let toggleRecording = Self("toggleRecording")
7
+ static let toggleRecording = Self("toggleRecording", default: .init(.f5))
8
8
  }
9
9
 
10
10
  // MARK: - Recording Mode
@@ -24,9 +24,9 @@ enum RecordingMode: String, CaseIterable, Identifiable, Sendable {
24
24
  }
25
25
  var hint: String {
26
26
  switch self {
27
- case .pushToTalk: return "Hold fn to record, release to stop & paste"
28
- case .dictation: return "Hold fn to dictate, release to stop & paste"
29
- case .command: return "Select text, hold fn, speak to rewrite"
27
+ case .pushToTalk: return "Hold F5 or your chosen shortcut to record, then release to paste"
28
+ case .dictation: return "Hold F5 or your chosen shortcut to dictate, then release to paste"
29
+ case .command: return "Select text, then hold F5 or your chosen shortcut and release to rewrite"
30
30
  }
31
31
  }
32
32
  }
@@ -37,16 +37,24 @@ struct TranscriptionResult: Sendable {
37
37
  let rawText: String
38
38
  let processedText: String?
39
39
  let timestamp: Date
40
+ let projectId: String?
41
+ let projectName: String?
40
42
  var displayText: String { processedText ?? rawText }
41
43
  }
42
44
 
45
+ enum RecordingTrigger {
46
+ case manual
47
+ case fnKey
48
+ case keyboardShortcut
49
+ }
50
+
43
51
  // MARK: - Recording Engine
44
52
 
45
53
  @MainActor
46
54
  final class RecordingEngine: ObservableObject {
47
55
  @Published var isRecording = false
48
56
  @Published var mode: RecordingMode = .pushToTalk
49
- @Published var useFnKey: Bool = true {
57
+ @Published var useFnKey: Bool = false {
50
58
  didSet {
51
59
  UserDefaults.standard.set(useFnKey, forKey: "useFnKey")
52
60
  updateFnMonitor()
@@ -63,7 +71,12 @@ final class RecordingEngine: ObservableObject {
63
71
  private var stdinPipe: Pipe?
64
72
  private var currentAudioPath: String?
65
73
  private var recordingTimer: Timer?
74
+ private var activeTrigger: RecordingTrigger?
75
+ private var keyboardShortcutIsDown = false
76
+ private var targetAppBundleIdentifier: String?
77
+ private var targetAppPid: pid_t?
66
78
  private let maxDuration = 300
79
+ var projectStore: ProjectStore?
67
80
 
68
81
  // fn key monitor (CGEventTap-based, swallows fn to prevent emoji picker)
69
82
  private let fnMonitor = FnKeyMonitor()
@@ -75,27 +88,40 @@ final class RecordingEngine: ObservableObject {
75
88
  try? FileManager.default.createDirectory(atPath: audioDir, withIntermediateDirectories: true)
76
89
 
77
90
  // Load preferences
78
- useFnKey = UserDefaults.standard.object(forKey: "useFnKey") as? Bool ?? true
91
+ useFnKey = UserDefaults.standard.object(forKey: "useFnKey") as? Bool ?? false
92
+ if KeyboardShortcuts.getShortcut(for: .toggleRecording) == nil {
93
+ KeyboardShortcuts.setShortcut(.init(.f5), for: .toggleRecording)
94
+ }
79
95
 
80
96
  // Set up fn key monitor — hold fn to record, release to stop (like WisprFlow)
81
97
  fnMonitor.onFnKeyDown = { [weak self] in
82
98
  Task { @MainActor [weak self] in
83
99
  guard let self, self.useFnKey, !self.isRecording else { return }
84
- self.startRecording()
100
+ self.startRecording(trigger: .fnKey)
85
101
  }
86
102
  }
87
103
  fnMonitor.onFnKeyUp = { [weak self] in
88
104
  Task { @MainActor [weak self] in
89
- guard let self, self.useFnKey, self.isRecording else { return }
105
+ guard let self, self.useFnKey, self.isRecording, self.activeTrigger == .fnKey else { return }
90
106
  self.stopAndTranscribe()
91
107
  }
92
108
  }
93
109
  updateFnMonitor()
94
110
 
95
- // Set up custom shortcut (toggle mode press to start, press to stop)
111
+ KeyboardShortcuts.onKeyDown(for: .toggleRecording) { [weak self] in
112
+ Task { @MainActor [weak self] in
113
+ guard let self, !self.keyboardShortcutIsDown else { return }
114
+ self.keyboardShortcutIsDown = true
115
+ guard !self.isRecording else { return }
116
+ self.startRecording(trigger: .keyboardShortcut)
117
+ }
118
+ }
96
119
  KeyboardShortcuts.onKeyUp(for: .toggleRecording) { [weak self] in
97
120
  Task { @MainActor [weak self] in
98
- self?.toggleRecording()
121
+ guard let self, self.keyboardShortcutIsDown else { return }
122
+ self.keyboardShortcutIsDown = false
123
+ guard self.isRecording, self.activeTrigger == .keyboardShortcut else { return }
124
+ self.stopAndTranscribe()
99
125
  }
100
126
  }
101
127
 
@@ -106,7 +132,7 @@ final class RecordingEngine: ObservableObject {
106
132
  if useFnKey {
107
133
  let ok = fnMonitor.start()
108
134
  if !ok {
109
- statusMessage = "fn needs Accessibility permission (System Settings > Privacy & Security > Accessibility)"
135
+ statusMessage = "fn needs Input Monitoring / Accessibility permission, and Globe must be set to Do Nothing"
110
136
  }
111
137
  } else {
112
138
  fnMonitor.stop()
@@ -115,32 +141,36 @@ final class RecordingEngine: ObservableObject {
115
141
 
116
142
  func updateStatus() {
117
143
  if isRecording || isTranscribing { return }
118
-
119
- var parts: [String] = []
120
- if useFnKey {
121
- parts.append("fn (hold)")
122
- }
123
- if let shortcut = KeyboardShortcuts.getShortcut(for: .toggleRecording) {
124
- parts.append(shortcut.description)
125
- }
126
-
127
- if parts.isEmpty {
128
- statusMessage = "No shortcut set — enable fn or set a custom shortcut"
129
- } else {
130
- statusMessage = "Ready — press \(parts.joined(separator: " or ")) to record"
131
- }
144
+ statusMessage = "Ready"
132
145
  }
133
146
 
134
147
  // MARK: - Toggle
135
148
 
136
149
  func toggleRecording() {
137
- if isRecording { stopAndTranscribe() } else { startRecording() }
150
+ if isRecording { stopAndTranscribe() } else { startRecording(trigger: .manual) }
138
151
  }
139
152
 
140
153
  // MARK: - Start Recording
141
154
 
142
- func startRecording() {
155
+ func startRecording(trigger: RecordingTrigger = .manual) {
143
156
  guard !isRecording else { return }
157
+ activeTrigger = trigger
158
+ keyboardShortcutIsDown = trigger == .keyboardShortcut
159
+
160
+ let myPID = ProcessInfo.processInfo.processIdentifier
161
+ let frontmostApp = NSWorkspace.shared.frontmostApplication
162
+ let isOwnApp = frontmostApp?.processIdentifier == myPID
163
+ targetAppBundleIdentifier = isOwnApp ? nil : frontmostApp?.bundleIdentifier
164
+ targetAppPid = isOwnApp ? nil : frontmostApp?.processIdentifier
165
+
166
+ if let store = projectStore {
167
+ let windowTitle = Self.focusedWindowTitle(pid: frontmostApp?.processIdentifier)
168
+ let projects = store.settings.projects
169
+ let detected = ProjectStore.matchProject(windowTitle: windowTitle, bundleId: targetAppBundleIdentifier, projects: projects)
170
+ if let detected {
171
+ store.setActive(detected.id)
172
+ }
173
+ }
144
174
 
145
175
  let ts = DateFormatter()
146
176
  ts.dateFormat = "yyyyMMdd'T'HHmmss"
@@ -149,11 +179,12 @@ final class RecordingEngine: ObservableObject {
149
179
 
150
180
  let proc = Process()
151
181
  let stdin = Pipe()
182
+ let stderrPipe = Pipe()
152
183
  proc.executableURL = URL(fileURLWithPath: "/bin/bash")
153
184
  proc.arguments = ["-c", """
154
185
  export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"
155
186
  if command -v ffmpeg &>/dev/null; then
156
- ffmpeg -f avfoundation -i ":0" -ar 16000 -ac 1 -t \(maxDuration) "\(path)" -y 2>/dev/null
187
+ ffmpeg -f avfoundation -i ":0" -ar 16000 -ac 1 -t \(maxDuration) "\(path)" -y
157
188
  elif command -v rec &>/dev/null; then
158
189
  rec -r 16000 -c 1 -b 16 "\(path)" trim 0 \(maxDuration)
159
190
  else
@@ -162,7 +193,7 @@ final class RecordingEngine: ObservableObject {
162
193
  """]
163
194
  proc.standardInput = stdin
164
195
  proc.standardOutput = FileHandle.nullDevice
165
- proc.standardError = FileHandle.nullDevice
196
+ proc.standardError = stderrPipe
166
197
 
167
198
  do {
168
199
  try proc.run()
@@ -170,7 +201,11 @@ final class RecordingEngine: ObservableObject {
170
201
  stdinPipe = stdin
171
202
  isRecording = true
172
203
  recordingDuration = 0
173
- statusMessage = mode == .command ? "Speak your instruction..." : "Recording — press fn to stop"
204
+ statusMessage = switch (mode, trigger) {
205
+ case (.command, _): "Speak your instruction..."
206
+ case (_, .manual): "Recording — click Stop when finished"
207
+ case (_, .fnKey), (_, .keyboardShortcut): "Recording — release to stop"
208
+ }
174
209
 
175
210
  recordingTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in
176
211
  Task { @MainActor [weak self] in
@@ -178,6 +213,9 @@ final class RecordingEngine: ObservableObject {
178
213
  }
179
214
  }
180
215
  } catch {
216
+ activeTrigger = nil
217
+ keyboardShortcutIsDown = false
218
+ targetAppBundleIdentifier = nil
181
219
  statusMessage = "Failed: \(error.localizedDescription)"
182
220
  }
183
221
  }
@@ -204,6 +242,14 @@ final class RecordingEngine: ObservableObject {
204
242
  currentAudioPath = nil
205
243
  recordProcess = nil
206
244
  stdinPipe = nil
245
+ let targetAppBundleIdentifier = targetAppBundleIdentifier
246
+ let systemPrompt = projectStore?.effectiveSystemPrompt ?? ""
247
+ let activeProjectId = projectStore?.settings.activeProjectId
248
+ let activeProjectName = projectStore?.activeProject?.name
249
+ activeTrigger = nil
250
+ keyboardShortcutIsDown = false
251
+ self.targetAppBundleIdentifier = nil
252
+ self.targetAppPid = nil
207
253
  let homePath = home
208
254
 
209
255
  Task.detached {
@@ -212,6 +258,12 @@ final class RecordingEngine: ObservableObject {
212
258
  try? await Task.sleep(for: .milliseconds(300))
213
259
  if proc.isRunning { proc.interrupt() }
214
260
 
261
+ // Check if ffmpeg exited with an error (e.g. no mic permission)
262
+ if proc.terminationStatus != 0, proc.terminationStatus != 1 {
263
+ await MainActor.run { self.finish("Recording failed (mic access denied — check System Settings > Privacy & Security > Microphone)") }
264
+ return
265
+ }
266
+
215
267
  guard let audioPath else {
216
268
  await MainActor.run { self.finish("No audio file") }
217
269
  return
@@ -220,13 +272,21 @@ final class RecordingEngine: ObservableObject {
220
272
  let attrs = try? FileManager.default.attributesOfItem(atPath: audioPath)
221
273
  let size = (attrs?[.size] as? Int) ?? 0
222
274
  guard size >= 1000 else {
223
- await MainActor.run { self.finish("Audio too shortspeak longer") }
275
+ await MainActor.run { self.finish("No audio capturedcheck microphone permissions") }
224
276
  return
225
277
  }
226
278
 
227
- let output = CLIRunner.run(["transcribe", audioPath, "--json"], home: homePath)
228
- let text = CLIRunner.parseJSON(output)
279
+ var cliArgs = ["transcribe", audioPath, "--json"]
280
+ if !systemPrompt.isEmpty {
281
+ cliArgs += ["--system-prompt", systemPrompt]
282
+ }
283
+ let output = CLIRunner.run(cliArgs, home: homePath)
284
+ if let error = CLIRunner.parseError(output) {
285
+ await MainActor.run { self.finish(error) }
286
+ return
287
+ }
229
288
 
289
+ let text = CLIRunner.parseJSON(output)
230
290
  guard let text, !text.isEmpty else {
231
291
  await MainActor.run { self.finish("Empty transcription") }
232
292
  return
@@ -237,12 +297,12 @@ final class RecordingEngine: ObservableObject {
237
297
  if curMode == .command {
238
298
  self.runCommandMode(instruction: text)
239
299
  } else {
240
- self.pasteIntoFrontApp(text)
300
+ self.pasteIntoFrontApp(text, targetAppBundleIdentifier: targetAppBundleIdentifier)
241
301
  self.recentTranscriptions.insert(
242
- TranscriptionResult(rawText: text, processedText: nil, timestamp: Date()), at: 0
302
+ TranscriptionResult(rawText: text, processedText: nil, timestamp: Date(), projectId: activeProjectId, projectName: activeProjectName), at: 0
243
303
  )
244
304
  if self.recentTranscriptions.count > 20 { self.recentTranscriptions.removeLast() }
245
- self.statusMessage = "Pasted: \(String(text.prefix(50)))"
305
+ // statusMessage updated by pasteIntoFrontApp
246
306
  }
247
307
  }
248
308
  }
@@ -250,7 +310,7 @@ final class RecordingEngine: ObservableObject {
250
310
 
251
311
  private func finish(_ msg: String) {
252
312
  isTranscribing = false
253
- statusMessage = msg
313
+ updateStatus()
254
314
  }
255
315
 
256
316
  // MARK: - Command Mode
@@ -285,35 +345,53 @@ final class RecordingEngine: ObservableObject {
285
345
  }
286
346
  }
287
347
 
348
+ // MARK: - Window Title (Accessibility API)
349
+
350
+ private static func focusedWindowTitle(pid: pid_t?) -> String? {
351
+ guard let pid else { return nil }
352
+ let app = AXUIElementCreateApplication(pid)
353
+ var windowRef: CFTypeRef?
354
+ guard AXUIElementCopyAttributeValue(app, kAXFocusedWindowAttribute as CFString, &windowRef) == .success,
355
+ let window = windowRef else { return nil }
356
+ var titleRef: CFTypeRef?
357
+ guard AXUIElementCopyAttributeValue(window as! AXUIElement, kAXTitleAttribute as CFString, &titleRef) == .success,
358
+ let title = titleRef as? String else { return nil }
359
+ return title
360
+ }
361
+
288
362
  // MARK: - Paste
289
363
 
290
- func pasteIntoFrontApp(_ text: String) {
364
+ func pasteIntoFrontApp(_ text: String, targetAppBundleIdentifier: String? = nil) {
291
365
  let pb = NSPasteboard.general
292
366
  pb.clearContents()
293
367
  pb.setString(text, forType: .string)
294
368
 
295
- // Activate the last user app (not us)
296
369
  let myPID = ProcessInfo.processInfo.processIdentifier
297
- if let target = NSWorkspace.shared.runningApplications.first(where: {
370
+ let runningApps = NSWorkspace.shared.runningApplications
371
+ let targetApp = runningApps.first(where: {
372
+ guard $0.processIdentifier != myPID, $0.activationPolicy == .regular else { return false }
373
+ guard let bundleIdentifier = targetAppBundleIdentifier else { return false }
374
+ return $0.bundleIdentifier == bundleIdentifier
375
+ }) ?? runningApps.first(where: {
298
376
  $0.activationPolicy == .regular && $0.processIdentifier != myPID
299
- }) {
300
- target.activate()
301
- }
377
+ })
378
+
379
+ targetApp?.activate()
302
380
 
303
- // Wait for activation, then Cmd+V
304
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
381
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
305
382
  self.postKey(0x09, flags: .maskCommand)
383
+ self.updateStatus()
306
384
  }
307
385
  }
308
386
 
309
387
  private func postKey(_ key: CGKeyCode, flags: CGEventFlags) {
310
388
  let src = CGEventSource(stateID: .hidSystemState)
311
- let down = CGEvent(keyboardEventSource: src, virtualKey: key, keyDown: true)
312
- down?.flags = flags
313
- down?.post(tap: .cghidEventTap)
314
- let up = CGEvent(keyboardEventSource: src, virtualKey: key, keyDown: false)
315
- up?.flags = flags
316
- up?.post(tap: .cghidEventTap)
389
+ guard let down = CGEvent(keyboardEventSource: src, virtualKey: key, keyDown: true),
390
+ let up = CGEvent(keyboardEventSource: src, virtualKey: key, keyDown: false) else { return }
391
+ down.flags = flags
392
+ up.flags = flags
393
+ down.post(tap: .cgSessionEventTap)
394
+ up.post(tap: .cgSessionEventTap)
317
395
  }
318
396
  }
319
397
 
@@ -324,19 +402,41 @@ enum CLIRunner: Sendable {
324
402
  let bin = "\(home)/.bun/bin/recordings"
325
403
  let escaped = args.map { "\"\($0)\"" }.joined(separator: " ")
326
404
  let proc = Process()
327
- let pipe = Pipe()
405
+ let outPipe = Pipe()
406
+ let errPipe = Pipe()
328
407
  proc.executableURL = URL(fileURLWithPath: "/bin/bash")
329
408
  proc.arguments = ["-c", """
330
409
  export PATH="\(home)/.bun/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
331
410
  "\(bin)" \(escaped)
332
411
  """]
333
- proc.standardOutput = pipe
334
- proc.standardError = FileHandle.nullDevice
412
+ proc.standardOutput = outPipe
413
+ proc.standardError = errPipe
335
414
  do {
336
415
  try proc.run()
337
416
  proc.waitUntilExit()
338
- return String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
339
- } catch { return "" }
417
+ let stdout = String(data: outPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
418
+ let stderr = String(data: errPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
419
+ if proc.terminationStatus != 0 {
420
+ let details = stderr.isEmpty ? stdout : stderr
421
+ return "ERROR: \(details.trimmingCharacters(in: .whitespacesAndNewlines))"
422
+ }
423
+ return stdout.isEmpty ? stderr : stdout
424
+ } catch {
425
+ return "ERROR: \(error.localizedDescription)"
426
+ }
427
+ }
428
+
429
+ static func parseError(_ output: String) -> String? {
430
+ let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines)
431
+ guard trimmed.hasPrefix("ERROR:") else { return nil }
432
+ let message = trimmed.dropFirst("ERROR:".count).trimmingCharacters(in: .whitespacesAndNewlines)
433
+ if message.contains("OpenAI API key not configured") {
434
+ return "OpenAI API key not configured on this Mac"
435
+ }
436
+ if message.isEmpty {
437
+ return "Transcription failed"
438
+ }
439
+ return String(message.prefix(120))
340
440
  }
341
441
 
342
442
  static func parseJSON(_ output: String) -> String? {
@@ -350,6 +450,6 @@ enum CLIRunner: Sendable {
350
450
  }
351
451
  return output.components(separatedBy: "\n")
352
452
  .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
353
- .first { !$0.isEmpty && !$0.hasPrefix("{") && !$0.contains("Transcribing") && !$0.hasPrefix("Saved") }
453
+ .first { !$0.isEmpty && !$0.hasPrefix("{") && !$0.contains("Transcribing") && !$0.hasPrefix("Saved") && !$0.hasPrefix("ERROR:") }
354
454
  }
355
455
  }
@@ -1,3 +1,4 @@
1
+ @preconcurrency import Cocoa
1
2
  import SwiftUI
2
3
  import KeyboardShortcuts
3
4
 
@@ -5,11 +6,19 @@ import KeyboardShortcuts
5
6
  struct RecordingsApp: App {
6
7
  @StateObject private var engine = RecordingEngine()
7
8
  @StateObject private var shortcuts = VoiceShortcuts()
9
+ @StateObject private var projectStore = ProjectStore()
10
+
11
+ init() {
12
+ AXIsProcessTrustedWithOptions(
13
+ [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary
14
+ )
15
+ }
8
16
 
9
17
  var body: some Scene {
10
18
  MenuBarExtra {
11
- MenuBarPopover(engine: engine, shortcuts: shortcuts)
12
- .frame(width: 320, height: 440)
19
+ MenuBarPopover(engine: engine, shortcuts: shortcuts, projectStore: projectStore)
20
+ .frame(width: 320, height: 420)
21
+ .onAppear { engine.projectStore = projectStore }
13
22
  } label: {
14
23
  if engine.isRecording {
15
24
  Image(systemName: "record.circle.fill")
@@ -22,9 +31,8 @@ struct RecordingsApp: App {
22
31
  }
23
32
  .menuBarExtraStyle(.window)
24
33
 
25
- // Settings window — opened via SettingsLink
26
34
  Settings {
27
- SettingsView(engine: engine, shortcuts: shortcuts)
35
+ SettingsView(engine: engine, shortcuts: shortcuts, projectStore: projectStore)
28
36
  }
29
37
  }
30
38
  }