nathangong 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,8 @@
1
1
  # nathangong
2
2
 
3
- Pops a macOS notification with a photo of **Nathan Gong** every time Claude Code
4
- finishes responding to a prompt. Download it and it just works.
3
+ Pops a photo of **Nathan Gong** on screen every time Claude Code finishes
4
+ responding to a prompt. Download it and it just works — no Rosetta, no
5
+ Homebrew, no notification permissions.
5
6
 
6
7
  ## Install
7
8
 
@@ -10,27 +11,22 @@ npm install -g nathangong
10
11
  ```
11
12
 
12
13
  That's it. The `postinstall` step wires a Claude Code [`Stop` hook](https://docs.claude.com/en/docs/claude-code/hooks)
13
- into `~/.claude/settings.json`. The next time Claude finishes a turn, Nathan says hi.
14
+ into `~/.claude/settings.json`. The next time Claude finishes a turn, Nathan's
15
+ face fades in as a small card in the top-right of your screen, then fades out.
14
16
 
15
- ### Photos vs. emoji
17
+ Photos are drawn by a tiny bundled **native universal binary** (`vendor/nathan-popup`,
18
+ built from `native/nathan-popup.swift`) — a borderless AppKit window. It never
19
+ steals focus, adds no Dock icon, and runs on both Apple Silicon and Intel. If
20
+ the binary can't launch for some reason, it falls back to a plain text
21
+ notification via `osascript`.
16
22
 
17
- The photo is rendered via the bundled [`node-notifier`](https://github.com/mikaelbr/node-notifier),
18
- whose `terminal-notifier` binary is **x86_64**. So:
19
-
20
- - **Intel Mac**, or **Apple Silicon with Rosetta** → you get the **photo**. 📸
21
- - **Apple Silicon without Rosetta** → you get an **emoji notification** (macOS
22
- can't attach a custom image without a runnable helper binary).
23
-
24
- To get photos on Apple Silicon, install Rosetta once:
25
-
26
- ```bash
27
- softwareupdate --install-rosetta --agree-to-license
28
- ```
23
+ Tune how long the card stays up (default 2.5s) with an env var on the hook:
24
+ `NATHANGONG_POPUP_SECONDS=4`.
29
25
 
30
26
  ## Commands
31
27
 
32
28
  ```bash
33
- nathangong test # fire a notification right now to check it works
29
+ nathangong test # pop Nathan on screen right now to check it works
34
30
  nathangong install # (re)wire the Stop hook
35
31
  nathangong uninstall # remove the Stop hook
