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.
- package/README.md +6 -4
- package/dist/commands/pair.js +2 -0
- package/dist/commands/serve.js +0 -4
- package/dist/platform/index.js +7 -3
- package/dist/platform/macos.d.ts +32 -0
- package/dist/platform/macos.js +287 -0
- package/dist/pwa/assets/index-499vYQvR.js +120 -0
- package/dist/pwa/assets/{index-B0F9mtid.css → index-UaZFu6XL.css} +1 -1
- package/dist/pwa/assets/{web-Z1623me-.js → web-Bp48ONY3.js} +1 -1
- package/dist/pwa/assets/{web-C6lkQj9J.js → web-CyJutAy4.js} +1 -1
- package/dist/pwa/index.html +2 -2
- package/dist/pwa/service-worker.js +1 -1
- package/dist/rpc-handler.js +0 -4
- package/dist/transports/http-transport.js +1 -0
- package/package.json +1 -1
- package/palmier-server/pwa/src/App.css +191 -33
- package/palmier-server/pwa/src/App.tsx +2 -0
- package/palmier-server/pwa/src/components/CapabilityToggles.tsx +288 -0
- package/palmier-server/pwa/src/components/HostMenu.tsx +15 -312
- package/palmier-server/pwa/src/components/SessionComposer.tsx +11 -2
- package/palmier-server/pwa/src/components/SessionsView.tsx +3 -1
- package/palmier-server/pwa/src/components/TaskCard.tsx +1 -1
- package/palmier-server/pwa/src/components/TaskForm.tsx +126 -74
- package/palmier-server/pwa/src/components/TasksView.tsx +3 -1
- package/palmier-server/pwa/src/native/Device.ts +0 -2
- package/palmier-server/pwa/src/pages/Dashboard.tsx +2 -0
- package/palmier-server/pwa/src/pages/PairHost.tsx +3 -1
- package/palmier-server/pwa/src/pages/PairSetup.tsx +70 -0
- package/src/commands/pair.ts +2 -0
- package/src/commands/serve.ts +0 -3
- package/src/platform/index.ts +4 -3
- package/src/platform/macos.ts +310 -0
- package/src/rpc-handler.ts +0 -5
- package/src/transports/http-transport.ts +1 -0
- package/test/macos-plist.test.ts +112 -0
- package/dist/app-registry.d.ts +0 -10
- package/dist/app-registry.js +0 -44
- package/dist/pwa/assets/index-SYs3mcdJ.js +0 -120
- 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
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
}
|