clauderipple 0.9.4 → 0.10.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 +80 -0
- package/README.md +31 -0
- package/dist/app/package.json +1 -1
- package/dist/cli/src/hooks/agent-title.js +7 -4
- package/dist/cli/src/index.js +43 -0
- package/dist/cli/src/remote.js +263 -0
- package/dist/cli/src/schtasks.js +48 -40
- package/dist/cli/src/settings.js +2 -1
- package/dist/mod/hooks/i18n.ts +252 -6
- package/dist/mod/hooks/judge.ts +35 -17
- package/dist/mod/hooks/modes.ts +56 -1
- package/dist/mod/hooks/register.tsx +238 -32
- package/dist/mod/hooks/session.ts +98 -1
- package/dist/mod/hooks/settings.js +7 -3
- package/dist/mod/hooks/settings.ts +9 -4
- package/dist/mod/hooks/stats.ts +25 -1
- package/dist/mod/hooks/ui/agents.tsx +858 -79
- package/dist/mod/hooks/ui/alerts.tsx +20 -17
- package/dist/mod/hooks/ui/art.ts +33 -7
- package/dist/mod/hooks/ui/band.tsx +262 -81
- package/dist/mod/hooks/ui/flat.ts +265 -0
- package/dist/mod/hooks/ui/kit.tsx +70 -55
- package/dist/mod/hooks/ui/look.ts +129 -0
- package/dist/mod/hooks/ui/settings.tsx +71 -46
- package/dist/mod/hooks/ui/setup.tsx +15 -13
- package/dist/mod/hooks/ui/theme.ts +3 -0
- package/dist/mod/hooks/ui/tools.ts +124 -0
- package/dist/mod/hooks/ui/view.ts +116 -3
- package/dist/mod/hooks/workers.ts +33 -6
- package/dist/mod/tests/register.test.tsx +155 -11
- package/dist/mod/tests/ui.test.tsx +375 -27
- package/dist/mod/types/index.d.ts +68 -2
- package/dist/remote/package.json +28 -0
- package/dist/remote/src/away.js +85 -0
- package/dist/remote/src/bridge-client.js +295 -0
- package/dist/remote/src/composer-menu.js +11 -0
- package/dist/remote/src/config.js +102 -0
- package/dist/remote/src/desktop.js +1018 -0
- package/dist/remote/src/e2ee.js +126 -0
- package/dist/remote/src/e2ee.ts +126 -0
- package/dist/remote/src/eventlog.js +298 -0
- package/dist/remote/src/expo-push.js +127 -0
- package/dist/remote/src/folders.js +79 -0
- package/dist/remote/src/fsutil.js +39 -0
- package/dist/remote/src/handoff.js +38 -0
- package/dist/remote/src/host-service.js +168 -0
- package/dist/remote/src/images.js +215 -0
- package/dist/remote/src/local-control.js +138 -0
- package/dist/remote/src/main.js +264 -0
- package/dist/remote/src/permissions.js +124 -0
- package/dist/remote/src/power.js +76 -0
- package/dist/remote/src/processes.js +24 -0
- package/dist/remote/src/protocol.js +32 -0
- package/dist/remote/src/protocol.ts +284 -0
- package/dist/remote/src/push-content.js +32 -0
- package/dist/remote/src/push.js +134 -0
- package/dist/remote/src/receipts.js +111 -0
- package/dist/remote/src/relay.js +438 -0
- package/dist/remote/src/router.js +208 -0
- package/dist/remote/src/sdk-launcher.js +18 -0
- package/dist/remote/src/server.js +706 -0
- package/dist/remote/src/service.js +157 -0
- package/dist/remote/src/sessions.js +1800 -0
- package/dist/remote/src/sidebar-storage.js +211 -0
- package/dist/remote/src/sidebar.js +173 -0
- package/dist/remote/src/tool-details.js +205 -0
- package/dist/remote/src/tool-details.ts +153 -0
- package/dist/remote/src/worktree.js +99 -0
- package/dist/remote/web/app.ts +746 -0
- package/dist/remote/web/conn.ts +193 -0
- package/dist/remote/web/demo.ts +134 -0
- package/dist/remote/web/dom.ts +124 -0
- package/dist/remote/web/esc.ts +4 -0
- package/dist/remote/web/i18n.ts +374 -0
- package/dist/remote/web/icon.svg +1 -0
- package/dist/remote/web/index.html +24 -0
- package/dist/remote/web/manifest.webmanifest +10 -0
- package/dist/remote/web/mascot.ts +60 -0
- package/dist/remote/web/md.ts +78 -0
- package/dist/remote/web/notify.ts +55 -0
- package/dist/remote/web/pushstate.ts +27 -0
- package/dist/remote/web/secure.ts +109 -0
- package/dist/remote/web/sidebar.ts +106 -0
- package/dist/remote/web/style.css +311 -0
- package/dist/remote/web/sw.ts +48 -0
- package/dist/remote/web/timeline.ts +224 -0
- package/dist/remote/web/views.ts +323 -0
- package/dist/router/src/admin.js +227 -4
- package/dist/router/src/config.js +30 -0
- package/dist/router/src/index.js +39 -0
- package/dist/router/src/ingress/translate.js +6 -2
- package/dist/router/src/phone.js +82 -0
- package/dist/router/src/presets.js +3 -0
- package/dist/router/src/providers/anthropic-usage.js +33 -1
- package/dist/router/src/providers/chatgpt/index.js +41 -11
- package/dist/router/src/providers/chatgpt/translate.js +147 -7
- package/dist/router/src/providers/google/index.js +26 -0
- package/dist/router/src/providers/google/prompt-refusal.js +29 -0
- package/dist/router/src/providers/google/usage.js +257 -0
- package/dist/router/src/providers/grok/index.js +18 -0
- package/dist/router/src/providers/grok/usage.js +77 -0
- package/dist/router/src/providers/openai/index.js +29 -5
- package/dist/router/src/providers/openai/translate.js +260 -27
- package/dist/router/src/providers/opencode-usage.js +116 -0
- package/dist/router/src/proxy.js +339 -258
- package/dist/router/src/reasoning.js +157 -0
- package/dist/router/src/remote-host.js +320 -0
- package/dist/router/src/remote-tunnel.js +320 -0
- package/dist/router/src/requestlog.js +48 -39
- package/dist/router/src/thinking.js +20 -9
- package/dist/router/src/usage-history.js +227 -0
- package/dist/router/src/version.js +1 -1
- package/dist/ui/app.js +436 -59
- package/dist/ui/i18n.js +184 -0
- package/dist/ui/index.html +24 -5
- package/dist/ui/style.css +39 -0
- package/docs/ARCHITECTURE.md +164 -12
- package/package.json +7 -1
|
@@ -25,6 +25,9 @@ export type StatusRequest = {
|
|
|
25
25
|
|
|
26
26
|
export type Effort = 'low' | 'medium' | 'high' | 'xhigh' | 'max'
|
|
27
27
|
|
|
28
|
+
/** The desktop's skins: the deep (dark water, the first look) and flat (the app's own surfaces). */
|
|
29
|
+
export type Skin = 'deep' | 'flat'
|
|
30
|
+
|
|
28
31
|
/** API usage may omit counts; the engine's ModelUsage does not expose cache TTL evidence. */
|
|
29
32
|
export type Usage = {
|
|
30
33
|
input_tokens?: number
|
|
@@ -183,6 +186,8 @@ export type Settings = {
|
|
|
183
186
|
swampAlert: boolean
|
|
184
187
|
layout: 'dashboard' | 'minimal'
|
|
185
188
|
appearance: 'auto' | 'dark' | 'light'
|
|
189
|
+
/** How the desktop draws the band and the cards: the deep or flat (ui/look.ts). */
|
|
190
|
+
skin: Skin
|
|
186
191
|
language: 'auto' | Lang
|
|
187
192
|
/** Band parts switched off: 'timer' | 'reason' | 'route'. */
|
|
188
193
|
hide: string[]
|
|
@@ -210,7 +215,14 @@ export type Verdict = { effort: Effort; sure?: number; why: string }
|
|
|
210
215
|
export type Pick = Verdict & { by: JudgeKind | 'manual' | 'followup'; at: number }
|
|
211
216
|
|
|
212
217
|
/** A judge's answer for a delegated task; a judge that names no kind means do. */
|
|
213
|
-
export type WorkerVerdict = {
|
|
218
|
+
export type WorkerVerdict = {
|
|
219
|
+
domain: Domain; level: Level; kind?: Kind; effort: Effort
|
|
220
|
+
/** Confidence in the level, the one that moves a task up the ladder. */
|
|
221
|
+
sure?: number
|
|
222
|
+
/** Jev's confidence in each answer, kept in the pick log so a rubric change can be compared. */
|
|
223
|
+
odds?: { domain?: number; level?: number; kind?: number; effort?: number }
|
|
224
|
+
why: string
|
|
225
|
+
}
|
|
214
226
|
|
|
215
227
|
/** What a spawn of ripple:auto (or a taken-over spawn) was turned into. */
|
|
216
228
|
export type WorkerPick = WorkerVerdict & {
|
|
@@ -220,10 +232,13 @@ export type WorkerPick = WorkerVerdict & {
|
|
|
220
232
|
position?: number
|
|
221
233
|
skipped?: { rung: Rung; position: number; model: string; reason: string }[]
|
|
222
234
|
target: TierTarget
|
|
223
|
-
|
|
235
|
+
/** Who named the tier: the judge, the main chat's tier marker (`session`), a model marker, or the default. */
|
|
236
|
+
by: JudgeKind | 'session' | 'marker' | 'default'
|
|
224
237
|
ms: number
|
|
225
238
|
/** Set when the tier's own agent could not run and the fallback did. */
|
|
226
239
|
fallbackReason?: string
|
|
240
|
+
/** Joins the pick's lines in the pick log: the pick, the judge's shadow verdict, the outcome. */
|
|
241
|
+
id?: string
|
|
227
242
|
}
|
|
228
243
|
|
|
229
244
|
export type AgentState = 'running' | 'waiting' | 'done' | 'failed'
|
|
@@ -243,6 +258,19 @@ export type AgentRec = {
|
|
|
243
258
|
endedAt?: number
|
|
244
259
|
/** Weighted tokens (spec §5 stats weights). */
|
|
245
260
|
cost: number
|
|
261
|
+
/** The provider it runs on by config name (`anthropic` for Claude's own), for its card's dot. */
|
|
262
|
+
provider?: string
|
|
263
|
+
/**
|
|
264
|
+
* The effort its last step was sent at: the model id's `@effort` where it carries one (the router
|
|
265
|
+
* forces that, packages/router/src/routing.ts), else the request's own effort.
|
|
266
|
+
*/
|
|
267
|
+
effort?: string | number
|
|
268
|
+
/** The model that answered its last step, by the id the API reported (TurnUsage `model`). */
|
|
269
|
+
answeredBy?: string
|
|
270
|
+
/** Its steps' token counts as the API reported them, summed; the shape of StatusRequest's usage. */
|
|
271
|
+
usage?: { input: number; cached: number; cacheWrite: number; output: number }
|
|
272
|
+
/** Tool calls it started. */
|
|
273
|
+
tools?: number
|
|
246
274
|
}
|
|
247
275
|
|
|
248
276
|
/** The main chat's prompt cache as far as its responses show it. */
|
|
@@ -265,6 +293,32 @@ export type WarmState = {
|
|
|
265
293
|
/** The hottest plan window at or above the alert line. */
|
|
266
294
|
export type Hot = { kind: string; percent: number; resetsAt?: string }
|
|
267
295
|
|
|
296
|
+
/**
|
|
297
|
+
* One plan window for the band's second row, the limits row: every window of every provider whose
|
|
298
|
+
* quota the router can read (Claude's plan windows and per-model weeklies, each ChatGPT account's
|
|
299
|
+
* windows, each Antigravity account's Gemini and third-party windows). The row reads them all from
|
|
300
|
+
* `GET /api/limits` (falling back to the older status routes on a router that predates it).
|
|
301
|
+
* `provider` is the provider's config name (the grouping key; 'claude' on the fallback path);
|
|
302
|
+
* `type` is its kind ('anthropic', 'chatgpt', 'google', …), which picks the group head's colour;
|
|
303
|
+
* `title` is the head's name ("Claude", "GPT", "Gemini"); `account` numbers the provider's eligible
|
|
304
|
+
* accounts from 0 (the row reads "GPT" alone, or "GPT 1", "GPT 2", … when a provider has several);
|
|
305
|
+
* `kind` is 'five_hour', 'seven_day', 'scoped' (a per-model/third-party window, its name in `label`)
|
|
306
|
+
* or 'window' (a window of another length, its minutes in `windowMinutes`).
|
|
307
|
+
*/
|
|
308
|
+
export type LimitItem = {
|
|
309
|
+
provider: string
|
|
310
|
+
/** The provider's kind, for the head's colour. Absent on an older item, the name stands in. */
|
|
311
|
+
type?: string
|
|
312
|
+
/** The head's name; absent, derived from the provider's type or name. */
|
|
313
|
+
title?: string
|
|
314
|
+
account: number
|
|
315
|
+
kind: 'five_hour' | 'seven_day' | 'scoped' | 'window'
|
|
316
|
+
label: string
|
|
317
|
+
windowMinutes?: number
|
|
318
|
+
percent: number
|
|
319
|
+
resetsAt?: string
|
|
320
|
+
}
|
|
321
|
+
|
|
268
322
|
export type Stats = {
|
|
269
323
|
prompts: number
|
|
270
324
|
requests: number
|
|
@@ -312,6 +366,8 @@ declare module 'claude-code' {
|
|
|
312
366
|
context: { percent: number; tokens: number; window: number } | null
|
|
313
367
|
hot: Hot | null
|
|
314
368
|
hotHidden: number | null
|
|
369
|
+
/** The window the hot card was put off for: the put-off lasts until that window moves on. */
|
|
370
|
+
hotHiddenFor: Hot | null
|
|
315
371
|
saveUntil: number | null
|
|
316
372
|
swampHidden: number | null
|
|
317
373
|
coldHidden: boolean
|
|
@@ -342,6 +398,14 @@ declare module 'claude-code' {
|
|
|
342
398
|
effortMenuOpen: boolean
|
|
343
399
|
agentsOpen: boolean
|
|
344
400
|
agentsShowDone: boolean
|
|
401
|
+
/** The agent whose conversation the Agents pane shows; null shows the list. */
|
|
402
|
+
agentOpen: string | null
|
|
403
|
+
/** Pages of earlier entries the open conversation shows beyond its latest. */
|
|
404
|
+
agentEarlier: number
|
|
405
|
+
/** The open conversation's tool runs shown call by call, by their first call's id. */
|
|
406
|
+
agentRunsOpen: string[]
|
|
407
|
+
/** Counts the Agents pane's seconds while an agent runs: a write redraws its clocks. */
|
|
408
|
+
agentsTick: number
|
|
345
409
|
jev: JevState
|
|
346
410
|
routerModels: RouterModel[]
|
|
347
411
|
/** The router's rung choices and presets (GET /api/mod-options), for the quick settings. */
|
|
@@ -354,6 +418,8 @@ declare module 'claude-code' {
|
|
|
354
418
|
judgeRuns: JudgeRun[]
|
|
355
419
|
judgeDownNetwork: boolean
|
|
356
420
|
limit: Hot | null
|
|
421
|
+
/** Every provider's plan windows for the limits row, from `GET /api/limits`. Null until read. */
|
|
422
|
+
limits: LimitItem[] | null
|
|
357
423
|
demo: 'cold' | 'swamp' | 'hot' | 'down' | 'kept' | null
|
|
358
424
|
}
|
|
359
425
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@clauderipple/remote",
|
|
3
|
+
"version": "0.8.10",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "Ripple Remote: a host on this machine that runs Claude Code sessions in any folder on any routed model, and a phone-first web app that drives them.",
|
|
7
|
+
"license": "GPL-3.0-only",
|
|
8
|
+
"scripts": { "test": "node --test test/*.test.ts" },
|
|
9
|
+
"bin": {
|
|
10
|
+
"ripple-host": "./src/main.ts"
|
|
11
|
+
},
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=24"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@anthropic-ai/claude-agent-sdk": "0.3.283",
|
|
17
|
+
"qrcode-generator": "2.0.4",
|
|
18
|
+
"tweetnacl": "1.0.3",
|
|
19
|
+
"web-push": "3.6.7",
|
|
20
|
+
"ws": "8.21.3",
|
|
21
|
+
"zod": "4.1.12"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/web-push": "3.6.4",
|
|
25
|
+
"@types/ws": "8.18.1",
|
|
26
|
+
"wrangler": "4.142.0"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Whether this Mac is "away" — its screen locked, or no keyboard/mouse input for a while. It is
|
|
2
|
+
// the gate for pushes about Mac sessions the host is not tailing (Sessions' onAlert path): the
|
|
3
|
+
// person is at the desk while these are false, so a phone buzz would only be noise. Probe failures
|
|
4
|
+
// answer "here", never "away": a wrong push is worse than a missed one.
|
|
5
|
+
import { execFile } from "node:child_process";
|
|
6
|
+
/** No input for this long counts as away, matching the phone's own idle threshold (Paseo's PRESENCE_THRESHOLD_MS). */
|
|
7
|
+
export const IDLE_MS = 180_000;
|
|
8
|
+
const CACHE_MS = 10_000;
|
|
9
|
+
const TIMEOUT_MS = 2_000;
|
|
10
|
+
const MAX_BUFFER = 4 * 1024 * 1024;
|
|
11
|
+
const run = (cmd, args) => new Promise((resolve, reject) => {
|
|
12
|
+
execFile(cmd, args, { timeout: TIMEOUT_MS, maxBuffer: MAX_BUFFER }, (error, stdout) => error ? reject(error) : resolve(stdout));
|
|
13
|
+
});
|
|
14
|
+
/**
|
|
15
|
+
* Whether the console session's dictionary carries `CGSSessionScreenIsLocked` true.
|
|
16
|
+
* Measured on this Mac (Darwin 27, unlocked): the key is absent altogether, not `<false/>`, so only
|
|
17
|
+
* a `<true/>` inside the console user's entry is a lock. Nothing parses as a lock means no lock.
|
|
18
|
+
*/
|
|
19
|
+
export function screenLocked(plist) {
|
|
20
|
+
const start = plist.indexOf("<key>IOConsoleUsers</key>");
|
|
21
|
+
if (start < 0)
|
|
22
|
+
return false;
|
|
23
|
+
const end = plist.indexOf("</array>", start);
|
|
24
|
+
const body = plist.slice(start, end < 0 ? plist.length : end);
|
|
25
|
+
for (const entry of body.split("<dict>").slice(1)) {
|
|
26
|
+
const close = entry.indexOf("</dict>");
|
|
27
|
+
const session = close < 0 ? entry : entry.slice(0, close);
|
|
28
|
+
if (!/<key>kCGSSessionOnConsoleKey<\/key>\s*<true\s*\/>/.test(session))
|
|
29
|
+
continue;
|
|
30
|
+
return /<key>CGSSessionScreenIsLocked<\/key>\s*<true\s*\/>/.test(session);
|
|
31
|
+
}
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
/** `HIDIdleTime` from `ioreg -c IOHIDSystem`, in milliseconds, or null when the line is missing. */
|
|
35
|
+
export function hidIdleMs(ioreg) {
|
|
36
|
+
const match = /"HIDIdleTime"\s*=\s*(\d+)/.exec(ioreg);
|
|
37
|
+
return match ? Number(match[1]) / 1_000_000 : null;
|
|
38
|
+
}
|
|
39
|
+
/** Both readings are `ioreg`; the answer is cached so a poll every few seconds probes once per CACHE_MS. */
|
|
40
|
+
export class MacAway {
|
|
41
|
+
runner;
|
|
42
|
+
platform;
|
|
43
|
+
now;
|
|
44
|
+
log;
|
|
45
|
+
at = -Infinity;
|
|
46
|
+
value = null;
|
|
47
|
+
pending = null;
|
|
48
|
+
/** The last probe failed; cleared by a probe that answers. One log line per failing streak. */
|
|
49
|
+
failed = false;
|
|
50
|
+
constructor(opts = {}) {
|
|
51
|
+
this.runner = opts.runner ?? run;
|
|
52
|
+
this.platform = opts.platform ?? process.platform;
|
|
53
|
+
this.now = opts.now ?? Date.now;
|
|
54
|
+
this.log = opts.log;
|
|
55
|
+
}
|
|
56
|
+
async away() {
|
|
57
|
+
if (this.platform !== "darwin")
|
|
58
|
+
return false;
|
|
59
|
+
const now = this.now();
|
|
60
|
+
if (this.value !== null && now - this.at < CACHE_MS)
|
|
61
|
+
return this.value;
|
|
62
|
+
// One probe at a time: a second caller rides the in-flight one instead of reading the old value.
|
|
63
|
+
this.at = now;
|
|
64
|
+
this.pending ??= this.probe().then(value => { this.value = value; return value; }).finally(() => { this.pending = null; });
|
|
65
|
+
return this.pending;
|
|
66
|
+
}
|
|
67
|
+
async probe() {
|
|
68
|
+
try {
|
|
69
|
+
const [lock, hid] = await Promise.all([
|
|
70
|
+
this.runner("/usr/sbin/ioreg", ["-n", "Root", "-d1", "-a"]),
|
|
71
|
+
this.runner("/usr/sbin/ioreg", ["-c", "IOHIDSystem"]),
|
|
72
|
+
]);
|
|
73
|
+
const idle = hidIdleMs(hid);
|
|
74
|
+
this.failed = false;
|
|
75
|
+
return screenLocked(lock) || (idle !== null && idle >= IDLE_MS);
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
if (!this.failed) {
|
|
79
|
+
this.failed = true;
|
|
80
|
+
this.log?.(`away: probe failed (${error instanceof Error ? error.message : String(error)}) — treated as here`);
|
|
81
|
+
}
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
// Client-only Remote Control transport. Never register or attach a desktop worker.
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
7
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
8
|
+
export const DESKTOP_BLOCKED = "Claude 앱에서 열려 있는 세션에 지금은 보낼 수 없습니다.";
|
|
9
|
+
export const LOGIN_EXPIRED = "이 컴퓨터의 Claude 로그인이 만료되었습니다. Claude 앱을 한 번 열어 주십시오.";
|
|
10
|
+
export const LOGIN_REFUSED = "이 컴퓨터의 Claude 로그인이 거절되었습니다 (401). Claude 앱을 한 번 열어 주십시오.";
|
|
11
|
+
class BridgeNetworkError extends Error {
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* This Mac's Claude login cannot be used (expired, unreadable, or refused): nothing reaches any
|
|
15
|
+
* Claude app session until Claude Code refreshes it, which the host never does itself. A state of
|
|
16
|
+
* the whole host, not of one session, so phones are told once rather than per request.
|
|
17
|
+
*/
|
|
18
|
+
export class BridgeAuthError extends Error {
|
|
19
|
+
}
|
|
20
|
+
/** While the login is expired, the keychain is read again at most this often (a child process each time). */
|
|
21
|
+
export const AUTH_REREAD_MS = 30_000;
|
|
22
|
+
export function object(value) {
|
|
23
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
24
|
+
}
|
|
25
|
+
export function loadBridgeAuth() {
|
|
26
|
+
if (process.platform !== "darwin")
|
|
27
|
+
throw new Error(DESKTOP_BLOCKED);
|
|
28
|
+
try {
|
|
29
|
+
const credentials = object(JSON.parse(execFileSync("security", ["find-generic-password", "-a", process.env.USER ?? os.userInfo().username, "-s", "Claude Code-credentials", "-w"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] })));
|
|
30
|
+
const oauth = object(credentials?.claudeAiOauth);
|
|
31
|
+
const account = object(object(JSON.parse(fs.readFileSync(path.join(os.homedir(), ".claude.json"), "utf8")))?.oauthAccount);
|
|
32
|
+
if (typeof oauth?.accessToken !== "string" || typeof oauth.expiresAt !== "number" || typeof account?.organizationUuid !== "string")
|
|
33
|
+
throw new Error();
|
|
34
|
+
return { accessToken: oauth.accessToken, expiresAt: oauth.expiresAt, organizationUuid: account.organizationUuid };
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
throw new BridgeAuthError("이 컴퓨터의 Claude 로그인 정보를 읽을 수 없습니다.");
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** `slash_commands` minus `terminal_slash_commands`, which phone UIs should hide (SDK init docs). */
|
|
41
|
+
export function slashCommands(init) {
|
|
42
|
+
if (!Array.isArray(init.slash_commands))
|
|
43
|
+
return null;
|
|
44
|
+
const terminal = new Set(Array.isArray(init.terminal_slash_commands) ? init.terminal_slash_commands : []);
|
|
45
|
+
return init.slash_commands.filter((c) => typeof c === "string" && !!c && !terminal.has(c));
|
|
46
|
+
}
|
|
47
|
+
export class BridgeClient {
|
|
48
|
+
fetchFn;
|
|
49
|
+
loadAuth;
|
|
50
|
+
cached = null;
|
|
51
|
+
readAt = 0;
|
|
52
|
+
now;
|
|
53
|
+
constructor(options = {}) {
|
|
54
|
+
this.fetchFn = options.fetch ?? fetch;
|
|
55
|
+
this.loadAuth = options.auth ?? loadBridgeAuth;
|
|
56
|
+
this.now = options.now ?? Date.now;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The keychain read is a synchronous child process; doing it per request blocked the host's
|
|
60
|
+
* event loop under the 3 s state poll until relay pings timed out (2026-10-12). Kept in memory
|
|
61
|
+
* and reread only near expiry or after a 401 (Claude Code refreshes the token on its own) — and
|
|
62
|
+
* near or past expiry at most every AUTH_REREAD_MS: an expired login otherwise meant one keychain
|
|
63
|
+
* read per request, every poll, for as long as it stayed expired (2026-10-15).
|
|
64
|
+
*/
|
|
65
|
+
auth() {
|
|
66
|
+
const now = this.now();
|
|
67
|
+
const stale = !this.cached || this.refused || this.cached.expiresAt - now < 5 * 60_000;
|
|
68
|
+
if (stale && (!this.cached || now - this.readAt >= AUTH_REREAD_MS)) {
|
|
69
|
+
this.readAt = now;
|
|
70
|
+
this.cached = this.loadAuth();
|
|
71
|
+
this.refused = false;
|
|
72
|
+
}
|
|
73
|
+
return this.cached;
|
|
74
|
+
}
|
|
75
|
+
/** The API answered 401 to the token in `cached`; cleared when the keychain is read again. */
|
|
76
|
+
refused = false;
|
|
77
|
+
async request(id, suffix, options) {
|
|
78
|
+
const auth = this.auth();
|
|
79
|
+
if (this.refused)
|
|
80
|
+
throw new BridgeAuthError(LOGIN_REFUSED);
|
|
81
|
+
if (auth.expiresAt <= this.now())
|
|
82
|
+
throw new BridgeAuthError(LOGIN_EXPIRED);
|
|
83
|
+
let response;
|
|
84
|
+
try {
|
|
85
|
+
// The bridge treats a prompt as the user's own only from a person's app platform
|
|
86
|
+
// (ios, android, web_claude_ai, desktop_app in CLI 2.1.296); any claude_code_* platform
|
|
87
|
+
// is demoted to "a message from another Claude session". These prompts are typed on a phone.
|
|
88
|
+
response = await this.fetchFn(`https://api.anthropic.com/v1/code/sessions/${encodeURIComponent(id)}${suffix}`, {
|
|
89
|
+
...options, redirect: "error", headers: { Authorization: `Bearer ${auth.accessToken}`, "anthropic-version": "2023-06-01", "anthropic-client-platform": "ios", "x-organization-uuid": auth.organizationUuid, "Content-Type": "application/json", "User-Agent": "RippleRemote/0.8.10" },
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
throw new BridgeNetworkError("Claude 앱과 연결하지 못했습니다.");
|
|
94
|
+
}
|
|
95
|
+
if (!response.ok) {
|
|
96
|
+
await response.body?.cancel();
|
|
97
|
+
if (response.status === 401) {
|
|
98
|
+
// Refused: the keychain is read again (Claude Code may have refreshed it) — at once if the
|
|
99
|
+
// last read is AUTH_REREAD_MS old, else then; until then requests fail here without a call.
|
|
100
|
+
if (this.now() - this.readAt >= AUTH_REREAD_MS)
|
|
101
|
+
this.cached = null;
|
|
102
|
+
else
|
|
103
|
+
this.refused = true;
|
|
104
|
+
throw new BridgeAuthError(LOGIN_REFUSED);
|
|
105
|
+
}
|
|
106
|
+
// Do not expose server bodies (which may echo credentials), or retry rejected writes.
|
|
107
|
+
throw new Error(`Claude 앱이 메시지를 받지 않았습니다 (${response.status}).`);
|
|
108
|
+
}
|
|
109
|
+
return response;
|
|
110
|
+
}
|
|
111
|
+
async post(id, payload) {
|
|
112
|
+
const response = await this.request(id, "/events", { method: "POST", body: JSON.stringify({ session_id: id, events: [{ payload }] }), signal: AbortSignal.timeout(15_000) });
|
|
113
|
+
await response.body?.cancel();
|
|
114
|
+
}
|
|
115
|
+
send(id, uuid, content) {
|
|
116
|
+
return this.post(id, { type: "user", uuid, message: { role: "user", content }, parent_tool_use_id: null });
|
|
117
|
+
}
|
|
118
|
+
interrupt(id) {
|
|
119
|
+
return this.post(id, { type: "control_request", request_id: randomUUID(), request: { subtype: "interrupt" } });
|
|
120
|
+
}
|
|
121
|
+
async session(id) {
|
|
122
|
+
const response = object(await (await this.request(id, "", { method: "GET", signal: AbortSignal.timeout(3000) })).json());
|
|
123
|
+
// CLI 2.1.296's single-session read uses response_shape ?? session.
|
|
124
|
+
const body = object(response?.response_shape ?? response?.session) ?? response;
|
|
125
|
+
const worker_status = body?.worker_status;
|
|
126
|
+
if (worker_status !== "running" && worker_status !== "idle" && worker_status !== "requires_action")
|
|
127
|
+
throw new Error("Remote Control session has no valid worker status.");
|
|
128
|
+
return { worker_status, ...(typeof body?.status === "string" ? { status: body.status } : {}),
|
|
129
|
+
...(typeof body?.status_bucket === "string" ? { status_bucket: body.status_bucket } : {}),
|
|
130
|
+
...(typeof body?.connection_status === "string" ? { connection_status: body.connection_status } : {}) };
|
|
131
|
+
}
|
|
132
|
+
async events(id) {
|
|
133
|
+
return object(await (await this.request(id, "/events?sort_order=asc", { signal: AbortSignal.timeout(15_000) })).json()) ?? {};
|
|
134
|
+
}
|
|
135
|
+
/** The session's slash commands, from the CLI's init event, without the terminal-only ones; null when it has none. */
|
|
136
|
+
async commands(id) {
|
|
137
|
+
const list = (await this.events(id)).data;
|
|
138
|
+
for (const value of Array.isArray(list) ? list : []) {
|
|
139
|
+
const payload = object(object(value)?.payload);
|
|
140
|
+
if (payload?.type === "system" && payload.subtype === "init")
|
|
141
|
+
return slashCommands(payload);
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Authorization requests still open on the session, oldest first. The stream starts at the
|
|
147
|
+
* newest event (`latest` below), so attach and every reconnect replay this list from the start
|
|
148
|
+
* cursor instead — otherwise an approval requested before the stream connected never cards.
|
|
149
|
+
* A request followed by its control_response (answered from another client) or
|
|
150
|
+
* control_cancel_request (withdrawn) is not open and is left out.
|
|
151
|
+
*/
|
|
152
|
+
async openRequests(id) {
|
|
153
|
+
const list = (await this.events(id)).data;
|
|
154
|
+
const open = new Map();
|
|
155
|
+
for (const value of Array.isArray(list) ? list : []) {
|
|
156
|
+
const item = object(value);
|
|
157
|
+
const payload = object(item?.payload);
|
|
158
|
+
if (!payload || typeof payload.request_id !== "string")
|
|
159
|
+
continue;
|
|
160
|
+
// Stored prompts carry the same source as stream frames; accept a missing one, never a foreign one.
|
|
161
|
+
const source = item?.source;
|
|
162
|
+
if (source !== undefined && source !== "worker")
|
|
163
|
+
continue;
|
|
164
|
+
const requestId = payload.request_id;
|
|
165
|
+
if (payload.type === "control_request") {
|
|
166
|
+
const request = object(payload.request);
|
|
167
|
+
const input = object(request?.input);
|
|
168
|
+
if (request?.subtype === "can_use_tool" && typeof request.tool_name === "string" && input && typeof request.tool_use_id === "string") {
|
|
169
|
+
open.set(requestId, { requestId, tool: request.tool_name, input, toolUseId: request.tool_use_id });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
else if (payload.type === "control_response" || payload.type === "control_cancel_request") {
|
|
173
|
+
open.delete(requestId);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return [...open.values()];
|
|
177
|
+
}
|
|
178
|
+
/** The newest durable event's number; the stream starts there instead of replaying the whole session. */
|
|
179
|
+
async latest(id, signal) {
|
|
180
|
+
const body = object(await (await this.request(id, "/events?sort_order=desc&limit=1", { signal })).json());
|
|
181
|
+
const first = Array.isArray(body?.data) ? object(body.data[0]) : null;
|
|
182
|
+
return sequenceOf(first?.sequence_num) ?? 0;
|
|
183
|
+
}
|
|
184
|
+
async stream(id, signal, handle, onConnect) {
|
|
185
|
+
let sequence = null;
|
|
186
|
+
while (!signal.aborted) {
|
|
187
|
+
if (sequence === null) {
|
|
188
|
+
try {
|
|
189
|
+
sequence = await this.latest(id, signal);
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
if (signal.aborted)
|
|
193
|
+
return;
|
|
194
|
+
if (!(error instanceof BridgeNetworkError))
|
|
195
|
+
throw error;
|
|
196
|
+
await delay(1000, undefined, { signal }).catch(error => { if (!signal.aborted)
|
|
197
|
+
throw error; });
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
let response;
|
|
202
|
+
try {
|
|
203
|
+
response = await this.request(id, `/events/stream?from_sequence_num=${sequence}`, { signal });
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
if (signal.aborted)
|
|
207
|
+
return;
|
|
208
|
+
if (!(error instanceof BridgeNetworkError))
|
|
209
|
+
throw error;
|
|
210
|
+
await delay(1000, undefined, { signal }).catch(error => { if (!signal.aborted)
|
|
211
|
+
throw error; });
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (!response.body)
|
|
215
|
+
throw new Error("Remote Control stream is empty.");
|
|
216
|
+
// Connected (or reconnected): the caller replays approvals already waiting, which the
|
|
217
|
+
// newest-event cursor above would otherwise skip.
|
|
218
|
+
onConnect?.();
|
|
219
|
+
let handling = false;
|
|
220
|
+
try {
|
|
221
|
+
for await (const frame of decodeSse(response.body)) {
|
|
222
|
+
handling = true;
|
|
223
|
+
// Only live permission prompts come from here; the conversation itself is read from the transcript.
|
|
224
|
+
if (frame.event === "catch_up_truncated") {
|
|
225
|
+
handling = false;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
const number = sequenceOf(frame.data.sequence_num);
|
|
229
|
+
if (frame.event === "client_event" && number !== undefined) {
|
|
230
|
+
if (number <= sequence) {
|
|
231
|
+
handling = false;
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
handle(frame);
|
|
235
|
+
sequence = number;
|
|
236
|
+
}
|
|
237
|
+
else if (frame.event === "ephemeral_event")
|
|
238
|
+
handle(frame);
|
|
239
|
+
handling = false;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
catch (error) {
|
|
243
|
+
if (signal.aborted)
|
|
244
|
+
return;
|
|
245
|
+
if (handling)
|
|
246
|
+
throw error;
|
|
247
|
+
// A broken read reconnects; HTTP rejection above is terminal. Writes are never retried.
|
|
248
|
+
}
|
|
249
|
+
await delay(1000, undefined, { signal }).catch(error => { if (!signal.aborted)
|
|
250
|
+
throw error; });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
/** The server sends sequence numbers as decimal strings ("9136"); tests and older shapes use numbers. */
|
|
255
|
+
function sequenceOf(value) {
|
|
256
|
+
const number = typeof value === "string" && /^\d+$/.test(value) ? Number(value) : value;
|
|
257
|
+
return typeof number === "number" && Number.isSafeInteger(number) && number >= 0 ? number : undefined;
|
|
258
|
+
}
|
|
259
|
+
export async function* decodeSse(body) {
|
|
260
|
+
const reader = body.getReader();
|
|
261
|
+
const decoder = new TextDecoder();
|
|
262
|
+
let pending = "", event = "", data = [];
|
|
263
|
+
try {
|
|
264
|
+
for (;;) {
|
|
265
|
+
const chunk = await reader.read();
|
|
266
|
+
if (chunk.done)
|
|
267
|
+
return;
|
|
268
|
+
pending += decoder.decode(chunk.value, { stream: true });
|
|
269
|
+
if (pending.length > 4 * 1024 * 1024)
|
|
270
|
+
throw new Error("Remote Control frame too large.");
|
|
271
|
+
let end;
|
|
272
|
+
while ((end = pending.indexOf("\n")) >= 0) {
|
|
273
|
+
const line = pending.slice(0, end).replace(/\r$/, "");
|
|
274
|
+
pending = pending.slice(end + 1);
|
|
275
|
+
if (line.startsWith("event:"))
|
|
276
|
+
event = line.slice(6).trim();
|
|
277
|
+
else if (line.startsWith("data:"))
|
|
278
|
+
data.push(line.slice(5).trimStart());
|
|
279
|
+
else if (!line) {
|
|
280
|
+
if (data.length) {
|
|
281
|
+
const parsed = object(JSON.parse(data.join("\n")));
|
|
282
|
+
if (parsed)
|
|
283
|
+
yield { event, data: parsed };
|
|
284
|
+
}
|
|
285
|
+
event = "";
|
|
286
|
+
data = [];
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
finally {
|
|
292
|
+
await reader.cancel().catch(() => undefined);
|
|
293
|
+
reader.releaseLock();
|
|
294
|
+
}
|
|
295
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// The composer's "+" menu beyond attachments: compact the conversation, or start fresh.
|
|
2
|
+
// Pure (no platform imports) so the send behaviour is unit-tested without the native runtime.
|
|
3
|
+
export const COMPOSER_MENU = [
|
|
4
|
+
{ id: "compact", ko: "대화 압축", en: "Compact conversation" },
|
|
5
|
+
{ id: "restart", ko: "새로 시작", en: "Start fresh", confirm: { ko: "지금 대화를 마치고 같은 설정으로 새 세션을 시작합니다.", en: "End this conversation and start fresh with the same settings." } },
|
|
6
|
+
];
|
|
7
|
+
/** The message an action sends. The host turns it into the SDK slash command; "/clear" is the
|
|
8
|
+
* fresh-session path it already handles, and "/compact" passes through to the CLI/bridge. */
|
|
9
|
+
export function composerCommand(id) {
|
|
10
|
+
return id === "compact" ? "/compact" : "/clear";
|
|
11
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Host settings and the pairing token, under ~/.clauderipple/remote (0600). The token is the
|
|
2
|
+
// only secret: whoever holds it can run Claude Code on this machine, so it is never logged,
|
|
3
|
+
// never put in a URL the server sees (the pairing link carries it in the #fragment), and
|
|
4
|
+
// compared in constant time.
|
|
5
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { ConfigStore, adminPort, configPath as routerConfigPath } from "../../router/src/config.js";
|
|
9
|
+
import { readJson, writeFileAtomic } from "./fsutil.js";
|
|
10
|
+
export const DEFAULT_PORT = 8797;
|
|
11
|
+
/** ClaudeRipple's own relay: what phone access turns back on with after it was switched off. */
|
|
12
|
+
export const DEFAULT_RELAY = "https://ripple-relay.clauderipple.workers.dev";
|
|
13
|
+
export function remoteHome() {
|
|
14
|
+
return process.env.RIPPLE_REMOTE_HOME ?? path.join(os.homedir(), ".clauderipple", "remote");
|
|
15
|
+
}
|
|
16
|
+
export function newToken() {
|
|
17
|
+
return randomBytes(32).toString("base64url");
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Where the router's admin API is listening, read from the router's own config. The admin API sits
|
|
21
|
+
* beside the proxy listener: the router derives its port as `admin.port ?? listen.port + 1`, so a
|
|
22
|
+
* default install is 8791 while this Mac (listen 8791) is 8792. Hardcoding 8792 only worked on this
|
|
23
|
+
* machine; on a default Windows install the host hit a dead port (`/api/status: HTTP 404`).
|
|
24
|
+
*/
|
|
25
|
+
export function routerAdminUrl(configFile = routerConfigPath()) {
|
|
26
|
+
return `http://127.0.0.1:${adminPort(new ConfigStore(configFile).get())}`;
|
|
27
|
+
}
|
|
28
|
+
/** `persist: false` reads without writing the generated secrets (for commands that only print). */
|
|
29
|
+
export function loadConfig(dir = remoteHome(), persist = true) {
|
|
30
|
+
const file = path.join(dir, "host.json");
|
|
31
|
+
const stored = readJson(file) ?? {};
|
|
32
|
+
const cfg = {
|
|
33
|
+
token: typeof stored.token === "string" && stored.token.length >= 32 ? stored.token : newToken(),
|
|
34
|
+
bind: typeof stored.bind === "string" ? stored.bind : "127.0.0.1",
|
|
35
|
+
port: typeof stored.port === "number" ? stored.port : DEFAULT_PORT,
|
|
36
|
+
adminUrl: typeof stored.adminUrl === "string" ? stored.adminUrl : routerAdminUrl(),
|
|
37
|
+
...(typeof stored.claudePath === "string" ? { claudePath: stored.claudePath } : {}),
|
|
38
|
+
...(typeof stored.relay === "string" ? { relay: stored.relay } : {}),
|
|
39
|
+
serverId: typeof stored.serverId === "string" && /^[\w-]{16,64}$/.test(stored.serverId) ? stored.serverId : randomBytes(16).toString("base64url"),
|
|
40
|
+
relaySecret: typeof stored.relaySecret === "string" && stored.relaySecret.length >= 32 ? stored.relaySecret : newToken(),
|
|
41
|
+
};
|
|
42
|
+
if (persist && (cfg.token !== stored.token || cfg.serverId !== stored.serverId || cfg.relaySecret !== stored.relaySecret))
|
|
43
|
+
saveConfig(cfg, dir);
|
|
44
|
+
return cfg;
|
|
45
|
+
}
|
|
46
|
+
export function saveConfig(cfg, dir = remoteHome()) {
|
|
47
|
+
writeFileAtomic(path.join(dir, "host.json"), JSON.stringify(cfg, null, 2) + "\n");
|
|
48
|
+
}
|
|
49
|
+
export function tokenMatches(expected, given) {
|
|
50
|
+
if (typeof given !== "string")
|
|
51
|
+
return false;
|
|
52
|
+
const a = Buffer.from(expected);
|
|
53
|
+
const b = Buffer.from(given);
|
|
54
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
55
|
+
}
|
|
56
|
+
const isTailnet = (ip) => {
|
|
57
|
+
const [a, b] = ip.split(".").map(Number);
|
|
58
|
+
return a === 100 && b !== undefined && b >= 64 && b <= 127; // 100.64.0.0/10, Tailscale's CGNAT range
|
|
59
|
+
};
|
|
60
|
+
const isLan = (ip) => /^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(ip);
|
|
61
|
+
function firstV4(ifaces, pick) {
|
|
62
|
+
for (const list of Object.values(ifaces))
|
|
63
|
+
for (const a of list ?? [])
|
|
64
|
+
if (a.family === "IPv4" && !a.internal && pick(a.address))
|
|
65
|
+
return a.address;
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
/** The address to listen on, and the one to put in the pairing link. */
|
|
69
|
+
export function resolveBind(bind, ifaces = os.networkInterfaces()) {
|
|
70
|
+
if (bind === "tailscale" || bind === "lan") {
|
|
71
|
+
const ip = firstV4(ifaces, bind === "tailscale" ? isTailnet : isLan);
|
|
72
|
+
if (!ip)
|
|
73
|
+
throw new Error(`no ${bind === "tailscale" ? "Tailscale (100.64.0.0/10)" : "LAN"} address on this machine`);
|
|
74
|
+
return { listen: ip, advertise: ip, reachableFromPhone: true };
|
|
75
|
+
}
|
|
76
|
+
if (bind === "0.0.0.0" || bind === "::") {
|
|
77
|
+
const ip = firstV4(ifaces, isTailnet) ?? firstV4(ifaces, isLan) ?? "127.0.0.1";
|
|
78
|
+
return { listen: bind, advertise: ip, reachableFromPhone: ip !== "127.0.0.1" };
|
|
79
|
+
}
|
|
80
|
+
const loopback = bind === "127.0.0.1" || bind === "::1" || bind === "localhost";
|
|
81
|
+
return { listen: bind, advertise: bind, reachableFromPhone: !loopback };
|
|
82
|
+
}
|
|
83
|
+
export function pairingUrl(advertise, port, token) {
|
|
84
|
+
const host = advertise.includes(":") ? `[${advertise}]` : advertise;
|
|
85
|
+
return `http://${host}:${port}/#pair=${token}`;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The pairing link through the relay. Everything after `#` stays on the phone: browsers never send a
|
|
89
|
+
* fragment, so the relay sees neither the token nor the host's public key, only the page request.
|
|
90
|
+
*/
|
|
91
|
+
export function relayPairingUrl(relay, serverId, hostPublicKey, token) {
|
|
92
|
+
const origin = new URL(relay).origin;
|
|
93
|
+
return `${origin}/#pair=${token}&server=${serverId}&hpk=${hostPublicKey}`;
|
|
94
|
+
}
|
|
95
|
+
/** The relay's address, validated: https (or http only for a local `wrangler dev`). */
|
|
96
|
+
export function normalizeRelay(url) {
|
|
97
|
+
const u = new URL(url);
|
|
98
|
+
const local = u.hostname === "localhost" || u.hostname === "127.0.0.1" || u.hostname === "[::1]";
|
|
99
|
+
if (u.protocol !== "https:" && !(u.protocol === "http:" && local))
|
|
100
|
+
throw new Error(`relay must be https:// (http:// only for localhost): ${url}`);
|
|
101
|
+
return u.origin;
|
|
102
|
+
}
|