palmier 0.6.0 → 0.6.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/.github/workflows/publish.yml +15 -2
- package/CLAUDE.md +2 -2
- package/DISCLAIMER.md +36 -0
- package/README.md +76 -87
- package/dist/agents/agent-instructions.md +1 -1
- package/dist/agents/agent.d.ts +2 -0
- package/dist/agents/agent.js +21 -0
- package/dist/agents/aider.d.ts +9 -0
- package/dist/agents/aider.js +32 -0
- package/dist/agents/cursor.d.ts +9 -0
- package/dist/agents/cursor.js +35 -0
- package/dist/agents/deepagents.d.ts +9 -0
- package/dist/agents/deepagents.js +35 -0
- package/dist/agents/droid.d.ts +9 -0
- package/dist/agents/droid.js +32 -0
- package/dist/agents/goose.d.ts +9 -0
- package/dist/agents/goose.js +32 -0
- package/dist/agents/opencode.d.ts +9 -0
- package/dist/agents/opencode.js +35 -0
- package/dist/agents/openhands.d.ts +9 -0
- package/dist/agents/openhands.js +35 -0
- package/dist/commands/pair.d.ts +1 -1
- package/dist/commands/pair.js +1 -1
- package/dist/commands/run.js +2 -2
- package/dist/pwa/apple-touch-icon.png +0 -0
- package/dist/pwa/assets/index-ByhOhTz1.js +118 -0
- package/dist/pwa/assets/index-_AmC1Rkn.css +1 -0
- package/dist/pwa/assets/plus-jakarta-sans-latin-ext-wght-normal-DmpS2jIq.woff2 +0 -0
- package/dist/pwa/assets/plus-jakarta-sans-latin-wght-normal-eXO_dkmS.woff2 +0 -0
- package/dist/pwa/assets/plus-jakarta-sans-vietnamese-wght-normal-qRpaaN48.woff2 +0 -0
- package/dist/pwa/favicon.ico +0 -0
- package/dist/pwa/index.html +17 -0
- package/dist/pwa/manifest.webmanifest +1 -0
- package/dist/pwa/pwa-192x192.png +0 -0
- package/dist/pwa/pwa-512x512.png +0 -0
- package/dist/pwa/registerSW.js +1 -0
- package/dist/pwa/service-worker.js +2 -0
- package/dist/rpc-handler.d.ts +4 -0
- package/dist/rpc-handler.js +5 -4
- package/dist/transports/http-transport.js +29 -41
- package/package.json +2 -2
- package/palmier-server/.github/workflows/ci.yml +21 -0
- package/palmier-server/.github/workflows/deploy.yml +38 -0
- package/palmier-server/CLAUDE.md +13 -0
- package/palmier-server/PRODUCTION.md +355 -0
- package/palmier-server/README.md +187 -0
- package/palmier-server/nats.conf +15 -0
- package/palmier-server/package.json +8 -0
- package/palmier-server/pnpm-lock.yaml +6597 -0
- package/palmier-server/pnpm-workspace.yaml +3 -0
- package/palmier-server/pwa/index.html +16 -0
- package/palmier-server/pwa/logo/logo-prompt.md +28 -0
- package/palmier-server/pwa/logo/logo_20260330.png +0 -0
- package/palmier-server/pwa/package.json +30 -0
- package/palmier-server/pwa/public/apple-touch-icon.png +0 -0
- package/palmier-server/pwa/public/favicon.ico +0 -0
- package/palmier-server/pwa/public/pwa-192x192.png +0 -0
- package/palmier-server/pwa/public/pwa-512x512.png +0 -0
- package/palmier-server/pwa/src/App.css +2387 -0
- package/palmier-server/pwa/src/App.tsx +21 -0
- package/palmier-server/pwa/src/agentLabels.ts +11 -0
- package/palmier-server/pwa/src/api.ts +61 -0
- package/palmier-server/pwa/src/components/HostMenu.tsx +289 -0
- package/palmier-server/pwa/src/components/PlanDialog.tsx +41 -0
- package/palmier-server/pwa/src/components/RunDetailView.tsx +293 -0
- package/palmier-server/pwa/src/components/RunsView.tsx +254 -0
- package/palmier-server/pwa/src/components/TabBar.tsx +31 -0
- package/palmier-server/pwa/src/components/TaskCard.tsx +213 -0
- package/palmier-server/pwa/src/components/TaskForm.tsx +580 -0
- package/palmier-server/pwa/src/components/TaskListView.tsx +415 -0
- package/palmier-server/pwa/src/constants.ts +2 -0
- package/palmier-server/pwa/src/contexts/HostConnectionContext.tsx +313 -0
- package/palmier-server/pwa/src/contexts/HostStoreContext.tsx +135 -0
- package/palmier-server/pwa/src/formatTime.ts +10 -0
- package/palmier-server/pwa/src/hooks/useBackClose.ts +75 -0
- package/palmier-server/pwa/src/hooks/useMediaQuery.ts +17 -0
- package/palmier-server/pwa/src/hooks/usePushSubscription.ts +75 -0
- package/palmier-server/pwa/src/main.tsx +14 -0
- package/palmier-server/pwa/src/pages/Dashboard.tsx +223 -0
- package/palmier-server/pwa/src/pages/PairHost.tsx +178 -0
- package/palmier-server/pwa/src/service-worker.ts +139 -0
- package/palmier-server/pwa/src/types.ts +79 -0
- package/palmier-server/pwa/src/vite-env.d.ts +11 -0
- package/palmier-server/pwa/tsconfig.json +21 -0
- package/palmier-server/pwa/tsconfig.node.json +19 -0
- package/palmier-server/pwa/vite.config.ts +47 -0
- package/palmier-server/server/.env.example +16 -0
- package/palmier-server/server/package.json +33 -0
- package/palmier-server/server/src/db.ts +34 -0
- package/palmier-server/server/src/index.ts +219 -0
- package/palmier-server/server/src/nats.ts +25 -0
- package/palmier-server/server/src/push.ts +68 -0
- package/palmier-server/server/src/routes/hosts.ts +45 -0
- package/palmier-server/server/src/routes/push.ts +100 -0
- package/palmier-server/server/tsconfig.json +20 -0
- package/palmier-server/spec.md +415 -0
- package/src/agents/agent-instructions.md +1 -1
- package/src/agents/agent.ts +23 -0
- package/src/agents/aider.ts +37 -0
- package/src/agents/cursor.ts +38 -0
- package/src/agents/deepagents.ts +38 -0
- package/src/agents/droid.ts +37 -0
- package/src/agents/goose.ts +35 -0
- package/src/agents/opencode.ts +38 -0
- package/src/agents/openhands.ts +38 -0
- package/src/commands/pair.ts +1 -1
- package/src/commands/run.ts +2 -2
- package/src/rpc-handler.ts +5 -4
- package/src/transports/http-transport.ts +31 -43
- package/test/result-state.test.ts +110 -0
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useContext,
|
|
4
|
+
useState,
|
|
5
|
+
useEffect,
|
|
6
|
+
useRef,
|
|
7
|
+
useCallback,
|
|
8
|
+
type ReactNode,
|
|
9
|
+
} from "react";
|
|
10
|
+
import { connect, StringCodec, type NatsConnection, type Subscription } from "nats.ws";
|
|
11
|
+
import { useHostStore } from "./HostStoreContext";
|
|
12
|
+
import type { PairedHost } from "../types";
|
|
13
|
+
|
|
14
|
+
type ConnectionMode = "nats" | "direct" | "disconnected";
|
|
15
|
+
|
|
16
|
+
interface HostConnectionContextValue {
|
|
17
|
+
/** Whether we have an active connection to the host. */
|
|
18
|
+
connected: boolean;
|
|
19
|
+
/** Current connection mode. */
|
|
20
|
+
mode: ConnectionMode;
|
|
21
|
+
/** The raw NATS connection (null in direct-only mode). Used for pub/sub. */
|
|
22
|
+
nc: NatsConnection | null;
|
|
23
|
+
/** Transport-agnostic RPC request. */
|
|
24
|
+
request<T = unknown>(method: string, params?: unknown, opts?: { timeout?: number }): Promise<T>;
|
|
25
|
+
/** Subscribe to task events. Returns unsubscribe function. */
|
|
26
|
+
subscribeEvents(hostId: string, callback: (msg: { subject: string; data: Uint8Array }) => void): () => void;
|
|
27
|
+
/** Current active host. */
|
|
28
|
+
activeHost: PairedHost | null;
|
|
29
|
+
/** Whether the current client has been revoked by the host. */
|
|
30
|
+
unauthorized: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const HostConnectionContext = createContext<HostConnectionContextValue>({
|
|
34
|
+
connected: false,
|
|
35
|
+
mode: "disconnected",
|
|
36
|
+
nc: null,
|
|
37
|
+
request() { return Promise.reject(new Error("No host connection")); },
|
|
38
|
+
subscribeEvents() { return () => {}; },
|
|
39
|
+
activeHost: null,
|
|
40
|
+
unauthorized: false,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const SSE_CONNECT_TIMEOUT_MS = 2_000;
|
|
44
|
+
const HEARTBEAT_TIMEOUT_MS = 6_000;
|
|
45
|
+
|
|
46
|
+
export function HostConnectionProvider({ children }: { children: ReactNode }) {
|
|
47
|
+
const { getActiveHost } = useHostStore();
|
|
48
|
+
const activeHost = getActiveHost();
|
|
49
|
+
|
|
50
|
+
const [nc, setNc] = useState<NatsConnection | null>(null);
|
|
51
|
+
const [natsConnected, setNatsConnected] = useState(false);
|
|
52
|
+
const ncRef = useRef<NatsConnection | null>(null);
|
|
53
|
+
|
|
54
|
+
const [sseConnected, setSseConnected] = useState(false);
|
|
55
|
+
const [unauthorized, setUnauthorized] = useState(false);
|
|
56
|
+
|
|
57
|
+
const sc = useRef(StringCodec());
|
|
58
|
+
const sseEventCallbacksRef = useRef<Set<(msg: { subject: string; data: Uint8Array }) => void>>(new Set());
|
|
59
|
+
const lastHeartbeat = useRef(0);
|
|
60
|
+
|
|
61
|
+
// Host is a "direct-only" host if it has a directUrl (paired with address)
|
|
62
|
+
const isDirectHost = activeHost != null && !!activeHost.directUrl;
|
|
63
|
+
|
|
64
|
+
const mode: ConnectionMode = !activeHost
|
|
65
|
+
? "disconnected"
|
|
66
|
+
: isDirectHost
|
|
67
|
+
? (sseConnected ? "direct" : "disconnected")
|
|
68
|
+
: (natsConnected ? "nats" : "disconnected");
|
|
69
|
+
const connected = mode !== "disconnected";
|
|
70
|
+
|
|
71
|
+
// Reset unauthorized when switching hosts or re-pairing (new client token)
|
|
72
|
+
useEffect(() => { setUnauthorized(false); }, [activeHost?.hostId, activeHost?.clientToken]);
|
|
73
|
+
|
|
74
|
+
// Fetch NATS config from server and connect (only for NATS hosts)
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
if (isDirectHost) {
|
|
77
|
+
if (ncRef.current) {
|
|
78
|
+
ncRef.current.close().catch(() => {});
|
|
79
|
+
ncRef.current = null;
|
|
80
|
+
setNc(null);
|
|
81
|
+
setNatsConnected(false);
|
|
82
|
+
}
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!activeHost) return;
|
|
87
|
+
|
|
88
|
+
let cancelled = false;
|
|
89
|
+
|
|
90
|
+
async function init() {
|
|
91
|
+
try {
|
|
92
|
+
const res = await fetch("/api/config");
|
|
93
|
+
if (!res.ok) {
|
|
94
|
+
console.error("[NATS] Failed to fetch config");
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const config = await res.json() as { natsWsUrl: string; natsToken: string };
|
|
98
|
+
if (!config.natsWsUrl) {
|
|
99
|
+
console.warn("[NATS] No WebSocket URL configured");
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (cancelled) return;
|
|
103
|
+
|
|
104
|
+
console.log("[NATS] Connecting to", config.natsWsUrl);
|
|
105
|
+
const conn = await connect({
|
|
106
|
+
servers: config.natsWsUrl,
|
|
107
|
+
token: config.natsToken,
|
|
108
|
+
});
|
|
109
|
+
if (cancelled) { conn.close().catch(() => {}); return; }
|
|
110
|
+
console.log("[NATS] Connected");
|
|
111
|
+
ncRef.current = conn;
|
|
112
|
+
setNc(conn);
|
|
113
|
+
setNatsConnected(true);
|
|
114
|
+
|
|
115
|
+
conn.closed().then(() => {
|
|
116
|
+
console.log("[NATS] Connection closed");
|
|
117
|
+
if (!cancelled) {
|
|
118
|
+
setNc(null);
|
|
119
|
+
setNatsConnected(false);
|
|
120
|
+
ncRef.current = null;
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
} catch (err) {
|
|
124
|
+
console.error("[NATS] Connection failed:", err);
|
|
125
|
+
if (!cancelled) setNatsConnected(false);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
init();
|
|
130
|
+
return () => {
|
|
131
|
+
cancelled = true;
|
|
132
|
+
if (ncRef.current) {
|
|
133
|
+
ncRef.current.close().catch(() => {});
|
|
134
|
+
ncRef.current = null;
|
|
135
|
+
setNc(null);
|
|
136
|
+
setNatsConnected(false);
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
}, [isDirectHost, activeHost]);
|
|
140
|
+
|
|
141
|
+
// SSE connection for direct (LAN) hosts only
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
if (!activeHost || !isDirectHost) {
|
|
144
|
+
setSseConnected(false);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const controller = new AbortController();
|
|
149
|
+
|
|
150
|
+
async function connectSSE(): Promise<ReadableStreamDefaultReader<Uint8Array> | null> {
|
|
151
|
+
const connectAc = new AbortController();
|
|
152
|
+
const timer = setTimeout(() => connectAc.abort(), SSE_CONNECT_TIMEOUT_MS);
|
|
153
|
+
controller.signal.addEventListener("abort", () => connectAc.abort());
|
|
154
|
+
try {
|
|
155
|
+
const res = await fetch(`${activeHost!.directUrl}/events`, {
|
|
156
|
+
headers: { Authorization: `Bearer ${activeHost!.clientToken}` },
|
|
157
|
+
signal: connectAc.signal,
|
|
158
|
+
});
|
|
159
|
+
clearTimeout(timer);
|
|
160
|
+
if (!res.ok) return null;
|
|
161
|
+
return res.body?.getReader() ?? null;
|
|
162
|
+
} catch {
|
|
163
|
+
clearTimeout(timer);
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function readStream(reader: ReadableStreamDefaultReader<Uint8Array>) {
|
|
169
|
+
setSseConnected(true);
|
|
170
|
+
console.log("[HOST] SSE connected to", activeHost!.directUrl);
|
|
171
|
+
|
|
172
|
+
lastHeartbeat.current = Date.now();
|
|
173
|
+
function checkHeartbeat() {
|
|
174
|
+
setTimeout(() => {
|
|
175
|
+
if (controller.signal.aborted) return;
|
|
176
|
+
if (Date.now() - lastHeartbeat.current < HEARTBEAT_TIMEOUT_MS) return;
|
|
177
|
+
console.log("[HOST] Heartbeat timeout — no data for", HEARTBEAT_TIMEOUT_MS / 1000, "s");
|
|
178
|
+
controller.abort();
|
|
179
|
+
setSseConnected(false);
|
|
180
|
+
console.log("[HOST] Direct host unreachable");
|
|
181
|
+
}, HEARTBEAT_TIMEOUT_MS);
|
|
182
|
+
}
|
|
183
|
+
checkHeartbeat();
|
|
184
|
+
|
|
185
|
+
const decoder = new TextDecoder();
|
|
186
|
+
let buffer = "";
|
|
187
|
+
try {
|
|
188
|
+
while (true) {
|
|
189
|
+
const { done, value } = await reader.read();
|
|
190
|
+
if (done) break;
|
|
191
|
+
buffer += decoder.decode(value, { stream: true });
|
|
192
|
+
lastHeartbeat.current = Date.now();
|
|
193
|
+
checkHeartbeat();
|
|
194
|
+
|
|
195
|
+
const lines = buffer.split("\n");
|
|
196
|
+
buffer = lines.pop() ?? "";
|
|
197
|
+
for (const line of lines) {
|
|
198
|
+
if (line.startsWith("data: ")) {
|
|
199
|
+
const jsonStr = line.slice(6);
|
|
200
|
+
try {
|
|
201
|
+
const event = JSON.parse(jsonStr) as { task_id?: string; event_type?: string };
|
|
202
|
+
if (event.task_id && event.event_type) {
|
|
203
|
+
const subject = `host-event.${activeHost!.hostId}.${event.task_id}`;
|
|
204
|
+
for (const cb of sseEventCallbacksRef.current) cb({ subject, data: sc.current.encode(jsonStr) });
|
|
205
|
+
}
|
|
206
|
+
} catch { /* skip malformed — includes heartbeat pings */ }
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
} catch {
|
|
211
|
+
// Stream ended or aborted
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
(async () => {
|
|
216
|
+
const reader = await connectSSE();
|
|
217
|
+
if (reader) await readStream(reader);
|
|
218
|
+
})();
|
|
219
|
+
|
|
220
|
+
return () => { controller.abort(); setSseConnected(false); };
|
|
221
|
+
}, [activeHost, isDirectHost]);
|
|
222
|
+
|
|
223
|
+
// Transport-agnostic RPC
|
|
224
|
+
const request = useCallback(async <T = unknown>(
|
|
225
|
+
method: string,
|
|
226
|
+
params?: unknown,
|
|
227
|
+
opts?: { timeout?: number },
|
|
228
|
+
): Promise<T> => {
|
|
229
|
+
if (!activeHost) throw new Error("No active host");
|
|
230
|
+
|
|
231
|
+
function checkUnauthorized(data: unknown): void {
|
|
232
|
+
if (data && typeof data === "object" && (data as Record<string, unknown>).error === "Unauthorized") {
|
|
233
|
+
setUnauthorized(true);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Direct host (paired with address) — always use HTTP
|
|
238
|
+
if (isDirectHost) {
|
|
239
|
+
console.log(`[HOST/HTTP] → ${method}`, params ?? "");
|
|
240
|
+
const res = await fetch(`${activeHost.directUrl}/rpc/${method}`, {
|
|
241
|
+
method: "POST",
|
|
242
|
+
headers: {
|
|
243
|
+
"Content-Type": "application/json",
|
|
244
|
+
Authorization: `Bearer ${activeHost.clientToken}`,
|
|
245
|
+
},
|
|
246
|
+
body: params != null ? JSON.stringify(params) : undefined,
|
|
247
|
+
signal: opts?.timeout ? AbortSignal.timeout(opts.timeout) : undefined,
|
|
248
|
+
});
|
|
249
|
+
if (res.status === 401) {
|
|
250
|
+
setUnauthorized(true);
|
|
251
|
+
throw new Error("Unauthorized");
|
|
252
|
+
}
|
|
253
|
+
const data = await res.json() as T;
|
|
254
|
+
checkUnauthorized(data);
|
|
255
|
+
console.log(`[HOST/HTTP] ← ${method}`, data);
|
|
256
|
+
return data;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// NATS mode
|
|
260
|
+
if (!ncRef.current) throw new Error("Not connected");
|
|
261
|
+
const subject = `host.${activeHost.hostId}.rpc.${method}`;
|
|
262
|
+
const payload = { ...(params as Record<string, unknown> ?? {}), clientToken: activeHost.clientToken };
|
|
263
|
+
const body = sc.current.encode(JSON.stringify(payload));
|
|
264
|
+
console.log(`[HOST/NATS] → ${method}`, params ?? "");
|
|
265
|
+
const msg = await ncRef.current.request(subject, body, { timeout: opts?.timeout ?? 10000 });
|
|
266
|
+
const decoded = JSON.parse(sc.current.decode(msg.data)) as T;
|
|
267
|
+
checkUnauthorized(decoded);
|
|
268
|
+
console.log(`[HOST/NATS] ← ${method}`, decoded);
|
|
269
|
+
return decoded;
|
|
270
|
+
}, [activeHost, isDirectHost]);
|
|
271
|
+
|
|
272
|
+
// Subscribe to task events
|
|
273
|
+
const subscribeEvents = useCallback((hostId: string, callback: (msg: { subject: string; data: Uint8Array }) => void): () => void => {
|
|
274
|
+
// Direct mode — register callback for SSE to forward events
|
|
275
|
+
if (isDirectHost) {
|
|
276
|
+
sseEventCallbacksRef.current.add(callback);
|
|
277
|
+
return () => { sseEventCallbacksRef.current.delete(callback); };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// NATS subscription
|
|
281
|
+
if (ncRef.current) {
|
|
282
|
+
const sub: Subscription = ncRef.current.subscribe(`host-event.${hostId}.>`);
|
|
283
|
+
let cancelled = false;
|
|
284
|
+
|
|
285
|
+
(async () => {
|
|
286
|
+
try {
|
|
287
|
+
for await (const msg of sub) {
|
|
288
|
+
if (cancelled) break;
|
|
289
|
+
callback({ subject: msg.subject, data: msg.data });
|
|
290
|
+
}
|
|
291
|
+
} catch {
|
|
292
|
+
// Subscription ended
|
|
293
|
+
}
|
|
294
|
+
})();
|
|
295
|
+
|
|
296
|
+
return () => { cancelled = true; sub.unsubscribe(); };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return () => {};
|
|
300
|
+
}, [activeHost, isDirectHost]);
|
|
301
|
+
|
|
302
|
+
return (
|
|
303
|
+
<HostConnectionContext.Provider
|
|
304
|
+
value={{ connected, mode, nc, request, subscribeEvents, activeHost, unauthorized }}
|
|
305
|
+
>
|
|
306
|
+
{children}
|
|
307
|
+
</HostConnectionContext.Provider>
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function useHostConnection() {
|
|
312
|
+
return useContext(HostConnectionContext);
|
|
313
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useContext,
|
|
4
|
+
useState,
|
|
5
|
+
useCallback,
|
|
6
|
+
useEffect,
|
|
7
|
+
type ReactNode,
|
|
8
|
+
} from "react";
|
|
9
|
+
import type { PairedHost } from "../types";
|
|
10
|
+
|
|
11
|
+
interface HostStoreContextValue {
|
|
12
|
+
pairedHosts: PairedHost[];
|
|
13
|
+
activeHostId: string | null;
|
|
14
|
+
addPairedHost(host: PairedHost): void;
|
|
15
|
+
removePairedHost(hostId: string): void;
|
|
16
|
+
renamePairedHost(hostId: string, name: string): void;
|
|
17
|
+
setActiveHostId(hostId: string): void;
|
|
18
|
+
getActiveHost(): PairedHost | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const HostStoreContext = createContext<HostStoreContextValue | null>(null);
|
|
22
|
+
|
|
23
|
+
const STORAGE_KEY = "palmier_paired_hosts";
|
|
24
|
+
const ACTIVE_KEY = "palmier_active_host";
|
|
25
|
+
|
|
26
|
+
function loadHosts(): PairedHost[] {
|
|
27
|
+
try {
|
|
28
|
+
const raw = localStorage.getItem(STORAGE_KEY);
|
|
29
|
+
if (raw) {
|
|
30
|
+
return JSON.parse(raw) as PairedHost[];
|
|
31
|
+
}
|
|
32
|
+
} catch { /* ignore */ }
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function saveHosts(hosts: PairedHost[]) {
|
|
37
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(hosts));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function loadActiveId(): string | null {
|
|
41
|
+
return localStorage.getItem(ACTIVE_KEY);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function saveActiveId(id: string | null) {
|
|
45
|
+
if (id) localStorage.setItem(ACTIVE_KEY, id);
|
|
46
|
+
else localStorage.removeItem(ACTIVE_KEY);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Local mode: served by palmier serve on localhost — auto-connect without pairing. */
|
|
50
|
+
const isLocalMode = !!(window as any).__PALMIER_SERVE__
|
|
51
|
+
&& (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1");
|
|
52
|
+
|
|
53
|
+
export function HostStoreProvider({ children }: { children: ReactNode }) {
|
|
54
|
+
const [pairedHosts, setPairedHosts] = useState<PairedHost[]>(loadHosts);
|
|
55
|
+
const [activeHostId, setActiveHostIdState] = useState<string | null>(() => {
|
|
56
|
+
const saved = loadActiveId();
|
|
57
|
+
const hosts = loadHosts();
|
|
58
|
+
if (saved && hosts.some((h) => h.hostId === saved)) return saved;
|
|
59
|
+
return hosts.length > 0 ? hosts[0].hostId : null;
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Auto-connect in local mode: inject a local host entry without pairing
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (!isLocalMode) return;
|
|
65
|
+
const localHostId = "local";
|
|
66
|
+
const existing = loadHosts().find((h) => h.hostId === localHostId);
|
|
67
|
+
if (!existing) {
|
|
68
|
+
const localHost: PairedHost = { hostId: localHostId, clientToken: "", directUrl: window.location.origin };
|
|
69
|
+
setPairedHosts((prev) => [...prev.filter((h) => h.hostId !== localHostId), localHost]);
|
|
70
|
+
setActiveHostIdState(localHostId);
|
|
71
|
+
}
|
|
72
|
+
}, []);
|
|
73
|
+
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
saveHosts(pairedHosts);
|
|
76
|
+
}, [pairedHosts]);
|
|
77
|
+
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
saveActiveId(activeHostId);
|
|
80
|
+
}, [activeHostId]);
|
|
81
|
+
|
|
82
|
+
const addPairedHost = useCallback((host: PairedHost) => {
|
|
83
|
+
setPairedHosts((prev) => {
|
|
84
|
+
const filtered = prev.filter((h) => h.hostId !== host.hostId);
|
|
85
|
+
return [...filtered, host];
|
|
86
|
+
});
|
|
87
|
+
setActiveHostIdState(host.hostId);
|
|
88
|
+
}, []);
|
|
89
|
+
|
|
90
|
+
const removePairedHost = useCallback((hostId: string) => {
|
|
91
|
+
setPairedHosts((prev) => {
|
|
92
|
+
const filtered = prev.filter((h) => h.hostId !== hostId);
|
|
93
|
+
if (filtered.length > 0) {
|
|
94
|
+
setActiveHostIdState(filtered[0].hostId);
|
|
95
|
+
} else {
|
|
96
|
+
setActiveHostIdState(null);
|
|
97
|
+
}
|
|
98
|
+
return filtered;
|
|
99
|
+
});
|
|
100
|
+
}, []);
|
|
101
|
+
|
|
102
|
+
const renamePairedHost = useCallback((hostId: string, name: string) => {
|
|
103
|
+
setPairedHosts((prev) =>
|
|
104
|
+
prev.map((h) => (h.hostId === hostId ? { ...h, name } : h))
|
|
105
|
+
);
|
|
106
|
+
}, []);
|
|
107
|
+
|
|
108
|
+
const setActiveHostId = useCallback((hostId: string) => {
|
|
109
|
+
setActiveHostIdState(hostId);
|
|
110
|
+
}, []);
|
|
111
|
+
|
|
112
|
+
const getActiveHost = useCallback((): PairedHost | null => {
|
|
113
|
+
return pairedHosts.find((h) => h.hostId === activeHostId) ?? null;
|
|
114
|
+
}, [pairedHosts, activeHostId]);
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<HostStoreContext.Provider value={{
|
|
118
|
+
pairedHosts,
|
|
119
|
+
activeHostId,
|
|
120
|
+
addPairedHost,
|
|
121
|
+
removePairedHost,
|
|
122
|
+
renamePairedHost,
|
|
123
|
+
setActiveHostId,
|
|
124
|
+
getActiveHost,
|
|
125
|
+
}}>
|
|
126
|
+
{children}
|
|
127
|
+
</HostStoreContext.Provider>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function useHostStore(): HostStoreContextValue {
|
|
132
|
+
const ctx = useContext(HostStoreContext);
|
|
133
|
+
if (!ctx) throw new Error("useHostStore must be used within HostStoreProvider");
|
|
134
|
+
return ctx;
|
|
135
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Format a timestamp for display. Shows time only if today, otherwise includes the date.
|
|
3
|
+
*/
|
|
4
|
+
export function formatTime(ms: number): string {
|
|
5
|
+
const d = new Date(ms);
|
|
6
|
+
const now = new Date();
|
|
7
|
+
const time = d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
|
|
8
|
+
if (d.toDateString() === now.toDateString()) return time;
|
|
9
|
+
return `${d.toLocaleDateString(undefined, { month: "short", day: "numeric" })} ${time}`;
|
|
10
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { useEffect, useRef, useCallback } from "react";
|
|
2
|
+
|
|
3
|
+
interface StackEntry {
|
|
4
|
+
close: () => void;
|
|
5
|
+
pushed: React.RefObject<boolean>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Global stack so only the top-most modal handles a back gesture.
|
|
10
|
+
* Each entry is a close callback + its pushed ref; popstate pops the top one.
|
|
11
|
+
*/
|
|
12
|
+
const stack: StackEntry[] = [];
|
|
13
|
+
|
|
14
|
+
function globalPopHandler() {
|
|
15
|
+
const top = stack.pop();
|
|
16
|
+
if (top) {
|
|
17
|
+
// Mark pushed=false BEFORE calling close so the effect
|
|
18
|
+
// knows the back navigation was already handled by the browser.
|
|
19
|
+
top.pushed.current = false;
|
|
20
|
+
top.close();
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Single global listener, added once
|
|
25
|
+
let listening = false;
|
|
26
|
+
function ensureListener() {
|
|
27
|
+
if (listening) return;
|
|
28
|
+
listening = true;
|
|
29
|
+
window.addEventListener("popstate", globalPopHandler);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Pushes a history entry when `open` becomes true.
|
|
34
|
+
* When the user swipes back / presses back, calls `onClose` instead of navigating away.
|
|
35
|
+
* When `open` becomes false programmatically, pops the history entry.
|
|
36
|
+
* Supports nesting: only the innermost (most recent) modal responds to back.
|
|
37
|
+
*/
|
|
38
|
+
export function useBackClose(open: boolean, onClose: () => void) {
|
|
39
|
+
const pushed = useRef(false);
|
|
40
|
+
const onCloseRef = useRef(onClose);
|
|
41
|
+
onCloseRef.current = onClose;
|
|
42
|
+
|
|
43
|
+
const stableClose = useCallback(() => onCloseRef.current(), []);
|
|
44
|
+
const entry = useRef<StackEntry>({ close: stableClose, pushed });
|
|
45
|
+
entry.current.close = stableClose;
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (open && !pushed.current) {
|
|
49
|
+
ensureListener();
|
|
50
|
+
history.pushState({ modal: true }, "");
|
|
51
|
+
stack.push(entry.current);
|
|
52
|
+
pushed.current = true;
|
|
53
|
+
} else if (!open && pushed.current) {
|
|
54
|
+
// Closed programmatically — browser hasn't gone back yet, so we must.
|
|
55
|
+
pushed.current = false;
|
|
56
|
+
const idx = stack.indexOf(entry.current);
|
|
57
|
+
if (idx !== -1) stack.splice(idx, 1);
|
|
58
|
+
history.back();
|
|
59
|
+
}
|
|
60
|
+
// If !open && !pushed.current, the popstate handler already went back — nothing to do.
|
|
61
|
+
}, [open, stableClose]);
|
|
62
|
+
|
|
63
|
+
// Cleanup on unmount if still pushed
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
const e = entry.current;
|
|
66
|
+
return () => {
|
|
67
|
+
if (pushed.current) {
|
|
68
|
+
pushed.current = false;
|
|
69
|
+
const idx = stack.indexOf(e);
|
|
70
|
+
if (idx !== -1) stack.splice(idx, 1);
|
|
71
|
+
history.back();
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
}, []);
|
|
75
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { useState, useEffect } from "react";
|
|
2
|
+
|
|
3
|
+
export function useMediaQuery(query: string): boolean {
|
|
4
|
+
const [matches, setMatches] = useState(
|
|
5
|
+
() => window.matchMedia(query).matches
|
|
6
|
+
);
|
|
7
|
+
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
const mql = window.matchMedia(query);
|
|
10
|
+
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
|
|
11
|
+
mql.addEventListener("change", handler);
|
|
12
|
+
setMatches(mql.matches);
|
|
13
|
+
return () => mql.removeEventListener("change", handler);
|
|
14
|
+
}, [query]);
|
|
15
|
+
|
|
16
|
+
return matches;
|
|
17
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
import { useHostStore } from "../contexts/HostStoreContext";
|
|
3
|
+
import { apiPost, apiGet } from "../api";
|
|
4
|
+
|
|
5
|
+
export function usePushSubscription() {
|
|
6
|
+
const { getActiveHost } = useHostStore();
|
|
7
|
+
const activeHost = getActiveHost();
|
|
8
|
+
const subscribedRef = useRef<string | null>(null);
|
|
9
|
+
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
// Skip push subscription for direct-only (LAN) hosts — no cloud server to relay through
|
|
12
|
+
if (!activeHost || activeHost.directUrl || subscribedRef.current === activeHost.hostId) return;
|
|
13
|
+
|
|
14
|
+
async function subscribe() {
|
|
15
|
+
try {
|
|
16
|
+
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
|
|
17
|
+
console.warn("[Push] Push notifications not supported");
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const registration = await navigator.serviceWorker.ready;
|
|
22
|
+
|
|
23
|
+
// Send hostId to SW so it can include it in push responses
|
|
24
|
+
registration.active?.postMessage({
|
|
25
|
+
type: "set-host-id",
|
|
26
|
+
hostId: activeHost!.hostId,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
let subscription = await registration.pushManager.getSubscription();
|
|
30
|
+
|
|
31
|
+
if (!subscription) {
|
|
32
|
+
// Get VAPID public key from server
|
|
33
|
+
const { publicKey } = await apiGet<{ publicKey: string }>(
|
|
34
|
+
"/api/push/vapid-key"
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
if (!publicKey) {
|
|
38
|
+
console.warn("[Push] No VAPID public key configured on server");
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Request permission
|
|
43
|
+
const permission = await Notification.requestPermission();
|
|
44
|
+
if (permission !== "granted") {
|
|
45
|
+
console.log("[Push] Permission denied");
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Subscribe
|
|
50
|
+
subscription = await registration.pushManager.subscribe({
|
|
51
|
+
userVisibleOnly: true,
|
|
52
|
+
applicationServerKey: publicKey,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Always ensure subscription is saved to server
|
|
57
|
+
const sub = subscription.toJSON();
|
|
58
|
+
await apiPost("/api/push/subscribe", {
|
|
59
|
+
hostId: activeHost!.hostId,
|
|
60
|
+
endpoint: sub.endpoint,
|
|
61
|
+
keys: {
|
|
62
|
+
p256dh: sub.keys!.p256dh,
|
|
63
|
+
auth: sub.keys!.auth,
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
subscribedRef.current = activeHost!.hostId;
|
|
68
|
+
} catch (err) {
|
|
69
|
+
console.error("[Push] Subscription failed:", err);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
subscribe();
|
|
74
|
+
}, [activeHost]);
|
|
75
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { StrictMode } from "react";
|
|
2
|
+
import { createRoot } from "react-dom/client";
|
|
3
|
+
import { BrowserRouter } from "react-router-dom";
|
|
4
|
+
import "@fontsource-variable/plus-jakarta-sans";
|
|
5
|
+
import App from "./App";
|
|
6
|
+
import "./App.css";
|
|
7
|
+
|
|
8
|
+
createRoot(document.getElementById("root")!).render(
|
|
9
|
+
<StrictMode>
|
|
10
|
+
<BrowserRouter>
|
|
11
|
+
<App />
|
|
12
|
+
</BrowserRouter>
|
|
13
|
+
</StrictMode>
|
|
14
|
+
);
|