palmier 0.8.10 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -1
- package/dist/commands/init.js +13 -2
- package/dist/commands/pair.js +3 -9
- package/dist/linked-device.d.ts +9 -0
- package/dist/linked-device.js +45 -0
- package/dist/mcp-tools.js +19 -19
- package/dist/network.d.ts +0 -5
- package/dist/network.js +75 -9
- package/dist/pwa/assets/index-BLCVzS_l.js +120 -0
- package/dist/pwa/assets/{index-DQJHVyP6.css → index-Cjjw24Ok.css} +1 -1
- package/dist/pwa/assets/{web-CaRUL7Kz.js → web-C2AU9S9n.js} +1 -1
- package/dist/pwa/assets/{web-C9g_YGd8.js → web-CfD_ah7K.js} +1 -1
- package/dist/pwa/assets/{web-D4ty3qtI.js → web-DugGj1t8.js} +1 -1
- package/dist/pwa/index.html +2 -2
- package/dist/pwa/service-worker.js +2 -2
- package/dist/rpc-handler.js +17 -23
- package/package.json +1 -2
- package/palmier-server/README.md +3 -2
- package/palmier-server/pwa/src/App.css +45 -4
- package/palmier-server/pwa/src/App.tsx +36 -15
- package/palmier-server/pwa/src/components/CapabilityToggles.tsx +65 -225
- package/palmier-server/pwa/src/components/ConnectionStatusIcon.tsx +54 -12
- package/palmier-server/pwa/src/components/HostMenu.tsx +110 -21
- package/palmier-server/pwa/src/components/RunDetailView.tsx +2 -2
- package/palmier-server/pwa/src/components/SessionComposer.tsx +9 -8
- package/palmier-server/pwa/src/components/SessionsView.tsx +5 -3
- package/palmier-server/pwa/src/components/TabBar.tsx +7 -5
- package/palmier-server/pwa/src/components/TaskForm.tsx +5 -3
- package/palmier-server/pwa/src/constants.ts +1 -1
- package/palmier-server/pwa/src/contexts/HostConnectionContext.tsx +41 -41
- package/palmier-server/pwa/src/contexts/HostStoreContext.tsx +17 -60
- package/palmier-server/pwa/src/hooks/usePushSubscription.ts +6 -7
- package/palmier-server/pwa/src/native/Device.ts +23 -38
- package/palmier-server/pwa/src/pages/Dashboard.tsx +36 -41
- package/palmier-server/pwa/src/pages/PairHost.tsx +20 -1
- package/palmier-server/pwa/src/pages/PairSetup.tsx +98 -39
- package/palmier-server/pwa/src/service-worker.ts +9 -6
- package/palmier-server/pwa/src/types.ts +2 -0
- package/palmier-server/spec.md +44 -15
- package/src/commands/init.ts +13 -2
- package/src/commands/pair.ts +3 -9
- package/src/linked-device.ts +52 -0
- package/src/mcp-tools.ts +19 -19
- package/src/network.ts +73 -9
- package/src/rpc-handler.ts +14 -22
- package/dist/device-capabilities.d.ts +0 -9
- package/dist/device-capabilities.js +0 -36
- package/dist/pwa/assets/index-iL_NTbsT.js +0 -120
- package/src/device-capabilities.ts +0 -57
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { useState, useEffect, useRef, useCallback } from "react";
|
|
2
2
|
import { createPortal } from "react-dom";
|
|
3
|
-
import { useNavigate } from "react-router-dom";
|
|
3
|
+
import { useNavigate, useParams, useLocation } from "react-router-dom";
|
|
4
4
|
import { Capacitor } from "@capacitor/core";
|
|
5
5
|
import { useHostStore } from "../contexts/HostStoreContext";
|
|
6
|
+
import { useHostConnection } from "../contexts/HostConnectionContext";
|
|
6
7
|
import { useMediaQuery } from "../hooks/useMediaQuery";
|
|
7
8
|
import { confirmLeaveDraft } from "../draftGuard";
|
|
9
|
+
import { Device } from "../native/Device";
|
|
8
10
|
import CapabilityToggles from "./CapabilityToggles";
|
|
9
11
|
|
|
10
12
|
/** Local mode: PWA is served by palmier serve on loopback. */
|
|
@@ -14,22 +16,64 @@ const isNative = Capacitor.isNativePlatform();
|
|
|
14
16
|
|
|
15
17
|
interface HostMenuProps {
|
|
16
18
|
daemonVersion?: string | null;
|
|
17
|
-
|
|
18
|
-
activeClientToken?: string | null;
|
|
19
|
+
linkedClientToken?: string | null;
|
|
19
20
|
request?<T = unknown>(method: string, params?: unknown): Promise<T>;
|
|
20
|
-
|
|
21
|
+
onEnabledCapabilitiesChange?(next: Set<string>): void;
|
|
22
|
+
onLinkedClientTokenChange?(next: string | null): void;
|
|
21
23
|
}
|
|
22
24
|
|
|
23
|
-
export default function HostMenu({ daemonVersion,
|
|
24
|
-
const { pairedHosts,
|
|
25
|
+
export default function HostMenu({ daemonVersion, linkedClientToken, request, onEnabledCapabilitiesChange, onLinkedClientTokenChange }: HostMenuProps) {
|
|
26
|
+
const { pairedHosts, removePairedHost, renamePairedHost } = useHostStore();
|
|
27
|
+
const { activeHost } = useHostConnection();
|
|
25
28
|
const navigate = useNavigate();
|
|
29
|
+
const location = useLocation();
|
|
30
|
+
const params = useParams<{ hostId?: string }>();
|
|
31
|
+
const activeHostId = params.hostId ?? null;
|
|
32
|
+
const activeClientToken = activeHost.clientToken || null;
|
|
33
|
+
const isLinkedDevice = !!activeClientToken && linkedClientToken === activeClientToken;
|
|
26
34
|
const isDesktop = useMediaQuery("(min-width: 768px)");
|
|
35
|
+
const [linkingBusy, setLinkingBusy] = useState(false);
|
|
36
|
+
|
|
37
|
+
async function makeThisLinkedDevice() {
|
|
38
|
+
if (!Device || !request || !activeClientToken) return;
|
|
39
|
+
setLinkingBusy(true);
|
|
40
|
+
try {
|
|
41
|
+
const { token: fcmToken } = await Device.getFcmToken();
|
|
42
|
+
if (!fcmToken) return;
|
|
43
|
+
await request("device.link", { fcmToken });
|
|
44
|
+
onLinkedClientTokenChange?.(activeClientToken);
|
|
45
|
+
} catch (err) {
|
|
46
|
+
console.error("Failed to make this the linked device:", err);
|
|
47
|
+
} finally {
|
|
48
|
+
setLinkingBusy(false);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function handleDelete(hostId: string) {
|
|
53
|
+
const wasActive = hostId === activeHostId;
|
|
54
|
+
// Only revoke against the currently-active host — `request` uses its client
|
|
55
|
+
// token. Non-active unpair is local-only; the host keeps a dangling token
|
|
56
|
+
// the user can clear via `palmier clients revoke`.
|
|
57
|
+
if (wasActive && request) {
|
|
58
|
+
try { await request("clients.revoke_self"); } catch { /* best effort */ }
|
|
59
|
+
}
|
|
60
|
+
removePairedHost(hostId);
|
|
61
|
+
setConfirmingDeleteId(null);
|
|
62
|
+
if (wasActive) navigate("/", { replace: true });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function switchHost(newHostId: string) {
|
|
66
|
+
const onTasks = location.pathname.endsWith("/tasks");
|
|
67
|
+
const tabSuffix = onTasks ? "/tasks" : "";
|
|
68
|
+
navigate(`/hosts/${encodeURIComponent(newHostId)}${tabSuffix}`);
|
|
69
|
+
}
|
|
27
70
|
|
|
28
71
|
const [visible, setVisible] = useState(false);
|
|
29
72
|
const [closing, setClosing] = useState(false);
|
|
30
73
|
const [renamingId, setRenamingId] = useState<string | null>(null);
|
|
31
74
|
const [renameValue, setRenameValue] = useState("");
|
|
32
75
|
const [confirmingDeleteId, setConfirmingDeleteId] = useState<string | null>(null);
|
|
76
|
+
const [confirmingLink, setConfirmingLink] = useState(false);
|
|
33
77
|
|
|
34
78
|
const drawerRef = useRef<HTMLDivElement>(null);
|
|
35
79
|
const renameInputRef = useRef<HTMLInputElement>(null);
|
|
@@ -117,7 +161,7 @@ export default function HostMenu({ daemonVersion, capabilityTokens, activeClient
|
|
|
117
161
|
if (isRenaming) return;
|
|
118
162
|
if (!isActive) {
|
|
119
163
|
if (!confirmLeaveDraft()) return;
|
|
120
|
-
|
|
164
|
+
switchHost(host.hostId);
|
|
121
165
|
if (!isDesktop) close();
|
|
122
166
|
}
|
|
123
167
|
}}
|
|
@@ -210,13 +254,29 @@ export default function HostMenu({ daemonVersion, capabilityTokens, activeClient
|
|
|
210
254
|
<>
|
|
211
255
|
<div className="drawer-divider" />
|
|
212
256
|
<div className="drawer-section">
|
|
213
|
-
<h3 className="drawer-section-label">
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
257
|
+
<h3 className="drawer-section-label">Device Capabilities</h3>
|
|
258
|
+
{isLinkedDevice ? (
|
|
259
|
+
<CapabilityToggles onChange={onEnabledCapabilitiesChange} />
|
|
260
|
+
) : (
|
|
261
|
+
<>
|
|
262
|
+
<p className="drawer-section-hint">
|
|
263
|
+
This device isn't the linked device for this host, so it can't provide capabilities (SMS, contacts, location, etc.).
|
|
264
|
+
</p>
|
|
265
|
+
<button
|
|
266
|
+
className="btn btn-secondary btn-full"
|
|
267
|
+
onClick={() => {
|
|
268
|
+
if (linkedClientToken && linkedClientToken !== activeClientToken) {
|
|
269
|
+
setConfirmingLink(true);
|
|
270
|
+
} else {
|
|
271
|
+
makeThisLinkedDevice();
|
|
272
|
+
}
|
|
273
|
+
}}
|
|
274
|
+
disabled={linkingBusy}
|
|
275
|
+
>
|
|
276
|
+
{linkingBusy ? "Linking…" : "Link this device"}
|
|
277
|
+
</button>
|
|
278
|
+
</>
|
|
279
|
+
)}
|
|
220
280
|
</div>
|
|
221
281
|
</>
|
|
222
282
|
)}
|
|
@@ -236,12 +296,16 @@ export default function HostMenu({ daemonVersion, capabilityTokens, activeClient
|
|
|
236
296
|
</>
|
|
237
297
|
);
|
|
238
298
|
|
|
299
|
+
const deletingIsLinked = !!confirmingDeleteId && confirmingDeleteId === activeHostId && isLinkedDevice;
|
|
239
300
|
const deleteModal = confirmingDeleteId && createPortal(
|
|
240
301
|
<div className="confirm-modal-overlay" onClick={() => setConfirmingDeleteId(null)}>
|
|
241
302
|
<div className="confirm-modal" onClick={(e) => e.stopPropagation()}>
|
|
242
|
-
<h2 className="confirm-modal-title">
|
|
303
|
+
<h2 className="confirm-modal-title">Unpair host?</h2>
|
|
243
304
|
<p className="confirm-modal-message">
|
|
244
|
-
"{pairedHosts.find((h) => h.hostId === confirmingDeleteId)?.name || confirmingDeleteId.slice(0, 8)}" will be unpaired
|
|
305
|
+
"{pairedHosts.find((h) => h.hostId === confirmingDeleteId)?.name || confirmingDeleteId.slice(0, 8)}" will be unpaired.
|
|
306
|
+
{deletingIsLinked && (
|
|
307
|
+
<> This device is currently linked to the host — unpairing will revoke its access to all device capabilities (SMS, contacts, location, etc.) until another device is linked.</>
|
|
308
|
+
)}
|
|
245
309
|
</p>
|
|
246
310
|
<div className="confirm-modal-actions">
|
|
247
311
|
<button
|
|
@@ -252,12 +316,35 @@ export default function HostMenu({ daemonVersion, capabilityTokens, activeClient
|
|
|
252
316
|
</button>
|
|
253
317
|
<button
|
|
254
318
|
className="btn btn-danger"
|
|
255
|
-
onClick={() =>
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
319
|
+
onClick={() => handleDelete(confirmingDeleteId)}
|
|
320
|
+
>
|
|
321
|
+
Unpair
|
|
322
|
+
</button>
|
|
323
|
+
</div>
|
|
324
|
+
</div>
|
|
325
|
+
</div>,
|
|
326
|
+
document.body
|
|
327
|
+
);
|
|
328
|
+
|
|
329
|
+
const linkModal = confirmingLink && createPortal(
|
|
330
|
+
<div className="confirm-modal-overlay" onClick={() => setConfirmingLink(false)}>
|
|
331
|
+
<div className="confirm-modal" onClick={(e) => e.stopPropagation()}>
|
|
332
|
+
<h2 className="confirm-modal-title">Link this device?</h2>
|
|
333
|
+
<p className="confirm-modal-message">
|
|
334
|
+
Only one device can be linked at a time — switching will disable those capabilities on the currently linked device.
|
|
335
|
+
</p>
|
|
336
|
+
<div className="confirm-modal-actions">
|
|
337
|
+
<button
|
|
338
|
+
className="btn btn-secondary"
|
|
339
|
+
onClick={() => setConfirmingLink(false)}
|
|
340
|
+
>
|
|
341
|
+
Cancel
|
|
342
|
+
</button>
|
|
343
|
+
<button
|
|
344
|
+
className="btn btn-primary"
|
|
345
|
+
onClick={() => { setConfirmingLink(false); makeThisLinkedDevice(); }}
|
|
259
346
|
>
|
|
260
|
-
|
|
347
|
+
Link
|
|
261
348
|
</button>
|
|
262
349
|
</div>
|
|
263
350
|
</div>
|
|
@@ -273,6 +360,7 @@ export default function HostMenu({ daemonVersion, capabilityTokens, activeClient
|
|
|
273
360
|
{drawerContent}
|
|
274
361
|
</div>
|
|
275
362
|
{deleteModal}
|
|
363
|
+
{linkModal}
|
|
276
364
|
</>
|
|
277
365
|
);
|
|
278
366
|
}
|
|
@@ -308,6 +396,7 @@ export default function HostMenu({ daemonVersion, capabilityTokens, activeClient
|
|
|
308
396
|
)}
|
|
309
397
|
|
|
310
398
|
{deleteModal}
|
|
399
|
+
{linkModal}
|
|
311
400
|
</>
|
|
312
401
|
);
|
|
313
402
|
}
|
|
@@ -69,7 +69,7 @@ export default function RunDetailView({ connected, hostId, request, subscribeEve
|
|
|
69
69
|
|
|
70
70
|
if (result.error) {
|
|
71
71
|
console.error("No result:", result.error);
|
|
72
|
-
navigate("/
|
|
72
|
+
navigate(hostId ? `/hosts/${encodeURIComponent(hostId)}` : "/", { replace: true });
|
|
73
73
|
return;
|
|
74
74
|
}
|
|
75
75
|
setMessages(result.messages ?? []);
|
|
@@ -77,7 +77,7 @@ export default function RunDetailView({ connected, hostId, request, subscribeEve
|
|
|
77
77
|
setAgent(result.agent);
|
|
78
78
|
} catch (err) {
|
|
79
79
|
console.error("Failed to load result:", err);
|
|
80
|
-
navigate("/
|
|
80
|
+
navigate(hostId ? `/hosts/${encodeURIComponent(hostId)}` : "/", { replace: true });
|
|
81
81
|
} finally {
|
|
82
82
|
setLoading(false);
|
|
83
83
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useEffect, useState } from "react";
|
|
2
2
|
import { useHostConnection } from "../contexts/HostConnectionContext";
|
|
3
|
+
import { useHostStore } from "../contexts/HostStoreContext";
|
|
3
4
|
import { setDraftMessage } from "../draftGuard";
|
|
4
5
|
import type { AgentInfo } from "../types";
|
|
5
6
|
|
|
@@ -9,17 +10,17 @@ interface SessionComposerProps {
|
|
|
9
10
|
onStarted(taskId: string, runId?: string): void;
|
|
10
11
|
}
|
|
11
12
|
|
|
12
|
-
function pickDefaultAgent(agents: AgentInfo[]): string {
|
|
13
|
-
const stored = localStorage.getItem("palmier:lastAgent");
|
|
13
|
+
function pickDefaultAgent(agents: AgentInfo[], preferred?: string): string {
|
|
14
14
|
const keys = agents.map((a) => a.key);
|
|
15
|
-
if (
|
|
15
|
+
if (preferred && keys.includes(preferred)) return preferred;
|
|
16
16
|
return agents[0]?.key ?? "";
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export default function SessionComposer({ agents, hostPlatform, onStarted }: SessionComposerProps) {
|
|
20
|
-
const { request } = useHostConnection();
|
|
20
|
+
const { request, activeHost } = useHostConnection();
|
|
21
|
+
const { setHostLastAgent } = useHostStore();
|
|
21
22
|
const [prompt, setPrompt] = useState("");
|
|
22
|
-
const [agent, setAgent] = useState(() => pickDefaultAgent(agents));
|
|
23
|
+
const [agent, setAgent] = useState(() => pickDefaultAgent(agents, activeHost.lastAgent));
|
|
23
24
|
const [yoloMode, setYoloMode] = useState(false);
|
|
24
25
|
const [running, setRunning] = useState(false);
|
|
25
26
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -28,9 +29,9 @@ export default function SessionComposer({ agents, hostPlatform, onStarted }: Ses
|
|
|
28
29
|
useEffect(() => {
|
|
29
30
|
if (!agents.length) return;
|
|
30
31
|
if (!agents.find((a) => a.key === agent)) {
|
|
31
|
-
setAgent(pickDefaultAgent(agents));
|
|
32
|
+
setAgent(pickDefaultAgent(agents, activeHost.lastAgent));
|
|
32
33
|
}
|
|
33
|
-
}, [agents, agent]);
|
|
34
|
+
}, [agents, agent, activeHost.lastAgent]);
|
|
34
35
|
|
|
35
36
|
// Draft guard: warns on navigation / reload when the input has content.
|
|
36
37
|
useEffect(() => {
|
|
@@ -77,7 +78,7 @@ export default function SessionComposer({ agents, hostPlatform, onStarted }: Ses
|
|
|
77
78
|
setError(result.error);
|
|
78
79
|
return;
|
|
79
80
|
}
|
|
80
|
-
|
|
81
|
+
setHostLastAgent(activeHost.hostId, agent);
|
|
81
82
|
setPrompt("");
|
|
82
83
|
setDraftMessage(null);
|
|
83
84
|
if (result.task_id) onStarted(result.task_id, result.run_id);
|
|
@@ -170,7 +170,8 @@ export default function SessionsView({ connected, hostId, request, subscribeEven
|
|
|
170
170
|
|
|
171
171
|
function handleCardClick(taskId: string, runId: string) {
|
|
172
172
|
if (!confirmLeaveDraft()) return;
|
|
173
|
-
|
|
173
|
+
if (!hostId) return;
|
|
174
|
+
navigate(`/hosts/${encodeURIComponent(hostId)}/runs/${encodeURIComponent(taskId)}/${encodeURIComponent(runId)}`);
|
|
174
175
|
}
|
|
175
176
|
|
|
176
177
|
const composer = !filterTaskId && (
|
|
@@ -178,8 +179,9 @@ export default function SessionsView({ connected, hostId, request, subscribeEven
|
|
|
178
179
|
agents={agents}
|
|
179
180
|
hostPlatform={hostPlatform}
|
|
180
181
|
onStarted={(taskId, runId) => {
|
|
181
|
-
if (
|
|
182
|
-
|
|
182
|
+
if (!hostId) return;
|
|
183
|
+
const base = `/hosts/${encodeURIComponent(hostId)}/runs/${encodeURIComponent(taskId)}`;
|
|
184
|
+
navigate(runId ? `${base}/${encodeURIComponent(runId)}` : base);
|
|
183
185
|
}}
|
|
184
186
|
/>
|
|
185
187
|
);
|
|
@@ -1,22 +1,24 @@
|
|
|
1
|
-
import { useNavigate, useLocation } from "react-router-dom";
|
|
1
|
+
import { useNavigate, useLocation, useParams } from "react-router-dom";
|
|
2
2
|
import { confirmLeaveDraft } from "../draftGuard";
|
|
3
3
|
|
|
4
4
|
export default function TabBar() {
|
|
5
5
|
const navigate = useNavigate();
|
|
6
6
|
const location = useLocation();
|
|
7
|
-
const
|
|
7
|
+
const { hostId } = useParams<{ hostId: string }>();
|
|
8
|
+
const isTasks = location.pathname.endsWith("/tasks");
|
|
8
9
|
const isSessions = !isTasks;
|
|
9
10
|
|
|
10
|
-
function go(
|
|
11
|
+
function go(suffix: string) {
|
|
11
12
|
if (!confirmLeaveDraft()) return;
|
|
12
|
-
|
|
13
|
+
if (!hostId) return;
|
|
14
|
+
navigate(`/hosts/${encodeURIComponent(hostId)}${suffix}`);
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
return (
|
|
16
18
|
<>
|
|
17
19
|
<button
|
|
18
20
|
className={`tab-btn ${isSessions ? "tab-btn-active" : ""}`}
|
|
19
|
-
onClick={() => go("
|
|
21
|
+
onClick={() => go("")}
|
|
20
22
|
>
|
|
21
23
|
<svg className="tab-icon" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
22
24
|
<path d="M2 8H4.5L6 4L8 12L10 6L11.5 8H14" />
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useEffect, useState, useCallback, useRef } from "react";
|
|
2
2
|
import { Capacitor } from "@capacitor/core";
|
|
3
3
|
import { useHostConnection } from "../contexts/HostConnectionContext";
|
|
4
|
+
import { useHostStore } from "../contexts/HostStoreContext";
|
|
4
5
|
import PlanDialog from "./PlanDialog";
|
|
5
6
|
import { useBackClose } from "../hooks/useBackClose";
|
|
6
7
|
import { Device } from "../native/Device";
|
|
@@ -103,10 +104,11 @@ interface TaskFormProps {
|
|
|
103
104
|
}
|
|
104
105
|
|
|
105
106
|
export default function TaskForm({ initial, agents, hostPlatform, isNotificationListener, onSaved, onRun, onCancel }: TaskFormProps) {
|
|
106
|
-
const { request } = useHostConnection();
|
|
107
|
+
const { request, activeHost } = useHostConnection();
|
|
108
|
+
const { setHostLastAgent } = useHostStore();
|
|
107
109
|
|
|
108
110
|
const defaultAgent = () => {
|
|
109
|
-
const lastAgent =
|
|
111
|
+
const lastAgent = activeHost.lastAgent;
|
|
110
112
|
const agentKeys = agents.map((a) => a.key);
|
|
111
113
|
if (lastAgent && agentKeys.includes(lastAgent)) return lastAgent;
|
|
112
114
|
return agents[0]?.key ?? "";
|
|
@@ -326,7 +328,7 @@ export default function TaskForm({ initial, agents, hostPlatform, isNotification
|
|
|
326
328
|
setError(result.error);
|
|
327
329
|
return;
|
|
328
330
|
}
|
|
329
|
-
if (!isEdit)
|
|
331
|
+
if (!isEdit) setHostLastAgent(activeHost.hostId, agent);
|
|
330
332
|
|
|
331
333
|
// Command-triggered on create: save the task, then start it and navigate
|
|
332
334
|
// to the run. Event-triggered tasks are started by the daemon in response
|
|
@@ -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.11";
|
|
@@ -12,7 +12,6 @@ import { Capacitor } from "@capacitor/core";
|
|
|
12
12
|
import { App as CapacitorApp } from "@capacitor/app";
|
|
13
13
|
import { Network } from "@capacitor/network";
|
|
14
14
|
import { SERVER_URL } from "../api";
|
|
15
|
-
import { useHostStore } from "./HostStoreContext";
|
|
16
15
|
import type { PairedHost } from "../types";
|
|
17
16
|
|
|
18
17
|
type ConnectionMode = "nats" | "lan" | "direct" | "connecting" | "disconnected";
|
|
@@ -31,29 +30,19 @@ interface HostConnectionContextValue {
|
|
|
31
30
|
/** Subscribe to task events. Returns unsubscribe function. */
|
|
32
31
|
subscribeEvents(hostId: string, callback: (msg: { subject: string; data: Uint8Array }) => void): () => void;
|
|
33
32
|
/** Current active host. */
|
|
34
|
-
activeHost: PairedHost
|
|
33
|
+
activeHost: PairedHost;
|
|
35
34
|
/** Whether the current client has been revoked by the host. */
|
|
36
35
|
unauthorized: boolean;
|
|
37
36
|
}
|
|
38
37
|
|
|
39
|
-
const HostConnectionContext = createContext<HostConnectionContextValue>(
|
|
40
|
-
connected: false,
|
|
41
|
-
mode: "connecting",
|
|
42
|
-
nc: null,
|
|
43
|
-
request() { return Promise.reject(new Error("No host connection")); },
|
|
44
|
-
subscribeEvents() { return () => {}; },
|
|
45
|
-
activeHost: null,
|
|
46
|
-
unauthorized: false,
|
|
47
|
-
});
|
|
38
|
+
const HostConnectionContext = createContext<HostConnectionContextValue | null>(null);
|
|
48
39
|
|
|
49
40
|
const SSE_CONNECT_TIMEOUT_MS = 2_000;
|
|
50
41
|
const HEARTBEAT_TIMEOUT_MS = 6_000;
|
|
51
42
|
const LAN_PROBE_TIMEOUT_MS = 1_500;
|
|
52
43
|
const LAN_KEEPALIVE_MS = 60_000;
|
|
53
44
|
|
|
54
|
-
export function HostConnectionProvider({ children }: { children: ReactNode }) {
|
|
55
|
-
const { getActiveHost } = useHostStore();
|
|
56
|
-
const activeHost = getActiveHost();
|
|
45
|
+
export function HostConnectionProvider({ children, activeHost }: { children: ReactNode; activeHost: PairedHost }) {
|
|
57
46
|
|
|
58
47
|
const [nc, setNc] = useState<NatsConnection | null>(null);
|
|
59
48
|
const [natsConnected, setNatsConnected] = useState(false);
|
|
@@ -69,31 +58,29 @@ export function HostConnectionProvider({ children }: { children: ReactNode }) {
|
|
|
69
58
|
const lastHeartbeat = useRef(0);
|
|
70
59
|
|
|
71
60
|
// Host is a "direct-only" host if it has a directUrl (paired with address)
|
|
72
|
-
const isDirectHost =
|
|
61
|
+
const isDirectHost = !!activeHost.directUrl;
|
|
73
62
|
// Auto-LAN is opportunistic on native server-paired hosts that have a known LAN URL.
|
|
74
|
-
const useLanRpc = isNative && !isDirectHost && !!activeHost
|
|
63
|
+
const useLanRpc = isNative && !isDirectHost && !!activeHost.lanUrl && lanReachable;
|
|
75
64
|
|
|
76
65
|
const mode: ConnectionMode = unauthorized || hostNotFound
|
|
77
66
|
? "disconnected"
|
|
78
|
-
:
|
|
79
|
-
? "connecting"
|
|
80
|
-
:
|
|
81
|
-
?
|
|
82
|
-
:
|
|
83
|
-
? "connecting"
|
|
84
|
-
: (useLanRpc ? "lan" : "nats");
|
|
67
|
+
: isDirectHost
|
|
68
|
+
? (sseConnected ? "direct" : "connecting")
|
|
69
|
+
: !natsConnected
|
|
70
|
+
? "connecting"
|
|
71
|
+
: (useLanRpc ? "lan" : "nats");
|
|
85
72
|
const connected = mode !== "connecting" && mode !== "disconnected";
|
|
86
73
|
|
|
87
74
|
// Reset terminal states when switching hosts or re-pairing (new client token).
|
|
88
75
|
useEffect(() => {
|
|
89
76
|
setUnauthorized(false);
|
|
90
77
|
setHostNotFound(false);
|
|
91
|
-
}, [activeHost
|
|
78
|
+
}, [activeHost.hostId, activeHost.clientToken]);
|
|
92
79
|
|
|
93
80
|
// Probe the host's LAN URL on native server-paired hosts. RPC routes via direct
|
|
94
81
|
// HTTP when the probe succeeds; events stay on NATS regardless.
|
|
95
82
|
useEffect(() => {
|
|
96
|
-
if (!isNative || isDirectHost || !activeHost
|
|
83
|
+
if (!isNative || isDirectHost || !activeHost.lanUrl) {
|
|
97
84
|
setLanReachable(false);
|
|
98
85
|
return;
|
|
99
86
|
}
|
|
@@ -141,7 +128,7 @@ export function HostConnectionProvider({ children }: { children: ReactNode }) {
|
|
|
141
128
|
appHandle.then((h) => h.remove());
|
|
142
129
|
netHandle.then((h) => h.remove());
|
|
143
130
|
};
|
|
144
|
-
}, [activeHost
|
|
131
|
+
}, [activeHost.hostId, activeHost.lanUrl, isDirectHost]);
|
|
145
132
|
|
|
146
133
|
// Fetch NATS config from server and connect (only for NATS hosts)
|
|
147
134
|
useEffect(() => {
|
|
@@ -155,8 +142,6 @@ export function HostConnectionProvider({ children }: { children: ReactNode }) {
|
|
|
155
142
|
return;
|
|
156
143
|
}
|
|
157
144
|
|
|
158
|
-
if (!activeHost) return;
|
|
159
|
-
|
|
160
145
|
let cancelled = false;
|
|
161
146
|
let retryDelayMs = 1_000;
|
|
162
147
|
const MAX_RETRY_MS = 30_000;
|
|
@@ -164,7 +149,7 @@ export function HostConnectionProvider({ children }: { children: ReactNode }) {
|
|
|
164
149
|
async function connectLoop() {
|
|
165
150
|
while (!cancelled) {
|
|
166
151
|
try {
|
|
167
|
-
const res = await fetch(`${SERVER_URL}/api/nats-credentials/${activeHost
|
|
152
|
+
const res = await fetch(`${SERVER_URL}/api/nats-credentials/${activeHost.hostId}`);
|
|
168
153
|
if (cancelled) return;
|
|
169
154
|
if (res.status === 401 || res.status === 403 || res.status === 404) {
|
|
170
155
|
console.error("[NATS] Host not found or rejected by relay:", res.status);
|
|
@@ -226,9 +211,24 @@ export function HostConnectionProvider({ children }: { children: ReactNode }) {
|
|
|
226
211
|
};
|
|
227
212
|
}, [isDirectHost, activeHost]);
|
|
228
213
|
|
|
214
|
+
// Mobile WebViews suspend WebSockets when the app backgrounds; nats.ws often
|
|
215
|
+
// doesn't notice the dead socket on resume, leaving RPCs to hang/throw "Not
|
|
216
|
+
// connected." Force-close on resume so the connectLoop reconnects with a
|
|
217
|
+
// fresh socket.
|
|
218
|
+
useEffect(() => {
|
|
219
|
+
if (isDirectHost) return;
|
|
220
|
+
const handle = CapacitorApp.addListener("appStateChange", (state: { isActive: boolean }) => {
|
|
221
|
+
if (state.isActive && ncRef.current) {
|
|
222
|
+
console.log("[NATS] App resumed; cycling NATS conn to recover from possible dead socket");
|
|
223
|
+
ncRef.current.close().catch(() => {});
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
return () => { handle.then((h) => h.remove()); };
|
|
227
|
+
}, [isDirectHost]);
|
|
228
|
+
|
|
229
229
|
// SSE connection for direct (LAN) hosts only
|
|
230
230
|
useEffect(() => {
|
|
231
|
-
if (!
|
|
231
|
+
if (!isDirectHost) {
|
|
232
232
|
setSseConnected(false);
|
|
233
233
|
return;
|
|
234
234
|
}
|
|
@@ -240,8 +240,8 @@ export function HostConnectionProvider({ children }: { children: ReactNode }) {
|
|
|
240
240
|
const timer = setTimeout(() => connectAc.abort(), SSE_CONNECT_TIMEOUT_MS);
|
|
241
241
|
controller.signal.addEventListener("abort", () => connectAc.abort());
|
|
242
242
|
try {
|
|
243
|
-
const res = await fetch(`${activeHost
|
|
244
|
-
headers: { Authorization: `Bearer ${activeHost
|
|
243
|
+
const res = await fetch(`${activeHost.directUrl}/events`, {
|
|
244
|
+
headers: { Authorization: `Bearer ${activeHost.clientToken}` },
|
|
245
245
|
signal: connectAc.signal,
|
|
246
246
|
});
|
|
247
247
|
clearTimeout(timer);
|
|
@@ -255,7 +255,7 @@ export function HostConnectionProvider({ children }: { children: ReactNode }) {
|
|
|
255
255
|
|
|
256
256
|
async function readStream(reader: ReadableStreamDefaultReader<Uint8Array>) {
|
|
257
257
|
setSseConnected(true);
|
|
258
|
-
console.log("[HOST] SSE connected to", activeHost
|
|
258
|
+
console.log("[HOST] SSE connected to", activeHost.directUrl);
|
|
259
259
|
|
|
260
260
|
lastHeartbeat.current = Date.now();
|
|
261
261
|
function checkHeartbeat() {
|
|
@@ -288,7 +288,7 @@ export function HostConnectionProvider({ children }: { children: ReactNode }) {
|
|
|
288
288
|
try {
|
|
289
289
|
const event = JSON.parse(jsonStr) as { task_id?: string; event_type?: string };
|
|
290
290
|
if (event.task_id && event.event_type) {
|
|
291
|
-
const subject = `host-event.${activeHost
|
|
291
|
+
const subject = `host-event.${activeHost.hostId}.${event.task_id}`;
|
|
292
292
|
for (const cb of sseEventCallbacksRef.current) cb({ subject, data: sc.current.encode(jsonStr) });
|
|
293
293
|
}
|
|
294
294
|
} catch { /* skip malformed — includes heartbeat pings */ }
|
|
@@ -314,8 +314,6 @@ export function HostConnectionProvider({ children }: { children: ReactNode }) {
|
|
|
314
314
|
params?: unknown,
|
|
315
315
|
opts?: { timeout?: number },
|
|
316
316
|
): Promise<T> => {
|
|
317
|
-
if (!activeHost) throw new Error("No active host");
|
|
318
|
-
|
|
319
317
|
function checkUnauthorized(data: unknown): void {
|
|
320
318
|
if (data && typeof data === "object" && (data as Record<string, unknown>).error === "Unauthorized") {
|
|
321
319
|
setUnauthorized(true);
|
|
@@ -328,7 +326,7 @@ export function HostConnectionProvider({ children }: { children: ReactNode }) {
|
|
|
328
326
|
method: "POST",
|
|
329
327
|
headers: {
|
|
330
328
|
"Content-Type": "application/json",
|
|
331
|
-
Authorization: `Bearer ${activeHost
|
|
329
|
+
Authorization: `Bearer ${activeHost.clientToken}`,
|
|
332
330
|
},
|
|
333
331
|
body: params != null ? JSON.stringify(params) : undefined,
|
|
334
332
|
signal: opts?.timeout ? AbortSignal.timeout(opts.timeout) : undefined,
|
|
@@ -345,8 +343,8 @@ export function HostConnectionProvider({ children }: { children: ReactNode }) {
|
|
|
345
343
|
|
|
346
344
|
async function callNats(): Promise<T> {
|
|
347
345
|
if (!ncRef.current) throw new Error("Not connected");
|
|
348
|
-
const subject = `host.${activeHost
|
|
349
|
-
const payload = { ...(params as Record<string, unknown> ?? {}), clientToken: activeHost
|
|
346
|
+
const subject = `host.${activeHost.hostId}.rpc.${method}`;
|
|
347
|
+
const payload = { ...(params as Record<string, unknown> ?? {}), clientToken: activeHost.clientToken };
|
|
350
348
|
const body = sc.current.encode(JSON.stringify(payload));
|
|
351
349
|
console.log(`[HOST/NATS] → ${method}`, params ?? "");
|
|
352
350
|
const msg = await ncRef.current.request(subject, body, { timeout: opts?.timeout ?? 10000 });
|
|
@@ -412,6 +410,8 @@ export function HostConnectionProvider({ children }: { children: ReactNode }) {
|
|
|
412
410
|
);
|
|
413
411
|
}
|
|
414
412
|
|
|
415
|
-
export function useHostConnection() {
|
|
416
|
-
|
|
413
|
+
export function useHostConnection(): HostConnectionContextValue {
|
|
414
|
+
const ctx = useContext(HostConnectionContext);
|
|
415
|
+
if (!ctx) throw new Error("useHostConnection must be used within HostConnectionProvider");
|
|
416
|
+
return ctx;
|
|
417
417
|
}
|