@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/index.js
CHANGED
|
@@ -627,7 +627,8 @@ function findProjectRecordingsPath(entry) {
|
|
|
627
627
|
function findRepositoryRoot(start) {
|
|
628
628
|
let dir = start;
|
|
629
629
|
while (true) {
|
|
630
|
-
|
|
630
|
+
const gitPath = join2(dir, ".git");
|
|
631
|
+
if (isRepositoryMarker(gitPath))
|
|
631
632
|
return dir;
|
|
632
633
|
const parent = dirname2(dir);
|
|
633
634
|
if (parent === dir)
|
|
@@ -635,6 +636,15 @@ function findRepositoryRoot(start) {
|
|
|
635
636
|
dir = parent;
|
|
636
637
|
}
|
|
637
638
|
}
|
|
639
|
+
function isRepositoryMarker(gitPath) {
|
|
640
|
+
try {
|
|
641
|
+
if (statSync(gitPath).isFile())
|
|
642
|
+
return true;
|
|
643
|
+
return existsSync(join2(gitPath, "HEAD"));
|
|
644
|
+
} catch {
|
|
645
|
+
return false;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
638
648
|
function canonicalExistingPath(path) {
|
|
639
649
|
try {
|
|
640
650
|
return realpathSync2(path);
|
|
@@ -663,14 +673,17 @@ function loadSecretKey(keyName) {
|
|
|
663
673
|
try {
|
|
664
674
|
const content = readFileSync(candidate, "utf-8");
|
|
665
675
|
const match = content.match(new RegExp(`export\\s+${keyName}\\s*=\\s*"([^"]+)"`));
|
|
666
|
-
|
|
667
|
-
|
|
676
|
+
const doubleQuotedValue = match?.[1];
|
|
677
|
+
if (doubleQuotedValue !== undefined)
|
|
678
|
+
return doubleQuotedValue;
|
|
668
679
|
const match2 = content.match(new RegExp(`export\\s+${keyName}\\s*=\\s*'([^']+)'`));
|
|
669
|
-
|
|
670
|
-
|
|
680
|
+
const singleQuotedValue = match2?.[1];
|
|
681
|
+
if (singleQuotedValue !== undefined)
|
|
682
|
+
return singleQuotedValue;
|
|
671
683
|
const match3 = content.match(new RegExp(`${keyName}\\s*=\\s*(.+)`));
|
|
672
|
-
|
|
673
|
-
|
|
684
|
+
const unquotedValue = match3?.[1];
|
|
685
|
+
if (unquotedValue !== undefined)
|
|
686
|
+
return unquotedValue.trim().replace(/^["']|["']$/g, "");
|
|
674
687
|
} catch {}
|
|
675
688
|
}
|
|
676
689
|
return "";
|
|
@@ -795,6 +808,7 @@ var MIGRATIONS = [
|
|
|
795
808
|
ALTER TABLE agents ADD COLUMN active_project_id TEXT REFERENCES projects(id) ON DELETE SET NULL;
|
|
796
809
|
`
|
|
797
810
|
];
|
|
811
|
+
var CURRENT_MIGRATION_LEVEL = MIGRATIONS.length - 1;
|
|
798
812
|
function getDatabase(dbPath) {
|
|
799
813
|
if (_db)
|
|
800
814
|
return _db;
|
|
@@ -818,7 +832,11 @@ function runMigrations(db) {
|
|
|
818
832
|
const currentLevel = result?.max_id ?? -1;
|
|
819
833
|
for (let i = currentLevel + 1;i < MIGRATIONS.length; i++) {
|
|
820
834
|
try {
|
|
821
|
-
|
|
835
|
+
const migration = MIGRATIONS[i];
|
|
836
|
+
if (migration === undefined) {
|
|
837
|
+
throw new Error(`Missing SQLite migration at version ${i}`);
|
|
838
|
+
}
|
|
839
|
+
db.run(migration);
|
|
822
840
|
db.query("INSERT INTO _migrations (id) VALUES (?)").run(i);
|
|
823
841
|
} catch (e) {
|
|
824
842
|
if (isBenignMigrationError(e)) {
|
|
@@ -1168,7 +1186,7 @@ function setAgentFocus(idOrName, projectId, db) {
|
|
|
1168
1186
|
}
|
|
1169
1187
|
|
|
1170
1188
|
// src/version.ts
|
|
1171
|
-
var VERSION = "0.
|
|
1189
|
+
var VERSION = "0.3.1";
|
|
1172
1190
|
|
|
1173
1191
|
// src/db/feedback.ts
|
|
1174
1192
|
function saveFeedback(input) {
|
|
@@ -1176,29 +1194,54 @@ function saveFeedback(input) {
|
|
|
1176
1194
|
db.query("INSERT INTO feedback (message, email, category, version) VALUES (?, ?, ?, ?)").run(input.message, input.email ?? null, input.category ?? "general", input.version ?? VERSION);
|
|
1177
1195
|
}
|
|
1178
1196
|
|
|
1197
|
+
// src/lib/retired-deployment-modes.ts
|
|
1198
|
+
var RETIRED_DEPLOYMENT_MODES = [
|
|
1199
|
+
"local",
|
|
1200
|
+
"self_hosted",
|
|
1201
|
+
"cloud",
|
|
1202
|
+
"remote",
|
|
1203
|
+
"hybrid"
|
|
1204
|
+
];
|
|
1205
|
+
function normalizeModeToken(value) {
|
|
1206
|
+
return value.trim().toLowerCase().replace(/-/g, "_");
|
|
1207
|
+
}
|
|
1208
|
+
function asRetiredDeploymentMode(value) {
|
|
1209
|
+
const token = normalizeModeToken(value);
|
|
1210
|
+
return RETIRED_DEPLOYMENT_MODES.includes(token) ? token : null;
|
|
1211
|
+
}
|
|
1212
|
+
function retiredDeploymentModeError(rawValue, sourceEnvKey, replacement) {
|
|
1213
|
+
const mode = asRetiredDeploymentMode(rawValue);
|
|
1214
|
+
const value = mode === "local" ? replacement.onBox : replacement.offBox;
|
|
1215
|
+
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}).`);
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1179
1218
|
// src/http/client.ts
|
|
1180
|
-
var
|
|
1219
|
+
var SERVER_BACKEND_VALUES = ["sqlite", "postgresql", "postgres"];
|
|
1181
1220
|
function envToken(name) {
|
|
1182
1221
|
return name.toUpperCase().replace(/-/g, "_");
|
|
1183
1222
|
}
|
|
1184
|
-
function
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1223
|
+
function clientStoreReplacement(envKey) {
|
|
1224
|
+
return { envKey, onBox: "sqlite", offBox: "http" };
|
|
1225
|
+
}
|
|
1226
|
+
function normalizeClientStore(value, sourceEnvKey, replacementEnvKey) {
|
|
1227
|
+
const normalized = normalizeModeToken(value);
|
|
1228
|
+
if (normalized === "sqlite")
|
|
1229
|
+
return "sqlite";
|
|
1230
|
+
if (normalized === "http" || normalized === "https")
|
|
1231
|
+
return "http";
|
|
1232
|
+
if (asRetiredDeploymentMode(value)) {
|
|
1233
|
+
throw retiredDeploymentModeError(value, sourceEnvKey, clientStoreReplacement(replacementEnvKey));
|
|
1192
1234
|
}
|
|
1193
|
-
throw new Error(`Unknown
|
|
1235
|
+
throw new Error(`Unknown client store: ${value}. Use sqlite or http.`);
|
|
1194
1236
|
}
|
|
1195
|
-
function
|
|
1237
|
+
function defaultApiBaseUrl(name) {
|
|
1196
1238
|
return `https://${name}.hasna.xyz`;
|
|
1197
1239
|
}
|
|
1198
1240
|
function envKeys(name) {
|
|
1199
1241
|
const token = envToken(name);
|
|
1200
1242
|
return {
|
|
1201
|
-
|
|
1243
|
+
storeKeys: [`HASNA_${token}_CLIENT_STORE`, `${token}_CLIENT_STORE`],
|
|
1244
|
+
retiredModeKeys: [`HASNA_${token}_STORAGE_MODE`, `HASNA_${token}_MODE`, `${token}_STORAGE_MODE`, `${token}_MODE`],
|
|
1202
1245
|
apiUrlKeys: [`HASNA_${token}_API_URL`, `${token}_API_URL`],
|
|
1203
1246
|
apiKeyKeys: [`HASNA_${token}_API_KEY`, `${token}_API_KEY`]
|
|
1204
1247
|
};
|
|
@@ -1211,6 +1254,20 @@ function firstEnv(env, keys) {
|
|
|
1211
1254
|
}
|
|
1212
1255
|
return null;
|
|
1213
1256
|
}
|
|
1257
|
+
function assertNoRetiredMode(env, keys) {
|
|
1258
|
+
for (const key of keys.retiredModeKeys) {
|
|
1259
|
+
const value = env[key]?.trim();
|
|
1260
|
+
if (!value)
|
|
1261
|
+
continue;
|
|
1262
|
+
const normalized = normalizeModeToken(value);
|
|
1263
|
+
if (SERVER_BACKEND_VALUES.includes(normalized))
|
|
1264
|
+
continue;
|
|
1265
|
+
if (asRetiredDeploymentMode(value)) {
|
|
1266
|
+
throw retiredDeploymentModeError(value, key, clientStoreReplacement(keys.storeKeys[0]));
|
|
1267
|
+
}
|
|
1268
|
+
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.`);
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1214
1271
|
function toV1BaseUrl(apiUrl) {
|
|
1215
1272
|
const url = new URL(apiUrl);
|
|
1216
1273
|
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
@@ -1226,45 +1283,42 @@ function toV1BaseUrl(apiUrl) {
|
|
|
1226
1283
|
}
|
|
1227
1284
|
function resolveTransport(name, env = process.env) {
|
|
1228
1285
|
const keys = envKeys(name);
|
|
1229
|
-
|
|
1286
|
+
assertNoRetiredMode(env, keys);
|
|
1287
|
+
const storeHit = firstEnv(env, keys.storeKeys);
|
|
1230
1288
|
const urlHit = firstEnv(env, keys.apiUrlKeys);
|
|
1231
1289
|
const keyHit = firstEnv(env, keys.apiKeyKeys);
|
|
1232
|
-
let
|
|
1233
|
-
let deprecatedAlias = null;
|
|
1290
|
+
let requested = "sqlite";
|
|
1234
1291
|
let modeSource = "default";
|
|
1235
|
-
if (
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
deprecatedAlias = normalized.deprecatedAlias;
|
|
1239
|
-
modeSource = modeHit.key;
|
|
1292
|
+
if (storeHit) {
|
|
1293
|
+
requested = normalizeClientStore(storeHit.value, storeHit.key, keys.storeKeys[0]);
|
|
1294
|
+
modeSource = storeHit.key;
|
|
1240
1295
|
} else if (urlHit && keyHit) {
|
|
1241
|
-
|
|
1296
|
+
requested = "http";
|
|
1242
1297
|
modeSource = "auto:api-url+api-key";
|
|
1243
1298
|
}
|
|
1244
|
-
if (
|
|
1245
|
-
return { transport: "
|
|
1299
|
+
if (requested === "sqlite") {
|
|
1300
|
+
return { transport: "sqlite", requested, modeSource, baseUrl: null, apiKeyPresent: Boolean(keyHit), misconfigured: false, warning: null };
|
|
1246
1301
|
}
|
|
1247
1302
|
if (!keyHit) {
|
|
1248
1303
|
return {
|
|
1249
|
-
transport: "
|
|
1250
|
-
|
|
1251
|
-
deprecatedAlias,
|
|
1304
|
+
transport: "sqlite",
|
|
1305
|
+
requested,
|
|
1252
1306
|
modeSource,
|
|
1253
1307
|
baseUrl: null,
|
|
1254
1308
|
apiKeyPresent: false,
|
|
1255
1309
|
misconfigured: true,
|
|
1256
|
-
warning: `${modeSource}=
|
|
1310
|
+
warning: `${modeSource}=http but no API key is set (${keys.apiKeyKeys[0]}). Refusing to route to the API.`
|
|
1257
1311
|
};
|
|
1258
1312
|
}
|
|
1259
|
-
const rawUrl = urlHit?.value ??
|
|
1313
|
+
const rawUrl = urlHit?.value ?? defaultApiBaseUrl(name);
|
|
1260
1314
|
let baseUrl;
|
|
1261
1315
|
try {
|
|
1262
1316
|
baseUrl = toV1BaseUrl(rawUrl);
|
|
1263
1317
|
} catch (error) {
|
|
1264
1318
|
const message = error instanceof Error ? error.message : String(error);
|
|
1265
|
-
return { transport: "
|
|
1319
|
+
return { transport: "sqlite", requested, modeSource, baseUrl: null, apiKeyPresent: true, misconfigured: true, warning: `Invalid API URL: ${message}.` };
|
|
1266
1320
|
}
|
|
1267
|
-
return { transport: "
|
|
1321
|
+
return { transport: "http", requested, modeSource, baseUrl, apiKeyPresent: true, misconfigured: false, warning: null };
|
|
1268
1322
|
}
|
|
1269
1323
|
|
|
1270
1324
|
class HasnaHttpError extends Error {
|
|
@@ -1376,6 +1430,8 @@ function createHttpTransport(options) {
|
|
|
1376
1430
|
const jitter = Math.floor(Math.random() * (backoff / 2 + 1));
|
|
1377
1431
|
await sleep(backoff + jitter);
|
|
1378
1432
|
}
|
|
1433
|
+
if (last === null)
|
|
1434
|
+
throw new Error(`Request to ${rel} completed without a result`);
|
|
1379
1435
|
throw last.error;
|
|
1380
1436
|
}
|
|
1381
1437
|
return {
|
|
@@ -1447,17 +1503,17 @@ function createStorageClient(name, transport) {
|
|
|
1447
1503
|
function resolveStorageClient(name, env = process.env, fetchImpl) {
|
|
1448
1504
|
const resolution = resolveTransport(name, env);
|
|
1449
1505
|
if (resolution.misconfigured) {
|
|
1450
|
-
throw new Error(resolution.warning ?? `Client for '${name}' is misconfigured for
|
|
1506
|
+
throw new Error(resolution.warning ?? `Client for '${name}' is misconfigured for the /v1 API.`);
|
|
1451
1507
|
}
|
|
1452
|
-
if (resolution.transport === "
|
|
1453
|
-
return { transport: "
|
|
1508
|
+
if (resolution.transport === "sqlite" || !resolution.baseUrl) {
|
|
1509
|
+
return { transport: "sqlite", client: null, resolution };
|
|
1454
1510
|
}
|
|
1455
1511
|
const keys = envKeys(name);
|
|
1456
1512
|
const apiKey = firstEnv(env, keys.apiKeyKeys)?.value;
|
|
1457
1513
|
if (!apiKey)
|
|
1458
|
-
throw new Error(`Client for '${name}' resolved to
|
|
1514
|
+
throw new Error(`Client for '${name}' resolved to the /v1 API without an API key.`);
|
|
1459
1515
|
const transport = createHttpTransport({ name, baseUrl: resolution.baseUrl, apiKey, ...fetchImpl ? { fetchImpl } : {} });
|
|
1460
|
-
return { transport: "
|
|
1516
|
+
return { transport: "http", client: createStorageClient(name, transport), resolution };
|
|
1461
1517
|
}
|
|
1462
1518
|
|
|
1463
1519
|
// src/store.ts
|
|
@@ -1481,12 +1537,15 @@ function listQuery(filter) {
|
|
|
1481
1537
|
}
|
|
1482
1538
|
function unwrap(res, key) {
|
|
1483
1539
|
if (res && typeof res === "object" && key in res) {
|
|
1484
|
-
|
|
1540
|
+
const value = res[key];
|
|
1541
|
+
if (value === undefined)
|
|
1542
|
+
throw new Error(`Response field ${key} is undefined`);
|
|
1543
|
+
return value;
|
|
1485
1544
|
}
|
|
1486
1545
|
return res;
|
|
1487
1546
|
}
|
|
1488
1547
|
var localStore = {
|
|
1489
|
-
mode: "
|
|
1548
|
+
mode: "sqlite",
|
|
1490
1549
|
baseUrl: null,
|
|
1491
1550
|
async createRecording(input, idempotencyKey) {
|
|
1492
1551
|
return withLocalStoreReaderLease(() => createRecording(input, undefined, idempotencyKey));
|
|
@@ -1539,7 +1598,7 @@ var localStore = {
|
|
|
1539
1598
|
};
|
|
1540
1599
|
function apiStore(client) {
|
|
1541
1600
|
return {
|
|
1542
|
-
mode: "
|
|
1601
|
+
mode: "http",
|
|
1543
1602
|
baseUrl: client.baseUrl,
|
|
1544
1603
|
async createRecording(input, idempotencyKey) {
|
|
1545
1604
|
const keyCandidate = idempotencyKey === undefined && (input.id === undefined || input.id === null) ? randomUUID2() : idempotencyKey;
|
|
@@ -1682,7 +1741,7 @@ function getStore(env = process.env) {
|
|
|
1682
1741
|
if (env === process.env && cached)
|
|
1683
1742
|
return cached;
|
|
1684
1743
|
const resolved = resolveStorageClient(APP, env);
|
|
1685
|
-
const store = resolved.transport === "
|
|
1744
|
+
const store = resolved.transport === "http" ? apiStore(resolved.client) : localStore;
|
|
1686
1745
|
if (env === process.env)
|
|
1687
1746
|
cached = store;
|
|
1688
1747
|
return store;
|
|
@@ -1709,6 +1768,9 @@ function resetClient() {
|
|
|
1709
1768
|
_client = null;
|
|
1710
1769
|
_clientApiKey = null;
|
|
1711
1770
|
}
|
|
1771
|
+
function redactKeyMaterial(text) {
|
|
1772
|
+
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]`);
|
|
1773
|
+
}
|
|
1712
1774
|
async function transcribeAudio(audioPath, config, options = {}) {
|
|
1713
1775
|
const client = getClient(config);
|
|
1714
1776
|
const startTime = Date.now();
|
|
@@ -1998,10 +2060,14 @@ function startRecording(config) {
|
|
|
1998
2060
|
const filename = `recording-${timestamp}.${config.audio_format}`;
|
|
1999
2061
|
const filepath = join3(config.audio_dir, filename);
|
|
2000
2062
|
const args = buildRecordArgs(filepath, config);
|
|
2063
|
+
const [command, ...commandArgs] = args;
|
|
2064
|
+
if (command === undefined) {
|
|
2065
|
+
throw new RecordingError("Recording command is empty.");
|
|
2066
|
+
}
|
|
2001
2067
|
const releaseLease = isGlobalRecordingsStatePath(filepath) ? acquireLocalStoreReaderLease() : () => {};
|
|
2002
2068
|
let recordProcess;
|
|
2003
2069
|
try {
|
|
2004
|
-
recordProcess = spawn(
|
|
2070
|
+
recordProcess = spawn(command, commandArgs, {
|
|
2005
2071
|
stdio: ["pipe", "pipe", "pipe"]
|
|
2006
2072
|
});
|
|
2007
2073
|
} catch (error) {
|
|
@@ -2100,21 +2166,527 @@ async function recordDuration(seconds, config) {
|
|
|
2100
2166
|
releaseLease();
|
|
2101
2167
|
}
|
|
2102
2168
|
}
|
|
2169
|
+
// src/lib/capture-probe.ts
|
|
2170
|
+
import { spawnSync as spawnSync2 } from "child_process";
|
|
2171
|
+
import { existsSync as existsSync3, readFileSync as readFileSync2, rmSync as rmSync2 } from "fs";
|
|
2172
|
+
import { join as join4 } from "path";
|
|
2173
|
+
import { tmpdir } from "os";
|
|
2174
|
+
|
|
2175
|
+
// src/lib/macos-bundle.ts
|
|
2176
|
+
var RECORDINGS_BUNDLE_IDENTIFIER = "com.hasna.recordings";
|
|
2177
|
+
var TCC_DATABASE_UNREADABLE_STATE = "undetermined_tcc_database_unreadable";
|
|
2178
|
+
|
|
2179
|
+
// src/lib/capture-probe.ts
|
|
2180
|
+
var RIFF_HEADER_BYTES = 12;
|
|
2181
|
+
var CHUNK_HEADER_BYTES = 8;
|
|
2182
|
+
var INT16_FULL_SCALE = 32768;
|
|
2183
|
+
var FMT_CHUNK_MIN_BYTES = 16;
|
|
2184
|
+
var WAVE_FORMAT_PCM = 1;
|
|
2185
|
+
var WAVE_FORMAT_EXTENSIBLE = 65534;
|
|
2186
|
+
var SUBFORMAT_OFFSET_IN_EXTENSION = 8;
|
|
2187
|
+
function readWavPeak(filepath) {
|
|
2188
|
+
const buf = readFileSync2(filepath);
|
|
2189
|
+
if (buf.length < RIFF_HEADER_BYTES) {
|
|
2190
|
+
throw new Error(`not a RIFF file (${buf.length} bytes): ${filepath}`);
|
|
2191
|
+
}
|
|
2192
|
+
if (buf.toString("ascii", 0, 4) !== "RIFF" || buf.toString("ascii", 8, 12) !== "WAVE") {
|
|
2193
|
+
throw new Error(`not a RIFF/WAVE file: ${filepath}`);
|
|
2194
|
+
}
|
|
2195
|
+
let offset = RIFF_HEADER_BYTES;
|
|
2196
|
+
let bitsPerSample = null;
|
|
2197
|
+
let formatTag = null;
|
|
2198
|
+
let dataStart = -1;
|
|
2199
|
+
let dataLength = 0;
|
|
2200
|
+
while (offset + CHUNK_HEADER_BYTES <= buf.length) {
|
|
2201
|
+
const chunkId = buf.toString("ascii", offset, offset + 4);
|
|
2202
|
+
const chunkSize = buf.readUInt32LE(offset + 4);
|
|
2203
|
+
const body = offset + CHUNK_HEADER_BYTES;
|
|
2204
|
+
if (chunkId === "fmt " && formatTag === null) {
|
|
2205
|
+
if (chunkSize < FMT_CHUNK_MIN_BYTES || body + FMT_CHUNK_MIN_BYTES > buf.length) {
|
|
2206
|
+
throw new Error(`truncated fmt chunk (${chunkSize} bytes, need ${FMT_CHUNK_MIN_BYTES}): ${filepath}`);
|
|
2207
|
+
}
|
|
2208
|
+
formatTag = buf.readUInt16LE(body);
|
|
2209
|
+
bitsPerSample = buf.readUInt16LE(body + 14);
|
|
2210
|
+
if (formatTag === WAVE_FORMAT_EXTENSIBLE) {
|
|
2211
|
+
const extensionStart = body + FMT_CHUNK_MIN_BYTES;
|
|
2212
|
+
const subFormatAt = extensionStart + SUBFORMAT_OFFSET_IN_EXTENSION;
|
|
2213
|
+
if (chunkSize < FMT_CHUNK_MIN_BYTES + 10 || subFormatAt + 2 > buf.length) {
|
|
2214
|
+
throw new Error(`WAVE_FORMAT_EXTENSIBLE without a readable SubFormat GUID: ${filepath}`);
|
|
2215
|
+
}
|
|
2216
|
+
formatTag = buf.readUInt16LE(subFormatAt);
|
|
2217
|
+
}
|
|
2218
|
+
} else if (chunkId === "data" && dataStart < 0) {
|
|
2219
|
+
dataStart = body;
|
|
2220
|
+
const remaining = buf.length - body;
|
|
2221
|
+
dataLength = chunkSize === 0 ? remaining : Math.min(chunkSize, remaining);
|
|
2222
|
+
}
|
|
2223
|
+
const declaredAdvance = chunkSize + chunkSize % 2;
|
|
2224
|
+
offset = body + Math.min(declaredAdvance, Math.max(0, buf.length - body));
|
|
2225
|
+
}
|
|
2226
|
+
if (dataStart < 0) {
|
|
2227
|
+
throw new Error(`no data chunk in WAV: ${filepath}`);
|
|
2228
|
+
}
|
|
2229
|
+
if (bitsPerSample === null || formatTag === null) {
|
|
2230
|
+
throw new Error(`no fmt chunk in WAV, so the sample format is unknown: ${filepath}`);
|
|
2231
|
+
}
|
|
2232
|
+
if (formatTag !== WAVE_FORMAT_PCM) {
|
|
2233
|
+
throw new Error(`expected uncompressed PCM (format tag ${WAVE_FORMAT_PCM}), got tag ${formatTag}: ${filepath}`);
|
|
2234
|
+
}
|
|
2235
|
+
if (bitsPerSample !== 16) {
|
|
2236
|
+
throw new Error(`expected 16-bit PCM, got ${bitsPerSample}-bit: ${filepath}`);
|
|
2237
|
+
}
|
|
2238
|
+
const samples = Math.floor(dataLength / 2);
|
|
2239
|
+
let maxAbs = 0;
|
|
2240
|
+
for (let i = 0;i < samples; i++) {
|
|
2241
|
+
const value = Math.abs(buf.readInt16LE(dataStart + i * 2));
|
|
2242
|
+
if (value > maxAbs)
|
|
2243
|
+
maxAbs = value;
|
|
2244
|
+
}
|
|
2245
|
+
return { samples, peak: maxAbs / INT16_FULL_SCALE };
|
|
2246
|
+
}
|
|
2247
|
+
var DEFAULT_PROBE_SECONDS = 1;
|
|
2248
|
+
var MAX_PROBE_SECONDS = 60;
|
|
2249
|
+
var DEFAULT_RECORD_EXECUTABLE = "rec";
|
|
2250
|
+
var SILENCE_PEAK_THRESHOLD = 0.001;
|
|
2251
|
+
var PROBE_TIMEOUT_GRACE_MS = 1e4;
|
|
2252
|
+
function probeMicrophoneCapture(config, options = {}) {
|
|
2253
|
+
const requested = options.seconds ?? DEFAULT_PROBE_SECONDS;
|
|
2254
|
+
const seconds = Math.min(Math.max(requested, 1), MAX_PROBE_SECONDS);
|
|
2255
|
+
const executable = options.executable ?? DEFAULT_RECORD_EXECUTABLE;
|
|
2256
|
+
const base = {
|
|
2257
|
+
tool: null,
|
|
2258
|
+
seconds,
|
|
2259
|
+
samples: 0,
|
|
2260
|
+
peak: 0,
|
|
2261
|
+
silent: null
|
|
2262
|
+
};
|
|
2263
|
+
const filepath = join4(tmpdir(), `recordings-capture-probe-${process.pid}-${Date.now()}.wav`);
|
|
2264
|
+
try {
|
|
2265
|
+
const result = spawnSync2(executable, [
|
|
2266
|
+
"-q",
|
|
2267
|
+
"-r",
|
|
2268
|
+
String(config.sample_rate),
|
|
2269
|
+
"-c",
|
|
2270
|
+
"1",
|
|
2271
|
+
"-b",
|
|
2272
|
+
"16",
|
|
2273
|
+
filepath,
|
|
2274
|
+
"trim",
|
|
2275
|
+
"0",
|
|
2276
|
+
String(seconds)
|
|
2277
|
+
], {
|
|
2278
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
2279
|
+
encoding: "utf8",
|
|
2280
|
+
timeout: options.timeoutMs ?? seconds * 1000 + PROBE_TIMEOUT_GRACE_MS
|
|
2281
|
+
});
|
|
2282
|
+
if (result.signal) {
|
|
2283
|
+
return {
|
|
2284
|
+
...base,
|
|
2285
|
+
ok: false,
|
|
2286
|
+
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.`
|
|
2287
|
+
};
|
|
2288
|
+
}
|
|
2289
|
+
if (result.error) {
|
|
2290
|
+
return {
|
|
2291
|
+
...base,
|
|
2292
|
+
ok: false,
|
|
2293
|
+
message: `could not run '${executable}': ${result.error.message}`
|
|
2294
|
+
};
|
|
2295
|
+
}
|
|
2296
|
+
if (!existsSync3(filepath)) {
|
|
2297
|
+
const stderr = (result.stderr || "").trim();
|
|
2298
|
+
return {
|
|
2299
|
+
...base,
|
|
2300
|
+
ok: false,
|
|
2301
|
+
message: `'${executable}' wrote no audio file${stderr ? `: ${stderr}` : ""}`
|
|
2302
|
+
};
|
|
2303
|
+
}
|
|
2304
|
+
let peak;
|
|
2305
|
+
try {
|
|
2306
|
+
peak = readWavPeak(filepath);
|
|
2307
|
+
} catch (error) {
|
|
2308
|
+
return {
|
|
2309
|
+
...base,
|
|
2310
|
+
ok: false,
|
|
2311
|
+
message: `could not read captured audio: ${error.message}`
|
|
2312
|
+
};
|
|
2313
|
+
}
|
|
2314
|
+
const tool = executable;
|
|
2315
|
+
if (peak.samples === 0) {
|
|
2316
|
+
return {
|
|
2317
|
+
...base,
|
|
2318
|
+
tool,
|
|
2319
|
+
ok: false,
|
|
2320
|
+
message: "capture produced no samples"
|
|
2321
|
+
};
|
|
2322
|
+
}
|
|
2323
|
+
if (peak.peak <= SILENCE_PEAK_THRESHOLD) {
|
|
2324
|
+
const subject = captureProbeSubject();
|
|
2325
|
+
return {
|
|
2326
|
+
...base,
|
|
2327
|
+
tool,
|
|
2328
|
+
samples: peak.samples,
|
|
2329
|
+
peak: peak.peak,
|
|
2330
|
+
ok: false,
|
|
2331
|
+
silent: true,
|
|
2332
|
+
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}`
|
|
2333
|
+
};
|
|
2334
|
+
}
|
|
2335
|
+
return {
|
|
2336
|
+
tool,
|
|
2337
|
+
seconds,
|
|
2338
|
+
samples: peak.samples,
|
|
2339
|
+
peak: peak.peak,
|
|
2340
|
+
silent: false,
|
|
2341
|
+
ok: true,
|
|
2342
|
+
message: `captured ${peak.samples} samples, peak amplitude ${peak.peak.toFixed(6)} ` + `(proves capture for ${captureProbeSubject().subject})`
|
|
2343
|
+
};
|
|
2344
|
+
} finally {
|
|
2345
|
+
rmSync2(filepath, { force: true });
|
|
2346
|
+
}
|
|
2347
|
+
}
|
|
2348
|
+
function captureProbeSubject(env = process.env, options = {}) {
|
|
2349
|
+
const platform = options.platform ?? process.platform;
|
|
2350
|
+
if (platform !== "darwin") {
|
|
2351
|
+
return {
|
|
2352
|
+
headless: false,
|
|
2353
|
+
subject_known: true,
|
|
2354
|
+
subject: "this process",
|
|
2355
|
+
note: "microphone access is not gated by TCC on this platform"
|
|
2356
|
+
};
|
|
2357
|
+
}
|
|
2358
|
+
const overSsh = Boolean(env.SSH_CONNECTION || env.SSH_TTY || env.SSH_CLIENT);
|
|
2359
|
+
const inTmux = Boolean(env.TMUX || env.TMUX_PANE);
|
|
2360
|
+
const termProgram = env.TERM_PROGRAM?.trim();
|
|
2361
|
+
const hasTty = options.hasTty ?? Boolean(process.stdin.isTTY || process.stdout.isTTY);
|
|
2362
|
+
if (overSsh) {
|
|
2363
|
+
const subject2 = "the SSH session (sshd), not Recordings.app";
|
|
2364
|
+
return {
|
|
2365
|
+
headless: true,
|
|
2366
|
+
subject_known: true,
|
|
2367
|
+
subject: subject2,
|
|
2368
|
+
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."
|
|
2369
|
+
};
|
|
2370
|
+
}
|
|
2371
|
+
if (inTmux) {
|
|
2372
|
+
const subject2 = "the tmux server, not Recordings.app and not this pane's shell";
|
|
2373
|
+
return {
|
|
2374
|
+
headless: false,
|
|
2375
|
+
subject_known: true,
|
|
2376
|
+
subject: subject2,
|
|
2377
|
+
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."
|
|
2378
|
+
};
|
|
2379
|
+
}
|
|
2380
|
+
if (!termProgram && !hasTty) {
|
|
2381
|
+
return {
|
|
2382
|
+
headless: true,
|
|
2383
|
+
subject_known: false,
|
|
2384
|
+
subject: "an unidentified responsible process (not Recordings.app)",
|
|
2385
|
+
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."
|
|
2386
|
+
};
|
|
2387
|
+
}
|
|
2388
|
+
const subject = `${termProgram || "the terminal application running this command"}, not Recordings.app`;
|
|
2389
|
+
return {
|
|
2390
|
+
headless: false,
|
|
2391
|
+
subject_known: Boolean(termProgram),
|
|
2392
|
+
subject,
|
|
2393
|
+
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."
|
|
2394
|
+
};
|
|
2395
|
+
}
|
|
2396
|
+
var TCC_UNREADABLE_STATE = TCC_DATABASE_UNREADABLE_STATE;
|
|
2397
|
+
function classifyPermissionState(permissionState) {
|
|
2398
|
+
switch (permissionState) {
|
|
2399
|
+
case "allowed":
|
|
2400
|
+
case "allowed_identity_unverified":
|
|
2401
|
+
case "denied":
|
|
2402
|
+
case "limited":
|
|
2403
|
+
case "unknown":
|
|
2404
|
+
case "stale_allowed_for_previous_app_build":
|
|
2405
|
+
case "unverified_no_installed_bundle":
|
|
2406
|
+
return "requested";
|
|
2407
|
+
case "not_determined":
|
|
2408
|
+
return "never_requested";
|
|
2409
|
+
default:
|
|
2410
|
+
break;
|
|
2411
|
+
}
|
|
2412
|
+
if (permissionState.startsWith("unknown("))
|
|
2413
|
+
return "requested";
|
|
2414
|
+
if (permissionState.endsWith("_identity_unverified"))
|
|
2415
|
+
return "requested";
|
|
2416
|
+
return "unknown";
|
|
2417
|
+
}
|
|
2418
|
+
function microphoneGrantInstruction(options) {
|
|
2419
|
+
const candidates = [options.installedAppPath, ...options.otherAppPaths ?? []].filter((path) => Boolean(path)).filter((path, index, all) => all.indexOf(path) === index).filter((path) => existsSync3(path));
|
|
2420
|
+
const bundlePath = candidates[0] ?? null;
|
|
2421
|
+
const steps = [];
|
|
2422
|
+
if (!bundlePath) {
|
|
2423
|
+
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').");
|
|
2424
|
+
return {
|
|
2425
|
+
bundle_path: null,
|
|
2426
|
+
bundle_identifier: RECORDINGS_BUNDLE_IDENTIFIER,
|
|
2427
|
+
candidate_bundle_paths: candidates,
|
|
2428
|
+
steps
|
|
2429
|
+
};
|
|
2430
|
+
}
|
|
2431
|
+
if (candidates.length > 1) {
|
|
2432
|
+
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.");
|
|
2433
|
+
}
|
|
2434
|
+
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.`);
|
|
2435
|
+
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 ${join4(bundlePath, "Contents", "MacOS", "Recordings")}.`);
|
|
2436
|
+
if (options.requestState === "never_requested") {
|
|
2437
|
+
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.");
|
|
2438
|
+
} else if (options.requestState === "unknown") {
|
|
2439
|
+
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.");
|
|
2440
|
+
}
|
|
2441
|
+
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'.");
|
|
2442
|
+
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.");
|
|
2443
|
+
return {
|
|
2444
|
+
bundle_path: bundlePath,
|
|
2445
|
+
bundle_identifier: RECORDINGS_BUNDLE_IDENTIFIER,
|
|
2446
|
+
candidate_bundle_paths: candidates,
|
|
2447
|
+
steps
|
|
2448
|
+
};
|
|
2449
|
+
}
|
|
2450
|
+
async function captureProbePrecondition() {
|
|
2451
|
+
const deps = await checkRecordingDeps();
|
|
2452
|
+
return deps.available ? { ready: true, message: deps.message } : { ready: false, message: deps.message };
|
|
2453
|
+
}
|
|
2454
|
+
// src/lib/persistence-probe.ts
|
|
2455
|
+
import { Database as Database2 } from "bun:sqlite";
|
|
2456
|
+
import { existsSync as existsSync4 } from "fs";
|
|
2457
|
+
var AUTO_FLIP_MODE_SOURCE = "auto:api-url+api-key";
|
|
2458
|
+
function safeBaseUrl(baseUrl) {
|
|
2459
|
+
if (!baseUrl)
|
|
2460
|
+
return baseUrl;
|
|
2461
|
+
try {
|
|
2462
|
+
const url = new URL(baseUrl);
|
|
2463
|
+
if (!url.username && !url.password)
|
|
2464
|
+
return baseUrl;
|
|
2465
|
+
url.username = "";
|
|
2466
|
+
url.password = "";
|
|
2467
|
+
return `${url.toString().replace(/\/+$/, "")} (credentials in the URL redacted)`;
|
|
2468
|
+
} catch {
|
|
2469
|
+
return "[unparseable URL redacted]";
|
|
2470
|
+
}
|
|
2471
|
+
}
|
|
2472
|
+
function readLocalRecordingCount(dbPath) {
|
|
2473
|
+
try {
|
|
2474
|
+
const db = new Database2(dbPath, { readonly: true });
|
|
2475
|
+
try {
|
|
2476
|
+
const row = db.query("SELECT COUNT(*) AS count FROM recordings").get();
|
|
2477
|
+
return typeof row?.count === "number" ? row.count : null;
|
|
2478
|
+
} finally {
|
|
2479
|
+
db.close();
|
|
2480
|
+
}
|
|
2481
|
+
} catch {
|
|
2482
|
+
return null;
|
|
2483
|
+
}
|
|
2484
|
+
}
|
|
2485
|
+
function describeActiveStore(config, env = process.env) {
|
|
2486
|
+
const localDbPath = config.db_path;
|
|
2487
|
+
const localDbPresent = existsSync4(localDbPath);
|
|
2488
|
+
const localDbRecordings = localDbPresent ? readLocalRecordingCount(localDbPath) : null;
|
|
2489
|
+
let resolution;
|
|
2490
|
+
try {
|
|
2491
|
+
resolution = resolveTransport(APP, env);
|
|
2492
|
+
} catch (error) {
|
|
2493
|
+
return {
|
|
2494
|
+
transport: "sqlite",
|
|
2495
|
+
mode_source: "unresolved",
|
|
2496
|
+
base_url: null,
|
|
2497
|
+
local_db_path: localDbPath,
|
|
2498
|
+
local_db_present: localDbPresent,
|
|
2499
|
+
local_db_recordings: localDbRecordings,
|
|
2500
|
+
divergent: false,
|
|
2501
|
+
warning: redactKeyMaterial(`could not resolve the active store: ${error.message}`)
|
|
2502
|
+
};
|
|
2503
|
+
}
|
|
2504
|
+
const divergent = resolution.transport === "http" && (localDbRecordings ?? 0) > 0;
|
|
2505
|
+
const warnings = [];
|
|
2506
|
+
if (resolution.warning)
|
|
2507
|
+
warnings.push(resolution.warning);
|
|
2508
|
+
if (resolution.transport === "http" && localDbPresent && localDbRecordings === null) {
|
|
2509
|
+
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'.");
|
|
2510
|
+
}
|
|
2511
|
+
if (divergent) {
|
|
2512
|
+
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.");
|
|
2513
|
+
}
|
|
2514
|
+
if (resolution.transport === "http" && resolution.modeSource === AUTO_FLIP_MODE_SOURCE) {
|
|
2515
|
+
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.");
|
|
2516
|
+
}
|
|
2517
|
+
return {
|
|
2518
|
+
transport: resolution.transport,
|
|
2519
|
+
mode_source: resolution.modeSource,
|
|
2520
|
+
base_url: safeBaseUrl(resolution.baseUrl),
|
|
2521
|
+
local_db_path: localDbPath,
|
|
2522
|
+
local_db_present: localDbPresent,
|
|
2523
|
+
local_db_recordings: localDbRecordings,
|
|
2524
|
+
divergent,
|
|
2525
|
+
warning: warnings.length > 0 ? warnings.join(" ") : null
|
|
2526
|
+
};
|
|
2527
|
+
}
|
|
2528
|
+
var PERSISTENCE_PROBE_TAG = "check-probe";
|
|
2529
|
+
var PERSISTENCE_PROBE_MARKER_PREFIX = "recordings check --probe persistence round-trip";
|
|
2530
|
+
async function probeRecordingPersistence(options = {}) {
|
|
2531
|
+
const store = options.store ?? getStore();
|
|
2532
|
+
const stamp = (options.now?.() ?? new Date).toISOString();
|
|
2533
|
+
const marker = `${PERSISTENCE_PROBE_MARKER_PREFIX} ${stamp}`;
|
|
2534
|
+
const transport = store.mode === "http" ? "http" : "sqlite";
|
|
2535
|
+
const base = {
|
|
2536
|
+
attempted: false,
|
|
2537
|
+
transport,
|
|
2538
|
+
base_url: safeBaseUrl(store.baseUrl),
|
|
2539
|
+
recording_id: null,
|
|
2540
|
+
read_back: false,
|
|
2541
|
+
cleaned_up: false,
|
|
2542
|
+
created_local_store: false,
|
|
2543
|
+
reachable: null
|
|
2544
|
+
};
|
|
2545
|
+
if (transport === "sqlite" && options.localStoreIsLegacy === true && !options.allowLocalMigration) {
|
|
2546
|
+
return {
|
|
2547
|
+
...base,
|
|
2548
|
+
ok: false,
|
|
2549
|
+
outcome: "skipped",
|
|
2550
|
+
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."
|
|
2551
|
+
};
|
|
2552
|
+
}
|
|
2553
|
+
if (transport === "http" && !options.allowRemoteWrite) {
|
|
2554
|
+
let reachable = null;
|
|
2555
|
+
let reachabilityDetail = "";
|
|
2556
|
+
try {
|
|
2557
|
+
await store.getRecordingStats();
|
|
2558
|
+
reachable = true;
|
|
2559
|
+
reachabilityDetail = " The store did answer a read-only stats request, so it is reachable " + "and the credential is accepted.";
|
|
2560
|
+
} catch (error) {
|
|
2561
|
+
reachable = false;
|
|
2562
|
+
reachabilityDetail = " The store did NOT answer a read-only stats request: " + `${redactKeyMaterial(error.message)}.`;
|
|
2563
|
+
}
|
|
2564
|
+
return {
|
|
2565
|
+
...base,
|
|
2566
|
+
reachable,
|
|
2567
|
+
ok: false,
|
|
2568
|
+
outcome: "skipped",
|
|
2569
|
+
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
|
|
2570
|
+
};
|
|
2571
|
+
}
|
|
2572
|
+
let created = null;
|
|
2573
|
+
try {
|
|
2574
|
+
created = await store.createRecording({
|
|
2575
|
+
raw_text: marker,
|
|
2576
|
+
processing_mode: "raw",
|
|
2577
|
+
model_used: "check-probe",
|
|
2578
|
+
duration_ms: 0,
|
|
2579
|
+
tags: ["diagnostic", PERSISTENCE_PROBE_TAG],
|
|
2580
|
+
metadata: { persistence_probe: true, probed_at: stamp }
|
|
2581
|
+
});
|
|
2582
|
+
} catch (error) {
|
|
2583
|
+
return {
|
|
2584
|
+
...base,
|
|
2585
|
+
attempted: true,
|
|
2586
|
+
ok: false,
|
|
2587
|
+
outcome: "failed",
|
|
2588
|
+
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}"'.`
|
|
2589
|
+
};
|
|
2590
|
+
}
|
|
2591
|
+
if (!created?.id) {
|
|
2592
|
+
return {
|
|
2593
|
+
...base,
|
|
2594
|
+
attempted: true,
|
|
2595
|
+
ok: false,
|
|
2596
|
+
outcome: "failed",
|
|
2597
|
+
message: "store accepted the write but returned no id"
|
|
2598
|
+
};
|
|
2599
|
+
}
|
|
2600
|
+
let readBack = false;
|
|
2601
|
+
let readError = null;
|
|
2602
|
+
try {
|
|
2603
|
+
const fetched = await store.getRecording(created.id);
|
|
2604
|
+
readBack = fetched?.raw_text === marker;
|
|
2605
|
+
if (fetched && !readBack) {
|
|
2606
|
+
readError = "the stored transcript did not match what was written";
|
|
2607
|
+
} else if (!fetched) {
|
|
2608
|
+
readError = "the write was accepted but the recording could not be read back";
|
|
2609
|
+
}
|
|
2610
|
+
} catch (error) {
|
|
2611
|
+
readError = `read-back failed: ${redactKeyMaterial(error.message)}`;
|
|
2612
|
+
}
|
|
2613
|
+
let cleanedUp = false;
|
|
2614
|
+
let cleanupError = null;
|
|
2615
|
+
try {
|
|
2616
|
+
const deleteReported = await store.deleteRecording(created.id);
|
|
2617
|
+
if (!deleteReported) {
|
|
2618
|
+
cleanupError = "the store reported nothing to delete";
|
|
2619
|
+
} else {
|
|
2620
|
+
const stillThere = await store.getRecording(created.id);
|
|
2621
|
+
if (stillThere) {
|
|
2622
|
+
cleanupError = "the store reported the delete succeeded but the recording is still readable";
|
|
2623
|
+
} else {
|
|
2624
|
+
cleanedUp = true;
|
|
2625
|
+
}
|
|
2626
|
+
}
|
|
2627
|
+
} catch (error) {
|
|
2628
|
+
cleanupError = redactKeyMaterial(error.message);
|
|
2629
|
+
}
|
|
2630
|
+
const createdLocalStore = transport === "sqlite" && options.localStoreExistedBefore === false;
|
|
2631
|
+
const where = store.baseUrl ? `the API store at ${safeBaseUrl(store.baseUrl)}` : "the local SQLite store";
|
|
2632
|
+
const createdNote = createdLocalStore ? " (this probe CREATED and migrated the local SQLite store, which did not exist before)" : "";
|
|
2633
|
+
if (readError) {
|
|
2634
|
+
return {
|
|
2635
|
+
...base,
|
|
2636
|
+
attempted: true,
|
|
2637
|
+
recording_id: created.id,
|
|
2638
|
+
cleaned_up: cleanedUp,
|
|
2639
|
+
created_local_store: createdLocalStore,
|
|
2640
|
+
ok: false,
|
|
2641
|
+
outcome: "failed",
|
|
2642
|
+
message: `${readError} (${where}, id ${created.id.slice(0, 8)})${createdNote}`
|
|
2643
|
+
};
|
|
2644
|
+
}
|
|
2645
|
+
if (!cleanedUp) {
|
|
2646
|
+
return {
|
|
2647
|
+
...base,
|
|
2648
|
+
attempted: true,
|
|
2649
|
+
recording_id: created.id,
|
|
2650
|
+
read_back: true,
|
|
2651
|
+
cleaned_up: false,
|
|
2652
|
+
created_local_store: createdLocalStore,
|
|
2653
|
+
ok: false,
|
|
2654
|
+
outcome: "failed",
|
|
2655
|
+
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}`
|
|
2656
|
+
};
|
|
2657
|
+
}
|
|
2658
|
+
return {
|
|
2659
|
+
...base,
|
|
2660
|
+
attempted: true,
|
|
2661
|
+
recording_id: created.id,
|
|
2662
|
+
read_back: true,
|
|
2663
|
+
cleaned_up: true,
|
|
2664
|
+
created_local_store: createdLocalStore,
|
|
2665
|
+
ok: true,
|
|
2666
|
+
outcome: "proved",
|
|
2667
|
+
message: `wrote, read back and confirmed removal of a marker recording in ${where}${createdNote}`
|
|
2668
|
+
};
|
|
2669
|
+
}
|
|
2103
2670
|
export {
|
|
2104
2671
|
transcribeBuffer,
|
|
2105
2672
|
transcribeAudio,
|
|
2106
2673
|
stopRecording,
|
|
2107
2674
|
startRecording,
|
|
2675
|
+
safeBaseUrl,
|
|
2108
2676
|
resolveTransport,
|
|
2109
2677
|
resolveStorageClient,
|
|
2110
2678
|
resetEnhancementClient,
|
|
2111
2679
|
resetClient,
|
|
2112
2680
|
recordDuration,
|
|
2681
|
+
readWavPeak,
|
|
2113
2682
|
processText,
|
|
2683
|
+
probeRecordingPersistence,
|
|
2684
|
+
probeMicrophoneCapture,
|
|
2114
2685
|
normalizePostProcessingMode,
|
|
2115
2686
|
normalizePostProcessingConfig,
|
|
2116
2687
|
normalizeModelSlots,
|
|
2117
2688
|
needsEnhancement,
|
|
2689
|
+
microphoneGrantInstruction,
|
|
2118
2690
|
loadConfig,
|
|
2119
2691
|
isRecording,
|
|
2120
2692
|
getStore,
|
|
@@ -2122,20 +2694,33 @@ export {
|
|
|
2122
2694
|
getCurrentFile,
|
|
2123
2695
|
ensureDataDir,
|
|
2124
2696
|
enhanceText,
|
|
2697
|
+
describeActiveStore,
|
|
2125
2698
|
createStorageClient,
|
|
2126
2699
|
createHttpTransport,
|
|
2700
|
+
classifyPermissionState,
|
|
2127
2701
|
checkRecordingDeps,
|
|
2702
|
+
captureProbeSubject,
|
|
2703
|
+
captureProbePrecondition,
|
|
2128
2704
|
__resetStore,
|
|
2129
2705
|
TranscriptionError,
|
|
2706
|
+
TCC_UNREADABLE_STATE,
|
|
2707
|
+
SILENCE_PEAK_THRESHOLD,
|
|
2130
2708
|
RecordingsV1Client,
|
|
2131
2709
|
ApiError as RecordingsV1ApiError,
|
|
2132
2710
|
RecordingNotFoundError,
|
|
2133
2711
|
RecordingError,
|
|
2712
|
+
RECORDINGS_BUNDLE_IDENTIFIER,
|
|
2713
|
+
PERSISTENCE_PROBE_TAG,
|
|
2714
|
+
PERSISTENCE_PROBE_MARKER_PREFIX,
|
|
2715
|
+
MAX_PROBE_SECONDS,
|
|
2134
2716
|
HasnaHttpError,
|
|
2135
2717
|
EnhancementError,
|
|
2136
2718
|
DEFAULT_TRANSCRIPTION_MODEL,
|
|
2719
|
+
DEFAULT_RECORD_EXECUTABLE,
|
|
2137
2720
|
DEFAULT_REALTIME_TRANSCRIPTION_MODEL,
|
|
2138
2721
|
DEFAULT_REALTIME_SESSION_MODEL,
|
|
2722
|
+
DEFAULT_PROBE_SECONDS,
|
|
2139
2723
|
DEFAULT_CONFIG,
|
|
2724
|
+
AUTO_FLIP_MODE_SOURCE,
|
|
2140
2725
|
APP
|
|
2141
2726
|
};
|