palmier 0.8.10 → 0.9.2
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/README.md +8 -1
- package/dist/commands/init.js +13 -2
- package/dist/commands/pair.js +3 -9
- package/dist/linked-device.d.ts +9 -0
- package/dist/linked-device.js +45 -0
- package/dist/mcp-tools.js +19 -19
- package/dist/network.d.ts +0 -5
- package/dist/network.js +75 -9
- package/dist/pwa/assets/index-BLCVzS_l.js +120 -0
- package/dist/pwa/assets/{index-DQJHVyP6.css → index-Cjjw24Ok.css} +1 -1
- package/dist/pwa/assets/{web-CaRUL7Kz.js → web-C2AU9S9n.js} +1 -1
- package/dist/pwa/assets/{web-C9g_YGd8.js → web-CfD_ah7K.js} +1 -1
- package/dist/pwa/assets/{web-D4ty3qtI.js → web-DugGj1t8.js} +1 -1
- package/dist/pwa/index.html +2 -2
- package/dist/pwa/service-worker.js +2 -2
- package/dist/rpc-handler.js +17 -23
- package/package.json +1 -2
- package/palmier-server/README.md +3 -2
- package/palmier-server/pwa/src/App.css +45 -4
- package/palmier-server/pwa/src/App.tsx +36 -15
- package/palmier-server/pwa/src/components/CapabilityToggles.tsx +65 -225
- package/palmier-server/pwa/src/components/ConnectionStatusIcon.tsx +54 -12
- package/palmier-server/pwa/src/components/HostMenu.tsx +110 -21
- package/palmier-server/pwa/src/components/RunDetailView.tsx +2 -2
- package/palmier-server/pwa/src/components/SessionComposer.tsx +9 -8
- package/palmier-server/pwa/src/components/SessionsView.tsx +5 -3
- package/palmier-server/pwa/src/components/TabBar.tsx +7 -5
- package/palmier-server/pwa/src/components/TaskForm.tsx +5 -3
- package/palmier-server/pwa/src/constants.ts +1 -1
- package/palmier-server/pwa/src/contexts/HostConnectionContext.tsx +41 -41
- package/palmier-server/pwa/src/contexts/HostStoreContext.tsx +17 -60
- package/palmier-server/pwa/src/hooks/usePushSubscription.ts +6 -7
- package/palmier-server/pwa/src/native/Device.ts +23 -38
- package/palmier-server/pwa/src/pages/Dashboard.tsx +36 -41
- package/palmier-server/pwa/src/pages/PairHost.tsx +20 -1
- package/palmier-server/pwa/src/pages/PairSetup.tsx +98 -39
- package/palmier-server/pwa/src/service-worker.ts +9 -6
- package/palmier-server/pwa/src/types.ts +2 -0
- package/palmier-server/spec.md +44 -15
- package/src/commands/init.ts +13 -2
- package/src/commands/pair.ts +3 -9
- package/src/linked-device.ts +52 -0
- package/src/mcp-tools.ts +19 -19
- package/src/network.ts +73 -9
- package/src/rpc-handler.ts +14 -22
- package/dist/device-capabilities.d.ts +0 -9
- package/dist/device-capabilities.js +0 -36
- package/dist/pwa/assets/index-iL_NTbsT.js +0 -120
- package/src/device-capabilities.ts +0 -57
package/README.md
CHANGED
|
@@ -78,7 +78,7 @@ Palmier exposes an [MCP](https://modelcontextprotocol.io) server at `http://loca
|
|
|
78
78
|
|
|
79
79
|
Resources support MCP subscriptions — clients can subscribe via `resources/subscribe` and receive real-time `notifications/resources/updated` events via the streamable HTTP transport when the resource changes.
|
|
80
80
|
|
|
81
|
-
All device tools work while the Palmier Android app is in the background — they communicate via FCM data messages which wake the app's service even when it's not in the foreground. Permissions listed above must be granted via toggles in the
|
|
81
|
+
All device tools work while the Palmier Android app is in the background — they communicate via FCM data messages which wake the app's service even when it's not in the foreground. Each host has one **linked device**: the phone the host uses for SMS, contacts, location, and other device capabilities. Choose it at pair time (the "Link to this device" checkbox) or later from the drawer. Permissions listed above must be granted via toggles in the linked device's drawer.
|
|
82
82
|
|
|
83
83
|
### Architecture
|
|
84
84
|
|
|
@@ -151,11 +151,14 @@ palmier clients revoke <token>
|
|
|
151
151
|
palmier clients revoke-all
|
|
152
152
|
```
|
|
153
153
|
|
|
154
|
+
Revoking the linked device also clears the host's linked-device record; device capabilities stop working until another paired device is linked from its drawer.
|
|
155
|
+
|
|
154
156
|
### The `init` Command
|
|
155
157
|
|
|
156
158
|
The wizard:
|
|
157
159
|
- Detects installed agent CLIs and caches the result
|
|
158
160
|
- Asks for the HTTP port
|
|
161
|
+
- Detects the default network interface (used for auto-LAN)
|
|
159
162
|
- Shows a summary (including any existing scheduled tasks to recover) and asks for confirmation
|
|
160
163
|
- Registers with the Palmier server, saves configuration to `~/.config/palmier/host.json`
|
|
161
164
|
- Installs a background daemon (systemd user service on Linux, LaunchAgent on macOS, Task Scheduler on Windows)
|
|
@@ -167,6 +170,10 @@ The daemon automatically recovers existing tasks by reinstalling their system ti
|
|
|
167
170
|
|
|
168
171
|
Agents are re-detected on every daemon start. Run `palmier restart` after installing or removing a CLI.
|
|
169
172
|
|
|
173
|
+
### Re-detecting the LAN Network
|
|
174
|
+
|
|
175
|
+
The default network interface is detected once during `palmier init` and saved to `host.json`. The daemon derives the current IP live from that interface on each client connect, so DHCP-assigned IP changes on the same adapter are picked up automatically. If you physically switch to a different network adapter (e.g., plug in Ethernet after running on WiFi, or add a new USB-tethered interface), run `palmier init` again to re-detect.
|
|
176
|
+
|
|
170
177
|
## CLI Reference
|
|
171
178
|
|
|
172
179
|
| Command | Description |
|
package/dist/commands/init.js
CHANGED
|
@@ -3,6 +3,7 @@ import { loadConfig, saveConfig } from "../config.js";
|
|
|
3
3
|
import { detectAgents } from "../agents/agent.js";
|
|
4
4
|
import { getPlatform } from "../platform/index.js";
|
|
5
5
|
import { pairCommand } from "./pair.js";
|
|
6
|
+
import { detectDefaultInterface, getInterfaceIpv4 } from "../network.js";
|
|
6
7
|
import { listTasks } from "../task.js";
|
|
7
8
|
const bold = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
8
9
|
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
@@ -31,6 +32,8 @@ export async function initCommand() {
|
|
|
31
32
|
const parsed = parseInt(portAnswer.trim(), 10);
|
|
32
33
|
if (parsed > 0 && parsed < 65536)
|
|
33
34
|
httpPort = parsed;
|
|
35
|
+
const defaultInterface = (await detectDefaultInterface()) ?? undefined;
|
|
36
|
+
const lanIp = defaultInterface ? getInterfaceIpv4(defaultInterface) : null;
|
|
34
37
|
console.log(`\n${bold("Setup summary:")}\n`);
|
|
35
38
|
console.log(` ${dim("Task storage:")} ${bold(process.cwd())}`);
|
|
36
39
|
console.log(` All tasks and execution data will be stored here.\n`);
|
|
@@ -39,8 +42,15 @@ export async function initCommand() {
|
|
|
39
42
|
console.log(` ${dim("Remote (web):")} ${cyan("https://app.palmier.me")}`);
|
|
40
43
|
console.log(` Pair a browser on any device. Traffic always goes through the relay.\n`);
|
|
41
44
|
console.log(` ${dim("Remote (app):")} ${cyan("https://github.com/caihongxu/palmier-android/releases")}`);
|
|
42
|
-
|
|
43
|
-
|
|
45
|
+
if (lanIp) {
|
|
46
|
+
console.log(` Download the Android APK. The app uses LAN for direct RPC`);
|
|
47
|
+
console.log(` on the same network (detected ${cyan(`http://${lanIp}:${httpPort}`)}),`);
|
|
48
|
+
console.log(` otherwise the relay.\n`);
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
console.log(` Download the Android APK. Traffic will go through the relay —`);
|
|
52
|
+
console.log(` ${red("could not detect a LAN interface")} for direct RPC.\n`);
|
|
53
|
+
}
|
|
44
54
|
console.log(` ${dim("Agents:")} ${agents.map((a) => a.label).join(", ")}\n`);
|
|
45
55
|
const existingTasks = listTasks(process.cwd());
|
|
46
56
|
if (existingTasks.length > 0) {
|
|
@@ -89,6 +99,7 @@ export async function initCommand() {
|
|
|
89
99
|
natsNkeySeed: registerResponse.natsNkeySeed,
|
|
90
100
|
agents,
|
|
91
101
|
httpPort,
|
|
102
|
+
defaultInterface,
|
|
92
103
|
};
|
|
93
104
|
saveConfig(config);
|
|
94
105
|
console.log(`Config saved to ${dim("~/.config/palmier/host.json")}`);
|
package/dist/commands/pair.js
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import * as http from "node:http";
|
|
2
2
|
import * as os from "node:os";
|
|
3
3
|
import { StringCodec } from "nats";
|
|
4
|
-
import { loadConfig
|
|
4
|
+
import { loadConfig } from "../config.js";
|
|
5
5
|
import { connectNats } from "../nats-client.js";
|
|
6
6
|
import { addClient } from "../client-store.js";
|
|
7
|
-
import { detectDefaultInterface } from "../network.js";
|
|
8
7
|
const CODE_CHARS = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; // no O/0/I/1/L
|
|
9
8
|
const CODE_LENGTH = 6;
|
|
10
9
|
export const PAIRING_EXPIRY_MS = 60 * 1000; // 1 minute
|
|
@@ -13,13 +12,8 @@ export function generatePairingCode() {
|
|
|
13
12
|
crypto.getRandomValues(bytes);
|
|
14
13
|
return Array.from(bytes, (b) => CODE_CHARS[b % CODE_CHARS.length]).join("");
|
|
15
14
|
}
|
|
16
|
-
|
|
15
|
+
function buildPairResponse(config, label) {
|
|
17
16
|
const client = addClient(label);
|
|
18
|
-
const iface = await detectDefaultInterface();
|
|
19
|
-
if (iface && iface !== config.defaultInterface) {
|
|
20
|
-
config.defaultInterface = iface;
|
|
21
|
-
saveConfig(config);
|
|
22
|
-
}
|
|
23
17
|
return {
|
|
24
18
|
hostId: config.hostId,
|
|
25
19
|
clientToken: client.token,
|
|
@@ -91,7 +85,7 @@ export async function pairCommand() {
|
|
|
91
85
|
}
|
|
92
86
|
}
|
|
93
87
|
catch { /* empty body is fine */ }
|
|
94
|
-
const response =
|
|
88
|
+
const response = buildPairResponse(config, label);
|
|
95
89
|
if (msg.reply) {
|
|
96
90
|
msg.respond(sc.encode(JSON.stringify(response)));
|
|
97
91
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface LinkedDevice {
|
|
2
|
+
clientToken: string;
|
|
3
|
+
fcmToken: string;
|
|
4
|
+
}
|
|
5
|
+
export declare function getLinkedDevice(): LinkedDevice | null;
|
|
6
|
+
export declare function setLinkedDevice(clientToken: string, fcmToken: string): void;
|
|
7
|
+
export declare function clearLinkedDevice(): void;
|
|
8
|
+
export declare function clearLinkedDeviceIfMatches(clientToken: string): boolean;
|
|
9
|
+
//# sourceMappingURL=linked-device.d.ts.map
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { CONFIG_DIR } from "./config.js";
|
|
4
|
+
const LINKED_DEVICE_FILE = path.join(CONFIG_DIR, "linked-device.json");
|
|
5
|
+
function read() {
|
|
6
|
+
try {
|
|
7
|
+
if (!fs.existsSync(LINKED_DEVICE_FILE))
|
|
8
|
+
return null;
|
|
9
|
+
const raw = fs.readFileSync(LINKED_DEVICE_FILE, "utf-8");
|
|
10
|
+
const parsed = JSON.parse(raw);
|
|
11
|
+
if (!parsed?.clientToken || !parsed?.fcmToken)
|
|
12
|
+
return null;
|
|
13
|
+
return { clientToken: parsed.clientToken, fcmToken: parsed.fcmToken };
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function write(device) {
|
|
20
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
21
|
+
if (!device) {
|
|
22
|
+
if (fs.existsSync(LINKED_DEVICE_FILE))
|
|
23
|
+
fs.unlinkSync(LINKED_DEVICE_FILE);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
fs.writeFileSync(LINKED_DEVICE_FILE, JSON.stringify(device, null, 2), "utf-8");
|
|
27
|
+
}
|
|
28
|
+
export function getLinkedDevice() {
|
|
29
|
+
return read();
|
|
30
|
+
}
|
|
31
|
+
export function setLinkedDevice(clientToken, fcmToken) {
|
|
32
|
+
write({ clientToken, fcmToken });
|
|
33
|
+
}
|
|
34
|
+
export function clearLinkedDevice() {
|
|
35
|
+
write(null);
|
|
36
|
+
}
|
|
37
|
+
export function clearLinkedDeviceIfMatches(clientToken) {
|
|
38
|
+
const current = read();
|
|
39
|
+
if (current?.clientToken === clientToken) {
|
|
40
|
+
write(null);
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
//# sourceMappingURL=linked-device.js.map
|
package/dist/mcp-tools.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { StringCodec } from "nats";
|
|
2
2
|
import { registerPending } from "./pending-requests.js";
|
|
3
|
-
import {
|
|
3
|
+
import { getLinkedDevice } from "./linked-device.js";
|
|
4
4
|
import { getNotifications, onNotificationsChanged } from "./notification-store.js";
|
|
5
5
|
import { getSmsMessages, onSmsChanged } from "./sms-store.js";
|
|
6
6
|
export class ToolError extends Error {
|
|
@@ -154,9 +154,9 @@ const deviceGeolocationTool = {
|
|
|
154
154
|
async handler(_args, ctx) {
|
|
155
155
|
if (!ctx.nc)
|
|
156
156
|
throw new ToolError("Not connected to server (NATS unavailable)", 503);
|
|
157
|
-
const device =
|
|
157
|
+
const device = getLinkedDevice();
|
|
158
158
|
if (!device)
|
|
159
|
-
throw new ToolError("No device
|
|
159
|
+
throw new ToolError("No linked device configured", 400);
|
|
160
160
|
const sc = StringCodec();
|
|
161
161
|
const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.geolocation`, sc.encode(JSON.stringify({ hostId: ctx.config.hostId, requestId: ctx.sessionId, fcmToken: device.fcmToken })), { timeout: 5_000 });
|
|
162
162
|
const ack = JSON.parse(sc.decode(ackReply.data));
|
|
@@ -195,9 +195,9 @@ const readContactsTool = {
|
|
|
195
195
|
async handler(_args, ctx) {
|
|
196
196
|
if (!ctx.nc)
|
|
197
197
|
throw new ToolError("Not connected to server (NATS unavailable)", 503);
|
|
198
|
-
const device =
|
|
198
|
+
const device = getLinkedDevice();
|
|
199
199
|
if (!device)
|
|
200
|
-
throw new ToolError("No device
|
|
200
|
+
throw new ToolError("No linked device configured", 400);
|
|
201
201
|
const sc = StringCodec();
|
|
202
202
|
const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.contacts`, sc.encode(JSON.stringify({ hostId: ctx.config.hostId, requestId: ctx.sessionId, fcmToken: device.fcmToken, action: "read" })), { timeout: 5_000 });
|
|
203
203
|
const ack = JSON.parse(sc.decode(ackReply.data));
|
|
@@ -241,9 +241,9 @@ const createContactTool = {
|
|
|
241
241
|
async handler(args, ctx) {
|
|
242
242
|
if (!ctx.nc)
|
|
243
243
|
throw new ToolError("Not connected to server (NATS unavailable)", 503);
|
|
244
|
-
const device =
|
|
244
|
+
const device = getLinkedDevice();
|
|
245
245
|
if (!device)
|
|
246
|
-
throw new ToolError("No device
|
|
246
|
+
throw new ToolError("No linked device configured", 400);
|
|
247
247
|
const { name, phone, email } = args;
|
|
248
248
|
if (!name)
|
|
249
249
|
throw new ToolError("name is required", 400);
|
|
@@ -292,9 +292,9 @@ const readCalendarTool = {
|
|
|
292
292
|
async handler(args, ctx) {
|
|
293
293
|
if (!ctx.nc)
|
|
294
294
|
throw new ToolError("Not connected to server (NATS unavailable)", 503);
|
|
295
|
-
const device =
|
|
295
|
+
const device = getLinkedDevice();
|
|
296
296
|
if (!device)
|
|
297
|
-
throw new ToolError("No device
|
|
297
|
+
throw new ToolError("No linked device configured", 400);
|
|
298
298
|
const { startDate, endDate } = args;
|
|
299
299
|
const sc = StringCodec();
|
|
300
300
|
const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.calendar`, sc.encode(JSON.stringify({
|
|
@@ -346,9 +346,9 @@ const createCalendarEventTool = {
|
|
|
346
346
|
async handler(args, ctx) {
|
|
347
347
|
if (!ctx.nc)
|
|
348
348
|
throw new ToolError("Not connected to server (NATS unavailable)", 503);
|
|
349
|
-
const device =
|
|
349
|
+
const device = getLinkedDevice();
|
|
350
350
|
if (!device)
|
|
351
|
-
throw new ToolError("No device
|
|
351
|
+
throw new ToolError("No linked device configured", 400);
|
|
352
352
|
const { title, startTime, endTime, location, description } = args;
|
|
353
353
|
if (!title || !startTime || !endTime)
|
|
354
354
|
throw new ToolError("title, startTime, and endTime are required", 400);
|
|
@@ -400,7 +400,7 @@ const sendSmsTool = {
|
|
|
400
400
|
async handler(args, ctx) {
|
|
401
401
|
if (!ctx.nc)
|
|
402
402
|
throw new ToolError("Not connected to server (NATS unavailable)", 503);
|
|
403
|
-
const device =
|
|
403
|
+
const device = getLinkedDevice();
|
|
404
404
|
if (!device)
|
|
405
405
|
throw new ToolError("No device has SMS Send enabled", 400);
|
|
406
406
|
const { to, body } = args;
|
|
@@ -452,9 +452,9 @@ const sendAlarmTool = {
|
|
|
452
452
|
async handler(args, ctx) {
|
|
453
453
|
if (!ctx.nc)
|
|
454
454
|
throw new ToolError("Not connected to server (NATS unavailable)", 503);
|
|
455
|
-
const device =
|
|
455
|
+
const device = getLinkedDevice();
|
|
456
456
|
if (!device)
|
|
457
|
-
throw new ToolError("No device
|
|
457
|
+
throw new ToolError("No linked device configured", 400);
|
|
458
458
|
const { title, description } = args;
|
|
459
459
|
if (!title)
|
|
460
460
|
throw new ToolError("title is required", 400);
|
|
@@ -502,9 +502,9 @@ const readBatteryTool = {
|
|
|
502
502
|
async handler(_args, ctx) {
|
|
503
503
|
if (!ctx.nc)
|
|
504
504
|
throw new ToolError("Not connected to server (NATS unavailable)", 503);
|
|
505
|
-
const device =
|
|
505
|
+
const device = getLinkedDevice();
|
|
506
506
|
if (!device)
|
|
507
|
-
throw new ToolError("No device
|
|
507
|
+
throw new ToolError("No linked device configured", 400);
|
|
508
508
|
const sc = StringCodec();
|
|
509
509
|
const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.battery`, sc.encode(JSON.stringify({ hostId: ctx.config.hostId, requestId: ctx.sessionId, fcmToken: device.fcmToken })), { timeout: 5_000 });
|
|
510
510
|
const ack = JSON.parse(sc.decode(ackReply.data));
|
|
@@ -546,7 +546,7 @@ const setRingerModeTool = {
|
|
|
546
546
|
async handler(args, ctx) {
|
|
547
547
|
if (!ctx.nc)
|
|
548
548
|
throw new ToolError("Not connected to server (NATS unavailable)", 503);
|
|
549
|
-
const device =
|
|
549
|
+
const device = getLinkedDevice();
|
|
550
550
|
if (!device)
|
|
551
551
|
throw new ToolError("No device has Do Not Disturb control enabled", 400);
|
|
552
552
|
const { mode } = args;
|
|
@@ -597,9 +597,9 @@ const sendEmailTool = {
|
|
|
597
597
|
async handler(args, ctx) {
|
|
598
598
|
if (!ctx.nc)
|
|
599
599
|
throw new ToolError("Not connected to server (NATS unavailable)", 503);
|
|
600
|
-
const device =
|
|
600
|
+
const device = getLinkedDevice();
|
|
601
601
|
if (!device)
|
|
602
|
-
throw new ToolError("No device
|
|
602
|
+
throw new ToolError("No linked device configured", 400);
|
|
603
603
|
const { to, subject, body, cc, bcc } = args;
|
|
604
604
|
if (!to)
|
|
605
605
|
throw new ToolError("to is required", 400);
|
package/dist/network.d.ts
CHANGED
|
@@ -1,8 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Resolve the name of the network interface used for the IPv4 default route.
|
|
3
|
-
* Returns null when no default route is found (e.g. fully offline host) or
|
|
4
|
-
* when the OS platform isn't supported by `default-gateway`.
|
|
5
|
-
*/
|
|
6
1
|
export declare function detectDefaultInterface(): Promise<string | null>;
|
|
7
2
|
export declare function getInterfaceIpv4(interfaceName: string): string | null;
|
|
8
3
|
export declare function buildLanUrl(port: number, interfaceName: string | undefined): string | null;
|
package/dist/network.js
CHANGED
|
@@ -1,19 +1,85 @@
|
|
|
1
1
|
import * as os from "node:os";
|
|
2
|
-
|
|
3
|
-
import { gateway4async } from "default-gateway";
|
|
2
|
+
import * as dgram from "node:dgram";
|
|
4
3
|
/**
|
|
5
4
|
* Resolve the name of the network interface used for the IPv4 default route.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* Falls back to the first non-internal IPv4 interface when the gateway lookup
|
|
6
|
+
* fails — `default-gateway` shells out to `wmic` on Windows, which was removed
|
|
7
|
+
* in Windows 11 24H2.
|
|
8
8
|
*/
|
|
9
|
+
function findInterfaceByIp(ip) {
|
|
10
|
+
for (const [name, addrs] of Object.entries(os.networkInterfaces())) {
|
|
11
|
+
for (const addr of addrs ?? []) {
|
|
12
|
+
if (addr.family === "IPv4" && addr.address === ip)
|
|
13
|
+
return name;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
/** Ask the kernel which local IPv4 would route to an external address. No packet is sent. */
|
|
19
|
+
function probeOutboundIp() {
|
|
20
|
+
return new Promise((resolve) => {
|
|
21
|
+
const sock = dgram.createSocket("udp4");
|
|
22
|
+
const cleanup = (ip) => { try {
|
|
23
|
+
sock.close();
|
|
24
|
+
}
|
|
25
|
+
catch { /* ignore */ } resolve(ip); };
|
|
26
|
+
sock.on("error", () => cleanup(null));
|
|
27
|
+
try {
|
|
28
|
+
sock.connect(80, "8.8.8.8", () => {
|
|
29
|
+
const addr = sock.address();
|
|
30
|
+
cleanup(addr.address && addr.address !== "0.0.0.0" ? addr.address : null);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
cleanup(null);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
/** Lower score = more preferred. 0=192.168, 1=10.x, 2=172.16-31, 3=everything else. */
|
|
39
|
+
function ipClass(ip) {
|
|
40
|
+
const parts = ip.split(".").map(Number);
|
|
41
|
+
if (parts.length !== 4 || parts.some((p) => Number.isNaN(p)))
|
|
42
|
+
return 3;
|
|
43
|
+
const [a, b] = parts;
|
|
44
|
+
if (a === 192 && b === 168)
|
|
45
|
+
return 0;
|
|
46
|
+
if (a === 10)
|
|
47
|
+
return 1;
|
|
48
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
49
|
+
return 2;
|
|
50
|
+
return 3;
|
|
51
|
+
}
|
|
52
|
+
/** Names that commonly belong to virtual/VPN adapters we'd rather skip. */
|
|
53
|
+
const VIRTUAL_NAME_PATTERNS = [
|
|
54
|
+
"vethernet", "virtualbox", "vmware", "hyper-v", "docker", "bridge",
|
|
55
|
+
"tailscale", "wireguard", "meta", "vpn", "tun", "tap", "loopback",
|
|
56
|
+
"wsl", "utun",
|
|
57
|
+
];
|
|
58
|
+
function isVirtualName(name) {
|
|
59
|
+
const lower = name.toLowerCase();
|
|
60
|
+
return VIRTUAL_NAME_PATTERNS.some((p) => lower.includes(p));
|
|
61
|
+
}
|
|
9
62
|
export async function detectDefaultInterface() {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
63
|
+
const probedIp = await probeOutboundIp();
|
|
64
|
+
if (probedIp) {
|
|
65
|
+
const name = findInterfaceByIp(probedIp);
|
|
66
|
+
if (name && !isVirtualName(name))
|
|
67
|
+
return name;
|
|
13
68
|
}
|
|
14
|
-
|
|
15
|
-
|
|
69
|
+
const candidates = [];
|
|
70
|
+
for (const [name, addrs] of Object.entries(os.networkInterfaces())) {
|
|
71
|
+
for (const addr of addrs ?? []) {
|
|
72
|
+
if (addr.family !== "IPv4" || addr.internal)
|
|
73
|
+
continue;
|
|
74
|
+
candidates.push({ name, klass: ipClass(addr.address), virtual: isVirtualName(name) });
|
|
75
|
+
}
|
|
16
76
|
}
|
|
77
|
+
candidates.sort((a, b) => {
|
|
78
|
+
if (a.virtual !== b.virtual)
|
|
79
|
+
return a.virtual ? 1 : -1;
|
|
80
|
+
return a.klass - b.klass;
|
|
81
|
+
});
|
|
82
|
+
return candidates[0]?.name ?? null;
|
|
17
83
|
}
|
|
18
84
|
export function getInterfaceIpv4(interfaceName) {
|
|
19
85
|
const addrs = os.networkInterfaces()[interfaceName];
|