@hasna/recordings 0.1.23 → 0.1.25

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.
Files changed (48) hide show
  1. package/LICENSE +191 -170
  2. package/README.md +16 -7
  3. package/dist/cli/index.js +119 -65
  4. package/dist/cli/storage.d.ts +3 -0
  5. package/dist/cli/storage.d.ts.map +1 -0
  6. package/dist/db/pg-migrations.d.ts +1 -1
  7. package/dist/db/storage-config.d.ts +27 -0
  8. package/dist/db/storage-config.d.ts.map +1 -0
  9. package/dist/db/storage-sync.d.ts +36 -0
  10. package/dist/db/storage-sync.d.ts.map +1 -0
  11. package/dist/index.d.ts +2 -2
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +110 -50
  14. package/dist/lib/enhancer.d.ts.map +1 -1
  15. package/dist/lib/transcriber.d.ts +1 -0
  16. package/dist/lib/transcriber.d.ts.map +1 -1
  17. package/dist/mcp/http.d.ts +13 -0
  18. package/dist/mcp/http.d.ts.map +1 -0
  19. package/dist/mcp/index.d.ts +2 -1
  20. package/dist/mcp/index.d.ts.map +1 -1
  21. package/dist/mcp/index.js +519 -408
  22. package/dist/mcp/storage-tools.d.ts +3 -0
  23. package/dist/mcp/storage-tools.d.ts.map +1 -0
  24. package/dist/storage.d.ts +7 -0
  25. package/dist/storage.d.ts.map +1 -0
  26. package/dist/storage.js +5656 -0
  27. package/dist/version.d.ts +1 -1
  28. package/package.json +8 -3
  29. package/scripts/install_macos_app.sh +17 -0
  30. package/src/native/Recordings/App/RecordingsApp.swift +3 -2
  31. package/src/native/Recordings/RecordingsLib/FnKeyMonitor.swift +2 -0
  32. package/src/native/Recordings/RecordingsLib/MenuBarPopover.swift +101 -80
  33. package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +26 -0
  34. package/src/native/Recordings/RecordingsLib/RealtimeTranscriptionClient.swift +13 -1
  35. package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +103 -31
  36. package/src/native/Recordings/RecordingsLib/SettingsView.swift +5 -1
  37. package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +12 -0
  38. package/src/native/Recordings/RecordingsTests/OpenAIAPIKeyStoreTests.swift +49 -0
  39. package/src/native/Recordings/RecordingsTests/PasteTargetTests.swift +54 -0
  40. package/src/native/Recordings/RecordingsTests/TranscriptResolutionTests.swift +58 -0
  41. package/dist/cli/cloud.d.ts +0 -3
  42. package/dist/cli/cloud.d.ts.map +0 -1
  43. package/dist/db/cloud-config.d.ts +0 -14
  44. package/dist/db/cloud-config.d.ts.map +0 -1
  45. package/dist/db/cloud-sync.d.ts +0 -29
  46. package/dist/db/cloud-sync.d.ts.map +0 -1
  47. package/dist/mcp/cloud-tools.d.ts +0 -3
  48. package/dist/mcp/cloud-tools.d.ts.map +0 -1
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const VERSION = "0.1.20";
1
+ export declare const VERSION = "0.1.25";
2
2
  //# sourceMappingURL=version.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/recordings",
3
- "version": "0.1.23",
3
+ "version": "0.1.25",
4
4
  "type": "module",
5
5
  "description": "Speech-to-text recording tool with MCP and CLI — records, transcribes, and optionally enhances text using AI",
6
6
  "repository": {
@@ -17,13 +17,18 @@
17
17
  ".": {
18
18
  "import": "./dist/index.js",
19
19
  "types": "./dist/index.d.ts"
20
+ },
21
+ "./storage": {
22
+ "import": "./dist/storage.js",
23
+ "types": "./dist/storage.d.ts"
20
24
  }
21
25
  },
