@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.
- package/README.md +156 -9
- package/assets/gocode-icon.ico +0 -0
- package/assets/gocode-icon.png +0 -0
- package/dist/src/cli.js +28 -1
- package/dist/src/cursor.js +113 -7
- package/dist/src/desktop_notify.js +584 -0
- package/dist/src/notify_copy.js +189 -0
- package/dist/src/on_stop.js +150 -3
- package/dist/src/opencode.js +64 -15
- package/dist/src/settings.js +9 -0
- package/dist/src/version.js +1 -1
- package/package.json +5 -2
|
@@ -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, "&")
|
|
170
|
+
.replace(/</g, "<")
|
|
171
|
+
.replace(/>/g, ">")
|
|
172
|
+
.replace(/"/g, """)
|
|
173
|
+
.replace(/'/g, "'");
|
|
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
|
+
}
|