palmier 0.7.4 → 0.7.7
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/dist/agents/shared-prompt.js +1 -1
- package/dist/commands/init.js +3 -2
- package/dist/commands/pair.js +1 -1
- package/dist/commands/run.js +4 -4
- package/dist/commands/serve.js +1 -1
- package/dist/config.js +2 -2
- package/dist/device-capabilities.d.ts +1 -1
- package/dist/events.js +1 -1
- package/dist/mcp-tools.js +64 -1
- package/dist/nats-client.d.ts +1 -1
- package/dist/nats-client.js +6 -3
- package/dist/pwa/assets/index-Bt8Hhaw3.js +118 -0
- package/dist/pwa/assets/{web-Dc9-IiRD.js → web-CkWrlNwc.js} +1 -1
- package/dist/pwa/assets/{web-_b3Dvcvz.js → web-lx34oBi7.js} +1 -1
- package/dist/pwa/index.html +1 -1
- package/dist/pwa/service-worker.js +1 -1
- package/dist/rpc-handler.js +6 -2
- package/dist/types.d.ts +2 -1
- package/package.json +1 -1
- package/palmier-server/PRODUCTION.md +31 -28
- package/palmier-server/README.md +35 -5
- package/palmier-server/nats.conf +9 -5
- package/palmier-server/package.json +2 -1
- package/palmier-server/pnpm-lock.yaml +6 -0
- package/palmier-server/pwa/src/components/HostMenu.tsx +98 -158
- package/palmier-server/pwa/src/components/TaskListView.tsx +4 -4
- package/palmier-server/pwa/src/constants.ts +1 -1
- package/palmier-server/pwa/src/contexts/HostConnectionContext.tsx +9 -5
- package/palmier-server/pwa/src/pages/Dashboard.tsx +4 -4
- package/palmier-server/pwa/src/pages/PairHost.tsx +6 -3
- package/palmier-server/server/package.json +3 -1
- package/palmier-server/server/src/index.ts +83 -2
- package/palmier-server/server/src/nats-jwt.ts +299 -0
- package/palmier-server/server/src/nats-setup.ts +48 -0
- package/palmier-server/server/src/nats.ts +12 -4
- package/palmier-server/server/src/routes/device.ts +24 -0
- package/palmier-server/server/src/routes/hosts.ts +13 -2
- package/palmier-server/spec.md +6 -5
- package/src/agents/shared-prompt.ts +1 -1
- package/src/commands/init.ts +7 -5
- package/src/commands/pair.ts +1 -1
- package/src/commands/run.ts +4 -4
- package/src/commands/serve.ts +1 -1
- package/src/config.ts +2 -2
- package/src/device-capabilities.ts +1 -0
- package/src/events.ts +1 -1
- package/src/mcp-tools.ts +68 -1
- package/src/nats-client.ts +10 -3
- package/src/rpc-handler.ts +6 -2
- package/src/types.ts +3 -2
- package/test/agent-instructions.test.ts +10 -10
- package/dist/pwa/assets/index-BirmfPUC.js +0 -118
|
@@ -64,6 +64,15 @@ interface DndAccessPlugin {
|
|
|
64
64
|
check(): Promise<DndAccessResult>;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
interface FullScreenIntentResult {
|
|
68
|
+
granted: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface FullScreenIntentPlugin {
|
|
72
|
+
request(): Promise<FullScreenIntentResult>;
|
|
73
|
+
check(): Promise<FullScreenIntentResult>;
|
|
74
|
+
}
|
|
75
|
+
|
|
67
76
|
const NotificationListener = Capacitor.isNativePlatform()
|
|
68
77
|
? registerPlugin<NotificationListenerPlugin>("NotificationListener")
|
|
69
78
|
: null;
|
|
@@ -83,6 +92,10 @@ const CalendarPermission = Capacitor.isNativePlatform()
|
|
|
83
92
|
const DndAccess = Capacitor.isNativePlatform()
|
|
84
93
|
? registerPlugin<DndAccessPlugin>("DndAccess")
|
|
85
94
|
: null;
|
|
95
|
+
|
|
96
|
+
const FullScreenIntent = Capacitor.isNativePlatform()
|
|
97
|
+
? registerPlugin<FullScreenIntentPlugin>("FullScreenIntent")
|
|
98
|
+
: null;
|
|
86
99
|
import { useHostStore } from "../contexts/HostStoreContext";
|
|
87
100
|
import { useMediaQuery } from "../hooks/useMediaQuery";
|
|
88
101
|
|
|
@@ -92,13 +105,13 @@ const isNative = Capacitor.isNativePlatform();
|
|
|
92
105
|
|
|
93
106
|
interface HostMenuProps {
|
|
94
107
|
daemonVersion?: string | null;
|
|
95
|
-
|
|
108
|
+
capabilityTokens?: Record<string, string | null>;
|
|
96
109
|
activeClientToken?: string | null;
|
|
97
110
|
request?<T = unknown>(method: string, params?: unknown): Promise<T>;
|
|
98
|
-
|
|
111
|
+
onCapabilityTokensChange?(tokens: Record<string, string | null>): void;
|
|
99
112
|
}
|
|
100
113
|
|
|
101
|
-
export default function HostMenu({ daemonVersion,
|
|
114
|
+
export default function HostMenu({ daemonVersion, capabilityTokens, activeClientToken, request, onCapabilityTokensChange }: HostMenuProps) {
|
|
102
115
|
const { pairedHosts, activeHostId, setActiveHostId, removePairedHost, renamePairedHost } = useHostStore();
|
|
103
116
|
const navigate = useNavigate();
|
|
104
117
|
const isDesktop = useMediaQuery("(min-width: 768px)");
|
|
@@ -109,22 +122,36 @@ export default function HostMenu({ daemonVersion, locationClientToken, activeCli
|
|
|
109
122
|
const [renameValue, setRenameValue] = useState("");
|
|
110
123
|
const [confirmingDeleteId, setConfirmingDeleteId] = useState<string | null>(null);
|
|
111
124
|
const [togglingLocation, setTogglingLocation] = useState(false);
|
|
112
|
-
const [notificationListenerEnabled, setNotificationListenerEnabled] = useState(false);
|
|
113
125
|
const [togglingNotificationListener, setTogglingNotificationListener] = useState(false);
|
|
114
|
-
const [smsEnabled, setSmsEnabled] = useState(false);
|
|
115
126
|
const [togglingSms, setTogglingSms] = useState(false);
|
|
116
|
-
const [contactsEnabled, setContactsEnabled] = useState(false);
|
|
117
127
|
const [togglingContacts, setTogglingContacts] = useState(false);
|
|
118
|
-
const [calendarEnabled, setCalendarEnabled] = useState(false);
|
|
119
128
|
const [togglingCalendar, setTogglingCalendar] = useState(false);
|
|
120
|
-
const [dndEnabled, setDndEnabled] = useState(false);
|
|
121
129
|
const [togglingDnd, setTogglingDnd] = useState(false);
|
|
122
|
-
const [alarmEnabled, setAlarmEnabled] = useState(false);
|
|
123
130
|
const [togglingAlarm, setTogglingAlarm] = useState(false);
|
|
124
|
-
const [batteryEnabled, setBatteryEnabled] = useState(false);
|
|
125
131
|
const [togglingBattery, setTogglingBattery] = useState(false);
|
|
132
|
+
const [togglingEmail, setTogglingEmail] = useState(false);
|
|
126
133
|
|
|
127
|
-
|
|
134
|
+
// Capability enabled = this device's client token matches the registered device for that capability
|
|
135
|
+
function isCapEnabled(cap: string): boolean {
|
|
136
|
+
return !!(activeClientToken && capabilityTokens?.[cap] === activeClientToken);
|
|
137
|
+
}
|
|
138
|
+
const locationEnabled = isCapEnabled("location");
|
|
139
|
+
const notificationListenerEnabled = isCapEnabled("notifications");
|
|
140
|
+
const smsEnabled = isCapEnabled("sms");
|
|
141
|
+
const contactsEnabled = isCapEnabled("contacts");
|
|
142
|
+
const calendarEnabled = isCapEnabled("calendar");
|
|
143
|
+
const dndEnabled = isCapEnabled("dnd");
|
|
144
|
+
const alarmEnabled = isCapEnabled("alert");
|
|
145
|
+
const batteryEnabled = isCapEnabled("battery");
|
|
146
|
+
const emailEnabled = isCapEnabled("email");
|
|
147
|
+
|
|
148
|
+
/** Update local capability tokens state after a toggle change */
|
|
149
|
+
function setCapEnabled(cap: string, enabled: boolean) {
|
|
150
|
+
const updated: Record<string, string | null> = {};
|
|
151
|
+
for (const [k, v] of Object.entries(capabilityTokens ?? {})) updated[k] = v ?? null;
|
|
152
|
+
updated[cap] = enabled ? (activeClientToken ?? null) : null;
|
|
153
|
+
onCapabilityTokensChange?.(updated);
|
|
154
|
+
}
|
|
128
155
|
|
|
129
156
|
// Sync location toggle with permission state — on mount and when app resumes from background
|
|
130
157
|
useEffect(() => {
|
|
@@ -136,7 +163,7 @@ export default function HostMenu({ daemonVersion, locationClientToken, activeCli
|
|
|
136
163
|
if (!fine) {
|
|
137
164
|
// Permission revoked — disable on host
|
|
138
165
|
request!("device.location.disable").then(() => {
|
|
139
|
-
|
|
166
|
+
setCapEnabled("location", false);
|
|
140
167
|
}).catch(() => {});
|
|
141
168
|
}
|
|
142
169
|
});
|
|
@@ -151,29 +178,6 @@ export default function HostMenu({ daemonVersion, locationClientToken, activeCli
|
|
|
151
178
|
return () => { listener.then((h) => h.remove()); };
|
|
152
179
|
}, [locationEnabled, activeClientToken]);
|
|
153
180
|
|
|
154
|
-
// Sync notification listener toggle with system state — on mount and when app resumes
|
|
155
|
-
useEffect(() => {
|
|
156
|
-
if (!isNative || !NotificationListener) return;
|
|
157
|
-
|
|
158
|
-
function syncNotificationListenerState() {
|
|
159
|
-
Promise.all([
|
|
160
|
-
NotificationListener!.check(),
|
|
161
|
-
Preferences.get({ key: "notificationListenerEnabled" }),
|
|
162
|
-
]).then(([{ enabled: systemEnabled }, { value: prefValue }]) => {
|
|
163
|
-
// Enabled only if both system permission is granted AND user toggled on
|
|
164
|
-
setNotificationListenerEnabled(systemEnabled && prefValue !== "false");
|
|
165
|
-
});
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
syncNotificationListenerState();
|
|
169
|
-
|
|
170
|
-
const listener = CapApp.addListener("resume", () => {
|
|
171
|
-
syncNotificationListenerState();
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
return () => { listener.then((h) => h.remove()); };
|
|
175
|
-
}, []);
|
|
176
|
-
|
|
177
181
|
async function handleNotificationListenerToggle() {
|
|
178
182
|
if (!NotificationListener || !request) return;
|
|
179
183
|
setTogglingNotificationListener(true);
|
|
@@ -181,7 +185,7 @@ export default function HostMenu({ daemonVersion, locationClientToken, activeCli
|
|
|
181
185
|
if (notificationListenerEnabled) {
|
|
182
186
|
await Preferences.set({ key: "notificationListenerEnabled", value: "false" });
|
|
183
187
|
await request("device.capability.disable", { capability: "notifications" });
|
|
184
|
-
|
|
188
|
+
setCapEnabled("notifications", false);
|
|
185
189
|
} else {
|
|
186
190
|
const { enabled: systemEnabled } = await NotificationListener.check();
|
|
187
191
|
if (!systemEnabled) {
|
|
@@ -192,7 +196,7 @@ export default function HostMenu({ daemonVersion, locationClientToken, activeCli
|
|
|
192
196
|
if (!fcmToken) { console.warn("No FCM token available"); return; }
|
|
193
197
|
await Preferences.set({ key: "notificationListenerEnabled", value: "true" });
|
|
194
198
|
await request("device.capability.enable", { capability: "notifications", fcmToken });
|
|
195
|
-
|
|
199
|
+
setCapEnabled("notifications", true);
|
|
196
200
|
}
|
|
197
201
|
} catch (err) {
|
|
198
202
|
console.error("Failed to toggle notification listener:", err);
|
|
@@ -201,28 +205,6 @@ export default function HostMenu({ daemonVersion, locationClientToken, activeCli
|
|
|
201
205
|
}
|
|
202
206
|
}
|
|
203
207
|
|
|
204
|
-
// Sync SMS toggle with permission state — on mount and when app resumes
|
|
205
|
-
useEffect(() => {
|
|
206
|
-
if (!isNative || !SmsPermission) return;
|
|
207
|
-
|
|
208
|
-
function syncSmsState() {
|
|
209
|
-
Promise.all([
|
|
210
|
-
SmsPermission!.check(),
|
|
211
|
-
Preferences.get({ key: "smsListenerEnabled" }),
|
|
212
|
-
]).then(([{ granted }, { value: prefValue }]) => {
|
|
213
|
-
setSmsEnabled(granted && prefValue !== "false");
|
|
214
|
-
});
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
syncSmsState();
|
|
218
|
-
|
|
219
|
-
const listener = CapApp.addListener("resume", () => {
|
|
220
|
-
syncSmsState();
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
return () => { listener.then((h) => h.remove()); };
|
|
224
|
-
}, []);
|
|
225
|
-
|
|
226
208
|
async function handleSmsToggle() {
|
|
227
209
|
if (!SmsPermission || !request) return;
|
|
228
210
|
setTogglingSms(true);
|
|
@@ -230,7 +212,7 @@ export default function HostMenu({ daemonVersion, locationClientToken, activeCli
|
|
|
230
212
|
if (smsEnabled) {
|
|
231
213
|
await Preferences.set({ key: "smsListenerEnabled", value: "false" });
|
|
232
214
|
await request("device.capability.disable", { capability: "sms" });
|
|
233
|
-
|
|
215
|
+
setCapEnabled("sms", false);
|
|
234
216
|
} else {
|
|
235
217
|
const { granted } = await SmsPermission.check();
|
|
236
218
|
if (!granted) {
|
|
@@ -241,7 +223,7 @@ export default function HostMenu({ daemonVersion, locationClientToken, activeCli
|
|
|
241
223
|
if (!fcmToken) { console.warn("No FCM token available"); return; }
|
|
242
224
|
await Preferences.set({ key: "smsListenerEnabled", value: "true" });
|
|
243
225
|
await request("device.capability.enable", { capability: "sms", fcmToken });
|
|
244
|
-
|
|
226
|
+
setCapEnabled("sms", true);
|
|
245
227
|
}
|
|
246
228
|
} catch (err) {
|
|
247
229
|
console.error("Failed to toggle SMS access:", err);
|
|
@@ -250,28 +232,6 @@ export default function HostMenu({ daemonVersion, locationClientToken, activeCli
|
|
|
250
232
|
}
|
|
251
233
|
}
|
|
252
234
|
|
|
253
|
-
// Sync contacts toggle with permission state — on mount and when app resumes
|
|
254
|
-
useEffect(() => {
|
|
255
|
-
if (!isNative || !ContactsPermission) return;
|
|
256
|
-
|
|
257
|
-
function syncContactsState() {
|
|
258
|
-
Promise.all([
|
|
259
|
-
ContactsPermission!.check(),
|
|
260
|
-
Preferences.get({ key: "contactsAccessEnabled" }),
|
|
261
|
-
]).then(([{ granted }, { value: prefValue }]) => {
|
|
262
|
-
setContactsEnabled(granted && prefValue !== "false");
|
|
263
|
-
});
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
syncContactsState();
|
|
267
|
-
|
|
268
|
-
const listener = CapApp.addListener("resume", () => {
|
|
269
|
-
syncContactsState();
|
|
270
|
-
});
|
|
271
|
-
|
|
272
|
-
return () => { listener.then((h) => h.remove()); };
|
|
273
|
-
}, []);
|
|
274
|
-
|
|
275
235
|
async function handleContactsToggle() {
|
|
276
236
|
if (!ContactsPermission || !request) return;
|
|
277
237
|
setTogglingContacts(true);
|
|
@@ -279,7 +239,7 @@ export default function HostMenu({ daemonVersion, locationClientToken, activeCli
|
|
|
279
239
|
if (contactsEnabled) {
|
|
280
240
|
await Preferences.set({ key: "contactsAccessEnabled", value: "false" });
|
|
281
241
|
await request("device.capability.disable", { capability: "contacts" });
|
|
282
|
-
|
|
242
|
+
setCapEnabled("contacts", false);
|
|
283
243
|
} else {
|
|
284
244
|
const { granted } = await ContactsPermission.check();
|
|
285
245
|
if (!granted) {
|
|
@@ -290,7 +250,7 @@ export default function HostMenu({ daemonVersion, locationClientToken, activeCli
|
|
|
290
250
|
if (!fcmToken) { console.warn("No FCM token available"); return; }
|
|
291
251
|
await Preferences.set({ key: "contactsAccessEnabled", value: "true" });
|
|
292
252
|
await request("device.capability.enable", { capability: "contacts", fcmToken });
|
|
293
|
-
|
|
253
|
+
setCapEnabled("contacts", true);
|
|
294
254
|
}
|
|
295
255
|
} catch (err) {
|
|
296
256
|
console.error("Failed to toggle contacts access:", err);
|
|
@@ -299,28 +259,6 @@ export default function HostMenu({ daemonVersion, locationClientToken, activeCli
|
|
|
299
259
|
}
|
|
300
260
|
}
|
|
301
261
|
|
|
302
|
-
// Sync calendar toggle with permission state — on mount and when app resumes
|
|
303
|
-
useEffect(() => {
|
|
304
|
-
if (!isNative || !CalendarPermission) return;
|
|
305
|
-
|
|
306
|
-
function syncCalendarState() {
|
|
307
|
-
Promise.all([
|
|
308
|
-
CalendarPermission!.check(),
|
|
309
|
-
Preferences.get({ key: "calendarAccessEnabled" }),
|
|
310
|
-
]).then(([{ granted }, { value: prefValue }]) => {
|
|
311
|
-
setCalendarEnabled(granted && prefValue !== "false");
|
|
312
|
-
});
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
syncCalendarState();
|
|
316
|
-
|
|
317
|
-
const listener = CapApp.addListener("resume", () => {
|
|
318
|
-
syncCalendarState();
|
|
319
|
-
});
|
|
320
|
-
|
|
321
|
-
return () => { listener.then((h) => h.remove()); };
|
|
322
|
-
}, []);
|
|
323
|
-
|
|
324
262
|
async function handleCalendarToggle() {
|
|
325
263
|
if (!CalendarPermission || !request) return;
|
|
326
264
|
setTogglingCalendar(true);
|
|
@@ -328,7 +266,7 @@ export default function HostMenu({ daemonVersion, locationClientToken, activeCli
|
|
|
328
266
|
if (calendarEnabled) {
|
|
329
267
|
await Preferences.set({ key: "calendarAccessEnabled", value: "false" });
|
|
330
268
|
await request("device.capability.disable", { capability: "calendar" });
|
|
331
|
-
|
|
269
|
+
setCapEnabled("calendar", false);
|
|
332
270
|
} else {
|
|
333
271
|
const { granted } = await CalendarPermission.check();
|
|
334
272
|
if (!granted) {
|
|
@@ -339,7 +277,7 @@ export default function HostMenu({ daemonVersion, locationClientToken, activeCli
|
|
|
339
277
|
if (!fcmToken) { console.warn("No FCM token available"); return; }
|
|
340
278
|
await Preferences.set({ key: "calendarAccessEnabled", value: "true" });
|
|
341
279
|
await request("device.capability.enable", { capability: "calendar", fcmToken });
|
|
342
|
-
|
|
280
|
+
setCapEnabled("calendar", true);
|
|
343
281
|
}
|
|
344
282
|
} catch (err) {
|
|
345
283
|
console.error("Failed to toggle calendar access:", err);
|
|
@@ -348,33 +286,13 @@ export default function HostMenu({ daemonVersion, locationClientToken, activeCli
|
|
|
348
286
|
}
|
|
349
287
|
}
|
|
350
288
|
|
|
351
|
-
// Sync DND access toggle with system state — on mount and when app resumes
|
|
352
|
-
useEffect(() => {
|
|
353
|
-
if (!isNative || !DndAccess) return;
|
|
354
|
-
|
|
355
|
-
function syncDndState() {
|
|
356
|
-
DndAccess!.check().then(({ enabled }) => {
|
|
357
|
-
setDndEnabled(enabled);
|
|
358
|
-
});
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
syncDndState();
|
|
362
|
-
|
|
363
|
-
const listener = CapApp.addListener("resume", () => {
|
|
364
|
-
syncDndState();
|
|
365
|
-
});
|
|
366
|
-
|
|
367
|
-
return () => { listener.then((h) => h.remove()); };
|
|
368
|
-
}, []);
|
|
369
|
-
|
|
370
289
|
async function handleDndToggle() {
|
|
371
290
|
if (!DndAccess || !request) return;
|
|
372
291
|
setTogglingDnd(true);
|
|
373
292
|
try {
|
|
374
293
|
if (dndEnabled) {
|
|
375
|
-
// DND access can only be revoked in system settings, but we unregister from host
|
|
376
294
|
await request("device.capability.disable", { capability: "dnd" });
|
|
377
|
-
|
|
295
|
+
setCapEnabled("dnd", false);
|
|
378
296
|
} else {
|
|
379
297
|
const { enabled: systemEnabled } = await DndAccess.check();
|
|
380
298
|
if (!systemEnabled) {
|
|
@@ -384,7 +302,7 @@ export default function HostMenu({ daemonVersion, locationClientToken, activeCli
|
|
|
384
302
|
const { value: fcmToken } = await Preferences.get({ key: "fcmToken" });
|
|
385
303
|
if (!fcmToken) { console.warn("No FCM token available"); return; }
|
|
386
304
|
await request("device.capability.enable", { capability: "dnd", fcmToken });
|
|
387
|
-
|
|
305
|
+
setCapEnabled("dnd", true);
|
|
388
306
|
}
|
|
389
307
|
} catch (err) {
|
|
390
308
|
console.error("Failed to toggle DND access:", err);
|
|
@@ -393,58 +311,69 @@ export default function HostMenu({ daemonVersion, locationClientToken, activeCli
|
|
|
393
311
|
}
|
|
394
312
|
}
|
|
395
313
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
if (!
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
314
|
+
/** Ensure full-screen intent permission is granted (needed for alert + email). */
|
|
315
|
+
async function ensureFullScreenIntent(): Promise<boolean> {
|
|
316
|
+
if (!FullScreenIntent) return true;
|
|
317
|
+
const { granted } = await FullScreenIntent.check();
|
|
318
|
+
if (granted) return true;
|
|
319
|
+
const result = await FullScreenIntent.request();
|
|
320
|
+
return result.granted;
|
|
321
|
+
}
|
|
403
322
|
|
|
404
323
|
async function handleAlarmToggle() {
|
|
405
324
|
if (!request) return;
|
|
406
325
|
setTogglingAlarm(true);
|
|
407
326
|
try {
|
|
408
327
|
if (alarmEnabled) {
|
|
409
|
-
await Preferences.set({ key: "alertAccessEnabled", value: "false" });
|
|
410
328
|
await request("device.capability.disable", { capability: "alert" });
|
|
411
|
-
|
|
329
|
+
setCapEnabled("alert", false);
|
|
412
330
|
} else {
|
|
331
|
+
if (!await ensureFullScreenIntent()) return;
|
|
413
332
|
const { value: fcmToken } = await Preferences.get({ key: "fcmToken" });
|
|
414
333
|
if (!fcmToken) { console.warn("No FCM token available"); return; }
|
|
415
|
-
await Preferences.set({ key: "alertAccessEnabled", value: "true" });
|
|
416
334
|
await request("device.capability.enable", { capability: "alert", fcmToken });
|
|
417
|
-
|
|
335
|
+
setCapEnabled("alert", true);
|
|
418
336
|
}
|
|
419
337
|
} catch (err) {
|
|
420
|
-
console.error("Failed to toggle
|
|
338
|
+
console.error("Failed to toggle alert access:", err);
|
|
421
339
|
} finally {
|
|
422
340
|
setTogglingAlarm(false);
|
|
423
341
|
}
|
|
424
342
|
}
|
|
425
343
|
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
344
|
+
async function handleEmailToggle() {
|
|
345
|
+
if (!request) return;
|
|
346
|
+
setTogglingEmail(true);
|
|
347
|
+
try {
|
|
348
|
+
if (emailEnabled) {
|
|
349
|
+
await request("device.capability.disable", { capability: "email" });
|
|
350
|
+
setCapEnabled("email", false);
|
|
351
|
+
} else {
|
|
352
|
+
if (!await ensureFullScreenIntent()) return;
|
|
353
|
+
const { value: fcmToken } = await Preferences.get({ key: "fcmToken" });
|
|
354
|
+
if (!fcmToken) { console.warn("No FCM token available"); return; }
|
|
355
|
+
await request("device.capability.enable", { capability: "email", fcmToken });
|
|
356
|
+
setCapEnabled("email", true);
|
|
357
|
+
}
|
|
358
|
+
} catch (err) {
|
|
359
|
+
console.error("Failed to toggle email access:", err);
|
|
360
|
+
} finally {
|
|
361
|
+
setTogglingEmail(false);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
433
364
|
|
|
434
365
|
async function handleBatteryToggle() {
|
|
435
366
|
if (!request) return;
|
|
436
367
|
setTogglingBattery(true);
|
|
437
368
|
try {
|
|
438
369
|
if (batteryEnabled) {
|
|
439
|
-
await Preferences.set({ key: "batteryAccessEnabled", value: "false" });
|
|
440
370
|
await request("device.capability.disable", { capability: "battery" });
|
|
441
|
-
|
|
371
|
+
setCapEnabled("battery", false);
|
|
442
372
|
} else {
|
|
443
373
|
const { value: fcmToken } = await Preferences.get({ key: "fcmToken" });
|
|
444
374
|
if (!fcmToken) { console.warn("No FCM token available"); return; }
|
|
445
|
-
await Preferences.set({ key: "batteryAccessEnabled", value: "true" });
|
|
446
375
|
await request("device.capability.enable", { capability: "battery", fcmToken });
|
|
447
|
-
|
|
376
|
+
setCapEnabled("battery", true);
|
|
448
377
|
}
|
|
449
378
|
} catch (err) {
|
|
450
379
|
console.error("Failed to toggle battery access:", err);
|
|
@@ -459,13 +388,12 @@ export default function HostMenu({ daemonVersion, locationClientToken, activeCli
|
|
|
459
388
|
try {
|
|
460
389
|
if (locationEnabled) {
|
|
461
390
|
await request("device.location.disable");
|
|
462
|
-
|
|
391
|
+
setCapEnabled("location", false);
|
|
463
392
|
} else {
|
|
464
|
-
// Request location permissions before enabling
|
|
465
393
|
if (LocationPermission) {
|
|
466
394
|
const result = await LocationPermission.request();
|
|
467
395
|
if (!result.fine) {
|
|
468
|
-
return;
|
|
396
|
+
return;
|
|
469
397
|
}
|
|
470
398
|
}
|
|
471
399
|
|
|
@@ -475,7 +403,7 @@ export default function HostMenu({ daemonVersion, locationClientToken, activeCli
|
|
|
475
403
|
return;
|
|
476
404
|
}
|
|
477
405
|
await request("device.location.enable", { fcmToken });
|
|
478
|
-
|
|
406
|
+
setCapEnabled("location", true);
|
|
479
407
|
}
|
|
480
408
|
} catch (err) {
|
|
481
409
|
console.error("Failed to toggle location:", err);
|
|
@@ -759,6 +687,18 @@ export default function HostMenu({ daemonVersion, locationClientToken, activeCli
|
|
|
759
687
|
<span className="toggle-switch-thumb" />
|
|
760
688
|
</button>
|
|
761
689
|
</label>
|
|
690
|
+
<label className="drawer-toggle">
|
|
691
|
+
<span className="drawer-toggle-label">Email Access</span>
|
|
692
|
+
<button
|
|
693
|
+
className={`toggle-switch ${emailEnabled ? "toggle-switch-on" : ""}`}
|
|
694
|
+
onClick={handleEmailToggle}
|
|
695
|
+
disabled={togglingEmail}
|
|
696
|
+
role="switch"
|
|
697
|
+
aria-checked={emailEnabled}
|
|
698
|
+
>
|
|
699
|
+
<span className="toggle-switch-thumb" />
|
|
700
|
+
</button>
|
|
701
|
+
</label>
|
|
762
702
|
</div>
|
|
763
703
|
</>
|
|
764
704
|
)}
|
|
@@ -26,10 +26,10 @@ interface TaskListViewProps {
|
|
|
26
26
|
onViewRun(taskId: string, runId?: string): void;
|
|
27
27
|
onUpdateRequired?(required: boolean): void;
|
|
28
28
|
onVersion?(version: string | null): void;
|
|
29
|
-
|
|
29
|
+
onCapabilityTokens?(tokens: Record<string, string | null>): void;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
export default function TaskListView({ connected, hostId, request, subscribeEvents, onViewRun, onUpdateRequired, onVersion,
|
|
32
|
+
export default function TaskListView({ connected, hostId, request, subscribeEvents, onViewRun, onUpdateRequired, onVersion, onCapabilityTokens }: TaskListViewProps) {
|
|
33
33
|
const [tasks, setTasks] = useState<Task[]>([]);
|
|
34
34
|
const [loadingTasks, setLoadingTasks] = useState(false);
|
|
35
35
|
const [taskError, setTaskError] = useState<string | null>(null);
|
|
@@ -53,7 +53,7 @@ export default function TaskListView({ connected, hostId, request, subscribeEven
|
|
|
53
53
|
setLoadingTasks(true);
|
|
54
54
|
setTaskError(null);
|
|
55
55
|
try {
|
|
56
|
-
const result = await request<{ tasks?: (Task & { status?: TaskStatus })[]; agents?: AgentInfo[]; version?: string | null; host_platform?: string; location_client_token?: string | null }>("task.list");
|
|
56
|
+
const result = await request<{ tasks?: (Task & { status?: TaskStatus })[]; agents?: AgentInfo[]; version?: string | null; host_platform?: string; location_client_token?: string | null; capability_tokens?: Record<string, string | null> }>("task.list");
|
|
57
57
|
const taskList = result.tasks ?? [];
|
|
58
58
|
const initialEvents = new Map<string, TaskStatus>();
|
|
59
59
|
const initialConfirms = new Map<string, { description: string; agentName?: string }>();
|
|
@@ -82,7 +82,7 @@ export default function TaskListView({ connected, hostId, request, subscribeEven
|
|
|
82
82
|
setAgentLabels(result.agents ?? []);
|
|
83
83
|
const version = result.version ?? null;
|
|
84
84
|
onVersion?.(version);
|
|
85
|
-
|
|
85
|
+
onCapabilityTokens?.(result.capability_tokens ?? {});
|
|
86
86
|
onUpdateRequired?.(!!version && isOlderThan(version, MIN_HOST_VERSION));
|
|
87
87
|
} catch (err) {
|
|
88
88
|
const errMsg = err instanceof Error ? err.message : String(err);
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Bump when a breaking host change is made. */
|
|
2
|
-
export const MIN_HOST_VERSION = "0.7.
|
|
2
|
+
export const MIN_HOST_VERSION = "0.7.6";
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
useCallback,
|
|
8
8
|
type ReactNode,
|
|
9
9
|
} from "react";
|
|
10
|
-
import { connect, StringCodec, type NatsConnection, type Subscription } from "nats.ws";
|
|
10
|
+
import { connect, jwtAuthenticator, StringCodec, type NatsConnection, type Subscription } from "nats.ws";
|
|
11
11
|
import { SERVER_URL } from "../api";
|
|
12
12
|
import { useHostStore } from "./HostStoreContext";
|
|
13
13
|
import type { PairedHost } from "../types";
|
|
@@ -90,12 +90,13 @@ export function HostConnectionProvider({ children }: { children: ReactNode }) {
|
|
|
90
90
|
|
|
91
91
|
async function init() {
|
|
92
92
|
try {
|
|
93
|
-
|
|
93
|
+
// Fetch host-scoped NATS credentials (can only access this host's subjects)
|
|
94
|
+
const res = await fetch(`${SERVER_URL}/api/nats-credentials/${activeHost!.hostId}`);
|
|
94
95
|
if (!res.ok) {
|
|
95
|
-
console.error("[NATS] Failed to fetch
|
|
96
|
+
console.error("[NATS] Failed to fetch credentials");
|
|
96
97
|
return;
|
|
97
98
|
}
|
|
98
|
-
const config = await res.json() as { natsWsUrl: string;
|
|
99
|
+
const config = await res.json() as { natsWsUrl: string; natsJwt: string; natsNkeySeed: string };
|
|
99
100
|
if (!config.natsWsUrl) {
|
|
100
101
|
console.warn("[NATS] No WebSocket URL configured");
|
|
101
102
|
return;
|
|
@@ -105,7 +106,10 @@ export function HostConnectionProvider({ children }: { children: ReactNode }) {
|
|
|
105
106
|
console.log("[NATS] Connecting to", config.natsWsUrl);
|
|
106
107
|
const conn = await connect({
|
|
107
108
|
servers: config.natsWsUrl,
|
|
108
|
-
|
|
109
|
+
authenticator: jwtAuthenticator(
|
|
110
|
+
config.natsJwt,
|
|
111
|
+
new TextEncoder().encode(config.natsNkeySeed),
|
|
112
|
+
),
|
|
109
113
|
});
|
|
110
114
|
if (cancelled) { conn.close().catch(() => {}); return; }
|
|
111
115
|
console.log("[NATS] Connected");
|
|
@@ -47,7 +47,7 @@ export default function Dashboard() {
|
|
|
47
47
|
const [updating, setUpdating] = useState(false);
|
|
48
48
|
const [updateError, setUpdateError] = useState<string | null>(null);
|
|
49
49
|
const [daemonVersion, setDaemonVersion] = useState<string | null>(null);
|
|
50
|
-
const [
|
|
50
|
+
const [capabilityTokens, setCapabilityTokens] = useState<Record<string, string | null>>({});
|
|
51
51
|
|
|
52
52
|
// Register push subscription for the active host
|
|
53
53
|
usePushSubscription();
|
|
@@ -88,11 +88,11 @@ export default function Dashboard() {
|
|
|
88
88
|
|
|
89
89
|
return (
|
|
90
90
|
<div className="dashboard">
|
|
91
|
-
{isDesktop && <HostMenu daemonVersion={daemonVersion}
|
|
91
|
+
{isDesktop && <HostMenu daemonVersion={daemonVersion} capabilityTokens={capabilityTokens} activeClientToken={activeClientToken} request={request} onCapabilityTokensChange={setCapabilityTokens} />}
|
|
92
92
|
|
|
93
93
|
<div className="dashboard-content">
|
|
94
94
|
<div className="tab-bar">
|
|
95
|
-
{!isDesktop && <HostMenu daemonVersion={daemonVersion}
|
|
95
|
+
{!isDesktop && <HostMenu daemonVersion={daemonVersion} capabilityTokens={capabilityTokens} activeClientToken={activeClientToken} request={request} onCapabilityTokensChange={setCapabilityTokens} />}
|
|
96
96
|
<TabBar />
|
|
97
97
|
</div>
|
|
98
98
|
|
|
@@ -141,7 +141,7 @@ export default function Dashboard() {
|
|
|
141
141
|
onViewRun={handleViewRun}
|
|
142
142
|
onUpdateRequired={setUpdateRequired}
|
|
143
143
|
onVersion={setDaemonVersion}
|
|
144
|
-
|
|
144
|
+
onCapabilityTokens={setCapabilityTokens}
|
|
145
145
|
/>
|
|
146
146
|
</div>
|
|
147
147
|
{isRunDetail ? (
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useState } from "react";
|
|
2
2
|
import { useNavigate } from "react-router-dom";
|
|
3
|
-
import { connect, StringCodec } from "nats.ws";
|
|
3
|
+
import { connect, jwtAuthenticator, StringCodec } from "nats.ws";
|
|
4
4
|
import { Capacitor } from "@capacitor/core";
|
|
5
5
|
import { Preferences } from "@capacitor/preferences";
|
|
6
6
|
import { useHostStore } from "../contexts/HostStoreContext";
|
|
@@ -54,12 +54,15 @@ export default function PairHost() {
|
|
|
54
54
|
// Server mode — pair via NATS
|
|
55
55
|
const configRes = await fetch(`${SERVER_URL}/api/config`);
|
|
56
56
|
if (!configRes.ok) throw new Error("Failed to fetch server config");
|
|
57
|
-
const config = await configRes.json() as { natsWsUrl: string;
|
|
57
|
+
const config = await configRes.json() as { natsWsUrl: string; natsJwt: string; natsNkeySeed: string };
|
|
58
58
|
if (!config.natsWsUrl) throw new Error("Server has no NATS WebSocket URL configured");
|
|
59
59
|
|
|
60
60
|
const nc = await connect({
|
|
61
61
|
servers: config.natsWsUrl,
|
|
62
|
-
|
|
62
|
+
authenticator: jwtAuthenticator(
|
|
63
|
+
config.natsJwt,
|
|
64
|
+
new TextEncoder().encode(config.natsNkeySeed),
|
|
65
|
+
),
|
|
63
66
|
});
|
|
64
67
|
|
|
65
68
|
const sc = StringCodec();
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
"scripts": {
|
|
6
6
|
"dev": "tsx watch src/index.ts",
|
|
7
7
|
"build": "tsc",
|
|
8
|
-
"start": "node dist/index.js"
|
|
8
|
+
"start": "node dist/index.js",
|
|
9
|
+
"nats-setup": "tsx src/nats-setup.ts"
|
|
9
10
|
},
|
|
10
11
|
"dependencies": {
|
|
11
12
|
"bcrypt": "^5.1.1",
|
|
@@ -16,6 +17,7 @@
|
|
|
16
17
|
"helmet": "^8.0.0",
|
|
17
18
|
"jsonwebtoken": "^9.0.2",
|
|
18
19
|
"nats": "^2.29.1",
|
|
20
|
+
"nkeys.js": "^1.1.0",
|
|
19
21
|
"pg": "^8.13.1",
|
|
20
22
|
"uuid": "^11.0.5",
|
|
21
23
|
"web-push": "^3.6.7"
|