22
26
  "scripts": {
23
- "build": "bun run build:cli && bun run build:mcp && bun run build:lib && tsc --emitDeclarationOnly --outDir dist",
27
+ "clean": "rm -rf dist",
28
+ "build": "bun run clean && bun run build:cli && bun run build:mcp && bun run build:lib && tsc --emitDeclarationOnly --outDir dist",
24
29
  "build:cli": "bun build src/cli/index.ts --target=bun --outfile=dist/cli/index.js --external=commander --external=chalk --external=openai",
25
30
  "build:mcp": "bun build src/mcp/index.ts --target=bun --outfile=dist/mcp/index.js --external=@modelcontextprotocol/sdk --external=openai",
26
- "build:lib": "bun build src/index.ts --target=bun --outfile=dist/index.js --external=openai",
31
+ "build:lib": "bun build src/index.ts src/storage.ts --target=bun --outdir=dist --external=openai",
27
32
  "typecheck": "tsc --noEmit",
28
33
  "test": "bun test",
29
34
  "test:coverage": "bun test --coverage",
@@ -105,4 +105,21 @@ APP_CDHASH="$(current_cdhash "$APP_DEST" || true)"
105
105
  reset_stale_permission "Microphone" "kTCCServiceMicrophone" "${HOME}/Library/Application Support/com.apple.TCC/TCC.db" "$APP_CDHASH"
106
106
  reset_stale_permission "Accessibility" "kTCCServiceAccessibility" "/Library/Application Support/com.apple.TCC/TCC.db" "$APP_CDHASH"
107
107
 
108
+ # A second copy in ~/Applications splits TCC permissions and leaves users running
109
+ # stale builds. Keep that launch point but always point it at the fresh build.
110
+ ALT_COPY="${HOME}/Applications/Recordings.app"
111
+ if [ -d "$ALT_COPY" ]; then
112
+ rm -rf "$ALT_COPY"
113
+ cp -R "$APP_SOURCE" "$ALT_COPY" || true
114
+ echo "Updated stale copy at ${ALT_COPY} to the freshly built app."
115
+ fi
116
+
117
+ # If an old instance is running, restart it on the new build.
118
+ if pgrep -x Recordings >/dev/null 2>&1; then
119
+ pkill -x Recordings || true
120
+ sleep 1
121
+ open "$APP_DEST" || true
122
+ echo "Restarted Recordings.app on the new build."
123
+ fi
124
+
108
125
  echo "Installed Recordings.app from package: ${APP_DEST}"
@@ -25,10 +25,11 @@ struct RecordingsApp: App {
25
25
  }
26
26
  } label: {
27
27
  if engine.isRecording {
28
- Image(systemName: "record.circle.fill")
29
- .symbolRenderingMode(.multicolor)
28
+ Image(systemName: "waveform")
29
+ .symbolEffect(.variableColor.iterative, isActive: true)
30
30
  } else if engine.isTranscribing {
31
31
  Image(systemName: "ellipsis.circle")
32
+ .symbolEffect(.pulse, isActive: true)
32
33
  } else {
33
34
  Image(systemName: "mic.fill")
34
35
  }
@@ -12,6 +12,8 @@ final class FnKeyMonitor: @unchecked Sendable {
12
12
  private var healthCheckTimer: Timer?
13
13
  private var fnIsDown = false
14
14
 
15
+ var isRunning: Bool { eventTap != nil }
16
+
15
17
  private static let fnKeyCode: UInt16 = 63
16
18
 
17
19
  /// Start monitoring. Returns true if successful.
@@ -28,11 +28,10 @@ public struct MenuBarPopover: View {
28
28
  VStack(spacing: 0) {
29
29
  header
30
30
  .padding(.horizontal, 16).padding(.top, 12).padding(.bottom, 8)
31
- Divider()
32
31
  modePicker
33
- .padding(.horizontal, 16).padding(.top, 10)
32
+ .padding(.horizontal, 16).padding(.top, 2)
34
33
  recordingArea
35
- .padding(.horizontal, 16).padding(.top, 8).padding(.bottom, 12)
34
+ .padding(.horizontal, 16).padding(.top, 10).padding(.bottom, 12)
36
35
  Divider()
37
36
  if !engine.recentTranscriptions.isEmpty {
38
37
  filterBar
@@ -44,6 +43,8 @@ public struct MenuBarPopover: View {
44
43
  Divider()
45
44
  footerMenu
46
45
  }
46
+ .animation(.smooth(duration: 0.25), value: engine.isRecording)
47
+ .animation(.smooth(duration: 0.25), value: engine.isTranscribing)
47
48
  }
48
49
 
49
50
  // MARK: - Header
@@ -54,16 +55,21 @@ public struct MenuBarPopover: View {
54
55
  Image(systemName: "mic.fill")
55
56
  .foregroundStyle(.tint)
56
57
  Text("Hasna Recordings")
58
+ .font(.headline)
57
59
  Spacer()
58
60
  if let shortcut = KeyboardShortcuts.getShortcut(for: .toggleRecording) {
59
61
  Text(shortcut.description)
62
+ .font(.caption)
60
63
  .foregroundStyle(.secondary)
64
+ .padding(.horizontal, 7)
65
+ .padding(.vertical, 3)
66
+ .glassEffect(.regular, in: .capsule)
61
67
  }
62
68
  }
63
69
  if !projectStore.settings.projects.isEmpty {
64
70
  HStack(spacing: 4) {
65
71
  Image(systemName: "folder.fill")
66
- .foregroundColor(projectStore.activeProject != nil ? .accentColor : .secondary)
72
+ .foregroundStyle(projectStore.activeProject != nil ? AnyShapeStyle(.tint) : AnyShapeStyle(.secondary))
67
73
  Menu {
68
74
  Button("None") { projectStore.setActive(nil) }
69
75
  Divider()
@@ -102,91 +108,103 @@ public struct MenuBarPopover: View {
102
108
  }
103
109
  }
104
110
  .pickerStyle(.segmented)
111
+ .help(engine.mode.hint)
105
112
  }
106
113
 
107
114
  @ViewBuilder
108
115
  private var recordingArea: some View {
109
116
  if engine.isRecording {
110
- VStack(spacing: 6) {
111
- HStack {
112
- Circle().fill(.red).frame(width: 8, height: 8)
113
- Text(fmt(engine.recordingDuration))
114
- .monospacedDigit()
115
- Spacer()
116
- Button("Stop") { engine.stopAndTranscribe() }
117
- .controlSize(.small)
118
- }
119
- // Live streaming transcription text
120
- if !engine.liveTranscriptionText.isEmpty {
121
- Text(engine.liveTranscriptionText)
122
- .font(.callout)
123
- .foregroundStyle(.primary)
124
- .multilineTextAlignment(.leading)
125
- .lineLimit(3)
126
- .padding(.vertical, 4)
127
- Text("Listening...")
128
- .font(.caption)
129
- .foregroundStyle(.green)
130
- }
131
- if let project = projectStore.activeProject {
132
- HStack(spacing: 4) {
133
- Image(systemName: "folder.fill").foregroundStyle(.tint)
134
- Text(project.name).foregroundStyle(.secondary)
135
- Spacer()
136
- }
137
- }
117
+ VStack(spacing: 8) {
118
+ HStack {
119
+ Image(systemName: "waveform")
120
+ .symbolEffect(.variableColor.iterative, isActive: true)
121
+ .foregroundStyle(.red)
122
+ Text(fmt(engine.recordingDuration))
123
+ .monospacedDigit()
124
+ .contentTransition(.numericText())
125
+ .font(.title3.weight(.medium))
126
+ Spacer()
127
+ Button("Stop", systemImage: "stop.fill") { engine.stopAndTranscribe() }
128
+ .buttonStyle(.glassProminent)
129
+ .tint(.red)
130
+ .controlSize(.small)
138
131
  }
139
- .padding(10)
140
- .glassEffect(.regular)
132
+ if !engine.liveTranscriptionText.isEmpty {
133
+ Text(engine.liveTranscriptionText)
134
+ .font(.callout)
135
+ .foregroundStyle(.primary)
136
+ .frame(maxWidth: .infinity, alignment: .leading)
137
+ .lineLimit(3)
138
+ .contentTransition(.opacity)
139
+ } else {
140
+ Text("Listening…")
141
+ .font(.caption)
142
+ .foregroundStyle(.secondary)
143
+ .frame(maxWidth: .infinity, alignment: .leading)
144
+ }
145
+ if let project = projectStore.activeProject {
146
+ activeProjectTag(project)
147
+ }
148
+ }
149
+ .padding(12)
150
+ .glassEffect(.regular.tint(.red.opacity(0.1)), in: .rect(cornerRadius: 14))
141
151
  } else if engine.isTranscribing {
142
- VStack(spacing: 6) {
143
- HStack(spacing: 8) {
144
- ProgressView().controlSize(.small)
145
- Text("Transcribing...")
146
- .foregroundStyle(.secondary)
147
- Spacer()
148
- }
149
- // Show accumulated text during transcribing
150
- if !engine.liveTranscriptionText.isEmpty {
151
- Text(engine.liveTranscriptionText)
152
- .font(.callout)
153
- .foregroundStyle(.primary)
154
- .multilineTextAlignment(.leading)
155
- .lineLimit(3)
156
- .padding(.vertical, 4)
157
- }
158
- if let project = projectStore.activeProject {
159
- HStack(spacing: 4) {
160
- Image(systemName: "folder.fill").foregroundStyle(.tint)
161
- Text(project.name).foregroundStyle(.secondary)
162
- Spacer()
163
- }
164
- }
152
+ VStack(spacing: 8) {
153
+ HStack(spacing: 8) {
154
+ ProgressView().controlSize(.small)
155
+ Text("Transcribing")
156
+ .foregroundStyle(.secondary)
157
+ Spacer()
158
+ }
159
+ if !engine.liveTranscriptionText.isEmpty {
160
+ Text(engine.liveTranscriptionText)
161
+ .font(.callout)
162
+ .foregroundStyle(.primary)
163
+ .frame(maxWidth: .infinity, alignment: .leading)
164
+ .lineLimit(3)
165
165
  }
166
- .frame(maxWidth: .infinity)
167
- .padding(10)
168
- .glassEffect(.regular)
166
+ if let project = projectStore.activeProject {
167
+ activeProjectTag(project)
168
+ }
169
+ }
170
+ .frame(maxWidth: .infinity)
171
+ .padding(12)
172
+ .glassEffect(.regular, in: .rect(cornerRadius: 14))
169
173
  } else {
170
- VStack(spacing: 6) {
171
- Button { engine.startRecording() } label: {
172
- Label("Record", systemImage: "mic.circle.fill")
173
- }
174
- .controlSize(.regular)
174
+ VStack(spacing: 8) {
175
+ Button {
176
+ engine.startRecording()
177
+ } label: {
178
+ Label("Record", systemImage: "mic.fill")
179
+ .frame(maxWidth: .infinity)
180
+ }
181
+ .buttonStyle(.glassProminent)
182
+ .controlSize(.large)
175
183
 
176
- if let shortcut = KeyboardShortcuts.getShortcut(for: .toggleRecording) {
177
- Text("or hold \(shortcut.description)")
178
- .foregroundStyle(.tertiary)
179
- }
180
- Text(engine.statusMessage)
184
+ if let shortcut = KeyboardShortcuts.getShortcut(for: .toggleRecording) {
185
+ Text("or hold \(shortcut.description)")
181
186
  .font(.caption)
182
- .foregroundStyle(engine.statusMessage == "Ready" ? Color.secondary : Color.orange)
183
- .multilineTextAlignment(.center)
184
- .lineLimit(2)
187
+ .foregroundStyle(.tertiary)
185
188
  }
186
- .frame(maxWidth: .infinity)
187
- .padding(10)
188
- .glassEffect(.regular)
189
+ Text(engine.statusMessage)
190
+ .font(.caption)
191
+ .foregroundStyle(engine.statusMessage == "Ready" ? Color.secondary : Color.orange)
192
+ .multilineTextAlignment(.center)
193
+ .lineLimit(2)
194
+ }
195
+ .frame(maxWidth: .infinity)
196
+ .padding(12)
197
+ .glassEffect(.regular, in: .rect(cornerRadius: 14))
198
+ }
199
+ }
200
+
201
+ private func activeProjectTag(_ project: RecProject) -> some View {
202
+ HStack(spacing: 4) {
203
+ Image(systemName: "folder.fill").foregroundStyle(.tint)
204
+ Text(project.name).foregroundStyle(.secondary)
205
+ Spacer()
189
206
  }
207
+ .font(.caption)
190
208
  }
191
209
 
192
210
  // MARK: - Filter Bar
@@ -300,13 +318,16 @@ struct FilterChip: View {
300
318
  var body: some View {
301
319
  Button(action: action) {
302
320
  Text(label)
303
- .padding(.horizontal, 8)
304
- .padding(.vertical, 3)
305
- .background(isActive ? Color.accentColor.opacity(0.15) : Color.clear)
306
- .clipShape(RoundedRectangle(cornerRadius: 6))
321
+ .font(.caption)
322
+ .padding(.horizontal, 9)
323
+ .padding(.vertical, 4)
307
324
  }
308
325
  .buttonStyle(.plain)
309
326
  .foregroundStyle(isActive ? .primary : .secondary)
327
+ .glassEffect(
328
+ isActive ? .regular.tint(.accentColor.opacity(0.25)).interactive() : .regular.interactive(),
329
+ in: .capsule
330
+ )
310
331
  }
311
332
  }
312
333
 
@@ -21,6 +21,32 @@ enum OpenAIAPIKeyStore {
21
21
  return ""
22
22
  }
23
23
 
24
+ /// Persist the key into ~/.hasna/recordings/config.json so the CLI (which the app
25
+ /// shells out to for final transcription) uses the same key as the app itself.
26
+ static func save(key: String, homePath: String) throws {
27
+ let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
28
+ let dir = URL(fileURLWithPath: homePath)
29
+ .appendingPathComponent(".hasna")
30
+ .appendingPathComponent("recordings")
31
+ let url = dir.appendingPathComponent("config.json")
32
+
33
+ var json: [String: Any] = [:]
34
+ if let data = try? Data(contentsOf: url),
35
+ let existing = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
36
+ json = existing
37
+ }
38
+
39
+ if trimmed.isEmpty {
40
+ json.removeValue(forKey: "openai_api_key")
41
+ } else {
42
+ json["openai_api_key"] = trimmed
43
+ }
44
+
45
+ try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
46
+ let data = try JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys])
47
+ try data.write(to: url, options: .atomic)
48
+ }
49
+
24
50
  private static func loadConfigKey(homePath: String, environment: [String: String]) -> String? {
25
51
  let url = URL(fileURLWithPath: homePath)
26
52
  .appendingPathComponent(".hasna")
@@ -166,6 +166,12 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
166
166
  /// Returns the final accumulated transcription text.
167
167
  @discardableResult
168
168
  public func stop() -> String {
169
+ if isStreaming {
170
+ NativeAppLog.write(
171
+ "realtime session summary items=\(itemOrder.count) committed=\(committedItemIDs.count) completed=\(completedItemIDs.count) deltaChars=\(deltaTextByItem.values.map(\.count).reduce(0, +)) finalChars=\(accumulatedText.count) error=\(error ?? "none")",
172
+ homePath: homePath
173
+ )
174
+ }
169
175
  isStreaming = false
170
176
  isConfigured = false
171
177
  receiveTask?.cancel()
@@ -256,7 +262,13 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
256
262
  if let detail = json["error"] as? [String: Any],
257
263
  let msg = detail["message"] as? String {
258
264
  fputs("[RealtimeClient] Error: \(msg)\n", stderr)
259
- self.error = msg
265
+ // "buffer too small" on commit just means server VAD already consumed
266
+ // the audio — the transcript is intact, so don't surface it as a failure.
267
+ if msg.localizedCaseInsensitiveContains("buffer too small") {
268
+ NativeAppLog.write("realtime benign commit error: \(msg)", homePath: homePath)
269
+ } else {
270
+ self.error = msg
271
+ }
260
272
  }
261
273
 
262
274
  default:
@@ -137,6 +137,7 @@ public final class RecordingEngine: ObservableObject {
137
137
 
138
138
  // fn key monitor (CGEventTap-based, swallows fn to prevent emoji picker)
139
139
  private let fnMonitor = FnKeyMonitor()
140
+ private var permissionRetryTimer: Timer?
140
141
 
141
142
  let home = FileManager.default.homeDirectoryForCurrentUser.path
142
143
  private var audioDir: String { "\(home)/.hasna/recordings/audio" }
@@ -193,6 +194,16 @@ public final class RecordingEngine: ObservableObject {
193
194
  }
194
195
  }
195
196
 
197
+ // Granting Accessibility does not revive a tap that failed to create,
198
+ // so retry until permissions arrive instead of requiring a relaunch.
199
+ permissionRetryTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in
200
+ Task { @MainActor [weak self] in
201
+ guard let self, self.useFnKey, !self.fnMonitor.isRunning, AXIsProcessTrusted() else { return }
202
+ self.log("accessibility granted — retrying fn monitor")
203
+ self.updateFnMonitor()
204
+ }
205
+ }
206
+
196
207
  updateStatus()
197
208
  }
198
209
 
@@ -411,7 +422,9 @@ public final class RecordingEngine: ObservableObject {
411
422
  if let message = client.error, !message.isEmpty {
412
423
  await MainActor.run {
413
424
  self.log("realtime unavailable message=\(message)")
414
- self.statusMessage = "Realtime unavailable — will transcribe after recording"
425
+ if self.isRecording {
426
+ self.statusMessage = "Live preview unavailable — will transcribe after recording"
427
+ }
415
428
  }
416
429
  }
417
430
  }
@@ -464,21 +477,33 @@ public final class RecordingEngine: ObservableObject {
464
477
  self.liveTranscriptionText = ""
465
478
 
466
479
  if let audioPath, self.writeCapturedWAV(to: audioPath) {
467
- if let realtimeText {
468
- self.log("realtime preview ignored for final chars=\(realtimeText.count)")
469
- }
470
- self.log("transcribing captured full audio audioPath=\(audioPath)")
480
+ self.log("transcribing captured full audio audioPath=\(audioPath) realtimePreviewChars=\(realtimeText?.count ?? 0)")
471
481
  self.fallbackTranscribe(
472
482
  audioPath: audioPath,
473
483
  curMode: curMode,
474
484
  targetAppBundleIdentifier: targetAppBundleIdentifier,
475
485
  targetAppPid: targetAppPid,
476
486
  activeProjectId: activeProjectId,
477
- activeProjectName: activeProjectName
487
+ activeProjectName: activeProjectName,
488
+ realtimeText: realtimeText
478
489
  )
479
490
  } else {
480
- self.log("no audio captured")
481
- self.finish("No audio captured")
491
+ let resolved = Self.resolveFinalTranscript(cliText: nil, cliError: "No audio captured", realtimeText: realtimeText)
492
+ if let text = resolved.text {
493
+ self.log("no audio file written; using realtime transcript chars=\(text.count)")
494
+ self.isTranscribing = false
495
+ self.finishWithText(
496
+ text,
497
+ curMode: curMode,
498
+ targetAppBundleIdentifier: targetAppBundleIdentifier,
499
+ targetAppPid: targetAppPid,
500
+ activeProjectId: activeProjectId,
501
+ activeProjectName: activeProjectName
502
+ )
503
+ } else {
504
+ self.log("no audio captured")
505
+ self.finish(resolved.failureStatus ?? "No audio captured")
506
+ }
482
507
  }
483
508
 
484
509
  self.activeAudioPath = nil
@@ -502,7 +527,7 @@ public final class RecordingEngine: ObservableObject {
502
527
 
503
528
  let shortcutText = voiceShortcuts?.match(text)
504
529
  let output = shortcutText ?? text
505
- pasteIntoFrontApp(output, targetAppBundleIdentifier: targetAppBundleIdentifier, targetAppPid: targetAppPid)
530
+ pasteIntoFrontApp(output, targetAppBundleIdentifier: targetAppBundleIdentifier, targetAppPid: targetAppPid, restoreClipboard: true)
506
531
  recentTranscriptions.insert(
507
532
  TranscriptionResult(
508
533
  rawText: text,
@@ -584,7 +609,7 @@ public final class RecordingEngine: ObservableObject {
584
609
 
585
610
  // MARK: - Fallback Transcription
586
611
 
587
- private func fallbackTranscribe(audioPath: String, curMode: RecordingMode, targetAppBundleIdentifier: String?, targetAppPid: pid_t?, activeProjectId: String?, activeProjectName: String?) {
612
+ private func fallbackTranscribe(audioPath: String, curMode: RecordingMode, targetAppBundleIdentifier: String?, targetAppPid: pid_t?, activeProjectId: String?, activeProjectName: String?, realtimeText: String? = nil) {
588
613
  let homePath = home
589
614
 
590
615
  isTranscribing = true
@@ -592,25 +617,27 @@ public final class RecordingEngine: ObservableObject {
592
617
 
593
618
  Task.detached {
594
619
  let output = CLIRunner.run(["--json", "transcribe", audioPath, "--no-enhance"], home: homePath)
595
- if let error = CLIRunner.parseError(output) {
596
- await MainActor.run {
597
- self.log("fallback transcription failed error=\(error)")
598
- self.finish(error)
620
+ let cliError = CLIRunner.parseError(output)
621
+ let cliText = cliError == nil ? CLIRunner.parseJSON(output) : nil
622
+
623
+ await MainActor.run {
624
+ if let cliError {
625
+ self.log("cli transcription failed error=\(cliError)")
626
+ } else if cliText == nil {
627
+ self.log("cli transcription empty output=\(output.prefix(160))")
599
628
  }
600
- return
601
- }
602
629
 
603
- let text = CLIRunner.parseJSON(output)
604
- guard let text, !text.isEmpty else {
605
- await MainActor.run {
606
- self.log("fallback transcription empty output=\(output.prefix(160))")
607
- self.finish("Empty transcription")
630
+ let resolved = Self.resolveFinalTranscript(cliText: cliText, cliError: cliError, realtimeText: realtimeText)
631
+ guard let text = resolved.text else {
632
+ self.finish(resolved.failureStatus ?? "Transcription failed")
633
+ return
608
634
  }
609
- return
610
- }
611
635
 
612
- await MainActor.run {
613
- self.log("fallback transcription succeeded chars=\(text.count)")
636
+ if cliText == nil {
637
+ self.log("using realtime transcript fallback chars=\(text.count)")
638
+ } else {
639
+ self.log("cli transcription succeeded chars=\(text.count)")
640
+ }
614
641
  self.isTranscribing = false
615
642
  self.finishWithText(
616
643
  text,
@@ -707,9 +734,10 @@ public final class RecordingEngine: ObservableObject {
707
734
 
708
735
  // MARK: - Paste
709
736
 
710
- func pasteIntoFrontApp(_ text: String, targetAppBundleIdentifier: String? = nil, targetAppPid: pid_t? = nil) {
737
+ func pasteIntoFrontApp(_ text: String, targetAppBundleIdentifier: String? = nil, targetAppPid: pid_t? = nil, restoreClipboard: Bool = false) {
711
738
  log("paste requested chars=\(text.count) target=\(targetAppBundleIdentifier ?? "nil") pid=\(targetAppPid.map(String.init) ?? "nil") accessibility=\(AXIsProcessTrusted())")
712
739
  let pb = NSPasteboard.general
740
+ let previousClipboard = restoreClipboard ? pb.string(forType: .string) : nil
713
741
  pb.clearContents()
714
742
  pb.setString(text, forType: .string)
715
743
 
@@ -724,6 +752,7 @@ public final class RecordingEngine: ObservableObject {
724
752
 
725
753
  let myPID = ProcessInfo.processInfo.processIdentifier
726
754
  let runningApps = NSWorkspace.shared.runningApplications
755
+ let frontmostPid = NSWorkspace.shared.frontmostApplication?.processIdentifier
727
756
  let candidates = runningApps.map {
728
757
  PasteTargetCandidate(
729
758
  pid: $0.processIdentifier,
@@ -735,7 +764,8 @@ public final class RecordingEngine: ObservableObject {
735
764
  candidates: candidates,
736
765
  currentPid: myPID,
737
766
  targetBundleIdentifier: targetAppBundleIdentifier,
738
- targetPid: targetAppPid
767
+ targetPid: targetAppPid,
768
+ frontmostPid: frontmostPid
739
769
  )
740
770
  let targetApp = selectedTarget.flatMap { selected in
741
771
  runningApps.first { $0.processIdentifier == selected.pid }
@@ -748,9 +778,13 @@ public final class RecordingEngine: ObservableObject {
748
778
  }
749
779
 
750
780
  // Activate the exact app that owned focus when recording started, then paste after focus settles.
751
- app.activate(options: [.activateIgnoringOtherApps])
781
+ let alreadyFrontmost = app.processIdentifier == frontmostPid
782
+ if !alreadyFrontmost {
783
+ app.activate(options: [.activateIgnoringOtherApps])
784
+ }
752
785
 
753
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
786
+ let pasteDelay: TimeInterval = alreadyFrontmost ? 0.15 : 0.5
787
+ DispatchQueue.main.asyncAfter(deadline: .now() + pasteDelay) {
754
788
  let src = CGEventSource(stateID: .hidSystemState)
755
789
  if let down = CGEvent(keyboardEventSource: src, virtualKey: 0x09, keyDown: true),
756
790
  let up = CGEvent(keyboardEventSource: src, virtualKey: 0x09, keyDown: false) {
@@ -759,8 +793,19 @@ public final class RecordingEngine: ObservableObject {
759
793
  down.post(tap: .cgSessionEventTap)
760
794
  up.post(tap: .cgSessionEventTap)
761
795
  }
762
- self.log("paste event posted")
796
+ self.log("paste event posted target=\(app.bundleIdentifier ?? "?") alreadyFrontmost=\(alreadyFrontmost)")
763
797
  self.statusMessage = "Pasted (\(text.count) chars)"
798
+
799
+ if let previousClipboard {
800
+ // Give the target app time to consume the paste, then hand the clipboard back.
801
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
802
+ let pb = NSPasteboard.general
803
+ if pb.string(forType: .string) == text {
804
+ pb.clearContents()
805
+ pb.setString(previousClipboard, forType: .string)
806
+ }
807
+ }
808
+ }
764
809
  }
765
810
  }
766
811
 
@@ -768,7 +813,8 @@ public final class RecordingEngine: ObservableObject {
768
813
  candidates: [PasteTargetCandidate],
769
814
  currentPid: pid_t,
770
815
  targetBundleIdentifier: String?,
771
- targetPid: pid_t?
816
+ targetPid: pid_t?,
817
+ frontmostPid: pid_t? = nil
772
818
  ) -> PasteTargetCandidate? {
773
819
  candidates.first {
774
820
  guard let targetPid else { return false }
@@ -777,11 +823,28 @@ public final class RecordingEngine: ObservableObject {
777
823
  guard $0.pid != currentPid, $0.isRegularApp else { return false }
778
824
  guard let targetBundleIdentifier else { return false }
779
825
  return $0.bundleIdentifier == targetBundleIdentifier
826
+ } ?? candidates.first {
827
+ guard let frontmostPid else { return false }
828
+ return $0.pid == frontmostPid && $0.pid != currentPid && $0.isRegularApp
780
829
  } ?? candidates.first {
781
830
  $0.isRegularApp && $0.pid != currentPid
782
831
  }
783
832
  }
784
833
 
834
+ nonisolated static func resolveFinalTranscript(
835
+ cliText: String?,
836
+ cliError: String?,
837
+ realtimeText: String?
838
+ ) -> (text: String?, failureStatus: String?) {
839
+ if let cliText, !cliText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
840
+ return (cliText, nil)
841
+ }
842
+ if let realtimeText, !realtimeText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
843
+ return (realtimeText, nil)
844
+ }
845
+ return (nil, cliError ?? "Empty transcription")
846
+ }
847
+
785
848
  private func ensureAccessibilityPermission(prompt: Bool) -> Bool {
786
849
  if AXIsProcessTrusted() {
787
850
  return true
@@ -861,6 +924,15 @@ enum CLIRunner: Sendable {
861
924
  let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines)
862
925
  guard trimmed.hasPrefix("ERROR:") else { return nil }
863
926
  let message = trimmed.dropFirst("ERROR:".count).trimmingCharacters(in: .whitespacesAndNewlines)
927
+ let lowercased = message.lowercased()
928
+ if lowercased.contains("401") || lowercased.contains("incorrect api key")
929
+ || lowercased.contains("invalid_api_key") || lowercased.contains("invalid or expired") {
930
+ return "OpenAI API key invalid or expired — update it in Recordings Settings"
931
+ }
932
+ if lowercased.contains("429") || lowercased.contains("exceeded your current quota")
933
+ || lowercased.contains("insufficient_quota") || lowercased.contains("quota exceeded") {
934
+ return "OpenAI quota exceeded — check the OpenAI account billing"
935
+ }
864
936
  if message.contains("OpenAI API key not configured") {
865
937
  return "OpenAI API key not configured on this Mac"
866
938
  }
@@ -29,7 +29,11 @@ public struct SettingsView: View {
29
29
  Section("OpenAI") {
30
30
  SecureField("API key", text: $openAIAPIKey)
31
31
  .textFieldStyle(.roundedBorder)
32
- Text("Used for realtime transcription in the menu bar app.")
32
+ .onChange(of: openAIAPIKey) {
33
+ // Keep the CLI config in sync — final transcription shells out to it.
34
+ try? OpenAIAPIKeyStore.save(key: openAIAPIKey, homePath: engine.home)
35
+ }
36
+ Text("Used for live transcription and the final paste. Stored in ~/.hasna/recordings/config.json.")
33
37
  .foregroundStyle(.secondary)
34
38
  }
35
39