@trygocode/notify 0.4.0 → 0.6.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 +81 -4
- package/assets/GoCodeNotifier.app/Contents/Info.plist +37 -0
- package/assets/GoCodeNotifier.app/Contents/MacOS/GoCodeNotifier +0 -0
- package/assets/GoCodeNotifier.app/Contents/Resources/AppIcon.icns +0 -0
- package/assets/GoCodeNotifier.app/Contents/_CodeSignature/CodeResources +128 -0
- package/dist/src/cli.js +69 -1
- package/dist/src/cursor.js +113 -7
- package/dist/src/desktop_notify.js +232 -1
- package/dist/src/doctor.js +26 -7
- package/dist/src/mac_helper.js +335 -0
- package/dist/src/opencode.js +64 -15
- package/dist/src/send.js +109 -36
- package/dist/src/version.js +1 -1
- package/package.json +1 -1
|
@@ -39,6 +39,7 @@ import path from "node:path";
|
|
|
39
39
|
import { fileURLToPath } from "node:url";
|
|
40
40
|
import { appendLog } from "./send.js";
|
|
41
41
|
import { gocodeDir } from "./creds.js";
|
|
42
|
+
import { cachedAuthStatus, notifyViaMacHelper, requestMacPermission, } from "./mac_helper.js";
|
|
42
43
|
/** Default banner timeout (PRD §2.4: 5s hard cap, same as the phone `send`). */
|
|
43
44
|
export const DEFAULT_DESKTOP_TIMEOUT_MS = 5000;
|
|
44
45
|
/** The Windows AppUserModelID the branded toast binds to (PRD §2.3). */
|
|
@@ -50,8 +51,22 @@ export const APP_NAME = "GoCode";
|
|
|
50
51
|
* AppleScript changes (e.g. the click handler added in 0.5.0) so an existing
|
|
51
52
|
* install's stale `GoCode.app` is recompiled once instead of silently reused.
|
|
52
53
|
* Tracked via a `~/.gocode/desktop/.bundle-v<N>` marker file.
|
|
54
|
+
*
|
|
55
|
+
* v3 (2026-06-20): inject a stable CFBundleIdentifier + register with
|
|
56
|
+
* LaunchServices. macOS 15/26 silently DROP notifications from an app bundle
|
|
57
|
+
* with no bundle id (osacompile's default applet has none), which is why
|
|
58
|
+
* banners "succeeded" (osascript exit 0) but never appeared. Bumped so existing
|
|
59
|
+
* id-less bundles get rebuilt once with the fix.
|
|
60
|
+
*/
|
|
61
|
+
export const MAC_BUNDLE_VERSION = 3;
|
|
62
|
+
/**
|
|
63
|
+
* Stable CFBundleIdentifier stamped into the compiled `GoCode.app`. macOS keys
|
|
64
|
+
* notification authorization (System Settings → Notifications) off this id, so
|
|
65
|
+
* it MUST be stable across rebuilds for the user's "Allow" grant to persist and
|
|
66
|
+
* for the app to even appear in the Notifications list. Reverse-DNS, matches
|
|
67
|
+
* the GoCode brand namespace.
|
|
53
68
|
*/
|
|
54
|
-
export const
|
|
69
|
+
export const MAC_BUNDLE_ID = "com.gocode.notify.desktop";
|
|
55
70
|
/**
|
|
56
71
|
* Env var that hard-disables ALL desktop banners regardless of settings — a
|
|
57
72
|
* machine-level opt-out for headless servers, CI, SSH sessions, or any box where
|
|
@@ -225,6 +240,123 @@ export async function notifyDesktop(payload, opts = {}) {
|
|
|
225
240
|
return { ok: false, platform: "unsupported", error: `unexpected error: ${msg}` };
|
|
226
241
|
}
|
|
227
242
|
}
|
|
243
|
+
/**
|
|
244
|
+
* Make the OS notification-permission UI appear for GoCode, so the user grants
|
|
245
|
+
* permission with a click instead of hunting through Settings.
|
|
246
|
+
*
|
|
247
|
+
* macOS (since 0.6.0 — the SIGNED helper changed this): we now ship a
|
|
248
|
+
* Developer-ID-signed `GoCodeNotifier.app` in the package, so we CAN pop the real
|
|
249
|
+
* native Allow/Deny MODAL via `UNUserNotificationCenter.requestAuthorization`.
|
|
250
|
+
* The flow is:
|
|
251
|
+
* 1. Try the signed helper's `request-permission` (see mac_helper.ts). On a
|
|
252
|
+
* real grant we're DONE — no Settings hunt, and a confirming banner fires.
|
|
253
|
+
* 2. If the signed helper is UNAVAILABLE (non-macOS publish / missing bundle)
|
|
254
|
+
* or the user previously DENIED (macOS won't re-prompt), fall back to the
|
|
255
|
+
* legacy reliable next-best thing: register the branded applet, open
|
|
256
|
+
* **System Settings → Notifications** (deep-link), and fire a priming
|
|
257
|
+
* banner so the GoCode toggle is one tap away.
|
|
258
|
+
* On Windows/Linux notifications don't gate behind a per-app grant the same
|
|
259
|
+
* way, so this just fires a priming banner and reports success.
|
|
260
|
+
*
|
|
261
|
+
* Best-effort + total: never throws; always resolves to a {@link PermissionResult}.
|
|
262
|
+
*/
|
|
263
|
+
export async function requestDesktopPermission(opts = {}) {
|
|
264
|
+
const platform = (opts.platform ?? process.platform);
|
|
265
|
+
const run = opts.run ?? defaultRunner;
|
|
266
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_DESKTOP_TIMEOUT_MS;
|
|
267
|
+
const logLine = async (line) => {
|
|
268
|
+
try {
|
|
269
|
+
if (opts.log)
|
|
270
|
+
await opts.log(line);
|
|
271
|
+
else
|
|
272
|
+
await appendLog(`PERMISSION: ${line}`, { home: opts.home, timestamp: opts.timestamp });
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
// logging must never block
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
if (platform !== "darwin") {
|
|
279
|
+
// Prime a banner so the app is registered; no per-app grant gate to open.
|
|
280
|
+
const primed = await notifyDesktop({ title: APP_NAME, body: "GoCode notifications are enabled.", sound: false }, opts);
|
|
281
|
+
const message = primed.ok
|
|
282
|
+
? "Fired a test banner — desktop notifications are working."
|
|
283
|
+
: `Could not fire a test banner (${primed.error ?? "unknown"}).`;
|
|
284
|
+
// Log the outcome too (parity with the macOS branch) so the `permissions`
|
|
285
|
+
// command leaves a breadcrumb on Linux / unsupported notification setups.
|
|
286
|
+
await logLine(message);
|
|
287
|
+
return {
|
|
288
|
+
platform: primed.platform,
|
|
289
|
+
openedSettings: false,
|
|
290
|
+
primed: primed.ok,
|
|
291
|
+
message,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
// macOS path.
|
|
295
|
+
//
|
|
296
|
+
// PREFERRED (2026-06-20): pop the REAL native Allow/Deny modal via the
|
|
297
|
+
// Developer-ID-SIGNED helper. Unlike the legacy `osacompile` applet (which
|
|
298
|
+
// can't request authorization at all), the signed app calls
|
|
299
|
+
// UNUserNotificationCenter.requestAuthorization, which shows the system modal
|
|
300
|
+
// the first time and persists the user's choice. We try this first; if it
|
|
301
|
+
// genuinely grants (or the user already granted), we're DONE — no Settings
|
|
302
|
+
// hunt needed. If the signed helper is unavailable (non-macOS publish, missing
|
|
303
|
+
// bundle) or the user previously denied, we fall back to the legacy
|
|
304
|
+
// open-Settings + priming-banner nudge below.
|
|
305
|
+
// `skipSignedHelper` (tests / hard opt-out) makes this resolve as unavailable
|
|
306
|
+
// so we go straight to the legacy open-Settings + priming-banner fallback.
|
|
307
|
+
const helper = opts.skipSignedHelper
|
|
308
|
+
? { ok: false, status: "unknown", unavailable: true }
|
|
309
|
+
: await requestMacPermission({
|
|
310
|
+
home: opts.home,
|
|
311
|
+
run,
|
|
312
|
+
timeoutMs: opts.timeoutMs,
|
|
313
|
+
log: logLine,
|
|
314
|
+
platform: "darwin",
|
|
315
|
+
bundledAppPath: opts.bundledAppPath,
|
|
316
|
+
}, opts.helperVersionTag);
|
|
317
|
+
if (!helper.unavailable && (helper.status === "authorized" || helper.granted === true)) {
|
|
318
|
+
const msg = "Showed the native macOS permission dialog — GoCode desktop notifications are now allowed.";
|
|
319
|
+
await logLine(msg);
|
|
320
|
+
// Also fire a confirming banner so the user immediately sees a real one.
|
|
321
|
+
await notifyDesktop({ title: APP_NAME, body: "Desktop notifications are on. You’ll get a banner when your agent finishes.", sound: true }, opts).catch(() => undefined);
|
|
322
|
+
return { platform: "darwin", openedSettings: false, primed: true, message: msg };
|
|
323
|
+
}
|
|
324
|
+
let openedSettings = false;
|
|
325
|
+
try {
|
|
326
|
+
// Open System Settings straight to the Notifications pane (deep-link). The
|
|
327
|
+
// modern URL; falls back silently if the OS doesn't honour it.
|
|
328
|
+
const opened = await run("open", ["x-apple.systempreferences:com.apple.preference.notifications"], {
|
|
329
|
+
timeoutMs,
|
|
330
|
+
});
|
|
331
|
+
openedSettings = opened.code === 0;
|
|
332
|
+
if (!openedSettings) {
|
|
333
|
+
await logLine(`could not open Notifications settings (open exit ${opened.code})`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
catch (err) {
|
|
337
|
+
await logLine(`open Settings threw — ignored: ${err instanceof Error ? err.message : String(err)}`);
|
|
338
|
+
}
|
|
339
|
+
// Fire a priming banner — this both verifies the path AND nudges macOS to
|
|
340
|
+
// register the (now bundle-id-stamped) app into the Notifications list.
|
|
341
|
+
const primed = await notifyDesktop({
|
|
342
|
+
title: APP_NAME,
|
|
343
|
+
body: "Tap “Allow” for GoCode in System Settings → Notifications to get desktop alerts.",
|
|
344
|
+
sound: true,
|
|
345
|
+
}, opts);
|
|
346
|
+
// If the signed helper was present but the user had previously DENIED, say so
|
|
347
|
+
// explicitly — they must re-enable it in Settings (macOS won't re-prompt).
|
|
348
|
+
const deniedNote = !helper.unavailable && helper.status === "denied"
|
|
349
|
+
? "GoCode notifications are currently turned OFF — re-enable them in System Settings → Notifications → GoCode. "
|
|
350
|
+
: "";
|
|
351
|
+
const message = primed.ok
|
|
352
|
+
? deniedNote +
|
|
353
|
+
(openedSettings
|
|
354
|
+
? "Opened System Settings → Notifications and fired a test banner. Turn ON “Allow Notifications” for GoCode."
|
|
355
|
+
: "Fired a test banner. Open System Settings → Notifications and turn ON “Allow Notifications” for GoCode.")
|
|
356
|
+
: `${deniedNote}Could not fire the priming banner (${primed.error ?? "unknown"}). Open System Settings → Notifications and enable GoCode manually.`;
|
|
357
|
+
await logLine(message);
|
|
358
|
+
return { platform: "darwin", openedSettings, primed: primed.ok, message };
|
|
359
|
+
}
|
|
228
360
|
// ── macOS ────────────────────────────────────────────────────────────────────
|
|
229
361
|
/** Path to the branded helper app bundle under `~/.gocode/desktop/GoCode.app`. */
|
|
230
362
|
export function macAppPath(opts) {
|
|
@@ -338,6 +470,14 @@ export async function ensureMacBrandedApp(ctx) {
|
|
|
338
470
|
// bundled PNG → icns via `sips` (ships with macOS); if that fails the banner
|
|
339
471
|
// is still branded by NAME ("GoCode") which is the important part.
|
|
340
472
|
await brandMacIcon(appPath, ctx).catch(() => { });
|
|
473
|
+
// CRITICAL (2026-06-20): give the bundle a stable CFBundleIdentifier and
|
|
474
|
+
// register it with LaunchServices. macOS 15/26 silently DROP notifications
|
|
475
|
+
// from a bundle with no identifier — osacompile's default applet ships none,
|
|
476
|
+
// which is why banners reported success but never appeared. This makes the
|
|
477
|
+
// app authorizable + visible in System Settings → Notifications so the OS
|
|
478
|
+
// can persist the user's "Allow" grant. Best-effort: a failure here just
|
|
479
|
+
// means the banner may still be suppressed (degrade, never block).
|
|
480
|
+
await ensureMacBundleIdentity(appPath, ctx).catch(() => { });
|
|
341
481
|
// Stamp the version marker so we don't recompile until the bundle changes.
|
|
342
482
|
// Clear any older `.bundle-v*` markers first so the dir doesn't accumulate.
|
|
343
483
|
try {
|
|
@@ -379,6 +519,50 @@ async function brandMacIcon(appPath, ctx) {
|
|
|
379
519
|
await ctx.log(`icon conversion skipped (sips code ${res.code}) — banner is name-branded only`);
|
|
380
520
|
}
|
|
381
521
|
}
|
|
522
|
+
/**
|
|
523
|
+
* Stamp a stable `CFBundleIdentifier` into the compiled applet's Info.plist and
|
|
524
|
+
* register the bundle with LaunchServices.
|
|
525
|
+
*
|
|
526
|
+
* WHY (2026-06-20): `osacompile` produces an applet whose Info.plist has NO
|
|
527
|
+
* `CFBundleIdentifier`. On macOS 15/26 the notification system keys
|
|
528
|
+
* authorization off the bundle id, so an id-less app is treated as
|
|
529
|
+
* un-authorizable and its `display notification` banners are silently dropped
|
|
530
|
+
* (the `osascript`/applet call still exits 0 — it "posted" — so nothing looked
|
|
531
|
+
* wrong from our side). Stamping a stable id makes the app:
|
|
532
|
+
* 1. appear in System Settings → Notifications (so the user can Allow it), and
|
|
533
|
+
* 2. retain that grant across rebuilds (the id is stable, see {@link MAC_BUNDLE_ID}).
|
|
534
|
+
* We then `lsregister -f` the bundle so the OS picks it up immediately rather
|
|
535
|
+
* than waiting for a Spotlight/LaunchServices sweep.
|
|
536
|
+
*
|
|
537
|
+
* Best-effort + total: never throws (caller wraps in `.catch`), and any failure
|
|
538
|
+
* just leaves the banner possibly-suppressed rather than blocking the hook.
|
|
539
|
+
*/
|
|
540
|
+
export async function ensureMacBundleIdentity(appPath, ctx) {
|
|
541
|
+
const plist = path.join(appPath, "Contents", "Info.plist");
|
|
542
|
+
// PlistBuddy ships with every macOS. `Set` updates an existing key; if the
|
|
543
|
+
// key is absent (osacompile's applet has none) `Set` fails, so try `Add`.
|
|
544
|
+
const setRes = await ctx.run("/usr/libexec/PlistBuddy", ["-c", `Set :CFBundleIdentifier ${MAC_BUNDLE_ID}`, plist], { timeoutMs: ctx.timeoutMs });
|
|
545
|
+
if (setRes.code !== 0) {
|
|
546
|
+
const addRes = await ctx.run("/usr/libexec/PlistBuddy", ["-c", `Add :CFBundleIdentifier string ${MAC_BUNDLE_ID}`, plist], { timeoutMs: ctx.timeoutMs });
|
|
547
|
+
if (addRes.code !== 0) {
|
|
548
|
+
await ctx.log(`could not stamp CFBundleIdentifier (PlistBuddy ${addRes.code}): ${addRes.stderr.trim()} — notifications may be suppressed by macOS`);
|
|
549
|
+
// No id → registering is pointless; bail (the banner may still be dropped,
|
|
550
|
+
// but we've logged the actionable reason).
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
// Register the bundle with LaunchServices so the OS recognises it as an app
|
|
555
|
+
// that can post notifications, NOW (not after a background sweep). `-f` forces
|
|
556
|
+
// a (re)register of this exact path.
|
|
557
|
+
const lsregister = "/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister";
|
|
558
|
+
const reg = await ctx.run(lsregister, ["-f", appPath], { timeoutMs: ctx.timeoutMs });
|
|
559
|
+
if (reg.code !== 0) {
|
|
560
|
+
await ctx.log(`lsregister returned ${reg.code} — bundle may not appear in Notification settings immediately`);
|
|
561
|
+
}
|
|
562
|
+
else {
|
|
563
|
+
await ctx.log(`stamped CFBundleIdentifier ${MAC_BUNDLE_ID} + registered bundle with LaunchServices`);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
382
566
|
/**
|
|
383
567
|
* Write (or clear) the click-target file the applet reads on a banner click
|
|
384
568
|
* (PRD §3.6). When `click` is set we write a single tab-separated line
|
|
@@ -402,6 +586,53 @@ function oneLine(s) {
|
|
|
402
586
|
return s.replace(/[\t\r\n]+/g, " ").trim();
|
|
403
587
|
}
|
|
404
588
|
async function notifyMac(banner, ctx) {
|
|
589
|
+
// PREFERRED path (2026-06-20): the Developer-ID-SIGNED helper app. It posts a
|
|
590
|
+
// first-class UserNotifications banner from a properly-authorized app and
|
|
591
|
+
// carries a real click action. Only used when the user has actually granted
|
|
592
|
+
// notification permission to the signed helper (status "authorized") — if they
|
|
593
|
+
// haven't (notDetermined/denied), a signed-helper post would silently no-op,
|
|
594
|
+
// so we fall through to the legacy applet which still shows *something*. We do
|
|
595
|
+
// NOT request authorization here (that's the `permissions`/`setup` flow's job,
|
|
596
|
+
// where there's a human to click Allow); a fire-and-forget banner must never
|
|
597
|
+
// pop a modal. Best-effort: any failure falls through to the applet.
|
|
598
|
+
if (!ctx.skipBranding && !ctx.skipSignedHelper) {
|
|
599
|
+
try {
|
|
600
|
+
// Read the CACHED auth status (a cheap file read) — never a live
|
|
601
|
+
// `open`+poll round-trip on the hot fire-and-forget path. The cache is
|
|
602
|
+
// populated by the `permissions`/`setup` flow when the user grants. No
|
|
603
|
+
// cache (or not "authorized") → fall through to the applet.
|
|
604
|
+
const cached = await cachedAuthStatus(ctx);
|
|
605
|
+
if (cached === "authorized") {
|
|
606
|
+
// KNOWN LIMITATION (signed-banner click-through): the signed helper exits
|
|
607
|
+
// ~0.4s after posting, so its in-process click delegate is gone by the
|
|
608
|
+
// time a user actually clicks the banner; a click then relaunches the app
|
|
609
|
+
// with no args (→ a no-op `status` run) and does NOT route to the IDE. We
|
|
610
|
+
// still pass the click target on the notification's userInfo (harmless),
|
|
611
|
+
// but reliable click-to-open remains the legacy applet's job. The signed
|
|
612
|
+
// path's win is the real Allow dialog + correct "GoCode" attribution; if
|
|
613
|
+
// a guaranteed click action matters more than attribution for a given
|
|
614
|
+
// banner, the applet fallback (below) handles the click via click-target.tsv.
|
|
615
|
+
if (banner.click) {
|
|
616
|
+
await writeMacClickTarget(banner.click, ctx).catch(() => { });
|
|
617
|
+
}
|
|
618
|
+
const posted = await notifyViaMacHelper({
|
|
619
|
+
title: banner.title,
|
|
620
|
+
body: banner.body,
|
|
621
|
+
sound: banner.sound,
|
|
622
|
+
clickApp: banner.click?.app,
|
|
623
|
+
clickPath: banner.click?.projectPath,
|
|
624
|
+
}, { home: ctx.home, run: ctx.run, timeoutMs: ctx.timeoutMs, log: ctx.log, platform: "darwin" }, ctx.helperVersionTag);
|
|
625
|
+
if (posted.ok) {
|
|
626
|
+
await ctx.log(`signed-helper banner shown (${banner.title})`);
|
|
627
|
+
return { ok: true, platform: "darwin" };
|
|
628
|
+
}
|
|
629
|
+
await ctx.log(`signed-helper banner failed (${posted.detail ?? posted.status}) — falling back to applet`);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
catch (err) {
|
|
633
|
+
await ctx.log(`signed-helper path errored (${err instanceof Error ? err.message : String(err)}) — falling back to applet`);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
405
636
|
// Branded path: invoke the compiled GoCode.app applet so the banner is
|
|
406
637
|
// attributed to "GoCode" (+ our icon) instead of "Script Editor".
|
|
407
638
|
if (!ctx.skipBranding) {
|
package/dist/src/doctor.js
CHANGED
|
@@ -193,14 +193,33 @@ export async function gatherDoctor(opts = {}) {
|
|
|
193
193
|
// ── 3c. Cursor stop hook ───────────────────────────────────────────────
|
|
194
194
|
const cursorHooksPath = path.join(home, ".cursor", "hooks.json");
|
|
195
195
|
const cursorHooks = await readJsonFile(cursorHooksPath);
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
196
|
+
// Cursor's hooks.json nests the events under a top-level `hooks` key:
|
|
197
|
+
// { "version": 1, "hooks": { "stop": [ { "command": ... } ] } }
|
|
198
|
+
// Older/loose configs put `stop` at the top level. Scan `stop` from BOTH
|
|
199
|
+
// locations (combining their commands) so the doctor doesn't false-negative
|
|
200
|
+
// on the real nested schema — that bug made it report "hook not found" even
|
|
201
|
+
// when the hook was correctly installed (2026-06-20) — AND so a mixed config
|
|
202
|
+
// carrying a top-level `stop` is still detected even when a nested `hooks`
|
|
203
|
+
// object exists for other events.
|
|
204
|
+
const cursorRoot = cursorHooks && typeof cursorHooks === "object"
|
|
205
|
+
? cursorHooks
|
|
202
206
|
: null;
|
|
203
|
-
const
|
|
207
|
+
const cursorNestedHooks = cursorRoot &&
|
|
208
|
+
"hooks" in cursorRoot &&
|
|
209
|
+
cursorRoot.hooks &&
|
|
210
|
+
typeof cursorRoot.hooks === "object"
|
|
211
|
+
? cursorRoot.hooks
|
|
212
|
+
: null;
|
|
213
|
+
const cursorStopEntries = [
|
|
214
|
+
// Nested `hooks.stop` (current Cursor schema).
|
|
215
|
+
...(cursorNestedHooks && "stop" in cursorNestedHooks
|
|
216
|
+
? flattenHookCommands(cursorNestedHooks.stop)
|
|
217
|
+
: []),
|
|
218
|
+
// Top-level `stop` (older/loose schema).
|
|
219
|
+
...(cursorRoot && "stop" in cursorRoot
|
|
220
|
+
? flattenHookCommands(cursorRoot.stop)
|
|
221
|
+
: []),
|
|
222
|
+
];
|
|
204
223
|
const cursorStopInstalled = cursorStopEntries.some((c) => c.includes(CURSOR_HOOK_TOKEN));
|
|
205
224
|
checks.push({
|
|
206
225
|
key: "hook:cursor-stop",
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
// `mac_helper` — the bridge to the Developer-ID-SIGNED macOS notifier app bundle
|
|
2
|
+
// (`GoCodeNotifier.app`) shipped pre-signed inside the npm tarball under
|
|
3
|
+
// `assets/`. This is the ONE thing the zero-dependency `osacompile` applet could
|
|
4
|
+
// never do: pop the real native "“GoCode” Would Like to Send You Notifications ·
|
|
5
|
+
// Allow / Don't Allow" authorization modal, and post first-class
|
|
6
|
+
// UserNotifications banners from a properly-authorized signed app.
|
|
7
|
+
//
|
|
8
|
+
// WHY A SEPARATE MODULE (and why it's all best-effort):
|
|
9
|
+
// `desktop_notify.ts` owns the legacy unsigned-applet path (always available,
|
|
10
|
+
// posts banners, but CANNOT request authorization). This module owns the
|
|
11
|
+
// SIGNED path. The two TWO HARD macOS rules below were both confirmed
|
|
12
|
+
// empirically (2026-06-20) and are the reason this isn't a one-liner:
|
|
13
|
+
//
|
|
14
|
+
// 1. LAUNCH VIA LaunchServices, NEVER exec the inner binary.
|
|
15
|
+
// Running `…/GoCodeNotifier.app/Contents/MacOS/GoCodeNotifier` directly
|
|
16
|
+
// bypasses LaunchServices and macOS AUTO-DENIES the authorization request
|
|
17
|
+
// (and that denial STICKS). We must launch with `open <app> --args …`.
|
|
18
|
+
// Because `open` discards the child's stdout, the helper writes its JSON
|
|
19
|
+
// result to a file we pass via `GOCODE_RESULT_FILE` and we read THAT.
|
|
20
|
+
//
|
|
21
|
+
// 2. RUN FROM A STABLE LOCATION, NEVER /tmp (or a volatile npm cache).
|
|
22
|
+
// Launching the bundle from `/tmp` yields "Notifications are not allowed
|
|
23
|
+
// for this application". So on first use we COPY the signed bundle out of
|
|
24
|
+
// the package's `assets/` into `~/.gocode/desktop/GoCodeNotifier.app`
|
|
25
|
+
// (stable, user-owned) and run it from there. Copying preserves the
|
|
26
|
+
// embedded code signature (it's just files), so it stays Developer-ID
|
|
27
|
+
// valid. We then `lsregister -f` it so the OS recognises it immediately.
|
|
28
|
+
//
|
|
29
|
+
// CONTRACT: every export is best-effort + total — never throws, always resolves
|
|
30
|
+
// to a structured result. A missing bundle / unsupported platform / wedged child
|
|
31
|
+
// resolves to `{ ok:false, … }` so the caller cleanly falls back to the unsigned
|
|
32
|
+
// applet. This module owns NO gating policy — callers decide whether to use it.
|
|
33
|
+
import { promises as fs } from "node:fs";
|
|
34
|
+
import os from "node:os";
|
|
35
|
+
import path from "node:path";
|
|
36
|
+
import { fileURLToPath } from "node:url";
|
|
37
|
+
import { desktopDir, defaultRunner } from "./desktop_notify.js";
|
|
38
|
+
import { VERSION } from "./version.js";
|
|
39
|
+
/**
|
|
40
|
+
* Stable CFBundleIdentifier of the SIGNED helper. DISTINCT from the legacy
|
|
41
|
+
* applet's `com.gocode.notify.desktop` on purpose: the signed app owns its own
|
|
42
|
+
* notification-authorization identity in System Settings → Notifications, so a
|
|
43
|
+
* poisoned/denied decision on the old shared id can never block the signed
|
|
44
|
+
* grant. MUST match the CFBundleIdentifier in
|
|
45
|
+
* `native/macos/GoCodeNotifier/Info.plist` + `build_and_sign.sh`.
|
|
46
|
+
*/
|
|
47
|
+
export const HELPER_BUNDLE_ID = "com.gocode.notify.helper";
|
|
48
|
+
/** The signed app bundle's name (same in `assets/` and at the runtime location). */
|
|
49
|
+
export const HELPER_APP_NAME = "GoCodeNotifier.app";
|
|
50
|
+
/** The executable inside the bundle (Contents/MacOS/<this>). */
|
|
51
|
+
export const HELPER_EXECUTABLE = "GoCodeNotifier";
|
|
52
|
+
/** Hard cap for a helper invocation. The Swift side also self-times-out. */
|
|
53
|
+
export const DEFAULT_HELPER_TIMEOUT_MS = 30000;
|
|
54
|
+
// ── path resolution ──────────────────────────────────────────────────────────
|
|
55
|
+
/**
|
|
56
|
+
* Resolve the SIGNED `GoCodeNotifier.app` shipped in the package's `assets/` dir.
|
|
57
|
+
* The compiled module lives at `dist/src/mac_helper.js`, so `assets/` is two
|
|
58
|
+
* levels up. Returns the bundle path (which may not exist on a non-macOS publish
|
|
59
|
+
* or a partial install — callers check existence). Mirrors `bundledIconPath`.
|
|
60
|
+
*/
|
|
61
|
+
export function bundledHelperAppPath() {
|
|
62
|
+
const here = path.dirname(fileURLToPath(import.meta.url)); // dist/src
|
|
63
|
+
return path.resolve(here, "..", "..", "assets", HELPER_APP_NAME);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The STABLE runtime location we copy the signed bundle to before running it
|
|
67
|
+
* (`~/.gocode/desktop/GoCodeNotifier.app`). Running from here — never `/tmp` and
|
|
68
|
+
* never the volatile npm cache — is REQUIRED for macOS to allow the app to
|
|
69
|
+
* request notification authorization (see module header rule 2).
|
|
70
|
+
*/
|
|
71
|
+
export function installedHelperAppPath(opts) {
|
|
72
|
+
return path.join(desktopDir(opts), HELPER_APP_NAME);
|
|
73
|
+
}
|
|
74
|
+
/** The signed helper's executable at the stable runtime location. */
|
|
75
|
+
export function installedHelperBinPath(opts) {
|
|
76
|
+
return path.join(installedHelperAppPath(opts), "Contents", "MacOS", HELPER_EXECUTABLE);
|
|
77
|
+
}
|
|
78
|
+
/** Marker recording which package version's bundle we last installed (for upgrades). */
|
|
79
|
+
function installedMarkerPath(opts) {
|
|
80
|
+
return path.join(desktopDir(opts), ".helper-installed");
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Cache file for the last-known authorization status. The fire-and-forget banner
|
|
84
|
+
* path reads THIS (a cheap file read) instead of doing a full `open`+poll
|
|
85
|
+
* round-trip on every notification — a live status query costs ~1s+ which is far
|
|
86
|
+
* too slow for a hook. The cache is written whenever we DO run the helper
|
|
87
|
+
* (`request-permission` / `status`), so it tracks the user's real decision; if
|
|
88
|
+
* the user later flips the toggle in System Settings the cache self-heals the
|
|
89
|
+
* next time the permission/status path runs (and a stale "authorized" only means
|
|
90
|
+
* one wasted signed-post that silently falls through to the applet anyway).
|
|
91
|
+
*/
|
|
92
|
+
export function authStatusCachePath(opts) {
|
|
93
|
+
return path.join(desktopDir(opts), ".auth-status");
|
|
94
|
+
}
|
|
95
|
+
/** Persist the last-known auth status (best-effort; failure is non-fatal). */
|
|
96
|
+
async function writeAuthStatusCache(status, opts) {
|
|
97
|
+
try {
|
|
98
|
+
await fs.mkdir(desktopDir(opts), { recursive: true, mode: 0o700 });
|
|
99
|
+
await fs.writeFile(authStatusCachePath(opts), status, { encoding: "utf8", mode: 0o600 });
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
// cache is an optimization — never fail the flow over it
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Read the cached authorization status WITHOUT running the helper (fast). Returns
|
|
107
|
+
* undefined when there is no cache yet (then the banner path uses the applet
|
|
108
|
+
* until the user runs the permission flow once). This is what `notifyMac` calls
|
|
109
|
+
* on the hot path.
|
|
110
|
+
*/
|
|
111
|
+
export async function cachedAuthStatus(opts) {
|
|
112
|
+
try {
|
|
113
|
+
const raw = (await fs.readFile(authStatusCachePath(opts), "utf8")).trim();
|
|
114
|
+
return raw ? raw : undefined;
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// ── ensure the signed bundle is installed at the stable location ──────────────
|
|
121
|
+
/**
|
|
122
|
+
* Copy the package's signed `GoCodeNotifier.app` to the stable runtime location
|
|
123
|
+
* (`~/.gocode/desktop/`) if it isn't there (or is stale for this package
|
|
124
|
+
* version), then register it with LaunchServices so the OS recognises it
|
|
125
|
+
* immediately. Idempotent. Returns the installed bundle path on success, or
|
|
126
|
+
* undefined when the signed bundle isn't available to copy (then the caller
|
|
127
|
+
* falls back to the unsigned applet).
|
|
128
|
+
*
|
|
129
|
+
* Copying preserves the embedded Developer-ID code signature (it's just a tree
|
|
130
|
+
* of files) — `cp -R` keeps the `_CodeSignature` dir + signed Mach-O intact.
|
|
131
|
+
*/
|
|
132
|
+
export async function ensureHelperInstalled(opts = {}, versionTag = VERSION) {
|
|
133
|
+
const run = opts.run ?? defaultRunner;
|
|
134
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_HELPER_TIMEOUT_MS;
|
|
135
|
+
const src = opts.bundledAppPath ?? bundledHelperAppPath();
|
|
136
|
+
const dest = installedHelperAppPath(opts);
|
|
137
|
+
const destBin = installedHelperBinPath(opts);
|
|
138
|
+
const marker = installedMarkerPath(opts);
|
|
139
|
+
// The signed bundle must exist in the package to install it.
|
|
140
|
+
try {
|
|
141
|
+
await fs.access(src);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
await opts.log?.(`signed helper not bundled at ${src} — using unsigned applet`);
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
// Already installed AND current for this package version? → reuse.
|
|
148
|
+
try {
|
|
149
|
+
await fs.access(destBin);
|
|
150
|
+
const tag = await fs.readFile(marker, "utf8").catch(() => "");
|
|
151
|
+
if (tag.trim() === versionTag)
|
|
152
|
+
return dest;
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
// need to (re)install — missing bin or stale/absent marker
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
await fs.mkdir(desktopDir(opts), { recursive: true, mode: 0o700 });
|
|
159
|
+
// Remove any stale copy so a partial/older bundle can't linger.
|
|
160
|
+
await fs.rm(dest, { recursive: true, force: true }).catch(() => { });
|
|
161
|
+
// `cp -R` preserves the code signature; Node's fs.cp also works but we use
|
|
162
|
+
// the injectable runner so tests can stub it without touching the disk.
|
|
163
|
+
const cp = await run("cp", ["-R", src, dest], { timeoutMs });
|
|
164
|
+
if (cp.code !== 0) {
|
|
165
|
+
await opts.log?.(`could not copy signed helper (cp ${cp.code}): ${cp.stderr.trim()} — using unsigned applet`);
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
// Register with LaunchServices so the OS picks it up NOW (not after a sweep).
|
|
169
|
+
const lsregister = "/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister";
|
|
170
|
+
await run(lsregister, ["-f", dest], { timeoutMs }).catch(() => ({ code: 1, stdout: "", stderr: "" }));
|
|
171
|
+
await fs.writeFile(marker, versionTag, { encoding: "utf8", mode: 0o600 }).catch(() => { });
|
|
172
|
+
return dest;
|
|
173
|
+
}
|
|
174
|
+
catch (err) {
|
|
175
|
+
await opts.log?.(`signed helper install error: ${err instanceof Error ? err.message : String(err)} — using unsigned applet`);
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// ── run the installed signed helper via LaunchServices ────────────────────────
|
|
180
|
+
/** True on macOS — the only platform the signed helper targets. */
|
|
181
|
+
function isMac(opts) {
|
|
182
|
+
return (opts.platform ?? process.platform) === "darwin";
|
|
183
|
+
}
|
|
184
|
+
/** A result meaning "we couldn't run the signed path; fall back to the applet". */
|
|
185
|
+
function unavailable(detail) {
|
|
186
|
+
return { ok: false, status: "unknown", detail, unavailable: true };
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Launch the installed signed helper via `open <app> --args …` (LaunchServices —
|
|
190
|
+
* the ONLY way macOS will let it request authorization), capturing its JSON
|
|
191
|
+
* result through a temp `GOCODE_RESULT_FILE` (because `open` discards the child's
|
|
192
|
+
* stdout). Polls the result file until the helper writes it or we hit the
|
|
193
|
+
* timeout. Best-effort + total: any failure resolves to a structured
|
|
194
|
+
* {@link HelperResult}, never throws.
|
|
195
|
+
*
|
|
196
|
+
* @param command the helper subcommand: "request-permission" | "notify" | "status"
|
|
197
|
+
* @param extraArgs additional `--flag value` pairs for the helper
|
|
198
|
+
*/
|
|
199
|
+
export async function runHelper(command, extraArgs, opts = {}, versionTag = VERSION) {
|
|
200
|
+
if (!isMac(opts))
|
|
201
|
+
return unavailable("signed helper is macOS-only");
|
|
202
|
+
const run = opts.run ?? defaultRunner;
|
|
203
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_HELPER_TIMEOUT_MS;
|
|
204
|
+
const appPath = await ensureHelperInstalled(opts, versionTag);
|
|
205
|
+
if (!appPath)
|
|
206
|
+
return unavailable("signed helper not installed");
|
|
207
|
+
// A unique result file per call so concurrent invocations don't clobber.
|
|
208
|
+
const resultFile = path.join(os.tmpdir(), `gocode-helper-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.json`);
|
|
209
|
+
// `open -n` launches a FRESH instance via launchd. CRITICAL: a LaunchServices
|
|
210
|
+
// launch does NOT inherit arbitrary env vars from THIS process — env passed to
|
|
211
|
+
// the `open` subprocess is the `open` tool's own env, not the launched app's.
|
|
212
|
+
// The supported way to inject a per-launch env var into the launched app is
|
|
213
|
+
// `open --env NAME=VALUE` (see open(1)); we use that so the Swift helper
|
|
214
|
+
// reliably sees GOCODE_RESULT_FILE and writes its JSON result there. We poll
|
|
215
|
+
// that file rather than `-W` (which can hang for the click window). We ALSO
|
|
216
|
+
// keep the var in the subprocess env as a belt-and-braces fallback for any OS
|
|
217
|
+
// where the flag is unsupported, but `--env` is the load-bearing path.
|
|
218
|
+
const args = [
|
|
219
|
+
"-n",
|
|
220
|
+
"--env",
|
|
221
|
+
`GOCODE_RESULT_FILE=${resultFile}`,
|
|
222
|
+
appPath,
|
|
223
|
+
"--args",
|
|
224
|
+
command,
|
|
225
|
+
...extraArgs,
|
|
226
|
+
];
|
|
227
|
+
const launch = await run("open", args, {
|
|
228
|
+
timeoutMs,
|
|
229
|
+
env: { GOCODE_RESULT_FILE: resultFile },
|
|
230
|
+
});
|
|
231
|
+
if (launch.code !== 0) {
|
|
232
|
+
await opts.log?.(`open failed to launch signed helper (code ${launch.code}): ${launch.stderr.trim()}`);
|
|
233
|
+
return unavailable(`open exit ${launch.code}`);
|
|
234
|
+
}
|
|
235
|
+
// Poll for the helper's JSON result. It writes the file as its LAST act, so a
|
|
236
|
+
// present-and-parseable file means the operation finished. Cap polling at the
|
|
237
|
+
// timeout; the Swift side has its own hard self-timeout so this can't run away.
|
|
238
|
+
const deadline = Date.now() + timeoutMs;
|
|
239
|
+
// Track the last "the file exists but I couldn't parse it" reason so a
|
|
240
|
+
// persistent malformed/truncated result is surfaced on timeout instead of a
|
|
241
|
+
// bare "timed out" (which would hide the real cause). A plain ENOENT — the
|
|
242
|
+
// helper simply hasn't written yet — is NOT logged (that's the normal poll).
|
|
243
|
+
let lastParseError;
|
|
244
|
+
// A non-ENOENT read failure (EACCES/ENOTDIR/etc.) — distinct from the normal
|
|
245
|
+
// "not written yet" ENOENT — so a persistent read problem is diagnosable.
|
|
246
|
+
let lastReadError;
|
|
247
|
+
while (Date.now() < deadline) {
|
|
248
|
+
let raw;
|
|
249
|
+
try {
|
|
250
|
+
raw = await fs.readFile(resultFile, "utf8");
|
|
251
|
+
}
|
|
252
|
+
catch (err) {
|
|
253
|
+
// ENOENT is the NORMAL case — the helper hasn't written the file yet, so
|
|
254
|
+
// retry quietly. Any OTHER errno (EACCES, ENOTDIR, an unexpected tmpdir
|
|
255
|
+
// problem) is NOT normal: record it so a persistent read failure surfaces
|
|
256
|
+
// in the timeout detail instead of masquerading as a generic timeout.
|
|
257
|
+
const code = err?.code;
|
|
258
|
+
if (code !== "ENOENT") {
|
|
259
|
+
lastReadError = err instanceof Error ? err.message : String(err);
|
|
260
|
+
}
|
|
261
|
+
await delay(150);
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
try {
|
|
265
|
+
const parsed = JSON.parse(raw.trim());
|
|
266
|
+
await fs.rm(resultFile, { force: true }).catch(() => { });
|
|
267
|
+
// Refresh the auth-status cache when this run reported an authorization
|
|
268
|
+
// status (request-permission / status). `notify` reports "posted", which
|
|
269
|
+
// we deliberately do NOT cache as an auth status.
|
|
270
|
+
if (command !== "notify" &&
|
|
271
|
+
["authorized", "denied", "notDetermined", "provisional", "ephemeral"].includes(parsed.status)) {
|
|
272
|
+
await writeAuthStatusCache(parsed.status, opts);
|
|
273
|
+
}
|
|
274
|
+
return parsed;
|
|
275
|
+
}
|
|
276
|
+
catch (err) {
|
|
277
|
+
// The file EXISTS but didn't parse — likely a mid-write truncation, so
|
|
278
|
+
// retry; but remember the reason so a persistent bad result is reported.
|
|
279
|
+
lastParseError = err instanceof Error ? err.message : String(err);
|
|
280
|
+
await delay(150);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
await fs.rm(resultFile, { force: true }).catch(() => { });
|
|
284
|
+
// Surface the most specific known cause in priority order: a malformed result
|
|
285
|
+
// (file existed but never parsed) > a non-ENOENT read failure > bare timeout.
|
|
286
|
+
const cause = lastParseError
|
|
287
|
+
? `result never parsed (last error: ${lastParseError})`
|
|
288
|
+
: lastReadError
|
|
289
|
+
? `result file unreadable (last error: ${lastReadError})`
|
|
290
|
+
: undefined;
|
|
291
|
+
const detail = cause ? `helper ${command} ${cause}` : `helper ${command} timed out`;
|
|
292
|
+
await opts.log?.(`signed helper ${command} timed out after ${timeoutMs}ms${cause ? ` — ${cause}` : ""}`);
|
|
293
|
+
return { ok: false, status: "timeout", detail, unavailable: false };
|
|
294
|
+
}
|
|
295
|
+
/** Promise-based sleep (no deps). */
|
|
296
|
+
function delay(ms) {
|
|
297
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
298
|
+
}
|
|
299
|
+
// ── public entry points ───────────────────────────────────────────────────────
|
|
300
|
+
/**
|
|
301
|
+
* Pop the NATIVE macOS notification Allow/Deny dialog for the SIGNED GoCode
|
|
302
|
+
* helper (the first time), or resolve to the persisted decision on later runs.
|
|
303
|
+
* Returns the parsed {@link HelperResult}; `granted:true` / `status:"authorized"`
|
|
304
|
+
* means the user allowed (or had already allowed) GoCode desktop notifications.
|
|
305
|
+
*
|
|
306
|
+
* On a machine where the signed bundle isn't available (non-macOS, or a publish
|
|
307
|
+
* that didn't ship it) this resolves to `{ unavailable:true }` so the caller can
|
|
308
|
+
* fall back to the legacy applet + "open Settings" nudge.
|
|
309
|
+
*/
|
|
310
|
+
export async function requestMacPermission(opts = {}, versionTag = VERSION) {
|
|
311
|
+
// Give the dialog a generous window: the user has to read + click.
|
|
312
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_HELPER_TIMEOUT_MS;
|
|
313
|
+
return runHelper("request-permission", ["--timeout", String(Math.floor(timeoutMs / 1000))], opts, versionTag);
|
|
314
|
+
}
|
|
315
|
+
/** Read the current authorization status without prompting (read-only). */
|
|
316
|
+
export async function macHelperStatus(opts = {}, versionTag = VERSION) {
|
|
317
|
+
return runHelper("status", [], opts, versionTag);
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Post ONE first-class UserNotifications banner via the SIGNED helper (attributed
|
|
321
|
+
* to the authorized "GoCode" app, with a real click action). Falls back to
|
|
322
|
+
* `{ unavailable:true }` when the signed bundle isn't usable, so the caller can
|
|
323
|
+
* use the legacy applet path instead.
|
|
324
|
+
*/
|
|
325
|
+
export async function notifyViaMacHelper(input, opts = {}, versionTag = VERSION) {
|
|
326
|
+
const args = [
|
|
327
|
+
"--title", input.title,
|
|
328
|
+
"--body", input.body,
|
|
329
|
+
"--sound", input.sound ? "1" : "0",
|
|
330
|
+
];
|
|
331
|
+
if (input.clickApp && input.clickPath) {
|
|
332
|
+
args.push("--click-app", input.clickApp, "--click-path", input.clickPath);
|
|
333
|
+
}
|
|
334
|
+
return runHelper("notify", args, opts, versionTag);
|
|
335
|
+
}
|