@bli-cockpit/cli 0.2.259 → 0.2.261
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/local-args-tower-msg.js +11 -8
- package/dist/commands/local-help-commands-tower.js +1 -1
- package/dist/commands/local-help-notes.js +2 -2
- package/dist/commands/msg-dm.js +46 -6
- package/dist/commands/msg-write.js +25 -0
- package/dist/commands/msg.js +8 -18
- package/dist/commands/notes-app-shared.js +84 -0
- package/dist/commands/notes-app-windows.js +220 -0
- package/dist/commands/notes-app.js +18 -81
- package/dist/commands/notes-record-capture-win32.js +170 -0
- package/dist/commands/notes-record-capture.js +19 -5
- package/dist/commands/notes-record-win32-script.js +332 -0
- package/dist/commands/notes-record.js +27 -16
- package/dist/commands/notes.js +4 -2
- package/dist/commands/public-root.js +1 -1
- package/package.json +2 -2
|
@@ -15,50 +15,30 @@
|
|
|
15
15
|
* status what is installed, whether it is running, and whether a newer
|
|
16
16
|
* release exists.
|
|
17
17
|
*
|
|
18
|
-
* Apple Silicon macOS
|
|
19
|
-
* a shell over `cockpit notes record`; it captures
|
|
20
|
-
* and LaunchAgent are `notes-app-plist.ts
|
|
18
|
+
* Apple Silicon macOS and 64-bit Windows (BLI-23208); Linux and Windows on Arm are
|
|
19
|
+
* refused by name. The app is a shell over `cockpit notes record`; it captures
|
|
20
|
+
* nothing itself. The Mac layout and LaunchAgent are `notes-app-plist.ts`; the
|
|
21
|
+
* Windows half (Run key instead of a LaunchAgent) is `notes-app-windows.ts`;
|
|
22
|
+
* what both share (deps, release question, checked download) is
|
|
23
|
+
* `notes-app-shared.ts`.
|
|
21
24
|
*/
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
25
|
+
import { randomBytes } from "node:crypto";
|
|
26
|
+
import { mkdir, rename, rm, writeFile } from "node:fs/promises";
|
|
25
27
|
import path from "node:path";
|
|
26
|
-
import { Readable, Transform } from "node:stream";
|
|
27
|
-
import { pipeline } from "node:stream/promises";
|
|
28
28
|
import { launchdUserDomain, reregisterDarwinAgent } from "../autostart-darwin.js";
|
|
29
29
|
import { writeLine } from "./cli-io.js";
|
|
30
|
-
import { ask } from "./notes-door.js";
|
|
31
30
|
import { NOTETAKER_APP_EXECUTABLE, NOTETAKER_APP_LABEL, NOTETAKER_APP_NAME, notetakerAppPaths, renderNotetakerAppPlist, } from "./notes-app-plist.js";
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
writeLine(io.stdout, command.json ? JSON.stringify({ ok: true, ...json }) : human);
|
|
37
|
-
return 0;
|
|
38
|
-
}
|
|
39
|
-
function refuse(io, command, reason, sentence, fields = {}) {
|
|
40
|
-
writeLine(io.stderr, `${TAG} refused ${JSON.stringify({ reason, ...fields })}`);
|
|
41
|
-
if (command.json)
|
|
42
|
-
writeLine(io.stdout, JSON.stringify({ ok: false, reason, detail: sentence }));
|
|
43
|
-
else
|
|
44
|
-
writeLine(io.stderr, sentence);
|
|
45
|
-
return 1;
|
|
46
|
-
}
|
|
47
|
-
const exists = async (file) => stat(file).then(() => true, () => false);
|
|
48
|
-
async function readReceipt(file) {
|
|
49
|
-
try {
|
|
50
|
-
const parsed = JSON.parse(await readFile(file, "utf8"));
|
|
51
|
-
return typeof parsed.tag === "string" && typeof parsed.version === "string" ? parsed : null;
|
|
52
|
-
}
|
|
53
|
-
catch {
|
|
54
|
-
return null;
|
|
55
|
-
}
|
|
56
|
-
}
|
|
31
|
+
import { TAG, downloadToFile, exists, newestRelease, readReceipt, refuse, say, } from "./notes-app-shared.js";
|
|
32
|
+
import { runNotesAppWindows } from "./notes-app-windows.js";
|
|
33
|
+
export { downloadToFile };
|
|
34
|
+
const MAC = "darwin-arm64";
|
|
57
35
|
export async function runNotesApp(command, io, deps) {
|
|
58
36
|
const verb = command.appAction ?? "status";
|
|
37
|
+
if (deps.platform === "win32" && (deps.arch ?? "x64") === "x64")
|
|
38
|
+
return await runNotesAppWindows(command, io, deps);
|
|
59
39
|
if (deps.platform !== "darwin") {
|
|
60
|
-
const where = deps.platform === "win32" ?
|
|
61
|
-
return refuse(io, command, "platform_not_supported", `The BLI Notetaker app is not available on ${where} yet. It runs in the menu bar on Apple Silicon Macs
|
|
40
|
+
const where = deps.platform === "win32" ? `Windows on ${deps.arch ?? "this processor"}` : deps.platform === "linux" ? "Linux" : deps.platform;
|
|
41
|
+
return refuse(io, command, "platform_not_supported", `The BLI Notetaker app is not available on ${where} yet. It runs in the menu bar on Apple Silicon Macs and in the notification area on 64-bit (x64) Windows. \`cockpit notes record\` is the terminal way to record.`, { platform: deps.platform, arch: deps.arch ?? null });
|
|
62
42
|
}
|
|
63
43
|
const paths = notetakerAppPaths(deps.homeDir);
|
|
64
44
|
if (verb === "uninstall")
|
|
@@ -67,49 +47,6 @@ export async function runNotesApp(command, io, deps) {
|
|
|
67
47
|
return await install(command, io, deps, paths);
|
|
68
48
|
return await status(command, io, deps, paths);
|
|
69
49
|
}
|
|
70
|
-
/** Ask the paired Tower for the newest release; a refusal comes back as reason + sentence. */
|
|
71
|
-
async function newestRelease(deps) {
|
|
72
|
-
const door = await deps.door();
|
|
73
|
-
if (!("deviceToken" in door))
|
|
74
|
-
return { ok: false, reason: door.reason, detail: door.detail };
|
|
75
|
-
const answer = await ask(door, { path: "/api/notes/app/release", method: "GET", label: "notes app release", timeoutMs: RELEASE_DEADLINE_MS });
|
|
76
|
-
if (!answer.ok)
|
|
77
|
-
return answer;
|
|
78
|
-
const body = answer.body;
|
|
79
|
-
const sha = typeof body.sha256 === "string" ? body.sha256.toLowerCase() : "";
|
|
80
|
-
if (!body.tag || !body.version || !/^[0-9a-f]{64}$/.test(sha) || !body.zip?.url?.startsWith("https://")) {
|
|
81
|
-
return { ok: false, reason: "release_answer_unreadable", detail: "Tower answered without a release this command can install." };
|
|
82
|
-
}
|
|
83
|
-
return { ok: true, release: { tag: body.tag, version: body.version, sha256: sha, zip: body.zip }, door };
|
|
84
|
-
}
|
|
85
|
-
/** Stream `url` to `dest`, hashing as it goes. No credential is sent: the link is GitHub's own signed one. */
|
|
86
|
-
export async function downloadToFile(fetchImpl, url, dest) {
|
|
87
|
-
let response;
|
|
88
|
-
try {
|
|
89
|
-
response = await fetchImpl(url, { signal: AbortSignal.timeout(DOWNLOAD_DEADLINE_MS) });
|
|
90
|
-
}
|
|
91
|
-
catch {
|
|
92
|
-
return { ok: false, reason: "download_unreachable" };
|
|
93
|
-
}
|
|
94
|
-
if (!response.ok || !response.body)
|
|
95
|
-
return { ok: false, reason: `download_http_${response.status}` };
|
|
96
|
-
const hash = createHash("sha256");
|
|
97
|
-
let bytes = 0;
|
|
98
|
-
const meter = new Transform({
|
|
99
|
-
transform(chunk, _encoding, done) {
|
|
100
|
-
hash.update(chunk);
|
|
101
|
-
bytes += chunk.length;
|
|
102
|
-
done(null, chunk);
|
|
103
|
-
},
|
|
104
|
-
});
|
|
105
|
-
try {
|
|
106
|
-
await pipeline(Readable.fromWeb(response.body), meter, createWriteStream(dest, { mode: 0o600 }));
|
|
107
|
-
}
|
|
108
|
-
catch {
|
|
109
|
-
return { ok: false, reason: "download_interrupted" };
|
|
110
|
-
}
|
|
111
|
-
return { ok: true, bytes, sha256: hash.digest("hex") };
|
|
112
|
-
}
|
|
113
50
|
async function appleSilicon(deps) {
|
|
114
51
|
const answer = await deps.exec("sysctl", ["-n", "hw.optional.arm64"]).catch(() => ({ code: 1, stdout: "", stderr: "" }));
|
|
115
52
|
return answer.code === 0 && answer.stdout.trim() === "1";
|
|
@@ -118,7 +55,7 @@ async function install(command, io, deps, paths) {
|
|
|
118
55
|
if (!(await appleSilicon(deps))) {
|
|
119
56
|
return refuse(io, command, "apple_silicon_only", "The BLI Notetaker app is built for Apple Silicon Macs (M1 and later); this Mac is not one. `cockpit notes record` works here from a terminal.");
|
|
120
57
|
}
|
|
121
|
-
const found = await newestRelease(deps);
|
|
58
|
+
const found = await newestRelease(deps, MAC);
|
|
122
59
|
if (!found.ok)
|
|
123
60
|
return refuse(io, command, found.reason, found.detail);
|
|
124
61
|
const { release, door } = found;
|
|
@@ -200,7 +137,7 @@ async function status(command, io, deps, paths) {
|
|
|
200
137
|
const running = loaded && /\bstate = running\b/.test(printed.stdout);
|
|
201
138
|
const pid = Number(/\bpid = (\d+)/.exec(printed.stdout)?.[1] ?? NaN);
|
|
202
139
|
// The newest release is best effort: a status line without it is still an answer.
|
|
203
|
-
const newest = await newestRelease(deps);
|
|
140
|
+
const newest = await newestRelease(deps, MAC);
|
|
204
141
|
const newestTag = newest.ok ? newest.release.tag : null;
|
|
205
142
|
const update = Boolean(installed && newestTag && receipt?.tag !== newestTag);
|
|
206
143
|
writeLine(io.stderr, `${TAG} status ${JSON.stringify({ installed, tag: receipt?.tag ?? null, plist, loaded, running, newest: newestTag, newest_reason: newest.ok ? null : newest.reason })}`);
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BLI-23208: the Windows microphone half of `cockpit notes record`.
|
|
3
|
+
*
|
|
4
|
+
* Capture mechanism: `record.ps1` in the session directory, run by Windows
|
|
5
|
+
* PowerShell 5.1, which `Add-Type`s a small C# class that records through the
|
|
6
|
+
* legacy winmm `waveIn` API (16 kHz, mono, 16-bit PCM, `WAVE_MAPPER`). Why this
|
|
7
|
+
* one: nothing is downloaded or shipped as a binary (the npm package copier
|
|
8
|
+
* reads files as UTF-8), `winmm.dll` and the .NET C# compiler ship with every
|
|
9
|
+
* Windows, and `waveIn` hands us raw buffers, so WE decide when bytes reach the
|
|
10
|
+
* disk. WinRT `MediaCapture` was rejected: PowerShell 7 has no WinRT projection
|
|
11
|
+
* and the Media Foundation sink only guarantees a readable file when it is
|
|
12
|
+
* finalized, which a killed recorder never does.
|
|
13
|
+
*
|
|
14
|
+
* Crash safety is the contract `repairWavHeader` relies on: the recorder writes
|
|
15
|
+
* a 44-byte header with the size fields at 36 / 0, then appends and flushes
|
|
16
|
+
* every completed 100 ms buffer. A `TerminateProcess` leaves header plus audio
|
|
17
|
+
* up to the last ~100 ms; a clean stop rewrites the real sizes.
|
|
18
|
+
*
|
|
19
|
+
* Same session contract as macOS (`notes-record-capture.ts`): `audio.wav`,
|
|
20
|
+
* `ready` (after the first buffer really arrived), a `stop` marker polled every
|
|
21
|
+
* buffer, `done`, and `failed.txt` holding a closed reason label.
|
|
22
|
+
*
|
|
23
|
+
* With Settings > Privacy & security > Microphone off, waveIn still opens and
|
|
24
|
+
* delivers SILENCE. So the recorder reads the consent store before opening and
|
|
25
|
+
* about once a second while recording; any `Deny` is `mic_denied`.
|
|
26
|
+
*
|
|
27
|
+
* The recorder must outlive `record start` and a tray Job Object kill, so it is
|
|
28
|
+
* started through WMI (`Win32_Process.Create`: its parent is the WMI provider,
|
|
29
|
+
* not the CLI) with a `Start-Process` fallback (never a Node child: it would inherit the CLI's pipes).
|
|
30
|
+
*
|
|
31
|
+
* This file imports nothing from `notes-record-capture.ts` (that file imports
|
|
32
|
+
* this one).
|
|
33
|
+
*/
|
|
34
|
+
import { spawnSync } from "node:child_process";
|
|
35
|
+
import path from "node:path";
|
|
36
|
+
const TAG = "[notes record]";
|
|
37
|
+
const WMI_TIMEOUT_MS = 15_000;
|
|
38
|
+
/** Same quoting as `powershellLiteral` in `autostart-windows-scripts.ts`; not imported so this module stays free of that file's workspace imports. */
|
|
39
|
+
export const powershellLiteral = (value) => `'${value.replace(/'/gu, "''")}'`;
|
|
40
|
+
/** Windows PowerShell 5.1 by absolute path (same answer as `windowsPowerShellPath` in `autostart-windows-scripts.ts`); PATH is never consulted. */
|
|
41
|
+
export function windowsPowerShellPath(env = process.env) {
|
|
42
|
+
const windowsRoot = env.SystemRoot ?? env.WINDIR ?? "C:\\Windows";
|
|
43
|
+
return path.win32.join(windowsRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
|
|
44
|
+
}
|
|
45
|
+
/** Set to `start_process` to skip WMI (a diagnostic knob for comparing the two launch paths on a machine). */
|
|
46
|
+
export const LAUNCH_OVERRIDE_ENV = "BLI_NOTES_RECORD_LAUNCH";
|
|
47
|
+
export { RECORDER_SCRIPT_WIN32 } from "./notes-record-win32-script.js";
|
|
48
|
+
const POWERSHELL_FLAGS = ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden"];
|
|
49
|
+
const encodeCommand = (script) => Buffer.from(script, "utf16le").toString("base64");
|
|
50
|
+
/**
|
|
51
|
+
* The PowerShell that runs `record.ps1` for `dir`. The directory is one
|
|
52
|
+
* single-quoted literal and the script is read and compiled in-process, so
|
|
53
|
+
* execution policy, spaces, apostrophes and non-ASCII in the path cannot bite
|
|
54
|
+
* (no `-File`).
|
|
55
|
+
*/
|
|
56
|
+
export function recorderBootstrap(dir) {
|
|
57
|
+
return [
|
|
58
|
+
"$ErrorActionPreference = 'Stop'",
|
|
59
|
+
`$dir = ${powershellLiteral(dir)}`,
|
|
60
|
+
"try {",
|
|
61
|
+
" & ([scriptblock]::Create([IO.File]::ReadAllText((Join-Path $dir 'record.ps1')))) $dir",
|
|
62
|
+
"} catch {",
|
|
63
|
+
" try { [IO.File]::AppendAllText((Join-Path $dir 'recorder.log'), 'bootstrap_failed type=' + $_.Exception.GetType().Name + [Environment]::NewLine) } catch { <# best effort: a log line that cannot be written must not stop the recorder #> }",
|
|
64
|
+
" if (-not [IO.File]::Exists((Join-Path $dir 'failed.txt'))) { try { [IO.File]::WriteAllText((Join-Path $dir 'failed.txt'), 'recorder_failed') } catch { <# nothing else to tell: the CLI then reports recorder_exited, and recorder.log says why #> } }",
|
|
65
|
+
"}",
|
|
66
|
+
"",
|
|
67
|
+
].join("\r\n");
|
|
68
|
+
}
|
|
69
|
+
/** The recorder's argument list (also what the Start-Process fallback passes). */
|
|
70
|
+
export function recorderArgs(dir) {
|
|
71
|
+
return [...POWERSHELL_FLAGS, "-EncodedCommand", encodeCommand(recorderBootstrap(dir))];
|
|
72
|
+
}
|
|
73
|
+
/** The recorder as one Windows command line, for `Win32_Process.Create`. */
|
|
74
|
+
export function recorderCommandLine(powershell, dir) {
|
|
75
|
+
return [`"${powershell}"`, ...recorderArgs(dir)].join(" ");
|
|
76
|
+
}
|
|
77
|
+
/** The short PowerShell that starts `commandLine` through WMI and prints `ok <pid>` or `fail <code>`. */
|
|
78
|
+
export function wmiLaunchScript(commandLine) {
|
|
79
|
+
return [
|
|
80
|
+
"$ErrorActionPreference = 'Stop'",
|
|
81
|
+
"try { [Console]::OutputEncoding = [Text.Encoding]::ASCII } catch { <# an older host refuses; parseWmiLaunchOutput also reads UTF-16 and BOMs #> }",
|
|
82
|
+
"try {",
|
|
83
|
+
" $startup = New-CimInstance -ClassName Win32_ProcessStartup -ClientOnly -Property @{ ShowWindow = [uint16]0 }",
|
|
84
|
+
` $result = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{ CommandLine = ${powershellLiteral(commandLine)}; ProcessStartupInformation = $startup }`,
|
|
85
|
+
" if ([int]$result.ReturnValue -eq 0) { Write-Output ('ok ' + [int]$result.ProcessId) } else { Write-Output ('fail ' + [int]$result.ReturnValue) }",
|
|
86
|
+
"} catch {",
|
|
87
|
+
" Write-Output 'fail exception'",
|
|
88
|
+
"}",
|
|
89
|
+
"",
|
|
90
|
+
].join("\r\n");
|
|
91
|
+
}
|
|
92
|
+
/** A hidden PowerShell that starts `file args` through Start-Process (no handle inheritance) and prints `ok <pid>`. */
|
|
93
|
+
export function startProcessScript(file, args) {
|
|
94
|
+
return [
|
|
95
|
+
"$ErrorActionPreference = 'Stop'",
|
|
96
|
+
"try { [Console]::OutputEncoding = [Text.Encoding]::ASCII } catch { <# an older host refuses; parseWmiLaunchOutput also reads UTF-16 and BOMs #> }",
|
|
97
|
+
"try {",
|
|
98
|
+
` $p = Start-Process -FilePath ${powershellLiteral(file)} -ArgumentList @(${args.map(powershellLiteral).join(", ")}) -WindowStyle Hidden -PassThru`,
|
|
99
|
+
" Write-Output ('ok ' + [int]$p.Id)",
|
|
100
|
+
"} catch {",
|
|
101
|
+
" Write-Output 'fail exception'",
|
|
102
|
+
"}",
|
|
103
|
+
"",
|
|
104
|
+
].join("\r\n");
|
|
105
|
+
}
|
|
106
|
+
/** Decode what the WMI helper printed: ASCII, UTF-8 with a BOM, or UTF-16LE (Windows PowerShell may emit any), CRLF or LF. */
|
|
107
|
+
export function parseWmiLaunchOutput(output) {
|
|
108
|
+
let text;
|
|
109
|
+
if (typeof output === "string")
|
|
110
|
+
text = output;
|
|
111
|
+
else if (output.length >= 2 && output[0] === 0xff && output[1] === 0xfe)
|
|
112
|
+
text = output.subarray(2).toString("utf16le");
|
|
113
|
+
else if (output.includes(0))
|
|
114
|
+
text = output.toString("latin1");
|
|
115
|
+
else
|
|
116
|
+
text = output.toString("utf8");
|
|
117
|
+
const lines = text.split("\0").join("").split("\uFEFF").join("").split(/\r?\n/u).map((line) => line.trim());
|
|
118
|
+
for (const line of lines.reverse()) {
|
|
119
|
+
const ok = /^ok (\d+)$/u.exec(line);
|
|
120
|
+
if (ok) {
|
|
121
|
+
const pid = Number(ok[1]);
|
|
122
|
+
return pid > 0 ? { ok: true, pid } : { ok: false, code: "no_pid" };
|
|
123
|
+
}
|
|
124
|
+
const fail = /^fail (\S+)$/u.exec(line);
|
|
125
|
+
if (fail)
|
|
126
|
+
return { ok: false, code: fail[1].slice(0, 32) };
|
|
127
|
+
}
|
|
128
|
+
return { ok: false, code: "unparsed" };
|
|
129
|
+
}
|
|
130
|
+
const defaultWinLaunchDeps = () => ({
|
|
131
|
+
powershell: windowsPowerShellPath(),
|
|
132
|
+
env: process.env,
|
|
133
|
+
run: (file, args) => {
|
|
134
|
+
const result = spawnSync(file, args, { windowsHide: true, timeout: WMI_TIMEOUT_MS, stdio: ["ignore", "pipe", "ignore"] });
|
|
135
|
+
return { status: result.status, stdout: Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.alloc(0), error: result.error };
|
|
136
|
+
},
|
|
137
|
+
start: (file, args) => {
|
|
138
|
+
// Not a Node child: libuv creates children with handle inheritance on, so a recorder spawned here would hold
|
|
139
|
+
// the CLI's stdout pipe open for as long as it records (the tray reads `record start`'s answer from that pipe).
|
|
140
|
+
// Start-Process goes through ShellExecute, which inherits nothing; the short PowerShell in between exits at once.
|
|
141
|
+
const result = spawnSync(file, [...POWERSHELL_FLAGS, "-EncodedCommand", encodeCommand(startProcessScript(file, args))], {
|
|
142
|
+
windowsHide: true, timeout: WMI_TIMEOUT_MS, stdio: ["ignore", "pipe", "ignore"],
|
|
143
|
+
});
|
|
144
|
+
if (result.error || !Buffer.isBuffer(result.stdout))
|
|
145
|
+
return null;
|
|
146
|
+
const answer = parseWmiLaunchOutput(result.stdout);
|
|
147
|
+
return answer.ok ? answer.pid : null;
|
|
148
|
+
},
|
|
149
|
+
log: (message, fields) => console.error(`${TAG} ${message}`, JSON.stringify(fields)),
|
|
150
|
+
});
|
|
151
|
+
/** Start the recorder for the session in `dir`, detached from this process. Returns its pid, or null. */
|
|
152
|
+
export function launchRecorderWin32(dir, overrides = {}) {
|
|
153
|
+
const deps = { ...defaultWinLaunchDeps(), ...overrides };
|
|
154
|
+
const session = dir.split(/[\\/]/u).filter(Boolean).pop() ?? "unknown";
|
|
155
|
+
let wmiReturn = "skipped";
|
|
156
|
+
if (deps.env[LAUNCH_OVERRIDE_ENV] !== "start_process") {
|
|
157
|
+
const script = wmiLaunchScript(recorderCommandLine(deps.powershell, dir));
|
|
158
|
+
const ran = deps.run(deps.powershell, [...POWERSHELL_FLAGS, "-EncodedCommand", encodeCommand(script)]);
|
|
159
|
+
const answer = ran.error ? { ok: false, code: "run_error" } : parseWmiLaunchOutput(ran.stdout);
|
|
160
|
+
if (answer.ok) {
|
|
161
|
+
deps.log("recorder launch", { session, pid: answer.pid, launch: "wmi", wmi_return: "0" });
|
|
162
|
+
return answer.pid;
|
|
163
|
+
}
|
|
164
|
+
wmiReturn = answer.code;
|
|
165
|
+
deps.log("recorder launch wmi refused, falling back to start_process", { session, wmi_return: wmiReturn, exit: ran.status });
|
|
166
|
+
}
|
|
167
|
+
const pid = deps.start(deps.powershell, recorderArgs(dir));
|
|
168
|
+
deps.log(pid === null ? "recorder launch failed" : "recorder launch", { session, pid, launch: "start_process", wmi_return: wmiReturn });
|
|
169
|
+
return pid;
|
|
170
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* BLI-22979: the microphone half of `cockpit notes record` (Apple Silicon macOS
|
|
2
|
+
* BLI-22979: the microphone half of `cockpit notes record` (Apple Silicon macOS;
|
|
3
|
+
* the Windows backend is `notes-record-capture-win32.ts`, BLI-23208, behind the
|
|
4
|
+
* same session directory, marker files and WAV format).
|
|
3
5
|
*
|
|
4
6
|
* Capture mechanism: `osascript -l JavaScript` driving AVFoundation's
|
|
5
7
|
* AVAudioRecorder. Why this one: `osascript` ships with every Mac, so nothing
|
|
@@ -24,6 +26,7 @@ import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
|
24
26
|
import path from "node:path";
|
|
25
27
|
import { randomBytes } from "node:crypto";
|
|
26
28
|
import { getCollectorRuntimePaths } from "../local-state-paths.js";
|
|
29
|
+
import { launchRecorderWin32, RECORDER_SCRIPT_WIN32 } from "./notes-record-capture-win32.js";
|
|
27
30
|
export const RECORDING_SAMPLE_RATE = 16_000;
|
|
28
31
|
/** The JXA recorder. `argv[0]` is the session directory. Reasons it writes to `failed.txt`: mic_denied, recorder_failed. */
|
|
29
32
|
export const RECORDER_SCRIPT = `ObjC.import('AVFoundation');
|
|
@@ -119,8 +122,15 @@ export async function readReason(root, id) {
|
|
|
119
122
|
export async function requestStop(root, id) {
|
|
120
123
|
await writeFile(path.join(root, id, "stop"), "stop\n", { mode: 0o600 });
|
|
121
124
|
}
|
|
122
|
-
/**
|
|
123
|
-
|
|
125
|
+
/**
|
|
126
|
+
* Start the detached recorder in `dir`. Returns its pid, or null when it could not be launched.
|
|
127
|
+
* `platform` picks the backend: osascript on macOS, Windows PowerShell on Windows (BLI-23208).
|
|
128
|
+
*/
|
|
129
|
+
export function launchRecorder(dir, platform = process.platform) {
|
|
130
|
+
if (platform === "win32")
|
|
131
|
+
return launchRecorderWin32(dir);
|
|
132
|
+
if (platform !== "darwin")
|
|
133
|
+
return null;
|
|
124
134
|
try {
|
|
125
135
|
const child = spawn("/usr/bin/osascript", ["-l", "JavaScript", path.join(dir, "record.js"), dir], {
|
|
126
136
|
windowsHide: true,
|
|
@@ -144,9 +154,13 @@ export function processAlive(pid) {
|
|
|
144
154
|
return error.code === "EPERM";
|
|
145
155
|
}
|
|
146
156
|
}
|
|
147
|
-
|
|
157
|
+
/** Create the session directory and write the platform's recorder script into it: `record.js` (macOS) or `record.ps1` (Windows). */
|
|
158
|
+
export async function prepareSessionDir(root, id, platform = process.platform) {
|
|
148
159
|
const dir = sessionDir(root, id);
|
|
149
160
|
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
150
|
-
|
|
161
|
+
if (platform === "win32")
|
|
162
|
+
await writeFile(path.join(dir, "record.ps1"), RECORDER_SCRIPT_WIN32, { mode: 0o600 });
|
|
163
|
+
else
|
|
164
|
+
await writeFile(path.join(dir, "record.js"), RECORDER_SCRIPT, { mode: 0o600 });
|
|
151
165
|
return dir;
|
|
152
166
|
}
|