clauderipple 0.10.0 → 0.11.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 (78) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.es.md +45 -0
  3. package/README.ja.md +17 -0
  4. package/README.ko.md +31 -0
  5. package/README.md +39 -0
  6. package/README.pt-BR.md +45 -0
  7. package/README.zh-CN.md +17 -0
  8. package/dist/app/assets/icon.icns +0 -0
  9. package/dist/app/assets/icon.ico +0 -0
  10. package/dist/app/assets/icon.png +0 -0
  11. package/dist/app/assets/trayDirectTemplate.png +0 -0
  12. package/dist/app/assets/trayDirectTemplate@2x.png +0 -0
  13. package/dist/app/assets/trayDownTemplate.png +0 -0
  14. package/dist/app/assets/trayDownTemplate@2x.png +0 -0
  15. package/dist/app/assets/trayTemplate.png +0 -0
  16. package/dist/app/assets/trayTemplate@2x.png +0 -0
  17. package/dist/app/assets/trayWarnTemplate.png +0 -0
  18. package/dist/app/assets/trayWarnTemplate@2x.png +0 -0
  19. package/dist/app/assets/trayWin.png +0 -0
  20. package/dist/app/assets/trayWin@2x.png +0 -0
  21. package/dist/app/assets/trayWinDirect.png +0 -0
  22. package/dist/app/assets/trayWinDirect@2x.png +0 -0
  23. package/dist/app/assets/trayWinDown.png +0 -0
  24. package/dist/app/assets/trayWinDown@2x.png +0 -0
  25. package/dist/app/assets/trayWinWarn.png +0 -0
  26. package/dist/app/assets/trayWinWarn@2x.png +0 -0
  27. package/dist/app/dist/claude-desktop.js +67 -0
  28. package/dist/app/dist/main.js +423 -110
  29. package/dist/app/dist/preload.js +21 -0
  30. package/dist/app/dist/protocol.js +121 -0
  31. package/dist/app/dist/status-model.js +87 -0
  32. package/dist/app/dist/window.js +133 -0
  33. package/dist/app/package.json +1 -1
  34. package/dist/cli/src/awake-setup.js +314 -0
  35. package/dist/cli/src/bundle.js +9 -5
  36. package/dist/cli/src/direct.js +159 -0
  37. package/dist/cli/src/doctor.js +205 -0
  38. package/dist/cli/src/index.js +184 -31
  39. package/dist/cli/src/launchd.js +29 -20
  40. package/dist/cli/src/launcher.js +215 -0
  41. package/dist/cli/src/power.js +182 -0
  42. package/dist/cli/src/registration.js +157 -0
  43. package/dist/cli/src/schtasks.js +2 -0
  44. package/dist/cli/src/settings.js +24 -0
  45. package/dist/cli/src/tray.js +5 -2
  46. package/dist/mod/hooks/i18n.ts +237 -237
  47. package/dist/mod/hooks/register.tsx +4 -5
  48. package/dist/mod/hooks/session.ts +6 -2
  49. package/dist/mod/hooks/ui/agents.tsx +81 -36
  50. package/dist/mod/hooks/ui/view.ts +8 -5
  51. package/dist/mod/tests/ui.test.tsx +46 -13
  52. package/dist/mod/types/index.d.ts +0 -2
  53. package/dist/remote/src/bridge-client.js +45 -4
  54. package/dist/router/src/admin.js +105 -2
  55. package/dist/router/src/awake-win.js +590 -0
  56. package/dist/router/src/awake.js +432 -0
  57. package/dist/router/src/config.js +64 -0
  58. package/dist/router/src/health.js +86 -3
  59. package/dist/router/src/hold.js +107 -0
  60. package/dist/router/src/index.js +69 -6
  61. package/dist/router/src/ingress/server.js +26 -4
  62. package/dist/router/src/power.js +107 -0
  63. package/dist/router/src/providers/google/antigravity.js +73 -10
  64. package/dist/router/src/providers/google/index.js +168 -17
  65. package/dist/router/src/providers/google/thoughtSignatures.js +13 -0
  66. package/dist/router/src/providers/google/transport.js +1 -1
  67. package/dist/router/src/providers/google/usage.js +71 -26
  68. package/dist/router/src/providers/retry.js +4 -1
  69. package/dist/router/src/proxy.js +50 -21
  70. package/dist/router/src/tether-win.js +211 -0
  71. package/dist/router/src/tether.js +712 -0
  72. package/dist/router/src/version.js +1 -1
  73. package/dist/ui/app.js +649 -2
  74. package/dist/ui/i18n.js +494 -77
  75. package/dist/ui/index.html +9 -1
  76. package/dist/ui/style.css +138 -0
  77. package/docs/ARCHITECTURE.md +294 -6
  78. package/package.json +2 -2
@@ -1,7 +1,12 @@
1
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.
2
+ // ClaudeRipple menu-bar app. The router owns the logic and the admin API on 127.0.0.1; this shell
3
+ // adds a tray icon with live health, the dashboard window, and shortcuts for restart / logs / login.
4
+ //
5
+ // The window draws the dashboard from the app's own copy of packages/ui (protocol.ts) and only asks
6
+ // the router for data, so it opens with the router dead — which is when it is needed: on
7
+ // 2026-10-20 a broken launch entry took the router down, and the router-served dashboard with it,
8
+ // and nothing was left to fix it from. A preload bridge lets the dashboard repair the router or
9
+ // switch Claude to its default connection through the CLI (`doctor` / `repair` / `direct`).
5
10
  var __importDefault = (this && this.__importDefault) || function (mod) {
6
11
  return (mod && mod.__esModule) ? mod : { "default": mod };
7
12
  };
@@ -11,6 +16,10 @@ const node_child_process_1 = require("node:child_process");
11
16
  const node_fs_1 = __importDefault(require("node:fs"));
12
17
  const node_os_1 = __importDefault(require("node:os"));
13
18
  const node_path_1 = __importDefault(require("node:path"));
19
+ const claude_desktop_1 = require("./claude-desktop");
20
+ const protocol_1 = require("./protocol");
21
+ const status_model_1 = require("./status-model");
22
+ const window_1 = require("./window");
14
23
  const home = process.env.CLAUDERIPPLE_HOME ?? node_path_1.default.join(node_os_1.default.homedir(), ".clauderipple");
15
24
  const POLL_MS = 5000;
16
25
  // ---- i18n: tray menu, dialogs, tooltips -------------------------------------------------
