@hasna/recordings 0.2.14 → 0.3.1
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 +2078 -277
- 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 +10 -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 +634 -49
- 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 +140 -56
- 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 +187 -79
- 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 +108 -49
- 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 +138 -24
- package/scripts/macos_artifact.ts +205 -33
- 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/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 +14 -7
- 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/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/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 +74 -8
- 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.
|
|
1930
|
+
var VERSION = "0.3.1";
|
|
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
|
|
1978
|
+
function defaultApiBaseUrl(name) {
|
|
1937
1979
|
return `https://${name}.hasna.xyz`;
|
|
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,7 +5179,7 @@ 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("--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);
|
|
@@ -3768,7 +5193,7 @@ appCommand.command("install").description("Install a release or explicitly appro
|
|
|
3768
5193
|
try {
|
|
3769
5194
|
assertReleaseOnlyOptions(opts);
|
|
3770
5195
|
if (opts.expectedHostname) {
|
|
3771
|
-
const hostnameResult =
|
|
5196
|
+
const hostnameResult = spawnSync7("/bin/hostname", ["-s"], {
|
|
3772
5197
|
encoding: "utf8",
|
|
3773
5198
|
stdio: ["ignore", "pipe", "ignore"],
|
|
3774
5199
|
env: {
|
|
@@ -3797,14 +5222,14 @@ appCommand.command("install").description("Install a release or explicitly appro
|
|
|
3797
5222
|
process.exit(1);
|
|
3798
5223
|
}
|
|
3799
5224
|
const updateClientPath = "/Applications/Recordings.app/Contents/Helpers/recordings-update-client";
|
|
3800
|
-
if (!
|
|
5225
|
+
if (!existsSync8(updateClientPath)) {
|
|
3801
5226
|
preparedInputs.cleanup();
|
|
3802
5227
|
console.error(chalk.red("Root-owned Recordings update broker client is not installed."));
|
|
3803
5228
|
process.exit(1);
|
|
3804
5229
|
}
|
|
3805
5230
|
const result2 = (() => {
|
|
3806
5231
|
try {
|
|
3807
|
-
return
|
|
5232
|
+
return spawnSync7(updateClientPath, [
|
|
3808
5233
|
"install",
|
|
3809
5234
|
"--artifact",
|
|
3810
5235
|
opts.artifact,
|
|
@@ -3851,7 +5276,7 @@ appCommand.command("install").description("Install a release or explicitly appro
|
|
|
3851
5276
|
process.exit(1);
|
|
3852
5277
|
}
|
|
3853
5278
|
const installerPath = getMacOSInstallerPath();
|
|
3854
|
-
if (!
|
|
5279
|
+
if (!existsSync8(installerPath)) {
|
|
3855
5280
|
console.error(chalk.red(`App installer missing from package: ${installerPath}`));
|
|
3856
5281
|
process.exit(1);
|
|
3857
5282
|
}
|
|
@@ -3890,6 +5315,9 @@ appCommand.command("install").description("Install a release or explicitly appro
|
|
|
3890
5315
|
if (opts.allowSigningIdentityMigration) {
|
|
3891
5316
|
installerArgs.push("--allow-signing-identity-migration");
|
|
3892
5317
|
}
|
|
5318
|
+
if (opts.allowAdhocIdentityMigration) {
|
|
5319
|
+
installerArgs.push("--allow-adhoc-identity-migration");
|
|
5320
|
+
}
|
|
3893
5321
|
if (opts.expectedOldIdentitySha256) {
|
|
3894
5322
|
installerArgs.push("--expected-old-identity-sha256", opts.expectedOldIdentitySha256);
|
|
3895
5323
|
}
|
|
@@ -3899,7 +5327,7 @@ appCommand.command("install").description("Install a release or explicitly appro
|
|
|
3899
5327
|
if (opts.launch)
|
|
3900
5328
|
installerArgs.push("--launch");
|
|
3901
5329
|
const installerEnvironment = createInstallerEnvironment(process.env, bunExecutable);
|
|
3902
|
-
const result =
|
|
5330
|
+
const result = spawnSync7("/bin/bash", installerArgs, {
|
|
3903
5331
|
stdio: "inherit",
|
|
3904
5332
|
env: installerEnvironment
|
|
3905
5333
|
});
|
|
@@ -3911,8 +5339,12 @@ appCommand.command("install").description("Install a release or explicitly appro
|
|
|
3911
5339
|
});
|
|
3912
5340
|
appCommand.command("status").description("Show installed Recordings.app status").option("--verbose", "Show package paths, code hash, and log path").action((opts) => {
|
|
3913
5341
|
const status = getMacOSAppStatus();
|
|
5342
|
+
const trigger = probeTriggerDiagnostics({
|
|
5343
|
+
accessibilityPermission: process.platform === "darwin" ? status.accessibility_permission : null,
|
|
5344
|
+
appLogPath: status.log_path
|
|
5345
|
+
});
|
|
3914
5346
|
if (program.opts().json) {
|
|
3915
|
-
console.log(JSON.stringify(status, null, 2));
|
|
5347
|
+
console.log(JSON.stringify({ ...status, trigger }, null, 2));
|
|
3916
5348
|
return;
|
|
3917
5349
|
}
|
|
3918
5350
|
console.log(chalk.bold("Recordings.app"));
|
|
@@ -3925,6 +5357,10 @@ appCommand.command("status").description("Show installed Recordings.app status")
|
|
|
3925
5357
|
console.log(`Microphone: ${status.microphone_permission}`);
|
|
3926
5358
|
console.log(`Accessibility: ${status.accessibility_permission}`);
|
|
3927
5359
|
}
|
|
5360
|
+
if (trigger) {
|
|
5361
|
+
console.log(`Trigger: ${trigger.summary}${trigger.can_fire ? "" : " \u2014 NOTHING CAN FIRE"}`);
|
|
5362
|
+
console.log(chalk.dim(" 'recordings check' reports the trigger in full and exits non-zero when nothing can fire."));
|
|
5363
|
+
}
|
|
3928
5364
|
if (opts.verbose) {
|
|
3929
5365
|
console.log(`Package: ${status.package_root}`);
|
|
3930
5366
|
console.log(`Installed app: ${status.installed ? status.installed_app_path : "missing"}`);
|
|
@@ -3945,9 +5381,11 @@ appCommand.command("permissions").description("Show macOS permission state for R
|
|
|
3945
5381
|
const status = getMacOSAppStatus();
|
|
3946
5382
|
const permissions = {
|
|
3947
5383
|
platform: status.platform,
|
|
3948
|
-
bundle_id:
|
|
5384
|
+
bundle_id: RECORDINGS_BUNDLE_IDENTIFIER,
|
|
3949
5385
|
installed_app_path: status.installed_app_path,
|
|
5386
|
+
installed: status.installed,
|
|
3950
5387
|
legacy_install_paths: status.legacy_install_paths,
|
|
5388
|
+
permission_subject: describeTccAuthorizationSubject(status.installed_app_path),
|
|
3951
5389
|
microphone: status.microphone_permission,
|
|
3952
5390
|
accessibility: status.accessibility_permission,
|
|
3953
5391
|
app_code_hash: status.app_code_hash,
|
|
@@ -3955,14 +5393,24 @@ appCommand.command("permissions").description("Show macOS permission state for R
|
|
|
3955
5393
|
signing_identifier: status.signing_identifier,
|
|
3956
5394
|
team_identifier: status.team_identifier,
|
|
3957
5395
|
designated_requirement: status.designated_requirement,
|
|
5396
|
+
microphone_grant_durability: status.microphone_grant_durability,
|
|
5397
|
+
accessibility_grant_durability: status.accessibility_grant_durability,
|
|
5398
|
+
microphone_stored_requirement: status.microphone_stored_requirement,
|
|
5399
|
+
accessibility_stored_requirement: status.accessibility_stored_requirement,
|
|
5400
|
+
ambiguous_installations: status.ambiguous_installations,
|
|
5401
|
+
warnings: buildPermissionWarnings(status),
|
|
3958
5402
|
log_path: status.log_path
|
|
3959
5403
|
};
|
|
3960
5404
|
if (program.opts().json) {
|
|
3961
5405
|
console.log(JSON.stringify(permissions, null, 2));
|
|
3962
5406
|
return;
|
|
3963
5407
|
}
|
|
3964
|
-
console.log(`
|
|
3965
|
-
console.log(`
|
|
5408
|
+
console.log(`Subject: ${permissions.permission_subject}`);
|
|
5409
|
+
console.log(`Microphone: ${permissions.microphone} (${permissions.microphone_grant_durability})`);
|
|
5410
|
+
console.log(`Accessibility: ${permissions.accessibility} (${permissions.accessibility_grant_durability})`);
|
|
5411
|
+
for (const warning of permissions.warnings) {
|
|
5412
|
+
console.log(chalk.yellow(`Warning: ${warning}`));
|
|
5413
|
+
}
|
|
3966
5414
|
console.log(`Log: ${permissions.log_path}`);
|
|
3967
5415
|
});
|
|
3968
5416
|
appCommand.command("reset-permissions").description("Reset macOS Microphone and Accessibility permissions for Recordings.app").action(() => {
|
|
@@ -3993,12 +5441,12 @@ appCommand.command("request-permissions").description("Open Recordings.app and t
|
|
|
3993
5441
|
});
|
|
3994
5442
|
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
5443
|
const status = getMacOSAppStatus();
|
|
3996
|
-
if (!
|
|
5444
|
+
if (!existsSync8(status.log_path)) {
|
|
3997
5445
|
console.log("");
|
|
3998
5446
|
return;
|
|
3999
5447
|
}
|
|
4000
5448
|
const lines = Math.max(1, parseInt(opts.lines, 10) || DEFAULT_LOG_LINES);
|
|
4001
|
-
const result =
|
|
5449
|
+
const result = spawnSync7("tail", ["-n", String(lines), status.log_path], {
|
|
4002
5450
|
encoding: "utf8"
|
|
4003
5451
|
});
|
|
4004
5452
|
if (result.error) {
|
|
@@ -4009,6 +5457,14 @@ appCommand.command("log").description("Show the Recordings.app diagnostic log").
|
|
|
4009
5457
|
if (result.stderr)
|
|
4010
5458
|
process.stderr.write(result.stderr);
|
|
4011
5459
|
});
|
|
5460
|
+
appCommand.command("snapshot [output]").description("Write the current main desktop to a PNG for local debugging").action((output) => {
|
|
5461
|
+
const path = exportDesktopSnapshot(output);
|
|
5462
|
+
if (program.opts().json) {
|
|
5463
|
+
console.log(JSON.stringify({ path }, null, 2));
|
|
5464
|
+
} else {
|
|
5465
|
+
console.log(chalk.green(`Desktop snapshot written: ${path}`));
|
|
5466
|
+
}
|
|
5467
|
+
});
|
|
4012
5468
|
appCommand.command("open").description("Open the installed Recordings.app").action(() => {
|
|
4013
5469
|
const status = getMacOSAppStatus();
|
|
4014
5470
|
if (process.platform !== "darwin") {
|
|
@@ -4019,18 +5475,60 @@ appCommand.command("open").description("Open the installed Recordings.app").acti
|
|
|
4019
5475
|
console.error(chalk.red("Recordings.app is not installed. Run: recordings app install"));
|
|
4020
5476
|
process.exit(1);
|
|
4021
5477
|
}
|
|
4022
|
-
const result =
|
|
5478
|
+
const result = spawnSync7("open", [status.installed_app_path], { stdio: "inherit" });
|
|
4023
5479
|
if (result.error) {
|
|
4024
5480
|
console.error(chalk.red(result.error.message));
|
|
4025
5481
|
process.exit(1);
|
|
4026
5482
|
}
|
|
4027
5483
|
process.exit(result.status ?? 1);
|
|
4028
5484
|
});
|
|
4029
|
-
program.command("check").description("Check system dependencies (sox, API keys)").action(async () => {
|
|
5485
|
+
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
5486
|
const config = loadConfig();
|
|
4031
5487
|
const parentOpts = program.opts();
|
|
4032
5488
|
const deps = await checkRecordingDeps();
|
|
4033
5489
|
const enhKey = config.enhancement_api_key || config.openai_api_key;
|
|
5490
|
+
const transcriberModel = resolveTranscriberModel(config);
|
|
5491
|
+
const transcriptionModel = config.transcription_model;
|
|
5492
|
+
const macStatus = process.platform === "darwin" ? getMacOSAppStatus() : null;
|
|
5493
|
+
const trigger = probeTriggerDiagnostics({
|
|
5494
|
+
accessibilityPermission: macStatus?.accessibility_permission ?? null,
|
|
5495
|
+
appLogPath: macStatus?.log_path ?? null
|
|
5496
|
+
});
|
|
5497
|
+
const activeStore = describeActiveStore(config);
|
|
5498
|
+
const localStoreWasLegacy = localStoreIsBehindSchema(config.db_path);
|
|
5499
|
+
let capture = null;
|
|
5500
|
+
let credential = null;
|
|
5501
|
+
let enhancementCredential = null;
|
|
5502
|
+
let persistence = null;
|
|
5503
|
+
if (opts.probe) {
|
|
5504
|
+
const parsedSeconds = Number.parseInt(String(opts.probeSeconds), 10);
|
|
5505
|
+
const seconds = Number.isFinite(parsedSeconds) && parsedSeconds > 0 ? Math.min(parsedSeconds, MAX_PROBE_SECONDS) : DEFAULT_PROBE_SECONDS;
|
|
5506
|
+
capture = deps.available ? probeMicrophoneCapture(config, { seconds }) : {
|
|
5507
|
+
ok: false,
|
|
5508
|
+
tool: null,
|
|
5509
|
+
seconds,
|
|
5510
|
+
samples: 0,
|
|
5511
|
+
peak: 0,
|
|
5512
|
+
silent: null,
|
|
5513
|
+
message: deps.message
|
|
5514
|
+
};
|
|
5515
|
+
credential = await verifyTranscriptionCredential(config, transcriptionModel, {
|
|
5516
|
+
apiKey: config.openai_api_key,
|
|
5517
|
+
role: "transcription"
|
|
5518
|
+
});
|
|
5519
|
+
enhancementCredential = enhKey ? await verifyTranscriptionCredential(config, transcriberModel, {
|
|
5520
|
+
apiKey: enhKey,
|
|
5521
|
+
role: "enhancement"
|
|
5522
|
+
}) : null;
|
|
5523
|
+
persistence = await probeRecordingPersistence({
|
|
5524
|
+
allowRemoteWrite: Boolean(opts.probeStoreWrite),
|
|
5525
|
+
allowLocalMigration: Boolean(opts.probeStoreWrite),
|
|
5526
|
+
localStoreExistedBefore: activeStore.local_db_present,
|
|
5527
|
+
localStoreIsLegacy: localStoreWasLegacy
|
|
5528
|
+
});
|
|
5529
|
+
}
|
|
5530
|
+
const probeFailed = Boolean(opts.probe && (capture && !capture.ok || credential && !credential.ok || enhancementCredential && !enhancementCredential.ok || persistence && persistence.outcome === "failed"));
|
|
5531
|
+
const triggerFailed = trigger !== null && !trigger.can_fire;
|
|
4034
5532
|
if (parentOpts.json) {
|
|
4035
5533
|
console.log(JSON.stringify({
|
|
4036
5534
|
recording: {
|
|
@@ -4041,14 +5539,30 @@ program.command("check").description("Check system dependencies (sox, API keys)"
|
|
|
4041
5539
|
openai_api_key_configured: Boolean(config.openai_api_key),
|
|
4042
5540
|
enhancement_api_key_configured: Boolean(enhKey),
|
|
4043
5541
|
enhancement_model: config.enhancement_model,
|
|
4044
|
-
transcriber_model:
|
|
5542
|
+
transcriber_model: transcriberModel,
|
|
4045
5543
|
realtime_session_model: config.realtime_session_model,
|
|
4046
5544
|
realtime_transcription_model: config.realtime_transcription_model,
|
|
4047
5545
|
post_processing_mode: config.post_processing_mode,
|
|
4048
5546
|
transcription_prompt_configured: Boolean(config.transcription_prompt?.trim()),
|
|
4049
5547
|
transcriber_prompt_configured: Boolean(config.transcriber_prompt?.trim()),
|
|
4050
|
-
config_warnings: config.config_warnings ?? []
|
|
5548
|
+
config_warnings: config.config_warnings ?? [],
|
|
5549
|
+
microphone_permission: macStatus?.microphone_permission ?? "unsupported",
|
|
5550
|
+
accessibility_permission: macStatus?.accessibility_permission ?? "unsupported",
|
|
5551
|
+
active_store: activeStore,
|
|
5552
|
+
capture_probe: capture,
|
|
5553
|
+
capture_probe_subject: captureProbeSubject(),
|
|
5554
|
+
microphone_grant_instruction: macStatus ? microphoneGrantInstruction({
|
|
5555
|
+
installedAppPath: macStatus.installed_app_path,
|
|
5556
|
+
otherAppPaths: macStatus.legacy_install_paths,
|
|
5557
|
+
requestState: classifyPermissionState(macStatus.microphone_permission)
|
|
5558
|
+
}) : null,
|
|
5559
|
+
credential_probe: credential,
|
|
5560
|
+
enhancement_credential_probe: enhancementCredential,
|
|
5561
|
+
persistence_probe: persistence,
|
|
5562
|
+
trigger
|
|
4051
5563
|
}, null, 2));
|
|
5564
|
+
if (probeFailed || triggerFailed)
|
|
5565
|
+
process.exitCode = 1;
|
|
4052
5566
|
return;
|
|
4053
5567
|
}
|
|
4054
5568
|
if (deps.available) {
|
|
@@ -4057,16 +5571,84 @@ program.command("check").description("Check system dependencies (sox, API keys)"
|
|
|
4057
5571
|
console.log(chalk.red(`\u2717 ${deps.message}`));
|
|
4058
5572
|
}
|
|
4059
5573
|
if (config.openai_api_key) {
|
|
4060
|
-
console.log(chalk.green(`\u2713 OpenAI API key
|
|
5574
|
+
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
5575
|
} else {
|
|
4062
5576
|
console.log(chalk.red(`\u2717 OpenAI API key not found. Set OPENAI_API_KEY env var or add to ~/.secrets`));
|
|
4063
5577
|
}
|
|
4064
5578
|
if (enhKey) {
|
|
4065
|
-
console.log(chalk.green(`\u2713 Enhancement API key
|
|
5579
|
+
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
5580
|
} else {
|
|
4067
5581
|
console.log(chalk.yellow(`\u26A0 Enhancement API key not configured \u2014 enhancement disabled`));
|
|
4068
5582
|
}
|
|
5583
|
+
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})`));
|
|
5584
|
+
if (activeStore.divergent) {
|
|
5585
|
+
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.`);
|
|
5586
|
+
}
|
|
5587
|
+
if (activeStore.warning) {
|
|
5588
|
+
console.log(chalk.dim(` ${activeStore.warning}`));
|
|
5589
|
+
}
|
|
5590
|
+
if (macStatus) {
|
|
5591
|
+
const micState = macStatus.microphone_permission;
|
|
5592
|
+
const micOk = micState.startsWith("allowed");
|
|
5593
|
+
const micVerified = micState === "allowed";
|
|
5594
|
+
const micStaleGrant = micState === "stale_allowed_for_previous_app_build";
|
|
5595
|
+
const micUnreadable = micState === TCC_UNREADABLE_STATE;
|
|
5596
|
+
console.log((micOk ? chalk.green("\u2713") : micUnreadable ? chalk.yellow("?") : chalk.red("\u2717")) + ` Microphone permission: ${micState}`);
|
|
5597
|
+
if (micOk) {
|
|
5598
|
+
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."));
|
|
5599
|
+
}
|
|
5600
|
+
if (micStaleGrant) {
|
|
5601
|
+
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."));
|
|
5602
|
+
}
|
|
5603
|
+
if (micUnreadable) {
|
|
5604
|
+
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."));
|
|
5605
|
+
}
|
|
5606
|
+
if (!micOk && !micUnreadable) {
|
|
5607
|
+
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:"));
|
|
5608
|
+
const instruction = microphoneGrantInstruction({
|
|
5609
|
+
installedAppPath: macStatus.installed_app_path,
|
|
5610
|
+
otherAppPaths: macStatus.legacy_install_paths,
|
|
5611
|
+
requestState: classifyPermissionState(macStatus.microphone_permission)
|
|
5612
|
+
});
|
|
5613
|
+
for (const [index, step] of instruction.steps.entries()) {
|
|
5614
|
+
console.log(chalk.dim(` ${index + 1}. ${step}`));
|
|
5615
|
+
}
|
|
5616
|
+
}
|
|
5617
|
+
console.log((macStatus.accessibility_permission.startsWith("allowed") ? chalk.green("\u2713") : chalk.yellow("\u26A0")) + ` Accessibility permission: ${macStatus.accessibility_permission}`);
|
|
5618
|
+
}
|
|
5619
|
+
if (trigger)
|
|
5620
|
+
reportTriggerDiagnosis(trigger);
|
|
5621
|
+
if (capture) {
|
|
5622
|
+
console.log((capture.ok ? chalk.green("\u2713") : chalk.red("\u2717")) + ` Microphone capture probe: ${capture.message}`);
|
|
5623
|
+
const subject = captureProbeSubject();
|
|
5624
|
+
console.log(chalk.dim(` subject: ${subject.note}`) + (subject.subject_known ? "" : chalk.yellow(" [subject unidentified \u2014 inconclusive]")));
|
|
5625
|
+
}
|
|
5626
|
+
if (credential) {
|
|
5627
|
+
console.log((credential.ok ? chalk.green("\u2713") : chalk.red("\u2717")) + ` Transcription credential (${credential.model}): ${credential.message}`);
|
|
5628
|
+
}
|
|
5629
|
+
if (enhancementCredential) {
|
|
5630
|
+
console.log((enhancementCredential.ok ? chalk.green("\u2713") : chalk.red("\u2717")) + ` Enhancement credential (${enhancementCredential.model}): ${enhancementCredential.message}`);
|
|
5631
|
+
}
|
|
5632
|
+
if (persistence) {
|
|
5633
|
+
const persistenceMarker = renderPersistenceMarker(persistence.outcome, {
|
|
5634
|
+
pass: chalk.green,
|
|
5635
|
+
warn: chalk.yellow,
|
|
5636
|
+
fail: chalk.red
|
|
5637
|
+
});
|
|
5638
|
+
console.log(persistenceMarker + ` Persistence round-trip: ${persistence.message}`);
|
|
5639
|
+
}
|
|
5640
|
+
if (probeFailed || triggerFailed)
|
|
5641
|
+
process.exitCode = 1;
|
|
4069
5642
|
});
|
|
5643
|
+
function reportTriggerDiagnosis(trigger) {
|
|
5644
|
+
console.log((trigger.can_fire ? chalk.green("\u2713") : chalk.red("\u2717")) + ` Recording trigger: ${trigger.summary}`);
|
|
5645
|
+
for (const failure of trigger.failures)
|
|
5646
|
+
console.log(chalk.red(` ${failure}`));
|
|
5647
|
+
for (const warning of trigger.warnings)
|
|
5648
|
+
console.log(chalk.yellow(` \u26A0 ${warning}`));
|
|
5649
|
+
for (const note of trigger.notes)
|
|
5650
|
+
console.log(chalk.dim(` ${note}`));
|
|
5651
|
+
}
|
|
4070
5652
|
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
5653
|
const config = loadConfig();
|
|
4072
5654
|
ensureDataDir(config);
|
|
@@ -4181,19 +5763,212 @@ Bye.`));
|
|
|
4181
5763
|
}
|
|
4182
5764
|
});
|
|
4183
5765
|
});
|
|
4184
|
-
program.command("shortcut").description("
|
|
4185
|
-
const { writeFileSync:
|
|
5766
|
+
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) => {
|
|
5767
|
+
const { writeFileSync: writeFileSync4, mkdirSync: mkdirSync5, chmodSync: chmodSync4, existsSync: existsSync9 } = __require("fs");
|
|
4186
5768
|
const { join: pathJoin2 } = __require("path");
|
|
4187
5769
|
const { homedir: getHome } = __require("os");
|
|
5770
|
+
const { spawnSync: runSync } = __require("child_process");
|
|
4188
5771
|
const home = getHome();
|
|
4189
5772
|
const scriptDir = pathJoin2(home, ".hasna", "recordings");
|
|
4190
|
-
mkdirSync4(scriptDir, { recursive: true });
|
|
4191
5773
|
const scriptPath = pathJoin2(scriptDir, "record-toggle.sh");
|
|
4192
5774
|
const pidFile = pathJoin2(scriptDir, ".recording.pid");
|
|
4193
5775
|
const recordingsBin = pathJoin2(home, ".bun", "bin", "recordings");
|
|
4194
|
-
const
|
|
5776
|
+
const audioDir = pathJoin2(scriptDir, "audio");
|
|
5777
|
+
if (opts.keys) {
|
|
5778
|
+
console.log(chalk.bold(`Keys accepted by --set (combine with cmd/ctrl/opt/shift):
|
|
5779
|
+
`));
|
|
5780
|
+
console.log(listBindableKeys().join(" "));
|
|
5781
|
+
console.log(chalk.dim(`
|
|
5782
|
+
fn/Globe is not in this list \u2014 it is a separate trigger, not a chord:
|
|
5783
|
+
`) + " recordings shortcut --fn on");
|
|
5784
|
+
return;
|
|
5785
|
+
}
|
|
5786
|
+
const appDefaultsReachable = TRIGGER_DEFAULTS_EXECUTABLE !== null;
|
|
5787
|
+
function requireMacOS(what) {
|
|
5788
|
+
if (appDefaultsReachable)
|
|
5789
|
+
return true;
|
|
5790
|
+
console.error(chalk.red(`${what} is only available on macOS`));
|
|
5791
|
+
process.exitCode = 1;
|
|
5792
|
+
return false;
|
|
5793
|
+
}
|
|
5794
|
+
let runningBundlesCache = null;
|
|
5795
|
+
function runningBundles() {
|
|
5796
|
+
runningBundlesCache ??= runningAppBundlePaths();
|
|
5797
|
+
return runningBundlesCache;
|
|
5798
|
+
}
|
|
5799
|
+
function grantTargetPaths() {
|
|
5800
|
+
const running = runningBundles();
|
|
5801
|
+
if (running.length > 0)
|
|
5802
|
+
return { paths: running, running: true };
|
|
5803
|
+
const status = getMacOSAppStatus();
|
|
5804
|
+
return {
|
|
5805
|
+
paths: [...status.installed ? [status.installed_app_path] : [], ...status.legacy_install_paths],
|
|
5806
|
+
running: false
|
|
5807
|
+
};
|
|
5808
|
+
}
|
|
5809
|
+
function showState() {
|
|
5810
|
+
const state = readTriggerState();
|
|
5811
|
+
console.log(chalk.bold(`Recording trigger
|
|
5812
|
+
`));
|
|
5813
|
+
if (state.shortcut) {
|
|
5814
|
+
console.log(` Hotkey ${chalk.cyan(formatShortcut(state.shortcut))}`);
|
|
5815
|
+
} else if (state.rawShortcut) {
|
|
5816
|
+
console.log(` Hotkey ${chalk.yellow(`unreadable (${state.rawShortcut})`)}`);
|
|
5817
|
+
} else {
|
|
5818
|
+
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")}`);
|
|
5819
|
+
}
|
|
5820
|
+
console.log(` fn/Globe key ${state.useFnKey ? chalk.cyan("on") : chalk.dim("off")}`);
|
|
5821
|
+
console.log();
|
|
5822
|
+
function showGrantTargets() {
|
|
5823
|
+
const running = runningBundles();
|
|
5824
|
+
const status = getMacOSAppStatus();
|
|
5825
|
+
const installed = [
|
|
5826
|
+
...status.installed ? [status.installed_app_path] : [],
|
|
5827
|
+
...status.legacy_install_paths
|
|
5828
|
+
];
|
|
5829
|
+
console.log(chalk.bold(`Which bundle the grants apply to
|
|
5830
|
+
`));
|
|
5831
|
+
if (running.length === 1) {
|
|
5832
|
+
const [runningBundle] = running;
|
|
5833
|
+
if (runningBundle === undefined)
|
|
5834
|
+
throw new Error("Running app bundle path is missing");
|
|
5835
|
+
console.log(` Running ${chalk.cyan(runningBundle)}`);
|
|
5836
|
+
} else if (running.length > 1) {
|
|
5837
|
+
console.log(` Running ${chalk.red(`${running.length} instances \u2014 grants are ambiguous`)}`);
|
|
5838
|
+
for (const path of running)
|
|
5839
|
+
console.log(` ${path}`);
|
|
5840
|
+
} else {
|
|
5841
|
+
console.log(` Running ${chalk.yellow("not running")}`);
|
|
5842
|
+
}
|
|
5843
|
+
if (installed.length > 0) {
|
|
5844
|
+
console.log(` Installed ${installed.join(`
|
|
5845
|
+
`)}`);
|
|
5846
|
+
}
|
|
5847
|
+
if (installed.length > 1) {
|
|
5848
|
+
console.log(chalk.yellow(" More than one installed copy \u2014 a grant given to one does not cover the others."));
|
|
5849
|
+
}
|
|
5850
|
+
console.log(chalk.dim(`
|
|
5851
|
+
This command reports the app's stored trigger, not its permission state:
|
|
5852
|
+
` + ` a CLI inherits the terminal's grants, so it cannot measure the app's.
|
|
5853
|
+
` + " Use 'recordings app permissions' for the bundle's actual grants."));
|
|
5854
|
+
console.log();
|
|
5855
|
+
}
|
|
5856
|
+
showGrantTargets();
|
|
5857
|
+
console.log(chalk.bold(`Permissions each trigger needs
|
|
5858
|
+
`));
|
|
5859
|
+
for (const requirement of TRIGGER_GRANT_REQUIREMENTS) {
|
|
5860
|
+
const grant = requirement.tccService ? chalk.yellow(requirement.tccService.replace(/^kTCCService/, "")) : chalk.green("none");
|
|
5861
|
+
console.log(` ${requirement.label.padEnd(15)} ${grant} \u2014 ${requirement.mechanism}`);
|
|
5862
|
+
if (requirement.settingsPath) {
|
|
5863
|
+
console.log(` ${" ".repeat(15)} ${requirement.settingsPath} > enable Recordings`);
|
|
5864
|
+
}
|
|
5865
|
+
}
|
|
5866
|
+
console.log();
|
|
5867
|
+
console.log(chalk.bold(`Change it
|
|
5868
|
+
`));
|
|
5869
|
+
console.log(` recordings shortcut --set f13`);
|
|
5870
|
+
console.log(` recordings shortcut --set "ctrl+opt+r"`);
|
|
5871
|
+
console.log(` recordings shortcut --fn on`);
|
|
5872
|
+
console.log(` recordings shortcut --reset`);
|
|
5873
|
+
console.log(chalk.dim(`
|
|
5874
|
+
Or in the app: Settings > Recording Shortcut.`));
|
|
5875
|
+
console.log(chalk.dim(" A changed hotkey is picked up the next time the app launches."));
|
|
5876
|
+
}
|
|
5877
|
+
function reportPickup() {
|
|
5878
|
+
const pickup = describeTriggerPickup(runningBundles());
|
|
5879
|
+
if (pickup.armed) {
|
|
5880
|
+
console.log(chalk.dim(" Recordings.app is not running; it will register this on next launch."));
|
|
5881
|
+
return;
|
|
5882
|
+
}
|
|
5883
|
+
console.log(chalk.yellow(" Recordings.app is running and still holds the previous trigger.") + `
|
|
5884
|
+
Quit and reopen it to arm this one:`);
|
|
5885
|
+
for (const path of pickup.runningBundlePaths)
|
|
5886
|
+
console.log(chalk.dim(` ${path}`));
|
|
5887
|
+
console.log(chalk.dim(" Exiting non-zero: the value was written, but the trigger that fires right now is " + "still the old one."));
|
|
5888
|
+
process.exitCode = 1;
|
|
5889
|
+
}
|
|
5890
|
+
if (opts.set !== undefined) {
|
|
5891
|
+
if (!requireMacOS("Setting the app hotkey"))
|
|
5892
|
+
return;
|
|
5893
|
+
let shortcut;
|
|
5894
|
+
try {
|
|
5895
|
+
shortcut = parseShortcutChord(String(opts.set));
|
|
5896
|
+
} catch (error) {
|
|
5897
|
+
if (error instanceof ShortcutParseError) {
|
|
5898
|
+
console.error(chalk.red(error.message));
|
|
5899
|
+
process.exitCode = 1;
|
|
5900
|
+
return;
|
|
5901
|
+
}
|
|
5902
|
+
throw error;
|
|
5903
|
+
}
|
|
5904
|
+
writeShortcut(shortcut);
|
|
5905
|
+
console.log(chalk.green(`Hotkey set to ${formatShortcut(shortcut)}`));
|
|
5906
|
+
console.log(chalk.dim(` ${RECORDINGS_BUNDLE_IDENTIFIER} ${TOGGLE_RECORDING_DEFAULTS_KEY}`));
|
|
5907
|
+
reportPickup();
|
|
5908
|
+
return;
|
|
5909
|
+
}
|
|
5910
|
+
if (opts.reset) {
|
|
5911
|
+
if (!requireMacOS("Resetting the app hotkey"))
|
|
5912
|
+
return;
|
|
5913
|
+
const shortcut = parseShortcutChord(DEFAULT_TOGGLE_RECORDING_CHORD);
|
|
5914
|
+
writeShortcut(shortcut);
|
|
5915
|
+
console.log(chalk.green(`Hotkey reset to ${formatShortcut(shortcut)}`));
|
|
5916
|
+
console.log(chalk.dim(` ${RECORDINGS_BUNDLE_IDENTIFIER} ${TOGGLE_RECORDING_DEFAULTS_KEY}`));
|
|
5917
|
+
reportPickup();
|
|
5918
|
+
return;
|
|
5919
|
+
}
|
|
5920
|
+
if (opts.fn !== undefined) {
|
|
5921
|
+
if (!requireMacOS("Changing the fn/Globe trigger"))
|
|
5922
|
+
return;
|
|
5923
|
+
const raw = String(opts.fn).toLowerCase();
|
|
5924
|
+
const enable = raw === "on" || raw === "true" || raw === "yes" || raw === "1";
|
|
5925
|
+
const disable = raw === "off" || raw === "false" || raw === "no" || raw === "0";
|
|
5926
|
+
if (!enable && !disable) {
|
|
5927
|
+
console.error(chalk.red(`--fn expects on or off, got "${opts.fn}"`));
|
|
5928
|
+
process.exitCode = 1;
|
|
5929
|
+
return;
|
|
5930
|
+
}
|
|
5931
|
+
writeUseFnKey(enable);
|
|
5932
|
+
console.log(chalk.green(`fn/Globe trigger ${enable ? "enabled" : "disabled"}`));
|
|
5933
|
+
console.log(chalk.dim(` ${RECORDINGS_BUNDLE_IDENTIFIER} ${USE_FN_KEY_DEFAULTS_KEY}`));
|
|
5934
|
+
if (enable) {
|
|
5935
|
+
const fnGrant = TRIGGER_GRANT_REQUIREMENTS.find((entry) => entry.id === "fn");
|
|
5936
|
+
if (fnGrant?.settingsPath) {
|
|
5937
|
+
console.log(`
|
|
5938
|
+
fn is a ${fnGrant.mechanism}, so it needs ` + `${fnGrant.tccService?.replace(/^kTCCService/, "") ?? "no"} permission:
|
|
5939
|
+
` + ` ${fnGrant.settingsPath} > enable Recordings`);
|
|
5940
|
+
const target = grantTargetPaths();
|
|
5941
|
+
if (target.paths.length === 0) {
|
|
5942
|
+
console.log(chalk.dim(" (no installed Recordings.app found to grant it to)"));
|
|
5943
|
+
}
|
|
5944
|
+
for (const path of target.paths) {
|
|
5945
|
+
console.log(chalk.dim(` grant it to: ${path}${target.running ? "" : " (not running \u2014 installed copy)"}`));
|
|
5946
|
+
}
|
|
5947
|
+
}
|
|
5948
|
+
console.log(chalk.dim(`
|
|
5949
|
+
While enabled the app swallows fn, so fn stops reaching other apps
|
|
5950
|
+
` + " (emoji picker, input-source switching). Turn it off with --fn off."));
|
|
5951
|
+
}
|
|
5952
|
+
console.log();
|
|
5953
|
+
reportPickup();
|
|
5954
|
+
return;
|
|
5955
|
+
}
|
|
5956
|
+
const wantsExternalLauncher = Boolean(opts.script) || Boolean(opts.raycast) || Boolean(opts.karabiner) || Boolean(opts.skhd) || Boolean(opts.hammerspoon);
|
|
5957
|
+
if (!wantsExternalLauncher) {
|
|
5958
|
+
if (!requireMacOS("Reading the app hotkey"))
|
|
5959
|
+
return;
|
|
5960
|
+
showState();
|
|
5961
|
+
return;
|
|
5962
|
+
}
|
|
5963
|
+
function writeToggleScript() {
|
|
5964
|
+
mkdirSync5(scriptDir, { recursive: true });
|
|
5965
|
+
const script = `#!/bin/bash
|
|
4195
5966
|
# Toggle recording on/off. Run this from a global hotkey.
|
|
4196
5967
|
# Each press toggles: start recording -> stop + transcribe + copy to clipboard
|
|
5968
|
+
#
|
|
5969
|
+
# This is the app-less path. If Recordings.app is running, prefer its own hotkey
|
|
5970
|
+
# ("recordings shortcut --set ..."): the app streams transcription and pastes into
|
|
5971
|
+
# the focused field, which this script cannot do.
|
|
4197
5972
|
set -e
|
|
4198
5973
|
|
|
4199
5974
|
PID_FILE="${pidFile}"
|
|
@@ -4206,7 +5981,7 @@ if [ -f "$PID_FILE" ]; then
|
|
|
4206
5981
|
rm -f "$PID_FILE"
|
|
4207
5982
|
|
|
4208
5983
|
# Find the most recent audio file
|
|
4209
|
-
AUDIO_DIR="${
|
|
5984
|
+
AUDIO_DIR="${audioDir}"
|
|
4210
5985
|
LATEST=$(ls -t "$AUDIO_DIR"/*.wav 2>/dev/null | head -1)
|
|
4211
5986
|
|
|
4212
5987
|
if [ -n "$LATEST" ]; then
|
|
@@ -4218,8 +5993,6 @@ if [ -f "$PID_FILE" ]; then
|
|
|
4218
5993
|
fi
|
|
4219
5994
|
if [ -n "$TEXT" ]; then
|
|
4220
5995
|
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
5996
|
fi
|
|
4224
5997
|
fi
|
|
4225
5998
|
|
|
@@ -4227,19 +6000,39 @@ if [ -f "$PID_FILE" ]; then
|
|
|
4227
6000
|
osascript -e 'display notification "Recording saved and copied to clipboard" with title "Recordings"' 2>/dev/null || true
|
|
4228
6001
|
else
|
|
4229
6002
|
# Start recording in background
|
|
4230
|
-
mkdir -p "${
|
|
4231
|
-
rec -r 16000 -c 1 -b 16 "${
|
|
6003
|
+
mkdir -p "${audioDir}"
|
|
6004
|
+
rec -r 16000 -c 1 -b 16 "${audioDir}/recording-$(date +%Y%m%dT%H%M%S).wav" trim 0 300 &
|
|
4232
6005
|
echo $! > "$PID_FILE"
|
|
4233
6006
|
|
|
4234
6007
|
# Notification
|
|
4235
6008
|
osascript -e 'display notification "Recording started..." with title "Recordings"' 2>/dev/null || true
|
|
4236
6009
|
fi
|
|
4237
6010
|
`;
|
|
4238
|
-
|
|
4239
|
-
|
|
6011
|
+
writeFileSync4(scriptPath, script, "utf-8");
|
|
6012
|
+
chmodSync4(scriptPath, 493);
|
|
6013
|
+
}
|
|
6014
|
+
function launcherMissing(label, present, install) {
|
|
6015
|
+
if (present)
|
|
6016
|
+
return false;
|
|
6017
|
+
console.error(chalk.red(`${label} is not installed \u2014 nothing would fire this hotkey.`));
|
|
6018
|
+
console.error(chalk.dim(` Install it with: ${install}`));
|
|
6019
|
+
console.error(chalk.dim(` Or use the app's own hotkey, which needs no extra software:
|
|
6020
|
+
` + " recordings shortcut --set f13"));
|
|
6021
|
+
process.exitCode = 1;
|
|
6022
|
+
return true;
|
|
6023
|
+
}
|
|
4240
6024
|
if (opts.karabiner) {
|
|
6025
|
+
if (!requireMacOS("Karabiner-Elements setup"))
|
|
6026
|
+
return;
|
|
6027
|
+
console.log(chalk.yellow("The app can use fn/Globe natively \u2014 no driver, no reboot:") + `
|
|
6028
|
+
recordings shortcut --fn on
|
|
6029
|
+
`);
|
|
6030
|
+
if (launcherMissing("Karabiner-Elements", existsSync9("/Applications/Karabiner-Elements.app"), "brew install --cask karabiner-elements")) {
|
|
6031
|
+
return;
|
|
6032
|
+
}
|
|
4241
6033
|
const karabinerDir = pathJoin2(home, ".config", "karabiner", "assets", "complex_modifications");
|
|
4242
|
-
|
|
6034
|
+
mkdirSync5(karabinerDir, { recursive: true });
|
|
6035
|
+
writeToggleScript();
|
|
4243
6036
|
const rule = {
|
|
4244
6037
|
title: "Recordings \u2014 Fn key to toggle recording",
|
|
4245
6038
|
rules: [
|
|
@@ -4263,23 +6056,27 @@ fi
|
|
|
4263
6056
|
]
|
|
4264
6057
|
};
|
|
4265
6058
|
const karabinerPath = pathJoin2(karabinerDir, "recordings-fn.json");
|
|
4266
|
-
|
|
6059
|
+
writeFileSync4(karabinerPath, JSON.stringify(rule, null, 2) + `
|
|
4267
6060
|
`, "utf-8");
|
|
4268
|
-
console.log(chalk.green("Karabiner-Elements rule
|
|
6061
|
+
console.log(chalk.green("Karabiner-Elements rule written:"));
|
|
4269
6062
|
console.log(chalk.dim(` ${karabinerPath}
|
|
4270
6063
|
`));
|
|
4271
|
-
console.log("
|
|
6064
|
+
console.log("It does nothing until you enable it:");
|
|
4272
6065
|
console.log(" 1. Open Karabiner-Elements");
|
|
4273
6066
|
console.log(" 2. Go to Complex Modifications tab");
|
|
4274
6067
|
console.log(" 3. Click Add Predefined Rule");
|
|
4275
6068
|
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
6069
|
return;
|
|
4279
6070
|
}
|
|
4280
6071
|
if (opts.raycast) {
|
|
6072
|
+
if (!requireMacOS("Raycast setup"))
|
|
6073
|
+
return;
|
|
6074
|
+
if (launcherMissing("Raycast", existsSync9("/Applications/Raycast.app"), "brew install --cask raycast")) {
|
|
6075
|
+
return;
|
|
6076
|
+
}
|
|
6077
|
+
writeToggleScript();
|
|
4281
6078
|
const raycastDir = pathJoin2(home, ".config", "raycast", "script-commands");
|
|
4282
|
-
|
|
6079
|
+
mkdirSync5(raycastDir, { recursive: true });
|
|
4283
6080
|
const raycastScript = `#!/bin/bash
|
|
4284
6081
|
|
|
4285
6082
|
# Required parameters:
|
|
@@ -4294,15 +6091,20 @@ fi
|
|
|
4294
6091
|
${scriptPath}
|
|
4295
6092
|
`;
|
|
4296
6093
|
const raycastPath = pathJoin2(raycastDir, "toggle-recording.sh");
|
|
4297
|
-
|
|
4298
|
-
|
|
4299
|
-
console.log(chalk.green("Raycast script command
|
|
6094
|
+
writeFileSync4(raycastPath, raycastScript, "utf-8");
|
|
6095
|
+
chmodSync4(raycastPath, 493);
|
|
6096
|
+
console.log(chalk.green("Raycast script command written:"));
|
|
4300
6097
|
console.log(chalk.dim(` ${raycastPath}`));
|
|
4301
|
-
console.log(chalk.dim("
|
|
4302
|
-
console.log(chalk.dim(" Then assign a hotkey in Raycast preferences"));
|
|
6098
|
+
console.log(chalk.dim(" Raycast > Script Commands > reload, then assign a hotkey."));
|
|
4303
6099
|
return;
|
|
4304
6100
|
}
|
|
4305
6101
|
if (opts.skhd) {
|
|
6102
|
+
if (!requireMacOS("skhd setup"))
|
|
6103
|
+
return;
|
|
6104
|
+
const skhdPresent = runSync("/usr/bin/env", ["which", "skhd"], { encoding: "utf8" }).status === 0;
|
|
6105
|
+
if (launcherMissing("skhd", skhdPresent, "brew install koekeishiya/formulae/skhd"))
|
|
6106
|
+
return;
|
|
6107
|
+
writeToggleScript();
|
|
4306
6108
|
console.log(chalk.bold(`Add to ~/.skhdrc:
|
|
4307
6109
|
`));
|
|
4308
6110
|
console.log(chalk.cyan(` fn - space : ${scriptPath}`));
|
|
@@ -4311,6 +6113,12 @@ ${scriptPath}
|
|
|
4311
6113
|
return;
|
|
4312
6114
|
}
|
|
4313
6115
|
if (opts.hammerspoon) {
|
|
6116
|
+
if (!requireMacOS("Hammerspoon setup"))
|
|
6117
|
+
return;
|
|
6118
|
+
if (launcherMissing("Hammerspoon", existsSync9("/Applications/Hammerspoon.app"), "brew install --cask hammerspoon")) {
|
|
6119
|
+
return;
|
|
6120
|
+
}
|
|
6121
|
+
writeToggleScript();
|
|
4314
6122
|
console.log(chalk.bold(`Add to ~/.hammerspoon/init.lua:
|
|
4315
6123
|
`));
|
|
4316
6124
|
console.log(chalk.cyan(` hs.hotkey.bind({"ctrl"}, "space", function()
|
|
@@ -4320,35 +6128,8 @@ ${scriptPath}
|
|
|
4320
6128
|
Then reload Hammerspoon config`));
|
|
4321
6129
|
return;
|
|
4322
6130
|
}
|
|
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
|
-
`);
|
|
6131
|
+
writeToggleScript();
|
|
6132
|
+
console.log(scriptPath);
|
|
4352
6133
|
});
|
|
4353
6134
|
function buildTranscriptionMetadata(config, processed, sources = {}) {
|
|
4354
6135
|
const transcriptionPromptConfigured = Boolean(config.transcription_prompt?.trim());
|
|
@@ -4382,7 +6163,7 @@ async function readSaveTextInput(text, opts) {
|
|
|
4382
6163
|
}
|
|
4383
6164
|
let rawText;
|
|
4384
6165
|
if (opts.textFile !== undefined) {
|
|
4385
|
-
rawText =
|
|
6166
|
+
rawText = readFileSync4(opts.textFile, "utf8");
|
|
4386
6167
|
} else if (opts.stdin) {
|
|
4387
6168
|
rawText = await Bun.stdin.text();
|
|
4388
6169
|
} else {
|
|
@@ -4596,7 +6377,7 @@ function relativeHint(value) {
|
|
|
4596
6377
|
return `${Math.floor(hours / 24)}d ago`;
|
|
4597
6378
|
}
|
|
4598
6379
|
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:
|
|
6380
|
+
const { readFileSync: readFileSync5, writeFileSync: writeFileSync4, existsSync: fileExists } = __require("fs");
|
|
4600
6381
|
const { join: pathJoin2 } = __require("path");
|
|
4601
6382
|
const { homedir: getHome } = __require("os");
|
|
4602
6383
|
const { execSync } = __require("child_process");
|
|
@@ -4629,14 +6410,14 @@ program.command("mcp").description("Install recordings MCP server into Claude Co
|
|
|
4629
6410
|
if (target === "codex") {
|
|
4630
6411
|
const configPath = pathJoin2(home, ".codex", "config.toml");
|
|
4631
6412
|
if (fileExists(configPath)) {
|
|
4632
|
-
const content =
|
|
6413
|
+
const content = readFileSync5(configPath, "utf-8");
|
|
4633
6414
|
if (opts.uninstall) {
|
|
4634
6415
|
const { content: next, removed } = removeCodexServerBlock(content, "recordings");
|
|
4635
|
-
|
|
6416
|
+
writeFileSync4(configPath, next, "utf-8");
|
|
4636
6417
|
console.log(removed ? chalk.green(`Removed from Codex: ${configPath}`) : chalk.yellow(`Codex: no recordings MCP block found in ${configPath}`));
|
|
4637
6418
|
} else {
|
|
4638
6419
|
const next = upsertCodexStdioBlock(content, "recordings", mcpCmd);
|
|
4639
|
-
|
|
6420
|
+
writeFileSync4(configPath, next, "utf-8");
|
|
4640
6421
|
console.log(chalk.green(`Installed into Codex: ${configPath}`));
|
|
4641
6422
|
}
|
|
4642
6423
|
} else {
|
|
@@ -4647,7 +6428,7 @@ program.command("mcp").description("Install recordings MCP server into Claude Co
|
|
|
4647
6428
|
const configPath = pathJoin2(home, ".gemini", "settings.json");
|
|
4648
6429
|
let config = {};
|
|
4649
6430
|
if (fileExists(configPath)) {
|
|
4650
|
-
config = JSON.parse(
|
|
6431
|
+
config = JSON.parse(readFileSync5(configPath, "utf-8"));
|
|
4651
6432
|
}
|
|
4652
6433
|
const servers = config["mcpServers"] || {};
|
|
4653
6434
|
if (opts.uninstall) {
|
|
@@ -4656,7 +6437,7 @@ program.command("mcp").description("Install recordings MCP server into Claude Co
|
|
|
4656
6437
|
servers["recordings"] = { command: mcpCmd, args: ["--stdio"] };
|
|
4657
6438
|
}
|
|
4658
6439
|
config["mcpServers"] = servers;
|
|
4659
|
-
|
|
6440
|
+
writeFileSync4(configPath, JSON.stringify(config, null, 2) + `
|
|
4660
6441
|
`, "utf-8");
|
|
4661
6442
|
console.log(chalk.green(`${action} Gemini: ${configPath}`));
|
|
4662
6443
|
}
|
|
@@ -4686,58 +6467,105 @@ program.command("feedback <message>").description("Send feedback").option("--ema
|
|
|
4686
6467
|
function getMacOSAppStatus() {
|
|
4687
6468
|
const packageRoot = findPackageRoot();
|
|
4688
6469
|
const home = process.env.HOME || process.env.USERPROFILE || "";
|
|
4689
|
-
const
|
|
6470
|
+
const canonicalAppPath = pathJoin(home, "Applications", "Recordings.app");
|
|
6471
|
+
const legacyInstallPaths = findLegacyMacOSAppPaths(home, canonicalAppPath);
|
|
6472
|
+
const installedAppPath = resolveInstalledAppPath(home, canonicalAppPath, legacyInstallPaths);
|
|
6473
|
+
const installedAppPathForGrants = existsSync8(installedAppPath) ? installedAppPath : null;
|
|
4690
6474
|
const executablePath = pathJoin(installedAppPath, "Contents", "MacOS", "Recordings");
|
|
4691
6475
|
const logPath = pathJoin(home, ".hasna", "recordings", "Recordings.log");
|
|
4692
6476
|
const installerPath = getMacOSInstallerPath(packageRoot);
|
|
4693
6477
|
const nativeSourcesPath = pathJoin(packageRoot, "src", "native", "Recordings");
|
|
4694
6478
|
const signingInfo = getCodeSigningInfo(installedAppPath);
|
|
4695
|
-
const
|
|
4696
|
-
const
|
|
6479
|
+
const ambiguousInstallations = [canonicalAppPath, ...legacyInstallPaths].filter((path) => existsSync8(path)).length > 1;
|
|
6480
|
+
const microphoneGrant = getTccGrant("kTCCServiceMicrophone", home, installedAppPathForGrants);
|
|
6481
|
+
const accessibilityGrant = getTccGrant("kTCCServiceAccessibility", home, installedAppPathForGrants);
|
|
4697
6482
|
return {
|
|
4698
6483
|
platform: process.platform,
|
|
4699
6484
|
package_root: packageRoot,
|
|
4700
6485
|
installer_path: installerPath,
|
|
4701
|
-
installer_available:
|
|
6486
|
+
installer_available: existsSync8(installerPath),
|
|
4702
6487
|
native_sources_path: nativeSourcesPath,
|
|
4703
|
-
native_sources_available:
|
|
6488
|
+
native_sources_available: existsSync8(pathJoin(nativeSourcesPath, "Package.swift")),
|
|
4704
6489
|
installed_app_path: installedAppPath,
|
|
4705
6490
|
legacy_install_paths: legacyInstallPaths,
|
|
4706
|
-
installed:
|
|
6491
|
+
installed: existsSync8(installedAppPath),
|
|
4707
6492
|
executable_path: executablePath,
|
|
4708
|
-
executable:
|
|
6493
|
+
executable: existsSync8(executablePath),
|
|
4709
6494
|
app_code_hash: signingInfo.cdHash,
|
|
4710
6495
|
ad_hoc_signed: signingInfo.adHoc,
|
|
4711
6496
|
signing_identifier: signingInfo.identifier,
|
|
4712
6497
|
team_identifier: signingInfo.teamIdentifier,
|
|
4713
6498
|
designated_requirement: signingInfo.designatedRequirement,
|
|
4714
6499
|
signature_authorities: signingInfo.authorities,
|
|
4715
|
-
microphone_permission:
|
|
4716
|
-
accessibility_permission:
|
|
6500
|
+
microphone_permission: microphoneGrant.state,
|
|
6501
|
+
accessibility_permission: accessibilityGrant.state,
|
|
6502
|
+
ambiguous_installations: ambiguousInstallations,
|
|
6503
|
+
microphone_grant_durability: microphoneGrant.durability,
|
|
6504
|
+
accessibility_grant_durability: accessibilityGrant.durability,
|
|
6505
|
+
microphone_stored_requirement: microphoneGrant.storedRequirement,
|
|
6506
|
+
accessibility_stored_requirement: accessibilityGrant.storedRequirement,
|
|
4717
6507
|
log_path: logPath
|
|
4718
6508
|
};
|
|
4719
6509
|
}
|
|
6510
|
+
function buildPermissionWarnings(status) {
|
|
6511
|
+
const warnings = [];
|
|
6512
|
+
if (status.platform !== "darwin")
|
|
6513
|
+
return warnings;
|
|
6514
|
+
if (!status.installed) {
|
|
6515
|
+
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");
|
|
6516
|
+
}
|
|
6517
|
+
if (status.ambiguous_installations) {
|
|
6518
|
+
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(", ")}`);
|
|
6519
|
+
}
|
|
6520
|
+
for (const [service, durability] of [
|
|
6521
|
+
["Microphone", status.microphone_grant_durability],
|
|
6522
|
+
["Accessibility", status.accessibility_grant_durability]
|
|
6523
|
+
]) {
|
|
6524
|
+
if (durability === "dies_on_rebuild_cdhash_pinned") {
|
|
6525
|
+
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");
|
|
6526
|
+
}
|
|
6527
|
+
}
|
|
6528
|
+
for (const [service, state] of [
|
|
6529
|
+
["Microphone", status.microphone_permission],
|
|
6530
|
+
["Accessibility", status.accessibility_permission]
|
|
6531
|
+
]) {
|
|
6532
|
+
if (state === "undetermined_tcc_database_unreadable") {
|
|
6533
|
+
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");
|
|
6534
|
+
}
|
|
6535
|
+
}
|
|
6536
|
+
return warnings;
|
|
6537
|
+
}
|
|
6538
|
+
function resolveInstalledAppPath(home, canonicalPath, legacyPaths) {
|
|
6539
|
+
const preference = [
|
|
6540
|
+
canonicalPath,
|
|
6541
|
+
pathJoin("/", "Applications", "Recordings.app"),
|
|
6542
|
+
pathJoin(home, ".hasna", "recordings", "Recordings.app")
|
|
6543
|
+
];
|
|
6544
|
+
for (const candidate of preference) {
|
|
6545
|
+
if (existsSync8(candidate))
|
|
6546
|
+
return candidate;
|
|
6547
|
+
}
|
|
6548
|
+
return legacyPaths.find((candidate) => existsSync8(candidate)) ?? canonicalPath;
|
|
6549
|
+
}
|
|
4720
6550
|
function findLegacyMacOSAppPaths(home, canonicalPath) {
|
|
4721
6551
|
const candidates = [
|
|
4722
6552
|
pathJoin(home, ".hasna", "recordings", "Recordings.app"),
|
|
4723
6553
|
pathJoin("/", "Applications", "Recordings.app")
|
|
4724
6554
|
];
|
|
4725
6555
|
const userApplications = pathJoin(home, "Applications");
|
|
4726
|
-
if (
|
|
6556
|
+
if (existsSync8(userApplications)) {
|
|
4727
6557
|
for (const entry of readdirSync2(userApplications, { withFileTypes: true })) {
|
|
4728
6558
|
if (entry.isDirectory() && entry.name.startsWith("Recordings.app.")) {
|
|
4729
6559
|
candidates.push(pathJoin(userApplications, entry.name));
|
|
4730
6560
|
}
|
|
4731
6561
|
}
|
|
4732
6562
|
}
|
|
4733
|
-
return [...new Set(candidates)].filter((candidate) => candidate !== canonicalPath &&
|
|
6563
|
+
return [...new Set(candidates)].filter((candidate) => candidate !== canonicalPath && existsSync8(candidate)).sort();
|
|
4734
6564
|
}
|
|
4735
6565
|
function resetMacOSPermissions() {
|
|
4736
6566
|
const services = ["Microphone", "Accessibility"];
|
|
4737
6567
|
for (const service of services) {
|
|
4738
|
-
const result =
|
|
4739
|
-
stdio: "inherit"
|
|
4740
|
-
});
|
|
6568
|
+
const result = spawnSync7("/usr/bin/tccutil", ["reset", service, RECORDINGS_BUNDLE_IDENTIFIER], { stdio: "inherit" });
|
|
4741
6569
|
if (result.error) {
|
|
4742
6570
|
console.error(chalk.red(result.error.message));
|
|
4743
6571
|
process.exit(1);
|
|
@@ -4745,7 +6573,7 @@ function resetMacOSPermissions() {
|
|
|
4745
6573
|
}
|
|
4746
6574
|
}
|
|
4747
6575
|
function getCodeSigningInfo(appPath) {
|
|
4748
|
-
if (process.platform !== "darwin" || !
|
|
6576
|
+
if (process.platform !== "darwin" || !existsSync8(appPath)) {
|
|
4749
6577
|
return {
|
|
4750
6578
|
cdHash: null,
|
|
4751
6579
|
adHoc: false,
|
|
@@ -4755,7 +6583,7 @@ function getCodeSigningInfo(appPath) {
|
|
|
4755
6583
|
authorities: []
|
|
4756
6584
|
};
|
|
4757
6585
|
}
|
|
4758
|
-
const result =
|
|
6586
|
+
const result = spawnSync7("/usr/bin/codesign", ["-d", "-r-", "--verbose=4", appPath], {
|
|
4759
6587
|
encoding: "utf8",
|
|
4760
6588
|
stdio: ["ignore", "pipe", "pipe"]
|
|
4761
6589
|
});
|
|
@@ -4766,58 +6594,31 @@ ${result.stderr}`;
|
|
|
4766
6594
|
const identifier = output.match(/^Identifier=(.+)$/m)?.[1]?.trim() ?? null;
|
|
4767
6595
|
const teamIdentifier = output.match(/^TeamIdentifier=(.+)$/m)?.[1]?.trim() ?? null;
|
|
4768
6596
|
const designatedRequirement = output.match(/^designated => (.+)$/m)?.[1]?.trim() ?? null;
|
|
4769
|
-
const authorities = [...output.matchAll(/^Authority=(.+)$/gm)].
|
|
6597
|
+
const authorities = [...output.matchAll(/^Authority=(.+)$/gm)].flatMap((match) => {
|
|
6598
|
+
const authority = match[1];
|
|
6599
|
+
return authority === undefined ? [] : [authority.trim()];
|
|
6600
|
+
});
|
|
4770
6601
|
return { cdHash, adHoc, identifier, teamIdentifier, designatedRequirement, authorities };
|
|
4771
6602
|
}
|
|
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})`;
|
|
6603
|
+
function getTccGrant(service, home, appPath) {
|
|
6604
|
+
if (process.platform !== "darwin") {
|
|
6605
|
+
return { state: "unsupported", storedRequirement: null, durability: "unknown" };
|
|
4806
6606
|
}
|
|
6607
|
+
return resolveTccGrant({ service, home, appPath });
|
|
4807
6608
|
}
|
|
4808
6609
|
function findPackageRoot() {
|
|
4809
|
-
let current =
|
|
6610
|
+
let current = dirname5(fileURLToPath(import.meta.url));
|
|
4810
6611
|
while (true) {
|
|
4811
6612
|
const packagePath = pathJoin(current, "package.json");
|
|
4812
|
-
if (
|
|
6613
|
+
if (existsSync8(packagePath)) {
|
|
4813
6614
|
try {
|
|
4814
|
-
const pkg = JSON.parse(
|
|
6615
|
+
const pkg = JSON.parse(readFileSync4(packagePath, "utf8"));
|
|
4815
6616
|
if (pkg.name === "@hasna/recordings") {
|
|
4816
6617
|
return current;
|
|
4817
6618
|
}
|
|
4818
6619
|
} catch {}
|
|
4819
6620
|
}
|
|
4820
|
-
const parent =
|
|
6621
|
+
const parent = dirname5(current);
|
|
4821
6622
|
if (parent === current) {
|
|
4822
6623
|
return process.cwd();
|
|
4823
6624
|
}
|