palmier 0.8.0 → 0.8.3
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 +11 -11
- 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/app-registry.d.ts +10 -0
- package/dist/app-registry.js +44 -0
- 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 +1 -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 +33 -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 +14 -18
- 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 +1 -4
- package/dist/platform/linux.d.ts +3 -9
- package/dist/platform/linux.js +9 -20
- 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-B0F9mtid.css +1 -0
- package/dist/pwa/assets/index-SYs3mcdJ.js +120 -0
- package/dist/pwa/assets/{web-CF-N8Di6.js → web-C6lkQj9J.js} +1 -1
- package/dist/pwa/assets/{web-BpM3fNCn.js → web-Z1623me-.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 +19 -48
- 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 +6 -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/README.md +1 -1
- package/palmier-server/pwa/src/App.css +170 -20
- package/palmier-server/pwa/src/App.tsx +15 -1
- package/palmier-server/pwa/src/components/HostMenu.tsx +282 -473
- package/palmier-server/pwa/src/components/RunDetailView.tsx +3 -3
- package/palmier-server/pwa/src/components/SessionsView.tsx +57 -25
- package/palmier-server/pwa/src/components/SwipeToDeleteRow.tsx +160 -0
- package/palmier-server/pwa/src/components/TaskCard.tsx +12 -4
- package/palmier-server/pwa/src/components/TaskForm.tsx +230 -33
- package/palmier-server/pwa/src/components/TasksView.tsx +5 -0
- package/palmier-server/pwa/src/constants.ts +1 -1
- package/palmier-server/pwa/src/native/Device.ts +66 -0
- package/palmier-server/pwa/src/pages/Dashboard.tsx +11 -6
- package/palmier-server/pwa/src/pages/PairHost.tsx +18 -2
- package/palmier-server/pwa/src/types.ts +1 -1
- package/palmier-server/server/src/index.ts +7 -7
- package/palmier-server/server/src/routes/device.ts +4 -4
- package/palmier-server/spec.md +47 -6
- 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/app-registry.ts +52 -0
- package/src/commands/info.ts +0 -5
- package/src/commands/init.ts +2 -11
- package/src/commands/pair.ts +1 -12
- package/src/commands/restart.ts +0 -3
- package/src/commands/run.ts +18 -65
- package/src/commands/serve.ts +31 -27
- package/src/config.ts +0 -8
- package/src/device-capabilities.ts +4 -3
- 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 +14 -20
- package/src/nats-client.ts +1 -4
- package/src/pending-requests.ts +4 -18
- package/src/platform/index.ts +1 -4
- package/src/platform/linux.ts +9 -20
- package/src/platform/platform.ts +1 -4
- package/src/platform/windows.ts +19 -40
- package/src/rpc-handler.ts +20 -48
- package/src/spawn-command.ts +11 -27
- package/src/task.ts +7 -70
- package/src/transports/http-transport.ts +6 -39
- package/src/transports/nats-transport.ts +3 -9
- package/src/types.ts +3 -10
- package/src/update-checker.ts +2 -5
- package/test/task-parsing.test.ts +2 -3
- package/test/windows-xml.test.ts +11 -12
- package/dist/pwa/assets/index-FP1Mipr6.js +0 -120
- package/dist/pwa/assets/index-bLTn8zBj.css +0 -1
|
@@ -1,22 +1,29 @@
|
|
|
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
|
-
type ScheduleType = "crons" | "specific_times";
|
|
9
|
+
type ScheduleType = "crons" | "specific_times" | "on_new_notification" | "on_new_sms";
|
|
10
|
+
type EventMode = "on_new_notification" | "on_new_sms";
|
|
8
11
|
|
|
9
12
|
|
|
10
13
|
const DAYS_OF_WEEK = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
|
11
14
|
|
|
12
15
|
type ScheduleSlot = "specific_times" | "hourly" | "daily" | "weekly" | "monthly";
|
|
13
|
-
type ScheduleMode = "ondemand" | "command" | ScheduleSlot;
|
|
16
|
+
type ScheduleMode = "ondemand" | "command" | EventMode | ScheduleSlot;
|
|
14
17
|
|
|
15
18
|
const SCHEDULE_SLOTS: readonly ScheduleMode[] = ["specific_times", "hourly", "daily", "weekly", "monthly"];
|
|
16
19
|
function isScheduleSlot(mode: ScheduleMode): mode is ScheduleSlot {
|
|
17
20
|
return (SCHEDULE_SLOTS as readonly string[]).includes(mode);
|
|
18
21
|
}
|
|
19
22
|
|
|
23
|
+
function isEventMode(mode: ScheduleMode): mode is EventMode {
|
|
24
|
+
return mode === "on_new_notification" || mode === "on_new_sms";
|
|
25
|
+
}
|
|
26
|
+
|
|
20
27
|
interface TriggerRow {
|
|
21
28
|
schedule: ScheduleSlot;
|
|
22
29
|
time: string;
|
|
@@ -41,7 +48,7 @@ function cronToRow(cron: string): TriggerRow {
|
|
|
41
48
|
return { ...newRow("daily"), time };
|
|
42
49
|
}
|
|
43
50
|
|
|
44
|
-
function valueToRow(scheduleType:
|
|
51
|
+
function valueToRow(scheduleType: "crons" | "specific_times", value: string): TriggerRow {
|
|
45
52
|
if (scheduleType === "specific_times") {
|
|
46
53
|
const [datePart, timePart] = value.split("T");
|
|
47
54
|
return { ...newRow("specific_times"), onceDate: datePart ?? "", onceTime: (timePart ?? "09:00").slice(0, 5) };
|
|
@@ -67,15 +74,21 @@ function rowToValue(row: TriggerRow): string | null {
|
|
|
67
74
|
return rowToCron(row);
|
|
68
75
|
}
|
|
69
76
|
|
|
70
|
-
function modeToScheduleType(mode: ScheduleSlot): ScheduleType {
|
|
71
|
-
|
|
77
|
+
function modeToScheduleType(mode: ScheduleSlot | EventMode): ScheduleType {
|
|
78
|
+
if (mode === "specific_times") return "specific_times";
|
|
79
|
+
if (mode === "on_new_notification") return "on_new_notification";
|
|
80
|
+
if (mode === "on_new_sms") return "on_new_sms";
|
|
81
|
+
return "crons";
|
|
72
82
|
}
|
|
73
83
|
|
|
74
84
|
function initialScheduleMode(initial?: Task): ScheduleMode {
|
|
75
|
-
if (initial
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
if (
|
|
85
|
+
if (!initial) return "daily";
|
|
86
|
+
if (initial.command) return "command";
|
|
87
|
+
const type = initial.schedule_type;
|
|
88
|
+
if (type === "on_new_notification" || type === "on_new_sms") return type;
|
|
89
|
+
if (type !== "crons" && type !== "specific_times") return "ondemand";
|
|
90
|
+
const first = initial.schedule_values?.[0];
|
|
91
|
+
if (!first) return "ondemand";
|
|
79
92
|
return valueToRow(type, first).schedule;
|
|
80
93
|
}
|
|
81
94
|
|
|
@@ -113,22 +126,92 @@ export default function TaskForm({ initial, agents, hostPlatform, onSaved, onRun
|
|
|
113
126
|
() => {
|
|
114
127
|
const type = initial?.schedule_type;
|
|
115
128
|
const values = initial?.schedule_values;
|
|
116
|
-
if (
|
|
117
|
-
|
|
129
|
+
if (values && (type === "crons" || type === "specific_times")) {
|
|
130
|
+
return values.map((v) => valueToRow(type, v));
|
|
131
|
+
}
|
|
132
|
+
const mode = initialScheduleMode(initial);
|
|
133
|
+
return isScheduleSlot(mode) ? [newRow(mode)] : [];
|
|
118
134
|
}
|
|
119
135
|
);
|
|
120
136
|
const [requiresConfirmation, setRequiresConfirmation] = useState(
|
|
121
137
|
initial?.requires_confirmation ?? false
|
|
122
138
|
);
|
|
139
|
+
const [scheduleEnabled, setScheduleEnabled] = useState(initial?.schedule_enabled ?? true);
|
|
123
140
|
const [yoloMode, setYoloMode] = useState(initial?.yolo_mode ?? false);
|
|
124
141
|
const [foregroundMode, setForegroundMode] = useState(initial?.foreground_mode ?? false);
|
|
125
142
|
const [command, setCommand] = useState(initial?.command ?? "");
|
|
126
143
|
const [saving, setSaving] = useState(false);
|
|
127
144
|
|
|
145
|
+
// Single optional app filter for on_new_notification tasks. Empty = any app;
|
|
146
|
+
// non-empty = single packageName filter (stored as a one-entry schedule_values
|
|
147
|
+
// array on save). Datalist options come from the host-side app registry.
|
|
148
|
+
// No migration: pre-existing multi-app tasks truncate to the first entry on
|
|
149
|
+
// first edit.
|
|
150
|
+
const initialNotificationApp: string = (() => {
|
|
151
|
+
if (initial?.schedule_type === "on_new_notification" && initial.schedule_values && initial.schedule_values.length > 0) {
|
|
152
|
+
return initial.schedule_values[0];
|
|
153
|
+
}
|
|
154
|
+
return "";
|
|
155
|
+
})();
|
|
156
|
+
const [notificationApp, setNotificationApp] = useState<string>(initialNotificationApp);
|
|
157
|
+
const [knownApps, setKnownApps] = useState<Array<{ packageName: string; appName: string; icon?: string }>>([]);
|
|
158
|
+
const [appDropdownOpen, setAppDropdownOpen] = useState(false);
|
|
159
|
+
|
|
160
|
+
// Merge native launcher enum (complete, native-only) with host registry
|
|
161
|
+
// (apps already seen — fills the gap on non-native clients). Dedup by
|
|
162
|
+
// packageName; free-form typing still works if neither knows the package.
|
|
163
|
+
useEffect(() => {
|
|
164
|
+
if (scheduleMode !== "on_new_notification") return;
|
|
165
|
+
let cancelled = false;
|
|
166
|
+
|
|
167
|
+
const merged = new Map<string, { packageName: string; appName: string; icon?: string }>();
|
|
168
|
+
const PALMIER_PACKAGE = "com.palmier.app";
|
|
169
|
+
const flush = () => { if (!cancelled) setKnownApps(Array.from(merged.values()).sort((a, b) => (a.appName || a.packageName).localeCompare(b.appName || b.packageName))); };
|
|
170
|
+
|
|
171
|
+
if (Capacitor.isNativePlatform() && Device) {
|
|
172
|
+
Device.getInstalledApps()
|
|
173
|
+
.then(({ apps }) => {
|
|
174
|
+
if (cancelled) return;
|
|
175
|
+
for (const a of apps) {
|
|
176
|
+
if (a.packageName === PALMIER_PACKAGE) continue;
|
|
177
|
+
merged.set(a.packageName, { packageName: a.packageName, appName: a.appName, icon: a.icon });
|
|
178
|
+
}
|
|
179
|
+
flush();
|
|
180
|
+
})
|
|
181
|
+
.catch(() => {});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
request<{ apps?: Array<{ packageName: string; appName: string }> }>("device.notifications.apps")
|
|
185
|
+
.then((res) => {
|
|
186
|
+
if (cancelled || !res.apps) return;
|
|
187
|
+
for (const a of res.apps) {
|
|
188
|
+
if (a.packageName === PALMIER_PACKAGE) continue;
|
|
189
|
+
if (!merged.has(a.packageName)) merged.set(a.packageName, a);
|
|
190
|
+
}
|
|
191
|
+
flush();
|
|
192
|
+
})
|
|
193
|
+
.catch(() => {});
|
|
194
|
+
|
|
195
|
+
return () => { cancelled = true; };
|
|
196
|
+
}, [scheduleMode]);
|
|
197
|
+
|
|
198
|
+
// Sender filter for on_new_sms tasks. Empty string = any sender; non-empty =
|
|
199
|
+
// whitelist a single sender (stored as a single-entry schedule_values array
|
|
200
|
+
// on save). Matching is normalized on the host — users don't need to worry
|
|
201
|
+
// about exact phone-number formatting.
|
|
202
|
+
const initialSmsSender: string = (() => {
|
|
203
|
+
if (initial?.schedule_type === "on_new_sms" && initial.schedule_values && initial.schedule_values.length > 0) {
|
|
204
|
+
return initial.schedule_values[0];
|
|
205
|
+
}
|
|
206
|
+
return "";
|
|
207
|
+
})();
|
|
208
|
+
const [smsSender, setSmsSender] = useState<string>(initialSmsSender);
|
|
209
|
+
|
|
128
210
|
const commandInputRef = useRef<HTMLInputElement>(null);
|
|
129
211
|
|
|
130
212
|
const modeIsScheduled = isScheduleSlot(scheduleMode);
|
|
131
213
|
const modeIsCommand = scheduleMode === "command";
|
|
214
|
+
const modeIsEvent = isEventMode(scheduleMode);
|
|
132
215
|
|
|
133
216
|
const selectedAgent = agents.find((a) => a.key === agent);
|
|
134
217
|
const agentSupportsYolo = !!selectedAgent?.supportsYolo;
|
|
@@ -145,13 +228,17 @@ export default function TaskForm({ initial, agents, hostPlatform, onSaved, onRun
|
|
|
145
228
|
|| agent !== (initial?.agent ?? "")
|
|
146
229
|
|| scheduleMode !== initialMode
|
|
147
230
|
|| requiresConfirmation !== (initial?.requires_confirmation ?? false)
|
|
231
|
+
|| scheduleEnabled !== (initial?.schedule_enabled ?? true)
|
|
148
232
|
|| yoloMode !== (initial?.yolo_mode ?? false)
|
|
149
233
|
|| foregroundMode !== (initial?.foreground_mode ?? false)
|
|
150
234
|
|| (modeIsCommand && command !== (initial?.command ?? ""))
|
|
151
235
|
|| (modeIsScheduled && (
|
|
152
236
|
JSON.stringify(collectScheduleValues()) !== JSON.stringify(initial?.schedule_values ?? [])
|
|
153
237
|
|| modeToScheduleType(scheduleMode as ScheduleSlot) !== (initial?.schedule_type ?? undefined)
|
|
154
|
-
))
|
|
238
|
+
))
|
|
239
|
+
|| (modeIsEvent && scheduleMode !== initial?.schedule_type)
|
|
240
|
+
|| (scheduleMode === "on_new_notification" && notificationApp.trim() !== initialNotificationApp.trim())
|
|
241
|
+
|| (scheduleMode === "on_new_sms" && smsSender.trim() !== initialSmsSender.trim());
|
|
155
242
|
|
|
156
243
|
const hasInvalidTrigger = modeIsScheduled && triggerRows.some((r) =>
|
|
157
244
|
r.schedule === "specific_times" && (!r.onceDate || new Date(`${r.onceDate}T${r.onceTime}`) <= new Date())
|
|
@@ -172,7 +259,7 @@ export default function TaskForm({ initial, agents, hostPlatform, onSaved, onRun
|
|
|
172
259
|
|
|
173
260
|
function addRow() {
|
|
174
261
|
if (!isScheduleSlot(scheduleMode)) return;
|
|
175
|
-
setTriggerRows((prev) => [...prev, newRow(scheduleMode)]);
|
|
262
|
+
setTriggerRows((prev) => [...prev, prev.length > 0 ? { ...prev[prev.length - 1] } : newRow(scheduleMode)]);
|
|
176
263
|
}
|
|
177
264
|
|
|
178
265
|
function changeScheduleMode(next: ScheduleMode) {
|
|
@@ -191,6 +278,12 @@ export default function TaskForm({ initial, agents, hostPlatform, onSaved, onRun
|
|
|
191
278
|
} else {
|
|
192
279
|
setCommand("");
|
|
193
280
|
}
|
|
281
|
+
if (next !== "on_new_notification") {
|
|
282
|
+
setNotificationApp(initialNotificationApp);
|
|
283
|
+
}
|
|
284
|
+
if (next !== "on_new_sms") {
|
|
285
|
+
setSmsSender(initialSmsSender);
|
|
286
|
+
}
|
|
194
287
|
}
|
|
195
288
|
|
|
196
289
|
function collectScheduleValues(): string[] {
|
|
@@ -211,15 +304,25 @@ export default function TaskForm({ initial, agents, hostPlatform, onSaved, onRun
|
|
|
211
304
|
setSaving(true);
|
|
212
305
|
setError(null);
|
|
213
306
|
try {
|
|
214
|
-
const scheduleValues = modeIsScheduled
|
|
215
|
-
|
|
307
|
+
const scheduleValues = modeIsScheduled
|
|
308
|
+
? collectScheduleValues()
|
|
309
|
+
: scheduleMode === "on_new_notification" && notificationApp.trim()
|
|
310
|
+
? [notificationApp.trim()]
|
|
311
|
+
: scheduleMode === "on_new_sms" && smsSender.trim()
|
|
312
|
+
? [smsSender.trim()]
|
|
313
|
+
: [];
|
|
314
|
+
const scheduleType: ScheduleType | null = modeIsScheduled
|
|
315
|
+
? modeToScheduleType(scheduleMode as ScheduleSlot)
|
|
316
|
+
: modeIsEvent
|
|
317
|
+
? modeToScheduleType(scheduleMode as EventMode)
|
|
318
|
+
: null;
|
|
216
319
|
const payload: Record<string, unknown> = {
|
|
217
320
|
user_prompt: userPrompt,
|
|
218
321
|
agent,
|
|
219
|
-
schedule_type:
|
|
220
|
-
schedule_values:
|
|
221
|
-
schedule_enabled:
|
|
222
|
-
requires_confirmation:
|
|
322
|
+
schedule_type: scheduleType,
|
|
323
|
+
schedule_values: scheduleValues.length > 0 ? scheduleValues : null,
|
|
324
|
+
schedule_enabled: scheduleMode !== "ondemand" && scheduleEnabled,
|
|
325
|
+
requires_confirmation: modeIsScheduled ? requiresConfirmation : false,
|
|
223
326
|
yolo_mode: yoloMode,
|
|
224
327
|
foreground_mode: foregroundMode,
|
|
225
328
|
command: modeIsCommand ? command : "",
|
|
@@ -235,7 +338,9 @@ export default function TaskForm({ initial, agents, hostPlatform, onSaved, onRun
|
|
|
235
338
|
}
|
|
236
339
|
if (!isEdit) localStorage.setItem("palmier:lastAgent", agent);
|
|
237
340
|
|
|
238
|
-
//
|
|
341
|
+
// Command-triggered on create: save the task, then start it and navigate
|
|
342
|
+
// to the run. Event-triggered tasks are started by the daemon in response
|
|
343
|
+
// to the next NATS event, so we just save.
|
|
239
344
|
if (modeIsCommand && !isEdit) {
|
|
240
345
|
onSaved(result);
|
|
241
346
|
try {
|
|
@@ -257,7 +362,7 @@ export default function TaskForm({ initial, agents, hostPlatform, onSaved, onRun
|
|
|
257
362
|
const saveButtonLabel = (() => {
|
|
258
363
|
if (isEdit) return "Save";
|
|
259
364
|
if (modeIsCommand) return "Run";
|
|
260
|
-
if (modeIsScheduled) return "Schedule";
|
|
365
|
+
if ((modeIsScheduled || modeIsEvent) && scheduleEnabled) return "Schedule";
|
|
261
366
|
return "Save";
|
|
262
367
|
})();
|
|
263
368
|
|
|
@@ -325,18 +430,6 @@ export default function TaskForm({ initial, agents, hostPlatform, onSaved, onRun
|
|
|
325
430
|
</div>
|
|
326
431
|
|
|
327
432
|
<div className="toggles-group">
|
|
328
|
-
{hostPlatform === "win32" && (
|
|
329
|
-
<label className="toggle-label">
|
|
330
|
-
<input
|
|
331
|
-
type="checkbox"
|
|
332
|
-
checked={foregroundMode}
|
|
333
|
-
onChange={(e) => setForegroundMode(e.target.checked)}
|
|
334
|
-
disabled={saving}
|
|
335
|
-
/>
|
|
336
|
-
Run in the foreground (host must login to Windows)
|
|
337
|
-
</label>
|
|
338
|
-
)}
|
|
339
|
-
|
|
340
433
|
<div className="schedule-section">
|
|
341
434
|
<h3 className="schedule-section-title">Schedule</h3>
|
|
342
435
|
<select
|
|
@@ -351,9 +444,91 @@ export default function TaskForm({ initial, agents, hostPlatform, onSaved, onRun
|
|
|
351
444
|
<option value="daily">Daily</option>
|
|
352
445
|
<option value="weekly">Weekly</option>
|
|
353
446
|
<option value="monthly">Monthly</option>
|
|
447
|
+
<option value="on_new_notification">On New Push Notification</option>
|
|
448
|
+
<option value="on_new_sms">On New SMS</option>
|
|
354
449
|
<option value="command">Command-triggered</option>
|
|
355
450
|
</select>
|
|
356
451
|
|
|
452
|
+
{modeIsEvent && (
|
|
453
|
+
<div className="schedule-reactive">
|
|
454
|
+
<p className="command-help-text">
|
|
455
|
+
{scheduleMode === "on_new_notification"
|
|
456
|
+
? "Runs each time a new notification arrives on the paired Android device."
|
|
457
|
+
: "Runs each time a new SMS arrives on the paired Android device."}
|
|
458
|
+
{" "}The triggering payload is spliced into your task prompt — reference it as “the new {scheduleMode === "on_new_notification" ? "notification" : "SMS"}”.
|
|
459
|
+
</p>
|
|
460
|
+
{scheduleMode === "on_new_notification" && (() => {
|
|
461
|
+
const q = notificationApp.trim().toLowerCase();
|
|
462
|
+
const filtered = q
|
|
463
|
+
? knownApps.filter((a) => a.packageName.toLowerCase().includes(q) || a.appName.toLowerCase().includes(q))
|
|
464
|
+
: knownApps;
|
|
465
|
+
return (
|
|
466
|
+
<>
|
|
467
|
+
<div className="app-combobox">
|
|
468
|
+
<input
|
|
469
|
+
className="form-input"
|
|
470
|
+
type="text"
|
|
471
|
+
value={notificationApp}
|
|
472
|
+
onChange={(e) => { setNotificationApp(e.target.value); setAppDropdownOpen(true); }}
|
|
473
|
+
onFocus={() => setAppDropdownOpen(true)}
|
|
474
|
+
onBlur={() => setAppDropdownOpen(false)}
|
|
475
|
+
placeholder={Capacitor.isNativePlatform() ? "App (optional)" : "App (optional, e.g. com.google.android.gm)"}
|
|
476
|
+
disabled={saving}
|
|
477
|
+
/>
|
|
478
|
+
{appDropdownOpen && filtered.length > 0 && (
|
|
479
|
+
<ul className="app-combobox-list">
|
|
480
|
+
{filtered.map((a) => (
|
|
481
|
+
<li
|
|
482
|
+
key={a.packageName}
|
|
483
|
+
className="app-combobox-row"
|
|
484
|
+
// onMouseDown instead of onClick so the input's blur doesn't close
|
|
485
|
+
// the dropdown before the selection registers.
|
|
486
|
+
onMouseDown={(e) => {
|
|
487
|
+
e.preventDefault();
|
|
488
|
+
setNotificationApp(a.packageName);
|
|
489
|
+
setAppDropdownOpen(false);
|
|
490
|
+
}}
|
|
491
|
+
>
|
|
492
|
+
{a.icon
|
|
493
|
+
? <img src={a.icon} alt="" className="app-combobox-icon" />
|
|
494
|
+
: <div className="app-combobox-icon app-combobox-icon-placeholder" />}
|
|
495
|
+
<div className="app-combobox-labels">
|
|
496
|
+
<div className="app-combobox-name">{a.appName || a.packageName}</div>
|
|
497
|
+
{a.appName && <div className="app-combobox-pkg">{a.packageName}</div>}
|
|
498
|
+
</div>
|
|
499
|
+
</li>
|
|
500
|
+
))}
|
|
501
|
+
</ul>
|
|
502
|
+
)}
|
|
503
|
+
</div>
|
|
504
|
+
<p className="command-help-text app-filter-help">
|
|
505
|
+
{notificationApp.trim()
|
|
506
|
+
? `Only notifications from ${notificationApp.trim()} will trigger this task.`
|
|
507
|
+
: "Every notification from your device triggers this task."}
|
|
508
|
+
</p>
|
|
509
|
+
</>
|
|
510
|
+
);
|
|
511
|
+
})()}
|
|
512
|
+
{scheduleMode === "on_new_sms" && (
|
|
513
|
+
<>
|
|
514
|
+
<input
|
|
515
|
+
className="form-input"
|
|
516
|
+
type="text"
|
|
517
|
+
value={smsSender}
|
|
518
|
+
onChange={(e) => setSmsSender(e.target.value)}
|
|
519
|
+
placeholder="From (optional), e.g. +1 555-1234)"
|
|
520
|
+
disabled={saving}
|
|
521
|
+
/>
|
|
522
|
+
<p className="command-help-text app-filter-help">
|
|
523
|
+
{smsSender.trim()
|
|
524
|
+
? `Only SMS from ${smsSender.trim()} will trigger this task. Formatting (spaces, dashes, parens) is ignored.`
|
|
525
|
+
: "Every SMS that arrives on your device triggers this task."}
|
|
526
|
+
</p>
|
|
527
|
+
</>
|
|
528
|
+
)}
|
|
529
|
+
</div>
|
|
530
|
+
)}
|
|
531
|
+
|
|
357
532
|
{modeIsCommand && (
|
|
358
533
|
<div className="schedule-reactive">
|
|
359
534
|
<p className="command-help-text">
|
|
@@ -461,6 +636,17 @@ export default function TaskForm({ initial, agents, hostPlatform, onSaved, onRun
|
|
|
461
636
|
</>
|
|
462
637
|
)}
|
|
463
638
|
|
|
639
|
+
{hostPlatform === "win32" && (
|
|
640
|
+
<label className="toggle-label">
|
|
641
|
+
<input
|
|
642
|
+
type="checkbox"
|
|
643
|
+
checked={foregroundMode}
|
|
644
|
+
onChange={(e) => setForegroundMode(e.target.checked)}
|
|
645
|
+
disabled={saving}
|
|
646
|
+
/>
|
|
647
|
+
Run in the foreground (host must login to Windows)
|
|
648
|
+
</label>
|
|
649
|
+
)}
|
|
464
650
|
{modeIsScheduled && (
|
|
465
651
|
<label className="toggle-label">
|
|
466
652
|
<input
|
|
@@ -472,6 +658,17 @@ export default function TaskForm({ initial, agents, hostPlatform, onSaved, onRun
|
|
|
472
658
|
Confirm before each run
|
|
473
659
|
</label>
|
|
474
660
|
)}
|
|
661
|
+
{scheduleMode !== "ondemand" && (
|
|
662
|
+
<label className="toggle-label">
|
|
663
|
+
<input
|
|
664
|
+
type="checkbox"
|
|
665
|
+
checked={scheduleEnabled}
|
|
666
|
+
onChange={(e) => setScheduleEnabled(e.target.checked)}
|
|
667
|
+
disabled={saving}
|
|
668
|
+
/>
|
|
669
|
+
Enabled
|
|
670
|
+
</label>
|
|
671
|
+
)}
|
|
475
672
|
</div>
|
|
476
673
|
</div>
|
|
477
674
|
|
|
@@ -122,6 +122,11 @@ export default function TasksView({ connected, hostId, request, subscribeEvents,
|
|
|
122
122
|
</div>
|
|
123
123
|
))}
|
|
124
124
|
</div>
|
|
125
|
+
) : tasks.length === 0 ? (
|
|
126
|
+
<div className="empty-state">
|
|
127
|
+
<p className="empty-state-text">No tasks yet</p>
|
|
128
|
+
<p className="empty-state-hint">Tap the + button to create your first task.</p>
|
|
129
|
+
</div>
|
|
125
130
|
) : (
|
|
126
131
|
<div className="task-list">
|
|
127
132
|
{tasks.map((task) => (
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Bump when a breaking host change is made. */
|
|
2
|
-
export const MIN_HOST_VERSION = "0.
|
|
2
|
+
export const MIN_HOST_VERSION = "0.8.3";
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Capacitor, registerPlugin, type PluginListenerHandle } from "@capacitor/core";
|
|
2
|
+
|
|
3
|
+
export type PermissionType =
|
|
4
|
+
| "location"
|
|
5
|
+
| "smsRead"
|
|
6
|
+
| "smsSend"
|
|
7
|
+
| "contacts"
|
|
8
|
+
| "calendar"
|
|
9
|
+
| "notificationListener"
|
|
10
|
+
| "dnd"
|
|
11
|
+
| "fullScreenIntent"
|
|
12
|
+
| "postNotifications";
|
|
13
|
+
|
|
14
|
+
export interface PermissionResult {
|
|
15
|
+
granted: boolean;
|
|
16
|
+
/**
|
|
17
|
+
* False when the native build doesn't recognize this permission type — the PWA
|
|
18
|
+
* is served remotely and may ship ahead of the installed APK. Callers should
|
|
19
|
+
* treat unsupported types as "cannot enable" rather than as a hard error.
|
|
20
|
+
*/
|
|
21
|
+
supported: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface DeepLinkEvent {
|
|
25
|
+
path: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface InstalledApp {
|
|
29
|
+
packageName: string;
|
|
30
|
+
appName: string;
|
|
31
|
+
/** data:image/png;base64 URL, 96x96. May be absent if the icon couldn't be rendered. */
|
|
32
|
+
icon?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface DevicePlugin {
|
|
36
|
+
getFcmToken(): Promise<{ token: string }>;
|
|
37
|
+
/** Returns the set of PermissionType strings this native build understands. */
|
|
38
|
+
getSupportedPermissions(): Promise<{ types: PermissionType[] }>;
|
|
39
|
+
checkPermission(opts: { type: PermissionType }): Promise<PermissionResult>;
|
|
40
|
+
requestPermission(opts: { type: PermissionType }): Promise<PermissionResult>;
|
|
41
|
+
/**
|
|
42
|
+
* Authoritative list of capabilities the user has enabled on this device.
|
|
43
|
+
* Native receivers + handlers consult this as a local kill-switch. Unknown
|
|
44
|
+
* capability names are stored but ignored — safe for PWAs that ship new caps
|
|
45
|
+
* ahead of the installed APK.
|
|
46
|
+
*/
|
|
47
|
+
setEnabledCapabilities(opts: { capabilities: string[] }): Promise<void>;
|
|
48
|
+
/** Returns user-visible (launcher) apps on the device, with 96x96 PNG icons. */
|
|
49
|
+
getInstalledApps(): Promise<{ apps: InstalledApp[] }>;
|
|
50
|
+
/**
|
|
51
|
+
* Returns whether the device has at least one app that can handle a mailto:
|
|
52
|
+
* intent. Used to gate the Sending Email capability — silent PackageManager
|
|
53
|
+
* lookup, no side effects. `supported: false` on older APKs that don't expose
|
|
54
|
+
* this method; treat as "cannot enable" rather than as a hard error.
|
|
55
|
+
*/
|
|
56
|
+
hasEmailClient(): Promise<{ available: boolean; supported: boolean }>;
|
|
57
|
+
addListener(
|
|
58
|
+
event: "deepLink",
|
|
59
|
+
handler: (ev: DeepLinkEvent) => void
|
|
60
|
+
): Promise<PluginListenerHandle>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Null on web — callers must guard with Capacitor.isNativePlatform(). */
|
|
64
|
+
export const Device = Capacitor.isNativePlatform()
|
|
65
|
+
? registerPlugin<DevicePlugin>("Device")
|
|
66
|
+
: null;
|
|
@@ -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 ? (
|
|
@@ -369,7 +374,7 @@ export default function Dashboard() {
|
|
|
369
374
|
</>
|
|
370
375
|
) : (
|
|
371
376
|
<div className="empty-state">
|
|
372
|
-
<p>{hasHosts ? "Connecting to host..." : "No
|
|
377
|
+
<p>{hasHosts ? "Connecting to host..." : "No host computer paired yet."}</p>
|
|
373
378
|
{!hasHosts && (
|
|
374
379
|
<button
|
|
375
380
|
className="btn btn-primary"
|
|
@@ -4,6 +4,7 @@ import { connect, jwtAuthenticator, StringCodec } from "nats.ws";
|
|
|
4
4
|
import { Capacitor } from "@capacitor/core";
|
|
5
5
|
import { Preferences } from "@capacitor/preferences";
|
|
6
6
|
import { useHostStore } from "../contexts/HostStoreContext";
|
|
7
|
+
import { Device } from "../native/Device";
|
|
7
8
|
import { SERVER_URL } from "../api";
|
|
8
9
|
import type { PairedHost } from "../types";
|
|
9
10
|
|
|
@@ -85,9 +86,24 @@ export default function PairHost() {
|
|
|
85
86
|
|
|
86
87
|
addPairedHost(host);
|
|
87
88
|
|
|
88
|
-
|
|
89
|
-
|
|
89
|
+
if (Capacitor.isNativePlatform() && Device) {
|
|
90
|
+
// Native receivers (SmsBroadcastReceiver, DeviceNotificationListenerService)
|
|
91
|
+
// read hostId to address relay messages.
|
|
90
92
|
await Preferences.set({ key: "hostId", value: response.hostId });
|
|
93
|
+
|
|
94
|
+
// Register this device's FCM token with the relay server so it can wake
|
|
95
|
+
// the device on the paired host's behalf. Moved here from native so the
|
|
96
|
+
// APK no longer needs to read hostId or trigger registration itself.
|
|
97
|
+
try {
|
|
98
|
+
const { token: fcmToken } = await Device.getFcmToken();
|
|
99
|
+
await fetch(`${SERVER_URL}/api/fcm/register`, {
|
|
100
|
+
method: "POST",
|
|
101
|
+
headers: { "Content-Type": "application/json" },
|
|
102
|
+
body: JSON.stringify({ hostId: response.hostId, fcmToken }),
|
|
103
|
+
});
|
|
104
|
+
} catch (err) {
|
|
105
|
+
console.warn("FCM token registration failed:", err);
|
|
106
|
+
}
|
|
91
107
|
}
|
|
92
108
|
|
|
93
109
|
navigate("/");
|
|
@@ -11,7 +11,7 @@ export interface Task {
|
|
|
11
11
|
name: string;
|
|
12
12
|
user_prompt: string;
|
|
13
13
|
agent?: string;
|
|
14
|
-
schedule_type?: "crons" | "specific_times";
|
|
14
|
+
schedule_type?: "crons" | "specific_times" | "on_new_notification" | "on_new_sms";
|
|
15
15
|
schedule_values?: string[];
|
|
16
16
|
schedule_enabled: boolean;
|
|
17
17
|
requires_confirmation: boolean;
|
|
@@ -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
|
});
|