36
32
  ```
@@ -47,11 +43,17 @@ The `preuninstall` step removes the hook from your settings. (You can also run
47
43
  ## How it works
48
44
 
49
45
  - The `Stop` hook runs `node .../scripts/notify.js` when Claude ends a turn.
50
- - `notify.js` picks a random photo from `assets/` and fires a notification via
51
- `node-notifier` (`contentImage`), falling back to `osascript` (emoji only).
46
+ - `notify.js` picks a random photo from `assets/` and shows it with the native
47
+ `vendor/nathan-popup` window, falling back to an `osascript` text notification.
52
48
  - Editing `~/.claude/settings.json` is idempotent and backs the file up to
53
49
  `settings.json.nathangong.bak` before any change.
54
50
 
51
+ ## Rebuilding the native binary
52
+
53
+ ```bash
54
+ bash native/build.sh # needs Xcode; rebuilds vendor/nathan-popup (universal, ad-hoc signed)
55
+ ```
56
+
55
57
  ## Use as a library
56
58
 
57
59
  ```js
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env bash
2
+ # Builds the universal (arm64 + x86_64) nathan-popup binary into ../vendor.
3
+ # Requires Xcode / the Swift toolchain. Run on macOS.
4
+ set -euo pipefail
5
+ cd "$(dirname "$0")"
6
+
7
+ SRC="nathan-popup.swift"
8
+ OUT="../vendor/nathan-popup"
9
+ mkdir -p ../vendor
10
+
11
+ build() { # <arch>
12
+ swiftc -O -target "$1-apple-macos11" "$SRC" -o "nathan-popup-$1"
13
+ }
14
+
15
+ echo "building arm64…"
16
+ build arm64
17
+ SLICES=(nathan-popup-arm64)
18
+
19
+ if build x86_64 2>/dev/null; then
20
+ echo "building x86_64… ok"
21
+ SLICES+=(nathan-popup-x86_64)
22
+ else
23
+ echo "x86_64 slice failed — shipping arm64-only"
24
+ fi
25
+
26
+ lipo -create -output "$OUT" "${SLICES[@]}"
27
+ rm -f nathan-popup-arm64 nathan-popup-x86_64
28
+ chmod +x "$OUT"
29
+ codesign -s - -f "$OUT" # ad-hoc sign so Gatekeeper is happy
30
+ echo "--- built $OUT ---"
31
+ lipo -info "$OUT"
32
+ codesign -dv "$OUT" 2>&1 | head -3
@@ -0,0 +1,100 @@
1
+ // nathan-popup <imagePath> [caption] [seconds]
2
+ //
3
+ // Pops a small floating card showing an image (a photo of Nathan Gong) in the
4
+ // top-right of the main screen, then fades out and exits. Deliberately avoids
5
+ // Notification Center: no notification permission, no deprecated
6
+ // NSUserNotification, no Rosetta — just an AppKit window. Works on any modern
7
+ // macOS on both arm64 and x86_64.
8
+ //
9
+ // Non-activating + accessory policy => never steals focus, no Dock icon.
10
+
11
+ import Cocoa
12
+
13
+ let args = CommandLine.arguments
14
+ guard args.count >= 2 else { exit(0) }
15
+ let imagePath = args[1]
16
+ let caption = args.count >= 3 ? args[2] : ""
17
+ let seconds = args.count >= 4 ? (Double(args[3]) ?? 2.5) : 2.5
18
+
19
+ guard let image = NSImage(contentsOfFile: imagePath) else { exit(0) }
20
+
21
+ let app = NSApplication.shared
22
+ app.setActivationPolicy(.accessory) // no Dock icon, no focus steal
23
+
24
+ // Layout
25
+ let imgW: CGFloat = 240
26
+ let ratio = image.size.width > 0 ? image.size.height / image.size.width : 1
27
+ let imgH = (imgW * ratio).rounded()
28
+ let capH: CGFloat = caption.isEmpty ? 0 : 24
29
+ let pad: CGFloat = 12
30
+ let winW = imgW + pad * 2
31
+ let winH = imgH + capH + pad * 2
32
+
33
+ guard let screen = NSScreen.main else { exit(0) }
34
+ let vf = screen.visibleFrame
35
+ let origin = NSPoint(x: vf.maxX - winW - 20, y: vf.maxY - winH - 20)
36
+
37
+ let panel = NSPanel(
38
+ contentRect: NSRect(origin: origin, size: NSSize(width: winW, height: winH)),
39
+ styleMask: [.borderless, .nonactivatingPanel],
40
+ backing: .buffered,
41
+ defer: false
42
+ )
43
+ panel.isFloatingPanel = true
44
+ panel.level = .floating
45
+ panel.backgroundColor = .clear
46
+ panel.isOpaque = false
47
+ panel.hasShadow = true
48
+ panel.ignoresMouseEvents = true
49
+ panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
50
+
51
+ let container = NSView(frame: NSRect(x: 0, y: 0, width: winW, height: winH))
52
+ container.wantsLayer = true
53
+ container.layer?.backgroundColor = NSColor(calibratedWhite: 0.10, alpha: 0.96).cgColor
54
+ container.layer?.cornerRadius = 16
55
+
56
+ let iv = NSImageView(frame: NSRect(x: pad, y: pad + capH, width: imgW, height: imgH))
57
+ iv.image = image
58
+ iv.imageScaling = .scaleProportionallyUpOrDown
59
+ iv.wantsLayer = true
60
+ iv.layer?.cornerRadius = 10
61
+ iv.layer?.masksToBounds = true
62
+ container.addSubview(iv)
63
+
64
+ if !caption.isEmpty {
65
+ let label = NSTextField(labelWithString: caption)
66
+ label.frame = NSRect(x: pad, y: pad - 3, width: imgW, height: capH)
67
+ label.alignment = .center
68
+ label.textColor = .white
69
+ label.font = NSFont.systemFont(ofSize: 13, weight: .semibold)
70
+ label.backgroundColor = .clear
71
+ label.isBezeled = false
72
+ label.isEditable = false
73
+ label.lineBreakMode = .byTruncatingTail
74
+ container.addSubview(label)
75
+ }
76
+
77
+ panel.contentView = container
78
+ panel.alphaValue = 0
79
+ panel.orderFrontRegardless()
80
+
81
+ NSAnimationContext.runAnimationGroup { ctx in
82
+ ctx.duration = 0.22
83
+ panel.animator().alphaValue = 1
84
+ }
85
+
86
+ DispatchQueue.main.asyncAfter(deadline: .now() + seconds) {
87
+ NSAnimationContext.runAnimationGroup({ ctx in
88
+ ctx.duration = 0.4
89
+ panel.animator().alphaValue = 0
90
+ }, completionHandler: {
91
+ app.terminate(nil)
92
+ })
93
+ }
94
+
95
+ // Safety valve: never linger forever.
96
+ DispatchQueue.main.asyncAfter(deadline: .now() + seconds + 2) {
97
+ app.terminate(nil)
98
+ }
99
+
100
+ app.run()
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "nathangong",
3
- "version": "0.1.0",
4
- "description": "Pops a macOS notification with a photo of Nathan Gong after every Claude Code turn.",
3
+ "version": "0.2.0",
4
+ "description": "Pops a photo of Nathan Gong on screen after every Claude Code turn. Native, no Rosetta.",
5
5
  "keywords": [
6
6
  "nathan gong",
7
7
  "claude",
@@ -20,6 +20,8 @@
20
20
  "bin",
21
21
  "scripts",
22
22
  "assets",
23
+ "vendor",
24
+ "native",
23
25
  "index.js",
24
26
  "README.md"
25
27
  ],
