clauderipple 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.
Files changed (71) hide show
  1. package/CHANGELOG.md +229 -0
  2. package/LICENSE +674 -0
  3. package/README.ko.md +328 -0
  4. package/README.md +372 -0
  5. package/bin/clauderipple.js +12 -0
  6. package/dist/app/assets/trayDownTemplate.png +0 -0
  7. package/dist/app/assets/trayDownTemplate@2x.png +0 -0
  8. package/dist/app/assets/trayTemplate.png +0 -0
  9. package/dist/app/assets/trayTemplate@2x.png +0 -0
  10. package/dist/app/assets/trayWarnTemplate.png +0 -0
  11. package/dist/app/assets/trayWarnTemplate@2x.png +0 -0
  12. package/dist/app/assets/trayWin.png +0 -0
  13. package/dist/app/assets/trayWin@2x.png +0 -0
  14. package/dist/app/assets/trayWinDown.png +0 -0
  15. package/dist/app/assets/trayWinDown@2x.png +0 -0
  16. package/dist/app/assets/trayWinWarn.png +0 -0
  17. package/dist/app/assets/trayWinWarn@2x.png +0 -0
  18. package/dist/app/dist/main.js +518 -0
  19. package/dist/cli/src/browser.js +21 -0
  20. package/dist/cli/src/bundle.js +51 -0
  21. package/dist/cli/src/certs.js +33 -0
  22. package/dist/cli/src/claude-auth.js +112 -0
  23. package/dist/cli/src/codex.js +172 -0
  24. package/dist/cli/src/gen-certs.js +7 -0
  25. package/dist/cli/src/hooks/agent-title.js +160 -0
  26. package/dist/cli/src/index.js +489 -0
  27. package/dist/cli/src/launchd.js +183 -0
  28. package/dist/cli/src/picker.js +166 -0
  29. package/dist/cli/src/probe.js +55 -0
  30. package/dist/cli/src/runtime.js +62 -0
  31. package/dist/cli/src/schtasks.js +134 -0
  32. package/dist/cli/src/settings.js +142 -0
  33. package/dist/cli/src/supervisor.js +100 -0
  34. package/dist/cli/src/tray.js +85 -0
  35. package/dist/router/src/admin.js +945 -0
  36. package/dist/router/src/bootstrap.js +80 -0
  37. package/dist/router/src/certs.js +65 -0
  38. package/dist/router/src/compat.js +172 -0
  39. package/dist/router/src/config.js +179 -0
  40. package/dist/router/src/health.js +45 -0
  41. package/dist/router/src/identity.js +51 -0
  42. package/dist/router/src/index.js +144 -0
  43. package/dist/router/src/ingress/models.js +29 -0
  44. package/dist/router/src/ingress/server.js +400 -0
  45. package/dist/router/src/ingress/translate.js +457 -0
  46. package/dist/router/src/log.js +81 -0
  47. package/dist/router/src/picker.js +74 -0
  48. package/dist/router/src/presets.js +267 -0
  49. package/dist/router/src/providers/anthropic-observed.js +88 -0
  50. package/dist/router/src/providers/anthropic-token-file.js +48 -0
  51. package/dist/router/src/providers/anthropic.js +203 -0
  52. package/dist/router/src/providers/chatgpt/auth.js +226 -0
  53. package/dist/router/src/providers/chatgpt/index.js +274 -0
  54. package/dist/router/src/providers/chatgpt/sse.js +28 -0
  55. package/dist/router/src/providers/chatgpt/translate.js +393 -0
  56. package/dist/router/src/providers/claude-oauth.js +252 -0
  57. package/dist/router/src/providers/openai/index.js +193 -0
  58. package/dist/router/src/providers/openai/translate.js +504 -0
  59. package/dist/router/src/proxy.js +724 -0
  60. package/dist/router/src/redact.js +43 -0
  61. package/dist/router/src/requestlog.js +346 -0
  62. package/dist/router/src/routing.js +113 -0
  63. package/dist/router/src/version.js +8 -0
  64. package/dist/router/src/x509.js +203 -0
  65. package/dist/ui/app.js +1228 -0
  66. package/dist/ui/i18n.js +95 -0
  67. package/dist/ui/index.html +104 -0
  68. package/dist/ui/presets-fallback.js +61 -0
  69. package/dist/ui/style.css +347 -0
  70. package/docs/ARCHITECTURE.md +441 -0
  71. package/package.json +66 -0
