palmier 0.8.10 → 0.9.2
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 +8 -1
- package/dist/commands/init.js +13 -2
- package/dist/commands/pair.js +3 -9
- package/dist/linked-device.d.ts +9 -0
- package/dist/linked-device.js +45 -0
- package/dist/mcp-tools.js +19 -19
- package/dist/network.d.ts +0 -5
- package/dist/network.js +75 -9
- package/dist/pwa/assets/index-BLCVzS_l.js +120 -0
- package/dist/pwa/assets/{index-DQJHVyP6.css → index-Cjjw24Ok.css} +1 -1
- package/dist/pwa/assets/{web-CaRUL7Kz.js → web-C2AU9S9n.js} +1 -1
- package/dist/pwa/assets/{web-C9g_YGd8.js → web-CfD_ah7K.js} +1 -1
- package/dist/pwa/assets/{web-D4ty3qtI.js → web-DugGj1t8.js} +1 -1
- package/dist/pwa/index.html +2 -2
- package/dist/pwa/service-worker.js +2 -2
- package/dist/rpc-handler.js +17 -23
- package/package.json +1 -2
- package/palmier-server/README.md +3 -2
- package/palmier-server/pwa/src/App.css +45 -4
- package/palmier-server/pwa/src/App.tsx +36 -15
- package/palmier-server/pwa/src/components/CapabilityToggles.tsx +65 -225
- package/palmier-server/pwa/src/components/ConnectionStatusIcon.tsx +54 -12
- package/palmier-server/pwa/src/components/HostMenu.tsx +110 -21
- package/palmier-server/pwa/src/components/RunDetailView.tsx +2 -2
- package/palmier-server/pwa/src/components/SessionComposer.tsx +9 -8
- package/palmier-server/pwa/src/components/SessionsView.tsx +5 -3
- package/palmier-server/pwa/src/components/TabBar.tsx +7 -5
- package/palmier-server/pwa/src/components/TaskForm.tsx +5 -3
- package/palmier-server/pwa/src/constants.ts +1 -1
- package/palmier-server/pwa/src/contexts/HostConnectionContext.tsx +41 -41
- package/palmier-server/pwa/src/contexts/HostStoreContext.tsx +17 -60
- package/palmier-server/pwa/src/hooks/usePushSubscription.ts +6 -7
- package/palmier-server/pwa/src/native/Device.ts +23 -38
- package/palmier-server/pwa/src/pages/Dashboard.tsx +36 -41
- package/palmier-server/pwa/src/pages/PairHost.tsx +20 -1
- package/palmier-server/pwa/src/pages/PairSetup.tsx +98 -39
- package/palmier-server/pwa/src/service-worker.ts +9 -6
- package/palmier-server/pwa/src/types.ts +2 -0
- package/palmier-server/spec.md +44 -15
- package/src/commands/init.ts +13 -2
- package/src/commands/pair.ts +3 -9
- package/src/linked-device.ts +52 -0
- package/src/mcp-tools.ts +19 -19
- package/src/network.ts +73 -9
- package/src/rpc-handler.ts +14 -22
- package/dist/device-capabilities.d.ts +0 -9
- package/dist/device-capabilities.js +0 -36
- package/dist/pwa/assets/index-iL_NTbsT.js +0 -120
- package/src/device-capabilities.ts +0 -57
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import { useEffect } from "react";
|
|
2
|
-
import { Routes, Route, useNavigate } from "react-router-dom";
|
|
3
|
-
import { HostStoreProvider } from "./contexts/HostStoreContext";
|
|
2
|
+
import { Routes, Route, useNavigate, useParams, Navigate } from "react-router-dom";
|
|
3
|
+
import { HostStoreProvider, useHostStore } from "./contexts/HostStoreContext";
|
|
4
4
|
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
8
|
import PairSetup from "./pages/PairSetup";
|
|
9
9
|
|
|
10
|
-
/** Routes FCM notification taps (fired by DevicePlugin) into the client-side router. */
|
|
11
10
|
function DeepLinkRouter() {
|
|
12
11
|
const navigate = useNavigate();
|
|
13
12
|
useEffect(() => {
|
|
@@ -18,21 +17,43 @@ function DeepLinkRouter() {
|
|
|
18
17
|
return null;
|
|
19
18
|
}
|
|
20
19
|
|
|
20
|
+
function RootRedirect() {
|
|
21
|
+
const { pairedHosts } = useHostStore();
|
|
22
|
+
if (pairedHosts.length === 0) return <Navigate to="/pair" replace />;
|
|
23
|
+
return <Navigate to={`/hosts/${encodeURIComponent(pairedHosts[0].hostId)}`} replace />;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function HostScope() {
|
|
27
|
+
const { hostId } = useParams<{ hostId: string }>();
|
|
28
|
+
const { pairedHosts } = useHostStore();
|
|
29
|
+
const host = pairedHosts.find((h) => h.hostId === hostId) ?? null;
|
|
30
|
+
|
|
31
|
+
if (!host) return <Navigate to="/" replace />;
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<HostConnectionProvider activeHost={host}>
|
|
35
|
+
<Routes>
|
|
36
|
+
<Route index element={<Dashboard />} />
|
|
37
|
+
<Route path="tasks" element={<Dashboard />} />
|
|
38
|
+
<Route path="runs/:taskId" element={<Dashboard />} />
|
|
39
|
+
<Route path="runs/:taskId/:runId" element={<Dashboard />} />
|
|
40
|
+
<Route path="pair/setup" element={<PairSetup />} />
|
|
41
|
+
<Route path="*" element={<Navigate to="." replace />} />
|
|
42
|
+
</Routes>
|
|
43
|
+
</HostConnectionProvider>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
21
47
|
export default function App() {
|
|
22
48
|
return (
|
|
23
49
|
<HostStoreProvider>
|
|
24
|
-
<
|
|
25
|
-
|
|
26
|
-
<
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
<Route path="/runs/:taskId/:runId" element={<Dashboard />} />
|
|
32
|
-
<Route path="/pair" element={<PairHost />} />
|
|
33
|
-
<Route path="/pair/setup" element={<PairSetup />} />
|
|
34
|
-
</Routes>
|
|
35
|
-
</HostConnectionProvider>
|
|
50
|
+
<DeepLinkRouter />
|
|
51
|
+
<Routes>
|
|
52
|
+
<Route path="/" element={<RootRedirect />} />
|
|
53
|
+
<Route path="/pair" element={<PairHost />} />
|
|
54
|
+
<Route path="/hosts/:hostId/*" element={<HostScope />} />
|
|
55
|
+
<Route path="*" element={<Navigate to="/" replace />} />
|
|
56
|
+
</Routes>
|
|
36
57
|
</HostStoreProvider>
|
|
37
58
|
);
|
|
38
59
|
}
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import { useState, useEffect } from "react";
|
|
2
|
-
import { createPortal } from "react-dom";
|
|
1
|
+
import { useState, useEffect, useCallback } from "react";
|
|
3
2
|
import { Capacitor } from "@capacitor/core";
|
|
4
3
|
import { App as CapacitorApp } from "@capacitor/app";
|
|
5
|
-
import { Device, type
|
|
4
|
+
import { Device, type CapabilityStatus } from "../native/Device";
|
|
6
5
|
|
|
7
6
|
const isNative = Capacitor.isNativePlatform();
|
|
8
7
|
|
|
@@ -14,265 +13,108 @@ interface CapabilityDefinition {
|
|
|
14
13
|
capability: string;
|
|
15
14
|
label: string;
|
|
16
15
|
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
16
|
}
|
|
24
17
|
|
|
25
18
|
const CAPABILITIES: CapabilityDefinition[] = [
|
|
26
|
-
{ capability: "sms-read", label: "Read SMS", group: "Messaging"
|
|
27
|
-
{ capability: "sms-send", label: "Send SMS", group: "Messaging"
|
|
28
|
-
{ capability: "send-email", label: "Send Email", group: "Messaging"
|
|
29
|
-
{ capability: "notifications", label: "Notifications from Other Apps", group: "Data"
|
|
30
|
-
{ capability: "contacts", label: "Manage Contacts", group: "Data"
|
|
31
|
-
{ capability: "calendar", label: "Manage Calendar", group: "Data"
|
|
32
|
-
{
|
|
33
|
-
|
|
34
|
-
|
|
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 },
|
|
19
|
+
{ capability: "sms-read", label: "Read SMS", group: "Messaging" },
|
|
20
|
+
{ capability: "sms-send", label: "Send SMS", group: "Messaging" },
|
|
21
|
+
{ capability: "send-email", label: "Send Email", group: "Messaging" },
|
|
22
|
+
{ capability: "notifications", label: "Notifications from Other Apps", group: "Data" },
|
|
23
|
+
{ capability: "contacts", label: "Manage Contacts", group: "Data" },
|
|
24
|
+
{ capability: "calendar", label: "Manage Calendar", group: "Data" },
|
|
25
|
+
{ capability: "location", label: "Get Location", group: "Device" },
|
|
26
|
+
{ capability: "dnd", label: "Set Ringer Mode", group: "Device" },
|
|
27
|
+
{ capability: "alarm", label: "Trigger Alarms", group: "Device" },
|
|
45
28
|
];
|
|
46
29
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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);
|
|
30
|
+
export async function loadEnabledCapabilities(): Promise<Set<string>> {
|
|
31
|
+
if (!isNative || !Device) return new Set();
|
|
32
|
+
try {
|
|
33
|
+
const { capabilities } = await Device.getCapabilityStatus();
|
|
34
|
+
return new Set(capabilities.filter((c) => c.enabled).map((c) => c.name));
|
|
35
|
+
} catch {
|
|
36
|
+
return new Set();
|
|
75
37
|
}
|
|
38
|
+
}
|
|
76
39
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
if (definition.permission && !supportedPerms.has(definition.permission)) return false;
|
|
81
|
-
if (definition.needsFullScreenIntent && !supportedPerms.has("fullScreenIntent")) return false;
|
|
82
|
-
return true;
|
|
83
|
-
}
|
|
40
|
+
interface CapabilityTogglesProps {
|
|
41
|
+
onChange?(enabled: Set<string>): void;
|
|
42
|
+
}
|
|
84
43
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
updated[capability] = enabled ? (activeClientToken ?? null) : null;
|
|
89
|
-
onCapabilityTokensChange(updated);
|
|
90
|
-
}
|
|
44
|
+
export default function CapabilityToggles({ onChange }: CapabilityTogglesProps) {
|
|
45
|
+
const [statuses, setStatuses] = useState<Map<string, CapabilityStatus>>(new Map());
|
|
46
|
+
const [busyCapability, setBusyCapability] = useState<string | null>(null);
|
|
91
47
|
|
|
92
|
-
|
|
93
|
-
// disable the capability on the host so agents don't keep pinging for fixes.
|
|
94
|
-
useEffect(() => {
|
|
48
|
+
const refresh = useCallback(async () => {
|
|
95
49
|
if (!isNative || !Device) return;
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}).catch(() => {});
|
|
105
|
-
}
|
|
106
|
-
});
|
|
50
|
+
try {
|
|
51
|
+
const { capabilities } = await Device.getCapabilityStatus();
|
|
52
|
+
const next = new Map<string, CapabilityStatus>();
|
|
53
|
+
for (const c of capabilities) next.set(c.name, c);
|
|
54
|
+
setStatuses(next);
|
|
55
|
+
onChange?.(new Set(capabilities.filter((c) => c.enabled).map((c) => c.name)));
|
|
56
|
+
} catch (err) {
|
|
57
|
+
console.error("Failed to read capability status:", err);
|
|
107
58
|
}
|
|
59
|
+
}, [onChange]);
|
|
108
60
|
|
|
109
|
-
|
|
110
|
-
const listener = CapacitorApp.addListener("resume", syncPermissionState);
|
|
111
|
-
return () => { listener.then((h) => h.remove()); };
|
|
112
|
-
}, [capabilityTokens, activeClientToken]);
|
|
61
|
+
useEffect(() => { refresh(); }, [refresh]);
|
|
113
62
|
|
|
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
63
|
useEffect(() => {
|
|
118
|
-
if (!isNative
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
}
|
|
64
|
+
if (!isNative) return;
|
|
65
|
+
const listener = CapacitorApp.addListener("resume", refresh);
|
|
66
|
+
return () => { listener.then((h) => h.remove()); };
|
|
67
|
+
}, [refresh]);
|
|
139
68
|
|
|
140
|
-
|
|
69
|
+
async function toggleCapability(definition: CapabilityDefinition) {
|
|
70
|
+
if (!Device) return;
|
|
71
|
+
const status = statuses.get(definition.capability);
|
|
72
|
+
if (!status) return;
|
|
73
|
+
setBusyCapability(definition.capability);
|
|
141
74
|
try {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
}
|
|
75
|
+
const result = await Device.setCapabilityEnabled({
|
|
76
|
+
capability: definition.capability,
|
|
77
|
+
enabled: !status.enabled,
|
|
78
|
+
});
|
|
79
|
+
if (!result.enabled && result.reason === "no-email-client") {
|
|
80
|
+
alert("No email app is installed on this device. Install one (e.g. Gmail) before enabling Send Email.");
|
|
181
81
|
}
|
|
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);
|
|
82
|
+
await refresh();
|
|
198
83
|
} catch (err) {
|
|
199
84
|
console.error(`Failed to toggle ${definition.capability}:`, err);
|
|
200
85
|
} finally {
|
|
201
|
-
|
|
86
|
+
setBusyCapability(null);
|
|
202
87
|
}
|
|
203
88
|
}
|
|
204
89
|
|
|
205
90
|
const visibleGroups = CAPABILITY_GROUPS
|
|
206
|
-
.map((group) => ({
|
|
91
|
+
.map((group) => ({
|
|
92
|
+
group,
|
|
93
|
+
items: CAPABILITIES.filter((d) => {
|
|
94
|
+
const s = statuses.get(d.capability);
|
|
95
|
+
return d.group === group && s?.supported;
|
|
96
|
+
}),
|
|
97
|
+
}))
|
|
207
98
|
.filter((g) => g.items.length > 0);
|
|
208
99
|
|
|
209
100
|
if (!isNative || visibleGroups.length === 0) return null;
|
|
210
101
|
|
|
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
102
|
return (
|
|
262
103
|
<>
|
|
263
104
|
{visibleGroups.map(({ group, items }, index) => (
|
|
264
105
|
<div key={group} className={index > 0 ? "drawer-toggle-group drawer-toggle-group-divided" : "drawer-toggle-group"}>
|
|
265
106
|
{items.map((definition) => {
|
|
266
|
-
const
|
|
107
|
+
const status = statuses.get(definition.capability);
|
|
108
|
+
const on = !!status?.enabled;
|
|
267
109
|
return (
|
|
268
110
|
<label key={definition.capability} className="drawer-toggle">
|
|
269
111
|
<span className="drawer-toggle-label">{definition.label}</span>
|
|
270
112
|
<button
|
|
271
|
-
className={`toggle-switch ${
|
|
113
|
+
className={`toggle-switch ${on ? "toggle-switch-on" : ""}`}
|
|
272
114
|
onClick={() => toggleCapability(definition)}
|
|
273
|
-
disabled={
|
|
115
|
+
disabled={busyCapability === definition.capability}
|
|
274
116
|
role="switch"
|
|
275
|
-
aria-checked={
|
|
117
|
+
aria-checked={on}
|
|
276
118
|
>
|
|
277
119
|
<span className="toggle-switch-thumb" />
|
|
278
120
|
</button>
|
|
@@ -281,8 +123,6 @@ export default function CapabilityToggles({ capabilityTokens, activeClientToken,
|
|
|
281
123
|
})}
|
|
282
124
|
</div>
|
|
283
125
|
))}
|
|
284
|
-
{switchModal}
|
|
285
|
-
{disableModal}
|
|
286
126
|
</>
|
|
287
127
|
);
|
|
288
128
|
}
|
|
@@ -4,6 +4,48 @@ import { useHostConnection } from "../contexts/HostConnectionContext";
|
|
|
4
4
|
|
|
5
5
|
const isNative = Capacitor.isNativePlatform();
|
|
6
6
|
|
|
7
|
+
const SVG_PROPS = {
|
|
8
|
+
width: 16,
|
|
9
|
+
height: 16,
|
|
10
|
+
viewBox: "0 0 24 24",
|
|
11
|
+
fill: "none",
|
|
12
|
+
stroke: "currentColor",
|
|
13
|
+
strokeWidth: 2,
|
|
14
|
+
strokeLinecap: "round" as const,
|
|
15
|
+
strokeLinejoin: "round" as const,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function WifiIcon() {
|
|
19
|
+
return (
|
|
20
|
+
<svg {...SVG_PROPS} aria-hidden="true">
|
|
21
|
+
<path d="M5 12.55a11 11 0 0 1 14.08 0" />
|
|
22
|
+
<path d="M1.42 9a16 16 0 0 1 21.16 0" />
|
|
23
|
+
<path d="M8.53 16.11a6 6 0 0 1 6.95 0" />
|
|
24
|
+
<line x1="12" y1="20" x2="12.01" y2="20" />
|
|
25
|
+
</svg>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function GlobeIcon() {
|
|
30
|
+
return (
|
|
31
|
+
<svg {...SVG_PROPS} aria-hidden="true">
|
|
32
|
+
<circle cx="12" cy="12" r="10" />
|
|
33
|
+
<line x1="2" y1="12" x2="22" y2="12" />
|
|
34
|
+
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
|
|
35
|
+
</svg>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function WarningIcon() {
|
|
40
|
+
return (
|
|
41
|
+
<svg {...SVG_PROPS} aria-hidden="true">
|
|
42
|
+
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
|
43
|
+
<line x1="12" y1="9" x2="12" y2="13" />
|
|
44
|
+
<line x1="12" y1="17" x2="12.01" y2="17" />
|
|
45
|
+
</svg>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
7
49
|
export default function ConnectionStatusIcon() {
|
|
8
50
|
const { mode } = useHostConnection();
|
|
9
51
|
const [popoverOpen, setPopoverOpen] = useState(false);
|
|
@@ -20,47 +62,47 @@ export default function ConnectionStatusIcon() {
|
|
|
20
62
|
|
|
21
63
|
if (mode === "direct") return null;
|
|
22
64
|
|
|
23
|
-
let icon:
|
|
65
|
+
let icon: React.ReactNode;
|
|
24
66
|
let label: string;
|
|
25
67
|
let modifier: string;
|
|
26
68
|
switch (mode) {
|
|
27
69
|
case "lan":
|
|
28
|
-
icon =
|
|
70
|
+
icon = <WifiIcon />;
|
|
29
71
|
label = "Connected via LAN";
|
|
30
72
|
modifier = "lan";
|
|
31
73
|
break;
|
|
32
74
|
case "nats":
|
|
33
|
-
icon =
|
|
75
|
+
icon = <GlobeIcon />;
|
|
34
76
|
label = isNative ? "Connected via relay" : "Connected";
|
|
35
77
|
modifier = "relay";
|
|
36
78
|
break;
|
|
37
79
|
case "disconnected":
|
|
38
|
-
icon =
|
|
80
|
+
icon = <WarningIcon />;
|
|
39
81
|
label = "Disconnected";
|
|
40
82
|
modifier = "disconnected";
|
|
41
83
|
break;
|
|
42
84
|
case "connecting":
|
|
43
85
|
default:
|
|
44
|
-
icon =
|
|
45
|
-
label = "Connecting
|
|
86
|
+
icon = <GlobeIcon />;
|
|
87
|
+
label = "Connecting\u2026";
|
|
46
88
|
modifier = "connecting";
|
|
47
89
|
break;
|
|
48
90
|
}
|
|
49
91
|
|
|
50
92
|
return (
|
|
51
|
-
<div
|
|
52
|
-
ref={containerRef}
|
|
53
|
-
className={`conn-status conn-status--${modifier}`}
|
|
54
|
-
>
|
|
93
|
+
<div ref={containerRef} className={`conn-status conn-status--${modifier}`}>
|
|
55
94
|
<button
|
|
56
95
|
type="button"
|
|
57
96
|
className="conn-status-btn"
|
|
58
97
|
aria-label={label}
|
|
59
98
|
onClick={() => setPopoverOpen((v) => !v)}
|
|
60
99
|
>
|
|
61
|
-
|
|
100
|
+
{icon}
|
|
62
101
|
</button>
|
|
63
|
-
<div
|
|
102
|
+
<div
|
|
103
|
+
className={`conn-status-popover ${popoverOpen ? "conn-status-popover--open" : ""}`}
|
|
104
|
+
role="tooltip"
|
|
105
|
+
>
|
|
64
106
|
{label}
|
|
65
107
|
</div>
|
|
66
108
|
</div>
|