@@ -30,8 +32,5 @@
30
32
  },
31
33
  "engines": {
32
34
  "node": ">=14"
33
- },
34
- "dependencies": {
35
- "node-notifier": "^10.0.1"
36
35
  }
37
36
  }
package/scripts/notify.js CHANGED
@@ -2,12 +2,10 @@
2
2
  "use strict";
3
3
 
4
4
  // The Claude Code `Stop` hook body: fires each time Claude finishes a turn.
5
- // Pops a macOS notification with a random photo of Nathan Gong.
6
- //
7
- // Uses node-notifier, which bundles its own copy of terminal-notifier and
8
- // supports `contentImage` — so the photo shows up with no extra system setup
9
- // (no Homebrew needed). Falls back to plain `osascript` (emoji only) if
10
- // node-notifier is unavailable or errors.
5
+ // Pops a floating card with a random photo of Nathan Gong via our bundled
6
+ // native `nathan-popup` binary (no notification permission, no Rosetta, works
7
+ // on arm64 + x86_64). Falls back to a plain `osascript` text notification if
8
+ // the binary is missing or can't launch.
11
9
  //
12
10
  // Rules: always exit 0 (a failing hook nags every turn) and never hang.
13
11
 
@@ -19,12 +17,12 @@ process.stdout.on("error", () => {});
19
17
  process.stderr.on("error", () => {});
20
18
 
21
19
  const TITLE = "nathan gong 🔔";
