palmier 0.8.3 → 0.8.4

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 (39) hide show
  1. package/README.md +6 -4
  2. package/dist/commands/pair.js +2 -0
  3. package/dist/commands/serve.js +0 -4
  4. package/dist/platform/index.js +7 -3
  5. package/dist/platform/macos.d.ts +32 -0
  6. package/dist/platform/macos.js +287 -0
  7. package/dist/pwa/assets/index-499vYQvR.js +120 -0
  8. package/dist/pwa/assets/{index-B0F9mtid.css → index-UaZFu6XL.css} +1 -1
  9. package/dist/pwa/assets/{web-Z1623me-.js → web-Bp48ONY3.js} +1 -1
  10. package/dist/pwa/assets/{web-C6lkQj9J.js → web-CyJutAy4.js} +1 -1
  11. package/dist/pwa/index.html +2 -2
  12. package/dist/pwa/service-worker.js +1 -1
  13. package/dist/rpc-handler.js +0 -4
  14. package/dist/transports/http-transport.js +1 -0
  15. package/package.json +1 -1
  16. package/palmier-server/pwa/src/App.css +191 -33
  17. package/palmier-server/pwa/src/App.tsx +2 -0
  18. package/palmier-server/pwa/src/components/CapabilityToggles.tsx +288 -0
  19. package/palmier-server/pwa/src/components/HostMenu.tsx +15 -312
  20. package/palmier-server/pwa/src/components/SessionComposer.tsx +11 -2
  21. package/palmier-server/pwa/src/components/SessionsView.tsx +3 -1
  22. package/palmier-server/pwa/src/components/TaskCard.tsx +1 -1
  23. package/palmier-server/pwa/src/components/TaskForm.tsx +126 -74
  24. package/palmier-server/pwa/src/components/TasksView.tsx +3 -1
  25. package/palmier-server/pwa/src/native/Device.ts +0 -2
  26. package/palmier-server/pwa/src/pages/Dashboard.tsx +2 -0
  27. package/palmier-server/pwa/src/pages/PairHost.tsx +3 -1
  28. package/palmier-server/pwa/src/pages/PairSetup.tsx +70 -0
  29. package/src/commands/pair.ts +2 -0
  30. package/src/commands/serve.ts +0 -3
  31. package/src/platform/index.ts +4 -3
  32. package/src/platform/macos.ts +310 -0
  33. package/src/rpc-handler.ts +0 -5
  34. package/src/transports/http-transport.ts +1 -0
  35. package/test/macos-plist.test.ts +112 -0
  36. package/dist/app-registry.d.ts +0 -10
  37. package/dist/app-registry.js +0 -44
  38. package/dist/pwa/assets/index-SYs3mcdJ.js +0 -120
  39. package/src/app-registry.ts +0 -52
@@ -5,6 +5,7 @@ import { HostConnectionProvider } from "./contexts/HostConnectionContext";
5
5
  import { Device } from "./native/Device";
6
6
  import Dashboard from "./pages/Dashboard";
7
7
  import PairHost from "./pages/PairHost";
8
+ import PairSetup from "./pages/PairSetup";
8
9
 
9
10
  /** Routes FCM notification taps (fired by DevicePlugin) into the client-side router. */
