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.
157 lines
5.5 KiB
JavaScript
157 lines
5.5 KiB
JavaScript
import { contextBridge, ipcRenderer } from 'electron';
|
|
|
|
const eventListeners = new Map();
|
|
|
|
const readArgValue = (name) => {
|
|
const prefix = `${name}=`;
|
|
const entry = process.argv.find((value) => typeof value === 'string' && value.startsWith(prefix));
|
|
if (!entry) {
|
|
return '';
|
|
}
|
|
return entry.slice(prefix.length);
|
|
};
|
|
|
|
const localOrigin = readArgValue('--openchamber-local-origin');
|
|
const apiBaseUrl = readArgValue('--openchamber-api-base-url');
|
|
const clientToken = readArgValue('--openchamber-client-token');
|
|
const homeDirectory = readArgValue('--openchamber-home');
|
|
const macosMajorRaw = readArgValue('--openchamber-macos-major');
|
|
const macosMajor = Number.parseInt(macosMajorRaw, 10);
|
|
|
|
// Preload re-executes on every cross-origin navigation (we run with
|
|
// sandbox:false, per-document). Two separate concerns to balance:
|
|
// - __OPENCHAMBER_ELECTRON__ is a shell-identity flag (no capability).
|
|
// Remote UIs still need it so isDesktopShell() returns true and the
|
|
// window renders with desktop affordances (DesktopHostSwitcher,
|
|
// title bar offsets, etc.). Expose unconditionally.
|
|
// - __TAURI__ is the IPC channel to the main process. The compatibility
|
|
// shim is exposed broadly, but privileged commands are gated in main.mjs.
|
|
// Local-only globals below stay limited to packaged UI / exact localOrigin.
|
|
// Everything driven by localOrigin (home dir, macOS hints) also stays
|
|
// local-only since it leaks info about the Electron host machine.
|
|
const currentOrigin = (() => {
|
|
try {
|
|
return typeof location !== 'undefined' ? location.origin : '';
|
|
} catch {
|
|
return '';
|
|
}
|
|
})();
|
|
const isLocalPage = currentOrigin !== 'null'
|
|
&& (currentOrigin === 'openchamber-ui://app'
|
|
|| (localOrigin && currentOrigin === localOrigin));
|
|
|
|
// Remote pages need __OPENCHAMBER_LOCAL_ORIGIN__ so the HostSwitcher knows
|
|
// the URL of the Local entry (isDesktopLocalOriginActive() falls back to
|
|
// window.location.origin otherwise — wrong on remote). Low risk: the value
|
|
// is just "http://127.0.0.1:<port>" which is not exploitable without the
|
|
// IPC channel, and CORS on the local server prevents remote-origin fetches.
|
|
if (localOrigin) {
|
|
contextBridge.exposeInMainWorld('__OPENCHAMBER_LOCAL_ORIGIN__', localOrigin);
|
|
}
|
|
|
|
if (apiBaseUrl) {
|
|
contextBridge.exposeInMainWorld('__OPENCHAMBER_API_BASE_URL__', apiBaseUrl);
|
|
}
|
|
|
|
if (clientToken && isLocalPage) {
|
|
contextBridge.exposeInMainWorld('__OPENCHAMBER_CLIENT_TOKEN__', clientToken);
|
|
}
|
|
|
|
// Home directory leaks the OS username — keep local-only. Remote pages
|
|
// operate on the REMOTE server's filesystem, local home is irrelevant
|
|
// (and would be misleading if consumed as a workspace hint).
|
|
if (isLocalPage && homeDirectory) {
|
|
contextBridge.exposeInMainWorld('__OPENCHAMBER_HOME__', homeDirectory);
|
|
}
|
|
|
|
// macOS major version drives window chrome offsets (traffic lights) — UI
|
|
// presentation only, safe to expose.
|
|
if (Number.isFinite(macosMajor) && macosMajor > 0) {
|
|
contextBridge.exposeInMainWorld('__OPENCHAMBER_MACOS_MAJOR__', macosMajor);
|
|
}
|
|
|
|
contextBridge.exposeInMainWorld('__OPENCHAMBER_ELECTRON__', {
|
|
runtime: 'electron',
|
|
});
|
|
|
|
contextBridge.exposeInMainWorld('__OPENCHAMBER_PLATFORM__', process.platform);
|
|
|
|
// Note: bootOutcome must stay writable from the main world's initScript so
|
|
// re-navigations (host switch via deep link) can refresh it. contextBridge-
|
|
// exposed globals are read-only, which blocks that update — rely solely on
|
|
// the main-process initScript injection (dispatched on did-finish-load).
|
|
|
|
const addListener = (event, handler) => {
|
|
const listeners = eventListeners.get(event) || new Set();
|
|
listeners.add(handler);
|
|
eventListeners.set(event, listeners);
|
|
|
|
return () => {
|
|
const current = eventListeners.get(event);
|
|
if (!current) {
|
|
return;
|
|
}
|
|
current.delete(handler);
|
|
if (current.size === 0) {
|
|
eventListeners.delete(event);
|
|
}
|
|
};
|
|
};
|
|
|
|
const dispatchNativeEvent = (event, detail) => {
|
|
const listeners = eventListeners.get(event);
|
|
if (listeners) {
|
|
for (const listener of listeners) {
|
|
try {
|
|
listener({ payload: detail });
|
|
} catch (error) {
|
|
console.error(`[electron:preload] listener failed for ${event}:`, error);
|
|
}
|
|
}
|
|
}
|
|
|
|
try {
|
|
const domEvent = detail === undefined
|
|
? new Event(event)
|
|
: new CustomEvent(event, { detail });
|
|
window.dispatchEvent(domEvent);
|
|
} catch (error) {
|
|
console.error(`[electron:preload] failed to dispatch DOM event ${event}:`, error);
|
|
}
|
|
};
|
|
|
|
// Main-process events are read-only notifications (update progress,
|
|
// window focus, etc.) — safe to deliver to any page rendered in this
|
|
// webContents. The events themselves don't grant capability.
|
|
ipcRenderer.on('openchamber:emit', (_evt, payload) => {
|
|
if (!payload || typeof payload !== 'object') {
|
|
return;
|
|
}
|
|
|
|
const event = typeof payload.event === 'string' ? payload.event : '';
|
|
if (!event) {
|
|
return;
|
|
}
|
|
|
|
dispatchNativeEvent(event, payload.detail);
|
|
});
|
|
|
|
// __TAURI__ is exposed on all pages; the main-process gate in
|
|
// ipcMain.handle('openchamber:invoke') decides per-command what is safe
|
|
// for non-local callers (window/host-switcher ops yes, file/shell ops
|
|
// no). See COMMANDS_SAFE_FOR_REMOTE in main.mjs.
|
|
contextBridge.exposeInMainWorld('__TAURI__', {
|
|
core: {
|
|
invoke: (cmd, args) => ipcRenderer.invoke('openchamber:invoke', cmd, args || {}),
|
|
},
|
|
dialog: {
|
|
open: (options) => ipcRenderer.invoke('openchamber:dialog:open', options || {}),
|
|
},
|
|
shell: {
|
|
open: (url) => ipcRenderer.invoke('openchamber:invoke', 'desktop_open_external_url', { url }),
|
|
},
|
|
event: {
|
|
listen: async (event, handler) => addListener(event, handler),
|
|
},
|
|
});
|