palmier 0.7.9 → 0.8.1

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/dist/commands/run.js +55 -0
  2. package/dist/commands/serve.js +22 -2
  3. package/dist/device-capabilities.d.ts +1 -1
  4. package/dist/event-queues.d.ts +36 -0
  5. package/dist/event-queues.js +53 -0
  6. package/dist/mcp-tools.js +2 -2
  7. package/dist/platform/windows.js +5 -2
  8. package/dist/pwa/assets/index-CQxcuDhM.css +1 -0
  9. package/dist/pwa/assets/index-DQfOEB03.js +120 -0
  10. package/dist/pwa/assets/{web-CF-N8Di6.js → web-D7Kq3Nvk.js} +1 -1
  11. package/dist/pwa/assets/{web-BpM3fNCn.js → web-DOyOiwsW.js} +1 -1
  12. package/dist/pwa/index.html +2 -2
  13. package/dist/pwa/service-worker.js +1 -1
  14. package/dist/rpc-handler.js +6 -5
  15. package/dist/transports/http-transport.js +15 -0
  16. package/dist/types.d.ts +6 -5
  17. package/package.json +1 -1
  18. package/palmier-server/README.md +1 -1
  19. package/palmier-server/pwa/src/App.css +5 -0
  20. package/palmier-server/pwa/src/App.tsx +15 -1
  21. package/palmier-server/pwa/src/components/HostMenu.tsx +155 -456
  22. package/palmier-server/pwa/src/components/SessionsView.tsx +9 -3
  23. package/palmier-server/pwa/src/components/TaskCard.tsx +12 -4
  24. package/palmier-server/pwa/src/components/TaskForm.tsx +79 -32
  25. package/palmier-server/pwa/src/components/TasksView.tsx +5 -0
  26. package/palmier-server/pwa/src/constants.ts +1 -1
  27. package/palmier-server/pwa/src/native/Device.ts +48 -0
  28. package/palmier-server/pwa/src/pages/PairHost.tsx +18 -2
  29. package/palmier-server/pwa/src/types.ts +1 -1
  30. package/palmier-server/spec.md +12 -2
  31. package/src/commands/run.ts +61 -0
  32. package/src/commands/serve.ts +22 -2
  33. package/src/device-capabilities.ts +1 -1
  34. package/src/event-queues.ts +56 -0
  35. package/src/mcp-tools.ts +2 -2
  36. package/src/platform/windows.ts +5 -2
  37. package/src/rpc-handler.ts +8 -7
  38. package/src/transports/http-transport.ts +14 -0
  39. package/src/types.ts +6 -5
  40. package/dist/pwa/assets/index-FP1Mipr6.js +0 -120
  41. package/dist/pwa/assets/index-bLTn8zBj.css +0 -1
@@ -1,101 +1,9 @@
1
1
  import { useState, useEffect, useRef, useCallback } from "react";
2
2
  import { createPortal } from "react-dom";
3
3
  import { useNavigate } from "react-router-dom";
