@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 +124 -4
- package/dist/db/database.d.ts.map +1 -1
- package/dist/index.js +26 -1
- package/dist/mcp/index.js +28 -3
- package/dist/version.d.ts +1 -1
- package/package.json +1 -1
- package/src/native/Recordings/Package.resolved +3 -3
- package/src/native/Recordings/Package.swift +1 -1
- package/src/native/Recordings/RecordingsLib/NativeAppDiagnostics.swift +36 -0
- package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +115 -4
- package/src/native/Recordings/RecordingsTests/NativeAppDiagnosticsTests.swift +23 -0
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.
|
|
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.
|
|
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;
|
|
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.
|
|
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.
|
|
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.
|
|
1
|
+
export declare const VERSION = "0.1.13";
|
|
2
2
|
//# sourceMappingURL=version.d.ts.map
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
|
-
"originHash" : "
|
|
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" : "
|
|
10
|
-
"version" : "
|
|
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",
|
|
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 {
|
|
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 {
|
|
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
|
+
}
|