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.
- package/CHANGELOG.md +51 -0
- package/README.es.md +45 -0
- package/README.ja.md +17 -0
- package/README.ko.md +31 -0
- package/README.md +39 -0
- package/README.pt-BR.md +45 -0
- package/README.zh-CN.md +17 -0
- package/dist/app/assets/icon.icns +0 -0
- package/dist/app/assets/icon.ico +0 -0
- package/dist/app/assets/icon.png +0 -0
- package/dist/app/assets/trayDirectTemplate.png +0 -0
- package/dist/app/assets/trayDirectTemplate@2x.png +0 -0
- package/dist/app/assets/trayDownTemplate.png +0 -0
- package/dist/app/assets/trayDownTemplate@2x.png +0 -0
- package/dist/app/assets/trayTemplate.png +0 -0
- package/dist/app/assets/trayTemplate@2x.png +0 -0
- package/dist/app/assets/trayWarnTemplate.png +0 -0
- package/dist/app/assets/trayWarnTemplate@2x.png +0 -0
- package/dist/app/assets/trayWin.png +0 -0
- package/dist/app/assets/trayWin@2x.png +0 -0
- package/dist/app/assets/trayWinDirect.png +0 -0
- package/dist/app/assets/trayWinDirect@2x.png +0 -0
- package/dist/app/assets/trayWinDown.png +0 -0
- package/dist/app/assets/trayWinDown@2x.png +0 -0
- package/dist/app/assets/trayWinWarn.png +0 -0
- package/dist/app/assets/trayWinWarn@2x.png +0 -0
- package/dist/app/dist/claude-desktop.js +67 -0
- package/dist/app/dist/main.js +423 -110
- package/dist/app/dist/preload.js +21 -0
- package/dist/app/dist/protocol.js +121 -0
- package/dist/app/dist/status-model.js +87 -0
- package/dist/app/dist/window.js +133 -0
- package/dist/app/package.json +1 -1
- package/dist/cli/src/awake-setup.js +314 -0
- package/dist/cli/src/bundle.js +9 -5
- package/dist/cli/src/direct.js +159 -0
- package/dist/cli/src/doctor.js +205 -0
- package/dist/cli/src/index.js +184 -31
- package/dist/cli/src/launchd.js +29 -20
- package/dist/cli/src/launcher.js +215 -0
- package/dist/cli/src/power.js +182 -0
- package/dist/cli/src/registration.js +157 -0
- package/dist/cli/src/schtasks.js +2 -0
- package/dist/cli/src/settings.js +24 -0
- package/dist/cli/src/tray.js +5 -2
- package/dist/mod/hooks/i18n.ts +237 -237
- package/dist/mod/hooks/register.tsx +4 -5
- package/dist/mod/hooks/session.ts +6 -2
- package/dist/mod/hooks/ui/agents.tsx +81 -36
- package/dist/mod/hooks/ui/view.ts +8 -5
- package/dist/mod/tests/ui.test.tsx +46 -13
- package/dist/mod/types/index.d.ts +0 -2
- package/dist/remote/src/bridge-client.js +45 -4
- package/dist/router/src/admin.js +105 -2
- package/dist/router/src/awake-win.js +590 -0
- package/dist/router/src/awake.js +432 -0
- package/dist/router/src/config.js +64 -0
- package/dist/router/src/health.js +86 -3
- package/dist/router/src/hold.js +107 -0
- package/dist/router/src/index.js +69 -6
- package/dist/router/src/ingress/server.js +26 -4
- package/dist/router/src/power.js +107 -0
- package/dist/router/src/providers/google/antigravity.js +73 -10
- package/dist/router/src/providers/google/index.js +168 -17
- package/dist/router/src/providers/google/thoughtSignatures.js +13 -0
- package/dist/router/src/providers/google/transport.js +1 -1
- package/dist/router/src/providers/google/usage.js +71 -26
- package/dist/router/src/providers/retry.js +4 -1
- package/dist/router/src/proxy.js +50 -21
- package/dist/router/src/tether-win.js +211 -0
- package/dist/router/src/tether.js +712 -0
- package/dist/router/src/version.js +1 -1
- package/dist/ui/app.js +649 -2
- package/dist/ui/i18n.js +494 -77
- package/dist/ui/index.html +9 -1
- package/dist/ui/style.css +138 -0
- package/docs/ARCHITECTURE.md +294 -6
- package/package.json +2 -2
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// `clauderipple app --install|--uninstall`: something to double-click that opens the menu-bar app.
|
|
2
|
+
//
|
|
3
|
+
// Installed from npm there is no application to open, and the menu-bar app is exactly what a
|
|
4
|
+
// person needs when the router is down (it offers repair and Claude's default connection). So a
|
|
5
|
+
// launcher runs `clauderipple tray --ensure`: fetch Electron once if it is missing, then start the
|
|
6
|
+
// tray the way `clauderipple tray` does.
|
|
7
|
+
//
|
|
8
|
+
// macOS ~/Applications/ClaudeRipple.app — Info.plist + a shell script, bundle id
|
|
9
|
+
// com.clauderipple.launcher (the router's own background bundle in ~/.clauderipple is
|
|
10
|
+
// com.clauderipple.router, the packaged app com.clauderipple.app; none collide)
|
|
11
|
+
// Windows Start Menu and Desktop shortcuts (.lnk, WScript.Shell) to a hidden PowerShell launcher
|
|
12
|
+
// Linux ~/.local/share/applications/clauderipple.desktop
|
|
13
|
+
//
|
|
14
|
+
// CLAUDERIPPLE_LAUNCHER_DIR replaces every target directory (tests, or a custom location).
|
|
15
|
+
import { execFileSync } from "node:child_process";
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
import os from "node:os";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { windowsPowerShellEnv } from "./windows-powershell.js";
|
|
20
|
+
export const LAUNCHER_BUNDLE_ID = "com.clauderipple.launcher";
|
|
21
|
+
const APP_NAME = "ClaudeRipple";
|
|
22
|
+
const DESKTOP_MARK = "X-ClaudeRipple-Launcher=1";
|
|
23
|
+
function overrideDir() {
|
|
24
|
+
return process.env.CLAUDERIPPLE_LAUNCHER_DIR ?? null;
|
|
25
|
+
}
|
|
26
|
+
export function macAppPath() {
|
|
27
|
+
return path.join(overrideDir() ?? path.join(os.homedir(), "Applications"), `${APP_NAME}.app`);
|
|
28
|
+
}
|
|
29
|
+
export function desktopEntryPath() {
|
|
30
|
+
const data = process.env.XDG_DATA_HOME || path.join(os.homedir(), ".local", "share");
|
|
31
|
+
return path.join(overrideDir() ?? path.join(data, "applications"), "clauderipple.desktop");
|
|
32
|
+
}
|
|
33
|
+
export function windowsTrayLauncherPath(home) {
|
|
34
|
+
return path.join(home, "clauderipple-tray.ps1");
|
|
35
|
+
}
|
|
36
|
+
/** POSIX single-quoted literal. */
|
|
37
|
+
function sh(value) {
|
|
38
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
39
|
+
}
|
|
40
|
+
/** PowerShell single-quoted literal; the only escape inside one is a doubled quote. */
|
|
41
|
+
function ps(value) {
|
|
42
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
43
|
+
}
|
|
44
|
+
/** A desktop-entry Exec argument (freedesktop Desktop Entry spec, "The Exec key"). */
|
|
45
|
+
function execArg(value) {
|
|
46
|
+
return `"${value.replace(/([\\"`$])/g, "\\$1").replace(/%/g, "%%")}"`;
|
|
47
|
+
}
|
|
48
|
+
function launchEnv(rt, home) {
|
|
49
|
+
return { CLAUDERIPPLE_HOME: home, ...rt.env };
|
|
50
|
+
}
|
|
51
|
+
/** The app icon (icon.icns / icon.png / icon.ico), committed in the app's assets and shipped in the npm package beside the tray. */
|
|
52
|
+
function findIcon(rt, file) {
|
|
53
|
+
for (const dir of [path.join(rt.repo, "packages", "app", "assets"), path.join(rt.repo, "dist", "app", "assets")]) {
|
|
54
|
+
const p = path.join(dir, file);
|
|
55
|
+
if (fs.existsSync(p))
|
|
56
|
+
return p;
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
export function macInfoPlist(version, icon) {
|
|
61
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
62
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
63
|
+
<plist version="1.0">
|
|
64
|
+
<dict>
|
|
65
|
+
<key>CFBundleName</key><string>${APP_NAME}</string>
|
|
66
|
+
<key>CFBundleDisplayName</key><string>${APP_NAME}</string>
|
|
67
|
+
<key>CFBundleIdentifier</key><string>${LAUNCHER_BUNDLE_ID}</string>
|
|
68
|
+
<key>CFBundleVersion</key><string>${version}</string>
|
|
69
|
+
<key>CFBundleShortVersionString</key><string>${version}</string>
|
|
70
|
+
<key>CFBundlePackageType</key><string>APPL</string>
|
|
71
|
+
<key>CFBundleExecutable</key><string>${APP_NAME}</string>
|
|
72
|
+
${icon ? " <key>CFBundleIconFile</key><string>icon</string>\n" : ""} <key>LSUIElement</key><true/>
|
|
73
|
+
</dict>
|
|
74
|
+
</plist>
|
|
75
|
+
`;
|
|
76
|
+
}
|
|
77
|
+
export function macLauncherScript(rt, home) {
|
|
78
|
+
const log = path.join(home, "logs", "launcher.log");
|
|
79
|
+
return [
|
|
80
|
+
"#!/bin/sh",
|
|
81
|
+
"# Opens the ClaudeRipple menu-bar app (generated by `clauderipple app --install`).",
|
|
82
|
+
...Object.entries(launchEnv(rt, home)).map(([k, v]) => `export ${k}=${sh(v)}`),
|
|
83
|
+
`mkdir -p ${sh(path.dirname(log))}`,
|
|
84
|
+
`exec ${sh(rt.node)} ${sh(rt.cli)} tray --ensure >> ${sh(log)} 2>&1`,
|
|
85
|
+
"",
|
|
86
|
+
].join("\n");
|
|
87
|
+
}
|
|
88
|
+
export function desktopEntry(rt, home) {
|
|
89
|
+
const env = Object.entries(launchEnv(rt, home)).map(([k, v]) => execArg(`${k}=${v}`));
|
|
90
|
+
const icon = findIcon(rt, "icon.png");
|
|
91
|
+
return [
|
|
92
|
+
"[Desktop Entry]",
|
|
93
|
+
"Type=Application",
|
|
94
|
+
`Name=${APP_NAME}`,
|
|
95
|
+
"Comment=ClaudeRipple menu-bar app: router status, repair, and Claude's default connection",
|
|
96
|
+
`Exec=env ${env.join(" ")} ${execArg(rt.node)} ${execArg(rt.cli)} tray --ensure`,
|
|
97
|
+
...(icon ? [`Icon=${icon}`] : []),
|
|
98
|
+
"Terminal=false",
|
|
99
|
+
"Categories=Utility;Development;",
|
|
100
|
+
DESKTOP_MARK,
|
|
101
|
+
"",
|
|
102
|
+
].join("\n");
|
|
103
|
+
}
|
|
104
|
+
export function windowsTrayLauncher(rt, home) {
|
|
105
|
+
const log = path.join(home, "logs", "launcher.log");
|
|
106
|
+
return [
|
|
107
|
+
...Object.entries(launchEnv(rt, home)).map(([k, v]) => `$env:${k} = ${ps(v)}`),
|
|
108
|
+
`New-Item -ItemType Directory -Force -Path ${ps(path.dirname(log))} | Out-Null`,
|
|
109
|
+
`& ${ps(rt.node)} ${ps(rt.cli)} tray --ensure *>> ${ps(log)}`,
|
|
110
|
+
].join("\r\n") + "\r\n";
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Creates (or, with `remove`, deletes) the shortcuts and prints each path. Without an override the
|
|
114
|
+
* folders are the user's own Start Menu programs and Desktop, as Windows names them.
|
|
115
|
+
*/
|
|
116
|
+
export function windowsShortcutScript(launcher, iconSource, dir, remove = false) {
|
|
117
|
+
const targets = dir
|
|
118
|
+
? `@(${ps(path.join(dir, "Start Menu", `${APP_NAME}.lnk`))}, ${ps(path.join(dir, "Desktop", `${APP_NAME}.lnk`))})`
|
|
119
|
+
: `@((Join-Path ([Environment]::GetFolderPath('Programs')) '${APP_NAME}.lnk'), (Join-Path ([Environment]::GetFolderPath('Desktop')) '${APP_NAME}.lnk'))`;
|
|
120
|
+
if (remove) {
|
|
121
|
+
return [`$ErrorActionPreference = 'Stop'`, `foreach ($p in ${targets}) { if (Test-Path -LiteralPath $p) { Remove-Item -LiteralPath $p -Force; $p } }`].join("; ");
|
|
122
|
+
}
|
|
123
|
+
// conhost --headless: started directly, PowerShell gets a console from the default terminal and
|
|
124
|
+
// a window stays on screen (#49, the same reason the router's scheduled task uses it).
|
|
125
|
+
const args = `--headless powershell.exe -NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File "${launcher}"`;
|
|
126
|
+
return [
|
|
127
|
+
`$ErrorActionPreference = 'Stop'`,
|
|
128
|
+
`$ws = New-Object -ComObject WScript.Shell`,
|
|
129
|
+
`foreach ($p in ${targets}) { New-Item -ItemType Directory -Force -Path (Split-Path -Parent $p) | Out-Null; $s = $ws.CreateShortcut($p); $s.TargetPath = (Join-Path $env:SystemRoot 'System32\\conhost.exe'); $s.Arguments = ${ps(args)}; $s.IconLocation = ${ps(`${iconSource},0`)}; $s.Description = 'ClaudeRipple menu-bar app'; $s.Save(); $p }`,
|
|
130
|
+
].join("; ");
|
|
131
|
+
}
|
|
132
|
+
function runPowerShell(script) {
|
|
133
|
+
return execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], {
|
|
134
|
+
env: windowsPowerShellEnv(),
|
|
135
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
136
|
+
windowsHide: true,
|
|
137
|
+
timeout: 30_000,
|
|
138
|
+
}).toString();
|
|
139
|
+
}
|
|
140
|
+
/** Bundle id of an existing .app, or null when it has none we can read. */
|
|
141
|
+
function bundleIdOf(app) {
|
|
142
|
+
try {
|
|
143
|
+
const plist = fs.readFileSync(path.join(app, "Contents", "Info.plist"), "utf8");
|
|
144
|
+
return /<key>CFBundleIdentifier<\/key>\s*<string>([^<]*)<\/string>/.exec(plist)?.[1] ?? null;
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
export function installLauncher(opts) {
|
|
151
|
+
const platform = opts.platform ?? process.platform;
|
|
152
|
+
if (opts.rt.packaged)
|
|
153
|
+
return { ok: true, paths: [], detail: "This is the standalone ClaudeRipple app, which opens the menu-bar app itself; no launcher needed." };
|
|
154
|
+
if (platform === "darwin") {
|
|
155
|
+
const app = macAppPath();
|
|
156
|
+
// The packaged app may sit there under the same name: never write into someone else's bundle.
|
|
157
|
+
if (fs.existsSync(app) && bundleIdOf(app) !== LAUNCHER_BUNDLE_ID) {
|
|
158
|
+
return { ok: false, paths: [], detail: `${app} already exists and is not ClaudeRipple's launcher; left it alone.` };
|
|
159
|
+
}
|
|
160
|
+
const contents = path.join(app, "Contents");
|
|
161
|
+
fs.mkdirSync(path.join(contents, "MacOS"), { recursive: true });
|
|
162
|
+
const icns = findIcon(opts.rt, "icon.icns");
|
|
163
|
+
if (icns) {
|
|
164
|
+
fs.mkdirSync(path.join(contents, "Resources"), { recursive: true });
|
|
165
|
+
fs.copyFileSync(icns, path.join(contents, "Resources", "icon.icns"));
|
|
166
|
+
}
|
|
167
|
+
fs.writeFileSync(path.join(contents, "Info.plist"), macInfoPlist(opts.version, icns !== null));
|
|
168
|
+
fs.writeFileSync(path.join(contents, "MacOS", APP_NAME), macLauncherScript(opts.rt, opts.home), { mode: 0o755 });
|
|
169
|
+
fs.chmodSync(path.join(contents, "MacOS", APP_NAME), 0o755);
|
|
170
|
+
return { ok: true, paths: [app], detail: `Open ${APP_NAME} from ${path.dirname(app)} (or Spotlight) to start the menu-bar app.` };
|
|
171
|
+
}
|
|
172
|
+
if (platform === "linux") {
|
|
173
|
+
const file = desktopEntryPath();
|
|
174
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
175
|
+
fs.writeFileSync(file, desktopEntry(opts.rt, opts.home), { mode: 0o755 });
|
|
176
|
+
return { ok: true, paths: [file], detail: `${APP_NAME} is in your applications menu.` };
|
|
177
|
+
}
|
|
178
|
+
if (platform === "win32") {
|
|
179
|
+
const launcher = windowsTrayLauncherPath(opts.home);
|
|
180
|
+
fs.mkdirSync(opts.home, { recursive: true });
|
|
181
|
+
fs.writeFileSync(launcher, windowsTrayLauncher(opts.rt, opts.home));
|
|
182
|
+
const out = (opts.powershell ?? runPowerShell)(windowsShortcutScript(launcher, findIcon(opts.rt, "icon.ico") ?? opts.rt.node, overrideDir()));
|
|
183
|
+
const paths = out.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
184
|
+
return { ok: true, paths, detail: `${APP_NAME} is in the Start Menu and on the Desktop.` };
|
|
185
|
+
}
|
|
186
|
+
return { ok: false, paths: [], detail: `no launcher for ${platform}; start the menu-bar app with: clauderipple tray` };
|
|
187
|
+
}
|
|
188
|
+
export function uninstallLauncher(opts) {
|
|
189
|
+
const platform = opts.platform ?? process.platform;
|
|
190
|
+
const removed = [];
|
|
191
|
+
if (platform === "darwin") {
|
|
192
|
+
const app = macAppPath();
|
|
193
|
+
if (fs.existsSync(app)) {
|
|
194
|
+
if (bundleIdOf(app) !== LAUNCHER_BUNDLE_ID)
|
|
195
|
+
return { ok: false, paths: [], detail: `${app} is not ClaudeRipple's launcher; left it alone.` };
|
|
196
|
+
fs.rmSync(app, { recursive: true, force: true });
|
|
197
|
+
removed.push(app);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
else if (platform === "linux") {
|
|
201
|
+
const file = desktopEntryPath();
|
|
202
|
+
if (fs.existsSync(file)) {
|
|
203
|
+
if (!fs.readFileSync(file, "utf8").includes(DESKTOP_MARK))
|
|
204
|
+
return { ok: false, paths: [], detail: `${file} is not ClaudeRipple's launcher; left it alone.` };
|
|
205
|
+
fs.rmSync(file);
|
|
206
|
+
removed.push(file);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
else if (platform === "win32") {
|
|
210
|
+
const out = (opts.powershell ?? runPowerShell)(windowsShortcutScript(windowsTrayLauncherPath(opts.home), opts.rt.node, overrideDir(), true));
|
|
211
|
+
removed.push(...out.split(/\r?\n/).map((l) => l.trim()).filter(Boolean));
|
|
212
|
+
fs.rmSync(windowsTrayLauncherPath(opts.home), { force: true });
|
|
213
|
+
}
|
|
214
|
+
return { ok: true, paths: removed, detail: removed.length ? "launcher removed" : "no launcher was installed" };
|
|
215
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// `clauderipple power | awake … | tether …` — lid-closed awake and hotspot failover from the
|
|
2
|
+
// terminal. Changes go through the running router's admin API (`PUT /api/power`), which saves them
|
|
3
|
+
// and applies them at once; with no router answering they are saved to config.json and apply when it
|
|
4
|
+
// starts (and `awake off` also clears a sleep bit the router left set).
|
|
5
|
+
import { persistPowerChange } from "../../router/src/power.js";
|
|
6
|
+
import { awakeRemove, awakeSetup, releaseAwake, releaseFailedHint } from "./awake-setup.js";
|
|
7
|
+
/** `HH:MM` → the next time the clock shows it (today, else tomorrow), as ISO. A full ISO time is accepted too. */
|
|
8
|
+
export function parseUntil(text, now) {
|
|
9
|
+
const m = /^(\d{1,2}):(\d{2})$/.exec(text);
|
|
10
|
+
if (!m) {
|
|
11
|
+
const t = Date.parse(text);
|
|
12
|
+
if (/^\d{4}-\d{2}-\d{2}T/.test(text) && !Number.isNaN(t))
|
|
13
|
+
return new Date(t).toISOString();
|
|
14
|
+
throw new Error(`--until takes HH:MM (24-hour), got "${text}"`);
|
|
15
|
+
}
|
|
16
|
+
const h = Number(m[1]);
|
|
17
|
+
const min = Number(m[2]);
|
|
18
|
+
if (h > 23 || min > 59)
|
|
19
|
+
throw new Error(`--until takes HH:MM (24-hour), got "${text}"`);
|
|
20
|
+
const at = new Date(now);
|
|
21
|
+
at.setHours(h, min, 0, 0);
|
|
22
|
+
if (at.getTime() <= now.getTime())
|
|
23
|
+
at.setDate(at.getDate() + 1);
|
|
24
|
+
return at.toISOString();
|
|
25
|
+
}
|
|
26
|
+
const clock = (iso) => new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
27
|
+
export function formatPower(s) {
|
|
28
|
+
const a = s.awake;
|
|
29
|
+
const t = s.tether;
|
|
30
|
+
const lines = [];
|
|
31
|
+
const setup = a.setup === "ok" ? "" : a.setup === "needs-setup" ? " · needs setup: clauderipple awake setup" : " · not supported here";
|
|
32
|
+
const machine = s.platform === "win32" ? "PC" : "Mac";
|
|
33
|
+
lines.push(`awake ${a.mode}${a.until ? ` until ${clock(a.until)}` : ""} · ${a.applied ? `holding the ${machine} awake` : "sleep allowed"} (${a.reason})${setup}`);
|
|
34
|
+
const facts = [];
|
|
35
|
+
if (a.battery)
|
|
36
|
+
facts.push(a.battery.percent === null ? "no battery" : `battery ${a.battery.percent}% ${a.battery.onBattery ? "(on battery)" : "(on power)"}`);
|
|
37
|
+
if (a.thermal)
|
|
38
|
+
facts.push(`thermal ${a.thermal}`);
|
|
39
|
+
if (a.lidClosed !== null)
|
|
40
|
+
facts.push(`lid ${a.lidClosed ? "closed" : "open"}`);
|
|
41
|
+
if (facts.length)
|
|
42
|
+
lines.push(` ${facts.join(" · ")}`);
|
|
43
|
+
if (!t.enabled)
|
|
44
|
+
lines.push(`tether off${t.state === "unsupported" ? " (not supported here)" : ""}`);
|
|
45
|
+
else {
|
|
46
|
+
lines.push(`tether on · ${t.network} · ${t.state}${t.pinned ? " (pinned)" : ""}${t.hotspotSsid ? ` · hotspot "${t.hotspotSsid}"` : ""}`);
|
|
47
|
+
if (t.lastProbe)
|
|
48
|
+
lines.push(` last probe ${t.lastProbe.ok ? "ok" : "FAILED"} at ${clock(t.lastProbe.at)}`);
|
|
49
|
+
if (t.lastSwitch)
|
|
50
|
+
lines.push(` last switch to ${t.lastSwitch.to} at ${clock(t.lastSwitch.at)}: ${t.lastSwitch.ok ? "ok" : "failed"} (${t.lastSwitch.reason})`);
|
|
51
|
+
}
|
|
52
|
+
if (t.hint && t.enabled)
|
|
53
|
+
lines.push(` → ${t.hint}`);
|
|
54
|
+
return lines;
|
|
55
|
+
}
|
|
56
|
+
export function adminPowerCall(port) {
|
|
57
|
+
return async (method, body) => {
|
|
58
|
+
try {
|
|
59
|
+
const res = await fetch(`http://127.0.0.1:${port}/api/power`, {
|
|
60
|
+
method,
|
|
61
|
+
signal: AbortSignal.timeout(15_000),
|
|
62
|
+
...(body ? { headers: { "content-type": "application/json" }, body: JSON.stringify(body) } : {}),
|
|
63
|
+
});
|
|
64
|
+
return { status: res.status, json: await res.json().catch(() => ({})) };
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
export const POWER_USAGE = `usage:
|
|
72
|
+
clauderipple power [--json]
|
|
73
|
+
clauderipple awake setup [--ssid <hotspot name>] [--remove]
|
|
74
|
+
clauderipple awake on|off|working [--until HH:MM]
|
|
75
|
+
clauderipple tether on|off`;
|
|
76
|
+
/** Runs `power`, `awake …` or `tether …`; returns the exit code. */
|
|
77
|
+
export async function powerCli(args, d) {
|
|
78
|
+
const [cmd, sub] = args;
|
|
79
|
+
const opt = (name) => {
|
|
80
|
+
const i = args.indexOf(`--${name}`);
|
|
81
|
+
return i >= 0 ? args[i + 1] : undefined;
|
|
82
|
+
};
|
|
83
|
+
const flag = (name) => args.includes(`--${name}`);
|
|
84
|
+
const platform = d.platform ?? process.platform;
|
|
85
|
+
const machine = platform === "win32" ? "PC" : "Mac";
|
|
86
|
+
const show = async () => {
|
|
87
|
+
const r = await d.call("GET");
|
|
88
|
+
if (!r) {
|
|
89
|
+
d.print("the router is not answering; start it with: clauderipple start");
|
|
90
|
+
return 1;
|
|
91
|
+
}
|
|
92
|
+
if (r.status !== 200) {
|
|
93
|
+
d.print(`error: ${r.json.error ?? `HTTP ${r.status}`}`);
|
|
94
|
+
return 1;
|
|
95
|
+
}
|
|
96
|
+
if (flag("json"))
|
|
97
|
+
d.print(JSON.stringify(r.json));
|
|
98
|
+
else
|
|
99
|
+
for (const line of formatPower(r.json))
|
|
100
|
+
d.print(line);
|
|
101
|
+
return 0;
|
|
102
|
+
};
|
|
103
|
+
/** Through the router when it answers; saved to the file otherwise. */
|
|
104
|
+
const change = async (c) => {
|
|
105
|
+
const r = await d.call("PUT", c);
|
|
106
|
+
if (r && r.status === 200) {
|
|
107
|
+
for (const line of formatPower(r.json))
|
|
108
|
+
d.print(line);
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
111
|
+
if (r) {
|
|
112
|
+
d.print(`error: ${r.json.error ?? `HTTP ${r.status}`}`);
|
|
113
|
+
return 1;
|
|
114
|
+
}
|
|
115
|
+
persistPowerChange(d.configFile, c);
|
|
116
|
+
d.print("✓ saved to config.json; the router is not running, so it applies when it starts (clauderipple start)");
|
|
117
|
+
if (c.awake?.mode === "off") {
|
|
118
|
+
const released = await releaseAwake(d.run, d.rippleHome, "awake off (router not running)", platform);
|
|
119
|
+
if (released === "released")
|
|
120
|
+
d.print(platform === "win32" ? "✓ the lid-close action is back to yours (the router had left it on Do nothing)" : "✓ sleep re-enabled (the router had left it disabled)");
|
|
121
|
+
if (released === "failed")
|
|
122
|
+
d.print(`✗ could not re-enable sleep: ${releaseFailedHint(platform)}`);
|
|
123
|
+
}
|
|
124
|
+
return 0;
|
|
125
|
+
};
|
|
126
|
+
if (cmd === "power")
|
|
127
|
+
return show();
|
|
128
|
+
if (cmd === "awake") {
|
|
129
|
+
if (sub === undefined || sub === "status")
|
|
130
|
+
return show();
|
|
131
|
+
if (sub === "setup") {
|
|
132
|
+
if (flag("remove")) {
|
|
133
|
+
// Off first, so a running router stops re-applying while the rule goes away.
|
|
134
|
+
await change({ awake: { mode: "off" }, tether: { enabled: false } });
|
|
135
|
+
const r = await awakeRemove(d.setupDeps());
|
|
136
|
+
d.print(`${r.ok ? "✓" : "✗"} ${r.detail}`);
|
|
137
|
+
return r.ok ? 0 : 1;
|
|
138
|
+
}
|
|
139
|
+
const ssid = opt("ssid");
|
|
140
|
+
if (args.includes("--ssid") && (ssid === undefined || ssid.startsWith("--"))) {
|
|
141
|
+
d.print("usage: clauderipple awake setup --ssid <hotspot name>");
|
|
142
|
+
return 1;
|
|
143
|
+
}
|
|
144
|
+
const r = await awakeSetup(ssid !== undefined ? { ssid } : {}, d.setupDeps());
|
|
145
|
+
d.print(`${r.ok ? "✓" : "✗"} ${r.detail}`);
|
|
146
|
+
if (!r.ok)
|
|
147
|
+
return 1;
|
|
148
|
+
if (ssid !== undefined)
|
|
149
|
+
persistPowerChange(d.configFile, { tether: { hotspotSsid: ssid } });
|
|
150
|
+
d.print("");
|
|
151
|
+
d.print("Next:");
|
|
152
|
+
d.print(` clauderipple awake working keep the ${machine} awake (lid closed too) while Claude works, and 10 min after`);
|
|
153
|
+
d.print(" clauderipple awake on [--until HH:MM] keep it awake regardless");
|
|
154
|
+
if (ssid !== undefined)
|
|
155
|
+
d.print(" clauderipple tether on join the phone hotspot when Wi-Fi has no internet, and come back later");
|
|
156
|
+
d.print(`Keep a closed ${machine === "PC" ? "laptop" : "Mac"} on a hard, ventilated surface — never in a bag while it is held awake.`);
|
|
157
|
+
return 0;
|
|
158
|
+
}
|
|
159
|
+
if (sub === "on" || sub === "off" || sub === "working") {
|
|
160
|
+
const raw = opt("until");
|
|
161
|
+
let until = null;
|
|
162
|
+
if (raw !== undefined) {
|
|
163
|
+
if (sub === "off") {
|
|
164
|
+
d.print("--until applies to on and working");
|
|
165
|
+
return 1;
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
until = parseUntil(raw, d.now());
|
|
169
|
+
}
|
|
170
|
+
catch (e) {
|
|
171
|
+
d.print(e.message);
|
|
172
|
+
return 1;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return change({ awake: { mode: sub, until } });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (cmd === "tether" && (sub === "on" || sub === "off"))
|
|
179
|
+
return change({ tether: { enabled: sub === "on" } });
|
|
180
|
+
d.print(POWER_USAGE);
|
|
181
|
+
return 1;
|
|
182
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// What `install` registers with the supervisor, as data — so `repair` can register exactly the
|
|
2
|
+
// same thing and `doctor` can tell whether what is registered is still this installation's.
|
|
3
|
+
//
|
|
4
|
+
// The failure this exists for (2026-10-20): the launchd plist named a program that no longer
|
|
5
|
+
// existed, so launchd could not start the router, Claude Desktop and Claude Code got
|
|
6
|
+
// ECONNREFUSED on everything, and the dashboard — served by the router — was gone with it.
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { BUNDLE_ID, launcherPath, launcherScript, writeBundle } from "./bundle.js";
|
|
10
|
+
import * as launchd from "./launchd.js";
|
|
11
|
+
import * as schtasks from "./schtasks.js";
|
|
12
|
+
import * as systemd from "./systemd.js";
|
|
13
|
+
/**
|
|
14
|
+
* Windows and Linux register the runtime + router script directly; so does a packaged app. A macOS
|
|
15
|
+
* source checkout or npm install runs a generated bundle instead (bundle.ts). `platform` is a
|
|
16
|
+
* parameter only so this pure mapping can be tested for every platform; nothing here runs anything.
|
|
17
|
+
*/
|
|
18
|
+
export function registration(rt, home, version, platform = process.platform, settingsPathEnv = process.env.CLAUDE_SETTINGS_PATH ?? null) {
|
|
19
|
+
const settingsEnv = settingsPathEnv ? { CLAUDE_SETTINGS_PATH: settingsPathEnv } : {};
|
|
20
|
+
if (rt.packaged || platform === "win32" || platform === "linux") {
|
|
21
|
+
return { program: rt.node, args: [rt.router], bundleId: "com.clauderipple.app", env: { ...rt.env, ...settingsEnv }, bundle: null };
|
|
22
|
+
}
|
|
23
|
+
return { program: launcherPath(home), args: [], bundleId: BUNDLE_ID, env: settingsEnv, bundle: { node: rt.node, script: rt.router, version } };
|
|
24
|
+
}
|
|
25
|
+
/** The single execution contract the GUI, admin API, hooks and supervisor read (`paths.json`). */
|
|
26
|
+
export function writePaths(rt, home) {
|
|
27
|
+
const file = path.join(home, "paths.json");
|
|
28
|
+
fs.mkdirSync(home, { recursive: true });
|
|
29
|
+
fs.writeFileSync(file, JSON.stringify({ node: rt.node, env: rt.env, cli: rt.cli, router: rt.router, hookScript: rt.hookScript, repo: rt.repo }, null, 2) + "\n");
|
|
30
|
+
return file;
|
|
31
|
+
}
|
|
32
|
+
/** Reads only the head of `file`: the program is often a 100MB binary, and only a script matters. */
|
|
33
|
+
function head(file) {
|
|
34
|
+
let fd = null;
|
|
35
|
+
try {
|
|
36
|
+
fd = fs.openSync(file, "r");
|
|
37
|
+
const buf = Buffer.alloc(4096);
|
|
38
|
+
const n = fs.readSync(fd, buf, 0, buf.length, 0);
|
|
39
|
+
return buf.subarray(0, n).toString("utf8");
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return "";
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
if (fd !== null)
|
|
46
|
+
fs.closeSync(fd);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Whether everything the definition starts exists. A launcher script (the macOS bundle) is followed
|
|
51
|
+
* one step: its `exec "node" "router"` targets are what actually went missing when node moved.
|
|
52
|
+
*/
|
|
53
|
+
export function targetsExist(program, args) {
|
|
54
|
+
if (!program || !fs.existsSync(program))
|
|
55
|
+
return false;
|
|
56
|
+
const paths = args.filter((a) => path.isAbsolute(a));
|
|
57
|
+
const text = head(program);
|
|
58
|
+
if (text.startsWith("#!")) {
|
|
59
|
+
const exec = /^exec\s+(.+)$/m.exec(text)?.[1] ?? "";
|
|
60
|
+
for (const m of exec.matchAll(/"([^"]+)"|'([^']+)'/g)) {
|
|
61
|
+
const p = m[1] ?? m[2];
|
|
62
|
+
if (path.isAbsolute(p))
|
|
63
|
+
paths.push(p);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return paths.every((p) => fs.existsSync(p));
|
|
67
|
+
}
|
|
68
|
+
function unescapeXml(s) {
|
|
69
|
+
return s.replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, "&");
|
|
70
|
+
}
|
|
71
|
+
/** ProgramArguments from a launchd plist, or [] when there are none. */
|
|
72
|
+
export function plistProgramArguments(xml) {
|
|
73
|
+
const array = /<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/.exec(xml)?.[1];
|
|
74
|
+
if (!array)
|
|
75
|
+
return [];
|
|
76
|
+
return [...array.matchAll(/<string>([\s\S]*?)<\/string>/g)].map((m) => unescapeXml(m[1]));
|
|
77
|
+
}
|
|
78
|
+
/** ExecStart's words from a systemd unit, undoing the quoting renderUnit applies. */
|
|
79
|
+
export function unitExecStart(unit) {
|
|
80
|
+
const line = /^ExecStart=(.*)$/m.exec(unit)?.[1];
|
|
81
|
+
if (!line)
|
|
82
|
+
return [];
|
|
83
|
+
return [...line.matchAll(/"((?:[^"\\]|\\.)*)"|(\S+)/g)].map((m) => m[1] !== undefined ? m[1].replace(/\$\$/g, "$").replace(/%%/g, "%").replace(/\\(.)/g, "$1") : m[2]);
|
|
84
|
+
}
|
|
85
|
+
/** Program and arguments from the Windows launcher script renderLauncher writes. */
|
|
86
|
+
export function launcherProgram(ps1) {
|
|
87
|
+
const program = /-FilePath '((?:[^']|'')*)'/.exec(ps1)?.[1]?.replace(/''/g, "'");
|
|
88
|
+
if (!program)
|
|
89
|
+
return [];
|
|
90
|
+
const list = /^\$routerArgs = @\((.*)\)$/m.exec(ps1)?.[1] ?? "";
|
|
91
|
+
const args = [...list.matchAll(/'((?:[^']|'')*)'/g)].map((m) => m[1].replace(/''/g, "'").replace(/^"(.*)"$/, "$1"));
|
|
92
|
+
return [program, ...args];
|
|
93
|
+
}
|
|
94
|
+
function readText(file) {
|
|
95
|
+
try {
|
|
96
|
+
return fs.readFileSync(file, "utf8");
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
function view(definition, registered, current, state, pid) {
|
|
103
|
+
const [program = null, ...args] = definition;
|
|
104
|
+
return { registered, program, args, programExists: targetsExist(program, args), state, pid, current };
|
|
105
|
+
}
|
|
106
|
+
/** launchd, with an injectable launchctl so tests never reach the real one. */
|
|
107
|
+
export function launchdSupervisor(reg, home, run = launchd.launchctl) {
|
|
108
|
+
const stdoutLog = path.join(home, "logs", "launchd.log");
|
|
109
|
+
const opts = { program: reg.program, args: reg.args, bundleId: reg.bundleId, home, env: reg.env };
|
|
110
|
+
return {
|
|
111
|
+
inspect() {
|
|
112
|
+
const text = readText(launchd.plistPath());
|
|
113
|
+
const bundleCurrent = !reg.bundle || readText(launcherPath(home)) === launcherScript({ home, node: reg.bundle.node, script: reg.bundle.script });
|
|
114
|
+
const current = text !== null && text === launchd.renderPlist({ ...opts, stdoutLog }) && bundleCurrent;
|
|
115
|
+
return view(text ? plistProgramArguments(text) : [], text !== null, current, launchd.agentState(run), launchd.agentPid(run));
|
|
116
|
+
},
|
|
117
|
+
register() {
|
|
118
|
+
if (reg.bundle)
|
|
119
|
+
writeBundle({ home, node: reg.bundle.node, script: reg.bundle.script, version: reg.bundle.version });
|
|
120
|
+
return launchd.installAgent(opts, run);
|
|
121
|
+
},
|
|
122
|
+
start: () => launchd.startAgent(run),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
export function systemdSupervisor(reg, home) {
|
|
126
|
+
const opts = { program: reg.program, args: reg.args, home, env: reg.env };
|
|
127
|
+
return {
|
|
128
|
+
inspect() {
|
|
129
|
+
const text = readText(systemd.unitPath());
|
|
130
|
+
return view(text ? unitExecStart(text) : [], text !== null, text === systemd.renderUnit(opts), systemd.agentState(), systemd.agentPid());
|
|
131
|
+
},
|
|
132
|
+
register: () => systemd.installAgent(opts),
|
|
133
|
+
start: () => systemd.startAgent(),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
export function schtasksSupervisor(reg, home) {
|
|
137
|
+
const opts = { program: reg.program, args: reg.args, home, env: reg.env };
|
|
138
|
+
const launcher = path.join(home, "clauderipple-router.ps1");
|
|
139
|
+
return {
|
|
140
|
+
inspect() {
|
|
141
|
+
const text = readText(launcher);
|
|
142
|
+
const registered = schtasks.existingTaskDefinition() !== null;
|
|
143
|
+
return view(text ? launcherProgram(text) : [], registered, registered && text === schtasks.renderLauncher(opts), schtasks.agentState(), schtasks.agentPid());
|
|
144
|
+
},
|
|
145
|
+
// installAgent keeps an existing task whose whole definition already matches (issue #9).
|
|
146
|
+
register: () => schtasks.installAgent(opts),
|
|
147
|
+
start: () => schtasks.startAgent(),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
/** This machine's supervisor. Chosen by the real platform, never a spoofed one. */
|
|
151
|
+
export function nativeSupervisor(reg, home) {
|
|
152
|
+
if (process.platform === "win32")
|
|
153
|
+
return schtasksSupervisor(reg, home);
|
|
154
|
+
if (process.platform === "linux")
|
|
155
|
+
return systemdSupervisor(reg, home);
|
|
156
|
+
return launchdSupervisor(reg, home);
|
|
157
|
+
}
|
package/dist/cli/src/schtasks.js
CHANGED
|
@@ -31,6 +31,8 @@ function run(script) {
|
|
|
31
31
|
env: windowsPowerShellEnv(),
|
|
32
32
|
stdio: ["ignore", "pipe", "pipe"],
|
|
33
33
|
windowsHide: true,
|
|
34
|
+
// A hung PowerShell must not hang doctor/repair, which the menu-bar app waits on.
|
|
35
|
+
timeout: 60_000,
|
|
34
36
|
}).toString();
|
|
35
37
|
return { ok: true, out: out.trim() };
|
|
36
38
|
}
|
package/dist/cli/src/settings.js
CHANGED
|
@@ -157,6 +157,30 @@ export function removeProxyEnv(opts) {
|
|
|
157
157
|
write(file, next);
|
|
158
158
|
return { changed: true, backup: b, notes, wroteSlots: [] };
|
|
159
159
|
}
|
|
160
|
+
/**
|
|
161
|
+
* Applies `mutate` to a copy of settings.json's env block and writes it back (with a backup) only
|
|
162
|
+
* when it reports a change. Every other key in the file is kept as it was; an emptied env block is
|
|
163
|
+
* dropped. For callers that own a precise set of keys, such as `direct on|off`.
|
|
164
|
+
*/
|
|
165
|
+
export function editSettingsEnv(mutate) {
|
|
166
|
+
const file = settingsPath();
|
|
167
|
+
const s = readSettings(file);
|
|
168
|
+
const env = { ...(s.env ?? {}) };
|
|
169
|
+
if (!mutate(env))
|
|
170
|
+
return { changed: false, backup: null, notes: [], wroteSlots: [] };
|
|
171
|
+
const b = backup(file);
|
|
172
|
+
const next = { ...s };
|
|
173
|
+
if (Object.keys(env).length === 0)
|
|
174
|
+
delete next.env;
|
|
175
|
+
else
|
|
176
|
+
next.env = env;
|
|
177
|
+
write(file, next);
|
|
178
|
+
return { changed: true, backup: b, notes: [], wroteSlots: [] };
|
|
179
|
+
}
|
|
180
|
+
/** settings.json's whole env block (empty when the file or the block is missing). */
|
|
181
|
+
export function settingsEnv() {
|
|
182
|
+
return { ...(readSettings(settingsPath()).env ?? {}) };
|
|
183
|
+
}
|
|
160
184
|
export function currentProxyEnv() {
|
|
161
185
|
const env = readSettings(settingsPath()).env ?? {};
|
|
162
186
|
const out = {};
|
package/dist/cli/src/tray.js
CHANGED
|
@@ -58,10 +58,13 @@ export function trayEnv(env = process.env) {
|
|
|
58
58
|
*/
|
|
59
59
|
export async function startTray(options = {}) {
|
|
60
60
|
const current = runtime();
|
|
61
|
+
// Started by hand (the app icon, `clauderipple tray`), the window opens; started by the logon
|
|
62
|
+
// supervisor in the foreground, it stays in the tray. The login item launches Electron directly.
|
|
63
|
+
const env = { ...trayEnv(), ...((options.detached ?? true) ? { CLAUDERIPPLE_APP_SHOW: "1" } : {}) };
|
|
61
64
|
if (current.packaged) {
|
|
62
65
|
// The packaged app IS the tray: relaunching its own binary with no script is what opens it.
|
|
63
66
|
// Our own CLI runs under ELECTRON_RUN_AS_NODE here, and inheriting it would relaunch Node.
|
|
64
|
-
const child = spawn(current.node, [], { detached: true, stdio: "ignore", env
|
|
67
|
+
const child = spawn(current.node, [], { detached: true, stdio: "ignore", env });
|
|
65
68
|
child.unref();
|
|
66
69
|
return { ok: true, message: "✓ tray started" };
|
|
67
70
|
}
|
|
@@ -85,7 +88,7 @@ export async function startTray(options = {}) {
|
|
|
85
88
|
detached: options.detached ?? true,
|
|
86
89
|
stdio: ["ignore", out, out],
|
|
87
90
|
// Electron would otherwise re-enter as a plain Node process, since that is how the CLI runs.
|
|
88
|
-
env
|
|
91
|
+
env,
|
|
89
92
|
});
|
|
90
93
|
fs.closeSync(out);
|
|
91
94
|
if (options.detached ?? true)
|