Files
AgentHub/server/lib/push.js
T
nntrivi2001 64017c6ca5 feat(run,notifications,profiles): stop sending prompts nobody typed, and fix notification + profile delivery
Run console:
- Picking a pipeline no longer pre-writes anything into the prompt box
  (`promptPrefill` in `LaneConsolePane.tsx` is gone — it wrote the driving
  slash command on a claude lane and a stage checklist on a codex one).
  Whatever sits in that box is sent as a real prompt the moment the run
  starts, so the box is the user's alone now.
- Resuming a session sends NO prompt at all (`handleStart` in `RunSetup.tsx`):
  the resumed session carries its own transcript, and leftover text in the box
  fired an extra turn into it.

Notifications:
- `client/src/lib/notify.ts` is now the ONE sender, used by both
  `useNotifications` and Settings' test button, and it reports where the
  notification landed: `POST /api/push/send` answering `{native:false,pushed:0}`
  means it went NOWHERE, so the client falls back to a local notification
  instead of counting the 200 as delivered.
- A notification carries the lane that raised it (`/workspace?lane=<id>`), and
  Workspace selects that lane on arrival.

Profiles:
- `server/lib/cc-profiles.js` reads and writes the macOS login Keychain item
  `Claude Code-credentials`, where the `claude` CLI actually keeps
  `claudeAiOauth` on darwin — a switch used to rewrite a file nothing reads.
  The file stays the fallback, is kept in step only when it already exists, and
  `AGENTHUB_CC_KEYCHAIN=0` pins it (tests set it).

Tests: npm run test:server (1820 pass), client vitest (553 pass).
2026-09-21 15:15:22 +07:00

144 lines
5.3 KiB
JavaScript

/**
* @file Handles web push notifications using the `web-push` library, including generating/loading VAPID keys, sending notifications to all subscribed clients, and cleaning up invalid subscriptions. It provides a function to retrieve the public VAPID key for client registration and a function to broadcast notifications to all subscribers stored in the database.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const webpush = require("web-push");
const path = require("path");
const fs = require("fs");
const { getDataDir } = require("./claude-home");
// Lives in the shared data dir alongside the SQLite DB (see getDataDir), so the
// web app and the native apps reuse one set of VAPID keys.
const KEYS_PATH = path.join(getDataDir(), "vapid-keys.json");
function loadOrCreateVapidKeys() {
if (fs.existsSync(KEYS_PATH)) {
return JSON.parse(fs.readFileSync(KEYS_PATH, "utf8"));
}
const keys = webpush.generateVAPIDKeys();
fs.mkdirSync(path.dirname(KEYS_PATH), { recursive: true });
fs.writeFileSync(KEYS_PATH, JSON.stringify(keys, null, 2));
return keys;
}
const vapidKeys = loadOrCreateVapidKeys();
webpush.setVapidDetails(
"https://git.smartgift.io.vn/Smartgift-AI/AgentHub",
vapidKeys.publicKey,
vapidKeys.privateKey
);
function getPublicKey() {
return vapidKeys.publicKey;
}
/**
* Fire a native OS notification when this process is the Electron main process
* (i.e. the desktop app embeds the server in-process). Web Push is unreliable
* inside Electron — Chromium-in-Electron ships without Firebase Cloud
* Messaging credentials, so `pushManager.subscribe()` in the renderer either
* fails or returns an endpoint that nothing can ever deliver to, leaving the
* `push_subscriptions` table empty. Calling Electron's main-process
* Notification API directly side-steps the push service entirely.
*
* Returns true when a notification was actually shown.
*
* @param {string} title
* @param {string} body
* @returns {boolean}
*/
function showNativeNotificationIfElectron(title, body) {
if (!process.versions || !process.versions.electron) return false;
try {
// `require("electron")` only resolves inside the Electron runtime; in a
// plain `node server/index.js` host it throws and we fall through.
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { Notification: ElectronNotification } = require("electron");
if (!ElectronNotification) return false;
if (
typeof ElectronNotification.isSupported === "function" &&
!ElectronNotification.isSupported()
) {
return false;
}
new ElectronNotification({ title, body, silent: false }).show();
return true;
} catch {
return false;
}
}
/**
* Dispatch a notification to every reachable surface:
* - A native Electron notification when hosted inside the desktop app.
* - A Web Push delivery to every subscribed browser endpoint.
*
* Both legs run unconditionally so whichever surface the user is on receives
* the alert. Under `npm start` the native leg is a no-op; under the desktop
* app the Web Push leg is typically a no-op (no FCM credentials in Electron,
* so `push_subscriptions` is empty).
*
* Returns `{ native, pushed, failed }` so the caller can surface what actually
* happened in its API response — silent failures stop looking like success.
*
* @param {string} [url] Same-origin path (e.g. `/workspace?lane=3`) the
* service worker's `notificationclick` handler navigates to. Undefined for
* notifications with no specific target.
*/
async function sendPushToAll(db, title, body, url) {
const native = showNativeNotificationIfElectron(title, body);
const subscriptions = db.prepare("SELECT * FROM push_subscriptions").all();
if (subscriptions.length === 0) {
return { native, pushed: 0, failed: 0 };
}
const payload = JSON.stringify({
title,
body,
// Same-origin path, resolved by the service worker against its own scope
// (client/public/sw.js spreads these straight into showNotification). An
// absolute forge/raw URL would 404 — this repo is self-hosted on Gitea and
// ships favicon.svg, not favicon.ico.
icon: "/favicon.svg",
badge: "/favicon.svg",
silent: false,
sound: "default",
// `data` is the standard NotificationOptions field notificationclick reads
// back via `event.notification.data`; omitted entirely when there's no url
// so `event.notification.data` stays undefined rather than `{url: undefined}`.
...(url ? { data: { url } } : {}),
});
const results = await Promise.allSettled(
subscriptions.map((sub) =>
webpush.sendNotification(
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
payload
)
)
);
// Remove subscriptions that are gone (HTTP 410); count what landed.
let pushed = 0;
let failed = 0;
for (let index = 0; index < results.length; index++) {
const result = results[index];
if (result.status === "fulfilled") {
pushed++;
} else {
failed++;
if (result.reason?.statusCode === 410) {
db.prepare("DELETE FROM push_subscriptions WHERE endpoint = ?").run(
subscriptions[index].endpoint
);
}
}
}
return { native, pushed, failed };
}
module.exports = { getPublicKey, sendPushToAll, showNativeNotificationIfElectron };