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.
Files changed (41) hide show
  1. package/README.md +3 -1
  2. package/dist/linked-device.d.ts +9 -0
  3. package/dist/linked-device.js +45 -0
  4. package/dist/mcp-tools.js +19 -19
  5. package/dist/pwa/assets/index-BLCVzS_l.js +120 -0
  6. package/dist/pwa/assets/{index-DhphickB.css → index-Cjjw24Ok.css} +1 -1
  7. package/dist/pwa/assets/{web-4WNPL7z3.js → web-C2AU9S9n.js} +1 -1
  8. package/dist/pwa/assets/{web-DjwsAB0V.js → web-CfD_ah7K.js} +1 -1
  9. package/dist/pwa/assets/{web-Bpd2nO1M.js → web-DugGj1t8.js} +1 -1
  10. package/dist/pwa/index.html +2 -2
  11. package/dist/pwa/service-worker.js +2 -2
  12. package/dist/rpc-handler.js +17 -23
  13. package/package.json +1 -1
  14. package/palmier-server/README.md +2 -1
  15. package/palmier-server/pwa/src/App.css +37 -0
  16. package/palmier-server/pwa/src/App.tsx +36 -15
  17. package/palmier-server/pwa/src/components/CapabilityToggles.tsx +65 -225
  18. package/palmier-server/pwa/src/components/HostMenu.tsx +110 -21
  19. package/palmier-server/pwa/src/components/RunDetailView.tsx +2 -2
  20. package/palmier-server/pwa/src/components/SessionComposer.tsx +9 -8
  21. package/palmier-server/pwa/src/components/SessionsView.tsx +5 -3
  22. package/palmier-server/pwa/src/components/TabBar.tsx +7 -5
  23. package/palmier-server/pwa/src/components/TaskForm.tsx +5 -3
  24. package/palmier-server/pwa/src/constants.ts +1 -1
  25. package/palmier-server/pwa/src/contexts/HostConnectionContext.tsx +41 -41
  26. package/palmier-server/pwa/src/contexts/HostStoreContext.tsx +17 -60
  27. package/palmier-server/pwa/src/hooks/usePushSubscription.ts +6 -7
  28. package/palmier-server/pwa/src/native/Device.ts +23 -38
  29. package/palmier-server/pwa/src/pages/Dashboard.tsx +36 -41
  30. package/palmier-server/pwa/src/pages/PairHost.tsx +20 -1
  31. package/palmier-server/pwa/src/pages/PairSetup.tsx +98 -39
  32. package/palmier-server/pwa/src/service-worker.ts +9 -6
  33. package/palmier-server/pwa/src/types.ts +2 -0
  34. package/palmier-server/spec.md +37 -11
  35. package/src/linked-device.ts +52 -0
  36. package/src/mcp-tools.ts +19 -19
  37. package/src/rpc-handler.ts +14 -22
  38. package/dist/device-capabilities.d.ts +0 -9
  39. package/dist/device-capabilities.js +0 -36
  40. package/dist/pwa/assets/index-B7S0YoMo.js +0 -120
  41. 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 isTasks = location.pathname.startsWith("/tasks");
7
+ const { hostId } = useParams<{ hostId: string }>();
8
+ const isTasks = location.pathname.endsWith("/tasks");
8
9
  const isSessions = !isTasks;
9
10
 
10
- function go(path: string) {
11
+ function go(suffix: string) {
11
12
  if (!confirmLeaveDraft()) return;
12
- navigate(path);
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 = localStorage.getItem("palmier: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) localStorage.setItem("palmier:lastAgent", agent);
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.9";
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 | null;
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 = activeHost != null && !!activeHost.directUrl;
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?.lanUrl && lanReachable;
63
+ const useLanRpc = isNative && !isDirectHost && !!activeHost.lanUrl && lanReachable;
75
64
 
76
65
  const mode: ConnectionMode = unauthorized || hostNotFound
77
66
  ? "disconnected"
78
- : !activeHost
79
- ? "connecting"
80
- : isDirectHost
81
- ? (sseConnected ? "direct" : "connecting")
82
- : !natsConnected
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?.hostId, activeHost?.clientToken]);
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?.lanUrl) {
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?.hostId, activeHost?.lanUrl, isDirectHost]);
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!.hostId}`);
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 (!activeHost || !isDirectHost) {
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!.directUrl}/events`, {
244
- headers: { Authorization: `Bearer ${activeHost!.clientToken}` },
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!.directUrl);
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!.hostId}.${event.task_id}`;
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!.clientToken}`,
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!.hostId}.rpc.${method}`;
349
- const payload = { ...(params as Record<string, unknown> ?? {}), clientToken: activeHost!.clientToken };
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
- return useContext(HostConnectionContext);
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
- setActiveHostId(hostId: string): void;
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
- function loadActiveId(): string | null {
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-connect without pairing. */
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[]>(loadHosts);
56
- const [activeHostId, setActiveHostIdState] = useState<string | null>(() => {
57
- const saved = loadActiveId();
45
+ const [pairedHosts, setPairedHosts] = useState<PairedHost[]>(() => {
58
46
  const hosts = loadHosts();
59
- if (saved && hosts.some((h) => h.hostId === saved)) return saved;
60
- return hosts.length > 0 ? hosts[0].hostId : null;
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 setActiveHostId = useCallback((hostId: string) => {
128
- setActiveHostIdState(hostId);
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
- setActiveHostId,
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 { useHostStore } from "../contexts/HostStoreContext";
3
+ import { useHostConnection } from "../contexts/HostConnectionContext";
4
4
  import { apiPost, apiGet } from "../api";
5
5
 
6
6
  export function usePushSubscription() {
7
- const { getActiveHost } = useHostStore();
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 (!activeHost || activeHost.directUrl || subscribedRef.current === activeHost.hostId) return;
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!.hostId,
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!.hostId,
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!.hostId;
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 set of PermissionType strings this native build understands. */
36
- getSupportedPermissions(): Promise<{ types: PermissionType[] }>;
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
- * Authoritative list of capabilities the user has enabled on this device.
41
- * Native receivers + handlers consult this as a local kill-switch. Unknown
42
- * capability names are stored but ignored safe for PWAs that ship new caps
43
- * ahead of the installed APK.
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
- setEnabledCapabilities(opts: { capabilities: string[] }): Promise<void>;
46
- /** Returns user-visible (launcher) apps on the device, with 96x96 PNG icons. */
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