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.
Files changed (96) hide show
  1. package/README.md +15 -1
  2. package/dist/agents/agent-instructions.md +6 -14
  3. package/dist/agents/aider.js +1 -1
  4. package/dist/agents/claude.js +1 -1
  5. package/dist/agents/cline.js +1 -1
  6. package/dist/agents/codex.js +1 -1
  7. package/dist/agents/copilot.js +1 -1
  8. package/dist/agents/cursor.js +1 -1
  9. package/dist/agents/deepagents.js +1 -1
  10. package/dist/agents/droid.js +1 -1
  11. package/dist/agents/gemini.js +1 -1
  12. package/dist/agents/goose.js +1 -1
  13. package/dist/agents/hermes.js +1 -1
  14. package/dist/agents/kimi.js +1 -1
  15. package/dist/agents/kiro.js +1 -1
  16. package/dist/agents/openclaw.js +1 -1
  17. package/dist/agents/opencode.js +1 -1
  18. package/dist/agents/qoder.js +1 -1
  19. package/dist/agents/qwen.js +1 -1
  20. package/dist/agents/shared-prompt.d.ts +3 -2
  21. package/dist/agents/shared-prompt.js +6 -4
  22. package/dist/commands/plan-generation.md +1 -0
  23. package/dist/commands/run.js +4 -7
  24. package/dist/location-device.d.ts +8 -0
  25. package/dist/location-device.js +32 -0
  26. package/dist/mcp-handler.d.ts +8 -0
  27. package/dist/mcp-handler.js +110 -0
  28. package/dist/mcp-tools.d.ts +27 -0
  29. package/dist/mcp-tools.js +218 -0
  30. package/dist/pwa/assets/{index-DhvJN8ie.css → index-C6Lz09EY.css} +1 -1
  31. package/dist/pwa/assets/index-C8vJwUNi.js +118 -0
  32. package/dist/pwa/assets/web-6UChJFov.js +1 -0
  33. package/dist/pwa/assets/web-NxTETXZK.js +1 -0
  34. package/dist/pwa/index.html +3 -3
  35. package/dist/pwa/service-worker.js +2 -2
  36. package/dist/rpc-handler.js +20 -8
  37. package/dist/spawn-command.js +3 -1
  38. package/dist/transports/http-transport.js +60 -129
  39. package/package.json +1 -1
  40. package/palmier-server/README.md +6 -1
  41. package/palmier-server/package.json +7 -1
  42. package/palmier-server/pnpm-lock.yaml +1025 -1
  43. package/palmier-server/pwa/index.html +1 -1
  44. package/palmier-server/pwa/package.json +3 -0
  45. package/palmier-server/pwa/src/App.css +64 -0
  46. package/palmier-server/pwa/src/api.ts +8 -2
  47. package/palmier-server/pwa/src/components/HostMenu.tsx +102 -1
  48. package/palmier-server/pwa/src/components/TaskCard.tsx +36 -8
  49. package/palmier-server/pwa/src/components/TaskForm.tsx +63 -53
  50. package/palmier-server/pwa/src/components/TaskListView.tsx +94 -78
  51. package/palmier-server/pwa/src/constants.ts +1 -1
  52. package/palmier-server/pwa/src/contexts/HostConnectionContext.tsx +2 -1
  53. package/palmier-server/pwa/src/hooks/usePushSubscription.ts +3 -0
  54. package/palmier-server/pwa/src/pages/Dashboard.tsx +5 -2
  55. package/palmier-server/pwa/src/pages/PairHost.tsx +10 -1
  56. package/palmier-server/pwa/src/service-worker.ts +7 -7
  57. package/palmier-server/server/.env.example +4 -0
  58. package/palmier-server/server/package.json +1 -0
  59. package/palmier-server/server/src/db.ts +10 -0
  60. package/palmier-server/server/src/fcm.ts +74 -0
  61. package/palmier-server/server/src/index.ts +101 -21
  62. package/palmier-server/server/src/notify.ts +34 -0
  63. package/palmier-server/server/src/push.ts +1 -1
  64. package/palmier-server/server/src/routes/fcm.ts +64 -0
  65. package/palmier-server/server/src/routes/push.ts +6 -5
  66. package/palmier-server/spec.md +4 -2
  67. package/src/agents/agent-instructions.md +6 -14
  68. package/src/agents/aider.ts +1 -1
  69. package/src/agents/claude.ts +1 -1
  70. package/src/agents/cline.ts +1 -1
  71. package/src/agents/codex.ts +1 -1
  72. package/src/agents/copilot.ts +1 -1
  73. package/src/agents/cursor.ts +1 -1
  74. package/src/agents/deepagents.ts +1 -1
  75. package/src/agents/droid.ts +1 -1
  76. package/src/agents/gemini.ts +1 -1
  77. package/src/agents/goose.ts +1 -1
  78. package/src/agents/hermes.ts +1 -1
  79. package/src/agents/kimi.ts +1 -1
  80. package/src/agents/kiro.ts +1 -1
  81. package/src/agents/openclaw.ts +1 -1
  82. package/src/agents/opencode.ts +1 -1
  83. package/src/agents/qoder.ts +1 -1
  84. package/src/agents/qwen.ts +1 -1
  85. package/src/agents/shared-prompt.ts +7 -4
  86. package/src/commands/plan-generation.md +1 -0
  87. package/src/commands/run.ts +4 -7
  88. package/src/location-device.ts +35 -0
  89. package/src/mcp-handler.ts +133 -0
  90. package/src/mcp-tools.ts +253 -0
  91. package/src/rpc-handler.ts +21 -8
  92. package/src/spawn-command.ts +3 -1
  93. package/src/transports/http-transport.ts +57 -128
  94. package/test/agent-instructions.test.ts +68 -5
  95. package/test/fixtures/agent-instructions-snapshot.md +58 -0
  96. 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<Set<string>>(new Set());
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 Set<string>();
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
- if (t.status.pending_confirmation) initialConfirms.add(t.id);
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 eventType: string | undefined;
114
+ let parsed: Record<string, unknown> = {};
113
115
  try {
114
- const parsed = JSON.parse(sc.decode(msg.data)) as { event_type?: string };
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 confirm-resolved: close pending confirmation dialog
119
- if (eventType === "confirm-resolved") {
120
- setPendingConfirms((prev) => {
121
- if (!prev.has(taskId)) return prev;
122
- const next = new Set(prev);
123
- next.delete(taskId);
124
- return next;
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 permission-resolved: close pending permission dialog
130
- if (eventType === "permission-resolved") {
131
- setPendingPermissions((prev) => {
132
- if (!prev.has(taskId)) return prev;
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(taskId);
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 input-resolved: close pending input dialog
141
- if (eventType === "input-resolved") {
142
- setPendingInputs((prev) => {
143
- if (!prev.has(taskId)) return prev;
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(taskId);
179
+ next.delete(sessionId);
146
180
  return next;
147
181
  });
148
- setInputValues((prev) => {
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(taskId: string, response: "confirmed" | "aborted") {
213
+ async function respondToConfirm(sessionId: string, response: "confirmed" | "aborted") {
196
214
  try {
197
- await request("task.user_input", { id: taskId, value: [response] });
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(taskId: string, values: string[]) {
229
+ async function respondToInput(sessionId: string, values: string[]) {
212
230
  try {
213
- await request("task.user_input", { id: taskId, value: values });
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((taskId) => {
304
- const task = tasks.find((t) => t.id === taskId);
321
+ {[...pendingConfirms.entries()].map(([sessionId, { description, agentName }]) => {
322
+ const subtitle = agentName || tasks.find((t) => t.id === sessionId)?.name;
305
323
  return (
306
- <div key={taskId} className="confirm-modal-overlay">
324
+ <div key={sessionId} className="confirm-modal-overlay">
307
325
  <div className="confirm-modal">
308
- <h2 className="confirm-modal-title">Task Confirmation</h2>
309
- <p className="confirm-modal-message">
310
- Run task "<strong>{task?.name || task?.user_prompt || taskId}</strong>"?
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(taskId, "confirmed")}>
330
+ <button className="btn btn-primary" onClick={() => respondToConfirm(sessionId, "confirmed")}>
314
331
  Confirm
315
332
  </button>
316
- <button className="btn btn-secondary" onClick={() => respondToConfirm(taskId, "aborted")}>
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(([taskId, descriptions]) => {
362
- const task = tasks.find((t) => t.id === taskId);
363
- const values = inputValues.get(taskId) ?? new Array(descriptions.length).fill("");
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={taskId} className="confirm-modal-overlay">
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-message">
369
- <strong>{task?.name || task?.user_prompt || taskId}</strong>
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
- {descriptions.map((desc, i) => (
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(taskId) ?? [])];
398
+ const arr = [...(next.get(sessionId) ?? [])];
383
399
  arr[i] = e.target.value;
384
- next.set(taskId, arr);
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(taskId, values)}
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(taskId, ["aborted"])}
420
+ onClick={() => respondToInput(sessionId, ["aborted"])}
405
421
  >
406
- Cancel & Abort Task
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.4";
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("/api/config");
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("/api/config");
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?.task_id === taskId && n.data?.host_id === dataHostId) {
42
- n.close();
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.task_id && data.host_id) {
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
- type: data.type,
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
@@ -12,6 +12,7 @@
12
12
  "cors": "^2.8.5",
13
13
  "dotenv": "^16.4.7",
14
14
  "express": "^4.21.2",
15
+ "firebase-admin": "^13.8.0",
15
16
  "helmet": "^8.0.0",
16
17
  "jsonwebtoken": "^9.0.2",
17
18
  "nats": "^2.29.1",
@@ -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
+ }