@hasna/recordings 0.1.12 → 0.1.13

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.
package/dist/cli/index.js CHANGED
@@ -7,7 +7,7 @@ var __require = import.meta.require;
7
7
  var require_package = __commonJS((exports, module) => {
8
8
  module.exports = {
9
9
  name: "@hasna/recordings",
10
- version: "0.1.12",
10
+ version: "0.1.13",
11
11
  type: "module",
12
12
  description: "Speech-to-text recording tool with MCP and CLI \u2014 records, transcribes, and optionally enhances text using AI",
13
13
  repository: {
@@ -9893,15 +9893,40 @@ function runMigrations(db) {
9893
9893
  const result = db.query("SELECT MAX(id) as max_id FROM _migrations").get();
9894
9894
  const currentLevel = result?.max_id ?? -1;
9895
9895
  for (let i = currentLevel + 1;i < MIGRATIONS.length; i++) {
9896
- db.run(MIGRATIONS[i]);
9897
9896
  try {
9897
+ db.run(MIGRATIONS[i]);
9898
9898
  db.query("INSERT INTO _migrations (id) VALUES (?)").run(i);
9899
9899
  } catch (e) {
9900
+ if (isBenignMigrationError(e)) {
9901
+ db.query("INSERT OR IGNORE INTO _migrations (id) VALUES (?)").run(i);
9902
+ continue;
9903
+ }
9900
9904
  if (!(e instanceof Error && e.message.includes("UNIQUE constraint failed"))) {
9901
9905
  throw e;
9902
9906
  }
9903
9907
  }
9904
9908
  }
9909
+ repairSchemaDrift(db);
9910
+ }
9911
+ function isBenignMigrationError(error) {
9912
+ if (!(error instanceof Error))
9913
+ return false;
9914
+ return error.message.includes("duplicate column name");
9915
+ }
9916
+ function repairSchemaDrift(db) {
9917
+ ensureColumn(db, "recordings", "goal", "TEXT");
9918
+ ensureColumn(db, "recordings", "role", "TEXT");
9919
+ ensureColumn(db, "recordings", "task_list_id", "TEXT");
9920
+ ensureColumn(db, "recordings", "machine_id", "TEXT");
9921
+ ensureColumn(db, "recordings", "metadata", "TEXT DEFAULT '{}'");
9922
+ ensureColumn(db, "agents", "active_project_id", "TEXT REFERENCES projects(id) ON DELETE SET NULL");
9923
+ }
9924
+ function ensureColumn(db, table, column, definition) {
9925
+ const rows = db.query(`PRAGMA table_info(${table})`).all();
9926
+ if (rows.some((row) => row.name === column)) {
9927
+ return;
9928
+ }
9929
+ db.run(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
9905
9930
  }
9906
9931
  function getAdapter() {
9907
9932
  if (!_adapter) {
@@ -10441,7 +10466,7 @@ async function processText(rawText, config, systemPrompt) {
10441
10466
  }
10442
10467
 
10443
10468
  // src/version.ts
10444
- var VERSION = "0.1.12";
10469
+ var VERSION = "0.1.13";
10445
10470
 
10446
10471
  // src/cli/index.ts
10447
10472
  var program = new Command;
@@ -10767,6 +10792,62 @@ appCommand.command("status").description("Show installed Recordings.app status")
10767
10792
  console.log(`Native sources: ${status.native_sources_available ? "available" : "missing"}`);
10768
10793
  console.log(`Installed app: ${status.installed ? status.installed_app_path : "missing"}`);
10769
10794
  console.log(`Executable: ${status.executable ? "available" : "missing"}`);
10795
+ if (process.platform === "darwin") {
10796
+ console.log(`Microphone: ${status.microphone_permission}`);
10797
+ console.log(`Accessibility: ${status.accessibility_permission}`);
10798
+ console.log(`Log: ${status.log_path}`);
10799
+ }
10800
+ });
10801
+ appCommand.command("permissions").description("Show macOS permission state for Recordings.app").action(() => {
10802
+ const status = getMacOSAppStatus();
10803
+ const permissions = {
10804
+ platform: status.platform,
10805
+ bundle_id: "com.hasna.recordings",
10806
+ microphone: status.microphone_permission,
10807
+ accessibility: status.accessibility_permission,
10808
+ log_path: status.log_path
10809
+ };
10810
+ if (program.opts().json) {
10811
+ console.log(JSON.stringify(permissions, null, 2));
10812
+ return;
10813
+ }
10814
+ console.log(`Microphone: ${permissions.microphone}`);
10815
+ console.log(`Accessibility: ${permissions.accessibility}`);
10816
+ console.log(`Log: ${permissions.log_path}`);
10817
+ });
10818
+ appCommand.command("reset-permissions").description("Reset macOS Microphone and Accessibility permissions for Recordings.app").action(() => {
10819
+ if (process.platform !== "darwin") {
10820
+ console.error(chalk.red("Permission reset is only available on macOS"));
10821
+ process.exit(1);
10822
+ }
10823
+ const services = ["Microphone", "Accessibility"];
10824
+ for (const service of services) {
10825
+ const result = spawnSync("tccutil", ["reset", service, "com.hasna.recordings"], {
10826
+ stdio: "inherit"
10827
+ });
10828
+ if (result.error) {
10829
+ console.error(chalk.red(result.error.message));
10830
+ process.exit(1);
10831
+ }
10832
+ }
10833
+ });
10834
+ appCommand.command("log").description("Show the Recordings.app diagnostic log").option("-n, --lines <lines>", "Number of lines to print", "120").action((opts) => {
10835
+ const status = getMacOSAppStatus();
10836
+ if (!existsSync6(status.log_path)) {
10837
+ console.log("");
10838
+ return;
10839
+ }
10840
+ const lines = Math.max(1, parseInt(opts.lines, 10) || 120);
10841
+ const result = spawnSync("tail", ["-n", String(lines), status.log_path], {
10842
+ encoding: "utf8"
10843
+ });
10844
+ if (result.error) {
10845
+ console.error(chalk.red(result.error.message));
10846
+ process.exit(1);
10847
+ }
10848
+ process.stdout.write(result.stdout);
10849
+ if (result.stderr)
10850
+ process.stderr.write(result.stderr);
10770
10851
  });
10771
10852
  appCommand.command("open").description("Open the installed Recordings.app").action(() => {
10772
10853
  const status = getMacOSAppStatus();
@@ -11232,6 +11313,7 @@ function getMacOSAppStatus() {
11232
11313
  const home = process.env.HOME || process.env.USERPROFILE || "";
11233
11314
  const installedAppPath = pathJoin(home, ".hasna", "recordings", "Recordings.app");
11234
11315
  const executablePath = pathJoin(installedAppPath, "Contents", "MacOS", "Recordings");
11316
+ const logPath = pathJoin(home, ".hasna", "recordings", "Recordings.log");
11235
11317
  const installerPath = pathJoin(packageRoot, "scripts", "install_macos_app.sh");
11236
11318
  const nativeSourcesPath = pathJoin(packageRoot, "src", "native", "Recordings");
11237
11319
  return {
@@ -11244,9 +11326,47 @@ function getMacOSAppStatus() {
11244
11326
  installed_app_path: installedAppPath,
11245
11327
  installed: existsSync6(installedAppPath),
11246
11328
  executable_path: executablePath,
11247
- executable: existsSync6(executablePath)
11329
+ executable: existsSync6(executablePath),
11330
+ microphone_permission: getTccPermission("kTCCServiceMicrophone", home),
11331
+ accessibility_permission: getTccPermission("kTCCServiceAccessibility", home),
11332
+ log_path: logPath
11248
11333
  };
11249
11334
  }
11335
+ function getTccPermission(service, home) {
11336
+ if (process.platform !== "darwin")
11337
+ return "unsupported";
11338
+ const dbPaths = [
11339
+ pathJoin(home, "Library", "Application Support", "com.apple.TCC", "TCC.db"),
11340
+ pathJoin("/", "Library", "Application Support", "com.apple.TCC", "TCC.db")
11341
+ ];
11342
+ const sql = "select auth_value from access where service = '" + service.replace(/'/g, "''") + "' and client = 'com.hasna.recordings' order by last_modified desc limit 1;";
11343
+ for (const dbPath of dbPaths) {
11344
+ if (!existsSync6(dbPath))
11345
+ continue;
11346
+ const result = spawnSync("sqlite3", [dbPath, sql], {
11347
+ encoding: "utf8",
11348
+ stdio: ["ignore", "pipe", "ignore"]
11349
+ });
11350
+ const value = result.stdout.trim();
11351
+ if (value)
11352
+ return tccAuthValueLabel(value);
11353
+ }
11354
+ return "not_determined";
11355
+ }
11356
+ function tccAuthValueLabel(value) {
11357
+ switch (value) {
11358
+ case "0":
11359
+ return "denied";
11360
+ case "1":
11361
+ return "unknown";
11362
+ case "2":
11363
+ return "allowed";
11364
+ case "3":
11365
+ return "limited";
11366
+ default:
11367
+ return `unknown(${value})`;
11368
+ }
11369
+ }
11250
11370
  function findPackageRoot() {
11251
11371
  let current = dirname3(fileURLToPath(import.meta.url));
11252
11372
  while (true) {
@@ -1 +1 @@
1
- {"version":3,"file":"database.d.ts","sourceRoot":"","sources":["../../src/db/database.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AACtC,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AA+F7C,wBAAgB,WAAW,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,QAAQ,CAiBrD;AA8BD,wBAAgB,aAAa,IAAI,IAAI,CAMpC;AAED,wBAAgB,aAAa,IAAI,IAAI,CAGpC;AAED,oEAAoE;AACpE,wBAAgB,UAAU,IAAI,aAAa,CAK1C;AAED,wBAAgB,SAAS,IAAI,MAAM,CAElC;AAED,wBAAgB,SAAS,IAAI,MAAM,CAElC"}
1
+ {"version":3,"file":"database.d.ts","sourceRoot":"","sources":["../../src/db/database.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AACtC,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AA+F7C,wBAAgB,WAAW,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,QAAQ,CAiBrD;AAsED,wBAAgB,aAAa,IAAI,IAAI,CAMpC;AAED,wBAAgB,aAAa,IAAI,IAAI,CAGpC;AAED,oEAAoE;AACpE,wBAAgB,UAAU,IAAI,aAAa,CAK1C;AAED,wBAAgB,SAAS,IAAI,MAAM,CAElC;AAED,wBAAgB,SAAS,IAAI,MAAM,CAElC"}
package/dist/index.js CHANGED
@@ -9844,15 +9844,40 @@ function runMigrations(db) {
9844
9844
  const result = db.query("SELECT MAX(id) as max_id FROM _migrations").get();
9845
9845
  const currentLevel = result?.max_id ?? -1;
9846
9846
  for (let i = currentLevel + 1;i < MIGRATIONS.length; i++) {
9847
- db.run(MIGRATIONS[i]);
9848
9847
  try {
9848
+ db.run(MIGRATIONS[i]);
9849
9849
  db.query("INSERT INTO _migrations (id) VALUES (?)").run(i);
9850
9850
  } catch (e) {
9851
+ if (isBenignMigrationError(e)) {
9852
+ db.query("INSERT OR IGNORE INTO _migrations (id) VALUES (?)").run(i);
9853
+ continue;
9854
+ }
9851
9855
  if (!(e instanceof Error && e.message.includes("UNIQUE constraint failed"))) {
9852
9856
  throw e;
9853
9857
  }
9854
9858
  }
9855
9859
  }
9860
+ repairSchemaDrift(db);
9861
+ }
9862
+ function isBenignMigrationError(error) {
9863
+ if (!(error instanceof Error))
9864
+ return false;
9865
+ return error.message.includes("duplicate column name");
9866
+ }
9867
+ function repairSchemaDrift(db) {
9868
+ ensureColumn(db, "recordings", "goal", "TEXT");
9869
+ ensureColumn(db, "recordings", "role", "TEXT");
9870
+ ensureColumn(db, "recordings", "task_list_id", "TEXT");
9871
+ ensureColumn(db, "recordings", "machine_id", "TEXT");
9872
+ ensureColumn(db, "recordings", "metadata", "TEXT DEFAULT '{}'");
9873
+ ensureColumn(db, "agents", "active_project_id", "TEXT REFERENCES projects(id) ON DELETE SET NULL");
9874
+ }
9875
+ function ensureColumn(db, table, column, definition) {
9876
+ const rows = db.query(`PRAGMA table_info(${table})`).all();
9877
+ if (rows.some((row) => row.name === column)) {
9878
+ return;
9879
+ }
9880
+ db.run(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
9856
9881
  }
9857
9882
  function closeDatabase() {
9858
9883
  if (_db) {
package/dist/mcp/index.js CHANGED
@@ -21,7 +21,7 @@ var __require = import.meta.require;
21
21
  var require_package = __commonJS((exports, module) => {
22
22
  module.exports = {
23
23
  name: "@hasna/recordings",
24
- version: "0.1.12",
24
+ version: "0.1.13",
25
25
  type: "module",
26
26
  description: "Speech-to-text recording tool with MCP and CLI \u2014 records, transcribes, and optionally enhances text using AI",
27
27
  repository: {
@@ -14530,15 +14530,40 @@ function runMigrations(db) {
14530
14530
  const result = db.query("SELECT MAX(id) as max_id FROM _migrations").get();
14531
14531
  const currentLevel = result?.max_id ?? -1;
14532
14532
  for (let i = currentLevel + 1;i < MIGRATIONS.length; i++) {
14533
- db.run(MIGRATIONS[i]);
14534
14533
  try {
14534
+ db.run(MIGRATIONS[i]);
14535
14535
  db.query("INSERT INTO _migrations (id) VALUES (?)").run(i);
14536
14536
  } catch (e) {
14537
+ if (isBenignMigrationError(e)) {
14538
+ db.query("INSERT OR IGNORE INTO _migrations (id) VALUES (?)").run(i);
14539
+ continue;
14540
+ }
14537
14541
  if (!(e instanceof Error && e.message.includes("UNIQUE constraint failed"))) {
14538
14542
  throw e;
14539
14543
  }
14540
14544
  }
14541
14545
  }
14546
+ repairSchemaDrift(db);
14547
+ }
14548
+ function isBenignMigrationError(error) {
14549
+ if (!(error instanceof Error))
14550
+ return false;
14551
+ return error.message.includes("duplicate column name");
14552
+ }
14553
+ function repairSchemaDrift(db) {
14554
+ ensureColumn(db, "recordings", "goal", "TEXT");
14555
+ ensureColumn(db, "recordings", "role", "TEXT");
14556
+ ensureColumn(db, "recordings", "task_list_id", "TEXT");
14557
+ ensureColumn(db, "recordings", "machine_id", "TEXT");
14558
+ ensureColumn(db, "recordings", "metadata", "TEXT DEFAULT '{}'");
14559
+ ensureColumn(db, "agents", "active_project_id", "TEXT REFERENCES projects(id) ON DELETE SET NULL");
14560
+ }
14561
+ function ensureColumn(db, table, column, definition) {
14562
+ const rows = db.query(`PRAGMA table_info(${table})`).all();
14563
+ if (rows.some((row) => row.name === column)) {
14564
+ return;
14565
+ }
14566
+ db.run(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
14542
14567
  }
14543
14568
  function getAdapter() {
14544
14569
  if (!_adapter) {
@@ -14958,7 +14983,7 @@ async function processText(rawText, config, systemPrompt) {
14958
14983
  }
14959
14984
 
14960
14985
  // src/version.ts
14961
- var VERSION = "0.1.12";
14986
+ var VERSION = "0.1.13";
14962
14987
 
14963
14988
  // src/mcp/index.ts
14964
14989
  var config = loadConfig();
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const VERSION = "0.1.12";
1
+ export declare const VERSION = "0.1.13";
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.12",
3
+ "version": "0.1.13",
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": {
@@ -1,13 +1,13 @@
1
1
  {
2
- "originHash" : "99390ba8c7a8c4d4b60ff9bc6f60500c0cc28740d0a509ae347d8785b97022b5",
2
+ "originHash" : "a63dc0aa4ab0839e19cd7644cfd2bb42729aafbf9ef920adeb5c3ac856b7701e",
3
3
  "pins" : [
4
4
  {
5
5
  "identity" : "keyboardshortcuts",
6
6
  "kind" : "remoteSourceControl",
7
7
  "location" : "https://github.com/sindresorhus/KeyboardShortcuts",
8
8
  "state" : {
9
- "revision" : "1aef85578fdd4f9eaeeb8d53b7b4fc31bf08fe27",
10
- "version" : "2.4.0"
9
+ "revision" : "de5b143889b9aa90a997b18e311527095dfb28e0",
10
+ "version" : "1.12.0"
11
11
  }
12
12
  },
13
13
  {
@@ -8,7 +8,7 @@ let package = Package(
8
8
  .macOS(.v26)
9
9
  ],
10
10
  dependencies: [
11
- .package(url: "https://github.com/sindresorhus/KeyboardShortcuts", from: "2.0.0"),
11
+ .package(url: "https://github.com/sindresorhus/KeyboardShortcuts", exact: "1.12.0"),
12
12
  .package(url: "https://github.com/apple/swift-testing.git", .upToNextMinor(from: "0.99.0")),
13
13
  ],
14
14
  targets: [
@@ -0,0 +1,36 @@
1
+ import Foundation
2
+
3
+ enum NativeAppLog {
4
+ private static let lock = NSLock()
5
+
6
+ static func write(_ message: String, homePath: String = FileManager.default.homeDirectoryForCurrentUser.path) {
7
+ lock.lock()
8
+ defer { lock.unlock() }
9
+
10
+ let dir = "\(homePath)/.hasna/recordings"
11
+ let path = "\(dir)/Recordings.log"
12
+ let line = "[\(Self.timestamp())] \(message)\n"
13
+
14
+ do {
15
+ try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
16
+ if FileManager.default.fileExists(atPath: path),
17
+ let handle = FileHandle(forWritingAtPath: path) {
18
+ defer { try? handle.close() }
19
+ try handle.seekToEnd()
20
+ if let data = line.data(using: .utf8) {
21
+ try handle.write(contentsOf: data)
22
+ }
23
+ } else {
24
+ try line.write(toFile: path, atomically: true, encoding: .utf8)
25
+ }
26
+ } catch {
27
+ fputs("[Recordings] log write failed: \(error.localizedDescription)\n", stderr)
28
+ }
29
+ }
30
+
31
+ private static func timestamp() -> String {
32
+ let formatter = ISO8601DateFormatter()
33
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
34
+ return formatter.string(from: Date())
35
+ }
36
+ }
@@ -1,12 +1,12 @@
1
1
  import AVFoundation
2
2
  @preconcurrency import ApplicationServices
3
3
  import SwiftUI
4
- import KeyboardShortcuts
4
+ @preconcurrency import KeyboardShortcuts
5
5
 
6
6
  // MARK: - Custom shortcut (not fn — fn is handled by FnKeyMonitor)
7
7
 
8
8
  extension KeyboardShortcuts.Name {
9
- static let toggleRecording = Self("toggleRecording", default: .init(.f5))
9
+ @MainActor static let toggleRecording = Self("toggleRecording", default: .init(.f5))
10
10
  }
11
11
 
12
12
  // MARK: - Recording Mode
@@ -142,6 +142,7 @@ public final class RecordingEngine: ObservableObject {
142
142
 
143
143
  public init() {
144
144
  try? FileManager.default.createDirectory(atPath: audioDir, withIntermediateDirectories: true)
145
+ log("RecordingEngine init; microphone=\(microphonePermissionLabel); accessibility=\(accessibilityPermissionLabel)")
145
146
 
146
147
  // Load preferences
147
148
  if let savedMode = UserDefaults.standard.string(forKey: "recordingMode"),
@@ -188,9 +189,66 @@ public final class RecordingEngine: ObservableObject {
188
189
  updateStatus()
189
190
  }
190
191
 
192
+ public var microphonePermissionLabel: String {
193
+ switch AVCaptureDevice.authorizationStatus(for: .audio) {
194
+ case .authorized:
195
+ return "Microphone allowed"
196
+ case .notDetermined:
197
+ return "Microphone not requested"
198
+ case .denied:
199
+ return "Microphone denied"
200
+ case .restricted:
201
+ return "Microphone restricted"
202
+ @unknown default:
203
+ return "Microphone unknown"
204
+ }
205
+ }
206
+
207
+ public var accessibilityPermissionLabel: String {
208
+ AXIsProcessTrusted() ? "Accessibility allowed" : "Accessibility needed"
209
+ }
210
+
211
+ public func requestMicrophonePermission() {
212
+ log("requestMicrophonePermission status=\(AVCaptureDevice.authorizationStatus(for: .audio).rawValue)")
213
+ AVCaptureDevice.requestAccess(for: .audio) { [weak self] granted in
214
+ Task { @MainActor [weak self] in
215
+ guard let self else { return }
216
+ self.log("requestMicrophonePermission result granted=\(granted)")
217
+ self.statusMessage = granted
218
+ ? "Microphone allowed"
219
+ : "Enable Microphone permission for Recordings in System Settings"
220
+ self.objectWillChange.send()
221
+ }
222
+ }
223
+ }
224
+
225
+ public func requestAccessibilityPermission() {
226
+ let trusted = ensureAccessibilityPermission(prompt: true)
227
+ log("requestAccessibilityPermission trusted=\(trusted)")
228
+ statusMessage = trusted
229
+ ? "Accessibility allowed"
230
+ : "Enable Accessibility permission for Recordings to paste"
231
+ objectWillChange.send()
232
+ }
233
+
234
+ public func openMicrophoneSettings() {
235
+ openPrivacySettings("Privacy_Microphone")
236
+ }
237
+
238
+ public func openAccessibilitySettings() {
239
+ openPrivacySettings("Privacy_Accessibility")
240
+ }
241
+
242
+ private func openPrivacySettings(_ pane: String) {
243
+ if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?\(pane)") {
244
+ NSWorkspace.shared.open(url)
245
+ }
246
+ }
247
+
191
248
  private func updateFnMonitor() {
192
249
  if useFnKey {
193
250
  let ok = fnMonitor.start()
251
+ log("fn monitor start ok=\(ok)")
194
252
  if !ok {
195
253
  statusMessage = "fn needs Input Monitoring / Accessibility permission, and Globe must be set to Do Nothing"
196
254
  }
@@ -214,6 +272,7 @@ public final class RecordingEngine: ObservableObject {
214
272
 
215
273
  public func startRecording(trigger: RecordingTrigger = .manual) {
216
274
  guard !isRecording else { return }
275
+ log("startRecording trigger=\(trigger) microphoneStatus=\(AVCaptureDevice.authorizationStatus(for: .audio).rawValue) accessibility=\(AXIsProcessTrusted())")
217
276
  activeTrigger = trigger
218
277
  keyboardShortcutIsDown = trigger == .keyboardShortcut
219
278
 
@@ -237,9 +296,11 @@ public final class RecordingEngine: ObservableObject {
237
296
  startNativeRecording()
238
297
  case .notDetermined:
239
298
  statusMessage = "Allow microphone access to record"
299
+ log("requesting microphone access before recording")
240
300
  AVCaptureDevice.requestAccess(for: .audio) { [weak self] granted in
241
301
  Task { @MainActor [weak self] in
242
302
  guard let self else { return }
303
+ self.log("microphone access response granted=\(granted)")
243
304
  if granted {
244
305
  self.startNativeRecording()
245
306
  } else {
@@ -250,6 +311,7 @@ public final class RecordingEngine: ObservableObject {
250
311
  }
251
312
  case .denied, .restricted:
252
313
  resetRecordingIntent()
314
+ log("microphone permission blocked status=\(AVCaptureDevice.authorizationStatus(for: .audio).rawValue)")
253
315
  statusMessage = "Enable Microphone permission for Recordings in System Settings"
254
316
  @unknown default:
255
317
  resetRecordingIntent()
@@ -262,12 +324,18 @@ public final class RecordingEngine: ObservableObject {
262
324
  pcmStreamState = streamState
263
325
 
264
326
  let apiKey = openAIAPIKey
327
+ log("startNativeRecording apiKeyConfigured=\(!apiKey.isEmpty)")
265
328
  if !apiKey.isEmpty {
266
329
  startRealtimeStreaming(apiKey: apiKey)
267
330
  }
268
331
 
269
332
  let client = realtimeClient
333
+ let homePath = home
334
+ let firstChunkLogged = LockedFlag()
270
335
  let recorder = NativePCMRecorder { [weak client] data in
336
+ if firstChunkLogged.take() {
337
+ NativeAppLog.write("native recorder received first PCM chunk bytes=\(data.count)", homePath: homePath)
338
+ }
271
339
  Task {
272
340
  let chunks = await streamState.append(data, chunkSize: 4_800)
273
341
  for chunk in chunks {
@@ -278,6 +346,7 @@ public final class RecordingEngine: ObservableObject {
278
346
 
279
347
  do {
280
348
  try recorder.start()
349
+ log("native recorder started")
281
350
  nativeRecorder = recorder
282
351
  isRecording = true
283
352
  recordingDuration = 0
@@ -298,6 +367,7 @@ public final class RecordingEngine: ObservableObject {
298
367
  }
299
368
  }
300
369
  } catch {
370
+ log("native recorder failed error=\(error.localizedDescription)")
301
371
  realtimeClient?.stop()
302
372
  realtimeClient = nil
303
373
  streamingTask?.cancel()
@@ -314,9 +384,11 @@ public final class RecordingEngine: ObservableObject {
314
384
  let systemPrompt = projectStore?.effectiveSystemPrompt ?? ""
315
385
  let client = RealtimeTranscriptionClient(apiKey: apiKey, homePath: home)
316
386
  realtimeClient = client
387
+ log("realtime streaming task starting")
317
388
 
318
389
  streamingTask = Task {
319
390
  await client.startStreaming(systemPrompt: systemPrompt)
391
+ self.log("realtime start completed streaming=\(client.isStreaming) error=\(client.error ?? "")")
320
392
 
321
393
  // Receive deltas
322
394
  while client.isStreaming {
@@ -332,6 +404,7 @@ public final class RecordingEngine: ObservableObject {
332
404
 
333
405
  if let message = client.error, !message.isEmpty {
334
406
  await MainActor.run {
407
+ self.log("realtime unavailable message=\(message)")
335
408
  self.statusMessage = "Realtime unavailable — will transcribe after recording"
336
409
  }
337
410
  }
@@ -342,6 +415,7 @@ public final class RecordingEngine: ObservableObject {
342
415
 
343
416
  public func stopAndTranscribe() {
344
417
  guard isRecording else { return }
418
+ log("stopAndTranscribe")
345
419
 
346
420
  recordingTimer?.invalidate()
347
421
  recordingTimer = nil
@@ -370,6 +444,7 @@ public final class RecordingEngine: ObservableObject {
370
444
  }
371
445
  self.recordedPCM = await pcmStreamState.capturedPCM()
372
446
  }
447
+ self.log("captured pcm bytes=\(self.recordedPCM.count)")
373
448
 
374
449
  let streamingResult = await client?.finish() ?? ""
375
450
 
@@ -382,6 +457,7 @@ public final class RecordingEngine: ObservableObject {
382
457
  self.liveTranscriptionText = ""
383
458
 
384
459
  if let text {
460
+ self.log("finish using realtime text chars=\(text.count)")
385
461
  self.isTranscribing = false
386
462
  self.finishWithText(
387
463
  text,
@@ -391,6 +467,7 @@ public final class RecordingEngine: ObservableObject {
391
467
  activeProjectName: activeProjectName
392
468
  )
393
469
  } else if let audioPath, self.writeCapturedWAV(to: audioPath) {
470
+ self.log("falling back to CLI transcription audioPath=\(audioPath)")
394
471
  self.fallbackTranscribe(
395
472
  audioPath: audioPath,
396
473
  curMode: curMode,
@@ -399,6 +476,7 @@ public final class RecordingEngine: ObservableObject {
399
476
  activeProjectName: activeProjectName
400
477
  )
401
478
  } else {
479
+ self.log("no audio captured")
402
480
  self.finish("No audio captured")
403
481
  }
404
482
 
@@ -408,6 +486,7 @@ public final class RecordingEngine: ObservableObject {
408
486
  }
409
487
 
410
488
  private func finishWithText(_ text: String, curMode: RecordingMode, targetAppBundleIdentifier: String?, activeProjectId: String?, activeProjectName: String?) {
489
+ log("finishWithText mode=\(curMode.rawValue) chars=\(text.count)")
411
490
  if curMode == .command {
412
491
  runCommandMode(instruction: text)
413
492
  return
@@ -439,8 +518,10 @@ public final class RecordingEngine: ObservableObject {
439
518
  bitsPerSample: 16,
440
519
  to: URL(fileURLWithPath: path)
441
520
  )
521
+ log("wrote wav path=\(path) pcmBytes=\(recordedPCM.count)")
442
522
  return true
443
523
  } catch {
524
+ log("failed to save wav error=\(error.localizedDescription)")
444
525
  statusMessage = "Failed to save audio"
445
526
  return false
446
527
  }
@@ -504,17 +585,24 @@ public final class RecordingEngine: ObservableObject {
504
585
  Task.detached {
505
586
  let output = CLIRunner.run(["--json", "transcribe", audioPath, "--no-enhance"], home: homePath)
506
587
  if let error = CLIRunner.parseError(output) {
507
- await MainActor.run { self.finish(error) }
588
+ await MainActor.run {
589
+ self.log("fallback transcription failed error=\(error)")
590
+ self.finish(error)
591
+ }
508
592
  return
509
593
  }
510
594
 
511
595
  let text = CLIRunner.parseJSON(output)
512
596
  guard let text, !text.isEmpty else {
513
- await MainActor.run { self.finish("Empty transcription") }
597
+ await MainActor.run {
598
+ self.log("fallback transcription empty output=\(output.prefix(160))")
599
+ self.finish("Empty transcription")
600
+ }
514
601
  return
515
602
  }
516
603
 
517
604
  await MainActor.run {
605
+ self.log("fallback transcription succeeded chars=\(text.count)")
518
606
  self.isTranscribing = false
519
607
  self.finishWithText(
520
608
  text,
@@ -534,6 +622,7 @@ public final class RecordingEngine: ObservableObject {
534
622
  }
535
623
 
536
624
  private func finish(_ msg: String) {
625
+ log("finish status=\(msg)")
537
626
  isTranscribing = false
538
627
  liveTranscriptionText = ""
539
628
  statusMessage = msg
@@ -550,6 +639,7 @@ public final class RecordingEngine: ObservableObject {
550
639
 
551
640
  private func runCommandMode(instruction: String) {
552
641
  guard ensureAccessibilityPermission(prompt: true) else {
642
+ log("command mode blocked by accessibility permission")
553
643
  statusMessage = "Enable Accessibility permission for Recordings to rewrite selected text"
554
644
  return
555
645
  }
@@ -609,11 +699,13 @@ public final class RecordingEngine: ObservableObject {
609
699
  // MARK: - Paste
610
700
 
611
701
  func pasteIntoFrontApp(_ text: String, targetAppBundleIdentifier: String? = nil) {
702
+ log("paste requested chars=\(text.count) target=\(targetAppBundleIdentifier ?? "nil") accessibility=\(AXIsProcessTrusted())")
612
703
  let pb = NSPasteboard.general
613
704
  pb.clearContents()
614
705
  pb.setString(text, forType: .string)
615
706
 
616
707
  guard ensureAccessibilityPermission(prompt: true) else {
708
+ log("paste blocked by accessibility permission; copied to clipboard")
617
709
  self.statusMessage = "Copied — enable Accessibility permission for Recordings to paste"
618
710
  return
619
711
  }
@@ -629,6 +721,7 @@ public final class RecordingEngine: ObservableObject {
629
721
  })
630
722
 
631
723
  guard let app = targetApp else {
724
+ log("paste target app not found")
632
725
  self.statusMessage = "No target app found"
633
726
  return
634
727
  }
@@ -645,6 +738,7 @@ public final class RecordingEngine: ObservableObject {
645
738
  down.post(tap: .cgSessionEventTap)
646
739
  up.post(tap: .cgSessionEventTap)
647
740
  }
741
+ self.log("paste event posted")
648
742
  self.statusMessage = "Pasted (\(text.count) chars)"
649
743
  }
650
744
  }
@@ -657,6 +751,23 @@ public final class RecordingEngine: ObservableObject {
657
751
  ["AXTrustedCheckOptionPrompt" as CFString: true] as CFDictionary
658
752
  )
659
753
  }
754
+
755
+ private func log(_ message: String) {
756
+ NativeAppLog.write(message, homePath: home)
757
+ }
758
+ }
759
+
760
+ private final class LockedFlag: @unchecked Sendable {
761
+ private let lock = NSLock()
762
+ private var value = true
763
+
764
+ func take() -> Bool {
765
+ lock.lock()
766
+ defer { lock.unlock() }
767
+ guard value else { return false }
768
+ value = false
769
+ return true
770
+ }
660
771
  }
661
772
 
662
773
  // MARK: - CLI Runner
@@ -0,0 +1,23 @@
1
+ import Foundation
2
+ import Testing
3
+ @testable import RecordingsLib
4
+
5
+ struct NativeAppDiagnosticsTests {
6
+ @Test("Native app log writes to recordings log file")
7
+ func logWritesFile() throws {
8
+ let home = try makeHome()
9
+
10
+ NativeAppLog.write("diagnostic-test", homePath: home)
11
+
12
+ let path = "\(home)/.hasna/recordings/Recordings.log"
13
+ let text = try String(contentsOfFile: path, encoding: .utf8)
14
+ #expect(text.contains("diagnostic-test"))
15
+ }
16
+
17
+ private func makeHome() throws -> String {
18
+ let url = FileManager.default.temporaryDirectory
19
+ .appendingPathComponent("recordings-diagnostics-tests-\(UUID().uuidString)")
20
+ try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
21
+ return url.path
22
+ }
23
+ }