10
11
  function DeepLinkRouter() {
@@ -29,6 +30,7 @@ export default function App() {
29
30
  <Route path="/runs/:taskId" element={<Dashboard />} />
30
31
  <Route path="/runs/:taskId/:runId" element={<Dashboard />} />
31
32
  <Route path="/pair" element={<PairHost />} />
33
+ <Route path="/pair/setup" element={<PairSetup />} />
32
34
  </Routes>
33
35
  </HostConnectionProvider>
34
36
  </HostStoreProvider>
@@ -0,0 +1,288 @@
1
+ import { useState, useEffect } from "react";
2
+ import { createPortal } from "react-dom";
3
+ import { Capacitor } from "@capacitor/core";
4
+ import { App as CapacitorApp } from "@capacitor/app";
5
+ import { Device, type PermissionType } from "../native/Device";
6
+
7
+ const isNative = Capacitor.isNativePlatform();
8
+
9
+ type CapabilityGroup = "Messaging" | "Data" | "Device";
10
+
11
+ const CAPABILITY_GROUPS: CapabilityGroup[] = ["Device", "Data", "Messaging"];
12
+
13
+ interface CapabilityDefinition {
14
+ capability: string;
15
+ label: string;
16
+ group: CapabilityGroup;
17
+ permission?: PermissionType;
18
+ needsFullScreenIntent?: boolean;
19
+ enableMethod?: string;
20
+ disableMethod?: string;
21
+ enableParams?(fcmToken: string): Record<string, unknown>;
22
+ disableParams?(): Record<string, unknown>;
23
+ }
24
+
25
+ const CAPABILITIES: CapabilityDefinition[] = [
26
+ { capability: "sms-read", label: "Read SMS", group: "Messaging", permission: "smsRead" },
27
+ { capability: "sms-send", label: "Send SMS", group: "Messaging", permission: "smsSend" },
28
+ { capability: "send-email", label: "Send Email", group: "Messaging", permission: "postNotifications" },
29
+ { capability: "notifications", label: "Read Notifications", group: "Data", permission: "notificationListener" },
30
+ { capability: "contacts", label: "Manage Contacts", group: "Data", permission: "contacts" },
31
+ { capability: "calendar", label: "Manage Calendar", group: "Data", permission: "calendar" },
32
+ {
33
+ capability: "location",
34
+ label: "Get Location",
35
+ group: "Device",
36
+ permission: "location",
37
+ enableMethod: "device.location.enable",
38
+ disableMethod: "device.location.disable",
39
+ enableParams: (fcmToken) => ({ fcmToken }),
40
+ disableParams: () => ({}),
41
+ },
42
+ { capability: "battery", label: "Read Battery Status", group: "Device" },
43
+ { capability: "dnd", label: "Set Ringer Mode", group: "Device", permission: "dnd" },
44
+ { capability: "alarm", label: "Trigger Alarms", group: "Device", needsFullScreenIntent: true },
45
+ ];
46
+
47
+ interface CapabilityTogglesProps {
48
+ capabilityTokens?: Record<string, string | null>;
49
+ activeClientToken?: string | null;
50
+ request<T = unknown>(method: string, params?: unknown): Promise<T>;
51
+ onCapabilityTokensChange(tokens: Record<string, string | null>): void;
52
+ }
53
+
54
+ export default function CapabilityToggles({ capabilityTokens, activeClientToken, request, onCapabilityTokensChange }: CapabilityTogglesProps) {
55
+ const [togglingCapability, setTogglingCapability] = useState<string | null>(null);
56
+ const [confirmingSwitchCapability, setConfirmingSwitchCapability] = useState<CapabilityDefinition | null>(null);
57
+ const [confirmingDisableCapability, setConfirmingDisableCapability] = useState<CapabilityDefinition | null>(null);
58
+
59
+ // Null while loading; empty set means the native plugin doesn't advertise the
60
+ // list (old APK / web) — fall back to the per-call {supported} flag.
61
+ const [supportedPerms, setSupportedPerms] = useState<Set<PermissionType> | null>(null);
62
+
63
+ useEffect(() => {
64
+ if (!isNative || !Device) {
65
+ setSupportedPerms(new Set());
66
+ return;
67
+ }
68
+ Device.getSupportedPermissions()
69
+ .then(({ types }) => setSupportedPerms(new Set(types)))
70
+ .catch(() => setSupportedPerms(new Set()));
71
+ }, []);
72
+
73
+ function isCapabilityEnabled(capability: string): boolean {
74
+ return !!(activeClientToken && capabilityTokens?.[capability] === activeClientToken);
75
+ }
76
+
77
+ function isCapabilityVisible(definition: CapabilityDefinition): boolean {
78
+ if (!supportedPerms) return false;
79
+ if (supportedPerms.size === 0) return true;
80
+ if (definition.permission && !supportedPerms.has(definition.permission)) return false;
81
+ if (definition.needsFullScreenIntent && !supportedPerms.has("fullScreenIntent")) return false;
82
+ return true;
83
+ }
84
+
85
+ function setCapabilityEnabled(capability: string, enabled: boolean) {
86
+ const updated: Record<string, string | null> = {};
87
+ for (const [k, v] of Object.entries(capabilityTokens ?? {})) updated[k] = v ?? null;
88
+ updated[capability] = enabled ? (activeClientToken ?? null) : null;
89
+ onCapabilityTokensChange(updated);
90
+ }
91
+
92
+ // If the OS location permission is revoked while the app is backgrounded,
93
+ // disable the capability on the host so agents don't keep pinging for fixes.
94
+ useEffect(() => {
95
+ if (!isNative || !Device) return;
96
+ const locationEnabled = isCapabilityEnabled("location");
97
+ if (!locationEnabled) return;
98
+
99
+ function syncPermissionState() {
100
+ Device!.checkPermission({ type: "location" }).then(({ granted }) => {
101
+ if (!granted) {
102
+ request("device.location.disable").then(() => {
103
+ setCapabilityEnabled("location", false);
104
+ }).catch(() => {});
105
+ }
106
+ });
107
+ }
108
+
109
+ syncPermissionState();
110
+ const listener = CapacitorApp.addListener("resume", syncPermissionState);
111
+ return () => { listener.then((h) => h.remove()); };
112
+ }, [capabilityTokens, activeClientToken]);
113
+
114
+ // Mirror the server-derived enabled set into native as a local kill-switch.
115
+ // Toggle paths write through immediately; this catches host-initiated changes
116
+ // (e.g. a capability revoked on another device) on the next render.
117
+ useEffect(() => {
118
+ if (!isNative || !Device) return;
119
+ const enabled = CAPABILITIES
120
+ .map((definition) => definition.capability)
121
+ .filter((capability) => capabilityTokens?.[capability] === activeClientToken);
122
+ Device.setEnabledCapabilities({ capabilities: enabled }).catch(() => {});
123
+ }, [capabilityTokens, activeClientToken]);
124
+
125
+ async function toggleCapability(definition: CapabilityDefinition, bypassConfirmation = false) {
126
+ const enabled = isCapabilityEnabled(definition.capability);
127
+
128
+ if (enabled && !bypassConfirmation) {
129
+ setConfirmingDisableCapability(definition);
130
+ return;
131
+ }
132
+
133
+ const ownedByOther = !enabled && !!capabilityTokens?.[definition.capability]
134
+ && capabilityTokens[definition.capability] !== activeClientToken;
135
+ if (ownedByOther && !bypassConfirmation) {
136
+ setConfirmingSwitchCapability(definition);
137
+ return;
138
+ }
139
+
140
+ setTogglingCapability(definition.capability);
141
+ try {
142
+ if (enabled) {
143
+ const method = definition.disableMethod ?? "device.capability.disable";
144
+ const params = definition.disableParams?.() ?? { capability: definition.capability };
145
+ await request(method, params);
146
+ setCapabilityEnabled(definition.capability, false);
147
+ return;
148
+ }
149
+
150
+ if (Device && definition.capability === "send-email") {
151
+ try {
152
+ const result = await Device.hasEmailClient();
153
+ if (result.supported && !result.available) {
154
+ alert("No email app is installed on this device. Install one (e.g. Gmail) before enabling Sending Email.");
155
+ return;
156
+ }
157
+ } catch { /* older APK: fall through */ }
158
+ }
159
+
160
+ if (Device && definition.permission) {
161
+ const check = await Device.checkPermission({ type: definition.permission });
162
+ if (!check.supported) {
163
+ console.warn(`Native build does not support permission '${definition.permission}'`);
164
+ return;
165
+ }
166
+ if (!check.granted) {
167
+ const result = await Device.requestPermission({ type: definition.permission });
168
+ if (!result.granted) return;
169
+ }
170
+ }
171
+ if (Device && definition.needsFullScreenIntent) {
172
+ const check = await Device.checkPermission({ type: "fullScreenIntent" });
173
+ if (!check.supported) {
174
+ console.warn("Native build does not support fullScreenIntent");
175
+ return;
176
+ }
177
+ if (!check.granted) {
178
+ const result = await Device.requestPermission({ type: "fullScreenIntent" });
179
+ if (!result.granted) return;
180
+ }
181
+ }
182
+
183
+ if (!Device) return;
184
+ const { token: fcmToken } = await Device.getFcmToken();
185
+ if (!fcmToken) { console.warn("No FCM token available"); return; }
186
+
187
+ // Whitelist the capability natively before enabling on the host, so an FCM
188
+ // from the host can't arrive in the gap before our useEffect syncs.
189
+ const enabledNow = CAPABILITIES
190
+ .map((c) => c.capability)
191
+ .filter((name) => name === definition.capability || capabilityTokens?.[name] === activeClientToken);
192
+ await Device.setEnabledCapabilities({ capabilities: enabledNow });
193
+
194
+ const method = definition.enableMethod ?? "device.capability.enable";
195
+ const params = definition.enableParams?.(fcmToken) ?? { capability: definition.capability, fcmToken };
196
+ await request(method, params);
197
+ setCapabilityEnabled(definition.capability, true);
198
+ } catch (err) {
199
+ console.error(`Failed to toggle ${definition.capability}:`, err);
200
+ } finally {
201
+ setTogglingCapability(null);
202
+ }
203
+ }
204
+
205
+ const visibleGroups = CAPABILITY_GROUPS
206
+ .map((group) => ({ group, items: CAPABILITIES.filter((d) => d.group === group && isCapabilityVisible(d)) }))
207
+ .filter((g) => g.items.length > 0);
208
+
209
+ if (!isNative || visibleGroups.length === 0) return null;
210
+
211
+ const switchModal = confirmingSwitchCapability && createPortal(
212
+ <div className="confirm-modal-overlay" onClick={() => setConfirmingSwitchCapability(null)}>
213
+ <div className="confirm-modal" onClick={(e) => e.stopPropagation()}>
214
+ <h2 className="confirm-modal-title">Switch {confirmingSwitchCapability.label} to this device?</h2>
215
+ <p className="confirm-modal-message">
216
+ {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.
217
+ </p>
218
+ <div className="confirm-modal-actions">
219
+ <button className="btn btn-secondary" onClick={() => setConfirmingSwitchCapability(null)}>Cancel</button>
220
+ <button
221
+ className="btn btn-primary"
222
+ onClick={() => {
223
+ const d = confirmingSwitchCapability;
224
+ setConfirmingSwitchCapability(null);
225
+ toggleCapability(d, true);
226
+ }}
227
+ >
228
+ Switch
229
+ </button>
230
+ </div>
231
+ </div>
232
+ </div>,
233
+ document.body,
234
+ );
235
+
236
+ const disableModal = confirmingDisableCapability && createPortal(
237
+ <div className="confirm-modal-overlay" onClick={() => setConfirmingDisableCapability(null)}>
238
+ <div className="confirm-modal" onClick={(e) => e.stopPropagation()}>
239
+ <h2 className="confirm-modal-title">Disable {confirmingDisableCapability.label}?</h2>
240
+ <p className="confirm-modal-message">
241
+ Agents running on the host will no longer be able to use {confirmingDisableCapability.label} until it is re-enabled on a device.
242
+ </p>
243
+ <div className="confirm-modal-actions">
244
+ <button className="btn btn-secondary" onClick={() => setConfirmingDisableCapability(null)}>Cancel</button>
245
+ <button
246
+ className="btn btn-danger"
247
+ onClick={() => {
248
+ const d = confirmingDisableCapability;
249
+ setConfirmingDisableCapability(null);
250
+ toggleCapability(d, true);
251
+ }}
252
+ >
253
+ Disable
254
+ </button>
255
+ </div>
256
+ </div>
257
+ </div>,
258
+ document.body,
259
+ );
260
+
261
+ return (
262
+ <>
263
+ {visibleGroups.map(({ group, items }, index) => (
264
+ <div key={group} className={index > 0 ? "drawer-toggle-group drawer-toggle-group-divided" : "drawer-toggle-group"}>
265
+ {items.map((definition) => {
266
+ const enabled = isCapabilityEnabled(definition.capability);
267
+ return (
268
+ <label key={definition.capability} className="drawer-toggle">
269
+ <span className="drawer-toggle-label">{definition.label}</span>
270
+ <button
271
+ className={`toggle-switch ${enabled ? "toggle-switch-on" : ""}`}
272
+ onClick={() => toggleCapability(definition)}
273
+ disabled={togglingCapability === definition.capability}
274
+ role="switch"
275
+ aria-checked={enabled}
276
+ >
277
+ <span className="toggle-switch-thumb" />
278
+ </button>
279
+ </label>
280
+ );
281
+ })}
282
+ </div>
283
+ ))}
284
+ {switchModal}
285
+ {disableModal}
286
+ </>
287
+ );
288
+ }
@@ -2,60 +2,15 @@ import { useState, useEffect, useRef, useCallback } from "react";
2
2
  import { createPortal } from "react-dom";
3
3
  import { useNavigate } from "react-router-dom";
4
4
  import { Capacitor } from "@capacitor/core";
5
- import { App as CapacitorApp } from "@capacitor/app";
6
- import { Device, type PermissionType } from "../native/Device";
7
5
  import { useHostStore } from "../contexts/HostStoreContext";
8
6
  import { useMediaQuery } from "../hooks/useMediaQuery";
9
7
  import { confirmLeaveDraft } from "../draftGuard";
8
+ import CapabilityToggles from "./CapabilityToggles";
10
9
 
11
10
  /** LAN mode: PWA is served by palmier serve (marker injected into HTML). */
12
11
  const isLanMode = !!(window as any).__PALMIER_SERVE__;
13
12
  const isNative = Capacitor.isNativePlatform();
14
13
 
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
-
59
14
  interface HostMenuProps {
60
15
  daemonVersion?: string | null;
61
16
  capabilityTokens?: Record<string, string | null>;
@@ -74,177 +29,10 @@ export default function HostMenu({ daemonVersion, capabilityTokens, activeClient
74
29
  const [renamingId, setRenamingId] = useState<string | null>(null);
75
30
  const [renameValue, setRenameValue] = useState("");
76
31
  const [confirmingDeleteId, setConfirmingDeleteId] = useState<string | null>(null);
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
- }, []);
97
-
98
- // Capability enabled = this device's client token matches the registered device for that capability
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;
114
- }
115
-
116
- /** Update local capability tokens state after a toggle change */
117
- function setCapabilityEnabled(capability: string, enabled: boolean) {
118
- const updated: Record<string, string | null> = {};
119
- for (const [k, v] of Object.entries(capabilityTokens ?? {})) updated[k] = v ?? null;
120
- updated[capability] = enabled ? (activeClientToken ?? null) : null;
121
- onCapabilityTokensChange?.(updated);
122
- }
123
-
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.
126
- useEffect(() => {
127
- if (!isNative || !Device || !request) return;
128
-
129
- const locationEnabled = isCapabilityEnabled("location");
130
- if (!locationEnabled) return;
131
-
132
- function syncPermissionState() {
133
- Device!.checkPermission({ type: "location" }).then(({ granted }) => {
134
- if (!granted) {
135
- request!("device.location.disable").then(() => {
136
- setCapabilityEnabled("location", false);
137
- }).catch(() => {});
138
- }
139
- });
140
- }
141
32
 
142
- syncPermissionState();
143
- const listener = CapacitorApp.addListener("resume", syncPermissionState);
144
- return () => { listener.then((h) => h.remove()); };
145
- }, [capabilityTokens, activeClientToken]);
146
-
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) {
159
- if (!request) return;
160
- const enabled = isCapabilityEnabled(definition.capability);
161
-
162
- if (enabled && !bypassConfirmation) {
163
- setConfirmingDisableCapability(definition);
164
- return;
165
- }
166
-
167
- const ownedByOther = !enabled && !!capabilityTokens?.[definition.capability]
168
- && capabilityTokens[definition.capability] !== activeClientToken;
169
- if (ownedByOther && !bypassConfirmation) {
170
- setConfirmingSwitchCapability(definition);
171
- return;
172
- }
173
-
174
- setTogglingCapability(definition.capability);
175
- try {
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;
182
- }
183
-
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.");
192
- return;
193
- }
194
- } catch {
195
- // Older APK without this method — fall through.
196
- }
197
- }
198
-
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");
214
- return;
215
- }
216
- if (!check.granted) {
217
- const result = await Device.requestPermission({ type: "fullScreenIntent" });
218
- if (!result.granted) return;
219
- }
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);
237
- } catch (err) {
238
- console.error(`Failed to toggle ${definition.capability}:`, err);
239
- } finally {
240
- setTogglingCapability(null);
241
- }
242
- }
243
33
  const drawerRef = useRef<HTMLDivElement>(null);
