clauderipple 0.9.5 → 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 +118 -0
- package/README.es.md +45 -0
- package/README.ja.md +17 -0
- package/README.ko.md +31 -0
- package/README.md +70 -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/hooks/agent-title.js +7 -4
- package/dist/cli/src/index.js +227 -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/remote.js +263 -0
- package/dist/cli/src/schtasks.js +50 -40
- package/dist/cli/src/settings.js +26 -1
- package/dist/cli/src/tray.js +5 -2
- package/dist/mod/hooks/i18n.ts +473 -227
- package/dist/mod/hooks/judge.ts +35 -17
- package/dist/mod/hooks/modes.ts +56 -1
- package/dist/mod/hooks/register.tsx +237 -32
- package/dist/mod/hooks/session.ts +102 -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 +903 -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 +120 -4
- package/dist/mod/hooks/workers.ts +33 -6
- package/dist/mod/tests/register.test.tsx +155 -11
- package/dist/mod/tests/ui.test.tsx +408 -27
- package/dist/mod/types/index.d.ts +66 -2
- package/dist/remote/package.json +28 -0
- package/dist/remote/src/away.js +85 -0
- package/dist/remote/src/bridge-client.js +336 -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 +331 -5
- package/dist/router/src/awake-win.js +590 -0
- package/dist/router/src/awake.js +432 -0
- package/dist/router/src/config.js +94 -0
- package/dist/router/src/health.js +86 -3
- package/dist/router/src/hold.js +107 -0
- package/dist/router/src/index.js +108 -6
- package/dist/router/src/ingress/server.js +26 -4
- package/dist/router/src/ingress/translate.js +6 -2
- package/dist/router/src/phone.js +82 -0
- package/dist/router/src/power.js +107 -0
- package/dist/router/src/providers/anthropic-usage.js +33 -1
- package/dist/router/src/providers/google/antigravity.js +73 -10
- package/dist/router/src/providers/google/index.js +191 -14
- package/dist/router/src/providers/google/prompt-refusal.js +29 -0
- 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 +302 -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/translate.js +13 -2
- package/dist/router/src/providers/opencode-usage.js +116 -0
- package/dist/router/src/providers/retry.js +4 -1
- package/dist/router/src/proxy.js +371 -261
- 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/tether-win.js +211 -0
- package/dist/router/src/tether.js +712 -0
- package/dist/router/src/usage-history.js +227 -0
- package/dist/router/src/version.js +1 -1
- package/dist/ui/app.js +1083 -59
- package/dist/ui/i18n.js +670 -69
- package/dist/ui/index.html +33 -6
- package/dist/ui/style.css +177 -0
- package/docs/ARCHITECTURE.md +402 -14
- package/package.json +8 -2
|
@@ -2,11 +2,9 @@ import crypto from "node:crypto";
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import zlib from "node:zlib";
|
|
4
4
|
import path from "node:path";
|
|
5
|
+
import { UsageHistory, addTotals, emptyTotals, finishTotals, recordTotals, sortByVolume } from "./usage-history.js";
|
|
5
6
|
const DEFAULT_MAX_BYTES = 20 * 1024 * 1024;
|
|
6
7
|
const MAX_CARRY = 64 * 1024;
|
|
7
|
-
function emptyBucket() {
|
|
8
|
-
return { count: 0, ok: 0, failed: 0, input: 0, cached: 0, cacheWrite: 0, output: 0, cacheHitPercent: 0, avgMs: 0 };
|
|
9
|
-
}
|
|
10
8
|
function isRecord(value) {
|
|
11
9
|
if (!value || typeof value !== "object")
|
|
12
10
|
return false;
|
|
@@ -21,13 +19,29 @@ export class RequestLog {
|
|
|
21
19
|
max;
|
|
22
20
|
maxBytes;
|
|
23
21
|
records;
|
|
22
|
+
history;
|
|
24
23
|
constructor(file, max = 2000, maxBytes = DEFAULT_MAX_BYTES) {
|
|
25
24
|
this.file = file;
|
|
26
25
|
this.max = max;
|
|
27
26
|
this.maxBytes = maxBytes;
|
|
28
|
-
|
|
27
|
+
const persisted = this.readRecords(this.file);
|
|
28
|
+
this.records = persisted.slice(-this.max);
|
|
29
|
+
this.history = new UsageHistory(path.join(path.dirname(file), "usage-hourly.jsonl"));
|
|
30
|
+
// Replay what the history has not seen yet: the `.1` file first when it may hold a record above
|
|
31
|
+
// the watermark, so the first run backfills both files. Its mtime is its last append, and a
|
|
32
|
+
// record starts before it is written, so an mtime at or below the watermark hour rules it out;
|
|
33
|
+
// the first record of requests.jsonl does not, since a long request that straddled the rotation
|
|
34
|
+
// starts earlier than records left in `.1`. The history skips everything at or below its
|
|
35
|
+
// watermark, so a restart counts no hour twice.
|
|
36
|
+
const watermark = this.history.watermark;
|
|
37
|
+
const rotatedHour = usageHourOfFile(`${file}.1`);
|
|
38
|
+
const replay = rotatedHour !== null && (watermark === null || rotatedHour > watermark)
|
|
39
|
+
? [...this.readRecords(`${file}.1`), ...persisted]
|
|
40
|
+
: persisted;
|
|
41
|
+
this.history.replay(replay);
|
|
29
42
|
}
|
|
30
43
|
add(record) {
|
|
44
|
+
this.history.add(record);
|
|
31
45
|
this.records.push(record);
|
|
32
46
|
if (this.records.length > this.max)
|
|
33
47
|
this.records.splice(0, this.records.length - this.max);
|
|
@@ -64,47 +78,34 @@ export class RequestLog {
|
|
|
64
78
|
}
|
|
65
79
|
/** Summarise records at or after an epoch timestamp in milliseconds. */
|
|
66
80
|
summary(sinceMs) {
|
|
67
|
-
const total =
|
|
68
|
-
const providers =
|
|
69
|
-
const
|
|
70
|
-
const add = (bucket, record) => {
|
|
71
|
-
bucket.count++;
|
|
72
|
-
if (record.ok)
|
|
73
|
-
bucket.ok++;
|
|
74
|
-
else if (!record.resent)
|
|
75
|
-
bucket.failed++;
|
|
76
|
-
bucket.avgMs += record.ms;
|
|
77
|
-
if (record.usage) {
|
|
78
|
-
bucket.input += record.usage.input;
|
|
79
|
-
bucket.cached += record.usage.cached;
|
|
80
|
-
bucket.cacheWrite += record.usage.cacheWrite ?? 0;
|
|
81
|
-
bucket.output += record.usage.output;
|
|
82
|
-
// A cache write is input the model read fresh, so it counts against the hit rate. Leaving it
|
|
83
|
-
// out read a turn that wrote 2,218 tokens and uncached 2 as a 100% hit (2026-09-23).
|
|
84
|
-
cacheBases.set(bucket, (cacheBases.get(bucket) ?? 0) + record.usage.input + record.usage.cached + (record.usage.cacheWrite ?? 0));
|
|
85
|
-
}
|
|
86
|
-
};
|
|
81
|
+
const total = emptyTotals();
|
|
82
|
+
const providers = new Map();
|
|
83
|
+
const models = new Map();
|
|
87
84
|
for (const record of this.records) {
|
|
88
85
|
const at = Date.parse(record.at);
|
|
89
86
|
if (!Number.isFinite(at) || at < sinceMs)
|
|
90
87
|
continue;
|
|
91
|
-
const provider = providers
|
|
92
|
-
|
|
93
|
-
|
|
88
|
+
const provider = providers.get(record.provider) ?? providers.set(record.provider, emptyTotals()).get(record.provider);
|
|
89
|
+
const key = `${record.provider}\u0000${record.target}`;
|
|
90
|
+
const model = models.get(key) ?? models.set(key, { totals: emptyTotals(), provider: record.provider, model: record.target }).get(key);
|
|
91
|
+
for (const bucket of [total, provider, model.totals])
|
|
92
|
+
addTotals(bucket, recordTotals(record));
|
|
94
93
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
94
|
+
const modelsList = [...models.values()].map(({ totals, provider, model }) => ({ ...finishTotals(totals), provider, model }));
|
|
95
|
+
sortByVolume(modelsList);
|
|
96
|
+
return {
|
|
97
|
+
total: finishTotals(total),
|
|
98
|
+
providers: Object.fromEntries([...providers].map(([name, totals]) => [name, finishTotals(totals)])),
|
|
99
|
+
models: modelsList,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/** Durable hourly usage over [fromMs, toMs), beyond the in-memory ring. */
|
|
103
|
+
usage(fromMs, toMs) {
|
|
104
|
+
return this.history.summary(fromMs, toMs);
|
|
102
105
|
}
|
|
103
|
-
|
|
106
|
+
readRecords(file) {
|
|
104
107
|
try {
|
|
105
|
-
|
|
106
|
-
return [];
|
|
107
|
-
const records = fs.readFileSync(this.file, "utf8")
|
|
108
|
+
return fs.readFileSync(file, "utf8")
|
|
108
109
|
.split("\n")
|
|
109
110
|
.flatMap((line) => {
|
|
110
111
|
try {
|
|
@@ -115,13 +116,21 @@ export class RequestLog {
|
|
|
115
116
|
return [];
|
|
116
117
|
}
|
|
117
118
|
});
|
|
118
|
-
return records.slice(-this.max);
|
|
119
119
|
}
|
|
120
120
|
catch {
|
|
121
121
|
return [];
|
|
122
122
|
}
|
|
123
123
|
}
|
|
124
124
|
}
|
|
125
|
+
/** The hour of a file's last write, or null when it does not exist. */
|
|
126
|
+
function usageHourOfFile(file) {
|
|
127
|
+
try {
|
|
128
|
+
return Math.floor(fs.statSync(file).mtimeMs / 3_600_000);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
125
134
|
/**
|
|
126
135
|
* Observes an Anthropic-compatible response without retaining or modifying its bytes.
|
|
127
136
|
* Call feed() after forwarding each response chunk, then finish() on response end.
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// Hotspot failover on Windows (tether.ts holds the shared probe/decision loop; this is the OS part).
|
|
2
|
+
//
|
|
3
|
+
// No administrator step: joining a saved Wi-Fi profile and disconnecting are ordinary user actions
|
|
4
|
+
// (`netsh wlan connect|disconnect`, WlanConnect/WlanDisconnect).
|
|
5
|
+
//
|
|
6
|
+
// Windows 11 24H2 put the Native Wifi calls that can reveal a BSSID behind the Location permission:
|
|
7
|
+
// WlanGetAvailableNetworkList, WlanGetNetworkBssList, WlanScan, and WlanQueryInterface with
|
|
8
|
+
// wlan_intf_opcode_current_connection (Microsoft Learn, "Changes to API behavior for Wi-Fi access and
|
|
9
|
+
// location"). `netsh wlan show interfaces` is the last of those, so it is not used: the Wi-Fi adapter
|
|
10
|
+
// comes from Get-NetAdapter (NdisPhysicalMedium 9 = Native 802.11, 1 = wireless LAN), its address from
|
|
11
|
+
// os.networkInterfaces() (keyed by the same adapter name), and "on the hotspot" from that address as
|
|
12
|
+
// on macOS: the iPhone range 172.20.10.0/28, or the network learned after our own `netsh wlan
|
|
13
|
+
// connect` to the hotspot's profile (address + netmask from os.networkInterfaces(), which Windows
|
|
14
|
+
// fills from GetAdaptersAddresses — no Location, no elevation). Profile listing (WlanGetProfileList /
|
|
15
|
+
// WlanGetProfile), connect and disconnect are not on Microsoft's list; if a build gates them anyway,
|
|
16
|
+
// netsh says so and the state reads `needs-location` with the Settings path.
|
|
17
|
+
//
|
|
18
|
+
// netsh's own text is translated, so it is read only where it cannot be avoided: profile names from
|
|
19
|
+
// `show profiles` (the part after " : " on each line — the label before it is what gets translated),
|
|
20
|
+
// and everything else from `export profile` XML (connectionMode auto|manual, the SSID), which is not.
|
|
21
|
+
//
|
|
22
|
+
// failover `netsh wlan connect name="<profile>" interface="<adapter>"`. Windows keeps the password
|
|
23
|
+
// in the profile, so the hotspot must have been joined once (no profile → a hint).
|
|
24
|
+
// return `netsh wlan disconnect interface="<adapter>"`; Windows' auto-config then joins the best
|
|
25
|
+
// network whose profile connects automatically. Whether a netsh disconnect suppresses that
|
|
26
|
+
// is not documented for Vista and later (WlanDisconnect's note about "on-demand" profiles
|
|
27
|
+
// is for XP), so if nothing has an address after 8s the first automatic profile other
|
|
28
|
+
// than the hotspot is joined by name. No automatic profile at all → no attempt, a hint.
|
|
29
|
+
import fs from "node:fs";
|
|
30
|
+
import os from "node:os";
|
|
31
|
+
import path from "node:path";
|
|
32
|
+
import { POWERSHELL, powershellEnv, psArgs } from "./awake-win.js";
|
|
33
|
+
const SYSTEM_ROOT = process.env.SystemRoot ?? process.env.SYSTEMROOT ?? "C:\\Windows";
|
|
34
|
+
export const NETSH = path.win32.join(SYSTEM_ROOT, "System32", "netsh.exe");
|
|
35
|
+
/** NdisPhysicalMedium: 9 Native802_11, 1 WirelessLan (8 is WirelessWan, cellular — not Wi-Fi). */
|
|
36
|
+
export const FIND_WIFI_PS = [
|
|
37
|
+
"[Console]::OutputEncoding = [Text.Encoding]::UTF8",
|
|
38
|
+
"$a = Get-NetAdapter -Physical -ErrorAction SilentlyContinue | Where-Object { $_.NdisPhysicalMedium -eq 9 -or $_.NdisPhysicalMedium -eq 1 } | Sort-Object { $_.Status -ne 'Up' } | Select-Object -First 1",
|
|
39
|
+
"if ($a) { $a.Name }",
|
|
40
|
+
].join("\n");
|
|
41
|
+
/** Names netsh can take between double quotes, and nothing else. */
|
|
42
|
+
export function quotable(name) {
|
|
43
|
+
return name.length > 0 && name.length <= 256 && !/["\r\n\0]/.test(name);
|
|
44
|
+
}
|
|
45
|
+
export function parseAdapterName(stdout) {
|
|
46
|
+
const name = stdout.replace(/^\uFEFF/, "").split(/\r?\n/).map((l) => l.trim()).find((l) => l) ?? null;
|
|
47
|
+
return name && quotable(name) ? name : null;
|
|
48
|
+
}
|
|
49
|
+
/** Windows 11 24H2+: netsh refuses Wi-Fi commands without Location consent, and says so. */
|
|
50
|
+
export function needsLocation(r) {
|
|
51
|
+
return /location permission|ms-settings:privacy-location/i.test(`${r.stdout}\n${r.stderr}`);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* `netsh wlan show profiles`, in profile order (Windows' connection preference):
|
|
55
|
+
* Profiles on interface Wi-Fi:
|
|
56
|
+
* Group policy profiles (read only)
|
|
57
|
+
* ---------------------------------
|
|
58
|
+
* <None>
|
|
59
|
+
* User profiles
|
|
60
|
+
* -------------
|
|
61
|
+
* All User Profile : Home
|
|
62
|
+
* All User Profile : PBJ’s iPhone
|
|
63
|
+
* The labels are translated; the " : " and the name after it are not.
|
|
64
|
+
*/
|
|
65
|
+
export function parseProfileNames(text) {
|
|
66
|
+
const names = [];
|
|
67
|
+
for (const line of text.split(/\r?\n/)) {
|
|
68
|
+
if (!/^\s{2,}\S/.test(line))
|
|
69
|
+
continue;
|
|
70
|
+
const i = line.indexOf(" : ");
|
|
71
|
+
if (i < 0)
|
|
72
|
+
continue;
|
|
73
|
+
const name = line.slice(i + 3).trim();
|
|
74
|
+
if (name && !names.includes(name))
|
|
75
|
+
names.push(name);
|
|
76
|
+
}
|
|
77
|
+
return names;
|
|
78
|
+
}
|
|
79
|
+
function xmlText(s) {
|
|
80
|
+
return s.replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n))).replace(/&/g, "&");
|
|
81
|
+
}
|
|
82
|
+
/** One `netsh wlan export profile` file (WLANProfile schema; not translated). */
|
|
83
|
+
export function parseProfileXml(xml) {
|
|
84
|
+
const body = xml.replace(/^\uFEFF/, "");
|
|
85
|
+
const name = /<name>([^<]*)<\/name>/.exec(body)?.[1];
|
|
86
|
+
if (name === undefined)
|
|
87
|
+
return null;
|
|
88
|
+
const ssid = /<SSID>[\s\S]*?<name>([^<]*)<\/name>/.exec(body)?.[1];
|
|
89
|
+
const mode = /<connectionMode>\s*(auto|manual)\s*<\/connectionMode>/i.exec(body)?.[1];
|
|
90
|
+
return { name: xmlText(name), ssid: xmlText(ssid ?? name), auto: mode?.toLowerCase() === "auto" };
|
|
91
|
+
}
|
|
92
|
+
/** Saved Wi-Fi profiles in Windows' preference order, with SSID and auto-connect from their XML. */
|
|
93
|
+
export async function listWifiProfiles(run) {
|
|
94
|
+
const shown = await run(NETSH, ["wlan", "show", "profiles"]);
|
|
95
|
+
if (needsLocation(shown))
|
|
96
|
+
return { profiles: [], location: true };
|
|
97
|
+
if (shown.code !== 0)
|
|
98
|
+
return { profiles: [], error: (shown.stdout || shown.stderr).trim().slice(0, 200) };
|
|
99
|
+
const order = parseProfileNames(shown.stdout);
|
|
100
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "clauderipple-wlan-"));
|
|
101
|
+
try {
|
|
102
|
+
// No `key=clear`: the passwords stay encrypted (and that would need elevation anyway).
|
|
103
|
+
const r = await run(NETSH, ["wlan", "export", "profile", `folder="${dir}"`], { verbatim: true });
|
|
104
|
+
if (needsLocation(r))
|
|
105
|
+
return { profiles: [], location: true };
|
|
106
|
+
const parsed = [];
|
|
107
|
+
for (const f of fs.readdirSync(dir)) {
|
|
108
|
+
if (!f.toLowerCase().endsWith(".xml"))
|
|
109
|
+
continue;
|
|
110
|
+
const p = parseProfileXml(fs.readFileSync(path.join(dir, f), "utf8"));
|
|
111
|
+
if (p && !parsed.some((q) => q.name === p.name))
|
|
112
|
+
parsed.push(p);
|
|
113
|
+
}
|
|
114
|
+
const rank = (p) => (order.includes(p.name) ? order.indexOf(p.name) : order.length);
|
|
115
|
+
return { profiles: parsed.sort((a, b) => rank(a) - rank(b)) };
|
|
116
|
+
}
|
|
117
|
+
finally {
|
|
118
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
export function hotspotProfile(profiles, ssid) {
|
|
122
|
+
return profiles.find((p) => p.ssid === ssid) ?? profiles.find((p) => p.name === ssid) ?? null;
|
|
123
|
+
}
|
|
124
|
+
/** Profiles Windows would join by itself, other than the hotspot's. */
|
|
125
|
+
export function autoProfiles(profiles, ssid) {
|
|
126
|
+
const hotspot = hotspotProfile(profiles, ssid);
|
|
127
|
+
return profiles.filter((p) => p.auto && p !== hotspot);
|
|
128
|
+
}
|
|
129
|
+
/** `netsh wlan connect`'s outcome. It returns when the request is queued; the address decides after. */
|
|
130
|
+
export function classifyNetshConnect(r) {
|
|
131
|
+
if (needsLocation(r))
|
|
132
|
+
return "needs-location";
|
|
133
|
+
if (r.code === 0)
|
|
134
|
+
return null;
|
|
135
|
+
const text = `${r.stdout}\n${r.stderr}`;
|
|
136
|
+
if (/is not available to connect/i.test(text))
|
|
137
|
+
return "hotspot-not-visible";
|
|
138
|
+
if (/no profile .* assigned|profile .* is not found/i.test(text))
|
|
139
|
+
return "no-hotspot-profile";
|
|
140
|
+
return "join-failed";
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* What an idle phone does to its hotspot differs, so the step to wake it is said per phone. Shared
|
|
144
|
+
* with the macOS hints; it lives here because tether.ts imports this module (not the other way).
|
|
145
|
+
*/
|
|
146
|
+
export const HOTSPOT_NOT_VISIBLE_STEPS = "On an iPhone, open Settings → Personal Hotspot once (and keep \"Allow Others to Join\" on). On Android, turn Wi-Fi hotspot on again: Android switches it off by itself after a while with nothing connected, unless its \"turn off hotspot automatically\" setting is off.";
|
|
147
|
+
export const WIN_HINTS = {
|
|
148
|
+
"hotspot-not-visible": `Windows could not find the phone's hotspot. ${HOTSPOT_NOT_VISIBLE_STEPS}`,
|
|
149
|
+
"no-hotspot-profile": "Windows has not joined this hotspot before. Join it once from the Wi-Fi menu so Windows saves its password; ClaudeRipple then switches to it by name.",
|
|
150
|
+
"needs-location": "Windows 11 24H2 and later allow Wi-Fi commands only with Location on: Settings → Privacy & security → Location → turn on Location services and \"Let desktop apps access your location\".",
|
|
151
|
+
"no-hotspot-ssid": "Set the phone hotspot's name: `clauderipple awake setup --ssid <hotspot name>`.",
|
|
152
|
+
"no-wifi": "Wi-Fi is not connected. Windows joins the phone hotspot by itself only if its saved profile is set to connect automatically.",
|
|
153
|
+
"join-failed": "Joining the hotspot failed. Check the hotspot name, and that Windows has joined it once before (it keeps the password).",
|
|
154
|
+
pinned: "Switching is paused because the network was pinned.",
|
|
155
|
+
};
|
|
156
|
+
export class WinTetherBackend {
|
|
157
|
+
noAddressOutcome = "hotspot-not-visible";
|
|
158
|
+
hints = WIN_HINTS;
|
|
159
|
+
run;
|
|
160
|
+
log;
|
|
161
|
+
adapter = null;
|
|
162
|
+
constructor(run, log) {
|
|
163
|
+
this.run = run;
|
|
164
|
+
this.log = log;
|
|
165
|
+
}
|
|
166
|
+
async findWifi() {
|
|
167
|
+
const r = await this.run(POWERSHELL, psArgs(FIND_WIFI_PS), { env: powershellEnv(), timeoutMs: 20_000 });
|
|
168
|
+
this.adapter = r.code === 0 ? parseAdapterName(r.stdout) : null;
|
|
169
|
+
return this.adapter;
|
|
170
|
+
}
|
|
171
|
+
async join(ssid) {
|
|
172
|
+
const list = await listWifiProfiles(this.run);
|
|
173
|
+
if (list.location)
|
|
174
|
+
return "needs-location";
|
|
175
|
+
const p = hotspotProfile(list.profiles, ssid);
|
|
176
|
+
if (!p)
|
|
177
|
+
return "no-hotspot-profile";
|
|
178
|
+
return this.connect(p.name);
|
|
179
|
+
}
|
|
180
|
+
async connect(profile) {
|
|
181
|
+
if (!quotable(profile) || !this.adapter)
|
|
182
|
+
return "join-failed";
|
|
183
|
+
const r = await this.run(NETSH, ["wlan", "connect", `name="${profile}"`, `interface="${this.adapter}"`], { verbatim: true, timeoutMs: 30_000 });
|
|
184
|
+
const failed = classifyNetshConnect(r);
|
|
185
|
+
if (failed && failed !== "needs-location")
|
|
186
|
+
this.log.warn(`tether: netsh said: ${(r.stdout + r.stderr).trim().slice(0, 200)}`);
|
|
187
|
+
return failed;
|
|
188
|
+
}
|
|
189
|
+
async returnBlocked(ssid) {
|
|
190
|
+
const list = await listWifiProfiles(this.run);
|
|
191
|
+
if (list.location)
|
|
192
|
+
return "Windows needs Location on for Wi-Fi commands";
|
|
193
|
+
if (list.error)
|
|
194
|
+
return null; // cannot tell: try anyway
|
|
195
|
+
return autoProfiles(list.profiles, ssid).length ? null : "no other Wi-Fi on this PC is set to connect automatically";
|
|
196
|
+
}
|
|
197
|
+
async leave() {
|
|
198
|
+
if (!this.adapter)
|
|
199
|
+
return "no Wi-Fi adapter";
|
|
200
|
+
const r = await this.run(NETSH, ["wlan", "disconnect", `interface="${this.adapter}"`], { verbatim: true, timeoutMs: 15_000 });
|
|
201
|
+
return r.code === 0 && !needsLocation(r) ? null : (r.stdout || r.stderr).trim().slice(0, 200) || `netsh exited ${r.code}`;
|
|
202
|
+
}
|
|
203
|
+
async joinKnown(ssid) {
|
|
204
|
+
const list = await listWifiProfiles(this.run);
|
|
205
|
+
const first = autoProfiles(list.profiles, ssid)[0];
|
|
206
|
+
if (!first)
|
|
207
|
+
return;
|
|
208
|
+
this.log.info(`tether: Windows joined nothing by itself; asking for "${first.name}"`);
|
|
209
|
+
await this.connect(first.name);
|
|
210
|
+
}
|
|
211
|
+
}
|