palmier 0.8.11 → 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 +3 -1
- package/dist/linked-device.d.ts +9 -0
- package/dist/linked-device.js +45 -0
- package/dist/mcp-tools.js +19 -19
- package/dist/pwa/assets/index-BLCVzS_l.js +120 -0
- package/dist/pwa/assets/{index-DhphickB.css → index-Cjjw24Ok.css} +1 -1
- package/dist/pwa/assets/{web-4WNPL7z3.js → web-C2AU9S9n.js} +1 -1
- package/dist/pwa/assets/{web-DjwsAB0V.js → web-CfD_ah7K.js} +1 -1
- package/dist/pwa/assets/{web-Bpd2nO1M.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 -1
- package/palmier-server/README.md +2 -1
- package/palmier-server/pwa/src/App.css +37 -0
- 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/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 +37 -11
- package/src/linked-device.ts +52 -0
- package/src/mcp-tools.ts +19 -19
- 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-B7S0YoMo.js +0 -120
- package/src/device-capabilities.ts +0 -57
|
@@ -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
|
}
|
|
@@ -10,19 +10,16 @@ import type { PairedHost } from "../types";
|
|
|
10
10
|
|
|
11
11
|
interface HostStoreContextValue {
|
|
12
12
|
pairedHosts: PairedHost[];
|
|
13
|
-
activeHostId: string | null;
|
|
14
13
|
addPairedHost(host: PairedHost): void;
|
|
15
14
|
removePairedHost(hostId: string): void;
|
|
16
15
|
renamePairedHost(hostId: string, name: string): void;
|
|
17
16
|
setHostLanUrl(hostId: string, lanUrl: string | undefined): void;
|
|
18
|
-
|
|
19
|
-
getActiveHost(): PairedHost | null;
|
|
17
|
+
setHostLastAgent(hostId: string, agent: string): void;
|
|
20
18
|
}
|
|
21
19
|
|
|
22
20
|
const HostStoreContext = createContext<HostStoreContextValue | null>(null);
|
|
23
21
|
|
|
24
22
|
const STORAGE_KEY = "palmier_paired_hosts";
|
|
25
|
-
const ACTIVE_KEY = "palmier_active_host";
|
|
26
23
|
|
|
27
24
|
function loadHosts(): PairedHost[] {
|
|
28
25
|
try {
|
|
@@ -38,76 +35,36 @@ function saveHosts(hosts: PairedHost[]) {
|
|
|
38
35
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(hosts));
|
|
39
36
|
}
|
|
40
37
|
|
|
41
|
-
|
|
42
|
-
return localStorage.getItem(ACTIVE_KEY);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function saveActiveId(id: string | null) {
|
|
46
|
-
if (id) localStorage.setItem(ACTIVE_KEY, id);
|
|
47
|
-
else localStorage.removeItem(ACTIVE_KEY);
|
|
48
|
-
}
|
|
38
|
+
export const LOCAL_HOST_ID = "local";
|
|
49
39
|
|
|
50
|
-
/** Local mode: served by palmier serve on localhost — auto-
|
|
40
|
+
/** Local mode: served by palmier serve on localhost — auto-inject a local host entry. */
|
|
51
41
|
const isLocalMode = !!(window as any).__PALMIER_SERVE__
|
|
52
42
|
&& (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1");
|
|
53
43
|
|
|
54
44
|
export function HostStoreProvider({ children }: { children: ReactNode }) {
|
|
55
|
-
const [pairedHosts, setPairedHosts] = useState<PairedHost[]>(
|
|
56
|
-
const [activeHostId, setActiveHostIdState] = useState<string | null>(() => {
|
|
57
|
-
const saved = loadActiveId();
|
|
45
|
+
const [pairedHosts, setPairedHosts] = useState<PairedHost[]>(() => {
|
|
58
46
|
const hosts = loadHosts();
|
|
59
|
-
if (
|
|
60
|
-
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
// Auto-connect in local mode: inject a local host entry without pairing
|
|
64
|
-
useEffect(() => {
|
|
65
|
-
if (!isLocalMode) return;
|
|
66
|
-
const localHostId = "local";
|
|
67
|
-
const existing = loadHosts().find((h) => h.hostId === localHostId);
|
|
68
|
-
if (!existing) {
|
|
69
|
-
const localHost: PairedHost = { hostId: localHostId, clientToken: "", directUrl: window.location.origin };
|
|
70
|
-
setPairedHosts((prev) => [...prev.filter((h) => h.hostId !== localHostId), localHost]);
|
|
71
|
-
setActiveHostIdState(localHostId);
|
|
47
|
+
if (isLocalMode && !hosts.some((h) => h.hostId === LOCAL_HOST_ID)) {
|
|
48
|
+
hosts.push({ hostId: LOCAL_HOST_ID, clientToken: "", directUrl: window.location.origin });
|
|
72
49
|
}
|
|
73
|
-
|
|
50
|
+
return hosts;
|
|
51
|
+
});
|
|
74
52
|
|
|
75
53
|
useEffect(() => {
|
|
76
54
|
saveHosts(pairedHosts);
|
|
77
55
|
}, [pairedHosts]);
|
|
78
56
|
|
|
79
|
-
useEffect(() => {
|
|
80
|
-
saveActiveId(activeHostId);
|
|
81
|
-
}, [activeHostId]);
|
|
82
|
-
|
|
83
57
|
const addPairedHost = useCallback((host: PairedHost) => {
|
|
84
58
|
setPairedHosts((prev) => {
|
|
85
59
|
const filtered = prev.filter((h) => h.hostId !== host.hostId);
|
|
86
60
|
return [...filtered, host];
|
|
87
61
|
});
|
|
88
|
-
setActiveHostIdState(host.hostId);
|
|
89
62
|
}, []);
|
|
90
63
|
|
|
91
64
|
const removePairedHost = useCallback((hostId: string) => {
|
|
92
65
|
setPairedHosts((prev) => prev.filter((h) => h.hostId !== hostId));
|
|
93
|
-
// Only change the active host when the active one is the one being deleted.
|
|
94
|
-
setActiveHostIdState((current) => {
|
|
95
|
-
if (current !== hostId) return current;
|
|
96
|
-
// The deleted host was active; we'll re-select once pairedHosts settles.
|
|
97
|
-
return null;
|
|
98
|
-
});
|
|
99
66
|
}, []);
|
|
100
67
|
|
|
101
|
-
// When the active host disappears (e.g. it was just unpaired), fall back to
|
|
102
|
-
// any remaining paired host, otherwise null.
|
|
103
|
-
useEffect(() => {
|
|
104
|
-
if (activeHostId !== null && !pairedHosts.find((h) => h.hostId === activeHostId)) {
|
|
105
|
-
setActiveHostIdState(pairedHosts[0]?.hostId ?? null);
|
|
106
|
-
} else if (activeHostId === null && pairedHosts.length > 0) {
|
|
107
|
-
setActiveHostIdState(pairedHosts[0].hostId);
|
|
108
|
-
}
|
|
109
|
-
}, [pairedHosts, activeHostId]);
|
|
110
|
-
|
|
111
68
|
const renamePairedHost = useCallback((hostId: string, name: string) => {
|
|
112
69
|
setPairedHosts((prev) =>
|
|
113
70
|
prev.map((h) => (h.hostId === hostId ? { ...h, name } : h))
|
|
@@ -124,24 +81,24 @@ export function HostStoreProvider({ children }: { children: ReactNode }) {
|
|
|
124
81
|
);
|
|
125
82
|
}, []);
|
|
126
83
|
|
|
127
|
-
const
|
|
128
|
-
|
|
84
|
+
const setHostLastAgent = useCallback((hostId: string, agent: string) => {
|
|
85
|
+
setPairedHosts((prev) =>
|
|
86
|
+
prev.map((h) => {
|
|
87
|
+
if (h.hostId !== hostId) return h;
|
|
88
|
+
if (h.lastAgent === agent) return h;
|
|
89
|
+
return { ...h, lastAgent: agent };
|
|
90
|
+
})
|
|
91
|
+
);
|
|
129
92
|
}, []);
|
|
130
93
|
|
|
131
|
-
const getActiveHost = useCallback((): PairedHost | null => {
|
|
132
|
-
return pairedHosts.find((h) => h.hostId === activeHostId) ?? null;
|
|
133
|
-
}, [pairedHosts, activeHostId]);
|
|
134
|
-
|
|
135
94
|
return (
|
|
136
95
|
<HostStoreContext.Provider value={{
|
|
137
96
|
pairedHosts,
|
|
138
|
-
activeHostId,
|
|
139
97
|
addPairedHost,
|
|
140
98
|
removePairedHost,
|
|
141
99
|
renamePairedHost,
|
|
142
100
|
setHostLanUrl,
|
|
143
|
-
|
|
144
|
-
getActiveHost,
|
|
101
|
+
setHostLastAgent,
|
|
145
102
|
}}>
|
|
146
103
|
{children}
|
|
147
104
|
</HostStoreContext.Provider>
|
|
@@ -1,18 +1,17 @@
|
|
|
1
1
|
import { useEffect, useRef } from "react";
|
|
2
2
|
import { Capacitor } from "@capacitor/core";
|
|
3
|
-
import {
|
|
3
|
+
import { useHostConnection } from "../contexts/HostConnectionContext";
|
|
4
4
|
import { apiPost, apiGet } from "../api";
|
|
5
5
|
|
|
6
6
|
export function usePushSubscription() {
|
|
7
|
-
const {
|
|
8
|
-
const activeHost = getActiveHost();
|
|
7
|
+
const { activeHost } = useHostConnection();
|
|
9
8
|
const subscribedRef = useRef<string | null>(null);
|
|
10
9
|
|
|
11
10
|
useEffect(() => {
|
|
12
11
|
// Native app uses FCM for notifications, not Web Push
|
|
13
12
|
if (Capacitor.isNativePlatform()) return;
|
|
14
13
|
// Skip push subscription for direct-only (LAN) hosts — no cloud server to relay through
|
|
15
|
-
if (
|
|
14
|
+
if (activeHost.directUrl || subscribedRef.current === activeHost.hostId) return;
|
|
16
15
|
|
|
17
16
|
async function subscribe() {
|
|
18
17
|
try {
|
|
@@ -26,7 +25,7 @@ export function usePushSubscription() {
|
|
|
26
25
|
// Send hostId to SW so it can include it in push responses
|
|
27
26
|
registration.active?.postMessage({
|
|
28
27
|
type: "set-host-id",
|
|
29
|
-
hostId: activeHost
|
|
28
|
+
hostId: activeHost.hostId,
|
|
30
29
|
});
|
|
31
30
|
|
|
32
31
|
let subscription = await registration.pushManager.getSubscription();
|
|
@@ -59,7 +58,7 @@ export function usePushSubscription() {
|
|
|
59
58
|
// Always ensure subscription is saved to server
|
|
60
59
|
const sub = subscription.toJSON();
|
|
61
60
|
await apiPost("/api/push/subscribe", {
|
|
62
|
-
hostId: activeHost
|
|
61
|
+
hostId: activeHost.hostId,
|
|
63
62
|
endpoint: sub.endpoint,
|
|
64
63
|
keys: {
|
|
65
64
|
p256dh: sub.keys!.p256dh,
|
|
@@ -67,7 +66,7 @@ export function usePushSubscription() {
|
|
|
67
66
|
},
|
|
68
67
|
});
|
|
69
68
|
|
|
70
|
-
subscribedRef.current = activeHost
|
|
69
|
+
subscribedRef.current = activeHost.hostId;
|
|
71
70
|
} catch (err) {
|
|
72
71
|
console.error("[Push] Subscription failed:", err);
|
|
73
72
|
}
|
|
@@ -1,26 +1,5 @@
|
|
|
1
1
|
import { Capacitor, registerPlugin, type PluginListenerHandle } from "@capacitor/core";
|
|
2
2
|
|
|
3
|
-
export type PermissionType =
|
|
4
|
-
| "location"
|
|
5
|
-
| "smsRead"
|
|
6
|
-
| "smsSend"
|
|
7
|
-
| "contacts"
|
|
8
|
-
| "calendar"
|
|
9
|
-
| "notificationListener"
|
|
10
|
-
| "dnd"
|
|
11
|
-
| "fullScreenIntent"
|
|
12
|
-
| "postNotifications";
|
|
13
|
-
|
|
14
|
-
export interface PermissionResult {
|
|
15
|
-
granted: boolean;
|
|
16
|
-
/**
|
|
17
|
-
* False when the native build doesn't recognize this permission type — the PWA
|
|
18
|
-
* is served remotely and may ship ahead of the installed APK. Callers should
|
|
19
|
-
* treat unsupported types as "cannot enable" rather than as a hard error.
|
|
20
|
-
*/
|
|
21
|
-
supported: boolean;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
3
|
export interface DeepLinkEvent {
|
|
25
4
|
path: string;
|
|
26
5
|
}
|
|
@@ -30,28 +9,34 @@ export interface InstalledApp {
|
|
|
30
9
|
appName: string;
|
|
31
10
|
}
|
|
32
11
|
|
|
12
|
+
export interface CapabilityStatus {
|
|
13
|
+
name: string;
|
|
14
|
+
enabled: boolean;
|
|
15
|
+
supported: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type SetCapabilityReason = "denied" | "no-email-client" | "unsupported";
|
|
19
|
+
|
|
20
|
+
export interface SetCapabilityResult {
|
|
21
|
+
enabled: boolean;
|
|
22
|
+
reason?: SetCapabilityReason;
|
|
23
|
+
}
|
|
24
|
+
|
|
33
25
|
interface DevicePlugin {
|
|
34
26
|
getFcmToken(): Promise<{ token: string }>;
|
|
35
|
-
/** Returns the
|
|
36
|
-
|
|
37
|
-
checkPermission(opts: { type: PermissionType }): Promise<PermissionResult>;
|
|
38
|
-
requestPermission(opts: { type: PermissionType }): Promise<PermissionResult>;
|
|
27
|
+
/** Returns one entry per capability the APK knows about. The PWA renders only these. */
|
|
28
|
+
getCapabilityStatus(): Promise<{ capabilities: CapabilityStatus[] }>;
|
|
39
29
|
/**
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
30
|
+
* Atomically gates a single capability. When `enabled: true`, the plugin drives
|
|
31
|
+
* any required permission dialog or system-Settings round-trip and writes the
|
|
32
|
+
* native kill-switch only on grant. When `enabled: false`, the kill-switch is
|
|
33
|
+
* removed (no OS dialog). The returned `enabled` reflects the actual stored
|
|
34
|
+
* state; `reason` distinguishes denial from preconditions like
|
|
35
|
+
* "no email app installed".
|
|
44
36
|
*/
|
|
45
|
-
|
|
46
|
-
/** Returns user-visible (launcher) apps on the device
|
|
37
|
+
setCapabilityEnabled(opts: { capability: string; enabled: boolean }): Promise<SetCapabilityResult>;
|
|
38
|
+
/** Returns user-visible (launcher) apps on the device. */
|
|
47
39
|
getInstalledApps(): Promise<{ apps: InstalledApp[] }>;
|
|
48
|
-
/**
|
|
49
|
-
* Returns whether the device has at least one app that can handle a mailto:
|
|
50
|
-
* intent. Used to gate the Sending Email capability — silent PackageManager
|
|
51
|
-
* lookup, no side effects. `supported: false` on older APKs that don't expose
|
|
52
|
-
* this method; treat as "cannot enable" rather than as a hard error.
|
|
53
|
-
*/
|
|
54
|
-
hasEmailClient(): Promise<{ available: boolean; supported: boolean }>;
|
|
55
40
|
addListener(
|
|
56
41
|
event: "deepLink",
|
|
57
42
|
handler: (ev: DeepLinkEvent) => void
|