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.
- package/CLAUDE.md +13 -0
- package/README.md +16 -14
- package/dist/agents/agent.d.ts +0 -4
- package/dist/agents/claude.js +1 -1
- package/dist/agents/codex.js +2 -2
- package/dist/agents/cursor.js +1 -1
- package/dist/agents/deepagents.js +1 -1
- package/dist/agents/gemini.js +3 -2
- package/dist/agents/goose.js +1 -1
- package/dist/agents/hermes.js +1 -1
- package/dist/agents/kiro.js +1 -1
- package/dist/agents/opencode.js +1 -1
- package/dist/agents/qoder.js +1 -1
- package/dist/agents/shared-prompt.d.ts +0 -3
- package/dist/agents/shared-prompt.js +0 -3
- package/dist/commands/info.d.ts +0 -3
- package/dist/commands/info.js +0 -5
- package/dist/commands/init.d.ts +0 -3
- package/dist/commands/init.js +2 -11
- package/dist/commands/pair.d.ts +1 -4
- package/dist/commands/pair.js +3 -12
- package/dist/commands/restart.d.ts +0 -3
- package/dist/commands/restart.js +0 -3
- package/dist/commands/run.d.ts +1 -14
- package/dist/commands/run.js +18 -61
- package/dist/commands/serve.d.ts +0 -3
- package/dist/commands/serve.js +29 -27
- package/dist/config.d.ts +0 -8
- package/dist/config.js +0 -8
- package/dist/device-capabilities.d.ts +1 -1
- package/dist/event-queues.d.ts +6 -21
- package/dist/event-queues.js +6 -21
- package/dist/events.d.ts +0 -6
- package/dist/events.js +1 -9
- package/dist/index.js +0 -1
- package/dist/mcp-handler.js +1 -2
- package/dist/mcp-tools.d.ts +0 -3
- package/dist/mcp-tools.js +12 -16
- package/dist/nats-client.d.ts +0 -3
- package/dist/nats-client.js +1 -4
- package/dist/pending-requests.d.ts +4 -18
- package/dist/pending-requests.js +4 -18
- package/dist/platform/index.d.ts +1 -4
- package/dist/platform/index.js +8 -7
- package/dist/platform/linux.d.ts +3 -9
- package/dist/platform/linux.js +9 -20
- package/dist/platform/macos.d.ts +32 -0
- package/dist/platform/macos.js +287 -0
- package/dist/platform/platform.d.ts +1 -4
- package/dist/platform/windows.d.ts +2 -5
- package/dist/platform/windows.js +19 -39
- package/dist/pwa/assets/index-499vYQvR.js +120 -0
- package/dist/pwa/assets/{index-CQxcuDhM.css → index-UaZFu6XL.css} +1 -1
- package/dist/pwa/assets/{web-DOyOiwsW.js → web-Bp48ONY3.js} +1 -1
- package/dist/pwa/assets/{web-D7Kq3Nvk.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.d.ts +0 -6
- package/dist/rpc-handler.js +14 -47
- package/dist/spawn-command.d.ts +10 -25
- package/dist/spawn-command.js +7 -15
- package/dist/task.d.ts +6 -64
- package/dist/task.js +7 -70
- package/dist/transports/http-transport.d.ts +0 -4
- package/dist/transports/http-transport.js +7 -28
- package/dist/transports/nats-transport.d.ts +0 -4
- package/dist/transports/nats-transport.js +3 -9
- package/dist/types.d.ts +3 -7
- package/dist/update-checker.d.ts +1 -4
- package/dist/update-checker.js +2 -5
- package/package.json +1 -1
- package/palmier-server/pwa/src/App.css +325 -22
- 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 +20 -207
- package/palmier-server/pwa/src/components/RunDetailView.tsx +3 -3
- package/palmier-server/pwa/src/components/SessionComposer.tsx +11 -2
- package/palmier-server/pwa/src/components/SessionsView.tsx +60 -32
- package/palmier-server/pwa/src/components/SwipeToDeleteRow.tsx +160 -0
- package/palmier-server/pwa/src/components/TaskCard.tsx +1 -1
- package/palmier-server/pwa/src/components/TaskForm.tsx +207 -5
- package/palmier-server/pwa/src/components/TasksView.tsx +3 -1
- package/palmier-server/pwa/src/constants.ts +1 -1
- package/palmier-server/pwa/src/native/Device.ts +18 -2
- package/palmier-server/pwa/src/pages/Dashboard.tsx +13 -6
- package/palmier-server/pwa/src/pages/PairHost.tsx +3 -1
- package/palmier-server/pwa/src/pages/PairSetup.tsx +70 -0
- package/palmier-server/server/src/index.ts +7 -7
- package/palmier-server/server/src/routes/device.ts +4 -4
- package/palmier-server/spec.md +38 -7
- package/src/agents/agent.ts +0 -4
- package/src/agents/claude.ts +1 -1
- package/src/agents/codex.ts +2 -2
- package/src/agents/cursor.ts +1 -1
- package/src/agents/deepagents.ts +1 -1
- package/src/agents/gemini.ts +3 -2
- package/src/agents/goose.ts +1 -1
- package/src/agents/hermes.ts +1 -1
- package/src/agents/kiro.ts +1 -1
- package/src/agents/opencode.ts +1 -1
- package/src/agents/qoder.ts +1 -1
- package/src/agents/shared-prompt.ts +0 -3
- package/src/commands/info.ts +0 -5
- package/src/commands/init.ts +2 -11
- package/src/commands/pair.ts +3 -12
- package/src/commands/restart.ts +0 -3
- package/src/commands/run.ts +18 -65
- package/src/commands/serve.ts +28 -27
- package/src/config.ts +0 -8
- package/src/device-capabilities.ts +3 -2
- package/src/event-queues.ts +6 -21
- package/src/events.ts +1 -9
- package/src/index.ts +0 -1
- package/src/mcp-handler.ts +1 -2
- package/src/mcp-tools.ts +12 -18
- package/src/nats-client.ts +1 -4
- package/src/pending-requests.ts +4 -18
- package/src/platform/index.ts +5 -7
- package/src/platform/linux.ts +9 -20
- package/src/platform/macos.ts +310 -0
- package/src/platform/platform.ts +1 -4
- package/src/platform/windows.ts +19 -40
- package/src/rpc-handler.ts +14 -47
- package/src/spawn-command.ts +11 -27
- package/src/task.ts +7 -70
- package/src/transports/http-transport.ts +7 -39
- package/src/transports/nats-transport.ts +3 -9
- package/src/types.ts +3 -10
- package/src/update-checker.ts +2 -5
- package/test/macos-plist.test.ts +112 -0
- package/test/task-parsing.test.ts +2 -3
- package/test/windows-xml.test.ts +11 -12
- 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
|
|
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
|
|
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 “the new {scheduleMode === "on_new_notification" ? "notification" : "SMS"}”.
|
|
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.
|
|
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
|
-
| "
|
|
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(),
|
|
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
|
-
<
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
|
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
|
|
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.
|
|
400
|
-
console.log("Listening for FCM
|
|
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-
|
|
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
|
|
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
|
|
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
|
|
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/
|
|
129
|
-
router.post("/
|
|
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}.
|
|
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
|
|
147
|
+
console.error("Device alarm response relay error:", err);
|
|
148
148
|
res.status(500).json({ error: "Internal server error" });
|
|
149
149
|
}
|
|
150
150
|
});
|