@trygocode/notify 0.5.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 +28 -0
- 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/desktop_notify.js +232 -1
- package/dist/src/doctor.js +26 -7
- package/dist/src/mac_helper.js +335 -0
- package/dist/src/send.js +109 -36
- package/dist/src/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -529,6 +529,34 @@ npm publish, real-device E2E), see
|
|
|
529
529
|
|
|
530
530
|
## Changelog
|
|
531
531
|
|
|
532
|
+
### 0.6.0
|
|
533
|
+
|
|
534
|
+
- **Real native macOS "Allow Notifications" dialog (signed helper).** GoCode now
|
|
535
|
+
ships a tiny **Developer-ID-signed** macOS app bundle (`GoCodeNotifier.app`,
|
|
536
|
+
Team `88DC7KFY7M`) inside the package. On macOS, `gocode-notify permissions`
|
|
537
|
+
(and the post-`setup` nudge) now pop the **real system Allow/Don't-Allow
|
|
538
|
+
permission modal** — the thing a plain `npx`/`osascript` helper can't do —
|
|
539
|
+
instead of only opening System Settings and asking you to hunt for the toggle.
|
|
540
|
+
Once allowed, desktop banners post as a **first-class, authorized** app
|
|
541
|
+
(proper attribution + a real click-to-open-your-IDE action).
|
|
542
|
+
- **How it ships:** the bundle is **pre-signed at build time** by the
|
|
543
|
+
maintainer (`native/macos/build_and_sign.sh`) and committed to `assets/`, so
|
|
544
|
+
end-users need **no Xcode, no Developer ID, no compile step** — the one-line
|
|
545
|
+
install is unchanged. The signature survives the npm pack/extract round-trip.
|
|
546
|
+
- **Two macOS rules it honors (both required for the dialog to appear):** the
|
|
547
|
+
helper is copied to a **stable** location (`~/.gocode/desktop/`) — never
|
|
548
|
+
`/tmp` — and is always launched via **LaunchServices** (`open`), never by
|
|
549
|
+
exec'ing the inner binary (direct-exec auto-denies and the denial sticks).
|
|
550
|
+
- **Hot-path stays fast:** fire-and-forget banners read a cached auth status
|
|
551
|
+
(`~/.gocode/desktop/.auth-status`), so a notification never does a slow live
|
|
552
|
+
permission round-trip. The signed banner is used only once you've granted;
|
|
553
|
+
otherwise it transparently falls back to the legacy applet banner.
|
|
554
|
+
- **Own identity:** the signed helper uses bundle id `com.gocode.notify.helper`
|
|
555
|
+
(distinct from the legacy applet's `com.gocode.notify.desktop`) so it owns
|
|
556
|
+
its own row in System Settings → Notifications.
|
|
557
|
+
- **Linux/Windows unchanged.** This is a macOS-only enhancement; other
|
|
558
|
+
platforms keep their existing branded-banner paths.
|
|
559
|
+
|
|
532
560
|
### 0.5.0
|
|
533
561
|
|
|
534
562
|
- **Question detection across ALL IDEs — "Agent needs you" vs "finished".** When
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<plist version="1.0">
|
|
4
|
+
<dict>
|
|
5
|
+
<!-- CFBundleIdentifier MUST stay stable across rebuilds: macOS keys the user's
|
|
6
|
+
notification-authorization grant off it, so changing it would forget the
|
|
7
|
+
Allow. This is DISTINCT from the legacy applet's `com.gocode.notify.desktop`
|
|
8
|
+
on purpose: the signed helper owns its own identity in System Settings →
|
|
9
|
+
Notifications so a poisoned/denied state on the old shared id can't block
|
|
10
|
+
the signed app's grant. Mirrors HELPER_BUNDLE_ID in mac_helper.ts. -->
|
|
11
|
+
<key>CFBundleIdentifier</key>
|
|
12
|
+
<string>com.gocode.notify.helper</string>
|
|
13
|
+
<key>CFBundleName</key>
|
|
14
|
+
<string>GoCode</string>
|
|
15
|
+
<key>CFBundleDisplayName</key>
|
|
16
|
+
<string>GoCode</string>
|
|
17
|
+
<key>CFBundleExecutable</key>
|
|
18
|
+
<string>GoCodeNotifier</string>
|
|
19
|
+
<key>CFBundlePackageType</key>
|
|
20
|
+
<string>APPL</string>
|
|
21
|
+
<key>CFBundleInfoDictionaryVersion</key>
|
|
22
|
+
<string>6.0</string>
|
|
23
|
+
<key>CFBundleShortVersionString</key>
|
|
24
|
+
<string>1.0.0</string>
|
|
25
|
+
<key>CFBundleVersion</key>
|
|
26
|
+
<string>1</string>
|
|
27
|
+
<!-- LSUIElement: a faceless background helper — no Dock icon, no menu bar. -->
|
|
28
|
+
<key>LSUIElement</key>
|
|
29
|
+
<true/>
|
|
30
|
+
<key>LSMinimumSystemVersion</key>
|
|
31
|
+
<string>11.0</string>
|
|
32
|
+
<key>CFBundleIconFile</key>
|
|
33
|
+
<string>AppIcon</string>
|
|
34
|
+
<key>NSHumanReadableCopyright</key>
|
|
35
|
+
<string>© BIGSMS Pty Ltd</string>
|
|
36
|
+
</dict>
|
|
37
|
+
</plist>
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<plist version="1.0">
|
|
4
|
+
<dict>
|
|
5
|
+
<key>files</key>
|
|
6
|
+
<dict>
|
|
7
|
+
<key>Resources/AppIcon.icns</key>
|
|
8
|
+
<data>
|
|
9
|
+
GbhU1RfeSzs8A7x841vrx8S9NpM=
|
|
10
|
+
</data>
|
|
11
|
+
</dict>
|
|
12
|
+
<key>files2</key>
|
|
13
|
+
<dict>
|
|
14
|
+
<key>Resources/AppIcon.icns</key>
|
|
15
|
+
<dict>
|
|
16
|
+
<key>hash2</key>
|
|
17
|
+
<data>
|
|
18
|
+
80/V6KfdsY5g+nVZNtqimRwukW+CXDnk+bV0NlYa75w=
|
|
19
|
+
</data>
|
|
20
|
+
</dict>
|
|
21
|
+
</dict>
|
|
22
|
+
<key>rules</key>
|
|
23
|
+
<dict>
|
|
24
|
+
<key>^Resources/</key>
|
|
25
|
+
<true/>
|
|
26
|
+
<key>^Resources/.*\.lproj/</key>
|
|
27
|
+
<dict>
|
|
28
|
+
<key>optional</key>
|
|
29
|
+
<true/>
|
|
30
|
+
<key>weight</key>
|
|
31
|
+
<real>1000</real>
|
|
32
|
+
</dict>
|
|
33
|
+
<key>^Resources/.*\.lproj/locversion.plist$</key>
|
|
34
|
+
<dict>
|
|
35
|
+
<key>omit</key>
|
|
36
|
+
<true/>
|
|
37
|
+
<key>weight</key>
|
|
38
|
+
<real>1100</real>
|
|
39
|
+
</dict>
|
|
40
|
+
<key>^Resources/Base\.lproj/</key>
|
|
41
|
+
<dict>
|
|
42
|
+
<key>weight</key>
|
|
43
|
+
<real>1010</real>
|
|
44
|
+
</dict>
|
|
45
|
+
<key>^version.plist$</key>
|
|
46
|
+
<true/>
|
|
47
|
+
</dict>
|
|
48
|
+
<key>rules2</key>
|
|
49
|
+
<dict>
|
|
50
|
+
<key>.*\.dSYM($|/)</key>
|
|
51
|
+
<dict>
|
|
52
|
+
<key>weight</key>
|
|
53
|
+
<real>11</real>
|
|
54
|
+
</dict>
|
|
55
|
+
<key>^(.*/)?\.DS_Store$</key>
|
|
56
|
+
<dict>
|
|
57
|
+
<key>omit</key>
|
|
58
|
+
<true/>
|
|
59
|
+
<key>weight</key>
|
|
60
|
+
<real>2000</real>
|
|
61
|
+
</dict>
|
|
62
|
+
<key>^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/</key>
|
|
63
|
+
<dict>
|
|
64
|
+
<key>nested</key>
|
|
65
|
+
<true/>
|
|
66
|
+
<key>weight</key>
|
|
67
|
+
<real>10</real>
|
|
68
|
+
</dict>
|
|
69
|
+
<key>^.*</key>
|
|
70
|
+
<true/>
|
|
71
|
+
<key>^Info\.plist$</key>
|
|
72
|
+
<dict>
|
|
73
|
+
<key>omit</key>
|
|
74
|
+
<true/>
|
|
75
|
+
<key>weight</key>
|
|
76
|
+
<real>20</real>
|
|
77
|
+
</dict>
|
|
78
|
+
<key>^PkgInfo$</key>
|
|
79
|
+
<dict>
|
|
80
|
+
<key>omit</key>
|
|
81
|
+
<true/>
|
|
82
|
+
<key>weight</key>
|
|
83
|
+
<real>20</real>
|
|
84
|
+
</dict>
|
|
85
|
+
<key>^Resources/</key>
|
|
86
|
+
<dict>
|
|
87
|
+
<key>weight</key>
|
|
88
|
+
<real>20</real>
|
|
89
|
+
</dict>
|
|
90
|
+
<key>^Resources/.*\.lproj/</key>
|
|
91
|
+
<dict>
|
|
92
|
+
<key>optional</key>
|
|
93
|
+
<true/>
|
|
94
|
+
<key>weight</key>
|
|
95
|
+
<real>1000</real>
|
|
96
|
+
</dict>
|
|
97
|
+
<key>^Resources/.*\.lproj/locversion.plist$</key>
|
|
98
|
+
<dict>
|
|
99
|
+
<key>omit</key>
|
|
100
|
+
<true/>
|
|
101
|
+
<key>weight</key>
|
|
102
|
+
<real>1100</real>
|
|
103
|
+
</dict>
|
|
104
|
+
<key>^Resources/Base\.lproj/</key>
|
|
105
|
+
<dict>
|
|
106
|
+
<key>weight</key>
|
|
107
|
+
<real>1010</real>
|
|
108
|
+
</dict>
|
|
109
|
+
<key>^[^/]+$</key>
|
|
110
|
+
<dict>
|
|
111
|
+
<key>nested</key>
|
|
112
|
+
<true/>
|
|
113
|
+
<key>weight</key>
|
|
114
|
+
<real>10</real>
|
|
115
|
+
</dict>
|
|
116
|
+
<key>^embedded\.provisionprofile$</key>
|
|
117
|
+
<dict>
|
|
118
|
+
<key>weight</key>
|
|
119
|
+
<real>20</real>
|
|
120
|
+
</dict>
|
|
121
|
+
<key>^version\.plist$</key>
|
|
122
|
+
<dict>
|
|
123
|
+
<key>weight</key>
|
|
124
|
+
<real>20</real>
|
|
125
|
+
</dict>
|
|
126
|
+
</dict>
|
|
127
|
+
</dict>
|
|
128
|
+
</plist>
|
package/dist/src/cli.js
CHANGED
|
@@ -21,7 +21,7 @@ import { setup } from "./setup.js";
|
|
|
21
21
|
import { uninstall } from "./uninstall.js";
|
|
22
22
|
import { cmdConfig } from "./config.js";
|
|
23
23
|
import { onStop } from "./on_stop.js";
|
|
24
|
-
import { notifyDesktop } from "./desktop_notify.js";
|
|
24
|
+
import { notifyDesktop, requestDesktopPermission } from "./desktop_notify.js";
|
|
25
25
|
import { gatherDoctor, formatDoctor } from "./doctor.js";
|
|
26
26
|
/** Subcommands the finished CLI will expose (see PRD §4.1). */
|
|
27
27
|
export const COMMANDS = [
|
|
@@ -31,6 +31,7 @@ export const COMMANDS = [
|
|
|
31
31
|
"setup",
|
|
32
32
|
"status",
|
|
33
33
|
"doctor",
|
|
34
|
+
"permissions",
|
|
34
35
|
"mcp",
|
|
35
36
|
"uninstall",
|
|
36
37
|
"config",
|
|
@@ -93,6 +94,7 @@ export function printHelp() {
|
|
|
93
94
|
" setup Pair + detect runtimes + write configs",
|
|
94
95
|
" status Report credentials / server / detected runtimes",
|
|
95
96
|
" doctor Self-diagnostic checklist with exact fix commands",
|
|
97
|
+
" permissions Request macOS desktop-notification permission (opens Settings)",
|
|
96
98
|
" mcp Run as an MCP server over stdio",
|
|
97
99
|
" uninstall Remove entries this tool added",
|
|
98
100
|
" config Get/set Notify settings (get | set <key> <value> | pull)",
|
|
@@ -366,6 +368,48 @@ export async function cmdTest(args, deps = {}) {
|
|
|
366
368
|
console.error(`gocode-notify test: ${detail}.`);
|
|
367
369
|
return 1;
|
|
368
370
|
}
|
|
371
|
+
/**
|
|
372
|
+
* Handle `gocode-notify permissions` (alias `perms`). Makes the OS
|
|
373
|
+
* notification-permission UI appear so the user grants GoCode desktop banners
|
|
374
|
+
* with a click instead of hunting through Settings.
|
|
375
|
+
*
|
|
376
|
+
* On macOS this now pops the REAL native Allow/Deny modal via the Developer-ID-
|
|
377
|
+
* signed helper bundled in the package (see {@link requestDesktopPermission}); if
|
|
378
|
+
* the signed helper is unavailable or you previously denied, it falls back to
|
|
379
|
+
* registering the branded bundle, opening System Settings → Notifications, and
|
|
380
|
+
* firing a priming banner. Always exits 0 — best-effort convenience, never a gate.
|
|
381
|
+
*/
|
|
382
|
+
export async function cmdPermissions(args, deps = {}) {
|
|
383
|
+
const flags = parseFlags(args);
|
|
384
|
+
const agent = isAgentDriven(flags);
|
|
385
|
+
const sink = deps.sink ?? stdoutSink;
|
|
386
|
+
const res = await requestDesktopPermission({
|
|
387
|
+
home: deps.home,
|
|
388
|
+
timeoutMs: deps.timeoutMs,
|
|
389
|
+
timestamp: deps.timestamp,
|
|
390
|
+
});
|
|
391
|
+
if (agent) {
|
|
392
|
+
// `ok` reflects whether the NOTIFICATION path actually worked (the priming
|
|
393
|
+
// banner fired) — NOT merely that we opened Settings. On macOS, opening
|
|
394
|
+
// Settings is only a partial remediation; reporting ok:true off it alone
|
|
395
|
+
// would be false-success telemetry for automation consuming the sink.
|
|
396
|
+
sink({
|
|
397
|
+
step: "permissions",
|
|
398
|
+
ok: res.primed,
|
|
399
|
+
detail: res.message,
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
else {
|
|
403
|
+
console.log(`🔔 ${res.message}`);
|
|
404
|
+
if (res.platform === "darwin" && !res.primed) {
|
|
405
|
+
// Only show the Settings-hunt hint when we did NOT manage a real grant
|
|
406
|
+
// (e.g. previously denied, or the signed helper wasn't available).
|
|
407
|
+
console.log(" (If no dialog appeared, enable “Allow Notifications” for GoCode in\n" +
|
|
408
|
+
" System Settings → Notifications — it’s now one tap away.)");
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
return 0;
|
|
412
|
+
}
|
|
369
413
|
/**
|
|
370
414
|
* Handle `gocode-notify status [--server URL]`. Prints a coherent report of
|
|
371
415
|
* credentials / server reachability / detected runtimes (PRD §4.1, §8). Purely
|
|
@@ -462,6 +506,28 @@ export async function cmdSetup(args, deps = {}) {
|
|
|
462
506
|
console.log(` ${step.ok ? "✓" : "✗"} ${step.step}: ${step.detail}`);
|
|
463
507
|
}
|
|
464
508
|
}
|
|
509
|
+
// On macOS, proactively trigger the notification-permission flow so the user
|
|
510
|
+
// doesn't have to hunt for the GoCode toggle: register the branded bundle,
|
|
511
|
+
// open System Settings → Notifications, and fire a priming banner. Skipped in
|
|
512
|
+
// agent-driven mode (no human to grant it) and when setup didn't succeed.
|
|
513
|
+
// Best-effort — never flips the setup exit code (a permission nudge failing
|
|
514
|
+
// must not make `setup` report failure).
|
|
515
|
+
if (!agent && result.ok && process.platform === "darwin") {
|
|
516
|
+
try {
|
|
517
|
+
const perm = await requestDesktopPermission({
|
|
518
|
+
home: deps.home,
|
|
519
|
+
timeoutMs: deps.timeoutMs,
|
|
520
|
+
});
|
|
521
|
+
console.log("");
|
|
522
|
+
console.log(` 🔔 Desktop notifications: ${perm.message}`);
|
|
523
|
+
}
|
|
524
|
+
catch (err) {
|
|
525
|
+
// Never let a permission nudge fail the setup — but DO leave a breadcrumb
|
|
526
|
+
// so an unexpected throw here isn't completely invisible to the user.
|
|
527
|
+
console.log("");
|
|
528
|
+
console.log(` 🔔 Desktop notifications: couldn't run the permission nudge (${err instanceof Error ? err.message : String(err)}). Run \`gocode-notify permissions\` manually.`);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
465
531
|
return result.ok ? 0 : 1;
|
|
466
532
|
}
|
|
467
533
|
/**
|
|
@@ -689,6 +755,8 @@ export async function runAsync(argv) {
|
|
|
689
755
|
return cmdStatus(argv.slice(1));
|
|
690
756
|
if (cmd === "doctor")
|
|
691
757
|
return cmdDoctor(argv.slice(1));
|
|
758
|
+
if (cmd === "permissions" || cmd === "perms")
|
|
759
|
+
return cmdPermissions(argv.slice(1));
|
|
692
760
|
if (cmd === "setup")
|
|
693
761
|
return cmdSetup(argv.slice(1));
|
|
694
762
|
if (cmd === "mcp")
|
|
@@ -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
|
+
}
|
package/dist/src/send.js
CHANGED
|
@@ -32,8 +32,25 @@ export const NOTIFY_KINDS = [
|
|
|
32
32
|
export function isNotifyKind(value) {
|
|
33
33
|
return typeof value === "string" && NOTIFY_KINDS.includes(value);
|
|
34
34
|
}
|
|
35
|
-
/**
|
|
36
|
-
|
|
35
|
+
/**
|
|
36
|
+
* Default PER-ATTEMPT request timeout. Bumped 5s → 12s (2026-06-20): a cold
|
|
37
|
+
* self-hosted OpenHands server reached over Tailscale routinely takes 5-8s to
|
|
38
|
+
* answer its first request after idle, so the old 5s cap silently dropped real
|
|
39
|
+
* `finished` / `awaiting_input` pushes (observed in `notify.log`:
|
|
40
|
+
* "timeout after 5000ms"). 12s comfortably clears a cold-start round-trip while
|
|
41
|
+
* still being a hard cap so a wedged server can never hang a hook's turn.
|
|
42
|
+
*/
|
|
43
|
+
export const DEFAULT_TIMEOUT_MS = 12000;
|
|
44
|
+
/**
|
|
45
|
+
* How many times to ATTEMPT a send before giving up (1 = no retry). A single
|
|
46
|
+
* cold-start timeout or transient network blip used to mean the user got no
|
|
47
|
+
* notification at all; we now retry transient failures (timeout / network /
|
|
48
|
+
* 5xx) with a short backoff so a slow-but-reachable server still delivers.
|
|
49
|
+
* A 4xx (auth / bad request) is permanent and is NOT retried.
|
|
50
|
+
*/
|
|
51
|
+
export const DEFAULT_MAX_ATTEMPTS = 3;
|
|
52
|
+
/** Backoff (ms) applied BEFORE attempt N (index 0 = before the 2nd attempt). */
|
|
53
|
+
export const RETRY_BACKOFF_MS = [500, 1500];
|
|
37
54
|
/** Rotate `notify.log` once it grows past this (keeps one `.1` backup). */
|
|
38
55
|
export const MAX_LOG_BYTES = 256 * 1024;
|
|
39
56
|
/** Absolute path to the failure log. */
|
|
@@ -93,13 +110,32 @@ async function failure(reason, opts, extra = {}) {
|
|
|
93
110
|
await appendLog(`SEND FAIL: ${reason}`, opts);
|
|
94
111
|
return { ok: false, error: reason, ...extra };
|
|
95
112
|
}
|
|
113
|
+
/** Real backoff sleep (cancellable-free; the caller never aborts a backoff). */
|
|
114
|
+
function defaultSleep(ms) {
|
|
115
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
116
|
+
}
|
|
96
117
|
/**
|
|
97
|
-
* Send one push to `/notify/send
|
|
98
|
-
* {@link SendResult}; callers may treat
|
|
118
|
+
* Send one push to `/notify/send`, retrying transient failures with a short
|
|
119
|
+
* backoff. Resolves (never rejects) to a {@link SendResult}; callers may treat
|
|
120
|
+
* ANY result as "exit 0".
|
|
121
|
+
*
|
|
122
|
+
* Retry policy (2026-06-20): a single cold-start timeout or transient network
|
|
123
|
+
* blip used to drop the user's notification entirely. We now attempt up to
|
|
124
|
+
* {@link DEFAULT_MAX_ATTEMPTS} times, backing off per {@link RETRY_BACKOFF_MS}
|
|
125
|
+
* between attempts. A 4xx (auth / bad-request) is PERMANENT and stops the loop
|
|
126
|
+
* immediately — retrying it would just burn the hook's time budget. Every
|
|
127
|
+
* attempt's failure is logged so `notify.log` shows the full retry trail.
|
|
99
128
|
*/
|
|
100
129
|
export async function send(payload, opts = {}) {
|
|
101
130
|
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
102
131
|
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
132
|
+
// Coerce defensively: a NaN (e.g. a failed CLI numeric parse) must not make
|
|
133
|
+
// `Math.max(1, NaN) === NaN` skip the loop and then throw on `last!.result`.
|
|
134
|
+
const rawAttempts = opts.maxAttempts;
|
|
135
|
+
const maxAttempts = typeof rawAttempts === "number" && Number.isFinite(rawAttempts)
|
|
136
|
+
? Math.max(1, Math.floor(rawAttempts))
|
|
137
|
+
: DEFAULT_MAX_ATTEMPTS;
|
|
138
|
+
const sleep = opts.sleepImpl ?? defaultSleep;
|
|
103
139
|
let creds;
|
|
104
140
|
try {
|
|
105
141
|
creds = await readCredentials(opts);
|
|
@@ -112,41 +148,78 @@ export async function send(payload, opts = {}) {
|
|
|
112
148
|
}
|
|
113
149
|
const server = normalizeServer(opts.server ?? creds.server ?? builtinDefaultServer());
|
|
114
150
|
const url = `${server}/api/v1/notify/send`;
|
|
115
|
-
const
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
151
|
+
const apiKey = creds.api_key;
|
|
152
|
+
const body = JSON.stringify(buildBody(payload));
|
|
153
|
+
/** One attempt — resolves to an {@link AttemptOutcome}, never throws. */
|
|
154
|
+
const attempt = async () => {
|
|
155
|
+
const controller = new AbortController();
|
|
156
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
157
|
+
try {
|
|
158
|
+
const res = await fetchImpl(url, {
|
|
159
|
+
method: "POST",
|
|
160
|
+
headers: {
|
|
161
|
+
"content-type": "application/json",
|
|
162
|
+
authorization: `Bearer ${apiKey}`,
|
|
163
|
+
},
|
|
164
|
+
body,
|
|
165
|
+
signal: controller.signal,
|
|
130
166
|
});
|
|
167
|
+
if (!res.ok) {
|
|
168
|
+
// 5xx is transient (server hiccup / cold start) → retriable.
|
|
169
|
+
// 4xx is permanent (auth / bad request) → do not retry.
|
|
170
|
+
const retriable = res.status >= 500;
|
|
171
|
+
return {
|
|
172
|
+
result: { ok: false, status: res.status, error: `server responded ${res.status} for kind=${payload.kind}` },
|
|
173
|
+
retriable,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
let sent;
|
|
177
|
+
try {
|
|
178
|
+
const json = (await res.json());
|
|
179
|
+
if (typeof json?.sent === "number")
|
|
180
|
+
sent = json.sent;
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
// A 2xx with an unparseable body still counts as delivered.
|
|
184
|
+
}
|
|
185
|
+
return { result: { ok: true, status: res.status, sent }, retriable: false };
|
|
131
186
|
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
187
|
+
catch (err) {
|
|
188
|
+
// Timeout (aborted) and network errors are both transient → retriable.
|
|
189
|
+
const reason = controller.signal.aborted
|
|
190
|
+
? `timeout after ${timeoutMs}ms for kind=${payload.kind}`
|
|
191
|
+
: `request failed: ${errMessage(err)}`;
|
|
192
|
+
return { result: { ok: false, error: reason }, retriable: true };
|
|
137
193
|
}
|
|
138
|
-
|
|
139
|
-
|
|
194
|
+
finally {
|
|
195
|
+
clearTimeout(timer);
|
|
140
196
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
197
|
+
};
|
|
198
|
+
let last;
|
|
199
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
200
|
+
if (i > 0) {
|
|
201
|
+
// Backoff before this retry. Clamp to the last defined step for any
|
|
202
|
+
// attempt count beyond the table so a larger `maxAttempts` still works.
|
|
203
|
+
const backoff = RETRY_BACKOFF_MS[Math.min(i - 1, RETRY_BACKOFF_MS.length - 1)] ?? 0;
|
|
204
|
+
await sleep(backoff);
|
|
205
|
+
}
|
|
206
|
+
last = await attempt();
|
|
207
|
+
if (last.result.ok) {
|
|
208
|
+
// Note the recovery in the log when an earlier attempt had failed, so a
|
|
209
|
+
// "it worked on retry 2" story is visible in notify.log.
|
|
210
|
+
if (i > 0) {
|
|
211
|
+
await appendLog(`SEND OK on attempt ${i + 1}/${maxAttempts} for kind=${payload.kind}`, opts);
|
|
212
|
+
}
|
|
213
|
+
return last.result;
|
|
214
|
+
}
|
|
215
|
+
// Log every failed attempt so the retry trail is diagnosable.
|
|
216
|
+
const willRetry = last.retriable && i < maxAttempts - 1;
|
|
217
|
+
await appendLog(`SEND FAIL (attempt ${i + 1}/${maxAttempts}${willRetry ? ", will retry" : ""}): ${last.result.error}`, opts);
|
|
218
|
+
// Permanent failure (4xx) — stop immediately, retrying can't help.
|
|
219
|
+
if (!last.retriable)
|
|
220
|
+
break;
|
|
151
221
|
}
|
|
222
|
+
// All attempts exhausted (or a permanent failure). `last` is always set here
|
|
223
|
+
// because the loop runs at least once (maxAttempts >= 1).
|
|
224
|
+
return last.result;
|
|
152
225
|
}
|
package/dist/src/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Single source of truth for the CLI version. Keep in sync with package.json.
|
|
2
|
-
export const VERSION = "0.
|
|
2
|
+
export const VERSION = "0.6.0";
|
package/package.json
CHANGED