nathangong 0.1.0 → 0.4.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/INSTALL.md ADDED
@@ -0,0 +1,51 @@
1
+ # nathangong — install from zip
2
+
3
+ macOS only. Requires **Node 18+** and **Claude Code** (for the Stop hook).
4
+
5
+ ## Quick start
6
+
7
+ ```bash
8
+ unzip nathangong-0.4.0.zip
9
+ cd nathangong-0.4.0
10
+ npm install
11
+ npm install -g .
12
+ nathangong test
13
+ ```
14
+
15
+ You should get one Nathan + whip overlay. Click Nathan to whip him away.
16
+
17
+ ## What gets installed
18
+
19
+ - `postinstall` wires a Claude Code **Stop** hook in `~/.claude/settings.json`
20
+ - After every Claude Code turn, Nathan slides in from the side and your cursor becomes an OpenWhip-style whip
21
+ - He runs away from your mouse — chase him down and click to dismiss
22
+
23
+ ## Commands
24
+
25
+ ```bash
26
+ nathangong test # try it now
27
+ nathangong install # re-wire the hook
28
+ nathangong uninstall # remove the hook
29
+ ```
30
+
31
+ ## Uninstall
32
+
33
+ ```bash
34
+ nathangong uninstall
35
+ npm uninstall -g nathangong
36
+ ```
37
+
38
+ ## Notes
39
+
40
+ - Only one Nathan session at a time (duplicate hook fires are ignored)
41
+ - Optional timeout: `NATHANGONG_POPUP_SECONDS=120`
42
+ - Whip overlay uses Electron (~150MB download on first `npm install`)
43
+ - Does **not** run on the Fleetline web app — local Claude Code only
44
+
45
+ ## Troubleshooting
46
+
47
+ **Nothing appears:** run `nathangong test` and check Node ≥ 18.
48
+
49
+ **Multiple Nathans:** dismiss the current one first; lock clears on exit.
50
+
51
+ **Native fallback only (no whip):** Electron failed to start — re-run `npm install` in the package folder.
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
+ Whip-a-Nathan for Claude Code: after every turn, your cursor becomes an
4
+ **OpenWhip**-style whip and a photo of **Nathan Gong** slides in slowly from
5
+ the left or right. Click Nathan to whip him away.
5
6
 
6
7
  ## Install
7
8
 
@@ -9,28 +10,20 @@ finishes responding to a prompt. Download it and it just works.
9
10
  npm install -g nathangong
10
11
  ```
11
12
 
12
- 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.
13
+ The `postinstall` step wires a Claude Code [`Stop` hook](https://docs.claude.com/en/docs/claude-code/hooks)
14
+ into `~/.claude/settings.json`.
14
15
 
15
- ### Photos vs. emoji
16
+ Uses an Electron overlay with OpenWhip whip physics (adapted from
17
+ [OpenWhip](https://github.com/GitFrog1111/OpenWhip)). Falls back to a native
18
+ macOS popup if Electron can't launch.
16
19
 
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
- ```
20
+ Set a max timeout if Nathan survives too long (default 60s):
21
+ `NATHANGONG_POPUP_SECONDS=120`.
29
22
 
30
23
  ## Commands
31
24
 
32
25
  ```bash
33
- nathangong test # fire a notification right now to check it works
26
+ nathangong test # pop Nathan on screen right now to check it works
34
27
  nathangong install # (re)wire the Stop hook
35
28
  nathangong uninstall # remove the Stop hook
36
29
  ```
@@ -47,11 +40,17 @@ The `preuninstall` step removes the hook from your settings. (You can also run
47
40
  ## How it works
48
41
 
49
42
  - 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).
43
+ - `notify.js` picks a random photo from `assets/` and shows it with the native
44
+ `vendor/nathan-popup` window, falling back to an `osascript` text notification.
52
45
  - Editing `~/.claude/settings.json` is idempotent and backs the file up to
53
46
  `settings.json.nathangong.bak` before any change.
54
47
 
48
+ ## Rebuilding the native binary
49
+
50
+ ```bash
51
+ bash native/build.sh # needs Xcode; rebuilds vendor/nathan-popup (universal, ad-hoc signed)
52
+ ```
53
+
55
54
  ## Use as a library
56
55
 
