palmier 0.8.0 → 0.8.3

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 (132) hide show
  1. package/CLAUDE.md +13 -0
  2. package/README.md +11 -11
  3. package/dist/agents/agent.d.ts +0 -4
  4. package/dist/agents/claude.js +1 -1
  5. package/dist/agents/codex.js +2 -2
  6. package/dist/agents/cursor.js +1 -1
  7. package/dist/agents/deepagents.js +1 -1
  8. package/dist/agents/gemini.js +3 -2
  9. package/dist/agents/goose.js +1 -1
  10. package/dist/agents/hermes.js +1 -1
  11. package/dist/agents/kiro.js +1 -1
  12. package/dist/agents/opencode.js +1 -1
  13. package/dist/agents/qoder.js +1 -1
  14. package/dist/agents/shared-prompt.d.ts +0 -3
  15. package/dist/agents/shared-prompt.js +0 -3
  16. package/dist/app-registry.d.ts +10 -0
  17. package/dist/app-registry.js +44 -0
  18. package/dist/commands/info.d.ts +0 -3
  19. package/dist/commands/info.js +0 -5
  20. package/dist/commands/init.d.ts +0 -3
  21. package/dist/commands/init.js +2 -11
  22. package/dist/commands/pair.d.ts +1 -4
  23. package/dist/commands/pair.js +1 -12
  24. package/dist/commands/restart.d.ts +0 -3
  25. package/dist/commands/restart.js +0 -3
  26. package/dist/commands/run.d.ts +1 -14
  27. package/dist/commands/run.js +18 -61
  28. package/dist/commands/serve.d.ts +0 -3
  29. package/dist/commands/serve.js +33 -27
  30. package/dist/config.d.ts +0 -8
  31. package/dist/config.js +0 -8
  32. package/dist/device-capabilities.d.ts +1 -1
  33. package/dist/event-queues.d.ts +6 -21
  34. package/dist/event-queues.js +6 -21
  35. package/dist/events.d.ts +0 -6
  36. package/dist/events.js +1 -9
  37. package/dist/index.js +0 -1
  38. package/dist/mcp-handler.js +1 -2
  39. package/dist/mcp-tools.d.ts +0 -3
  40. package/dist/mcp-tools.js +14 -18
  41. package/dist/nats-client.d.ts +0 -3
  42. package/dist/nats-client.js +1 -4
  43. package/dist/pending-requests.d.ts +4 -18
  44. package/dist/pending-requests.js +4 -18
  45. package/dist/platform/index.d.ts +1 -4
  46. package/dist/platform/index.js +1 -4
  47. package/dist/platform/linux.d.ts +3 -9
  48. package/dist/platform/linux.js +9 -20
  49. package/dist/platform/platform.d.ts +1 -4
  50. package/dist/platform/windows.d.ts +2 -5
  51. package/dist/platform/windows.js +19 -39
  52. package/dist/pwa/assets/index-B0F9mtid.css +1 -0
  53. package/dist/pwa/assets/index-SYs3mcdJ.js +120 -0
  54. package/dist/pwa/assets/{web-CF-N8Di6.js → web-C6lkQj9J.js} +1 -1
  55. package/dist/pwa/assets/{web-BpM3fNCn.js → web-Z1623me-.js} +1 -1
  56. package/dist/pwa/index.html +2 -2
  57. package/dist/pwa/service-worker.js +1 -1
  58. package/dist/rpc-handler.d.ts +0 -6
  59. package/dist/rpc-handler.js +19 -48
  60. package/dist/spawn-command.d.ts +10 -25
  61. package/dist/spawn-command.js +7 -15
  62. package/dist/task.d.ts +6 -64
  63. package/dist/task.js +7 -70
  64. package/dist/transports/http-transport.d.ts +0 -4
  65. package/dist/transports/http-transport.js +6 -28
  66. package/dist/transports/nats-transport.d.ts +0 -4
  67. package/dist/transports/nats-transport.js +3 -9
  68. package/dist/types.d.ts +3 -7
  69. package/dist/update-checker.d.ts +1 -4
  70. package/dist/update-checker.js +2 -5
  71. package/package.json +1 -1
  72. package/palmier-server/README.md +1 -1
  73. package/palmier-server/pwa/src/App.css +170 -20
  74. package/palmier-server/pwa/src/App.tsx +15 -1
  75. package/palmier-server/pwa/src/components/HostMenu.tsx +282 -473
  76. package/palmier-server/pwa/src/components/RunDetailView.tsx +3 -3
  77. package/palmier-server/pwa/src/components/SessionsView.tsx +57 -25
  78. package/palmier-server/pwa/src/components/SwipeToDeleteRow.tsx +160 -0
  79. package/palmier-server/pwa/src/components/TaskCard.tsx +12 -4
  80. package/palmier-server/pwa/src/components/TaskForm.tsx +230 -33
  81. package/palmier-server/pwa/src/components/TasksView.tsx +5 -0
  82. package/palmier-server/pwa/src/constants.ts +1 -1
  83. package/palmier-server/pwa/src/native/Device.ts +66 -0
  84. package/palmier-server/pwa/src/pages/Dashboard.tsx +11 -6
  85. package/palmier-server/pwa/src/pages/PairHost.tsx +18 -2
  86. package/palmier-server/pwa/src/types.ts +1 -1
  87. package/palmier-server/server/src/index.ts +7 -7
  88. package/palmier-server/server/src/routes/device.ts +4 -4
  89. package/palmier-server/spec.md +47 -6
  90. package/src/agents/agent.ts +0 -4
  91. package/src/agents/claude.ts +1 -1
  92. package/src/agents/codex.ts +2 -2
  93. package/src/agents/cursor.ts +1 -1
  94. package/src/agents/deepagents.ts +1 -1
  95. package/src/agents/gemini.ts +3 -2
  96. package/src/agents/goose.ts +1 -1
  97. package/src/agents/hermes.ts +1 -1
  98. package/src/agents/kiro.ts +1 -1
  99. package/src/agents/opencode.ts +1 -1
  100. package/src/agents/qoder.ts +1 -1
  101. package/src/agents/shared-prompt.ts +0 -3
  102. package/src/app-registry.ts +52 -0
  103. package/src/commands/info.ts +0 -5
  104. package/src/commands/init.ts +2 -11
  105. package/src/commands/pair.ts +1 -12
  106. package/src/commands/restart.ts +0 -3
  107. package/src/commands/run.ts +18 -65
  108. package/src/commands/serve.ts +31 -27
  109. package/src/config.ts +0 -8
  110. package/src/device-capabilities.ts +4 -3
  111. package/src/event-queues.ts +6 -21
  112. package/src/events.ts +1 -9
  113. package/src/index.ts +0 -1
  114. package/src/mcp-handler.ts +1 -2
  115. package/src/mcp-tools.ts +14 -20
  116. package/src/nats-client.ts +1 -4
  117. package/src/pending-requests.ts +4 -18
  118. package/src/platform/index.ts +1 -4
  119. package/src/platform/linux.ts +9 -20
  120. package/src/platform/platform.ts +1 -4
  121. package/src/platform/windows.ts +19 -40
  122. package/src/rpc-handler.ts +20 -48
  123. package/src/spawn-command.ts +11 -27
  124. package/src/task.ts +7 -70
  125. package/src/transports/http-transport.ts +6 -39
  126. package/src/transports/nats-transport.ts +3 -9
  127. package/src/types.ts +3 -10
  128. package/src/update-checker.ts +2 -5
  129. package/test/task-parsing.test.ts +2 -3
  130. package/test/windows-xml.test.ts +11 -12
  131. package/dist/pwa/assets/index-FP1Mipr6.js +0 -120
  132. 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,50 @@ import { confirmLeaveDraft } from "../draftGuard";