4
- import { Capacitor, registerPlugin } from "@capacitor/core";
5
- import { App as CapApp } from "@capacitor/app";
6
- import { Preferences } from "@capacitor/preferences";
7
-
8
- interface LocationPermissionResult {
9
- fine: boolean;
10
- background: boolean;
11
- }
12
-
13
- interface LocationPermissionPlugin {
14
- request(): Promise<LocationPermissionResult>;
15
- check(): Promise<LocationPermissionResult>;
16
- }
17
-
18
- interface NotificationListenerResult {
19
- enabled: boolean;
20
- }
21
-
22
- interface NotificationListenerPlugin {
23
- request(): Promise<NotificationListenerResult>;
24
- check(): Promise<NotificationListenerResult>;
25
- }
26
-
27
- const LocationPermission = Capacitor.isNativePlatform()
28
- ? registerPlugin<LocationPermissionPlugin>("LocationPermission")
29
- : null;
30
-
31
- interface SmsPermissionResult {
32
- granted: boolean;
33
- }
34
-
35
- interface SmsPermissionPlugin {
36
- request(): Promise<SmsPermissionResult>;
37
- check(): Promise<SmsPermissionResult>;
38
- }
39
-
40
- interface ContactsPermissionResult {
41
- granted: boolean;
42
- }
43
-
44
- interface ContactsPermissionPlugin {
45
- request(): Promise<ContactsPermissionResult>;
46
- check(): Promise<ContactsPermissionResult>;
47
- }
48
-
49
- interface CalendarPermissionResult {
50
- granted: boolean;
51
- }
52
-
53
- interface CalendarPermissionPlugin {
54
- request(): Promise<CalendarPermissionResult>;
55
- check(): Promise<CalendarPermissionResult>;
56
- }
57
-
58
- interface DndAccessResult {
59
- enabled: boolean;
60
- }
61
-
62
- interface DndAccessPlugin {
63
- request(): Promise<DndAccessResult>;
64
- check(): Promise<DndAccessResult>;
65
- }
66
-
67
- interface FullScreenIntentResult {
68
- granted: boolean;
69
- }
70
-
71
- interface FullScreenIntentPlugin {
72
- request(): Promise<FullScreenIntentResult>;
73
- check(): Promise<FullScreenIntentResult>;
74
- }
75
-
76
- const NotificationListener = Capacitor.isNativePlatform()
77
- ? registerPlugin<NotificationListenerPlugin>("NotificationListener")
78
- : null;
79
-
80
- const SmsPermission = Capacitor.isNativePlatform()
81
- ? registerPlugin<SmsPermissionPlugin>("SmsPermission")
82
- : null;
83
-
84
- const ContactsPermission = Capacitor.isNativePlatform()
85
- ? registerPlugin<ContactsPermissionPlugin>("ContactsPermission")
86
- : null;
87
-
88
- const CalendarPermission = Capacitor.isNativePlatform()
89
- ? registerPlugin<CalendarPermissionPlugin>("CalendarPermission")
90
- : null;
91
-
92
- const DndAccess = Capacitor.isNativePlatform()
93
- ? registerPlugin<DndAccessPlugin>("DndAccess")
94
- : null;
95
-
96
- const FullScreenIntent = Capacitor.isNativePlatform()
97
- ? registerPlugin<FullScreenIntentPlugin>("FullScreenIntent")
98
- : null;
4
+ import { Capacitor } from "@capacitor/core";
5
+ import { App as CapacitorApp } from "@capacitor/app";
6
+ import { Device, type PermissionType } from "../native/Device";
99
7
  import { useHostStore } from "../contexts/HostStoreContext";
100
8
  import { useMediaQuery } from "../hooks/useMediaQuery";
101
9
  import { confirmLeaveDraft } from "../draftGuard";
@@ -104,6 +12,42 @@ import { confirmLeaveDraft } from "../draftGuard";
104
12
  const isLanMode = !!(window as any).__PALMIER_SERVE__;
105
13
  const isNative = Capacitor.isNativePlatform();
106
14
 