57
56
  ```js
package/bin/cli.js CHANGED
File without changes
@@ -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,144 @@
1
+ // nathan-popup <imagePath> [caption] [seconds]
2
+ //
3
+ // Fallback when Electron/OpenWhip overlay isn't available: Nathan slides in
4
+ // slowly from the left or right. Click him to dismiss.
5
+
6
+ import Cocoa
7
+
8
+ let args = CommandLine.arguments
9
+ guard args.count >= 2 else { exit(0) }
10
+ let imagePath = args[1]
11
+ let caption = args.count >= 3 ? args[2] : ""
12
+ let maxSeconds = args.count >= 4 ? (Double(args[3]) ?? 60) : 60
13
+
14
+ guard let image = NSImage(contentsOfFile: imagePath) else { exit(0) }
15
+
16
+ enum EntrySide: CaseIterable {
17
+ case left, right
18
+ }
19
+
20
+ final class WhackTargetView: NSView {
21
+ var onWhack: (() -> Void)?
22
+
23
+ override func mouseDown(with event: NSEvent) {
24
+ onWhack?()
25
+ }
26
+ }
27
+
28
+ func targetFrame(for side: EntrySide, in visibleFrame: NSRect, size: NSSize) -> NSRect {
29
+ let y = visibleFrame.midY - size.height / 2
30
+ switch side {
31
+ case .left:
32
+ return NSRect(x: visibleFrame.minX + 20, y: y, width: size.width, height: size.height)
33
+ case .right:
34
+ return NSRect(x: visibleFrame.maxX - size.width - 20, y: y, width: size.width, height: size.height)
35
+ }
36
+ }
37
+
38
+ func offScreenFrame(for side: EntrySide, resting: NSRect, visibleFrame: NSRect) -> NSRect {
39
+ switch side {
40
+ case .left:
41
+ return NSRect(x: visibleFrame.minX - resting.width - 20, y: resting.origin.y, width: resting.width, height: resting.height)
42
+ case .right:
43
+ return NSRect(x: visibleFrame.maxX + 20, y: resting.origin.y, width: resting.width, height: resting.height)
44
+ }
45
+ }
46
+
47
+ let app = NSApplication.shared
48
+ app.setActivationPolicy(.accessory)
49
+
50
+ let imgW: CGFloat = 240
51
+ let ratio = image.size.width > 0 ? image.size.height / image.size.width : 1
52
+ let imgH = (imgW * ratio).rounded()
53
+ let capH: CGFloat = caption.isEmpty ? 0 : 24
54
+ let pad: CGFloat = 12
55
+ let winW = imgW + pad * 2
56
+ let winH = imgH + capH + pad * 2
57
+ let winSize = NSSize(width: winW, height: winH)
58
+
59
+ guard let screen = NSScreen.main else { exit(0) }
60
+ let vf = screen.visibleFrame
61
+ let side = EntrySide.allCases.randomElement() ?? .right
62
+ let endFrame = targetFrame(for: side, in: vf, size: winSize)
63
+ let startFrame = offScreenFrame(for: side, resting: endFrame, visibleFrame: vf)
64
+
65
+ let panel = NSPanel(
66
+ contentRect: startFrame,
67
+ styleMask: [.borderless, .nonactivatingPanel],
68
+ backing: .buffered,
69
+ defer: false
70
+ )
71
+ panel.isFloatingPanel = true
72
+ panel.level = .floating
73
+ panel.backgroundColor = .clear
74
+ panel.isOpaque = false
75
+ panel.hasShadow = true
76
+ panel.ignoresMouseEvents = false
77
+ panel.acceptsMouseMovedEvents = true
78
+ panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
79
+
80
+ let container = WhackTargetView(frame: NSRect(x: 0, y: 0, width: winW, height: winH))
81
+ container.wantsLayer = true
82
+ container.layer?.backgroundColor = NSColor(calibratedWhite: 0.10, alpha: 0.94).cgColor
83
+ container.layer?.cornerRadius = 16
84
+
85
+ let iv = NSImageView(frame: NSRect(x: pad, y: pad + capH, width: imgW, height: imgH))
86
+ iv.image = image
87
+ iv.imageScaling = .scaleProportionallyUpOrDown
88
+ iv.wantsLayer = true
89
+ iv.layer?.cornerRadius = 10
90
+ iv.layer?.masksToBounds = true
91
+ container.addSubview(iv)
92
+
93
+ if !caption.isEmpty {
94
+ let label = NSTextField(labelWithString: caption)
95
+ label.frame = NSRect(x: pad, y: pad - 3, width: imgW, height: capH)
96
+ label.alignment = .center
97
+ label.textColor = .white
98
+ label.font = NSFont.systemFont(ofSize: 13, weight: .semibold)
99
+ label.backgroundColor = .clear
100
+ label.isBezeled = false
101
+ label.isEditable = false
102
+ label.lineBreakMode = .byTruncatingTail
103
+ container.addSubview(label)
104
+ }
105
+
106
+ panel.contentView = container
107
+ panel.alphaValue = 0
108
+ panel.setFrame(startFrame, display: false)
109
+ panel.orderFrontRegardless()
110
+
111
+ var dismissed = false
112
+
113
+ func cleanupAndExit() {
114
+ app.terminate(nil)
115
+ }
116
+
117
+ func dismissWhacked() {
118
+ guard !dismissed else { return }
119
+ dismissed = true
120
+
121
+ NSAnimationContext.runAnimationGroup({ ctx in
122
+ ctx.duration = 0.25
123
+ panel.animator().alphaValue = 0
124
+ }, completionHandler: {
125
+ cleanupAndExit()
126
+ })
127
+ }
128
+
129
+ container.onWhack = dismissWhacked
130
+
131
+ NSAnimationContext.runAnimationGroup { ctx in
132
+ ctx.duration = 1.5
133
+ ctx.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
134
+ panel.animator().alphaValue = 1
135
+ panel.animator().setFrame(endFrame, display: true)
136
+ }
137
+
138
+ DispatchQueue.main.asyncAfter(deadline: .now() + max(maxSeconds, 10)) {
139
+ guard !dismissed else { return }
140
+ dismissed = true
141
+ cleanupAndExit()
142
+ }
143
+
144
+ 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.4.0",
4
+ "description": "Whip-a-Nathan: OpenWhip cursor + Nathan slides in from the side after every Claude Code turn.",
5
5
  "keywords": [
6
6
  "nathan gong",
7
7
  "claude",
@@ -20,8 +20,12 @@
20
20
  "bin",
21
21
  "scripts",
22
22
  "assets",
23
+ "vendor",
24
+ "native",
25
+ "whip",
23
26
  "index.js",
24
- "README.md"
27
+ "README.md",
28
+ "INSTALL.md"
25
29
  ],
26
30
  "scripts": {
27
31
  "postinstall": "node scripts/postinstall.js",
@@ -29,9 +33,9 @@
29
33
  "test": "node bin/cli.js test"
30
34
  },
31
35
  "engines": {
32
- "node": ">=14"
36
+ "node": ">=18.0.0"
33
37
  },
34
38
  "dependencies": {
35
- "node-notifier": "^10.0.1"
39
+ "electron": "^33.0.0"
36
40
  }
37
41
  }
package/scripts/notify.js CHANGED
@@ -2,29 +2,27 @@
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
+ // Opens an OpenWhip-style whip overlay (cursor becomes the whip) and slides
6
+ // Nathan in gently from the left or right. Click Nathan to whip him away.
7
+ // Falls back to the native popup, then osascript, if Electron can't launch.
11
8
  //
12
9
  // Rules: always exit 0 (a failing hook nags every turn) and never hang.
13
10
 
14
11
  const fs = require("fs");
15
12
  const path = require("path");
16
13
  const { spawn } = require("child_process");
14
+ const { tryAcquireLock, claimLock, clearLock } = require("./session-lock");
17
15
 
18
16
  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,69 @@ 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
+ }
52
+ }
53
+
54
+ function electronBinary() {
55
+ try {
56
+ return require("electron");
57
+ } catch (_) {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ function launchWhipOverlay(image, caption, seconds) {
63
+ const electronPath = electronBinary();
64
+ if (!electronPath) return false;
65
+
66
+ const main = path.join(__dirname, "..", "whip", "main.js");
67
+ if (!fs.existsSync(main)) return false;
68
+
69
+ if (!tryAcquireLock()) return false;
70
+
71
+ const child = spawn(
72
+ electronPath,
73
+ [main, image, caption, seconds],
74
+ {
75
+ detached: true,
76
+ stdio: "ignore",
77
+ cwd: path.join(__dirname, "..", "whip"),
78
+ },
79
+ );
80
+
81
+ child.on("error", () => clearLock());
82
+ child.on("spawn", () => claimLock(child.pid));
83
+ child.unref();
84
+ return true;
49
85
  }
50
86
 
51
- // Emoji-only fallback macOS's built-in notification can't render an image.
52
- function osascriptFallback(line) {
87
+ function launchNativePopup(image, caption, seconds) {
88
+ const popup = path.join(__dirname, "..", "vendor", "nathan-popup");
89
+ if (!canRun(popup)) return false;
90
+
91
+ if (!tryAcquireLock()) return false;
92
+
93
+ const child = spawn(popup, [image, caption, seconds], {
94
+ detached: true,
95
+ stdio: "ignore",
96
+ });
97
+
98
+ child.on("error", () => clearLock());
99
+ child.on("spawn", () => claimLock(child.pid));
100
+ child.unref();
101
+ return true;
102
+ }
103
+
104
+ function osascriptFallback(caption) {
53
105
  const script =
54
106
  "display notification " +
55
- JSON.stringify(line) +
107
+ JSON.stringify(caption) +
56
108
  " with title " +
57
109
  JSON.stringify(TITLE);
58
110
  const child = spawn("osascript", ["-e", script], {
@@ -63,42 +115,24 @@ function osascriptFallback(line) {
63
115
  }
64
116
 
65
117
  function main() {
66
- if (process.platform !== "darwin") return done();
118
+ if (process.platform !== "darwin") return process.exit(0);
67
119
 
68
- const line = pick(LINES);
120
+ const caption = pick(CAPTIONS);
69
121
  const image = pickImage();
122
+ const seconds = String(Number(process.env.NATHANGONG_POPUP_SECONDS) || 60);
70
123
 
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();
124
+ if (image && launchWhipOverlay(image, caption, seconds)) {
125
+ setTimeout(() => process.exit(0), 150);
126
+ return;
81
127
  }
82
128
 
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();
129
+ if (image && launchNativePopup(image, caption, seconds)) {
130
+ setTimeout(() => process.exit(0), 150);
131
+ return;
101
132
  }
133
+
134
+ osascriptFallback(caption);
135
+ process.exit(0);
102
136
  }
103
137
 
104
138
  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 (_) {}
@@ -0,0 +1,74 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const os = require("os");
5
+ const path = require("path");
6
+
7
+ const LOCK_PATH = path.join(os.tmpdir(), "nathangong-session.lock");
8
+ const STALE_MS = 8000;
9
+
10
+ function readLock() {
11
+ try {
12
+ const raw = fs.readFileSync(LOCK_PATH, "utf8").trim();
13
+ const [pidRaw, startedRaw] = raw.split(":");
14
+ return {
15
+ pid: Number(pidRaw),
16
+ startedAt: Number(startedRaw) || 0,
17
+ };
18
+ } catch (_) {
19
+ return null;
20
+ }
21
+ }
22
+
23
+ function isAlive(pid) {
24
+ if (!pid || !Number.isFinite(pid)) return false;
25
+ try {
26
+ process.kill(pid, 0);
27
+ return true;
28
+ } catch (_) {
29
+ return false;
30
+ }
31
+ }
32
+
33
+ function clearLock() {
34
+ try {
35
+ fs.unlinkSync(LOCK_PATH);
36
+ } catch (_) {}
37
+ }
38
+
39
+ /** Returns false if another live session already owns the lock. */
40
+ function tryAcquireLock() {
41
+ const existing = readLock();
42
+ if (existing) {
43
+ const age = Date.now() - existing.startedAt;
44
+ if (isAlive(existing.pid) && age < STALE_MS * 60) {
45
+ return false;
46
+ }
47
+ clearLock();
48
+ }
49
+
50
+ try {
51
+ fs.writeFileSync(LOCK_PATH, `0:${Date.now()}`, { flag: "wx" });
52
+ return true;
53
+ } catch (err) {
54
+ if (err && err.code === "EEXIST") return false;
55
+ return false;
56
+ }
57
+ }
58
+
59
+ function claimLock(pid) {
60
+ fs.writeFileSync(LOCK_PATH, `${pid}:${Date.now()}`);
61
+ }
62
+
63
+ function releaseLock(pid) {
64
+ const existing = readLock();
65
+ if (existing && existing.pid === pid) clearLock();
66
+ }
67
+
68
+ module.exports = {
69
+ LOCK_PATH,
70
+ tryAcquireLock,
71
+ claimLock,
72
+ releaseLock,
73
+ clearLock,
74
+ };
Binary file
package/whip/main.js ADDED
@@ -0,0 +1,90 @@
1
+ "use strict";
2
+
3
+ const { app, BrowserWindow, ipcMain, screen } = require("electron");
4
+ const path = require("path");
5
+ const { claimLock, releaseLock } = require("../scripts/session-lock");
6
+
7
+ let overlay = null;
8
+
9
+ const gotLock = app.requestSingleInstanceLock();
10
+ if (!gotLock) {
11
+ app.quit();
12
+ }
13
+
14
+ app.on("second-instance", () => {
15
+ // Ignore duplicate hook fires while Nathan is already on screen.
16
+ });
17
+
18
+ function createOverlay(imagePath, caption, maxSeconds) {
19
+ const display = screen.getPrimaryDisplay();
20
+ const { bounds } = display;
21
+
22
+ overlay = new BrowserWindow({
23
+ x: bounds.x,
24
+ y: bounds.y,
25
+ width: bounds.width,
26
+ height: bounds.height,
27
+ transparent: true,
28
+ frame: false,
29
+ alwaysOnTop: true,
30
+ focusable: false,
31
+ skipTaskbar: true,
32
+ resizable: false,
33
+ hasShadow: false,
34
+ webPreferences: {
35
+ preload: path.join(__dirname, "preload.js"),
36
+ contextIsolation: true,
37
+ nodeIntegration: false,
38
+ },
39
+ });
40
+
41
+ overlay.setAlwaysOnTop(true, "screen-saver");
42
+ overlay.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
43
+
44
+ const query = new URLSearchParams({
45
+ maxSeconds: String(maxSeconds),
46
+ }).toString();
47
+
48
+ overlay.loadFile(path.join(__dirname, "overlay.html"), { query });
49
+
50
+ overlay.webContents.on("did-finish-load", () => {
51
+ overlay.webContents.send("init-nathan", { imagePath, caption });
52
+ overlay.webContents.send("spawn-whip");
53
+ });
54
+
55
+ const timeoutMs = Math.max(Number(maxSeconds) || 60, 10) * 1000;
56
+ setTimeout(() => {
57
+ if (overlay && !overlay.isDestroyed()) {
58
+ app.quit();
59
+ }
60
+ }, timeoutMs);
61
+ }
62
+
63
+ ipcMain.on("whip-crack", () => {
64
+ // Cosmetic only — OpenWhip sends Ctrl-C here; nathangong does not.
65
+ });
66
+
67
+ ipcMain.on("hide-overlay", () => {
68
+ app.quit();
69
+ });
70
+
71
+ app.whenReady().then(() => {
72
+ if (!gotLock) return;
73
+
74
+ const imagePath = process.argv[2];
75
+ const caption = process.argv[3] || "";
76
+ const maxSeconds = process.argv[4] || "60";
77
+ if (!imagePath) {
78
+ app.quit();
79
+ return;
80
+ }
81
+
82
+ claimLock(process.pid);
83
+ createOverlay(imagePath, caption, maxSeconds);
84
+ });
85
+
86
+ app.on("will-quit", () => {
87
+ releaseLock(process.pid);
88
+ });
89
+
90
+ app.on("window-all-closed", () => app.quit());
@@ -0,0 +1,534 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>nathangong</title>
7
+ <style>
8
+ * { margin: 0; padding: 0; cursor: none; box-sizing: border-box; }
9
+ html, body { overflow: hidden; background: transparent; width: 100%; height: 100%; }
10
+ canvas { display: block; position: fixed; inset: 0; z-index: 1; }
11
+ #nathan-card {
12
+ position: fixed;
13
+ left: 0;
14
+ top: 0;
15
+ z-index: 2;
16
+ width: 264px;
17
+ padding: 12px;
18
+ border-radius: 16px;
19
+ background: rgba(26, 26, 26, 0.94);
20
+ box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35);
21
+ opacity: 0;
22
+ pointer-events: auto;
23
+ will-change: left, top, opacity;
24
+ }
25
+ #nathan-card.visible {
26
+ opacity: 1;
27
+ }
28
+ #nathan-img {
29
+ display: block;
30
+ width: 240px;
31
+ height: auto;
32
+ border-radius: 10px;
33
+ }
34
+ #nathan-caption {
35
+ margin-top: 8px;
36
+ text-align: center;
37
+ color: #fff;
38
+ font: 600 13px/1.2 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
39
+ }
40
+ #nathan-caption:empty { display: none; }
41
+ </style>
42
+ </head>
43
+ <body>
44
+ <canvas id="c"></canvas>
45
+ <div id="nathan-card">
46
+ <img id="nathan-img" alt="Nathan Gong" />
47
+ <div id="nathan-caption"></div>
48
+ </div>
49
+
50
+ <script>
51
+ const params = new URLSearchParams(window.location.search);
52
+
53
+ const nathanCard = document.getElementById("nathan-card");
54
+ const nathanImg = document.getElementById("nathan-img");
55
+ const nathanCaptionEl = document.getElementById("nathan-caption");
56
+
57
+ const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
58
+ const lerp = (a, b, t) => a + (b - a) * t;
59
+
60
+ const N = {
61
+ margin: 24,
62
+ fleeRadius: 200,
63
+ fleeSpeed: 5.5,
64
+ slideDurationMs: 1500,
65
+ };
66
+
67
+ let fromLeft = Math.random() < 0.5;
68
+ let nathanX = 0;
69
+ let nathanY = 0;
70
+ let nathanSlidingIn = false;
71
+ let nathanCanFlee = false;
72
+ let slideStartAt = 0;
73
+ let slideFromX = 0;
74
+ let slideFromY = 0;
75
+ let slideToX = 0;
76
+ let slideToY = 0;
77
+
78
+ function nathanSize() {
79
+ const rect = nathanCard.getBoundingClientRect();
80
+ return { w: rect.width || 264, h: rect.height || 320 };
81
+ }
82
+
83
+ function clampNathanPosition(x, y) {
84
+ const { w, h } = nathanSize();
85
+ return {
86
+ x: clamp(x, N.margin, Math.max(N.margin, W - w - N.margin)),
87
+ y: clamp(y, N.margin, Math.max(N.margin, H - h - N.margin)),
88
+ };
89
+ }
90
+
91
+ function setNathanPosition(x, y) {
92
+ const clamped = clampNathanPosition(x, y);
93
+ nathanX = clamped.x;
94
+ nathanY = clamped.y;
95
+ nathanCard.style.left = `${nathanX}px`;
96
+ nathanCard.style.top = `${nathanY}px`;
97
+ }
98
+
99
+ function startSlideIn() {
100
+ const { w, h } = nathanSize();
101
+ slideToY = (H - h) / 2;
102
+ slideToX = fromLeft ? N.margin : Math.max(N.margin, W - w - N.margin);
103
+ slideFromY = slideToY;
104
+ slideFromX = fromLeft ? -w - 40 : W + 40;
105
+
106
+ setNathanPosition(slideFromX, slideFromY);
107
+ nathanCard.classList.add("visible");
108
+ nathanSlidingIn = true;
109
+ nathanCanFlee = false;
110
+ slideStartAt = performance.now();
111
+ }
112
+
113
+ function initNathan({ imagePath, caption }) {
114
+ nathanCaptionEl.textContent = caption || "";
115
+ if (imagePath) {
116
+ nathanImg.onload = () => startSlideIn();
117
+ nathanImg.src = encodeURI(`file://${imagePath}`);
118
+ if (nathanImg.complete) startSlideIn();
119
+ } else {
120
+ startSlideIn();
121
+ }
122
+ }
123
+
124
+ function updateNathanMotion() {
125
+ if (nathanWhacked) return;
126
+
127
+ if (nathanSlidingIn) {
128
+ const t = clamp((performance.now() - slideStartAt) / N.slideDurationMs, 0, 1);
129
+ const eased = 1 - Math.pow(1 - t, 3);
130
+ setNathanPosition(
131
+ lerp(slideFromX, slideToX, eased),
132
+ lerp(slideFromY, slideToY, eased),
133
+ );
134
+ if (t >= 1) {
135
+ nathanSlidingIn = false;
136
+ nathanCanFlee = true;
137
+ }
138
+ return;
139
+ }
140
+
141
+ if (!nathanCanFlee) return;
142
+
143
+ const { w, h } = nathanSize();
144
+ const cx = nathanX + w / 2;
145
+ const cy = nathanY + h / 2;
146
+ const dx = cx - mouseX;
147
+ const dy = cy - mouseY;
148
+ const dist = Math.hypot(dx, dy);
149
+ if (dist > N.fleeRadius || dist < 1) return;
150
+
151
+ const push = ((N.fleeRadius - dist) / N.fleeRadius) * N.fleeSpeed;
152
+ setNathanPosition(nathanX + (dx / dist) * push, nathanY + (dy / dist) * push);
153
+ }
154
+
155
+ window.bridge.onInitNathan(initNathan);
156
+
157
+ let nathanWhacked = false;
158
+ function whackNathan() {
159
+ if (nathanWhacked) return;
160
+ nathanWhacked = true;
161
+ nathanCanFlee = false;
162
+ nathanCard.style.opacity = "0";
163
+ nathanCard.style.transform = "scale(0.85)";
164
+ nathanCard.style.transition = "opacity 0.25s ease-in, transform 0.25s ease-in";
165
+ playCrackSound();
166
+ if (whip && !dropping) dropping = true;
167
+ setTimeout(() => window.bridge.hideOverlay(), 450);
168
+ }
169
+
170
+ nathanCard.addEventListener("mousedown", (e) => {
171
+ e.stopPropagation();
172
+ whackNathan();
173
+ });
174
+
175
+ // ── OpenWhip physics (adapted) ───────────────────────────────────────────────
176
+ const P = {
177
+ segments: 28,
178
+ segmentLength: 25,
179
+ taper: 0.6,
180
+ gravity: 1.0,
181
+ dropGravity: 0.95,
182
+ damping: 0.96,
183
+ constraintIters: 20,
184
+ maxStretchRatio: 1.2,
185
+ baseTargetAngle: -1.12,
186
+ handleAimByMouseX: 0.4,
187
+ handleAimByMouseY: 0.2,
188
+ handleAimClamp: 2.0,
189
+ handleSpring: 0.7,
190
+ handleAngularDamping: 0.078,
191
+ basePoseSegments: 2,
192
+ basePoseStiffStart: 0.9,
193
+ basePoseStiffEnd: 0.8,
194
+ handleMaxBendDeg: 16,
195
+ tipMaxBendDeg: 130,
196
+ bendRigidityStart: 0.8,
197
+ bendRigidityEnd: 0.12,
198
+ wallBounce: 0.42,
199
+ wallFriction: 0.86,
200
+ crackSpeed: 340,
201
+ crackCooldownMs: 200,
202
+ firstCrackGraceMs: 350,
203
+ lineWidthHandle: 7,
204
+ lineWidthTip: 5,
205
+ outlineWidth: 3,
206
+ handleExtraWidth: 5,
207
+ handleThickSegments: 2,
208
+ bgAlpha: 0.011,
209
+ arcWidth: 260,
210
+ arcHeight: 185,
211
+ };
212
+
213
+ const canvas = document.getElementById("c");
214
+ const ctx = canvas.getContext("2d");
215
+ let W, H;
216
+
217
+ function resize() {
218
+ W = canvas.width = window.innerWidth;
219
+ H = canvas.height = window.innerHeight;
220
+ }
221
+ resize();
222
+ window.addEventListener("resize", resize);
223
+
224
+ let mouseX = W / 2;
225
+ let mouseY = H / 2;
226
+ let prevMouseX = mouseX;
227
+ let prevMouseY = mouseY;
228
+ let whip = null;
229
+ let dropping = false;
230
+ let lastCrackTime = 0;
231
+ let whipSpawnTime = 0;
232
+ let handleAngle = P.baseTargetAngle;
233
+ let handleAngVel = 0;
234
+
235
+ const WHIP_CRACK_SOUNDS = ["sounds/A.mp3", "sounds/B.mp3", "sounds/C.mp3", "sounds/D.mp3", "sounds/E.mp3"];
236
+
237
+ document.addEventListener("mousemove", (e) => {
238
+ mouseX = e.clientX;
239
+ mouseY = e.clientY;
240
+ });
241
+
242
+ document.addEventListener("mousedown", (e) => {
243
+ if (e.target.closest("#nathan-card")) return;
244
+ if (whip && !dropping) dropping = true;
245
+ });
246
+
247
+ function spawnWhip(mx, my) {
248
+ dropping = false;
249
+ lastCrackTime = 0;
250
+ whipSpawnTime = Date.now();
251
+ const pts = [];
252
+ for (let i = 0; i < P.segments; i++) {
253
+ const t = i / (P.segments - 1);
254
+ const x = mx + t * P.arcWidth;
255
+ const y = my - Math.sin(t * Math.PI * 0.75) * P.arcHeight;
256
+ pts.push({ x, y, px: x, py: y });
257
+ }
258
+ return pts;
259
+ }
260
+
261
+ function segLen(i) {
262
+ const t = i / (P.segments - 1);
263
+ return P.segmentLength * (1 - t * (1 - P.taper));
264
+ }
265
+
266
+ function catmullPoint(pts, i) {
267
+ const n = pts.length;
268
+ if (n === 0) return { x: 0, y: 0 };
269
+ if (i < 0) {
270
+ if (n >= 2) return { x: 2 * pts[0].x - pts[1].x, y: 2 * pts[0].y - pts[1].y };
271
+ return { x: pts[0].x, y: pts[0].y };
272
+ }
273
+ if (i >= n) {
274
+ if (n >= 2) {
275
+ const a = pts[n - 2], b = pts[n - 1];
276
+ return { x: 2 * b.x - a.x, y: 2 * b.y - a.y };
277
+ }
278
+ return { x: pts[n - 1].x, y: pts[n - 1].y };
279
+ }
280
+ return pts[i];
281
+ }
282
+
283
+ function whipSegmentBezier(pts, i) {
284
+ const p0 = catmullPoint(pts, i - 1);
285
+ const p1 = pts[i];
286
+ const p2 = pts[i + 1];
287
+ const p3 = catmullPoint(pts, i + 2);
288
+ return {
289
+ cp1x: p1.x + (p2.x - p0.x) / 6,
290
+ cp1y: p1.y + (p2.y - p0.y) / 6,
291
+ cp2x: p2.x - (p3.x - p1.x) / 6,
292
+ cp2y: p2.y - (p3.y - p1.y) / 6,
293
+ x2: p2.x,
294
+ y2: p2.y,
295
+ };
296
+ }
297
+
298
+ const wrapPi = (a) => {
299
+ while (a > Math.PI) a -= Math.PI * 2;
300
+ while (a < -Math.PI) a += Math.PI * 2;
301
+ return a;
302
+ };
303
+
304
+ function playCrackSound() {
305
+ if (!WHIP_CRACK_SOUNDS.length) return;
306
+ const src = WHIP_CRACK_SOUNDS[Math.floor(Math.random() * WHIP_CRACK_SOUNDS.length)];
307
+ const a = new Audio(src);
308
+ a.play().catch(() => {});
309
+ }
310
+
311
+ function updateHandleAim() {
312
+ if (dropping) return;
313
+ const mvx = mouseX - prevMouseX;
314
+ const mvy = mouseY - prevMouseY;
315
+ const delta = clamp(
316
+ mvx * P.handleAimByMouseX + mvy * P.handleAimByMouseY,
317
+ -P.handleAimClamp,
318
+ P.handleAimClamp
319
+ );
320
+ const target = P.baseTargetAngle + delta;
321
+ const err = wrapPi(target - handleAngle);
322
+ handleAngVel += err * P.handleSpring;
323
+ handleAngVel *= P.handleAngularDamping;
324
+ handleAngle = wrapPi(handleAngle + handleAngVel);
325
+ }
326
+
327
+ function applyBasePose() {
328
+ if (!whip || dropping) return;
329
+ const dx = Math.cos(handleAngle);
330
+ const dy = Math.sin(handleAngle);
331
+ const guided = Math.min(P.basePoseSegments, whip.length - 1);
332
+ for (let i = 1; i <= guided; i++) {
333
+ const t = (i - 1) / Math.max(guided - 1, 1);
334
+ const stiff = lerp(P.basePoseStiffStart, P.basePoseStiffEnd, t);
335
+ const prev = whip[i - 1];
336
+ const p = whip[i];
337
+ const targetLen = segLen(i - 1);
338
+ const tx = prev.x + dx * targetLen;
339
+ const ty = prev.y + dy * targetLen;
340
+ p.x = lerp(p.x, tx, stiff);
341
+ p.y = lerp(p.y, ty, stiff);
342
+ }
343
+ }
344
+
345
+ function applyBendLimits() {
346
+ if (!whip || whip.length < 3) return;
347
+ for (let i = 1; i < whip.length - 1; i++) {
348
+ const a = whip[i - 1];
349
+ const b = whip[i];
350
+ const c = whip[i + 1];
351
+ const v1x = a.x - b.x;
352
+ const v1y = a.y - b.y;
353
+ const v2x = c.x - b.x;
354
+ const v2y = c.y - b.y;
355
+ const l1 = Math.hypot(v1x, v1y) || 0.0001;
356
+ const l2 = Math.hypot(v2x, v2y) || 0.0001;
357
+ const n1x = v1x / l1, n1y = v1y / l1;
358
+ const n2x = v2x / l2, n2y = v2y / l2;
359
+ const dot = clamp(n1x * n2x + n1y * n2y, -1, 1);
360
+ const angle = Math.acos(dot);
361
+ const t = i / (whip.length - 2);
362
+ const maxBend = lerp(P.handleMaxBendDeg, P.tipMaxBendDeg, t) * Math.PI / 180;
363
+ const bend = Math.PI - angle;
364
+ if (bend <= maxBend) continue;
365
+ const cross = n1x * n2y - n1y * n2x;
366
+ const sign = cross >= 0 ? 1 : -1;
367
+ const targetAngle = Math.PI - maxBend;
368
+ const targetA = Math.atan2(n1y, n1x) + sign * targetAngle;
369
+ const tx = b.x + Math.cos(targetA) * l2;
370
+ const ty = b.y + Math.sin(targetA) * l2;
371
+ const rigidity = lerp(P.bendRigidityStart, P.bendRigidityEnd, t);
372
+ c.x = lerp(c.x, tx, rigidity);
373
+ c.y = lerp(c.y, ty, rigidity);
374
+ }
375
+ }
376
+
377
+ function capSegmentStretch() {
378
+ if (!whip || whip.length < 2) return;
379
+ for (let i = 0; i < whip.length - 1; i++) {
380
+ const a = whip[i];
381
+ const b = whip[i + 1];
382
+ const dx = b.x - a.x;
383
+ const dy = b.y - a.y;
384
+ const dist = Math.hypot(dx, dy) || 0.0001;
385
+ const maxLen = segLen(i) * P.maxStretchRatio;
386
+ if (dist <= maxLen) continue;
387
+ const k = maxLen / dist;
388
+ b.x = a.x + dx * k;
389
+ b.y = a.y + dy * k;
390
+ }
391
+ }
392
+
393
+ function applyWallCollisions() {
394
+ if (!whip || dropping) return;
395
+ for (let i = 1; i < whip.length; i++) {
396
+ const p = whip[i];
397
+ let vx = p.x - p.px;
398
+ let vy = p.y - p.py;
399
+ let hit = false;
400
+ if (p.x < 0) { p.x = 0; if (vx < 0) vx = -vx * P.wallBounce; vy *= P.wallFriction; hit = true; }
401
+ else if (p.x > W) { p.x = W; if (vx > 0) vx = -vx * P.wallBounce; vy *= P.wallFriction; hit = true; }
402
+ if (p.y < 0) { p.y = 0; if (vy < 0) vy = -vy * P.wallBounce; vx *= P.wallFriction; hit = true; }
403
+ else if (p.y > H) { p.y = H; if (vy > 0) vy = -vy * P.wallBounce; vx *= P.wallFriction; hit = true; }
404
+ if (hit) { p.px = p.x - vx; p.py = p.y - vy; }
405
+ }
406
+ }
407
+
408
+ function update() {
409
+ if (!whip) return;
410
+ const g = dropping ? P.dropGravity : P.gravity;
411
+ updateHandleAim();
412
+ const start = dropping ? 0 : 1;
413
+ for (let i = start; i < whip.length; i++) {
414
+ const p = whip[i];
415
+ const vx = (p.x - p.px) * P.damping;
416
+ const vy = (p.y - p.py) * P.damping;
417
+ p.px = p.x;
418
+ p.py = p.y;
419
+ p.x += vx;
420
+ p.y += vy + g;
421
+ }
422
+ if (!dropping) {
423
+ whip[0].x = mouseX;
424
+ whip[0].y = mouseY;
425
+ whip[0].px = mouseX;
426
+ whip[0].py = mouseY;
427
+ }
428
+ capSegmentStretch();
429
+ applyWallCollisions();
430
+ applyBasePose();
431
+ for (let iter = 0; iter < P.constraintIters; iter++) {
432
+ for (let i = 0; i < whip.length - 1; i++) {
433
+ const a = whip[i], b = whip[i + 1];
434
+ const dx = b.x - a.x, dy = b.y - a.y;
435
+ const dist = Math.sqrt(dx * dx + dy * dy) || 0.0001;
436
+ const target = segLen(i);
437
+ const diff = (dist - target) / dist * 0.5;
438
+ const ox = dx * diff, oy = dy * diff;
439
+ if (i === 0 && !dropping) {
440
+ b.x -= ox * 2;
441
+ b.y -= oy * 2;
442
+ } else {
443
+ a.x += ox; a.y += oy;
444
+ b.x -= ox; b.y -= oy;
445
+ }
446
+ }
447
+ applyBendLimits();
448
+ if (!dropping) applyBasePose();
449
+ capSegmentStretch();
450
+ applyWallCollisions();
451
+ }
452
+ const tip = whip[whip.length - 1];
453
+ const tipVel = Math.hypot(tip.x - tip.px, tip.y - tip.py);
454
+ if (!dropping && tipVel > P.crackSpeed) {
455
+ const now = Date.now();
456
+ if (now - whipSpawnTime >= P.firstCrackGraceMs && now - lastCrackTime > P.crackCooldownMs) {
457
+ lastCrackTime = now;
458
+ playCrackSound();
459
+ }
460
+ }
461
+ if (dropping && whip.every((p) => p.y > H + 60)) {
462
+ whip = null;
463
+ dropping = false;
464
+ if (nathanWhacked) window.bridge.hideOverlay();
465
+ }
466
+ prevMouseX = mouseX;
467
+ prevMouseY = mouseY;
468
+ }
469
+
470
+ function draw() {
471
+ ctx.clearRect(0, 0, W, H);
472
+ ctx.fillStyle = `rgba(0,0,0,${P.bgAlpha})`;
473
+ ctx.fillRect(0, 0, W, H);
474
+ if (!whip) return;
475
+ ctx.lineCap = "round";
476
+ ctx.lineJoin = "round";
477
+ ctx.strokeStyle = "#fff";
478
+ if (whip.length >= 2) {
479
+ ctx.beginPath();
480
+ ctx.moveTo(whip[0].x, whip[0].y);
481
+ for (let i = 0; i < whip.length - 1; i++) {
482
+ const { cp1x, cp1y, cp2x, cp2y, x2, y2 } = whipSegmentBezier(whip, i);
483
+ ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x2, y2);
484
+ }
485
+ ctx.lineWidth = P.lineWidthTip + P.outlineWidth * 2;
486
+ ctx.stroke();
487
+ const thickLinks = Math.min(P.handleThickSegments, whip.length - 1);
488
+ if (thickLinks > 0 && P.handleExtraWidth > 0) {
489
+ ctx.beginPath();
490
+ ctx.moveTo(whip[0].x, whip[0].y);
491
+ for (let i = 0; i < thickLinks; i++) {
492
+ const { cp1x, cp1y, cp2x, cp2y, x2, y2 } = whipSegmentBezier(whip, i);
493
+ ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x2, y2);
494
+ }
495
+ ctx.lineWidth = P.lineWidthHandle + P.handleExtraWidth + P.outlineWidth * 2;
496
+ ctx.stroke();
497
+ }
498
+ }
499
+ ctx.strokeStyle = "#111";
500
+ for (let i = 0; i < whip.length - 1; i++) {
501
+ const t = i / Math.max(1, whip.length - 2);
502
+ const extra = i < P.handleThickSegments ? P.handleExtraWidth : 0;
503
+ ctx.lineWidth = lerp(P.lineWidthHandle, P.lineWidthTip, t) + extra;
504
+ const { cp1x, cp1y, cp2x, cp2y, x2, y2 } = whipSegmentBezier(whip, i);
505
+ ctx.beginPath();
506
+ ctx.moveTo(whip[i].x, whip[i].y);
507
+ ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x2, y2);
508
+ ctx.stroke();
509
+ }
510
+ }
511
+
512
+ function loop() {
513
+ updateNathanMotion();
514
+ update();
515
+ draw();
516
+ requestAnimationFrame(loop);
517
+ }
518
+ loop();
519
+
520
+ window.bridge.onSpawnWhip(() => {
521
+ whip = spawnWhip(mouseX || W / 2, mouseY || H / 2);
522
+ dropping = false;
523
+ prevMouseX = mouseX;
524
+ prevMouseY = mouseY;
525
+ handleAngle = P.baseTargetAngle;
526
+ handleAngVel = 0;
527
+ });
528
+
529
+ window.bridge.onDropWhip(() => {
530
+ if (whip && !dropping) dropping = true;
531
+ });
532
+ </script>
533
+ </body>
534
+ </html>
@@ -0,0 +1,9 @@
1
+ const { contextBridge, ipcRenderer } = require("electron");
2
+
3
+ contextBridge.exposeInMainWorld("bridge", {
4
+ whipCrack: () => ipcRenderer.send("whip-crack"),
5
+ hideOverlay: () => ipcRenderer.send("hide-overlay"),
6
+ onSpawnWhip: (fn) => ipcRenderer.on("spawn-whip", () => fn()),
7
+ onDropWhip: (fn) => ipcRenderer.on("drop-whip", () => fn()),
8
+ onInitNathan: (fn) => ipcRenderer.on("init-nathan", (_event, payload) => fn(payload)),
9
+ });
Binary file
Binary file
Binary file
Binary file
Binary file