104
12
  const isLanMode = !!(window as any).__PALMIER_SERVE__;
105
13
  const isNative = Capacitor.isNativePlatform();
106
14
 
15
+ type CapabilityGroup = "Messaging" | "Data" | "Device";
16
+
17
+ const CAPABILITY_GROUPS: CapabilityGroup[] = ["Device", "Data", "Messaging"];
18
+
19
+ interface CapabilityDefinition {
20
+ /** Server-side capability name used in device.capability.{enable,disable} RPCs. */
21
+ capability: string;
22
+ /** Label shown in the drawer toggle. */
23
+ label: string;
24
+ /** Drawer section this capability is grouped under. */
25
+ group: CapabilityGroup;
26
+ /** Runtime or settings permission to request before enabling. */
27
+ permission?: PermissionType;
28
+ /** True for capabilities that display a full-screen UI (alarm). */
29
+ needsFullScreenIntent?: boolean;
30
+ /** Override RPC methods; location uses device.location.{enable,disable} instead. */
31
+ enableMethod?: string;
32
+ disableMethod?: string;
33
+ enableParams?(fcmToken: string): Record<string, unknown>;
34
+ disableParams?(): Record<string, unknown>;
35
+ }
36
+
37
+ const CAPABILITIES: CapabilityDefinition[] = [
38
+ { capability: "sms-read", label: "Read SMS", group: "Messaging", permission: "smsRead" },
39
+ { capability: "sms-send", label: "Send SMS", group: "Messaging", permission: "smsSend" },
40
+ { capability: "send-email", label: "Send Email", group: "Messaging", permission: "postNotifications" },
41
+ { capability: "notifications", label: "Read Notifications", group: "Data", permission: "notificationListener" },
42
+ { capability: "contacts", label: "Manage Contacts", group: "Data", permission: "contacts" },
43
+ { capability: "calendar", label: "Manage Calendar", group: "Data", permission: "calendar" },
44
+ {
45
+ capability: "location",
46
+ label: "Get Location",
47
+ group: "Device",
48
+ permission: "location",
49
+ enableMethod: "device.location.enable",
50
+ disableMethod: "device.location.disable",
51
+ enableParams: (fcmToken) => ({ fcmToken }),
52
+ disableParams: () => ({}),
53
+ },
54
+ { capability: "battery", label: "Read Battery Status", group: "Device" },
55
+ { capability: "dnd", label: "Set Ringer Mode", group: "Device", permission: "dnd" },
56
+ { capability: "alarm", label: "Trigger Alarms", group: "Device", needsFullScreenIntent: true },
57
+ ];
58
+
107
59
  interface HostMenuProps {
108
60
  daemonVersion?: string | null;
109
61
  capabilityTokens?: Record<string, string | null>;
@@ -122,294 +74,170 @@ export default function HostMenu({ daemonVersion, capabilityTokens, activeClient
122
74
  const [renamingId, setRenamingId] = useState<string | null>(null);
123
75
  const [renameValue, setRenameValue] = useState("");
124
76
  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);
77
+ const [togglingCapability, setTogglingCapability] = useState<string | null>(null);
78
+ const [confirmingSwitchCapability, setConfirmingSwitchCapability] = useState<CapabilityDefinition | null>(null);
79
+ const [confirmingDisableCapability, setConfirmingDisableCapability] = useState<CapabilityDefinition | null>(null);
80
+ /**
81
+ * Permission types the installed APK understands. Null while loading; an empty
82
+ * set means the native plugin doesn't expose a discovery method (pre-Device
83
+ * plugin build) in that case we don't pre-filter the UI and rely on per-call
84
+ * {supported: false} from the native side as the fallback.
85
+ */
86
+ const [supportedPerms, setSupportedPerms] = useState<Set<PermissionType> | null>(null);
87
+
88
+ useEffect(() => {
89
+ if (!isNative || !Device) {
90
+ setSupportedPerms(new Set());
91
+ return;
92
+ }
93
+ Device.getSupportedPermissions()
94
+ .then(({ types }) => setSupportedPerms(new Set(types)))
95
+ .catch(() => setSupportedPerms(new Set())); // old APK: fall back to per-call supported flag
96
+ }, []);
134
97
 
135
98
  // 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);
99
+ function isCapabilityEnabled(capability: string): boolean {
100
+ return !!(activeClientToken && capabilityTokens?.[capability] === activeClientToken);
101
+ }
102
+
103
+ /**
104
+ * A capability is shown when native either explicitly supports its permission, or
105
+ * can't advertise support (empty set = old APK or web) — in the latter case the
106
+ * toggle still works because the per-call `supported` flag guards at tap time.
107
+ */
108
+ function isCapabilityVisible(definition: CapabilityDefinition): boolean {
109
+ if (!supportedPerms) return false;
110
+ if (supportedPerms.size === 0) return true;
111
+ if (definition.permission && !supportedPerms.has(definition.permission)) return false;
112
+ if (definition.needsFullScreenIntent && !supportedPerms.has("fullScreenIntent")) return false;
113
+ return true;
138
114
  }
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
115
 
149
116
  /** Update local capability tokens state after a toggle change */
150
- function setCapEnabled(cap: string, enabled: boolean) {
117
+ function setCapabilityEnabled(capability: string, enabled: boolean) {
151
118
  const updated: Record<string, string | null> = {};
152
119
  for (const [k, v] of Object.entries(capabilityTokens ?? {})) updated[k] = v ?? null;
153
- updated[cap] = enabled ? (activeClientToken ?? null) : null;
120
+ updated[capability] = enabled ? (activeClientToken ?? null) : null;
154
121
  onCapabilityTokensChange?.(updated);
155
122
  }
156
123
 
157
- // Sync location toggle with permission state on mount and when app resumes from background
124
+ // If the OS location permission is revoked while the app is backgrounded,
125
+ // disable the capability on the host so agents don't keep pinging for fixes.
158
126
  useEffect(() => {
159
- if (!isNative || !LocationPermission || !request) return;
127
+ if (!isNative || !Device || !request) return;
128
+
129
+ const locationEnabled = isCapabilityEnabled("location");
130
+ if (!locationEnabled) return;
160
131
 
161
132
  function syncPermissionState() {
162
- if (!locationEnabled) return;
163
- LocationPermission!.check().then(({ fine }) => {
164
- if (!fine) {
165
- // Permission revoked — disable on host
133
+ Device!.checkPermission({ type: "location" }).then(({ granted }) => {
134
+ if (!granted) {
166
135
  request!("device.location.disable").then(() => {
167
- setCapEnabled("location", false);
136
+ setCapabilityEnabled("location", false);
168
137
  }).catch(() => {});
169
138
  }
170
139
  });
171
140
  }
172
141
 
173
142
  syncPermissionState();
174
-
175
- const listener = CapApp.addListener("resume", () => {
176
- syncPermissionState();
177
- });
178
-
143
+ const listener = CapacitorApp.addListener("resume", syncPermissionState);
179
144
  return () => { listener.then((h) => h.remove()); };
180
- }, [locationEnabled, activeClientToken]);
181
-
182
- async function handleNotificationListenerToggle() {
183
- if (!NotificationListener || !request) return;
184
- setTogglingNotificationListener(true);
185
- 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);
201
- }
202
- } catch (err) {
203
- console.error("Failed to toggle notification listener:", err);
204
- } finally {
205
- setTogglingNotificationListener(false);
206
- }
207
- }
208
-
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;
222
- }
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();
248
- if (!result.granted) return;
249
- }
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
- }
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;
276
- }
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
- }
145
+ }, [capabilityTokens, activeClientToken]);
289
146
 
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;
302
- }
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() {
147
+ // Mirror the server-derived enabled set into native as the local kill-switch.
148
+ // Toggling below writes through immediately; this useEffect catches host-initiated
149
+ // changes (e.g. a capability revoked on another device) on the next render.
150
+ useEffect(() => {
151
+ if (!isNative || !Device) return;
152
+ const enabledCapabilities = CAPABILITIES
153
+ .map((definition) => definition.capability)
154
+ .filter((capability) => capabilityTokens?.[capability] === activeClientToken);
155
+ Device.setEnabledCapabilities({ capabilities: enabledCapabilities }).catch(() => {});
156
+ }, [capabilityTokens, activeClientToken]);
157
+
158
+ async function toggleCapability(definition: CapabilityDefinition, bypassConfirmation = false) {
325
159
  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);
160
+ const enabled = isCapabilityEnabled(definition.capability);
161
+
162
+ if (enabled && !bypassConfirmation) {
163
+ setConfirmingDisableCapability(definition);
164
+ return;
342
165
  }
343
- }
344
166
 
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);
167
+ const ownedByOther = !enabled && !!capabilityTokens?.[definition.capability]
168
+ && capabilityTokens[definition.capability] !== activeClientToken;
169
+ if (ownedByOther && !bypassConfirmation) {
170
+ setConfirmingSwitchCapability(definition);
171
+ return;
363
172
  }
