@trygocode/notify 0.3.4 → 0.5.0

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.
@@ -0,0 +1,584 @@
1
+ // `desktop_notify` — BRANDED native desktop notifications on the dev machine
2
+ // (GOCODE_NOTIFY_DESKTOP PRD §2). The companion to the phone push: when the
3
+ // agent finishes a turn / errors / a loop completes, the SAME event that pings
4
+ // your phone via FCM also raises a real OS banner on the computer you're sitting
5
+ // at — so you get told whether you're at your desk or away.
6
+ //
7
+ // SCOPE OF THIS MODULE: take a resolved {title, body, kind, sound} and raise ONE
8
+ // native banner on whatever OS we're on, BRANDED as "GoCode" with the GoCode
9
+ // icon. It owns the per-platform mechanics + the one-time branding setup; the
10
+ // DECISION of whether to fire (settings gating) lives in `on_stop.ts` / the CLI,
11
+ // exactly like the phone `send` path.
12
+ //
13
+ // Contract (mirrors `send.ts` — PRD §0.5 "Never block the agent"):
14
+ // - Hard timeout (default 5s) — a wedged osascript/PowerShell can never hang a
15
+ // hook's turn. Best-effort: NEVER throws, NEVER rejects; always resolves to a
16
+ // {@link DesktopResult}. A failed banner must never block or fail the agent.
17
+ // - Failures are appended to `~/.gocode/notify.log` (shared with the sender).
18
+ //
19
+ // BRANDING — the whole point of this module (zero new npm deps, OS tools only):
20
+ // • macOS → we `osacompile` a tiny "GoCode.app" AppleScript bundle ONCE into
21
+ // `~/.gocode/desktop/GoCode.app`, drop the bundled GoCode `.icns`/PNG into
22
+ // it, and invoke its `applet` binary. macOS attributes the banner to the
23
+ // SENDING app bundle, so the notification shows "GoCode" + our icon (a bare
24
+ // `osascript display notification` would show "Script Editor"). `osacompile`
25
+ // ships with every macOS — no Xcode, no Swift, no Homebrew.
26
+ // • Windows → we ensure a Start-Menu `.lnk` shortcut carrying our
27
+ // AppUserModelID ("GoCode.Notify") exists ONCE, then raise a WinRT
28
+ // `ToastGeneric` toast bound to that AUMID via inline PowerShell. Windows
29
+ // keys the toast's app name + icon off the AUMID's shortcut, so the toast
30
+ // reads "GoCode". PowerShell + WinRT ship with Windows 10/11 — no deps.
31
+ // • Linux → `notify-send -a GoCode -i <icon>` (the `-a` app-name + `-i` icon
32
+ // flags brand it). libnotify is present on essentially every desktop distro.
33
+ //
34
+ // Zero runtime deps — Node built-ins only (child_process / fs / os / path / url),
35
+ // matching the package's zero-dependency rule.
36
+ import { spawn } from "node:child_process";
37
+ import { promises as fs } from "node:fs";
38
+ import path from "node:path";
39
+ import { fileURLToPath } from "node:url";
40
+ import { appendLog } from "./send.js";
41
+ import { gocodeDir } from "./creds.js";
42
+ /** Default banner timeout (PRD §2.4: 5s hard cap, same as the phone `send`). */
43
+ export const DEFAULT_DESKTOP_TIMEOUT_MS = 5000;
44
+ /** The Windows AppUserModelID the branded toast binds to (PRD §2.3). */
45
+ export const WINDOWS_AUMID = "GoCode.Notify";
46
+ /** The app/bundle display name every platform brands the banner with. */
47
+ export const APP_NAME = "GoCode";
48
+ /**
49
+ * Version of the compiled macOS helper bundle. BUMP this whenever the applet's
50
+ * AppleScript changes (e.g. the click handler added in 0.5.0) so an existing
51
+ * install's stale `GoCode.app` is recompiled once instead of silently reused.
52
+ * Tracked via a `~/.gocode/desktop/.bundle-v<N>` marker file.
53
+ */
54
+ export const MAC_BUNDLE_VERSION = 2;
55
+ /**
56
+ * Env var that hard-disables ALL desktop banners regardless of settings — a
57
+ * machine-level opt-out for headless servers, CI, SSH sessions, or any box where
58
+ * a native banner makes no sense. Treated as truthy for any value other than the
59
+ * usual falsy strings. Checked first thing in {@link notifyDesktop} so it can
60
+ * never spawn a banner process. (The test suite also sets it so unit tests never
61
+ * raise real OS banners.)
62
+ */
63
+ export const DISABLE_DESKTOP_ENV = "GOCODE_NOTIFY_NO_DESKTOP";
64
+ /** True when {@link DISABLE_DESKTOP_ENV} is set to a truthy value. */
65
+ export function desktopDisabledByEnv(env = process.env) {
66
+ const raw = env[DISABLE_DESKTOP_ENV];
67
+ if (raw == null)
68
+ return false;
69
+ const v = raw.trim().toLowerCase();
70
+ return v !== "" && v !== "0" && v !== "false" && v !== "no" && v !== "off";
71
+ }
72
+ /**
73
+ * Default OS-command runner: spawn `cmd args`, optionally feed `input` on stdin,
74
+ * capture stdout/stderr, and resolve with the exit code. NEVER rejects — a spawn
75
+ * error (binary missing) resolves to a non-zero code so the flow treats it as a
76
+ * skip, not a throw. A hard timeout kills a wedged child so a hook never hangs.
77
+ */
78
+ export const defaultRunner = (cmd, args, opts) => new Promise((resolve) => {
79
+ let settled = false;
80
+ const finish = (r) => {
81
+ if (settled)
82
+ return;
83
+ settled = true;
84
+ resolve(r);
85
+ };
86
+ let child;
87
+ try {
88
+ child = spawn(cmd, args, {
89
+ stdio: ["pipe", "pipe", "pipe"],
90
+ env: opts.env ? { ...process.env, ...opts.env } : process.env,
91
+ });
92
+ }
93
+ catch (err) {
94
+ finish({ code: 127, stdout: "", stderr: String(err) });
95
+ return;
96
+ }
97
+ let stdout = "";
98
+ let stderr = "";
99
+ child.stdout?.setEncoding("utf8");
100
+ child.stderr?.setEncoding("utf8");
101
+ child.stdout?.on("data", (c) => (stdout += c));
102
+ child.stderr?.on("data", (c) => (stderr += c));
103
+ const timer = setTimeout(() => {
104
+ try {
105
+ child.kill("SIGKILL");
106
+ }
107
+ catch {
108
+ // ignore
109
+ }
110
+ finish({ code: 124, stdout, stderr: stderr || "timeout" });
111
+ }, opts.timeoutMs);
112
+ child.on("error", (err) => {
113
+ clearTimeout(timer);
114
+ finish({ code: 127, stdout, stderr: stderr || String(err) });
115
+ });
116
+ child.on("close", (code) => {
117
+ clearTimeout(timer);
118
+ finish({ code: code ?? 1, stdout, stderr });
119
+ });
120
+ if (opts.input !== undefined) {
121
+ try {
122
+ child.stdin?.end(opts.input);
123
+ }
124
+ catch {
125
+ // a broken pipe must not throw
126
+ }
127
+ }
128
+ else {
129
+ try {
130
+ child.stdin?.end();
131
+ }
132
+ catch {
133
+ // ignore
134
+ }
135
+ }
136
+ });
137
+ /** Directory under `~/.gocode/` holding the branding helper (the .app / marker). */
138
+ export function desktopDir(opts) {
139
+ return path.join(gocodeDir(opts), "desktop");
140
+ }
141
+ /**
142
+ * Resolve the bundled GoCode icon shipped in the package's `assets/` dir. The
143
+ * compiled module lives at `dist/src/desktop_notify.js`, so `assets/` is two
144
+ * levels up (`dist/src/.. /.. /assets`). Returns the platform-appropriate file
145
+ * (PNG everywhere; `.ico` is only meaningful to Windows but we ship PNG too).
146
+ * Returns undefined when the asset can't be located (then we brand by name only).
147
+ */
148
+ export function bundledIconPath(ext = "png") {
149
+ try {
150
+ const here = path.dirname(fileURLToPath(import.meta.url)); // dist/src
151
+ const candidate = path.resolve(here, "..", "..", "assets", `gocode-icon.${ext}`);
152
+ return candidate;
153
+ }
154
+ catch {
155
+ return undefined;
156
+ }
157
+ }
158
+ /** Escape a string for safe embedding inside an AppleScript double-quoted literal. */
159
+ export function escapeAppleScript(s) {
160
+ return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
161
+ }
162
+ /** Escape a string for safe embedding inside a PowerShell single-quoted literal. */
163
+ export function escapePowerShellSingleQuoted(s) {
164
+ return s.replace(/'/g, "''");
165
+ }
166
+ /** Escape text for safe embedding inside toast XML (the 5 XML entities). */
167
+ export function escapeXml(s) {
168
+ return s
169
+ .replace(/&/g, "&amp;")
170
+ .replace(/</g, "&lt;")
171
+ .replace(/>/g, "&gt;")
172
+ .replace(/"/g, "&quot;")
173
+ .replace(/'/g, "&apos;");
174
+ }
175
+ /**
176
+ * Raise ONE branded native desktop banner for `payload`. Best-effort + total:
177
+ * NEVER throws, NEVER rejects; always resolves to a {@link DesktopResult}, and
178
+ * the caller can treat any result as "exit 0". Branding setup is one-time and
179
+ * cached; if it fails the banner still fires unbranded (degrade, never block).
180
+ */
181
+ export async function notifyDesktop(payload, opts = {}) {
182
+ const platform = (opts.platform ?? process.platform);
183
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_DESKTOP_TIMEOUT_MS;
184
+ const run = opts.run ?? defaultRunner;
185
+ const logLine = async (line) => {
186
+ try {
187
+ if (opts.log)
188
+ await opts.log(line);
189
+ else
190
+ await appendLog(`DESKTOP: ${line}`, { home: opts.home, timestamp: opts.timestamp });
191
+ }
192
+ catch {
193
+ // logging must never block the flow
194
+ }
195
+ };
196
+ // Machine-level hard opt-out (headless / CI / SSH). Never spawn anything.
197
+ // Honour a per-call `opts.env` override too (consistency with the runner,
198
+ // which merges opts.env into the child env) — falling back to process.env.
199
+ if (desktopDisabledByEnv({ ...process.env, ...(opts.env ?? {}) })) {
200
+ await logLine(`disabled via ${DISABLE_DESKTOP_ENV} — skipped`);
201
+ return { ok: false, platform: "unsupported", error: `disabled via ${DISABLE_DESKTOP_ENV}` };
202
+ }
203
+ const title = (payload.title ?? APP_NAME).trim() || APP_NAME;
204
+ const body = (payload.body ?? "").trim();
205
+ const sound = payload.sound !== false; // default ON (PRD §2.1)
206
+ const click = payload.click;
207
+ try {
208
+ if (platform === "darwin") {
209
+ return await notifyMac({ title, body, sound, click }, { ...opts, run, timeoutMs, log: logLine });
210
+ }
211
+ if (platform === "win32") {
212
+ return await notifyWindows({ title, body, sound, click }, { ...opts, run, timeoutMs, log: logLine });
213
+ }
214
+ if (platform === "linux") {
215
+ return await notifyLinux({ title, body, sound, click }, { ...opts, run, timeoutMs, log: logLine });
216
+ }
217
+ await logLine(`no desktop banner path for platform "${platform}" — skipped`);
218
+ return { ok: false, platform: "unsupported", error: `unsupported platform ${platform}` };
219
+ }
220
+ catch (err) {
221
+ // Defence in depth: this module must never throw. Surface the real message
222
+ // (platform-specific integrations are fragile — keep the breadcrumb).
223
+ const msg = err instanceof Error ? err.message : String(err);
224
+ await logLine(`unexpected error on ${platform} — treated as no-op: ${msg}`);
225
+ return { ok: false, platform: "unsupported", error: `unexpected error: ${msg}` };
226
+ }
227
+ }
228
+ // ── macOS ────────────────────────────────────────────────────────────────────
229
+ /** Path to the branded helper app bundle under `~/.gocode/desktop/GoCode.app`. */
230
+ export function macAppPath(opts) {
231
+ return path.join(desktopDir(opts), "GoCode.app");
232
+ }
233
+ /**
234
+ * Path to the click-target state file the applet reads when a banner is clicked
235
+ * (PRD §3.6). Holds a single `"<app>\t<projectPath>"` line written just before
236
+ * the most recent banner is posted; the applet's click handler opens that IDE.
237
+ * Single-file (last-write-wins) is fine: the user clicks the banner they just
238
+ * saw, and a banner only stays clickable while it's the latest.
239
+ */
240
+ export function macClickTargetPath(opts) {
241
+ return path.join(desktopDir(opts), "click-target.tsv");
242
+ }
243
+ /**
244
+ * Ensure the branded `GoCode.app` AppleScript bundle exists under
245
+ * `~/.gocode/desktop/`. Compiled ONCE with `osacompile` (ships with macOS), then
246
+ * the bundled GoCode icon is dropped in as `applet.icns` so the banner shows our
247
+ * icon + "GoCode". Idempotent: a second call no-ops when the bundle is present.
248
+ *
249
+ * The bundle's AppleScript reads the banner text from env vars
250
+ * (GOCODE_NOTIFY_TITLE / GOCODE_NOTIFY_MESSAGE / GOCODE_NOTIFY_SOUND) so we never
251
+ * recompile per banner — we just set env + invoke the `applet` binary directly
252
+ * (the only invocation that actually works headless; `open -a` silently no-ops
253
+ * for AppleScript applets).
254
+ *
255
+ * CLICK-THROUGH (PRD §3.6): clicking a banner posted by an AppleScript applet
256
+ * sends a fresh `run` event back to THIS applet (documented macOS behaviour). We
257
+ * distinguish the two run modes by the presence of GOCODE_NOTIFY_TITLE:
258
+ * • env var SET → we're posting a banner (the normal invoke).
259
+ * • env var EMPTY → we were re-run by a notification CLICK → read the last
260
+ * click-target file and `open -a <ide> <projectPath>` to focus the IDE.
261
+ * This keeps click-through ZERO-dependency (pure osacompile/AppleScript) — no
262
+ * Swift, no signed binary, no extra npm dep.
263
+ *
264
+ * Returns the applet binary path on success, or undefined when branding could
265
+ * not be set up (caller then falls back to a plain unbranded `osascript`).
266
+ */
267
+ export async function ensureMacBrandedApp(ctx) {
268
+ const appPath = macAppPath(ctx);
269
+ const appletBin = path.join(appPath, "Contents", "MacOS", "applet");
270
+ const versionMarker = path.join(desktopDir(ctx), `.bundle-v${MAC_BUNDLE_VERSION}`);
271
+ try {
272
+ // Already built AND at the CURRENT bundle version? → reuse. The version
273
+ // marker lets a package update (e.g. adding the click handler) force a
274
+ // one-time recompile of a stale bundle from an older install.
275
+ await fs.access(appletBin);
276
+ await fs.access(versionMarker);
277
+ return appletBin;
278
+ }
279
+ catch {
280
+ // need to (re)build it — missing applet OR stale/absent version marker
281
+ }
282
+ try {
283
+ await fs.mkdir(desktopDir(ctx), { recursive: true, mode: 0o700 });
284
+ // The applet reads the banner text from the environment so we compile ONCE.
285
+ // The same applet handles a notification CLICK (a second `run` with NO env)
286
+ // by opening the IDE recorded in the click-target file (see header).
287
+ const clickFile = macClickTargetPath(ctx);
288
+ const script = [
289
+ 'on run',
290
+ ' set theTitle to (system attribute "GOCODE_NOTIFY_TITLE")',
291
+ ' if theTitle is "" then',
292
+ ' -- No banner env → this run came from a notification CLICK. Open the',
293
+ ' -- IDE recorded by the last post (format: "<app>\\t<projectPath>").',
294
+ ' my handleClick()',
295
+ ' return',
296
+ ' end if',
297
+ ' set theMsg to (system attribute "GOCODE_NOTIFY_MESSAGE")',
298
+ ' set theSound to (system attribute "GOCODE_NOTIFY_SOUND")',
299
+ ' if theSound is "1" then',
300
+ ' display notification theMsg with title theTitle sound name "Glass"',
301
+ ' else',
302
+ ' display notification theMsg with title theTitle',
303
+ ' end if',
304
+ 'end run',
305
+ '',
306
+ 'on handleClick()',
307
+ ' try',
308
+ ` set p to "${escapeAppleScript(clickFile)}"`,
309
+ ' set f to POSIX file p',
310
+ ' set raw to (read f as «class utf8»)',
311
+ ' if raw is "" then return',
312
+ ' set AppleScript\'s text item delimiters to tab',
313
+ ' set parts to text items of raw',
314
+ ' set AppleScript\'s text item delimiters to ""',
315
+ ' if (count of parts) < 2 then return',
316
+ ' set ideApp to item 1 of parts',
317
+ ' set projPath to item 2 of parts',
318
+ ' -- strip any trailing newline from projPath',
319
+ ' if projPath ends with linefeed then set projPath to text 1 thru -2 of projPath',
320
+ ' if projPath ends with return then set projPath to text 1 thru -2 of projPath',
321
+ ' do shell script "open -a " & quoted form of ideApp & " " & quoted form of projPath',
322
+ ' end try',
323
+ 'end handleClick',
324
+ ].join("\n");
325
+ const scptPath = path.join(desktopDir(ctx), "gocode-notify.applescript");
326
+ await fs.writeFile(scptPath, script, "utf8");
327
+ // Remove any stale/partial bundle so osacompile writes a clean one.
328
+ await fs.rm(appPath, { recursive: true, force: true }).catch(() => { });
329
+ const compiled = await ctx.run("osacompile", ["-o", appPath, scptPath], {
330
+ timeoutMs: ctx.timeoutMs,
331
+ });
332
+ if (compiled.code !== 0) {
333
+ await ctx.log(`osacompile failed (code ${compiled.code}): ${compiled.stderr.trim()} — falling back to unbranded`);
334
+ return undefined;
335
+ }
336
+ // Drop the GoCode icon into the bundle so the banner is branded with it.
337
+ // The applet's icon file is Contents/Resources/applet.icns. We convert the
338
+ // bundled PNG → icns via `sips` (ships with macOS); if that fails the banner
339
+ // is still branded by NAME ("GoCode") which is the important part.
340
+ await brandMacIcon(appPath, ctx).catch(() => { });
341
+ // Stamp the version marker so we don't recompile until the bundle changes.
342
+ // Clear any older `.bundle-v*` markers first so the dir doesn't accumulate.
343
+ try {
344
+ const dir = desktopDir(ctx);
345
+ for (const entry of await fs.readdir(dir)) {
346
+ if (entry.startsWith(".bundle-v")) {
347
+ await fs.rm(path.join(dir, entry), { force: true }).catch(() => { });
348
+ }
349
+ }
350
+ await fs.writeFile(versionMarker, "", { encoding: "utf8", mode: 0o600 });
351
+ }
352
+ catch {
353
+ // marker is an optimization — a failed write just means we recompile next
354
+ // time (correct, just slightly wasteful). Never fail the banner over it.
355
+ }
356
+ return appletBin;
357
+ }
358
+ catch (err) {
359
+ await ctx.log(`branded app setup error: ${err instanceof Error ? err.message : String(err)} — falling back to unbranded`);
360
+ return undefined;
361
+ }
362
+ }
363
+ /** Best-effort: convert the bundled PNG to icns and replace the applet icon. */
364
+ async function brandMacIcon(appPath, ctx) {
365
+ const png = bundledIconPath("png");
366
+ if (!png)
367
+ return;
368
+ try {
369
+ await fs.access(png);
370
+ }
371
+ catch {
372
+ return; // asset missing — keep the default applet icon (still name-branded)
373
+ }
374
+ const icnsTarget = path.join(appPath, "Contents", "Resources", "applet.icns");
375
+ const res = await ctx.run("sips", ["-s", "format", "icns", png, "--out", icnsTarget], {
376
+ timeoutMs: ctx.timeoutMs,
377
+ });
378
+ if (res.code !== 0) {
379
+ await ctx.log(`icon conversion skipped (sips code ${res.code}) — banner is name-branded only`);
380
+ }
381
+ }
382
+ /**
383
+ * Write (or clear) the click-target file the applet reads on a banner click
384
+ * (PRD §3.6). When `click` is set we write a single tab-separated line
385
+ * `"<app>\t<projectPath>"`; when it's undefined we truncate the file to empty so
386
+ * a click on this banner no-ops rather than re-opening a STALE target from a
387
+ * previous banner. Best-effort — the caller swallows any error.
388
+ */
389
+ export async function writeMacClickTarget(click, opts) {
390
+ const file = macClickTargetPath(opts);
391
+ await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
392
+ // Strip tabs/newlines from fields so the single-line TSV format can't be
393
+ // broken by a weird app name or path (paths with tabs are not a thing we hit,
394
+ // but defend anyway). Empty content when no target → click no-ops.
395
+ const content = click
396
+ ? `${oneLine(click.app)}\t${oneLine(click.projectPath)}\n`
397
+ : "";
398
+ await fs.writeFile(file, content, { encoding: "utf8", mode: 0o600 });
399
+ }
400
+ /** Collapse tabs/newlines to spaces so a value is safe in the single-line TSV. */
401
+ function oneLine(s) {
402
+ return s.replace(/[\t\r\n]+/g, " ").trim();
403
+ }
404
+ async function notifyMac(banner, ctx) {
405
+ // Branded path: invoke the compiled GoCode.app applet so the banner is
406
+ // attributed to "GoCode" (+ our icon) instead of "Script Editor".
407
+ if (!ctx.skipBranding) {
408
+ const applet = await ensureMacBrandedApp(ctx);
409
+ if (applet) {
410
+ // Record the click target BEFORE posting, so a click on the banner we're
411
+ // about to show opens the right IDE/project (last-write-wins — the user
412
+ // clicks the banner they just saw). We ONLY write when THIS banner has a
413
+ // click target: a later display-only banner (no IDE source, e.g. a webhook
414
+ // ping) must NOT clear an earlier clickable banner's target, or clicking
415
+ // that still-visible earlier banner would no-op. Best-effort: a failed
416
+ // write just means the click no-ops (the banner still shows). (macOS only
417
+ // surfaces one process-wide applet, so this is a single shared file; the
418
+ // window between two clickable banners that target DIFFERENT projects is
419
+ // the known limit of the zero-dep applet approach — see header.)
420
+ if (banner.click) {
421
+ await writeMacClickTarget(banner.click, ctx).catch(() => { });
422
+ }
423
+ // The compiled applet reads the banner text from the environment (via
424
+ // AppleScript `system attribute`), so we pass it as env vars rather than
425
+ // recompiling per banner.
426
+ const res = await ctx.run(applet, [], {
427
+ timeoutMs: ctx.timeoutMs,
428
+ env: {
429
+ ...(ctx.env ?? {}),
430
+ GOCODE_NOTIFY_TITLE: banner.title,
431
+ GOCODE_NOTIFY_MESSAGE: banner.body,
432
+ GOCODE_NOTIFY_SOUND: banner.sound ? "1" : "0",
433
+ },
434
+ });
435
+ if (res.code === 0) {
436
+ await ctx.log(`branded banner shown (${banner.title})`);
437
+ return { ok: true, platform: "darwin" };
438
+ }
439
+ await ctx.log(`branded applet failed (code ${res.code}): ${res.stderr.trim()} — falling back to osascript`);
440
+ }
441
+ }
442
+ // Fallback: plain unbranded osascript (shows "Script Editor" but still works).
443
+ const t = escapeAppleScript(banner.title);
444
+ const b = escapeAppleScript(banner.body);
445
+ const soundClause = banner.sound ? ' sound name "Glass"' : "";
446
+ const script = `display notification "${b}" with title "${t}"${soundClause}`;
447
+ const res = await ctx.run("osascript", ["-e", script], { timeoutMs: ctx.timeoutMs });
448
+ if (res.code === 0) {
449
+ await ctx.log(`unbranded banner shown (${banner.title})`);
450
+ return { ok: true, platform: "darwin" };
451
+ }
452
+ await ctx.log(`osascript failed (code ${res.code}): ${res.stderr.trim()}`);
453
+ return { ok: false, platform: "darwin", error: res.stderr.trim() || `osascript exit ${res.code}` };
454
+ }
455
+ // ── Windows ──────────────────────────────────────────────────────────────────
456
+ /**
457
+ * Build the inline PowerShell that (1) ensures a Start-Menu shortcut carrying
458
+ * our AUMID exists so Windows brands the toast "GoCode", then (2) raises a WinRT
459
+ * ToastGeneric toast bound to that AUMID. Pure PowerShell + WinRT — no modules.
460
+ */
461
+ export function buildWindowsToastScript(banner, opts = {}) {
462
+ const aumid = opts.aumid ?? WINDOWS_AUMID;
463
+ const title = escapeXml(banner.title);
464
+ const body = escapeXml(banner.body);
465
+ const iconLine = opts.iconPath
466
+ ? `<image placement="appLogoOverride" hint-crop="circle" src="${escapeXml(opts.iconPath)}"/>`
467
+ : "";
468
+ const audioLine = banner.sound ? "" : '<audio silent="true"/>';
469
+ // The shortcut-ensure block creates a .lnk in the user's Start Menu with the
470
+ // System.AppUserModel.ID property = our AUMID (required for an unpackaged app
471
+ // to brand a toast). Idempotent: skipped when the shortcut already exists.
472
+ return [
473
+ "$ErrorActionPreference = 'Stop'",
474
+ `$AppId = '${escapePowerShellSingleQuoted(aumid)}'`,
475
+ "$AppName = 'GoCode'",
476
+ "$startMenu = [Environment]::GetFolderPath('Programs')",
477
+ "$lnk = Join-Path $startMenu 'GoCode.lnk'",
478
+ // Ensure a Start-Menu shortcut whose AppUserModelID = our AUMID exists. An
479
+ // unpackaged app MUST have such a shortcut for Windows to brand its toast
480
+ // (name + icon) off the AUMID. Done once; skipped when already present.
481
+ "if (-not (Test-Path $lnk)) {",
482
+ " try {",
483
+ " $ws = New-Object -ComObject WScript.Shell",
484
+ " $s = $ws.CreateShortcut($lnk)",
485
+ " $s.TargetPath = (Join-Path $env:SystemRoot 'System32\\\\cmd.exe')",
486
+ " $s.Arguments = '/c rem GoCode Notify'",
487
+ " $s.IconLocation = (Join-Path $env:SystemRoot 'System32\\\\cmd.exe') + ',0'",
488
+ " $s.Save()",
489
+ // Stamp System.AppUserModel.ID onto the shortcut via the Windows property
490
+ // store (the ONLY thing that actually links the .lnk to our AUMID).
491
+ " $code = @'",
492
+ "using System;",
493
+ "using System.Runtime.InteropServices;",
494
+ "public static class GoCodeLnk {",
495
+ " [ComImport, Guid(\"00021401-0000-0000-C000-000000000046\")] public class CShellLink {}",
496
+ " [ComImport, Guid(\"000214F9-0000-0000-C000-000000000046\"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]",
497
+ " public interface IShellLinkW {",
498
+ " void GetPath([Out] System.Text.StringBuilder f, int c, IntPtr d, int g); void GetIDList(out IntPtr p); void SetIDList(IntPtr p);",
499
+ " void GetDescription(System.Text.StringBuilder n, int c); void SetDescription(string n); void GetWorkingDirectory(System.Text.StringBuilder d, int c);",
500
+ " void SetWorkingDirectory(string d); void GetArguments(System.Text.StringBuilder a, int c); void SetArguments(string a);",
501
+ " void GetHotkey(out short h); void SetHotkey(short h); void GetShowCmd(out int s); void SetShowCmd(int s);",
502
+ " void GetIconLocation(System.Text.StringBuilder p, int c, out int i); void SetIconLocation(string p, int i);",
503
+ " void SetRelativePath(string p, int r); void Resolve(IntPtr h, int f); void SetPath(string p);",
504
+ " }",
505
+ " [ComImport, Guid(\"45e2b4ae-b1c3-11d0-b92f-00a0c90312e1\"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]",
506
+ " public interface IShellLinkDataList { void AddDataBlock(IntPtr d); void CopyDataBlock(uint s, out IntPtr d); void RemoveDataBlock(uint s); void GetFlags(out uint f); void SetFlags(uint f); }",
507
+ " [StructLayout(LayoutKind.Sequential)] public struct PROPERTYKEY { public Guid fmtid; public uint pid; }",
508
+ " [ComImport, Guid(\"886d8eeb-8cf2-4446-8d02-cdba1dbdcf99\"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]",
509
+ " public interface IPropertyStore { void GetCount(out uint c); void GetAt(uint i, out PROPERTYKEY k); void GetValue(ref PROPERTYKEY k, out PROPVARIANT v); void SetValue(ref PROPERTYKEY k, ref PROPVARIANT v); void Commit(); }",
510
+ " [StructLayout(LayoutKind.Explicit)] public struct PROPVARIANT { [FieldOffset(0)] public ushort vt; [FieldOffset(8)] public IntPtr p; }",
511
+ " [DllImport(\"ole32.dll\")] public static extern int PropVariantClear(ref PROPVARIANT pv);",
512
+ " [DllImport(\"shell32.dll\", CharSet=CharSet.Unicode)] public static extern IntPtr SHStrDupW(string s, out IntPtr o);",
513
+ " public static void SetAumid(string lnk, string aumid) {",
514
+ " var link = (IShellLinkW)new CShellLink(); var pf = (System.Runtime.InteropServices.ComTypes.IPersistFile)link; pf.Load(lnk, 0);",
515
+ " var ps = (IPropertyStore)link; var key = new PROPERTYKEY { fmtid = new Guid(\"9F4C2855-9F79-4B39-A8D0-E1D42DE1D5F3\"), pid = 5 };",
516
+ " var pv = new PROPVARIANT { vt = 31 }; IntPtr str; SHStrDupW(aumid, out str); pv.p = str;",
517
+ " ps.SetValue(ref key, ref pv); ps.Commit(); pf.Save(lnk, true); PropVariantClear(ref pv);",
518
+ " }",
519
+ "}",
520
+ "'@",
521
+ " Add-Type -TypeDefinition $code -Language CSharp | Out-Null",
522
+ " [GoCodeLnk]::SetAumid($lnk, $AppId)",
523
+ " } catch {}",
524
+ "}",
525
+ "[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType=WindowsRuntime] | Out-Null",
526
+ "[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType=WindowsRuntime] | Out-Null",
527
+ "$template = @\"",
528
+ "<toast>",
529
+ " <visual>",
530
+ ' <binding template="ToastGeneric">',
531
+ ` <text>${title}</text>`,
532
+ ` <text>${body}</text>`,
533
+ ` ${iconLine}`,
534
+ " </binding>",
535
+ " </visual>",
536
+ ` ${audioLine}`,
537
+ "</toast>",
538
+ "\"@",
539
+ "$xml = New-Object Windows.Data.Xml.Dom.XmlDocument",
540
+ "$xml.LoadXml($template)",
541
+ "$toast = [Windows.UI.Notifications.ToastNotification]::new($xml)",
542
+ "$notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($AppId)",
543
+ "$notifier.Show($toast)",
544
+ ].join("\n");
545
+ }
546
+ async function notifyWindows(banner, ctx) {
547
+ const iconPath = bundledIconPath("png");
548
+ const script = buildWindowsToastScript(banner, {
549
+ aumid: WINDOWS_AUMID,
550
+ iconPath: ctx.skipBranding ? undefined : iconPath,
551
+ });
552
+ // Run via powershell with stdin so we never hit cmd-line length / quoting limits.
553
+ const res = await ctx.run("powershell", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", "-"], { input: script, timeoutMs: ctx.timeoutMs });
554
+ if (res.code === 0) {
555
+ await ctx.log(`toast shown (${banner.title})`);
556
+ return { ok: true, platform: "win32" };
557
+ }
558
+ await ctx.log(`powershell toast failed (code ${res.code}): ${res.stderr.trim()}`);
559
+ return { ok: false, platform: "win32", error: res.stderr.trim() || `powershell exit ${res.code}` };
560
+ }
561
+ // ── Linux ────────────────────────────────────────────────────────────────────
562
+ async function notifyLinux(banner, ctx) {
563
+ const icon = bundledIconPath("png");
564
+ const args = ["-a", APP_NAME];
565
+ if (icon && !ctx.skipBranding)
566
+ args.push("-i", icon);
567
+ // Best-effort sound control: there is no portable notify-send sound flag, but
568
+ // the freedesktop `suppress-sound` hint is honoured by GNOME/KDE daemons, so
569
+ // map `sound:false` → suppress. (Daemons that ignore the hint just play their
570
+ // default — no portable way to force-silence those, hence "best effort".)
571
+ if (!banner.sound)
572
+ args.push("-h", "int:suppress-sound:1");
573
+ // notify-send positional args: <summary> [body]
574
+ args.push(banner.title);
575
+ if (banner.body)
576
+ args.push(banner.body);
577
+ const res = await ctx.run("notify-send", args, { timeoutMs: ctx.timeoutMs });
578
+ if (res.code === 0) {
579
+ await ctx.log(`notify-send banner shown (${banner.title})`);
580
+ return { ok: true, platform: "linux" };
581
+ }
582
+ await ctx.log(`notify-send failed (code ${res.code}): ${res.stderr.trim()}`);
583
+ return { ok: false, platform: "linux", error: res.stderr.trim() || `notify-send exit ${res.code}` };
584
+ }