@@ -65,11 +74,17 @@ const STRINGS = {
65
74
  proxyLine: (host, port, slots, flight) => `Proxy ${host}:${port} · ${slots} · ${flight}`,
66
75
  startAtLogin: "Start at login",
67
76
  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
77
  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.",
78
+ open: "Open ClaudeRipple…",
79
+ directHeadline: "Claude's default connection (Ripple off)",
80
+ tooltipDirect: "ClaudeRipple: using Claude's default connection",
81
+ notifyDownTitle: "Ripple is not responding",
82
+ notifyDownBody: "Claude cannot reach AI right now. Repair Ripple, or switch to Claude's default connection.",
83
+ actRepair: "Repair",
84
+ actDirectOn: "Use Claude's default connection",
85
+ restartConfirm: "Quit and reopen the Claude app?",
86
+ restartConfirmDetail: "Open conversations and any work in progress in the Claude app will stop.",
87
+ restartNow: "Reopen",
73
88
  setupNeeded: "setup not finished",
74
89
  setupNeededDetail: "ClaudeRipple is installed but not set up yet",
75
90
  runSetup: "Finish setting up ClaudeRipple…",
@@ -94,78 +109,84 @@ const STRINGS = {
94
109
  ko: {
95
110
  healthy: "정상",
96
111
  attentionNeeded: "확인 필요",
97
- routerNotRunning: "라우터가 꺼져 있음",
98
- slotsMapped: (n) => `슬롯 ${n}개 매핑`,
112
+ routerNotRunning: "리플 꺼짐",
113
+ slotsMapped: (n) => `슬롯 ${n}개 연결`,
99
114
  inFlight: "진행 중",
100
115
  connected: "Claude Desktop 연결됨",
101
116
  notConnected: "Claude Desktop 연결 안 됨",
102
- percentOfWeekUsed: (percent, plan) => `ChatGPT ${plan} 주간 ${percent}%`,
117
+ percentOfWeekUsed: (percent, plan) => `ChatGPT ${plan} 이번 주 ${percent}% 사용`,
103
118
  cliVersion: (v) => `Claude Code CLI ${v}`,
104
- restartRouter: "라우터 재시작",
105
- startRouter: "라우터 시작",
106
- pickerOn: "Code 탭 피커에 GPT 모델 이름 표시…",
107
- pickerOff: "피커의 GPT 모델 표시 끄기…",
108
- pickerState: (on) => (on ? "피커: GPT 모델을 실명으로 표시 중 ✓" : "피커: Claude 이름으로 GPT 사용(별칭 모드)"),
109
- pickerOnConfirm: "ClaudeRipple 인증서를 신뢰하기 위해 macOS가 로그인 암호를 묻습니다(ClaudeRipple은 암호를 보지 않습니다). 끝나면 Claude Desktop을 완전히 종료했다가 다시 여세요.",
110
- pickerDone: "완료. 이제 Claude Desktop을 완전히 종료한 뒤 다시 열고 Code 탭 피커를 확인하세요.",
111
- pickerOffDone: "완료. Claude Desktop을 종료했다가 다시 열면 적용됩니다.",
119
+ restartRouter: "리플 재시작",
120
+ startRouter: "리플 시작",
121
+ pickerOn: "Code 탭 피커에 GPT 모델 표시…",
122
+ pickerOff: "피커에서 GPT 모델 숨기기…",
123
+ pickerState: (on) => (on ? "피커: GPT 모델 이름 표시 중 ✓" : "피커: Claude 이름으로 GPT 사용 (별칭 모드)"),
124
+ pickerOnConfirm: "ClaudeRipple 인증서를 신뢰하려면 macOS 로그인 암호를 입력해야 합니다(ClaudeRipple은 암호를 보지 않습니다). 완료 후 Claude Desktop을 완전히 종료했다가 다시 열어 주십시오.",
125
+ pickerDone: "완료되었습니다. Claude Desktop을 완전히 종료했다가 다시 연 뒤 Code 탭 피커를 확인해 주십시오.",
126
+ pickerOffDone: "완료되었습니다. Claude Desktop을 완전히 종료했다가 다시 열면 적용됩니다.",
112
127
  cancel: "취소",
113
128
  agentTitleOn: "서브에이전트에 모델 이름 표시",
114
- agentTitleOff: "서브에이전트 모델 이름 표시 끄기",
115
- agentTitleDone: (on) => (on ? "켰습니다. 새로 뜨는 서브에이전트 제목이 \"Terra·high · …\"처럼 보입니다." : "껐습니다."),
129
+ agentTitleOff: "서브에이전트 모델 이름 숨기기",
130
+ agentTitleDone: (on) => (on ? "켰습니다. 새로 시작하는 서브에이전트 제목이 \"Terra·high · …\"처럼 표시됩니다." : "껐습니다."),
116
131
  signInChatgpt: "ChatGPT 로그인…",
117
132
  connectClaudeSubscription: "Claude 구독 연결…",
118
133
  copyStatus: "상태 복사",
119
134
  showLogs: "로그 보기",
120
135
  rerunSetup: "ClaudeRipple 설정 다시 실행…",
121
- setupTitle: "ClaudeRipple을 설정합니다",
122
- setupPrompt: "로컬 인증서 생성, Claude Code 설정(~/.claude/settings.json) 연결, 백그라운드 라우터 등록을 진행합니다. 계속할까요?",
136
+ setupTitle: "ClaudeRipple 설정",
137
+ setupPrompt: "로컬 인증서를 만들고, Claude Code 설정(~/.claude/settings.json)을 연결하고, 리플이 백그라운드에서 돌도록 등록합니다. 계속하시겠습니까?",
123
138
  setupLater: "나중에",
124
139
  setupContinue: "계속",
125
140
  setupDone: "ClaudeRipple 설정이 완료되었습니다.",
126
- setupFailed: "설정을 마치지 못했습니다",
141
+ setupFailed: "ClaudeRipple 설정을 완료하지 못했습니다",
127
142
  updatedTitle: (v) => `ClaudeRipple ${v}`,
128
- updatedRestarting: (from) => `지금 떠 있는 라우터는 ${from}입니다. 이 버전으로 바꿉니다…`,
129
- updatedFailed: "라우터를 이 버전으로 바꾸지 못했습니다",
130
- problem: (code) => code === "settings" ? "Claude Code가 ClaudeRipple을 거치지 않음"
131
- : code === "upstream" ? "Anthropic에 연결 안 됨"
132
- : code === "picker-ca" ? "피커 모드: 인증서 미신뢰"
133
- : code === "picker-proxy" ? "피커 모드: Claude Desktop 프록시 미설정"
143
+ updatedRestarting: (from) => `현재 실행 중인 리플은 ${from} 버전입니다. 새 버전으로 전환합니다…`,
144
+ updatedFailed: "리플을 새 버전으로 전환하지 못했습니다",
145
+ problem: (code) => code === "settings" ? "Claude Code가 ClaudeRipple에 연결되지 않음"
146
+ : code === "upstream" ? "Anthropic 연결 안 됨"
147
+ : code === "picker-ca" ? "피커 모드: 인증서 신뢰 필요"
148
+ : code === "picker-proxy" ? "피커 모드: Claude Desktop 프록시 설정 필요"
134
149
  : code.startsWith("provider:") ? `프로바이더 ${code.slice(9)} 연결 안 됨`
135
150
  : code,
136
151
  about: "ClaudeRipple 정보",
137
- aboutDetail: "Claude Desktop을 끄지 않고 그 안에서 GPT 등 다른 모델을 씁니다.\n\n독립 오픈소스 프로젝트(GPL-3.0)이며 Anthropic·OpenAI와 제휴·보증·후원 관계가 없습니다. Claude와 Claude Code는 Anthropic, PBC의 상표입니다.",
152
+ aboutDetail: "Claude Desktop을 끄지 않고 그 안에서 GPT 등 다양한 모델을 함께 씁니다.\n\n독립 오픈소스 프로젝트(GPL-3.0)이며 Anthropic·OpenAI와 제휴·보증·후원 관계가 없습니다. Claude와 Claude Code는 Anthropic, PBC의 상표입니다.",
138
153
  quit: "종료",
139
154
  tooltip: (state) => `ClaudeRipple ${state}`,
140
- tooltipDown: "ClaudeRipple: 라우터가 꺼져 있어 Claude Desktop이 연결되지 않습니다",
155
+ tooltipDown: "ClaudeRipple: 리플이 꺼져 있어 Claude Desktop이 연결되지 않습니다",
141
156
  proxyLine: (host, port, slots, flight) => `프록시 ${host}:${port} · ${slots} · ${flight}`,
142
157
  startAtLogin: "로그인 시 자동 시작",
143
158
  downNotifyTitle: "Claude Desktop이 연결되지 않습니다",
144
- downNotifyBody: "ClaudeRipple 라우터가 꺼져 있어 Claude Desktop이 밖으로 나가지 못합니다. 눌러서 시작하세요.",
145
- starting: "라우터를 시작하는 중…",
146
- openDashboard: "브라우저에서 대시보드 열기…",
147
- offlineHeading: "라우터가 꺼져 있습니다",
148
- offlineBody: "Claude Desktop은 모든 통신을 ClaudeRipple로 보냅니다. 그래서 라우터가 꺼져 있는 동안에는 앱이 흰 화면과 ERR_PROXY_CONNECTION_FAILED만 보여줍니다. 라우터를 시작한 뒤 Claude Desktop을 새로고침하세요.",
149
- setupNeeded: "설정이 끝나지 않았습니다",
150
- setupNeededDetail: "설치는 됐지만 아직 설정하지 않았습니다",
151
- runSetup: "ClaudeRipple 설정 마치기…",
159
+ starting: "리플을 시작하는 중…",
160
+ open: "ClaudeRipple 열기…",
161
+ directHeadline: "Claude 기본 연결 사용 중 (리플 꺼짐)",
162
+ tooltipDirect: "ClaudeRipple: Claude 기본 연결 사용 중",
163
+ notifyDownTitle: "리플이 응답하지 않습니다",
164
+ notifyDownBody: "지금 Claude가 AI에 연결되지 않습니다. 리플을 복구하거나 Claude 기본 연결로 전환할 수 있습니다.",
165
+ actRepair: "복구",
166
+ actDirectOn: "Claude 기본 연결로 전환",
167
+ restartConfirm: "Claude 앱을 종료했다가 다시 여시겠습니까?",
168
+ restartConfirmDetail: "Claude 앱의 열린 대화와 진행 중인 작업이 중단됩니다.",
169
+ restartNow: "다시 열기",
170
+ setupNeeded: "설정 필요",
171
+ setupNeededDetail: "ClaudeRipple이 설치되었지만 아직 설정이 완료되지 않았습니다.",
172
+ runSetup: "ClaudeRipple 설정 완료하기…",
152
173
  claudeCurrent: "현재 로그인",
153
174
  claudeUsage: (label, windows) => `Claude ${label} · ${windows}`,
154
175
  window5h: "5시간",
155
176
  windowWeek: "주간",
156
177
  checkUpdates: "업데이트 확인…",
157
- updateAvailableItem: (v) => `새 버전 업데이트: ClaudeRipple ${v}…`,
158
- upToDate: (v) => `최신 버전입니다: ClaudeRipple ${v}`,
159
- updateAvailable: (latest, current) => `새 버전이 나왔습니다: ClaudeRipple ${latest} (지금 ${current})`,
160
- updateDetail: "진행 중인 요청이 끝나면 라우터가 새 버전으로 다시 시작합니다. 트레이는 저절로 다시 열립니다.",
178
+ updateAvailableItem: (v) => `ClaudeRipple ${v} 버전으로 업데이트…`,
179
+ upToDate: (v) => `최신 버전입니다 (ClaudeRipple ${v})`,
180
+ updateAvailable: (latest, current) => `새 버전이 나왔습니다: ClaudeRipple ${latest} (현재 ${current})`,
181
+ updateDetail: "진행 중인 요청이 끝나면 리플이 새 버전으로 다시 시작됩니다. 트레이 메뉴는 자동으로 다시 열립니다.",
161
182
  updateNow: "업데이트",
162
183
  later: "나중에",
163
184
  updating: (v) => `ClaudeRipple ${v} 업데이트 중…`,
164
185
  updateDone: (v) => `ClaudeRipple ${v} 업데이트 완료`,
165
- updateFailed: "업데이트를 마치지 못했습니다",
166
- updateCheckFailed: "업데이트를 확인하지 못했습니다",
167
- updatePackaged: "독립 실행 앱입니다. 릴리스 페이지에서 새 버전을 받으세요.",
168
- updateCheckout: "소스 체크아웃입니다. git pull로 업데이트하세요.",
186
+ updateFailed: "업데이트를 완료하지 못했습니다",
187
+ updateCheckFailed: "업데이트 정보를 확인하지 못했습니다",
188
+ updatePackaged: "설치형 앱입니다. 릴리스 페이지에서 최신 버전을 다운로드해 주십시오.",
189
+ updateCheckout: "소스 코드로 실행 중입니다. git pull 명령으로 업데이트해 주십시오.",
169
190
  },
170
191
  zh: {
171
192
  healthy: "运行正常",
@@ -217,11 +238,17 @@ const STRINGS = {
217
238
  proxyLine: (host, port, slots, flight) => `代理 ${host}:${port} · ${slots} · ${flight}`,
218
239
  startAtLogin: "登录时启动",
219
240
  downNotifyTitle: "Claude Desktop 无法连接",
220
- downNotifyBody: "ClaudeRipple 路由服务未运行,因此 Claude Desktop 无法向外发送请求。点击即可启动路由服务。",
221
241
  starting: "正在启动路由服务…",
222
- openDashboard: "在浏览器中打开控制面板…",
223
- offlineHeading: "路由服务未运行",
224
- offlineBody: "Claude Desktop 的所有流量都经过 ClaudeRipple,因此路由服务停止期间,应用会显示空白窗口并报 ERR_PROXY_CONNECTION_FAILED。请启动路由服务,然后重新加载 Claude Desktop。",
242
+ open: "打开 ClaudeRipple…",
243
+ directHeadline: "正在使用 Claude 默认连接(Ripple 已关闭)",
244
+ tooltipDirect: "ClaudeRipple:正在使用 Claude 默认连接",
245
+ notifyDownTitle: "Ripple 没有响应",
246
+ notifyDownBody: "Claude 现在无法连接 AI。可以修复 Ripple,或切换到 Claude 默认连接。",
247
+ actRepair: "修复",
248
+ actDirectOn: "切换到 Claude 默认连接",
249
+ restartConfirm: "要退出并重新打开 Claude 应用吗?",
250
+ restartConfirmDetail: "Claude 应用中打开的对话和进行中的工作会停止。",
251
+ restartNow: "重新打开",
225
252
  setupNeeded: "设置未完成",
226
253
  setupNeededDetail: "ClaudeRipple 已安装,但尚未完成设置",
227
254
  runSetup: "完成 ClaudeRipple 设置…",
@@ -293,11 +320,17 @@ const STRINGS = {
293
320
  proxyLine: (host, port, slots, flight) => `プロキシ ${host}:${port} · ${slots} · ${flight}`,
294
321
  startAtLogin: "ログイン時に起動",
295
322
  downNotifyTitle: "Claude Desktopが接続できません",
296
- downNotifyBody: "ClaudeRippleのルーターが停止しているため、Claude Desktopは外部と通信できません。クリックして起動してください。",
297
323
  starting: "ルーターを起動中…",
298
- openDashboard: "ブラウザでダッシュボードを開く…",
299
- offlineHeading: "ルーターが停止しています",
300
- offlineBody: "Claude Desktopはすべての通信をClaudeRipple経由で送るため、ルーターが停止している間はERR_PROXY_CONNECTION_FAILEDと表示された空白のウィンドウになります。ルーターを起動してから、Claude Desktopを再読み込みしてください。",
324
+ open: "ClaudeRippleを開く…",
325
+ directHeadline: "Claude の標準接続を使用中(Ripple はオフ)",
326
+ tooltipDirect: "ClaudeRipple:Claude の標準接続を使用中",
327
+ notifyDownTitle: "Ripple が応答しません",
328
+ notifyDownBody: "現在 Claude は AI に接続できません。Ripple を修復するか、Claude の標準接続に切り替えられます。",
329
+ actRepair: "修復",
330
+ actDirectOn: "Claude の標準接続に切り替え",
331
+ restartConfirm: "Claude アプリを終了して開き直しますか?",
332
+ restartConfirmDetail: "Claude アプリで開いている会話や進行中の作業は止まります。",
333
+ restartNow: "開き直す",
301
334
  setupNeeded: "セットアップ未完了",
302
335
  setupNeededDetail: "ClaudeRippleはインストール済みですが、まだセットアップされていません",
303
336
  runSetup: "ClaudeRippleのセットアップを完了…",
@@ -369,11 +402,17 @@ const STRINGS = {
369
402
  proxyLine: (host, port, slots, flight) => `Proxy ${host}:${port} · ${slots} · ${flight}`,
370
403
  startAtLogin: "Abrir al iniciar sesión",
371
404
  downNotifyTitle: "Claude Desktop no puede conectarse",
372
- downNotifyBody: "El router de ClaudeRipple no está en ejecución, así que Claude Desktop no puede conectarse. Haz clic para iniciarlo.",
373
405
  starting: "iniciando el router…",
374
- openDashboard: "Abrir el panel en el navegador…",
375
- offlineHeading: "El router no está en ejecución",
376
- offlineBody: "Claude Desktop envía todo su tráfico a través de ClaudeRipple, así que mientras el router esté detenido la app muestra una ventana en blanco con ERR_PROXY_CONNECTION_FAILED. Inicia el router y recarga Claude Desktop.",
406
+ open: "Abrir ClaudeRipple…",
407
+ directHeadline: "Conexión predeterminada de Claude (Ripple apagado)",
408
+ tooltipDirect: "ClaudeRipple: usando la conexión predeterminada de Claude",
409
+ notifyDownTitle: "Ripple no responde",
410
+ notifyDownBody: "Claude no puede conectarse a la IA ahora mismo. Repara Ripple o usa la conexión predeterminada de Claude.",
411
+ actRepair: "Reparar",
412
+ actDirectOn: "Usar la conexión predeterminada de Claude",
413
+ restartConfirm: "¿Cerrar y volver a abrir la app de Claude?",
414
+ restartConfirmDetail: "Las conversaciones abiertas y el trabajo en curso en la app de Claude se detendrán.",
415
+ restartNow: "Reabrir",
377
416
  setupNeeded: "configuración pendiente",
378
417
  setupNeededDetail: "ClaudeRipple está instalado, pero aún no está configurado",
379
418
  runSetup: "Terminar de configurar ClaudeRipple…",
@@ -445,11 +484,17 @@ const STRINGS = {
445
484
  proxyLine: (host, port, slots, flight) => `Proxy ${host}:${port} · ${slots} · ${flight}`,
446
485
  startAtLogin: "Iniciar ao fazer login",
447
486
  downNotifyTitle: "O Claude Desktop não consegue se conectar",
448
- downNotifyBody: "O roteador do ClaudeRipple não está rodando, então o Claude Desktop não consegue acessar a rede. Clique para iniciá-lo.",
449
487
  starting: "iniciando o roteador…",
450
- openDashboard: "Abrir painel no navegador…",
451
- offlineHeading: "O roteador não está rodando",
452
- offlineBody: "O Claude Desktop envia todo o tráfego pelo ClaudeRipple, então, enquanto o roteador estiver parado, o app mostra uma janela em branco com ERR_PROXY_CONNECTION_FAILED. Inicie o roteador e recarregue o Claude Desktop.",
488
+ open: "Abrir o ClaudeRipple…",
489
+ directHeadline: "Conexão padrão do Claude (Ripple desligado)",
490
+ tooltipDirect: "ClaudeRipple: usando a conexão padrão do Claude",
491
+ notifyDownTitle: "O Ripple não está respondendo",
492
+ notifyDownBody: "O Claude não consegue acessar a IA agora. Repare o Ripple ou use a conexão padrão do Claude.",
493
+ actRepair: "Reparar",
494
+ actDirectOn: "Usar a conexão padrão do Claude",
495
+ restartConfirm: "Encerrar e reabrir o app do Claude?",
496
+ restartConfirmDetail: "As conversas abertas e o trabalho em andamento no app do Claude vão parar.",
497
+ restartNow: "Reabrir",
453
498
  setupNeeded: "configuração não concluída",
454
499
  setupNeededDetail: "O ClaudeRipple está instalado, mas ainda não foi configurado",
455
500
  runSetup: "Concluir a configuração do ClaudeRipple…",
@@ -489,6 +534,8 @@ let lastError = null;
489
534
  let starting = false;
490
535
  let downSince = null;
491
536
  let downNotified = false;
537
+ /** Whether the last health poll got an answer; null before the first poll. */
538
+ let answered = null;
492
539
  // Long enough that a router still coming up after login does not fire a notification.
493
540
  const DOWN_NOTIFY_AFTER_MS = 20_000;
494
541
  // ---- login item ------------------------------------------------------------------------
@@ -539,7 +586,16 @@ function setLoginItem(on) {
539
586
  }
540
587
  writeAppState({ loginItemInitialized: true });
541
588
  }
589
+ // Overrides for running this app against stand-ins instead of the real installation (tests, and
590
+ // trying a change without touching the router someone depends on):
591
+ // CLAUDERIPPLE_APP_ADMIN_URL the admin API base the health poll and the dashboard use
592
+ // CLAUDERIPPLE_APP_CLI a CLI script to run instead of the installed one
593
+ // CLAUDERIPPLE_APP_USER_DATA Electron's data folder, which also keys the one-tray lock
594
+ // CLAUDERIPPLE_HOME (logs, app.json, config.json) is honoured as everywhere else.
542
595
  function adminUrl() {
596
+ const override = process.env.CLAUDERIPPLE_APP_ADMIN_URL;
597
+ if (override)
598
+ return override.endsWith("/") ? override : `${override}/`;
543
599
  return `http://127.0.0.1:${readConfigPorts().admin}/`;
544
600
  }
545
601
  /**
@@ -554,11 +610,8 @@ function uiLang() {
554
610
  const base = first.toLowerCase().split(/[-_]/)[0];
555
611
  return base in STRINGS ? base : "en";
556
612
  }
557
- /** The GUI follows the OS language by default; a language the user picked in the GUI itself wins (it is stored in the page). */
558
- function guiUrl() {
559
- return `${adminUrl()}?lang=${uiLang()}`;
560
- }
561
613
  async function poll() {
614
+ const before = answered;
562
615
  try {
563
616
  const res = await fetch(`${adminUrl()}api/status`, { signal: AbortSignal.timeout(3000) });
564
617
  if (!res.ok)
@@ -567,27 +620,57 @@ async function poll() {
567
620
  lastError = null;
568
621
  downSince = null;
569
622
  downNotified = false;
623
+ answered = true;
570
624
  void reconcileRouter(last);
571
625
  }
572
626
  catch (e) {
573
627
  last = null;
574
628
  lastError = e.message;
629
+ answered = false;
575
630
  downSince ??= Date.now();
576
- // An update restarts the router on purpose; that is not the outage the notification is for.
577
- if (!downNotified && !updating && Date.now() - downSince >= DOWN_NOTIFY_AFTER_MS) {
631
+ // An update or one of the window's own actions restarts the router on purpose; that is not
632
+ // the outage the notification is for. Neither is the direct mode, where the router being off
633
+ // is the point — so doctor is asked first, and once per outage. With the window on screen the
634
+ // user is already looking at the same news.
635
+ if ((0, status_model_1.shouldNotifyDown)({ downForMs: Date.now() - downSince, graceMs: DOWN_NOTIFY_AFTER_MS, alreadyNotified: downNotified, updating, busy: !!rec.busy || (0, window_1.windowVisible)(), mode: rec.doctor?.mode ?? null })) {
578
636
  downNotified = true;
579
- notifyDown();
637
+ void refreshDoctor().then(() => {
638
+ if (!last && rec.doctor?.mode !== "direct")
639
+ notifyDown();
640
+ });
580
641
  }
581
642
  }
643
+ // Up or down changed: what doctor said about the rest (direct, mixed) may have changed with it.
644
+ if (before !== null && before !== answered && !rec.busy)
645
+ void refreshDoctor();
582
646
  render();
583
647
  }
584
- /** The router being down means Claude Desktop is dead in the water, so say so out loud. */
648
+ // Held so the handlers are not collected with it before the user gets to the notification.
649
+ let downNotification = null;
650
+ /** The router being down means Claude is silent, so say so out loud, with the two ways out. */
585
651
  function notifyDown() {
586
- if (!electron_1.Notification.isSupported())
652
+ if (!electron_1.Notification.isSupported()) {
653
+ // Nothing else would tell the user; the window does.
654
+ showWindow("health");
587
655
  return;
588
- const n = new electron_1.Notification({ title: L.downNotifyTitle, body: L.downNotifyBody });
589
- n.on("click", () => void startRouter());
656
+ }
657
+ const n = new electron_1.Notification({
658
+ title: L.notifyDownTitle,
659
+ body: L.notifyDownBody,
660
+ icon: notifyIcon(),
661
+ // macOS shows these as buttons on the notification (an Options menu when there are two).
662
+ actions: [
663
+ { type: "button", text: L.actRepair },
664
+ { type: "button", text: L.actDirectOn },
665
+ ],
666
+ });
667
+ n.on("click", () => showWindow("health"));
668
+ n.on("action", (_event, index) => {
669
+ showWindow("health");
670
+ void act(index === 0 ? "repair" : "direct-on");
671
+ });
590
672
  n.show();
673
+ downNotification = n;
591
674
  }
592
675
  /** Idempotent: `clauderipple start` leaves a router that is already coming up alone. */
593
676
  async function startRouter() {
@@ -618,33 +701,28 @@ const isMac = process.platform === "darwin";
618
701
  * state read as a colour rather than only a shape.
619
702
  */
620
703
  function icon(state) {
621
- const mac = { ok: "trayTemplate.png", warn: "trayWarnTemplate.png", down: "trayDownTemplate.png" };
622
- const win = { ok: "trayWin.png", warn: "trayWinWarn.png", down: "trayWinDown.png" };
704
+ const mac = { ok: "trayTemplate.png", warn: "trayWarnTemplate.png", down: "trayDownTemplate.png", direct: "trayDirectTemplate.png" };
705
+ const win = { ok: "trayWin.png", warn: "trayWinWarn.png", down: "trayWinDown.png", direct: "trayWinDirect.png" };
623
706
  const img = electron_1.nativeImage.createFromPath(node_path_1.default.join(__dirname, "..", "assets", (isMac ? mac : win)[state]));
624
707
  if (isMac)
625
708
  img.setTemplateImage(true);
626
709
  return img;
627
710
  }
711
+ /** The app icon on notifications: macOS uses the bundle's own; elsewhere Electron would show its default. */
712
+ function notifyIcon() {
713
+ return isMac ? undefined : node_path_1.default.join(__dirname, "..", "assets", "icon.png");
714
+ }
628
715
  /**
629
- * The dashboard is a web page served by the router, so it opens in the user's own browser. With
630
- * the router down there is nothing to open: start it (or run setup, when that is what is missing)
631
- * and open the page once it answers, rather than sending the browser to a refused connection.
716
+ * The dashboard window. It opens with the router down too (its page is the app's own), and then on
717
+ * Status, where the way back is. Before setup there is no router to ask anything of, so the step
718
+ * that is actually missing comes first.
632
719
  */
633
720
  async function openDashboard() {
634
- if (last) {
635
- void electron_1.shell.openExternal(guiUrl());
636
- return;
637
- }
638
- // Before setup there is no router to start, so offer the step that is actually missing.
639
- const message = needsSetup() ? (await setup(), "") : await startRouter();
640
- // startRouter() returns once the CLI has finished; ask the router itself rather than waiting
641
- // for the next tick, since the user is standing in front of the menu.
642
- await poll();
643
- if (last) {
644
- void electron_1.shell.openExternal(guiUrl());
645
- return;
721
+ if (!last && needsSetup()) {
722
+ await setup();
723
+ await poll();
646
724
  }
647
- await electron_1.dialog.showMessageBox({ type: "warning", message: L.offlineHeading, detail: [L.offlineBody, message].filter(Boolean).join("\n\n") });
725
+ showWindow(last ? undefined : "health");
648
726
  }
649
727
  // ---- update handling -------------------------------------------------------------------
650
728
  // Installing a new version replaces files, not the process: the supervisor started the router at
@@ -690,7 +768,7 @@ async function reconcileRouter(s) {
690
768
  reconciled = true;
691
769
  const from = `${s.version}${s.runtime?.router ? ` (${s.runtime.router})` : ""}`;
692
770
  if (electron_1.Notification.isSupported())
693
- new electron_1.Notification({ title: L.updatedTitle(electron_1.app.getVersion()), body: L.updatedRestarting(from) }).show();
771
+ new electron_1.Notification({ title: L.updatedTitle(electron_1.app.getVersion()), body: L.updatedRestarting(from), icon: notifyIcon() }).show();
694
772
  const outputs = [];
695
773
  if (installedElsewhere || runningElsewhere)
696
774
  outputs.push(await runCli(["install"]));
@@ -721,6 +799,9 @@ function packagedRuntime() {
721
799
  }
722
800
  /** Prefer this app's bundled Electron runtime; existing installations retain their recorded runtime. */
723
801
  function cliPaths() {
802
+ const override = process.env.CLAUDERIPPLE_APP_CLI;
803
+ if (override)
804
+ return { node: process.execPath, env: { ELECTRON_RUN_AS_NODE: "1" }, cli: override };
724
805
  const packaged = packagedRuntime();
725
806
  if (packaged && node_fs_1.default.existsSync(packaged.cli))
726
807
  return packaged;
@@ -743,15 +824,204 @@ function cliPaths() {
743
824
  function runCli(args) {
744
825
  return runCliResult(args).then((r) => r.output);
745
826
  }
746
- function runCliResult(args) {
827
+ /**
828
+ * Runs the CLI without blocking anything. `stdout` is kept apart for the `--json` commands, whose
829
+ * one object must not be mixed with warnings on stderr. With `timeoutMs` a CLI that hangs (a
830
+ * supervisor that never answers) is ended rather than left holding a button down.
831
+ */
832
+ function runCliResult(args, timeoutMs = 0) {
747
833
  const { node, env, cli } = cliPaths();
748
834
  return new Promise((resolve) => {
749
835
  // An update prints npm's and the installer's own lines; the default 1MB buffer is not for that.
750
- (0, node_child_process_1.execFile)(node, [cli, ...args], { env: { ...process.env, ...env, CLAUDERIPPLE_HOME: home }, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {
751
- resolve({ ok: !err, output: `${stdout}${stderr}${err ? `\n${err.message}` : ""}`.trim() });
836
+ (0, node_child_process_1.execFile)(node, [cli, ...args], { env: { ...process.env, ...env, CLAUDERIPPLE_HOME: home }, maxBuffer: 16 * 1024 * 1024, timeout: timeoutMs, windowsHide: true }, (err, stdout, stderr) => {
837
+ const timedOut = !!err && timeoutMs > 0 && err.killed === true;
838
+ resolve({ ok: !err, output: `${stdout}${stderr}${err ? `\n${err.message}` : ""}`.trim(), stdout: String(stdout), timedOut });
752
839
  });
753
840
  });
754
841
  }
842
+ // ---- recovery: doctor, repair, direct -----------------------------------------------------
843
+ // The CLI does the work and answers in JSON; this keeps what it last said and what is running,
844
+ // and hands both to the dashboard (window.rippleApp) to draw its connection card.
845
+ const ACTION_TIMEOUT_MS = 40_000;
846
+ const DOCTOR_TIMEOUT_MS = 15_000;
847
+ /** Background check, mainly to notice the direct mode while the router is still answering. */
848
+ const DOCTOR_EVERY_MS = 5 * 60_000;
849
+ /** While the window is open it should not show something a minute old. */
850
+ const DOCTOR_WHILE_OPEN_MS = 10_000;
851
+ const LOG_LINES = 40;
852
+ const rec = {
853
+ doctor: null,
854
+ doctorFailed: false,
855
+ busy: null,
856
+ result: null,
857
+ restartClaude: false,
858
+ };
859
+ let doctorRun = null;
860
+ /** One `doctor` at a time; callers that arrive while one runs share it. */
861
+ function refreshDoctor() {
862
+ doctorRun ??= (async () => {
863
+ const r = await runCliResult(["doctor", "--json"], DOCTOR_TIMEOUT_MS);
864
+ const d = (0, status_model_1.parseJsonOutput)(r.stdout);
865
+ rec.doctor = d;
866
+ rec.doctorFailed = !d;
867
+ })().finally(() => {
868
+ doctorRun = null;
869
+ render();
870
+ });
871
+ return doctorRun;
872
+ }
873
+ /** The end of router.log, read from disk: it is there to look at precisely when the router is not. */
874
+ function logTail() {
875
+ const file = node_path_1.default.join(home, "logs", "router.log");
876
+ try {
877
+ const size = node_fs_1.default.statSync(file).size;
878
+ const length = Math.min(size, 16 * 1024);
879
+ const fd = node_fs_1.default.openSync(file, "r");
880
+ try {
881
+ const buf = Buffer.alloc(length);
882
+ node_fs_1.default.readSync(fd, buf, 0, length, size - length);
883
+ return (0, status_model_1.tailLines)(buf.toString("utf8"), LOG_LINES).map((l) => (l.length > 400 ? `${l.slice(0, 400)}…` : l));
884
+ }
885
+ finally {
886
+ node_fs_1.default.closeSync(fd);
887
+ }
888
+ }
889
+ catch {
890
+ return [];
891
+ }
892
+ }
893
+ /** What the dashboard's connection card is drawn from. */
894
+ function bridgeState() {
895
+ return {
896
+ doctor: rec.doctor,
897
+ doctorFailed: rec.doctorFailed,
898
+ routerAnswering: answered,
899
+ busy: rec.busy,
900
+ result: rec.result,
901
+ restartClaude: rec.restartClaude,
902
+ loginItem: loginItemOn(),
903
+ appVersion: electron_1.app.getVersion(),
904
+ platform: process.platform,
905
+ };
906
+ }
907
+ let lastSent = "";
908
+ /** Tells the open window, if any, when the state changed (render() runs on every poll). */
909
+ function pushState() {
910
+ if (!(0, window_1.mainWindow)())
911
+ return;
912
+ const state = bridgeState();
913
+ const key = JSON.stringify(state);
914
+ if (key === lastSent)
915
+ return;
916
+ lastSent = key;
917
+ (0, window_1.sendToWindow)("ripple-app:state", state);
918
+ }
919
+ function showWindow(view) {
920
+ (0, window_1.openWindow)({
921
+ lang: uiLang(),
922
+ savedBounds: () => appState().window ?? null,
923
+ saveBounds: (bounds) => writeAppState({ window: bounds }),
924
+ shown: () => {
925
+ lastSent = "";
926
+ if (!rec.busy)
927
+ void refreshDoctor();
928
+ },
929
+ }, view);
930
+ }
931
+ const BRIDGE_ACTIONS = new Set(["repair", "direct-on", "direct-off", "restart-claude", "refresh", "login-item", "logs"]);
932
+ /** Only our own page may drive the bridge, never something it navigated to. */
933
+ function fromOurPage(event) {
934
+ return (event.senderFrame?.url ?? "").startsWith(`${protocol_1.ORIGIN}/`);
935
+ }
936
+ function wireBridge() {
937
+ electron_1.ipcMain.handle("ripple-app:state", (event) => (fromOurPage(event) ? bridgeState() : null));
938
+ electron_1.ipcMain.handle("ripple-app:log", (event) => (fromOurPage(event) ? logTail() : []));
939
+ electron_1.ipcMain.handle("ripple-app:act", async (event, id) => {
940
+ if (!fromOurPage(event) || typeof id !== "string" || !BRIDGE_ACTIONS.has(id))
941
+ return null;
942
+ await act(id);
943
+ return bridgeState();
944
+ });
945
+ }
946
+ async function act(id) {
947
+ switch (id) {
948
+ case "repair":
949
+ return runAction("repair", ["repair", "--json"]);
950
+ case "direct-on":
951
+ return runAction("direct-on", ["direct", "on", "--json"]);
952
+ case "direct-off":
953
+ return runAction("direct-off", ["direct", "off", "--json"]);
954
+ case "restart-claude":
955
+ return restartClaude();
956
+ case "logs":
957
+ node_fs_1.default.mkdirSync(node_path_1.default.join(home, "logs"), { recursive: true });
958
+ void electron_1.shell.openPath(node_path_1.default.join(home, "logs"));
959
+ return;
960
+ case "refresh":
961
+ if (rec.busy)
962
+ return;
963
+ rec.busy = "checking";
964
+ render();
965
+ await Promise.all([poll(), refreshDoctor()]);
966
+ rec.busy = null;
967
+ render();
968
+ return;
969
+ case "login-item":
970
+ setLoginItem(true);
971
+ render();
972
+ return;
973
+ }
974
+ }
975
+ async function runAction(action, args) {
976
+ if (rec.busy)
977
+ return;
978
+ rec.busy = action;
979
+ rec.result = null;
980
+ render();
981
+ const r = await runCliResult(args, ACTION_TIMEOUT_MS);
982
+ const out = (0, status_model_1.parseJsonOutput)(r.stdout);
983
+ const ok = !r.timedOut && out?.ok === true;
984
+ const details = action === "repair"
985
+ ? (0, status_model_1.repairDetails)(out, r.output)
986
+ : [out?.detail ?? "", ...(ok ? [] : (0, status_model_1.tailLines)(out ? "" : r.output, 8))].filter(Boolean);
987
+ rec.result = { action, ok, timedOut: r.timedOut, details, at: Date.now() };
988
+ if (ok && out?.restartClaude)
989
+ rec.restartClaude = true;
990
+ if (out?.doctor) {
991
+ rec.doctor = out.doctor;
992
+ rec.doctorFailed = false;
993
+ }
994
+ rec.busy = null;
995
+ await poll();
996
+ if (!out?.doctor)
997
+ await refreshDoctor();
998
+ render();
999
+ }
1000
+ async function restartClaude() {
1001
+ if (rec.busy)
1002
+ return;
1003
+ const options = {
1004
+ type: "question",
1005
+ message: L.restartConfirm,
1006
+ detail: L.restartConfirmDetail,
1007
+ buttons: [L.restartNow, L.cancel],
1008
+ defaultId: 0,
1009
+ cancelId: 1,
1010
+ };
1011
+ const parent = (0, window_1.mainWindow)();
1012
+ const choice = parent ? await electron_1.dialog.showMessageBox(parent, options) : await electron_1.dialog.showMessageBox(options);
1013
+ if (choice.response !== 0)
1014
+ return;
1015
+ rec.busy = "restart-claude";
1016
+ rec.result = null;
1017
+ render();
1018
+ const ok = await (0, claude_desktop_1.reopenClaude)();
1019
+ rec.busy = null;
1020
+ rec.result = { action: "restart-claude", ok, details: [], at: Date.now() };
1021
+ if (ok)
1022
+ rec.restartClaude = false;
1023
+ render();
1024
+ }
755
1025
  let availableUpdate = null;
756
1026
  let updating = false;
757
1027
  const UPDATE_CHECK_MS = 12 * 60 * 60 * 1000;
@@ -797,7 +1067,7 @@ async function offerUpdate(check) {
797
1067
  render();
798
1068
  const version = check.latest ?? "";
799
1069
  if (electron_1.Notification.isSupported())
800
- new electron_1.Notification({ title: "ClaudeRipple", body: L.updating(version) }).show();
1070
+ new electron_1.Notification({ title: "ClaudeRipple", body: L.updating(version), icon: notifyIcon() }).show();
801
1071
  const r = await runCliResult(["update"]);
802
1072
  updating = false;
803
1073
  if (!r.ok) {
@@ -807,7 +1077,7 @@ async function offerUpdate(check) {
807
1077
  }
808
1078
  availableUpdate = null;
809
1079
  if (electron_1.Notification.isSupported())
810
- new electron_1.Notification({ title: "ClaudeRipple", body: L.updateDone(version) }).show();
1080
+ new electron_1.Notification({ title: "ClaudeRipple", body: L.updateDone(version), icon: notifyIcon() }).show();
811
1081
  // A new tray from the new files, then this one goes. The lock is let go first, or the new tray
812
1082
  // would see this one and leave.
813
1083
  electron_1.app.releaseSingleInstanceLock();
@@ -862,10 +1132,14 @@ function windowsText(quota) {
862
1132
  .join(" · ");
863
1133
  }
864
1134
  function render() {
1135
+ pushState();
865
1136
  if (!tray)
866
1137
  return;
867
1138
  const state = healthy(last);
868
- tray.setImage(icon(state));
1139
+ // The direct mode is chosen, not broken: its own mark, and no "cannot connect" line.
1140
+ const shown = (0, status_model_1.trayState)(state, rec.doctor);
1141
+ const direct = shown === "direct";
1142
+ tray.setImage(icon(shown));
869
1143
  const s = last;
870
1144
  const quota = Object.values(s?.chatgpt?.quota ?? {}).find((q) => q);
871
1145
  const quotaLine = quota?.rate_limits?.primary
@@ -877,22 +1151,29 @@ function render() {
877
1151
  // "Router not running" is useless advice to someone who has never set it up — that reads as a
878
1152
  // fault when the truth is there is one step left. Separate the two states everywhere.
879
1153
  const unconfigured = !s && needsSetup();
880
- const headline = s
881
- ? `ClaudeRipple · ${state === "ok" ? L.healthy : L.attentionNeeded}`
882
- : `ClaudeRipple · ${unconfigured ? L.setupNeeded : L.routerNotRunning}`;
1154
+ const headline = direct
1155
+ ? `ClaudeRipple · ${L.directHeadline}`
1156
+ : s
1157
+ ? `ClaudeRipple · ${state === "ok" ? L.healthy : L.attentionNeeded}`
1158
+ : `ClaudeRipple · ${unconfigured ? L.setupNeeded : L.routerNotRunning}`;
883
1159
  // What is actually in the way, not only that something is: the router's readiness list names it.
884
1160
  const problems = (s?.readiness?.problems ?? []).map((code) => L.problem(code));
885
- const detail = s
886
- ? [connected ? L.connected : L.notConnected, ...problems, quotaLine].filter(Boolean).join(" · ")
887
- : unconfigured
888
- ? L.setupNeededDetail
889
- : L.downNotifyTitle;
1161
+ const detail = direct
1162
+ ? null
1163
+ : s
1164
+ ? [connected ? L.connected : L.notConnected, ...problems, quotaLine].filter(Boolean).join(" · ")
1165
+ : unconfigured
1166
+ ? L.setupNeededDetail
1167
+ : L.downNotifyTitle;
890
1168
  // One line per Claude subscription account, current login first (#40).
891
1169
  const claudeLines = (s?.claude?.accounts ?? [])
892
1170
  .map((account) => ({ account, windows: windowsText(account.quota) }))
893
1171
  .filter(({ windows }) => windows)
894
1172
  .map(({ account, windows }) => ({ label: L.claudeUsage(account.id === "current" ? L.claudeCurrent : account.label, windows), click: () => void openDashboard() }));
895
1173
  const template = [
1174
+ // First, because it is the way back when nothing else works: the window needs no router.
1175
+ { label: L.open, click: () => void openDashboard() },
1176
+ { type: "separator" },
896
1177
  { label: headline, click: () => void openDashboard() },
897
1178
  ...(detail ? [{ label: detail, click: () => void openDashboard() }] : []),
898
1179
  ...claudeLines,
@@ -900,8 +1181,6 @@ function render() {
900
1181
  ? [{ label: updating ? L.updating(availableUpdate.latest ?? "") : L.updateAvailableItem(availableUpdate.latest ?? ""), enabled: !updating, click: () => void offerUpdate(availableUpdate) }]
901
1182
  : []),
902
1183
  { type: "separator" },
903
- // Enabled even when the router is down: it is started first, then the page is opened.
904
- { label: L.openDashboard, click: () => void openDashboard() },
905
1184
  { label: L.startAtLogin, type: "checkbox", checked: loginItemOn(), click: (item) => setLoginItem(item.checked) },
906
1185
  { type: "separator" },
907
1186
  { label: unconfigured ? L.runSetup : L.rerunSetup, click: () => void setup() },
@@ -924,14 +1203,38 @@ function render() {
924
1203
  { label: L.quit, role: "quit" },
925
1204
  ];
926
1205
  tray.setContextMenu(electron_1.Menu.buildFromTemplate(template));
927
- tray.setToolTip(s ? L.tooltip(state) : L.tooltipDown);
1206
+ tray.setToolTip(direct ? L.tooltipDirect : s ? L.tooltip(state) : L.tooltipDown);
928
1207
  }
1208
+ /**
1209
+ * The dashboard this app draws: the copy of packages/ui inside a packaged build, else the one next
1210
+ * to the tray's own folder (a checkout's packages/ui, an npm install's dist/ui — the same files the
1211
+ * router serves).
1212
+ */
1213
+ function uiRoot() {
1214
+ const resources = process.resourcesPath;
1215
+ const candidates = [
1216
+ ...(resources ? [node_path_1.default.join(resources, "clauderipple", "packages", "ui")] : []),
1217
+ node_path_1.default.resolve(__dirname, "..", "..", "ui"),
1218
+ ];
1219
+ return candidates.find((dir) => node_fs_1.default.existsSync(node_path_1.default.join(dir, "index.html"))) ?? candidates[candidates.length - 1];
1220
+ }
1221
+ (0, protocol_1.registerScheme)();
929
1222
  // One tray. `clauderipple tray` run while one is up started a second icon (Windows VM,
930
1223
  // 2026-10-10); the newcomer now leaves at once with code 0, which the CLI reads as "already running".
1224
+ if (process.env.CLAUDERIPPLE_APP_USER_DATA)
1225
+ electron_1.app.setPath("userData", process.env.CLAUDERIPPLE_APP_USER_DATA);
931
1226
  if (!electron_1.app.requestSingleInstanceLock())
932
1227
  electron_1.app.exit(0);
1228
+ // Opening the app again (Finder, Start menu, `clauderipple tray`) while it already runs in the
1229
+ // tray is someone looking for it: bring the window forward rather than nothing.
1230
+ electron_1.app.on("second-instance", () => {
1231
+ if (electron_1.app.isReady())
1232
+ showWindow(last ? undefined : "health");
1233
+ });
933
1234
  electron_1.app.whenReady().then(() => {
934
1235
  L = STRINGS[uiLang()];
1236
+ (0, protocol_1.handleScheme)(uiRoot(), adminUrl);
1237
+ wireBridge();
935
1238
  // Without this, Windows attributes our notifications to "Electron" instead of ClaudeRipple.
936
1239
  if (process.platform === "win32")
937
1240
  electron_1.app.setAppUserModelId("com.clauderipple.app");
@@ -950,8 +1253,18 @@ electron_1.app.whenReady().then(() => {
950
1253
  tray = new electron_1.Tray(icon("down"));
951
1254
  tray.on("click", () => tray?.popUpContextMenu());
952
1255
  render();
1256
+ // Opened from its icon (the CLI sets this; a login launch does not): show the window, not just a
1257
+ // tray icon the person may never notice. Taken out so an update's relaunch does not inherit it.
1258
+ if (process.env.CLAUDERIPPLE_APP_SHOW === "1") {
1259
+ delete process.env.CLAUDERIPPLE_APP_SHOW;
1260
+ showWindow("health");
1261
+ }
953
1262
  void poll();
954
1263
  setInterval(() => void poll(), POLL_MS);
1264
+ // What the health poll cannot see (the direct mode, a supervisor gone missing) comes from doctor.
1265
+ setTimeout(() => void refreshDoctor(), 3_000);
1266
+ setInterval(() => void (!rec.busy && refreshDoctor()), DOCTOR_EVERY_MS);
1267
+ setInterval(() => void ((0, window_1.windowVisible)() && !rec.busy && refreshDoctor()), DOCTOR_WHILE_OPEN_MS);
955
1268
  if (needsSetup())
956
1269
  void promptForSetup();
957
1270
  // Not at the very start: a tray opened at login shares the first minute with everything else.