@@ -0,0 +1,518 @@
1
+ "use strict";
2
+ // ClaudeRipple menu-bar app. It owns no logic: the router serves the GUI and the admin API on
3
+ // 127.0.0.1; this shell adds a tray icon with live health, a window for the GUI, and shortcuts
4
+ // for restart / logs / login. If the router is down the tray says so and offers to start it.
5
+ var __importDefault = (this && this.__importDefault) || function (mod) {
6
+ return (mod && mod.__esModule) ? mod : { "default": mod };
7
+ };
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ const electron_1 = require("electron");
10
+ const node_child_process_1 = require("node:child_process");
11
+ const node_fs_1 = __importDefault(require("node:fs"));
12
+ const node_os_1 = __importDefault(require("node:os"));
13
+ const node_path_1 = __importDefault(require("node:path"));
14
+ const home = process.env.CLAUDERIPPLE_HOME ?? node_path_1.default.join(node_os_1.default.homedir(), ".clauderipple");
15
+ const POLL_MS = 5000;
16
+ // ---- i18n: tray menu, dialogs, tooltips -------------------------------------------------
17
+ const STRINGS = {
18
+ en: {
19
+ healthy: "healthy",
20
+ attentionNeeded: "attention needed",
21
+ routerNotRunning: "router not running",
22
+ slotsMapped: (n) => `${n} slot${n === 1 ? "" : "s"} mapped`,
23
+ inFlight: "in flight",
24
+ connected: "Claude Desktop connected",
25
+ notConnected: "Claude Desktop not connected",
26
+ percentOfWeekUsed: (percent, plan) => `ChatGPT ${plan} ${percent}% this week`,
27
+ cliVersion: (v) => `Claude Code CLI ${v}`,
28
+ restartRouter: "Restart Router",
29
+ startRouter: "Start Router",
30
+ pickerOn: "Show GPT models in the Code tab picker…",
31
+ pickerOff: "Stop showing GPT models in the picker…",
32
+ pickerState: (on) => (on ? "Picker: GPT models shown by name ✓" : "Picker: GPT models via Claude names (alias mode)"),
33
+ pickerOnConfirm: "macOS will ask for your login password to trust the ClaudeRipple certificate (ClaudeRipple never sees it). Then quit and reopen Claude Desktop.",
34
+ pickerDone: "Done. Now quit Claude Desktop completely and open it again, then check the Code tab picker.",
35
+ pickerOffDone: "Done. Quit and reopen Claude Desktop to apply.",
36
+ cancel: "Cancel",
37
+ agentTitleOn: "Show model names on subagents",
38
+ agentTitleOff: "Stop showing model names on subagents",
39
+ agentTitleDone: (on) => (on ? "On. New subagents will be titled like \"Terra·high · …\"." : "Off."),
40
+ signInChatgpt: "Sign in to ChatGPT…",
41
+ connectClaudeSubscription: "Connect Claude subscription…",
42
+ copyStatus: "Copy Status",
43
+ showLogs: "Show Logs",
44
+ rerunSetup: "Run ClaudeRipple setup again…",
45
+ setupTitle: "Set up ClaudeRipple",
46
+ setupPrompt: "ClaudeRipple will create a local certificate, connect Claude Code settings (~/.claude/settings.json), and register the background router. Continue?",
47
+ setupLater: "Later",
48
+ setupContinue: "Continue",
49
+ setupDone: "ClaudeRipple setup finished.",
50
+ setupFailed: "Setup did not finish",
51
+ updatedTitle: (v) => `ClaudeRipple ${v}`,
52
+ updatedRestarting: (from) => `The running router is ${from}. Switching it to this version…`,
53
+ updatedFailed: "The router could not be switched to this version",
54
+ problem: (code) => code === "settings" ? "Claude Code is not pointed at ClaudeRipple"
55
+ : code === "upstream" ? "Anthropic is unreachable"
56
+ : code === "picker-ca" ? "picker mode: certificate not trusted"
57
+ : code === "picker-proxy" ? "picker mode: Claude Desktop proxy not set"
58
+ : code.startsWith("provider:") ? `provider ${code.slice(9)} unreachable`
59
+ : code,
60
+ about: "About ClaudeRipple",
61
+ aboutDetail: "Run GPT and other models inside Claude Desktop, without turning Claude off.\n\nIndependent open-source project (GPL-3.0). Not affiliated with, endorsed by, or sponsored by Anthropic or OpenAI. Claude and Claude Code are trademarks of Anthropic, PBC.",
62
+ quit: "Quit",
63
+ tooltip: (state) => `ClaudeRipple ${state}`,
64
+ tooltipDown: "ClaudeRipple: router not running — Claude Desktop cannot connect",
65
+ proxyLine: (host, port, slots, flight) => `Proxy ${host}:${port} · ${slots} · ${flight}`,
66
+ startAtLogin: "Start at login",
67
+ downNotifyTitle: "Claude Desktop cannot connect",
68
+ downNotifyBody: "The ClaudeRipple router is not running, so Claude Desktop has no way out. Click to start it.",
69
+ starting: "starting the router…",
70
+ openDashboard: "Open Dashboard in Browser…",
71
+ offlineHeading: "The router is not running",
72
+ offlineBody: "Claude Desktop sends all of its traffic through ClaudeRipple, so while the router is down the app shows a blank window with ERR_PROXY_CONNECTION_FAILED. Start the router and reload Claude Desktop.",
73
+ setupNeeded: "setup not finished",
74
+ setupNeededDetail: "ClaudeRipple is installed but not set up yet",
75
+ runSetup: "Finish setting up ClaudeRipple…",
76
+ },
77
+ ko: {
78
+ healthy: "정상",
79
+ attentionNeeded: "확인 필요",
80
+ routerNotRunning: "라우터가 꺼져 있음",
81
+ slotsMapped: (n) => `슬롯 ${n}개 매핑`,
82
+ inFlight: "진행 중",
83
+ connected: "Claude Desktop 연결됨",
84
+ notConnected: "Claude Desktop 연결 안 됨",
85
+ percentOfWeekUsed: (percent, plan) => `ChatGPT ${plan} 주간 ${percent}%`,
86
+ cliVersion: (v) => `Claude Code CLI ${v}`,
87
+ restartRouter: "라우터 재시작",
88
+ startRouter: "라우터 시작",
89
+ pickerOn: "Code 탭 피커에 GPT 모델 이름 표시…",
90
+ pickerOff: "피커의 GPT 모델 표시 끄기…",
91
+ pickerState: (on) => (on ? "피커: GPT 모델을 실명으로 표시 중 ✓" : "피커: Claude 이름으로 GPT 사용(별칭 모드)"),
92
+ pickerOnConfirm: "ClaudeRipple 인증서를 신뢰하기 위해 macOS가 로그인 암호를 묻습니다(ClaudeRipple은 암호를 보지 않습니다). 끝나면 Claude Desktop을 완전히 종료했다가 다시 여세요.",
93
+ pickerDone: "완료. 이제 Claude Desktop을 완전히 종료한 뒤 다시 열고 Code 탭 피커를 확인하세요.",
94
+ pickerOffDone: "완료. Claude Desktop을 종료했다가 다시 열면 적용됩니다.",
95
+ cancel: "취소",
96
+ agentTitleOn: "서브에이전트에 모델 이름 표시",
97
+ agentTitleOff: "서브에이전트 모델 이름 표시 끄기",
98
+ agentTitleDone: (on) => (on ? "켰습니다. 새로 뜨는 서브에이전트 제목이 \"Terra·high · …\"처럼 보입니다." : "껐습니다."),
99
+ signInChatgpt: "ChatGPT 로그인…",
100
+ connectClaudeSubscription: "Claude 구독 연결…",
101
+ copyStatus: "상태 복사",
102
+ showLogs: "로그 보기",
103
+ rerunSetup: "ClaudeRipple 설정 다시 실행…",
104
+ setupTitle: "ClaudeRipple을 설정합니다",
105
+ setupPrompt: "로컬 인증서 생성, Claude Code 설정(~/.claude/settings.json) 연결, 백그라운드 라우터 등록을 진행합니다. 계속할까요?",
106
+ setupLater: "나중에",
107
+ setupContinue: "계속",
108
+ setupDone: "ClaudeRipple 설정이 완료되었습니다.",
109
+ setupFailed: "설정을 마치지 못했습니다",
110
+ updatedTitle: (v) => `ClaudeRipple ${v}`,
111
+ updatedRestarting: (from) => `지금 떠 있는 라우터는 ${from}입니다. 이 버전으로 바꿉니다…`,
112
+ updatedFailed: "라우터를 이 버전으로 바꾸지 못했습니다",
113
+ problem: (code) => code === "settings" ? "Claude Code가 ClaudeRipple을 거치지 않음"
114
+ : code === "upstream" ? "Anthropic에 연결 안 됨"
115
+ : code === "picker-ca" ? "피커 모드: 인증서 미신뢰"
116
+ : code === "picker-proxy" ? "피커 모드: Claude Desktop 프록시 미설정"
117
+ : code.startsWith("provider:") ? `프로바이더 ${code.slice(9)} 연결 안 됨`
118
+ : code,
119
+ about: "ClaudeRipple 정보",
120
+ aboutDetail: "Claude Desktop을 끄지 않고 그 안에서 GPT 등 다른 모델을 씁니다.\n\n독립 오픈소스 프로젝트(GPL-3.0)이며 Anthropic·OpenAI와 제휴·보증·후원 관계가 없습니다. Claude와 Claude Code는 Anthropic, PBC의 상표입니다.",
121
+ quit: "종료",
122
+ tooltip: (state) => `ClaudeRipple ${state}`,
123
+ tooltipDown: "ClaudeRipple: 라우터가 꺼져 있어 Claude Desktop이 연결되지 않습니다",
124
+ proxyLine: (host, port, slots, flight) => `프록시 ${host}:${port} · ${slots} · ${flight}`,
125
+ startAtLogin: "로그인 시 자동 시작",
126
+ downNotifyTitle: "Claude Desktop이 연결되지 않습니다",
127
+ downNotifyBody: "ClaudeRipple 라우터가 꺼져 있어 Claude Desktop이 밖으로 나가지 못합니다. 눌러서 시작하세요.",
128
+ starting: "라우터를 시작하는 중…",
129
+ openDashboard: "브라우저에서 대시보드 열기…",
130
+ offlineHeading: "라우터가 꺼져 있습니다",
131
+ offlineBody: "Claude Desktop은 모든 통신을 ClaudeRipple로 보냅니다. 그래서 라우터가 꺼져 있는 동안에는 앱이 흰 화면과 ERR_PROXY_CONNECTION_FAILED만 보여줍니다. 라우터를 시작한 뒤 Claude Desktop을 새로고침하세요.",
132
+ setupNeeded: "설정이 끝나지 않았습니다",
133
+ setupNeededDetail: "설치는 됐지만 아직 설정하지 않았습니다",
134
+ runSetup: "ClaudeRipple 설정 마치기…",
135
+ },
136
+ };
137
+ let L = STRINGS.en;
138
+ function readConfigPorts() {
139
+ try {
140
+ const c = JSON.parse(node_fs_1.default.readFileSync(node_path_1.default.join(home, "config.json"), "utf8"));
141
+ const proxy = c.listen?.port ?? 8790;
142
+ return { proxy, admin: c.admin?.port ?? proxy + 1 };
143
+ }
144
+ catch {
145
+ return { proxy: 8790, admin: 8791 };
146
+ }
147
+ }
148
+ let tray = null;
149
+ let last = null;
150
+ let lastError = null;
151
+ let starting = false;
152
+ let downSince = null;
153
+ let downNotified = false;
154
+ // Long enough that a router still coming up after login does not fire a notification.
155
+ const DOWN_NOTIFY_AFTER_MS = 20_000;
156
+ // ---- login item ------------------------------------------------------------------------
157
+ // The tray app has to be running before the user opens Claude Desktop: with the router down the
158
+ // app shows nothing but ERR_PROXY_CONNECTION_FAILED, and the tray is the only place that says why.
159
+ const stateFile = node_path_1.default.join(home, "app.json");
160
+ function appState() {
161
+ try {
162
+ return JSON.parse(node_fs_1.default.readFileSync(stateFile, "utf8"));
163
+ }
164
+ catch {
165
+ return {};
166
+ }
167
+ }
168
+ function writeAppState(patch) {
169
+ try {
170
+ node_fs_1.default.mkdirSync(home, { recursive: true });
171
+ node_fs_1.default.writeFileSync(stateFile, `${JSON.stringify({ ...appState(), ...patch }, null, 2)}\n`);
172
+ }
173
+ catch {
174
+ /* a read-only home only costs us the remembered default */
175
+ }
176
+ }
177
+ function loginItemOn() {
178
+ try {
179
+ return electron_1.app.getLoginItemSettings().openAtLogin;
180
+ }
181
+ catch {
182
+ return false;
183
+ }
184
+ }
185
+ function setLoginItem(on) {
186
+ try {
187
+ electron_1.app.setLoginItemSettings({ openAtLogin: on });
188
+ }
189
+ catch {
190
+ /* macOS may refuse in an unsigned development build */
191
+ }
192
+ writeAppState({ loginItemInitialized: true });
193
+ }
194
+ function adminUrl() {
195
+ return `http://127.0.0.1:${readConfigPorts().admin}/`;
196
+ }
197
+ /**
198
+ * The language the user actually set in the OS. `app.getLocale()` is Chromium's app locale and
199
+ * reads `en-US` on a Korean Windows (measured 2026-09-14), so the tray, notifications and the GUI
200
+ * came up in English there. macOS happened to agree, which hid it.
201
+ */
202
+ function uiLang() {
203
+ const candidates = [...electron_1.app.getPreferredSystemLanguages(), electron_1.app.getSystemLocale(), electron_1.app.getLocale()];
204
+ const first = candidates.find((l) => typeof l === "string" && l.length > 0) ?? "en";
205
+ return first.toLowerCase().startsWith("ko") ? "ko" : "en";
206
+ }
207
+ /** The GUI follows the OS language by default; a language the user picked in the GUI itself wins (it is stored in the page). */
208
+ function guiUrl() {
209
+ return `${adminUrl()}?lang=${uiLang()}`;
210
+ }
211
+ async function poll() {
212
+ try {
213
+ const res = await fetch(`${adminUrl()}api/status`, { signal: AbortSignal.timeout(3000) });
214
+ if (!res.ok)
215
+ throw new Error(`HTTP ${res.status}`);
216
+ last = (await res.json());
217
+ lastError = null;
218
+ downSince = null;
219
+ downNotified = false;
220
+ void reconcileRouter(last);
221
+ }
222
+ catch (e) {
223
+ last = null;
224
+ lastError = e.message;
225
+ downSince ??= Date.now();
226
+ if (!downNotified && Date.now() - downSince >= DOWN_NOTIFY_AFTER_MS) {
227
+ downNotified = true;
228
+ notifyDown();
229
+ }
230
+ }
231
+ render();
232
+ }
233
+ /** The router being down means Claude Desktop is dead in the water, so say so out loud. */
234
+ function notifyDown() {
235
+ if (!electron_1.Notification.isSupported())
236
+ return;
237
+ const n = new electron_1.Notification({ title: L.downNotifyTitle, body: L.downNotifyBody });
238
+ n.on("click", () => void startRouter());
239
+ n.show();
240
+ }
241
+ /** Idempotent: `clauderipple start` leaves a router that is already coming up alone. */
242
+ async function startRouter() {
243
+ if (starting)
244
+ return L.starting;
245
+ starting = true;
246
+ try {
247
+ return await runCli(["start"]);
248
+ }
249
+ finally {
250
+ starting = false;
251
+ void poll();
252
+ }
253
+ }
254
+ function healthy(s) {
255
+ if (!s)
256
+ return "down";
257
+ const proxyOk = s.settings.HTTPS_PROXY === `http://127.0.0.1:${s.listen.port}`;
258
+ const providersOk = Object.values(s.providers).every((p) => p.reachable !== false);
259
+ if (!proxyOk || s.consecutiveUpstreamFailures > 0)
260
+ return "warn";
261
+ return providersOk ? "ok" : "warn";
262
+ }
263
+ const isMac = process.platform === "darwin";
264
+ /**
265
+ * macOS tints template images (black + alpha) to match the menu bar. Windows does not, so the same
266
+ * mark would vanish on a dark taskbar — it gets a coloured variant instead, which also lets the
267
+ * state read as a colour rather than only a shape.
268
+ */
269
+ function icon(state) {
270
+ const mac = { ok: "trayTemplate.png", warn: "trayWarnTemplate.png", down: "trayDownTemplate.png" };
271
+ const win = { ok: "trayWin.png", warn: "trayWinWarn.png", down: "trayWinDown.png" };
272
+ const img = electron_1.nativeImage.createFromPath(node_path_1.default.join(__dirname, "..", "assets", (isMac ? mac : win)[state]));
273
+ if (isMac)
274
+ img.setTemplateImage(true);
275
+ return img;
276
+ }
277
+ /**
278
+ * The dashboard is a web page served by the router, so it opens in the user's own browser. With
279
+ * the router down there is nothing to open: start it (or run setup, when that is what is missing)
280
+ * and open the page once it answers, rather than sending the browser to a refused connection.
281
+ */
282
+ async function openDashboard() {
283
+ if (last) {
284
+ void electron_1.shell.openExternal(guiUrl());
285
+ return;
286
+ }
287
+ // Before setup there is no router to start, so offer the step that is actually missing.
288
+ const message = needsSetup() ? (await setup(), "") : await startRouter();
289
+ // startRouter() returns once the CLI has finished; ask the router itself rather than waiting
290
+ // for the next tick, since the user is standing in front of the menu.
291
+ await poll();
292
+ if (last) {
293
+ void electron_1.shell.openExternal(guiUrl());
294
+ return;
295
+ }
296
+ await electron_1.dialog.showMessageBox({ type: "warning", message: L.offlineHeading, detail: [L.offlineBody, message].filter(Boolean).join("\n\n") });
297
+ }
298
+ // ---- update handling -------------------------------------------------------------------
299
+ // Installing a new version replaces files, not the process: the supervisor started the router at
300
+ // logon from the previous files, and `start` leaves a running router alone. So after an update
301
+ // the old router kept serving — the 0.1.1 fixes never ran on the Windows install that reported
302
+ // the bugs (2026-09-15). A zip unpacked next to the old folder is worse: the supervisor and
303
+ // paths.json still name the old folder, so even a restart brings the old code back.
304
+ //
305
+ // Once per app run, when the router answers: if it runs another version, restart it; if this
306
+ // installation's files are not the ones recorded, re-run `install` first so the supervisor
307
+ // points here. A source checkout recorded in paths.json is a developer's choice and is left alone.
308
+ let reconciled = false;
309
+ function savedPaths() {
310
+ try {
311
+ return JSON.parse(node_fs_1.default.readFileSync(node_path_1.default.join(home, "paths.json"), "utf8"));
312
+ }
313
+ catch {
314
+ return null;
315
+ }
316
+ }
317
+ /** A router path inside a packaged app (macOS .app bundle or electron-builder resources). */
318
+ function looksPackaged(routerPath) {
319
+ return /\.app[\\/]Contents[\\/]Resources[\\/]clauderipple[\\/]/.test(routerPath) || /[\\/]resources[\\/]clauderipple[\\/]/i.test(routerPath);
320
+ }
321
+ function samePath(a, b) {
322
+ const norm = (p) => (process.platform === "win32" ? node_path_1.default.resolve(p).toLowerCase() : node_path_1.default.resolve(p));
323
+ return norm(a) === norm(b);
324
+ }
325
+ async function reconcileRouter(s) {
326
+ if (reconciled)
327
+ return;
328
+ const packaged = packagedRuntime();
329
+ if (!packaged?.router)
330
+ return; // development shell: never touch a checkout's router
331
+ const saved = savedPaths();
332
+ if (!saved?.router || !looksPackaged(saved.router))
333
+ return;
334
+ const installedElsewhere = !samePath(saved.router, packaged.router);
335
+ const runningElsewhere = !!s.runtime?.router && !samePath(s.runtime.router, packaged.router);
336
+ const otherVersion = s.version !== electron_1.app.getVersion();
337
+ if (!installedElsewhere && !runningElsewhere && !otherVersion)
338
+ return;
339
+ reconciled = true;
340
+ const from = `${s.version}${s.runtime?.router ? ` (${s.runtime.router})` : ""}`;
341
+ if (electron_1.Notification.isSupported())
342
+ new electron_1.Notification({ title: L.updatedTitle(electron_1.app.getVersion()), body: L.updatedRestarting(from) }).show();
343
+ const outputs = [];
344
+ if (installedElsewhere || runningElsewhere)
345
+ outputs.push(await runCli(["install"]));
346
+ outputs.push(await runCli(["restart"]));
347
+ const out = outputs.join("\n\n");
348
+ if (/✗|error|failed|not restarted|start failed/i.test(out)) {
349
+ await electron_1.dialog.showMessageBox({ type: "warning", message: L.updatedFailed, detail: out });
350
+ }
351
+ void poll();
352
+ }
353
+ /**
354
+ * The sources this app carries, when it is a packaged build. macOS keeps them inside the .app;
355
+ * electron-builder puts them next to app.asar everywhere else.
356
+ *
357
+ * This used to match only the macOS bundle path, so on Windows it returned null — which made
358
+ * needsSetup() answer false and the first-run setup prompt never appear. The app installed, said
359
+ * "router not running", and left the user with no way to find out what to do (2026-09-14).
360
+ */
361
+ function packagedRuntime() {
362
+ const macBundle = process.execPath.match(/^(.*\.app)\/Contents\/MacOS\//)?.[1];
363
+ const resources = macBundle
364
+ ? node_path_1.default.join(macBundle, "Contents", "Resources", "clauderipple")
365
+ : node_path_1.default.join(process.resourcesPath ?? "", "clauderipple");
366
+ const cli = node_path_1.default.join(resources, "packages", "cli", "src", "index.ts");
367
+ if (!node_fs_1.default.existsSync(cli))
368
+ return null;
369
+ return { node: process.execPath, env: { ELECTRON_RUN_AS_NODE: "1" }, cli, router: node_path_1.default.join(resources, "packages", "router", "src", "index.ts") };
370
+ }
371
+ /** Prefer this app's bundled Electron runtime; existing installations retain their recorded runtime. */
372
+ function cliPaths() {
373
+ const packaged = packagedRuntime();
374
+ if (packaged && node_fs_1.default.existsSync(packaged.cli))
375
+ return packaged;
376
+ try {
377
+ const p = JSON.parse(node_fs_1.default.readFileSync(node_path_1.default.join(home, "paths.json"), "utf8"));
378
+ if (p.node && p.cli && node_fs_1.default.existsSync(p.node) && node_fs_1.default.existsSync(p.cli))
379
+ return { node: p.node, env: p.env ?? {}, cli: p.cli };
380
+ }
381
+ catch {
382
+ /* fall through to the development layout */
383
+ }
384
+ // process.execPath is Electron: without ELECTRON_RUN_AS_NODE it launches a second app instead of
385
+ // running the CLI, and that instance never exits (observed 2026-09-14 with no paths.json).
386
+ // A checkout keeps the CLI two levels up as TypeScript; an npm install has the built JavaScript
387
+ // in the same place, since the tray is copied into the same tree it was built beside.
388
+ const nearby = [node_path_1.default.resolve(__dirname, "..", "..", "cli", "src", "index.ts"), node_path_1.default.resolve(__dirname, "..", "..", "cli", "src", "index.js")];
389
+ const cli = nearby.find((candidate) => node_fs_1.default.existsSync(candidate)) ?? nearby[0];
390
+ return { node: process.execPath, env: { ELECTRON_RUN_AS_NODE: "1" }, cli };
391
+ }
392
+ function runCli(args) {
393
+ const { node, env, cli } = cliPaths();
394
+ return new Promise((resolve) => {
395
+ (0, node_child_process_1.execFile)(node, [cli, ...args], { env: { ...process.env, ...env, CLAUDERIPPLE_HOME: home } }, (err, stdout, stderr) => {
396
+ resolve(`${stdout}${stderr}${err ? `\n${err.message}` : ""}`.trim());
397
+ });
398
+ });
399
+ }
400
+ // Offer setup only when there is no working installation at all. A developer who runs the router
401
+ // from a source checkout keeps that; switching to the app's bundled runtime is a menu action.
402
+ function needsSetup() {
403
+ const current = packagedRuntime();
404
+ if (!current)
405
+ return false;
406
+ try {
407
+ const saved = JSON.parse(node_fs_1.default.readFileSync(node_path_1.default.join(home, "paths.json"), "utf8"));
408
+ const usable = !!saved.node && !!saved.router && node_fs_1.default.existsSync(saved.node) && node_fs_1.default.existsSync(saved.router);
409
+ return !usable;
410
+ }
411
+ catch {
412
+ return true;
413
+ }
414
+ }
415
+ async function setup() {
416
+ const out = await runCli(["install"]);
417
+ // `install` marks each step with ✓ or ✗ and ends on a failed probe; saying "finished" over a
418
+ // failure would send the user away believing it worked.
419
+ const failed = out.includes("✗") || /error|failed/i.test(out);
420
+ await electron_1.dialog.showMessageBox({
421
+ type: failed ? "warning" : "info",
422
+ message: failed ? L.setupFailed : L.setupDone,
423
+ detail: out,
424
+ });
425
+ void poll();
426
+ }
427
+ async function promptForSetup() {
428
+ const choice = await electron_1.dialog.showMessageBox({
429
+ title: "ClaudeRipple",
430
+ message: L.setupTitle,
431
+ detail: L.setupPrompt,
432
+ buttons: [L.setupContinue, L.setupLater],
433
+ defaultId: 0,
434
+ cancelId: 1,
435
+ });
436
+ if (choice.response === 0)
437
+ await setup();
438
+ }
439
+ function render() {
440
+ if (!tray)
441
+ return;
442
+ const state = healthy(last);
443
+ tray.setImage(icon(state));
444
+ const s = last;
445
+ const quota = Object.values(s?.chatgpt?.quota ?? {}).find((q) => q);
446
+ const quotaLine = quota?.rate_limits?.primary
447
+ ? L.percentOfWeekUsed(quota.rate_limits.primary.used_percent ?? "?", quota.plan_type ?? "")
448
+ : null;
449
+ // Two status lines (clickable: they open the window), then only the actions that need the tray.
450
+ // Everything else lives in the GUI window. Disabled items render grey, so the status lines stay enabled.
451
+ const connected = !!s && s.settings.HTTPS_PROXY === `http://127.0.0.1:${s.listen.port}`;
452
+ // "Router not running" is useless advice to someone who has never set it up — that reads as a
453
+ // fault when the truth is there is one step left. Separate the two states everywhere.
454
+ const unconfigured = !s && needsSetup();
455
+ const headline = s
456
+ ? `ClaudeRipple · ${state === "ok" ? L.healthy : L.attentionNeeded}`
457
+ : `ClaudeRipple · ${unconfigured ? L.setupNeeded : L.routerNotRunning}`;
458
+ // What is actually in the way, not only that something is: the router's readiness list names it.
459
+ const problems = (s?.readiness?.problems ?? []).map((code) => L.problem(code));
460
+ const detail = s
461
+ ? [connected ? L.connected : L.notConnected, ...problems, quotaLine].filter(Boolean).join(" · ")
462
+ : unconfigured
463
+ ? L.setupNeededDetail
464
+ : L.downNotifyTitle;
465
+ const template = [
466
+ { label: headline, click: () => void openDashboard() },
467
+ ...(detail ? [{ label: detail, click: () => void openDashboard() }] : []),
468
+ { type: "separator" },
469
+ // Enabled even when the router is down: it is started first, then the page is opened.
470
+ { label: L.openDashboard, click: () => void openDashboard() },
471
+ { label: L.startAtLogin, type: "checkbox", checked: loginItemOn(), click: (item) => setLoginItem(item.checked) },
472
+ { type: "separator" },
473
+ { label: unconfigured ? L.runSetup : L.rerunSetup, click: () => void setup() },
474
+ ...(unconfigured
475
+ ? []
476
+ : [
477
+ {
478
+ label: s ? L.restartRouter : L.startRouter,
479
+ click: async () => void electron_1.dialog.showMessageBox({ message: s ? await runCli(["restart"]) : await startRouter() }),
480
+ },
481
+ ]),
482
+ // Only offered while a ChatGPT provider has no usable credentials (own login or a reused Codex CLI login).
483
+ ...(s && Object.values(s.chatgpt?.signedIn ?? {}).some((ok) => !ok)
484
+ ? [{ label: L.signInChatgpt, click: async () => void electron_1.dialog.showMessageBox({ message: await runCli(["login"]) }) }]
485
+ : []),
486
+ { label: L.connectClaudeSubscription, click: async () => void electron_1.dialog.showMessageBox({ message: await runCli(["claude-login"]) }) },
487
+ { type: "separator" },
488
+ { label: L.about, click: () => void electron_1.dialog.showMessageBox({ title: "ClaudeRipple", message: "ClaudeRipple", detail: L.aboutDetail }) },
489
+ { label: L.quit, role: "quit" },
490
+ ];
491
+ tray.setContextMenu(electron_1.Menu.buildFromTemplate(template));
492
+ tray.setToolTip(s ? L.tooltip(state) : L.tooltipDown);
493
+ }
494
+ electron_1.app.whenReady().then(() => {
495
+ L = STRINGS[uiLang()];
496
+ // Without this, Windows attributes our notifications to "Electron" instead of ClaudeRipple.
497
+ if (process.platform === "win32")
498
+ electron_1.app.setAppUserModelId("com.clauderipple.app");
499
+ // macOS keeps its application menu (it owns Cmd-Q and the edit shortcuts); elsewhere it is noise.
500
+ if (!isMac)
501
+ electron_1.Menu.setApplicationMenu(null);
502
+ if (isMac)
503
+ electron_1.app.dock?.hide();
504
+ // Default to starting at login on first run; after that the user's choice stands.
505
+ if (!appState().loginItemInitialized)
506
+ setLoginItem(true);
507
+ tray = new electron_1.Tray(icon("down"));
508
+ tray.on("click", () => tray?.popUpContextMenu());
509
+ render();
510
+ void poll();
511
+ setInterval(() => void poll(), POLL_MS);
512
+ if (needsSetup())
513
+ void promptForSetup();
514
+ });
515
+ // A tray-only app owns no windows; without this Electron would quit the moment a dialog closes.
516
+ electron_1.app.on("window-all-closed", () => {
517
+ /* keep running in the tray */
518
+ });
@@ -0,0 +1,21 @@
1
+ // Opens a URL in the user's default browser. Shared by the CLI (sign-in commands, `ui`) and the
2
+ // router's admin API (sign-in started from the GUI).
3
+ import { execFileSync } from "node:child_process";
4
+ /** Returns false if the platform command failed; the caller then shows the URL instead. */
5
+ export function openBrowser(url) {
6
+ try {
7
+ if (process.platform === "win32") {
8
+ // Not `cmd /c start`: cmd treats & as a command separator, so an OAuth URL arrives truncated
9
+ // at its first parameter and the provider answers "missing_required_parameter" (observed
10
+ // 2026-09-14). Start-Process takes the URL as one argument, quoted PowerShell-style.
11
+ const quoted = `'${url.replace(/'/g, "''")}'`;
12
+ execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", `Start-Process ${quoted}`], { stdio: "ignore", windowsHide: true });
13
+ }
14
+ else
15
+ execFileSync("open", [url], { stdio: "ignore" });
16
+ return true;
17
+ }
18
+ catch {
19
+ return false;
20
+ }
21
+ }
@@ -0,0 +1,51 @@
1
+ // macOS attributes a launchd background item to the code signer of the executable it runs.
2
+ // Running `node` directly makes System Settings say "Node.js Foundation". So the agent runs a
3
+ // tiny app bundle named ClaudeRipple instead: an ad-hoc signed launcher script that execs node
4
+ // with the router. Login Items and the background-activity notice then show "ClaudeRipple".
5
+ import { execFileSync } from "node:child_process";
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ export const BUNDLE_ID = "com.clauderipple.router";
9
+ export function bundlePath(home) {
10
+ return path.join(home, "ClaudeRipple.app");
11
+ }
12
+ export function launcherPath(home) {
13
+ return path.join(bundlePath(home), "Contents", "MacOS", "ClaudeRipple");
14
+ }
15
+ export function writeBundle(opts) {
16
+ const app = bundlePath(opts.home);
17
+ const macos = path.join(app, "Contents", "MacOS");
18
+ fs.mkdirSync(macos, { recursive: true });
19
+ fs.writeFileSync(path.join(app, "Contents", "Info.plist"), `<?xml version="1.0" encoding="UTF-8"?>
20
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
21
+ <plist version="1.0">
22
+ <dict>
23
+ <key>CFBundleName</key><string>ClaudeRipple</string>
24
+ <key>CFBundleDisplayName</key><string>ClaudeRipple</string>
25
+ <key>CFBundleIdentifier</key><string>${BUNDLE_ID}</string>
26
+ <key>CFBundleVersion</key><string>${opts.version}</string>
27
+ <key>CFBundleShortVersionString</key><string>${opts.version}</string>
28
+ <key>CFBundlePackageType</key><string>APPL</string>
29
+ <key>CFBundleExecutable</key><string>ClaudeRipple</string>
30
+ <key>LSUIElement</key><true/>
31
+ <key>LSBackgroundOnly</key><true/>
32
+ </dict>
33
+ </plist>
34
+ `);
35
+ const launcher = launcherPath(opts.home);
36
+ fs.writeFileSync(launcher, `#!/bin/sh
37
+ # ClaudeRipple router launcher (generated by \`clauderipple install\`).
38
+ export CLAUDERIPPLE_HOME="${opts.home}"
39
+ exec "${opts.node}" "${opts.script}"
40
+ `, { mode: 0o755 });
41
+ try {
42
+ execFileSync("codesign", ["--force", "--sign", "-", "--identifier", BUNDLE_ID, app], { stdio: "ignore" });
43
+ }
44
+ catch {
45
+ /* ad-hoc signing is best effort; the launcher still runs unsigned */
46
+ }
47
+ return launcher;
48
+ }
49
+ export function removeBundle(home) {
50
+ fs.rmSync(bundlePath(home), { recursive: true, force: true });
51
+ }
@@ -0,0 +1,33 @@
1
+ // Generate the local CA and the api.anthropic.com leaf certificate. Pure node:crypto (see
2
+ // ../../router/src/x509.ts) so this works wherever Node runs, including Windows, which ships no
3
+ // openssl. Nothing is installed in any trust store; the CA is handed to Node only, through
4
+ // NODE_EXTRA_CA_CERTS (picker mode additionally trusts it in the login keychain).
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import { createCa, createLeaf } from "../../router/src/x509.js";
8
+ export function certPaths(home) {
9
+ return {
10
+ caPem: path.join(home, "ca.pem"),
11
+ caKey: path.join(home, "ca.key"),
12
+ leafPem: path.join(home, "leaf.pem"),
13
+ leafKey: path.join(home, "leaf.key"),
14
+ };
15
+ }
16
+ export function generateCerts(home, upstream = "api.anthropic.com", days = 3650) {
17
+ fs.mkdirSync(home, { recursive: true, mode: 0o700 });
18
+ const p = certPaths(home);
19
+ const ca = createCa({ days });
20
+ fs.writeFileSync(p.caPem, ca.certPem);
21
+ fs.writeFileSync(p.caKey, ca.keyPem, { mode: 0o600 });
22
+ const leaf = createLeaf({ host: upstream, caCertPem: ca.certPem, caKeyPem: ca.keyPem, days: Math.min(days, 825) });
23
+ fs.writeFileSync(p.leafPem, leaf.certPem);
24
+ fs.writeFileSync(p.leafKey, leaf.keyPem, { mode: 0o600 });
25
+ // Pre-existing installs were written before the mode argument; make sure the keys are private.
26
+ for (const f of [p.caKey, p.leafKey])
27
+ fs.chmodSync(f, 0o600);
28
+ return p;
29
+ }
30
+ export function certsExist(home) {
31
+ const p = certPaths(home);
32
+ return [p.caPem, p.caKey, p.leafPem, p.leafKey].every((f) => fs.existsSync(f));
33
+ }