@hasna/recordings 0.2.14 → 0.3.2
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/Dockerfile.package +4 -5
- package/README.md +113 -8
- package/bun.lock +2 -2
- package/dist/__tests__/helpers/installer-guard-execution.d.ts +22 -0
- package/dist/__tests__/helpers/installer-guard-execution.d.ts.map +1 -0
- package/dist/__tests__/helpers/installer-preflight.d.ts +22 -0
- package/dist/__tests__/helpers/installer-preflight.d.ts.map +1 -0
- package/dist/__tests__/helpers/native-fs-guard.d.ts.map +1 -1
- package/dist/__tests__/helpers/source-assertions.d.ts +169 -0
- package/dist/__tests__/helpers/source-assertions.d.ts.map +1 -0
- package/dist/__tests__/preload.d.ts +1 -4
- package/dist/__tests__/preload.d.ts.map +1 -1
- package/dist/cli/desktop-snapshot.d.ts +20 -0
- package/dist/cli/desktop-snapshot.d.ts.map +1 -0
- package/dist/cli/index.js +2094 -280
- package/dist/cli/macos-permissions.d.ts +72 -0
- package/dist/cli/macos-permissions.d.ts.map +1 -1
- package/dist/cli/macos-shortcut.d.ts +139 -0
- package/dist/cli/macos-shortcut.d.ts.map +1 -0
- package/dist/cli/trigger-probe.d.ts +172 -0
- package/dist/cli/trigger-probe.d.ts.map +1 -0
- package/dist/db/database.d.ts +9 -0
- package/dist/db/database.d.ts.map +1 -1
- package/dist/http/client.d.ts +19 -7
- package/dist/http/client.d.ts.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +635 -50
- package/dist/lib/capture-probe.d.ts +216 -0
- package/dist/lib/capture-probe.d.ts.map +1 -0
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/macos-bundle.d.ts +50 -0
- package/dist/lib/macos-bundle.d.ts.map +1 -0
- package/dist/lib/persistence-probe.d.ts +208 -0
- package/dist/lib/persistence-probe.d.ts.map +1 -0
- package/dist/lib/recorder.d.ts.map +1 -1
- package/dist/lib/retired-deployment-modes.d.ts +24 -0
- package/dist/lib/retired-deployment-modes.d.ts.map +1 -0
- package/dist/lib/transcriber.d.ts +60 -0
- package/dist/lib/transcriber.d.ts.map +1 -1
- package/dist/mcp/index.js +141 -57
- package/dist/server/cloud-config.d.ts +25 -3
- package/dist/server/cloud-config.d.ts.map +1 -1
- package/dist/server/cloud-readiness.d.ts.map +1 -1
- package/dist/server/cloud.d.ts +8 -7
- package/dist/server/cloud.d.ts.map +1 -1
- package/dist/server/index.js +188 -80
- package/dist/server/migrate-command.d.ts.map +1 -1
- package/dist/server/serve.d.ts.map +1 -1
- package/dist/storage.d.ts +2 -2
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +109 -50
- package/dist/store.d.ts +1 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.d.ts.map +1 -1
- package/package.json +25 -6
- package/scripts/ci-linux-suite.ts +403 -0
- package/scripts/ci-native-build.ts +402 -0
- package/scripts/enforce_identity_migration.sh +110 -0
- package/scripts/generate-sdk.ts +1 -1
- package/scripts/install_macos_app.sh +192 -31
- package/scripts/macos_artifact.ts +323 -43
- package/scripts/native/realtime-settle-probe/README.md +39 -0
- package/scripts/native/realtime-settle-probe/main.swift +135 -0
- package/scripts/policy/local-only-approved-targets.txt +21 -0
- package/scripts/read_local_only_targets.sh +203 -0
- package/scripts/scan-artifact.ts +40 -0
- package/scripts/set-version.ts +145 -0
- package/scripts/smoke_macos_app.sh +62 -16
- package/scripts/vacuity-manifest-gen.ts +96 -0
- package/scripts/vacuity-manifests/enhancement-screen-mirror.tsv +44 -0
- package/scripts/vacuity-manifests/install-chain.tsv +13 -0
- package/scripts/vacuity-manifests/must-survive-controls.tsv +10 -0
- package/scripts/vacuity-manifests/reorder.tsv +4 -0
- package/scripts/vacuity-manifests/source-side.tsv +15 -0
- package/scripts/vacuity-manifests/variable-operands.tsv +16 -0
- package/scripts/vacuity-manifests/version-sites.tsv +61 -0
- package/scripts/vacuity-mutation-battery.ts +266 -0
- package/src/native/Recordings/App/MenuBarStatusView.swift +23 -11
- package/src/native/Recordings/App/RecordingsApp.swift +37 -1
- package/src/native/Recordings/App/RuntimeSmoke.swift +5 -1
- package/src/native/Recordings/App/SidebarView.swift +1 -1
- package/src/native/Recordings/Package.swift +24 -5
- package/src/native/Recordings/RecordingsLib/EnhancementScreen.swift +91 -0
- package/src/native/Recordings/RecordingsLib/FnKeyMonitor.swift +27 -4
- package/src/native/Recordings/RecordingsLib/Info.plist +2 -2
- package/src/native/Recordings/RecordingsLib/MenuBarPresentation.swift +56 -3
- package/src/native/Recordings/RecordingsLib/NativePCMRecorder.swift +13 -0
- package/src/native/Recordings/RecordingsLib/PasteDeliveryVerification.swift +460 -0
- package/src/native/Recordings/RecordingsLib/PermissionRequestLaunchPlan.swift +21 -1
- package/src/native/Recordings/RecordingsLib/RealtimeTranscriptionClient.swift +41 -7
- package/src/native/Recordings/RecordingsLib/RecordingAttemptAlert.swift +36 -0
- package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +1071 -75
- package/src/native/Recordings/RecordingsLib/SettingsView.swift +47 -3
- package/src/native/Recordings/RecordingsLib/VoiceShortcuts.swift +11 -6
- package/src/native/Recordings/RecordingsTests/BarOnlyLaunchPlanTests.swift +99 -0
- package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +3 -3
- package/src/native/Recordings/RecordingsTests/EnhancementScreenTests.swift +191 -0
- package/src/native/Recordings/RecordingsTests/MenuBarPresentationTests.swift +144 -4
- package/src/native/Recordings/RecordingsTests/PasteDeliveryVerificationTests.swift +463 -0
- package/src/native/Recordings/RecordingsTests/PasteTargetTests.swift +33 -20
- package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +1 -1
- package/src/native/Recordings/RecordingsTests/RealtimeSettleBenchmark.swift +200 -0
- package/src/native/Recordings/RecordingsTests/RealtimeSettleBudgetTests.swift +80 -0
- package/src/native/Recordings/RecordingsTests/RealtimeTranscriptionTests.swift +22 -4
- package/src/native/Recordings/RecordingsTests/RecordingEngineDeliveryTests.swift +12 -6
- package/src/native/Recordings/RecordingsTests/RecordingEngineHomeIsolationTests.swift +67 -0
- package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +12 -3
- package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +207 -11
- package/src/native/Recordings/RecordingsTests/SpeechIntentTests.swift +17 -6
- package/src/native/Recordings/RecordingsTests/TestHomeDirectory.swift +23 -0
- package/src/native/Recordings/Updater/Broker/ApplicationProcessQuiescence.swift +7 -1
- package/src/native/Recordings/Updater/Broker/AtomicActivation.swift +10 -10
- package/src/native/Recordings/Updater/Broker/BrokerMain.swift +5 -1
- package/src/native/Recordings/Updater/Broker/HostOSProductVersion.swift +1 -0
- package/src/native/Recordings/Updater/Broker/PeerIdentity.swift +59 -3
- package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +21 -2
- package/src/native/Recordings/Updater/ProtocolTests/ExpectationIntegrityTests.swift +60 -0
- package/src/native/Recordings/Updater/ProtocolTests/UpdateXPCInterfaceTests.swift +113 -0
- package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +106 -5
- package/src/native/Recordings/build.sh +226 -20
- package/scripts/native/prebuilds/darwin-universal/recordings_fs_guard.node +0 -0
package/dist/cli/index.js
CHANGED
|
@@ -5,7 +5,7 @@ var __require = import.meta.require;
|
|
|
5
5
|
// src/cli/index.ts
|
|
6
6
|
import { Command } from "commander";
|
|
7
7
|
|
|
8
|
-
// node_modules/@hasna/events/dist/commander.js
|
|
8
|
+
// ../../node_modules/.bun/@hasna+events@0.1.11/node_modules/@hasna/events/dist/commander.js
|
|
9
9
|
import { chmod, mkdir, readFile, rename, writeFile } from "fs/promises";
|
|
10
10
|
import { existsSync } from "fs";
|
|
11
11
|
import { homedir } from "os";
|
|
@@ -900,13 +900,13 @@ function collectValues(value, previous) {
|
|
|
900
900
|
|
|
901
901
|
// src/cli/index.ts
|
|
902
902
|
import chalk from "chalk";
|
|
903
|
-
import { spawnSync as
|
|
903
|
+
import { spawnSync as spawnSync7 } from "child_process";
|
|
904
904
|
import {
|
|
905
|
-
existsSync as
|
|
906
|
-
readFileSync as
|
|
905
|
+
existsSync as existsSync8,
|
|
906
|
+
readFileSync as readFileSync4,
|
|
907
907
|
readdirSync as readdirSync2
|
|
908
908
|
} from "fs";
|
|
909
|
-
import { dirname as
|
|
909
|
+
import { dirname as dirname5, join as pathJoin } from "path";
|
|
910
910
|
import { fileURLToPath } from "url";
|
|
911
911
|
|
|
912
912
|
// src/lib/config.ts
|
|
@@ -1335,7 +1335,8 @@ function findProjectRecordingsPath(entry) {
|
|
|
1335
1335
|
function findRepositoryRoot(start) {
|
|
1336
1336
|
let dir = start;
|
|
1337
1337
|
while (true) {
|
|
1338
|
-
|
|
1338
|
+
const gitPath = join3(dir, ".git");
|
|
1339
|
+
if (isRepositoryMarker(gitPath))
|
|
1339
1340
|
return dir;
|
|
1340
1341
|
const parent = dirname2(dir);
|
|
1341
1342
|
if (parent === dir)
|
|
@@ -1343,6 +1344,15 @@ function findRepositoryRoot(start) {
|
|
|
1343
1344
|
dir = parent;
|
|
1344
1345
|
}
|
|
1345
1346
|
}
|
|
1347
|
+
function isRepositoryMarker(gitPath) {
|
|
1348
|
+
try {
|
|
1349
|
+
if (statSync(gitPath).isFile())
|
|
1350
|
+
return true;
|
|
1351
|
+
return existsSync2(join3(gitPath, "HEAD"));
|
|
1352
|
+
} catch {
|
|
1353
|
+
return false;
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1346
1356
|
function canonicalExistingPath(path) {
|
|
1347
1357
|
try {
|
|
1348
1358
|
return realpathSync2(path);
|
|
@@ -1371,14 +1381,17 @@ function loadSecretKey(keyName) {
|
|
|
1371
1381
|
try {
|
|
1372
1382
|
const content = readFileSync(candidate, "utf-8");
|
|
1373
1383
|
const match = content.match(new RegExp(`export\\s+${keyName}\\s*=\\s*"([^"]+)"`));
|
|
1374
|
-
|
|
1375
|
-
|
|
1384
|
+
const doubleQuotedValue = match?.[1];
|
|
1385
|
+
if (doubleQuotedValue !== undefined)
|
|
1386
|
+
return doubleQuotedValue;
|
|
1376
1387
|
const match2 = content.match(new RegExp(`export\\s+${keyName}\\s*=\\s*'([^']+)'`));
|
|
1377
|
-
|
|
1378
|
-
|
|
1388
|
+
const singleQuotedValue = match2?.[1];
|
|
1389
|
+
if (singleQuotedValue !== undefined)
|
|
1390
|
+
return singleQuotedValue;
|
|
1379
1391
|
const match3 = content.match(new RegExp(`${keyName}\\s*=\\s*(.+)`));
|
|
1380
|
-
|
|
1381
|
-
|
|
1392
|
+
const unquotedValue = match3?.[1];
|
|
1393
|
+
if (unquotedValue !== undefined)
|
|
1394
|
+
return unquotedValue.trim().replace(/^["']|["']$/g, "");
|
|
1382
1395
|
} catch {}
|
|
1383
1396
|
}
|
|
1384
1397
|
return "";
|
|
@@ -1536,6 +1549,7 @@ var MIGRATIONS = [
|
|
|
1536
1549
|
ALTER TABLE agents ADD COLUMN active_project_id TEXT REFERENCES projects(id) ON DELETE SET NULL;
|
|
1537
1550
|
`
|
|
1538
1551
|
];
|
|
1552
|
+
var CURRENT_MIGRATION_LEVEL = MIGRATIONS.length - 1;
|
|
1539
1553
|
function getDatabase(dbPath) {
|
|
1540
1554
|
if (_db)
|
|
1541
1555
|
return _db;
|
|
@@ -1559,7 +1573,11 @@ function runMigrations(db) {
|
|
|
1559
1573
|
const currentLevel = result?.max_id ?? -1;
|
|
1560
1574
|
for (let i = currentLevel + 1;i < MIGRATIONS.length; i++) {
|
|
1561
1575
|
try {
|
|
1562
|
-
|
|
1576
|
+
const migration = MIGRATIONS[i];
|
|
1577
|
+
if (migration === undefined) {
|
|
1578
|
+
throw new Error(`Missing SQLite migration at version ${i}`);
|
|
1579
|
+
}
|
|
1580
|
+
db.run(migration);
|
|
1563
1581
|
db.query("INSERT INTO _migrations (id) VALUES (?)").run(i);
|
|
1564
1582
|
} catch (e) {
|
|
1565
1583
|
if (isBenignMigrationError(e)) {
|
|
@@ -1909,7 +1927,7 @@ function setAgentFocus(idOrName, projectId, db) {
|
|
|
1909
1927
|
}
|
|
1910
1928
|
|
|
1911
1929
|
// src/version.ts
|
|
1912
|
-
var VERSION = "0.2
|
|
1930
|
+
var VERSION = "0.3.2";
|
|
1913
1931
|
|
|
1914
1932
|
// src/db/feedback.ts
|
|
1915
1933
|
function saveFeedback(input) {
|
|
@@ -1917,29 +1935,54 @@ function saveFeedback(input) {
|
|
|
1917
1935
|
db.query("INSERT INTO feedback (message, email, category, version) VALUES (?, ?, ?, ?)").run(input.message, input.email ?? null, input.category ?? "general", input.version ?? VERSION);
|
|
1918
1936
|
}
|
|
1919
1937
|
|
|
1938
|
+
// src/lib/retired-deployment-modes.ts
|
|
1939
|
+
var RETIRED_DEPLOYMENT_MODES = [
|
|
1940
|
+
"local",
|
|
1941
|
+
"self_hosted",
|
|
1942
|
+
"cloud",
|
|
1943
|
+
"remote",
|
|
1944
|
+
"hybrid"
|
|
1945
|
+
];
|
|
1946
|
+
function normalizeModeToken(value) {
|
|
1947
|
+
return value.trim().toLowerCase().replace(/-/g, "_");
|
|
1948
|
+
}
|
|
1949
|
+
function asRetiredDeploymentMode(value) {
|
|
1950
|
+
const token = normalizeModeToken(value);
|
|
1951
|
+
return RETIRED_DEPLOYMENT_MODES.includes(token) ? token : null;
|
|
1952
|
+
}
|
|
1953
|
+
function retiredDeploymentModeError(rawValue, sourceEnvKey, replacement) {
|
|
1954
|
+
const mode = asRetiredDeploymentMode(rawValue);
|
|
1955
|
+
const value = mode === "local" ? replacement.onBox : replacement.offBox;
|
|
1956
|
+
return new Error(`${sourceEnvKey}=${rawValue} names a deployment mode, and deployment modes are removed: ` + `${RETIRED_DEPLOYMENT_MODES.join(" | ")} no longer select anything. ` + `Set ${replacement.envKey}=${value} instead ` + `(${replacement.envKey} takes ${replacement.onBox} or ${replacement.offBox}).`);
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1920
1959
|
// src/http/client.ts
|
|
1921
|
-
var
|
|
1960
|
+
var SERVER_BACKEND_VALUES = ["sqlite", "postgresql", "postgres"];
|
|
1922
1961
|
function envToken(name) {
|
|
1923
1962
|
return name.toUpperCase().replace(/-/g, "_");
|
|
1924
1963
|
}
|
|
1925
|
-
function
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1964
|
+
function clientStoreReplacement(envKey) {
|
|
1965
|
+
return { envKey, onBox: "sqlite", offBox: "http" };
|
|
1966
|
+
}
|
|
1967
|
+
function normalizeClientStore(value, sourceEnvKey, replacementEnvKey) {
|
|
1968
|
+
const normalized = normalizeModeToken(value);
|
|
1969
|
+
if (normalized === "sqlite")
|
|
1970
|
+
return "sqlite";
|
|
1971
|
+
if (normalized === "http" || normalized === "https")
|
|
1972
|
+
return "http";
|
|
1973
|
+
if (asRetiredDeploymentMode(value)) {
|
|
1974
|
+
throw retiredDeploymentModeError(value, sourceEnvKey, clientStoreReplacement(replacementEnvKey));
|
|
1933
1975
|
}
|
|
1934
|
-
throw new Error(`Unknown
|
|
1976
|
+
throw new Error(`Unknown client store: ${value}. Use sqlite or http.`);
|
|
1935
1977
|
}
|
|
1936
|
-
function
|
|
1937
|
-
return `
|
|
1978
|
+
function defaultApiBaseUrl(name) {
|
|
1979
|
+
return `http://localhost:8874`;
|
|
1938
1980
|
}
|
|
1939
1981
|
function envKeys(name) {
|
|
1940
1982
|
const token = envToken(name);
|
|
1941
1983
|
return {
|
|
1942
|
-
|
|
1984
|
+
storeKeys: [`HASNA_${token}_CLIENT_STORE`, `${token}_CLIENT_STORE`],
|
|
1985
|
+
retiredModeKeys: [`HASNA_${token}_STORAGE_MODE`, `HASNA_${token}_MODE`, `${token}_STORAGE_MODE`, `${token}_MODE`],
|
|
1943
1986
|
apiUrlKeys: [`HASNA_${token}_API_URL`, `${token}_API_URL`],
|
|
1944
1987
|
apiKeyKeys: [`HASNA_${token}_API_KEY`, `${token}_API_KEY`]
|
|
1945
1988
|
};
|
|
@@ -1952,6 +1995,20 @@ function firstEnv(env, keys) {
|
|
|
1952
1995
|
}
|
|
1953
1996
|
return null;
|
|
1954
1997
|
}
|
|
1998
|
+
function assertNoRetiredMode(env, keys) {
|
|
1999
|
+
for (const key of keys.retiredModeKeys) {
|
|
2000
|
+
const value = env[key]?.trim();
|
|
2001
|
+
if (!value)
|
|
2002
|
+
continue;
|
|
2003
|
+
const normalized = normalizeModeToken(value);
|
|
2004
|
+
if (SERVER_BACKEND_VALUES.includes(normalized))
|
|
2005
|
+
continue;
|
|
2006
|
+
if (asRetiredDeploymentMode(value)) {
|
|
2007
|
+
throw retiredDeploymentModeError(value, key, clientStoreReplacement(keys.storeKeys[0]));
|
|
2008
|
+
}
|
|
2009
|
+
throw new Error(`${key}=${value} is not a client store. ${key} no longer selects one; ` + `set ${keys.storeKeys[0]}=sqlite or ${keys.storeKeys[0]}=http instead.`);
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
1955
2012
|
function toV1BaseUrl(apiUrl) {
|
|
1956
2013
|
const url = new URL(apiUrl);
|
|
1957
2014
|
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
@@ -1967,45 +2024,42 @@ function toV1BaseUrl(apiUrl) {
|
|
|
1967
2024
|
}
|
|
1968
2025
|
function resolveTransport(name, env = process.env) {
|
|
1969
2026
|
const keys = envKeys(name);
|
|
1970
|
-
|
|
2027
|
+
assertNoRetiredMode(env, keys);
|
|
2028
|
+
const storeHit = firstEnv(env, keys.storeKeys);
|
|
1971
2029
|
const urlHit = firstEnv(env, keys.apiUrlKeys);
|
|
1972
2030
|
const keyHit = firstEnv(env, keys.apiKeyKeys);
|
|
1973
|
-
let
|
|
1974
|
-
let deprecatedAlias = null;
|
|
2031
|
+
let requested = "sqlite";
|
|
1975
2032
|
let modeSource = "default";
|
|
1976
|
-
if (
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
deprecatedAlias = normalized.deprecatedAlias;
|
|
1980
|
-
modeSource = modeHit.key;
|
|
2033
|
+
if (storeHit) {
|
|
2034
|
+
requested = normalizeClientStore(storeHit.value, storeHit.key, keys.storeKeys[0]);
|
|
2035
|
+
modeSource = storeHit.key;
|
|
1981
2036
|
} else if (urlHit && keyHit) {
|
|
1982
|
-
|
|
2037
|
+
requested = "http";
|
|
1983
2038
|
modeSource = "auto:api-url+api-key";
|
|
1984
2039
|
}
|
|
1985
|
-
if (
|
|
1986
|
-
return { transport: "
|
|
2040
|
+
if (requested === "sqlite") {
|
|
2041
|
+
return { transport: "sqlite", requested, modeSource, baseUrl: null, apiKeyPresent: Boolean(keyHit), misconfigured: false, warning: null };
|
|
1987
2042
|
}
|
|
1988
2043
|
if (!keyHit) {
|
|
1989
2044
|
return {
|
|
1990
|
-
transport: "
|
|
1991
|
-
|
|
1992
|
-
deprecatedAlias,
|
|
2045
|
+
transport: "sqlite",
|
|
2046
|
+
requested,
|
|
1993
2047
|
modeSource,
|
|
1994
2048
|
baseUrl: null,
|
|
1995
2049
|
apiKeyPresent: false,
|
|
1996
2050
|
misconfigured: true,
|
|
1997
|
-
warning: `${modeSource}=
|
|
2051
|
+
warning: `${modeSource}=http but no API key is set (${keys.apiKeyKeys[0]}). Refusing to route to the API.`
|
|
1998
2052
|
};
|
|
1999
2053
|
}
|
|
2000
|
-
const rawUrl = urlHit?.value ??
|
|
2054
|
+
const rawUrl = urlHit?.value ?? defaultApiBaseUrl(name);
|
|
2001
2055
|
let baseUrl;
|
|
2002
2056
|
try {
|
|
2003
2057
|
baseUrl = toV1BaseUrl(rawUrl);
|
|
2004
2058
|
} catch (error) {
|
|
2005
2059
|
const message = error instanceof Error ? error.message : String(error);
|
|
2006
|
-
return { transport: "
|
|
2060
|
+
return { transport: "sqlite", requested, modeSource, baseUrl: null, apiKeyPresent: true, misconfigured: true, warning: `Invalid API URL: ${message}.` };
|
|
2007
2061
|
}
|
|
2008
|
-
return { transport: "
|
|
2062
|
+
return { transport: "http", requested, modeSource, baseUrl, apiKeyPresent: true, misconfigured: false, warning: null };
|
|
2009
2063
|
}
|
|
2010
2064
|
|
|
2011
2065
|
class HasnaHttpError extends Error {
|
|
@@ -2117,6 +2171,8 @@ function createHttpTransport(options) {
|
|
|
2117
2171
|
const jitter = Math.floor(Math.random() * (backoff / 2 + 1));
|
|
2118
2172
|
await sleep(backoff + jitter);
|
|
2119
2173
|
}
|
|
2174
|
+
if (last === null)
|
|
2175
|
+
throw new Error(`Request to ${rel} completed without a result`);
|
|
2120
2176
|
throw last.error;
|
|
2121
2177
|
}
|
|
2122
2178
|
return {
|
|
@@ -2188,17 +2244,17 @@ function createStorageClient(name, transport) {
|
|
|
2188
2244
|
function resolveStorageClient(name, env = process.env, fetchImpl) {
|
|
2189
2245
|
const resolution = resolveTransport(name, env);
|
|
2190
2246
|
if (resolution.misconfigured) {
|
|
2191
|
-
throw new Error(resolution.warning ?? `Client for '${name}' is misconfigured for
|
|
2247
|
+
throw new Error(resolution.warning ?? `Client for '${name}' is misconfigured for the /v1 API.`);
|
|
2192
2248
|
}
|
|
2193
|
-
if (resolution.transport === "
|
|
2194
|
-
return { transport: "
|
|
2249
|
+
if (resolution.transport === "sqlite" || !resolution.baseUrl) {
|
|
2250
|
+
return { transport: "sqlite", client: null, resolution };
|
|
2195
2251
|
}
|
|
2196
2252
|
const keys = envKeys(name);
|
|
2197
2253
|
const apiKey = firstEnv(env, keys.apiKeyKeys)?.value;
|
|
2198
2254
|
if (!apiKey)
|
|
2199
|
-
throw new Error(`Client for '${name}' resolved to
|
|
2255
|
+
throw new Error(`Client for '${name}' resolved to the /v1 API without an API key.`);
|
|
2200
2256
|
const transport = createHttpTransport({ name, baseUrl: resolution.baseUrl, apiKey, ...fetchImpl ? { fetchImpl } : {} });
|
|
2201
|
-
return { transport: "
|
|
2257
|
+
return { transport: "http", client: createStorageClient(name, transport), resolution };
|
|
2202
2258
|
}
|
|
2203
2259
|
|
|
2204
2260
|
// src/store.ts
|
|
@@ -2222,12 +2278,15 @@ function listQuery(filter) {
|
|
|
2222
2278
|
}
|
|
2223
2279
|
function unwrap(res, key) {
|
|
2224
2280
|
if (res && typeof res === "object" && key in res) {
|
|
2225
|
-
|
|
2281
|
+
const value = res[key];
|
|
2282
|
+
if (value === undefined)
|
|
2283
|
+
throw new Error(`Response field ${key} is undefined`);
|
|
2284
|
+
return value;
|
|
2226
2285
|
}
|
|
2227
2286
|
return res;
|
|
2228
2287
|
}
|
|
2229
2288
|
var localStore = {
|
|
2230
|
-
mode: "
|
|
2289
|
+
mode: "sqlite",
|
|
2231
2290
|
baseUrl: null,
|
|
2232
2291
|
async createRecording(input, idempotencyKey) {
|
|
2233
2292
|
return withLocalStoreReaderLease(() => createRecording(input, undefined, idempotencyKey));
|
|
@@ -2280,7 +2339,7 @@ var localStore = {
|
|
|
2280
2339
|
};
|
|
2281
2340
|
function apiStore(client) {
|
|
2282
2341
|
return {
|
|
2283
|
-
mode: "
|
|
2342
|
+
mode: "http",
|
|
2284
2343
|
baseUrl: client.baseUrl,
|
|
2285
2344
|
async createRecording(input, idempotencyKey) {
|
|
2286
2345
|
const keyCandidate = idempotencyKey === undefined && (input.id === undefined || input.id === null) ? randomUUID5() : idempotencyKey;
|
|
@@ -2448,7 +2507,7 @@ function getStore(env = process.env) {
|
|
|
2448
2507
|
if (env === process.env && cached)
|
|
2449
2508
|
return cached;
|
|
2450
2509
|
const resolved = resolveStorageClient(APP, env);
|
|
2451
|
-
const store = resolved.transport === "
|
|
2510
|
+
const store = resolved.transport === "http" ? apiStore(resolved.client) : localStore;
|
|
2452
2511
|
if (env === process.env)
|
|
2453
2512
|
cached = store;
|
|
2454
2513
|
return store;
|
|
@@ -2509,10 +2568,14 @@ function startRecording(config) {
|
|
|
2509
2568
|
const filename = `recording-${timestamp}.${config.audio_format}`;
|
|
2510
2569
|
const filepath = join4(config.audio_dir, filename);
|
|
2511
2570
|
const args = buildRecordArgs(filepath, config);
|
|
2571
|
+
const [command, ...commandArgs] = args;
|
|
2572
|
+
if (command === undefined) {
|
|
2573
|
+
throw new RecordingError("Recording command is empty.");
|
|
2574
|
+
}
|
|
2512
2575
|
const releaseLease = isGlobalRecordingsStatePath(filepath) ? acquireLocalStoreReaderLease() : () => {};
|
|
2513
2576
|
let recordProcess;
|
|
2514
2577
|
try {
|
|
2515
|
-
recordProcess = spawn2(
|
|
2578
|
+
recordProcess = spawn2(command, commandArgs, {
|
|
2516
2579
|
stdio: ["pipe", "pipe", "pipe"]
|
|
2517
2580
|
});
|
|
2518
2581
|
} catch (error) {
|
|
@@ -2621,6 +2684,48 @@ function getClient(config) {
|
|
|
2621
2684
|
_clientApiKey = config.openai_api_key;
|
|
2622
2685
|
return _client;
|
|
2623
2686
|
}
|
|
2687
|
+
function redactKeyMaterial(text) {
|
|
2688
|
+
return text.replace(/\b(sk|rk|org)-[A-Za-z0-9_*\-]{4,}/g, "[redacted]").replace(/\bBearer\s+[A-Za-z0-9._*\-]{4,}/gi, "Bearer [redacted]").replace(/\bBasic\s+[A-Za-z0-9+/=]{8,}/gi, "Basic [redacted]").replace(/:\/\/[^/\s:@]+(?::[^/\s@]*)?@/g, "://[redacted]@").replace(/\b(x-api-key|api[-_]?key|authorization|token|secret|password)(\s*[:=]\s*)("?)[^\s"',;}&]+\3/gi, (_match, name, separator) => `${name}${separator}[redacted]`);
|
|
2689
|
+
}
|
|
2690
|
+
var defaultCredentialModelRetriever = async (model, apiKey) => {
|
|
2691
|
+
const client = new OpenAI({ apiKey });
|
|
2692
|
+
return await client.models.retrieve(model);
|
|
2693
|
+
};
|
|
2694
|
+
async function verifyTranscriptionCredential(config, model, options = {}) {
|
|
2695
|
+
const role = options.role ?? "transcription";
|
|
2696
|
+
const apiKey = options.apiKey ?? config.openai_api_key;
|
|
2697
|
+
const retrieveModel = options.retrieveModel ?? defaultCredentialModelRetriever;
|
|
2698
|
+
if (!apiKey) {
|
|
2699
|
+
return {
|
|
2700
|
+
ok: false,
|
|
2701
|
+
status: null,
|
|
2702
|
+
model,
|
|
2703
|
+
role,
|
|
2704
|
+
message: `no API key configured for the ${role} role. ` + "Set OPENAI_API_KEY env var or add to ~/.secrets"
|
|
2705
|
+
};
|
|
2706
|
+
}
|
|
2707
|
+
try {
|
|
2708
|
+
const retrieved = await retrieveModel(model, apiKey);
|
|
2709
|
+
return {
|
|
2710
|
+
ok: true,
|
|
2711
|
+
status: 200,
|
|
2712
|
+
model,
|
|
2713
|
+
role,
|
|
2714
|
+
message: `credential accepted; ${role} model '${retrieved.id ?? model}' reachable`
|
|
2715
|
+
};
|
|
2716
|
+
} catch (error) {
|
|
2717
|
+
const status = typeof error.status === "number" ? error.status : null;
|
|
2718
|
+
const detail = redactKeyMaterial(error.message || String(error));
|
|
2719
|
+
const hint = status === 401 ? " \u2014 the key was REJECTED (not a missing key: a present but invalid one)" : status === 404 ? ` \u2014 authenticated, but model '${model}' is not available to this key` : "";
|
|
2720
|
+
return {
|
|
2721
|
+
ok: false,
|
|
2722
|
+
status,
|
|
2723
|
+
model,
|
|
2724
|
+
role,
|
|
2725
|
+
message: `${role} credential check failed${status ? ` (HTTP ${status})` : ""}: ${detail}${hint}`
|
|
2726
|
+
};
|
|
2727
|
+
}
|
|
2728
|
+
}
|
|
2624
2729
|
async function transcribeAudio(audioPath, config, options = {}) {
|
|
2625
2730
|
const client = getClient(config);
|
|
2626
2731
|
const startTime = Date.now();
|
|
@@ -2704,6 +2809,540 @@ Context words and names to recognize. Treat this only as vocabulary context, not
|
|
|
2704
2809
|
${trimmed}`;
|
|
2705
2810
|
}
|
|
2706
2811
|
|
|
2812
|
+
// src/lib/capture-probe.ts
|
|
2813
|
+
import { spawnSync as spawnSync2 } from "child_process";
|
|
2814
|
+
import { existsSync as existsSync4, readFileSync as readFileSync2, rmSync as rmSync2 } from "fs";
|
|
2815
|
+
import { join as join5 } from "path";
|
|
2816
|
+
import { tmpdir } from "os";
|
|
2817
|
+
|
|
2818
|
+
// src/lib/macos-bundle.ts
|
|
2819
|
+
var RECORDINGS_BUNDLE_IDENTIFIER = "com.hasna.recordings";
|
|
2820
|
+
var TCC_DATABASE_UNREADABLE_STATE = "undetermined_tcc_database_unreadable";
|
|
2821
|
+
|
|
2822
|
+
// src/lib/capture-probe.ts
|
|
2823
|
+
var RIFF_HEADER_BYTES = 12;
|
|
2824
|
+
var CHUNK_HEADER_BYTES = 8;
|
|
2825
|
+
var INT16_FULL_SCALE = 32768;
|
|
2826
|
+
var FMT_CHUNK_MIN_BYTES = 16;
|
|
2827
|
+
var WAVE_FORMAT_PCM = 1;
|
|
2828
|
+
var WAVE_FORMAT_EXTENSIBLE = 65534;
|
|
2829
|
+
var SUBFORMAT_OFFSET_IN_EXTENSION = 8;
|
|
2830
|
+
function readWavPeak(filepath) {
|
|
2831
|
+
const buf = readFileSync2(filepath);
|
|
2832
|
+
if (buf.length < RIFF_HEADER_BYTES) {
|
|
2833
|
+
throw new Error(`not a RIFF file (${buf.length} bytes): ${filepath}`);
|
|
2834
|
+
}
|
|
2835
|
+
if (buf.toString("ascii", 0, 4) !== "RIFF" || buf.toString("ascii", 8, 12) !== "WAVE") {
|
|
2836
|
+
throw new Error(`not a RIFF/WAVE file: ${filepath}`);
|
|
2837
|
+
}
|
|
2838
|
+
let offset = RIFF_HEADER_BYTES;
|
|
2839
|
+
let bitsPerSample = null;
|
|
2840
|
+
let formatTag = null;
|
|
2841
|
+
let dataStart = -1;
|
|
2842
|
+
let dataLength = 0;
|
|
2843
|
+
while (offset + CHUNK_HEADER_BYTES <= buf.length) {
|
|
2844
|
+
const chunkId = buf.toString("ascii", offset, offset + 4);
|
|
2845
|
+
const chunkSize = buf.readUInt32LE(offset + 4);
|
|
2846
|
+
const body = offset + CHUNK_HEADER_BYTES;
|
|
2847
|
+
if (chunkId === "fmt " && formatTag === null) {
|
|
2848
|
+
if (chunkSize < FMT_CHUNK_MIN_BYTES || body + FMT_CHUNK_MIN_BYTES > buf.length) {
|
|
2849
|
+
throw new Error(`truncated fmt chunk (${chunkSize} bytes, need ${FMT_CHUNK_MIN_BYTES}): ${filepath}`);
|
|
2850
|
+
}
|
|
2851
|
+
formatTag = buf.readUInt16LE(body);
|
|
2852
|
+
bitsPerSample = buf.readUInt16LE(body + 14);
|
|
2853
|
+
if (formatTag === WAVE_FORMAT_EXTENSIBLE) {
|
|
2854
|
+
const extensionStart = body + FMT_CHUNK_MIN_BYTES;
|
|
2855
|
+
const subFormatAt = extensionStart + SUBFORMAT_OFFSET_IN_EXTENSION;
|
|
2856
|
+
if (chunkSize < FMT_CHUNK_MIN_BYTES + 10 || subFormatAt + 2 > buf.length) {
|
|
2857
|
+
throw new Error(`WAVE_FORMAT_EXTENSIBLE without a readable SubFormat GUID: ${filepath}`);
|
|
2858
|
+
}
|
|
2859
|
+
formatTag = buf.readUInt16LE(subFormatAt);
|
|
2860
|
+
}
|
|
2861
|
+
} else if (chunkId === "data" && dataStart < 0) {
|
|
2862
|
+
dataStart = body;
|
|
2863
|
+
const remaining = buf.length - body;
|
|
2864
|
+
dataLength = chunkSize === 0 ? remaining : Math.min(chunkSize, remaining);
|
|
2865
|
+
}
|
|
2866
|
+
const declaredAdvance = chunkSize + chunkSize % 2;
|
|
2867
|
+
offset = body + Math.min(declaredAdvance, Math.max(0, buf.length - body));
|
|
2868
|
+
}
|
|
2869
|
+
if (dataStart < 0) {
|
|
2870
|
+
throw new Error(`no data chunk in WAV: ${filepath}`);
|
|
2871
|
+
}
|
|
2872
|
+
if (bitsPerSample === null || formatTag === null) {
|
|
2873
|
+
throw new Error(`no fmt chunk in WAV, so the sample format is unknown: ${filepath}`);
|
|
2874
|
+
}
|
|
2875
|
+
if (formatTag !== WAVE_FORMAT_PCM) {
|
|
2876
|
+
throw new Error(`expected uncompressed PCM (format tag ${WAVE_FORMAT_PCM}), got tag ${formatTag}: ${filepath}`);
|
|
2877
|
+
}
|
|
2878
|
+
if (bitsPerSample !== 16) {
|
|
2879
|
+
throw new Error(`expected 16-bit PCM, got ${bitsPerSample}-bit: ${filepath}`);
|
|
2880
|
+
}
|
|
2881
|
+
const samples = Math.floor(dataLength / 2);
|
|
2882
|
+
let maxAbs = 0;
|
|
2883
|
+
for (let i = 0;i < samples; i++) {
|
|
2884
|
+
const value = Math.abs(buf.readInt16LE(dataStart + i * 2));
|
|
2885
|
+
if (value > maxAbs)
|
|
2886
|
+
maxAbs = value;
|
|
2887
|
+
}
|
|
2888
|
+
return { samples, peak: maxAbs / INT16_FULL_SCALE };
|
|
2889
|
+
}
|
|
2890
|
+
var DEFAULT_PROBE_SECONDS = 1;
|
|
2891
|
+
var MAX_PROBE_SECONDS = 60;
|
|
2892
|
+
var DEFAULT_RECORD_EXECUTABLE = "rec";
|
|
2893
|
+
var SILENCE_PEAK_THRESHOLD = 0.001;
|
|
2894
|
+
var PROBE_TIMEOUT_GRACE_MS = 1e4;
|
|
2895
|
+
function probeMicrophoneCapture(config, options = {}) {
|
|
2896
|
+
const requested = options.seconds ?? DEFAULT_PROBE_SECONDS;
|
|
2897
|
+
const seconds = Math.min(Math.max(requested, 1), MAX_PROBE_SECONDS);
|
|
2898
|
+
const executable = options.executable ?? DEFAULT_RECORD_EXECUTABLE;
|
|
2899
|
+
const base = {
|
|
2900
|
+
tool: null,
|
|
2901
|
+
seconds,
|
|
2902
|
+
samples: 0,
|
|
2903
|
+
peak: 0,
|
|
2904
|
+
silent: null
|
|
2905
|
+
};
|
|
2906
|
+
const filepath = join5(tmpdir(), `recordings-capture-probe-${process.pid}-${Date.now()}.wav`);
|
|
2907
|
+
try {
|
|
2908
|
+
const result = spawnSync2(executable, [
|
|
2909
|
+
"-q",
|
|
2910
|
+
"-r",
|
|
2911
|
+
String(config.sample_rate),
|
|
2912
|
+
"-c",
|
|
2913
|
+
"1",
|
|
2914
|
+
"-b",
|
|
2915
|
+
"16",
|
|
2916
|
+
filepath,
|
|
2917
|
+
"trim",
|
|
2918
|
+
"0",
|
|
2919
|
+
String(seconds)
|
|
2920
|
+
], {
|
|
2921
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
2922
|
+
encoding: "utf8",
|
|
2923
|
+
timeout: options.timeoutMs ?? seconds * 1000 + PROBE_TIMEOUT_GRACE_MS
|
|
2924
|
+
});
|
|
2925
|
+
if (result.signal) {
|
|
2926
|
+
return {
|
|
2927
|
+
...base,
|
|
2928
|
+
ok: false,
|
|
2929
|
+
message: `'${executable}' did not finish within ` + `${((options.timeoutMs ?? seconds * 1000 + PROBE_TIMEOUT_GRACE_MS) / 1000).toFixed(1)}s ` + `and was killed (${result.signal}). The input device is most likely busy or absent.`
|
|
2930
|
+
};
|
|
2931
|
+
}
|
|
2932
|
+
if (result.error) {
|
|
2933
|
+
return {
|
|
2934
|
+
...base,
|
|
2935
|
+
ok: false,
|
|
2936
|
+
message: `could not run '${executable}': ${result.error.message}`
|
|
2937
|
+
};
|
|
2938
|
+
}
|
|
2939
|
+
if (!existsSync4(filepath)) {
|
|
2940
|
+
const stderr = (result.stderr || "").trim();
|
|
2941
|
+
return {
|
|
2942
|
+
...base,
|
|
2943
|
+
ok: false,
|
|
2944
|
+
message: `'${executable}' wrote no audio file${stderr ? `: ${stderr}` : ""}`
|
|
2945
|
+
};
|
|
2946
|
+
}
|
|
2947
|
+
let peak;
|
|
2948
|
+
try {
|
|
2949
|
+
peak = readWavPeak(filepath);
|
|
2950
|
+
} catch (error) {
|
|
2951
|
+
return {
|
|
2952
|
+
...base,
|
|
2953
|
+
ok: false,
|
|
2954
|
+
message: `could not read captured audio: ${error.message}`
|
|
2955
|
+
};
|
|
2956
|
+
}
|
|
2957
|
+
const tool = executable;
|
|
2958
|
+
if (peak.samples === 0) {
|
|
2959
|
+
return {
|
|
2960
|
+
...base,
|
|
2961
|
+
tool,
|
|
2962
|
+
ok: false,
|
|
2963
|
+
message: "capture produced no samples"
|
|
2964
|
+
};
|
|
2965
|
+
}
|
|
2966
|
+
if (peak.peak <= SILENCE_PEAK_THRESHOLD) {
|
|
2967
|
+
const subject = captureProbeSubject();
|
|
2968
|
+
return {
|
|
2969
|
+
...base,
|
|
2970
|
+
tool,
|
|
2971
|
+
samples: peak.samples,
|
|
2972
|
+
peak: peak.peak,
|
|
2973
|
+
ok: false,
|
|
2974
|
+
silent: true,
|
|
2975
|
+
message: `captured ${peak.samples} samples with no usable signal ` + `(peak ${peak.peak.toFixed(6)}, at or below the ${SILENCE_PEAK_THRESHOLD} silence threshold). ` + (process.platform === "darwin" ? "On macOS this is what a process WITHOUT the Microphone permission receives \u2014 " + "CoreAudio zero-fills its buffers instead of returning an error. " + "Also rule out a hardware-muted or unselected input device. " : "The input device delivered no signal \u2014 check that it is selected and not muted. ") + `This probe's capture is attributed to ${subject.subject}. ${subject.note}`
|
|
2976
|
+
};
|
|
2977
|
+
}
|
|
2978
|
+
return {
|
|
2979
|
+
tool,
|
|
2980
|
+
seconds,
|
|
2981
|
+
samples: peak.samples,
|
|
2982
|
+
peak: peak.peak,
|
|
2983
|
+
silent: false,
|
|
2984
|
+
ok: true,
|
|
2985
|
+
message: `captured ${peak.samples} samples, peak amplitude ${peak.peak.toFixed(6)} ` + `(proves capture for ${captureProbeSubject().subject})`
|
|
2986
|
+
};
|
|
2987
|
+
} finally {
|
|
2988
|
+
rmSync2(filepath, { force: true });
|
|
2989
|
+
}
|
|
2990
|
+
}
|
|
2991
|
+
function captureProbeSubject(env = process.env, options = {}) {
|
|
2992
|
+
const platform = options.platform ?? process.platform;
|
|
2993
|
+
if (platform !== "darwin") {
|
|
2994
|
+
return {
|
|
2995
|
+
headless: false,
|
|
2996
|
+
subject_known: true,
|
|
2997
|
+
subject: "this process",
|
|
2998
|
+
note: "microphone access is not gated by TCC on this platform"
|
|
2999
|
+
};
|
|
3000
|
+
}
|
|
3001
|
+
const overSsh = Boolean(env.SSH_CONNECTION || env.SSH_TTY || env.SSH_CLIENT);
|
|
3002
|
+
const inTmux = Boolean(env.TMUX || env.TMUX_PANE);
|
|
3003
|
+
const termProgram = env.TERM_PROGRAM?.trim();
|
|
3004
|
+
const hasTty = options.hasTty ?? Boolean(process.stdin.isTTY || process.stdout.isTTY);
|
|
3005
|
+
if (overSsh) {
|
|
3006
|
+
const subject2 = "the SSH session (sshd), not Recordings.app";
|
|
3007
|
+
return {
|
|
3008
|
+
headless: true,
|
|
3009
|
+
subject_known: true,
|
|
3010
|
+
subject: subject2,
|
|
3011
|
+
note: "Running over SSH: macOS cannot display a consent prompt to a session with no GUI, " + "so Microphone stays not_determined and a silent capture here says NOTHING about " + "whether Recordings.app can record. Judge the app by its own TCC entry and its log."
|
|
3012
|
+
};
|
|
3013
|
+
}
|
|
3014
|
+
if (inTmux) {
|
|
3015
|
+
const subject2 = "the tmux server, not Recordings.app and not this pane's shell";
|
|
3016
|
+
return {
|
|
3017
|
+
headless: false,
|
|
3018
|
+
subject_known: true,
|
|
3019
|
+
subject: subject2,
|
|
3020
|
+
note: "Running inside tmux: TCC attributes this capture to the tmux binary, which holds its " + "own grant. tmux also snapshots the environment at pane creation, so SSH variables may " + "be missing even in a remote session \u2014 treat a silent result as inconclusive about both " + "Recordings.app and about whether anyone could have been prompted."
|
|
3021
|
+
};
|
|
3022
|
+
}
|
|
3023
|
+
if (!termProgram && !hasTty) {
|
|
3024
|
+
return {
|
|
3025
|
+
headless: true,
|
|
3026
|
+
subject_known: false,
|
|
3027
|
+
subject: "an unidentified responsible process (not Recordings.app)",
|
|
3028
|
+
note: "The responsible process could not be identified: no SSH variables, no TERM_PROGRAM and " + "no tty, which is what launchd, cron, CI and sudo look like. Whatever holds the grant, it " + "is not Recordings.app, and nothing here can be shown a consent prompt. Inconclusive."
|
|
3029
|
+
};
|
|
3030
|
+
}
|
|
3031
|
+
const subject = `${termProgram || "the terminal application running this command"}, not Recordings.app`;
|
|
3032
|
+
return {
|
|
3033
|
+
headless: false,
|
|
3034
|
+
subject_known: Boolean(termProgram),
|
|
3035
|
+
subject,
|
|
3036
|
+
note: `Grants are per responsible process: this probe exercises ${subject}. ` + "A pass proves the microphone hardware and the input device work; it does not " + "transfer to Recordings.app, which needs its own grant."
|
|
3037
|
+
};
|
|
3038
|
+
}
|
|
3039
|
+
var TCC_UNREADABLE_STATE = TCC_DATABASE_UNREADABLE_STATE;
|
|
3040
|
+
function classifyPermissionState(permissionState) {
|
|
3041
|
+
switch (permissionState) {
|
|
3042
|
+
case "allowed":
|
|
3043
|
+
case "allowed_identity_unverified":
|
|
3044
|
+
case "denied":
|
|
3045
|
+
case "limited":
|
|
3046
|
+
case "unknown":
|
|
3047
|
+
case "stale_allowed_for_previous_app_build":
|
|
3048
|
+
case "unverified_no_installed_bundle":
|
|
3049
|
+
return "requested";
|
|
3050
|
+
case "not_determined":
|
|
3051
|
+
return "never_requested";
|
|
3052
|
+
default:
|
|
3053
|
+
break;
|
|
3054
|
+
}
|
|
3055
|
+
if (permissionState.startsWith("unknown("))
|
|
3056
|
+
return "requested";
|
|
3057
|
+
if (permissionState.endsWith("_identity_unverified"))
|
|
3058
|
+
return "requested";
|
|
3059
|
+
return "unknown";
|
|
3060
|
+
}
|
|
3061
|
+
function microphoneGrantInstruction(options) {
|
|
3062
|
+
const candidates = [options.installedAppPath, ...options.otherAppPaths ?? []].filter((path) => Boolean(path)).filter((path, index, all) => all.indexOf(path) === index).filter((path) => existsSync4(path));
|
|
3063
|
+
const bundlePath = candidates[0] ?? null;
|
|
3064
|
+
const steps = [];
|
|
3065
|
+
if (!bundlePath) {
|
|
3066
|
+
steps.push("No Recordings.app bundle was found on disk, so there is nothing to grant Microphone to yet. " + "Install the app first ('recordings app install').");
|
|
3067
|
+
return {
|
|
3068
|
+
bundle_path: null,
|
|
3069
|
+
bundle_identifier: RECORDINGS_BUNDLE_IDENTIFIER,
|
|
3070
|
+
candidate_bundle_paths: candidates,
|
|
3071
|
+
steps
|
|
3072
|
+
};
|
|
3073
|
+
}
|
|
3074
|
+
if (candidates.length > 1) {
|
|
3075
|
+
steps.push(`AMBIGUOUS: ${candidates.length} Recordings.app bundles exist (${candidates.join(", ")}). ` + "A TCC grant is bound to the bundle's code signature, so granting one does not grant the " + "other, and the toggle in Settings does not say which is which. Remove the bundles you are " + "not running before granting, or the grant may attach to the wrong one.");
|
|
3076
|
+
}
|
|
3077
|
+
steps.push(`At the keyboard on the machine itself (not over SSH), launch ${bundlePath} and start a ` + "recording once. macOS shows the consent sheet titled " + `"\u201CRecordings\u201D would like to access the microphone" \u2014 click Allow.`);
|
|
3078
|
+
steps.push("If no sheet appears, open System Settings \u2192 Privacy & Security \u2192 Microphone " + "and switch ON the row named \u201CRecordings\u201D. " + `That row is bundle ${RECORDINGS_BUNDLE_IDENTIFIER} at ${bundlePath}; the binary that ` + `receives the grant is ${join5(bundlePath, "Contents", "MacOS", "Recordings")}.`);
|
|
3079
|
+
if (options.requestState === "never_requested") {
|
|
3080
|
+
steps.push("Note: the app has never requested microphone access on this machine (no TCC entry exists), " + "so the Microphone list will NOT contain a \u201CRecordings\u201D row until the app asks once. " + "Do the launch-and-record step first; the Settings toggle only exists afterwards.");
|
|
3081
|
+
} else if (options.requestState === "unknown") {
|
|
3082
|
+
steps.push("Note: whether the app has already asked could NOT be determined \u2014 the permission state " + `did not resolve (e.g. ${TCC_UNREADABLE_STATE}: the user TCC database could not be read). ` + "So a \u201CRecordings\u201D row may or may not already be in the Microphone list. Full Disk Access " + "is inherited from the session's responsible process, not granted per-tool, so re-read the " + "state from a plain ssh shell (whose parent holds it) rather than granting it to tmux or " + "bun \u2014 and not under sudo, which changes the responsible process. Do not conclude anything " + "from this state alone.");
|
|
3083
|
+
}
|
|
3084
|
+
steps.push("Confirm afterwards with 'recordings app status --json' \u2014 microphone_permission must read " + "allowed, and a fresh line in ~/.hasna/recordings/Recordings.log must read " + "'RecordingEngine init; microphone=Microphone allowed'.");
|
|
3085
|
+
steps.push("The grant is bound to the bundle's code signature: re-signing or rebuilding the app with a " + "different identity voids it and this instruction has to be repeated.");
|
|
3086
|
+
return {
|
|
3087
|
+
bundle_path: bundlePath,
|
|
3088
|
+
bundle_identifier: RECORDINGS_BUNDLE_IDENTIFIER,
|
|
3089
|
+
candidate_bundle_paths: candidates,
|
|
3090
|
+
steps
|
|
3091
|
+
};
|
|
3092
|
+
}
|
|
3093
|
+
|
|
3094
|
+
// src/lib/persistence-probe.ts
|
|
3095
|
+
import { Database as Database2 } from "bun:sqlite";
|
|
3096
|
+
import { existsSync as existsSync5 } from "fs";
|
|
3097
|
+
var AUTO_FLIP_MODE_SOURCE = "auto:api-url+api-key";
|
|
3098
|
+
function safeBaseUrl(baseUrl) {
|
|
3099
|
+
if (!baseUrl)
|
|
3100
|
+
return baseUrl;
|
|
3101
|
+
try {
|
|
3102
|
+
const url = new URL(baseUrl);
|
|
3103
|
+
if (!url.username && !url.password)
|
|
3104
|
+
return baseUrl;
|
|
3105
|
+
url.username = "";
|
|
3106
|
+
url.password = "";
|
|
3107
|
+
return `${url.toString().replace(/\/+$/, "")} (credentials in the URL redacted)`;
|
|
3108
|
+
} catch {
|
|
3109
|
+
return "[unparseable URL redacted]";
|
|
3110
|
+
}
|
|
3111
|
+
}
|
|
3112
|
+
function readLocalRecordingCount(dbPath) {
|
|
3113
|
+
try {
|
|
3114
|
+
const db = new Database2(dbPath, { readonly: true });
|
|
3115
|
+
try {
|
|
3116
|
+
const row = db.query("SELECT COUNT(*) AS count FROM recordings").get();
|
|
3117
|
+
return typeof row?.count === "number" ? row.count : null;
|
|
3118
|
+
} finally {
|
|
3119
|
+
db.close();
|
|
3120
|
+
}
|
|
3121
|
+
} catch {
|
|
3122
|
+
return null;
|
|
3123
|
+
}
|
|
3124
|
+
}
|
|
3125
|
+
function localStoreIsBehindSchema(dbPath) {
|
|
3126
|
+
if (!existsSync5(dbPath))
|
|
3127
|
+
return null;
|
|
3128
|
+
try {
|
|
3129
|
+
const db = new Database2(dbPath, { readonly: true });
|
|
3130
|
+
try {
|
|
3131
|
+
const row = db.query("SELECT MAX(id) AS max_id FROM _migrations").get();
|
|
3132
|
+
const level = typeof row?.max_id === "number" ? row.max_id : -1;
|
|
3133
|
+
return level < CURRENT_MIGRATION_LEVEL;
|
|
3134
|
+
} finally {
|
|
3135
|
+
db.close();
|
|
3136
|
+
}
|
|
3137
|
+
} catch {
|
|
3138
|
+
return null;
|
|
3139
|
+
}
|
|
3140
|
+
}
|
|
3141
|
+
function describeActiveStore(config, env = process.env) {
|
|
3142
|
+
const localDbPath = config.db_path;
|
|
3143
|
+
const localDbPresent = existsSync5(localDbPath);
|
|
3144
|
+
const localDbRecordings = localDbPresent ? readLocalRecordingCount(localDbPath) : null;
|
|
3145
|
+
let resolution;
|
|
3146
|
+
try {
|
|
3147
|
+
resolution = resolveTransport(APP, env);
|
|
3148
|
+
} catch (error) {
|
|
3149
|
+
return {
|
|
3150
|
+
transport: "sqlite",
|
|
3151
|
+
mode_source: "unresolved",
|
|
3152
|
+
base_url: null,
|
|
3153
|
+
local_db_path: localDbPath,
|
|
3154
|
+
local_db_present: localDbPresent,
|
|
3155
|
+
local_db_recordings: localDbRecordings,
|
|
3156
|
+
divergent: false,
|
|
3157
|
+
warning: redactKeyMaterial(`could not resolve the active store: ${error.message}`)
|
|
3158
|
+
};
|
|
3159
|
+
}
|
|
3160
|
+
const divergent = resolution.transport === "http" && (localDbRecordings ?? 0) > 0;
|
|
3161
|
+
const warnings = [];
|
|
3162
|
+
if (resolution.warning)
|
|
3163
|
+
warnings.push(resolution.warning);
|
|
3164
|
+
if (resolution.transport === "http" && localDbPresent && localDbRecordings === null) {
|
|
3165
|
+
warnings.push(`writes go to ${safeBaseUrl(resolution.baseUrl)}, and ${localDbPath} exists but could not be counted, ` + "so whether a second dataset is sitting there is UNKNOWN \u2014 not 'none'.");
|
|
3166
|
+
}
|
|
3167
|
+
if (divergent) {
|
|
3168
|
+
warnings.push(`writes go to ${safeBaseUrl(resolution.baseUrl)}, but ${localDbPath} still holds ` + `${localDbRecordings} recordings from an earlier on-box-only period. ` + "That file is NOT the live store \u2014 auditing it undercounts and looks like data loss.");
|
|
3169
|
+
}
|
|
3170
|
+
if (resolution.transport === "http" && resolution.modeSource === AUTO_FLIP_MODE_SOURCE) {
|
|
3171
|
+
warnings.push("the API transport was selected by the mere PRESENCE of " + "HASNA_RECORDINGS_API_URL + HASNA_RECORDINGS_API_KEY, with no store variable set. " + "Check `launchctl getenv HASNA_RECORDINGS_API_URL` too: a launchd session variable " + "is inherited by the GUI app as well as by shells, and is invisible in a login profile.");
|
|
3172
|
+
}
|
|
3173
|
+
return {
|
|
3174
|
+
transport: resolution.transport,
|
|
3175
|
+
mode_source: resolution.modeSource,
|
|
3176
|
+
base_url: safeBaseUrl(resolution.baseUrl),
|
|
3177
|
+
local_db_path: localDbPath,
|
|
3178
|
+
local_db_present: localDbPresent,
|
|
3179
|
+
local_db_recordings: localDbRecordings,
|
|
3180
|
+
divergent,
|
|
3181
|
+
warning: warnings.length > 0 ? warnings.join(" ") : null
|
|
3182
|
+
};
|
|
3183
|
+
}
|
|
3184
|
+
var PROBE_SEVERITY_GLYPHS = {
|
|
3185
|
+
pass: "\u2713",
|
|
3186
|
+
warn: "?",
|
|
3187
|
+
fail: "\u2717"
|
|
3188
|
+
};
|
|
3189
|
+
function persistenceOutcomeSeverity(outcome) {
|
|
3190
|
+
switch (outcome) {
|
|
3191
|
+
case "proved":
|
|
3192
|
+
return "pass";
|
|
3193
|
+
case "skipped":
|
|
3194
|
+
return "warn";
|
|
3195
|
+
case "failed":
|
|
3196
|
+
return "fail";
|
|
3197
|
+
}
|
|
3198
|
+
}
|
|
3199
|
+
function renderPersistenceMarker(outcome, paint) {
|
|
3200
|
+
const severity = persistenceOutcomeSeverity(outcome);
|
|
3201
|
+
return paint[severity](PROBE_SEVERITY_GLYPHS[severity]);
|
|
3202
|
+
}
|
|
3203
|
+
var PERSISTENCE_PROBE_TAG = "check-probe";
|
|
3204
|
+
var PERSISTENCE_PROBE_MARKER_PREFIX = "recordings check --probe persistence round-trip";
|
|
3205
|
+
async function probeRecordingPersistence(options = {}) {
|
|
3206
|
+
const store = options.store ?? getStore();
|
|
3207
|
+
const stamp = (options.now?.() ?? new Date).toISOString();
|
|
3208
|
+
const marker = `${PERSISTENCE_PROBE_MARKER_PREFIX} ${stamp}`;
|
|
3209
|
+
const transport = store.mode === "http" ? "http" : "sqlite";
|
|
3210
|
+
const base = {
|
|
3211
|
+
attempted: false,
|
|
3212
|
+
transport,
|
|
3213
|
+
base_url: safeBaseUrl(store.baseUrl),
|
|
3214
|
+
recording_id: null,
|
|
3215
|
+
read_back: false,
|
|
3216
|
+
cleaned_up: false,
|
|
3217
|
+
created_local_store: false,
|
|
3218
|
+
reachable: null
|
|
3219
|
+
};
|
|
3220
|
+
if (transport === "sqlite" && options.localStoreIsLegacy === true && !options.allowLocalMigration) {
|
|
3221
|
+
return {
|
|
3222
|
+
...base,
|
|
3223
|
+
ok: false,
|
|
3224
|
+
outcome: "skipped",
|
|
3225
|
+
message: "NOT MEASURED: the local SQLite store is behind the current schema, and writing to it " + "would apply pending migrations to a legacy file. Migrate deliberately " + "('recordings migrate') or re-run with --probe-store-write to accept the migration as " + "part of the probe."
|
|
3226
|
+
};
|
|
3227
|
+
}
|
|
3228
|
+
if (transport === "http" && !options.allowRemoteWrite) {
|
|
3229
|
+
let reachable = null;
|
|
3230
|
+
let reachabilityDetail = "";
|
|
3231
|
+
try {
|
|
3232
|
+
await store.getRecordingStats();
|
|
3233
|
+
reachable = true;
|
|
3234
|
+
reachabilityDetail = " The store did answer a read-only stats request, so it is reachable " + "and the credential is accepted.";
|
|
3235
|
+
} catch (error) {
|
|
3236
|
+
reachable = false;
|
|
3237
|
+
reachabilityDetail = " The store did NOT answer a read-only stats request: " + `${redactKeyMaterial(error.message)}.`;
|
|
3238
|
+
}
|
|
3239
|
+
return {
|
|
3240
|
+
...base,
|
|
3241
|
+
reachable,
|
|
3242
|
+
ok: false,
|
|
3243
|
+
outcome: "skipped",
|
|
3244
|
+
message: `NOT MEASURED: the write round-trip was skipped, because it would add a marker recording ` + `to the shared API store at ${safeBaseUrl(store.baseUrl)}, which is production for every ` + `machine pointed at it. Re-run with --probe-store-write to prove the write path for real.` + reachabilityDetail
|
|
3245
|
+
};
|
|
3246
|
+
}
|
|
3247
|
+
let created = null;
|
|
3248
|
+
try {
|
|
3249
|
+
created = await store.createRecording({
|
|
3250
|
+
raw_text: marker,
|
|
3251
|
+
processing_mode: "raw",
|
|
3252
|
+
model_used: "check-probe",
|
|
3253
|
+
duration_ms: 0,
|
|
3254
|
+
tags: ["diagnostic", PERSISTENCE_PROBE_TAG],
|
|
3255
|
+
metadata: { persistence_probe: true, probed_at: stamp }
|
|
3256
|
+
});
|
|
3257
|
+
} catch (error) {
|
|
3258
|
+
return {
|
|
3259
|
+
...base,
|
|
3260
|
+
attempted: true,
|
|
3261
|
+
ok: false,
|
|
3262
|
+
outcome: "failed",
|
|
3263
|
+
message: `store rejected the write: ${redactKeyMaterial(error.message)}. ` + "If this was a timeout rather than a refusal, the write may still have committed under " + `an id this process never saw \u2014 check with 'recordings search "${PERSISTENCE_PROBE_MARKER_PREFIX}"'.`
|
|
3264
|
+
};
|
|
3265
|
+
}
|
|
3266
|
+
if (!created?.id) {
|
|
3267
|
+
return {
|
|
3268
|
+
...base,
|
|
3269
|
+
attempted: true,
|
|
3270
|
+
ok: false,
|
|
3271
|
+
outcome: "failed",
|
|
3272
|
+
message: "store accepted the write but returned no id"
|
|
3273
|
+
};
|
|
3274
|
+
}
|
|
3275
|
+
let readBack = false;
|
|
3276
|
+
let readError = null;
|
|
3277
|
+
try {
|
|
3278
|
+
const fetched = await store.getRecording(created.id);
|
|
3279
|
+
readBack = fetched?.raw_text === marker;
|
|
3280
|
+
if (fetched && !readBack) {
|
|
3281
|
+
readError = "the stored transcript did not match what was written";
|
|
3282
|
+
} else if (!fetched) {
|
|
3283
|
+
readError = "the write was accepted but the recording could not be read back";
|
|
3284
|
+
}
|
|
3285
|
+
} catch (error) {
|
|
3286
|
+
readError = `read-back failed: ${redactKeyMaterial(error.message)}`;
|
|
3287
|
+
}
|
|
3288
|
+
let cleanedUp = false;
|
|
3289
|
+
let cleanupError = null;
|
|
3290
|
+
try {
|
|
3291
|
+
const deleteReported = await store.deleteRecording(created.id);
|
|
3292
|
+
if (!deleteReported) {
|
|
3293
|
+
cleanupError = "the store reported nothing to delete";
|
|
3294
|
+
} else {
|
|
3295
|
+
const stillThere = await store.getRecording(created.id);
|
|
3296
|
+
if (stillThere) {
|
|
3297
|
+
cleanupError = "the store reported the delete succeeded but the recording is still readable";
|
|
3298
|
+
} else {
|
|
3299
|
+
cleanedUp = true;
|
|
3300
|
+
}
|
|
3301
|
+
}
|
|
3302
|
+
} catch (error) {
|
|
3303
|
+
cleanupError = redactKeyMaterial(error.message);
|
|
3304
|
+
}
|
|
3305
|
+
const createdLocalStore = transport === "sqlite" && options.localStoreExistedBefore === false;
|
|
3306
|
+
const where = store.baseUrl ? `the API store at ${safeBaseUrl(store.baseUrl)}` : "the local SQLite store";
|
|
3307
|
+
const createdNote = createdLocalStore ? " (this probe CREATED and migrated the local SQLite store, which did not exist before)" : "";
|
|
3308
|
+
if (readError) {
|
|
3309
|
+
return {
|
|
3310
|
+
...base,
|
|
3311
|
+
attempted: true,
|
|
3312
|
+
recording_id: created.id,
|
|
3313
|
+
cleaned_up: cleanedUp,
|
|
3314
|
+
created_local_store: createdLocalStore,
|
|
3315
|
+
ok: false,
|
|
3316
|
+
outcome: "failed",
|
|
3317
|
+
message: `${readError} (${where}, id ${created.id.slice(0, 8)})${createdNote}`
|
|
3318
|
+
};
|
|
3319
|
+
}
|
|
3320
|
+
if (!cleanedUp) {
|
|
3321
|
+
return {
|
|
3322
|
+
...base,
|
|
3323
|
+
attempted: true,
|
|
3324
|
+
recording_id: created.id,
|
|
3325
|
+
read_back: true,
|
|
3326
|
+
cleaned_up: false,
|
|
3327
|
+
created_local_store: createdLocalStore,
|
|
3328
|
+
ok: false,
|
|
3329
|
+
outcome: "failed",
|
|
3330
|
+
message: `wrote and read back a marker recording in ${where}, but could NOT confirm its removal ` + `(${cleanupError ?? "cleanup failed"}). The probe recording ${created.id} may still be ` + `there \u2014 remove it with 'recordings delete ${created.id}'.${createdNote}`
|
|
3331
|
+
};
|
|
3332
|
+
}
|
|
3333
|
+
return {
|
|
3334
|
+
...base,
|
|
3335
|
+
attempted: true,
|
|
3336
|
+
recording_id: created.id,
|
|
3337
|
+
read_back: true,
|
|
3338
|
+
cleaned_up: true,
|
|
3339
|
+
created_local_store: createdLocalStore,
|
|
3340
|
+
ok: true,
|
|
3341
|
+
outcome: "proved",
|
|
3342
|
+
message: `wrote, read back and confirmed removal of a marker recording in ${where}${createdNote}`
|
|
3343
|
+
};
|
|
3344
|
+
}
|
|
3345
|
+
|
|
2707
3346
|
// src/lib/enhancer.ts
|
|
2708
3347
|
import OpenAI2 from "openai";
|
|
2709
3348
|
var _enhancementClient = null;
|
|
@@ -2968,80 +3607,805 @@ ${block}` : block;
|
|
|
2968
3607
|
}
|
|
2969
3608
|
|
|
2970
3609
|
// src/cli/macos-permissions.ts
|
|
2971
|
-
import { spawnSync as
|
|
2972
|
-
import {
|
|
2973
|
-
|
|
3610
|
+
import { spawnSync as spawnSync3 } from "child_process";
|
|
3611
|
+
import { existsSync as existsSync6, mkdtempSync, rmSync as rmSync3, statSync as statSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
3612
|
+
import { tmpdir as tmpdir2 } from "os";
|
|
3613
|
+
import { join as join6 } from "path";
|
|
3614
|
+
var defaultPermissionHelperRunner = (executable, arguments_, options) => spawnSync3(executable, arguments_, options);
|
|
2974
3615
|
function runMacOSPermissionRequest(appPath, runner = defaultPermissionHelperRunner) {
|
|
2975
|
-
const executable =
|
|
3616
|
+
const executable = join6(appPath, "Contents", "MacOS", "Recordings");
|
|
2976
3617
|
const result = runner(executable, ["--request-permissions", "--open-permission-settings"], { stdio: "inherit" });
|
|
2977
3618
|
return {
|
|
2978
3619
|
exitCode: result.error ? 1 : result.status ?? 1,
|
|
2979
3620
|
errorMessage: result.error?.message
|
|
2980
3621
|
};
|
|
2981
3622
|
}
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
return hostName.trim();
|
|
3623
|
+
var CODESIGN_REQUIREMENT_SATISFIED_STATUS = 0;
|
|
3624
|
+
var CODESIGN_REQUIREMENT_UNSATISFIED_STATUS = 3;
|
|
3625
|
+
function tccDatabasePaths(home) {
|
|
3626
|
+
return [
|
|
3627
|
+
join6(home, "Library", "Application Support", "com.apple.TCC", "TCC.db"),
|
|
3628
|
+
join6("/", "Library", "Application Support", "com.apple.TCC", "TCC.db")
|
|
3629
|
+
];
|
|
2990
3630
|
}
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
|
|
3631
|
+
function tccAuthValueLabel(value) {
|
|
3632
|
+
switch (value) {
|
|
3633
|
+
case "0":
|
|
3634
|
+
return "denied";
|
|
3635
|
+
case "1":
|
|
3636
|
+
return "unknown";
|
|
3637
|
+
case "2":
|
|
3638
|
+
return "allowed";
|
|
3639
|
+
case "3":
|
|
3640
|
+
return "limited";
|
|
3641
|
+
default:
|
|
3642
|
+
return `unknown(${value})`;
|
|
3643
|
+
}
|
|
3644
|
+
}
|
|
3645
|
+
var defaultCodesignRequirementRunner = (requirementPath, appPath) => spawnSync3("/usr/bin/codesign", ["--verify", "--verbose=2", "-R", requirementPath, appPath], { encoding: "utf8", stdio: ["ignore", "ignore", "ignore"] });
|
|
3646
|
+
function verifyStoredRequirementWithCodesign(csreqHex, appPath, runner = defaultCodesignRequirementRunner, bundleExists = existsSync6) {
|
|
3647
|
+
const normalized = csreqHex.trim();
|
|
3648
|
+
if (normalized.length === 0 || normalized.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(normalized)) {
|
|
3649
|
+
return "unverifiable";
|
|
3650
|
+
}
|
|
3651
|
+
if (!appPath || !bundleExists(appPath))
|
|
3652
|
+
return "unverifiable";
|
|
3653
|
+
let scratchDirectory = null;
|
|
3013
3654
|
try {
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
|
|
3655
|
+
scratchDirectory = mkdtempSync(join6(tmpdir2(), "recordings-tcc-csreq-"));
|
|
3656
|
+
const requirementPath = join6(scratchDirectory, "tcc-requirement.bin");
|
|
3657
|
+
writeFileSync2(requirementPath, Buffer.from(normalized, "hex"));
|
|
3658
|
+
const result = runner(requirementPath, appPath);
|
|
3659
|
+
if (result.error)
|
|
3660
|
+
return "unverifiable";
|
|
3661
|
+
if (result.status === CODESIGN_REQUIREMENT_SATISFIED_STATUS)
|
|
3662
|
+
return "satisfied";
|
|
3663
|
+
if (result.status === CODESIGN_REQUIREMENT_UNSATISFIED_STATUS)
|
|
3664
|
+
return "unsatisfied";
|
|
3665
|
+
return "unverifiable";
|
|
3018
3666
|
} catch {
|
|
3019
|
-
return
|
|
3667
|
+
return "unverifiable";
|
|
3668
|
+
} finally {
|
|
3669
|
+
if (scratchDirectory)
|
|
3670
|
+
rmSync3(scratchDirectory, { recursive: true, force: true });
|
|
3020
3671
|
}
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
|
-
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
}
|
|
3672
|
+
}
|
|
3673
|
+
var defaultTccPermissionProbe = {
|
|
3674
|
+
databasePresence: (dbPath) => {
|
|
3675
|
+
try {
|
|
3676
|
+
statSync2(dbPath);
|
|
3677
|
+
return "present";
|
|
3678
|
+
} catch (error) {
|
|
3679
|
+
return error.code === "ENOENT" ? "absent" : "indeterminate";
|
|
3680
|
+
}
|
|
3681
|
+
},
|
|
3682
|
+
readAccessRow: (dbPath, service) => {
|
|
3683
|
+
const sql = "select ifnull(auth_value, '') || '|' || ifnull(hex(csreq), '') from access where service = '" + service.replace(/'/g, "''") + "' and client = '" + RECORDINGS_BUNDLE_IDENTIFIER + "' and client_type = 0 order by last_modified desc limit 1;";
|
|
3684
|
+
const result = spawnSync3("/usr/bin/sqlite3", [dbPath, sql], {
|
|
3685
|
+
encoding: "utf8",
|
|
3686
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
3687
|
+
});
|
|
3688
|
+
if (result.error)
|
|
3689
|
+
return { kind: "unreadable", detail: result.error.message };
|
|
3690
|
+
if (result.status !== 0) {
|
|
3691
|
+
const detail = (result.stderr ?? "").trim() || `sqlite3 exited ${result.status}`;
|
|
3692
|
+
if (/no such table/i.test(detail))
|
|
3693
|
+
return { kind: "absent" };
|
|
3694
|
+
return { kind: "unreadable", detail };
|
|
3695
|
+
}
|
|
3696
|
+
const value = result.stdout?.trim() ?? "";
|
|
3697
|
+
if (!value)
|
|
3698
|
+
return { kind: "absent" };
|
|
3699
|
+
const [authValue = "", csreqHex = ""] = value.split("|");
|
|
3700
|
+
return { kind: "row", row: { authValue, csreqHex } };
|
|
3701
|
+
},
|
|
3702
|
+
verifyStoredRequirement: (csreqHex, appPath) => verifyStoredRequirementWithCodesign(csreqHex, appPath),
|
|
3703
|
+
describeStoredRequirement: (csreqHex) => describeStoredRequirementWithCsreq(csreqHex)
|
|
3704
|
+
};
|
|
3705
|
+
function describeStoredRequirementWithCsreq(csreqHex, runner = (requirementPath) => spawnSync3("/usr/bin/csreq", ["-r", requirementPath, "-t"], {
|
|
3706
|
+
encoding: "utf8",
|
|
3707
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
3708
|
+
})) {
|
|
3709
|
+
const normalized = csreqHex.trim();
|
|
3710
|
+
if (normalized.length === 0 || normalized.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(normalized)) {
|
|
3711
|
+
return null;
|
|
3712
|
+
}
|
|
3713
|
+
let scratchDirectory = null;
|
|
3714
|
+
try {
|
|
3715
|
+
scratchDirectory = mkdtempSync(join6(tmpdir2(), "recordings-tcc-decode-"));
|
|
3716
|
+
const requirementPath = join6(scratchDirectory, "tcc-requirement.bin");
|
|
3717
|
+
writeFileSync2(requirementPath, Buffer.from(normalized, "hex"));
|
|
3718
|
+
const result = runner(requirementPath);
|
|
3719
|
+
if (result.error || result.status !== 0)
|
|
3720
|
+
return null;
|
|
3721
|
+
const text = (result.stdout ?? "").trim();
|
|
3722
|
+
return text.length > 0 ? text : null;
|
|
3723
|
+
} catch {
|
|
3724
|
+
return null;
|
|
3725
|
+
} finally {
|
|
3726
|
+
if (scratchDirectory)
|
|
3727
|
+
rmSync3(scratchDirectory, { recursive: true, force: true });
|
|
3728
|
+
}
|
|
3729
|
+
}
|
|
3730
|
+
function resolveTccGrant(options) {
|
|
3731
|
+
const probe = options.probe ?? defaultTccPermissionProbe;
|
|
3732
|
+
let sawUnreadableDatabase = false;
|
|
3733
|
+
for (const dbPath of tccDatabasePaths(options.home)) {
|
|
3734
|
+
const presence = probe.databasePresence(dbPath);
|
|
3735
|
+
if (presence === "indeterminate") {
|
|
3736
|
+
sawUnreadableDatabase = true;
|
|
3737
|
+
continue;
|
|
3738
|
+
}
|
|
3739
|
+
if (presence === "absent")
|
|
3740
|
+
continue;
|
|
3741
|
+
const lookup = probe.readAccessRow(dbPath, options.service);
|
|
3742
|
+
if (lookup.kind === "unreadable") {
|
|
3743
|
+
sawUnreadableDatabase = true;
|
|
3744
|
+
continue;
|
|
3745
|
+
}
|
|
3746
|
+
if (lookup.kind === "absent")
|
|
3747
|
+
continue;
|
|
3748
|
+
const row = lookup.row;
|
|
3749
|
+
const storedRequirement = probe.describeStoredRequirement?.(row.csreqHex) ?? null;
|
|
3750
|
+
const durability = classifyTccGrantDurability({
|
|
3751
|
+
designatedRequirement: storedRequirement,
|
|
3752
|
+
adHocSigned: false
|
|
3753
|
+
});
|
|
3754
|
+
const authValue = row.authValue.trim();
|
|
3755
|
+
if (authValue.length === 0)
|
|
3756
|
+
continue;
|
|
3757
|
+
const label = tccAuthValueLabel(authValue);
|
|
3758
|
+
if (label !== "allowed") {
|
|
3759
|
+
return { state: label, storedRequirement, durability };
|
|
3760
|
+
}
|
|
3761
|
+
if (!options.appPath) {
|
|
3762
|
+
return { state: "unverified_no_installed_bundle", storedRequirement, durability };
|
|
3763
|
+
}
|
|
3764
|
+
switch (probe.verifyStoredRequirement(row.csreqHex, options.appPath)) {
|
|
3765
|
+
case "satisfied":
|
|
3766
|
+
return { state: "allowed", storedRequirement, durability };
|
|
3767
|
+
case "unsatisfied":
|
|
3768
|
+
return { state: "stale_allowed_for_previous_app_build", storedRequirement, durability };
|
|
3769
|
+
default:
|
|
3770
|
+
return { state: "allowed_identity_unverified", storedRequirement, durability };
|
|
3771
|
+
}
|
|
3772
|
+
}
|
|
3773
|
+
return {
|
|
3774
|
+
state: sawUnreadableDatabase ? TCC_DATABASE_UNREADABLE_STATE : "not_determined",
|
|
3775
|
+
storedRequirement: null,
|
|
3776
|
+
durability: "unknown"
|
|
3777
|
+
};
|
|
3778
|
+
}
|
|
3779
|
+
function classifyTccGrantDurability(options) {
|
|
3780
|
+
if (options.adHocSigned)
|
|
3781
|
+
return "dies_on_rebuild_cdhash_pinned";
|
|
3782
|
+
const requirement = options.designatedRequirement?.trim();
|
|
3783
|
+
if (!requirement)
|
|
3784
|
+
return "unknown";
|
|
3785
|
+
const operators = requirement.replace(/"[^"]*"/g, '""');
|
|
3786
|
+
if (/\bcdhash\b/i.test(operators))
|
|
3787
|
+
return "dies_on_rebuild_cdhash_pinned";
|
|
3788
|
+
if (/\banchor\s+apple\s+generic\b/i.test(operators))
|
|
3789
|
+
return "survives_rebuild_developer_id";
|
|
3790
|
+
if (/\banchor\s+apple\b/i.test(operators))
|
|
3791
|
+
return "survives_rebuild_certificate_anchored";
|
|
3792
|
+
if (/\bcertificate\s+(root|leaf|\d+)\b|\banchor\s+H""/i.test(operators)) {
|
|
3793
|
+
return "survives_rebuild_certificate_anchored";
|
|
3794
|
+
}
|
|
3795
|
+
return "unknown";
|
|
3796
|
+
}
|
|
3797
|
+
function describeTccAuthorizationSubject(appPath) {
|
|
3798
|
+
if (!appPath) {
|
|
3799
|
+
return `${RECORDINGS_BUNDLE_IDENTIFIER} (no installed bundle found \u2014 nothing to report a grant for)`;
|
|
3800
|
+
}
|
|
3801
|
+
return `${appPath} (${RECORDINGS_BUNDLE_IDENTIFIER})`;
|
|
3802
|
+
}
|
|
3803
|
+
|
|
3804
|
+
// src/cli/macos-shortcut.ts
|
|
3805
|
+
import { spawnSync as spawnSync4 } from "child_process";
|
|
3806
|
+
import { existsSync as existsSync7 } from "fs";
|
|
3807
|
+
var TRIGGER_DEFAULTS_EXECUTABLE = process.platform === "darwin" ? "/usr/bin/defaults" : process.env.RECORDINGS_TEST_DEFAULTS_EXECUTABLE ?? null;
|
|
3808
|
+
var RECORDINGS_BUNDLE_ID = RECORDINGS_BUNDLE_IDENTIFIER;
|
|
3809
|
+
var SHORTCUT_USER_DEFAULTS_PREFIX = "KeyboardShortcuts_";
|
|
3810
|
+
var TOGGLE_RECORDING_SHORTCUT_NAME = "toggleRecording";
|
|
3811
|
+
var USE_FN_KEY_DEFAULTS_KEY = "useFnKey";
|
|
3812
|
+
var DEFAULT_TOGGLE_RECORDING_CHORD = "f5";
|
|
3813
|
+
var TOGGLE_RECORDING_DEFAULTS_KEY = `${SHORTCUT_USER_DEFAULTS_PREFIX}${TOGGLE_RECORDING_SHORTCUT_NAME}`;
|
|
3814
|
+
var TRIGGER_GRANT_REQUIREMENTS = [
|
|
3815
|
+
{
|
|
3816
|
+
id: "hotkey",
|
|
3817
|
+
label: "Hotkey",
|
|
3818
|
+
mechanism: "Carbon RegisterEventHotKey, via KeyboardShortcuts 1.12.0",
|
|
3819
|
+
tccService: null,
|
|
3820
|
+
settingsPath: null
|
|
3821
|
+
},
|
|
3822
|
+
{
|
|
3823
|
+
id: "fn",
|
|
3824
|
+
label: "fn/Globe",
|
|
3825
|
+
mechanism: "CGEventTap (.defaultTap \u2014 it swallows fn)",
|
|
3826
|
+
tccService: "kTCCServiceAccessibility",
|
|
3827
|
+
settingsPath: "System Settings > Privacy & Security > Accessibility"
|
|
3828
|
+
}
|
|
3829
|
+
];
|
|
3830
|
+
var NON_CHORD_KEYS = {
|
|
3831
|
+
fn: "fn/Globe is not a Carbon key, so it cannot be part of a chord. Enable it with: recordings shortcut --fn on",
|
|
3832
|
+
function: "fn/Globe is not a Carbon key, so it cannot be part of a chord. Enable it with: recordings shortcut --fn on",
|
|
3833
|
+
globe: "fn/Globe is not a Carbon key, so it cannot be part of a chord. Enable it with: recordings shortcut --fn on",
|
|
3834
|
+
"\uD83C\uDF10": "fn/Globe is not a Carbon key, so it cannot be part of a chord. Enable it with: recordings shortcut --fn on"
|
|
3835
|
+
};
|
|
3836
|
+
var CARBON_MODIFIERS = {
|
|
3837
|
+
cmd: 256,
|
|
3838
|
+
shift: 512,
|
|
3839
|
+
opt: 2048,
|
|
3840
|
+
ctrl: 4096
|
|
3841
|
+
};
|
|
3842
|
+
var MODIFIER_ALIASES = {
|
|
3843
|
+
cmd: "cmd",
|
|
3844
|
+
command: "cmd",
|
|
3845
|
+
meta: "cmd",
|
|
3846
|
+
super: "cmd",
|
|
3847
|
+
"\u2318": "cmd",
|
|
3848
|
+
shift: "shift",
|
|
3849
|
+
"\u21E7": "shift",
|
|
3850
|
+
opt: "opt",
|
|
3851
|
+
option: "opt",
|
|
3852
|
+
alt: "opt",
|
|
3853
|
+
"\u2325": "opt",
|
|
3854
|
+
ctrl: "ctrl",
|
|
3855
|
+
control: "ctrl",
|
|
3856
|
+
"\u2303": "ctrl"
|
|
3857
|
+
};
|
|
3858
|
+
var MODIFIER_GLYPHS = [
|
|
3859
|
+
["ctrl", "\u2303"],
|
|
3860
|
+
["opt", "\u2325"],
|
|
3861
|
+
["shift", "\u21E7"],
|
|
3862
|
+
["cmd", "\u2318"]
|
|
3863
|
+
];
|
|
3864
|
+
var CARBON_KEY_CODES = {
|
|
3865
|
+
a: 0,
|
|
3866
|
+
s: 1,
|
|
3867
|
+
d: 2,
|
|
3868
|
+
f: 3,
|
|
3869
|
+
h: 4,
|
|
3870
|
+
g: 5,
|
|
3871
|
+
z: 6,
|
|
3872
|
+
x: 7,
|
|
3873
|
+
c: 8,
|
|
3874
|
+
v: 9,
|
|
3875
|
+
b: 11,
|
|
3876
|
+
q: 12,
|
|
3877
|
+
w: 13,
|
|
3878
|
+
e: 14,
|
|
3879
|
+
r: 15,
|
|
3880
|
+
y: 16,
|
|
3881
|
+
t: 17,
|
|
3882
|
+
o: 31,
|
|
3883
|
+
u: 32,
|
|
3884
|
+
i: 34,
|
|
3885
|
+
p: 35,
|
|
3886
|
+
l: 37,
|
|
3887
|
+
j: 38,
|
|
3888
|
+
k: 40,
|
|
3889
|
+
n: 45,
|
|
3890
|
+
m: 46,
|
|
3891
|
+
"1": 18,
|
|
3892
|
+
"2": 19,
|
|
3893
|
+
"3": 20,
|
|
3894
|
+
"4": 21,
|
|
3895
|
+
"6": 22,
|
|
3896
|
+
"5": 23,
|
|
3897
|
+
"9": 25,
|
|
3898
|
+
"7": 26,
|
|
3899
|
+
"8": 28,
|
|
3900
|
+
"0": 29,
|
|
3901
|
+
equal: 24,
|
|
3902
|
+
minus: 27,
|
|
3903
|
+
rightbracket: 30,
|
|
3904
|
+
leftbracket: 33,
|
|
3905
|
+
quote: 39,
|
|
3906
|
+
semicolon: 41,
|
|
3907
|
+
backslash: 42,
|
|
3908
|
+
comma: 43,
|
|
3909
|
+
slash: 44,
|
|
3910
|
+
period: 47,
|
|
3911
|
+
grave: 50,
|
|
3912
|
+
tab: 48,
|
|
3913
|
+
space: 49,
|
|
3914
|
+
return: 36,
|
|
3915
|
+
enter: 36,
|
|
3916
|
+
delete: 51,
|
|
3917
|
+
escape: 53,
|
|
3918
|
+
esc: 53,
|
|
3919
|
+
f1: 122,
|
|
3920
|
+
f2: 120,
|
|
3921
|
+
f3: 99,
|
|
3922
|
+
f4: 118,
|
|
3923
|
+
f5: 96,
|
|
3924
|
+
f6: 97,
|
|
3925
|
+
f7: 98,
|
|
3926
|
+
f8: 100,
|
|
3927
|
+
f9: 101,
|
|
3928
|
+
f10: 109,
|
|
3929
|
+
f11: 103,
|
|
3930
|
+
f12: 111,
|
|
3931
|
+
f13: 105,
|
|
3932
|
+
f14: 107,
|
|
3933
|
+
f15: 113,
|
|
3934
|
+
f16: 106,
|
|
3935
|
+
f17: 64,
|
|
3936
|
+
f18: 79,
|
|
3937
|
+
f19: 80,
|
|
3938
|
+
f20: 90,
|
|
3939
|
+
home: 115,
|
|
3940
|
+
end: 119,
|
|
3941
|
+
pageup: 116,
|
|
3942
|
+
pagedown: 121,
|
|
3943
|
+
left: 123,
|
|
3944
|
+
right: 124,
|
|
3945
|
+
down: 125,
|
|
3946
|
+
up: 126
|
|
3947
|
+
};
|
|
3948
|
+
var KEY_LABELS = new Map;
|
|
3949
|
+
for (const [name, code] of Object.entries(CARBON_KEY_CODES)) {
|
|
3950
|
+
if (!KEY_LABELS.has(code))
|
|
3951
|
+
KEY_LABELS.set(code, name);
|
|
3952
|
+
}
|
|
3953
|
+
|
|
3954
|
+
class ShortcutParseError extends Error {
|
|
3955
|
+
}
|
|
3956
|
+
function listBindableKeys() {
|
|
3957
|
+
return Object.keys(CARBON_KEY_CODES);
|
|
3958
|
+
}
|
|
3959
|
+
function parseShortcutChord(input) {
|
|
3960
|
+
const trimmed = input.trim();
|
|
3961
|
+
if (!trimmed)
|
|
3962
|
+
throw new ShortcutParseError("Shortcut is empty");
|
|
3963
|
+
const normalized = trimmed.replace(/[\u2303\u2325\u21E7\u2318]/g, (glyph) => `${glyph}+`).toLowerCase();
|
|
3964
|
+
const tokens = normalized.split("+").map((token) => token.trim()).filter((token) => token.length > 0);
|
|
3965
|
+
if (tokens.length === 0)
|
|
3966
|
+
throw new ShortcutParseError(`Could not parse shortcut "${input}"`);
|
|
3967
|
+
let carbonModifiers = 0;
|
|
3968
|
+
const keyTokens = [];
|
|
3969
|
+
for (const token of tokens) {
|
|
3970
|
+
const modifier = MODIFIER_ALIASES[token];
|
|
3971
|
+
if (modifier) {
|
|
3972
|
+
carbonModifiers |= CARBON_MODIFIERS[modifier];
|
|
3973
|
+
continue;
|
|
3974
|
+
}
|
|
3975
|
+
keyTokens.push(token);
|
|
3976
|
+
}
|
|
3977
|
+
if (keyTokens.length === 0) {
|
|
3978
|
+
throw new ShortcutParseError(`"${input}" is only modifiers \u2014 a shortcut needs a real key, e.g. "f13" or "ctrl+opt+r"`);
|
|
3979
|
+
}
|
|
3980
|
+
if (keyTokens.length > 1) {
|
|
3981
|
+
throw new ShortcutParseError(`"${input}" names more than one key (${keyTokens.join(", ")}) \u2014 bind exactly one`);
|
|
3982
|
+
}
|
|
3983
|
+
const [keyToken] = keyTokens;
|
|
3984
|
+
if (keyToken === undefined) {
|
|
3985
|
+
throw new ShortcutParseError(`Could not parse shortcut "${input}"`);
|
|
3986
|
+
}
|
|
3987
|
+
const nonChordReason = NON_CHORD_KEYS[keyToken];
|
|
3988
|
+
if (nonChordReason)
|
|
3989
|
+
throw new ShortcutParseError(nonChordReason);
|
|
3990
|
+
const carbonKeyCode = CARBON_KEY_CODES[keyToken];
|
|
3991
|
+
if (carbonKeyCode === undefined) {
|
|
3992
|
+
throw new ShortcutParseError(`Unknown key "${keyToken}" \u2014 see 'recordings shortcut --keys'`);
|
|
3993
|
+
}
|
|
3994
|
+
return { carbonKeyCode, carbonModifiers };
|
|
3995
|
+
}
|
|
3996
|
+
function formatShortcut(shortcut) {
|
|
3997
|
+
let prefix = "";
|
|
3998
|
+
for (const [token, glyph] of MODIFIER_GLYPHS) {
|
|
3999
|
+
if ((shortcut.carbonModifiers & CARBON_MODIFIERS[token]) === CARBON_MODIFIERS[token]) {
|
|
4000
|
+
prefix += glyph;
|
|
4001
|
+
}
|
|
4002
|
+
}
|
|
4003
|
+
const label = KEY_LABELS.get(shortcut.carbonKeyCode);
|
|
4004
|
+
return `${prefix}${label ? label.toUpperCase() : `keycode ${shortcut.carbonKeyCode}`}`;
|
|
4005
|
+
}
|
|
4006
|
+
var defaultBundleIdentifierReader = (bundlePath) => {
|
|
4007
|
+
if (TRIGGER_DEFAULTS_EXECUTABLE === null)
|
|
4008
|
+
return null;
|
|
4009
|
+
if (!existsSync7(`${bundlePath}/Contents/Info.plist`) && !existsSync7(`${bundlePath}/Contents/Info`)) {
|
|
4010
|
+
return null;
|
|
4011
|
+
}
|
|
4012
|
+
const result = spawnSync4(TRIGGER_DEFAULTS_EXECUTABLE, ["read", `${bundlePath}/Contents/Info`, "CFBundleIdentifier"], { encoding: "utf8" });
|
|
4013
|
+
if (result.status !== 0)
|
|
4014
|
+
return null;
|
|
4015
|
+
return result.stdout.trim() || null;
|
|
4016
|
+
};
|
|
4017
|
+
var PROCESS_LISTER_EXECUTABLE = process.platform === "darwin" ? "/bin/ps" : process.env.RECORDINGS_TEST_PS_EXECUTABLE ?? "/bin/ps";
|
|
4018
|
+
var defaultProcessLister = () => {
|
|
4019
|
+
const result = spawnSync4(PROCESS_LISTER_EXECUTABLE, ["-Awwo", "comm="], { encoding: "utf8" });
|
|
4020
|
+
if (result.status !== 0)
|
|
4021
|
+
return null;
|
|
4022
|
+
return result.stdout;
|
|
4023
|
+
};
|
|
4024
|
+
function runningAppBundlePaths(probes = {}) {
|
|
4025
|
+
const listProcesses = probes.listProcesses ?? defaultProcessLister;
|
|
4026
|
+
const readBundleIdentifier = probes.readBundleIdentifier ?? defaultBundleIdentifierReader;
|
|
4027
|
+
const listing = listProcesses();
|
|
4028
|
+
if (!listing)
|
|
4029
|
+
return [];
|
|
4030
|
+
const suffixPattern = /\/Contents\/MacOS\/[^/]+$/;
|
|
4031
|
+
const paths = new Set;
|
|
4032
|
+
const identifierCache = new Map;
|
|
4033
|
+
const readCached = (candidate) => {
|
|
4034
|
+
const cached2 = identifierCache.get(candidate);
|
|
4035
|
+
if (cached2 !== undefined)
|
|
4036
|
+
return cached2;
|
|
4037
|
+
const identifier = readBundleIdentifier(candidate);
|
|
4038
|
+
identifierCache.set(candidate, identifier);
|
|
4039
|
+
return identifier;
|
|
4040
|
+
};
|
|
4041
|
+
for (const raw of listing.split(`
|
|
4042
|
+
`)) {
|
|
4043
|
+
const line = raw.trim();
|
|
4044
|
+
const suffix = suffixPattern.exec(line);
|
|
4045
|
+
if (!suffix)
|
|
4046
|
+
continue;
|
|
4047
|
+
const bundle = line.slice(0, suffix.index);
|
|
4048
|
+
for (let index = bundle.indexOf("/");index !== -1; index = bundle.indexOf("/", index + 1)) {
|
|
4049
|
+
const candidate = bundle.slice(index);
|
|
4050
|
+
if (!isThisApp(candidate, readCached))
|
|
4051
|
+
continue;
|
|
4052
|
+
paths.add(candidate);
|
|
4053
|
+
break;
|
|
4054
|
+
}
|
|
4055
|
+
}
|
|
4056
|
+
return [...paths].sort();
|
|
4057
|
+
}
|
|
4058
|
+
function isThisApp(candidate, readBundleIdentifier) {
|
|
4059
|
+
return readBundleIdentifier(candidate) === RECORDINGS_BUNDLE_ID;
|
|
4060
|
+
}
|
|
4061
|
+
function readDefault(key) {
|
|
4062
|
+
if (TRIGGER_DEFAULTS_EXECUTABLE === null)
|
|
4063
|
+
return null;
|
|
4064
|
+
const result = spawnSync4(TRIGGER_DEFAULTS_EXECUTABLE, ["read", RECORDINGS_BUNDLE_ID, key], {
|
|
4065
|
+
encoding: "utf8"
|
|
4066
|
+
});
|
|
4067
|
+
if (result.status !== 0)
|
|
4068
|
+
return null;
|
|
4069
|
+
return result.stdout.trim();
|
|
4070
|
+
}
|
|
4071
|
+
function writeDefault(key, value) {
|
|
4072
|
+
if (TRIGGER_DEFAULTS_EXECUTABLE === null) {
|
|
4073
|
+
throw new Error(`cannot write ${key}: this machine has no readable app UserDefaults domain`);
|
|
4074
|
+
}
|
|
4075
|
+
const result = spawnSync4(TRIGGER_DEFAULTS_EXECUTABLE, ["write", RECORDINGS_BUNDLE_ID, key, "-string", value], {
|
|
4076
|
+
encoding: "utf8"
|
|
4077
|
+
});
|
|
4078
|
+
if (result.status !== 0) {
|
|
4079
|
+
throw new Error(`defaults write ${key} failed: ${result.stderr?.trim() || `exit ${result.status}`}`);
|
|
4080
|
+
}
|
|
4081
|
+
}
|
|
4082
|
+
function parseStoredShortcut(raw) {
|
|
4083
|
+
if (!raw)
|
|
4084
|
+
return null;
|
|
4085
|
+
try {
|
|
4086
|
+
const parsed = JSON.parse(raw);
|
|
4087
|
+
if (typeof parsed.carbonKeyCode !== "number" || typeof parsed.carbonModifiers !== "number") {
|
|
4088
|
+
return null;
|
|
4089
|
+
}
|
|
4090
|
+
return { carbonKeyCode: parsed.carbonKeyCode, carbonModifiers: parsed.carbonModifiers };
|
|
4091
|
+
} catch {
|
|
4092
|
+
return null;
|
|
4093
|
+
}
|
|
4094
|
+
}
|
|
4095
|
+
function readTriggerState() {
|
|
4096
|
+
const rawShortcut = readDefault(TOGGLE_RECORDING_DEFAULTS_KEY);
|
|
4097
|
+
const rawFn = readDefault(USE_FN_KEY_DEFAULTS_KEY);
|
|
4098
|
+
return {
|
|
4099
|
+
shortcut: parseStoredShortcut(rawShortcut),
|
|
4100
|
+
rawShortcut,
|
|
4101
|
+
useFnKey: rawFn === "1" || rawFn?.toLowerCase() === "true"
|
|
4102
|
+
};
|
|
4103
|
+
}
|
|
4104
|
+
function writeShortcut(shortcut) {
|
|
4105
|
+
writeDefault(TOGGLE_RECORDING_DEFAULTS_KEY, JSON.stringify({ carbonKeyCode: shortcut.carbonKeyCode, carbonModifiers: shortcut.carbonModifiers }));
|
|
4106
|
+
}
|
|
4107
|
+
function writeUseFnKey(enabled) {
|
|
4108
|
+
if (TRIGGER_DEFAULTS_EXECUTABLE === null) {
|
|
4109
|
+
throw new Error(`cannot write ${USE_FN_KEY_DEFAULTS_KEY}: this machine has no readable app UserDefaults domain`);
|
|
4110
|
+
}
|
|
4111
|
+
const result = spawnSync4(TRIGGER_DEFAULTS_EXECUTABLE, ["write", RECORDINGS_BUNDLE_ID, USE_FN_KEY_DEFAULTS_KEY, "-bool", enabled ? "true" : "false"], { encoding: "utf8" });
|
|
4112
|
+
if (result.status !== 0) {
|
|
4113
|
+
throw new Error(`defaults write ${USE_FN_KEY_DEFAULTS_KEY} failed: ${result.stderr?.trim() || `exit ${result.status}`}`);
|
|
4114
|
+
}
|
|
4115
|
+
}
|
|
4116
|
+
|
|
4117
|
+
// src/cli/trigger-probe.ts
|
|
4118
|
+
import { closeSync, openSync, readSync, statSync as statSync3 } from "fs";
|
|
4119
|
+
var FN_BLOCKING_ACCESSIBILITY_STATES = [
|
|
4120
|
+
"denied",
|
|
4121
|
+
"stale_allowed_for_previous_app_build"
|
|
4122
|
+
];
|
|
4123
|
+
var APP_LOG_TAIL_BYTES = 1024 * 1024;
|
|
4124
|
+
var TRIGGER_LOG_MARKER = "trigger bindings:";
|
|
4125
|
+
function resolveHotkeyBinding(state) {
|
|
4126
|
+
const base = { defaults_key: TOGGLE_RECORDING_DEFAULTS_KEY };
|
|
4127
|
+
if (state.shortcut) {
|
|
4128
|
+
return {
|
|
4129
|
+
...base,
|
|
4130
|
+
state: "bound",
|
|
4131
|
+
chord: formatShortcut(state.shortcut),
|
|
4132
|
+
shortcut: state.shortcut,
|
|
4133
|
+
stored_raw: state.rawShortcut,
|
|
4134
|
+
can_fire: "yes"
|
|
4135
|
+
};
|
|
4136
|
+
}
|
|
4137
|
+
if (state.rawShortcut === null) {
|
|
4138
|
+
return {
|
|
4139
|
+
...base,
|
|
4140
|
+
state: "default_pending",
|
|
4141
|
+
chord: formatShortcut(parseShortcutChord(DEFAULT_TOGGLE_RECORDING_CHORD)),
|
|
4142
|
+
shortcut: null,
|
|
4143
|
+
stored_raw: null,
|
|
4144
|
+
can_fire: "yes"
|
|
4145
|
+
};
|
|
4146
|
+
}
|
|
4147
|
+
const normalized = state.rawShortcut.trim().toLowerCase();
|
|
4148
|
+
if (normalized === "0" || normalized === "false") {
|
|
4149
|
+
return {
|
|
4150
|
+
...base,
|
|
4151
|
+
state: "cleared",
|
|
4152
|
+
chord: null,
|
|
4153
|
+
shortcut: null,
|
|
4154
|
+
stored_raw: state.rawShortcut,
|
|
4155
|
+
can_fire: "no"
|
|
4156
|
+
};
|
|
4157
|
+
}
|
|
4158
|
+
return {
|
|
4159
|
+
...base,
|
|
4160
|
+
state: "unreadable",
|
|
4161
|
+
chord: null,
|
|
4162
|
+
shortcut: null,
|
|
4163
|
+
stored_raw: state.rawShortcut,
|
|
4164
|
+
can_fire: "unknown"
|
|
4165
|
+
};
|
|
4166
|
+
}
|
|
4167
|
+
function resolveFnTrigger(useFnKey, accessibilityPermission) {
|
|
4168
|
+
const base = {
|
|
4169
|
+
use_fn_key: useFnKey,
|
|
4170
|
+
accessibility_permission: accessibilityPermission,
|
|
4171
|
+
defaults_key: USE_FN_KEY_DEFAULTS_KEY
|
|
4172
|
+
};
|
|
4173
|
+
if (!useFnKey)
|
|
4174
|
+
return { ...base, can_fire: "no" };
|
|
4175
|
+
if (accessibilityPermission !== null) {
|
|
4176
|
+
if (FN_BLOCKING_ACCESSIBILITY_STATES.includes(accessibilityPermission)) {
|
|
4177
|
+
return { ...base, can_fire: "no" };
|
|
4178
|
+
}
|
|
4179
|
+
if (accessibilityPermission.startsWith("allowed"))
|
|
4180
|
+
return { ...base, can_fire: "yes" };
|
|
4181
|
+
}
|
|
4182
|
+
return { ...base, can_fire: "unknown" };
|
|
4183
|
+
}
|
|
4184
|
+
function parseTriggerBindingsLog(logText) {
|
|
4185
|
+
if (!logText)
|
|
4186
|
+
return null;
|
|
4187
|
+
const lines = logText.split(`
|
|
4188
|
+
`);
|
|
4189
|
+
for (let index = lines.length - 1;index >= 0; index -= 1) {
|
|
4190
|
+
const line = lines[index];
|
|
4191
|
+
if (line === undefined)
|
|
4192
|
+
continue;
|
|
4193
|
+
const markerAt = line.indexOf(TRIGGER_LOG_MARKER);
|
|
4194
|
+
if (markerAt === -1)
|
|
4195
|
+
continue;
|
|
4196
|
+
const body = line.slice(markerAt + TRIGGER_LOG_MARKER.length);
|
|
4197
|
+
const useFnKey = matchBoolean(body, "useFnKey");
|
|
4198
|
+
const fnMonitorRunning = matchBoolean(body, "fnMonitorRunning");
|
|
4199
|
+
if (useFnKey === null || fnMonitorRunning === null)
|
|
4200
|
+
continue;
|
|
4201
|
+
const blocked = matchField2(body, "blocked", null);
|
|
4202
|
+
return {
|
|
4203
|
+
observed_at: /^\[([^\]]+)\]/.exec(line)?.[1] ?? null,
|
|
4204
|
+
shortcut_stored: matchField2(body, "shortcutStored", "shortcutArmed"),
|
|
4205
|
+
use_fn_key: useFnKey,
|
|
4206
|
+
fn_monitor_running: fnMonitorRunning,
|
|
4207
|
+
microphone: matchField2(body, "microphone", "accessibility"),
|
|
4208
|
+
accessibility: matchField2(body, "accessibility", "blocked"),
|
|
4209
|
+
blocked: blocked === null || blocked === "none" ? null : blocked
|
|
4210
|
+
};
|
|
4211
|
+
}
|
|
4212
|
+
return null;
|
|
4213
|
+
}
|
|
4214
|
+
function matchField2(body, field, nextField) {
|
|
4215
|
+
const start = body.indexOf(`${field}=`);
|
|
4216
|
+
if (start === -1)
|
|
4217
|
+
return null;
|
|
4218
|
+
const valueAt = start + field.length + 1;
|
|
4219
|
+
if (nextField === null)
|
|
4220
|
+
return body.slice(valueAt).trim() || null;
|
|
4221
|
+
const end = body.indexOf(` ${nextField}=`, valueAt);
|
|
4222
|
+
if (end === -1)
|
|
4223
|
+
return body.slice(valueAt).trim() || null;
|
|
4224
|
+
return body.slice(valueAt, end).trim() || null;
|
|
4225
|
+
}
|
|
4226
|
+
function matchBoolean(body, field) {
|
|
4227
|
+
const match = new RegExp(`(?:^|\\s)${field}=(true|false)(?:\\s|$)`).exec(body);
|
|
4228
|
+
if (!match)
|
|
4229
|
+
return null;
|
|
4230
|
+
return match[1] === "true";
|
|
4231
|
+
}
|
|
4232
|
+
function readAppLogTail(logPath, maxBytes = APP_LOG_TAIL_BYTES) {
|
|
4233
|
+
if (!logPath)
|
|
4234
|
+
return null;
|
|
4235
|
+
let handle = null;
|
|
4236
|
+
try {
|
|
4237
|
+
const size = statSync3(logPath).size;
|
|
4238
|
+
if (size === 0)
|
|
4239
|
+
return null;
|
|
4240
|
+
const length = Math.min(size, maxBytes);
|
|
4241
|
+
const buffer = Buffer.allocUnsafe(length);
|
|
4242
|
+
handle = openSync(logPath, "r");
|
|
4243
|
+
const read = readSync(handle, buffer, 0, length, size - length);
|
|
4244
|
+
return buffer.subarray(0, read).toString("utf8");
|
|
4245
|
+
} catch {
|
|
4246
|
+
return null;
|
|
4247
|
+
} finally {
|
|
4248
|
+
if (handle !== null) {
|
|
4249
|
+
try {
|
|
4250
|
+
closeSync(handle);
|
|
4251
|
+
} catch {}
|
|
4252
|
+
}
|
|
4253
|
+
}
|
|
4254
|
+
}
|
|
4255
|
+
function diagnoseTrigger(input) {
|
|
4256
|
+
const hotkey = resolveHotkeyBinding(input.trigger);
|
|
4257
|
+
const fn = resolveFnTrigger(input.trigger.useFnKey, input.accessibilityPermission);
|
|
4258
|
+
const failures = [];
|
|
4259
|
+
const warnings = [];
|
|
4260
|
+
const notes = [];
|
|
4261
|
+
const canFire = hotkey.can_fire !== "no" || fn.can_fire !== "no";
|
|
4262
|
+
if (!canFire) {
|
|
4263
|
+
failures.push(`No recording trigger can fire: ${describeHotkey(hotkey)}, and ${describeFn(fn)}. ` + `Nothing will start a recording from the keyboard. Fix it with one of:
|
|
4264
|
+
` + ` recordings shortcut --reset (bind the app's default hotkey again)
|
|
4265
|
+
` + ` recordings shortcut --set "f13" (bind a hotkey of your choosing)
|
|
4266
|
+
` + " recordings shortcut --fn on (use fn/Globe as push-to-talk)");
|
|
4267
|
+
}
|
|
4268
|
+
if (hotkey.state === "unreadable") {
|
|
4269
|
+
warnings.push(`The stored hotkey value is not a shortcut this CLI can read (${hotkey.stored_raw}). ` + "The app decodes it with the same JSON shape, so it most likely registers no hotkey " + "either \u2014 but that is not proved here, so it is not counted as a failure. Re-bind it " + "with 'recordings shortcut --reset' to be certain.");
|
|
4270
|
+
}
|
|
4271
|
+
if (hotkey.state === "default_pending") {
|
|
4272
|
+
notes.push(`No hotkey is stored, so the app registers its own default ${hotkey.chord} the next ` + "time it launches. Nothing is wrong; it is just not written down yet.");
|
|
4273
|
+
}
|
|
4274
|
+
if (hotkey.state === "cleared" && fn.can_fire !== "no") {
|
|
4275
|
+
notes.push("The hotkey was explicitly cleared (the app stores a disabled marker rather than " + "removing the key), so fn/Globe is the only trigger.");
|
|
4276
|
+
}
|
|
4277
|
+
if (fn.use_fn_key && fn.can_fire === "no") {
|
|
4278
|
+
warnings.push(`fn/Globe is enabled but its Accessibility grant is ${fn.accessibility_permission}, and ` + "the fn trigger is a CGEventTap that macOS will not create without it. Grant it in " + "System Settings > Privacy & Security > Accessibility, then quit and reopen the app.");
|
|
4279
|
+
}
|
|
4280
|
+
if (fn.use_fn_key && fn.can_fire === "unknown") {
|
|
4281
|
+
warnings.push(`fn/Globe is enabled and its Accessibility grant reads ${fn.accessibility_permission ?? "unmeasured"}` + " \u2014 that is not a denial and not a pass, so whether the fn tap can be created is " + "undecided here. 'recordings app permissions' reports the grant in full.");
|
|
4282
|
+
}
|
|
4283
|
+
const observation = input.observation;
|
|
4284
|
+
if (!observation) {
|
|
4285
|
+
notes.push("The app has not logged a resolved trigger, so whether its fn event tap is running is " + "unknown. The app writes that line at launch and on every trigger change; if it is " + "absent the app has not run since the log was last truncated.");
|
|
4286
|
+
} else {
|
|
4287
|
+
const when = observation.observed_at ? ` at ${observation.observed_at}` : "";
|
|
4288
|
+
notes.push(`The app last reported${when}: fn tap ${observation.fn_monitor_running ? "running" : "NOT running"}` + `, useFnKey=${observation.use_fn_key}, hotkey ${observation.shortcut_stored ?? "unreported"}.`);
|
|
4289
|
+
if (observation.use_fn_key !== input.trigger.useFnKey) {
|
|
4290
|
+
warnings.push(`The app is running with useFnKey=${observation.use_fn_key} while storage now says ` + `${input.trigger.useFnKey}${when ? ` (last reported${when})` : ""}. A running instance keeps ` + "the trigger it registered with, so the current setting is NOT armed \u2014 quit and " + "reopen the app.");
|
|
4291
|
+
} else if (hotkey.state === "bound" && observation.shortcut_stored !== null && observation.shortcut_stored !== `carbonKeyCode=${hotkey.shortcut.carbonKeyCode} carbonModifiers=${hotkey.shortcut.carbonModifiers}`) {
|
|
4292
|
+
warnings.push(`The app registered hotkey ${observation.shortcut_stored}${when} while storage now holds ` + `${hotkey.chord}. The running instance keeps what it registered with, so the stored ` + "hotkey is NOT armed \u2014 quit and reopen the app.");
|
|
4293
|
+
}
|
|
4294
|
+
if (observation.use_fn_key && !observation.fn_monitor_running) {
|
|
4295
|
+
warnings.push(`The app itself reported its fn event tap NOT running${when}. It retries while ` + "Accessibility is trusted, so this may since have recovered \u2014 it is reported, not " + "counted as a failure. 'recordings app log' shows what happened next.");
|
|
4296
|
+
}
|
|
4297
|
+
if (observation.blocked) {
|
|
4298
|
+
warnings.push(`The app reported its trigger blocked${when}: ${observation.blocked}`);
|
|
4299
|
+
}
|
|
4300
|
+
}
|
|
4301
|
+
return {
|
|
4302
|
+
can_fire: canFire,
|
|
4303
|
+
summary: `hotkey ${describeHotkey(hotkey)}, ${describeFn(fn)}`,
|
|
4304
|
+
defaults_domain: RECORDINGS_BUNDLE_ID,
|
|
4305
|
+
hotkey,
|
|
4306
|
+
fn,
|
|
4307
|
+
app_observation: observation,
|
|
4308
|
+
failures,
|
|
4309
|
+
warnings,
|
|
4310
|
+
notes
|
|
4311
|
+
};
|
|
4312
|
+
}
|
|
4313
|
+
function describeHotkey(hotkey) {
|
|
4314
|
+
switch (hotkey.state) {
|
|
4315
|
+
case "bound":
|
|
4316
|
+
return hotkey.chord;
|
|
4317
|
+
case "default_pending":
|
|
4318
|
+
return `${hotkey.chord} (the app's default, registered at next launch)`;
|
|
4319
|
+
case "cleared":
|
|
4320
|
+
return "cleared (nothing is registered)";
|
|
4321
|
+
case "unreadable":
|
|
4322
|
+
return `unreadable (${hotkey.stored_raw})`;
|
|
4323
|
+
}
|
|
4324
|
+
}
|
|
4325
|
+
function describeFn(fn) {
|
|
4326
|
+
if (!fn.use_fn_key)
|
|
4327
|
+
return "fn/Globe off";
|
|
4328
|
+
if (fn.can_fire === "no")
|
|
4329
|
+
return `fn/Globe on but Accessibility is ${fn.accessibility_permission}`;
|
|
4330
|
+
if (fn.can_fire === "unknown")
|
|
4331
|
+
return "fn/Globe on, Accessibility undecided";
|
|
4332
|
+
return "fn/Globe on";
|
|
4333
|
+
}
|
|
4334
|
+
function probeTriggerDiagnostics(input) {
|
|
4335
|
+
if (TRIGGER_DEFAULTS_EXECUTABLE === null)
|
|
4336
|
+
return null;
|
|
4337
|
+
return diagnoseTrigger({
|
|
4338
|
+
trigger: readTriggerState(),
|
|
4339
|
+
accessibilityPermission: input.accessibilityPermission,
|
|
4340
|
+
observation: parseTriggerBindingsLog(readAppLogTail(input.appLogPath))
|
|
4341
|
+
});
|
|
4342
|
+
}
|
|
4343
|
+
function describeTriggerPickup(runningBundlePaths) {
|
|
4344
|
+
return { armed: runningBundlePaths.length === 0, runningBundlePaths };
|
|
4345
|
+
}
|
|
4346
|
+
|
|
4347
|
+
// src/lib/machine.ts
|
|
4348
|
+
import { hostname } from "os";
|
|
4349
|
+
function currentMachineId(env = process.env, hostName = hostname()) {
|
|
4350
|
+
const configured = env.HASNA_MACHINE_ID?.trim();
|
|
4351
|
+
if (configured)
|
|
4352
|
+
return configured;
|
|
4353
|
+
return hostName.trim();
|
|
4354
|
+
}
|
|
4355
|
+
|
|
4356
|
+
// src/lib/bun-runtime.ts
|
|
4357
|
+
import { accessSync, constants as fsConstants, realpathSync as realpathSync3, statSync as statSync4 } from "fs";
|
|
4358
|
+
import { isAbsolute as isAbsolute2 } from "path";
|
|
4359
|
+
import { randomBytes } from "crypto";
|
|
4360
|
+
import { spawnSync as spawnSync5 } from "child_process";
|
|
4361
|
+
var BUN_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
4362
|
+
var INSTALLER_ENVIRONMENT_KEYS = [
|
|
4363
|
+
"HOME",
|
|
4364
|
+
"SSH_CONNECTION",
|
|
4365
|
+
"RECORDINGS_EXPECTED_TEAM_IDENTIFIER",
|
|
4366
|
+
"RECORDINGS_LAUNCH_TIMEOUT_SECONDS",
|
|
4367
|
+
"RECORDINGS_LOCK_STALE_SECONDS",
|
|
4368
|
+
"RECORDINGS_MAINTENANCE_STALE_SECONDS",
|
|
4369
|
+
"RECORDINGS_READER_DRAIN_TIMEOUT_MS",
|
|
4370
|
+
"RECORDINGS_SQLITE_BUSY_TIMEOUT_MS"
|
|
4371
|
+
];
|
|
4372
|
+
var INSTALLER_PATH = "/usr/bin:/bin:/usr/sbin:/sbin";
|
|
4373
|
+
function validateBunExecutable(candidate) {
|
|
4374
|
+
if (!isAbsolute2(candidate))
|
|
4375
|
+
return { reason: "path is not absolute" };
|
|
4376
|
+
let executable;
|
|
4377
|
+
try {
|
|
4378
|
+
executable = realpathSync3(candidate);
|
|
4379
|
+
if (!statSync4(executable).isFile())
|
|
4380
|
+
return { reason: "resolved path is not a regular file" };
|
|
4381
|
+
accessSync(executable, fsConstants.X_OK);
|
|
4382
|
+
} catch {
|
|
4383
|
+
return { reason: "path is missing, inaccessible, or not executable" };
|
|
4384
|
+
}
|
|
4385
|
+
const nonce = randomBytes(24).toString("hex");
|
|
4386
|
+
const probe = spawnSync5(executable, [
|
|
4387
|
+
"-e",
|
|
4388
|
+
`
|
|
4389
|
+
import { realpathSync, statSync } from "node:fs";
|
|
4390
|
+
const expected = process.argv[1];
|
|
4391
|
+
const nonce = process.argv[2];
|
|
4392
|
+
const actual = process.execPath;
|
|
4393
|
+
if (!expected || !nonce || realpathSync(actual) !== realpathSync(expected)) process.exit(66);
|
|
4394
|
+
if (!statSync(actual).isFile()) process.exit(66);
|
|
4395
|
+
if (!/^\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?(?:\\+[0-9A-Za-z.-]+)?$/.test(Bun.version)) {
|
|
4396
|
+
process.exit(66);
|
|
4397
|
+
}
|
|
4398
|
+
process.stdout.write(nonce + ":" + Bun.version);
|
|
4399
|
+
`,
|
|
4400
|
+
executable,
|
|
4401
|
+
nonce
|
|
4402
|
+
], {
|
|
4403
|
+
encoding: "utf8",
|
|
4404
|
+
env: {
|
|
4405
|
+
HOME: "/tmp",
|
|
4406
|
+
PATH: "/usr/bin:/bin:/usr/sbin:/sbin",
|
|
4407
|
+
TMPDIR: "/tmp"
|
|
4408
|
+
},
|
|
3045
4409
|
maxBuffer: 1024,
|
|
3046
4410
|
stdio: ["ignore", "pipe", "ignore"],
|
|
3047
4411
|
timeout: 5000
|
|
@@ -3086,17 +4450,17 @@ function createInstallerEnvironment(environment, bunExecutable) {
|
|
|
3086
4450
|
import { createHash as createHash2 } from "crypto";
|
|
3087
4451
|
import {
|
|
3088
4452
|
chmodSync as chmodSync2,
|
|
3089
|
-
closeSync,
|
|
4453
|
+
closeSync as closeSync2,
|
|
3090
4454
|
constants,
|
|
3091
4455
|
fstatSync,
|
|
3092
4456
|
fsyncSync,
|
|
3093
|
-
mkdtempSync,
|
|
3094
|
-
openSync,
|
|
3095
|
-
readFileSync as
|
|
3096
|
-
rmSync as
|
|
3097
|
-
writeFileSync as
|
|
4457
|
+
mkdtempSync as mkdtempSync2,
|
|
4458
|
+
openSync as openSync2,
|
|
4459
|
+
readFileSync as readFileSync3,
|
|
4460
|
+
rmSync as rmSync4,
|
|
4461
|
+
writeFileSync as writeFileSync3
|
|
3098
4462
|
} from "fs";
|
|
3099
|
-
import { isAbsolute as isAbsolute3, join as
|
|
4463
|
+
import { isAbsolute as isAbsolute3, join as join7 } from "path";
|
|
3100
4464
|
var LOWER_SHA256 = /^[a-f0-9]{64}$/;
|
|
3101
4465
|
var LOWER_SOURCE_SHA = /^[a-f0-9]{40}$/;
|
|
3102
4466
|
var RELEASE_VERSION = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/;
|
|
@@ -3169,7 +4533,7 @@ function prepareReleaseInstallInputs(input) {
|
|
|
3169
4533
|
if (!isAbsolute3(snapshotRoot)) {
|
|
3170
4534
|
throw new Error("release snapshot root must be absolute");
|
|
3171
4535
|
}
|
|
3172
|
-
const snapshotDirectory =
|
|
4536
|
+
const snapshotDirectory = mkdtempSync2(join7(snapshotRoot, "recordings-release-install."));
|
|
3173
4537
|
let cleaned = false;
|
|
3174
4538
|
const cleanup = () => {
|
|
3175
4539
|
if (cleaned)
|
|
@@ -3178,12 +4542,12 @@ function prepareReleaseInstallInputs(input) {
|
|
|
3178
4542
|
try {
|
|
3179
4543
|
chmodSync2(snapshotDirectory, 448);
|
|
3180
4544
|
} catch {}
|
|
3181
|
-
|
|
4545
|
+
rmSync4(snapshotDirectory, { recursive: true, force: true });
|
|
3182
4546
|
};
|
|
3183
4547
|
try {
|
|
3184
|
-
const manifestSnapshot =
|
|
4548
|
+
const manifestSnapshot = join7(snapshotDirectory, `${input.manifestSha256}.manifest.json`);
|
|
3185
4549
|
const envelopeDigest = createHash2("sha256").update(envelopeBytes).digest("hex");
|
|
3186
|
-
const envelopeSnapshot =
|
|
4550
|
+
const envelopeSnapshot = join7(snapshotDirectory, `${envelopeDigest}.envelope.json`);
|
|
3187
4551
|
writeSnapshot(manifestSnapshot, manifestBytes);
|
|
3188
4552
|
writeSnapshot(envelopeSnapshot, envelopeBytes);
|
|
3189
4553
|
chmodSync2(snapshotDirectory, 320);
|
|
@@ -3198,15 +4562,15 @@ function assertAbsolutePath(value, label) {
|
|
|
3198
4562
|
throw new Error(`${label} path must be absolute`);
|
|
3199
4563
|
}
|
|
3200
4564
|
function readBoundedRegularFile(path, label, maximum) {
|
|
3201
|
-
const descriptor =
|
|
4565
|
+
const descriptor = openSync2(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
3202
4566
|
try {
|
|
3203
4567
|
const metadata = fstatSync(descriptor);
|
|
3204
4568
|
if (!metadata.isFile() || metadata.size < 1 || metadata.size > maximum) {
|
|
3205
4569
|
throw new Error(`${label} must be a non-empty bounded regular file`);
|
|
3206
4570
|
}
|
|
3207
|
-
return
|
|
4571
|
+
return readFileSync3(descriptor);
|
|
3208
4572
|
} finally {
|
|
3209
|
-
|
|
4573
|
+
closeSync2(descriptor);
|
|
3210
4574
|
}
|
|
3211
4575
|
}
|
|
3212
4576
|
function parseObject(bytes, label) {
|
|
@@ -3285,12 +4649,73 @@ function isObject(value) {
|
|
|
3285
4649
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3286
4650
|
}
|
|
3287
4651
|
function writeSnapshot(path, bytes) {
|
|
3288
|
-
|
|
3289
|
-
const descriptor =
|
|
4652
|
+
writeFileSync3(path, bytes, { flag: "wx", mode: 256 });
|
|
4653
|
+
const descriptor = openSync2(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
3290
4654
|
try {
|
|
3291
4655
|
fsyncSync(descriptor);
|
|
3292
4656
|
} finally {
|
|
3293
|
-
|
|
4657
|
+
closeSync2(descriptor);
|
|
4658
|
+
}
|
|
4659
|
+
}
|
|
4660
|
+
|
|
4661
|
+
// src/cli/desktop-snapshot.ts
|
|
4662
|
+
import { chmodSync as chmodSync3, mkdirSync as mkdirSync4, mkdtempSync as mkdtempSync3, renameSync as renameSync2, rmSync as rmSync5, statSync as statSync5 } from "fs";
|
|
4663
|
+
import { dirname as dirname4, join as join8, resolve as resolve3 } from "path";
|
|
4664
|
+
import { spawnSync as spawnSync6 } from "child_process";
|
|
4665
|
+
var SCREEN_CAPTURE_EXECUTABLE = "/usr/sbin/screencapture";
|
|
4666
|
+
var DEFAULT_DESKTOP_SNAPSHOT = "desktop-snapshot.png";
|
|
4667
|
+
function runScreenCapture(executable, arguments_) {
|
|
4668
|
+
const result = spawnSync6(executable, arguments_, {
|
|
4669
|
+
encoding: "utf8",
|
|
4670
|
+
stdio: ["ignore", "ignore", "pipe"]
|
|
4671
|
+
});
|
|
4672
|
+
return {
|
|
4673
|
+
error: result.error,
|
|
4674
|
+
status: result.status,
|
|
4675
|
+
stderr: result.stderr
|
|
4676
|
+
};
|
|
4677
|
+
}
|
|
4678
|
+
function exportDesktopSnapshot(output = DEFAULT_DESKTOP_SNAPSHOT, dependencies = {}) {
|
|
4679
|
+
if ((dependencies.platform ?? process.platform) !== "darwin") {
|
|
4680
|
+
throw new Error("Desktop snapshots are only supported on macOS");
|
|
4681
|
+
}
|
|
4682
|
+
const destination = resolve3(dependencies.cwd ?? process.cwd(), output);
|
|
4683
|
+
const destinationDirectory = dirname4(destination);
|
|
4684
|
+
mkdirSync4(destinationDirectory, { recursive: true });
|
|
4685
|
+
const stagingDirectory = mkdtempSync3(join8(destinationDirectory, ".recordings-desktop-"));
|
|
4686
|
+
chmodSync3(stagingDirectory, 448);
|
|
4687
|
+
const stagingPath = join8(stagingDirectory, "snapshot.png");
|
|
4688
|
+
try {
|
|
4689
|
+
const capture = dependencies.capture ?? runScreenCapture;
|
|
4690
|
+
const result = capture(SCREEN_CAPTURE_EXECUTABLE, [
|
|
4691
|
+
"-x",
|
|
4692
|
+
"-m",
|
|
4693
|
+
"-C",
|
|
4694
|
+
"-t",
|
|
4695
|
+
"png",
|
|
4696
|
+
stagingPath
|
|
4697
|
+
]);
|
|
4698
|
+
if (result.error) {
|
|
4699
|
+
throw new Error(`Could not run macOS screencapture: ${result.error.message}`);
|
|
4700
|
+
}
|
|
4701
|
+
if (result.status !== 0) {
|
|
4702
|
+
const detail = result.stderr?.trim();
|
|
4703
|
+
throw new Error(detail ? `Desktop snapshot failed: ${detail}` : "Desktop snapshot failed; allow Screen Recording access for the invoking terminal");
|
|
4704
|
+
}
|
|
4705
|
+
let size = 0;
|
|
4706
|
+
try {
|
|
4707
|
+
const snapshot = statSync5(stagingPath);
|
|
4708
|
+
if (snapshot.isFile())
|
|
4709
|
+
size = snapshot.size;
|
|
4710
|
+
} catch {}
|
|
4711
|
+
if (size === 0) {
|
|
4712
|
+
throw new Error("Desktop snapshot produced no image; allow Screen Recording access for the invoking terminal");
|
|
4713
|
+
}
|
|
4714
|
+
chmodSync3(stagingPath, 384);
|
|
4715
|
+
renameSync2(stagingPath, destination);
|
|
4716
|
+
return destination;
|
|
4717
|
+
} finally {
|
|
4718
|
+
rmSync5(stagingDirectory, { recursive: true, force: true });
|
|
3294
4719
|
}
|
|
3295
4720
|
}
|
|
3296
4721
|
|
|
@@ -3330,13 +4755,13 @@ program.command("record").description("Record from microphone, transcribe, and o
|
|
|
3330
4755
|
console.log(chalk.blue("Recording... Press") + chalk.yellow(" Enter ") + chalk.blue("to stop."));
|
|
3331
4756
|
}
|
|
3332
4757
|
audioPath = startRecording(config);
|
|
3333
|
-
await new Promise((
|
|
4758
|
+
await new Promise((resolve4) => {
|
|
3334
4759
|
process.stdin.setRawMode?.(true);
|
|
3335
4760
|
process.stdin.resume();
|
|
3336
4761
|
process.stdin.once("data", () => {
|
|
3337
4762
|
process.stdin.setRawMode?.(false);
|
|
3338
4763
|
process.stdin.pause();
|
|
3339
|
-
|
|
4764
|
+
resolve4();
|
|
3340
4765
|
});
|
|
3341
4766
|
});
|
|
3342
4767
|
stopRecording();
|
|
@@ -3525,7 +4950,7 @@ ${text}`;
|
|
|
3525
4950
|
process.exit(1);
|
|
3526
4951
|
}
|
|
3527
4952
|
});
|
|
3528
|
-
program.command("save <text>").description("Save raw text as a recording (no audio). Routes to the
|
|
4953
|
+
program.command("save <text>").description("Save raw text as a recording (no audio). Routes to the /v1 API when configured, else the on-box SQLite store.").option("-t, --tags <tags>", "Comma-separated tags").option("--enhance", "Enhance the text via the configured model before saving").option("--model <model>", "Value for model_used", "direct-input").action(async (rawText, opts) => {
|
|
3529
4954
|
const parentOpts = program.opts();
|
|
3530
4955
|
let processedText;
|
|
3531
4956
|
let mode = "raw";
|
|
@@ -3727,13 +5152,13 @@ program.command("projects").description("List registered projects").option("-n,
|
|
|
3727
5152
|
printPaginationHints(page.length, projects.length, pagination);
|
|
3728
5153
|
});
|
|
3729
5154
|
program.command("init").description("Initialize .recordings/ in current directory").action(() => {
|
|
3730
|
-
const { mkdirSync:
|
|
3731
|
-
const { join:
|
|
3732
|
-
const dir =
|
|
3733
|
-
const audioDir =
|
|
3734
|
-
const configFile =
|
|
3735
|
-
|
|
3736
|
-
if (!
|
|
5155
|
+
const { mkdirSync: mkdirSync5, writeFileSync: writeFileSync4, existsSync: existsSync9 } = __require("fs");
|
|
5156
|
+
const { join: join9 } = __require("path");
|
|
5157
|
+
const dir = join9(process.cwd(), ".recordings");
|
|
5158
|
+
const audioDir = join9(dir, "audio");
|
|
5159
|
+
const configFile = join9(dir, "config.json");
|
|
5160
|
+
mkdirSync5(audioDir, { recursive: true });
|
|
5161
|
+
if (!existsSync9(configFile)) {
|
|
3737
5162
|
const defaultConf = {
|
|
3738
5163
|
transcription_model: "gpt-4o-transcribe",
|
|
3739
5164
|
realtime_session_model: "gpt-realtime",
|
|
@@ -3746,7 +5171,7 @@ program.command("init").description("Initialize .recordings/ in current director
|
|
|
3746
5171
|
post_processing_mode: "auto",
|
|
3747
5172
|
auto_enhance: true
|
|
3748
5173
|
};
|
|
3749
|
-
|
|
5174
|
+
writeFileSync4(configFile, JSON.stringify(defaultConf, null, 2));
|
|
3750
5175
|
}
|
|
3751
5176
|
console.log(chalk.green("Initialized .recordings/ directory"));
|
|
3752
5177
|
console.log(chalk.dim(" config: .recordings/config.json"));
|
|
@@ -3754,12 +5179,20 @@ program.command("init").description("Initialize .recordings/ in current director
|
|
|
3754
5179
|
console.log(chalk.dim(" db: .recordings/recordings.db"));
|
|
3755
5180
|
});
|
|
3756
5181
|
var appCommand = program.command("app").description("Manage the macOS app installed from this package");
|
|
3757
|
-
appCommand.command("install").description("Install a release or explicitly approved local-only Recordings.app artifact").requiredOption("--artifact <path>", "Finalized Recordings.app ZIP artifact").requiredOption("--manifest <path>", "Artifact provenance manifest").option("--envelope <path>", "Signed release envelope (required for release artifacts)").option("--expected-team-id <team>", "Required Developer ID TeamIdentifier for release artifacts").requiredOption("--manifest-sha256 <sha256>", "Authenticated release-manifest SHA-256").requiredOption("--expected-source-sha <sha>", "Exact approved 40-character source commit").requiredOption("--expected-version <version>", "Exact approved release version").option("--expected-hostname <hostname>", "Exact deployment hostname to verify before any install mutation").option("--artifact-policy <policy>", "Artifact policy: release or local-only", "release").option("--approved-target <station>", "Exact approved target; fleet for release artifacts", "fleet").option("--approved-target-identity-kind <kind>", "Target identity kind: hardware_uuid_sha256 or tailscale_node_id_sha256").option("--approved-target-identity-sha256 <sha256>", "Authenticated SHA-256 of the approved target identity; none for release artifacts", "none").option("--acknowledge-local-signing-and-permissions", "Acknowledge local-only ad-hoc identity and possible permission reauthorization").option("--expected-old-identity-sha256 <sha256>", "Exact installed identity approved for migration").option("--expected-new-identity-sha256 <sha256>", "Exact candidate identity approved for migration").option("--allow-signing-identity-migration", "Allow one reviewed signer change that requires new macOS permission approval").option("--launch", "Launch and verify the canonical app after installation").option("--launch-timeout <seconds>", "Canonical process launch timeout").action((opts) => {
|
|
5182
|
+
appCommand.command("install").description("Install a release or explicitly approved local-only Recordings.app artifact").requiredOption("--artifact <path>", "Finalized Recordings.app ZIP artifact").requiredOption("--manifest <path>", "Artifact provenance manifest").option("--envelope <path>", "Signed release envelope (required for release artifacts)").option("--expected-team-id <team>", "Required Developer ID TeamIdentifier for release artifacts").requiredOption("--manifest-sha256 <sha256>", "Authenticated release-manifest SHA-256").requiredOption("--expected-source-sha <sha>", "Exact approved 40-character source commit").requiredOption("--expected-version <version>", "Exact approved release version").option("--expected-hostname <hostname>", "Exact deployment hostname to verify before any install mutation").option("--artifact-policy <policy>", "Artifact policy: release or local-only", "release").option("--approved-target <station>", "Exact approved target; fleet for release artifacts", "fleet").option("--variant <variant>", "Artifact variant the operator intends to install: full or bar (default: full)", "full").option("--approved-target-identity-kind <kind>", "Target identity kind: hardware_uuid_sha256 or tailscale_node_id_sha256").option("--approved-target-identity-sha256 <sha256>", "Authenticated SHA-256 of the approved target identity; none for release artifacts", "none").option("--acknowledge-local-signing-and-permissions", "Acknowledge local-only ad-hoc identity and possible permission reauthorization").option("--expected-old-identity-sha256 <sha256>", "Exact installed identity approved for migration").option("--expected-new-identity-sha256 <sha256>", "Exact candidate identity approved for migration").option("--allow-signing-identity-migration", "Allow one reviewed signer change that requires new macOS permission approval").option("--allow-adhoc-identity-migration", "Accept that replacing an ad-hoc signed local-only app voids its Microphone and Accessibility grants").option("--launch", "Launch and verify the canonical app after installation").option("--launch-timeout <seconds>", "Canonical process launch timeout").action((opts) => {
|
|
3758
5183
|
if (process.platform !== "darwin") {
|
|
3759
5184
|
console.error(chalk.red("Recordings.app installation is only supported on macOS"));
|
|
3760
5185
|
process.exit(1);
|
|
3761
5186
|
}
|
|
5187
|
+
if (opts.variant !== "full" && opts.variant !== "bar") {
|
|
5188
|
+
console.error(chalk.red("Artifact variant must be full or bar."));
|
|
5189
|
+
process.exit(1);
|
|
5190
|
+
}
|
|
3762
5191
|
if (opts.artifactPolicy === "release") {
|
|
5192
|
+
if (opts.variant !== "full") {
|
|
5193
|
+
console.error(chalk.red("Release installs are always full-app artifacts. The bar variant installs " + "through the local-only path (--artifact-policy local-only --variant bar)."));
|
|
5194
|
+
process.exit(1);
|
|
5195
|
+
}
|
|
3763
5196
|
if (!opts.envelope) {
|
|
3764
5197
|
console.error(chalk.red("Release installation requires --envelope."));
|
|
3765
5198
|
process.exit(1);
|
|
@@ -3768,7 +5201,7 @@ appCommand.command("install").description("Install a release or explicitly appro
|
|
|
3768
5201
|
try {
|
|
3769
5202
|
assertReleaseOnlyOptions(opts);
|
|
3770
5203
|
if (opts.expectedHostname) {
|
|
3771
|
-
const hostnameResult =
|
|
5204
|
+
const hostnameResult = spawnSync7("/bin/hostname", ["-s"], {
|
|
3772
5205
|
encoding: "utf8",
|
|
3773
5206
|
stdio: ["ignore", "pipe", "ignore"],
|
|
3774
5207
|
env: {
|
|
@@ -3797,14 +5230,14 @@ appCommand.command("install").description("Install a release or explicitly appro
|
|
|
3797
5230
|
process.exit(1);
|
|
3798
5231
|
}
|
|
3799
5232
|
const updateClientPath = "/Applications/Recordings.app/Contents/Helpers/recordings-update-client";
|
|
3800
|
-
if (!
|
|
5233
|
+
if (!existsSync8(updateClientPath)) {
|
|
3801
5234
|
preparedInputs.cleanup();
|
|
3802
5235
|
console.error(chalk.red("Root-owned Recordings update broker client is not installed."));
|
|
3803
5236
|
process.exit(1);
|
|
3804
5237
|
}
|
|
3805
5238
|
const result2 = (() => {
|
|
3806
5239
|
try {
|
|
3807
|
-
return
|
|
5240
|
+
return spawnSync7(updateClientPath, [
|
|
3808
5241
|
"install",
|
|
3809
5242
|
"--artifact",
|
|
3810
5243
|
opts.artifact,
|
|
@@ -3851,7 +5284,7 @@ appCommand.command("install").description("Install a release or explicitly appro
|
|
|
3851
5284
|
process.exit(1);
|
|
3852
5285
|
}
|
|
3853
5286
|
const installerPath = getMacOSInstallerPath();
|
|
3854
|
-
if (!
|
|
5287
|
+
if (!existsSync8(installerPath)) {
|
|
3855
5288
|
console.error(chalk.red(`App installer missing from package: ${installerPath}`));
|
|
3856
5289
|
process.exit(1);
|
|
3857
5290
|
}
|
|
@@ -3877,6 +5310,7 @@ appCommand.command("install").description("Install a release or explicitly appro
|
|
|
3877
5310
|
if (opts.expectedHostname) {
|
|
3878
5311
|
installerArgs.push("--expected-hostname", opts.expectedHostname);
|
|
3879
5312
|
}
|
|
5313
|
+
installerArgs.push("--variant", opts.variant);
|
|
3880
5314
|
if (opts.approvedTargetIdentityKind) {
|
|
3881
5315
|
installerArgs.push("--approved-target-identity-kind", opts.approvedTargetIdentityKind);
|
|
3882
5316
|
}
|
|
@@ -3890,6 +5324,9 @@ appCommand.command("install").description("Install a release or explicitly appro
|
|
|
3890
5324
|
if (opts.allowSigningIdentityMigration) {
|
|
3891
5325
|
installerArgs.push("--allow-signing-identity-migration");
|
|
3892
5326
|
}
|
|
5327
|
+
if (opts.allowAdhocIdentityMigration) {
|
|
5328
|
+
installerArgs.push("--allow-adhoc-identity-migration");
|
|
5329
|
+
}
|
|
3893
5330
|
if (opts.expectedOldIdentitySha256) {
|
|
3894
5331
|
installerArgs.push("--expected-old-identity-sha256", opts.expectedOldIdentitySha256);
|
|
3895
5332
|
}
|
|
@@ -3899,7 +5336,7 @@ appCommand.command("install").description("Install a release or explicitly appro
|
|
|
3899
5336
|
if (opts.launch)
|
|
3900
5337
|
installerArgs.push("--launch");
|
|
3901
5338
|
const installerEnvironment = createInstallerEnvironment(process.env, bunExecutable);
|
|
3902
|
-
const result =
|
|
5339
|
+
const result = spawnSync7("/bin/bash", installerArgs, {
|
|
3903
5340
|
stdio: "inherit",
|
|
3904
5341
|
env: installerEnvironment
|
|
3905
5342
|
});
|
|
@@ -3911,8 +5348,12 @@ appCommand.command("install").description("Install a release or explicitly appro
|
|
|
3911
5348
|
});
|
|
3912
5349
|
appCommand.command("status").description("Show installed Recordings.app status").option("--verbose", "Show package paths, code hash, and log path").action((opts) => {
|
|
3913
5350
|
const status = getMacOSAppStatus();
|
|
5351
|
+
const trigger = probeTriggerDiagnostics({
|
|
5352
|
+
accessibilityPermission: process.platform === "darwin" ? status.accessibility_permission : null,
|
|
5353
|
+
appLogPath: status.log_path
|
|
5354
|
+
});
|
|
3914
5355
|
if (program.opts().json) {
|
|
3915
|
-
console.log(JSON.stringify(status, null, 2));
|
|
5356
|
+
console.log(JSON.stringify({ ...status, trigger }, null, 2));
|
|
3916
5357
|
return;
|
|
3917
5358
|
}
|
|
3918
5359
|
console.log(chalk.bold("Recordings.app"));
|
|
@@ -3925,6 +5366,10 @@ appCommand.command("status").description("Show installed Recordings.app status")
|
|
|
3925
5366
|
console.log(`Microphone: ${status.microphone_permission}`);
|
|
3926
5367
|
console.log(`Accessibility: ${status.accessibility_permission}`);
|
|
3927
5368
|
}
|
|
5369
|
+
if (trigger) {
|
|
5370
|
+
console.log(`Trigger: ${trigger.summary}${trigger.can_fire ? "" : " \u2014 NOTHING CAN FIRE"}`);
|
|
5371
|
+
console.log(chalk.dim(" 'recordings check' reports the trigger in full and exits non-zero when nothing can fire."));
|
|
5372
|
+
}
|
|
3928
5373
|
if (opts.verbose) {
|
|
3929
5374
|
console.log(`Package: ${status.package_root}`);
|
|
3930
5375
|
console.log(`Installed app: ${status.installed ? status.installed_app_path : "missing"}`);
|
|
@@ -3945,9 +5390,11 @@ appCommand.command("permissions").description("Show macOS permission state for R
|
|
|
3945
5390
|
const status = getMacOSAppStatus();
|
|
3946
5391
|
const permissions = {
|
|
3947
5392
|
platform: status.platform,
|
|
3948
|
-
bundle_id:
|
|
5393
|
+
bundle_id: RECORDINGS_BUNDLE_IDENTIFIER,
|
|
3949
5394
|
installed_app_path: status.installed_app_path,
|
|
5395
|
+
installed: status.installed,
|
|
3950
5396
|
legacy_install_paths: status.legacy_install_paths,
|
|
5397
|
+
permission_subject: describeTccAuthorizationSubject(status.installed_app_path),
|
|
3951
5398
|
microphone: status.microphone_permission,
|
|
3952
5399
|
accessibility: status.accessibility_permission,
|
|
3953
5400
|
app_code_hash: status.app_code_hash,
|
|
@@ -3955,14 +5402,24 @@ appCommand.command("permissions").description("Show macOS permission state for R
|
|
|
3955
5402
|
signing_identifier: status.signing_identifier,
|
|
3956
5403
|
team_identifier: status.team_identifier,
|
|
3957
5404
|
designated_requirement: status.designated_requirement,
|
|
5405
|
+
microphone_grant_durability: status.microphone_grant_durability,
|
|
5406
|
+
accessibility_grant_durability: status.accessibility_grant_durability,
|
|
5407
|
+
microphone_stored_requirement: status.microphone_stored_requirement,
|
|
5408
|
+
accessibility_stored_requirement: status.accessibility_stored_requirement,
|
|
5409
|
+
ambiguous_installations: status.ambiguous_installations,
|
|
5410
|
+
warnings: buildPermissionWarnings(status),
|
|
3958
5411
|
log_path: status.log_path
|
|
3959
5412
|
};
|
|
3960
5413
|
if (program.opts().json) {
|
|
3961
5414
|
console.log(JSON.stringify(permissions, null, 2));
|
|
3962
5415
|
return;
|
|
3963
5416
|
}
|
|
3964
|
-
console.log(`
|
|
3965
|
-
console.log(`
|
|
5417
|
+
console.log(`Subject: ${permissions.permission_subject}`);
|
|
5418
|
+
console.log(`Microphone: ${permissions.microphone} (${permissions.microphone_grant_durability})`);
|
|
5419
|
+
console.log(`Accessibility: ${permissions.accessibility} (${permissions.accessibility_grant_durability})`);
|
|
5420
|
+
for (const warning of permissions.warnings) {
|
|
5421
|
+
console.log(chalk.yellow(`Warning: ${warning}`));
|
|
5422
|
+
}
|
|
3966
5423
|
console.log(`Log: ${permissions.log_path}`);
|
|
3967
5424
|
});
|
|
3968
5425
|
appCommand.command("reset-permissions").description("Reset macOS Microphone and Accessibility permissions for Recordings.app").action(() => {
|
|
@@ -3993,12 +5450,12 @@ appCommand.command("request-permissions").description("Open Recordings.app and t
|
|
|
3993
5450
|
});
|
|
3994
5451
|
appCommand.command("log").description("Show the Recordings.app diagnostic log").option("-n, --lines <lines>", "Number of lines to print", String(DEFAULT_LOG_LINES)).action((opts) => {
|
|
3995
5452
|
const status = getMacOSAppStatus();
|
|
3996
|
-
if (!
|
|
5453
|
+
if (!existsSync8(status.log_path)) {
|
|
3997
5454
|
console.log("");
|
|
3998
5455
|
return;
|
|
3999
5456
|
}
|
|
4000
5457
|
const lines = Math.max(1, parseInt(opts.lines, 10) || DEFAULT_LOG_LINES);
|
|
4001
|
-
const result =
|
|
5458
|
+
const result = spawnSync7("tail", ["-n", String(lines), status.log_path], {
|
|
4002
5459
|
encoding: "utf8"
|
|
4003
5460
|
});
|
|
4004
5461
|
if (result.error) {
|
|
@@ -4009,6 +5466,14 @@ appCommand.command("log").description("Show the Recordings.app diagnostic log").
|
|
|
4009
5466
|
if (result.stderr)
|
|
4010
5467
|
process.stderr.write(result.stderr);
|
|
4011
5468
|
});
|
|
5469
|
+
appCommand.command("snapshot [output]").description("Write the current main desktop to a PNG for local debugging").action((output) => {
|
|
5470
|
+
const path = exportDesktopSnapshot(output);
|
|
5471
|
+
if (program.opts().json) {
|
|
5472
|
+
console.log(JSON.stringify({ path }, null, 2));
|
|
5473
|
+
} else {
|
|
5474
|
+
console.log(chalk.green(`Desktop snapshot written: ${path}`));
|
|
5475
|
+
}
|
|
5476
|
+
});
|
|
4012
5477
|
appCommand.command("open").description("Open the installed Recordings.app").action(() => {
|
|
4013
5478
|
const status = getMacOSAppStatus();
|
|
4014
5479
|
if (process.platform !== "darwin") {
|
|
@@ -4019,18 +5484,60 @@ appCommand.command("open").description("Open the installed Recordings.app").acti
|
|
|
4019
5484
|
console.error(chalk.red("Recordings.app is not installed. Run: recordings app install"));
|
|
4020
5485
|
process.exit(1);
|
|
4021
5486
|
}
|
|
4022
|
-
const result =
|
|
5487
|
+
const result = spawnSync7("open", [status.installed_app_path], { stdio: "inherit" });
|
|
4023
5488
|
if (result.error) {
|
|
4024
5489
|
console.error(chalk.red(result.error.message));
|
|
4025
5490
|
process.exit(1);
|
|
4026
5491
|
}
|
|
4027
5492
|
process.exit(result.status ?? 1);
|
|
4028
5493
|
});
|
|
4029
|
-
program.command("check").description("Check system dependencies (sox, API keys)").action(async () => {
|
|
5494
|
+
program.command("check").description("Check system dependencies (sox, API keys) and macOS capture permissions. " + "Use --probe to exercise the microphone and credential for real.").option("--probe", "Prove capability instead of reporting presence: record a short sample and assert signal, and make one authenticated API request").option("--probe-seconds <seconds>", `Length of the capture probe in seconds (1-${MAX_PROBE_SECONDS})`, String(DEFAULT_PROBE_SECONDS)).option("--probe-store-write", "Allow the persistence probe to write a marker recording to a SHARED API store. Off by default: that store is production for every machine pointed at it").action(async (opts) => {
|
|
4030
5495
|
const config = loadConfig();
|
|
4031
5496
|
const parentOpts = program.opts();
|
|
4032
5497
|
const deps = await checkRecordingDeps();
|
|
4033
5498
|
const enhKey = config.enhancement_api_key || config.openai_api_key;
|
|
5499
|
+
const transcriberModel = resolveTranscriberModel(config);
|
|
5500
|
+
const transcriptionModel = config.transcription_model;
|
|
5501
|
+
const macStatus = process.platform === "darwin" ? getMacOSAppStatus() : null;
|
|
5502
|
+
const trigger = probeTriggerDiagnostics({
|
|
5503
|
+
accessibilityPermission: macStatus?.accessibility_permission ?? null,
|
|
5504
|
+
appLogPath: macStatus?.log_path ?? null
|
|
5505
|
+
});
|
|
5506
|
+
const activeStore = describeActiveStore(config);
|
|
5507
|
+
const localStoreWasLegacy = localStoreIsBehindSchema(config.db_path);
|
|
5508
|
+
let capture = null;
|
|
5509
|
+
let credential = null;
|
|
5510
|
+
let enhancementCredential = null;
|
|
5511
|
+
let persistence = null;
|
|
5512
|
+
if (opts.probe) {
|
|
5513
|
+
const parsedSeconds = Number.parseInt(String(opts.probeSeconds), 10);
|
|
5514
|
+
const seconds = Number.isFinite(parsedSeconds) && parsedSeconds > 0 ? Math.min(parsedSeconds, MAX_PROBE_SECONDS) : DEFAULT_PROBE_SECONDS;
|
|
5515
|
+
capture = deps.available ? probeMicrophoneCapture(config, { seconds }) : {
|
|
5516
|
+
ok: false,
|
|
5517
|
+
tool: null,
|
|
5518
|
+
seconds,
|
|
5519
|
+
samples: 0,
|
|
5520
|
+
peak: 0,
|
|
5521
|
+
silent: null,
|
|
5522
|
+
message: deps.message
|
|
5523
|
+
};
|
|
5524
|
+
credential = await verifyTranscriptionCredential(config, transcriptionModel, {
|
|
5525
|
+
apiKey: config.openai_api_key,
|
|
5526
|
+
role: "transcription"
|
|
5527
|
+
});
|
|
5528
|
+
enhancementCredential = enhKey ? await verifyTranscriptionCredential(config, transcriberModel, {
|
|
5529
|
+
apiKey: enhKey,
|
|
5530
|
+
role: "enhancement"
|
|
5531
|
+
}) : null;
|
|
5532
|
+
persistence = await probeRecordingPersistence({
|
|
5533
|
+
allowRemoteWrite: Boolean(opts.probeStoreWrite),
|
|
5534
|
+
allowLocalMigration: Boolean(opts.probeStoreWrite),
|
|
5535
|
+
localStoreExistedBefore: activeStore.local_db_present,
|
|
5536
|
+
localStoreIsLegacy: localStoreWasLegacy
|
|
5537
|
+
});
|
|
5538
|
+
}
|
|
5539
|
+
const probeFailed = Boolean(opts.probe && (capture && !capture.ok || credential && !credential.ok || enhancementCredential && !enhancementCredential.ok || persistence && persistence.outcome === "failed"));
|
|
5540
|
+
const triggerFailed = trigger !== null && !trigger.can_fire;
|
|
4034
5541
|
if (parentOpts.json) {
|
|
4035
5542
|
console.log(JSON.stringify({
|
|
4036
5543
|
recording: {
|
|
@@ -4041,14 +5548,30 @@ program.command("check").description("Check system dependencies (sox, API keys)"
|
|
|
4041
5548
|
openai_api_key_configured: Boolean(config.openai_api_key),
|
|
4042
5549
|
enhancement_api_key_configured: Boolean(enhKey),
|
|
4043
5550
|
enhancement_model: config.enhancement_model,
|
|
4044
|
-
transcriber_model:
|
|
5551
|
+
transcriber_model: transcriberModel,
|
|
4045
5552
|
realtime_session_model: config.realtime_session_model,
|
|
4046
5553
|
realtime_transcription_model: config.realtime_transcription_model,
|
|
4047
5554
|
post_processing_mode: config.post_processing_mode,
|
|
4048
5555
|
transcription_prompt_configured: Boolean(config.transcription_prompt?.trim()),
|
|
4049
5556
|
transcriber_prompt_configured: Boolean(config.transcriber_prompt?.trim()),
|
|
4050
|
-
config_warnings: config.config_warnings ?? []
|
|
5557
|
+
config_warnings: config.config_warnings ?? [],
|
|
5558
|
+
microphone_permission: macStatus?.microphone_permission ?? "unsupported",
|
|
5559
|
+
accessibility_permission: macStatus?.accessibility_permission ?? "unsupported",
|
|
5560
|
+
active_store: activeStore,
|
|
5561
|
+
capture_probe: capture,
|
|
5562
|
+
capture_probe_subject: captureProbeSubject(),
|
|
5563
|
+
microphone_grant_instruction: macStatus ? microphoneGrantInstruction({
|
|
5564
|
+
installedAppPath: macStatus.installed_app_path,
|
|
5565
|
+
otherAppPaths: macStatus.legacy_install_paths,
|
|
5566
|
+
requestState: classifyPermissionState(macStatus.microphone_permission)
|
|
5567
|
+
}) : null,
|
|
5568
|
+
credential_probe: credential,
|
|
5569
|
+
enhancement_credential_probe: enhancementCredential,
|
|
5570
|
+
persistence_probe: persistence,
|
|
5571
|
+
trigger
|
|
4051
5572
|
}, null, 2));
|
|
5573
|
+
if (probeFailed || triggerFailed)
|
|
5574
|
+
process.exitCode = 1;
|
|
4052
5575
|
return;
|
|
4053
5576
|
}
|
|
4054
5577
|
if (deps.available) {
|
|
@@ -4057,16 +5580,84 @@ program.command("check").description("Check system dependencies (sox, API keys)"
|
|
|
4057
5580
|
console.log(chalk.red(`\u2717 ${deps.message}`));
|
|
4058
5581
|
}
|
|
4059
5582
|
if (config.openai_api_key) {
|
|
4060
|
-
console.log(chalk.green(`\u2713 OpenAI API key
|
|
5583
|
+
console.log(chalk.green(`\u2713 OpenAI API key present`) + chalk.dim(opts.probe ? "" : " (presence only \u2014 run 'recordings check --probe' to verify it is accepted)"));
|
|
4061
5584
|
} else {
|
|
4062
5585
|
console.log(chalk.red(`\u2717 OpenAI API key not found. Set OPENAI_API_KEY env var or add to ~/.secrets`));
|
|
4063
5586
|
}
|
|
4064
5587
|
if (enhKey) {
|
|
4065
|
-
console.log(chalk.green(`\u2713 Enhancement API key
|
|
5588
|
+
console.log(chalk.green(`\u2713 Enhancement API key present (model: ${transcriberModel})`) + chalk.dim(opts.probe ? "" : " (presence only \u2014 'recordings check --probe' verifies it is accepted)"));
|
|
4066
5589
|
} else {
|
|
4067
5590
|
console.log(chalk.yellow(`\u26A0 Enhancement API key not configured \u2014 enhancement disabled`));
|
|
4068
5591
|
}
|
|
5592
|
+
console.log(chalk.green("\u2713") + ` Active store: ${activeStore.transport}` + (activeStore.base_url ? ` \u2192 ${activeStore.base_url}` : ` \u2192 ${activeStore.local_db_path}`) + chalk.dim(` (selected by ${activeStore.mode_source})`));
|
|
5593
|
+
if (activeStore.divergent) {
|
|
5594
|
+
console.log(chalk.yellow("\u26A0") + ` Two datasets present: ${activeStore.local_db_recordings} recordings sit in ` + `${activeStore.local_db_path}, which is NOT the live store.`);
|
|
5595
|
+
}
|
|
5596
|
+
if (activeStore.warning) {
|
|
5597
|
+
console.log(chalk.dim(` ${activeStore.warning}`));
|
|
5598
|
+
}
|
|
5599
|
+
if (macStatus) {
|
|
5600
|
+
const micState = macStatus.microphone_permission;
|
|
5601
|
+
const micOk = micState.startsWith("allowed");
|
|
5602
|
+
const micVerified = micState === "allowed";
|
|
5603
|
+
const micStaleGrant = micState === "stale_allowed_for_previous_app_build";
|
|
5604
|
+
const micUnreadable = micState === TCC_UNREADABLE_STATE;
|
|
5605
|
+
console.log((micOk ? chalk.green("\u2713") : micUnreadable ? chalk.yellow("?") : chalk.red("\u2717")) + ` Microphone permission: ${micState}`);
|
|
5606
|
+
if (micOk) {
|
|
5607
|
+
console.log(chalk.dim(micVerified ? ` Note: this is the TCC row for bundle id ${RECORDINGS_BUNDLE_IDENTIFIER}, and its ` + "stored code-signing requirement still validates against the installed bundle \u2014 " + "so the grant binds to the app that is installed, not to a previous build." : ` Note: this is the TCC row for bundle id ${RECORDINGS_BUNDLE_IDENTIFIER}. The row's ` + "code-signing requirement was NOT verified against the installed bundle, so a " + "bundle re-signed with a different identity can still be denied at runtime. " + "Confirm in the app's log."));
|
|
5608
|
+
}
|
|
5609
|
+
if (micStaleGrant) {
|
|
5610
|
+
console.log(chalk.dim(" The TCC row says allowed, but its stored code-signing requirement does NOT match " + "the installed bundle \u2014 the grant belongs to a previous build and macOS will deny " + "at runtime. Toggling it in System Settings will not fix it; the row must be reset " + "(`recordings app reset-permissions`) and re-granted so it binds to the bundle " + "that is installed now."));
|
|
5611
|
+
}
|
|
5612
|
+
if (micUnreadable) {
|
|
5613
|
+
console.log(chalk.dim(" Could not read the TCC database. This is NOT a denial and NOT proof the app never " + "asked. Reading it needs Full Disk Access, which is held by the session's " + "RESPONSIBLE process and inherited by its children \u2014 not granted per-tool: on " + "a fleet Mac `bun` is explicitly denied and still reads the database over SSH, " + "because it inherits sshd's grant. So re-run from a plain ssh shell rather than " + "granting Full Disk Access to tmux or bun, and not under sudo, which changes the " + "responsible process. A missing sqlite3, a locked database or a corrupt file " + "produce this same state, so the cause is not established either."));
|
|
5614
|
+
}
|
|
5615
|
+
if (!micOk && !micUnreadable) {
|
|
5616
|
+
console.log(chalk.dim(" Recordings.app cannot capture audio without this. macOS does not error when it is " + "missing \u2014 it delivers silent audio. This grant CANNOT be set remotely; it needs a " + "human at the keyboard:"));
|
|
5617
|
+
const instruction = microphoneGrantInstruction({
|
|
5618
|
+
installedAppPath: macStatus.installed_app_path,
|
|
5619
|
+
otherAppPaths: macStatus.legacy_install_paths,
|
|
5620
|
+
requestState: classifyPermissionState(macStatus.microphone_permission)
|
|
5621
|
+
});
|
|
5622
|
+
for (const [index, step] of instruction.steps.entries()) {
|
|
5623
|
+
console.log(chalk.dim(` ${index + 1}. ${step}`));
|
|
5624
|
+
}
|
|
5625
|
+
}
|
|
5626
|
+
console.log((macStatus.accessibility_permission.startsWith("allowed") ? chalk.green("\u2713") : chalk.yellow("\u26A0")) + ` Accessibility permission: ${macStatus.accessibility_permission}`);
|
|
5627
|
+
}
|
|
5628
|
+
if (trigger)
|
|
5629
|
+
reportTriggerDiagnosis(trigger);
|
|
5630
|
+
if (capture) {
|
|
5631
|
+
console.log((capture.ok ? chalk.green("\u2713") : chalk.red("\u2717")) + ` Microphone capture probe: ${capture.message}`);
|
|
5632
|
+
const subject = captureProbeSubject();
|
|
5633
|
+
console.log(chalk.dim(` subject: ${subject.note}`) + (subject.subject_known ? "" : chalk.yellow(" [subject unidentified \u2014 inconclusive]")));
|
|
5634
|
+
}
|
|
5635
|
+
if (credential) {
|
|
5636
|
+
console.log((credential.ok ? chalk.green("\u2713") : chalk.red("\u2717")) + ` Transcription credential (${credential.model}): ${credential.message}`);
|
|
5637
|
+
}
|
|
5638
|
+
if (enhancementCredential) {
|
|
5639
|
+
console.log((enhancementCredential.ok ? chalk.green("\u2713") : chalk.red("\u2717")) + ` Enhancement credential (${enhancementCredential.model}): ${enhancementCredential.message}`);
|
|
5640
|
+
}
|
|
5641
|
+
if (persistence) {
|
|
5642
|
+
const persistenceMarker = renderPersistenceMarker(persistence.outcome, {
|
|
5643
|
+
pass: chalk.green,
|
|
5644
|
+
warn: chalk.yellow,
|
|
5645
|
+
fail: chalk.red
|
|
5646
|
+
});
|
|
5647
|
+
console.log(persistenceMarker + ` Persistence round-trip: ${persistence.message}`);
|
|
5648
|
+
}
|
|
5649
|
+
if (probeFailed || triggerFailed)
|
|
5650
|
+
process.exitCode = 1;
|
|
4069
5651
|
});
|
|
5652
|
+
function reportTriggerDiagnosis(trigger) {
|
|
5653
|
+
console.log((trigger.can_fire ? chalk.green("\u2713") : chalk.red("\u2717")) + ` Recording trigger: ${trigger.summary}`);
|
|
5654
|
+
for (const failure of trigger.failures)
|
|
5655
|
+
console.log(chalk.red(` ${failure}`));
|
|
5656
|
+
for (const warning of trigger.warnings)
|
|
5657
|
+
console.log(chalk.yellow(` \u26A0 ${warning}`));
|
|
5658
|
+
for (const note of trigger.notes)
|
|
5659
|
+
console.log(chalk.dim(` ${note}`));
|
|
5660
|
+
}
|
|
4070
5661
|
program.command("listen").description("Push-to-talk mode \u2014 press Space to start/stop recording, Esc to quit").option("-t, --tags <tags>", "Comma-separated tags for all recordings").option("--no-enhance", "Skip AI enhancement").option("--post-processing <mode>", "Post-processing mode: off, auto, or always").option("--prompt <prompt>", "Vocabulary/context prompt for transcription").option("--transcriber-prompt <prompt>", "Instructions for post-transcription cleanup").option("--system-prompt <prompt>", "Alias for --transcriber-prompt").option("--transcriber-model <model>", "Model for post-transcription cleanup").option("-l, --language <lang>", "Language code").option("--copy", "Copy output to clipboard").option("--paste", "Copy output to clipboard AND paste into frontmost app").action(async (opts) => {
|
|
4071
5662
|
const config = loadConfig();
|
|
4072
5663
|
ensureDataDir(config);
|
|
@@ -4181,19 +5772,212 @@ Bye.`));
|
|
|
4181
5772
|
}
|
|
4182
5773
|
});
|
|
4183
5774
|
});
|
|
4184
|
-
program.command("shortcut").description("
|
|
4185
|
-
const { writeFileSync:
|
|
5775
|
+
program.command("shortcut").description("Show or change the global recording trigger (macOS). Exits non-zero when a change was " + "written while Recordings.app was running, because the running instance keeps the " + "trigger it registered with \u2014 the write is stored but not armed until it is reopened.").option("--set <chord>", 'Set the app hotkey, e.g. "f13" or "ctrl+opt+r"').option("--reset", "Reset the app hotkey to the app's built-in default").option("--fn <state>", "Use fn/Globe as push-to-talk: on|off").option("--keys", "List the key names accepted by --set").option("--script", "Write the app-less toggle script and print its path").option("--raycast", "Generate a Raycast script command (requires Raycast)").option("--karabiner", "Generate a Karabiner-Elements rule (requires Karabiner-Elements)").option("--skhd", "Print an skhd hotkey config (requires skhd)").option("--hammerspoon", "Print a Hammerspoon config (requires Hammerspoon)").action((opts) => {
|
|
5776
|
+
const { writeFileSync: writeFileSync4, mkdirSync: mkdirSync5, chmodSync: chmodSync4, existsSync: existsSync9 } = __require("fs");
|
|
4186
5777
|
const { join: pathJoin2 } = __require("path");
|
|
4187
5778
|
const { homedir: getHome } = __require("os");
|
|
5779
|
+
const { spawnSync: runSync } = __require("child_process");
|
|
4188
5780
|
const home = getHome();
|
|
4189
5781
|
const scriptDir = pathJoin2(home, ".hasna", "recordings");
|
|
4190
|
-
mkdirSync4(scriptDir, { recursive: true });
|
|
4191
5782
|
const scriptPath = pathJoin2(scriptDir, "record-toggle.sh");
|
|
4192
5783
|
const pidFile = pathJoin2(scriptDir, ".recording.pid");
|
|
4193
5784
|
const recordingsBin = pathJoin2(home, ".bun", "bin", "recordings");
|
|
4194
|
-
const
|
|
5785
|
+
const audioDir = pathJoin2(scriptDir, "audio");
|
|
5786
|
+
if (opts.keys) {
|
|
5787
|
+
console.log(chalk.bold(`Keys accepted by --set (combine with cmd/ctrl/opt/shift):
|
|
5788
|
+
`));
|
|
5789
|
+
console.log(listBindableKeys().join(" "));
|
|
5790
|
+
console.log(chalk.dim(`
|
|
5791
|
+
fn/Globe is not in this list \u2014 it is a separate trigger, not a chord:
|
|
5792
|
+
`) + " recordings shortcut --fn on");
|
|
5793
|
+
return;
|
|
5794
|
+
}
|
|
5795
|
+
const appDefaultsReachable = TRIGGER_DEFAULTS_EXECUTABLE !== null;
|
|
5796
|
+
function requireMacOS(what) {
|
|
5797
|
+
if (appDefaultsReachable)
|
|
5798
|
+
return true;
|
|
5799
|
+
console.error(chalk.red(`${what} is only available on macOS`));
|
|
5800
|
+
process.exitCode = 1;
|
|
5801
|
+
return false;
|
|
5802
|
+
}
|
|
5803
|
+
let runningBundlesCache = null;
|
|
5804
|
+
function runningBundles() {
|
|
5805
|
+
runningBundlesCache ??= runningAppBundlePaths();
|
|
5806
|
+
return runningBundlesCache;
|
|
5807
|
+
}
|
|
5808
|
+
function grantTargetPaths() {
|
|
5809
|
+
const running = runningBundles();
|
|
5810
|
+
if (running.length > 0)
|
|
5811
|
+
return { paths: running, running: true };
|
|
5812
|
+
const status = getMacOSAppStatus();
|
|
5813
|
+
return {
|
|
5814
|
+
paths: [...status.installed ? [status.installed_app_path] : [], ...status.legacy_install_paths],
|
|
5815
|
+
running: false
|
|
5816
|
+
};
|
|
5817
|
+
}
|
|
5818
|
+
function showState() {
|
|
5819
|
+
const state = readTriggerState();
|
|
5820
|
+
console.log(chalk.bold(`Recording trigger
|
|
5821
|
+
`));
|
|
5822
|
+
if (state.shortcut) {
|
|
5823
|
+
console.log(` Hotkey ${chalk.cyan(formatShortcut(state.shortcut))}`);
|
|
5824
|
+
} else if (state.rawShortcut) {
|
|
5825
|
+
console.log(` Hotkey ${chalk.yellow(`unreadable (${state.rawShortcut})`)}`);
|
|
5826
|
+
} else {
|
|
5827
|
+
console.log(` Hotkey ${chalk.dim("not set \u2014 the app writes its default")} ` + `${chalk.cyan(DEFAULT_TOGGLE_RECORDING_CHORD.toUpperCase())} ${chalk.dim("on next launch")}`);
|
|
5828
|
+
}
|
|
5829
|
+
console.log(` fn/Globe key ${state.useFnKey ? chalk.cyan("on") : chalk.dim("off")}`);
|
|
5830
|
+
console.log();
|
|
5831
|
+
function showGrantTargets() {
|
|
5832
|
+
const running = runningBundles();
|
|
5833
|
+
const status = getMacOSAppStatus();
|
|
5834
|
+
const installed = [
|
|
5835
|
+
...status.installed ? [status.installed_app_path] : [],
|
|
5836
|
+
...status.legacy_install_paths
|
|
5837
|
+
];
|
|
5838
|
+
console.log(chalk.bold(`Which bundle the grants apply to
|
|
5839
|
+
`));
|
|
5840
|
+
if (running.length === 1) {
|
|
5841
|
+
const [runningBundle] = running;
|
|
5842
|
+
if (runningBundle === undefined)
|
|
5843
|
+
throw new Error("Running app bundle path is missing");
|
|
5844
|
+
console.log(` Running ${chalk.cyan(runningBundle)}`);
|
|
5845
|
+
} else if (running.length > 1) {
|
|
5846
|
+
console.log(` Running ${chalk.red(`${running.length} instances \u2014 grants are ambiguous`)}`);
|
|
5847
|
+
for (const path of running)
|
|
5848
|
+
console.log(` ${path}`);
|
|
5849
|
+
} else {
|
|
5850
|
+
console.log(` Running ${chalk.yellow("not running")}`);
|
|
5851
|
+
}
|
|
5852
|
+
if (installed.length > 0) {
|
|
5853
|
+
console.log(` Installed ${installed.join(`
|
|
5854
|
+
`)}`);
|
|
5855
|
+
}
|
|
5856
|
+
if (installed.length > 1) {
|
|
5857
|
+
console.log(chalk.yellow(" More than one installed copy \u2014 a grant given to one does not cover the others."));
|
|
5858
|
+
}
|
|
5859
|
+
console.log(chalk.dim(`
|
|
5860
|
+
This command reports the app's stored trigger, not its permission state:
|
|
5861
|
+
` + ` a CLI inherits the terminal's grants, so it cannot measure the app's.
|
|
5862
|
+
` + " Use 'recordings app permissions' for the bundle's actual grants."));
|
|
5863
|
+
console.log();
|
|
5864
|
+
}
|
|
5865
|
+
showGrantTargets();
|
|
5866
|
+
console.log(chalk.bold(`Permissions each trigger needs
|
|
5867
|
+
`));
|
|
5868
|
+
for (const requirement of TRIGGER_GRANT_REQUIREMENTS) {
|
|
5869
|
+
const grant = requirement.tccService ? chalk.yellow(requirement.tccService.replace(/^kTCCService/, "")) : chalk.green("none");
|
|
5870
|
+
console.log(` ${requirement.label.padEnd(15)} ${grant} \u2014 ${requirement.mechanism}`);
|
|
5871
|
+
if (requirement.settingsPath) {
|
|
5872
|
+
console.log(` ${" ".repeat(15)} ${requirement.settingsPath} > enable Recordings`);
|
|
5873
|
+
}
|
|
5874
|
+
}
|
|
5875
|
+
console.log();
|
|
5876
|
+
console.log(chalk.bold(`Change it
|
|
5877
|
+
`));
|
|
5878
|
+
console.log(` recordings shortcut --set f13`);
|
|
5879
|
+
console.log(` recordings shortcut --set "ctrl+opt+r"`);
|
|
5880
|
+
console.log(` recordings shortcut --fn on`);
|
|
5881
|
+
console.log(` recordings shortcut --reset`);
|
|
5882
|
+
console.log(chalk.dim(`
|
|
5883
|
+
Or in the app: Settings > Recording Shortcut.`));
|
|
5884
|
+
console.log(chalk.dim(" A changed hotkey is picked up the next time the app launches."));
|
|
5885
|
+
}
|
|
5886
|
+
function reportPickup() {
|
|
5887
|
+
const pickup = describeTriggerPickup(runningBundles());
|
|
5888
|
+
if (pickup.armed) {
|
|
5889
|
+
console.log(chalk.dim(" Recordings.app is not running; it will register this on next launch."));
|
|
5890
|
+
return;
|
|
5891
|
+
}
|
|
5892
|
+
console.log(chalk.yellow(" Recordings.app is running and still holds the previous trigger.") + `
|
|
5893
|
+
Quit and reopen it to arm this one:`);
|
|
5894
|
+
for (const path of pickup.runningBundlePaths)
|
|
5895
|
+
console.log(chalk.dim(` ${path}`));
|
|
5896
|
+
console.log(chalk.dim(" Exiting non-zero: the value was written, but the trigger that fires right now is " + "still the old one."));
|
|
5897
|
+
process.exitCode = 1;
|
|
5898
|
+
}
|
|
5899
|
+
if (opts.set !== undefined) {
|
|
5900
|
+
if (!requireMacOS("Setting the app hotkey"))
|
|
5901
|
+
return;
|
|
5902
|
+
let shortcut;
|
|
5903
|
+
try {
|
|
5904
|
+
shortcut = parseShortcutChord(String(opts.set));
|
|
5905
|
+
} catch (error) {
|
|
5906
|
+
if (error instanceof ShortcutParseError) {
|
|
5907
|
+
console.error(chalk.red(error.message));
|
|
5908
|
+
process.exitCode = 1;
|
|
5909
|
+
return;
|
|
5910
|
+
}
|
|
5911
|
+
throw error;
|
|
5912
|
+
}
|
|
5913
|
+
writeShortcut(shortcut);
|
|
5914
|
+
console.log(chalk.green(`Hotkey set to ${formatShortcut(shortcut)}`));
|
|
5915
|
+
console.log(chalk.dim(` ${RECORDINGS_BUNDLE_IDENTIFIER} ${TOGGLE_RECORDING_DEFAULTS_KEY}`));
|
|
5916
|
+
reportPickup();
|
|
5917
|
+
return;
|
|
5918
|
+
}
|
|
5919
|
+
if (opts.reset) {
|
|
5920
|
+
if (!requireMacOS("Resetting the app hotkey"))
|
|
5921
|
+
return;
|
|
5922
|
+
const shortcut = parseShortcutChord(DEFAULT_TOGGLE_RECORDING_CHORD);
|
|
5923
|
+
writeShortcut(shortcut);
|
|
5924
|
+
console.log(chalk.green(`Hotkey reset to ${formatShortcut(shortcut)}`));
|
|
5925
|
+
console.log(chalk.dim(` ${RECORDINGS_BUNDLE_IDENTIFIER} ${TOGGLE_RECORDING_DEFAULTS_KEY}`));
|
|
5926
|
+
reportPickup();
|
|
5927
|
+
return;
|
|
5928
|
+
}
|
|
5929
|
+
if (opts.fn !== undefined) {
|
|
5930
|
+
if (!requireMacOS("Changing the fn/Globe trigger"))
|
|
5931
|
+
return;
|
|
5932
|
+
const raw = String(opts.fn).toLowerCase();
|
|
5933
|
+
const enable = raw === "on" || raw === "true" || raw === "yes" || raw === "1";
|
|
5934
|
+
const disable = raw === "off" || raw === "false" || raw === "no" || raw === "0";
|
|
5935
|
+
if (!enable && !disable) {
|
|
5936
|
+
console.error(chalk.red(`--fn expects on or off, got "${opts.fn}"`));
|
|
5937
|
+
process.exitCode = 1;
|
|
5938
|
+
return;
|
|
5939
|
+
}
|
|
5940
|
+
writeUseFnKey(enable);
|
|
5941
|
+
console.log(chalk.green(`fn/Globe trigger ${enable ? "enabled" : "disabled"}`));
|
|
5942
|
+
console.log(chalk.dim(` ${RECORDINGS_BUNDLE_IDENTIFIER} ${USE_FN_KEY_DEFAULTS_KEY}`));
|
|
5943
|
+
if (enable) {
|
|
5944
|
+
const fnGrant = TRIGGER_GRANT_REQUIREMENTS.find((entry) => entry.id === "fn");
|
|
5945
|
+
if (fnGrant?.settingsPath) {
|
|
5946
|
+
console.log(`
|
|
5947
|
+
fn is a ${fnGrant.mechanism}, so it needs ` + `${fnGrant.tccService?.replace(/^kTCCService/, "") ?? "no"} permission:
|
|
5948
|
+
` + ` ${fnGrant.settingsPath} > enable Recordings`);
|
|
5949
|
+
const target = grantTargetPaths();
|
|
5950
|
+
if (target.paths.length === 0) {
|
|
5951
|
+
console.log(chalk.dim(" (no installed Recordings.app found to grant it to)"));
|
|
5952
|
+
}
|
|
5953
|
+
for (const path of target.paths) {
|
|
5954
|
+
console.log(chalk.dim(` grant it to: ${path}${target.running ? "" : " (not running \u2014 installed copy)"}`));
|
|
5955
|
+
}
|
|
5956
|
+
}
|
|
5957
|
+
console.log(chalk.dim(`
|
|
5958
|
+
While enabled the app swallows fn, so fn stops reaching other apps
|
|
5959
|
+
` + " (emoji picker, input-source switching). Turn it off with --fn off."));
|
|
5960
|
+
}
|
|
5961
|
+
console.log();
|
|
5962
|
+
reportPickup();
|
|
5963
|
+
return;
|
|
5964
|
+
}
|
|
5965
|
+
const wantsExternalLauncher = Boolean(opts.script) || Boolean(opts.raycast) || Boolean(opts.karabiner) || Boolean(opts.skhd) || Boolean(opts.hammerspoon);
|
|
5966
|
+
if (!wantsExternalLauncher) {
|
|
5967
|
+
if (!requireMacOS("Reading the app hotkey"))
|
|
5968
|
+
return;
|
|
5969
|
+
showState();
|
|
5970
|
+
return;
|
|
5971
|
+
}
|
|
5972
|
+
function writeToggleScript() {
|
|
5973
|
+
mkdirSync5(scriptDir, { recursive: true });
|
|
5974
|
+
const script = `#!/bin/bash
|
|
4195
5975
|
# Toggle recording on/off. Run this from a global hotkey.
|
|
4196
5976
|
# Each press toggles: start recording -> stop + transcribe + copy to clipboard
|
|
5977
|
+
#
|
|
5978
|
+
# This is the app-less path. If Recordings.app is running, prefer its own hotkey
|
|
5979
|
+
# ("recordings shortcut --set ..."): the app streams transcription and pastes into
|
|
5980
|
+
# the focused field, which this script cannot do.
|
|
4197
5981
|
set -e
|
|
4198
5982
|
|
|
4199
5983
|
PID_FILE="${pidFile}"
|
|
@@ -4206,7 +5990,7 @@ if [ -f "$PID_FILE" ]; then
|
|
|
4206
5990
|
rm -f "$PID_FILE"
|
|
4207
5991
|
|
|
4208
5992
|
# Find the most recent audio file
|
|
4209
|
-
AUDIO_DIR="${
|
|
5993
|
+
AUDIO_DIR="${audioDir}"
|
|
4210
5994
|
LATEST=$(ls -t "$AUDIO_DIR"/*.wav 2>/dev/null | head -1)
|
|
4211
5995
|
|
|
4212
5996
|
if [ -n "$LATEST" ]; then
|
|
@@ -4218,8 +6002,6 @@ if [ -f "$PID_FILE" ]; then
|
|
|
4218
6002
|
fi
|
|
4219
6003
|
if [ -n "$TEXT" ]; then
|
|
4220
6004
|
echo -n "$TEXT" | pbcopy
|
|
4221
|
-
# Optional: paste into frontmost app
|
|
4222
|
-
# osascript -e 'delay 0.1' -e 'tell application "System Events" to keystroke "v" using command down'
|
|
4223
6005
|
fi
|
|
4224
6006
|
fi
|
|
4225
6007
|
|
|
@@ -4227,19 +6009,39 @@ if [ -f "$PID_FILE" ]; then
|
|
|
4227
6009
|
osascript -e 'display notification "Recording saved and copied to clipboard" with title "Recordings"' 2>/dev/null || true
|
|
4228
6010
|
else
|
|
4229
6011
|
# Start recording in background
|
|
4230
|
-
mkdir -p "${
|
|
4231
|
-
rec -r 16000 -c 1 -b 16 "${
|
|
6012
|
+
mkdir -p "${audioDir}"
|
|
6013
|
+
rec -r 16000 -c 1 -b 16 "${audioDir}/recording-$(date +%Y%m%dT%H%M%S).wav" trim 0 300 &
|
|
4232
6014
|
echo $! > "$PID_FILE"
|
|
4233
6015
|
|
|
4234
6016
|
# Notification
|
|
4235
6017
|
osascript -e 'display notification "Recording started..." with title "Recordings"' 2>/dev/null || true
|
|
4236
6018
|
fi
|
|
4237
6019
|
`;
|
|
4238
|
-
|
|
4239
|
-
|
|
6020
|
+
writeFileSync4(scriptPath, script, "utf-8");
|
|
6021
|
+
chmodSync4(scriptPath, 493);
|
|
6022
|
+
}
|
|
6023
|
+
function launcherMissing(label, present, install) {
|
|
6024
|
+
if (present)
|
|
6025
|
+
return false;
|
|
6026
|
+
console.error(chalk.red(`${label} is not installed \u2014 nothing would fire this hotkey.`));
|
|
6027
|
+
console.error(chalk.dim(` Install it with: ${install}`));
|
|
6028
|
+
console.error(chalk.dim(` Or use the app's own hotkey, which needs no extra software:
|
|
6029
|
+
` + " recordings shortcut --set f13"));
|
|
6030
|
+
process.exitCode = 1;
|
|
6031
|
+
return true;
|
|
6032
|
+
}
|
|
4240
6033
|
if (opts.karabiner) {
|
|
6034
|
+
if (!requireMacOS("Karabiner-Elements setup"))
|
|
6035
|
+
return;
|
|
6036
|
+
console.log(chalk.yellow("The app can use fn/Globe natively \u2014 no driver, no reboot:") + `
|
|
6037
|
+
recordings shortcut --fn on
|
|
6038
|
+
`);
|
|
6039
|
+
if (launcherMissing("Karabiner-Elements", existsSync9("/Applications/Karabiner-Elements.app"), "brew install --cask karabiner-elements")) {
|
|
6040
|
+
return;
|
|
6041
|
+
}
|
|
4241
6042
|
const karabinerDir = pathJoin2(home, ".config", "karabiner", "assets", "complex_modifications");
|
|
4242
|
-
|
|
6043
|
+
mkdirSync5(karabinerDir, { recursive: true });
|
|
6044
|
+
writeToggleScript();
|
|
4243
6045
|
const rule = {
|
|
4244
6046
|
title: "Recordings \u2014 Fn key to toggle recording",
|
|
4245
6047
|
rules: [
|
|
@@ -4263,23 +6065,27 @@ fi
|
|
|
4263
6065
|
]
|
|
4264
6066
|
};
|
|
4265
6067
|
const karabinerPath = pathJoin2(karabinerDir, "recordings-fn.json");
|
|
4266
|
-
|
|
6068
|
+
writeFileSync4(karabinerPath, JSON.stringify(rule, null, 2) + `
|
|
4267
6069
|
`, "utf-8");
|
|
4268
|
-
console.log(chalk.green("Karabiner-Elements rule
|
|
6070
|
+
console.log(chalk.green("Karabiner-Elements rule written:"));
|
|
4269
6071
|
console.log(chalk.dim(` ${karabinerPath}
|
|
4270
6072
|
`));
|
|
4271
|
-
console.log("
|
|
6073
|
+
console.log("It does nothing until you enable it:");
|
|
4272
6074
|
console.log(" 1. Open Karabiner-Elements");
|
|
4273
6075
|
console.log(" 2. Go to Complex Modifications tab");
|
|
4274
6076
|
console.log(" 3. Click Add Predefined Rule");
|
|
4275
6077
|
console.log(' 4. Enable "Fn key toggles speech recording"');
|
|
4276
|
-
console.log(chalk.dim(`
|
|
4277
|
-
Press Fn to start recording, Fn again to stop + copy to clipboard`));
|
|
4278
6078
|
return;
|
|
4279
6079
|
}
|
|
4280
6080
|
if (opts.raycast) {
|
|
6081
|
+
if (!requireMacOS("Raycast setup"))
|
|
6082
|
+
return;
|
|
6083
|
+
if (launcherMissing("Raycast", existsSync9("/Applications/Raycast.app"), "brew install --cask raycast")) {
|
|
6084
|
+
return;
|
|
6085
|
+
}
|
|
6086
|
+
writeToggleScript();
|
|
4281
6087
|
const raycastDir = pathJoin2(home, ".config", "raycast", "script-commands");
|
|
4282
|
-
|
|
6088
|
+
mkdirSync5(raycastDir, { recursive: true });
|
|
4283
6089
|
const raycastScript = `#!/bin/bash
|
|
4284
6090
|
|
|
4285
6091
|
# Required parameters:
|
|
@@ -4294,15 +6100,20 @@ fi
|
|
|
4294
6100
|
${scriptPath}
|
|
4295
6101
|
`;
|
|
4296
6102
|
const raycastPath = pathJoin2(raycastDir, "toggle-recording.sh");
|
|
4297
|
-
|
|
4298
|
-
|
|
4299
|
-
console.log(chalk.green("Raycast script command
|
|
6103
|
+
writeFileSync4(raycastPath, raycastScript, "utf-8");
|
|
6104
|
+
chmodSync4(raycastPath, 493);
|
|
6105
|
+
console.log(chalk.green("Raycast script command written:"));
|
|
4300
6106
|
console.log(chalk.dim(` ${raycastPath}`));
|
|
4301
|
-
console.log(chalk.dim("
|
|
4302
|
-
console.log(chalk.dim(" Then assign a hotkey in Raycast preferences"));
|
|
6107
|
+
console.log(chalk.dim(" Raycast > Script Commands > reload, then assign a hotkey."));
|
|
4303
6108
|
return;
|
|
4304
6109
|
}
|
|
4305
6110
|
if (opts.skhd) {
|
|
6111
|
+
if (!requireMacOS("skhd setup"))
|
|
6112
|
+
return;
|
|
6113
|
+
const skhdPresent = runSync("/usr/bin/env", ["which", "skhd"], { encoding: "utf8" }).status === 0;
|
|
6114
|
+
if (launcherMissing("skhd", skhdPresent, "brew install koekeishiya/formulae/skhd"))
|
|
6115
|
+
return;
|
|
6116
|
+
writeToggleScript();
|
|
4306
6117
|
console.log(chalk.bold(`Add to ~/.skhdrc:
|
|
4307
6118
|
`));
|
|
4308
6119
|
console.log(chalk.cyan(` fn - space : ${scriptPath}`));
|
|
@@ -4311,6 +6122,12 @@ ${scriptPath}
|
|
|
4311
6122
|
return;
|
|
4312
6123
|
}
|
|
4313
6124
|
if (opts.hammerspoon) {
|
|
6125
|
+
if (!requireMacOS("Hammerspoon setup"))
|
|
6126
|
+
return;
|
|
6127
|
+
if (launcherMissing("Hammerspoon", existsSync9("/Applications/Hammerspoon.app"), "brew install --cask hammerspoon")) {
|
|
6128
|
+
return;
|
|
6129
|
+
}
|
|
6130
|
+
writeToggleScript();
|
|
4314
6131
|
console.log(chalk.bold(`Add to ~/.hammerspoon/init.lua:
|
|
4315
6132
|
`));
|
|
4316
6133
|
console.log(chalk.cyan(` hs.hotkey.bind({"ctrl"}, "space", function()
|
|
@@ -4320,35 +6137,8 @@ ${scriptPath}
|
|
|
4320
6137
|
Then reload Hammerspoon config`));
|
|
4321
6138
|
return;
|
|
4322
6139
|
}
|
|
4323
|
-
|
|
4324
|
-
console.log(
|
|
4325
|
-
`));
|
|
4326
|
-
console.log(`Bind it to a hotkey using any of these:
|
|
4327
|
-
`);
|
|
4328
|
-
console.log(chalk.bold(" Karabiner-Elements") + chalk.dim(" (for Fn key specifically)"));
|
|
4329
|
-
console.log(` brew install --cask karabiner-elements`);
|
|
4330
|
-
console.log(` recordings shortcut --karabiner
|
|
4331
|
-
`);
|
|
4332
|
-
console.log(chalk.bold(" Raycast"));
|
|
4333
|
-
console.log(` recordings shortcut --raycast
|
|
4334
|
-
`);
|
|
4335
|
-
console.log(chalk.bold(" skhd"));
|
|
4336
|
-
console.log(` recordings shortcut --skhd
|
|
4337
|
-
`);
|
|
4338
|
-
console.log(chalk.bold(" Hammerspoon"));
|
|
4339
|
-
console.log(` recordings shortcut --hammerspoon
|
|
4340
|
-
`);
|
|
4341
|
-
console.log(chalk.bold(" macOS Automator"));
|
|
4342
|
-
console.log(` 1. Open Automator > Quick Action`);
|
|
4343
|
-
console.log(` 2. Add "Run Shell Script" action`);
|
|
4344
|
-
console.log(` 3. Paste: ${scriptPath}`);
|
|
4345
|
-
console.log(` 4. Save as "Toggle Recording"`);
|
|
4346
|
-
console.log(` 5. System Settings > Keyboard > Shortcuts > Services`);
|
|
4347
|
-
console.log(` 6. Assign a shortcut to "Toggle Recording"
|
|
4348
|
-
`);
|
|
4349
|
-
console.log(chalk.bold(" Alfred"));
|
|
4350
|
-
console.log(` Create a workflow with a Hotkey trigger \u2192 Run Script: ${scriptPath}
|
|
4351
|
-
`);
|
|
6140
|
+
writeToggleScript();
|
|
6141
|
+
console.log(scriptPath);
|
|
4352
6142
|
});
|
|
4353
6143
|
function buildTranscriptionMetadata(config, processed, sources = {}) {
|
|
4354
6144
|
const transcriptionPromptConfigured = Boolean(config.transcription_prompt?.trim());
|
|
@@ -4382,7 +6172,7 @@ async function readSaveTextInput(text, opts) {
|
|
|
4382
6172
|
}
|
|
4383
6173
|
let rawText;
|
|
4384
6174
|
if (opts.textFile !== undefined) {
|
|
4385
|
-
rawText =
|
|
6175
|
+
rawText = readFileSync4(opts.textFile, "utf8");
|
|
4386
6176
|
} else if (opts.stdin) {
|
|
4387
6177
|
rawText = await Bun.stdin.text();
|
|
4388
6178
|
} else {
|
|
@@ -4596,7 +6386,7 @@ function relativeHint(value) {
|
|
|
4596
6386
|
return `${Math.floor(hours / 24)}d ago`;
|
|
4597
6387
|
}
|
|
4598
6388
|
program.command("mcp").description("Install recordings MCP server into Claude Code, Codex, or Gemini").option("--claude", "Install into Claude Code (via `claude mcp add`)").option("--codex", "Install into Codex (~/.codex/config.toml)").option("--gemini", "Install into Gemini (~/.gemini/settings.json)").option("--all", "Install into all supported agents").option("--uninstall", "Remove recordings MCP from config").action(async (opts) => {
|
|
4599
|
-
const { readFileSync:
|
|
6389
|
+
const { readFileSync: readFileSync5, writeFileSync: writeFileSync4, existsSync: fileExists } = __require("fs");
|
|
4600
6390
|
const { join: pathJoin2 } = __require("path");
|
|
4601
6391
|
const { homedir: getHome } = __require("os");
|
|
4602
6392
|
const { execSync } = __require("child_process");
|
|
@@ -4629,14 +6419,14 @@ program.command("mcp").description("Install recordings MCP server into Claude Co
|
|
|
4629
6419
|
if (target === "codex") {
|
|
4630
6420
|
const configPath = pathJoin2(home, ".codex", "config.toml");
|
|
4631
6421
|
if (fileExists(configPath)) {
|
|
4632
|
-
const content =
|
|
6422
|
+
const content = readFileSync5(configPath, "utf-8");
|
|
4633
6423
|
if (opts.uninstall) {
|
|
4634
6424
|
const { content: next, removed } = removeCodexServerBlock(content, "recordings");
|
|
4635
|
-
|
|
6425
|
+
writeFileSync4(configPath, next, "utf-8");
|
|
4636
6426
|
console.log(removed ? chalk.green(`Removed from Codex: ${configPath}`) : chalk.yellow(`Codex: no recordings MCP block found in ${configPath}`));
|
|
4637
6427
|
} else {
|
|
4638
6428
|
const next = upsertCodexStdioBlock(content, "recordings", mcpCmd);
|
|
4639
|
-
|
|
6429
|
+
writeFileSync4(configPath, next, "utf-8");
|
|
4640
6430
|
console.log(chalk.green(`Installed into Codex: ${configPath}`));
|
|
4641
6431
|
}
|
|
4642
6432
|
} else {
|
|
@@ -4647,7 +6437,7 @@ program.command("mcp").description("Install recordings MCP server into Claude Co
|
|
|
4647
6437
|
const configPath = pathJoin2(home, ".gemini", "settings.json");
|
|
4648
6438
|
let config = {};
|
|
4649
6439
|
if (fileExists(configPath)) {
|
|
4650
|
-
config = JSON.parse(
|
|
6440
|
+
config = JSON.parse(readFileSync5(configPath, "utf-8"));
|
|
4651
6441
|
}
|
|
4652
6442
|
const servers = config["mcpServers"] || {};
|
|
4653
6443
|
if (opts.uninstall) {
|
|
@@ -4656,7 +6446,7 @@ program.command("mcp").description("Install recordings MCP server into Claude Co
|
|
|
4656
6446
|
servers["recordings"] = { command: mcpCmd, args: ["--stdio"] };
|
|
4657
6447
|
}
|
|
4658
6448
|
config["mcpServers"] = servers;
|
|
4659
|
-
|
|
6449
|
+
writeFileSync4(configPath, JSON.stringify(config, null, 2) + `
|
|
4660
6450
|
`, "utf-8");
|
|
4661
6451
|
console.log(chalk.green(`${action} Gemini: ${configPath}`));
|
|
4662
6452
|
}
|
|
@@ -4686,58 +6476,109 @@ program.command("feedback <message>").description("Send feedback").option("--ema
|
|
|
4686
6476
|
function getMacOSAppStatus() {
|
|
4687
6477
|
const packageRoot = findPackageRoot();
|
|
4688
6478
|
const home = process.env.HOME || process.env.USERPROFILE || "";
|
|
4689
|
-
const
|
|
6479
|
+
const canonicalAppPath = pathJoin(home, "Applications", "Recordings.app");
|
|
6480
|
+
const legacyInstallPaths = findLegacyMacOSAppPaths(home, canonicalAppPath);
|
|
6481
|
+
const installedAppPath = resolveInstalledAppPath(home, canonicalAppPath, legacyInstallPaths);
|
|
6482
|
+
const installedAppPathForGrants = existsSync8(installedAppPath) ? installedAppPath : null;
|
|
4690
6483
|
const executablePath = pathJoin(installedAppPath, "Contents", "MacOS", "Recordings");
|
|
4691
6484
|
const logPath = pathJoin(home, ".hasna", "recordings", "Recordings.log");
|
|
4692
6485
|
const installerPath = getMacOSInstallerPath(packageRoot);
|
|
4693
6486
|
const nativeSourcesPath = pathJoin(packageRoot, "src", "native", "Recordings");
|
|
4694
6487
|
const signingInfo = getCodeSigningInfo(installedAppPath);
|
|
4695
|
-
const
|
|
4696
|
-
const
|
|
6488
|
+
const ambiguousInstallations = [canonicalAppPath, ...legacyInstallPaths].filter((path) => existsSync8(path)).length > 1;
|
|
6489
|
+
const microphoneGrant = getTccGrant("kTCCServiceMicrophone", home, installedAppPathForGrants);
|
|
6490
|
+
const accessibilityGrant = getTccGrant("kTCCServiceAccessibility", home, installedAppPathForGrants);
|
|
4697
6491
|
return {
|
|
4698
6492
|
platform: process.platform,
|
|
4699
6493
|
package_root: packageRoot,
|
|
4700
6494
|
installer_path: installerPath,
|
|
4701
|
-
installer_available:
|
|
6495
|
+
installer_available: existsSync8(installerPath),
|
|
4702
6496
|
native_sources_path: nativeSourcesPath,
|
|
4703
|
-
native_sources_available:
|
|
6497
|
+
native_sources_available: existsSync8(pathJoin(nativeSourcesPath, "Package.swift")),
|
|
4704
6498
|
installed_app_path: installedAppPath,
|
|
4705
6499
|
legacy_install_paths: legacyInstallPaths,
|
|
4706
|
-
installed:
|
|
6500
|
+
installed: existsSync8(installedAppPath),
|
|
4707
6501
|
executable_path: executablePath,
|
|
4708
|
-
executable:
|
|
6502
|
+
executable: existsSync8(executablePath),
|
|
4709
6503
|
app_code_hash: signingInfo.cdHash,
|
|
4710
6504
|
ad_hoc_signed: signingInfo.adHoc,
|
|
4711
6505
|
signing_identifier: signingInfo.identifier,
|
|
4712
6506
|
team_identifier: signingInfo.teamIdentifier,
|
|
4713
6507
|
designated_requirement: signingInfo.designatedRequirement,
|
|
4714
6508
|
signature_authorities: signingInfo.authorities,
|
|
4715
|
-
microphone_permission:
|
|
4716
|
-
accessibility_permission:
|
|
6509
|
+
microphone_permission: microphoneGrant.state,
|
|
6510
|
+
accessibility_permission: accessibilityGrant.state,
|
|
6511
|
+
ambiguous_installations: ambiguousInstallations,
|
|
6512
|
+
microphone_grant_durability: microphoneGrant.durability,
|
|
6513
|
+
accessibility_grant_durability: accessibilityGrant.durability,
|
|
6514
|
+
microphone_stored_requirement: microphoneGrant.storedRequirement,
|
|
6515
|
+
accessibility_stored_requirement: accessibilityGrant.storedRequirement,
|
|
4717
6516
|
log_path: logPath
|
|
4718
6517
|
};
|
|
4719
6518
|
}
|
|
6519
|
+
function buildPermissionWarnings(status) {
|
|
6520
|
+
const warnings = [];
|
|
6521
|
+
if (status.platform !== "darwin")
|
|
6522
|
+
return warnings;
|
|
6523
|
+
if (!status.installed) {
|
|
6524
|
+
warnings.push(`no app bundle exists at ${status.installed_app_path}, so the states above describe no ` + "installed code \u2014 install the app before trusting them");
|
|
6525
|
+
}
|
|
6526
|
+
if (status.ambiguous_installations) {
|
|
6527
|
+
warnings.push("more than one Recordings.app is installed, so the states above may describe a bundle " + `other than the one macOS granted: reporting on ${status.installed_app_path}, also ` + `present are ${status.legacy_install_paths.join(", ")}`);
|
|
6528
|
+
}
|
|
6529
|
+
for (const [service, durability] of [
|
|
6530
|
+
["Microphone", status.microphone_grant_durability],
|
|
6531
|
+
["Accessibility", status.accessibility_grant_durability]
|
|
6532
|
+
]) {
|
|
6533
|
+
if (durability === "dies_on_rebuild_cdhash_pinned") {
|
|
6534
|
+
warnings.push(`the ${service} grant is pinned to one exact build, so the next rebuild will silently ` + "revoke it \u2014 re-sign with a stable certificate identity to keep it");
|
|
6535
|
+
}
|
|
6536
|
+
}
|
|
6537
|
+
for (const [service, state] of [
|
|
6538
|
+
["Microphone", status.microphone_permission],
|
|
6539
|
+
["Accessibility", status.accessibility_permission]
|
|
6540
|
+
]) {
|
|
6541
|
+
if (state === "undetermined_tcc_database_unreadable") {
|
|
6542
|
+
warnings.push(`the TCC database holding the ${service} decision could not be read, so that state is ` + "unknown rather than ungranted \u2014 the usual cause is that this process lacks Full " + "Disk Access");
|
|
6543
|
+
}
|
|
6544
|
+
}
|
|
6545
|
+
return warnings;
|
|
6546
|
+
}
|
|
6547
|
+
function resolveInstalledAppPath(home, canonicalPath, legacyPaths) {
|
|
6548
|
+
const preference = [
|
|
6549
|
+
canonicalPath,
|
|
6550
|
+
pathJoin("/", "Applications", "HasnaRecordings.app"),
|
|
6551
|
+
pathJoin("/", "Applications", "Recordings.app"),
|
|
6552
|
+
pathJoin(home, ".hasna", "recordings", "HasnaRecordings.app"),
|
|
6553
|
+
pathJoin(home, ".hasna", "recordings", "Recordings.app")
|
|
6554
|
+
];
|
|
6555
|
+
for (const candidate of preference) {
|
|
6556
|
+
if (existsSync8(candidate))
|
|
6557
|
+
return candidate;
|
|
6558
|
+
}
|
|
6559
|
+
return legacyPaths.find((candidate) => existsSync8(candidate)) ?? canonicalPath;
|
|
6560
|
+
}
|
|
4720
6561
|
function findLegacyMacOSAppPaths(home, canonicalPath) {
|
|
4721
6562
|
const candidates = [
|
|
4722
6563
|
pathJoin(home, ".hasna", "recordings", "Recordings.app"),
|
|
4723
|
-
pathJoin("
|
|
6564
|
+
pathJoin(home, ".hasna", "recordings", "HasnaRecordings.app"),
|
|
6565
|
+
pathJoin("/", "Applications", "Recordings.app"),
|
|
6566
|
+
pathJoin("/", "Applications", "HasnaRecordings.app")
|
|
4724
6567
|
];
|
|
4725
6568
|
const userApplications = pathJoin(home, "Applications");
|
|
4726
|
-
if (
|
|
6569
|
+
if (existsSync8(userApplications)) {
|
|
4727
6570
|
for (const entry of readdirSync2(userApplications, { withFileTypes: true })) {
|
|
4728
|
-
if (entry.isDirectory() && entry.name.startsWith("Recordings.app.")) {
|
|
6571
|
+
if (entry.isDirectory() && (entry.name.startsWith("Recordings.app.") || entry.name.startsWith("HasnaRecordings.app."))) {
|
|
4729
6572
|
candidates.push(pathJoin(userApplications, entry.name));
|
|
4730
6573
|
}
|
|
4731
6574
|
}
|
|
4732
6575
|
}
|
|
4733
|
-
return [...new Set(candidates)].filter((candidate) => candidate !== canonicalPath &&
|
|
6576
|
+
return [...new Set(candidates)].filter((candidate) => candidate !== canonicalPath && existsSync8(candidate)).sort();
|
|
4734
6577
|
}
|
|
4735
6578
|
function resetMacOSPermissions() {
|
|
4736
6579
|
const services = ["Microphone", "Accessibility"];
|
|
4737
6580
|
for (const service of services) {
|
|
4738
|
-
const result =
|
|
4739
|
-
stdio: "inherit"
|
|
4740
|
-
});
|
|
6581
|
+
const result = spawnSync7("/usr/bin/tccutil", ["reset", service, RECORDINGS_BUNDLE_IDENTIFIER], { stdio: "inherit" });
|
|
4741
6582
|
if (result.error) {
|
|
4742
6583
|
console.error(chalk.red(result.error.message));
|
|
4743
6584
|
process.exit(1);
|
|
@@ -4745,7 +6586,7 @@ function resetMacOSPermissions() {
|
|
|
4745
6586
|
}
|
|
4746
6587
|
}
|
|
4747
6588
|
function getCodeSigningInfo(appPath) {
|
|
4748
|
-
if (process.platform !== "darwin" || !
|
|
6589
|
+
if (process.platform !== "darwin" || !existsSync8(appPath)) {
|
|
4749
6590
|
return {
|
|
4750
6591
|
cdHash: null,
|
|
4751
6592
|
adHoc: false,
|
|
@@ -4755,7 +6596,7 @@ function getCodeSigningInfo(appPath) {
|
|
|
4755
6596
|
authorities: []
|
|
4756
6597
|
};
|
|
4757
6598
|
}
|
|
4758
|
-
const result =
|
|
6599
|
+
const result = spawnSync7("/usr/bin/codesign", ["-d", "-r-", "--verbose=4", appPath], {
|
|
4759
6600
|
encoding: "utf8",
|
|
4760
6601
|
stdio: ["ignore", "pipe", "pipe"]
|
|
4761
6602
|
});
|
|
@@ -4766,58 +6607,31 @@ ${result.stderr}`;
|
|
|
4766
6607
|
const identifier = output.match(/^Identifier=(.+)$/m)?.[1]?.trim() ?? null;
|
|
4767
6608
|
const teamIdentifier = output.match(/^TeamIdentifier=(.+)$/m)?.[1]?.trim() ?? null;
|
|
4768
6609
|
const designatedRequirement = output.match(/^designated => (.+)$/m)?.[1]?.trim() ?? null;
|
|
4769
|
-
const authorities = [...output.matchAll(/^Authority=(.+)$/gm)].
|
|
6610
|
+
const authorities = [...output.matchAll(/^Authority=(.+)$/gm)].flatMap((match) => {
|
|
6611
|
+
const authority = match[1];
|
|
6612
|
+
return authority === undefined ? [] : [authority.trim()];
|
|
6613
|
+
});
|
|
4770
6614
|
return { cdHash, adHoc, identifier, teamIdentifier, designatedRequirement, authorities };
|
|
4771
6615
|
}
|
|
4772
|
-
function
|
|
4773
|
-
if (process.platform !== "darwin")
|
|
4774
|
-
return "unsupported";
|
|
4775
|
-
const dbPaths = [
|
|
4776
|
-
pathJoin(home, "Library", "Application Support", "com.apple.TCC", "TCC.db"),
|
|
4777
|
-
pathJoin("/", "Library", "Application Support", "com.apple.TCC", "TCC.db")
|
|
4778
|
-
];
|
|
4779
|
-
const sql = "select auth_value from access where service = '" + service.replace(/'/g, "''") + "' and client = 'com.hasna.recordings' order by last_modified desc limit 1;";
|
|
4780
|
-
for (const dbPath of dbPaths) {
|
|
4781
|
-
if (!existsSync4(dbPath))
|
|
4782
|
-
continue;
|
|
4783
|
-
const result = spawnSync4("/usr/bin/sqlite3", [dbPath, sql], {
|
|
4784
|
-
encoding: "utf8",
|
|
4785
|
-
stdio: ["ignore", "pipe", "ignore"]
|
|
4786
|
-
});
|
|
4787
|
-
const value = result.stdout.trim();
|
|
4788
|
-
if (!value)
|
|
4789
|
-
continue;
|
|
4790
|
-
return `${tccAuthValueLabel(value)}_identity_unverified`;
|
|
4791
|
-
}
|
|
4792
|
-
return "not_determined";
|
|
4793
|
-
}
|
|
4794
|
-
function tccAuthValueLabel(value) {
|
|
4795
|
-
switch (value) {
|
|
4796
|
-
case "0":
|
|
4797
|
-
return "denied";
|
|
4798
|
-
case "1":
|
|
4799
|
-
return "unknown";
|
|
4800
|
-
case "2":
|
|
4801
|
-
return "allowed";
|
|
4802
|
-
case "3":
|
|
4803
|
-
return "limited";
|
|
4804
|
-
default:
|
|
4805
|
-
return `unknown(${value})`;
|
|
6616
|
+
function getTccGrant(service, home, appPath) {
|
|
6617
|
+
if (process.platform !== "darwin") {
|
|
6618
|
+
return { state: "unsupported", storedRequirement: null, durability: "unknown" };
|
|
4806
6619
|
}
|
|
6620
|
+
return resolveTccGrant({ service, home, appPath });
|
|
4807
6621
|
}
|
|
4808
6622
|
function findPackageRoot() {
|
|
4809
|
-
let current =
|
|
6623
|
+
let current = dirname5(fileURLToPath(import.meta.url));
|
|
4810
6624
|
while (true) {
|
|
4811
6625
|
const packagePath = pathJoin(current, "package.json");
|
|
4812
|
-
if (
|
|
6626
|
+
if (existsSync8(packagePath)) {
|
|
4813
6627
|
try {
|
|
4814
|
-
const pkg = JSON.parse(
|
|
6628
|
+
const pkg = JSON.parse(readFileSync4(packagePath, "utf8"));
|
|
4815
6629
|
if (pkg.name === "@hasna/recordings") {
|
|
4816
6630
|
return current;
|
|
4817
6631
|
}
|
|
4818
6632
|
} catch {}
|
|
4819
6633
|
}
|
|
4820
|
-
const parent =
|
|
6634
|
+
const parent = dirname5(current);
|
|
4821
6635
|
if (parent === current) {
|
|
4822
6636
|
return process.cwd();
|
|
4823
6637
|
}
|