palmier 0.8.7 → 0.8.9
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 +26 -17
- package/dist/commands/init.js +6 -11
- package/dist/commands/pair.js +3 -0
- package/dist/platform/macos.js +2 -4
- package/dist/pwa/assets/index-1gs4vwFo.js +120 -0
- package/dist/pwa/assets/{index-UaZFu6XL.css → index-DQJHVyP6.css} +1 -1
- package/dist/pwa/assets/{web-DYwZE4qa.js → web-BqVsIFtP.js} +1 -1
- package/dist/pwa/assets/web-DrSNtZ3i.js +1 -0
- package/dist/pwa/assets/{web-nSzKzI8x.js → web-lefgO9YR.js} +1 -1
- package/dist/pwa/index.html +2 -2
- package/dist/pwa/service-worker.js +1 -1
- package/dist/transports/http-transport.js +42 -27
- package/dist/types.d.ts +0 -2
- package/package.json +1 -1
- package/palmier-server/CLAUDE.md +4 -0
- package/palmier-server/PRODUCTION.md +1 -1
- package/palmier-server/README.md +1 -1
- package/palmier-server/pnpm-lock.yaml +12 -0
- package/palmier-server/pwa/package.json +1 -0
- package/palmier-server/pwa/src/App.css +61 -0
- package/palmier-server/pwa/src/components/CapabilityToggles.tsx +1 -1
- package/palmier-server/pwa/src/components/ConnectionStatusIcon.tsx +69 -0
- package/palmier-server/pwa/src/components/HostMenu.tsx +5 -4
- package/palmier-server/pwa/src/constants.ts +1 -1
- package/palmier-server/pwa/src/contexts/HostConnectionContext.tsx +156 -66
- package/palmier-server/pwa/src/pages/Dashboard.tsx +2 -0
- package/palmier-server/pwa/src/pages/PairHost.tsx +9 -9
- package/palmier-server/pwa/src/types.ts +3 -1
- package/palmier-server/spec.md +21 -19
- package/src/commands/init.ts +7 -12
- package/src/commands/pair.ts +3 -0
- package/src/platform/macos.ts +2 -1
- package/src/transports/http-transport.ts +34 -29
- package/src/types.ts +0 -2
- package/dist/pwa/assets/index-BiAE5qeC.js +0 -120
|
@@ -26,7 +26,7 @@ const CAPABILITIES: CapabilityDefinition[] = [
|
|
|
26
26
|
{ capability: "sms-read", label: "Read SMS", group: "Messaging", permission: "smsRead" },
|
|
27
27
|
{ capability: "sms-send", label: "Send SMS", group: "Messaging", permission: "smsSend" },
|
|
28
28
|
{ capability: "send-email", label: "Send Email", group: "Messaging", permission: "postNotifications" },
|
|
29
|
-
{ capability: "notifications", label: "
|
|
29
|
+
{ capability: "notifications", label: "Notifications from Other Apps", group: "Data", permission: "notificationListener" },
|
|
30
30
|
{ capability: "contacts", label: "Manage Contacts", group: "Data", permission: "contacts" },
|
|
31
31
|
{ capability: "calendar", label: "Manage Calendar", group: "Data", permission: "calendar" },
|
|
32
32
|
{
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { useState, useRef, useEffect } from "react";
|
|
2
|
+
import { Capacitor } from "@capacitor/core";
|
|
3
|
+
import { useHostConnection } from "../contexts/HostConnectionContext";
|
|
4
|
+
|
|
5
|
+
const isNative = Capacitor.isNativePlatform();
|
|
6
|
+
|
|
7
|
+
export default function ConnectionStatusIcon() {
|
|
8
|
+
const { mode } = useHostConnection();
|
|
9
|
+
const [popoverOpen, setPopoverOpen] = useState(false);
|
|
10
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
11
|
+
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
if (!popoverOpen) return;
|
|
14
|
+
function onPointerDown(e: PointerEvent) {
|
|
15
|
+
if (!containerRef.current?.contains(e.target as Node)) setPopoverOpen(false);
|
|
16
|
+
}
|
|
17
|
+
document.addEventListener("pointerdown", onPointerDown);
|
|
18
|
+
return () => document.removeEventListener("pointerdown", onPointerDown);
|
|
19
|
+
}, [popoverOpen]);
|
|
20
|
+
|
|
21
|
+
if (mode === "direct") return null;
|
|
22
|
+
|
|
23
|
+
let icon: string;
|
|
24
|
+
let label: string;
|
|
25
|
+
let modifier: string;
|
|
26
|
+
switch (mode) {
|
|
27
|
+
case "lan":
|
|
28
|
+
icon = "\u{1F4F6}";
|
|
29
|
+
label = "Connected via LAN";
|
|
30
|
+
modifier = "lan";
|
|
31
|
+
break;
|
|
32
|
+
case "nats":
|
|
33
|
+
icon = "\u{1F310}";
|
|
34
|
+
label = isNative ? "Connected via relay" : "Connected";
|
|
35
|
+
modifier = "relay";
|
|
36
|
+
break;
|
|
37
|
+
case "disconnected":
|
|
38
|
+
icon = "\u26A0\uFE0F";
|
|
39
|
+
label = "Disconnected";
|
|
40
|
+
modifier = "disconnected";
|
|
41
|
+
break;
|
|
42
|
+
case "connecting":
|
|
43
|
+
default:
|
|
44
|
+
icon = "\u{1F310}";
|
|
45
|
+
label = "Connecting…";
|
|
46
|
+
modifier = "connecting";
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div
|
|
52
|
+
ref={containerRef}
|
|
53
|
+
className={`conn-status conn-status--${modifier}`}
|
|
54
|
+
>
|
|
55
|
+
<button
|
|
56
|
+
type="button"
|
|
57
|
+
className="conn-status-btn"
|
|
58
|
+
aria-label={label}
|
|
59
|
+
title={label}
|
|
60
|
+
onClick={() => setPopoverOpen((v) => !v)}
|
|
61
|
+
>
|
|
62
|
+
<span aria-hidden="true">{icon}</span>
|
|
63
|
+
</button>
|
|
64
|
+
<div className={`conn-status-popover ${popoverOpen ? "conn-status-popover--open" : ""}`} role="tooltip">
|
|
65
|
+
{label}
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -7,8 +7,9 @@ import { useMediaQuery } from "../hooks/useMediaQuery";
|
|
|
7
7
|
import { confirmLeaveDraft } from "../draftGuard";
|
|
8
8
|
import CapabilityToggles from "./CapabilityToggles";
|
|
9
9
|
|
|
10
|
-
/**
|
|
11
|
-
const
|
|
10
|
+
/** Local mode: PWA is served by palmier serve on loopback. */
|
|
11
|
+
const isLoopback = !!(window as any).__PALMIER_SERVE__
|
|
12
|
+
&& (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1");
|
|
12
13
|
const isNative = Capacitor.isNativePlatform();
|
|
13
14
|
|
|
14
15
|
interface HostMenuProps {
|
|
@@ -99,7 +100,7 @@ export default function HostMenu({ daemonVersion, capabilityTokens, activeClient
|
|
|
99
100
|
</button>
|
|
100
101
|
)}
|
|
101
102
|
|
|
102
|
-
{!
|
|
103
|
+
{!isLoopback && pairedHosts.length > 0 && (
|
|
103
104
|
<div className="drawer-section">
|
|
104
105
|
<h3 className="drawer-section-label">Hosts</h3>
|
|
105
106
|
<div className="host-picker-inline">
|
|
@@ -193,7 +194,7 @@ export default function HostMenu({ daemonVersion, capabilityTokens, activeClient
|
|
|
193
194
|
</div>
|
|
194
195
|
)}
|
|
195
196
|
|
|
196
|
-
{!
|
|
197
|
+
{!isLoopback && (<>
|
|
197
198
|
<div className="drawer-divider" />
|
|
198
199
|
<div className="drawer-section">
|
|
199
200
|
<button
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Bump when a breaking host change is made. */
|
|
2
|
-
export const MIN_HOST_VERSION = "0.8.
|
|
2
|
+
export const MIN_HOST_VERSION = "0.8.8";
|
|
@@ -8,11 +8,16 @@ import {
|
|
|
8
8
|
type ReactNode,
|
|
9
9
|
} from "react";
|
|
10
10
|
import { connect, jwtAuthenticator, StringCodec, type NatsConnection, type Subscription } from "nats.ws";
|
|
11
|
+
import { Capacitor } from "@capacitor/core";
|
|
12
|
+
import { App as CapacitorApp } from "@capacitor/app";
|
|
13
|
+
import { Network } from "@capacitor/network";
|
|
11
14
|
import { SERVER_URL } from "../api";
|
|
12
15
|
import { useHostStore } from "./HostStoreContext";
|
|
13
16
|
import type { PairedHost } from "../types";
|
|
14
17
|
|
|
15
|
-
type ConnectionMode = "nats" | "direct" | "disconnected";
|
|
18
|
+
type ConnectionMode = "nats" | "lan" | "direct" | "connecting" | "disconnected";
|
|
19
|
+
|
|
20
|
+
const isNative = Capacitor.isNativePlatform();
|
|
16
21
|
|
|
17
22
|
interface HostConnectionContextValue {
|
|
18
23
|
/** Whether we have an active connection to the host. */
|
|
@@ -33,7 +38,7 @@ interface HostConnectionContextValue {
|
|
|
33
38
|
|
|
34
39
|
const HostConnectionContext = createContext<HostConnectionContextValue>({
|
|
35
40
|
connected: false,
|
|
36
|
-
mode: "
|
|
41
|
+
mode: "connecting",
|
|
37
42
|
nc: null,
|
|
38
43
|
request() { return Promise.reject(new Error("No host connection")); },
|
|
39
44
|
subscribeEvents() { return () => {}; },
|
|
@@ -43,6 +48,8 @@ const HostConnectionContext = createContext<HostConnectionContextValue>({
|
|
|
43
48
|
|
|
44
49
|
const SSE_CONNECT_TIMEOUT_MS = 2_000;
|
|
45
50
|
const HEARTBEAT_TIMEOUT_MS = 6_000;
|
|
51
|
+
const LAN_PROBE_TIMEOUT_MS = 1_500;
|
|
52
|
+
const LAN_KEEPALIVE_MS = 60_000;
|
|
46
53
|
|
|
47
54
|
export function HostConnectionProvider({ children }: { children: ReactNode }) {
|
|
48
55
|
const { getActiveHost } = useHostStore();
|
|
@@ -54,6 +61,8 @@ export function HostConnectionProvider({ children }: { children: ReactNode }) {
|
|
|
54
61
|
|
|
55
62
|
const [sseConnected, setSseConnected] = useState(false);
|
|
56
63
|
const [unauthorized, setUnauthorized] = useState(false);
|
|
64
|
+
const [hostNotFound, setHostNotFound] = useState(false);
|
|
65
|
+
const [lanReachable, setLanReachable] = useState(false);
|
|
57
66
|
|
|
58
67
|
const sc = useRef(StringCodec());
|
|
59
68
|
const sseEventCallbacksRef = useRef<Set<(msg: { subject: string; data: Uint8Array }) => void>>(new Set());
|
|
@@ -61,16 +70,69 @@ export function HostConnectionProvider({ children }: { children: ReactNode }) {
|
|
|
61
70
|
|
|
62
71
|
// Host is a "direct-only" host if it has a directUrl (paired with address)
|
|
63
72
|
const isDirectHost = activeHost != null && !!activeHost.directUrl;
|
|
73
|
+
// Auto-LAN is opportunistic on native server-paired hosts that have a known LAN URL.
|
|
74
|
+
const useLanRpc = isNative && !isDirectHost && !!activeHost?.lanUrl && lanReachable;
|
|
64
75
|
|
|
65
|
-
const mode: ConnectionMode =
|
|
76
|
+
const mode: ConnectionMode = unauthorized || hostNotFound
|
|
66
77
|
? "disconnected"
|
|
67
|
-
:
|
|
68
|
-
?
|
|
69
|
-
:
|
|
70
|
-
|
|
78
|
+
: !activeHost
|
|
79
|
+
? "connecting"
|
|
80
|
+
: isDirectHost
|
|
81
|
+
? (sseConnected ? "direct" : "connecting")
|
|
82
|
+
: !natsConnected
|
|
83
|
+
? "connecting"
|
|
84
|
+
: (useLanRpc ? "lan" : "nats");
|
|
85
|
+
const connected = mode !== "connecting" && mode !== "disconnected";
|
|
86
|
+
|
|
87
|
+
// Reset terminal states when switching hosts or re-pairing (new client token).
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
setUnauthorized(false);
|
|
90
|
+
setHostNotFound(false);
|
|
91
|
+
}, [activeHost?.hostId, activeHost?.clientToken]);
|
|
71
92
|
|
|
72
|
-
//
|
|
73
|
-
|
|
93
|
+
// Probe the host's LAN URL on native server-paired hosts. RPC routes via direct
|
|
94
|
+
// HTTP when the probe succeeds; events stay on NATS regardless.
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
if (!isNative || isDirectHost || !activeHost?.lanUrl) {
|
|
97
|
+
setLanReachable(false);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let cancelled = false;
|
|
102
|
+
const lanUrl = activeHost.lanUrl;
|
|
103
|
+
const expectedHostId = activeHost.hostId;
|
|
104
|
+
|
|
105
|
+
async function probe() {
|
|
106
|
+
try {
|
|
107
|
+
const res = await fetch(`${lanUrl}/health`, {
|
|
108
|
+
signal: AbortSignal.timeout(LAN_PROBE_TIMEOUT_MS),
|
|
109
|
+
});
|
|
110
|
+
if (cancelled) return;
|
|
111
|
+
if (!res.ok) { setLanReachable(false); return; }
|
|
112
|
+
const data = await res.json() as { hostId?: string };
|
|
113
|
+
const reachable = data.hostId === expectedHostId;
|
|
114
|
+
setLanReachable(reachable);
|
|
115
|
+
if (reachable) console.log("[HOST/LAN] reachable:", lanUrl);
|
|
116
|
+
} catch {
|
|
117
|
+
if (!cancelled) setLanReachable(false);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
probe();
|
|
122
|
+
const intervalId = window.setInterval(probe, LAN_KEEPALIVE_MS);
|
|
123
|
+
|
|
124
|
+
const appHandle = CapacitorApp.addListener("appStateChange", (state: { isActive: boolean }) => {
|
|
125
|
+
if (state.isActive) probe();
|
|
126
|
+
});
|
|
127
|
+
const netHandle = Network.addListener("networkStatusChange", () => probe());
|
|
128
|
+
|
|
129
|
+
return () => {
|
|
130
|
+
cancelled = true;
|
|
131
|
+
clearInterval(intervalId);
|
|
132
|
+
appHandle.then((h) => h.remove());
|
|
133
|
+
netHandle.then((h) => h.remove());
|
|
134
|
+
};
|
|
135
|
+
}, [activeHost?.hostId, activeHost?.lanUrl, isDirectHost]);
|
|
74
136
|
|
|
75
137
|
// Fetch NATS config from server and connect (only for NATS hosts)
|
|
76
138
|
useEffect(() => {
|
|
@@ -87,51 +149,63 @@ export function HostConnectionProvider({ children }: { children: ReactNode }) {
|
|
|
87
149
|
if (!activeHost) return;
|
|
88
150
|
|
|
89
151
|
let cancelled = false;
|
|
152
|
+
let retryDelayMs = 1_000;
|
|
153
|
+
const MAX_RETRY_MS = 30_000;
|
|
90
154
|
|
|
91
|
-
async function
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
return;
|
|
103
|
-
}
|
|
104
|
-
if (cancelled) return;
|
|
155
|
+
async function connectLoop() {
|
|
156
|
+
while (!cancelled) {
|
|
157
|
+
try {
|
|
158
|
+
const res = await fetch(`${SERVER_URL}/api/nats-credentials/${activeHost!.hostId}`);
|
|
159
|
+
if (cancelled) return;
|
|
160
|
+
if (res.status === 401 || res.status === 403 || res.status === 404) {
|
|
161
|
+
console.error("[NATS] Host not found or rejected by relay:", res.status);
|
|
162
|
+
setHostNotFound(true);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (!res.ok) throw new Error(`credentials fetch ${res.status}`);
|
|
105
166
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
new TextEncoder().encode(config.natsNkeySeed),
|
|
112
|
-
),
|
|
113
|
-
});
|
|
114
|
-
if (cancelled) { conn.close().catch(() => {}); return; }
|
|
115
|
-
console.log("[NATS] Connected");
|
|
116
|
-
ncRef.current = conn;
|
|
117
|
-
setNc(conn);
|
|
118
|
-
setNatsConnected(true);
|
|
119
|
-
|
|
120
|
-
conn.closed().then(() => {
|
|
121
|
-
console.log("[NATS] Connection closed");
|
|
122
|
-
if (!cancelled) {
|
|
123
|
-
setNc(null);
|
|
124
|
-
setNatsConnected(false);
|
|
125
|
-
ncRef.current = null;
|
|
167
|
+
const config = await res.json() as { natsWsUrl: string; natsJwt: string; natsNkeySeed: string };
|
|
168
|
+
if (!config.natsWsUrl) {
|
|
169
|
+
console.error("[NATS] Relay returned empty natsWsUrl — treating as terminal");
|
|
170
|
+
setHostNotFound(true);
|
|
171
|
+
return;
|
|
126
172
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
173
|
+
if (cancelled) return;
|
|
174
|
+
|
|
175
|
+
console.log("[NATS] Connecting to", config.natsWsUrl);
|
|
176
|
+
const conn = await connect({
|
|
177
|
+
servers: config.natsWsUrl,
|
|
178
|
+
authenticator: jwtAuthenticator(
|
|
179
|
+
config.natsJwt,
|
|
180
|
+
new TextEncoder().encode(config.natsNkeySeed),
|
|
181
|
+
),
|
|
182
|
+
});
|
|
183
|
+
if (cancelled) { conn.close().catch(() => {}); return; }
|
|
184
|
+
console.log("[NATS] Connected");
|
|
185
|
+
ncRef.current = conn;
|
|
186
|
+
setNc(conn);
|
|
187
|
+
setNatsConnected(true);
|
|
188
|
+
retryDelayMs = 1_000;
|
|
189
|
+
|
|
190
|
+
await conn.closed();
|
|
191
|
+
if (cancelled) return;
|
|
192
|
+
|
|
193
|
+
console.log("[NATS] Connection closed, will reconnect");
|
|
194
|
+
ncRef.current = null;
|
|
195
|
+
setNc(null);
|
|
196
|
+
setNatsConnected(false);
|
|
197
|
+
} catch (err) {
|
|
198
|
+
if (cancelled) return;
|
|
199
|
+
console.warn(`[NATS] Connect failed, retrying in ${retryDelayMs}ms:`, err);
|
|
200
|
+
setNatsConnected(false);
|
|
201
|
+
await new Promise((r) => setTimeout(r, retryDelayMs));
|
|
202
|
+
retryDelayMs = Math.min(retryDelayMs * 2, MAX_RETRY_MS);
|
|
203
|
+
}
|
|
131
204
|
}
|
|
132
205
|
}
|
|
133
206
|
|
|
134
|
-
|
|
207
|
+
connectLoop();
|
|
208
|
+
|
|
135
209
|
return () => {
|
|
136
210
|
cancelled = true;
|
|
137
211
|
if (ncRef.current) {
|
|
@@ -239,14 +313,13 @@ export function HostConnectionProvider({ children }: { children: ReactNode }) {
|
|
|
239
313
|
}
|
|
240
314
|
}
|
|
241
315
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
const res = await fetch(`${activeHost.directUrl}/rpc/${method}`, {
|
|
316
|
+
async function callHttp(baseUrl: string, label: string): Promise<T> {
|
|
317
|
+
console.log(`[HOST/${label}] → ${method}`, params ?? "");
|
|
318
|
+
const res = await fetch(`${baseUrl}/rpc/${method}`, {
|
|
246
319
|
method: "POST",
|
|
247
320
|
headers: {
|
|
248
321
|
"Content-Type": "application/json",
|
|
249
|
-
Authorization: `Bearer ${activeHost
|
|
322
|
+
Authorization: `Bearer ${activeHost!.clientToken}`,
|
|
250
323
|
},
|
|
251
324
|
body: params != null ? JSON.stringify(params) : undefined,
|
|
252
325
|
signal: opts?.timeout ? AbortSignal.timeout(opts.timeout) : undefined,
|
|
@@ -257,22 +330,39 @@ export function HostConnectionProvider({ children }: { children: ReactNode }) {
|
|
|
257
330
|
}
|
|
258
331
|
const data = await res.json() as T;
|
|
259
332
|
checkUnauthorized(data);
|
|
260
|
-
console.log(`[HOST
|
|
333
|
+
console.log(`[HOST/${label}] ← ${method}`, data);
|
|
261
334
|
return data;
|
|
262
335
|
}
|
|
263
336
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
337
|
+
async function callNats(): Promise<T> {
|
|
338
|
+
if (!ncRef.current) throw new Error("Not connected");
|
|
339
|
+
const subject = `host.${activeHost!.hostId}.rpc.${method}`;
|
|
340
|
+
const payload = { ...(params as Record<string, unknown> ?? {}), clientToken: activeHost!.clientToken };
|
|
341
|
+
const body = sc.current.encode(JSON.stringify(payload));
|
|
342
|
+
console.log(`[HOST/NATS] → ${method}`, params ?? "");
|
|
343
|
+
const msg = await ncRef.current.request(subject, body, { timeout: opts?.timeout ?? 10000 });
|
|
344
|
+
const decoded = JSON.parse(sc.current.decode(msg.data)) as T;
|
|
345
|
+
checkUnauthorized(decoded);
|
|
346
|
+
console.log(`[HOST/NATS] ← ${method}`, decoded);
|
|
347
|
+
return decoded;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Direct (loopback) host — always HTTP, no fallback.
|
|
351
|
+
if (isDirectHost) return callHttp(activeHost.directUrl!, "HTTP");
|
|
352
|
+
|
|
353
|
+
// Auto-LAN: try HTTP first; on failure mark unreachable and retry over NATS.
|
|
354
|
+
if (useLanRpc && activeHost.lanUrl) {
|
|
355
|
+
try {
|
|
356
|
+
return await callHttp(activeHost.lanUrl, "LAN");
|
|
357
|
+
} catch (err) {
|
|
358
|
+
if (err instanceof Error && err.message === "Unauthorized") throw err;
|
|
359
|
+
console.log("[HOST/LAN] failed, falling back to NATS:", err);
|
|
360
|
+
setLanReachable(false);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return callNats();
|
|
365
|
+
}, [activeHost, isDirectHost, useLanRpc]);
|
|
276
366
|
|
|
277
367
|
// Subscribe to task events
|
|
278
368
|
const subscribeEvents = useCallback((hostId: string, callback: (msg: { subject: string; data: Uint8Array }) => void): () => void => {
|
|
@@ -7,6 +7,7 @@ import { useHostConnection } from "../contexts/HostConnectionContext";
|
|
|
7
7
|
import TasksView from "../components/TasksView";
|
|
8
8
|
import TabBar from "../components/TabBar";
|
|
9
9
|
import HostMenu from "../components/HostMenu";
|
|
10
|
+
import ConnectionStatusIcon from "../components/ConnectionStatusIcon";
|
|
10
11
|
import SessionsView from "../components/SessionsView";
|
|
11
12
|
import RunDetailView from "../components/RunDetailView";
|
|
12
13
|
import { usePushSubscription } from "../hooks/usePushSubscription";
|
|
@@ -298,6 +299,7 @@ export default function Dashboard() {
|
|
|
298
299
|
<div className="app-title-bar">
|
|
299
300
|
{!isDesktop && <HostMenu daemonVersion={daemonVersion} capabilityTokens={capabilityTokens} activeClientToken={activeClientToken} request={request} onCapabilityTokensChange={setCapabilityTokens} />}
|
|
300
301
|
<h1 className="app-title">Palmier</h1>
|
|
302
|
+
<ConnectionStatusIcon />
|
|
301
303
|
</div>
|
|
302
304
|
<div className="tab-bar">
|
|
303
305
|
<TabBar />
|
|
@@ -15,8 +15,9 @@ interface PairResponse {
|
|
|
15
15
|
hostName?: string;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
/**
|
|
19
|
-
const
|
|
18
|
+
/** Loopback (Local) mode: PWA is served by palmier serve on localhost. */
|
|
19
|
+
const isLoopback = !!(window as any).__PALMIER_SERVE__
|
|
20
|
+
&& (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1");
|
|
20
21
|
|
|
21
22
|
export default function PairHost() {
|
|
22
23
|
const [code, setCode] = useState("");
|
|
@@ -38,8 +39,7 @@ export default function PairHost() {
|
|
|
38
39
|
try {
|
|
39
40
|
let response: PairResponse;
|
|
40
41
|
|
|
41
|
-
if (
|
|
42
|
-
// LAN mode — same-origin fetch to the host serving this page
|
|
42
|
+
if (isLoopback) {
|
|
43
43
|
const res = await fetch("/pair", {
|
|
44
44
|
method: "POST",
|
|
45
45
|
headers: { "Content-Type": "application/json" },
|
|
@@ -53,7 +53,6 @@ export default function PairHost() {
|
|
|
53
53
|
|
|
54
54
|
response = await res.json() as PairResponse;
|
|
55
55
|
} else {
|
|
56
|
-
// Server mode — pair via NATS
|
|
57
56
|
const configRes = await fetch(`${SERVER_URL}/api/config`);
|
|
58
57
|
if (!configRes.ok) throw new Error("Failed to fetch server config");
|
|
59
58
|
const config = await configRes.json() as { natsWsUrl: string; natsJwt: string; natsNkeySeed: string };
|
|
@@ -82,7 +81,8 @@ export default function PairHost() {
|
|
|
82
81
|
const host: PairedHost = {
|
|
83
82
|
hostId: response.hostId,
|
|
84
83
|
clientToken: response.clientToken,
|
|
85
|
-
directUrl:
|
|
84
|
+
directUrl: isLoopback ? window.location.origin : undefined,
|
|
85
|
+
lanUrl: !isLoopback ? response.directUrl : undefined,
|
|
86
86
|
...(response.hostName ? { name: response.hostName } : {}),
|
|
87
87
|
};
|
|
88
88
|
|
|
@@ -125,15 +125,15 @@ export default function PairHost() {
|
|
|
125
125
|
<div className="pair-page">
|
|
126
126
|
<div className="pair-card">
|
|
127
127
|
<div className="pair-header">
|
|
128
|
-
<h1 className="pair-title">{
|
|
128
|
+
<h1 className="pair-title">{isLoopback ? "Pair" : "Pair with Host"}</h1>
|
|
129
129
|
<p className="pair-subtitle">
|
|
130
|
-
{
|
|
130
|
+
{isLoopback
|
|
131
131
|
? "Enter the pairing code shown in your terminal."
|
|
132
132
|
: "Connect this device to a Palmier host"}
|
|
133
133
|
</p>
|
|
134
134
|
</div>
|
|
135
135
|
|
|
136
|
-
{!
|
|
136
|
+
{!isLoopback && (
|
|
137
137
|
<div className="pair-instructions">
|
|
138
138
|
<div className="pair-instruction-block">
|
|
139
139
|
<h3 className="pair-instruction-heading">Setting up a new host?</h3>
|
|
@@ -64,6 +64,8 @@ export interface PairedHost {
|
|
|
64
64
|
hostId: string;
|
|
65
65
|
clientToken: string;
|
|
66
66
|
name?: string;
|
|
67
|
-
/** If set, all communication uses HTTP to this URL instead of NATS. */
|
|
67
|
+
/** If set, all communication uses HTTP to this URL instead of NATS. Set only for loopback (Local mode). */
|
|
68
68
|
directUrl?: string;
|
|
69
|
+
/** Host's LAN URL captured at pair time. Native Capacitor app probes for reachability and routes RPC over HTTP when reachable; events stay on NATS. */
|
|
70
|
+
lanUrl?: string;
|
|
69
71
|
}
|
package/palmier-server/spec.md
CHANGED
|
@@ -12,7 +12,7 @@ The host supports **Linux** (systemd), **macOS** (launchd user LaunchAgent), and
|
|
|
12
12
|
|
|
13
13
|
### 1.2 Components
|
|
14
14
|
|
|
15
|
-
* **Host Binary (Node.js):** Runs persistently on the user's host machine as a NATS + HTTP RPC handler. Manages file system operations (task CRUD), OS-level scheduling (systemd), and task generation. Provides a CLI with commands: `palmier init` (provisioning), `palmier pair` (generate pairing code for device pairing), `palmier clients` (manage client tokens), `palmier run <task-id>` (executes a task via the configured agent tool), `palmier uninstall` (stop daemon and remove all scheduled tasks), and `palmier serve` (persistent RPC handler, default command). The `serve` process always starts
|
|
15
|
+
* **Host Binary (Node.js):** Runs persistently on the user's host machine as a NATS + HTTP RPC handler. Manages file system operations (task CRUD), OS-level scheduling (systemd), and task generation. Provides a CLI with commands: `palmier init` (provisioning), `palmier pair` (generate pairing code for device pairing), `palmier clients` (manage client tokens), `palmier run <task-id>` (executes a task via the configured agent tool), `palmier uninstall` (stop daemon and remove all scheduled tasks), and `palmier serve` (persistent RPC handler, default command). The `serve` process always starts an HTTP server bound to `0.0.0.0` alongside the NATS transport. The web UI, `/pair`, and `/events` are gated to loopback callers; `/rpc/<method>` (bearer-auth) and `/health` (public) are reachable from the LAN to support the Capacitor app's auto-LAN mode. Exposes a localhost-only MCP server at `/mcp` (streamable HTTP transport) with tools: `notify`, `request-input`, `request-confirmation`, `device-geolocation`, `read-contacts`, `create-contact`, `read-calendar`, `create-calendar-event`, `send-sms-message`, `send-alarm`, `read-battery`, `set-ringer-mode`; and resources: `notifications://device` (device notifications), `sms-messages://device` (SMS messages). Tools and resources are auto-generated as REST endpoints from shared registries (`ToolDefinition[]`, `ResourceDefinition[]`) — zero duplication. Tool REST endpoints are POST with `taskId` query param; resource REST endpoints are GET. `/request-permission` remains a separate endpoint (not part of the MCP registries). MCP resources support subscriptions — clients call `resources/subscribe` and the server holds the POST response open as an SSE stream, pushing `notifications/resources/updated` notifications when the resource data changes. MCP sessions track agent names from `initialize` clientInfo for logging and UI display. `palmier run` is a short-lived process invoked by systemd. Task execution is abstracted through an `AgentTool` interface (`src/agents/agent.ts`) so different AI CLI tools can be supported — each agent implements `getPromptCommandLine()`, `getTaskRunCommandLine()`, and `init()`. The task's `agent` field (e.g., `"claude"`) selects which agent is used.
|
|
16
16
|
|
|
17
17
|
* **Web Server (Node.js):** Serves the PWA assets (React) via `app.palmier.me` (Cloudflare proxied), manages Web Push VAPID keys, and provides host registration. Uses **PostgreSQL** for persistent storage (host registrations, push subscriptions, FCM tokens). Connects to NATS via TCP to subscribe to `host-event.>` for sending push notifications (confirmations, dismissals, completion/failure). For `POST /api/push/respond` (confirmation responses via push notification action buttons), the Web Server forwards the response to the host via the `task.user_input` NATS RPC. Subscribes to `host.*.push.send` NATS subjects to relay push notification requests from the host CLI. Subscribes to `host.*.fcm.geolocation` to relay device geolocation requests via FCM. Subscribes to `host.*.fcm.contacts`, `host.*.fcm.calendar`, `host.*.fcm.sms`, `host.*.fcm.alarm`, `host.*.fcm.battery`, and `host.*.fcm.ringer` to relay device capability requests via FCM. Provides HTTP endpoints for Android to post responses back (`/api/device/contacts-response`, `/api/device/calendar-response`, `/api/device/sms-response`, `/api/device/alarm-response`, `/api/device/battery-response`, `/api/device/ringer-response`). Co-located with the NATS server on the same machine.
|
|
18
18
|
|
|
@@ -90,42 +90,42 @@ Each host machine is provisioned via `palmier init`, an interactive wizard that
|
|
|
90
90
|
`palmier init` is an interactive wizard that:
|
|
91
91
|
|
|
92
92
|
1. Detects installed agent CLIs.
|
|
93
|
-
2. Asks
|
|
94
|
-
3. Shows a summary of task storage directory, local access URL,
|
|
93
|
+
2. Asks which HTTP port to use (default 7256).
|
|
94
|
+
3. Shows a summary of task storage directory, local access URL, detected agents, and any existing tasks to recover. Asks for confirmation before proceeding.
|
|
95
95
|
4. Registers with the Palmier server via `POST <url>/api/hosts/register` — server returns `{ hostId, natsUrl, natsWsUrl, natsJwt, natsNkeySeed }`.
|
|
96
|
-
5. Saves config to `~/.config/palmier/host.json` (includes `httpPort`,
|
|
96
|
+
5. Saves config to `~/.config/palmier/host.json` (includes `httpPort`, NATS credentials).
|
|
97
97
|
6. Installs a systemd user service (Linux), user LaunchAgent (macOS), or Task Scheduler entry (Windows) and auto-enters pair mode.
|
|
98
98
|
|
|
99
99
|
The daemon automatically recovers existing tasks by reinstalling their system timers on startup.
|
|
100
100
|
|
|
101
|
-
The `serve` daemon always starts an HTTP server
|
|
101
|
+
The `serve` daemon always starts an HTTP server bound to `0.0.0.0:<port>`. Two access modes are available:
|
|
102
102
|
|
|
103
|
-
**Local mode** (always available):
|
|
104
|
-
-
|
|
103
|
+
**Local mode** (always available, loopback only):
|
|
104
|
+
- The PWA is accessible at `http://localhost:<port>` without pairing or internet. The PWA is bundled with the host package. The serve daemon injects `window.__PALMIER_SERVE__=true` into the HTML; the PWA detects this and auto-connects. Web UI assets, `/pair`, and `/events` are gated to loopback callers (`127.0.0.1`/`::1`); non-loopback requests get 404.
|
|
105
105
|
|
|
106
|
-
**
|
|
107
|
-
- HTTP server binds to `0.0.0.0:<port>`, making the PWA accessible from the local network at `http://<host-ip>:<port>`. Non-localhost access requires pairing via a pairing code. Push notifications are not available.
|
|
108
|
-
|
|
109
|
-
**Server mode** (NATS cloud relay, always on):
|
|
106
|
+
**Server mode** (NATS cloud relay):
|
|
110
107
|
- Communication is relayed through the Palmier cloud server via NATS. PWA is accessed at `https://app.palmier.me`. Enables push notifications and remote access.
|
|
111
108
|
|
|
109
|
+
**Auto-LAN** (transparent perf optimization on top of Server mode):
|
|
110
|
+
- The host's HTTP server exposes `/rpc/<method>` (bearer-auth) and `/health` (public) on all interfaces, so other devices on the LAN can reach them. The native Capacitor app probes `${lanUrl}/health` (URL captured at pair time) and routes RPC over direct HTTP when reachable, falling back to NATS otherwise. Events stay on NATS regardless. Browser PWAs cannot use this path due to Private Network Access / mixed-content rules.
|
|
111
|
+
|
|
112
112
|
### 2.2 Device Pairing
|
|
113
113
|
|
|
114
114
|
Local access (`http://localhost:<port>`) requires no pairing — the PWA auto-connects with a placeholder host ID.
|
|
115
115
|
|
|
116
|
-
For
|
|
116
|
+
For server-mode pairing, `palmier pair` generates a 6-character pairing code from the charset `ABCDEFGHJKMNPQRSTUVWXYZ23456789` (excludes ambiguous O/0/I/1/L) and listens on both NATS (relay) and HTTP (loopback only) in parallel:
|
|
117
117
|
|
|
118
|
-
**Server
|
|
118
|
+
**Server pairing (NATS, primary path):**
|
|
119
119
|
1. Host subscribes to `pair.<CODE>` on NATS with a 5-minute timeout.
|
|
120
120
|
2. User enters the code in the PWA at `https://app.palmier.me`.
|
|
121
|
-
3. Host validates the code, generates a client token via `addClient()`, and responds with `{ hostId, clientToken }`.
|
|
121
|
+
3. Host validates the code, generates a client token via `addClient()`, and responds with `{ hostId, clientToken, directUrl, hostName }`. `directUrl` is the host's LAN URL (`http://<lan-ip>:<port>`) — stored on the device as `lanUrl` and probed by the native app for auto-LAN.
|
|
122
122
|
|
|
123
|
-
**
|
|
124
|
-
1. Host registers the code with the serve daemon via `POST /pair-register
|
|
125
|
-
2. User opens `http
|
|
126
|
-
3. PWA posts `POST /pair` with `{ code }`
|
|
123
|
+
**Local pairing (HTTP, loopback only):**
|
|
124
|
+
1. Host registers the code with the serve daemon via `POST /pair-register` (loopback-gated).
|
|
125
|
+
2. User opens `http://localhost:<port>` on the host machine and enters the code.
|
|
126
|
+
3. PWA posts `POST /pair` (loopback-gated) with `{ code }` and gets the same payload as above. The PWA treats `directUrl = window.location.origin` (loopback), and stores nothing as `lanUrl`.
|
|
127
127
|
|
|
128
|
-
|
|
128
|
+
The PWA stores the paired host in localStorage and navigates to the dashboard. Codes expire after 5 minutes or first successful use.
|
|
129
129
|
|
|
130
130
|
### 2.3 Client Management
|
|
131
131
|
|
|
@@ -143,6 +143,8 @@ All communication is scoped per host. **Request-reply** is used for RPC-style ca
|
|
|
143
143
|
|
|
144
144
|
The **RPC method is derived from the NATS subject**, not the message body. The host subscribes to `host.<host_id>.rpc.>` and extracts the method by splitting the subject at `rpc.` (e.g., `...rpc.task.create` → `task.create`). The message body contains the request parameters as JSON, including the `clientToken` field for authentication.
|
|
145
145
|
|
|
146
|
+
**Auto-LAN (native Capacitor app only).** When the device probes the host's LAN URL successfully, it routes the same RPC methods over direct HTTP (`POST <lanUrl>/rpc/<method>` with `Authorization: Bearer <clientToken>`) instead of through NATS. Identical request/response payloads, lower latency. Browser PWA always uses NATS. Events (`host-event.*`) always flow through NATS regardless of mode.
|
|
147
|
+
|
|
146
148
|
**Host RPC endpoints** (request-reply, subject: `host.<host_id>.rpc.<method>`):
|
|
147
149
|
|
|
148
150
|
| Method | Params | Description |
|
package/src/commands/init.ts
CHANGED
|
@@ -37,25 +37,21 @@ export async function initCommand(): Promise<void> {
|
|
|
37
37
|
|
|
38
38
|
console.log(` Found: ${green(agents.map((a) => a.label).join(", "))}\n`);
|
|
39
39
|
|
|
40
|
-
const lanAnswer = await ask("Enable LAN access (direct HTTP from local network)? (y/N): ");
|
|
41
|
-
const lanEnabled = lanAnswer.trim().toLowerCase() === "y";
|
|
42
|
-
|
|
43
40
|
let httpPort = 7256;
|
|
44
|
-
const
|
|
45
|
-
const portAnswer = await ask(`${portLabel} (default ${httpPort}): `);
|
|
41
|
+
const portAnswer = await ask(`HTTP port (default ${httpPort}): `);
|
|
46
42
|
const parsed = parseInt(portAnswer.trim(), 10);
|
|
47
43
|
if (parsed > 0 && parsed < 65536) httpPort = parsed;
|
|
48
44
|
|
|
45
|
+
const lanIp = detectLanIp();
|
|
46
|
+
|
|
49
47
|
console.log(`\n${bold("Setup summary:")}\n`);
|
|
50
48
|
console.log(` ${dim("Task storage:")} ${bold(process.cwd())}`);
|
|
51
49
|
console.log(` All tasks and execution data will be stored here.\n`);
|
|
52
50
|
console.log(` ${dim("Local access:")} ${cyan(`http://localhost:${httpPort}`)}`);
|
|
53
|
-
console.log(`
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
console.log(` Accessible from other devices on your local network. Pairing required.\n`);
|
|
58
|
-
}
|
|
51
|
+
console.log(` Open in a browser on this machine — no internet required.\n`);
|
|
52
|
+
console.log(` ${dim("Remote access:")} ${cyan("https://app.palmier.me")}`);
|
|
53
|
+
console.log(` Pair the app to your host. The app uses ${cyan(`http://${lanIp}:${httpPort}`)}`);
|
|
54
|
+
console.log(` for direct RPC when on the same network, otherwise the relay.\n`);
|
|
59
55
|
console.log(` ${dim("Agents:")} ${agents.map((a) => a.label).join(", ")}\n`);
|
|
60
56
|
|
|
61
57
|
const existingTasks = listTasks(process.cwd());
|
|
@@ -106,7 +102,6 @@ export async function initCommand(): Promise<void> {
|
|
|
106
102
|
natsNkeySeed: registerResponse.natsNkeySeed,
|
|
107
103
|
agents,
|
|
108
104
|
httpPort,
|
|
109
|
-
lanEnabled,
|
|
110
105
|
};
|
|
111
106
|
|
|
112
107
|
saveConfig(config);
|