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.
Files changed (110) hide show
  1. package/.github/workflows/publish.yml +15 -2
  2. package/CLAUDE.md +2 -2
  3. package/DISCLAIMER.md +36 -0
  4. package/README.md +76 -87
  5. package/dist/agents/agent-instructions.md +1 -1
  6. package/dist/agents/agent.d.ts +2 -0
  7. package/dist/agents/agent.js +21 -0
  8. package/dist/agents/aider.d.ts +9 -0
  9. package/dist/agents/aider.js +32 -0
  10. package/dist/agents/cursor.d.ts +9 -0
  11. package/dist/agents/cursor.js +35 -0
  12. package/dist/agents/deepagents.d.ts +9 -0
  13. package/dist/agents/deepagents.js +35 -0
  14. package/dist/agents/droid.d.ts +9 -0
  15. package/dist/agents/droid.js +32 -0
  16. package/dist/agents/goose.d.ts +9 -0
  17. package/dist/agents/goose.js +32 -0
  18. package/dist/agents/opencode.d.ts +9 -0
  19. package/dist/agents/opencode.js +35 -0
  20. package/dist/agents/openhands.d.ts +9 -0
  21. package/dist/agents/openhands.js +35 -0
  22. package/dist/commands/pair.d.ts +1 -1
  23. package/dist/commands/pair.js +1 -1
  24. package/dist/commands/run.js +2 -2
  25. package/dist/pwa/apple-touch-icon.png +0 -0
  26. package/dist/pwa/assets/index-ByhOhTz1.js +118 -0
  27. package/dist/pwa/assets/index-_AmC1Rkn.css +1 -0
  28. package/dist/pwa/assets/plus-jakarta-sans-latin-ext-wght-normal-DmpS2jIq.woff2 +0 -0
  29. package/dist/pwa/assets/plus-jakarta-sans-latin-wght-normal-eXO_dkmS.woff2 +0 -0
  30. package/dist/pwa/assets/plus-jakarta-sans-vietnamese-wght-normal-qRpaaN48.woff2 +0 -0
  31. package/dist/pwa/favicon.ico +0 -0
  32. package/dist/pwa/index.html +17 -0
  33. package/dist/pwa/manifest.webmanifest +1 -0
  34. package/dist/pwa/pwa-192x192.png +0 -0
  35. package/dist/pwa/pwa-512x512.png +0 -0
  36. package/dist/pwa/registerSW.js +1 -0
  37. package/dist/pwa/service-worker.js +2 -0
  38. package/dist/rpc-handler.d.ts +4 -0
  39. package/dist/rpc-handler.js +5 -4
  40. package/dist/transports/http-transport.js +29 -41
  41. package/package.json +2 -2
  42. package/palmier-server/.github/workflows/ci.yml +21 -0
  43. package/palmier-server/.github/workflows/deploy.yml +38 -0
  44. package/palmier-server/CLAUDE.md +13 -0
  45. package/palmier-server/PRODUCTION.md +355 -0
  46. package/palmier-server/README.md +187 -0
  47. package/palmier-server/nats.conf +15 -0
  48. package/palmier-server/package.json +8 -0
  49. package/palmier-server/pnpm-lock.yaml +6597 -0
  50. package/palmier-server/pnpm-workspace.yaml +3 -0
  51. package/palmier-server/pwa/index.html +16 -0
  52. package/palmier-server/pwa/logo/logo-prompt.md +28 -0
  53. package/palmier-server/pwa/logo/logo_20260330.png +0 -0
  54. package/palmier-server/pwa/package.json +30 -0
  55. package/palmier-server/pwa/public/apple-touch-icon.png +0 -0
  56. package/palmier-server/pwa/public/favicon.ico +0 -0
  57. package/palmier-server/pwa/public/pwa-192x192.png +0 -0
  58. package/palmier-server/pwa/public/pwa-512x512.png +0 -0
  59. package/palmier-server/pwa/src/App.css +2387 -0
  60. package/palmier-server/pwa/src/App.tsx +21 -0
  61. package/palmier-server/pwa/src/agentLabels.ts +11 -0
  62. package/palmier-server/pwa/src/api.ts +61 -0
  63. package/palmier-server/pwa/src/components/HostMenu.tsx +289 -0
  64. package/palmier-server/pwa/src/components/PlanDialog.tsx +41 -0
  65. package/palmier-server/pwa/src/components/RunDetailView.tsx +293 -0
  66. package/palmier-server/pwa/src/components/RunsView.tsx +254 -0
  67. package/palmier-server/pwa/src/components/TabBar.tsx +31 -0
  68. package/palmier-server/pwa/src/components/TaskCard.tsx +213 -0
  69. package/palmier-server/pwa/src/components/TaskForm.tsx +580 -0
  70. package/palmier-server/pwa/src/components/TaskListView.tsx +415 -0
  71. package/palmier-server/pwa/src/constants.ts +2 -0
  72. package/palmier-server/pwa/src/contexts/HostConnectionContext.tsx +313 -0
  73. package/palmier-server/pwa/src/contexts/HostStoreContext.tsx +135 -0
  74. package/palmier-server/pwa/src/formatTime.ts +10 -0
  75. package/palmier-server/pwa/src/hooks/useBackClose.ts +75 -0
  76. package/palmier-server/pwa/src/hooks/useMediaQuery.ts +17 -0
  77. package/palmier-server/pwa/src/hooks/usePushSubscription.ts +75 -0
  78. package/palmier-server/pwa/src/main.tsx +14 -0
  79. package/palmier-server/pwa/src/pages/Dashboard.tsx +223 -0
  80. package/palmier-server/pwa/src/pages/PairHost.tsx +178 -0
  81. package/palmier-server/pwa/src/service-worker.ts +139 -0
  82. package/palmier-server/pwa/src/types.ts +79 -0
  83. package/palmier-server/pwa/src/vite-env.d.ts +11 -0
  84. package/palmier-server/pwa/tsconfig.json +21 -0
  85. package/palmier-server/pwa/tsconfig.node.json +19 -0
  86. package/palmier-server/pwa/vite.config.ts +47 -0
  87. package/palmier-server/server/.env.example +16 -0
  88. package/palmier-server/server/package.json +33 -0
  89. package/palmier-server/server/src/db.ts +34 -0
  90. package/palmier-server/server/src/index.ts +219 -0
  91. package/palmier-server/server/src/nats.ts +25 -0
  92. package/palmier-server/server/src/push.ts +68 -0
  93. package/palmier-server/server/src/routes/hosts.ts +45 -0
  94. package/palmier-server/server/src/routes/push.ts +100 -0
  95. package/palmier-server/server/tsconfig.json +20 -0
  96. package/palmier-server/spec.md +415 -0
  97. package/src/agents/agent-instructions.md +1 -1
  98. package/src/agents/agent.ts +23 -0
  99. package/src/agents/aider.ts +37 -0
  100. package/src/agents/cursor.ts +38 -0
  101. package/src/agents/deepagents.ts +38 -0
  102. package/src/agents/droid.ts +37 -0
  103. package/src/agents/goose.ts +35 -0
  104. package/src/agents/opencode.ts +38 -0
  105. package/src/agents/openhands.ts +38 -0
  106. package/src/commands/pair.ts +1 -1
  107. package/src/commands/run.ts +2 -2
  108. package/src/rpc-handler.ts +5 -4
  109. package/src/transports/http-transport.ts +31 -43
  110. 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
+ );