364
- }
365
173
 
366
- async function handleBatteryToggle() {
367
- if (!request) return;
368
- setTogglingBattery(true);
174
+ setTogglingCapability(definition.capability);
369
175
  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);
176
+ if (enabled) {
177
+ const method = definition.disableMethod ?? "device.capability.disable";
178
+ const params = definition.disableParams?.() ?? { capability: definition.capability };
179
+ await request(method, params);
180
+ setCapabilityEnabled(definition.capability, false);
181
+ return;
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) {
184
+ if (Device && definition.capability === "send-email") {
185
+ // Verify a mailto: handler exists before enabling — silent PackageManager
186
+ // lookup. If the APK predates this method, allow enabling and let the
187
+ // user discover the missing-app failure at first use.
188
+ try {
189
+ const result = await Device.hasEmailClient();
190
+ if (result.supported && !result.available) {
191
+ alert("No email app is installed on this device. Install one (e.g. Gmail) before enabling Sending Email.");
397
192
  return;
398
193
  }
194
+ } catch {
195
+ // Older APK without this method — fall through.
399
196
  }
197
+ }
400
198
 
401
- const { value: fcmToken } = await Preferences.get({ key: "fcmToken" });
402
- if (!fcmToken) {
403
- console.warn("No FCM token available");
199
+ if (Device && definition.permission) {
200
+ const check = await Device.checkPermission({ type: definition.permission });
201
+ if (!check.supported) {
202
+ console.warn(`Native build does not support permission '${definition.permission}'`);
203
+ return;
204
+ }
205
+ if (!check.granted) {
206
+ const result = await Device.requestPermission({ type: definition.permission });
207
+ if (!result.granted) return;
208
+ }
209
+ }
210
+ if (Device && definition.needsFullScreenIntent) {
211
+ const check = await Device.checkPermission({ type: "fullScreenIntent" });
212
+ if (!check.supported) {
213
+ console.warn("Native build does not support fullScreenIntent");
404
214
  return;
405
215
  }
406
- await request("device.location.enable", { fcmToken });
407
- setCapEnabled("location", true);
216
+ if (!check.granted) {
217
+ const result = await Device.requestPermission({ type: "fullScreenIntent" });
218
+ if (!result.granted) return;
219
+ }
408
220
  }
221
+
222
+ if (!Device) return;
223
+ const { token: fcmToken } = await Device.getFcmToken();
224
+ if (!fcmToken) { console.warn("No FCM token available"); return; }
225
+
226
+ // Whitelist the capability natively before enabling on the host, so an FCM
227
+ // from the host can't arrive in the gap where our useEffect hasn't synced yet.
228
+ const enabledNow = CAPABILITIES
229
+ .map((c) => c.capability)
230
+ .filter((name) => name === definition.capability || capabilityTokens?.[name] === activeClientToken);
231
+ await Device.setEnabledCapabilities({ capabilities: enabledNow });
232
+
233
+ const method = definition.enableMethod ?? "device.capability.enable";
234
+ const params = definition.enableParams?.(fcmToken) ?? { capability: definition.capability, fcmToken };
235
+ await request(method, params);
236
+ setCapabilityEnabled(definition.capability, true);
409
237
  } catch (err) {
410
- console.error("Failed to toggle location:", err);
238
+ console.error(`Failed to toggle ${definition.capability}:`, err);
411
239
  } finally {
412
- setTogglingLocation(false);
240
+ setTogglingCapability(null);
413
241
  }
414
242
  }
