@bli-cockpit/cli 0.2.260 → 0.2.262

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.
@@ -19,6 +19,7 @@ import { parseGlossaryArgs } from "./glossary.js";
19
19
  import { parseSpendArgs } from "./spend.js";
20
20
  import { parsePushArgs } from "./push.js";
21
21
  import { parseRootsArgs } from "./roots.js";
22
+ import { parseSlackPrivacyArgs } from "./slack-privacy-args.js";
22
23
  import { parseMilestoneArgs } from "./local-args-tower-milestone.js";
23
24
  import { SURFACE_ALIASES } from "./local-help.js";
24
25
  import { parseAgentRulesArgs, parseAnalyzeArgs, parseAutostartArgs, parseBackfillArgs, parseCleanArgs, parseDoctorArgs, parseInstallArgs, parseLoginArgs, parseMemoryArgs, parseLogoutArgs, parseOnboardArgs, parseReleaseArgs, parseServeArgs, parseSessionsArgs, parseSourcesArgs, parseStartArgs, parseStatusArgs, parseSyncArgs, parseUpdateArgs, } from "./local-args-collector.js";
@@ -67,7 +68,8 @@ const COMMAND_PARSERS = new Map([
67
68
  ["ci", (_command, rest) => parseCiArgs(rest)],
68
69
  ["delivery", (_command, rest) => parseDeliveryArgs(rest)],
69
70
  ["ops", (_command, rest) => parseOpsArgs(rest)],
70
- ["slack", (_command, rest) => parseSlackArgs(rest)],
71
+ // BLI-23244: `slack privacy` has its own flags and its own file.
72
+ ["slack", (_command, rest) => (rest.includes("privacy") ? parseSlackPrivacyArgs(rest.filter((arg) => arg !== "privacy")) : parseSlackArgs(rest))],
71
73
  ["settings", (_command, rest) => parseSettingsArgs(rest)],
72
74
  ["team", (_command, rest) => parseTeamArgs(rest)],
73
75
  ["workbook", (_command, rest) => parseWorkbookArgs(rest)],
@@ -304,7 +304,13 @@ export const TOWER_COMMAND_HELP = [
304
304
  [
305
305
  "slack",
306
306
  [
307
- "Usage: cockpit slack [connect [--workspace bli|blue_pearl] | disconnect --workspace bli|blue_pearl | coverage [--workspace bli|blue_pearl] [--stale-only] [--list] | read [filters] [--format compact|full]] [--json]",
307
+ "Usage: cockpit slack [connect [--workspace bli|blue_pearl] | disconnect --workspace bli|blue_pearl | coverage [--workspace bli|blue_pearl] [--stale-only] [--list] | read [filters] [--format compact|full] | privacy] [--json]",
308
+ "",
309
+ " cockpit slack privacy (founders only)",
310
+ " Lists which Slack conversations Tower keeps out and lets in, and the founders' Slack ids.",
311
+ " A DM or group DM that includes a founder, and any channel a founder excludes, never enters",
312
+ " Tower unless a founder lets it in. Ids only (C…, D…, G…), never names. Changing a list needs",
313
+ " a signed-in browser session (ticket 23265), never this machine's token. Anyone else: owner_only.",
308
314
  "",
309
315
  " cockpit slack coverage",
310
316
  " What Cockpit's Slack bot can see: how many channels it is in, what each",
@@ -6,8 +6,8 @@ export const NOTES_HELP = [
6
6
  "Usage: cockpit notes [list|show <id>|shelf|shelves|folders|mkdir <path>|rmdir <path>|rename <path>|upload <paths...> [--wait]|paste|share <id>|unshare <id>|summarize <id>|move <id>|accounts|connect|detach <id>|sync <id>|sync-status [<id>]|recordings <note-id>|recording <id>|record start|stop|status|cancel|app install|uninstall|status] [flags]",
7
7
  "Audio uploads: MP3, M4A, WAV, WebM and OGG, up to 1 GB. Over 4 MB (a whole meeting) the file goes straight to storage and is transcribed in the background; --wait then polls for up to 4 hours and prints progress, otherwise for at most 330 seconds.",
8
8
  "",
9
- "record start|stop [<session-id>]|status|cancel — record a meeting in a room from this Mac's microphone (Apple Silicon macOS; Windows says it is not supported yet). Nothing leaves the machine while recording and there is no auto-stop. `stop` trims the silence away locally, uploads only the speech, and Tower writes a note with Speaker 1, Speaker 2... lines (add --wait to watch the transcription). `cancel` stops and KEEPS the file; `status` lists what is recording or waiting; `stop <session-id>` finishes a cancelled or crashed session. The original recording stays on this machine.",
10
- "app install|uninstall|status [--json] — the BLI Notetaker menu-bar app (Apple Silicon macOS; Windows and Linux say they are not supported yet). `install` asks Tower for the newest release, downloads it, refuses it if its sha256 differs, puts it in ~/Applications/BLI Notetaker.app (replacing an older one), clears the download quarantine and starts it at login with a LaunchAgent. The menu runs the `record` verbs above: Record, Stop, Open last note, Finish last recording. Log: ~/Library/Logs/BLI Notetaker.log. `uninstall` removes the app and the LaunchAgent and keeps your recordings; `status` says what is installed, whether it runs, and whether a newer release exists.",
9
+ "record start|stop [<session-id>]|status|cancel — record a meeting in a room from this machine's microphone (Apple Silicon macOS and Windows; Linux says it is not supported). On Windows the microphone needs Settings > Privacy & security > Microphone on, including Let desktop apps access your microphone. Nothing leaves the machine while recording and there is no auto-stop. `stop` trims the silence away locally, uploads only the speech, and Tower writes a note with Speaker 1, Speaker 2... lines (add --wait to watch the transcription). `cancel` stops and KEEPS the file; `status` lists what is recording or waiting; `stop <session-id>` finishes a cancelled or crashed session. The original recording stays on this machine.",
10
+ "app install|uninstall|status [--json] — the BLI Notetaker app (Apple Silicon macOS menu bar, or 64-bit Windows 10/11 taskbar notification area; Linux and Windows on Arm say they are not supported yet). `install` asks Tower for the newest release for this machine, downloads it, refuses it if its sha256 differs, and starts it at login: on the Mac it goes in ~/Applications/BLI Notetaker.app (replacing an older one, download quarantine cleared) with a LaunchAgent; on Windows it goes in %LOCALAPPDATA%\\Programs\\BLI Notetaker\\bli-notetaker.exe with a Startup entry (HKCU Run value, no admin; Windows does not block it because the CLI downloaded it). The menu runs the `record` verbs above: Record, Stop, Open last note, Finish last recording. Log: ~/Library/Logs/BLI Notetaker.log on the Mac, %LOCALAPPDATA%\\BLI Notetaker\\notetaker.log on Windows. `uninstall` removes the app and its startup entry and keeps your recordings and the log; `status` says what is installed, whether it runs, whether it starts at login, and whether a newer release exists.",
11
11
  "folders [--tree] [--json]: list folders and visible note counts.",
12
12
  "mkdir <path> [--json]: create a folder and any missing ancestors.",
13
13
  "rename <path> --name <name> [--json]: rename a folder and its descendant paths.",
@@ -0,0 +1,84 @@
1
+ /**
2
+ * What the macOS and Windows halves of `cockpit notes app` share (BLI-23208): the dependency bag, the Tower release
3
+ * question (platform-aware), the checked download, and the one refusal / answer shape. Sibling of `notes-app.ts`
4
+ * (router and the Mac half) and `notes-app-windows.ts`.
5
+ */
6
+ import { createHash } from "node:crypto";
7
+ import { createWriteStream } from "node:fs";
8
+ import { readFile, stat } from "node:fs/promises";
9
+ import { Readable, Transform } from "node:stream";
10
+ import { pipeline } from "node:stream/promises";
11
+ import { writeLine } from "./cli-io.js";
12
+ import { ask } from "./notes-door.js";
13
+ export const TAG = "[notes app]";
14
+ const RELEASE_DEADLINE_MS = 30_000;
15
+ const DOWNLOAD_DEADLINE_MS = 10 * 60_000;
16
+ export function say(io, command, human, json) {
17
+ writeLine(io.stdout, command.json ? JSON.stringify({ ok: true, ...json }) : human);
18
+ return 0;
19
+ }
20
+ export function refuse(io, command, reason, sentence, fields = {}) {
21
+ writeLine(io.stderr, `${TAG} refused ${JSON.stringify({ reason, ...fields })}`);
22
+ if (command.json)
23
+ writeLine(io.stdout, JSON.stringify({ ok: false, reason, detail: sentence }));
24
+ else
25
+ writeLine(io.stderr, sentence);
26
+ return 1;
27
+ }
28
+ export const exists = async (file) => stat(file).then(() => true, () => false);
29
+ export async function readReceipt(file) {
30
+ try {
31
+ const parsed = JSON.parse((await readFile(file, "utf8")).replace(/^/, ""));
32
+ return typeof parsed.tag === "string" && typeof parsed.version === "string" ? parsed : null;
33
+ }
34
+ catch {
35
+ return null;
36
+ }
37
+ }
38
+ /** Ask the paired Tower for the newest release for `platform`; a refusal comes back as reason + sentence. */
39
+ export async function newestRelease(deps, platform) {
40
+ const door = await deps.door();
41
+ if (!("deviceToken" in door))
42
+ return { ok: false, reason: door.reason, detail: door.detail };
43
+ const answer = await ask(door, { path: `/api/notes/app/release?platform=${platform}`, method: "GET", label: "notes app release", timeoutMs: RELEASE_DEADLINE_MS });
44
+ if (!answer.ok)
45
+ return answer;
46
+ const body = answer.body;
47
+ const sha = typeof body.sha256 === "string" ? body.sha256.toLowerCase() : "";
48
+ if (!body.tag || !body.version || !/^[0-9a-f]{64}$/.test(sha) || !body.zip?.url?.startsWith("https://")) {
49
+ return { ok: false, reason: "release_answer_unreadable", detail: "Tower answered without a release this command can install." };
50
+ }
51
+ // A door older than BLI-23208 ignores `?platform=` and answers the Mac zip: never install that on another machine.
52
+ if (platform !== "darwin-arm64" && body.platform !== platform) {
53
+ return { ok: false, reason: "release_platform_mismatch", detail: `Tower answered for ${body.platform ?? "an unnamed platform"}, not ${platform}. Its release door needs the Windows update before this machine can install the app.` };
54
+ }
55
+ return { ok: true, release: { tag: body.tag, version: body.version, sha256: sha, zip: body.zip }, door };
56
+ }
57
+ /** Stream `url` to `dest`, hashing as it goes. No credential is sent: the link is GitHub's own signed one. */
58
+ export async function downloadToFile(fetchImpl, url, dest) {
59
+ let response;
60
+ try {
61
+ response = await fetchImpl(url, { signal: AbortSignal.timeout(DOWNLOAD_DEADLINE_MS) });
62
+ }
63
+ catch {
64
+ return { ok: false, reason: "download_unreachable" };
65
+ }
66
+ if (!response.ok || !response.body)
67
+ return { ok: false, reason: `download_http_${response.status}` };
68
+ const hash = createHash("sha256");
69
+ let bytes = 0;
70
+ const meter = new Transform({
71
+ transform(chunk, _encoding, done) {
72
+ hash.update(chunk);
73
+ bytes += chunk.length;
74
+ done(null, chunk);
75
+ },
76
+ });
77
+ try {
78
+ await pipeline(Readable.fromWeb(response.body), meter, createWriteStream(dest, { mode: 0o600 }));
79
+ }
80
+ catch {
81
+ return { ok: false, reason: "download_interrupted" };
82
+ }
83
+ return { ok: true, bytes, sha256: hash.digest("hex") };
84
+ }
@@ -0,0 +1,220 @@
1
+ /**
2
+ * `cockpit notes app install | uninstall | status` on Windows (BLI-23208): the same contract as the Mac half in
3
+ * `notes-app.ts`, with the Windows parts swapped in.
4
+ *
5
+ * install asks Tower for the `win32-x64` release, downloads the zip with Node (so it carries no Mark-of-the-Web
6
+ * and SmartScreen has nothing to block), REFUSES it if the sha256 differs, unpacks it with the tar.exe
7
+ * Windows 10 1803+ ships, stops a running copy, replaces
8
+ * `%LOCALAPPDATA%\Programs\BLI Notetaker\bli-notetaker.exe`, writes `install-receipt.json`, registers the
9
+ * HKCU Run value (no admin, no Task Scheduler) and starts the app now, windowless and detached.
10
+ * uninstall stops it, deletes the Run value and the install directory. Recordings and the log stay.
11
+ * status installed, startup registered (and pointing here), running (pid), newest release, update available.
12
+ *
13
+ * Every external program is addressed by its System32 path (a Git-for-Windows `tar` earlier on PATH must not win).
14
+ * Output of `reg` and `tasklist` is read defensively: NULs, a BOM and localised "no match" prose are all tolerated.
15
+ */
16
+ import { randomBytes } from "node:crypto";
17
+ import { mkdir, rename, rm, writeFile } from "node:fs/promises";
18
+ import { execFile } from "node:child_process";
19
+ import path from "node:path";
20
+ import { writeLine } from "./cli-io.js";
21
+ import { parseWmiLaunchOutput, startProcessScript, windowsPowerShellPath } from "./notes-record-capture-win32.js";
22
+ import { TAG, downloadToFile, exists, newestRelease, readReceipt, refuse, say } from "./notes-app-shared.js";
23
+ export const NOTETAKER_WIN_EXE = "bli-notetaker.exe";
24
+ export const NOTETAKER_WIN_RUN_VALUE = "BLI Notetaker";
25
+ export const NOTETAKER_WIN_RUN_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run";
26
+ const WIN = "win32-x64";
27
+ const REPLACE_ATTEMPTS = 7;
28
+ const REPLACE_WAIT_MS = 500;
29
+ const BUSY = new Set(["EPERM", "EBUSY", "EACCES"]);
30
+ const NONE = { code: 1, stdout: "", stderr: "" };
31
+ /** `%LOCALAPPDATA%` (or, failing that, the Local sibling of `%APPDATA%`, or `<home>\AppData\Local`). */
32
+ export function notetakerWindowsPaths(env, homeDir, p = path.win32) {
33
+ const local = env.LOCALAPPDATA || (env.APPDATA ? p.join(p.dirname(env.APPDATA), "Local") : p.join(homeDir, "AppData", "Local"));
34
+ const programs = p.join(local, "Programs");
35
+ const dir = p.join(programs, "BLI Notetaker");
36
+ return { programs, dir, exe: p.join(dir, NOTETAKER_WIN_EXE), receipt: p.join(dir, "install-receipt.json"), log: p.join(local, "BLI Notetaker", "notetaker.log") };
37
+ }
38
+ export function windowsSystemTool(env, name, p = path.win32) {
39
+ return p.join(env.SystemRoot ?? env.WINDIR ?? "C:\\Windows", "System32", name);
40
+ }
41
+ /** The Run value: the quoted exe, then `--tower-url` when the address is plain enough to sit on a command line. */
42
+ export function runValueCommand(exe, towerUrl) {
43
+ const plain = towerUrl !== undefined && /^https?:\/\/[^\s"]+$/.test(towerUrl);
44
+ return `"${exe}"${plain ? ` --tower-url ${towerUrl}` : ""}`;
45
+ }
46
+ /** `reg add` arguments. Node quotes the `/d` value for CreateProcess (`\"` for each embedded quote), which reg.exe reads back. */
47
+ export const regAddArgs = (value) => ["add", NOTETAKER_WIN_RUN_KEY, "/v", NOTETAKER_WIN_RUN_VALUE, "/t", "REG_SZ", "/d", value, "/f"];
48
+ const clean = (text) => text.replace(/\u0000/g, "").replace(/^\uFEFF/, "");
49
+ /** `reg query ... /v "BLI Notetaker"` → the value's data, or null when there is none. */
50
+ export function parseRunValue(stdout) {
51
+ const match = /^\s+BLI Notetaker\s+REG_(?:SZ|EXPAND_SZ)\s+(.*?)\s*$/im.exec(clean(stdout));
52
+ return match?.[1] ?? null;
53
+ }
54
+ /** First token of a Run value: the quoted exe path, or the text up to the first space. */
55
+ export function runValueExe(value) {
56
+ return value.startsWith('"') ? value.slice(1).split('"')[0] ?? "" : value.split(" ")[0] ?? "";
57
+ }
58
+ /** `tasklist /FO CSV /NH` → the pid of the first `bli-notetaker.exe` row. Prose in any language means none. */
59
+ export function parseTasklistPid(stdout) {
60
+ const match = /^"bli-notetaker\.exe","(\d+)"/im.exec(clean(stdout));
61
+ return match ? Number(match[1]) : null;
62
+ }
63
+ /**
64
+ * Start the app through Start-Process (ShellExecute), never as a Node child: libuv creates children with handle
65
+ * inheritance on, so a Node-spawned app held this CLI's stdout pipe for as long as it ran and anything reading
66
+ * `install` through a pipe (a script, an agent) waited until the app quit. Seen on a Windows 11 box.
67
+ */
68
+ async function defaultLaunch(exe, args) {
69
+ const powershell = windowsPowerShellPath();
70
+ const script = startProcessScript(exe, args);
71
+ return await new Promise((resolve) => {
72
+ execFile(powershell, ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")], { windowsHide: true, timeout: 20_000, encoding: "buffer" }, (error, stdout) => {
73
+ if (error && !stdout?.length)
74
+ return resolve({ ok: false, detail: error.code?.toString() ?? "start_process_failed" });
75
+ const answer = parseWmiLaunchOutput(stdout);
76
+ resolve(answer.ok ? { ok: true } : { ok: false, detail: `start_process_${answer.code}` });
77
+ });
78
+ });
79
+ }
80
+ const timer = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
81
+ /** Run `action` until it stops failing with a "still in use" code, for about three seconds. */
82
+ export async function whileBusy(deps, action) {
83
+ for (let attempt = 1;; attempt += 1) {
84
+ try {
85
+ await action();
86
+ return { ok: true, attempts: attempt };
87
+ }
88
+ catch (error) {
89
+ const code = error.code ?? "unknown";
90
+ if (!BUSY.has(code) || attempt >= REPLACE_ATTEMPTS)
91
+ return { ok: false, code, attempts: attempt };
92
+ await (deps.sleep ?? timer)(REPLACE_WAIT_MS);
93
+ }
94
+ }
95
+ }
96
+ export async function runNotesAppWindows(command, io, deps) {
97
+ const env = deps.env ?? process.env;
98
+ const p = deps.pathApi ?? path.win32;
99
+ const paths = notetakerWindowsPaths(env, deps.homeDir, p);
100
+ const tool = (name) => windowsSystemTool(env, name, p);
101
+ const run = async (name, args) => await deps.exec(tool(name), args).catch(() => NONE);
102
+ const verb = command.appAction ?? "status";
103
+ if (verb === "install")
104
+ return await install(command, io, deps, paths, run, tool);
105
+ if (verb === "uninstall")
106
+ return await uninstall(command, io, deps, paths, run);
107
+ return await status(command, io, deps, paths, run);
108
+ }
109
+ const stopApp = (run) => run("taskkill.exe", ["/F", "/T", "/IM", NOTETAKER_WIN_EXE]);
110
+ async function install(command, io, deps, paths, run, tool) {
111
+ const p = deps.pathApi ?? path.win32;
112
+ const found = await newestRelease(deps, WIN);
113
+ if (!found.ok)
114
+ return refuse(io, command, found.reason, found.detail, { platform: WIN });
115
+ const { release, door } = found;
116
+ const previous = await readReceipt(paths.receipt);
117
+ writeLine(io.stderr, `${TAG} release ${JSON.stringify({ platform: WIN, tag: release.tag, zip_bytes: release.zip.bytes, previous: previous?.tag ?? null })}`);
118
+ if (!command.json)
119
+ writeLine(io.stderr, `Downloading BLI Notetaker ${release.version} (${(release.zip.bytes / 1_000_000).toFixed(1)} MB)...`);
120
+ await mkdir(paths.programs, { recursive: true });
121
+ const staging = p.join(paths.programs, `.bli-notetaker-install-${randomBytes(4).toString("hex")}`);
122
+ await mkdir(staging, { recursive: true });
123
+ try {
124
+ const zip = p.join(staging, "app.zip");
125
+ const downloaded = await downloadToFile(io.fetch, release.zip.url, zip);
126
+ if (!downloaded.ok) {
127
+ return refuse(io, command, downloaded.reason, `The download did not finish (${downloaded.reason}). Nothing was installed; try again.`);
128
+ }
129
+ if (downloaded.sha256 !== release.sha256) {
130
+ return refuse(io, command, "sha256_mismatch", "The downloaded app does not match the checksum its release publishes, so it was not installed. Nothing on this PC changed.", { tag: release.tag, bytes: downloaded.bytes, expected_prefix: release.sha256.slice(0, 12), got_prefix: downloaded.sha256.slice(0, 12) });
131
+ }
132
+ writeLine(io.stderr, `${TAG} downloaded ${JSON.stringify({ tag: release.tag, bytes: downloaded.bytes, sha256_prefix: downloaded.sha256.slice(0, 12), reason: "sha256_match" })}`);
133
+ const unpacked = p.join(staging, "unpacked");
134
+ await mkdir(unpacked, { recursive: true });
135
+ const untar = await deps.exec(tool("tar.exe"), ["-xf", zip, "-C", unpacked]).catch(() => NONE);
136
+ const stagedExe = p.join(unpacked, NOTETAKER_WIN_EXE);
137
+ if (untar.code !== 0 || !(await exists(stagedExe))) {
138
+ return refuse(io, command, "bundle_unreadable", "The downloaded zip did not hold bli-notetaker.exe (or this PC's tar.exe could not unpack it). Nothing was installed.", { tar_exit: untar.code });
139
+ }
140
+ // Stop the running copy first. A recording in progress keeps going: the recorder is its own process, not the tray's child.
141
+ const stopped = await stopApp(run);
142
+ await mkdir(paths.dir, { recursive: true });
143
+ const replaced = await exists(paths.exe);
144
+ const moved = await whileBusy(deps, () => rename(stagedExe, paths.exe));
145
+ if (!moved.ok) {
146
+ return refuse(io, command, "exe_in_use", "BLI Notetaker is still running and Windows will not let it be replaced. Quit it from the notification area icon (click ^ if it is hidden), then run `cockpit notes app install` again.", { tag: release.tag, error_code: moved.code, attempts: moved.attempts, taskkill_exit: stopped.code });
147
+ }
148
+ writeLine(io.stderr, `${TAG} unpacked ${JSON.stringify({ replaced, previous: previous?.tag ?? null, taskkill_exit: stopped.code, replace_attempts: moved.attempts })}`);
149
+ const receipt = { tag: release.tag, version: release.version, sha256: release.sha256, installed_at: deps.now().toISOString() };
150
+ await writeFile(paths.receipt, `${JSON.stringify(receipt)}\n`, "utf8");
151
+ const registered = await run("reg.exe", regAddArgs(runValueCommand(paths.exe, door.dashboardUrl)));
152
+ writeLine(io.stderr, `${TAG} startup ${JSON.stringify({ registered: registered.code === 0, reg_exit: registered.code })}`);
153
+ const started = await (deps.launch ?? defaultLaunch)(paths.exe, door.dashboardUrl ? ["--tower-url", door.dashboardUrl] : []);
154
+ writeLine(io.stderr, `${TAG} installed ${JSON.stringify({ tag: release.tag, replaced, startup_registered: registered.code === 0, started: started.ok, launch_detail: started.detail ?? null })}`);
155
+ if (registered.code !== 0 || !started.ok) {
156
+ return refuse(io, command, registered.code !== 0 ? "startup_registration_failed" : "launch_failed", `BLI Notetaker ${release.version} is installed at ${paths.exe}, but ${registered.code !== 0 ? "Windows would not register it to start when you sign in" : `it could not be started (${started.detail ?? "unknown"})`}. Run \`cockpit notes app install\` again, or open it from that folder.`, { tag: release.tag, reg_exit: registered.code });
157
+ }
158
+ return say(io, command, [
159
+ `Installed BLI Notetaker ${release.version}${replaced ? ` (replaced ${previous?.version ?? "an older copy"})` : ""} and started it.`,
160
+ "Look for the BLI Notetaker icon in the taskbar notification area (click ^ if it is hidden): Record, Stop, Open last note. It starts again when you sign in.",
161
+ `App: ${paths.exe}`,
162
+ `Log: ${paths.log}`,
163
+ "The app is not code-signed. Windows SmartScreen does not block it because this command downloaded it itself, so it carries no \"downloaded from the internet\" mark. The first Record uses the microphone allowed in Settings > Privacy & security > Microphone.",
164
+ ].join("\n"), { installed: true, platform: WIN, tag: release.tag, version: release.version, replaced, previous: previous?.tag ?? null, app: paths.exe, startup: { registered: true, key: `${NOTETAKER_WIN_RUN_KEY}\\${NOTETAKER_WIN_RUN_VALUE}` }, log: paths.log });
165
+ }
166
+ finally {
167
+ await rm(staging, { recursive: true, force: true }).catch(() => undefined);
168
+ }
169
+ }
170
+ async function uninstall(command, io, deps, paths, run) {
171
+ // taskkill exits 128 when nothing was running, which is fine.
172
+ const stopped = await stopApp(run);
173
+ const had = { app: await exists(paths.exe), receipt: await exists(paths.receipt), startup: parseRunValue((await run("reg.exe", ["query", NOTETAKER_WIN_RUN_KEY, "/v", NOTETAKER_WIN_RUN_VALUE])).stdout) !== null };
174
+ const deleted = await run("reg.exe", ["delete", NOTETAKER_WIN_RUN_KEY, "/v", NOTETAKER_WIN_RUN_VALUE, "/f"]);
175
+ const dirGone = await whileBusy(deps, () => rm(paths.dir, { recursive: true, force: true }));
176
+ writeLine(io.stderr, `${TAG} uninstalled ${JSON.stringify({ ...had, taskkill_exit: stopped.code, reg_delete_exit: deleted.code, dir_removed: dirGone.ok, error_code: dirGone.ok ? null : dirGone.code })}`);
177
+ if (!dirGone.ok) {
178
+ return refuse(io, command, "exe_in_use", "BLI Notetaker is still running and Windows will not let its folder be removed. Quit it from the notification area icon, then run `cockpit notes app uninstall` again.", { error_code: dirGone.code, attempts: dirGone.attempts });
179
+ }
180
+ if (!had.app && !had.startup)
181
+ return say(io, command, "BLI Notetaker was not installed. Nothing to remove.", { removed: false });
182
+ return say(io, command, [
183
+ "Removed BLI Notetaker: the app, its startup entry and its receipt.",
184
+ `Your recordings stay on this PC (\`cockpit notes record status\` lists any not yet uploaded), and so does the log: ${paths.log}`,
185
+ ].join("\n"), { removed: true, ...had });
186
+ }
187
+ async function status(command, io, deps, paths, run) {
188
+ const receipt = await readReceipt(paths.receipt);
189
+ const installed = await exists(paths.exe);
190
+ const value = parseRunValue((await run("reg.exe", ["query", NOTETAKER_WIN_RUN_KEY, "/v", NOTETAKER_WIN_RUN_VALUE])).stdout);
191
+ const p = deps.pathApi ?? path.win32;
192
+ const pointsHere = value !== null && p.normalize(runValueExe(value)).toLowerCase() === p.normalize(paths.exe).toLowerCase();
193
+ const pid = parseTasklistPid((await run("tasklist.exe", ["/FI", `IMAGENAME eq ${NOTETAKER_WIN_EXE}`, "/FO", "CSV", "/NH"])).stdout);
194
+ const running = pid !== null;
195
+ // The newest release is best effort: a status line without it is still an answer.
196
+ const newest = await newestRelease(deps, WIN);
197
+ const newestTag = newest.ok ? newest.release.tag : null;
198
+ const update = Boolean(installed && newestTag && receipt?.tag !== newestTag);
199
+ writeLine(io.stderr, `${TAG} status ${JSON.stringify({ platform: WIN, installed, tag: receipt?.tag ?? null, startup_registered: value !== null, points_here: pointsHere, running, newest: newestTag, newest_reason: newest.ok ? null : newest.reason })}`);
200
+ const json = {
201
+ platform: WIN, installed, tag: receipt?.tag ?? null, version: receipt?.version ?? null, installed_at: receipt?.installed_at ?? null,
202
+ app: paths.exe, startup: { registered: value !== null, points_here: pointsHere, key: `${NOTETAKER_WIN_RUN_KEY}\\${NOTETAKER_WIN_RUN_VALUE}` },
203
+ process: { running, pid }, log: paths.log,
204
+ newest: newest.ok ? { tag: newest.release.tag, version: newest.release.version } : { reason: newest.reason },
205
+ update_available: update,
206
+ };
207
+ const lines = installed
208
+ ? [
209
+ `BLI Notetaker ${receipt?.version ?? "(version unknown)"} is installed: ${paths.exe}`,
210
+ running ? `Running (pid ${pid}). Look for its icon in the taskbar notification area (click ^ if it is hidden).` : "Not running; run `cockpit notes app install` to start it now, or it starts when you sign in.",
211
+ value === null ? "Not set to start when you sign in; run `cockpit notes app install` to register it." : pointsHere ? "Starts again when you sign in." : "Its sign-in entry points at a different file; run `cockpit notes app install` to repair it.",
212
+ newest.ok ? (update ? `A newer release exists: ${newest.release.version}. Run \`cockpit notes app install\` to update.` : "It is the newest release.") : `Could not check for a newer release (${newest.reason}).`,
213
+ `Log: ${paths.log}`,
214
+ ]
215
+ : [
216
+ "BLI Notetaker is not installed. Run `cockpit notes app install`.",
217
+ newest.ok ? `Newest release: ${newest.release.version}.` : `Could not check the newest release (${newest.reason}).`,
218
+ ];
219
+ return say(io, command, lines.join("\n"), json);
220
+ }
@@ -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 only; Windows and Linux are refused by name. The app is
19
- * a shell over `cockpit notes record`; it captures nothing itself. Its layout
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 { createHash, randomBytes } from "node:crypto";
23
- import { createWriteStream } from "node:fs";
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
- const TAG = "[notes app]";
33
- const RELEASE_DEADLINE_MS = 30_000;
34
- const DOWNLOAD_DEADLINE_MS = 10 * 60_000;
35
- function say(io, command, human, json) {
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" ? "Windows" : deps.platform === "linux" ? "Linux" : deps.platform;
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; Windows is a follow-up. \`cockpit notes record\` is the terminal way to record.`, { platform: deps.platform });
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
- /** Start the detached recorder in `dir`. Returns its pid, or null when it could not be launched. */
123
- export function launchRecorder(dir) {
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
- export async function prepareSessionDir(root, id) {
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
- await writeFile(path.join(dir, "record.js"), RECORDER_SCRIPT, { mode: 0o600 });
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
  }
@@ -0,0 +1,332 @@
1
+ /**
2
+ * BLI-23208: the Windows recorder itself, `record.ps1` and the C# it compiles. Split from
3
+ * `notes-record-capture-win32.ts` (which launches it) so each file stays readable; the design notes live there.
4
+ * Pure text: nothing here runs on import.
5
+ */
6
+ /**
7
+ * The C# recorder. C# 5 and APIs of .NET Framework 4.x AND .NET 6+ only, so it
8
+ * compiles under Windows PowerShell 5.1 (CodeDom) and PowerShell 7 (Roslyn).
9
+ * Unmanaged WAVEHDRs and buffers, polled for WHDR_DONE: no delegate callback
10
+ * that the GC could collect. Exit codes: 0 stopped, 2 no microphone, 3 mic
11
+ * denied, 4 recorder failed.
12
+ */
13
+ const RECORDER_CSHARP = String.raw `using System;
14
+ using System.Diagnostics;
15
+ using System.IO;
16
+ using System.Runtime.InteropServices;
17
+ using System.Text;
18
+ using System.Threading;
19
+ using Microsoft.Win32;
20
+
21
+ public sealed class BliRecorder
22
+ {
23
+ [StructLayout(LayoutKind.Sequential, Pack = 1)]
24
+ public struct WaveFormatEx
25
+ {
26
+ public ushort wFormatTag;
27
+ public ushort nChannels;
28
+ public uint nSamplesPerSec;
29
+ public uint nAvgBytesPerSec;
30
+ public ushort nBlockAlign;
31
+ public ushort wBitsPerSample;
32
+ public ushort cbSize;
33
+ }
34
+
35
+ [StructLayout(LayoutKind.Sequential)]
36
+ public struct WaveHdr
37
+ {
38
+ public IntPtr lpData;
39
+ public uint dwBufferLength;
40
+ public uint dwBytesRecorded;
41
+ public IntPtr dwUser;
42
+ public uint dwFlags;
43
+ public uint dwLoops;
44
+ public IntPtr lpNext;
45
+ public IntPtr reserved;
46
+ }
47
+
48
+ [DllImport("winmm.dll")] static extern uint waveInGetNumDevs();
49
+ [DllImport("winmm.dll")] static extern uint waveInOpen(out IntPtr hwi, uint deviceId, ref WaveFormatEx fmt, IntPtr callback, IntPtr instance, uint flags);
50
+ [DllImport("winmm.dll")] static extern uint waveInPrepareHeader(IntPtr hwi, IntPtr hdr, uint size);
51
+ [DllImport("winmm.dll")] static extern uint waveInUnprepareHeader(IntPtr hwi, IntPtr hdr, uint size);
52
+ [DllImport("winmm.dll")] static extern uint waveInAddBuffer(IntPtr hwi, IntPtr hdr, uint size);
53
+ [DllImport("winmm.dll")] static extern uint waveInStart(IntPtr hwi);
54
+ [DllImport("winmm.dll")] static extern uint waveInReset(IntPtr hwi);
55
+ [DllImport("winmm.dll")] static extern uint waveInClose(IntPtr hwi);
56
+
57
+ const uint WaveMapper = 0xFFFFFFFF;
58
+ const uint WhdrDone = 1;
59
+ const int BufferCount = 8;
60
+ const int BufferBytes = 3200;
61
+ const string Consent = "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\CapabilityAccessManager\\ConsentStore\\microphone";
62
+
63
+ readonly string dir;
64
+ bool ready;
65
+ long total;
66
+ int buffers;
67
+ FileStream file;
68
+ byte[] scratch = new byte[BufferBytes];
69
+
70
+ BliRecorder(string sessionDir) { dir = sessionDir; }
71
+
72
+ public static int Run(string sessionDir, string host)
73
+ {
74
+ BliRecorder recorder = new BliRecorder(sessionDir);
75
+ try
76
+ {
77
+ recorder.Log("start host=" + host + " pid=" + Process.GetCurrentProcess().Id + " session_id=" + Process.GetCurrentProcess().SessionId);
78
+ return recorder.Record();
79
+ }
80
+ catch (Exception e)
81
+ {
82
+ recorder.Log("exception type=" + e.GetType().Name);
83
+ if (!recorder.ready) recorder.Fail("recorder_failed");
84
+ return 4;
85
+ }
86
+ }
87
+
88
+ void Log(string line)
89
+ {
90
+ try { File.AppendAllText(Path.Combine(dir, "recorder.log"), DateTime.UtcNow.ToString("o") + " " + line + "\r\n"); }
91
+ catch (Exception) { /* best effort: a log line that cannot be written must not stop the recording */ }
92
+ }
93
+
94
+ void Fail(string reason)
95
+ {
96
+ try { File.WriteAllText(Path.Combine(dir, "failed.txt"), reason); }
97
+ catch (Exception) { /* the CLI then reports recorder_exited; the Log line below still says why */ }
98
+ Log("failed reason=" + reason);
99
+ }
100
+
101
+ static string ReadConsent(RegistryHive hive, string subKey)
102
+ {
103
+ try
104
+ {
105
+ using (RegistryKey root = RegistryKey.OpenBaseKey(hive, RegistryView.Registry64))
106
+ using (RegistryKey key = root.OpenSubKey(subKey))
107
+ {
108
+ if (key == null) return "absent";
109
+ object value = key.GetValue("Value");
110
+ return value == null ? "absent" : value.ToString();
111
+ }
112
+ }
113
+ catch (Exception) { return "unreadable"; }
114
+ }
115
+
116
+ static bool Denied(out string summary)
117
+ {
118
+ string machine = ReadConsent(RegistryHive.LocalMachine, Consent);
119
+ string user = ReadConsent(RegistryHive.CurrentUser, Consent);
120
+ string desktop = ReadConsent(RegistryHive.CurrentUser, Consent + "\\NonPackaged");
121
+ summary = "machine=" + machine + " user=" + user + " desktop_apps=" + desktop;
122
+ return IsDeny(machine) || IsDeny(user) || IsDeny(desktop);
123
+ }
124
+
125
+ static bool IsDeny(string value) { return string.Equals(value, "Deny", StringComparison.OrdinalIgnoreCase); }
126
+
127
+ static byte[] Header(uint dataBytes)
128
+ {
129
+ using (MemoryStream memory = new MemoryStream())
130
+ using (BinaryWriter writer = new BinaryWriter(memory))
131
+ {
132
+ writer.Write(Encoding.ASCII.GetBytes("RIFF"));
133
+ writer.Write((uint)(36 + dataBytes));
134
+ writer.Write(Encoding.ASCII.GetBytes("WAVEfmt "));
135
+ writer.Write((uint)16);
136
+ writer.Write((ushort)1);
137
+ writer.Write((ushort)1);
138
+ writer.Write((uint)16000);
139
+ writer.Write((uint)32000);
140
+ writer.Write((ushort)2);
141
+ writer.Write((ushort)16);
142
+ writer.Write(Encoding.ASCII.GetBytes("data"));
143
+ writer.Write(dataBytes);
144
+ writer.Flush();
145
+ return memory.ToArray();
146
+ }
147
+ }
148
+
149
+ void Check(string what, uint rc)
150
+ {
151
+ if (rc == 0) return;
152
+ Log(what + " rc=" + rc);
153
+ throw new InvalidOperationException(what);
154
+ }
155
+
156
+ void Append(IntPtr hdr, IntPtr data, int bytesOffset)
157
+ {
158
+ int got = Marshal.ReadInt32(hdr, bytesOffset);
159
+ if (got > BufferBytes) got = BufferBytes;
160
+ if (got <= 0) return;
161
+ Marshal.Copy(data, scratch, 0, got);
162
+ file.Write(scratch, 0, got);
163
+ file.Flush();
164
+ total += got;
165
+ buffers++;
166
+ if (!ready)
167
+ {
168
+ File.WriteAllText(Path.Combine(dir, "ready"), "ok");
169
+ ready = true;
170
+ Log("ready first_buffer_bytes=" + got);
171
+ }
172
+ else if (buffers % 600 == 0)
173
+ {
174
+ Log("progress buffers=" + buffers + " bytes=" + total);
175
+ }
176
+ }
177
+
178
+ int Record()
179
+ {
180
+ string summary;
181
+ bool denied = Denied(out summary);
182
+ Log("privacy " + summary);
183
+ if (denied) { Fail("mic_denied"); return 3; }
184
+
185
+ uint devices = waveInGetNumDevs();
186
+ Log("devices=" + devices);
187
+ if (devices == 0) { Fail("no_microphone"); return 2; }
188
+
189
+ WaveFormatEx format = new WaveFormatEx();
190
+ format.wFormatTag = 1;
191
+ format.nChannels = 1;
192
+ format.nSamplesPerSec = 16000;
193
+ format.nAvgBytesPerSec = 32000;
194
+ format.nBlockAlign = 2;
195
+ format.wBitsPerSample = 16;
196
+ format.cbSize = 0;
197
+ IntPtr hwi;
198
+ uint rc = waveInOpen(out hwi, WaveMapper, ref format, IntPtr.Zero, IntPtr.Zero, 0);
199
+ Log("open rc=" + rc + " format=pcm,1ch,16000hz,16bit");
200
+ if (rc != 0)
201
+ {
202
+ bool none = rc == 2 || rc == 6;
203
+ Fail(none ? "no_microphone" : "recorder_failed");
204
+ return none ? 2 : 4;
205
+ }
206
+
207
+ int headerSize = Marshal.SizeOf(typeof(WaveHdr));
208
+ int flagsOffset = (int)Marshal.OffsetOf(typeof(WaveHdr), "dwFlags");
209
+ int bytesOffset = (int)Marshal.OffsetOf(typeof(WaveHdr), "dwBytesRecorded");
210
+ IntPtr[] headers = new IntPtr[BufferCount];
211
+ IntPtr[] data = new IntPtr[BufferCount];
212
+ bool[] prepared = new bool[BufferCount];
213
+ bool started = false;
214
+ string endedBy = "stop";
215
+ try
216
+ {
217
+ file = new FileStream(Path.Combine(dir, "audio.wav"), FileMode.Create, FileAccess.ReadWrite, FileShare.ReadWrite, 4096);
218
+ byte[] head = Header(0);
219
+ file.Write(head, 0, head.Length);
220
+ file.Flush();
221
+
222
+ for (int i = 0; i < BufferCount; i++)
223
+ {
224
+ data[i] = Marshal.AllocHGlobal(BufferBytes);
225
+ headers[i] = Marshal.AllocHGlobal(headerSize);
226
+ WaveHdr hdr = new WaveHdr();
227
+ hdr.lpData = data[i];
228
+ hdr.dwBufferLength = BufferBytes;
229
+ Marshal.StructureToPtr(hdr, headers[i], false);
230
+ Check("prepare", waveInPrepareHeader(hwi, headers[i], (uint)headerSize));
231
+ prepared[i] = true;
232
+ Check("addbuffer", waveInAddBuffer(hwi, headers[i], (uint)headerSize));
233
+ }
234
+ Check("start", waveInStart(hwi));
235
+ started = true;
236
+ Log("recording buffers=" + BufferCount + " buffer_bytes=" + BufferBytes);
237
+
238
+ string stopPath = Path.Combine(dir, "stop");
239
+ Stopwatch clock = Stopwatch.StartNew();
240
+ long lastPrivacy = 0;
241
+ int next = 0;
242
+ while (true)
243
+ {
244
+ if (File.Exists(stopPath)) break;
245
+ if (clock.ElapsedMilliseconds - lastPrivacy >= 1000)
246
+ {
247
+ lastPrivacy = clock.ElapsedMilliseconds;
248
+ string now;
249
+ if (Denied(out now)) { Log("privacy " + now); endedBy = "mic_denied"; break; }
250
+ }
251
+ uint flags = (uint)Marshal.ReadInt32(headers[next], flagsOffset);
252
+ if ((flags & WhdrDone) == 0) { Thread.Sleep(10); continue; }
253
+ Append(headers[next], data[next], bytesOffset);
254
+ Check("addbuffer", waveInAddBuffer(hwi, headers[next], (uint)headerSize));
255
+ next = (next + 1) % BufferCount;
256
+ }
257
+
258
+ // Reset hands every queued buffer back (the oldest partly filled): keep what they hold, oldest first.
259
+ waveInReset(hwi);
260
+ started = false;
261
+ for (int i = 0; i < BufferCount; i++)
262
+ {
263
+ int index = (next + i) % BufferCount;
264
+ uint flags = (uint)Marshal.ReadInt32(headers[index], flagsOffset);
265
+ if ((flags & WhdrDone) != 0) Append(headers[index], data[index], bytesOffset);
266
+ }
267
+ }
268
+ finally
269
+ {
270
+ if (started) waveInReset(hwi);
271
+ for (int i = 0; i < BufferCount; i++)
272
+ {
273
+ if (prepared[i]) waveInUnprepareHeader(hwi, headers[i], (uint)headerSize);
274
+ if (headers[i] != IntPtr.Zero) Marshal.FreeHGlobal(headers[i]);
275
+ if (data[i] != IntPtr.Zero) Marshal.FreeHGlobal(data[i]);
276
+ }
277
+ waveInClose(hwi);
278
+ if (file != null)
279
+ {
280
+ try
281
+ {
282
+ uint size = total > 0xFFFFFFFFL - 36 ? 0xFFFFFFFFu - 36 : (uint)total;
283
+ file.Seek(0, SeekOrigin.Begin);
284
+ byte[] sized = Header(size);
285
+ file.Write(sized, 0, sized.Length);
286
+ file.Flush();
287
+ }
288
+ catch (Exception e) { Log("size_rewrite_failed type=" + e.GetType().Name); }
289
+ file.Close();
290
+ }
291
+ }
292
+
293
+ Log("closed ended_by=" + endedBy + " buffers=" + buffers + " bytes=" + total);
294
+ if (endedBy == "mic_denied") { Fail("mic_denied"); return 3; }
295
+ File.WriteAllText(Path.Combine(dir, "done"), "ok");
296
+ return 0;
297
+ }
298
+ }
299
+ `;
300
+ /**
301
+ * The recorder script, `record.ps1`. PURE ASCII (asserted by a test): Windows
302
+ * PowerShell 5.1 reads a BOM-less file as ANSI. `$args[0]` is the session
303
+ * directory. Reasons it writes to `failed.txt`: mic_denied, no_microphone,
304
+ * recorder_failed. Everything is written through [IO.File], never Out-File.
305
+ */
306
+ export const RECORDER_SCRIPT_WIN32 = `param([Parameter(Position = 0)][string]$Dir)
307
+ $ErrorActionPreference = 'Stop'
308
+ if (-not $Dir) { exit 4 }
309
+ $source = @'
310
+ ${RECORDER_CSHARP.replace(/\r?\n$/u, "")}
311
+ '@
312
+ function Write-RecorderLog([string]$Line) {
313
+ try { [IO.File]::AppendAllText((Join-Path $Dir 'recorder.log'), $Line + [Environment]::NewLine) } catch { <# best effort: a log line that cannot be written must not stop the recorder #> }
314
+ }
315
+ try {
316
+ Add-Type -TypeDefinition $source -Language CSharp
317
+ } catch {
318
+ Write-RecorderLog ('add_type_failed type=' + $_.Exception.GetType().Name)
319
+ 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 #> }
320
+ exit 4
321
+ }
322
+ $code = 4
323
+ try {
324
+ $code = [BliRecorder]::Run($Dir, ('powershell-' + $PSVersionTable.PSVersion.ToString()))
325
+ } catch {
326
+ Write-RecorderLog ('run_exception type=' + $_.Exception.GetType().Name)
327
+ if (-not [IO.File]::Exists((Join-Path $Dir 'ready'))) {
328
+ 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 #> }
329
+ }
330
+ }
331
+ exit $code
332
+ `.replace(/\r?\n/gu, "\r\n");
@@ -14,10 +14,14 @@
14
14
  *
15
15
  * Nothing leaves the machine while recording, and there is no auto-stop. The
16
16
  * original file is never deleted or changed by the trim. Apple Silicon macOS
17
- * only for now: Windows says so by name (a follow-up ticket adds it).
17
+ * and Windows (BLI-23208) record; any other platform refuses by name. Both
18
+ * platforms share one session directory, marker files, JSON answers and WAV
19
+ * format, so the trim, upload and recovery below are the same code.
18
20
  *
19
- * Capture is `notes-record-capture.ts`; the speech trim is `notes-record-wav.ts`.
21
+ * Capture is `notes-record-capture.ts` (macOS) and
22
+ * `notes-record-capture-win32.ts` (Windows); the speech trim is `notes-record-wav.ts`.
20
23
  */
24
+ import path from "node:path";
21
25
  import { setTimeout as delay } from "node:timers/promises";
22
26
  import { writeLine } from "./cli-io.js";
23
27
  import { audioPath, fileExists, fileSize, launchRecorder, listSessions, newSessionId, prepareSessionDir, processAlive, readReason, recordingsRoot, requestStop, sessionDir, trimmedPath, writeSession, RECORDING_SAMPLE_RATE, } from "./notes-record-capture.js";
@@ -31,7 +35,7 @@ export function defaultRecordDeps(io, upload) {
31
35
  platform: process.platform,
32
36
  now: () => new Date(),
33
37
  sleep: io.sleep ?? ((ms) => delay(ms)),
34
- launch: launchRecorder,
38
+ launch: (dir) => launchRecorder(dir, process.platform),
35
39
  alive: processAlive,
36
40
  kill: (pid) => { try {
37
41
  process.kill(pid, "SIGTERM");
@@ -45,12 +49,19 @@ const stateOf = (session, deps) => {
45
49
  return deps.alive(session.pid) ? "recording" : "crashed";
46
50
  return session.endedBy === "cancel" ? "cancelled" : "stopped";
47
51
  };
48
- const MIC_DENIED = "The microphone is blocked for this terminal. Open System Settings > Privacy & Security > Microphone, switch your terminal app on, then run `cockpit notes record start` again.";
49
- const REFUSALS = {
50
- mic_denied: MIC_DENIED,
51
- mic_permission_unanswered: "macOS asked for microphone access and nobody answered. Run `cockpit notes record start` again and choose Allow.",
52
- recorder_failed: "The recorder could not open the microphone. Check that a microphone is connected and not in use, then try again.",
53
- };
52
+ const MIC_DENIED_DARWIN = "The microphone is blocked for this terminal. Open System Settings > Privacy & Security > Microphone, switch your terminal app on, then run `cockpit notes record start` again.";
53
+ const MIC_DENIED_WIN32 = "The microphone is blocked. Open Settings > Privacy & security > Microphone, turn on Microphone access and Let desktop apps access your microphone, then run `cockpit notes record start` again.";
54
+ /** The sentence for a recorder reason, per platform: the macOS and Windows microphone switches live in different places. */
55
+ function refusalSentence(platform, reason) {
56
+ switch (reason) {
57
+ case "mic_denied": return platform === "win32" ? MIC_DENIED_WIN32 : MIC_DENIED_DARWIN;
58
+ case "mic_permission_unanswered": return "macOS asked for microphone access and nobody answered. Run `cockpit notes record start` again and choose Allow.";
59
+ case "no_microphone": return "No microphone was found. Connect or enable a microphone, then run `cockpit notes record start` again.";
60
+ case "recorder_failed": return "The recorder could not open the microphone. Check that a microphone is connected and not in use, then try again.";
61
+ default: return "The recorder stopped before it started.";
62
+ }
63
+ }
64
+ const RECORDER_NAME = { darwin: "macOS recorder (osascript)", win32: "Windows recorder (PowerShell)" };
54
65
  function say(io, command, human, json, ok = true) {
55
66
  writeLine(io.stdout, command.json ? JSON.stringify({ ok, ...json }) : human);
56
67
  return ok ? 0 : 1;
@@ -74,9 +85,8 @@ const megabytes = (bytes) => (bytes === null ? "no file yet" : `${(bytes / 1_000
74
85
  const audioSeconds = (bytes) => (bytes === null ? 0 : Math.max(0, bytes - 44) / (RECORDING_SAMPLE_RATE * 2));
75
86
  export async function runNotesRecord(command, io, deps) {
76
87
  const verb = command.recordAction ?? "status";
77
- if (deps.platform !== "darwin") {
78
- const where = deps.platform === "win32" ? "Windows" : deps.platform;
79
- return refuse(io, command, "platform_not_supported", `Recording a meeting is not supported yet on ${where}. It works on Apple Silicon Macs today; Windows is a follow-up.`);
88
+ if (deps.platform !== "darwin" && deps.platform !== "win32") {
89
+ return refuse(io, command, "platform_not_supported", `Recording a meeting is not supported on ${deps.platform}. It works on Apple Silicon Macs and on Windows.`);
80
90
  }
81
91
  const root = recordingsRoot(command.homeDir);
82
92
  switch (verb) {
@@ -93,10 +103,10 @@ async function startRecording(command, io, deps, root) {
93
103
  return refuse(io, command, "already_recording", `A recording is already running (started ${running.startedAt}). Run \`cockpit notes record stop\` to finish it or \`cockpit notes record cancel\` to keep the file and stop.`);
94
104
  }
95
105
  const id = newSessionId(deps.now());
96
- const dir = await prepareSessionDir(root, id);
106
+ const dir = await prepareSessionDir(root, id, deps.platform);
97
107
  const pid = deps.launch(dir);
98
108
  if (pid === null) {
99
- return refuse(io, command, "recorder_not_launched", "Could not start the macOS recorder (osascript). Nothing was recorded.");
109
+ return refuse(io, command, "recorder_not_launched", `Could not start the ${RECORDER_NAME[deps.platform] ?? "recorder"}. Nothing was recorded.`);
100
110
  }
101
111
  const session = { id, startedAt: deps.now().toISOString(), pid };
102
112
  await writeSession(root, session);
@@ -106,7 +116,7 @@ async function startRecording(command, io, deps, root) {
106
116
  const failed = await readReason(root, id);
107
117
  if (failed) {
108
118
  await writeSession(root, { ...session, endedAt: deps.now().toISOString(), endedBy: "cancel" });
109
- return refuse(io, command, failed, REFUSALS[failed] ?? "The recorder stopped before it started.");
119
+ return refuse(io, command, failed, refusalSentence(deps.platform, failed));
110
120
  }
111
121
  if (await fileExists(`${sessionDir(root, id)}/ready`)) {
112
122
  writeLine(io.stderr, `${TAG} recording ${JSON.stringify({ session: id, reason: "mic_open" })}`);
@@ -114,7 +124,8 @@ async function startRecording(command, io, deps, root) {
114
124
  }
115
125
  if (!deps.alive(pid)) {
116
126
  await writeSession(root, { ...session, endedAt: deps.now().toISOString(), endedBy: "cancel" });
117
- return refuse(io, command, "recorder_exited", "The recorder exited before the microphone opened. Nothing was recorded.");
127
+ const log = deps.platform === "win32" ? ` Details: ${path.join(sessionDir(root, id), "recorder.log")}` : "";
128
+ return refuse(io, command, "recorder_exited", `The recorder exited before the microphone opened. Nothing was recorded.${log}`);
118
129
  }
119
130
  await deps.sleep(POLL_MS);
120
131
  }
@@ -37,7 +37,7 @@
37
37
  * (it runs on a shared server-side key, not a `notes_accounts` row), read
38
38
  * off its `granola_sync_runs` ledger.
39
39
  * - `notes-app.ts` — BLI-23207: `app install|uninstall|status`, the BLI
40
- * Notetaker menu-bar app on this Mac (its LaunchAgent: `notes-app-plist.ts`).
40
+ * Notetaker app on this Mac or Windows PC (Mac LaunchAgent: `notes-app-plist.ts`; Windows: `notes-app-windows.ts`).
41
41
  * - `notes-transcription-stats.ts` — BLI-18003: what meeting transcription
42
42
  * cost and did per provider for a month, and the spend against the cap.
43
43
  *
@@ -105,10 +105,12 @@ export async function runNotes(command, io) {
105
105
  });
106
106
  }));
107
107
  }
108
- // BLI-23207: the menu-bar app is installed on this Mac; only install and status ask Tower (for the newest release).
108
+ // BLI-23207/23208: the menu-bar / notification-area app is installed on this Mac or Windows PC; only install and status ask Tower (for the newest release).
109
109
  if (command.action === "app") {
110
110
  return runNotesApp(command, io, {
111
111
  platform: process.platform,
112
+ arch: process.arch,
113
+ env: io.env,
112
114
  homeDir: command.homeDir ?? os.homedir(),
113
115
  uid: process.getuid?.() ?? 0,
114
116
  exec: io.exec ?? defaultExec(),
@@ -21,7 +21,7 @@ export async function runCockpitCli(argv, io) {
21
21
  }
22
22
 
23
23
  if (command === "--version" || command === "-V" || command === "version") {
24
- writeLine(io?.stdout ?? process.stdout, "0.2.260");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.262");
25
25
  return 0;
26
26
  }
27
27
 
@@ -0,0 +1,28 @@
1
+ /**
2
+ * `cockpit slack privacy` arguments (BLI-23244): a founder LISTS their Slack privacy lists (which conversations Tower
3
+ * keeps out or lets in, and the founders' Slack identities). Ids only, never names.
4
+ *
5
+ * Read-only on purpose. Changing a list needs a signed-in browser session, so that an agent holding a founder's device
6
+ * token can never opt a founder's conversation in; the server refuses device-token writes. The browser card is
7
+ * ticket 23265. Split from `local-args-tower-admin.ts`, which is at this repo's readability band.
8
+ */
9
+ import { optionalNonEmpty, optionalUrl, parseNamedArgs } from "./local-arg-values.js";
10
+ const VALUE_FLAGS = ["--home", "--dashboard-url"];
11
+ const WRITE_FLAGS = ["--exclude", "--optin", "--remove", "--identity", "--no-account"];
12
+ const ALLOWED_FLAGS = [...VALUE_FLAGS, "--json"];
13
+ export const SLACK_PRIVACY_WRITE_REFUSAL = "slack privacy only lists. Changing a founder privacy list needs a signed-in browser session, so an agent holding this machine's token can never opt a founder's conversation in (the Settings card is ticket 23265).";
14
+ export function parseSlackPrivacyArgs(args) {
15
+ if (args.some((arg) => WRITE_FLAGS.includes(arg)))
16
+ throw new Error(SLACK_PRIVACY_WRITE_REFUSAL);
17
+ const values = parseNamedArgs(args, { allowedFlags: ALLOWED_FLAGS, valueFlags: VALUE_FLAGS });
18
+ if (values.positionals.length > 0)
19
+ throw new Error("slack privacy takes no arguments; it lists the founder privacy lists.");
20
+ return {
21
+ kind: "slack",
22
+ action: "privacy",
23
+ privacy: { op: "list" },
24
+ homeDir: optionalNonEmpty(values.flags.get("--home")),
25
+ dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
26
+ json: values.booleans.has("--json"),
27
+ };
28
+ }
@@ -0,0 +1,31 @@
1
+ import { writeLine } from "./cli-io.js";
2
+ import { asRecord, callTower, openTower, writeCommandFailure } from "./tower-command.js";
3
+ export async function runSlackPrivacy(command, io) {
4
+ const tower = await openTower("slack", command, io);
5
+ const result = await callTower(tower, { path: "/api/slack/founder-privacy", label: "slack-privacy" });
6
+ if (!result.ok)
7
+ return writeCommandFailure(io, command.json, result);
8
+ writeLine(io.stderr, `[slack cli] privacy answered ${JSON.stringify({ op: "list", reason: "ok" })}`);
9
+ const body = asRecord(result.body);
10
+ if (command.json) {
11
+ writeLine(io.stdout, JSON.stringify(body));
12
+ return 0;
13
+ }
14
+ for (const line of describe(body))
15
+ writeLine(io.stdout, line);
16
+ return 0;
17
+ }
18
+ /** Plain lines for a person. Exported for its test. */
19
+ export function describe(body) {
20
+ const rows = (key) => (Array.isArray(body[key]) ? body[key] : []);
21
+ const mark = (row) => ` ${String(row["conversationId"])} (${String(row["workspace"])}) since ${String(row["addedAt"]).slice(0, 10)}${row["addedByYou"] ? " — you" : " — the other founder"}`;
22
+ const identities = rows("identities");
23
+ return [
24
+ `Kept out (${rows("exclusions").length}):`, ...rows("exclusions").map(mark),
25
+ `Let in (${rows("optins").length}):`, ...rows("optins").map(mark),
26
+ `Founder Slack ids stated (${identities.length}):`,
27
+ ...identities.map((row) => ` ${String(row["workspace"])}: ${row["slackUserId"] ? String(row["slackUserId"]) : "no account"}${row["yours"] ? " — you" : ""}`),
28
+ "Any DM or group DM that includes a founder is kept out unless it is on the let-in list.",
29
+ "To change a list, use Tower in the browser (ticket 23265); this command only lists.",
30
+ ];
31
+ }
@@ -22,7 +22,10 @@
22
22
  */
23
23
  import { colorEnabled, dim, writeLine } from "./cli-io.js";
24
24
  import { asRecord, callTower, openTower, writeCommandFailure } from "./tower-command.js";
25
+ import { runSlackPrivacy } from "./slack-privacy.js";
25
26
  export async function runSlack(command, io) {
27
+ if (command.action === "privacy")
28
+ return runSlackPrivacy(command, io);
26
29
  const tower = await openTower("slack", command, io);
27
30
  if (command.action === "connect" || command.action === "disconnect") {
28
31
  const result = await callTower(tower, { path: `/api/slack/connections${command.action === "disconnect" ? `?workspace=${command.workspace}` : ""}`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.260",
3
+ "version": "0.2.262",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,7 +28,7 @@
28
28
  },
29
29
  "dependencies": {
30
30
  "@bli-cockpit/memory-mcp": "0.1.77",
31
- "@bli-cockpit/mcp": "0.1.159",
31
+ "@bli-cockpit/mcp": "0.1.161",
32
32
  "@bli-cockpit/telemetry-core": "0.1.84"
33
33
  },
34
34
  "repository": {