22
- const LINES = [
23
- "nathan gong has entered the chat 🧑‍💻",
24
- "a wild nathan gong appears 👋",
25
- "nathan gong approves this prompt ✅",
26
- "thinking about nathan gong 🤔",
27
- "nathan gong says: nice turn 🏀",
20
+ const CAPTIONS = [
21
+ "nathan gong 🧑‍💻",
22
+ "a wild nathan gong 👋",
23
+ "nathan approves ✅",
24
+ "thinking of nathan 🤔",
25
+ "nathan gong 🏀",
28
26
  ];
29
27
 
30
28
  function pick(arr) {
@@ -44,15 +42,20 @@ function pickImage() {
44
42
  }
45
43
  }
46
44
 
47
- function done() {
48
- process.exit(0);
45
+ function canRun(p) {
46
+ try {
47
+ fs.accessSync(p, fs.constants.X_OK);
48
+ return true;
49
+ } catch (_) {
50
+ return false;
51
+ }
49
52
  }
50
53
 
51
- // Emoji-only fallback — macOS's built-in notification can't render an image.
52
- function osascriptFallback(line) {
54
+ // Emoji-only fallback — used only when the native popup can't run.
55
+ function osascriptFallback(caption) {
53
56
  const script =
54
57
  "display notification " +
55
- JSON.stringify(line) +
58
+ JSON.stringify(caption) +
56
59
  " with title " +
57
60
  JSON.stringify(TITLE);
58
61
  const child = spawn("osascript", ["-e", script], {
@@ -63,42 +66,27 @@ function osascriptFallback(line) {
63
66
  }
64
67
 
65
68
  function main() {
66
- if (process.platform !== "darwin") return done();
69
+ if (process.platform !== "darwin") return process.exit(0);
67
70
 
68
- const line = pick(LINES);
71
+ const caption = pick(CAPTIONS);
69
72
  const image = pickImage();
73
+ const popup = path.join(__dirname, "..", "vendor", "nathan-popup");
74
+ const seconds = String(Number(process.env.NATHANGONG_POPUP_SECONDS) || 2.5);
70
75
 
71
- // Safety net: never let the hook hang the turn.
72
- const safety = setTimeout(done, 5000);
73
- safety.unref();
74
-
75
- let notifier;
76
- try {
77
- notifier = require("node-notifier");
78
- } catch (_) {
79
- osascriptFallback(line);
80
- return done();
76
+ if (image && canRun(popup)) {
77
+ const child = spawn(popup, [image, caption, seconds], {
78
+ detached: true,
79
+ stdio: "ignore",
80
+ });
81
+ child.on("error", () => osascriptFallback(caption));
82
+ child.unref();
83
+ // Linger briefly to catch a launch error, then always exit 0.
84
+ setTimeout(() => process.exit(0), 150);
85
+ return;
81
86
  }
82
87
 
83
- try {
84
- notifier.notify(
85
- {
86
- title: TITLE,
87
- message: line,
88
- contentImage: image || undefined, // right-side thumbnail (the photo)
89
- appIcon: image || undefined, // left icon
90
- sound: false,
91
- wait: false,
92
- },
93
- (err) => {
94
- if (err) osascriptFallback(line);
95
- done();
96
- }
97
- );
98
- } catch (_) {
99
- osascriptFallback(line);
100
- done();
101
- }
88
+ osascriptFallback(caption);
89
+ process.exit(0);
102
90
  }
103
91
 
104
92
  main();
@@ -28,23 +28,3 @@ try {
28
28
  log("nathangong: couldn't wire the hook automatically (" + (err && err.message) + ").");
29
29
  log("nathangong: run `nathangong install` to set it up manually.");
30
30
  }
31
-
32
- // Apple Silicon without Rosetta can't run the bundled x86_64 terminal-notifier,
33
- // so it falls back to an emoji notification. Let the user know how to get photos.
34
- try {
35
- const os = require("os");
36
- if (process.platform === "darwin" && os.arch() === "arm64") {
37
- const { execFileSync } = require("child_process");
38
- let rosetta = false;
39
- try {
40
- execFileSync("/usr/bin/pgrep", ["-q", "oahd"]);
41
- rosetta = true;
42
- } catch (_) {}
43
- if (!rosetta) {
44
- log("");
45
- log("nathangong: you're on Apple Silicon without Rosetta — you'll get emoji");
46
- log("nathangong: notifications. For actual PHOTOS of Nathan, install Rosetta once:");
47
- log(" softwareupdate --install-rosetta --agree-to-license");
48
- }
49
- }
50
- } catch (_) {}
Binary file