@hasna/recordings 0.1.12 → 0.1.14

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.14",
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.14";
10445
10470
 
10446
10471
  // src/cli/index.ts
10447
10472
  var program = new Command;
@@ -10767,6 +10792,65 @@ 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
+ console.log(`Code hash: ${status.app_code_hash ?? "unavailable"}`);
10796
+ if (process.platform === "darwin") {
10797
+ console.log(`Microphone: ${status.microphone_permission}`);
10798
+ console.log(`Accessibility: ${status.accessibility_permission}`);
10799
+ console.log(`Log: ${status.log_path}`);
10800
+ }
10801
+ });
10802
+ appCommand.command("permissions").description("Show macOS permission state for Recordings.app").action(() => {
10803
+ const status = getMacOSAppStatus();
10804
+ const permissions = {
10805
+ platform: status.platform,
10806
+ bundle_id: "com.hasna.recordings",
10807
+ microphone: status.microphone_permission,
10808
+ accessibility: status.accessibility_permission,
10809
+ app_code_hash: status.app_code_hash,
10810
+ ad_hoc_signed: status.ad_hoc_signed,
10811
+ log_path: status.log_path
10812
+ };
10813
+ if (program.opts().json) {
10814
+ console.log(JSON.stringify(permissions, null, 2));
10815
+ return;
10816
+ }
10817
+ console.log(`Microphone: ${permissions.microphone}`);
10818
+ console.log(`Accessibility: ${permissions.accessibility}`);
10819
+ console.log(`Log: ${permissions.log_path}`);
10820
+ });
10821
+ appCommand.command("reset-permissions").description("Reset macOS Microphone and Accessibility permissions for Recordings.app").action(() => {
10822
+ if (process.platform !== "darwin") {
10823
+ console.error(chalk.red("Permission reset is only available on macOS"));
10824
+ process.exit(1);
10825
+ }
10826
+ const services = ["Microphone", "Accessibility"];
10827
+ for (const service of services) {
10828
+ const result = spawnSync("tccutil", ["reset", service, "com.hasna.recordings"], {
10829
+ stdio: "inherit"
10830
+ });
10831
+ if (result.error) {
10832
+ console.error(chalk.red(result.error.message));
10833
+ process.exit(1);
10834
+ }
10835
+ }
10836
+ });
10837
+ appCommand.command("log").description("Show the Recordings.app diagnostic log").option("-n, --lines <lines>", "Number of lines to print", "120").action((opts) => {
10838
+ const status = getMacOSAppStatus();
10839
+ if (!existsSync6(status.log_path)) {
10840
+ console.log("");
10841
+ return;
10842
+ }
10843
+ const lines = Math.max(1, parseInt(opts.lines, 10) || 120);
10844
+ const result = spawnSync("tail", ["-n", String(lines), status.log_path], {
10845
+ encoding: "utf8"
10846
+ });
10847
+ if (result.error) {
10848
+ console.error(chalk.red(result.error.message));
10849
+ process.exit(1);
10850
+ }
10851
+ process.stdout.write(result.stdout);
10852
+ if (result.stderr)
10853
+ process.stderr.write(result.stderr);
10770
10854
  });
