feat(desktop): remote-host mode + OS-store client-cert mTLS (D-1/D-2)
- remote-hosts.ts + DesktopPrefs (remoteHosts/selectedHostId, immutable, back-compat) + tray host-picker. - will-navigate lock is now dynamic to the selected remote origin, fails closed on null (no open-redirect). - select-client-certificate handler: event.preventDefault() first, pick by issuer CN, non-silent missing-cert dialog + cert-less callback -> clean nginx rejection (Chromium sources certs from OS store). Verified: npm run typecheck exit 0; pure-helper assertions pass.
This commit is contained in:
@@ -11,7 +11,8 @@
|
|||||||
*
|
*
|
||||||
* __dirname is available because esbuild emits this as a CJS bundle (build.mjs).
|
* __dirname is available because esbuild emits this as a CJS bundle (build.mjs).
|
||||||
*/
|
*/
|
||||||
import { app, BrowserWindow, Menu, Notification, Tray } from 'electron'
|
import { app, BrowserWindow, Menu, Notification, Tray, dialog } from 'electron'
|
||||||
|
import type { Certificate } from 'electron'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
|
|
||||||
import { createLogger } from './logger.js'
|
import { createLogger } from './logger.js'
|
||||||
@@ -20,11 +21,21 @@ import { startEmbeddedServer } from './embedded-server.js'
|
|||||||
import { computeNotifications } from './notifications.js'
|
import { computeNotifications } from './notifications.js'
|
||||||
import { mapLiveSessions } from './live-poll.js'
|
import { mapLiveSessions } from './live-poll.js'
|
||||||
import { parseDeepLink, deepLinkToPath, DEEP_LINK_PROTOCOL } from './deep-link.js'
|
import { parseDeepLink, deepLinkToPath, DEEP_LINK_PROTOCOL } from './deep-link.js'
|
||||||
import { createMainWindow } from './window.js'
|
import { createMainWindow, originOf } from './window.js'
|
||||||
import { buildAppMenu } from './menu.js'
|
import { buildAppMenu } from './menu.js'
|
||||||
import { createTray } from './tray.js'
|
import { createTray, buildTrayMenu } from './tray.js'
|
||||||
|
import type { TrayHandlers } from './tray.js'
|
||||||
|
import {
|
||||||
|
selectHost,
|
||||||
|
selectedRemoteHost,
|
||||||
|
remoteHostOrigin,
|
||||||
|
makeRemoteHost,
|
||||||
|
addRemoteHost,
|
||||||
|
removeRemoteHost,
|
||||||
|
TERMINAL_ZONE_SUFFIX,
|
||||||
|
} from './remote-hosts.js'
|
||||||
import type { NotifyOptions } from './notify-policy.js'
|
import type { NotifyOptions } from './notify-policy.js'
|
||||||
import type { EmbeddedServer, SessionStatusSnapshot } from './types.js'
|
import type { DesktopPrefs, EmbeddedServer, SessionStatusSnapshot } from './types.js'
|
||||||
|
|
||||||
/** Live-session poll cadence — cheap loopback GET, so a few seconds is plenty. */
|
/** Live-session poll cadence — cheap loopback GET, so a few seconds is plenty. */
|
||||||
const POLL_INTERVAL_MS = 4000
|
const POLL_INTERVAL_MS = 4000
|
||||||
@@ -35,12 +46,75 @@ const PRELOAD_FILE = 'preload.cjs'
|
|||||||
/** Tray icon location relative to the build/ output dir (shipped under assets/). */
|
/** Tray icon location relative to the build/ output dir (shipped under assets/). */
|
||||||
const TRAY_ICON_SUBPATH = path.join('..', 'assets', 'trayTemplate.png')
|
const TRAY_ICON_SUBPATH = path.join('..', 'assets', 'trayTemplate.png')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DEVICE_CA_CN — Common Name of the device client-certificate CA (VPS Track V2,
|
||||||
|
* gen-device-ca.sh). The device `.p12` lives in the OS keychain (macOS login
|
||||||
|
* keychain / Windows Personal store); Chromium sources client certificates ONLY
|
||||||
|
* from the OS store — reading a `.p12` in-app is infeasible — so on an mTLS
|
||||||
|
* handshake we select the cert whose issuer CN matches this. Override for a
|
||||||
|
* differently-named CA via the WEBTERM_DEVICE_CA_CN env var.
|
||||||
|
*/
|
||||||
|
const DEVICE_CA_CN = process.env.WEBTERM_DEVICE_CA_CN ?? 'WebTerminal Device CA'
|
||||||
|
|
||||||
|
/** Add-remote-host prompt window dimensions. */
|
||||||
|
const PROMPT_WIDTH = 460
|
||||||
|
const PROMPT_HEIGHT = 260
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal self-contained HTML for the "Add remote host" prompt window. It runs no
|
||||||
|
* Node (sandboxed, no preload) and returns its result by setting `location.hash`
|
||||||
|
* (read back via did-navigate-in-page); user text is JSON+URI-encoded into the
|
||||||
|
* hash, never interpolated into markup, so there is no injection surface. The CSP
|
||||||
|
* allows only inline style/script from this trusted, app-authored document.
|
||||||
|
*/
|
||||||
|
const PROMPT_HTML = `<!doctype html>
|
||||||
|
<html><head><meta charset="utf-8">
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'">
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: dark light; }
|
||||||
|
body { font: 13px -apple-system, Segoe UI, system-ui, sans-serif; margin: 0; padding: 18px;
|
||||||
|
background: #0e0f13; color: #e6e6e6; }
|
||||||
|
h1 { font-size: 15px; margin: 0 0 14px; }
|
||||||
|
label { display: block; margin-bottom: 10px; }
|
||||||
|
span { display: block; margin-bottom: 4px; color: #9aa0ab; }
|
||||||
|
input { width: 100%; box-sizing: border-box; padding: 7px 8px; border-radius: 6px;
|
||||||
|
border: 1px solid #333; background: #16181d; color: #e6e6e6; font: inherit; }
|
||||||
|
.row { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
|
||||||
|
button { padding: 7px 14px; border-radius: 6px; border: 1px solid #333;
|
||||||
|
background: #22252c; color: #e6e6e6; font: inherit; cursor: pointer; }
|
||||||
|
button[type=submit] { background: #2d6cdf; border-color: #2d6cdf; color: #fff; }
|
||||||
|
</style></head>
|
||||||
|
<body>
|
||||||
|
<h1>Add remote host</h1>
|
||||||
|
<form id="f">
|
||||||
|
<label><span>Name</span><input id="name" autofocus placeholder="Laptop"></label>
|
||||||
|
<label><span>URL</span><input id="url" value="https://" placeholder="https://t1.terminal.yaojia.wang"></label>
|
||||||
|
<div class="row">
|
||||||
|
<button type="button" id="cancel">Cancel</button>
|
||||||
|
<button type="submit">Add</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<script>
|
||||||
|
var form = document.getElementById('f');
|
||||||
|
form.addEventListener('submit', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var payload = { name: document.getElementById('name').value, url: document.getElementById('url').value };
|
||||||
|
location.hash = 'ok:' + encodeURIComponent(JSON.stringify(payload));
|
||||||
|
});
|
||||||
|
document.getElementById('cancel').addEventListener('click', function () { location.hash = 'cancel'; });
|
||||||
|
</script>
|
||||||
|
</body></html>`
|
||||||
|
|
||||||
const logger = createLogger('main')
|
const logger = createLogger('main')
|
||||||
const settings = createSettingsStore(app.getPath('userData'), process.platform, logger)
|
const settings = createSettingsStore(app.getPath('userData'), process.platform, logger)
|
||||||
|
|
||||||
let mainWindow: BrowserWindow | null = null
|
let mainWindow: BrowserWindow | null = null
|
||||||
let tray: Tray | null = null
|
let tray: Tray | null = null
|
||||||
let server: EmbeddedServer | null = null
|
let server: EmbeddedServer | null = null
|
||||||
|
// The origin the main window is currently locked to (local embedded server, or a
|
||||||
|
// selected remote tunnel host). Read live by window.ts's will-navigate guard;
|
||||||
|
// reassigned (never mutated) whenever we point the window at a different host.
|
||||||
|
let activeOrigin: string | null = null
|
||||||
let pollTimer: NodeJS.Timeout | null = null
|
let pollTimer: NodeJS.Timeout | null = null
|
||||||
let isQuitting = false
|
let isQuitting = false
|
||||||
let isShuttingDown = false
|
let isShuttingDown = false
|
||||||
@@ -66,6 +140,193 @@ function focusWindow(): void {
|
|||||||
mainWindow.focus()
|
mainWindow.focus()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True iff `value` is a plain, indexable object (not null, not an array). */
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The embedded local server's base URL (`http://127.0.0.1:<port>`), or null. */
|
||||||
|
function localBaseUrl(): string | null {
|
||||||
|
return server ? `http://127.0.0.1:${server.port}` : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the page URL + origin the window should currently be on, given prefs:
|
||||||
|
* the selected remote host, else the local embedded server. Returns null only if
|
||||||
|
* neither is resolvable (e.g. server not up yet and no valid remote selected).
|
||||||
|
*/
|
||||||
|
function resolveTarget(prefs: DesktopPrefs): { url: string; origin: string } | null {
|
||||||
|
const remote = selectedRemoteHost(prefs)
|
||||||
|
if (remote) {
|
||||||
|
const origin = remoteHostOrigin(remote)
|
||||||
|
if (origin !== null) return { url: remote.url, origin }
|
||||||
|
}
|
||||||
|
const base = localBaseUrl()
|
||||||
|
if (base === null) return null
|
||||||
|
return { url: `${base}/`, origin: base }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rebuild the tray context menu from the current host list + selection. */
|
||||||
|
function refreshTray(): void {
|
||||||
|
if (!tray) return
|
||||||
|
const prefs = settings.get()
|
||||||
|
tray.setContextMenu(
|
||||||
|
buildTrayMenu(
|
||||||
|
{ remoteHosts: prefs.remoteHosts, selectedHostId: prefs.selectedHostId },
|
||||||
|
trayHandlers,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch the window to a host (null → local). Persists the selection, updates the
|
||||||
|
* origin lock, navigates the window (programmatic loadURL does NOT trip
|
||||||
|
* will-navigate), refreshes the tray, and focuses. A no-op if nothing resolves.
|
||||||
|
*/
|
||||||
|
function switchToHost(id: string | null): void {
|
||||||
|
const prefs = settings.set(selectHost(settings.get(), id))
|
||||||
|
const target = resolveTarget(prefs)
|
||||||
|
if (!target) {
|
||||||
|
logger.warn('cannot switch host: no resolvable target (server not ready?)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
activeOrigin = target.origin
|
||||||
|
if (mainWindow) void mainWindow.loadURL(target.url)
|
||||||
|
refreshTray()
|
||||||
|
focusWindow()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prompt for a new remote host, validate it, persist it, and switch to it. */
|
||||||
|
async function addRemoteHostFlow(): Promise<void> {
|
||||||
|
const input = await promptRemoteHost()
|
||||||
|
if (!input) return
|
||||||
|
const host = makeRemoteHost(input.name, input.url)
|
||||||
|
if (!host) {
|
||||||
|
showError(
|
||||||
|
'Invalid remote host',
|
||||||
|
'That is not a valid tunnel URL.',
|
||||||
|
`Enter an https URL under ${TERMINAL_ZONE_SUFFIX}, ` +
|
||||||
|
'e.g. https://t1.terminal.yaojia.wang',
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
settings.set(addRemoteHost(settings.get(), host))
|
||||||
|
switchToHost(host.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove a remote host; if it was selected, selection falls back to local. */
|
||||||
|
function removeRemoteHostFlow(id: string): void {
|
||||||
|
const prefs = settings.set(removeRemoteHost(settings.get(), id))
|
||||||
|
const target = resolveTarget(prefs)
|
||||||
|
if (target) {
|
||||||
|
activeOrigin = target.origin
|
||||||
|
if (mainWindow) void mainWindow.loadURL(target.url)
|
||||||
|
}
|
||||||
|
refreshTray()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handlers wired into the tray host-picker (stable object; reads prefs live). */
|
||||||
|
const trayHandlers: TrayHandlers = {
|
||||||
|
show: () => focusWindow(),
|
||||||
|
quit: () => {
|
||||||
|
isQuitting = true
|
||||||
|
app.quit()
|
||||||
|
},
|
||||||
|
selectHost: (id) => switchToHost(id),
|
||||||
|
addHost: () => {
|
||||||
|
void addRemoteHostFlow()
|
||||||
|
},
|
||||||
|
removeHost: (id) => removeRemoteHostFlow(id),
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Show a native error dialog (parented to the main window when one exists). */
|
||||||
|
function showError(title: string, message: string, detail: string): void {
|
||||||
|
const options = { type: 'error' as const, title, message, detail }
|
||||||
|
if (mainWindow) void dialog.showMessageBox(mainWindow, options)
|
||||||
|
else void dialog.showMessageBox(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modal prompt for a remote-host name + URL. Resolves to the entered values, or
|
||||||
|
* null if cancelled/closed. The window returns its result via location.hash (no
|
||||||
|
* Node, no preload, no IPC surface); see PROMPT_HTML.
|
||||||
|
*/
|
||||||
|
function promptRemoteHost(): Promise<{ name: string; url: string } | null> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const win = new BrowserWindow({
|
||||||
|
parent: mainWindow ?? undefined,
|
||||||
|
modal: mainWindow !== null,
|
||||||
|
width: PROMPT_WIDTH,
|
||||||
|
height: PROMPT_HEIGHT,
|
||||||
|
resizable: false,
|
||||||
|
minimizable: false,
|
||||||
|
maximizable: false,
|
||||||
|
title: 'Add remote host',
|
||||||
|
backgroundColor: '#0e0f13',
|
||||||
|
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true },
|
||||||
|
})
|
||||||
|
let settled = false
|
||||||
|
const finish = (result: { name: string; url: string } | null): void => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
resolve(result)
|
||||||
|
if (!win.isDestroyed()) win.close()
|
||||||
|
}
|
||||||
|
win.webContents.on('did-navigate-in-page', (_event, navUrl) => {
|
||||||
|
let hash: string
|
||||||
|
try {
|
||||||
|
hash = new URL(navUrl).hash
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (hash === '#cancel') {
|
||||||
|
finish(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!hash.startsWith('#ok:')) return
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(decodeURIComponent(hash.slice('#ok:'.length)))
|
||||||
|
if (isRecord(parsed) && typeof parsed['name'] === 'string' && typeof parsed['url'] === 'string') {
|
||||||
|
finish({ name: parsed['name'], url: parsed['url'] })
|
||||||
|
} else {
|
||||||
|
finish(null)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
finish(null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
win.on('closed', () => finish(null))
|
||||||
|
void win.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(PROMPT_HTML)}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* D-2: choose the client certificate whose issuer is our device CA. The OS
|
||||||
|
* keychain may hold unrelated certs, so match on issuer CN rather than picking
|
||||||
|
* blindly. Returns null when nothing in the list was issued by DEVICE_CA_CN.
|
||||||
|
*/
|
||||||
|
function pickCertByIssuer(list: readonly Certificate[], issuerCn: string): Certificate | null {
|
||||||
|
return (
|
||||||
|
list.find(
|
||||||
|
(cert) => cert.issuerName === issuerCn || cert.issuer.commonName === issuerCn,
|
||||||
|
) ?? null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tell the user their device cert isn't installed (D-2: never fail silently). */
|
||||||
|
function showDeviceCertMissingDialog(requestUrl: string): void {
|
||||||
|
const host = originOf(requestUrl) ?? requestUrl
|
||||||
|
showError(
|
||||||
|
'Device certificate not installed',
|
||||||
|
'No matching device certificate was found in the OS keychain.',
|
||||||
|
`Connecting to ${host} requires your device client certificate (issued by ` +
|
||||||
|
`"${DEVICE_CA_CN}"). Install the provided device.p12 into the login keychain ` +
|
||||||
|
'(macOS: double-click the file) or the Personal certificate store (Windows), ' +
|
||||||
|
'then reconnect. On macOS you will be prompted once to "Always Allow" access ' +
|
||||||
|
'to the private key.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** Find the first terminalapp:// argument in a process argv list. */
|
/** Find the first terminalapp:// argument in a process argv list. */
|
||||||
function findDeepLinkArg(argv: readonly string[]): string | null {
|
function findDeepLinkArg(argv: readonly string[]): string | null {
|
||||||
const prefix = `${DEEP_LINK_PROTOCOL}://`
|
const prefix = `${DEEP_LINK_PROTOCOL}://`
|
||||||
@@ -90,9 +351,13 @@ function dispatchDeepLink(url: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const targetPath = deepLinkToPath(link)
|
const targetPath = deepLinkToPath(link)
|
||||||
|
const base = `http://127.0.0.1:${server.port}`
|
||||||
|
// A deep link joins a LOCAL session, so navigate the window back to the local
|
||||||
|
// origin and move the lock with it (in case a remote host was active).
|
||||||
|
activeOrigin = base
|
||||||
focusWindow()
|
focusWindow()
|
||||||
mainWindow.webContents.send(DEEP_LINK_CHANNEL, targetPath)
|
mainWindow.webContents.send(DEEP_LINK_CHANNEL, targetPath)
|
||||||
void mainWindow.loadURL(`http://127.0.0.1:${server.port}${targetPath}`)
|
void mainWindow.loadURL(`${base}${targetPath}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Hide-to-tray on window close (keep the server alive) unless we're quitting. */
|
/** Hide-to-tray on window close (keep the server alive) unless we're quitting. */
|
||||||
@@ -154,18 +419,23 @@ async function onReady(): Promise<void> {
|
|||||||
server = await startEmbeddedServer({ prefs, logger })
|
server = await startEmbeddedServer({ prefs, logger })
|
||||||
const base = `http://127.0.0.1:${server.port}`
|
const base = `http://127.0.0.1:${server.port}`
|
||||||
|
|
||||||
mainWindow = createMainWindow(`${base}/`, path.join(__dirname, PRELOAD_FILE))
|
// Honor a previously-selected remote host across restarts; else land on local.
|
||||||
|
const initial = resolveTarget(prefs) ?? { url: `${base}/`, origin: base }
|
||||||
|
activeOrigin = initial.origin
|
||||||
|
mainWindow = createMainWindow(
|
||||||
|
initial.url,
|
||||||
|
path.join(__dirname, PRELOAD_FILE),
|
||||||
|
() => activeOrigin,
|
||||||
|
)
|
||||||
installWindowCloseGuard(mainWindow)
|
installWindowCloseGuard(mainWindow)
|
||||||
|
|
||||||
Menu.setApplicationMenu(buildAppMenu())
|
Menu.setApplicationMenu(buildAppMenu())
|
||||||
try {
|
try {
|
||||||
tray = createTray(path.join(__dirname, TRAY_ICON_SUBPATH), {
|
tray = createTray(
|
||||||
show: () => focusWindow(),
|
path.join(__dirname, TRAY_ICON_SUBPATH),
|
||||||
quit: () => {
|
{ remoteHosts: prefs.remoteHosts, selectedHostId: prefs.selectedHostId },
|
||||||
isQuitting = true
|
trayHandlers,
|
||||||
app.quit()
|
)
|
||||||
},
|
|
||||||
})
|
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
// A missing/invalid tray icon (cosmetic) must not take down a working app.
|
// A missing/invalid tray icon (cosmetic) must not take down a working app.
|
||||||
logger.warn(`tray unavailable (continuing without it): ${errorMessage(err)}`)
|
logger.warn(`tray unavailable (continuing without it): ${errorMessage(err)}`)
|
||||||
@@ -216,6 +486,34 @@ if (!app.requestSingleInstanceLock()) {
|
|||||||
} else {
|
} else {
|
||||||
app.setAsDefaultProtocolClient(DEEP_LINK_PROTOCOL)
|
app.setAsDefaultProtocolClient(DEEP_LINK_PROTOCOL)
|
||||||
|
|
||||||
|
// ── D-2: mTLS device-certificate selection (from the OS keychain) ──────────
|
||||||
|
// When a remote tunnel host (nginx `ssl_verify_client on`) requests a client
|
||||||
|
// cert, Chromium asks us which of the OS-store certs to present. preventDefault
|
||||||
|
// is MANDATORY: without it Electron auto-picks list[0] (any cert, no control).
|
||||||
|
// We deliberately choose the one issued by our device CA; if none is installed
|
||||||
|
// we show a clear dialog (never a silent callback) and proceed cert-less so the
|
||||||
|
// TLS handshake fails cleanly rather than hanging.
|
||||||
|
app.on('select-client-certificate', (event, _webContents, url, list, callback) => {
|
||||||
|
event.preventDefault()
|
||||||
|
const cert = pickCertByIssuer(list, DEVICE_CA_CN)
|
||||||
|
if (!cert) {
|
||||||
|
showDeviceCertMissingDialog(url)
|
||||||
|
callback() // no cert → nginx rejects the handshake (clean, non-hanging failure)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
callback(cert)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── D-3 (deferred — documented TODO; coordinate with Track V/S) ────────────
|
||||||
|
// This box can also be its OWN tunnel source. A follow-up may optionally spawn
|
||||||
|
// + supervise a local `frpc` from here (PLAN_NATIVE_TUNNEL §"C-Desktop / D-3")
|
||||||
|
// and inject the embedded server's ALLOWED_ORIGINS=
|
||||||
|
// https://<name>.terminal.yaojia.wang (additive via the backend config.ts). The
|
||||||
|
// embedded server already defaults BIND_HOST=127.0.0.1 (server-config.ts), which
|
||||||
|
// satisfies precondition P-A — EXCEPT when `lanSharing` is enabled: that binds
|
||||||
|
// 0.0.0.0 and re-opens an unauthenticated shell on the LAN, bypassing mTLS. So a
|
||||||
|
// tunnel-source host must keep lanSharing OFF. Not implemented here.
|
||||||
|
|
||||||
app.on('second-instance', (_event, argv) => {
|
app.on('second-instance', (_event, argv) => {
|
||||||
focusWindow()
|
focusWindow()
|
||||||
const url = findDeepLinkArg(argv)
|
const url = findDeepLinkArg(argv)
|
||||||
|
|||||||
@@ -12,7 +12,8 @@
|
|||||||
* CRITICAL rule); they never mutate their arguments.
|
* CRITICAL rule); they never mutate their arguments.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { DesktopPrefs } from './types.js'
|
import type { DesktopPrefs, RemoteHost } from './types.js'
|
||||||
|
import { normalizeRemoteUrl } from './remote-hosts.js'
|
||||||
|
|
||||||
/** Valid TCP port range (inclusive); mirrors src/config.ts's PORT bounds. */
|
/** Valid TCP port range (inclusive); mirrors src/config.ts's PORT bounds. */
|
||||||
const MIN_PORT = 1
|
const MIN_PORT = 1
|
||||||
@@ -36,6 +37,8 @@ export function defaultPrefs(platform: NodeJS.Platform): DesktopPrefs {
|
|||||||
openAtLogin: false,
|
openAtLogin: false,
|
||||||
notifyOnApproval: true,
|
notifyOnApproval: true,
|
||||||
notifyOnStatusChange: true,
|
notifyOnStatusChange: true,
|
||||||
|
remoteHosts: [],
|
||||||
|
selectedHostId: null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,6 +73,51 @@ function pickShellPath(value: unknown, fallback: string | null): string | null {
|
|||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate one untrusted remote-host record. Requires string id/name and a URL
|
||||||
|
* that survives normalization (https + tunnel zone); the stored URL is replaced
|
||||||
|
* with its canonical form. Returns null for anything malformed, so a corrupt or
|
||||||
|
* hand-edited entry is dropped rather than trusted downstream.
|
||||||
|
*/
|
||||||
|
function pickRemoteHost(value: unknown): RemoteHost | null {
|
||||||
|
if (!isRecord(value)) return null
|
||||||
|
const { id, name, url } = value
|
||||||
|
if (typeof id !== 'string' || id === '') return null
|
||||||
|
if (typeof name !== 'string' || name.trim() === '') return null
|
||||||
|
if (typeof url !== 'string') return null
|
||||||
|
const normalizedUrl = normalizeRemoteUrl(url)
|
||||||
|
if (normalizedUrl === null) return null
|
||||||
|
return { id, name: name.trim(), url: normalizedUrl }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adopt a list of remote hosts from untrusted input: keep only records that
|
||||||
|
* validate, and drop duplicate ids (first wins) so lookups are unambiguous. A
|
||||||
|
* non-array yields an empty list.
|
||||||
|
*/
|
||||||
|
function pickRemoteHosts(value: unknown): readonly RemoteHost[] {
|
||||||
|
if (!Array.isArray(value)) return []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const hosts: RemoteHost[] = []
|
||||||
|
for (const entry of value) {
|
||||||
|
const host = pickRemoteHost(entry)
|
||||||
|
if (host === null || seen.has(host.id)) continue
|
||||||
|
seen.add(host.id)
|
||||||
|
hosts.push(host)
|
||||||
|
}
|
||||||
|
return hosts
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adopt the selected host id: null (local), or a string that refers to a host
|
||||||
|
* actually present in the validated list. A dangling id degrades to null so we
|
||||||
|
* never point selection at a host that was dropped or never existed.
|
||||||
|
*/
|
||||||
|
function pickSelectedHostId(value: unknown, hosts: readonly RemoteHost[]): string | null {
|
||||||
|
if (typeof value !== 'string') return null
|
||||||
|
return hosts.some((host) => host.id === value) ? value : null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Re-derive a fully-formed DesktopPrefs from untrusted parsed-JSON input.
|
* Re-derive a fully-formed DesktopPrefs from untrusted parsed-JSON input.
|
||||||
* Starts from defaults and adopts each field only when it is the right type and
|
* Starts from defaults and adopts each field only when it is the right type and
|
||||||
@@ -78,6 +126,7 @@ function pickShellPath(value: unknown, fallback: string | null): string | null {
|
|||||||
export function validatePrefs(raw: unknown, platform: NodeJS.Platform): DesktopPrefs {
|
export function validatePrefs(raw: unknown, platform: NodeJS.Platform): DesktopPrefs {
|
||||||
const defaults = defaultPrefs(platform)
|
const defaults = defaultPrefs(platform)
|
||||||
if (!isRecord(raw)) return defaults
|
if (!isRecord(raw)) return defaults
|
||||||
|
const remoteHosts = pickRemoteHosts(raw['remoteHosts'])
|
||||||
return {
|
return {
|
||||||
port: pickPort(raw['port'], defaults.port),
|
port: pickPort(raw['port'], defaults.port),
|
||||||
lanSharing: pickBoolean(raw['lanSharing'], defaults.lanSharing),
|
lanSharing: pickBoolean(raw['lanSharing'], defaults.lanSharing),
|
||||||
@@ -85,6 +134,8 @@ export function validatePrefs(raw: unknown, platform: NodeJS.Platform): DesktopP
|
|||||||
openAtLogin: pickBoolean(raw['openAtLogin'], defaults.openAtLogin),
|
openAtLogin: pickBoolean(raw['openAtLogin'], defaults.openAtLogin),
|
||||||
notifyOnApproval: pickBoolean(raw['notifyOnApproval'], defaults.notifyOnApproval),
|
notifyOnApproval: pickBoolean(raw['notifyOnApproval'], defaults.notifyOnApproval),
|
||||||
notifyOnStatusChange: pickBoolean(raw['notifyOnStatusChange'], defaults.notifyOnStatusChange),
|
notifyOnStatusChange: pickBoolean(raw['notifyOnStatusChange'], defaults.notifyOnStatusChange),
|
||||||
|
remoteHosts,
|
||||||
|
selectedHostId: pickSelectedHostId(raw['selectedHostId'], remoteHosts),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
125
desktop/src/remote-hosts.ts
Normal file
125
desktop/src/remote-hosts.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* desktop/src/remote-hosts.ts — D-1: remote-host mode (PLAN_NATIVE_TUNNEL,
|
||||||
|
* Track C-Desktop). Pure, Electron-free helpers over the RemoteHost list held in
|
||||||
|
* DesktopPrefs, plus the URL validation that guards what the BrowserWindow is
|
||||||
|
* allowed to load.
|
||||||
|
*
|
||||||
|
* A "remote host" is one of the user's own machines exposed through the mTLS
|
||||||
|
* reverse tunnel at `https://<name>.terminal.yaojia.wang/`. The desktop app can
|
||||||
|
* switch the main window between "This machine (local)" (the embedded loopback
|
||||||
|
* server, which keeps running) and any configured remote host; the device client
|
||||||
|
* certificate from the OS keychain (see main.ts / D-2) is the only auth gate.
|
||||||
|
*
|
||||||
|
* Every function is PURE and returns NEW objects (immutability — coding-style
|
||||||
|
* CRITICAL rule); none mutate their arguments. No `electron` import, so this file
|
||||||
|
* is unit-testable in plain Node.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
|
import type { DesktopPrefs, RemoteHost } from './types.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tunnel DNS zone (PLAN_NATIVE_TUNNEL: wildcard `*.terminal.yaojia.wang`).
|
||||||
|
* Overridable so a differently-named deployment isn't hard-blocked. A remote host
|
||||||
|
* URL must be https (mixed-content + mTLS both require TLS) and, by default, live
|
||||||
|
* under this zone — a boundary sanity check, not the security boundary itself
|
||||||
|
* (that is mTLS at nginx + the will-navigate origin lock).
|
||||||
|
*/
|
||||||
|
export const TERMINAL_ZONE_SUFFIX = process.env.WEBTERM_TUNNEL_ZONE ?? '.terminal.yaojia.wang'
|
||||||
|
|
||||||
|
/** Parse an origin, returning null for anything malformed. */
|
||||||
|
function originOf(url: string): string | null {
|
||||||
|
try {
|
||||||
|
return new URL(url).origin
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate + normalize a user-entered remote-host URL. Returns the canonical
|
||||||
|
* `https://<host>/` form, or null when the input is not a usable https tunnel URL
|
||||||
|
* (unparseable, non-https, or — by default — outside the tunnel zone). Validation
|
||||||
|
* at the boundary: everything downstream may trust the returned string.
|
||||||
|
*/
|
||||||
|
export function normalizeRemoteUrl(raw: string): string | null {
|
||||||
|
let parsed: URL
|
||||||
|
try {
|
||||||
|
parsed = new URL(raw.trim())
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (parsed.protocol !== 'https:') return null
|
||||||
|
if (parsed.hostname === '') return null
|
||||||
|
if (TERMINAL_ZONE_SUFFIX !== '' && !parsed.hostname.endsWith(TERMINAL_ZONE_SUFFIX)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
// Canonical form: origin + trailing slash (path/query/hash stripped — the
|
||||||
|
// frontend is served from the root, and the origin is all the lock compares).
|
||||||
|
return `${parsed.origin}/`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a RemoteHost from user input, or null if the name is blank or the URL is
|
||||||
|
* not a valid https tunnel URL. The id is a fresh uuid (stable across renames).
|
||||||
|
*/
|
||||||
|
export function makeRemoteHost(name: string, url: string): RemoteHost | null {
|
||||||
|
const trimmedName = name.trim()
|
||||||
|
const normalizedUrl = normalizeRemoteUrl(url)
|
||||||
|
if (trimmedName === '' || normalizedUrl === null) return null
|
||||||
|
return { id: randomUUID(), name: trimmedName, url: normalizedUrl }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Look up a configured host by id. */
|
||||||
|
export function findRemoteHost(
|
||||||
|
prefs: Readonly<DesktopPrefs>,
|
||||||
|
id: string,
|
||||||
|
): RemoteHost | undefined {
|
||||||
|
return prefs.remoteHosts.find((host) => host.id === id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutably append a host, de-duplicating by URL so re-adding the same machine
|
||||||
|
* replaces the old entry (and its name) rather than piling up duplicates.
|
||||||
|
*/
|
||||||
|
export function addRemoteHost(
|
||||||
|
prefs: Readonly<DesktopPrefs>,
|
||||||
|
host: RemoteHost,
|
||||||
|
): DesktopPrefs {
|
||||||
|
const withoutDup = prefs.remoteHosts.filter((existing) => existing.url !== host.url)
|
||||||
|
return { ...prefs, remoteHosts: [...withoutDup, host] }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutably remove a host by id. If the removed host was selected, selection
|
||||||
|
* falls back to local (selectedHostId → null) so we never point at a gone host.
|
||||||
|
*/
|
||||||
|
export function removeRemoteHost(prefs: Readonly<DesktopPrefs>, id: string): DesktopPrefs {
|
||||||
|
const remoteHosts = prefs.remoteHosts.filter((host) => host.id !== id)
|
||||||
|
const selectedHostId = prefs.selectedHostId === id ? null : prefs.selectedHostId
|
||||||
|
return { ...prefs, remoteHosts, selectedHostId }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutably select a host (null → local). Selecting an unknown id is a no-op —
|
||||||
|
* the caller stays on whatever was selected, never a dangling reference.
|
||||||
|
*/
|
||||||
|
export function selectHost(prefs: Readonly<DesktopPrefs>, id: string | null): DesktopPrefs {
|
||||||
|
if (id === null) return { ...prefs, selectedHostId: null }
|
||||||
|
if (findRemoteHost(prefs, id) === undefined) return { ...prefs }
|
||||||
|
return { ...prefs, selectedHostId: id }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The currently-selected remote host, or null when local is selected (either
|
||||||
|
* selectedHostId is null, or it dangles at a host that no longer exists).
|
||||||
|
*/
|
||||||
|
export function selectedRemoteHost(prefs: Readonly<DesktopPrefs>): RemoteHost | null {
|
||||||
|
if (prefs.selectedHostId === null) return null
|
||||||
|
return findRemoteHost(prefs, prefs.selectedHostId) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The origin a remote host's window should be locked to, or null if malformed. */
|
||||||
|
export function remoteHostOrigin(host: Readonly<RemoteHost>): string | null {
|
||||||
|
return originOf(host.url)
|
||||||
|
}
|
||||||
@@ -3,30 +3,92 @@
|
|||||||
* after its window is hidden (the embedded server must keep running so remote
|
* after its window is hidden (the embedded server must keep running so remote
|
||||||
* devices stay connected — the vibe-coding scenario).
|
* devices stay connected — the vibe-coding scenario).
|
||||||
*
|
*
|
||||||
|
* The context menu also hosts the D-1 host-picker (PLAN_NATIVE_TUNNEL /
|
||||||
|
* C-Desktop): "This machine (local)" ↔ each configured remote tunnel host, as a
|
||||||
|
* radio group, plus add/remove. The menu is rebuilt (never mutated) whenever the
|
||||||
|
* host list or selection changes — call buildTrayMenu() and setContextMenu().
|
||||||
|
*
|
||||||
* Icon requirement: `iconPath` MUST point at an existing image. new Tray() with a
|
* Icon requirement: `iconPath` MUST point at an existing image. new Tray() with a
|
||||||
* missing/invalid path can throw on some platforms; that failure surfaces to the
|
* missing/invalid path can throw on some platforms; that failure surfaces to the
|
||||||
* caller rather than being swallowed (a broken install should be visible, not
|
* caller rather than being swallowed (a broken install should be visible, not
|
||||||
* silently degrade). The caller owns whether to guard startup around it.
|
* silently degrade). The caller owns whether to guard startup around it.
|
||||||
*/
|
*/
|
||||||
import { Menu, Tray } from 'electron'
|
import { Menu, Tray } from 'electron'
|
||||||
|
import type { MenuItemConstructorOptions } from 'electron'
|
||||||
|
import type { RemoteHost } from './types.js'
|
||||||
|
|
||||||
const TRAY_TOOLTIP = 'Web Terminal'
|
const TRAY_TOOLTIP = 'Web Terminal'
|
||||||
|
/** Label for the local (embedded server) entry — selectedHostId === null. */
|
||||||
|
const LOCAL_HOST_LABEL = 'This machine (local)'
|
||||||
|
|
||||||
export interface TrayHandlers {
|
export interface TrayHandlers {
|
||||||
show(): void
|
show(): void
|
||||||
quit(): void
|
quit(): void
|
||||||
|
/** Switch the active host; null → "This machine (local)". */
|
||||||
|
selectHost(id: string | null): void
|
||||||
|
/** Prompt for + add a new remote host. */
|
||||||
|
addHost(): void
|
||||||
|
/** Remove a configured remote host by id. */
|
||||||
|
removeHost(id: string): void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createTray(iconPath: string, handlers: TrayHandlers): Tray {
|
/** The host-picker state the menu renders (the local entry is implicit). */
|
||||||
const tray = new Tray(iconPath)
|
export interface TrayHostState {
|
||||||
|
readonly remoteHosts: readonly RemoteHost[]
|
||||||
|
/** Selected host id, or null → local. */
|
||||||
|
readonly selectedHostId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
const menu = Menu.buildFromTemplate([
|
/** Build the tray context menu for the given host state (pure construction). */
|
||||||
{ label: 'Show Web Terminal', click: handlers.show },
|
export function buildTrayMenu(state: TrayHostState, handlers: TrayHandlers): Menu {
|
||||||
|
const localItem: MenuItemConstructorOptions = {
|
||||||
|
label: LOCAL_HOST_LABEL,
|
||||||
|
type: 'radio',
|
||||||
|
checked: state.selectedHostId === null,
|
||||||
|
click: () => handlers.selectHost(null),
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostItems: MenuItemConstructorOptions[] = state.remoteHosts.map((host) => ({
|
||||||
|
label: host.name,
|
||||||
|
type: 'radio',
|
||||||
|
checked: state.selectedHostId === host.id,
|
||||||
|
click: () => handlers.selectHost(host.id),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const selectedRemoteId =
|
||||||
|
state.selectedHostId !== null &&
|
||||||
|
state.remoteHosts.some((host) => host.id === state.selectedHostId)
|
||||||
|
? state.selectedHostId
|
||||||
|
: null
|
||||||
|
|
||||||
|
const template: MenuItemConstructorOptions[] = [
|
||||||
|
{ label: 'Host', enabled: false },
|
||||||
|
localItem,
|
||||||
|
...hostItems,
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{ label: 'Quit', click: handlers.quit },
|
{ label: 'Add remote host…', click: () => handlers.addHost() },
|
||||||
])
|
{
|
||||||
|
label: 'Remove selected host',
|
||||||
|
enabled: selectedRemoteId !== null,
|
||||||
|
click: () => {
|
||||||
|
if (selectedRemoteId !== null) handlers.removeHost(selectedRemoteId)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ label: 'Show Web Terminal', click: () => handlers.show() },
|
||||||
|
{ label: 'Quit', click: () => handlers.quit() },
|
||||||
|
]
|
||||||
|
|
||||||
|
return Menu.buildFromTemplate(template)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTray(
|
||||||
|
iconPath: string,
|
||||||
|
state: TrayHostState,
|
||||||
|
handlers: TrayHandlers,
|
||||||
|
): Tray {
|
||||||
|
const tray = new Tray(iconPath)
|
||||||
tray.setToolTip(TRAY_TOOLTIP)
|
tray.setToolTip(TRAY_TOOLTIP)
|
||||||
tray.setContextMenu(menu)
|
tray.setContextMenu(buildTrayMenu(state, handlers))
|
||||||
return tray
|
return tray
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,22 @@ export type DesktopClaudeStatus = 'working' | 'waiting' | 'idle' | 'unknown' | '
|
|||||||
/** Approval gate kind, mirrored from backend PermissionGate (src/types.ts:101). */
|
/** Approval gate kind, mirrored from backend PermissionGate (src/types.ts:101). */
|
||||||
export type DesktopPermissionGate = 'tool' | 'plan'
|
export type DesktopPermissionGate = 'tool' | 'plan'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A remote web-terminal host reachable over the mTLS reverse tunnel
|
||||||
|
* (Track C-Desktop, PLAN_NATIVE_TUNNEL §"C-Desktop / D-1"). `url` is the https
|
||||||
|
* origin the BrowserWindow loads, e.g. `https://t1.terminal.yaojia.wang/`; the
|
||||||
|
* device client certificate (D-2, sourced from the OS keychain) is the only auth
|
||||||
|
* gate. `id` is a stable opaque handle used by the tray host-picker + prefs.
|
||||||
|
*/
|
||||||
|
export interface RemoteHost {
|
||||||
|
/** Stable opaque id (uuid); the tray/prefs key, never shown to the user. */
|
||||||
|
readonly id: string
|
||||||
|
/** Human label shown in the tray host-picker. */
|
||||||
|
readonly name: string
|
||||||
|
/** The https origin URL to load, normalized to `https://<host>/`. */
|
||||||
|
readonly url: string
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User preferences, persisted as JSON in the app's userData dir (hand-rolled
|
* User preferences, persisted as JSON in the app's userData dir (hand-rolled
|
||||||
* store — matches the project's minimal-deps convention; no electron-store).
|
* store — matches the project's minimal-deps convention; no electron-store).
|
||||||
@@ -38,6 +54,10 @@ export interface DesktopPrefs {
|
|||||||
readonly notifyOnApproval: boolean
|
readonly notifyOnApproval: boolean
|
||||||
/** Fire a native notification when a session's Claude status changes. */
|
/** Fire a native notification when a session's Claude status changes. */
|
||||||
readonly notifyOnStatusChange: boolean
|
readonly notifyOnStatusChange: boolean
|
||||||
|
/** Configured remote tunnel hosts (D-1); empty on a fresh install. */
|
||||||
|
readonly remoteHosts: readonly RemoteHost[]
|
||||||
|
/** Selected host id, or null → "This machine (local)" (the embedded server). */
|
||||||
|
readonly selectedHostId: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Handle returned by the embedded server, for lifecycle + window wiring. */
|
/** Handle returned by the embedded server, for lifecycle + window wiring. */
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* desktop/src/window.ts — creates the single BrowserWindow that hosts the
|
* desktop/src/window.ts — creates the single BrowserWindow that hosts the
|
||||||
* unchanged web frontend, loaded from the embedded localhost server.
|
* unchanged web frontend, loaded from either the embedded localhost server or a
|
||||||
|
* selected remote tunnel host (D-1, PLAN_NATIVE_TUNNEL / C-Desktop).
|
||||||
*
|
*
|
||||||
* Hardening (DESKTOP_PLAN §8 / TECH_DOC §7): contextIsolation on, nodeIntegration
|
* Hardening (DESKTOP_PLAN §8 / TECH_DOC §7): contextIsolation on, nodeIntegration
|
||||||
* off, sandbox on, preload restricted to a minimal contextBridge. Because the
|
* off, sandbox on, preload restricted to a minimal contextBridge. We deny every
|
||||||
* only page ever loaded is the trusted embedded http://127.0.0.1:<port> origin,
|
* new-window request and block navigation to any foreign origin — a defence-in-
|
||||||
* we deny every new-window request and block navigation to any foreign origin —
|
* depth guard against a hijacked page trying to escape the active origin.
|
||||||
* a defence-in-depth guard against a hijacked page trying to escape localhost.
|
*
|
||||||
|
* D-1 note: the origin lock is DYNAMIC. The window may be pointed (by main.ts,
|
||||||
|
* via a programmatic loadURL — which does NOT fire will-navigate) at exactly one
|
||||||
|
* host at a time: the embedded `http://127.0.0.1:<port>` origin OR the selected
|
||||||
|
* remote `https://<name>.terminal.yaojia.wang` origin. `getAllowedOrigin` returns
|
||||||
|
* whichever is active NOW, so renderer-initiated navigation stays locked to that
|
||||||
|
* single origin and everything else is still blocked.
|
||||||
*/
|
*/
|
||||||
import { BrowserWindow } from 'electron'
|
import { BrowserWindow } from 'electron'
|
||||||
|
|
||||||
@@ -15,7 +22,7 @@ const WINDOW_HEIGHT = 720
|
|||||||
const BACKGROUND_COLOR = '#0e0f13'
|
const BACKGROUND_COLOR = '#0e0f13'
|
||||||
|
|
||||||
/** Parse the origin of a URL, returning null for anything malformed. */
|
/** Parse the origin of a URL, returning null for anything malformed. */
|
||||||
function originOf(url: string): string | null {
|
export function originOf(url: string): string | null {
|
||||||
try {
|
try {
|
||||||
return new URL(url).origin
|
return new URL(url).origin
|
||||||
} catch {
|
} catch {
|
||||||
@@ -23,7 +30,16 @@ function originOf(url: string): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createMainWindow(url: string, preloadPath: string): BrowserWindow {
|
/**
|
||||||
|
* Create the main window. `url` is the initial page to load; `getAllowedOrigin`
|
||||||
|
* is queried live on every navigation attempt and must return the origin the
|
||||||
|
* window is currently allowed on (or null to allow nothing / fail closed).
|
||||||
|
*/
|
||||||
|
export function createMainWindow(
|
||||||
|
url: string,
|
||||||
|
preloadPath: string,
|
||||||
|
getAllowedOrigin: () => string | null,
|
||||||
|
): BrowserWindow {
|
||||||
const win = new BrowserWindow({
|
const win = new BrowserWindow({
|
||||||
width: WINDOW_WIDTH,
|
width: WINDOW_WIDTH,
|
||||||
height: WINDOW_HEIGHT,
|
height: WINDOW_HEIGHT,
|
||||||
@@ -36,15 +52,14 @@ export function createMainWindow(url: string, preloadPath: string): BrowserWindo
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const allowedOrigin = originOf(url)
|
|
||||||
|
|
||||||
// Never spawn child windows; the frontend has no legitimate reason to.
|
// Never spawn child windows; the frontend has no legitimate reason to.
|
||||||
win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
|
win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
|
||||||
|
|
||||||
// Block navigation away from the embedded localhost origin.
|
// Block navigation away from the currently-active (local or remote) origin.
|
||||||
win.webContents.on('will-navigate', (event, targetUrl) => {
|
win.webContents.on('will-navigate', (event, targetUrl) => {
|
||||||
if (allowedOrigin === null) return
|
const allowedOrigin = getAllowedOrigin()
|
||||||
if (originOf(targetUrl) !== allowedOrigin) {
|
// Fail closed: if we can't determine the active origin, allow nothing.
|
||||||
|
if (allowedOrigin === null || originOf(targetUrl) !== allowedOrigin) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user