palmier 0.6.6 → 0.6.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -1
- package/dist/agents/agent-instructions.md +6 -14
- package/dist/agents/aider.js +1 -1
- package/dist/agents/claude.js +1 -1
- package/dist/agents/cline.js +1 -1
- package/dist/agents/codex.js +1 -1
- package/dist/agents/copilot.js +1 -1
- package/dist/agents/cursor.js +1 -1
- package/dist/agents/deepagents.js +1 -1
- package/dist/agents/droid.js +1 -1
- package/dist/agents/gemini.js +1 -1
- package/dist/agents/goose.js +1 -1
- package/dist/agents/hermes.js +1 -1
- package/dist/agents/kimi.js +1 -1
- package/dist/agents/kiro.js +1 -1
- package/dist/agents/openclaw.js +1 -1
- package/dist/agents/opencode.js +1 -1
- package/dist/agents/qoder.js +1 -1
- package/dist/agents/qwen.js +1 -1
- package/dist/agents/shared-prompt.d.ts +3 -2
- package/dist/agents/shared-prompt.js +6 -4
- package/dist/commands/plan-generation.md +1 -0
- package/dist/commands/run.js +4 -7
- package/dist/location-device.d.ts +8 -0
- package/dist/location-device.js +32 -0
- package/dist/mcp-handler.d.ts +8 -0
- package/dist/mcp-handler.js +110 -0
- package/dist/mcp-tools.d.ts +27 -0
- package/dist/mcp-tools.js +218 -0
- package/dist/pwa/assets/{index-DhvJN8ie.css → index-C6Lz09EY.css} +1 -1
- package/dist/pwa/assets/index-C8vJwUNi.js +118 -0
- package/dist/pwa/assets/web-6UChJFov.js +1 -0
- package/dist/pwa/assets/web-NxTETXZK.js +1 -0
- package/dist/pwa/index.html +3 -3
- package/dist/pwa/service-worker.js +2 -2
- package/dist/rpc-handler.js +20 -8
- package/dist/spawn-command.js +3 -1
- package/dist/transports/http-transport.js +60 -129
- package/package.json +1 -1
- package/palmier-server/README.md +6 -1
- package/palmier-server/package.json +7 -1
- package/palmier-server/pnpm-lock.yaml +1025 -1
- package/palmier-server/pwa/index.html +1 -1
- package/palmier-server/pwa/package.json +3 -0
- package/palmier-server/pwa/src/App.css +64 -0
- package/palmier-server/pwa/src/api.ts +8 -2
- package/palmier-server/pwa/src/components/HostMenu.tsx +102 -1
- package/palmier-server/pwa/src/components/TaskCard.tsx +36 -8
- package/palmier-server/pwa/src/components/TaskForm.tsx +63 -53
- package/palmier-server/pwa/src/components/TaskListView.tsx +94 -78
- package/palmier-server/pwa/src/constants.ts +1 -1
- package/palmier-server/pwa/src/contexts/HostConnectionContext.tsx +2 -1
- package/palmier-server/pwa/src/hooks/usePushSubscription.ts +3 -0
- package/palmier-server/pwa/src/pages/Dashboard.tsx +5 -2
- package/palmier-server/pwa/src/pages/PairHost.tsx +10 -1
- package/palmier-server/pwa/src/service-worker.ts +7 -7
- package/palmier-server/server/.env.example +4 -0
- package/palmier-server/server/package.json +1 -0
- package/palmier-server/server/src/db.ts +10 -0
- package/palmier-server/server/src/fcm.ts +74 -0
- package/palmier-server/server/src/index.ts +101 -21
- package/palmier-server/server/src/notify.ts +34 -0
- package/palmier-server/server/src/push.ts +1 -1
- package/palmier-server/server/src/routes/fcm.ts +64 -0
- package/palmier-server/server/src/routes/push.ts +6 -5
- package/palmier-server/spec.md +4 -2
- package/src/agents/agent-instructions.md +6 -14
- package/src/agents/aider.ts +1 -1
- package/src/agents/claude.ts +1 -1
- package/src/agents/cline.ts +1 -1
- package/src/agents/codex.ts +1 -1
- package/src/agents/copilot.ts +1 -1
- package/src/agents/cursor.ts +1 -1
- package/src/agents/deepagents.ts +1 -1
- package/src/agents/droid.ts +1 -1
- package/src/agents/gemini.ts +1 -1
- package/src/agents/goose.ts +1 -1
- package/src/agents/hermes.ts +1 -1
- package/src/agents/kimi.ts +1 -1
- package/src/agents/kiro.ts +1 -1
- package/src/agents/openclaw.ts +1 -1
- package/src/agents/opencode.ts +1 -1
- package/src/agents/qoder.ts +1 -1
- package/src/agents/qwen.ts +1 -1
- package/src/agents/shared-prompt.ts +7 -4
- package/src/commands/plan-generation.md +1 -0
- package/src/commands/run.ts +4 -7
- package/src/location-device.ts +35 -0
- package/src/mcp-handler.ts +133 -0
- package/src/mcp-tools.ts +253 -0
- package/src/rpc-handler.ts +21 -8
- package/src/spawn-command.ts +3 -1
- package/src/transports/http-transport.ts +57 -128
- package/test/agent-instructions.test.ts +68 -5
- package/test/fixtures/agent-instructions-snapshot.md +58 -0
- package/dist/pwa/assets/index-CXqKVvmk.js +0 -118
|
@@ -26,16 +26,17 @@ interface TaskListViewProps {
|
|
|
26
26
|
onViewRun(taskId: string, runId?: string): void;
|
|
27
27
|
onUpdateRequired?(required: boolean): void;
|
|
28
28
|
onVersion?(version: string | null): void;
|
|
29
|
+
onLocationClientToken?(token: string | null): void;
|
|
29
30
|
}
|
|
30
31
|
|
|
31
|
-
export default function TaskListView({ connected, hostId, request, subscribeEvents, onViewRun, onUpdateRequired, onVersion }: TaskListViewProps) {
|
|
32
|
+
export default function TaskListView({ connected, hostId, request, subscribeEvents, onViewRun, onUpdateRequired, onVersion, onLocationClientToken }: TaskListViewProps) {
|
|
32
33
|
const [tasks, setTasks] = useState<Task[]>([]);
|
|
33
34
|
const [loadingTasks, setLoadingTasks] = useState(false);
|
|
34
35
|
const [taskError, setTaskError] = useState<string | null>(null);
|
|
35
36
|
const [taskEvents, setTaskStatuss] = useState<Map<string, TaskStatus>>(new Map());
|
|
36
|
-
const [pendingConfirms, setPendingConfirms] = useState<
|
|
37
|
+
const [pendingConfirms, setPendingConfirms] = useState<Map<string, { description: string; agentName?: string }>>(new Map());
|
|
37
38
|
const [pendingPermissions, setPendingPermissions] = useState<Map<string, RequiredPermission[]>>(new Map());
|
|
38
|
-
const [pendingInputs, setPendingInputs] = useState<Map<string, string[]>>(new Map());
|
|
39
|
+
const [pendingInputs, setPendingInputs] = useState<Map<string, { questions: string[]; description?: string; agentName?: string }>>(new Map());
|
|
39
40
|
const [inputValues, setInputValues] = useState<Map<string, string[]>>(new Map());
|
|
40
41
|
|
|
41
42
|
const [showForm, setShowForm] = useState(false);
|
|
@@ -52,20 +53,20 @@ export default function TaskListView({ connected, hostId, request, subscribeEven
|
|
|
52
53
|
setLoadingTasks(true);
|
|
53
54
|
setTaskError(null);
|
|
54
55
|
try {
|
|
55
|
-
const result = await request<{ tasks?: (Task & { status?: TaskStatus })[]; agents?: AgentInfo[]; version?: string | null; host_platform?: string }>("task.list");
|
|
56
|
+
const result = await request<{ tasks?: (Task & { status?: TaskStatus })[]; agents?: AgentInfo[]; version?: string | null; host_platform?: string; location_client_token?: string | null }>("task.list");
|
|
56
57
|
const taskList = result.tasks ?? [];
|
|
57
58
|
const initialEvents = new Map<string, TaskStatus>();
|
|
58
|
-
const initialConfirms = new
|
|
59
|
+
const initialConfirms = new Map<string, { description: string; agentName?: string }>();
|
|
59
60
|
const initialPerms = new Map<string, RequiredPermission[]>();
|
|
60
|
-
const initialInputs = new Map<string, string[]>();
|
|
61
|
+
const initialInputs = new Map<string, { questions: string[]; description?: string; agentName?: string }>();
|
|
61
62
|
const initialInputVals = new Map<string, string[]>();
|
|
62
63
|
for (const t of taskList) {
|
|
63
64
|
if (t.status) {
|
|
64
65
|
initialEvents.set(t.id, t.status);
|
|
65
|
-
|
|
66
|
+
// pending_confirmation no longer comes from task.list (confirmation is sessionId-based now)
|
|
66
67
|
if (t.status.pending_permission?.length) initialPerms.set(t.id, t.status.pending_permission);
|
|
67
68
|
if (t.status.pending_input?.length) {
|
|
68
|
-
initialInputs.set(t.id, t.status.pending_input);
|
|
69
|
+
initialInputs.set(t.id, { questions: t.status.pending_input });
|
|
69
70
|
initialInputVals.set(t.id, new Array(t.status.pending_input.length).fill(""));
|
|
70
71
|
}
|
|
71
72
|
}
|
|
@@ -81,6 +82,7 @@ export default function TaskListView({ connected, hostId, request, subscribeEven
|
|
|
81
82
|
setAgentLabels(result.agents ?? []);
|
|
82
83
|
const version = result.version ?? null;
|
|
83
84
|
onVersion?.(version);
|
|
85
|
+
onLocationClientToken?.(result.location_client_token ?? null);
|
|
84
86
|
onUpdateRequired?.(!!version && isOlderThan(version, MIN_HOST_VERSION));
|
|
85
87
|
} catch (err) {
|
|
86
88
|
const errMsg = err instanceof Error ? err.message : String(err);
|
|
@@ -109,43 +111,81 @@ export default function TaskListView({ connected, hostId, request, subscribeEven
|
|
|
109
111
|
if (tokens.length < 3) return;
|
|
110
112
|
const taskId = tokens.slice(2).join(".");
|
|
111
113
|
|
|
112
|
-
let
|
|
114
|
+
let parsed: Record<string, unknown> = {};
|
|
113
115
|
try {
|
|
114
|
-
|
|
115
|
-
eventType = parsed.event_type;
|
|
116
|
+
parsed = JSON.parse(sc.decode(msg.data)) as Record<string, unknown>;
|
|
116
117
|
} catch { return; }
|
|
118
|
+
const eventType = parsed.event_type as string | undefined;
|
|
119
|
+
const sessionId = parsed.session_id as string | undefined;
|
|
117
120
|
|
|
118
|
-
// Handle
|
|
119
|
-
if (eventType === "
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
121
|
+
// Handle input-request: show standalone input dialog (keyed by sessionId)
|
|
122
|
+
if (eventType === "input-request" && sessionId) {
|
|
123
|
+
const questions = parsed.input_questions as string[];
|
|
124
|
+
const agentName = parsed.agent_name as string | undefined;
|
|
125
|
+
const description = parsed.description as string | undefined;
|
|
126
|
+
if (questions?.length) {
|
|
127
|
+
setPendingInputs((prev) => {
|
|
128
|
+
if (prev.has(sessionId)) return prev;
|
|
129
|
+
const next = new Map(prev);
|
|
130
|
+
next.set(sessionId, { questions, description, agentName });
|
|
131
|
+
return next;
|
|
132
|
+
});
|
|
133
|
+
setInputValues((prev) => {
|
|
134
|
+
if (prev.has(sessionId)) return prev;
|
|
135
|
+
const next = new Map(prev);
|
|
136
|
+
next.set(sessionId, new Array(questions.length).fill(""));
|
|
137
|
+
return next;
|
|
138
|
+
});
|
|
139
|
+
}
|
|
126
140
|
return;
|
|
127
141
|
}
|
|
128
142
|
|
|
129
|
-
// Handle
|
|
130
|
-
if (eventType === "
|
|
131
|
-
|
|
132
|
-
if (!prev.has(
|
|
143
|
+
// Handle input-resolved: close pending input dialog
|
|
144
|
+
if (eventType === "input-resolved" && sessionId) {
|
|
145
|
+
setPendingInputs((prev) => {
|
|
146
|
+
if (!prev.has(sessionId)) return prev;
|
|
133
147
|
const next = new Map(prev);
|
|
134
|
-
next.delete(
|
|
148
|
+
next.delete(sessionId);
|
|
149
|
+
return next;
|
|
150
|
+
});
|
|
151
|
+
setInputValues((prev) => {
|
|
152
|
+
const next = new Map(prev);
|
|
153
|
+
next.delete(sessionId);
|
|
135
154
|
return next;
|
|
136
155
|
});
|
|
137
156
|
return;
|
|
138
157
|
}
|
|
139
158
|
|
|
140
|
-
// Handle
|
|
141
|
-
if (eventType === "
|
|
142
|
-
|
|
143
|
-
|
|
159
|
+
// Handle confirm-request: show standalone confirmation dialog (keyed by sessionId)
|
|
160
|
+
if (eventType === "confirm-request" && sessionId) {
|
|
161
|
+
const description = parsed.description as string;
|
|
162
|
+
const agentName = parsed.agent_name as string | undefined;
|
|
163
|
+
if (description) {
|
|
164
|
+
setPendingConfirms((prev) => {
|
|
165
|
+
if (prev.has(sessionId)) return prev;
|
|
166
|
+
const next = new Map(prev);
|
|
167
|
+
next.set(sessionId, { description, agentName });
|
|
168
|
+
return next;
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Handle confirm-resolved: close pending confirmation dialog
|
|
175
|
+
if (eventType === "confirm-resolved" && sessionId) {
|
|
176
|
+
setPendingConfirms((prev) => {
|
|
177
|
+
if (!prev.has(sessionId)) return prev;
|
|
144
178
|
const next = new Map(prev);
|
|
145
|
-
next.delete(
|
|
179
|
+
next.delete(sessionId);
|
|
146
180
|
return next;
|
|
147
181
|
});
|
|
148
|
-
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Handle permission-resolved: close pending permission dialog
|
|
186
|
+
if (eventType === "permission-resolved") {
|
|
187
|
+
setPendingPermissions((prev) => {
|
|
188
|
+
if (!prev.has(taskId)) return prev;
|
|
149
189
|
const next = new Map(prev);
|
|
150
190
|
next.delete(taskId);
|
|
151
191
|
return next;
|
|
@@ -157,14 +197,6 @@ export default function TaskListView({ connected, hostId, request, subscribeEven
|
|
|
157
197
|
const status = await request<TaskStatus & { error?: string }>("task.status", { id: taskId }, { timeout: 5000 });
|
|
158
198
|
if (status.error) return;
|
|
159
199
|
setTaskStatuss((prev) => { const next = new Map(prev); next.set(taskId, status); return next; });
|
|
160
|
-
if (status.pending_confirmation) {
|
|
161
|
-
setPendingConfirms((prev) => {
|
|
162
|
-
if (prev.has(taskId)) return prev;
|
|
163
|
-
const next = new Set(prev);
|
|
164
|
-
next.add(taskId);
|
|
165
|
-
return next;
|
|
166
|
-
});
|
|
167
|
-
}
|
|
168
200
|
if (status.pending_permission?.length) {
|
|
169
201
|
setPendingPermissions((prev) => {
|
|
170
202
|
if (prev.has(taskId)) return prev;
|
|
@@ -173,28 +205,14 @@ export default function TaskListView({ connected, hostId, request, subscribeEven
|
|
|
173
205
|
return next;
|
|
174
206
|
});
|
|
175
207
|
}
|
|
176
|
-
if (status.pending_input?.length) {
|
|
177
|
-
setPendingInputs((prev) => {
|
|
178
|
-
if (prev.has(taskId)) return prev;
|
|
179
|
-
const next = new Map(prev);
|
|
180
|
-
next.set(taskId, status.pending_input!);
|
|
181
|
-
return next;
|
|
182
|
-
});
|
|
183
|
-
setInputValues((prev) => {
|
|
184
|
-
if (prev.has(taskId)) return prev;
|
|
185
|
-
const next = new Map(prev);
|
|
186
|
-
next.set(taskId, new Array(status.pending_input!.length).fill(""));
|
|
187
|
-
return next;
|
|
188
|
-
});
|
|
189
|
-
}
|
|
190
208
|
} catch { /* skip */ }
|
|
191
209
|
});
|
|
192
210
|
return unsubscribe;
|
|
193
211
|
}, [connected, hostId, subscribeEvents, request]);
|
|
194
212
|
|
|
195
|
-
async function respondToConfirm(
|
|
213
|
+
async function respondToConfirm(sessionId: string, response: "confirmed" | "aborted") {
|
|
196
214
|
try {
|
|
197
|
-
await request("task.user_input", { id:
|
|
215
|
+
await request("task.user_input", { id: sessionId, value: [response] });
|
|
198
216
|
} catch (err) {
|
|
199
217
|
console.error("[TaskListView] Failed to respond to confirmation:", err);
|
|
200
218
|
}
|
|
@@ -208,9 +226,9 @@ export default function TaskListView({ connected, hostId, request, subscribeEven
|
|
|
208
226
|
}
|
|
209
227
|
}
|
|
210
228
|
|
|
211
|
-
async function respondToInput(
|
|
229
|
+
async function respondToInput(sessionId: string, values: string[]) {
|
|
212
230
|
try {
|
|
213
|
-
await request("task.user_input", { id:
|
|
231
|
+
await request("task.user_input", { id: sessionId, value: values });
|
|
214
232
|
} catch (err) {
|
|
215
233
|
console.error("[TaskListView] Failed to respond to input request:", err);
|
|
216
234
|
}
|
|
@@ -300,20 +318,19 @@ export default function TaskListView({ connected, hostId, request, subscribeEven
|
|
|
300
318
|
)}
|
|
301
319
|
|
|
302
320
|
{createPortal(<>
|
|
303
|
-
{[...pendingConfirms].map((
|
|
304
|
-
const
|
|
321
|
+
{[...pendingConfirms.entries()].map(([sessionId, { description, agentName }]) => {
|
|
322
|
+
const subtitle = agentName || tasks.find((t) => t.id === sessionId)?.name;
|
|
305
323
|
return (
|
|
306
|
-
<div key={
|
|
324
|
+
<div key={sessionId} className="confirm-modal-overlay">
|
|
307
325
|
<div className="confirm-modal">
|
|
308
|
-
<h2 className="confirm-modal-title">
|
|
309
|
-
<p className="confirm-modal-
|
|
310
|
-
|
|
311
|
-
</p>
|
|
326
|
+
<h2 className="confirm-modal-title">Confirmation Required</h2>
|
|
327
|
+
{subtitle && <p className="confirm-modal-subtitle">{subtitle}</p>}
|
|
328
|
+
<p className="confirm-modal-message">{description}</p>
|
|
312
329
|
<div className="confirm-modal-actions">
|
|
313
|
-
<button className="btn btn-primary" onClick={() => respondToConfirm(
|
|
330
|
+
<button className="btn btn-primary" onClick={() => respondToConfirm(sessionId, "confirmed")}>
|
|
314
331
|
Confirm
|
|
315
332
|
</button>
|
|
316
|
-
<button className="btn btn-secondary" onClick={() => respondToConfirm(
|
|
333
|
+
<button className="btn btn-secondary" onClick={() => respondToConfirm(sessionId, "aborted")}>
|
|
317
334
|
Abort
|
|
318
335
|
</button>
|
|
319
336
|
</div>
|
|
@@ -358,18 +375,17 @@ export default function TaskListView({ connected, hostId, request, subscribeEven
|
|
|
358
375
|
);
|
|
359
376
|
})}
|
|
360
377
|
|
|
361
|
-
{[...pendingInputs.entries()].map(([
|
|
362
|
-
const
|
|
363
|
-
const
|
|
378
|
+
{[...pendingInputs.entries()].map(([sessionId, { questions, description, agentName }]) => {
|
|
379
|
+
const values = inputValues.get(sessionId) ?? new Array(questions.length).fill("");
|
|
380
|
+
const subtitle = agentName || tasks.find((t) => t.id === sessionId)?.name;
|
|
364
381
|
return (
|
|
365
|
-
<div key={
|
|
382
|
+
<div key={sessionId} className="confirm-modal-overlay">
|
|
366
383
|
<div className="confirm-modal input-modal">
|
|
367
384
|
<h2 className="confirm-modal-title">Input Required</h2>
|
|
368
|
-
<p className="confirm-modal-
|
|
369
|
-
|
|
370
|
-
</p>
|
|
385
|
+
{subtitle && <p className="confirm-modal-subtitle">{subtitle}</p>}
|
|
386
|
+
{description && <p className="confirm-modal-message">{description}</p>}
|
|
371
387
|
<div className="input-list">
|
|
372
|
-
{
|
|
388
|
+
{questions.map((desc: string, i: number) => (
|
|
373
389
|
<div key={i} className="input-item">
|
|
374
390
|
<label className="input-label">{desc}</label>
|
|
375
391
|
<input
|
|
@@ -379,9 +395,9 @@ export default function TaskListView({ connected, hostId, request, subscribeEven
|
|
|
379
395
|
onChange={(e) => {
|
|
380
396
|
setInputValues((prev) => {
|
|
381
397
|
const next = new Map(prev);
|
|
382
|
-
const arr = [...(next.get(
|
|
398
|
+
const arr = [...(next.get(sessionId) ?? [])];
|
|
383
399
|
arr[i] = e.target.value;
|
|
384
|
-
next.set(
|
|
400
|
+
next.set(sessionId, arr);
|
|
385
401
|
return next;
|
|
386
402
|
});
|
|
387
403
|
}}
|
|
@@ -394,16 +410,16 @@ export default function TaskListView({ connected, hostId, request, subscribeEven
|
|
|
394
410
|
<button
|
|
395
411
|
className="btn btn-primary"
|
|
396
412
|
disabled={values.some((v) => !v.trim())}
|
|
397
|
-
onClick={() => respondToInput(
|
|
413
|
+
onClick={() => respondToInput(sessionId, values)}
|
|
398
414
|
>
|
|
399
415
|
Submit
|
|
400
416
|
</button>
|
|
401
417
|
</div>
|
|
402
418
|
<button
|
|
403
419
|
className="permission-abort-link"
|
|
404
|
-
onClick={() => respondToInput(
|
|
420
|
+
onClick={() => respondToInput(sessionId, ["aborted"])}
|
|
405
421
|
>
|
|
406
|
-
Cancel
|
|
422
|
+
Cancel
|
|
407
423
|
</button>
|
|
408
424
|
</div>
|
|
409
425
|
</div>
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Bump when a breaking host change is made. */
|
|
2
|
-
export const MIN_HOST_VERSION = "0.6.
|
|
2
|
+
export const MIN_HOST_VERSION = "0.6.7";
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
type ReactNode,
|
|
9
9
|
} from "react";
|
|
10
10
|
import { connect, StringCodec, type NatsConnection, type Subscription } from "nats.ws";
|
|
11
|
+
import { SERVER_URL } from "../api";
|
|
11
12
|
import { useHostStore } from "./HostStoreContext";
|
|
12
13
|
import type { PairedHost } from "../types";
|
|
13
14
|
|
|
@@ -89,7 +90,7 @@ export function HostConnectionProvider({ children }: { children: ReactNode }) {
|
|
|
89
90
|
|
|
90
91
|
async function init() {
|
|
91
92
|
try {
|
|
92
|
-
const res = await fetch(
|
|
93
|
+
const res = await fetch(`${SERVER_URL}/api/config`);
|
|
93
94
|
if (!res.ok) {
|
|
94
95
|
console.error("[NATS] Failed to fetch config");
|
|
95
96
|
return;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useEffect, useRef } from "react";
|
|
2
|
+
import { Capacitor } from "@capacitor/core";
|
|
2
3
|
import { useHostStore } from "../contexts/HostStoreContext";
|
|
3
4
|
import { apiPost, apiGet } from "../api";
|
|
4
5
|
|
|
@@ -8,6 +9,8 @@ export function usePushSubscription() {
|
|
|
8
9
|
const subscribedRef = useRef<string | null>(null);
|
|
9
10
|
|
|
10
11
|
useEffect(() => {
|
|
12
|
+
// Native app uses FCM for notifications, not Web Push
|
|
13
|
+
if (Capacitor.isNativePlatform()) return;
|
|
11
14
|
// Skip push subscription for direct-only (LAN) hosts — no cloud server to relay through
|
|
12
15
|
if (!activeHost || activeHost.directUrl || subscribedRef.current === activeHost.hostId) return;
|
|
13
16
|
|
|
@@ -47,6 +47,7 @@ export default function Dashboard() {
|
|
|
47
47
|
const [updating, setUpdating] = useState(false);
|
|
48
48
|
const [updateError, setUpdateError] = useState<string | null>(null);
|
|
49
49
|
const [daemonVersion, setDaemonVersion] = useState<string | null>(null);
|
|
50
|
+
const [locationClientToken, setLocationClientToken] = useState<string | null>(null);
|
|
50
51
|
|
|
51
52
|
// Register push subscription for the active host
|
|
52
53
|
usePushSubscription();
|
|
@@ -83,14 +84,15 @@ export default function Dashboard() {
|
|
|
83
84
|
|
|
84
85
|
const hasHosts = pairedHosts.length > 0;
|
|
85
86
|
const showTaskContent = hasHosts && connected && activeHostId && !unauthorized;
|
|
87
|
+
const activeClientToken = pairedHosts.find((h) => h.hostId === activeHostId)?.clientToken ?? null;
|
|
86
88
|
|
|
87
89
|
return (
|
|
88
90
|
<div className="dashboard">
|
|
89
|
-
{isDesktop && <HostMenu daemonVersion={daemonVersion} />}
|
|
91
|
+
{isDesktop && <HostMenu daemonVersion={daemonVersion} locationClientToken={locationClientToken} activeClientToken={activeClientToken} request={request} onLocationClientTokenChange={setLocationClientToken} />}
|
|
90
92
|
|
|
91
93
|
<div className="dashboard-content">
|
|
92
94
|
<div className="tab-bar">
|
|
93
|
-
{!isDesktop && <HostMenu daemonVersion={daemonVersion} />}
|
|
95
|
+
{!isDesktop && <HostMenu daemonVersion={daemonVersion} locationClientToken={locationClientToken} activeClientToken={activeClientToken} request={request} onLocationClientTokenChange={setLocationClientToken} />}
|
|
94
96
|
<TabBar />
|
|
95
97
|
</div>
|
|
96
98
|
|
|
@@ -139,6 +141,7 @@ export default function Dashboard() {
|
|
|
139
141
|
onViewRun={handleViewRun}
|
|
140
142
|
onUpdateRequired={setUpdateRequired}
|
|
141
143
|
onVersion={setDaemonVersion}
|
|
144
|
+
onLocationClientToken={setLocationClientToken}
|
|
142
145
|
/>
|
|
143
146
|
</div>
|
|
144
147
|
{isRunDetail ? (
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { useState } from "react";
|
|
2
2
|
import { useNavigate } from "react-router-dom";
|
|
3
3
|
import { connect, StringCodec } from "nats.ws";
|
|
4
|
+
import { Capacitor } from "@capacitor/core";
|
|
5
|
+
import { Preferences } from "@capacitor/preferences";
|
|
4
6
|
import { useHostStore } from "../contexts/HostStoreContext";
|
|
7
|
+
import { SERVER_URL } from "../api";
|
|
5
8
|
import type { PairedHost } from "../types";
|
|
6
9
|
|
|
7
10
|
interface PairResponse {
|
|
@@ -49,7 +52,7 @@ export default function PairHost() {
|
|
|
49
52
|
response = await res.json() as PairResponse;
|
|
50
53
|
} else {
|
|
51
54
|
// Server mode — pair via NATS
|
|
52
|
-
const configRes = await fetch(
|
|
55
|
+
const configRes = await fetch(`${SERVER_URL}/api/config`);
|
|
53
56
|
if (!configRes.ok) throw new Error("Failed to fetch server config");
|
|
54
57
|
const config = await configRes.json() as { natsWsUrl: string; natsToken: string };
|
|
55
58
|
if (!config.natsWsUrl) throw new Error("Server has no NATS WebSocket URL configured");
|
|
@@ -78,6 +81,12 @@ export default function PairHost() {
|
|
|
78
81
|
};
|
|
79
82
|
|
|
80
83
|
addPairedHost(host);
|
|
84
|
+
|
|
85
|
+
// Write hostId to native SharedPreferences for FCM token registration
|
|
86
|
+
if (Capacitor.isNativePlatform()) {
|
|
87
|
+
await Preferences.set({ key: "hostId", value: response.hostId });
|
|
88
|
+
}
|
|
89
|
+
|
|
81
90
|
navigate("/");
|
|
82
91
|
} catch (err) {
|
|
83
92
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -33,14 +33,15 @@ self.addEventListener("push", (event) => {
|
|
|
33
33
|
// Silent dismiss: close matching notification without showing a new one
|
|
34
34
|
if (type === "confirm-dismiss" || type === "permission-dismiss" || type === "input-dismiss") {
|
|
35
35
|
const data = payload.data ?? payload;
|
|
36
|
-
const taskId = (data as Record<string, unknown>).task_id;
|
|
37
36
|
const dataHostId = (data as Record<string, unknown>).host_id;
|
|
37
|
+
const requestId = (data as Record<string, unknown>).session_id;
|
|
38
|
+
const taskId = (data as Record<string, unknown>).task_id; // permission-dismiss still uses task_id
|
|
38
39
|
event.waitUntil(
|
|
39
40
|
self.registration.getNotifications().then((notifications) => {
|
|
40
41
|
for (const n of notifications) {
|
|
41
|
-
if (n.data?.
|
|
42
|
-
|
|
43
|
-
}
|
|
42
|
+
if (n.data?.host_id !== dataHostId) continue;
|
|
43
|
+
if (requestId && n.data?.session_id === requestId) { n.close(); continue; }
|
|
44
|
+
if (taskId && n.data?.task_id === taskId) { n.close(); } // permission only
|
|
44
45
|
}
|
|
45
46
|
})
|
|
46
47
|
);
|
|
@@ -85,7 +86,7 @@ self.addEventListener("notificationclick", (event) => {
|
|
|
85
86
|
const data = notification.data ?? {};
|
|
86
87
|
const action = event.action;
|
|
87
88
|
|
|
88
|
-
if (action && data.type === "confirm" && data.
|
|
89
|
+
if (action && data.type === "confirm" && data.session_id && data.host_id) {
|
|
89
90
|
const response = action === "confirm" ? "confirmed" : "aborted";
|
|
90
91
|
|
|
91
92
|
event.waitUntil(
|
|
@@ -93,8 +94,7 @@ self.addEventListener("notificationclick", (event) => {
|
|
|
93
94
|
method: "POST",
|
|
94
95
|
headers: { "Content-Type": "application/json" },
|
|
95
96
|
body: JSON.stringify({
|
|
96
|
-
|
|
97
|
-
task_id: data.task_id,
|
|
97
|
+
session_id: data.session_id,
|
|
98
98
|
host_id: data.host_id,
|
|
99
99
|
response,
|
|
100
100
|
}),
|
|
@@ -14,3 +14,7 @@ NATS_TOKEN=
|
|
|
14
14
|
VAPID_PUBLIC_KEY=
|
|
15
15
|
VAPID_PRIVATE_KEY=
|
|
16
16
|
VAPID_MAILTO=mailto:admin@example.com
|
|
17
|
+
|
|
18
|
+
# Firebase Admin SDK (for FCM push to Android devices)
|
|
19
|
+
# Download from: Firebase Console → Project Settings → Service accounts → Generate new private key
|
|
20
|
+
GOOGLE_APPLICATION_CREDENTIALS=./palmier-firebase-adminsdk.json
|
|
@@ -27,6 +27,16 @@ export async function initDb(): Promise<void> {
|
|
|
27
27
|
UNIQUE(host_id, endpoint)
|
|
28
28
|
);
|
|
29
29
|
`);
|
|
30
|
+
await client.query(`
|
|
31
|
+
CREATE TABLE IF NOT EXISTS fcm_tokens (
|
|
32
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
33
|
+
host_id UUID NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
|
|
34
|
+
fcm_token TEXT NOT NULL,
|
|
35
|
+
device_label VARCHAR(255),
|
|
36
|
+
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
|
37
|
+
UNIQUE(host_id, fcm_token)
|
|
38
|
+
);
|
|
39
|
+
`);
|
|
30
40
|
console.log("Database tables initialized.");
|
|
31
41
|
} finally {
|
|
32
42
|
client.release();
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import admin from "firebase-admin";
|
|
2
|
+
import { pool } from "./db.js";
|
|
3
|
+
|
|
4
|
+
let initialized = false;
|
|
5
|
+
|
|
6
|
+
function ensureInitialized(): boolean {
|
|
7
|
+
if (initialized) return true;
|
|
8
|
+
|
|
9
|
+
if (!process.env.GOOGLE_APPLICATION_CREDENTIALS) {
|
|
10
|
+
console.warn("GOOGLE_APPLICATION_CREDENTIALS not set. FCM will not work.");
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
admin.initializeApp({
|
|
15
|
+
credential: admin.credential.applicationDefault(),
|
|
16
|
+
});
|
|
17
|
+
initialized = true;
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function sendFcmToClients(
|
|
22
|
+
hostId: string,
|
|
23
|
+
data: Record<string, string>
|
|
24
|
+
): Promise<void> {
|
|
25
|
+
if (!ensureInitialized()) {
|
|
26
|
+
console.warn("[FCM] Not initialized, skipping message for host", hostId);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const result = await pool.query(
|
|
31
|
+
"SELECT fcm_token FROM fcm_tokens WHERE host_id = $1",
|
|
32
|
+
[hostId]
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
if (result.rows.length === 0) {
|
|
36
|
+
console.warn(`[FCM] No FCM tokens registered for host ${hostId}`);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const sendPromises = result.rows.map(async (row) => {
|
|
41
|
+
try {
|
|
42
|
+
await admin.messaging().send({
|
|
43
|
+
token: row.fcm_token,
|
|
44
|
+
data,
|
|
45
|
+
});
|
|
46
|
+
} catch (err: any) {
|
|
47
|
+
if (
|
|
48
|
+
err.code === "messaging/registration-token-not-registered" ||
|
|
49
|
+
err.code === "messaging/invalid-registration-token"
|
|
50
|
+
) {
|
|
51
|
+
await pool.query(
|
|
52
|
+
"DELETE FROM fcm_tokens WHERE host_id = $1 AND fcm_token = $2",
|
|
53
|
+
[hostId, row.fcm_token]
|
|
54
|
+
);
|
|
55
|
+
console.log(`[FCM] Removed stale token for host ${hostId}`);
|
|
56
|
+
} else {
|
|
57
|
+
console.error(`[FCM] Failed to send to token:`, err.message);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
await Promise.allSettled(sendPromises);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function sendFcmToDevice(
|
|
66
|
+
fcmToken: string,
|
|
67
|
+
data: Record<string, string>
|
|
68
|
+
): Promise<void> {
|
|
69
|
+
if (!ensureInitialized()) {
|
|
70
|
+
throw new Error("FCM not initialized");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
await admin.messaging().send({ token: fcmToken, data });
|
|
74
|
+
}
|