15
+ interface CapabilityDefinition {
16
+ /** Server-side capability name used in device.capability.{enable,disable} RPCs. */
17
+ capability: string;
18
+ /** Label shown in the drawer toggle. */
19
+ label: string;
20
+ /** Runtime or settings permission to request before enabling. */
21
+ permission?: PermissionType;
22
+ /** True for capabilities that display full-screen alerts (alert, send-email). */
23
+ needsFullScreenIntent?: boolean;
24
+ /** Override RPC methods; location uses device.location.{enable,disable} instead. */
25
+ enableMethod?: string;
26
+ disableMethod?: string;
27
+ enableParams?(fcmToken: string): Record<string, unknown>;
28
+ disableParams?(): Record<string, unknown>;
29
+ }
30
+
31
+ const CAPABILITIES: CapabilityDefinition[] = [
32
+ {
33
+ capability: "location",
34
+ label: "Location Access",
35
+ permission: "location",
36
+ enableMethod: "device.location.enable",
37
+ disableMethod: "device.location.disable",
38
+ enableParams: (fcmToken) => ({ fcmToken }),
39
+ disableParams: () => ({}),
40
+ },
41
+ { capability: "notifications", label: "Notification Access", permission: "notificationListener" },
42
+ { capability: "sms", label: "SMS Access", permission: "sms" },
43
+ { capability: "contacts", label: "Contacts Access", permission: "contacts" },
44
+ { capability: "calendar", label: "Calendar Access", permission: "calendar" },
45
+ { capability: "dnd", label: "Do Not Disturb Control", permission: "dnd" },
46
+ { capability: "alert", label: "Alert Access", needsFullScreenIntent: true },
47
+ { capability: "battery", label: "Battery Access" },
48
+ { capability: "send-email", label: "Email Drafting", needsFullScreenIntent: true },
49
+ ];
50
+
107
51
  interface HostMenuProps {
108
52
  daemonVersion?: string | null;
109
53
  capabilityTokens?: Record<string, string | null>;
@@ -122,294 +66,140 @@ export default function HostMenu({ daemonVersion, capabilityTokens, activeClient
122
66
  const [renamingId, setRenamingId] = useState<string | null>(null);
123
67
  const [renameValue, setRenameValue] = useState("");
124
68
  const [confirmingDeleteId, setConfirmingDeleteId] = useState<string | null>(null);
125
- const [togglingLocation, setTogglingLocation] = useState(false);
126
- const [togglingNotificationListener, setTogglingNotificationListener] = useState(false);
127
- const [togglingSms, setTogglingSms] = useState(false);
128
- const [togglingContacts, setTogglingContacts] = useState(false);
129
- const [togglingCalendar, setTogglingCalendar] = useState(false);
130
- const [togglingDnd, setTogglingDnd] = useState(false);
131
- const [togglingAlarm, setTogglingAlarm] = useState(false);
132
- const [togglingBattery, setTogglingBattery] = useState(false);
133
- const [togglingEmail, setTogglingEmail] = useState(false);
69
+ const [togglingCapability, setTogglingCapability] = useState<string | null>(null);
70
+ /**
71
+ * Permission types the installed APK understands. Null while loading; an empty
72
+ * set means the native plugin doesn't expose a discovery method (pre-Device
73
+ * plugin build) in that case we don't pre-filter the UI and rely on per-call
74
+ * {supported: false} from the native side as the fallback.
75
+ */
76
+ const [supportedPerms, setSupportedPerms] = useState<Set<PermissionType> | null>(null);
77
+
78
+ useEffect(() => {
79
+ if (!isNative || !Device) {
80
+ setSupportedPerms(new Set());
81
+ return;
82
+ }
83
+ Device.getSupportedPermissions()
84
+ .then(({ types }) => setSupportedPerms(new Set(types)))
85
+ .catch(() => setSupportedPerms(new Set())); // old APK: fall back to per-call supported flag
86
+ }, []);
134
87
 
135
88
  // Capability enabled = this device's client token matches the registered device for that capability
136
- function isCapEnabled(cap: string): boolean {
137
- return !!(activeClientToken && capabilityTokens?.[cap] === activeClientToken);
89
+ function isCapabilityEnabled(capability: string): boolean {
90
+ return !!(activeClientToken && capabilityTokens?.[capability] === activeClientToken);
91
+ }
92
+
93
+ /**
94
+ * A capability is shown when native either explicitly supports its permission, or
95
+ * can't advertise support (empty set = old APK or web) — in the latter case the
96
+ * toggle still works because the per-call `supported` flag guards at tap time.
97
+ */
98
+ function isCapabilityVisible(definition: CapabilityDefinition): boolean {
99
+ if (!supportedPerms) return false;
100
+ if (supportedPerms.size === 0) return true;
101
+ if (definition.permission && !supportedPerms.has(definition.permission)) return false;
102
+ if (definition.needsFullScreenIntent && !supportedPerms.has("fullScreenIntent")) return false;
103
+ return true;
138
104
  }
139
- const locationEnabled = isCapEnabled("location");
140
- const notificationListenerEnabled = isCapEnabled("notifications");
141
- const smsEnabled = isCapEnabled("sms");
142
- const contactsEnabled = isCapEnabled("contacts");
143
- const calendarEnabled = isCapEnabled("calendar");
144
- const dndEnabled = isCapEnabled("dnd");
145
- const alarmEnabled = isCapEnabled("alert");
146
- const batteryEnabled = isCapEnabled("battery");
147
- const emailEnabled = isCapEnabled("email");
148
105
 
149
106
  /** Update local capability tokens state after a toggle change */
150
- function setCapEnabled(cap: string, enabled: boolean) {
107
+ function setCapabilityEnabled(capability: string, enabled: boolean) {
151
108
  const updated: Record<string, string | null> = {};
152
109
  for (const [k, v] of Object.entries(capabilityTokens ?? {})) updated[k] = v ?? null;
153
- updated[cap] = enabled ? (activeClientToken ?? null) : null;
110
+ updated[capability] = enabled ? (activeClientToken ?? null) : null;
154
111
  onCapabilityTokensChange?.(updated);
155
112
  }
156
113
 
157
- // Sync location toggle with permission state on mount and when app resumes from background
114
+ // If the OS location permission is revoked while the app is backgrounded,
115
+ // disable the capability on the host so agents don't keep pinging for fixes.
158
116
  useEffect(() => {
159
- if (!isNative || !LocationPermission || !request) return;
117
+ if (!isNative || !Device || !request) return;
118
+
119
+ const locationEnabled = isCapabilityEnabled("location");
120
+ if (!locationEnabled) return;
160
121
 
161
122
  function syncPermissionState() {
162
- if (!locationEnabled) return;
163
- LocationPermission!.check().then(({ fine }) => {
164
- if (!fine) {
165
- // Permission revoked — disable on host
123
+ Device!.checkPermission({ type: "location" }).then(({ granted }) => {
124
+ if (!granted) {
166
125
  request!("device.location.disable").then(() => {
167
- setCapEnabled("location", false);
126
+ setCapabilityEnabled("location", false);
168
127
  }).catch(() => {});
169
128
  }
170
129
  });
171
130
  }
172
131
 
173
132
  syncPermissionState();
174
-
175
- const listener = CapApp.addListener("resume", () => {
176
- syncPermissionState();
177
- });
178
-
133
+ const listener = CapacitorApp.addListener("resume", syncPermissionState);
179
134
  return () => { listener.then((h) => h.remove()); };
180
- }, [locationEnabled, activeClientToken]);
135
+ }, [capabilityTokens, activeClientToken]);
181
136
 
182
- async function handleNotificationListenerToggle() {
183
- if (!NotificationListener || !request) return;
184
- setTogglingNotificationListener(true);
137
+ // Mirror the server-derived enabled set into native as the local kill-switch.
138
+ // Toggling below writes through immediately; this useEffect catches host-initiated
139
+ // changes (e.g. a capability revoked on another device) on the next render.
140
+ useEffect(() => {
141
+ if (!isNative || !Device) return;
142
+ const enabledCapabilities = CAPABILITIES
143
+ .map((definition) => definition.capability)
144
+ .filter((capability) => capabilityTokens?.[capability] === activeClientToken);
145
+ Device.setEnabledCapabilities({ capabilities: enabledCapabilities }).catch(() => {});
146
+ }, [capabilityTokens, activeClientToken]);
147
+
148
+ async function toggleCapability(definition: CapabilityDefinition) {
149
+ if (!request) return;
150
+ const enabled = isCapabilityEnabled(definition.capability);
151
+ setTogglingCapability(definition.capability);
185
152
  try {
186
- if (notificationListenerEnabled) {
187
- await Preferences.set({ key: "notificationListenerEnabled", value: "false" });
188
- await request("device.capability.disable", { capability: "notifications" });
189
- setCapEnabled("notifications", false);
190
- } else {
191
- const { enabled: systemEnabled } = await NotificationListener.check();
192
- if (!systemEnabled) {
193
- const result = await NotificationListener.request();
194
- if (!result.enabled) return;
195
- }
196
- const { value: fcmToken } = await Preferences.get({ key: "fcmToken" });
197
- if (!fcmToken) { console.warn("No FCM token available"); return; }
198
- await Preferences.set({ key: "notificationListenerEnabled", value: "true" });
199
- await request("device.capability.enable", { capability: "notifications", fcmToken });
200
- setCapEnabled("notifications", true);
153
+ if (enabled) {
154
+ const method = definition.disableMethod ?? "device.capability.disable";
155
+ const params = definition.disableParams?.() ?? { capability: definition.capability };
156
+ await request(method, params);
157
+ setCapabilityEnabled(definition.capability, false);
158
+ return;
201
159
  }
202
- } catch (err) {
203
- console.error("Failed to toggle notification listener:", err);
204
- } finally {
205
- setTogglingNotificationListener(false);
206
- }
207
- }
208
160
 
209
- async function handleSmsToggle() {
210
- if (!SmsPermission || !request) return;
211
- setTogglingSms(true);
212
- try {
213
- if (smsEnabled) {
214
- await Preferences.set({ key: "smsListenerEnabled", value: "false" });
215
- await request("device.capability.disable", { capability: "sms" });
216
- setCapEnabled("sms", false);
217
- } else {
218
- const { granted } = await SmsPermission.check();
219
- if (!granted) {
220
- const result = await SmsPermission.request();
221
- if (!result.granted) return;
161
+ if (Device && definition.permission) {
162
+ const check = await Device.checkPermission({ type: definition.permission });
163
+ if (!check.supported) {
164
+ console.warn(`Native build does not support permission '${definition.permission}'`);
165
+ return;
222
166
  }
223
- const { value: fcmToken } = await Preferences.get({ key: "fcmToken" });
224
- if (!fcmToken) { console.warn("No FCM token available"); return; }
225
- await Preferences.set({ key: "smsListenerEnabled", value: "true" });
226
- await request("device.capability.enable", { capability: "sms", fcmToken });
227
- setCapEnabled("sms", true);
228
- }
229
- } catch (err) {
230
- console.error("Failed to toggle SMS access:", err);
231
- } finally {
232
- setTogglingSms(false);
233
- }
234
- }
235
-
236
- async function handleContactsToggle() {
237
- if (!ContactsPermission || !request) return;
238
- setTogglingContacts(true);
239
- try {
240
- if (contactsEnabled) {
241
- await Preferences.set({ key: "contactsAccessEnabled", value: "false" });
242
- await request("device.capability.disable", { capability: "contacts" });
243
- setCapEnabled("contacts", false);
244
- } else {
245
- const { granted } = await ContactsPermission.check();
246
- if (!granted) {
247
- const result = await ContactsPermission.request();
167
+ if (!check.granted) {
168
+ const result = await Device.requestPermission({ type: definition.permission });
248
169
  if (!result.granted) return;
249
170
  }
250
- const { value: fcmToken } = await Preferences.get({ key: "fcmToken" });
251
- if (!fcmToken) { console.warn("No FCM token available"); return; }
252
- await Preferences.set({ key: "contactsAccessEnabled", value: "true" });
253
- await request("device.capability.enable", { capability: "contacts", fcmToken });
254
- setCapEnabled("contacts", true);
255
171
  }
256
- } catch (err) {
257
- console.error("Failed to toggle contacts access:", err);
258
- } finally {
259
- setTogglingContacts(false);
260
- }
261
- }
262
-
263
- async function handleCalendarToggle() {
264
- if (!CalendarPermission || !request) return;
265
- setTogglingCalendar(true);
266
- try {
267
- if (calendarEnabled) {
268
- await Preferences.set({ key: "calendarAccessEnabled", value: "false" });
269
- await request("device.capability.disable", { capability: "calendar" });
270
- setCapEnabled("calendar", false);
271
- } else {
272
- const { granted } = await CalendarPermission.check();
273
- if (!granted) {
274
- const result = await CalendarPermission.request();
275
- if (!result.granted) return;
172
+ if (Device && definition.needsFullScreenIntent) {
173
+ const check = await Device.checkPermission({ type: "fullScreenIntent" });
174
+ if (!check.supported) {
175
+ console.warn("Native build does not support fullScreenIntent");
176
+ return;
276
177
  }
277
- const { value: fcmToken } = await Preferences.get({ key: "fcmToken" });
278
- if (!fcmToken) { console.warn("No FCM token available"); return; }
279
- await Preferences.set({ key: "calendarAccessEnabled", value: "true" });
280
- await request("device.capability.enable", { capability: "calendar", fcmToken });
281
- setCapEnabled("calendar", true);
282
- }
283
- } catch (err) {
284
- console.error("Failed to toggle calendar access:", err);
285
- } finally {
286
- setTogglingCalendar(false);
287
- }
288
- }
289
-
290
- async function handleDndToggle() {
291
- if (!DndAccess || !request) return;
292
- setTogglingDnd(true);
293
- try {
294
- if (dndEnabled) {
295
- await request("device.capability.disable", { capability: "dnd" });
296
- setCapEnabled("dnd", false);
297
- } else {
298
- const { enabled: systemEnabled } = await DndAccess.check();
299
- if (!systemEnabled) {
300
- const result = await DndAccess.request();
301
- if (!result.enabled) return;
178
+ if (!check.granted) {
179
+ const result = await Device.requestPermission({ type: "fullScreenIntent" });
180
+ if (!result.granted) return;
302
181
  }
303
- const { value: fcmToken } = await Preferences.get({ key: "fcmToken" });
304
- if (!fcmToken) { console.warn("No FCM token available"); return; }
305
- await request("device.capability.enable", { capability: "dnd", fcmToken });
306
- setCapEnabled("dnd", true);
307
- }
308
- } catch (err) {
309
- console.error("Failed to toggle DND access:", err);
310
- } finally {
311
- setTogglingDnd(false);
312
- }
313
- }
314
-
315
- /** Ensure full-screen intent permission is granted (needed for alert + email). */
316
- async function ensureFullScreenIntent(): Promise<boolean> {
317
- if (!FullScreenIntent) return true;
318
- const { granted } = await FullScreenIntent.check();
319
- if (granted) return true;
320
- const result = await FullScreenIntent.request();
321
- return result.granted;
322
- }
323
-
324
- async function handleAlarmToggle() {
325
- if (!request) return;
326
- setTogglingAlarm(true);
327
- try {
328
- if (alarmEnabled) {
329
- await request("device.capability.disable", { capability: "alert" });
330
- setCapEnabled("alert", false);
331
- } else {
332
- if (!await ensureFullScreenIntent()) return;
333
- const { value: fcmToken } = await Preferences.get({ key: "fcmToken" });
334
- if (!fcmToken) { console.warn("No FCM token available"); return; }
335
- await request("device.capability.enable", { capability: "alert", fcmToken });
336
- setCapEnabled("alert", true);
337
- }
338
- } catch (err) {
339
- console.error("Failed to toggle alert access:", err);
340
- } finally {
341
- setTogglingAlarm(false);
342
- }
343
- }
344
-
345
- async function handleEmailToggle() {
346
- if (!request) return;
347
- setTogglingEmail(true);
348
- try {
349
- if (emailEnabled) {
350
- await request("device.capability.disable", { capability: "email" });
351
- setCapEnabled("email", false);
352
- } else {
353
- if (!await ensureFullScreenIntent()) return;
354
- const { value: fcmToken } = await Preferences.get({ key: "fcmToken" });
355
- if (!fcmToken) { console.warn("No FCM token available"); return; }
356
- await request("device.capability.enable", { capability: "email", fcmToken });
357
- setCapEnabled("email", true);
358
- }
359
- } catch (err) {
360
- console.error("Failed to toggle email access:", err);
361
- } finally {
362
- setTogglingEmail(false);
363
- }
364
- }
365
-
366
- async function handleBatteryToggle() {
367
- if (!request) return;
368
- setTogglingBattery(true);
369
- try {
370
- if (batteryEnabled) {
371
- await request("device.capability.disable", { capability: "battery" });
372
- setCapEnabled("battery", false);
373
- } else {
374
- const { value: fcmToken } = await Preferences.get({ key: "fcmToken" });
375
- if (!fcmToken) { console.warn("No FCM token available"); return; }
376
- await request("device.capability.enable", { capability: "battery", fcmToken });
377
- setCapEnabled("battery", true);
378
182
  }
379
- } catch (err) {
380
- console.error("Failed to toggle battery access:", err);
381
- } finally {
382
- setTogglingBattery(false);
383
- }
384
- }
385
183
 
386
- async function handleLocationToggle() {
387
- if (!request) return;
388
- setTogglingLocation(true);
389
- try {
390
- if (locationEnabled) {
391
- await request("device.location.disable");
392
- setCapEnabled("location", false);
393
- } else {
394
- if (LocationPermission) {
395
- const result = await LocationPermission.request();
396
- if (!result.fine) {
397
- return;
398
- }
399
- }
400
-
401
- const { value: fcmToken } = await Preferences.get({ key: "fcmToken" });
402
- if (!fcmToken) {
403
- console.warn("No FCM token available");
404
- return;
405
- }
406
- await request("device.location.enable", { fcmToken });
407
- setCapEnabled("location", true);
408
- }
184
+ if (!Device) return;
185
+ const { token: fcmToken } = await Device.getFcmToken();
186
+ if (!fcmToken) { console.warn("No FCM token available"); return; }
187
+
188
+ // Whitelist the capability natively before enabling on the host, so an FCM
189
+ // from the host can't arrive in the gap where our useEffect hasn't synced yet.
190
+ const enabledNow = CAPABILITIES
191
+ .map((c) => c.capability)
192
+ .filter((name) => name === definition.capability || capabilityTokens?.[name] === activeClientToken);
193
+ await Device.setEnabledCapabilities({ capabilities: enabledNow });
194
+
195
+ const method = definition.enableMethod ?? "device.capability.enable";
196
+ const params = definition.enableParams?.(fcmToken) ?? { capability: definition.capability, fcmToken };
197
+ await request(method, params);
198
+ setCapabilityEnabled(definition.capability, true);
409
199
  } catch (err) {
410
- console.error("Failed to toggle location:", err);
200
+ console.error(`Failed to toggle ${definition.capability}:`, err);
411
201
  } finally {
412
- setTogglingLocation(false);
202
+ setTogglingCapability(null);
413
203
  }
414
204
  }
415
205
  const drawerRef = useRef<HTMLDivElement>(null);
@@ -596,114 +386,23 @@ export default function HostMenu({ daemonVersion, capabilityTokens, activeClient
596
386
  <>
597
387
  <div className="drawer-divider" />
598
388
  <div className="drawer-section">
599
- <label className="drawer-toggle">
600
- <span className="drawer-toggle-label">Location Access</span>
601
- <button
602
- className={`toggle-switch ${locationEnabled ? "toggle-switch-on" : ""}`}
603
- onClick={handleLocationToggle}
604
- disabled={togglingLocation}
605
- role="switch"
606
- aria-checked={locationEnabled}
607
- >
608
- <span className="toggle-switch-thumb" />
609
- </button>
610
- </label>
611
- <label className="drawer-toggle">
612
- <span className="drawer-toggle-label">Notification Access</span>
613
- <button
614
- className={`toggle-switch ${notificationListenerEnabled ? "toggle-switch-on" : ""}`}
615
- onClick={handleNotificationListenerToggle}
616
- disabled={togglingNotificationListener}
617
- role="switch"
618
- aria-checked={notificationListenerEnabled}
619
- >
620
- <span className="toggle-switch-thumb" />
621
- </button>
622
- </label>
623
- <label className="drawer-toggle">
624
- <span className="drawer-toggle-label">SMS Access</span>
625
- <button
626
- className={`toggle-switch ${smsEnabled ? "toggle-switch-on" : ""}`}
627
- onClick={handleSmsToggle}
628
- disabled={togglingSms}
629
- role="switch"
630
- aria-checked={smsEnabled}
631
- >
632
- <span className="toggle-switch-thumb" />
633
- </button>
634
- </label>
635
- <label className="drawer-toggle">
636
- <span className="drawer-toggle-label">Contacts Access</span>
637
- <button
638
- className={`toggle-switch ${contactsEnabled ? "toggle-switch-on" : ""}`}
639
- onClick={handleContactsToggle}
640
- disabled={togglingContacts}
641
- role="switch"
642
- aria-checked={contactsEnabled}
643
- >
644
- <span className="toggle-switch-thumb" />
645
- </button>
646
- </label>
647
- <label className="drawer-toggle">
648
- <span className="drawer-toggle-label">Calendar Access</span>
649
- <button
650
- className={`toggle-switch ${calendarEnabled ? "toggle-switch-on" : ""}`}
651
- onClick={handleCalendarToggle}
652
- disabled={togglingCalendar}
653
- role="switch"
654
- aria-checked={calendarEnabled}
655
- >
656
- <span className="toggle-switch-thumb" />
657
- </button>
658
- </label>
659
- <label className="drawer-toggle">
660
- <span className="drawer-toggle-label">Do Not Disturb Control</span>
661
- <button
662
- className={`toggle-switch ${dndEnabled ? "toggle-switch-on" : ""}`}
663
- onClick={handleDndToggle}
664
- disabled={togglingDnd}
665
- role="switch"
666
- aria-checked={dndEnabled}
667
- >
668
- <span className="toggle-switch-thumb" />
669
- </button>
670
- </label>
671
- <label className="drawer-toggle">
672
- <span className="drawer-toggle-label">Alert Access</span>
673
- <button
674
- className={`toggle-switch ${alarmEnabled ? "toggle-switch-on" : ""}`}
675
- onClick={handleAlarmToggle}
676
- disabled={togglingAlarm}
677
- role="switch"
678
- aria-checked={alarmEnabled}
679
- >
680
- <span className="toggle-switch-thumb" />
681
- </button>
682
- </label>
683
- <label className="drawer-toggle">
684
- <span className="drawer-toggle-label">Battery Access</span>
685
- <button
686
- className={`toggle-switch ${batteryEnabled ? "toggle-switch-on" : ""}`}
687
- onClick={handleBatteryToggle}
688
- disabled={togglingBattery}
689
- role="switch"
690
- aria-checked={batteryEnabled}
691
- >
692
- <span className="toggle-switch-thumb" />
693
- </button>
694
- </label>
695
- <label className="drawer-toggle">
696
- <span className="drawer-toggle-label">Email Access</span>
697
- <button
698
- className={`toggle-switch ${emailEnabled ? "toggle-switch-on" : ""}`}
699
- onClick={handleEmailToggle}
700
- disabled={togglingEmail}
701
- role="switch"
702
- aria-checked={emailEnabled}
703
- >
704
- <span className="toggle-switch-thumb" />
705
- </button>
706
- </label>
389
+ {CAPABILITIES.filter(isCapabilityVisible).map((definition) => {
390
+ const enabled = isCapabilityEnabled(definition.capability);
391
+ return (
392
+ <label key={definition.capability} className="drawer-toggle">
393
+ <span className="drawer-toggle-label">{definition.label}</span>
394
+ <button
395
+ className={`toggle-switch ${enabled ? "toggle-switch-on" : ""}`}
396
+ onClick={() => toggleCapability(definition)}
397
+ disabled={togglingCapability === definition.capability}
398
+ role="switch"
399
+ aria-checked={enabled}
400
+ >
401
+ <span className="toggle-switch-thumb" />
402
+ </button>
403
+ </label>
404
+ );
405
+ })}
707
406
  </div>
708
407
  </>
709
408
  )}