palmier 0.8.1 → 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 (133) hide show
  1. package/CLAUDE.md +13 -0
  2. package/README.md +16 -14
  3. package/dist/agents/agent.d.ts +0 -4
  4. package/dist/agents/claude.js +1 -1
  5. package/dist/agents/codex.js +2 -2
  6. package/dist/agents/cursor.js +1 -1
  7. package/dist/agents/deepagents.js +1 -1
  8. package/dist/agents/gemini.js +3 -2
  9. package/dist/agents/goose.js +1 -1
  10. package/dist/agents/hermes.js +1 -1
  11. package/dist/agents/kiro.js +1 -1
  12. package/dist/agents/opencode.js +1 -1
  13. package/dist/agents/qoder.js +1 -1
  14. package/dist/agents/shared-prompt.d.ts +0 -3
  15. package/dist/agents/shared-prompt.js +0 -3
  16. package/dist/commands/info.d.ts +0 -3
  17. package/dist/commands/info.js +0 -5
  18. package/dist/commands/init.d.ts +0 -3
  19. package/dist/commands/init.js +2 -11
  20. package/dist/commands/pair.d.ts +1 -4
  21. package/dist/commands/pair.js +3 -12
  22. package/dist/commands/restart.d.ts +0 -3
  23. package/dist/commands/restart.js +0 -3
  24. package/dist/commands/run.d.ts +1 -14
  25. package/dist/commands/run.js +18 -61
  26. package/dist/commands/serve.d.ts +0 -3
  27. package/dist/commands/serve.js +29 -27
  28. package/dist/config.d.ts +0 -8
  29. package/dist/config.js +0 -8
  30. package/dist/device-capabilities.d.ts +1 -1
  31. package/dist/event-queues.d.ts +6 -21
  32. package/dist/event-queues.js +6 -21
  33. package/dist/events.d.ts +0 -6
  34. package/dist/events.js +1 -9
  35. package/dist/index.js +0 -1
  36. package/dist/mcp-handler.js +1 -2
  37. package/dist/mcp-tools.d.ts +0 -3
  38. package/dist/mcp-tools.js +12 -16
  39. package/dist/nats-client.d.ts +0 -3
  40. package/dist/nats-client.js +1 -4
  41. package/dist/pending-requests.d.ts +4 -18
  42. package/dist/pending-requests.js +4 -18
  43. package/dist/platform/index.d.ts +1 -4
  44. package/dist/platform/index.js +8 -7
  45. package/dist/platform/linux.d.ts +3 -9
  46. package/dist/platform/linux.js +9 -20
  47. package/dist/platform/macos.d.ts +32 -0
  48. package/dist/platform/macos.js +287 -0
  49. package/dist/platform/platform.d.ts +1 -4
  50. package/dist/platform/windows.d.ts +2 -5
  51. package/dist/platform/windows.js +19 -39
  52. package/dist/pwa/assets/index-499vYQvR.js +120 -0
  53. package/dist/pwa/assets/{index-CQxcuDhM.css → index-UaZFu6XL.css} +1 -1
  54. package/dist/pwa/assets/{web-DOyOiwsW.js → web-Bp48ONY3.js} +1 -1
  55. package/dist/pwa/assets/{web-D7Kq3Nvk.js → web-CyJutAy4.js} +1 -1
  56. package/dist/pwa/index.html +2 -2
  57. package/dist/pwa/service-worker.js +1 -1
  58. package/dist/rpc-handler.d.ts +0 -6
  59. package/dist/rpc-handler.js +14 -47
  60. package/dist/spawn-command.d.ts +10 -25
  61. package/dist/spawn-command.js +7 -15
  62. package/dist/task.d.ts +6 -64
  63. package/dist/task.js +7 -70
  64. package/dist/transports/http-transport.d.ts +0 -4
  65. package/dist/transports/http-transport.js +7 -28
  66. package/dist/transports/nats-transport.d.ts +0 -4
  67. package/dist/transports/nats-transport.js +3 -9
  68. package/dist/types.d.ts +3 -7
  69. package/dist/update-checker.d.ts +1 -4
  70. package/dist/update-checker.js +2 -5
  71. package/package.json +1 -1
  72. package/palmier-server/pwa/src/App.css +325 -22
  73. package/palmier-server/pwa/src/App.tsx +2 -0
  74. package/palmier-server/pwa/src/components/CapabilityToggles.tsx +288 -0
  75. package/palmier-server/pwa/src/components/HostMenu.tsx +20 -207
  76. package/palmier-server/pwa/src/components/RunDetailView.tsx +3 -3
  77. package/palmier-server/pwa/src/components/SessionComposer.tsx +11 -2
  78. package/palmier-server/pwa/src/components/SessionsView.tsx +60 -32
  79. package/palmier-server/pwa/src/components/SwipeToDeleteRow.tsx +160 -0
  80. package/palmier-server/pwa/src/components/TaskCard.tsx +1 -1
  81. package/palmier-server/pwa/src/components/TaskForm.tsx +207 -5
  82. package/palmier-server/pwa/src/components/TasksView.tsx +3 -1
  83. package/palmier-server/pwa/src/constants.ts +1 -1
  84. package/palmier-server/pwa/src/native/Device.ts +18 -2
  85. package/palmier-server/pwa/src/pages/Dashboard.tsx +13 -6
  86. package/palmier-server/pwa/src/pages/PairHost.tsx +3 -1
  87. package/palmier-server/pwa/src/pages/PairSetup.tsx +70 -0
  88. package/palmier-server/server/src/index.ts +7 -7
  89. package/palmier-server/server/src/routes/device.ts +4 -4
  90. package/palmier-server/spec.md +38 -7
  91. package/src/agents/agent.ts +0 -4
  92. package/src/agents/claude.ts +1 -1
  93. package/src/agents/codex.ts +2 -2
  94. package/src/agents/cursor.ts +1 -1
  95. package/src/agents/deepagents.ts +1 -1
  96. package/src/agents/gemini.ts +3 -2
  97. package/src/agents/goose.ts +1 -1
  98. package/src/agents/hermes.ts +1 -1
  99. package/src/agents/kiro.ts +1 -1
  100. package/src/agents/opencode.ts +1 -1
  101. package/src/agents/qoder.ts +1 -1
  102. package/src/agents/shared-prompt.ts +0 -3
  103. package/src/commands/info.ts +0 -5
  104. package/src/commands/init.ts +2 -11
  105. package/src/commands/pair.ts +3 -12
  106. package/src/commands/restart.ts +0 -3
  107. package/src/commands/run.ts +18 -65
  108. package/src/commands/serve.ts +28 -27
  109. package/src/config.ts +0 -8
  110. package/src/device-capabilities.ts +3 -2
  111. package/src/event-queues.ts +6 -21
  112. package/src/events.ts +1 -9
  113. package/src/index.ts +0 -1
  114. package/src/mcp-handler.ts +1 -2
  115. package/src/mcp-tools.ts +12 -18
  116. package/src/nats-client.ts +1 -4
  117. package/src/pending-requests.ts +4 -18
  118. package/src/platform/index.ts +5 -7
  119. package/src/platform/linux.ts +9 -20
  120. package/src/platform/macos.ts +310 -0
  121. package/src/platform/platform.ts +1 -4
  122. package/src/platform/windows.ts +19 -40
  123. package/src/rpc-handler.ts +14 -47
  124. package/src/spawn-command.ts +11 -27
  125. package/src/task.ts +7 -70
  126. package/src/transports/http-transport.ts +7 -39
  127. package/src/transports/nats-transport.ts +3 -9
  128. package/src/types.ts +3 -10
  129. package/src/update-checker.ts +2 -5
  130. package/test/macos-plist.test.ts +112 -0
  131. package/test/task-parsing.test.ts +2 -3
  132. package/test/windows-xml.test.ts +11 -12
  133. package/dist/pwa/assets/index-DQfOEB03.js +0 -120