10771
10855
  appCommand.command("open").description("Open the installed Recordings.app").action(() => {
10772
10856
  const status = getMacOSAppStatus();
@@ -11232,8 +11316,11 @@ function getMacOSAppStatus() {
11232
11316
  const home = process.env.HOME || process.env.USERPROFILE || "";
11233
11317
  const installedAppPath = pathJoin(home, ".hasna", "recordings", "Recordings.app");
11234
11318
  const executablePath = pathJoin(installedAppPath, "Contents", "MacOS", "Recordings");
11319
+ const logPath = pathJoin(home, ".hasna", "recordings", "Recordings.log");
11235
11320
  const installerPath = pathJoin(packageRoot, "scripts", "install_macos_app.sh");
11236
11321
  const nativeSourcesPath = pathJoin(packageRoot, "src", "native", "Recordings");
11322
+ const signingInfo = getCodeSigningInfo(installedAppPath);
11323
+ const permissionCodeHash = signingInfo.adHoc ? signingInfo.cdHash : null;
11237
11324
  return {
11238
11325
  platform: process.platform,
11239
11326
  package_root: packageRoot,
@@ -11244,9 +11331,69 @@ function getMacOSAppStatus() {
11244
11331
  installed_app_path: installedAppPath,
11245
11332
  installed: existsSync6(installedAppPath),
11246
11333
  executable_path: executablePath,
11247
- executable: existsSync6(executablePath)
11334
+ executable: existsSync6(executablePath),
11335
+ app_code_hash: signingInfo.cdHash,
11336
+ ad_hoc_signed: signingInfo.adHoc,
11337
+ microphone_permission: getTccPermission("kTCCServiceMicrophone", home, permissionCodeHash),
11338
+ accessibility_permission: getTccPermission("kTCCServiceAccessibility", home, permissionCodeHash),
11339
+ log_path: logPath
11248
11340
  };
11249
11341
  }
11342
+ function getCodeSigningInfo(appPath) {
11343
+ if (process.platform !== "darwin" || !existsSync6(appPath)) {
11344
+ return { cdHash: null, adHoc: false };
11345
+ }
11346
+ const result = spawnSync("codesign", ["-d", "--verbose=4", appPath], {
11347
+ encoding: "utf8",
11348
+ stdio: ["ignore", "pipe", "pipe"]
11349
+ });
11350
+ const output = `${result.stdout}
11351
+ ${result.stderr}`;
11352
+ const cdHash = output.match(/^CDHash=([a-fA-F0-9]+)/m)?.[1]?.toLowerCase() ?? null;
11353
+ const adHoc = /Signature=adhoc/.test(output);
11354
+ return { cdHash, adHoc };
11355
+ }
11356
+ function getTccPermission(service, home, currentCodeHash) {
11357
+ if (process.platform !== "darwin")
11358
+ return "unsupported";
11359
+ const dbPaths = [
11360
+ pathJoin(home, "Library", "Application Support", "com.apple.TCC", "TCC.db"),
11361
+ pathJoin("/", "Library", "Application Support", "com.apple.TCC", "TCC.db")
11362
+ ];
11363
+ const sql = "select auth_value || '|' || ifnull(hex(csreq), '') from access where service = '" + service.replace(/'/g, "''") + "' and client = 'com.hasna.recordings' order by last_modified desc limit 1;";
11364
+ for (const dbPath of dbPaths) {
11365
+ if (!existsSync6(dbPath))
11366
+ continue;
11367
+ const result = spawnSync("sqlite3", [dbPath, sql], {
11368
+ encoding: "utf8",
11369
+ stdio: ["ignore", "pipe", "ignore"]
11370
+ });
11371
+ const value = result.stdout.trim();
11372
+ if (!value)
11373
+ continue;
11374
+ const [authValue, csreqHex = ""] = value.split("|");
11375
+ const label = tccAuthValueLabel(authValue ?? "");
11376
+ if (label === "allowed" && currentCodeHash && csreqHex && !csreqHex.toLowerCase().includes(currentCodeHash.toLowerCase())) {
11377
+ return "stale_allowed_for_previous_app_build";
11378
+ }
11379
+ return label;
11380
+ }
11381
+ return "not_determined";
11382
+ }
11383
+ function tccAuthValueLabel(value) {
11384
+ switch (value) {
11385
+ case "0":
11386
+ return "denied";
11387
+ case "1":
11388
+ return "unknown";
11389
+ case "2":
11390
+ return "allowed";
11391
+ case "3":
11392
+ return "limited";
11393
+ default:
11394
+ return `unknown(${value})`;
11395
+ }
11396
+ }
11250
11397
  function findPackageRoot() {
11251
11398
  let current = dirname3(fileURLToPath(import.meta.url));
11252
11399
  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.14",
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.14";
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.14";
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.14",
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": {
@@ -73,4 +73,36 @@ rm -rf "$APP_DEST"
73
73
  mkdir -p "$DATA_DIR"
74
74
  cp -R "$APP_SOURCE" "$APP_DEST" || warn_or_fail "failed to copy app bundle"
75
75
 
76
+ current_cdhash() {
77
+ codesign -d --verbose=4 "$1" 2>&1 | awk -F= '/^CDHash=/ { print toupper($2); exit }'
78
+ }
79
+
80
+ tcc_csreq_hex() {
81
+ local db_path="$1"
82
+ local service="$2"
83
+ if [ ! -r "$db_path" ] || ! command -v sqlite3 >/dev/null 2>&1; then
84
+ return 0
85
+ fi
86
+ sqlite3 "$db_path" \
87
+ "SELECT hex(csreq) FROM access WHERE service = '${service}' AND client = 'com.hasna.recordings' ORDER BY last_modified DESC LIMIT 1;" \
88
+ 2>/dev/null || true
89
+ }
90
+
91
+ reset_stale_permission() {
92
+ local service="$1"
93
+ local tcc_service="$2"
94
+ local db_path="$3"
95
+ local cdhash="$4"
96
+ local csreq_hex
97
+ csreq_hex="$(tcc_csreq_hex "$db_path" "$tcc_service" | tr '[:lower:]' '[:upper:]')"
98
+ if [ -n "$cdhash" ] && [ -n "$csreq_hex" ] && [[ "$csreq_hex" != *"$cdhash"* ]]; then
99
+ tccutil reset "$service" com.hasna.recordings >/dev/null 2>&1 || true
100
+ echo "Reset stale ${service} permission for the newly installed Recordings.app."
101
+ fi
102
+ }
103
+
104
+ APP_CDHASH="$(current_cdhash "$APP_DEST" || true)"
105
+ reset_stale_permission "Microphone" "kTCCServiceMicrophone" "${HOME}/Library/Application Support/com.apple.TCC/TCC.db" "$APP_CDHASH"
106
+ reset_stale_permission "Accessibility" "kTCCServiceAccessibility" "/Library/Application Support/com.apple.TCC/TCC.db" "$APP_CDHASH"
107
+
76
108
  echo "Installed Recordings.app from package: ${APP_DEST}"
@@ -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: [
@@ -1,5 +1,5 @@
1
1
  import SwiftUI
2
- import KeyboardShortcuts
2
+ @preconcurrency import KeyboardShortcuts
3
3
 
4
4
  public struct MenuBarPopover: View {
5
5
  @ObservedObject public var engine: RecordingEngine
@@ -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
+ }
@@ -121,6 +121,9 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
121
121
  public func finish(timeoutMilliseconds: UInt64 = 1_800) async -> String {
122
122
  guard isStreaming else { return accumulatedText }
123
123
  let initialCompletedCount = completedEventCount
124
+ if initialCompletedCount > 0 {
125
+ return stop()
126
+ }
124
127
  await commitInput()
125
128
 
126
129
  let deadline = Date().addingTimeInterval(TimeInterval(timeoutMilliseconds) / 1_000)
@@ -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
@@ -127,6 +127,7 @@ public final class RecordingEngine: ObservableObject {
127
127
  private var streamingText = ""
128
128
  private var recordedPCM = Data()
129
129
  private var activeAudioPath: String?
130
+ private var lastAccessibilityPromptAt: Date?
130
131
 
131
132
  // fn key monitor (CGEventTap-based, swallows fn to prevent emoji picker)
132
133
  private let fnMonitor = FnKeyMonitor()
@@ -142,6 +143,7 @@ public final class RecordingEngine: ObservableObject {
142
143
 
143
144
  public init() {
144
145
  try? FileManager.default.createDirectory(atPath: audioDir, withIntermediateDirectories: true)
146
+ log("RecordingEngine init; microphone=\(microphonePermissionLabel); accessibility=\(accessibilityPermissionLabel)")
145
147
 
146
148
  // Load preferences
147
149
  if let savedMode = UserDefaults.standard.string(forKey: "recordingMode"),
@@ -188,9 +190,66 @@ public final class RecordingEngine: ObservableObject {
188
190
  updateStatus()
189
191
  }
190
192
 
193
+ public var microphonePermissionLabel: String {
194
+ switch AVCaptureDevice.authorizationStatus(for: .audio) {
195
+ case .authorized:
196
+ return "Microphone allowed"
197
+ case .notDetermined:
198
+ return "Microphone not requested"
199
+ case .denied:
200
+ return "Microphone denied"
201
+ case .restricted:
202
+ return "Microphone restricted"
203
+ @unknown default:
204
+ return "Microphone unknown"
205
+ }
206
+ }
207
+
208
+ public var accessibilityPermissionLabel: String {
209
+ AXIsProcessTrusted() ? "Accessibility allowed" : "Accessibility needed"
210
+ }
211
+
212
+ public func requestMicrophonePermission() {
213
+ log("requestMicrophonePermission status=\(AVCaptureDevice.authorizationStatus(for: .audio).rawValue)")
214
+ AVCaptureDevice.requestAccess(for: .audio) { [weak self] granted in
215
+ Task { @MainActor [weak self] in
216
+ guard let self else { return }
217
+ self.log("requestMicrophonePermission result granted=\(granted)")
218
+ self.statusMessage = granted
219
+ ? "Microphone allowed"
220
+ : "Enable Microphone permission for Recordings in System Settings"
221
+ self.objectWillChange.send()
222
+ }
223
+ }
224
+ }
225
+
226
+ public func requestAccessibilityPermission() {
227
+ let trusted = ensureAccessibilityPermission(prompt: true)
228
+ log("requestAccessibilityPermission trusted=\(trusted)")
229
+ statusMessage = trusted
230
+ ? "Accessibility allowed"
231
+ : "Enable Accessibility permission for Recordings to paste"
232
+ objectWillChange.send()
233
+ }
234
+
235
+ public func openMicrophoneSettings() {
236
+ openPrivacySettings("Privacy_Microphone")
237
+ }
238
+
239
+ public func openAccessibilitySettings() {
240
+ openPrivacySettings("Privacy_Accessibility")
241
+ }
242
+
243
+ private func openPrivacySettings(_ pane: String) {
244
+ if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?\(pane)") {
245
+ NSWorkspace.shared.open(url)
246
+ }
247
+ }
248
+
191
249
  private func updateFnMonitor() {
192
250
  if useFnKey {
193
251
  let ok = fnMonitor.start()
252
+ log("fn monitor start ok=\(ok)")
194
253
  if !ok {
195
254
  statusMessage = "fn needs Input Monitoring / Accessibility permission, and Globe must be set to Do Nothing"
196
255
  }
@@ -214,6 +273,7 @@ public final class RecordingEngine: ObservableObject {
214
273
 
215
274
  public func startRecording(trigger: RecordingTrigger = .manual) {
216
275
  guard !isRecording else { return }
276
+ log("startRecording trigger=\(trigger) microphoneStatus=\(AVCaptureDevice.authorizationStatus(for: .audio).rawValue) accessibility=\(AXIsProcessTrusted())")
217
277
  activeTrigger = trigger
218
278
  keyboardShortcutIsDown = trigger == .keyboardShortcut
219
279
 
@@ -237,9 +297,11 @@ public final class RecordingEngine: ObservableObject {
237
297
  startNativeRecording()
238
298
  case .notDetermined:
239
299
  statusMessage = "Allow microphone access to record"
300
+ log("requesting microphone access before recording")
240
301
  AVCaptureDevice.requestAccess(for: .audio) { [weak self] granted in
241
302
  Task { @MainActor [weak self] in
242
303
  guard let self else { return }
304
+ self.log("microphone access response granted=\(granted)")
243
305
  if granted {
244
306
  self.startNativeRecording()
245
307
  } else {
@@ -250,6 +312,7 @@ public final class RecordingEngine: ObservableObject {
250
312
  }
251
313
  case .denied, .restricted:
252
314
  resetRecordingIntent()
315
+ log("microphone permission blocked status=\(AVCaptureDevice.authorizationStatus(for: .audio).rawValue)")
253
316
  statusMessage = "Enable Microphone permission for Recordings in System Settings"
254
317
  @unknown default:
255
318
  resetRecordingIntent()
@@ -262,12 +325,18 @@ public final class RecordingEngine: ObservableObject {
262
325
  pcmStreamState = streamState
263
326
 
264
327
  let apiKey = openAIAPIKey
328
+ log("startNativeRecording apiKeyConfigured=\(!apiKey.isEmpty)")
265
329
  if !apiKey.isEmpty {
266
330
  startRealtimeStreaming(apiKey: apiKey)
267
331
  }
268
332
 
269
333
  let client = realtimeClient
334
+ let homePath = home
335
+ let firstChunkLogged = LockedFlag()
270
336
  let recorder = NativePCMRecorder { [weak client] data in
337
+ if firstChunkLogged.take() {
338
+ NativeAppLog.write("native recorder received first PCM chunk bytes=\(data.count)", homePath: homePath)
339
+ }
271
340
  Task {
272
341
  let chunks = await streamState.append(data, chunkSize: 4_800)
273
342
  for chunk in chunks {
@@ -278,6 +347,7 @@ public final class RecordingEngine: ObservableObject {
278
347
 
279
348
  do {
280
349
  try recorder.start()
350
+ log("native recorder started")
281
351
  nativeRecorder = recorder
282
352
  isRecording = true
283
353
  recordingDuration = 0
@@ -298,6 +368,7 @@ public final class RecordingEngine: ObservableObject {
298
368
  }
299
369
  }
300
370
  } catch {
371
+ log("native recorder failed error=\(error.localizedDescription)")
301
372
  realtimeClient?.stop()
302
373
  realtimeClient = nil
303
374
  streamingTask?.cancel()
@@ -314,9 +385,11 @@ public final class RecordingEngine: ObservableObject {
314
385
  let systemPrompt = projectStore?.effectiveSystemPrompt ?? ""
315
386
  let client = RealtimeTranscriptionClient(apiKey: apiKey, homePath: home)
316
387
  realtimeClient = client
388
+ log("realtime streaming task starting")
317
389
 
318
390
  streamingTask = Task {
319
391
  await client.startStreaming(systemPrompt: systemPrompt)
392
+ self.log("realtime start completed streaming=\(client.isStreaming) error=\(client.error ?? "")")
320
393
 
321
394
  // Receive deltas
322
395
  while client.isStreaming {
@@ -332,6 +405,7 @@ public final class RecordingEngine: ObservableObject {
332
405
 
333
406
  if let message = client.error, !message.isEmpty {
334
407
  await MainActor.run {
408
+ self.log("realtime unavailable message=\(message)")
335
409
  self.statusMessage = "Realtime unavailable — will transcribe after recording"
336
410
  }
337
411
  }
@@ -342,6 +416,7 @@ public final class RecordingEngine: ObservableObject {
342
416
 
343
417
  public func stopAndTranscribe() {
344
418
  guard isRecording else { return }
419
+ log("stopAndTranscribe")
345
420
 
346
421
  recordingTimer?.invalidate()
347
422
  recordingTimer = nil
@@ -370,6 +445,7 @@ public final class RecordingEngine: ObservableObject {
370
445
  }
371
446
  self.recordedPCM = await pcmStreamState.capturedPCM()
372
447
  }
448
+ self.log("captured pcm bytes=\(self.recordedPCM.count)")
373
449
 
374
450
  let streamingResult = await client?.finish() ?? ""
375
451
 
@@ -382,6 +458,7 @@ public final class RecordingEngine: ObservableObject {
382
458
  self.liveTranscriptionText = ""
383
459
 
384
460
  if let text {
461
+ self.log("finish using realtime text chars=\(text.count)")
385
462
  self.isTranscribing = false
386
463
  self.finishWithText(
387
464
  text,
@@ -391,6 +468,7 @@ public final class RecordingEngine: ObservableObject {
391
468
  activeProjectName: activeProjectName
392
469
  )
393
470
  } else if let audioPath, self.writeCapturedWAV(to: audioPath) {
471
+ self.log("falling back to CLI transcription audioPath=\(audioPath)")
394
472
  self.fallbackTranscribe(
395
473
  audioPath: audioPath,
396
474
  curMode: curMode,
@@ -399,6 +477,7 @@ public final class RecordingEngine: ObservableObject {
399
477
  activeProjectName: activeProjectName
400
478
  )
401
479
  } else {
480
+ self.log("no audio captured")
402
481
  self.finish("No audio captured")
403
482
  }
404
483
 
@@ -408,6 +487,7 @@ public final class RecordingEngine: ObservableObject {
408
487
  }
409
488
 
410
489
  private func finishWithText(_ text: String, curMode: RecordingMode, targetAppBundleIdentifier: String?, activeProjectId: String?, activeProjectName: String?) {
490
+ log("finishWithText mode=\(curMode.rawValue) chars=\(text.count)")
411
491
  if curMode == .command {
412
492
  runCommandMode(instruction: text)
413
493
  return
@@ -439,8 +519,10 @@ public final class RecordingEngine: ObservableObject {
439
519
  bitsPerSample: 16,
440
520
  to: URL(fileURLWithPath: path)
441
521
  )
522
+ log("wrote wav path=\(path) pcmBytes=\(recordedPCM.count)")
442
523
  return true
443
524
  } catch {
525
+ log("failed to save wav error=\(error.localizedDescription)")
444
526
  statusMessage = "Failed to save audio"
445
527
  return false
446
528
  }
@@ -504,17 +586,24 @@ public final class RecordingEngine: ObservableObject {
504
586
  Task.detached {
505
587
  let output = CLIRunner.run(["--json", "transcribe", audioPath, "--no-enhance"], home: homePath)
506
588
  if let error = CLIRunner.parseError(output) {
507
- await MainActor.run { self.finish(error) }
589
+ await MainActor.run {
590
+ self.log("fallback transcription failed error=\(error)")
591
+ self.finish(error)
592
+ }
508
593
  return
509
594
  }
510
595
 
511
596
  let text = CLIRunner.parseJSON(output)
512
597
  guard let text, !text.isEmpty else {
513
- await MainActor.run { self.finish("Empty transcription") }
598
+ await MainActor.run {
599
+ self.log("fallback transcription empty output=\(output.prefix(160))")
600
+ self.finish("Empty transcription")
601
+ }
514
602
  return
515
603
  }
516
604
 
517
605
  await MainActor.run {
606
+ self.log("fallback transcription succeeded chars=\(text.count)")
518
607
  self.isTranscribing = false
519
608
  self.finishWithText(
520
609
  text,
@@ -534,6 +623,7 @@ public final class RecordingEngine: ObservableObject {
534
623
  }
535
624
 
536
625
  private func finish(_ msg: String) {
626
+ log("finish status=\(msg)")
537
627
  isTranscribing = false
538
628
  liveTranscriptionText = ""
539
629
  statusMessage = msg
@@ -549,7 +639,8 @@ public final class RecordingEngine: ObservableObject {
549
639
  // MARK: - Command Mode
550
640
 
551
641
  private func runCommandMode(instruction: String) {
552
- guard ensureAccessibilityPermission(prompt: true) else {
642
+ guard ensureAccessibilityPermission(prompt: shouldPromptAccessibility()) else {
643
+ log("command mode blocked by accessibility permission")
553
644
  statusMessage = "Enable Accessibility permission for Recordings to rewrite selected text"
554
645
  return
555
646
  }
@@ -609,12 +700,17 @@ public final class RecordingEngine: ObservableObject {
609
700
  // MARK: - Paste
610
701
 
611
702
  func pasteIntoFrontApp(_ text: String, targetAppBundleIdentifier: String? = nil) {
703
+ log("paste requested chars=\(text.count) target=\(targetAppBundleIdentifier ?? "nil") accessibility=\(AXIsProcessTrusted())")
612
704
  let pb = NSPasteboard.general
613
705
  pb.clearContents()
614
706
  pb.setString(text, forType: .string)
615
707
 
616
- guard ensureAccessibilityPermission(prompt: true) else {
617
- self.statusMessage = "Copied enable Accessibility permission for Recordings to paste"
708
+ let prompted = shouldPromptAccessibility()
709
+ guard ensureAccessibilityPermission(prompt: prompted) else {
710
+ log("paste blocked by accessibility permission; copied to clipboard")
711
+ self.statusMessage = prompted
712
+ ? "Copied — approve Accessibility for this Recordings app"
713
+ : "Copied — waiting for Accessibility approval"
618
714
  return
619
715
  }
620
716
 
@@ -629,6 +725,7 @@ public final class RecordingEngine: ObservableObject {
629
725
  })
630
726
 
631
727
  guard let app = targetApp else {
728
+ log("paste target app not found")
632
729
  self.statusMessage = "No target app found"
633
730
  return
634
731
  }
@@ -645,18 +742,49 @@ public final class RecordingEngine: ObservableObject {
645
742
  down.post(tap: .cgSessionEventTap)
646
743
  up.post(tap: .cgSessionEventTap)
647
744
  }
745
+ self.log("paste event posted")
648
746
  self.statusMessage = "Pasted (\(text.count) chars)"
649
747
  }
650
748
  }
651
749
 
652
750
  private func ensureAccessibilityPermission(prompt: Bool) -> Bool {
751
+ if AXIsProcessTrusted() {
752
+ return true
753
+ }
653
754
  if !prompt {
654
- return AXIsProcessTrusted()
755
+ return false
655
756
  }
656
757
  return AXIsProcessTrustedWithOptions(
657
758
  ["AXTrustedCheckOptionPrompt" as CFString: true] as CFDictionary
658
759
  )
659
760
  }
761
+
762
+ private func shouldPromptAccessibility() -> Bool {
763
+ let now = Date()
764
+ if let lastAccessibilityPromptAt,
765
+ now.timeIntervalSince(lastAccessibilityPromptAt) < 20 {
766
+ return false
767
+ }
768
+ lastAccessibilityPromptAt = now
769
+ return true
770
+ }
771
+
772
+ private func log(_ message: String) {
773
+ NativeAppLog.write(message, homePath: home)
774
+ }
775
+ }
776
+
777
+ private final class LockedFlag: @unchecked Sendable {
778
+ private let lock = NSLock()
779
+ private var value = true
780
+
781
+ func take() -> Bool {
782
+ lock.lock()
783
+ defer { lock.unlock() }
784
+ guard value else { return false }
785
+ value = false
786
+ return true
787
+ }
660
788
  }
661
789
 
662
790
  // MARK: - CLI Runner
@@ -1,5 +1,5 @@
1
1
  import SwiftUI
2
- import KeyboardShortcuts
2
+ @preconcurrency import KeyboardShortcuts
3
3
 
4
4
  public struct SettingsView: View {
5
5
  @ObservedObject public var engine: RecordingEngine
@@ -45,6 +45,32 @@ public struct SettingsView: View {
45
45
  .foregroundStyle(.secondary)
46
46
  }
47
47
 
48
+ Section("Permissions") {
49
+ HStack {
50
+ Text("Microphone")
51
+ Spacer()
52
+ Text(engine.microphonePermissionLabel)
53
+ .foregroundStyle(.secondary)
54
+ }
55
+ Button("Request Microphone") {
56
+ engine.requestMicrophonePermission()
57
+ }
58
+ HStack {
59
+ Text("Accessibility")
60
+ Spacer()
61
+ Text(engine.accessibilityPermissionLabel)
62
+ .foregroundStyle(.secondary)
63
+ }
64
+ HStack {
65
+ Button("Request Accessibility") {
66
+ engine.requestAccessibilityPermission()
67
+ }
68
+ Button("Open Accessibility Settings") {
69
+ engine.openAccessibilitySettings()
70
+ }
71
+ }
72
+ }
73
+
48
74
  Section("System Prompt") {
49
75
  TextEditor(text: $projectStore.settings.globalSystemPrompt)
50
76
  .frame(height: 80)
@@ -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
+ }