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.
Files changed (49) hide show
  1. package/README.md +8 -1
  2. package/dist/commands/init.js +13 -2
  3. package/dist/commands/pair.js +3 -9
  4. package/dist/linked-device.d.ts +9 -0
  5. package/dist/linked-device.js +45 -0
  6. package/dist/mcp-tools.js +19 -19
  7. package/dist/network.d.ts +0 -5
  8. package/dist/network.js +75 -9
  9. package/dist/pwa/assets/index-BLCVzS_l.js +120 -0
  10. package/dist/pwa/assets/{index-DQJHVyP6.css → index-Cjjw24Ok.css} +1 -1
  11. package/dist/pwa/assets/{web-CaRUL7Kz.js → web-C2AU9S9n.js} +1 -1
  12. package/dist/pwa/assets/{web-C9g_YGd8.js → web-CfD_ah7K.js} +1 -1
  13. package/dist/pwa/assets/{web-D4ty3qtI.js → web-DugGj1t8.js} +1 -1
  14. package/dist/pwa/index.html +2 -2
  15. package/dist/pwa/service-worker.js +2 -2
  16. package/dist/rpc-handler.js +17 -23
  17. package/package.json +1 -2
  18. package/palmier-server/README.md +3 -2
  19. package/palmier-server/pwa/src/App.css +45 -4
  20. package/palmier-server/pwa/src/App.tsx +36 -15
  21. package/palmier-server/pwa/src/components/CapabilityToggles.tsx +65 -225
  22. package/palmier-server/pwa/src/components/ConnectionStatusIcon.tsx +54 -12
  23. package/palmier-server/pwa/src/components/HostMenu.tsx +110 -21
  24. package/palmier-server/pwa/src/components/RunDetailView.tsx +2 -2
  25. package/palmier-server/pwa/src/components/SessionComposer.tsx +9 -8
  26. package/palmier-server/pwa/src/components/SessionsView.tsx +5 -3
  27. package/palmier-server/pwa/src/components/TabBar.tsx +7 -5
  28. package/palmier-server/pwa/src/components/TaskForm.tsx +5 -3
  29. package/palmier-server/pwa/src/constants.ts +1 -1
  30. package/palmier-server/pwa/src/contexts/HostConnectionContext.tsx +41 -41
  31. package/palmier-server/pwa/src/contexts/HostStoreContext.tsx +17 -60
  32. package/palmier-server/pwa/src/hooks/usePushSubscription.ts +6 -7
  33. package/palmier-server/pwa/src/native/Device.ts +23 -38
  34. package/palmier-server/pwa/src/pages/Dashboard.tsx +36 -41
  35. package/palmier-server/pwa/src/pages/PairHost.tsx +20 -1
  36. package/palmier-server/pwa/src/pages/PairSetup.tsx +98 -39
  37. package/palmier-server/pwa/src/service-worker.ts +9 -6
  38. package/palmier-server/pwa/src/types.ts +2 -0
  39. package/palmier-server/spec.md +44 -15
  40. package/src/commands/init.ts +13 -2
  41. package/src/commands/pair.ts +3 -9
  42. package/src/linked-device.ts +52 -0
  43. package/src/mcp-tools.ts +19 -19
  44. package/src/network.ts +73 -9
  45. package/src/rpc-handler.ts +14 -22
  46. package/dist/device-capabilities.d.ts +0 -9
  47. package/dist/device-capabilities.js +0 -36
  48. package/dist/pwa/assets/index-iL_NTbsT.js +0 -120
  49. 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
- <HostConnectionProvider>
25
- <DeepLinkRouter />
26
- <Routes>
27
- <Route path="/" element={<Dashboard />} />
28
- <Route path="/tasks" element={<Dashboard />} />
29
- <Route path="/runs" element={<Dashboard />} />
30
- <Route path="/runs/:taskId" element={<Dashboard />} />
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 PermissionType } from "../native/Device";
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", 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: "Notifications from Other Apps", 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 },
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
- 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);
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
- 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
- }
40
+ interface CapabilityTogglesProps {
41
+ onChange?(enabled: Set<string>): void;
42
+ }
84
43
 
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
- }
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
- // 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(() => {
48
+ const refresh = useCallback(async () => {
95
49
  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
- });
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
- syncPermissionState();
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 || !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
- }
64
+ if (!isNative) return;
65
+ const listener = CapacitorApp.addListener("resume", refresh);
66
+ return () => { listener.then((h) => h.remove()); };
67
+ }, [refresh]);
139
68
 
140
- setTogglingCapability(definition.capability);
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
- 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
- }
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
- setTogglingCapability(null);
86
+ setBusyCapability(null);
202
87
  }
203
88
  }
204
89
 
205
90
  const visibleGroups = CAPABILITY_GROUPS
206
- .map((group) => ({ group, items: CAPABILITIES.filter((d) => d.group === group && isCapabilityVisible(d)) }))
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 enabled = isCapabilityEnabled(definition.capability);
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 ${enabled ? "toggle-switch-on" : ""}`}
113
+ className={`toggle-switch ${on ? "toggle-switch-on" : ""}`}
272
114
  onClick={() => toggleCapability(definition)}
273
- disabled={togglingCapability === definition.capability}
115
+ disabled={busyCapability === definition.capability}
274
116
  role="switch"
275
- aria-checked={enabled}
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: string;
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 = "\u{1F4F6}";
70
+ icon = <WifiIcon />;
29
71
  label = "Connected via LAN";
30
72
  modifier = "lan";
31
73
  break;
32
74
  case "nats":
33
- icon = "\u{1F310}";
75
+ icon = <GlobeIcon />;
34
76
  label = isNative ? "Connected via relay" : "Connected";
35
77
  modifier = "relay";
36
78
  break;
37
79
  case "disconnected":
38
- icon = "\u26A0\uFE0F";
80
+ icon = <WarningIcon />;
39
81
  label = "Disconnected";
40
82
  modifier = "disconnected";
41
83
  break;
42
84
  case "connecting":
43
85
  default:
44
- icon = "\u{1F310}";
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
- <span aria-hidden="true">{icon}</span>
100
+ {icon}
62
101
  </button>
63
- <div className={`conn-status-popover ${popoverOpen ? "conn-status-popover--open" : ""}`} role="tooltip">
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>