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.
Files changed (49) hide show
  1. package/README.md +8 -1
  2. package/dist/commands/init.js +13 -2
  3. package/dist/commands/pair.js +3 -9
  4. package/dist/linked-device.d.ts +9 -0
  5. package/dist/linked-device.js +45 -0
  6. package/dist/mcp-tools.js +19 -19
  7. package/dist/network.d.ts +0 -5
  8. package/dist/network.js +75 -9
  9. package/dist/pwa/assets/index-BLCVzS_l.js +120 -0
  10. package/dist/pwa/assets/{index-DQJHVyP6.css → index-Cjjw24Ok.css} +1 -1
  11. package/dist/pwa/assets/{web-CaRUL7Kz.js → web-C2AU9S9n.js} +1 -1
  12. package/dist/pwa/assets/{web-C9g_YGd8.js → web-CfD_ah7K.js} +1 -1
  13. package/dist/pwa/assets/{web-D4ty3qtI.js → web-DugGj1t8.js} +1 -1
  14. package/dist/pwa/index.html +2 -2
  15. package/dist/pwa/service-worker.js +2 -2
  16. package/dist/rpc-handler.js +17 -23
  17. package/package.json +1 -2
  18. package/palmier-server/README.md +3 -2
  19. package/palmier-server/pwa/src/App.css +45 -4
  20. package/palmier-server/pwa/src/App.tsx +36 -15
  21. package/palmier-server/pwa/src/components/CapabilityToggles.tsx +65 -225
  22. package/palmier-server/pwa/src/components/ConnectionStatusIcon.tsx +54 -12
  23. package/palmier-server/pwa/src/components/HostMenu.tsx +110 -21
  24. package/palmier-server/pwa/src/components/RunDetailView.tsx +2 -2
  25. package/palmier-server/pwa/src/components/SessionComposer.tsx +9 -8
  26. package/palmier-server/pwa/src/components/SessionsView.tsx +5 -3
  27. package/palmier-server/pwa/src/components/TabBar.tsx +7 -5
  28. package/palmier-server/pwa/src/components/TaskForm.tsx +5 -3
  29. package/palmier-server/pwa/src/constants.ts +1 -1
  30. package/palmier-server/pwa/src/contexts/HostConnectionContext.tsx +41 -41
  31. package/palmier-server/pwa/src/contexts/HostStoreContext.tsx +17 -60
  32. package/palmier-server/pwa/src/hooks/usePushSubscription.ts +6 -7
  33. package/palmier-server/pwa/src/native/Device.ts +23 -38
  34. package/palmier-server/pwa/src/pages/Dashboard.tsx +36 -41
  35. package/palmier-server/pwa/src/pages/PairHost.tsx +20 -1
  36. package/palmier-server/pwa/src/pages/PairSetup.tsx +98 -39
  37. package/palmier-server/pwa/src/service-worker.ts +9 -6
  38. package/palmier-server/pwa/src/types.ts +2 -0
  39. package/palmier-server/spec.md +44 -15
  40. package/src/commands/init.ts +13 -2
  41. package/src/commands/pair.ts +3 -9
  42. package/src/linked-device.ts +52 -0
  43. package/src/mcp-tools.ts +19 -19
  44. package/src/network.ts +73 -9
  45. package/src/rpc-handler.ts +14 -22
  46. package/dist/device-capabilities.d.ts +0 -9
  47. package/dist/device-capabilities.js +0 -36
  48. package/dist/pwa/assets/index-iL_NTbsT.js +0 -120
  49. 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 Android app's settings menu.
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 |
@@ -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
- console.log(` Download the Android APK. The app uses LAN for direct RPC`);
43
- console.log(` when on the same network, otherwise the relay.\n`);
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")}`);
@@ -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, saveConfig } from "../config.js";
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
- async function buildPairResponse(config, label) {
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 = await buildPairResponse(config, label);
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 { getCapabilityDevice } from "./device-capabilities.js";
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 = getCapabilityDevice("location");
157
+ const device = getLinkedDevice();
158
158
  if (!device)
159
- throw new ToolError("No device has location access enabled", 400);
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 = getCapabilityDevice("contacts");
198
+ const device = getLinkedDevice();
199
199
  if (!device)
200
- throw new ToolError("No device has contacts access enabled", 400);
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 = getCapabilityDevice("contacts");
244
+ const device = getLinkedDevice();
245
245
  if (!device)
246
- throw new ToolError("No device has contacts access enabled", 400);
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 = getCapabilityDevice("calendar");
295
+ const device = getLinkedDevice();
296
296
  if (!device)
297
- throw new ToolError("No device has calendar access enabled", 400);
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 = getCapabilityDevice("calendar");
349
+ const device = getLinkedDevice();
350
350
  if (!device)
351
- throw new ToolError("No device has calendar access enabled", 400);
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 = getCapabilityDevice("sms-send");
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 = getCapabilityDevice("alarm");
455
+ const device = getLinkedDevice();
456
456
  if (!device)
457
- throw new ToolError("No device has alarm access enabled", 400);
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 = getCapabilityDevice("battery");
505
+ const device = getLinkedDevice();
506
506
  if (!device)
507
- throw new ToolError("No device has battery access enabled", 400);
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 = getCapabilityDevice("dnd");
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 = getCapabilityDevice("send-email");
600
+ const device = getLinkedDevice();
601
601
  if (!device)
602
- throw new ToolError("No device has send-email access enabled", 400);
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
- // @ts-expect-error - default-gateway ships no types
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
- * Returns null when no default route is found (e.g. fully offline host) or
7
- * when the OS platform isn't supported by `default-gateway`.
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
- try {
11
- const result = await gateway4async();
12
- return result.int ?? null;
63
+ const probedIp = await probeOutboundIp();
64
+ if (probedIp) {
65
+ const name = findInterfaceByIp(probedIp);
66
+ if (name && !isVirtualName(name))
67
+ return name;
13
68
  }
14
- catch {
15
- return null;
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];