415
243
  const drawerRef = useRef<HTMLDivElement>(null);
@@ -471,20 +299,17 @@ export default function HostMenu({ daemonVersion, capabilityTokens, activeClient
471
299
 
472
300
  const drawerContent = (
473
301
  <>
474
- <div className="drawer-header">
475
- <span className="drawer-title">Palmier</span>
476
- {!isDesktop && (
477
- <button
478
- className="drawer-close-btn"
479
- onClick={close}
480
- aria-label="Close menu"
481
- >
482
- <svg width="16" height="16" viewBox="0 0 16 16" fill="none">
483
- <path d="M4 4L12 12M12 4L4 12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
484
- </svg>
485
- </button>
486
- )}
487
- </div>
302
+ {!isDesktop && (
303
+ <button
304
+ className="drawer-close-btn"
305
+ onClick={close}
306
+ aria-label="Close menu"
307
+ >
308
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none">
309
+ <path d="M4 4L12 12M12 4L4 12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
310
+ </svg>
311
+ </button>
312
+ )}
488
313
 
489
314
  {!isLanMode && pairedHosts.length > 0 && (
490
315
  <div className="drawer-section">
@@ -592,121 +417,41 @@ export default function HostMenu({ daemonVersion, capabilityTokens, activeClient
592
417
  </div>
593
418
  </>)}
594
419
 
595
- {isNative && (
596
- <>
597
- <div className="drawer-divider" />
598
- <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>
707
- </div>
708
- </>
709
- )}
420
+ {isNative && (() => {
421
+ const visibleGroups = CAPABILITY_GROUPS
422
+ .map((group) => ({ group, items: CAPABILITIES.filter((definition) => definition.group === group && isCapabilityVisible(definition)) }))
423
+ .filter((g) => g.items.length > 0);
424
+ if (visibleGroups.length === 0) return null;
425
+ return (
426
+ <>
427
+ <div className="drawer-divider" />
428
+ <div className="drawer-section">
429
+ <h3 className="drawer-section-label">Host capabilities on this device</h3>
430
+ {visibleGroups.map(({ group, items }, index) => (
431
+ <div key={group} className={index > 0 ? "drawer-toggle-group drawer-toggle-group-divided" : "drawer-toggle-group"}>
432
+ {items.map((definition) => {
433
+ const enabled = isCapabilityEnabled(definition.capability);
434
+ return (
435
+ <label key={definition.capability} className="drawer-toggle">
436
+ <span className="drawer-toggle-label">{definition.label}</span>
437
+ <button
438
+ className={`toggle-switch ${enabled ? "toggle-switch-on" : ""}`}
439
+ onClick={() => toggleCapability(definition)}
440
+ disabled={togglingCapability === definition.capability}
441
+ role="switch"
442
+ aria-checked={enabled}
443
+ >
444
+ <span className="toggle-switch-thumb" />
445
+ </button>
446
+ </label>
447
+ );
448
+ })}
449
+ </div>
450
+ ))}
451
+ </div>
452
+ </>
453
+ );
454
+ })()}
710
455
 
711
456
  <div className="drawer-footer">
712
457
  {daemonVersion && (
@@ -752,6 +497,66 @@ export default function HostMenu({ daemonVersion, capabilityTokens, activeClient
752
497
  document.body
753
498
  );
754
499
 
500
+ const disableCapabilityModal = confirmingDisableCapability && createPortal(
501
+ <div className="confirm-modal-overlay" onClick={() => setConfirmingDisableCapability(null)}>
502
+ <div className="confirm-modal" onClick={(e) => e.stopPropagation()}>
503
+ <h2 className="confirm-modal-title">Disable {confirmingDisableCapability.label}?</h2>
504
+ <p className="confirm-modal-message">
505
+ Agents running on the host will no longer be able to use {confirmingDisableCapability.label} until it is re-enabled on a device.
506
+ </p>
507
+ <div className="confirm-modal-actions">
508
+ <button
509
+ className="btn btn-secondary"
510
+ onClick={() => setConfirmingDisableCapability(null)}
511
+ >
512
+ Cancel
513
+ </button>
514
+ <button
515
+ className="btn btn-danger"
516
+ onClick={() => {
517
+ const definition = confirmingDisableCapability;
518
+ setConfirmingDisableCapability(null);
519
+ toggleCapability(definition, true);
520
+ }}
521
+ >
522
+ Disable
523
+ </button>
524
+ </div>
525
+ </div>
526
+ </div>,
527
+ document.body
528
+ );
529
+
530
+ const switchCapabilityModal = confirmingSwitchCapability && createPortal(
531
+ <div className="confirm-modal-overlay" onClick={() => setConfirmingSwitchCapability(null)}>
532
+ <div className="confirm-modal" onClick={(e) => e.stopPropagation()}>
533
+ <h2 className="confirm-modal-title">Switch {confirmingSwitchCapability.label} to this device?</h2>
534
+ <p className="confirm-modal-message">
535
+ {confirmingSwitchCapability.label} is currently enabled on another device. Switching will make this device the one the host uses for {confirmingSwitchCapability.label}, and it will be disabled on the other device.
536
+ </p>
537
+ <div className="confirm-modal-actions">
538
+ <button
539
+ className="btn btn-secondary"
540
+ onClick={() => setConfirmingSwitchCapability(null)}
541
+ >
542
+ Cancel
543
+ </button>
544
+ <button
545
+ className="btn btn-primary"
546
+ onClick={() => {
547
+ const definition = confirmingSwitchCapability;
548
+ setConfirmingSwitchCapability(null);
549
+ toggleCapability(definition, true);
550
+ }}
551
+ >
552
+ Switch
553
+ </button>
554
+ </div>
555
+ </div>
556
+ </div>,
557
+ document.body
558
+ );
559
+
755
560
  // Desktop: persistent inline sidebar
756
561
  if (isDesktop) {
757
562
  return (
@@ -760,6 +565,8 @@ export default function HostMenu({ daemonVersion, capabilityTokens, activeClient
760
565
  {drawerContent}
761
566
  </div>
762
567
  {deleteModal}
568
+ {switchCapabilityModal}
569
+ {disableCapabilityModal}
763
570
  </>
764
571
  );
765
572
  }
@@ -795,6 +602,8 @@ export default function HostMenu({ daemonVersion, capabilityTokens, activeClient
795
602
  )}
796
603
 
797
604
  {deleteModal}
605
+ {switchCapabilityModal}
606
+ {disableCapabilityModal}
798
607
  </>
799
608
  );
800
609
  }