@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 +151 -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/scripts/install_macos_app.sh +32 -0
- package/src/native/Recordings/Package.resolved +3 -3
- package/src/native/Recordings/Package.swift +1 -1
- package/src/native/Recordings/RecordingsLib/MenuBarPopover.swift +1 -1
- package/src/native/Recordings/RecordingsLib/NativeAppDiagnostics.swift +36 -0
- package/src/native/Recordings/RecordingsLib/RealtimeTranscriptionClient.swift +3 -0
- package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +136 -8
- package/src/native/Recordings/RecordingsLib/SettingsView.swift +27 -1
- 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.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.
|
|
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;
|
|
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.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.
|
|
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.
|
|
1
|
+
export declare const VERSION = "0.1.14";
|
|
2
2
|
//# sourceMappingURL=version.d.ts.map
|
package/package.json
CHANGED
|
@@ -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" : "
|
|
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
|
+
}
|
|
@@ -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 {
|
|
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 {
|
|
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:
|
|
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
|
-
|
|
617
|
-
|
|
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
|
|
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
|
+
}
|