@@ -1,7 +1,9 @@
1
1
  import { useEffect, useState, useCallback, useRef } from "react";
2
+ import { Capacitor } from "@capacitor/core";
2
3
  import { useHostConnection } from "../contexts/HostConnectionContext";
3
4
  import PlanDialog from "./PlanDialog";
4
5
  import { useBackClose } from "../hooks/useBackClose";
6
+ import { Device } from "../native/Device";
5
7
  import type { AgentInfo, Task } from "../types";
6
8
 
7
9
  type ScheduleType = "crons" | "specific_times" | "on_new_notification" | "on_new_sms";
@@ -94,12 +96,13 @@ interface TaskFormProps {
94
96
  initial?: Task;
95
97
  agents: AgentInfo[];
96
98
  hostPlatform?: string;
99
+ isNotificationListener: boolean;
97
100
  onSaved(task: Task): void;
98
101
  onRun(taskId: string, runId?: string): void;
99
102
  onCancel(): void;
100
103
  }
101
104
 
102
- export default function TaskForm({ initial, agents, hostPlatform, onSaved, onRun, onCancel }: TaskFormProps) {
105
+ export default function TaskForm({ initial, agents, hostPlatform, isNotificationListener, onSaved, onRun, onCancel }: TaskFormProps) {
103
106
  const { request } = useHostConnection();
104
107
 
105
108
  const defaultAgent = () => {
@@ -140,6 +143,60 @@ export default function TaskForm({ initial, agents, hostPlatform, onSaved, onRun
140
143
  const [command, setCommand] = useState(initial?.command ?? "");
141
144
  const [saving, setSaving] = useState(false);
142
145
 
146
+ // Single optional app filter for on_new_notification tasks. Empty = any app;
147
+ // non-empty = single packageName filter (stored as a one-entry schedule_values
148
+ // array on save). Datalist options come from the host-side app registry.
149
+ // No migration: pre-existing multi-app tasks truncate to the first entry on
150
+ // first edit.
151
+ const initialNotificationApp: string = (() => {
152
+ if (initial?.schedule_type === "on_new_notification" && initial.schedule_values && initial.schedule_values.length > 0) {
153
+ return initial.schedule_values[0];
154
+ }
155
+ return "";
156
+ })();
157
+ const [notificationApp, setNotificationApp] = useState<string>(initialNotificationApp);
158
+ const [knownApps, setKnownApps] = useState<Array<{ packageName: string; appName: string }>>([]);
159
+ const [knownAppsLoading, setKnownAppsLoading] = useState(false);
160
+ const [appFilterOpen, setAppFilterOpen] = useState(false);
161
+ const [appSearch, setAppSearch] = useState("");
162
+ const closeAppFilter = useCallback(() => setAppFilterOpen(false), []);
163
+ useBackClose(appFilterOpen, closeAppFilter);
164
+
165
+ // Only the notification-listening device can enumerate installed apps. On any
166
+ // other client we leave the list empty and fall back to a plain packageName
167
+ // input — the app registry we used to maintain on the host was inconsistent
168
+ // across devices, so we no longer cache it.
169
+ useEffect(() => {
170
+ if (scheduleMode !== "on_new_notification") return;
171
+ if (!isNotificationListener || !Capacitor.isNativePlatform() || !Device) return;
172
+ let cancelled = false;
173
+ setKnownAppsLoading(true);
174
+ Device.getInstalledApps()
175
+ .then(({ apps }) => {
176
+ if (cancelled) return;
177
+ const PALMIER_PACKAGE = "com.palmier.app";
178
+ const list = apps
179
+ .filter((a) => a.packageName !== PALMIER_PACKAGE)
180
+ .sort((a, b) => (a.appName || a.packageName).localeCompare(b.appName || b.packageName));
181
+ setKnownApps(list);
182
+ })
183
+ .catch(() => {})
184
+ .finally(() => { if (!cancelled) setKnownAppsLoading(false); });
185
+ return () => { cancelled = true; };
186
+ }, [scheduleMode, isNotificationListener]);
187
+
188
+ // Sender filter for on_new_sms tasks. Empty string = any sender; non-empty =
189
+ // whitelist a single sender (stored as a single-entry schedule_values array
190
+ // on save). Matching is normalized on the host — users don't need to worry
191
+ // about exact phone-number formatting.
192
+ const initialSmsSender: string = (() => {
193
+ if (initial?.schedule_type === "on_new_sms" && initial.schedule_values && initial.schedule_values.length > 0) {
194
+ return initial.schedule_values[0];
195
+ }
196
+ return "";
197
+ })();
198
+ const [smsSender, setSmsSender] = useState<string>(initialSmsSender);
199
+
143
200
  const commandInputRef = useRef<HTMLInputElement>(null);
144
201
 
145
202
  const modeIsScheduled = isScheduleSlot(scheduleMode);
@@ -169,7 +226,9 @@ export default function TaskForm({ initial, agents, hostPlatform, onSaved, onRun
169
226
  JSON.stringify(collectScheduleValues()) !== JSON.stringify(initial?.schedule_values ?? [])
170
227
  || modeToScheduleType(scheduleMode as ScheduleSlot) !== (initial?.schedule_type ?? undefined)
171
228
  ))
172
- || (modeIsEvent && scheduleMode !== initial?.schedule_type);
229
+ || (modeIsEvent && scheduleMode !== initial?.schedule_type)
230
+ || (scheduleMode === "on_new_notification" && notificationApp.trim() !== initialNotificationApp.trim())
231
+ || (scheduleMode === "on_new_sms" && smsSender.trim() !== initialSmsSender.trim());
173
232
 
174
233
  const hasInvalidTrigger = modeIsScheduled && triggerRows.some((r) =>
175
234
  r.schedule === "specific_times" && (!r.onceDate || new Date(`${r.onceDate}T${r.onceTime}`) <= new Date())
@@ -209,6 +268,12 @@ export default function TaskForm({ initial, agents, hostPlatform, onSaved, onRun
209
268
  } else {
210
269
  setCommand("");
211
270
  }
271
+ if (next !== "on_new_notification") {
272
+ setNotificationApp(initialNotificationApp);
273
+ }
274
+ if (next !== "on_new_sms") {
275
+ setSmsSender(initialSmsSender);
276
+ }
212
277
  }
213
278
 
214
279
  function collectScheduleValues(): string[] {
@@ -229,7 +294,13 @@ export default function TaskForm({ initial, agents, hostPlatform, onSaved, onRun
229
294
  setSaving(true);
230
295
  setError(null);
231
296
  try {
232
- const scheduleValues = modeIsScheduled ? collectScheduleValues() : [];
297
+ const scheduleValues = modeIsScheduled
298
+ ? collectScheduleValues()
299
+ : scheduleMode === "on_new_notification" && notificationApp.trim()
300
+ ? [notificationApp.trim()]
301
+ : scheduleMode === "on_new_sms" && smsSender.trim()
302
+ ? [smsSender.trim()]
303
+ : [];
233
304
  const scheduleType: ScheduleType | null = modeIsScheduled
234
305
  ? modeToScheduleType(scheduleMode as ScheduleSlot)
235
306
  : modeIsEvent
@@ -363,7 +434,7 @@ export default function TaskForm({ initial, agents, hostPlatform, onSaved, onRun
363
434
  <option value="daily">Daily</option>
364
435
  <option value="weekly">Weekly</option>
365
436
  <option value="monthly">Monthly</option>
366
- <option value="on_new_notification">On New Push Notification</option>
437
+ <option value="on_new_notification">On New Notification</option>
367
438
  <option value="on_new_sms">On New SMS</option>
368
439
  <option value="command">Command-triggered</option>
369
440
  </select>
@@ -372,10 +443,75 @@ export default function TaskForm({ initial, agents, hostPlatform, onSaved, onRun
372
443
  <div className="schedule-reactive">
373
444
  <p className="command-help-text">
374
445
  {scheduleMode === "on_new_notification"
375
- ? "Runs each time a new notification arrives on the paired Android device."
446
+ ? "Runs each time a new push notification arrives on the paired Android device."
376
447
  : "Runs each time a new SMS arrives on the paired Android device."}
377
448
  {" "}The triggering payload is spliced into your task prompt — reference it as &ldquo;the new {scheduleMode === "on_new_notification" ? "notification" : "SMS"}&rdquo;.
378
449
  </p>
450
+ {scheduleMode === "on_new_notification" && (() => {
451
+ const selected = knownApps.find((a) => a.packageName === notificationApp);
452
+ const selectedLabel = selected?.appName || notificationApp;
453
+ return (
454
+ <>
455
+ {isNotificationListener ? (
456
+ notificationApp.trim() ? (
457
+ <div className="app-filter-selected">
458
+ <span className="app-filter-selected-name">{selectedLabel}</span>
459
+ {selected?.appName && <span className="app-filter-selected-pkg">{selected.packageName}</span>}
460
+ <button
461
+ type="button"
462
+ className="app-filter-selected-clear"
463
+ onClick={() => setNotificationApp("")}
464
+ aria-label="Clear app filter"
465
+ disabled={saving}
466
+ >
467
+
468
+ </button>
469
+ </div>
470
+ ) : (
471
+ <button
472
+ type="button"
473
+ className="btn btn-link app-filter-trigger"
474
+ onClick={() => { setAppSearch(""); setAppFilterOpen(true); }}
475
+ disabled={saving}
476
+ >
477
+ Select app
478
+ </button>
479
+ )
480
+ ) : (
481
+ <input
482
+ className="form-input"
483
+ type="text"
484
+ value={notificationApp}
485
+ onChange={(e) => setNotificationApp(e.target.value)}
486
+ placeholder="App (optional), e.g. com.google.android.gm"
487
+ disabled={saving}
488
+ />
489
+ )}
490
+ <p className="command-help-text app-filter-help">
491
+ {notificationApp.trim()
492
+ ? `Only notifications from ${selectedLabel} will trigger this task.`
493
+ : "Every notification from your device triggers this task."}
494
+ </p>
495
+ </>
496
+ );
497
+ })()}
498
+ {scheduleMode === "on_new_sms" && (
499
+ <>
500
+ <input
501
+ className="form-input"
502
+ type="text"
503
+ value={smsSender}
504
+ onChange={(e) => setSmsSender(e.target.value)}
505
+ placeholder="From (optional), e.g. +1 555-1234)"
506
+ disabled={saving}
507
+ />
508
+ <p className="command-help-text app-filter-help">
509
+ {smsSender.trim()
510
+ ? `Only SMS from ${smsSender.trim()} will trigger this task. Formatting (spaces, dashes, parens) is ignored.`
511
+ : "Every SMS that arrives on your device triggers this task."}
512
+ </p>
513
+ </>
514
+ )}
379
515
  </div>
380
516
  )}
381
517
 
@@ -549,6 +685,72 @@ export default function TaskForm({ initial, agents, hostPlatform, onSaved, onRun
549
685
 
550
686
  </>)}
551
687
  </div>
688
+ {appFilterOpen && (() => {
689
+ const q = appSearch.trim().toLowerCase();
690
+ const filtered = q
691
+ ? knownApps.filter((a) => a.packageName.toLowerCase().includes(q) || a.appName.toLowerCase().includes(q))
692
+ : knownApps;
693
+ return (
694
+ <div className="app-filter-overlay" onClick={closeAppFilter}>
695
+ <div className="app-filter-dialog" onClick={(e) => e.stopPropagation()}>
696
+ <div className="app-filter-header">
697
+ <h2>Select app</h2>
698
+ <button className="app-filter-close" onClick={closeAppFilter} aria-label="Close">
699
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none">
700
+ <path d="M4 4L12 12M12 4L4 12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
701
+ </svg>
702
+ </button>
703
+ </div>
704
+ <input
705
+ className="form-input app-filter-search"
706
+ type="text"
707
+ value={appSearch}
708
+ onChange={(e) => setAppSearch(e.target.value)}
709
+ onKeyDown={(e) => {
710
+ if (e.key === "Enter" && appSearch.trim()) {
711
+ setNotificationApp(appSearch.trim());
712
+ closeAppFilter();
713
+ }
714
+ }}
715
+ placeholder="Search or type a package name"
716
+ autoFocus
717
+ />
718
+ <ul className="app-filter-list">
719
+ {knownAppsLoading && knownApps.length === 0
720
+ ? Array.from({ length: 6 }).map((_, i) => (
721
+ <li key={`sk-${i}`} className="app-filter-row app-filter-skeleton">
722
+ <div className="app-filter-skeleton-bar" />
723
+ </li>
724
+ ))
725
+ : filtered.length === 0 && !appSearch.trim()
726
+ ? <li className="app-filter-empty">No apps</li>
727
+ : filtered.map((a) => (
728
+ <li
729
+ key={a.packageName}
730
+ className="app-filter-row"
731
+ onClick={() => { setNotificationApp(a.packageName); closeAppFilter(); }}
732
+ >
733
+ <div className="app-filter-row-labels">
734
+ <div className="app-filter-row-name">{a.appName || a.packageName}</div>
735
+ {a.appName && <div className="app-filter-row-pkg">{a.packageName}</div>}
736
+ </div>
737
+ </li>
738
+ ))}
739
+ {appSearch.trim() && (
740
+ <li
741
+ className="app-filter-row"
742
+ onClick={() => { setNotificationApp(appSearch.trim()); closeAppFilter(); }}
743
+ >
744
+ <div className="app-filter-row-labels">
745
+ <div className="app-filter-row-name">{appSearch.trim()}</div>
746
+ </div>
747
+ </li>
748
+ )}
749
+ </ul>
750
+ </div>
751
+ </div>
752
+ );
753
+ })()}
552
754
  </div>
553
755
  );
554
756
  }
@@ -12,10 +12,11 @@ interface TasksViewProps {
12
12
  subscribeEvents(hostId: string, callback: (msg: { subject: string; data: Uint8Array }) => void): () => void;
13
13
  agents: AgentInfo[];
14
14
  hostPlatform?: string;
15
+ isNotificationListener: boolean;
15
16
  onViewRun(taskId: string, runId?: string): void;
16
17
  }
17
18
 
18
- export default function TasksView({ connected, hostId, request, subscribeEvents, agents, hostPlatform, onViewRun }: TasksViewProps) {
19
+ export default function TasksView({ connected, hostId, request, subscribeEvents, agents, hostPlatform, isNotificationListener, onViewRun }: TasksViewProps) {
19
20
  const [tasks, setTasks] = useState<Task[]>([]);
20
21
  const [loadingTasks, setLoadingTasks] = useState(false);
21
22
  const [taskError, setTaskError] = useState<string | null>(null);
@@ -167,6 +168,7 @@ export default function TasksView({ connected, hostId, request, subscribeEvents,
167
168
  initial={editingTask}
168
169
  agents={agents}
169
170
  hostPlatform={hostPlatform}
171
+ isNotificationListener={isNotificationListener}
170
172
  onSaved={handleTaskSaved}
171
173
  onRun={onViewRun}
172
174
  onCancel={closeForm}
@@ -1,2 +1,2 @@
1
1
  /** Bump when a breaking host change is made. */
2
- export const MIN_HOST_VERSION = "0.8.0";
2
+ export const MIN_HOST_VERSION = "0.8.3";
@@ -2,12 +2,14 @@ import { Capacitor, registerPlugin, type PluginListenerHandle } from "@capacitor
2
2
 
3
3
  export type PermissionType =
4
4
  | "location"
5
- | "sms"
5
+ | "smsRead"
6
+ | "smsSend"
6
7
  | "contacts"
7
8
  | "calendar"
8
9
  | "notificationListener"
9
10
  | "dnd"
10
- | "fullScreenIntent";
11
+ | "fullScreenIntent"
12
+ | "postNotifications";
11
13
 
12
14
  export interface PermissionResult {
13
15
  granted: boolean;
@@ -23,6 +25,11 @@ export interface DeepLinkEvent {
23
25
  path: string;
24
26
  }
25
27
 
28
+ export interface InstalledApp {
29
+ packageName: string;
30
+ appName: string;
31
+ }
32
+
26
33
  interface DevicePlugin {
27
34
  getFcmToken(): Promise<{ token: string }>;
28
35
  /** Returns the set of PermissionType strings this native build understands. */
@@ -36,6 +43,15 @@ interface DevicePlugin {
36
43
  * ahead of the installed APK.
37
44
  */
38
45
  setEnabledCapabilities(opts: { capabilities: string[] }): Promise<void>;
46
+ /** Returns user-visible (launcher) apps on the device, with 96x96 PNG icons. */
47
+ getInstalledApps(): Promise<{ apps: InstalledApp[] }>;
48
+ /**
49
+ * Returns whether the device has at least one app that can handle a mailto:
50
+ * intent. Used to gate the Sending Email capability — silent PackageManager
51
+ * lookup, no side effects. `supported: false` on older APKs that don't expose
52
+ * this method; treat as "cannot enable" rather than as a hard error.
53
+ */
54
+ hasEmailClient(): Promise<{ available: boolean; supported: boolean }>;
39
55
  addListener(
40
56
  event: "deepLink",
41
57
  handler: (ev: DeepLinkEvent) => void
@@ -282,7 +282,7 @@ export default function Dashboard() {
282
282
  } catch {
283
283
  // Expected: connection drops during daemon restart
284
284
  }
285
- setTimeout(() => window.location.reload(), 10000);
285
+ setTimeout(() => window.location.reload(), 15000);
286
286
  }
287
287
 
288
288
  const hasHosts = pairedHosts.length > 0;
@@ -294,10 +294,15 @@ export default function Dashboard() {
294
294
  {isDesktop && <HostMenu daemonVersion={daemonVersion} capabilityTokens={capabilityTokens} activeClientToken={activeClientToken} request={request} onCapabilityTokensChange={setCapabilityTokens} />}
295
295
 
296
296
  <div className="dashboard-content">
297
- <div className="tab-bar">
298
- {!isDesktop && <HostMenu daemonVersion={daemonVersion} capabilityTokens={capabilityTokens} activeClientToken={activeClientToken} request={request} onCapabilityTokensChange={setCapabilityTokens} />}
299
- <TabBar />
300
- </div>
297
+ <header className="app-header">
298
+ <div className="app-title-bar">
299
+ {!isDesktop && <HostMenu daemonVersion={daemonVersion} capabilityTokens={capabilityTokens} activeClientToken={activeClientToken} request={request} onCapabilityTokensChange={setCapabilityTokens} />}
300
+ <h1 className="app-title">Palmier</h1>
301
+ </div>
302
+ <div className="tab-bar">
303
+ <TabBar />
304
+ </div>
305
+ </header>
301
306
 
302
307
  <main className="dashboard-main">
303
308
  {unauthorized ? (
@@ -343,6 +348,7 @@ export default function Dashboard() {
343
348
  subscribeEvents={subscribeEvents}
344
349
  agents={agents}
345
350
  hostPlatform={hostPlatform}
351
+ isNotificationListener={!!activeClientToken && capabilityTokens["notifications"] === activeClientToken}
346
352
  onViewRun={handleViewRun}
347
353
  />
348
354
  )}
@@ -362,6 +368,7 @@ export default function Dashboard() {
362
368
  request={request}
363
369
  subscribeEvents={subscribeEvents}
364
370
  agents={agents}
371
+ hostPlatform={hostPlatform}
365
372
  filterTaskId={runsFilterTaskId}
366
373
  onClearFilter={() => { if (confirmLeaveDraft()) navigate("/"); }}
367
374
  />
@@ -369,7 +376,7 @@ export default function Dashboard() {
369
376
  </>
370
377
  ) : (
371
378
  <div className="empty-state">
372
- <p>{hasHosts ? "Connecting to host..." : "No hosts paired yet."}</p>
379
+ <p>{hasHosts ? "Connecting to host..." : "No host computer paired yet."}</p>
373
380
  {!hasHosts && (
374
381
  <button
375
382
  className="btn btn-primary"
@@ -12,6 +12,7 @@ interface PairResponse {
12
12
  hostId: string;
13
13
  clientToken: string;
14
14
  directUrl?: string;
15
+ hostName?: string;
15
16
  }
16
17
 
17
18
  /** LAN mode: PWA is served by palmier serve (marker injected into HTML). */
@@ -82,6 +83,7 @@ export default function PairHost() {
82
83
  hostId: response.hostId,
83
84
  clientToken: response.clientToken,
84
85
  directUrl: isLanMode ? window.location.origin : undefined,
86
+ ...(response.hostName ? { name: response.hostName } : {}),
85
87
  };
86
88
 
87
89
  addPairedHost(host);
@@ -106,7 +108,7 @@ export default function PairHost() {
106
108
  }
107
109
  }
108
110
 
109
- navigate("/");
111
+ navigate(Capacitor.isNativePlatform() ? "/pair/setup" : "/");
110
112
  } catch (err) {
111
113
  const message = err instanceof Error ? err.message : String(err);
112
114
  if (message.includes("timeout") || message.includes("TIMEOUT") || message.includes("503") || message.toLowerCase().includes("no responders")) {
@@ -0,0 +1,70 @@
1
+ import { useEffect, useState } from "react";
2
+ import { useNavigate } from "react-router-dom";
3
+ import { useHostConnection } from "../contexts/HostConnectionContext";
4
+ import { useHostStore } from "../contexts/HostStoreContext";
5
+ import CapabilityToggles from "../components/CapabilityToggles";
6
+
7
+ interface HostInfoResponse {
8
+ capability_tokens?: Record<string, string | null>;
9
+ }
10
+
11
+ export default function PairSetup() {
12
+ const navigate = useNavigate();
13
+ const { connected, request } = useHostConnection();
14
+ const { getActiveHost } = useHostStore();
15
+ const activeHost = getActiveHost();
16
+ const activeClientToken = activeHost?.clientToken ?? null;
17
+
18
+ const [capabilityTokens, setCapabilityTokens] = useState<Record<string, string | null>>({});
19
+ const [loaded, setLoaded] = useState(false);
20
+
21
+ // If the user lands here without an active host (direct URL, refresh), bounce
22
+ // back to the dashboard — setup only makes sense right after pairing.
23
+ useEffect(() => {
24
+ if (!activeHost) navigate("/", { replace: true });
25
+ }, [activeHost, navigate]);
26
+
27
+ useEffect(() => {
28
+ if (!connected || !activeHost) return;
29
+ let cancelled = false;
30
+ request<HostInfoResponse>("host.info")
31
+ .then((res) => {
32
+ if (cancelled) return;
33
+ setCapabilityTokens(res.capability_tokens ?? {});
34
+ setLoaded(true);
35
+ })
36
+ .catch(() => { if (!cancelled) setLoaded(true); });
37
+ return () => { cancelled = true; };
38
+ }, [connected, activeHost, request]);
39
+
40
+ return (
41
+ <div className="pair-setup">
42
+ <div className="pair-setup-inner">
43
+ <h1 className="pair-setup-title">What capabilities of this device do you want your host computer to have?</h1>
44
+ <p className="pair-setup-description">
45
+ You can change these later from the menu.
46
+ </p>
47
+
48
+ {!loaded ? (
49
+ <div className="pair-setup-loading">Connecting to host…</div>
50
+ ) : (
51
+ <CapabilityToggles
52
+ capabilityTokens={capabilityTokens}
53
+ activeClientToken={activeClientToken}
54
+ request={request}
55
+ onCapabilityTokensChange={setCapabilityTokens}
56
+ />
57
+ )}
58
+
59
+ <div className="pair-setup-actions">
60
+ <button
61
+ className="btn btn-primary btn-full"
62
+ onClick={() => navigate("/", { replace: true })}
63
+ >
64
+ Finish
65
+ </button>
66
+ </div>
67
+ </div>
68
+ </div>
69
+ );
70
+ }
@@ -392,12 +392,12 @@ async function main(): Promise<void> {
392
392
  }
393
393
  })();
394
394
 
395
- // Subscribe to alert requests from hosts
395
+ // Subscribe to alarm requests from hosts
396
396
  (async () => {
397
397
  try {
398
398
  const conn = await getNatsConnection();
399
- const sub = conn.subscribe("host.*.fcm.alert");
400
- console.log("Listening for FCM alert requests");
399
+ const sub = conn.subscribe("host.*.fcm.alarm");
400
+ console.log("Listening for FCM alarm requests");
401
401
 
402
402
  for await (const msg of sub) {
403
403
  try {
@@ -418,14 +418,14 @@ async function main(): Promise<void> {
418
418
  }
419
419
 
420
420
  const fcmPayload: Record<string, string> = {
421
- type: "send-alert",
421
+ type: "send-alarm",
422
422
  requestId: data.requestId,
423
423
  hostId: data.hostId,
424
424
  };
425
425
  if (data.title) fcmPayload.title = data.title;
426
426
  if (data.description) fcmPayload.description = data.description;
427
427
 
428
- console.log(`[FCM] Sending alert request for host ${data.hostId}`);
428
+ console.log(`[FCM] Sending alarm request for host ${data.hostId}`);
429
429
  if (data.fcmToken) {
430
430
  await sendFcmToDevice(data.fcmToken, fcmPayload);
431
431
  } else {
@@ -436,14 +436,14 @@ async function main(): Promise<void> {
436
436
  msg.respond(sc.encode(JSON.stringify({ ok: true })));
437
437
  }
438
438
  } catch (err) {
439
- console.error("[FCM] Error handling alert request:", err);
439
+ console.error("[FCM] Error handling alarm request:", err);
440
440
  if (msg.reply) {
441
441
  msg.respond(sc.encode(JSON.stringify({ error: String(err) })));
442
442
  }
443
443
  }
444
444
  }
445
445
  } catch (err) {
446
- console.error("Failed to subscribe to FCM alert requests:", err);
446
+ console.error("Failed to subscribe to FCM alarm requests:", err);
447
447
  }
448
448
  })();
449
449
 
@@ -125,8 +125,8 @@ router.post("/sms-response", async (req: Request, res: Response) => {
125
125
  }
126
126
  });
127
127
 
128
- // POST /api/device/alert-response - Receive alert response from Android, relay to host via NATS
129
- router.post("/alert-response", async (req: Request, res: Response) => {
128
+ // POST /api/device/alarm-response - Receive alarm response from Android, relay to host via NATS
129
+ router.post("/alarm-response", async (req: Request, res: Response) => {
130
130
  try {
131
131
  const { requestId, hostId, result } = req.body;
132
132
 
@@ -138,13 +138,13 @@ router.post("/alert-response", async (req: Request, res: Response) => {
138
138
  const conn = await getNatsConnection();
139
139
  const sc = StringCodec();
140
140
  conn.publish(
141
- `host.${hostId}.alert.${requestId}`,
141
+ `host.${hostId}.alarm.${requestId}`,
142
142
  sc.encode(JSON.stringify(result)),
143
143
  );
144
144
 
145
145
  res.json({ ok: true });
146
146
  } catch (err) {
147
- console.error("Device alert response relay error:", err);
147
+ console.error("Device alarm response relay error:", err);
148
148
  res.status(500).json({ error: "Internal server error" });
149
149
  }
150
150
  });