Add a packaged-client runtime boundary so the shared UI can talk to local, desktop, remote, and VS Code runtimes through the right transport instead of assuming one same-origin web server. Centralize OpenChamber-owned API access behind RuntimeAPIs, runtimeFetch, and runtime URL helpers, while keeping official OpenCode traffic on the SDK path. Support runtime switching, remote host selection, desktop client credentials, and headless connection links for pairing packaged clients with remote OpenChamber servers. Harden the new auth model by moving long-lived client tokens out of browser URLs, introducing short-lived scoped URL tokens for browser-owned transports, restricting URL-token access to explicit readable/realtime routes, and making client-token management session-scoped or self-scoped as appropriate. Update browser-owned assets and preview proxy flows to work with the split runtime model, including authenticated project icons, preview token propagation, CSP-safe preview bridge injection, and preview proxy auth that survives short-lived URL-token expiry. Tighten Electron security boundaries for packaged clients by gating privileged preload state to trusted origins and requiring explicit confirmation before connect deep-links import or switch remote runtimes. Also refresh agent guidance and project skills so future runtime/API, auth, preview, UI, CLI, settings, locale, and drag-to-reorder work follows the new architecture.
325 lines
11 KiB
JavaScript
325 lines
11 KiB
JavaScript
const PUSH_SUBSCRIPTIONS_VERSION = 1;
|
|
const UI_VISIBILITY_TTL_MS = 30_000;
|
|
|
|
const isLoopbackHttpOrigin = (value) => {
|
|
if (typeof value !== 'string') {
|
|
return false;
|
|
}
|
|
|
|
return value.startsWith('http://localhost')
|
|
|| value.startsWith('http://127.0.0.1')
|
|
|| value.startsWith('http://[::1]');
|
|
};
|
|
|
|
export const createPushRuntime = (deps) => {
|
|
const {
|
|
fsPromises,
|
|
path,
|
|
webPush,
|
|
PUSH_SUBSCRIPTIONS_FILE_PATH,
|
|
readSettingsFromDiskMigrated,
|
|
writeSettingsToDisk,
|
|
} = deps;
|
|
|
|
let persistPushSubscriptionsLock = Promise.resolve();
|
|
let pushInitialized = false;
|
|
|
|
const uiVisibilityByToken = new Map();
|
|
const pruneUiVisibility = (now = Date.now()) => {
|
|
for (const [token, state] of uiVisibilityByToken) {
|
|
if (!state || now - state.updatedAt > UI_VISIBILITY_TTL_MS) {
|
|
uiVisibilityByToken.delete(token);
|
|
}
|
|
}
|
|
};
|
|
|
|
const readPushSubscriptionsFromDisk = async () => {
|
|
try {
|
|
const raw = await fsPromises.readFile(PUSH_SUBSCRIPTIONS_FILE_PATH, 'utf8');
|
|
const parsed = JSON.parse(raw);
|
|
if (!parsed || typeof parsed !== 'object') {
|
|
return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession: {} };
|
|
}
|
|
if (typeof parsed.version !== 'number' || parsed.version !== PUSH_SUBSCRIPTIONS_VERSION) {
|
|
return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession: {} };
|
|
}
|
|
|
|
const subscriptionsBySession =
|
|
parsed.subscriptionsBySession && typeof parsed.subscriptionsBySession === 'object'
|
|
? parsed.subscriptionsBySession
|
|
: {};
|
|
|
|
return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession };
|
|
} catch (error) {
|
|
if (error && typeof error === 'object' && error.code === 'ENOENT') {
|
|
return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession: {} };
|
|
}
|
|
console.warn('Failed to read push subscriptions file:', error);
|
|
return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession: {} };
|
|
}
|
|
};
|
|
|
|
const writePushSubscriptionsToDisk = async (data) => {
|
|
await fsPromises.mkdir(path.dirname(PUSH_SUBSCRIPTIONS_FILE_PATH), { recursive: true });
|
|
await fsPromises.writeFile(PUSH_SUBSCRIPTIONS_FILE_PATH, JSON.stringify(data, null, 2), 'utf8');
|
|
};
|
|
|
|
const persistPushSubscriptionUpdate = async (mutate) => {
|
|
persistPushSubscriptionsLock = persistPushSubscriptionsLock.then(async () => {
|
|
await fsPromises.mkdir(path.dirname(PUSH_SUBSCRIPTIONS_FILE_PATH), { recursive: true });
|
|
const current = await readPushSubscriptionsFromDisk();
|
|
const next = mutate({
|
|
version: PUSH_SUBSCRIPTIONS_VERSION,
|
|
subscriptionsBySession: current.subscriptionsBySession || {},
|
|
});
|
|
await writePushSubscriptionsToDisk(next);
|
|
return next;
|
|
});
|
|
|
|
return persistPushSubscriptionsLock;
|
|
};
|
|
|
|
const getOrCreateVapidKeys = async () => {
|
|
const settings = await readSettingsFromDiskMigrated();
|
|
const existing = settings?.vapidKeys;
|
|
if (existing && typeof existing.publicKey === 'string' && typeof existing.privateKey === 'string') {
|
|
return { publicKey: existing.publicKey, privateKey: existing.privateKey };
|
|
}
|
|
|
|
const generated = webPush.generateVAPIDKeys();
|
|
const next = {
|
|
...settings,
|
|
vapidKeys: {
|
|
publicKey: generated.publicKey,
|
|
privateKey: generated.privateKey,
|
|
},
|
|
};
|
|
|
|
await writeSettingsToDisk(next);
|
|
return { publicKey: generated.publicKey, privateKey: generated.privateKey };
|
|
};
|
|
|
|
const normalizePushSubscriptions = (record) => {
|
|
if (!Array.isArray(record)) return [];
|
|
return record
|
|
.map((entry) => {
|
|
if (!entry || typeof entry !== 'object') return null;
|
|
const endpoint = entry.endpoint;
|
|
const p256dh = entry.p256dh;
|
|
const auth = entry.auth;
|
|
if (typeof endpoint !== 'string' || typeof p256dh !== 'string' || typeof auth !== 'string') {
|
|
return null;
|
|
}
|
|
return {
|
|
endpoint,
|
|
p256dh,
|
|
auth,
|
|
createdAt: typeof entry.createdAt === 'number' ? entry.createdAt : null,
|
|
};
|
|
})
|
|
.filter(Boolean);
|
|
};
|
|
|
|
const addOrUpdatePushSubscription = async (uiSessionToken, subscription, userAgent) => {
|
|
if (!uiSessionToken) {
|
|
return;
|
|
}
|
|
|
|
await ensurePushInitialized();
|
|
|
|
const now = Date.now();
|
|
|
|
await persistPushSubscriptionUpdate((current) => {
|
|
const subsBySession = { ...(current.subscriptionsBySession || {}) };
|
|
const existing = Array.isArray(subsBySession[uiSessionToken]) ? subsBySession[uiSessionToken] : [];
|
|
|
|
const filtered = existing.filter((entry) => entry && typeof entry.endpoint === 'string' && entry.endpoint !== subscription.endpoint);
|
|
|
|
filtered.unshift({
|
|
endpoint: subscription.endpoint,
|
|
p256dh: subscription.p256dh,
|
|
auth: subscription.auth,
|
|
createdAt: now,
|
|
lastSeenAt: now,
|
|
userAgent: typeof userAgent === 'string' && userAgent.length > 0 ? userAgent : undefined,
|
|
});
|
|
|
|
subsBySession[uiSessionToken] = filtered.slice(0, 10);
|
|
|
|
return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession: subsBySession };
|
|
});
|
|
};
|
|
|
|
const removePushSubscription = async (uiSessionToken, endpoint) => {
|
|
if (!uiSessionToken || !endpoint) return;
|
|
|
|
await ensurePushInitialized();
|
|
|
|
await persistPushSubscriptionUpdate((current) => {
|
|
const subsBySession = { ...(current.subscriptionsBySession || {}) };
|
|
const existing = Array.isArray(subsBySession[uiSessionToken]) ? subsBySession[uiSessionToken] : [];
|
|
const filtered = existing.filter((entry) => entry && typeof entry.endpoint === 'string' && entry.endpoint !== endpoint);
|
|
if (filtered.length === 0) {
|
|
delete subsBySession[uiSessionToken];
|
|
} else {
|
|
subsBySession[uiSessionToken] = filtered;
|
|
}
|
|
return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession: subsBySession };
|
|
});
|
|
};
|
|
|
|
const removePushSubscriptionFromAllSessions = async (endpoint) => {
|
|
if (!endpoint) return;
|
|
|
|
await ensurePushInitialized();
|
|
|
|
await persistPushSubscriptionUpdate((current) => {
|
|
const subsBySession = { ...(current.subscriptionsBySession || {}) };
|
|
for (const [token, entries] of Object.entries(subsBySession)) {
|
|
if (!Array.isArray(entries)) continue;
|
|
const filtered = entries.filter((entry) => entry && typeof entry.endpoint === 'string' && entry.endpoint !== endpoint);
|
|
if (filtered.length === 0) {
|
|
delete subsBySession[token];
|
|
} else {
|
|
subsBySession[token] = filtered;
|
|
}
|
|
}
|
|
return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession: subsBySession };
|
|
});
|
|
};
|
|
|
|
const sendPushToSubscription = async (sub, payload) => {
|
|
await ensurePushInitialized();
|
|
const body = JSON.stringify(payload);
|
|
|
|
const pushSubscription = {
|
|
endpoint: sub.endpoint,
|
|
keys: {
|
|
p256dh: sub.p256dh,
|
|
auth: sub.auth,
|
|
},
|
|
};
|
|
|
|
try {
|
|
await webPush.sendNotification(pushSubscription, body);
|
|
} catch (error) {
|
|
const statusCode = typeof error?.statusCode === 'number' ? error.statusCode : null;
|
|
if (statusCode === 410 || statusCode === 404) {
|
|
await removePushSubscriptionFromAllSessions(sub.endpoint);
|
|
return;
|
|
}
|
|
console.warn('[Push] Failed to send notification:', error);
|
|
}
|
|
};
|
|
|
|
const sendPushToAllUiSessions = async (payload, options = {}) => {
|
|
const requireNoSse = options.requireNoSse === true;
|
|
const store = await readPushSubscriptionsFromDisk();
|
|
const sessions = store.subscriptionsBySession || {};
|
|
const subscriptionsByEndpoint = new Map();
|
|
|
|
for (const record of Object.values(sessions)) {
|
|
const subscriptions = normalizePushSubscriptions(record);
|
|
if (subscriptions.length === 0) continue;
|
|
|
|
for (const sub of subscriptions) {
|
|
if (!subscriptionsByEndpoint.has(sub.endpoint)) {
|
|
subscriptionsByEndpoint.set(sub.endpoint, sub);
|
|
}
|
|
}
|
|
}
|
|
|
|
await Promise.all(Array.from(subscriptionsByEndpoint.values()).map(async (sub) => {
|
|
if (requireNoSse && isAnyUiVisible()) {
|
|
return;
|
|
}
|
|
await sendPushToSubscription(sub, payload);
|
|
}));
|
|
};
|
|
|
|
const updateUiVisibility = (token, visible) => {
|
|
if (!token) return;
|
|
const now = Date.now();
|
|
const nextVisible = Boolean(visible);
|
|
uiVisibilityByToken.set(token, { visible: nextVisible, updatedAt: now });
|
|
};
|
|
|
|
const isAnyUiVisible = () => {
|
|
const now = Date.now();
|
|
pruneUiVisibility(now);
|
|
for (const state of uiVisibilityByToken.values()) {
|
|
if (state.visible === true && now - state.updatedAt <= UI_VISIBILITY_TTL_MS) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const isUiVisible = (token) => {
|
|
const now = Date.now();
|
|
pruneUiVisibility(now);
|
|
const state = uiVisibilityByToken.get(token);
|
|
return state?.visible === true && now - state.updatedAt <= UI_VISIBILITY_TTL_MS;
|
|
};
|
|
|
|
const resolveVapidSubject = async () => {
|
|
const configured = process.env.OPENCHAMBER_VAPID_SUBJECT;
|
|
if (typeof configured === 'string' && configured.trim().length > 0) {
|
|
return configured.trim();
|
|
}
|
|
|
|
const originEnv = process.env.OPENCHAMBER_PUBLIC_ORIGIN;
|
|
if (typeof originEnv === 'string' && originEnv.trim().length > 0) {
|
|
const trimmed = originEnv.trim();
|
|
if (isLoopbackHttpOrigin(trimmed)) {
|
|
return 'mailto:openchamber@localhost';
|
|
}
|
|
return trimmed;
|
|
}
|
|
|
|
try {
|
|
const settings = await readSettingsFromDiskMigrated();
|
|
const stored = settings?.publicOrigin;
|
|
if (typeof stored === 'string' && stored.trim().length > 0) {
|
|
const trimmed = stored.trim();
|
|
if (isLoopbackHttpOrigin(trimmed)) {
|
|
return 'mailto:openchamber@localhost';
|
|
}
|
|
return trimmed;
|
|
}
|
|
} catch {
|
|
}
|
|
|
|
return 'mailto:openchamber@localhost';
|
|
};
|
|
|
|
const ensurePushInitialized = async () => {
|
|
if (pushInitialized) return;
|
|
const keys = await getOrCreateVapidKeys();
|
|
const subject = await resolveVapidSubject();
|
|
|
|
if (subject === 'mailto:openchamber@localhost') {
|
|
console.warn('[Push] No public origin configured for VAPID; set OPENCHAMBER_VAPID_SUBJECT or enable push once from a real origin.');
|
|
}
|
|
|
|
webPush.setVapidDetails(subject, keys.publicKey, keys.privateKey);
|
|
pushInitialized = true;
|
|
};
|
|
|
|
const setPushInitialized = (value) => {
|
|
pushInitialized = value === true;
|
|
};
|
|
|
|
return {
|
|
getOrCreateVapidKeys,
|
|
addOrUpdatePushSubscription,
|
|
removePushSubscription,
|
|
sendPushToAllUiSessions,
|
|
updateUiVisibility,
|
|
isAnyUiVisible,
|
|
isUiVisible,
|
|
ensurePushInitialized,
|
|
setPushInitialized,
|
|
};
|
|
};
|