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 +21 -19
- package/native/build.sh +32 -0
- package/native/nathan-popup.swift +100 -0
- package/package.json +4 -5
- package/scripts/notify.js +36 -48
- package/scripts/postinstall.js +0 -20
- package/vendor/nathan-popup +0 -0
package/README.md
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# nathangong
|
|
2
2
|
|
|
3
|
-
Pops a
|
|
4
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
18
|
-
|
|
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 #
|
|
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
|
|
51
|
-
`
|
|
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
|
package/native/build.sh
ADDED
|
@@ -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.
|
|
4
|
-
"description": "Pops a
|
|
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
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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
|
|
23
|
-
"nathan gong
|
|
24
|
-
"a wild nathan gong
|
|
25
|
-
"nathan
|
|
26
|
-
"thinking
|
|
27
|
-
"nathan gong
|
|
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
|
|
48
|
-
|
|
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 —
|
|
52
|
-
function osascriptFallback(
|
|
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(
|
|
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
|
|
69
|
+
if (process.platform !== "darwin") return process.exit(0);
|
|
67
70
|
|
|
68
|
-
const
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
return
|
|
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
|
-
|
|
84
|
-
|
|
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();
|
package/scripts/postinstall.js
CHANGED
|
@@ -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
|