244
34
  const renameInputRef = useRef<HTMLInputElement>(null);
245
35
 
246
- // In LAN mode, there's only one host — no picker/pairing needed
247
-
248
36
  const close = useCallback(() => {
249
37
  setClosing(true);
250
38
  }, []);
@@ -417,41 +205,20 @@ export default function HostMenu({ daemonVersion, capabilityTokens, activeClient
417
205
  </div>
418
206
  </>)}
419
207
 
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
- })()}
208
+ {isNative && request && (
209
+ <>
210
+ <div className="drawer-divider" />
211
+ <div className="drawer-section">
212
+ <h3 className="drawer-section-label">Host capabilities on this device</h3>
213
+ <CapabilityToggles
214
+ capabilityTokens={capabilityTokens}
215
+ activeClientToken={activeClientToken}
216
+ request={request}
217
+ onCapabilityTokensChange={(tokens) => onCapabilityTokensChange?.(tokens)}
218
+ />
219
+ </div>
220
+ </>
221
+ )}
455
222
 
456
223
  <div className="drawer-footer">
457
224
  {daemonVersion && (
@@ -497,66 +264,6 @@ export default function HostMenu({ daemonVersion, capabilityTokens, activeClient
497
264
  document.body
498
265
  );
499
266
 
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
-
560
267
  // Desktop: persistent inline sidebar
561
268
  if (isDesktop) {
562
269
  return (
@@ -565,8 +272,6 @@ export default function HostMenu({ daemonVersion, capabilityTokens, activeClient
565
272
  {drawerContent}
566
273
  </div>
567
274
  {deleteModal}
568
- {switchCapabilityModal}
569
- {disableCapabilityModal}
570
275
  </>
571
276
  );
572
277
  }
@@ -602,8 +307,6 @@ export default function HostMenu({ daemonVersion, capabilityTokens, activeClient
602
307
  )}
603
308
 
604
309
  {deleteModal}
605
- {switchCapabilityModal}
606
- {disableCapabilityModal}
607
310
  </>
608
311
  );
609
312
  }