diff --git a/assets/electron/installer.nsh b/assets/electron/installer.nsh index ffee6646..bf28f6eb 100644 --- a/assets/electron/installer.nsh +++ b/assets/electron/installer.nsh @@ -31,18 +31,14 @@ ${if} $0 != "" ${andIf} $1 != "" + ; Write raw values to a line-based provisioning file rather than JSON: NSIS + ; has no string-escaping, so a URL or token containing a quote or backslash + ; would corrupt hand-written JSON. The app converts this into a properly + ; serialized desktop.json on first launch (see electron/desktop-provisioning.ts). CreateDirectory "$PROFILE\.freshell" - FileOpen $2 "$PROFILE\.freshell\desktop.json" w - FileWrite $2 "{$\r$\n" - FileWrite $2 " $\"serverMode$\": $\"remote$\",$\r$\n" - FileWrite $2 " $\"port$\": 3001,$\r$\n" - FileWrite $2 " $\"remoteUrl$\": $\"$0$\",$\r$\n" - FileWrite $2 " $\"remoteToken$\": $\"$1$\",$\r$\n" - FileWrite $2 " $\"globalHotkey$\": $\"CommandOrControl+`$\",$\r$\n" - FileWrite $2 " $\"startOnLogin$\": false,$\r$\n" - FileWrite $2 " $\"minimizeToTray$\": true,$\r$\n" - FileWrite $2 " $\"setupCompleted$\": true$\r$\n" - FileWrite $2 "}$\r$\n" + FileOpen $2 "$PROFILE\.freshell\desktop.provision" w + FileWrite $2 "FRESHELL_REMOTE_URL=$0$\r$\n" + FileWrite $2 "FRESHELL_TOKEN=$1$\r$\n" FileClose $2 ${endIf} !macroend diff --git a/docs/development/windows-electron-build.md b/docs/development/windows-electron-build.md index f3190553..12133e0d 100644 --- a/docs/development/windows-electron-build.md +++ b/docs/development/windows-electron-build.md @@ -18,9 +18,10 @@ on the wrong platform. - Node.js (matching `engines.node`, currently `>=22.5.0`) and npm. - Visual Studio Build Tools with the **Desktop development with C++** workload, and Python 3 — required for `node-gyp` to compile `node-pty`. -- `curl`, `tar`, and `unzip` on `PATH` — `scripts/prepare-bundled-node.ts` shells - out to them to download the standalone Node binary and headers. Windows 10+ - ships `curl`/`tar`; `unzip` is provided by Git for Windows (`usr/bin`). +- No extra download tools are needed: `scripts/prepare-bundled-node.ts` fetches + the standalone Node binary and headers over Node's own `http`/`https` and + extracts them with the bundled `tar` and `extract-zip` packages (not external + `curl`/`tar`/`unzip`). ## Option A — from a native Windows shell diff --git a/electron/desktop-provisioning.ts b/electron/desktop-provisioning.ts new file mode 100644 index 00000000..5494bc6b --- /dev/null +++ b/electron/desktop-provisioning.ts @@ -0,0 +1,81 @@ +import type { DesktopConfig } from './types.js' + +/** + * One-time provisioning from a silent installer. + * + * The installer cannot safely emit JSON (it has no string-escaping), so it + * writes raw values to a line-based `desktop.provision` file instead. We parse + * that file here and persist a real config via `patchDesktopConfig`, whose + * `JSON.stringify` serialization escapes quotes/backslashes correctly. + */ + +export interface ParsedProvisioning { + remoteUrl?: string + remoteToken?: string +} + +/** + * Parse `KEY=value` lines. The value keeps every character after the first `=` + * verbatim (so a token may contain `=`, `"`, `\`, or meaningful surrounding + * whitespace); only the line ending is stripped, by the split. The key is + * trimmed for tolerance. Unknown or malformed lines are ignored. + */ +export function parseProvisioning(content: string): ParsedProvisioning { + const result: ParsedProvisioning = {} + for (const line of content.split(/\r?\n/)) { + const idx = line.indexOf('=') + if (idx === -1) continue + const key = line.slice(0, idx).trim() + const value = line.slice(idx + 1) + if (key === 'FRESHELL_REMOTE_URL') result.remoteUrl = value + else if (key === 'FRESHELL_TOKEN') result.remoteToken = value + } + return result +} + +export interface ProvisioningDeps { + /** Returns the file contents, or undefined if the provision file is absent. */ + readFile: (path: string) => string | undefined + deleteFile: (path: string) => void + patchDesktopConfig: (patch: Partial) => Promise +} + +/** + * Apply a provision file if present, then always remove it (so it only takes + * effect once). A malformed file must never block startup, so persistence + * errors are swallowed. Returns true when a file was found and consumed. + */ +export async function applyProvisioningFile( + provisionPath: string, + deps: ProvisioningDeps, +): Promise { + let content: string | undefined + try { + content = deps.readFile(provisionPath) + } catch { + // The file exists but is unreadable (locked, a directory, bad perms). + // It must not brick startup, and it must not wedge every launch, so make a + // best-effort attempt to clear it and bail. + deps.deleteFile(provisionPath) + return true + } + + if (content === undefined) return false + + try { + const { remoteUrl, remoteToken } = parseProvisioning(content) + if (remoteUrl && remoteToken) { + await deps.patchDesktopConfig({ + serverMode: 'remote', + remoteUrl, + remoteToken, + setupCompleted: true, + }) + } + } catch { + // A malformed provision file or persist failure must not brick startup. + } finally { + deps.deleteFile(provisionPath) + } + return true +} diff --git a/electron/entry.ts b/electron/entry.ts index 3c1c312a..e6178a75 100644 --- a/electron/entry.ts +++ b/electron/entry.ts @@ -33,7 +33,12 @@ import { runStartup, type StartupContext, type BrowserWindowLike } from './start import { initMainProcess } from './main.js' import { createWizardWindow } from './setup-wizard/wizard-window.js' import { createChooseLaunchOptionHandler } from './launch-choice-handler.js' -import type { LaunchServerCandidate } from './types.js' +import { buildLaunchOptions } from './launch-options.js' +import { applyProvisioningFile } from './desktop-provisioning.js' +import { createPortAvailabilityCheck } from './port-check.js' +import type { ForcedLaunch, LaunchServerCandidate } from './types.js' + +const isPortAvailable = createPortAvailabilityCheck() const isDev = process.env.ELECTRON_DEV === '1' const configDir = path.join(os.homedir(), '.freshell') @@ -41,6 +46,13 @@ const configDir = path.join(os.homedir(), '.freshell') /** True during the wizard flow; prevents app.quit() on window-all-closed. */ let wizardPhase = true +/** + * An explicit chooser selection to honor on the next main() pass. Set by the + * choose-launch-option handler before it restarts the launch flow, consumed + * once at the top of main(). + */ +let pendingForcedLaunch: ForcedLaunch | undefined + async function main(): Promise { // Wait for Electron to be ready before creating any BrowserWindow or using // Electron APIs that require the app to be initialized. @@ -59,6 +71,26 @@ async function main(): Promise { }) } + // Apply one-time provisioning from a silent install. The installer writes raw + // values to desktop.provision (it cannot escape JSON); we convert them into a + // properly-serialized desktop.json here, then remove the provision file. + await applyProvisioningFile(path.join(configDir, 'desktop.provision'), { + readFile: (p) => (fs.existsSync(p) ? fs.readFileSync(p, 'utf-8') : undefined), + deleteFile: (p) => { + try { + fs.rmSync(p, { force: true }) + } catch { + /* best-effort cleanup */ + } + }, + patchDesktopConfig, + }) + + // Consume any pending forced launch (set by the chooser handler before it + // restarted main). It must apply only to this pass. + const forcedLaunch = pendingForcedLaunch + pendingForcedLaunch = undefined + // Read desktop config (or use defaults for first run) const desktopConfig = (await readDesktopConfig()) ?? getDefaultDesktopConfig() const port = desktopConfig.port ?? 3001 @@ -100,6 +132,7 @@ async function main(): Promise { // Construct the startup context const ctx: StartupContext = { desktopConfig, + forcedLaunch, daemonManager, serverSpawner, hotkeyManager, @@ -244,6 +277,9 @@ async function main(): Promise { ipcMain.removeHandler('choose-launch-option') let pendingLaunchChooser: { candidates: LaunchServerCandidate[]; reason: string } | undefined + // webContents id of the launch chooser window, so choose-launch-option only + // honors requests originating from it (the API is exposed to every window). + let chooserWebContentsId: number | undefined // Register the complete-setup handler before runStartup so it is available // when the wizard renderer calls it via the preload API. @@ -264,18 +300,21 @@ async function main(): Promise { }) }) - ipcMain.handle('get-launch-options', () => ({ - candidates: pendingLaunchChooser?.candidates ?? [], - reason: pendingLaunchChooser?.reason ?? 'Choose how Freshell should connect.', - alwaysAskOnLaunch: desktopConfig.alwaysAskOnLaunch, - port: desktopConfig.port, - })) + ipcMain.handle('get-launch-options', () => + buildLaunchOptions({ pending: pendingLaunchChooser, desktopConfig }), + ) ipcMain.handle('choose-launch-option', createChooseLaunchOptionHandler({ patchDesktopConfig, getCurrentPort: () => desktopConfig.port, validateServerAuth: (url: string, token: string) => ctx.fetchAuthenticated?.(`${url}/api/settings`, token) ?? Promise.resolve(false), - restartMain: async () => { + isAllowedSender: (event) => { + const senderId = (event as { sender?: { id?: number } }).sender?.id + return chooserWebContentsId !== undefined && senderId === chooserWebContentsId + }, + isPortAvailable, + restartMain: async (forced: ForcedLaunch) => { + pendingForcedLaunch = forced wizardPhase = true for (const win of BrowserWindow.getAllWindows()) { win.close() @@ -329,6 +368,8 @@ async function main(): Promise { contextIsolation: true, }, }) + // Only this window may drive choose-launch-option (see isAllowedSender). + chooserWebContentsId = chooserWin.webContents.id if (isDev) { await chooserWin.loadURL('http://localhost:5175') diff --git a/electron/launch-choice-handler.ts b/electron/launch-choice-handler.ts index 7dac47b1..eb7ed21e 100644 --- a/electron/launch-choice-handler.ts +++ b/electron/launch-choice-handler.ts @@ -1,21 +1,58 @@ import { normalizeServerUrl } from './launch-discovery.js' -import type { DesktopConfig, LaunchChoice, LaunchChoiceResult } from './types.js' +import { validateLaunchPort, validateRemoteLaunchUrl } from './launch-chooser/chooser-logic.js' +import { LaunchChoiceSchema } from './types.js' +import type { DesktopConfig, ForcedLaunch, LaunchChoiceResult } from './types.js' export interface ChooseLaunchOptionHandlerOptions { patchDesktopConfig: (patch: Partial) => Promise - restartMain: () => Promise | void + /** + * Restart the launch flow, forcing the just-chosen action so it is honored + * this launch regardless of saved config, `alwaysAskOnLaunch`, or + * re-discovered servers. + */ + restartMain: (forced: ForcedLaunch) => Promise | void getCurrentPort: () => number validateServerAuth?: (url: string, token: string) => Promise + /** + * Defense-in-depth: reject choices from any renderer other than the launch + * chooser window. `choose-launch-option` is exposed via preload to every + * window, so without this an untrusted renderer could force a launch. + */ + isAllowedSender?: (event: unknown) => boolean + /** + * Authoritative (main-process) check that nothing is already listening on a + * "Start local" port before we close the chooser and spawn. The renderer + * guard is only advisory (stale candidate list, races, crafted requests). + */ + isPortAvailable?: (port: number) => Promise } export function createChooseLaunchOptionHandler(options: ChooseLaunchOptionHandlerOptions) { - return async (_event: unknown, choice: LaunchChoice): Promise => { + return async (event: unknown, rawChoice: unknown): Promise => { + if (options.isAllowedSender && !options.isAllowedSender(event)) { + return { ok: false, error: 'Unexpected launch request.' } + } + + // The payload comes from a renderer over IPC, so validate its shape at + // runtime — TypeScript's union does not survive the boundary. + const parsed = LaunchChoiceSchema.safeParse(rawChoice) + if (!parsed.success) { + return { ok: false, error: 'Invalid launch request.' } + } + const choice = parsed.data + if (choice.kind === 'remote' || choice.kind === 'connect') { if (!choice.url) { return { ok: false, error: 'Choose a server URL.' } } const url = normalizeServerUrl(choice.url) + // Validate the scheme server-side (not just in the renderer) so a crafted + // choice can never make the app load a file:// or other non-web URL. + const urlError = validateRemoteLaunchUrl(url) + if (urlError) { + return { ok: false, error: urlError } + } const token = choice.token?.trim() if (choice.requiresAuth !== false) { if (!token) { @@ -35,23 +72,65 @@ export function createChooseLaunchOptionHandler(options: ChooseLaunchOptionHandl } } - await options.patchDesktopConfig({ - serverMode: 'remote', - remoteUrl: url, - remoteToken: token, - alwaysAskOnLaunch: choice.alwaysAskOnLaunch, - setupCompleted: true, - }) - } else { + // "Remember this choice" gates whether the server selection is saved as + // the new default. The always-ask preference is standalone and always + // persisted so the user can leave (or stay in) the chooser next launch. + if (choice.remember) { + await options.patchDesktopConfig({ + serverMode: 'remote', + remoteUrl: url, + remoteToken: token, + alwaysAskOnLaunch: choice.alwaysAskOnLaunch, + setupCompleted: true, + }) + } else { + await options.patchDesktopConfig({ alwaysAskOnLaunch: choice.alwaysAskOnLaunch }) + } + + await options.restartMain({ kind: 'connect', url, token }) + return { ok: true } + } + + // Defensive default: the schema only allows connect/remote/start-local, and + // connect/remote are handled above, so anything else is rejected outright + // rather than silently treated as start-local. + if (choice.kind !== 'start-local') { + return { ok: false, error: 'Invalid launch request.' } + } + + const port = choice.port ?? options.getCurrentPort() + const portError = validateLaunchPort(port) + if (portError) { + return { ok: false, error: portError } + } + + // Authoritatively confirm the port is free before closing the chooser and + // spawning. If we cannot determine availability, refuse rather than risk + // spawning onto an occupied port (which could load the wrong process). + if (options.isPortAvailable) { + let available = false + try { + available = await options.isPortAvailable(port) + } catch { + available = false + } + if (!available) { + return { ok: false, error: `Port ${port} is already in use. Choose a different port, or connect to that server.` } + } + } + + if (choice.remember) { await options.patchDesktopConfig({ serverMode: 'app-bound', - port: choice.port ?? options.getCurrentPort(), + port, alwaysAskOnLaunch: choice.alwaysAskOnLaunch, setupCompleted: true, }) + } else { + await options.patchDesktopConfig({ alwaysAskOnLaunch: choice.alwaysAskOnLaunch }) } - await options.restartMain() + await options.restartMain({ kind: 'start-local', port }) return { ok: true } } } diff --git a/electron/launch-chooser/chooser-logic.ts b/electron/launch-chooser/chooser-logic.ts index 6cd52ab3..9fda63e3 100644 --- a/electron/launch-chooser/chooser-logic.ts +++ b/electron/launch-chooser/chooser-logic.ts @@ -26,6 +26,18 @@ export function validateRemoteLaunchUrl(value: string): string { } } +/** + * Validate a local-server port. Mirrors the chooser's number-input bounds + * (1024–65535) and rejects non-integers (e.g. NaN from an empty field). + * Returns an error message, or null when the port is acceptable. + */ +export function validateLaunchPort(port: number): string | null { + if (!Number.isInteger(port) || port < 1024 || port > 65535) { + return 'Enter a port between 1024 and 65535' + } + return null +} + export function buildConnectChoice(input: { url: string token?: string diff --git a/electron/launch-chooser/chooser.tsx b/electron/launch-chooser/chooser.tsx index 80581d47..8447d66b 100644 --- a/electron/launch-chooser/chooser.tsx +++ b/electron/launch-chooser/chooser.tsx @@ -5,6 +5,7 @@ import { buildRemoteChoice, buildStartLocalChoice, formatLaunchReason, + validateLaunchPort, validateRemoteLaunchUrl, } from './chooser-logic.js' @@ -16,6 +17,7 @@ declare global { reason: string alwaysAskOnLaunch: boolean port: number + remoteUrl?: string }> chooseLaunchOption: (choice: LaunchChoice) => Promise } @@ -39,6 +41,7 @@ export function LaunchChooser() { setReason(options.reason) setAlwaysAskOnLaunch(options.alwaysAskOnLaunch) setPort(options.port) + setRemoteUrl(options.remoteUrl ?? '') }) }, []) @@ -82,6 +85,18 @@ export function LaunchChooser() { await choose(buildRemoteChoice({ url: remoteUrl, token: remoteToken, alwaysAskOnLaunch, remember })) } + const startLocal = async () => { + // Only basic range validation here. Whether the port is actually free is + // decided authoritatively by the main process at choose time (a fresh bind + // test), so the renderer never false-rejects from a stale candidate list. + const portError = validateLaunchPort(port) + if (portError) { + setError(portError) + return + } + await choose(buildStartLocalChoice({ port, alwaysAskOnLaunch, remember })) + } + return (
@@ -147,7 +162,7 @@ export function LaunchChooser() { Port setPort(Number(event.target.value))} /> - diff --git a/electron/launch-options.ts b/electron/launch-options.ts new file mode 100644 index 00000000..e16ab794 --- /dev/null +++ b/electron/launch-options.ts @@ -0,0 +1,27 @@ +import type { DesktopConfig, LaunchServerCandidate } from './types.js' + +export interface LaunchOptionsResponse { + candidates: LaunchServerCandidate[] + reason: string + alwaysAskOnLaunch: boolean + port: number + /** Last-known remote URL, so the chooser can pre-fill it during saved-remote recovery. */ + remoteUrl: string +} + +/** + * Build the payload the launch chooser renderer requests via the + * `get-launch-options` IPC channel. Pure so it can be unit-tested without Electron. + */ +export function buildLaunchOptions(input: { + pending?: { candidates: LaunchServerCandidate[]; reason: string } + desktopConfig: DesktopConfig +}): LaunchOptionsResponse { + return { + candidates: input.pending?.candidates ?? [], + reason: input.pending?.reason ?? 'Choose how Freshell should connect.', + alwaysAskOnLaunch: input.desktopConfig.alwaysAskOnLaunch, + port: input.desktopConfig.port, + remoteUrl: input.desktopConfig.remoteUrl ?? '', + } +} diff --git a/electron/port-check.ts b/electron/port-check.ts new file mode 100644 index 00000000..938c9794 --- /dev/null +++ b/electron/port-check.ts @@ -0,0 +1,21 @@ +import net from 'net' + +/** + * Returns a probe that reports whether a TCP port can actually be bound on this + * host right now. Unlike a reachability check against `localhost`, this attempts + * a real `listen()` on all interfaces, so it also catches a port occupied on a + * different local interface (e.g. a server bound to `0.0.0.0` under WSL or when + * network access is enabled). Resolves `false` on any bind error, erring toward + * "occupied" when uncertain rather than risk a doomed spawn. + */ +export function createPortAvailabilityCheck(): (port: number) => Promise { + return (port: number) => + new Promise((resolve) => { + const tester = net.createServer() + tester.once('error', () => resolve(false)) + tester.once('listening', () => { + tester.close(() => resolve(true)) + }) + tester.listen(port) + }) +} diff --git a/electron/startup.ts b/electron/startup.ts index 1ea215be..fee507a8 100644 --- a/electron/startup.ts +++ b/electron/startup.ts @@ -2,7 +2,7 @@ import path from 'path' import { buildLocalProbeUrls, discoverLocalServers, normalizeServerUrl } from './launch-discovery.js' import { chooseLaunchAction } from './launch-policy.js' import { resolveCandidateToken } from './token-resolver.js' -import type { DesktopConfig, LaunchServerCandidate } from './types.js' +import type { DesktopConfig, ForcedLaunch, LaunchServerCandidate } from './types.js' import type { DaemonManager } from './daemon/daemon-manager.js' import type { ServerSpawner } from './server-spawner.js' import type { HotkeyManager } from './hotkey.js' @@ -46,6 +46,11 @@ export interface StartupContext { /** Read AUTH_TOKEN from the .env file in configDir. Returns undefined if not found. */ readEnvToken?: (envPath: string) => Promise discoverLaunchCandidates?: () => Promise + /** + * An explicit chooser selection to honor for this launch. When set, startup + * skips discovery and policy and performs exactly this action. + */ + forcedLaunch?: ForcedLaunch } export type StartupResult = @@ -131,7 +136,10 @@ async function loadMainWindow( }, }) - const loadUrl = authToken ? `${serverUrl}?token=${authToken}` : serverUrl + // Percent-encode the token: the renderer reads it back via URLSearchParams, + // so a raw token containing +, &, #, or whitespace would otherwise be + // corrupted and the app would load unauthenticated. + const loadUrl = authToken ? `${serverUrl}?token=${encodeURIComponent(authToken)}` : serverUrl await window.loadURL(loadUrl) window.show() @@ -182,13 +190,70 @@ async function loadMainWindow( return { type: 'main', serverUrl, window, updateCheckTimer } } +async function startAppBoundServer(ctx: StartupContext, port: number): Promise { + if (ctx.isDev) { + await ctx.serverSpawner.start({ + spawn: { + mode: 'dev', + tsxPath: 'npx', + serverSourceEntry: 'server/index.ts', + }, + port, + envFile: path.join(ctx.configDir, '.env'), + configDir: ctx.configDir, + }) + return 'http://localhost:5173' + } + + if (!ctx.resourcesPath) { + throw new Error('resourcesPath is required for production app-bound mode') + } + const resourcesPath = ctx.resourcesPath + await ctx.serverSpawner.start({ + spawn: { + mode: 'production', + nodeBinary: path.join(resourcesPath, 'bundled-node', 'bin', ctx.platform === 'win32' ? 'node.exe' : 'node'), + serverEntry: path.join(resourcesPath, 'server', 'index.js'), + nativeModulesDir: path.join(resourcesPath, 'bundled-node', 'native-modules'), + serverNodeModulesDir: path.join(resourcesPath, 'server-node-modules'), + }, + port, + envFile: path.join(ctx.configDir, '.env'), + configDir: ctx.configDir, + }) + return `http://localhost:${port}` +} + +/** + * Perform exactly the action the user selected in the chooser, bypassing + * discovery and policy. This is what makes a chooser selection authoritative + * for the launch regardless of `alwaysAskOnLaunch` or detected servers. + */ +async function executeForcedLaunch(ctx: StartupContext, forced: ForcedLaunch): Promise { + if (forced.kind === 'connect') { + return loadMainWindow(ctx, normalizeServerUrl(forced.url), forced.token) + } + + // start-local: spawn a fresh bundled server on the chosen port. Its auth + // token comes from the local .env, never from a saved remote token. + const serverUrl = await startAppBoundServer(ctx, forced.port) + const authToken = ctx.readEnvToken + ? await ctx.readEnvToken(path.join(ctx.configDir, '.env')) + : undefined + return loadMainWindow(ctx, serverUrl, authToken) +} + export async function runStartup(ctx: StartupContext): Promise { - const { desktopConfig, isDev, port } = ctx + const { desktopConfig, port } = ctx if (!desktopConfig.setupCompleted) { return { type: 'wizard' } } + if (ctx.forcedLaunch) { + return executeForcedLaunch(ctx, ctx.forcedLaunch) + } + const discoverCandidates = ctx.discoverLaunchCandidates ?? (() => defaultDiscoverLaunchCandidates(ctx)) const candidates = await discoverCandidates() const savedRemoteReachable = desktopConfig.serverMode === 'remote' && !!desktopConfig.remoteUrl @@ -235,37 +300,7 @@ export async function runStartup(ctx: StartupContext): Promise { break } case 'app-bound': { - if (isDev) { - await ctx.serverSpawner.start({ - spawn: { - mode: 'dev', - tsxPath: 'npx', - serverSourceEntry: 'server/index.ts', - }, - port, - envFile: path.join(ctx.configDir, '.env'), - configDir: ctx.configDir, - }) - serverUrl = 'http://localhost:5173' - } else { - if (!ctx.resourcesPath) { - throw new Error('resourcesPath is required for production app-bound mode') - } - const resourcesPath = ctx.resourcesPath - await ctx.serverSpawner.start({ - spawn: { - mode: 'production', - nodeBinary: path.join(resourcesPath, 'bundled-node', 'bin', ctx.platform === 'win32' ? 'node.exe' : 'node'), - serverEntry: path.join(resourcesPath, 'server', 'index.js'), - nativeModulesDir: path.join(resourcesPath, 'bundled-node', 'native-modules'), - serverNodeModulesDir: path.join(resourcesPath, 'server-node-modules'), - }, - port, - envFile: path.join(ctx.configDir, '.env'), - configDir: ctx.configDir, - }) - serverUrl = `http://localhost:${port}` - } + serverUrl = await startAppBoundServer(ctx, port) break } case 'remote': { diff --git a/electron/types.ts b/electron/types.ts index a3781c89..b234470c 100644 --- a/electron/types.ts +++ b/electron/types.ts @@ -45,15 +45,27 @@ export interface LaunchServerCandidate { token?: string } -export interface LaunchChoice { - kind: 'connect' | 'remote' | 'start-local' - url?: string - token?: string - port?: number - requiresAuth?: boolean - alwaysAskOnLaunch: boolean - remember: boolean -} +// Schema (not just a TS type) because LaunchChoice crosses the IPC boundary and +// must be runtime-validated before it can drive a launch. +export const LaunchChoiceSchema = z.object({ + kind: z.enum(['connect', 'remote', 'start-local']), + url: z.string().optional(), + token: z.string().optional(), + port: z.number().optional(), + requiresAuth: z.boolean().optional(), + alwaysAskOnLaunch: z.boolean(), + remember: z.boolean(), +}) + +export type LaunchChoice = z.infer + +/** + * An explicit launch selection that must be honored for the current launch, + * independent of saved config, `alwaysAskOnLaunch`, or re-discovered servers. + */ +export type ForcedLaunch = + | { kind: 'connect'; url: string; token?: string } + | { kind: 'start-local'; port: number } export type LaunchChoiceResult = | { ok: true } diff --git a/test/e2e-electron/electron-app.test.ts b/test/e2e-electron/electron-app.test.ts index 1651bc6b..30c9bc29 100644 --- a/test/e2e-electron/electron-app.test.ts +++ b/test/e2e-electron/electron-app.test.ts @@ -264,8 +264,10 @@ test.describe('Launch chooser', () => { const chooser = await app.firstWindow() await chooser.waitForLoadState('domcontentloaded') - await chooser.getByRole('checkbox', { name: 'Always ask on launch' }).uncheck() - await chooser.getByRole('button', { name: 'Connect', exact: true }).first().click() + // The candidate button's accessible name is "Connect to ". + // Selecting it must connect this launch even with "Always ask on launch" + // still checked — that is exactly the forced-launch behavior under test. + await chooser.getByRole('button', { name: /^Connect to / }).first().click() const mainPage = await waitForWindowUrl(app, /http:\/\/localhost:3001/, 60_000) await mainPage.waitForLoadState('domcontentloaded') diff --git a/test/unit/electron/desktop-provisioning.test.ts b/test/unit/electron/desktop-provisioning.test.ts new file mode 100644 index 00000000..b8466371 --- /dev/null +++ b/test/unit/electron/desktop-provisioning.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it, vi } from 'vitest' +import { applyProvisioningFile, parseProvisioning } from '../../../electron/desktop-provisioning.js' + +describe('desktop provisioning', () => { + describe('parseProvisioning', () => { + it('parses remote url and token from line-based content', () => { + expect( + parseProvisioning('FRESHELL_REMOTE_URL=http://10.0.0.5:3001\nFRESHELL_TOKEN=abc123\n'), + ).toEqual({ remoteUrl: 'http://10.0.0.5:3001', remoteToken: 'abc123' }) + }) + + it('preserves tokens containing quotes and backslashes (line form needs no escaping)', () => { + const result = parseProvisioning('FRESHELL_REMOTE_URL=http://h:3001\r\nFRESHELL_TOKEN=a"b\\c\r\n') + expect(result.remoteToken).toBe('a"b\\c') + }) + + it('keeps = characters that appear inside the value', () => { + expect(parseProvisioning('FRESHELL_TOKEN=a=b=c').remoteToken).toBe('a=b=c') + }) + + it('preserves leading/trailing whitespace in the value (raw preservation)', () => { + expect(parseProvisioning('FRESHELL_TOKEN= spaced-token ').remoteToken).toBe(' spaced-token ') + }) + + it('ignores unrelated or malformed lines', () => { + expect(parseProvisioning('# comment\nNOPE\nFRESHELL_REMOTE_URL=http://h:3001')).toEqual({ + remoteUrl: 'http://h:3001', + }) + }) + }) + + describe('applyProvisioningFile', () => { + const provisionPath = '/home/u/.freshell/desktop.provision' + + it('returns false and does nothing when the file is absent', async () => { + const patchDesktopConfig = vi.fn() + const deleteFile = vi.fn() + const applied = await applyProvisioningFile(provisionPath, { + readFile: () => undefined, + deleteFile, + patchDesktopConfig, + }) + expect(applied).toBe(false) + expect(patchDesktopConfig).not.toHaveBeenCalled() + expect(deleteFile).not.toHaveBeenCalled() + }) + + it('patches a remote config and then deletes the provision file', async () => { + const patchDesktopConfig = vi.fn().mockResolvedValue(undefined) + const deleteFile = vi.fn() + const applied = await applyProvisioningFile(provisionPath, { + readFile: () => 'FRESHELL_REMOTE_URL=http://10.0.0.5:3001\nFRESHELL_TOKEN=a"b\\c\n', + deleteFile, + patchDesktopConfig, + }) + expect(applied).toBe(true) + expect(patchDesktopConfig).toHaveBeenCalledWith({ + serverMode: 'remote', + remoteUrl: 'http://10.0.0.5:3001', + remoteToken: 'a"b\\c', + setupCompleted: true, + }) + expect(deleteFile).toHaveBeenCalledWith(provisionPath) + }) + + it('deletes the file even when patching throws (e.g. an invalid URL)', async () => { + const patchDesktopConfig = vi.fn().mockRejectedValue(new Error('invalid url')) + const deleteFile = vi.fn() + const applied = await applyProvisioningFile(provisionPath, { + readFile: () => 'FRESHELL_REMOTE_URL=not-a-url\nFRESHELL_TOKEN=abc\n', + deleteFile, + patchDesktopConfig, + }) + expect(applied).toBe(true) + expect(deleteFile).toHaveBeenCalledWith(provisionPath) + }) + + it('does not patch when only one of url/token is present, but still clears the file', async () => { + const patchDesktopConfig = vi.fn() + const deleteFile = vi.fn() + await applyProvisioningFile(provisionPath, { + readFile: () => 'FRESHELL_REMOTE_URL=http://h:3001\n', + deleteFile, + patchDesktopConfig, + }) + expect(patchDesktopConfig).not.toHaveBeenCalled() + expect(deleteFile).toHaveBeenCalledWith(provisionPath) + }) + + it('does not throw and best-effort clears the file when reading it fails (locked/dir/perms)', async () => { + const patchDesktopConfig = vi.fn() + const deleteFile = vi.fn() + const applied = await applyProvisioningFile(provisionPath, { + readFile: () => { + throw new Error('EISDIR: illegal operation on a directory') + }, + deleteFile, + patchDesktopConfig, + }) + expect(applied).toBe(true) + expect(patchDesktopConfig).not.toHaveBeenCalled() + expect(deleteFile).toHaveBeenCalledWith(provisionPath) + }) + }) +}) diff --git a/test/unit/electron/electron-builder-config.test.ts b/test/unit/electron/electron-builder-config.test.ts index 852163b9..2861964e 100644 --- a/test/unit/electron/electron-builder-config.test.ts +++ b/test/unit/electron/electron-builder-config.test.ts @@ -69,15 +69,19 @@ describe('electron-builder Windows config', () => { expect(include).not.toContain('taskkill') }) - it('can provision remote desktop config from silent installer args', () => { + it('provisions remote desktop config from silent installer args without hand-writing JSON', () => { const include = readText(path.join(PROJECT_ROOT, 'assets', 'electron', 'installer.nsh')) expect(include).toContain('${StdUtils.GetParameter} $0 "FRESHELL_REMOTE_URL" ""') expect(include).toContain('${StdUtils.GetParameter} $1 "FRESHELL_TOKEN" ""') - expect(include).toContain('FileOpen $2 "$PROFILE\\.freshell\\desktop.json" w') - expect(include).toContain('$\\"serverMode$\\": $\\"remote$\\",') - expect(include).toContain('$\\"remoteUrl$\\": $\\"$0$\\",') - expect(include).toContain('$\\"remoteToken$\\": $\\"$1$\\",') - expect(include).toContain('$\\"setupCompleted$\\": true') + // Raw values are written to a line-based provision file. NSIS cannot escape + // JSON, so a token/URL containing a quote or backslash would otherwise + // corrupt the file; the app serializes a real desktop.json on first launch. + expect(include).toContain('FileOpen $2 "$PROFILE\\.freshell\\desktop.provision" w') + expect(include).toContain('FileWrite $2 "FRESHELL_REMOTE_URL=$0') + expect(include).toContain('FileWrite $2 "FRESHELL_TOKEN=$1') + // The injection-prone hand-written JSON path must be gone. + expect(include).not.toContain('desktop.json" w') + expect(include).not.toContain('$\\"serverMode$\\"') }) }) diff --git a/test/unit/electron/launch-choice-handler.test.ts b/test/unit/electron/launch-choice-handler.test.ts index bd79a80b..53ac3203 100644 --- a/test/unit/electron/launch-choice-handler.test.ts +++ b/test/unit/electron/launch-choice-handler.test.ts @@ -143,4 +143,274 @@ describe('launch choice handler', () => { }) expect(restartMain).toHaveBeenCalled() }) + + it('forwards the chosen connection to restartMain so it is honored this launch', async () => { + const patchDesktopConfig = vi.fn().mockResolvedValue(undefined) + const restartMain = vi.fn().mockResolvedValue(undefined) + const handler = createChooseLaunchOptionHandler({ + patchDesktopConfig, + restartMain, + getCurrentPort: () => 3001, + }) + + await handler({}, { + kind: 'connect', + url: 'http://localhost:3001', + token: 'tok', + requiresAuth: true, + alwaysAskOnLaunch: true, + remember: true, + }) + + expect(restartMain).toHaveBeenCalledWith({ + kind: 'connect', + url: 'http://localhost:3001', + token: 'tok', + }) + }) + + it('forwards the chosen local port to restartMain so it is honored this launch', async () => { + const patchDesktopConfig = vi.fn().mockResolvedValue(undefined) + const restartMain = vi.fn().mockResolvedValue(undefined) + const handler = createChooseLaunchOptionHandler({ + patchDesktopConfig, + restartMain, + getCurrentPort: () => 3001, + }) + + await handler({}, { + kind: 'start-local', + port: 3009, + alwaysAskOnLaunch: false, + remember: true, + }) + + expect(restartMain).toHaveBeenCalledWith({ kind: 'start-local', port: 3009 }) + }) + + it('does not persist the server selection when remember is false (connect)', async () => { + const patchDesktopConfig = vi.fn().mockResolvedValue(undefined) + const restartMain = vi.fn().mockResolvedValue(undefined) + const validateServerAuth = vi.fn().mockResolvedValue(true) + const handler = createChooseLaunchOptionHandler({ + patchDesktopConfig, + restartMain, + getCurrentPort: () => 3001, + validateServerAuth, + }) + + const result = await handler({}, { + kind: 'connect', + url: 'http://localhost:3001', + token: 'tok', + requiresAuth: true, + alwaysAskOnLaunch: false, + remember: false, + }) + + expect(result).toEqual({ ok: true }) + // Only the standalone always-ask preference is persisted, not the server selection. + expect(patchDesktopConfig).toHaveBeenCalledWith({ alwaysAskOnLaunch: false }) + expect(restartMain).toHaveBeenCalledWith({ + kind: 'connect', + url: 'http://localhost:3001', + token: 'tok', + }) + }) + + it('does not persist app-bound mode when remember is false (start-local)', async () => { + const patchDesktopConfig = vi.fn().mockResolvedValue(undefined) + const restartMain = vi.fn().mockResolvedValue(undefined) + const handler = createChooseLaunchOptionHandler({ + patchDesktopConfig, + restartMain, + getCurrentPort: () => 3001, + }) + + const result = await handler({}, { + kind: 'start-local', + port: 3005, + alwaysAskOnLaunch: true, + remember: false, + }) + + expect(result).toEqual({ ok: true }) + expect(patchDesktopConfig).toHaveBeenCalledWith({ alwaysAskOnLaunch: true }) + expect(restartMain).toHaveBeenCalledWith({ kind: 'start-local', port: 3005 }) + }) + + it('rejects an out-of-range start-local port before persisting or restarting', async () => { + const patchDesktopConfig = vi.fn().mockResolvedValue(undefined) + const restartMain = vi.fn().mockResolvedValue(undefined) + const handler = createChooseLaunchOptionHandler({ + patchDesktopConfig, + restartMain, + getCurrentPort: () => 3001, + }) + + for (const port of [0, 80, 70000]) { + const result = await handler({}, { + kind: 'start-local', + port, + alwaysAskOnLaunch: false, + remember: true, + }) + expect(result.ok).toBe(false) + } + + expect(patchDesktopConfig).not.toHaveBeenCalled() + expect(restartMain).not.toHaveBeenCalled() + }) + + it('rejects a connect choice whose URL is not http(s), even when auth is skipped', async () => { + const patchDesktopConfig = vi.fn().mockResolvedValue(undefined) + const restartMain = vi.fn().mockResolvedValue(undefined) + const handler = createChooseLaunchOptionHandler({ + patchDesktopConfig, + restartMain, + getCurrentPort: () => 3001, + }) + + const result = await handler({}, { + kind: 'connect', + url: 'file:///etc/passwd', + requiresAuth: false, + alwaysAskOnLaunch: false, + remember: false, + }) + + expect(result.ok).toBe(false) + expect(patchDesktopConfig).not.toHaveBeenCalled() + expect(restartMain).not.toHaveBeenCalled() + }) + + it('rejects choices from a sender that is not the launch chooser window', async () => { + const patchDesktopConfig = vi.fn().mockResolvedValue(undefined) + const restartMain = vi.fn().mockResolvedValue(undefined) + const handler = createChooseLaunchOptionHandler({ + patchDesktopConfig, + restartMain, + getCurrentPort: () => 3001, + isAllowedSender: () => false, + }) + + const result = await handler({}, { + kind: 'connect', + url: 'http://localhost:3001', + token: 'tok', + requiresAuth: true, + alwaysAskOnLaunch: false, + remember: true, + }) + + expect(result.ok).toBe(false) + expect(patchDesktopConfig).not.toHaveBeenCalled() + expect(restartMain).not.toHaveBeenCalled() + }) + + it('rejects start-local when the chosen port is already in use (authoritative main-process check)', async () => { + const patchDesktopConfig = vi.fn().mockResolvedValue(undefined) + const restartMain = vi.fn().mockResolvedValue(undefined) + const isPortAvailable = vi.fn().mockResolvedValue(false) + const handler = createChooseLaunchOptionHandler({ + patchDesktopConfig, + restartMain, + getCurrentPort: () => 3001, + isPortAvailable, + }) + + const result = await handler({}, { + kind: 'start-local', + port: 3001, + alwaysAskOnLaunch: false, + remember: true, + }) + + expect(result.ok).toBe(false) + expect(isPortAvailable).toHaveBeenCalledWith(3001) + expect(patchDesktopConfig).not.toHaveBeenCalled() + expect(restartMain).not.toHaveBeenCalled() + }) + + it('starts local when the chosen port is available', async () => { + const patchDesktopConfig = vi.fn().mockResolvedValue(undefined) + const restartMain = vi.fn().mockResolvedValue(undefined) + const isPortAvailable = vi.fn().mockResolvedValue(true) + const handler = createChooseLaunchOptionHandler({ + patchDesktopConfig, + restartMain, + getCurrentPort: () => 3001, + isPortAvailable, + }) + + const result = await handler({}, { + kind: 'start-local', + port: 3050, + alwaysAskOnLaunch: false, + remember: true, + }) + + expect(result).toEqual({ ok: true }) + expect(isPortAvailable).toHaveBeenCalledWith(3050) + expect(restartMain).toHaveBeenCalledWith({ kind: 'start-local', port: 3050 }) + }) + + it('refuses start-local when port availability cannot be determined', async () => { + const patchDesktopConfig = vi.fn().mockResolvedValue(undefined) + const restartMain = vi.fn().mockResolvedValue(undefined) + const isPortAvailable = vi.fn().mockRejectedValue(new Error('probe failed')) + const handler = createChooseLaunchOptionHandler({ + patchDesktopConfig, + restartMain, + getCurrentPort: () => 3001, + isPortAvailable, + }) + + const result = await handler({}, { + kind: 'start-local', + port: 3050, + alwaysAskOnLaunch: false, + remember: true, + }) + + expect(result.ok).toBe(false) + expect(restartMain).not.toHaveBeenCalled() + }) + + it('rejects an unknown launch kind instead of falling through to start-local', async () => { + const patchDesktopConfig = vi.fn().mockResolvedValue(undefined) + const restartMain = vi.fn().mockResolvedValue(undefined) + const handler = createChooseLaunchOptionHandler({ + patchDesktopConfig, + restartMain, + getCurrentPort: () => 3001, + }) + + const result = await handler({}, { + kind: 'bogus', + port: 3050, + alwaysAskOnLaunch: false, + remember: true, + } as never) + + expect(result.ok).toBe(false) + expect(patchDesktopConfig).not.toHaveBeenCalled() + expect(restartMain).not.toHaveBeenCalled() + }) + + it('returns a controlled error (does not throw) for a non-object payload', async () => { + const patchDesktopConfig = vi.fn().mockResolvedValue(undefined) + const restartMain = vi.fn().mockResolvedValue(undefined) + const handler = createChooseLaunchOptionHandler({ + patchDesktopConfig, + restartMain, + getCurrentPort: () => 3001, + }) + + const result = await handler({}, null as never) + + expect(result.ok).toBe(false) + expect(patchDesktopConfig).not.toHaveBeenCalled() + expect(restartMain).not.toHaveBeenCalled() + }) }) diff --git a/test/unit/electron/launch-chooser/chooser-logic.test.ts b/test/unit/electron/launch-chooser/chooser-logic.test.ts index 32e58cc8..8d9e4ea5 100644 --- a/test/unit/electron/launch-chooser/chooser-logic.test.ts +++ b/test/unit/electron/launch-chooser/chooser-logic.test.ts @@ -4,6 +4,7 @@ import { buildRemoteChoice, buildStartLocalChoice, formatLaunchReason, + validateLaunchPort, validateRemoteLaunchUrl, } from '../../../../electron/launch-chooser/chooser-logic.js' @@ -64,4 +65,17 @@ describe('launch chooser logic', () => { expect(formatLaunchReason('missing-token')).toContain('needs a token') expect(formatLaunchReason('unknown')).toContain('connect to an existing server') }) + + it('accepts ports inside the allowed range', () => { + expect(validateLaunchPort(1024)).toBeNull() + expect(validateLaunchPort(3001)).toBeNull() + expect(validateLaunchPort(65535)).toBeNull() + }) + + it('rejects ports outside the allowed range or that are not whole numbers', () => { + for (const port of [0, 80, 1023, 65536, 70000, -1, Number.NaN, 3001.5]) { + expect(validateLaunchPort(port)).toContain('between 1024 and 65535') + } + }) + }) diff --git a/test/unit/electron/launch-chooser/chooser.test.tsx b/test/unit/electron/launch-chooser/chooser.test.tsx index 5fe826b7..df4effc9 100644 --- a/test/unit/electron/launch-chooser/chooser.test.tsx +++ b/test/unit/electron/launch-chooser/chooser.test.tsx @@ -90,4 +90,57 @@ describe('LaunchChooser', () => { expect((await screen.findByRole('alert')).textContent).toContain('Enter a token for the remote server') expect(chooseLaunchOption).not.toHaveBeenCalled() }) + + it('pre-fills the remote URL from the saved configuration', async () => { + window.freshellDesktop = { + getLaunchOptions: vi.fn().mockResolvedValue({ + candidates: [], + reason: 'saved-remote-unreachable', + alwaysAskOnLaunch: false, + port: 3001, + remoteUrl: 'http://10.0.0.5:3001', + }), + chooseLaunchOption: vi.fn().mockResolvedValue(undefined), + } + + render() + + const urlInput = (await screen.findByLabelText('URL')) as HTMLInputElement + await waitFor(() => expect(urlInput.value).toBe('http://10.0.0.5:3001')) + }) + + it('rejects an out-of-range local port before sending a choice', async () => { + const { chooseLaunchOption } = installDesktopApi({ candidates: [] }) + + render() + + const portInput = await screen.findByLabelText('Port') + fireEvent.change(portInput, { target: { value: '80' } }) + fireEvent.click(screen.getByRole('button', { name: 'Start local' })) + + expect((await screen.findByRole('alert')).textContent).toContain('between 1024 and 65535') + expect(chooseLaunchOption).not.toHaveBeenCalled() + }) + + it('submits "Start local" and lets the main process decide, even when a candidate occupies that port', async () => { + const chooseLaunchOption = vi.fn().mockResolvedValue({ ok: true }) + installDesktopApi({ + candidates: [localCandidate({ id: 'l', url: 'http://localhost:3001', label: 'localhost:3001' })], + chooseLaunchOption, + }) + + render() + + // Wait for candidates to load; default port (3001) matches the detected server. + await screen.findByRole('button', { name: 'Connect to localhost:3001' }) + fireEvent.click(screen.getByRole('button', { name: 'Start local' })) + + // The renderer no longer second-guesses occupancy from a stale snapshot; + // it submits and the authoritative main-process check is the decider. + await waitFor(() => + expect(chooseLaunchOption).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'start-local', port: 3001 }), + ), + ) + }) }) diff --git a/test/unit/electron/launch-options.test.ts b/test/unit/electron/launch-options.test.ts new file mode 100644 index 00000000..c9f5fe23 --- /dev/null +++ b/test/unit/electron/launch-options.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' +import { buildLaunchOptions } from '../../../electron/launch-options.js' +import type { DesktopConfig } from '../../../electron/types.js' + +function config(overrides: Partial = {}): DesktopConfig { + return { + serverMode: 'remote', + port: 3001, + knownServers: [], + alwaysAskOnLaunch: false, + globalHotkey: 'CommandOrControl+`', + startOnLogin: false, + minimizeToTray: true, + setupCompleted: true, + ...overrides, + } +} + +describe('buildLaunchOptions', () => { + it('includes the saved remote URL so the chooser can pre-fill it for recovery', () => { + const result = buildLaunchOptions({ + pending: { candidates: [], reason: 'saved-remote-unreachable' }, + desktopConfig: config({ remoteUrl: 'http://10.0.0.5:3001' }), + }) + expect(result.remoteUrl).toBe('http://10.0.0.5:3001') + expect(result.reason).toBe('saved-remote-unreachable') + expect(result.alwaysAskOnLaunch).toBe(false) + expect(result.port).toBe(3001) + expect(result.candidates).toEqual([]) + }) + + it('defaults remoteUrl to empty string and supplies a fallback reason when no chooser is pending', () => { + const result = buildLaunchOptions({ + desktopConfig: config({ serverMode: 'app-bound', remoteUrl: undefined }), + }) + expect(result.remoteUrl).toBe('') + expect(result.candidates).toEqual([]) + expect(result.reason).toContain('Choose how Freshell should connect') + }) + + it('passes through pending candidates and reason', () => { + const candidate = { + id: 'a', + url: 'http://localhost:3001', + origin: 'port-scan' as const, + ownership: 'detected-local' as const, + } + const result = buildLaunchOptions({ + pending: { candidates: [candidate], reason: 'multiple-candidates' }, + desktopConfig: config(), + }) + expect(result.candidates).toEqual([candidate]) + expect(result.reason).toBe('multiple-candidates') + }) +}) diff --git a/test/unit/electron/port-check.test.ts b/test/unit/electron/port-check.test.ts new file mode 100644 index 00000000..6b5e40f6 --- /dev/null +++ b/test/unit/electron/port-check.test.ts @@ -0,0 +1,29 @@ +import net from 'net' +import { describe, expect, it } from 'vitest' +import { createPortAvailabilityCheck } from '../../../electron/port-check.js' + +function listenOnEphemeral(): Promise<{ port: number; close: () => Promise }> { + return new Promise((resolve) => { + const server = net.createServer() + server.listen(0, () => { + const port = (server.address() as net.AddressInfo).port + resolve({ + port, + close: () => new Promise((done) => server.close(() => done())), + }) + }) + }) +} + +describe('createPortAvailabilityCheck', () => { + it('reports a port held by another listener as unavailable, and free once released', async () => { + const isPortAvailable = createPortAvailabilityCheck() + const { port, close } = await listenOnEphemeral() + try { + expect(await isPortAvailable(port)).toBe(false) + } finally { + await close() + } + expect(await isPortAvailable(port)).toBe(true) + }) +}) diff --git a/test/unit/electron/startup.test.ts b/test/unit/electron/startup.test.ts index 808d46a1..7eb1dd6b 100644 --- a/test/unit/electron/startup.test.ts +++ b/test/unit/electron/startup.test.ts @@ -532,6 +532,94 @@ describe('runStartup', () => { }) }) + describe('forced launch (explicit chooser selection)', () => { + it('honors a forced connect without discovery or policy, even when alwaysAskOnLaunch is true', async () => { + const discoverLaunchCandidates = vi.fn().mockResolvedValue([ + { + id: 'local-a', + url: 'http://localhost:3001', + origin: 'port-scan', + ownership: 'detected-local', + label: 'localhost:3001', + requiresAuth: true, + token: 'local-token', + }, + ]) + const fetchHealthCheck = vi.fn() + const mockWindow = createMockWindow() + const ctx = createDefaultContext({ + desktopConfig: { + serverMode: 'remote', + port: 3001, + remoteUrl: 'http://saved:3001', + remoteToken: 'saved-token', + knownServers: [], + alwaysAskOnLaunch: true, + globalHotkey: 'CommandOrControl+`', + startOnLogin: false, + minimizeToTray: true, + setupCompleted: true, + }, + discoverLaunchCandidates, + fetchHealthCheck, + createBrowserWindow: vi.fn().mockReturnValue(mockWindow), + forcedLaunch: { kind: 'connect', url: 'http://10.0.0.5:3001', token: 'vpn-token' }, + }) + + const result = await runStartup(ctx) + + expect(discoverLaunchCandidates).not.toHaveBeenCalled() + expect(fetchHealthCheck).not.toHaveBeenCalled() + expect(ctx.serverSpawner.start).not.toHaveBeenCalled() + expect(result.type).toBe('main') + expect(mockWindow.loadURL).toHaveBeenCalledWith('http://10.0.0.5:3001?token=vpn-token') + }) + + it('starts a local server on the forced port even when other servers are detected', async () => { + const discoverLaunchCandidates = vi.fn().mockResolvedValue([ + { + id: 'other', + url: 'http://localhost:3001', + origin: 'port-scan', + ownership: 'detected-local', + label: 'localhost:3001', + requiresAuth: false, + }, + ]) + const mockWindow = createMockWindow() + const ctx = createDefaultContext({ + desktopConfig: { + serverMode: 'remote', + port: 3001, + remoteUrl: 'http://saved:3001', + remoteToken: 'saved-token', + knownServers: [], + alwaysAskOnLaunch: false, + globalHotkey: 'CommandOrControl+`', + startOnLogin: false, + minimizeToTray: true, + setupCompleted: true, + }, + discoverLaunchCandidates, + readEnvToken: vi.fn().mockResolvedValue('env-token'), + createBrowserWindow: vi.fn().mockReturnValue(mockWindow), + forcedLaunch: { kind: 'start-local', port: 3007 }, + }) + + const result = await runStartup(ctx) + + expect(discoverLaunchCandidates).not.toHaveBeenCalled() + expect(ctx.serverSpawner.start).toHaveBeenCalledTimes(1) + const startArgs = (ctx.serverSpawner.start as ReturnType).mock.calls[0][0] + expect(startArgs.port).toBe(3007) + expect(result.type).toBe('main') + if (result.type === 'main') { + expect(result.serverUrl).toBe('http://localhost:3007') + } + expect(mockWindow.loadURL).toHaveBeenCalledWith('http://localhost:3007?token=env-token') + }) + }) + it('registers hotkey with configured accelerator', async () => { const ctx = createDefaultContext() await runStartup(ctx) @@ -758,6 +846,19 @@ describe('runStartup', () => { expect(mockWindow.loadURL).toHaveBeenCalledWith('http://localhost:3001?token=test-auth-token-abc') }) + it('URL-encodes the auth token so metacharacters survive the renderer round-trip', async () => { + // The renderer reads the token back via URLSearchParams.get, so a raw + // token containing +, &, #, or a trailing space must be percent-encoded + // or it would be corrupted (and the app would load unauthenticated). + const mockWindow = createMockWindow() + const ctx = createDefaultContext({ + createBrowserWindow: vi.fn().mockReturnValue(mockWindow), + readEnvToken: vi.fn().mockResolvedValue('a+b&c#d '), + }) + await runStartup(ctx) + expect(mockWindow.loadURL).toHaveBeenCalledWith('http://localhost:3001?token=a%2Bb%26c%23d%20') + }) + it('appends ?token= to URL for daemon mode', async () => { const mockWindow = createMockWindow() const ctx = createDefaultContext({