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
|
@@ -10,6 +10,7 @@ import HostMenu from "../components/HostMenu";
|
|
|
10
10
|
import ConnectionStatusIcon from "../components/ConnectionStatusIcon";
|
|
11
11
|
import SessionsView from "../components/SessionsView";
|
|
12
12
|
import RunDetailView from "../components/RunDetailView";
|
|
13
|
+
import { loadEnabledCapabilities } from "../components/CapabilityToggles";
|
|
13
14
|
import { usePushSubscription } from "../hooks/usePushSubscription";
|
|
14
15
|
import { useMediaQuery } from "../hooks/useMediaQuery";
|
|
15
16
|
import { setAgentLabels } from "../agentLabels";
|
|
@@ -45,14 +46,16 @@ interface PermissionPrompt { permissions: RequiredPermission[]; sessionName?: st
|
|
|
45
46
|
interface InputPrompt { questions: string[]; description?: string; sessionName?: string }
|
|
46
47
|
|
|
47
48
|
export default function Dashboard() {
|
|
48
|
-
const {
|
|
49
|
-
const { connected, request, subscribeEvents, unauthorized } = useHostConnection();
|
|
49
|
+
const { removePairedHost, setHostLanUrl } = useHostStore();
|
|
50
|
+
const { connected, request, subscribeEvents, unauthorized, activeHost } = useHostConnection();
|
|
51
|
+
const hostId = activeHost.hostId;
|
|
52
|
+
const activeClientToken = activeHost.clientToken || null;
|
|
50
53
|
const navigate = useNavigate();
|
|
51
54
|
const location = useLocation();
|
|
52
55
|
const params = useParams<{ taskId?: string; runId?: string }>();
|
|
53
56
|
const isDesktop = useMediaQuery("(min-width: 768px)");
|
|
54
57
|
|
|
55
|
-
const isTasksTab = location.pathname.
|
|
58
|
+
const isTasksTab = location.pathname.endsWith("/tasks");
|
|
56
59
|
const runsFilterTaskId = params.runId ? undefined : params.taskId;
|
|
57
60
|
|
|
58
61
|
// "latest" is passed through to RunDetailView, which does its own resolution
|
|
@@ -63,10 +66,13 @@ export default function Dashboard() {
|
|
|
63
66
|
const [updating, setUpdating] = useState(false);
|
|
64
67
|
const [updateError, setUpdateError] = useState<string | null>(null);
|
|
65
68
|
const [daemonVersion, setDaemonVersion] = useState<string | null>(null);
|
|
66
|
-
const [
|
|
69
|
+
const [linkedClientToken, setLinkedClientToken] = useState<string | null>(null);
|
|
70
|
+
const [enabledCapabilities, setEnabledCapabilities] = useState<Set<string>>(new Set());
|
|
67
71
|
const [agents, setAgents] = useState<AgentInfo[]>([]);
|
|
68
72
|
const [hostPlatform, setHostPlatform] = useState<string | undefined>();
|
|
69
73
|
|
|
74
|
+
const isLinkedDevice = !!activeClientToken && linkedClientToken === activeClientToken;
|
|
75
|
+
|
|
70
76
|
// Pending prompt state — owned by Dashboard because these modals must show
|
|
71
77
|
// regardless of which tab (Sessions/Tasks/RunDetail) is currently rendered.
|
|
72
78
|
const [pendingConfirms, setPendingConfirms] = useState<Map<string, ConfirmPrompt>>(new Map());
|
|
@@ -78,7 +84,13 @@ export default function Dashboard() {
|
|
|
78
84
|
|
|
79
85
|
useEffect(() => {
|
|
80
86
|
window.scrollTo(0, 0);
|
|
81
|
-
}, [
|
|
87
|
+
}, [hostId]);
|
|
88
|
+
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
let cancelled = false;
|
|
91
|
+
loadEnabledCapabilities().then((caps) => { if (!cancelled) setEnabledCapabilities(caps); });
|
|
92
|
+
return () => { cancelled = true; };
|
|
93
|
+
}, []);
|
|
82
94
|
|
|
83
95
|
// host.info bootstrap: agents/version/platform + any prompts that were already
|
|
84
96
|
// pending when this PWA connected. Runs once per (host, connection).
|
|
@@ -88,21 +100,19 @@ export default function Dashboard() {
|
|
|
88
100
|
agents?: AgentInfo[];
|
|
89
101
|
version?: string | null;
|
|
90
102
|
host_platform?: string;
|
|
91
|
-
|
|
103
|
+
linked_client_token?: string | null;
|
|
92
104
|
pending_prompts?: PendingPrompt[];
|
|
93
105
|
lan_url?: string | null;
|
|
94
106
|
}>("host.info")
|
|
95
107
|
.then((result) => {
|
|
96
108
|
setAgents(result.agents ?? []);
|
|
97
109
|
setHostPlatform(result.host_platform);
|
|
98
|
-
|
|
110
|
+
setLinkedClientToken(result.linked_client_token ?? null);
|
|
99
111
|
setAgentLabels(result.agents ?? []);
|
|
100
112
|
const version = result.version ?? null;
|
|
101
113
|
setDaemonVersion(version);
|
|
102
114
|
setUpdateRequired(!!version && isOlderThan(version, MIN_HOST_VERSION));
|
|
103
|
-
|
|
104
|
-
setHostLanUrl(activeHostId, result.lan_url ?? undefined);
|
|
105
|
-
}
|
|
115
|
+
setHostLanUrl(hostId, result.lan_url ?? undefined);
|
|
106
116
|
|
|
107
117
|
// Seed modal state from already-pending prompts.
|
|
108
118
|
const confirms = new Map<string, ConfirmPrompt>();
|
|
@@ -136,13 +146,13 @@ export default function Dashboard() {
|
|
|
136
146
|
setInputValues(inputVals);
|
|
137
147
|
})
|
|
138
148
|
.catch(() => { /* silent — update-required prompt guards the broken case */ });
|
|
139
|
-
}, [connected,
|
|
149
|
+
}, [connected, hostId, request, setHostLanUrl]);
|
|
140
150
|
|
|
141
151
|
// Always-on event subscription for modal lifecycle. Independent of which tab
|
|
142
152
|
// is active. Task-card status updates happen inside TasksView while mounted.
|
|
143
153
|
useEffect(() => {
|
|
144
|
-
if (!connected
|
|
145
|
-
const unsubscribe = subscribeEvents(
|
|
154
|
+
if (!connected) return;
|
|
155
|
+
const unsubscribe = subscribeEvents(hostId, (msg) => {
|
|
146
156
|
const tokens = msg.subject.split(".");
|
|
147
157
|
if (tokens.length < 3) return;
|
|
148
158
|
const taskId = tokens.slice(2).join(".");
|
|
@@ -239,7 +249,7 @@ export default function Dashboard() {
|
|
|
239
249
|
}
|
|
240
250
|
});
|
|
241
251
|
return unsubscribe;
|
|
242
|
-
}, [connected,
|
|
252
|
+
}, [connected, hostId, subscribeEvents]);
|
|
243
253
|
|
|
244
254
|
async function respondToConfirm(sessionId: string, response: "confirmed" | "aborted") {
|
|
245
255
|
try {
|
|
@@ -267,11 +277,8 @@ export default function Dashboard() {
|
|
|
267
277
|
|
|
268
278
|
function handleViewRun(taskId: string, runId?: string) {
|
|
269
279
|
if (!confirmLeaveDraft()) return;
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
} else {
|
|
273
|
-
navigate(`/runs/${encodeURIComponent(taskId)}`);
|
|
274
|
-
}
|
|
280
|
+
const base = `/hosts/${encodeURIComponent(hostId)}/runs/${encodeURIComponent(taskId)}`;
|
|
281
|
+
navigate(runId ? `${base}/${encodeURIComponent(runId)}` : base);
|
|
275
282
|
}
|
|
276
283
|
|
|
277
284
|
async function handleUpdate() {
|
|
@@ -290,18 +297,16 @@ export default function Dashboard() {
|
|
|
290
297
|
setTimeout(() => window.location.reload(), 15000);
|
|
291
298
|
}
|
|
292
299
|
|
|
293
|
-
const
|
|
294
|
-
const showTaskContent = hasHosts && connected && activeHostId && !unauthorized;
|
|
295
|
-
const activeClientToken = pairedHosts.find((h) => h.hostId === activeHostId)?.clientToken ?? null;
|
|
300
|
+
const showTaskContent = connected && !unauthorized;
|
|
296
301
|
|
|
297
302
|
return (
|
|
298
303
|
<div className="dashboard">
|
|
299
|
-
{isDesktop && <HostMenu daemonVersion={daemonVersion}
|
|
304
|
+
{isDesktop && <HostMenu daemonVersion={daemonVersion} linkedClientToken={linkedClientToken} request={request} onEnabledCapabilitiesChange={setEnabledCapabilities} onLinkedClientTokenChange={setLinkedClientToken} />}
|
|
300
305
|
|
|
301
306
|
<div className="dashboard-content">
|
|
302
307
|
<header className="app-header">
|
|
303
308
|
<div className="app-title-bar">
|
|
304
|
-
{!isDesktop && <HostMenu daemonVersion={daemonVersion}
|
|
309
|
+
{!isDesktop && <HostMenu daemonVersion={daemonVersion} linkedClientToken={linkedClientToken} request={request} onEnabledCapabilitiesChange={setEnabledCapabilities} onLinkedClientTokenChange={setLinkedClientToken} />}
|
|
305
310
|
<h1 className="app-title">Palmier</h1>
|
|
306
311
|
<ConnectionStatusIcon />
|
|
307
312
|
</div>
|
|
@@ -336,9 +341,7 @@ export default function Dashboard() {
|
|
|
336
341
|
</button>
|
|
337
342
|
<button
|
|
338
343
|
className="btn btn-secondary"
|
|
339
|
-
onClick={() => {
|
|
340
|
-
if (activeHostId) removePairedHost(activeHostId);
|
|
341
|
-
}}
|
|
344
|
+
onClick={() => { removePairedHost(hostId); navigate("/", { replace: true }); }}
|
|
342
345
|
>
|
|
343
346
|
Remove Host
|
|
344
347
|
</button>
|
|
@@ -349,19 +352,19 @@ export default function Dashboard() {
|
|
|
349
352
|
{isTasksTab && !isRunDetail && (
|
|
350
353
|
<TasksView
|
|
351
354
|
connected={connected}
|
|
352
|
-
hostId={
|
|
355
|
+
hostId={hostId}
|
|
353
356
|
request={request}
|
|
354
357
|
subscribeEvents={subscribeEvents}
|
|
355
358
|
agents={agents}
|
|
356
359
|
hostPlatform={hostPlatform}
|
|
357
|
-
isNotificationListener={
|
|
360
|
+
isNotificationListener={isLinkedDevice && enabledCapabilities.has("notifications")}
|
|
358
361
|
onViewRun={handleViewRun}
|
|
359
362
|
/>
|
|
360
363
|
)}
|
|
361
364
|
{isRunDetail ? (
|
|
362
365
|
<RunDetailView
|
|
363
366
|
connected={connected}
|
|
364
|
-
hostId={
|
|
367
|
+
hostId={hostId}
|
|
365
368
|
request={request}
|
|
366
369
|
subscribeEvents={subscribeEvents}
|
|
367
370
|
taskId={params.taskId!}
|
|
@@ -370,27 +373,19 @@ export default function Dashboard() {
|
|
|
370
373
|
) : !isTasksTab ? (
|
|
371
374
|
<SessionsView
|
|
372
375
|
connected={connected}
|
|
373
|
-
hostId={
|
|
376
|
+
hostId={hostId}
|
|
374
377
|
request={request}
|
|
375
378
|
subscribeEvents={subscribeEvents}
|
|
376
379
|
agents={agents}
|
|
377
380
|
hostPlatform={hostPlatform}
|
|
378
381
|
filterTaskId={runsFilterTaskId}
|
|
379
|
-
onClearFilter={() => { if (confirmLeaveDraft()) navigate(
|
|
382
|
+
onClearFilter={() => { if (confirmLeaveDraft()) navigate(`/hosts/${encodeURIComponent(hostId)}`); }}
|
|
380
383
|
/>
|
|
381
384
|
) : null}
|
|
382
385
|
</>
|
|
383
386
|
) : (
|
|
384
387
|
<div className="empty-state">
|
|
385
|
-
<p>
|
|
386
|
-
{!hasHosts && (
|
|
387
|
-
<button
|
|
388
|
-
className="btn btn-primary"
|
|
389
|
-
onClick={() => navigate("/pair")}
|
|
390
|
-
>
|
|
391
|
-
Pair Host
|
|
392
|
-
</button>
|
|
393
|
-
)}
|
|
388
|
+
<p>Connecting to host...</p>
|
|
394
389
|
</div>
|
|
395
390
|
)}
|
|
396
391
|
</main>
|
|
@@ -18,8 +18,11 @@ interface PairResponse {
|
|
|
18
18
|
const isLoopback = !!(window as any).__PALMIER_SERVE__
|
|
19
19
|
&& (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1");
|
|
20
20
|
|
|
21
|
+
const isNative = Capacitor.isNativePlatform();
|
|
22
|
+
|
|
21
23
|
export default function PairHost() {
|
|
22
24
|
const [code, setCode] = useState("");
|
|
25
|
+
const [makeLinked, setMakeLinked] = useState(true);
|
|
23
26
|
const [pairing, setPairing] = useState(false);
|
|
24
27
|
const [error, setError] = useState<string | null>(null);
|
|
25
28
|
const { addPairedHost } = useHostStore();
|
|
@@ -106,7 +109,8 @@ export default function PairHost() {
|
|
|
106
109
|
}
|
|
107
110
|
}
|
|
108
111
|
|
|
109
|
-
|
|
112
|
+
const base = `/hosts/${encodeURIComponent(response.hostId)}`;
|
|
113
|
+
navigate(isNative && makeLinked ? `${base}/pair/setup` : base);
|
|
110
114
|
} catch (err) {
|
|
111
115
|
const message = err instanceof Error ? err.message : String(err);
|
|
112
116
|
if (message.includes("timeout") || message.includes("TIMEOUT") || message.includes("503") || message.toLowerCase().includes("no responders")) {
|
|
@@ -176,6 +180,21 @@ export default function PairHost() {
|
|
|
176
180
|
/>
|
|
177
181
|
</label>
|
|
178
182
|
|
|
183
|
+
{isNative && (
|
|
184
|
+
<label className="pair-checkbox">
|
|
185
|
+
<input
|
|
186
|
+
type="checkbox"
|
|
187
|
+
checked={makeLinked}
|
|
188
|
+
onChange={(e) => setMakeLinked(e.target.checked)}
|
|
189
|
+
disabled={pairing}
|
|
190
|
+
/>
|
|
191
|
+
<span className="pair-checkbox-text">
|
|
192
|
+
<span className="pair-checkbox-title">Link this device</span>
|
|
193
|
+
<span className="pair-checkbox-hint">The host will use this device for SMS, contacts, calendar, location, and alarms. Only one device can be linked at a time.</span>
|
|
194
|
+
</span>
|
|
195
|
+
</label>
|
|
196
|
+
)}
|
|
197
|
+
|
|
179
198
|
{error && <p className="pair-error">{error}</p>}
|
|
180
199
|
|
|
181
200
|
<button
|
|
@@ -1,71 +1,130 @@
|
|
|
1
1
|
import { useEffect, useState } from "react";
|
|
2
|
+
import { createPortal } from "react-dom";
|
|
2
3
|
import { useNavigate } from "react-router-dom";
|
|
3
4
|
import { useHostConnection } from "../contexts/HostConnectionContext";
|
|
4
5
|
import { useHostStore } from "../contexts/HostStoreContext";
|
|
5
6
|
import CapabilityToggles from "../components/CapabilityToggles";
|
|
7
|
+
import { Device } from "../native/Device";
|
|
6
8
|
|
|
7
9
|
interface HostInfoResponse {
|
|
8
|
-
capability_tokens?: Record<string, string | null>;
|
|
9
10
|
lan_url?: string | null;
|
|
11
|
+
linked_client_token?: string | null;
|
|
10
12
|
}
|
|
11
13
|
|
|
14
|
+
type Phase = "loading" | "confirming" | "linking" | "wizard" | "linkError";
|
|
15
|
+
|
|
12
16
|
export default function PairSetup() {
|
|
13
17
|
const navigate = useNavigate();
|
|
14
|
-
const { connected, request } = useHostConnection();
|
|
15
|
-
const {
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
+
const { connected, request, activeHost } = useHostConnection();
|
|
19
|
+
const { setHostLanUrl, pairedHosts } = useHostStore();
|
|
20
|
+
const isFirstHost = pairedHosts.length <= 1;
|
|
21
|
+
|
|
22
|
+
const [phase, setPhase] = useState<Phase>("loading");
|
|
23
|
+
const [linkedClientToken, setLinkedClientToken] = useState<string | null>(null);
|
|
24
|
+
const [linkError, setLinkError] = useState<string | null>(null);
|
|
18
25
|
|
|
19
|
-
|
|
20
|
-
|
|
26
|
+
function goToHost() {
|
|
27
|
+
navigate(`/hosts/${encodeURIComponent(activeHost.hostId)}`, { replace: true });
|
|
28
|
+
}
|
|
21
29
|
|
|
22
|
-
//
|
|
23
|
-
// back to the dashboard — setup only makes sense right after pairing.
|
|
30
|
+
// Phase: loading → fetch host.info, then transition.
|
|
24
31
|
useEffect(() => {
|
|
25
|
-
if (!
|
|
26
|
-
|
|
32
|
+
if (!connected || phase !== "loading") return;
|
|
33
|
+
let cancelled = false;
|
|
34
|
+
request<HostInfoResponse>("host.info").catch(() => ({} as HostInfoResponse)).then((info) => {
|
|
35
|
+
if (cancelled) return;
|
|
36
|
+
setHostLanUrl(activeHost.hostId, info.lan_url ?? undefined);
|
|
37
|
+
const linked = info.linked_client_token ?? null;
|
|
38
|
+
setLinkedClientToken(linked);
|
|
39
|
+
const otherDeviceLinked = !!linked && linked !== activeHost.clientToken;
|
|
40
|
+
setPhase(otherDeviceLinked ? "confirming" : "linking");
|
|
41
|
+
});
|
|
42
|
+
return () => { cancelled = true; };
|
|
43
|
+
}, [connected, phase, activeHost, request, setHostLanUrl]);
|
|
27
44
|
|
|
45
|
+
// Phase: linking → call device.link, then either show wizard (first host)
|
|
46
|
+
// or navigate (subsequent host).
|
|
28
47
|
useEffect(() => {
|
|
29
|
-
if (
|
|
30
|
-
const activeHostId = activeHost.hostId;
|
|
48
|
+
if (phase !== "linking") return;
|
|
31
49
|
let cancelled = false;
|
|
32
|
-
|
|
33
|
-
|
|
50
|
+
(async () => {
|
|
51
|
+
try {
|
|
52
|
+
if (Device) {
|
|
53
|
+
const { token: fcmToken } = await Device.getFcmToken();
|
|
54
|
+
if (!fcmToken) throw new Error("Could not read FCM token");
|
|
55
|
+
await request("device.link", { fcmToken });
|
|
56
|
+
}
|
|
34
57
|
if (cancelled) return;
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
58
|
+
if (isFirstHost) setPhase("wizard");
|
|
59
|
+
else goToHost();
|
|
60
|
+
} catch (err) {
|
|
61
|
+
if (cancelled) return;
|
|
62
|
+
setLinkError(err instanceof Error ? err.message : String(err));
|
|
63
|
+
setPhase("linkError");
|
|
64
|
+
}
|
|
65
|
+
})();
|
|
40
66
|
return () => { cancelled = true; };
|
|
41
|
-
|
|
67
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
68
|
+
}, [phase]);
|
|
69
|
+
|
|
70
|
+
function confirmLink() { setPhase("linking"); }
|
|
71
|
+
function cancelLink() { goToHost(); }
|
|
72
|
+
function retryLink() { setLinkError(null); setPhase("linking"); }
|
|
73
|
+
|
|
74
|
+
const linkModal = phase === "confirming" && createPortal(
|
|
75
|
+
<div className="confirm-modal-overlay" onClick={cancelLink}>
|
|
76
|
+
<div className="confirm-modal" onClick={(e) => e.stopPropagation()}>
|
|
77
|
+
<h2 className="confirm-modal-title">Link this device?</h2>
|
|
78
|
+
<p className="confirm-modal-message">
|
|
79
|
+
Only one device can be linked at a time — switching will disable those capabilities on the currently linked device.
|
|
80
|
+
</p>
|
|
81
|
+
<div className="confirm-modal-actions">
|
|
82
|
+
<button className="btn btn-secondary" onClick={cancelLink}>Cancel</button>
|
|
83
|
+
<button className="btn btn-primary" onClick={confirmLink}>Link</button>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
</div>,
|
|
87
|
+
document.body,
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
if (phase === "loading" || phase === "confirming" || phase === "linking" || phase === "linkError") {
|
|
91
|
+
const isWizardCandidate = isFirstHost;
|
|
92
|
+
return (
|
|
93
|
+
<div className="pair-setup">
|
|
94
|
+
<div className="pair-setup-inner">
|
|
95
|
+
{isWizardCandidate && <h1 className="pair-setup-title">Device Capabilities</h1>}
|
|
96
|
+
<div className="pair-setup-loading">
|
|
97
|
+
{phase === "loading" && "Connecting to host…"}
|
|
98
|
+
{phase === "confirming" && "Awaiting confirmation…"}
|
|
99
|
+
{phase === "linking" && "Linking device…"}
|
|
100
|
+
{phase === "linkError" && (
|
|
101
|
+
<>
|
|
102
|
+
<p className="pair-error">{linkError}</p>
|
|
103
|
+
<button className="btn btn-primary" onClick={retryLink}>Retry</button>
|
|
104
|
+
<button className="btn btn-secondary" onClick={goToHost} style={{ marginTop: 8 }}>Skip linking</button>
|
|
105
|
+
</>
|
|
106
|
+
)}
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
{linkModal}
|
|
110
|
+
</div>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
42
113
|
|
|
114
|
+
// phase === "wizard"
|
|
115
|
+
void linkedClientToken;
|
|
43
116
|
return (
|
|
44
117
|
<div className="pair-setup">
|
|
45
118
|
<div className="pair-setup-inner">
|
|
46
|
-
<h1 className="pair-setup-title">
|
|
119
|
+
<h1 className="pair-setup-title">Device Capabilities</h1>
|
|
47
120
|
<p className="pair-setup-description">
|
|
48
|
-
You can change these later from the menu.
|
|
121
|
+
Choose what the host can use this device for. You can change these later from the menu.
|
|
49
122
|
</p>
|
|
50
123
|
|
|
51
|
-
|
|
52
|
-
<div className="pair-setup-loading">Connecting to host…</div>
|
|
53
|
-
) : (
|
|
54
|
-
<CapabilityToggles
|
|
55
|
-
capabilityTokens={capabilityTokens}
|
|
56
|
-
activeClientToken={activeClientToken}
|
|
57
|
-
request={request}
|
|
58
|
-
onCapabilityTokensChange={setCapabilityTokens}
|
|
59
|
-
/>
|
|
60
|
-
)}
|
|
124
|
+
<CapabilityToggles />
|
|
61
125
|
|
|
62
126
|
<div className="pair-setup-actions">
|
|
63
|
-
<button
|
|
64
|
-
className="btn btn-primary btn-full"
|
|
65
|
-
onClick={() => navigate("/", { replace: true })}
|
|
66
|
-
>
|
|
67
|
-
Finish
|
|
68
|
-
</button>
|
|
127
|
+
<button className="btn btn-primary btn-full" onClick={goToHost}>Finish</button>
|
|
69
128
|
</div>
|
|
70
129
|
</div>
|
|
71
130
|
</div>
|
|
@@ -104,14 +104,17 @@ self.addEventListener("notificationclick", (event) => {
|
|
|
104
104
|
);
|
|
105
105
|
} else {
|
|
106
106
|
// User tapped the notification body — open the PWA.
|
|
107
|
-
// For task-complete/fail notifications, deep-link to the result view
|
|
107
|
+
// For task-complete/fail notifications, deep-link to the result view
|
|
108
|
+
// scoped to the originating host so the PWA switches hosts automatically.
|
|
109
|
+
const hostId = data.host_id;
|
|
108
110
|
const taskId = data.task_id;
|
|
109
111
|
const runId = data.run_id;
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
112
|
+
const hostPrefix = hostId ? `/hosts/${encodeURIComponent(hostId)}` : "";
|
|
113
|
+
const targetUrl = hostPrefix && taskId && runId
|
|
114
|
+
? `${hostPrefix}/runs/${encodeURIComponent(taskId)}/${encodeURIComponent(runId)}`
|
|
115
|
+
: hostPrefix && taskId
|
|
116
|
+
? `${hostPrefix}/runs/${encodeURIComponent(taskId)}/latest`
|
|
117
|
+
: hostPrefix || "/";
|
|
115
118
|
|
|
116
119
|
event.waitUntil(
|
|
117
120
|
self.clients
|
|
@@ -68,4 +68,6 @@ export interface PairedHost {
|
|
|
68
68
|
directUrl?: string;
|
|
69
69
|
/** Host's LAN URL, refreshed from each `host.info` response so laptop/DHCP IP changes propagate. Native Capacitor app probes for reachability and routes RPC over HTTP when reachable; events stay on NATS. */
|
|
70
70
|
lanUrl?: string;
|
|
71
|
+
/** Last-used agent key for this host. Seeds the agent picker on the session composer and task form. */
|
|
72
|
+
lastAgent?: string;
|
|
71
73
|
}
|
package/palmier-server/spec.md
CHANGED
|
@@ -27,7 +27,7 @@ The host supports **Linux** (systemd), **macOS** (launchd user LaunchAgent), and
|
|
|
27
27
|
- **Battery**: `BatteryHandler` — no permission required
|
|
28
28
|
- **Ringer mode**: `RingerHandler` — requires Do Not Disturb access (system settings toggle)
|
|
29
29
|
|
|
30
|
-
The notification listener excludes Palmier's own task notifications (channel `palmier_tasks`) and the default SMS app's notifications (to avoid duplicates with the SMS resource). Each capability can be toggled on/off
|
|
30
|
+
The notification listener excludes Palmier's own task notifications (channel `palmier_tasks`) and the default SMS app's notifications (to avoid duplicates with the SMS resource). Each capability can be toggled on/off from the drawer when the device is the host's **linked device** (the one device the host talks to for device capabilities); toggles are backed by SharedPreferences flags that handlers check before executing. See the `palmier-android` repo.
|
|
31
31
|
|
|
32
32
|
* **PWA (React):** The user-facing frontend, primarily targeting mobile devices. Connects to the NATS server via **WebSockets** at `nats.palmier.me` (DNS only, not Cloudflare proxied, to avoid interference with persistent connections). No user accounts — paired hosts are stored in localStorage.
|
|
33
33
|
|
|
@@ -59,15 +59,15 @@ The Android app is a thin native shell over the remotely-hosted PWA. The design
|
|
|
59
59
|
|
|
60
60
|
- **Server mode only.** LAN and Local modes are browser-only. The WebView blocks cleartext `http://<host-ip>:<port>` requests as mixed content, so LAN users must open the PWA from Chrome/Safari directly.
|
|
61
61
|
- **Offline fallback.** When `app.palmier.me` is unreachable, the WebView loads `www/offline.html` (configured via `server.errorPath`), which auto-reloads when connectivity returns.
|
|
62
|
-
- **PWA ships ahead of the APK.** The PWA may reference
|
|
62
|
+
- **PWA ships ahead of the APK.** The PWA may reference capabilities or native methods that the installed APK doesn't implement yet. `Device.getCapabilityStatus()` only returns capabilities the APK knows about, so the PWA naturally hides toggles it can't fulfill. Calls to `setCapabilityEnabled` for unknown capabilities resolve with `{ enabled: false, reason: "unsupported" }` rather than throwing.
|
|
63
63
|
|
|
64
|
-
**Unified `Device` Capacitor plugin.** A single plugin (`DevicePlugin.kt`) exposes the entire native surface — FCM token,
|
|
64
|
+
**Unified `Device` Capacitor plugin.** A single plugin (`DevicePlugin.kt`) exposes the entire native surface — FCM token, capability gating (with internal permission orchestration), installed-app enumeration, deep-link events. Methods: `getFcmToken`, `getCapabilityStatus`, `setCapabilityEnabled({capability, enabled})`, `getInstalledApps`, `addListener("deepLink", ...)`. Capabilities: `sms-read`, `sms-send`, `send-email`, `notifications`, `contacts`, `calendar`, `location`, `dnd`, `alarm`.
|
|
65
65
|
|
|
66
|
-
**Capability kill-switch (`CapabilityState`).** Local whitelist persisted as a JSON-array string under `enabledCapabilities` in `CapacitorStorage` SharedPreferences
|
|
66
|
+
**Capability kill-switch (`CapabilityState`).** Local whitelist persisted as a JSON-array string under `enabledCapabilities` in `CapacitorStorage` SharedPreferences. Native receivers (`SmsBroadcastReceiver`, `DeviceNotificationListenerService`) and all FCM handlers consult `CapabilityState.isEnabled` before acting — a second line of defense beyond the server-side linked-device check. The plugin owns reads and writes through `setCapabilityEnabled` (writes only after permission grant) and prunes the set on every app resume so any permission revoked in system Settings auto-flips the corresponding toggle off. Battery is the one capability without a kill-switch — it's always allowed.
|
|
67
67
|
|
|
68
68
|
**FCM token flow.** The PWA reads the current token on demand via `Device.getFcmToken()`; no cached copy in SharedPreferences. `PalmierFirebaseMessagingService.onNewToken` still re-registers with the relay server itself (using the stored `hostId`) because background token refreshes can fire while the PWA isn't running.
|
|
69
69
|
|
|
70
|
-
**Deep links.** FCM notification taps pass a relative path (e.g. `/runs/:taskId/:runId`) via an `Intent` extra named `deepLink`. `MainActivity.handleDeepLink` forwards the path to `DevicePlugin.emitDeepLink`, which emits a `deepLink` event the PWA's router handles client-side. If the plugin isn't ready yet (intent arrives before `onPostCreate`), `MainActivity` buffers the path and flushes in `onPostCreate`. No external `intent-filter` is registered — Android 11+ `<queries>` entries are declared so `hasEmailClient` and installed-app enumeration work without `QUERY_ALL_PACKAGES`.
|
|
70
|
+
**Deep links.** FCM notification taps pass a host-scoped relative path (e.g. `/hosts/:hostId/runs/:taskId/:runId`) via an `Intent` extra named `deepLink`. Including the host in the path ensures the PWA switches to the originating host even if the user was viewing a different one when the notification arrived. `MainActivity.handleDeepLink` forwards the path to `DevicePlugin.emitDeepLink`, which emits a `deepLink` event the PWA's router handles client-side. If the plugin isn't ready yet (intent arrives before `onPostCreate`), `MainActivity` buffers the path and flushes in `onPostCreate`. No external `intent-filter` is registered — Android 11+ `<queries>` entries are declared so `hasEmailClient` and installed-app enumeration work without `QUERY_ALL_PACKAGES`.
|
|
71
71
|
|
|
72
72
|
**Notification listener filtering and debounce.** `DeviceNotificationListenerService` drops notifications from (a) Palmier's own `palmier_tasks` channel to avoid feedback loops and (b) the default SMS app (SMS is captured separately via `SmsBroadcastReceiver`, which arrives before the SMS app's notification). Empty-title+body notifications are skipped. A 2-second debounce per `packageName:title` key dedupes rapid updates; the debounce map is LRU-capped at 200 entries.
|
|
73
73
|
|
|
@@ -75,7 +75,7 @@ The Android app is a thin native shell over the remotely-hosted PWA. The design
|
|
|
75
75
|
|
|
76
76
|
**Alarm capability.** `AlarmHandler` posts a `CATEGORY_ALARM` notification on the DND-bypassing `palmier_alarms` channel with a full-screen intent targeting `AlarmActivity`. `AlarmActivity` extends `AppCompatActivity`, shows over the lock screen (`setShowWhenLocked`/`setTurnScreenOn` on O_MR1+, legacy window flags below), and plays the default alarm ringtone on the alarm audio stream via `RingtoneManager`. Requires `USE_FULL_SCREEN_INTENT`; Android 14+ requires the user to grant it in per-app settings.
|
|
77
77
|
|
|
78
|
-
**Email capability.** FCM `send-email` messages post a "Pending email" notification whose tap launches `EmailActivity` — a translucent `Activity` (not `AppCompatActivity`, so `Theme.Translucent` applies) that builds a `mailto:` URI and starts the email app with `ACTION_SENDTO`. The activity auto-finishes on result, returning the user to the previous screen. `
|
|
78
|
+
**Email capability.** FCM `send-email` messages post a "Pending email" notification whose tap launches `EmailActivity` — a translucent `Activity` (not `AppCompatActivity`, so `Theme.Translucent` applies) that builds a `mailto:` URI and starts the email app with `ACTION_SENDTO`. The activity auto-finishes on result, returning the user to the previous screen. `setCapabilityEnabled("send-email", true)` checks for an installed `mailto:` resolver before granting; if none exists it returns `{ enabled: false, reason: "no-email-client" }` so the PWA can prompt the user to install one.
|
|
79
79
|
|
|
80
80
|
**Location capability.** `GeolocationForegroundService` briefly starts as a foreground service (`FOREGROUND_SERVICE_TYPE_LOCATION` on U+) and uses `FusedLocationProviderClient.getCurrentLocation(PRIORITY_HIGH_ACCURACY)` for a single fix before stopping itself. Requires both `ACCESS_FINE_LOCATION` and `ACCESS_BACKGROUND_LOCATION` on Q+; the plugin requests them sequentially.
|
|
81
81
|
|
|
@@ -140,6 +140,17 @@ Client tokens are stored on the host in `~/.config/palmier/clients.json`. Each t
|
|
|
140
140
|
|
|
141
141
|
If no clients exist, the host skips client validation (backward compatibility for unpaired hosts).
|
|
142
142
|
|
|
143
|
+
### 2.3.1 Linked Device
|
|
144
|
+
|
|
145
|
+
Each host tracks a single **linked device** — the one paired device responsible for answering device capability requests (SMS, contacts, calendar, location, alarm, ringer, email, battery). The host stores only `{ clientToken, fcmToken }` in `~/.config/palmier/linked-device.json`; it has no knowledge of which capabilities are actually enabled on the device. That set lives in Android SharedPreferences on the linked device itself and is consulted by the FCM handlers as a local kill-switch.
|
|
146
|
+
|
|
147
|
+
- **Opt-in at pair time.** The PWA shows a "Link this device" checkbox during pairing (native only, default on). If checked, the pair flow continues to a setup step that calls `device.link` with the FCM token. On the very first host pair (when the device has no other paired hosts), that step also shows a one-time "Device Capabilities" screen with all toggles default OFF; the user opts into each one, and Finish writes the set to SharedPreferences. On subsequent host pairs the capability screen is skipped — the device-wide enabled set is host-agnostic and set once.
|
|
148
|
+
- **Reassignment.** Any paired device can take over as the linked device from the drawer's "Link this device" button. This displaces the previous linked device (its drawer toggles go dark).
|
|
149
|
+
- **Loss.** If the linked device is unpaired (via `clients.revoke_self` or CLI `palmier clients revoke`), `linked-device.json` is cleared and capability tools return "No linked device configured" until the user picks a new one.
|
|
150
|
+
- **Routing.** MCP capability tools look up the linked device once per invocation and publish FCM to its token (via `host.<host_id>.fcm.<capability>` relayed by the server). Non-linked devices aren't woken and don't receive capability FCMs.
|
|
151
|
+
|
|
152
|
+
Battery reads don't have a Settings toggle — capability is always on — but still route to the linked device (which is where the Android handler runs).
|
|
153
|
+
|
|
143
154
|
### 2.4 NATS Communication
|
|
144
155
|
|
|
145
156
|
All communication is scoped per host. **Request-reply** is used for RPC-style calls (task CRUD, status queries) — the PWA publishes a request and receives a response on an auto-generated inbox, eliminating the need for separate response subjects.
|
|
@@ -152,7 +163,10 @@ The **RPC method is derived from the NATS subject**, not the message body. The h
|
|
|
152
163
|
|
|
153
164
|
| Method | Params | Description |
|
|
154
165
|
|---|---|---|
|
|
155
|
-
| `host.info` | *(none)* | Bootstrap metadata fetched once per connection. Returns `{ agents, version, host_platform,
|
|
166
|
+
| `host.info` | *(none)* | Bootstrap metadata fetched once per connection. Returns `{ agents, version, host_platform, linked_client_token, pending_prompts, lan_url }`. `linked_client_token` is the clientToken of the device currently linked to the host (or `null`); device capability requests route to that device's FCM token. `pending_prompts` is an array of prompts already waiting when the PWA reconnects (each `{ key, type, params?, meta? }`), so modals can render without replaying events. |
|
|
167
|
+
| `device.link` | `fcmToken` | Mark the calling client as the host's linked device. Stores `{ clientToken, fcmToken }` in `~/.config/palmier/linked-device.json` and replaces any existing linked device. Device capability tools (`device-geolocation`, `read-contacts`, `send-sms-message`, etc.) route FCM to this device. |
|
|
168
|
+
| `device.unlink` | *(none)* | Clear the linked device if the caller is currently linked. No-op otherwise. |
|
|
169
|
+
| `clients.revoke_self` | *(none)* | Revoke the calling client's token. Also clears the linked device when the caller was the linked one. Called by the PWA when the user unpairs the currently-active host. |
|
|
156
170
|
| `task.list` | *(none)* | List all tasks with frontmatter, created_at, and current status. |
|
|
157
171
|
| `task.get` | `id` | Get a single task with frontmatter and current status. |
|
|
158
172
|
| `task.create` | `user_prompt`, `agent`, `schedule_type?`, `schedule_values?`, `schedule_enabled?`, `requires_confirmation?`, `yolo_mode?`, `foreground_mode?`, `command?` | Create a new task with auto-generated name (30s timeout for prompts > 50 chars), install system timers if a schedule is present. |
|
|
@@ -294,7 +308,7 @@ Task lifecycle status is persisted to a `status.json` file in the task directory
|
|
|
294
308
|
|
|
295
309
|
The `task.list` RPC includes each task's current status (read from `status.json`). The `task.status` RPC returns the status for a single task.
|
|
296
310
|
|
|
297
|
-
The PWA receives initial statuses from `task.list` on load. It subscribes to `host-event.<
|
|
311
|
+
The PWA receives initial statuses from `task.list` on load. It subscribes to `host-event.<hostId>.>` for live updates; on each notification it parses the `event_type` field and calls the host's `task.status` RPC to fetch the current status. Task cards display the `user_prompt` as the title (truncated to 2 lines) and a status indicator: a marching dots animation when running (`started`), a red dot for errors (`aborted` or `failed`), a gray dot when the schedule is disabled or absent, and a green dot when idle (no entry or `finished`). When the last run was successful (`finished`), a "View Result" button loads the task's result file in a popup dialog. The `specific_times` date/time picker only allows selecting future dates and times.
|
|
298
312
|
|
|
299
313
|
The Web Server subscribes to `host-event.>` and sends push notifications based on `event_type`: confirmation pushes for `confirm-request`, dismiss pushes for `confirm-resolved`, permission pushes for `permission-request`, dismiss pushes for `permission-resolved`, and report-ready/failure pushes for `report-generated` events.
|
|
300
314
|
|
|
@@ -320,7 +334,7 @@ The PWA connects to **one host at a time**. A host menu (hamburger drawer) lets
|
|
|
320
334
|
|
|
321
335
|
2. If hosts are paired, PWA fetches host-scoped NATS credentials from `GET /api/nats-credentials/<hostId>` (returns `{ natsWsUrl, natsJwt, natsNkeySeed }`) and connects to NATS via WebSocket using JWT auth. The credentials are scoped to the paired host's subjects only.
|
|
322
336
|
|
|
323
|
-
3. PWA sends a `host.info` request using NATS request-reply, including the `clientToken` in the payload. The response carries bootstrap metadata (`agents`, `host_platform`, `version`, `
|
|
337
|
+
3. PWA sends a `host.info` request using NATS request-reply, including the `clientToken` in the payload. The response carries bootstrap metadata (`agents`, `host_platform`, `version`, `linked_client_token`) and `pending_prompts` — any prompts already open on the host when the PWA connected. The Dashboard consumes this once per connection; both tabs read from it.
|
|
324
338
|
|
|
325
339
|
4. PWA lands on the Sessions tab and fetches run history via `taskrun.list`. `task.list` is not called at startup — it fires lazily the first time the user opens the Tasks tab. If either RPC fails with NATS 503 ("no responders"), the PWA shows an empty state — this is not treated as an error.
|
|
326
340
|
|
|
@@ -330,12 +344,24 @@ The PWA connects to **one host at a time**. A host menu (hamburger drawer) lets
|
|
|
330
344
|
|
|
331
345
|
### 4.2 UI Layout: Sessions & Tasks Tabs
|
|
332
346
|
|
|
333
|
-
|
|
347
|
+
All authenticated views are scoped under `/hosts/:hostId/` so the URL is the source of truth for "which host am I looking at":
|
|
348
|
+
|
|
349
|
+
- `/hosts/:hostId` — Sessions tab (default)
|
|
350
|
+
- `/hosts/:hostId/tasks` — Tasks tab
|
|
351
|
+
- `/hosts/:hostId/runs/:taskId` — latest run for a task
|
|
352
|
+
- `/hosts/:hostId/runs/:taskId/:runId` — specific run
|
|
353
|
+
- `/hosts/:hostId/pair/setup` — capability setup right after pairing (native only)
|
|
354
|
+
- `/pair` — enter a pairing code
|
|
355
|
+
- `/` — redirects to `/hosts/<firstPairedHostId>`, or to `/pair` if no hosts are paired. No "last visited" state is persisted; the URL is the source of truth for the active host.
|
|
356
|
+
|
|
357
|
+
Unknown or stale `:hostId` values redirect back to `/`. This lets notification deep links (`/hosts/:hostId/runs/...`) switch the active host automatically instead of opening a run against whatever host happens to be selected.
|
|
358
|
+
|
|
359
|
+
The PWA has two tabs: **Sessions** (default) and **Tasks** (secondary). Sessions is the primary workflow — it lists all run history across tasks (a "session" is a single run) and includes a session composer at the top of the list.
|
|
334
360
|
|
|
335
361
|
* **Session composer:** An inline textarea with an agent picker, a yolo-mode toggle, and a round play button. Entering text and clicking play dispatches `task.run_oneoff`, starting an immediate unsaved session. The composer never opens a dialog; typing is direct. When the textarea has content, navigating away (tab switch, host switch, browser reload, clicking a session row) triggers a confirmation dialog so the draft isn't lost silently.
|
|
336
362
|
* **Tasks tab:** Lists saved tasks (scheduled or reusable). A floating round `+` button in the bottom-right of the screen opens the task form, which is used only to create/edit saved or scheduled tasks — it has no Run button (run one-offs via the session composer instead). The form's primary action is "Save" (no schedule) or "Schedule" (when a schedule is configured).
|
|
337
363
|
|
|
338
|
-
Bootstrap data (agents, host version, host platform,
|
|
364
|
+
Bootstrap data (agents, host version, host platform, linked device) is fetched once per connection at the Dashboard level via `host.info` — independent of which tab is active. `task.list` is called lazily on the Tasks tab mount; `taskrun.list` is called lazily on the Sessions tab mount. Neither list RPC carries bootstrap metadata.
|
|
339
365
|
|
|
340
366
|
Dashboard owns the always-on NATS event subscription and renders pending `confirm-request` / `permission-request` / `input-request` modals via React portal, so prompts surface regardless of which tab is active. Initial pending prompts (those already open when the PWA connects) are seeded from `host.info`'s `pending_prompts` field — each entry carries the display context (`session_name`, `description`, `input_questions`) needed to render the modal cold, since the task list is no longer available at bootstrap. `session_name` is a unified label: agent name for confirm/input, task name for permission.
|
|
341
367
|
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { CONFIG_DIR } from "./config.js";
|
|
4
|
+
|
|
5
|
+
const LINKED_DEVICE_FILE = path.join(CONFIG_DIR, "linked-device.json");
|
|
6
|
+
|
|
7
|
+
export interface LinkedDevice {
|
|
8
|
+
clientToken: string;
|
|
9
|
+
fcmToken: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function read(): LinkedDevice | null {
|
|
13
|
+
try {
|
|
14
|
+
if (!fs.existsSync(LINKED_DEVICE_FILE)) return null;
|
|
15
|
+
const raw = fs.readFileSync(LINKED_DEVICE_FILE, "utf-8");
|
|
16
|
+
const parsed = JSON.parse(raw) as Partial<LinkedDevice>;
|
|
17
|
+
if (!parsed?.clientToken || !parsed?.fcmToken) return null;
|
|
18
|
+
return { clientToken: parsed.clientToken, fcmToken: parsed.fcmToken };
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function write(device: LinkedDevice | null): void {
|
|
25
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
26
|
+
if (!device) {
|
|
27
|
+
if (fs.existsSync(LINKED_DEVICE_FILE)) fs.unlinkSync(LINKED_DEVICE_FILE);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
fs.writeFileSync(LINKED_DEVICE_FILE, JSON.stringify(device, null, 2), "utf-8");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function getLinkedDevice(): LinkedDevice | null {
|
|
34
|
+
return read();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function setLinkedDevice(clientToken: string, fcmToken: string): void {
|
|
38
|
+
write({ clientToken, fcmToken });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function clearLinkedDevice(): void {
|
|
42
|
+
write(null);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function clearLinkedDeviceIfMatches(clientToken: string): boolean {
|
|
46
|
+
const current = read();
|
|
47
|
+
if (current?.clientToken === clientToken) {
|
|
48
|
+
write(null);
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
return false;
|
|
52
|
+
}
|