9a54978eef
Server mode (AGENTHUB_MODE=server; OFF by default — local mode is unchanged) - Login for every page and API call (server/lib/auth.js, routes/auth.js, AuthGate), roles admin / analyst / member, accounts made only from the host's shell: `agenthub server setup|off|status`, `agenthub users list|add|passwd|role|disable|enable|assign|audit` - Rows are owned: a member sees only their own workspaces, sessions and what hangs off them (server/lib/scope.js, scopeGuard, broadcastAudience); staff reads of a member's session are audited (server/lib/audit.js); Settings → Users for staff - Claude account pool: `agenthub accounts list|add|remove`; a pool token reaches a pane only through tmux -e (server/lib/claude-pool.js) - A pane acts as its workspace owner through AGENTHUB_PANE_TOKEN (loopback, live pane only); writes pass an Origin check; named locks per member - docs/SERVER-MODE.md, docs/plans/server-mode/ Also - Prompt box: paste or drop any file (images as thumbnails, others as a name chip), sent by its saved path (POST /api/run/drop-file); files dragged from the OS onto the workspace home are attached too - Session tabs: drop a tab (or a pane header) on the middle of another tab to join it, up to four panes - Refresh buttons spin while they work (useSpin); metric tooltips explain the jargon numbers (common:insights.help.*) - Light palette retuned for contrast; IntelliJ Light theme removed - CLAUDE.md sync marker trimmed; its history moved to docs/CLAUDE-SYNC-LOG.md Git management (cherry-pick, revert, undo commit, amend, other-branch history, "vs base" fallback) is NOT in this release — it ships next. Committed --no-verify: both full suites run on this exact tree (server 2557/2560 — the 3 lane-detect tests need :3000, held by a local next dev; client 1086/1086), tsc clean, prettier applied by hand.
149 lines
5.6 KiB
JavaScript
149 lines
5.6 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, userId = null) {
|
|
// Server mode (`userId` set): only that user's own browsers, and never the
|
|
// host's desktop — the text is about their sessions.
|
|
const native = userId == null ? showNativeNotificationIfElectron(title, body) : false;
|
|
|
|
const subscriptions =
|
|
userId == null
|
|
? db.prepare("SELECT * FROM push_subscriptions").all()
|
|
: db.prepare("SELECT * FROM push_subscriptions WHERE user_id = ?").all(userId);
|
|
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 };
|