diff --git a/AGENTS.md b/AGENTS.md index 3d01994db..cbf8fba67 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,6 +81,8 @@ npm run serve # Build and run production server # `npm run check` for safe verification, or build from a worktree. ``` +Windows desktop builds (`npm run electron:build:win`) must run on native Windows — see [docs/development/windows-electron-build.md](docs/development/windows-electron-build.md). + ### Testing ```bash npm test # Coordinated full suite (default + server configs) diff --git a/assets/electron/installer.nsh b/assets/electron/installer.nsh new file mode 100644 index 000000000..ffee66463 --- /dev/null +++ b/assets/electron/installer.nsh @@ -0,0 +1,48 @@ +!ifndef nsProcess::FindProcess + !include "nsProcess.nsh" +!endif + +!macro quitIfFreshellIsRunning + ${nsProcess::FindProcess} "${APP_EXECUTABLE_FILENAME}" $R0 + ${if} $R0 == 0 + SetErrorLevel 1 + ${if} ${Silent} + DetailPrint "${PRODUCT_NAME} is running. Quit ${PRODUCT_NAME} before running this installer." + ${else} + MessageBox MB_OK|MB_ICONEXCLAMATION|MB_TOPMOST "${PRODUCT_NAME} is running. Quit ${PRODUCT_NAME} before running this installer." + ${endIf} + ${nsProcess::Unload} + Quit + ${endIf} + ${nsProcess::Unload} +!macroend + +!macro customInit + !insertmacro quitIfFreshellIsRunning +!macroend + +!macro customCheckAppRunning + !insertmacro quitIfFreshellIsRunning +!macroend + +!macro customInstall + ${StdUtils.GetParameter} $0 "FRESHELL_REMOTE_URL" "" + ${StdUtils.GetParameter} $1 "FRESHELL_TOKEN" "" + + ${if} $0 != "" + ${andIf} $1 != "" + 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" + FileClose $2 + ${endIf} +!macroend diff --git a/docs/development/windows-electron-build.md b/docs/development/windows-electron-build.md new file mode 100644 index 000000000..f3190553b --- /dev/null +++ b/docs/development/windows-electron-build.md @@ -0,0 +1,98 @@ +# Building the Windows Electron App + +This documents how to produce the Windows desktop installer +(`release/Freshell Setup .exe`). + +## Key constraint: it must run on native Windows + +The Windows build **cannot be produced from WSL/Linux**. `npm run +electron:build:win` begins with `scripts/assert-native-windows-build.ts`, which +hard-fails unless `process.platform === 'win32'` — because `node-pty` has to be +compiled for win32. Running the pipeline from Linux produces a broken installer +(a tiny NSIS stub with no bundled `node.exe`) and, if you let it, a Linux +AppImage instead. If you see a ~few-hundred-KB `Freshell Setup *.exe`, you built +on the wrong platform. + +## Prerequisites (on the Windows side) + +- 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`). + +## Option A — from a native Windows shell + +```powershell +npm install # installs Windows-native deps (compiles node-pty for win32) +$env:CI = "true" +npm run electron:build:win # assert win32 → build → prepare:bundled-node → electron-builder --win nsis +``` + +`electron:build:win` runs, in order: the platform assert, `npm run build` +(typecheck + client + server), `build:electron`, `build:wizard`, +`build:launch-chooser`, `prepare:bundled-node` (downloads the standalone Node, +recompiles `node-pty`, prunes `server-node-modules`), then `electron-builder +--win nsis --publish never`. + +Output lands in `release/`. + +## Option B — driving the Windows build from WSL + +Your dev checkout usually lives on the WSL filesystem, but the build must run as +a native Windows process. **Do not** build over the `\\wsl.localhost\...` UNC +path (slow and fragile over 9p). Instead, copy the working tree to a +Windows-local path and run Windows' own npm against it via interop. + +1. Copy the worktree to a Windows-local dir, excluding regenerable/platform dirs: + + ```bash + rsync -rlt --delete --no-perms --no-owner --no-group \ + --exclude='.git' --exclude='node_modules/' --exclude='dist/' \ + --exclude='release/' --exclude='bundled-node/' --exclude='server-node-modules/' \ + ./ "/mnt/c/Users//AppData/Local/Temp/freshell-electron-build/" + ``` + +2. Run Windows npm in that dir via `cmd.exe`. Always `cd /d` to a real Windows + path first — `cmd.exe` launched from WSL inherits the UNC cwd and will warn + and mangle relative paths: + + ```bash + cmd.exe /c 'cd /d C:\Users\\AppData\Local\Temp\freshell-electron-build && set "CI=true" && set "PORT=39517" && npm install && npm run electron:build:win' + ``` + + - `PORT=` is belt-and-suspenders for the `prebuild` guard. (It + normally auto-skips here because the copied `.git` is a worktree pointer, + so `isLinkedWorktreeCheckout` is true — but WSL2 forwards `localhost`, so a + live dev server on the default port is otherwise visible to the guard.) + - Reusing a previous build dir keeps its warm Windows `node_modules` (with the + already-compiled win32 `node-pty`), making `npm install` a fast no-op. + +3. To move artifacts off `/mnt/c`, prefer WSL `cp` over `cmd copy` — `cmd`'s + quote/path handling through interop is unreliable for paths with spaces. + +## What you get + +`electron-builder.yml` targets **`nsis`** for Windows: a one-click, per-user +installer (`oneClick: true`, `perMachine: false`). + +- `release/Freshell Setup .exe` — the installer. Running it installs to + `%LOCALAPPDATA%\Programs\Freshell\Freshell.exe` and (with `runAfterFinish`) + launches the app. +- `release/win-unpacked/Freshell.exe` — the app executable itself; run it + directly to launch without installing. + +The installer is **unsigned** unless a code-signing certificate is configured, so +Windows SmartScreen will warn on first run. + +## Sanity-check a build + +A good build should show: + +- `release/Freshell Setup .exe` is full size (hundreds of MB), not a + small stub. +- `release/win-unpacked/resources/bundled-node/bin/node.exe` exists (the bundled + server runtime — absent in broken cross-builds). +- `release/win-unpacked/resources/server-node-modules/node-pty/prebuilds/win32-x64/conpty.node` + exists. diff --git a/docs/superpowers/plans/2026-05-24-electron-launch-connection-chooser.md b/docs/superpowers/plans/2026-05-24-electron-launch-connection-chooser.md new file mode 100644 index 000000000..009531405 --- /dev/null +++ b/docs/superpowers/plans/2026-05-24-electron-launch-connection-chooser.md @@ -0,0 +1,2299 @@ +# Electron Launch Connection Chooser Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a Windows-friendly Electron launch flow that can detect existing Freshell servers, connect to remote/VPN servers, start a new bundled local server, and optionally always ask before choosing. + +**Architecture:** Keep `electron/startup.ts` responsible for orchestration, but extract detection and selection into focused modules so startup does not grow into a policy sink. Treat servers as one of three ownership classes: detected local server, Electron-owned server, or remote server; never manage a detected or remote process. Add a small launcher renderer for ambiguous or forced-choice startup, while preserving the current setup wizard for first-time installation. + +**Tech Stack:** Electron main process, React/Vite launcher UI, TypeScript, Zod config schema, Vitest Electron unit tests, Playwright Electron e2e tests. + +--- + +## File Structure + +- Create `electron/launch-discovery.ts`: Pure helpers for building local candidate URLs, probing `/api/health`, normalizing URLs, and returning typed server candidates. +- Create `electron/token-resolver.ts`: Pure helpers for resolving tokens from saved desktop config, local server `.env`, and local server `config.json` when the candidate is loopback-only. +- Create `electron/launch-policy.ts`: Pure decision engine that receives config, candidates, and token availability, then returns `auto-connect`, `show-chooser`, `start-local`, or `show-setup`. +- Create `electron/launch-chooser/index.html`: Minimal renderer entry page for the chooser window. +- Create `electron/launch-chooser/main.tsx`: React entrypoint for the chooser window. +- Create `electron/launch-chooser/chooser.tsx`: Accessible chooser UI with local server list, remote URL/token form, start-local action, and always-ask setting. +- Create `electron/launch-chooser/chooser-logic.ts`: Pure form validation and choice serialization for tests. +- Modify `electron/types.ts`: Add `alwaysAskOnLaunch`, optional `knownServers`, and typed launch chooser IPC payloads. +- Modify `electron/desktop-config.ts`: Default `alwaysAskOnLaunch` to `false`; preserve and patch the new fields. +- Modify `electron/startup.ts`: Use launch policy before server-mode switching; add a `chooser` startup result; centralize `loadMainWindow`. +- Modify `electron/entry.ts`: Register chooser IPC handlers, show chooser when `runStartup()` requests it, and restart startup after chooser selection. +- Modify `electron/preload.ts`: Expose chooser APIs to the renderer. +- Modify `electron-builder.yml`: Include the built launch chooser assets beside existing setup wizard assets. +- Modify `package.json`: Add `build:launch-chooser` and include it in `electron:build` and `electron:build:win`. +- Modify `server/index.ts`: Extend `/api/health` with `instanceId`, `startedAt`, and `requiresAuth` without requiring auth. +- Test `test/unit/electron/launch-discovery.test.ts`. +- Test `test/unit/electron/token-resolver.test.ts`. +- Test `test/unit/electron/launch-policy.test.ts`. +- Test `test/unit/electron/launch-chooser/chooser-logic.test.ts`. +- Modify `test/unit/electron/desktop-config.test.ts`. +- Modify `test/unit/electron/startup.test.ts`. +- Modify `test/unit/electron/preload.test.ts`. +- Modify `test/unit/server/api.test.ts`. +- Modify `test/e2e-electron/electron-app.test.ts`. + +## Design Decisions + +- `alwaysAskOnLaunch` defaults to `false`. When true, startup shows the chooser even when exactly one saved or detected candidate is valid. +- Detection is local-first and bounded: check the configured port, default port `3001`, saved local URLs, and the small local range `3001-3010`. +- Do not add LAN broadcast discovery in this iteration. Remote/VPN connection is explicit URL entry plus saved history. +- `/api/health` remains unauthenticated and must identify Freshell with `app: "freshell"` and `ok: true`. +- Local token lookup is allowed only for loopback candidates: `localhost`, `127.0.0.1`, and `[::1]`. +- If multiple valid candidates exist, show the chooser. +- If no valid candidate exists and config says `app-bound`, auto-start local unless `alwaysAskOnLaunch` is true. +- If config says `remote` and the saved remote is unreachable, show chooser with the remote option prefilled and an error message. +- Tokens should not be baked into the executable. Installer-time provisioning remains supported; runtime choices update `%USERPROFILE%\.freshell\desktop.json`. + +--- + +### Task 1: Desktop Config Schema + +**Files:** +- Modify: `electron/types.ts` +- Modify: `electron/desktop-config.ts` +- Modify: `test/unit/electron/desktop-config.test.ts` + +- [ ] **Step 1: Write failing config tests** + +Add these tests to `test/unit/electron/desktop-config.test.ts`: + +```ts + describe('launch chooser fields', () => { + it('defaults alwaysAskOnLaunch to false', () => { + const config = getDefaultDesktopConfig() + expect(config.alwaysAskOnLaunch).toBe(false) + }) + + it('schema defaults alwaysAskOnLaunch to false when omitted', () => { + const result = DesktopConfigSchema.parse({ + serverMode: 'app-bound', + }) + expect(result.alwaysAskOnLaunch).toBe(false) + }) + + it('preserves known servers from config file', async () => { + const freshellDir = path.join(tempDir, '.freshell') + await fsp.mkdir(freshellDir, { recursive: true }) + await fsp.writeFile( + path.join(freshellDir, 'desktop.json'), + JSON.stringify({ + serverMode: 'remote', + port: 3001, + remoteUrl: 'http://10.0.0.5:3001', + remoteToken: 'vpn-token', + knownServers: [ + { + url: 'http://localhost:3001', + label: 'Local dev server', + lastConnectedAt: '2026-05-24T18:00:00.000Z', + }, + ], + alwaysAskOnLaunch: true, + globalHotkey: 'CommandOrControl+`', + startOnLogin: false, + minimizeToTray: true, + setupCompleted: true, + }), + ) + + const config = await readDesktopConfig() + expect(config).not.toBeNull() + expect(config!.alwaysAskOnLaunch).toBe(true) + expect(config!.knownServers).toEqual([ + { + url: 'http://localhost:3001', + label: 'Local dev server', + lastConnectedAt: '2026-05-24T18:00:00.000Z', + }, + ]) + }) + + it('patches alwaysAskOnLaunch and knownServers', async () => { + await writeDesktopConfig(getDefaultDesktopConfig()) + + const patched = await patchDesktopConfig({ + alwaysAskOnLaunch: true, + knownServers: [ + { + url: 'http://localhost:3002', + label: 'Local 3002', + lastConnectedAt: '2026-05-24T18:05:00.000Z', + }, + ], + }) + + expect(patched.alwaysAskOnLaunch).toBe(true) + expect(patched.knownServers).toEqual([ + { + url: 'http://localhost:3002', + label: 'Local 3002', + lastConnectedAt: '2026-05-24T18:05:00.000Z', + }, + ]) + }) + }) +``` + +- [ ] **Step 2: Run the failing config tests** + +Run: + +```bash +CI=true npm run test:vitest -- --config vitest.electron.config.ts test/unit/electron/desktop-config.test.ts --run +``` + +Expected: FAIL because `alwaysAskOnLaunch` and `knownServers` are not in `DesktopConfigSchema`. + +- [ ] **Step 3: Add schema fields** + +Update `electron/types.ts`: + +```ts +import { z } from 'zod' + +export const KnownServerSchema = z.object({ + url: z.string().url(), + label: z.string().optional(), + lastConnectedAt: z.string().datetime().optional(), +}) + +export const DesktopConfigSchema = z.object({ + serverMode: z.enum(['daemon', 'app-bound', 'remote']), + port: z.number().default(3001), + remoteUrl: z.string().url().optional(), + remoteToken: z.string().optional(), + knownServers: z.array(KnownServerSchema).default([]), + alwaysAskOnLaunch: z.boolean().default(false), + globalHotkey: z.string().default('CommandOrControl+`'), + startOnLogin: z.boolean().default(false), + minimizeToTray: z.boolean().default(true), + setupCompleted: z.boolean().default(false), + windowState: z.object({ + x: z.number(), + y: z.number(), + width: z.number(), + height: z.number(), + maximized: z.boolean(), + }).optional(), +}) + +export type KnownServer = z.infer +export type DesktopConfig = z.infer + +export type ServerOwnership = 'owned' | 'detected-local' | 'remote' + +export interface LaunchServerCandidate { + id: string + url: string + origin: 'configured' | 'known' | 'port-scan' | 'manual' + ownership: ServerOwnership + label?: string + version?: string + instanceId?: string + startedAt?: string + ready?: boolean + requiresAuth?: boolean + token?: string +} + +export interface LaunchChoice { + kind: 'connect' | 'remote' | 'start-local' + url?: string + token?: string + port?: number + alwaysAskOnLaunch: boolean + remember: boolean +} +``` + +- [ ] **Step 4: Add config defaults** + +Update `getDefaultDesktopConfig()` in `electron/desktop-config.ts`: + +```ts +export function getDefaultDesktopConfig(): DesktopConfig { + return { + serverMode: 'app-bound', + port: 3001, + knownServers: [], + alwaysAskOnLaunch: false, + globalHotkey: 'CommandOrControl+`', + startOnLogin: false, + minimizeToTray: true, + setupCompleted: false, + } +} +``` + +- [ ] **Step 5: Verify config tests pass** + +Run: + +```bash +CI=true npm run test:vitest -- --config vitest.electron.config.ts test/unit/electron/desktop-config.test.ts --run +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +Run: + +```bash +git add electron/types.ts electron/desktop-config.ts test/unit/electron/desktop-config.test.ts +git commit -m "feat(electron): add launch chooser config fields" +``` + +--- + +### Task 2: Local Server Discovery + +**Files:** +- Create: `electron/launch-discovery.ts` +- Create: `test/unit/electron/launch-discovery.test.ts` + +- [ ] **Step 1: Write failing discovery tests** + +Create `test/unit/electron/launch-discovery.test.ts`: + +```ts +import { describe, expect, it, vi } from 'vitest' +import { + buildLocalProbeUrls, + discoverLocalServers, + isLoopbackUrl, + normalizeServerUrl, +} from '../../../electron/launch-discovery.js' +import type { DesktopConfig } from '../../../electron/types.js' + +function config(overrides: Partial = {}): DesktopConfig { + return { + serverMode: 'app-bound', + port: 3001, + knownServers: [], + alwaysAskOnLaunch: false, + globalHotkey: 'CommandOrControl+`', + startOnLogin: false, + minimizeToTray: true, + setupCompleted: true, + ...overrides, + } +} + +describe('launch discovery', () => { + it('normalizes server URLs by trimming trailing slashes', () => { + expect(normalizeServerUrl('http://localhost:3001/')).toBe('http://localhost:3001') + expect(normalizeServerUrl('http://localhost:3001///')).toBe('http://localhost:3001') + }) + + it('recognizes only loopback URLs as local token-readable URLs', () => { + expect(isLoopbackUrl('http://localhost:3001')).toBe(true) + expect(isLoopbackUrl('http://127.0.0.1:3001')).toBe(true) + expect(isLoopbackUrl('http://[::1]:3001')).toBe(true) + expect(isLoopbackUrl('http://10.0.0.5:3001')).toBe(false) + expect(isLoopbackUrl('not a url')).toBe(false) + }) + + it('builds unique local probe URLs from config port, defaults, range, and known local servers', () => { + const urls = buildLocalProbeUrls(config({ + port: 3004, + knownServers: [ + { url: 'http://localhost:3002', label: 'Known local' }, + { url: 'http://10.0.0.5:3001', label: 'Remote VPN' }, + ], + })) + + expect(urls[0]).toBe('http://localhost:3004') + expect(urls).toContain('http://localhost:3001') + expect(urls).toContain('http://localhost:3010') + expect(urls).toContain('http://localhost:3002') + expect(urls).not.toContain('http://10.0.0.5:3001') + expect(new Set(urls).size).toBe(urls.length) + }) + + it('returns only healthy Freshell servers', async () => { + const fetchHealth = vi.fn(async (url: string) => { + if (url === 'http://localhost:3001/api/health') { + return { + ok: true, + app: 'freshell', + version: '0.7.0', + ready: true, + instanceId: 'local-a', + startedAt: '2026-05-24T18:00:00.000Z', + requiresAuth: true, + } + } + if (url === 'http://localhost:3002/api/health') { + return { ok: true, app: 'not-freshell' } + } + throw new Error('ECONNREFUSED') + }) + + const candidates = await discoverLocalServers({ + urls: ['http://localhost:3001', 'http://localhost:3002', 'http://localhost:3003'], + fetchHealth, + }) + + expect(candidates).toEqual([ + { + id: 'local-a', + url: 'http://localhost:3001', + origin: 'port-scan', + ownership: 'detected-local', + label: 'localhost:3001', + version: '0.7.0', + ready: true, + instanceId: 'local-a', + startedAt: '2026-05-24T18:00:00.000Z', + requiresAuth: true, + }, + ]) + }) +}) +``` + +- [ ] **Step 2: Run the failing discovery tests** + +Run: + +```bash +CI=true npm run test:vitest -- --config vitest.electron.config.ts test/unit/electron/launch-discovery.test.ts --run +``` + +Expected: FAIL because `electron/launch-discovery.ts` does not exist. + +- [ ] **Step 3: Implement discovery** + +Create `electron/launch-discovery.ts`: + +```ts +import type { DesktopConfig, LaunchServerCandidate } from './types.js' + +export interface HealthPayload { + app?: string + ok?: boolean + version?: string + ready?: boolean + instanceId?: string + startedAt?: string + requiresAuth?: boolean +} + +export interface DiscoverLocalServersOptions { + urls: string[] + fetchHealth?: (url: string) => Promise +} + +export function normalizeServerUrl(value: string): string { + return value.trim().replace(/\/+$/, '') +} + +export function isLoopbackUrl(value: string): boolean { + try { + const url = new URL(value) + return url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]' + } catch { + return false + } +} + +export function buildLocalProbeUrls(config: DesktopConfig): string[] { + const urls: string[] = [] + const add = (url: string) => { + const normalized = normalizeServerUrl(url) + if (isLoopbackUrl(normalized) && !urls.includes(normalized)) { + urls.push(normalized) + } + } + + add(`http://localhost:${config.port}`) + add('http://localhost:3001') + + for (let port = 3001; port <= 3010; port += 1) { + add(`http://localhost:${port}`) + } + + for (const server of config.knownServers ?? []) { + add(server.url) + } + + return urls +} + +async function defaultFetchHealth(url: string): Promise { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), 750) + try { + const response = await fetch(url, { signal: controller.signal }) + if (!response.ok) return { ok: false } + return await response.json() as HealthPayload + } finally { + clearTimeout(timer) + } +} + +export async function discoverLocalServers(options: DiscoverLocalServersOptions): Promise { + const fetchHealth = options.fetchHealth ?? defaultFetchHealth + const results = await Promise.all(options.urls.map(async (url) => { + const normalized = normalizeServerUrl(url) + try { + const health = await fetchHealth(`${normalized}/api/health`) + if (health.app !== 'freshell' || health.ok !== true) { + return undefined + } + + const parsed = new URL(normalized) + return { + id: health.instanceId ?? normalized, + url: normalized, + origin: 'port-scan' as const, + ownership: 'detected-local' as const, + label: `${parsed.hostname}:${parsed.port}`, + version: health.version, + ready: health.ready, + instanceId: health.instanceId, + startedAt: health.startedAt, + requiresAuth: health.requiresAuth ?? true, + } + } catch { + return undefined + } + })) + + return results.filter((candidate): candidate is LaunchServerCandidate => candidate !== undefined) +} +``` + +- [ ] **Step 4: Verify discovery tests pass** + +Run: + +```bash +CI=true npm run test:vitest -- --config vitest.electron.config.ts test/unit/electron/launch-discovery.test.ts --run +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +Run: + +```bash +git add electron/launch-discovery.ts test/unit/electron/launch-discovery.test.ts +git commit -m "feat(electron): discover local Freshell servers" +``` + +--- + +### Task 3: Token Resolution + +**Files:** +- Create: `electron/token-resolver.ts` +- Create: `test/unit/electron/token-resolver.test.ts` + +- [ ] **Step 1: Write failing token resolver tests** + +Create `test/unit/electron/token-resolver.test.ts`: + +```ts +import path from 'path' +import { describe, expect, it, vi } from 'vitest' +import { + extractTokenFromConfigJson, + extractTokenFromEnv, + resolveCandidateToken, +} from '../../../electron/token-resolver.js' +import type { DesktopConfig, LaunchServerCandidate } from '../../../electron/types.js' + +function localCandidate(url = 'http://localhost:3001'): LaunchServerCandidate { + return { + id: url, + url, + origin: 'port-scan', + ownership: 'detected-local', + label: url, + requiresAuth: true, + } +} + +function config(overrides: Partial = {}): DesktopConfig { + return { + serverMode: 'app-bound', + port: 3001, + knownServers: [], + alwaysAskOnLaunch: false, + globalHotkey: 'CommandOrControl+`', + startOnLogin: false, + minimizeToTray: true, + setupCompleted: true, + ...overrides, + } +} + +describe('token resolver', () => { + it('extracts AUTH_TOKEN from env content', () => { + expect(extractTokenFromEnv('AUTH_TOKEN=abc123\nPORT=3001\n')).toBe('abc123') + expect(extractTokenFromEnv('AUTH_TOKEN=\"quoted-token\"\n')).toBe('quoted-token') + expect(extractTokenFromEnv('PORT=3001\n')).toBeUndefined() + }) + + it('extracts token from config json using supported keys', () => { + expect(extractTokenFromConfigJson(JSON.stringify({ authToken: 'config-a' }))).toBe('config-a') + expect(extractTokenFromConfigJson(JSON.stringify({ token: 'config-b' }))).toBe('config-b') + expect(extractTokenFromConfigJson('{bad json')).toBeUndefined() + }) + + it('uses matching saved remote token first', async () => { + const readTextFile = vi.fn() + const token = await resolveCandidateToken({ + candidate: localCandidate('http://localhost:3001'), + desktopConfig: config({ + remoteUrl: 'http://localhost:3001', + remoteToken: 'saved-token', + }), + configDir: '/home/user/.freshell', + readTextFile, + }) + + expect(token).toBe('saved-token') + expect(readTextFile).not.toHaveBeenCalled() + }) + + it('reads loopback token from .env when no saved token exists', async () => { + const readTextFile = vi.fn(async (filePath: string) => { + expect(filePath).toBe(path.join('/home/user/.freshell', '.env')) + return 'AUTH_TOKEN=env-token\n' + }) + + const token = await resolveCandidateToken({ + candidate: localCandidate('http://127.0.0.1:3001'), + desktopConfig: config(), + configDir: '/home/user/.freshell', + readTextFile, + }) + + expect(token).toBe('env-token') + }) + + it('falls back to config.json for loopback token', async () => { + const readTextFile = vi.fn(async (filePath: string) => { + if (filePath.endsWith('.env')) throw new Error('missing env') + return JSON.stringify({ authToken: 'json-token' }) + }) + + const token = await resolveCandidateToken({ + candidate: localCandidate(), + desktopConfig: config(), + configDir: '/home/user/.freshell', + readTextFile, + }) + + expect(token).toBe('json-token') + }) + + it('does not read local token files for remote candidates', async () => { + const readTextFile = vi.fn(async () => 'AUTH_TOKEN=local-token\n') + + const token = await resolveCandidateToken({ + candidate: { + ...localCandidate('http://10.0.0.5:3001'), + ownership: 'remote', + }, + desktopConfig: config(), + configDir: '/home/user/.freshell', + readTextFile, + }) + + expect(token).toBeUndefined() + expect(readTextFile).not.toHaveBeenCalled() + }) +}) +``` + +- [ ] **Step 2: Run the failing token tests** + +Run: + +```bash +CI=true npm run test:vitest -- --config vitest.electron.config.ts test/unit/electron/token-resolver.test.ts --run +``` + +Expected: FAIL because `electron/token-resolver.ts` does not exist. + +- [ ] **Step 3: Implement token resolver** + +Create `electron/token-resolver.ts`: + +```ts +import fsp from 'fs/promises' +import path from 'path' +import { isLoopbackUrl, normalizeServerUrl } from './launch-discovery.js' +import type { DesktopConfig, LaunchServerCandidate } from './types.js' + +export interface ResolveCandidateTokenOptions { + candidate: LaunchServerCandidate + desktopConfig: DesktopConfig + configDir: string + readTextFile?: (filePath: string) => Promise +} + +export function extractTokenFromEnv(content: string): string | undefined { + for (const line of content.split(/\r?\n/)) { + const match = line.match(/^AUTH_TOKEN=(.*)$/) + if (!match) continue + const raw = match[1].trim() + return raw.replace(/^"(.*)"$/, '$1') + } + return undefined +} + +export function extractTokenFromConfigJson(content: string): string | undefined { + try { + const parsed = JSON.parse(content) as { authToken?: unknown; token?: unknown } + if (typeof parsed.authToken === 'string' && parsed.authToken.length > 0) { + return parsed.authToken + } + if (typeof parsed.token === 'string' && parsed.token.length > 0) { + return parsed.token + } + } catch { + return undefined + } + return undefined +} + +async function readOptional(filePath: string, readTextFile: (filePath: string) => Promise): Promise { + try { + return await readTextFile(filePath) + } catch { + return undefined + } +} + +export async function resolveCandidateToken(options: ResolveCandidateTokenOptions): Promise { + const readTextFile = options.readTextFile ?? ((filePath: string) => fsp.readFile(filePath, 'utf-8')) + const candidateUrl = normalizeServerUrl(options.candidate.url) + const remoteUrl = options.desktopConfig.remoteUrl ? normalizeServerUrl(options.desktopConfig.remoteUrl) : undefined + + if (remoteUrl === candidateUrl && options.desktopConfig.remoteToken) { + return options.desktopConfig.remoteToken + } + + if (!isLoopbackUrl(candidateUrl)) { + return undefined + } + + const envContent = await readOptional(path.join(options.configDir, '.env'), readTextFile) + const envToken = envContent ? extractTokenFromEnv(envContent) : undefined + if (envToken) return envToken + + const configContent = await readOptional(path.join(options.configDir, 'config.json'), readTextFile) + return configContent ? extractTokenFromConfigJson(configContent) : undefined +} +``` + +- [ ] **Step 4: Verify token tests pass** + +Run: + +```bash +CI=true npm run test:vitest -- --config vitest.electron.config.ts test/unit/electron/token-resolver.test.ts --run +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +Run: + +```bash +git add electron/token-resolver.ts test/unit/electron/token-resolver.test.ts +git commit -m "feat(electron): resolve launch tokens for local servers" +``` + +--- + +### Task 4: Launch Policy + +**Files:** +- Create: `electron/launch-policy.ts` +- Create: `test/unit/electron/launch-policy.test.ts` + +- [ ] **Step 1: Write failing launch policy tests** + +Create `test/unit/electron/launch-policy.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { chooseLaunchAction } from '../../../electron/launch-policy.js' +import type { DesktopConfig, LaunchServerCandidate } from '../../../electron/types.js' + +function config(overrides: Partial = {}): DesktopConfig { + return { + serverMode: 'app-bound', + port: 3001, + knownServers: [], + alwaysAskOnLaunch: false, + globalHotkey: 'CommandOrControl+`', + startOnLogin: false, + minimizeToTray: true, + setupCompleted: true, + ...overrides, + } +} + +function candidate(url: string, token?: string): LaunchServerCandidate { + return { + id: url, + url, + origin: 'port-scan', + ownership: 'detected-local', + label: url, + ready: true, + requiresAuth: true, + token, + } +} + +describe('launch policy', () => { + it('shows setup when setup is incomplete', () => { + expect(chooseLaunchAction({ + desktopConfig: config({ setupCompleted: false }), + candidates: [], + savedRemoteReachable: false, + })).toEqual({ type: 'show-setup' }) + }) + + it('shows chooser when alwaysAskOnLaunch is true even with one candidate', () => { + const candidates = [candidate('http://localhost:3001', 'token')] + expect(chooseLaunchAction({ + desktopConfig: config({ alwaysAskOnLaunch: true }), + candidates, + savedRemoteReachable: false, + })).toEqual({ + type: 'show-chooser', + candidates, + reason: 'always-ask', + }) + }) + + it('auto-connects to one detected candidate with a token', () => { + const candidates = [candidate('http://localhost:3001', 'token')] + expect(chooseLaunchAction({ + desktopConfig: config(), + candidates, + savedRemoteReachable: false, + })).toEqual({ + type: 'auto-connect', + candidate: candidates[0], + }) + }) + + it('shows chooser for multiple candidates', () => { + const candidates = [ + candidate('http://localhost:3001', 'token-a'), + candidate('http://localhost:3002', 'token-b'), + ] + expect(chooseLaunchAction({ + desktopConfig: config(), + candidates, + savedRemoteReachable: false, + })).toEqual({ + type: 'show-chooser', + candidates, + reason: 'multiple-candidates', + }) + }) + + it('starts local for app-bound config when no candidates exist', () => { + expect(chooseLaunchAction({ + desktopConfig: config({ serverMode: 'app-bound' }), + candidates: [], + savedRemoteReachable: false, + })).toEqual({ type: 'start-local' }) + }) + + it('auto-connects to reachable saved remote config', () => { + expect(chooseLaunchAction({ + desktopConfig: config({ + serverMode: 'remote', + remoteUrl: 'http://10.0.0.5:3001', + remoteToken: 'vpn-token', + }), + candidates: [], + savedRemoteReachable: true, + })).toEqual({ + type: 'auto-connect', + candidate: { + id: 'http://10.0.0.5:3001', + url: 'http://10.0.0.5:3001', + origin: 'configured', + ownership: 'remote', + label: 'http://10.0.0.5:3001', + token: 'vpn-token', + }, + }) + }) + + it('shows chooser for unreachable saved remote config', () => { + expect(chooseLaunchAction({ + desktopConfig: config({ + serverMode: 'remote', + remoteUrl: 'http://10.0.0.5:3001', + remoteToken: 'vpn-token', + }), + candidates: [], + savedRemoteReachable: false, + })).toEqual({ + type: 'show-chooser', + candidates: [], + reason: 'saved-remote-unreachable', + }) + }) +}) +``` + +- [ ] **Step 2: Run the failing launch policy tests** + +Run: + +```bash +CI=true npm run test:vitest -- --config vitest.electron.config.ts test/unit/electron/launch-policy.test.ts --run +``` + +Expected: FAIL because `electron/launch-policy.ts` does not exist. + +- [ ] **Step 3: Implement launch policy** + +Create `electron/launch-policy.ts`: + +```ts +import { normalizeServerUrl } from './launch-discovery.js' +import type { DesktopConfig, LaunchServerCandidate } from './types.js' + +export type LaunchAction = + | { type: 'show-setup' } + | { type: 'start-local' } + | { type: 'auto-connect'; candidate: LaunchServerCandidate } + | { type: 'show-chooser'; candidates: LaunchServerCandidate[]; reason: 'always-ask' | 'multiple-candidates' | 'missing-token' | 'saved-remote-unreachable' | 'manual-choice' } + +export interface ChooseLaunchActionOptions { + desktopConfig: DesktopConfig + candidates: LaunchServerCandidate[] + savedRemoteReachable: boolean +} + +export function chooseLaunchAction(options: ChooseLaunchActionOptions): LaunchAction { + const { desktopConfig, candidates, savedRemoteReachable } = options + + if (!desktopConfig.setupCompleted) { + return { type: 'show-setup' } + } + + if (desktopConfig.alwaysAskOnLaunch) { + return { type: 'show-chooser', candidates, reason: 'always-ask' } + } + + if (desktopConfig.serverMode === 'remote' && desktopConfig.remoteUrl) { + if (savedRemoteReachable) { + const url = normalizeServerUrl(desktopConfig.remoteUrl) + return { + type: 'auto-connect', + candidate: { + id: url, + url, + origin: 'configured', + ownership: 'remote', + label: url, + token: desktopConfig.remoteToken, + }, + } + } + + return { type: 'show-chooser', candidates, reason: 'saved-remote-unreachable' } + } + + if (candidates.length > 1) { + return { type: 'show-chooser', candidates, reason: 'multiple-candidates' } + } + + if (candidates.length === 1) { + if (candidates[0].requiresAuth && !candidates[0].token) { + return { type: 'show-chooser', candidates, reason: 'missing-token' } + } + return { type: 'auto-connect', candidate: candidates[0] } + } + + if (desktopConfig.serverMode === 'app-bound') { + return { type: 'start-local' } + } + + return { type: 'show-chooser', candidates, reason: 'manual-choice' } +} +``` + +- [ ] **Step 4: Verify launch policy tests pass** + +Run: + +```bash +CI=true npm run test:vitest -- --config vitest.electron.config.ts test/unit/electron/launch-policy.test.ts --run +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +Run: + +```bash +git add electron/launch-policy.ts test/unit/electron/launch-policy.test.ts +git commit -m "feat(electron): choose launch action from server discovery" +``` + +--- + +### Task 5: Startup Integration Without UI + +**Files:** +- Modify: `electron/startup.ts` +- Modify: `test/unit/electron/startup.test.ts` + +- [ ] **Step 1: Write failing startup integration tests** + +Add these tests to `test/unit/electron/startup.test.ts`: + +```ts + describe('launch discovery integration', () => { + it('returns chooser when alwaysAskOnLaunch is enabled', async () => { + const ctx = createDefaultContext({ + desktopConfig: { + serverMode: 'app-bound', + port: 3001, + knownServers: [], + alwaysAskOnLaunch: true, + globalHotkey: 'CommandOrControl+`', + startOnLogin: false, + minimizeToTray: true, + setupCompleted: true, + }, + discoverLaunchCandidates: vi.fn().mockResolvedValue([ + { + id: 'local-a', + url: 'http://localhost:3001', + origin: 'port-scan', + ownership: 'detected-local', + label: 'localhost:3001', + token: 'local-token', + requiresAuth: true, + }, + ]), + }) + + const result = await runStartup(ctx) + + expect(result).toEqual({ + type: 'chooser', + candidates: [ + { + id: 'local-a', + url: 'http://localhost:3001', + origin: 'port-scan', + ownership: 'detected-local', + label: 'localhost:3001', + token: 'local-token', + requiresAuth: true, + }, + ], + reason: 'always-ask', + }) + expect(ctx.serverSpawner.start).not.toHaveBeenCalled() + expect(ctx.createBrowserWindow).not.toHaveBeenCalled() + }) + + it('auto-connects to one discovered local server without spawning a new server', async () => { + const ctx = createDefaultContext({ + discoverLaunchCandidates: vi.fn().mockResolvedValue([ + { + id: 'local-a', + url: 'http://localhost:3001', + origin: 'port-scan', + ownership: 'detected-local', + label: 'localhost:3001', + token: 'local-token', + requiresAuth: true, + }, + ]), + }) + + const result = await runStartup(ctx) + + expect(ctx.serverSpawner.start).not.toHaveBeenCalled() + expect(result.type).toBe('main') + if (result.type === 'main') { + expect(result.serverUrl).toBe('http://localhost:3001') + } + const window = (ctx.createBrowserWindow as ReturnType).mock.results[0].value + expect(window.loadURL).toHaveBeenCalledWith('http://localhost:3001?token=local-token') + }) + }) +``` + +- [ ] **Step 2: Run the failing startup tests** + +Run: + +```bash +CI=true npm run test:vitest -- --config vitest.electron.config.ts test/unit/electron/startup.test.ts --run +``` + +Expected: FAIL because `StartupContext` has no `discoverLaunchCandidates`, and `StartupResult` has no `chooser` result. + +- [ ] **Step 3: Update startup types** + +Modify the imports and interfaces in `electron/startup.ts`: + +```ts +import { buildLocalProbeUrls, discoverLocalServers } from './launch-discovery.js' +import { chooseLaunchAction } from './launch-policy.js' +import { resolveCandidateToken } from './token-resolver.js' +import type { DesktopConfig, LaunchServerCandidate } from './types.js' + +export interface StartupContext { + desktopConfig: DesktopConfig + daemonManager: DaemonManager + serverSpawner: ServerSpawner + hotkeyManager: HotkeyManager + windowStatePersistence: WindowStatePersistence + updateManager: UpdateManager + isDev: boolean + port: number + resourcesPath?: string + configDir: string + platform: NodeJS.Platform + createBrowserWindow: (options: Record) => BrowserWindowLike + createTray: () => void + fetchHealthCheck?: (url: string) => Promise + readEnvToken?: (envPath: string) => Promise + discoverLaunchCandidates?: () => Promise +} + +export type StartupResult = + | { type: 'wizard' } + | { type: 'chooser'; candidates: LaunchServerCandidate[]; reason: string } + | { type: 'main'; serverUrl: string; window: BrowserWindowLike; updateCheckTimer: ReturnType } +``` + +- [ ] **Step 4: Add candidate discovery helper** + +Add this helper in `electron/startup.ts` above `runStartup()`: + +```ts +async function defaultDiscoverLaunchCandidates(ctx: StartupContext): Promise { + const urls = buildLocalProbeUrls(ctx.desktopConfig) + const candidates = await discoverLocalServers({ urls }) + return Promise.all(candidates.map(async (candidate) => ({ + ...candidate, + token: await resolveCandidateToken({ + candidate, + desktopConfig: ctx.desktopConfig, + configDir: ctx.configDir, + }), + }))) +} +``` + +- [ ] **Step 5: Refactor main window loading into a helper** + +Move the existing window creation, token appending, hotkey registration, tray creation, and update timer logic into: + +```ts +async function loadMainWindow( + ctx: StartupContext, + serverUrl: string, + authToken: string | undefined, +): Promise> { + const windowState = await ctx.windowStatePersistence.load() + const window = ctx.createBrowserWindow({ + x: windowState.x, + y: windowState.y, + width: windowState.width, + height: windowState.height, + show: false, + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + }, + }) + + const loadUrl = authToken ? `${serverUrl}?token=${authToken}` : serverUrl + await window.loadURL(loadUrl) + window.show() + + if (windowState.maximized) { + window.maximize() + } + + let saveTimeout: ReturnType | undefined + const saveState = () => { + clearTimeout(saveTimeout) + saveTimeout = setTimeout(() => { + const bounds = window.getBounds?.() + const maximized = window.isMaximized?.() ?? false + if (bounds) { + void ctx.windowStatePersistence.save({ + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height, + maximized, + }) + } + }, 500) + } + + window.on('resize', saveState) + window.on('move', saveState) + + ctx.hotkeyManager.register(ctx.desktopConfig.globalHotkey, () => { + if (window.isVisible() && window.isFocused()) { + window.hide() + } else { + window.show() + window.focus() + } + }) + + try { + ctx.createTray() + } catch (err) { + console.warn('Failed to create system tray:', err) + } + + const updateCheckTimer = setTimeout(() => { + void ctx.updateManager.checkForUpdates() + }, 10_000) + + return { type: 'main', serverUrl, window, updateCheckTimer } +} +``` + +- [ ] **Step 6: Call policy before the existing server-mode switch** + +At the top of `runStartup()`, after the existing setup check, add: + +```ts + const discoverCandidates = ctx.discoverLaunchCandidates ?? (() => defaultDiscoverLaunchCandidates(ctx)) + const candidates = await discoverCandidates() + const savedRemoteReachable = desktopConfig.serverMode === 'remote' && !!desktopConfig.remoteUrl + ? await checkRemoteReachable(ctx, desktopConfig.remoteUrl) + : false + const launchAction = chooseLaunchAction({ desktopConfig, candidates, savedRemoteReachable }) + + if (launchAction.type === 'show-setup') { + return { type: 'wizard' } + } + + if (launchAction.type === 'show-chooser') { + return { + type: 'chooser', + candidates: launchAction.candidates, + reason: launchAction.reason, + } + } + + if (launchAction.type === 'auto-connect') { + return loadMainWindow(ctx, launchAction.candidate.url, launchAction.candidate.token) + } +``` + +Add this helper above `runStartup()`: + +```ts +async function checkRemoteReachable(ctx: StartupContext, remoteUrl: string): Promise { + const fetchFn = ctx.fetchHealthCheck ?? (async (url: string) => { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), 10_000) + try { + const response = await fetch(url, { signal: controller.signal }) + return response.ok + } finally { + clearTimeout(timer) + } + }) + + try { + return await fetchFn(`${remoteUrl}/api/health`) + } catch { + return false + } +} +``` + +Then replace the old final window-loading block with: + +```ts + let authToken: string | undefined + + if (desktopConfig.serverMode === 'remote') { + authToken = desktopConfig.remoteToken + } else if (ctx.readEnvToken) { + authToken = await ctx.readEnvToken(path.join(ctx.configDir, '.env')) + } + + return loadMainWindow(ctx, serverUrl, authToken) +``` + +- [ ] **Step 7: Verify startup tests pass** + +Run: + +```bash +CI=true npm run test:vitest -- --config vitest.electron.config.ts test/unit/electron/startup.test.ts --run +``` + +Expected: PASS. + +- [ ] **Step 8: Commit** + +Run: + +```bash +git add electron/startup.ts test/unit/electron/startup.test.ts +git commit -m "feat(electron): integrate launch discovery into startup" +``` + +--- + +### Task 6: Chooser Logic and UI + +**Files:** +- Create: `electron/launch-chooser/index.html` +- Create: `electron/launch-chooser/main.tsx` +- Create: `electron/launch-chooser/chooser.tsx` +- Create: `electron/launch-chooser/chooser-logic.ts` +- Create: `test/unit/electron/launch-chooser/chooser-logic.test.ts` +- Modify: `electron/preload.ts` +- Modify: `test/unit/electron/preload.test.ts` + +- [ ] **Step 1: Write failing chooser logic tests** + +Create `test/unit/electron/launch-chooser/chooser-logic.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { + buildConnectChoice, + buildRemoteChoice, + buildStartLocalChoice, + validateRemoteLaunchUrl, +} from '../../../../electron/launch-chooser/chooser-logic.js' + +describe('launch chooser logic', () => { + it('validates remote launch URLs', () => { + expect(validateRemoteLaunchUrl('http://10.0.0.5:3001')).toBe('') + expect(validateRemoteLaunchUrl('https://freshell.internal')).toBe('') + expect(validateRemoteLaunchUrl('localhost:3001')).toBe('Enter a valid http or https URL') + expect(validateRemoteLaunchUrl('ftp://example.com')).toBe('Enter a valid http or https URL') + }) + + it('builds a connect choice', () => { + expect(buildConnectChoice({ + url: 'http://localhost:3001', + token: 'local-token', + alwaysAskOnLaunch: true, + remember: true, + })).toEqual({ + kind: 'connect', + url: 'http://localhost:3001', + token: 'local-token', + alwaysAskOnLaunch: true, + remember: true, + }) + }) + + it('builds a remote choice', () => { + expect(buildRemoteChoice({ + url: 'http://10.0.0.5:3001/', + token: 'vpn-token', + alwaysAskOnLaunch: false, + remember: true, + })).toEqual({ + kind: 'remote', + url: 'http://10.0.0.5:3001', + token: 'vpn-token', + alwaysAskOnLaunch: false, + remember: true, + }) + }) + + it('builds a start-local choice', () => { + expect(buildStartLocalChoice({ + port: 3003, + alwaysAskOnLaunch: false, + remember: true, + })).toEqual({ + kind: 'start-local', + port: 3003, + alwaysAskOnLaunch: false, + remember: true, + }) + }) +}) +``` + +- [ ] **Step 2: Run the failing chooser logic tests** + +Run: + +```bash +CI=true npm run test:vitest -- --config vitest.electron.config.ts test/unit/electron/launch-chooser/chooser-logic.test.ts --run +``` + +Expected: FAIL because chooser logic does not exist. + +- [ ] **Step 3: Implement chooser logic** + +Create `electron/launch-chooser/chooser-logic.ts`: + +```ts +import { normalizeServerUrl } from '../launch-discovery.js' +import type { LaunchChoice } from '../types.js' + +export function validateRemoteLaunchUrl(value: string): string { + try { + const url = new URL(value) + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + return 'Enter a valid http or https URL' + } + return '' + } catch { + return 'Enter a valid http or https URL' + } +} + +export function buildConnectChoice(input: { + url: string + token?: string + alwaysAskOnLaunch: boolean + remember: boolean +}): LaunchChoice { + return { + kind: 'connect', + url: normalizeServerUrl(input.url), + token: input.token, + alwaysAskOnLaunch: input.alwaysAskOnLaunch, + remember: input.remember, + } +} + +export function buildRemoteChoice(input: { + url: string + token: string + alwaysAskOnLaunch: boolean + remember: boolean +}): LaunchChoice { + return { + kind: 'remote', + url: normalizeServerUrl(input.url), + token: input.token, + alwaysAskOnLaunch: input.alwaysAskOnLaunch, + remember: input.remember, + } +} + +export function buildStartLocalChoice(input: { + port: number + alwaysAskOnLaunch: boolean + remember: boolean +}): LaunchChoice { + return { + kind: 'start-local', + port: input.port, + alwaysAskOnLaunch: input.alwaysAskOnLaunch, + remember: input.remember, + } +} +``` + +- [ ] **Step 4: Implement chooser renderer** + +Create `electron/launch-chooser/index.html`: + +```html + + + + + + Freshell Launch + + +
+ + + +``` + +Create `electron/launch-chooser/main.tsx`: + +```tsx +import React from 'react' +import { createRoot } from 'react-dom/client' +import './chooser.css' +import { LaunchChooser } from './chooser.js' + +createRoot(document.getElementById('root')!).render( + + + , +) +``` + +Create `electron/launch-chooser/chooser.tsx`: + +```tsx +import { useEffect, useState } from 'react' +import type { LaunchChoice, LaunchServerCandidate } from '../types.js' +import { + buildConnectChoice, + buildRemoteChoice, + buildStartLocalChoice, + validateRemoteLaunchUrl, +} from './chooser-logic.js' + +declare global { + interface Window { + freshellDesktop?: { + getLaunchOptions: () => Promise<{ candidates: LaunchServerCandidate[]; reason: string; alwaysAskOnLaunch: boolean; port: number }> + chooseLaunchOption: (choice: LaunchChoice) => Promise + } + } +} + +export function LaunchChooser() { + const [candidates, setCandidates] = useState([]) + const [reason, setReason] = useState('') + const [port, setPort] = useState(3001) + const [remoteUrl, setRemoteUrl] = useState('') + const [remoteToken, setRemoteToken] = useState('') + const [alwaysAskOnLaunch, setAlwaysAskOnLaunch] = useState(false) + const [remember, setRemember] = useState(true) + const [error, setError] = useState('') + + useEffect(() => { + void window.freshellDesktop?.getLaunchOptions().then((options) => { + setCandidates(options.candidates) + setReason(options.reason) + setAlwaysAskOnLaunch(options.alwaysAskOnLaunch) + setPort(options.port) + }) + }, []) + + const choose = async (choice: LaunchChoice) => { + setError('') + await window.freshellDesktop?.chooseLaunchOption(choice) + } + + const connectRemote = async () => { + const validation = validateRemoteLaunchUrl(remoteUrl) + if (validation) { + setError(validation) + return + } + await choose(buildRemoteChoice({ url: remoteUrl, token: remoteToken, alwaysAskOnLaunch, remember })) + } + + return ( +
+
+

Choose Freshell server

+

{reason}

+ +
+

Local servers

+ {candidates.length === 0 ? ( +

No running local Freshell servers were detected.

+ ) : ( +
    + {candidates.map((candidate) => ( +
  • +
    + {candidate.label ?? candidate.url} + {candidate.version ? `Version ${candidate.version}` : candidate.url} +
    + +
  • + ))} +
+ )} +
+ +
+

Remote server

+ + + +
+ +
+

New local server

+ + +
+ +
+ + +
+ + {error ?

{error}

: null} +
+
+ ) +} +``` + +Create `electron/launch-chooser/chooser.css`: + +```css +body { + margin: 0; + font-family: system-ui, sans-serif; + background: #101418; + color: #f4f7fa; +} + +.launch-shell { + min-height: 100vh; + display: grid; + place-items: center; + padding: 24px; +} + +.launch-panel { + width: min(720px, 100%); + border: 1px solid #2f3842; + border-radius: 8px; + padding: 24px; + background: #171d23; +} + +.launch-panel h1, +.launch-panel h2 { + margin: 0 0 12px; +} + +.launch-reason { + color: #b9c3ce; +} + +.launch-section { + border-top: 1px solid #2f3842; + padding: 16px 0; +} + +.launch-section ul { + list-style: none; + margin: 0; + padding: 0; +} + +.launch-section li { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 12px 0; +} + +.launch-section span { + display: block; + color: #b9c3ce; + font-size: 13px; +} + +label { + display: grid; + gap: 6px; + margin: 10px 0; +} + +input { + min-height: 36px; + border: 1px solid #3d4854; + border-radius: 6px; + padding: 0 10px; + color: #f4f7fa; + background: #0f1419; +} + +button { + min-height: 36px; + border: 1px solid #56616d; + border-radius: 6px; + padding: 0 14px; + color: #f4f7fa; + background: #244d7a; +} + +.launch-options { + display: flex; + flex-wrap: wrap; + gap: 16px; +} + +.launch-options label { + display: flex; + align-items: center; +} + +.launch-error { + color: #ffb4b4; +} +``` + +- [ ] **Step 5: Expose chooser preload APIs** + +Update `electron/preload.ts` so `window.freshellDesktop` includes: + +```ts + getLaunchOptions: () => ipcRenderer.invoke('get-launch-options'), + chooseLaunchOption: (choice: LaunchChoice) => ipcRenderer.invoke('choose-launch-option', choice), +``` + +Also import `type { LaunchChoice } from './types.js'`. + +- [ ] **Step 6: Add preload test coverage** + +Add assertions to `test/unit/electron/preload.test.ts` that the exposed API has `getLaunchOptions` and `chooseLaunchOption`, and that each calls `ipcRenderer.invoke()` with `get-launch-options` and `choose-launch-option`. + +- [ ] **Step 7: Verify chooser and preload tests pass** + +Run: + +```bash +CI=true npm run test:vitest -- --config vitest.electron.config.ts test/unit/electron/launch-chooser/chooser-logic.test.ts test/unit/electron/preload.test.ts --run +``` + +Expected: PASS. + +- [ ] **Step 8: Commit** + +Run: + +```bash +git add electron/launch-chooser electron/preload.ts test/unit/electron/launch-chooser/chooser-logic.test.ts test/unit/electron/preload.test.ts +git commit -m "feat(electron): add launch chooser renderer" +``` + +--- + +### Task 7: Entry IPC and Choice Application + +**Files:** +- Modify: `electron/entry.ts` +- Modify: `test/unit/electron/main.test.ts` + +- [ ] **Step 1: Write failing entry tests** + +Add tests in `test/unit/electron/main.test.ts` or the existing entry test file that verify: + +```ts +it('registers launch chooser IPC handlers before showing chooser', async () => { + const ipcMain = createMockIpcMain() + await mainWithMocks({ + ipcMain, + runStartupResult: { + type: 'chooser', + candidates: [{ id: 'local-a', url: 'http://localhost:3001', origin: 'port-scan', ownership: 'detected-local' }], + reason: 'always-ask', + }, + }) + + expect(ipcMain.handle).toHaveBeenCalledWith('get-launch-options', expect.any(Function)) + expect(ipcMain.handle).toHaveBeenCalledWith('choose-launch-option', expect.any(Function)) +}) + +it('persists remote launch choice and restarts startup', async () => { + const patchDesktopConfig = vi.fn().mockResolvedValue(undefined) + const restartMain = vi.fn().mockResolvedValue(undefined) + const handler = createChooseLaunchOptionHandler({ patchDesktopConfig, restartMain }) + + await handler({}, { + kind: 'remote', + url: 'http://10.0.0.5:3001', + token: 'vpn-token', + alwaysAskOnLaunch: true, + remember: true, + }) + + expect(patchDesktopConfig).toHaveBeenCalledWith({ + serverMode: 'remote', + remoteUrl: 'http://10.0.0.5:3001', + remoteToken: 'vpn-token', + alwaysAskOnLaunch: true, + setupCompleted: true, + }) + expect(restartMain).toHaveBeenCalled() +}) +``` + +If the current `main.test.ts` does not expose helpers like `mainWithMocks`, first extract pure `createChooseLaunchOptionHandler()` from `electron/entry.ts` and test that function directly. + +- [ ] **Step 2: Run the failing entry tests** + +Run: + +```bash +CI=true npm run test:vitest -- --config vitest.electron.config.ts test/unit/electron/main.test.ts --run +``` + +Expected: FAIL because chooser IPC is not registered. + +- [ ] **Step 3: Implement chooser window and handlers** + +In `electron/entry.ts`, remove existing handlers before registration: + +```ts + ipcMain.removeHandler('get-launch-options') + ipcMain.removeHandler('choose-launch-option') +``` + +Register: + +```ts + let pendingLaunchChooser: { candidates: LaunchServerCandidate[]; reason: string } | undefined + + ipcMain.handle('get-launch-options', () => ({ + candidates: pendingLaunchChooser?.candidates ?? [], + reason: pendingLaunchChooser?.reason ?? 'Choose how Freshell should connect.', + alwaysAskOnLaunch: desktopConfig.alwaysAskOnLaunch, + port: desktopConfig.port, + })) + + ipcMain.handle('choose-launch-option', async (_event, choice: LaunchChoice) => { + if (choice.kind === 'remote') { + await patchDesktopConfig({ + serverMode: 'remote', + remoteUrl: choice.url, + remoteToken: choice.token, + alwaysAskOnLaunch: choice.alwaysAskOnLaunch, + setupCompleted: true, + }) + } else if (choice.kind === 'connect') { + await patchDesktopConfig({ + serverMode: 'remote', + remoteUrl: choice.url, + remoteToken: choice.token, + alwaysAskOnLaunch: choice.alwaysAskOnLaunch, + setupCompleted: true, + }) + } else { + await patchDesktopConfig({ + serverMode: 'app-bound', + port: choice.port ?? desktopConfig.port, + alwaysAskOnLaunch: choice.alwaysAskOnLaunch, + setupCompleted: true, + }) + } + + setTimeout(() => { + main().catch((err) => { + console.error('Failed to restart after launch choice:', err) + }) + }, 250) + }) +``` + +When startup returns chooser: + +```ts + if (result.type === 'chooser') { + pendingLaunchChooser = { + candidates: result.candidates, + reason: result.reason, + } + + const chooserWin = new BrowserWindow({ + width: 760, + height: 720, + show: false, + webPreferences: { + preload: path.join(__dirname, 'preload.js'), + nodeIntegration: false, + contextIsolation: true, + }, + }) + + if (isDev) { + await chooserWin.loadURL('http://localhost:5174') + } else { + await chooserWin.loadFile(path.join(__dirname, 'launch-chooser', 'index.html')) + } + chooserWin.show() + return + } +``` + +- [ ] **Step 4: Verify entry tests pass** + +Run: + +```bash +CI=true npm run test:vitest -- --config vitest.electron.config.ts test/unit/electron/main.test.ts --run +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +Run: + +```bash +git add electron/entry.ts test/unit/electron/main.test.ts +git commit -m "feat(electron): wire launch chooser IPC" +``` + +--- + +### Task 8: Server Health Metadata + +**Files:** +- Modify: `server/index.ts` +- Modify: `test/unit/server/api.test.ts` + +- [ ] **Step 1: Write failing health metadata test** + +Add to `test/unit/server/api.test.ts` in the `/api/health` describe block: + +```ts + it('returns launch discovery metadata without requiring auth', async () => { + const res = await request(app).get('/api/health') + + expect(res.status).toBe(200) + expect(res.body).toMatchObject({ + app: 'freshell', + ok: true, + requiresAuth: true, + }) + expect(typeof res.body.version).toBe('string') + expect(typeof res.body.ready).toBe('boolean') + expect(typeof res.body.instanceId).toBe('string') + expect(typeof res.body.startedAt).toBe('string') + }) +``` + +- [ ] **Step 2: Run the failing server test** + +Run: + +```bash +CI=true npm run test:vitest -- test/unit/server/api.test.ts --run +``` + +Expected: FAIL because `instanceId`, `startedAt`, and `requiresAuth` are absent. + +- [ ] **Step 3: Add health metadata** + +Near server startup state creation in `server/index.ts`, add stable values: + +```ts + const healthStartedAt = new Date().toISOString() + const healthInstanceId = serverInstanceId +``` + +Move this after `serverInstanceId` is initialized, or use a local UUID before the route if the route cannot be moved. Then update `/api/health`: + +```ts + app.get('/api/health', (_req, res) => { + res.json({ + app: 'freshell', + ok: true, + version: APP_VERSION, + ready: startupState.isReady(), + instanceId: healthInstanceId, + startedAt: healthStartedAt, + requiresAuth: true, + }) + }) +``` + +If `serverInstanceId` is initialized after route registration and moving the route would be invasive, use: + +```ts + const healthStartedAt = new Date().toISOString() + const healthInstanceId = crypto.randomUUID() +``` + +and import `crypto` from `node:crypto`. + +- [ ] **Step 4: Verify server test passes** + +Run: + +```bash +CI=true npm run test:vitest -- test/unit/server/api.test.ts --run +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +Run: + +```bash +git add server/index.ts test/unit/server/api.test.ts +git commit -m "feat(server): expose launch metadata in health check" +``` + +--- + +### Task 9: Build Wiring + +**Files:** +- Modify: `package.json` +- Modify: `electron-builder.yml` +- Modify: `test/unit/electron/electron-builder-config.test.ts` + +- [ ] **Step 1: Write failing build config tests** + +Add to `test/unit/electron/electron-builder-config.test.ts`: + +```ts + it('packages the launch chooser renderer assets', () => { + const config = loadConfig() + expect(config.extraResources).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + from: 'dist/launch-chooser', + to: 'launch-chooser', + }), + ]), + ) + }) +``` + +- [ ] **Step 2: Run failing build config test** + +Run: + +```bash +CI=true npm run test:vitest -- --config vitest.electron.config.ts test/unit/electron/electron-builder-config.test.ts --run +``` + +Expected: FAIL because launch chooser assets are not packaged. + +- [ ] **Step 3: Wire build scripts** + +Modify `package.json` scripts: + +```json +"build:launch-chooser": "vite build --config vite.launch-chooser.config.ts", +"electron:build": "npm run build && npm run build:electron && npm run build:wizard && npm run build:launch-chooser && npm run prepare:bundled-node && electron-builder", +"electron:build:win": "tsx scripts/assert-native-windows-build.ts && npm run build && npm run build:electron && npm run build:wizard && npm run build:launch-chooser && npm run prepare:bundled-node && electron-builder --win nsis portable --publish never" +``` + +Create `vite.launch-chooser.config.ts` if the existing wizard Vite config cannot be parameterized: + +```ts +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import path from 'path' + +export default defineConfig({ + plugins: [react()], + root: path.resolve(__dirname, 'electron/launch-chooser'), + build: { + outDir: path.resolve(__dirname, 'dist/launch-chooser'), + emptyOutDir: true, + }, +}) +``` + +- [ ] **Step 4: Package launch chooser assets** + +Modify `electron-builder.yml` `extraResources`: + +```yaml + - from: dist/launch-chooser + to: launch-chooser +``` + +- [ ] **Step 5: Verify build config tests pass** + +Run: + +```bash +CI=true npm run test:vitest -- --config vitest.electron.config.ts test/unit/electron/electron-builder-config.test.ts --run +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +Run: + +```bash +git add package.json package-lock.json electron-builder.yml vite.launch-chooser.config.ts test/unit/electron/electron-builder-config.test.ts +git commit -m "build(electron): package launch chooser" +``` + +--- + +### Task 10: Electron E2E Coverage + +**Files:** +- Modify: `test/e2e-electron/electron-app.test.ts` + +- [ ] **Step 1: Add e2e test for always-ask chooser** + +Add a test that writes desktop config with `alwaysAskOnLaunch: true`, starts the Electron app against the running test server, and asserts the chooser appears instead of the main app: + +```ts +test('shows launch chooser when alwaysAskOnLaunch is true', async () => { + const tmpHome = await createTempHome() + await writeDesktopConfig(tmpHome, { + serverMode: 'remote', + port: 3001, + remoteUrl: serverInfo.baseUrl, + remoteToken: getRunningServerToken(), + knownServers: [], + alwaysAskOnLaunch: true, + globalHotkey: 'CommandOrControl+`', + startOnLogin: false, + minimizeToTray: true, + setupCompleted: true, + }) + + const app = await launchElectron({ tmpHome }) + const chooser = await app.firstWindow() + + await expect(chooser.getByRole('heading', { name: 'Choose Freshell server' })).toBeVisible() + await expect(chooser.getByRole('checkbox', { name: 'Always ask on launch' })).toBeChecked() + + await app.close() +}) +``` + +- [ ] **Step 2: Add e2e test for connecting to existing server** + +Add: + +```ts +test('connects to an existing server selected from chooser', async () => { + const tmpHome = await createTempHome() + await writeDesktopConfig(tmpHome, { + serverMode: 'app-bound', + port: 3001, + knownServers: [{ url: serverInfo.baseUrl, label: 'Test server' }], + alwaysAskOnLaunch: true, + globalHotkey: 'CommandOrControl+`', + startOnLogin: false, + minimizeToTray: true, + setupCompleted: true, + }) + + const app = await launchElectron({ tmpHome }) + const chooser = await app.firstWindow() + + await chooser.getByRole('button', { name: 'Connect' }).first().click() + const mainWindow = await app.waitForEvent('window') + + await expect(mainWindow).toHaveURL(new RegExp(serverInfo.baseUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))) + + await app.close() +}) +``` + +- [ ] **Step 3: Run Electron e2e tests** + +Run: + +```bash +CI=true npm run test:e2e-electron -- --run +``` + +Expected: PASS. If this repo does not expose `test:e2e-electron`, use the existing command already used by this file in `package.json`. + +- [ ] **Step 4: Commit** + +Run: + +```bash +git add test/e2e-electron/electron-app.test.ts +git commit -m "test(electron): cover launch chooser startup" +``` + +--- + +### Task 11: Final Verification + +**Files:** +- No source edits unless verification finds a defect. + +- [ ] **Step 1: Run focused Electron unit tests** + +Run: + +```bash +CI=true npm run test:vitest -- --config vitest.electron.config.ts \ + test/unit/electron/desktop-config.test.ts \ + test/unit/electron/launch-discovery.test.ts \ + test/unit/electron/token-resolver.test.ts \ + test/unit/electron/launch-policy.test.ts \ + test/unit/electron/startup.test.ts \ + test/unit/electron/launch-chooser/chooser-logic.test.ts \ + test/unit/electron/preload.test.ts \ + test/unit/electron/electron-builder-config.test.ts \ + --run +``` + +Expected: PASS. + +- [ ] **Step 2: Run focused server unit test** + +Run: + +```bash +CI=true npm run test:vitest -- test/unit/server/api.test.ts --run +``` + +Expected: PASS. + +- [ ] **Step 3: Run coordinated check** + +Run: + +```bash +FRESHELL_TEST_SUMMARY="electron launch connection chooser" CI=true npm run check +``` + +Expected: PASS. If the coordinator reports another holder, wait and rerun; do not kill the holder. + +- [ ] **Step 4: Build Electron assets** + +Run: + +```bash +CI=true npm run build:electron +CI=true npm run build:wizard +CI=true npm run build:launch-chooser +``` + +Expected: all commands exit 0. + +- [ ] **Step 5: Build Windows artifacts from a Windows-local temp path** + +Run the existing native Windows build process from a Windows-local path, not from the WSL UNC path: + +```bash +CI=true npm run electron:build:win +``` + +Expected: `release/Freshell Setup 0.7.0.exe` and `release/Freshell 0.7.0.exe` are produced. + +- [ ] **Step 6: Smoke test local startup choices** + +Run the packaged app with three configs: + +```json +{ + "serverMode": "app-bound", + "port": 3001, + "knownServers": [], + "alwaysAskOnLaunch": false, + "globalHotkey": "CommandOrControl+`", + "startOnLogin": false, + "minimizeToTray": true, + "setupCompleted": true +} +``` + +Expected: bundled local server starts and main window appears. + +```json +{ + "serverMode": "app-bound", + "port": 3001, + "knownServers": [], + "alwaysAskOnLaunch": true, + "globalHotkey": "CommandOrControl+`", + "startOnLogin": false, + "minimizeToTray": true, + "setupCompleted": true +} +``` + +Expected: chooser appears and “Start local” starts the bundled local server. + +```json +{ + "serverMode": "remote", + "port": 3001, + "remoteUrl": "http://10.0.0.5:3001", + "remoteToken": "test-token", + "knownServers": [], + "alwaysAskOnLaunch": false, + "globalHotkey": "CommandOrControl+`", + "startOnLogin": false, + "minimizeToTray": true, + "setupCompleted": true +} +``` + +Expected: reachable remote connects; unreachable remote opens chooser with remote option available. + +- [ ] **Step 7: Commit verification fixes or create final commit** + +If verification required changes: + +```bash +git add +git commit -m "fix(electron): harden launch chooser verification issues" +``` + +If no changes were required, do not create an empty commit. + +--- + +## Self-Review + +- Spec coverage: The plan covers detecting local servers, connecting to one existing server, choosing among multiple local servers, connecting to a remote/VPN server, starting a new local bundled server, and adding a default-false “always ask” setting. +- Token handling: The plan covers saved desktop tokens first, then local loopback-only token lookup from `.env` and `config.json`, then chooser prompt for missing tokens. It explicitly avoids using local tokens for remote URLs. +- Ownership safety: The plan keeps detected local servers as `detected-local`, remote servers as `remote`, and app-bound starts as Electron-owned via existing `serverSpawner`. It does not add process management for detected servers. +- Placeholder scan: No steps rely on “add tests for this” or undefined behavior; each code task includes concrete test or implementation content. +- Type consistency: `alwaysAskOnLaunch`, `knownServers`, `LaunchServerCandidate`, and `LaunchChoice` are introduced in Task 1 and reused consistently in later tasks. diff --git a/electron-builder.yml b/electron-builder.yml index ec0e2ecc5..b8e2f998b 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -49,6 +49,11 @@ extraResources: to: client filter: - "**/*" + # Launch chooser assets (loaded from the real filesystem before connecting) + - from: dist/launch-chooser + to: launch-chooser + filter: + - "**/*" # Pruned server runtime dependencies (see prepare-bundled-node.ts Step 4) - from: server-node-modules to: server-node-modules @@ -84,8 +89,8 @@ linux: icon: assets/electron/icons nsis: - oneClick: false - allowToChangeInstallationDirectory: true + oneClick: true + runAfterFinish: true + include: assets/electron/installer.nsh -publish: - provider: github +publish: null diff --git a/electron/desktop-config.ts b/electron/desktop-config.ts index cba94ee3c..6f41fa9bd 100644 --- a/electron/desktop-config.ts +++ b/electron/desktop-config.ts @@ -17,6 +17,8 @@ export function getDefaultDesktopConfig(): DesktopConfig { return { serverMode: 'app-bound', port: 3001, + knownServers: [], + alwaysAskOnLaunch: false, globalHotkey: 'CommandOrControl+`', startOnLogin: false, minimizeToTray: true, diff --git a/electron/entry.ts b/electron/entry.ts index 68e0b9b84..3c1c312a7 100644 --- a/electron/entry.ts +++ b/electron/entry.ts @@ -9,10 +9,11 @@ // (or via electron-builder's packaged app) import { app, BrowserWindow, globalShortcut, ipcMain, Tray, Menu, nativeImage } from 'electron' -import fs from 'fs' import path from 'path' import os from 'os' import http from 'http' +import https from 'https' +import fs from 'fs' import { fileURLToPath } from 'url' const __filename = fileURLToPath(import.meta.url) @@ -26,10 +27,13 @@ import { createHotkeyManager } from './hotkey.js' import { createWindowStatePersistence } from './window-state.js' import { createUpdateManager } from './updater.js' import { createTray } from './tray.js' +import { resolveTrayIconPath } from './icon-path.js' import { buildAppMenu } from './menu.js' import { runStartup, type StartupContext, type BrowserWindowLike } from './startup.js' 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' const isDev = process.env.ELECTRON_DEV === '1' const configDir = path.join(os.homedir(), '.freshell') @@ -123,6 +127,29 @@ async function main(): Promise { }) }) }, + fetchAuthenticated: (url: string, token: string): Promise => { + return new Promise((resolve) => { + let parsed: URL + try { + parsed = new URL(url) + } catch { + resolve(false) + return + } + + const client = parsed.protocol === 'https:' ? https : http + const timer = setTimeout(() => resolve(false), 10_000) + const req = client.get(parsed, { headers: { 'x-auth-token': token } }, (res) => { + clearTimeout(timer) + resolve(res.statusCode !== undefined && res.statusCode >= 200 && res.statusCode < 400) + res.resume() + }) + req.on('error', () => { + clearTimeout(timer) + resolve(false) + }) + }) + }, readEnvToken: async (envPath: string): Promise => { try { const fsp = await import('fs/promises') @@ -156,11 +183,12 @@ async function main(): Promise { return win as unknown as BrowserWindowLike }, createTray: () => { - const iconName = process.platform === 'win32' ? 'tray-icon-win.ico' : 'tray-icon.png' - const devIconPath = path.join(__dirname, '..', '..', '..', 'assets', 'electron', iconName) - const iconPath = isDev || fs.existsSync(devIconPath) - ? devIconPath - : path.join(process.resourcesPath!, 'assets', iconName) + const iconPath = resolveTrayIconPath({ + platform: process.platform, + isDev, + moduleDir: __dirname, + resourcesPath: process.resourcesPath, + }) createTray( Tray as any, @@ -212,6 +240,10 @@ async function main(): Promise { ipcMain.removeHandler('get-server-status') ipcMain.removeHandler('set-global-hotkey') ipcMain.removeHandler('install-update') + ipcMain.removeHandler('get-launch-options') + ipcMain.removeHandler('choose-launch-option') + + let pendingLaunchChooser: { candidates: LaunchServerCandidate[]; reason: string } | undefined // Register the complete-setup handler before runStartup so it is available // when the wizard renderer calls it via the preload API. @@ -232,6 +264,30 @@ 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('choose-launch-option', createChooseLaunchOptionHandler({ + patchDesktopConfig, + getCurrentPort: () => desktopConfig.port, + validateServerAuth: (url: string, token: string) => ctx.fetchAuthenticated?.(`${url}/api/settings`, token) ?? Promise.resolve(false), + restartMain: async () => { + wizardPhase = true + for (const win of BrowserWindow.getAllWindows()) { + win.close() + } + setTimeout(() => { + main().catch((err) => { + console.error('Failed to restart after launch choice:', err) + }) + }, 250) + }, + })) + // Run startup sequence const result = await runStartup(ctx) @@ -256,6 +312,35 @@ async function main(): Promise { return } + if (result.type === 'chooser') { + wizardPhase = false + pendingLaunchChooser = { + candidates: result.candidates, + reason: result.reason, + } + + const chooserWin = new BrowserWindow({ + width: 760, + height: 720, + show: false, + webPreferences: { + preload: path.join(__dirname, 'preload.js'), + nodeIntegration: false, + contextIsolation: true, + }, + }) + + if (isDev) { + await chooserWin.loadURL('http://localhost:5175') + } else { + const packagedChooser = path.join(process.resourcesPath, 'launch-chooser', 'index.html') + const unpackagedChooser = path.join(app.getAppPath(), 'dist', 'launch-chooser', 'index.html') + await chooserWin.loadFile(fs.existsSync(packagedChooser) ? packagedChooser : unpackagedChooser) + } + chooserWin.show() + return + } + // Register IPC handlers for the main window's renderer process ipcMain.handle('get-server-mode', () => desktopConfig.serverMode) diff --git a/electron/icon-path.ts b/electron/icon-path.ts new file mode 100644 index 000000000..663a83ca0 --- /dev/null +++ b/electron/icon-path.ts @@ -0,0 +1,35 @@ +import fs from 'fs' +import path from 'path' + +export interface ResolveTrayIconPathOptions { + platform: NodeJS.Platform + isDev: boolean + moduleDir: string + resourcesPath?: string + existsSync?: (path: string) => boolean +} + +export function getTrayIconName(platform: NodeJS.Platform): string { + return platform === 'win32' ? 'tray-icon-win.ico' : 'tray-icon.png' +} + +export function resolveTrayIconPath({ + platform, + isDev, + moduleDir, + resourcesPath, + existsSync = fs.existsSync, +}: ResolveTrayIconPathOptions): string { + const iconName = getTrayIconName(platform) + const devIconPath = path.join(moduleDir, '..', '..', '..', 'assets', 'electron', iconName) + + if (isDev || existsSync(devIconPath)) { + return devIconPath + } + + if (!resourcesPath) { + throw new Error('resourcesPath is required for packaged tray icon') + } + + return path.join(resourcesPath, 'assets', iconName) +} diff --git a/electron/launch-choice-handler.ts b/electron/launch-choice-handler.ts new file mode 100644 index 000000000..7dac47b15 --- /dev/null +++ b/electron/launch-choice-handler.ts @@ -0,0 +1,57 @@ +import { normalizeServerUrl } from './launch-discovery.js' +import type { DesktopConfig, LaunchChoice, LaunchChoiceResult } from './types.js' + +export interface ChooseLaunchOptionHandlerOptions { + patchDesktopConfig: (patch: Partial) => Promise + restartMain: () => Promise | void + getCurrentPort: () => number + validateServerAuth?: (url: string, token: string) => Promise +} + +export function createChooseLaunchOptionHandler(options: ChooseLaunchOptionHandlerOptions) { + return async (_event: unknown, choice: LaunchChoice): Promise => { + if (choice.kind === 'remote' || choice.kind === 'connect') { + if (!choice.url) { + return { ok: false, error: 'Choose a server URL.' } + } + + const url = normalizeServerUrl(choice.url) + const token = choice.token?.trim() + if (choice.requiresAuth !== false) { + if (!token) { + return { ok: false, error: `Enter a token for ${url}` } + } + + if (options.validateServerAuth) { + let authenticated = false + try { + authenticated = await options.validateServerAuth(url, token) + } catch { + authenticated = false + } + if (!authenticated) { + return { ok: false, error: 'The server rejected that token.' } + } + } + } + + await options.patchDesktopConfig({ + serverMode: 'remote', + remoteUrl: url, + remoteToken: token, + alwaysAskOnLaunch: choice.alwaysAskOnLaunch, + setupCompleted: true, + }) + } else { + await options.patchDesktopConfig({ + serverMode: 'app-bound', + port: choice.port ?? options.getCurrentPort(), + alwaysAskOnLaunch: choice.alwaysAskOnLaunch, + setupCompleted: true, + }) + } + + await options.restartMain() + return { ok: true } + } +} diff --git a/electron/launch-chooser/chooser-logic.ts b/electron/launch-chooser/chooser-logic.ts new file mode 100644 index 000000000..6cd52ab32 --- /dev/null +++ b/electron/launch-chooser/chooser-logic.ts @@ -0,0 +1,76 @@ +import { normalizeServerUrl } from '../launch-discovery.js' +import type { LaunchChoice } from '../types.js' + +const launchReasonMessages: Record = { + 'always-ask': 'Choose whether to connect to an existing server or start a new local server.', + 'multiple-candidates': 'Multiple local Freshell servers are running. Choose one, connect to a remote server, or start a new local server.', + 'missing-token': 'The saved server needs a token. Choose a server, enter a token, or start a new local server.', + 'saved-remote-token-invalid': 'The saved remote server rejected its stored token. Enter a new token, choose another server, or start a new local server.', + 'saved-remote-unreachable': 'The saved remote server is not reachable. Choose another server or start a new local server.', + 'manual-choice': 'Choose whether to connect to an existing server or start a new local server.', +} + +export function formatLaunchReason(reason: string): string { + return launchReasonMessages[reason] ?? 'Choose whether to connect to an existing server or start a new local server.' +} + +export function validateRemoteLaunchUrl(value: string): string { + try { + const url = new URL(value) + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + return 'Enter a valid http or https URL' + } + return '' + } catch { + return 'Enter a valid http or https URL' + } +} + +export function buildConnectChoice(input: { + url: string + token?: string + requiresAuth?: boolean + alwaysAskOnLaunch: boolean + remember: boolean +}): LaunchChoice { + const choice: LaunchChoice = { + kind: 'connect', + url: normalizeServerUrl(input.url), + token: input.token, + alwaysAskOnLaunch: input.alwaysAskOnLaunch, + remember: input.remember, + } + if (input.requiresAuth !== undefined) { + choice.requiresAuth = input.requiresAuth + } + return choice +} + +export function buildRemoteChoice(input: { + url: string + token: string + alwaysAskOnLaunch: boolean + remember: boolean +}): LaunchChoice { + return { + kind: 'remote', + url: normalizeServerUrl(input.url), + token: input.token, + requiresAuth: true, + alwaysAskOnLaunch: input.alwaysAskOnLaunch, + remember: input.remember, + } +} + +export function buildStartLocalChoice(input: { + port: number + alwaysAskOnLaunch: boolean + remember: boolean +}): LaunchChoice { + return { + kind: 'start-local', + port: input.port, + alwaysAskOnLaunch: input.alwaysAskOnLaunch, + remember: input.remember, + } +} diff --git a/electron/launch-chooser/chooser.css b/electron/launch-chooser/chooser.css new file mode 100644 index 000000000..689bee2c4 --- /dev/null +++ b/electron/launch-chooser/chooser.css @@ -0,0 +1,110 @@ +body { + margin: 0; + font-family: system-ui, sans-serif; + background: #101418; + color: #f4f7fa; +} + +.launch-shell { + min-height: 100vh; + display: grid; + place-items: center; + padding: 24px; +} + +.launch-panel { + width: min(720px, 100%); + border: 1px solid #2f3842; + border-radius: 8px; + padding: 24px; + background: #171d23; +} + +.launch-panel h1, +.launch-panel h2 { + margin: 0 0 12px; +} + +.launch-reason { + color: #b9c3ce; +} + +.launch-section { + border-top: 1px solid #2f3842; + padding: 16px 0; +} + +.launch-section ul { + list-style: none; + margin: 0; + padding: 0; +} + +.launch-section li { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 12px 0; +} + +.candidate-summary { + min-width: 0; +} + +.candidate-actions { + min-width: min(260px, 42%); + display: grid; + justify-items: end; + gap: 8px; +} + +.candidate-actions label { + width: 100%; + margin: 0; +} + +.launch-section span { + display: block; + color: #b9c3ce; + font-size: 13px; +} + +label { + display: grid; + gap: 6px; + margin: 10px 0; +} + +input { + min-height: 36px; + border: 1px solid #3d4854; + border-radius: 6px; + padding: 0 10px; + color: #f4f7fa; + background: #0f1419; +} + +button { + min-height: 36px; + border: 1px solid #56616d; + border-radius: 6px; + padding: 0 14px; + color: #f4f7fa; + background: #244d7a; +} + +.launch-options { + display: flex; + flex-wrap: wrap; + gap: 16px; +} + +.launch-options label { + display: flex; + align-items: center; +} + +.launch-error { + color: #ffb4b4; +} diff --git a/electron/launch-chooser/chooser.tsx b/electron/launch-chooser/chooser.tsx new file mode 100644 index 000000000..80581d471 --- /dev/null +++ b/electron/launch-chooser/chooser.tsx @@ -0,0 +1,170 @@ +import { useEffect, useState } from 'react' +import type { LaunchChoice, LaunchChoiceResult, LaunchServerCandidate } from '../types.js' +import { + buildConnectChoice, + buildRemoteChoice, + buildStartLocalChoice, + formatLaunchReason, + validateRemoteLaunchUrl, +} from './chooser-logic.js' + +declare global { + interface Window { + freshellDesktop?: { + getLaunchOptions: () => Promise<{ + candidates: LaunchServerCandidate[] + reason: string + alwaysAskOnLaunch: boolean + port: number + }> + chooseLaunchOption: (choice: LaunchChoice) => Promise + } + } +} + +export function LaunchChooser() { + const [candidates, setCandidates] = useState([]) + const [reason, setReason] = useState('') + const [port, setPort] = useState(3001) + const [remoteUrl, setRemoteUrl] = useState('') + const [remoteToken, setRemoteToken] = useState('') + const [candidateTokens, setCandidateTokens] = useState>({}) + const [alwaysAskOnLaunch, setAlwaysAskOnLaunch] = useState(false) + const [remember, setRemember] = useState(true) + const [error, setError] = useState('') + + useEffect(() => { + void window.freshellDesktop?.getLaunchOptions().then((options) => { + setCandidates(options.candidates) + setReason(options.reason) + setAlwaysAskOnLaunch(options.alwaysAskOnLaunch) + setPort(options.port) + }) + }, []) + + const choose = async (choice: LaunchChoice) => { + setError('') + const result = await window.freshellDesktop?.chooseLaunchOption(choice) + if (result && !result.ok) { + setError(result.error) + } + } + + const candidateLabel = (candidate: LaunchServerCandidate) => candidate.label ?? candidate.url + const candidateNeedsToken = (candidate: LaunchServerCandidate) => candidate.requiresAuth !== false && !candidate.token + + const connectCandidate = async (candidate: LaunchServerCandidate) => { + const token = candidate.token ?? candidateTokens[candidate.id]?.trim() + if (candidateNeedsToken(candidate) && !token) { + setError(`Enter a token for ${candidateLabel(candidate)}`) + return + } + + await choose(buildConnectChoice({ + url: candidate.url, + token, + requiresAuth: candidate.requiresAuth, + alwaysAskOnLaunch, + remember, + })) + } + + const connectRemote = async () => { + const validation = validateRemoteLaunchUrl(remoteUrl) + if (validation) { + setError(validation) + return + } + if (!remoteToken.trim()) { + setError('Enter a token for the remote server') + return + } + await choose(buildRemoteChoice({ url: remoteUrl, token: remoteToken, alwaysAskOnLaunch, remember })) + } + + return ( +
+
+

Choose Freshell server

+

{formatLaunchReason(reason)}

+ +
+

Local servers

+ {candidates.length === 0 ? ( +

No running local Freshell servers were detected.

+ ) : ( +
    + {candidates.map((candidate) => ( +
  • +
    + {candidateLabel(candidate)} + {candidate.version ? `Version ${candidate.version}` : candidate.url} +
    +
    + {candidateNeedsToken(candidate) ? ( + + ) : null} + +
    +
  • + ))} +
+ )} +
+ +
+

Remote server

+ + + +
+ +
+

New local server

+ + +
+ +
+ + +
+ + {error ?

{error}

: null} +
+
+ ) +} diff --git a/electron/launch-chooser/index.html b/electron/launch-chooser/index.html new file mode 100644 index 000000000..234cb0a1a --- /dev/null +++ b/electron/launch-chooser/index.html @@ -0,0 +1,12 @@ + + + + + + Freshell Launch + + +
+ + + diff --git a/electron/launch-chooser/main.tsx b/electron/launch-chooser/main.tsx new file mode 100644 index 000000000..e56f683dd --- /dev/null +++ b/electron/launch-chooser/main.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import { createRoot } from 'react-dom/client' +import './chooser.css' +import { LaunchChooser } from './chooser.js' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/electron/launch-discovery.ts b/electron/launch-discovery.ts new file mode 100644 index 000000000..09895ec8d --- /dev/null +++ b/electron/launch-discovery.ts @@ -0,0 +1,96 @@ +import type { DesktopConfig, LaunchServerCandidate } from './types.js' + +export interface HealthPayload { + app?: string + ok?: boolean + version?: string + ready?: boolean + instanceId?: string + startedAt?: string + requiresAuth?: boolean +} + +export interface DiscoverLocalServersOptions { + urls: string[] + fetchHealth?: (url: string) => Promise +} + +export function normalizeServerUrl(value: string): string { + return value.trim().replace(/\/+$/, '') +} + +export function isLoopbackUrl(value: string): boolean { + try { + const url = new URL(value) + return url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]' + } catch { + return false + } +} + +export function buildLocalProbeUrls(config: DesktopConfig): string[] { + const urls: string[] = [] + const add = (url: string) => { + const normalized = normalizeServerUrl(url) + if (isLoopbackUrl(normalized) && !urls.includes(normalized)) { + urls.push(normalized) + } + } + + add(`http://localhost:${config.port}`) + add('http://localhost:3001') + + for (let port = 3001; port <= 3010; port += 1) { + add(`http://localhost:${port}`) + } + + for (const server of config.knownServers ?? []) { + add(server.url) + } + + return urls +} + +async function defaultFetchHealth(url: string): Promise { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), 750) + try { + const response = await fetch(url, { signal: controller.signal }) + if (!response.ok) return { ok: false } + return await response.json() as HealthPayload + } finally { + clearTimeout(timer) + } +} + +export async function discoverLocalServers(options: DiscoverLocalServersOptions): Promise { + const fetchHealth = options.fetchHealth ?? defaultFetchHealth + const results = await Promise.all(options.urls.map(async (url) => { + const normalized = normalizeServerUrl(url) + try { + const health = await fetchHealth(`${normalized}/api/health`) + if (health.app !== 'freshell' || health.ok !== true) { + return undefined + } + + const parsed = new URL(normalized) + const candidate: LaunchServerCandidate = { + id: health.instanceId ?? normalized, + url: normalized, + origin: 'port-scan', + ownership: 'detected-local', + label: `${parsed.hostname}:${parsed.port}`, + version: health.version, + ready: health.ready, + instanceId: health.instanceId, + startedAt: health.startedAt, + requiresAuth: health.requiresAuth ?? true, + } + return candidate + } catch { + return undefined + } + })) + + return results.filter((candidate): candidate is LaunchServerCandidate => candidate !== undefined) +} diff --git a/electron/launch-policy.ts b/electron/launch-policy.ts new file mode 100644 index 000000000..587311239 --- /dev/null +++ b/electron/launch-policy.ts @@ -0,0 +1,90 @@ +import type { DesktopConfig, LaunchServerCandidate } from './types.js' + +export type LaunchAction = + | { type: 'show-setup' } + | { type: 'start-local' } + | { type: 'auto-connect'; candidate: LaunchServerCandidate } + | { + type: 'show-chooser' + candidates: LaunchServerCandidate[] + reason: + | 'always-ask' + | 'multiple-candidates' + | 'missing-token' + | 'saved-remote-token-invalid' + | 'saved-remote-unreachable' + | 'manual-choice' + } + +export interface ChooseLaunchActionOptions { + desktopConfig: DesktopConfig + candidates: LaunchServerCandidate[] + savedRemoteReachable: boolean + savedRemoteAuthenticated?: boolean +} + +export function chooseLaunchAction(options: ChooseLaunchActionOptions): LaunchAction { + const { desktopConfig, candidates, savedRemoteReachable, savedRemoteAuthenticated } = options + + if (!desktopConfig.setupCompleted) { + return { type: 'show-setup' } + } + + if (desktopConfig.alwaysAskOnLaunch) { + return { type: 'show-chooser', candidates, reason: 'always-ask' } + } + + if (desktopConfig.serverMode === 'remote' && desktopConfig.remoteUrl) { + if (savedRemoteReachable) { + if (!desktopConfig.remoteToken) { + return { type: 'show-chooser', candidates, reason: 'missing-token' } + } + if (savedRemoteAuthenticated === false) { + return { type: 'show-chooser', candidates, reason: 'saved-remote-token-invalid' } + } + + const url = normalizeServerUrl(desktopConfig.remoteUrl) + return { + type: 'auto-connect', + candidate: { + id: url, + url, + origin: 'configured', + ownership: 'remote', + label: url, + token: desktopConfig.remoteToken, + }, + } + } + + return { type: 'show-chooser', candidates, reason: 'saved-remote-unreachable' } + } + + if (candidates.length > 1) { + return { type: 'show-chooser', candidates, reason: 'multiple-candidates' } + } + + if (candidates.length === 1) { + if (candidates[0].requiresAuth && !candidates[0].token) { + return { type: 'show-chooser', candidates, reason: 'missing-token' } + } + + return { type: 'auto-connect', candidate: candidates[0] } + } + + if (desktopConfig.serverMode === 'app-bound' || desktopConfig.serverMode === 'daemon') { + return { type: 'start-local' } + } + + return { type: 'show-chooser', candidates, reason: 'manual-choice' } +} + +function normalizeServerUrl(url: string): string { + const trimmed = url.trim() + + try { + return new URL(trimmed).toString().replace(/\/$/, '') + } catch { + return trimmed.replace(/\/+$/, '') + } +} diff --git a/electron/preload.ts b/electron/preload.ts index 5d1eca4f2..b28f769e7 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -11,6 +11,20 @@ export interface WizardSetupConfig { globalHotkey: string } +export interface LaunchChoice { + kind: 'connect' | 'remote' | 'start-local' + url?: string + token?: string + port?: number + requiresAuth?: boolean + alwaysAskOnLaunch: boolean + remember: boolean +} + +export type LaunchChoiceResult = + | { ok: true } + | { ok: false; error: string } + export interface FreshellDesktopApi { platform: string isElectron: boolean @@ -21,6 +35,8 @@ export interface FreshellDesktopApi { onUpdateDownloaded: (callback: () => void) => void installUpdate: () => Promise completeSetup: (config: WizardSetupConfig) => Promise + getLaunchOptions: () => Promise + chooseLaunchOption: (choice: LaunchChoice) => Promise } export interface ContextBridgeApi { @@ -46,6 +62,8 @@ export function registerPreloadApi( onUpdateDownloaded: (callback: () => void) => ipcRenderer.on('update-downloaded', callback), installUpdate: () => ipcRenderer.invoke('install-update'), completeSetup: (config: WizardSetupConfig) => ipcRenderer.invoke('complete-setup', config), + getLaunchOptions: () => ipcRenderer.invoke('get-launch-options'), + chooseLaunchOption: (choice: LaunchChoice) => ipcRenderer.invoke('choose-launch-option', choice), } contextBridge.exposeInMainWorld('freshellDesktop', api) diff --git a/electron/startup.ts b/electron/startup.ts index 1717367f4..1ea215be3 100644 --- a/electron/startup.ts +++ b/electron/startup.ts @@ -1,5 +1,8 @@ import path from 'path' -import type { DesktopConfig } from './types.js' +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 { DaemonManager } from './daemon/daemon-manager.js' import type { ServerSpawner } from './server-spawner.js' import type { HotkeyManager } from './hotkey.js' @@ -39,23 +42,184 @@ export interface StartupContext { createBrowserWindow: (options: Record) => BrowserWindowLike createTray: () => void fetchHealthCheck?: (url: string) => Promise + fetchAuthenticated?: (url: string, token: string) => Promise /** Read AUTH_TOKEN from the .env file in configDir. Returns undefined if not found. */ readEnvToken?: (envPath: string) => Promise + discoverLaunchCandidates?: () => Promise } export type StartupResult = | { type: 'wizard' } + | { type: 'chooser'; candidates: LaunchServerCandidate[]; reason: string } | { type: 'main'; serverUrl: string; window: BrowserWindowLike; updateCheckTimer: ReturnType } +async function defaultDiscoverLaunchCandidates(ctx: StartupContext): Promise { + const urls = buildLocalProbeUrls(ctx.desktopConfig) + const candidates = await discoverLocalServers({ urls }) + return Promise.all(candidates.map(async (candidate) => ({ + ...candidate, + token: await resolveCandidateToken({ + candidate, + desktopConfig: ctx.desktopConfig, + configDir: ctx.configDir, + }), + }))) +} + +async function checkRemoteReachable(ctx: StartupContext, remoteUrl: string): Promise { + const fetchFn = ctx.fetchHealthCheck ?? (async (url: string) => { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), 10_000) + try { + const response = await fetch(url, { signal: controller.signal }) + return response.ok + } finally { + clearTimeout(timer) + } + }) + + try { + return await fetchFn(`${normalizeServerUrl(remoteUrl)}/api/health`) + } catch { + return false + } +} + +async function checkRemoteAuthenticated( + ctx: StartupContext, + remoteUrl: string, + token: string | undefined, +): Promise { + if (!token) return false + + const authCheck = ctx.fetchAuthenticated ?? (async (url: string, authToken: string) => { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), 10_000) + try { + const response = await fetch(url, { + headers: { 'x-auth-token': authToken }, + signal: controller.signal, + }) + return response.ok + } finally { + clearTimeout(timer) + } + }) + + try { + return await authCheck(`${normalizeServerUrl(remoteUrl)}/api/settings`, token) + } catch { + return false + } +} + +async function loadMainWindow( + ctx: StartupContext, + serverUrl: string, + authToken: string | undefined, +): Promise> { + const windowState = await ctx.windowStatePersistence.load() + const window = ctx.createBrowserWindow({ + x: windowState.x, + y: windowState.y, + width: windowState.width, + height: windowState.height, + show: false, + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + }, + }) + + const loadUrl = authToken ? `${serverUrl}?token=${authToken}` : serverUrl + await window.loadURL(loadUrl) + window.show() + + if (windowState.maximized) { + window.maximize() + } + + let saveTimeout: ReturnType | undefined + const saveState = () => { + clearTimeout(saveTimeout) + saveTimeout = setTimeout(() => { + const bounds = window.getBounds?.() + const maximized = window.isMaximized?.() ?? false + if (bounds) { + void ctx.windowStatePersistence.save({ + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height, + maximized, + }) + } + }, 500) + } + + window.on('resize', saveState) + window.on('move', saveState) + + ctx.hotkeyManager.register(ctx.desktopConfig.globalHotkey, () => { + if (window.isVisible() && window.isFocused()) { + window.hide() + } else { + window.show() + window.focus() + } + }) + + try { + ctx.createTray() + } catch (err) { + console.warn('Failed to create system tray:', err) + } + + const updateCheckTimer = setTimeout(() => { + void ctx.updateManager.checkForUpdates() + }, 10_000) + + return { type: 'main', serverUrl, window, updateCheckTimer } +} + export async function runStartup(ctx: StartupContext): Promise { const { desktopConfig, isDev, port } = ctx - // 1. If setup not completed, signal wizard if (!desktopConfig.setupCompleted) { return { type: 'wizard' } } - // 2. Based on serverMode, ensure server is accessible + const discoverCandidates = ctx.discoverLaunchCandidates ?? (() => defaultDiscoverLaunchCandidates(ctx)) + const candidates = await discoverCandidates() + const savedRemoteReachable = desktopConfig.serverMode === 'remote' && !!desktopConfig.remoteUrl + ? await checkRemoteReachable(ctx, desktopConfig.remoteUrl) + : false + const savedRemoteAuthenticated = desktopConfig.serverMode === 'remote' && !!desktopConfig.remoteUrl && savedRemoteReachable + ? await checkRemoteAuthenticated(ctx, desktopConfig.remoteUrl, desktopConfig.remoteToken) + : undefined + const launchAction = chooseLaunchAction({ + desktopConfig, + candidates, + savedRemoteReachable, + savedRemoteAuthenticated, + }) + + if (launchAction.type === 'show-setup') { + return { type: 'wizard' } + } + + if (launchAction.type === 'show-chooser') { + return { + type: 'chooser', + candidates: launchAction.candidates, + reason: launchAction.reason, + } + } + + if (launchAction.type === 'auto-connect') { + return loadMainWindow(ctx, launchAction.candidate.url, launchAction.candidate.token) + } + let serverUrl: string switch (desktopConfig.serverMode) { @@ -82,7 +246,6 @@ export async function runStartup(ctx: StartupContext): Promise { envFile: path.join(ctx.configDir, '.env'), configDir: ctx.configDir, }) - // In dev mode, point at Vite dev server serverUrl = 'http://localhost:5173' } else { if (!ctx.resourcesPath) { @@ -108,31 +271,8 @@ export async function runStartup(ctx: StartupContext): Promise { case 'remote': { const remoteUrl = desktopConfig.remoteUrl if (!remoteUrl) { - throw new Error('Remote URL not configured. Please re-run setup.') - } - - // Validate connectivity (with timeout to prevent hangs) - const fetchFn = ctx.fetchHealthCheck ?? (async (url: string) => { - const controller = new AbortController() - const timer = setTimeout(() => controller.abort(), 10_000) - try { - const response = await fetch(url, { signal: controller.signal }) - return response.ok - } finally { - clearTimeout(timer) - } - }) - - let ok: boolean - try { - ok = await fetchFn(`${remoteUrl}/api/health`) - } catch { - throw new Error(`Cannot connect to remote server at ${remoteUrl}`) - } - if (!ok) { - throw new Error(`Cannot connect to remote server at ${remoteUrl}`) + return { type: 'chooser', candidates, reason: 'manual-choice' } } - serverUrl = remoteUrl break } @@ -140,83 +280,13 @@ export async function runStartup(ctx: StartupContext): Promise { throw new Error(`Unknown server mode: ${desktopConfig.serverMode}`) } - // 3. Load window state and create window - const windowState = await ctx.windowStatePersistence.load() - const window = ctx.createBrowserWindow({ - x: windowState.x, - y: windowState.y, - width: windowState.width, - height: windowState.height, - show: false, - webPreferences: { - nodeIntegration: false, - contextIsolation: true, - }, - }) - - // Resolve auth token for automatic authentication let authToken: string | undefined if (desktopConfig.serverMode === 'remote') { - // Remote mode: use the token from the wizard config authToken = desktopConfig.remoteToken } else if (ctx.readEnvToken) { - // App-bound / daemon mode: read token from ~/.freshell/.env authToken = await ctx.readEnvToken(path.join(ctx.configDir, '.env')) } - // Build the final URL with auth token - const loadUrl = authToken ? `${serverUrl}?token=${authToken}` : serverUrl - await window.loadURL(loadUrl) - window.show() - - if (windowState.maximized) { - window.maximize() - } - - // 4. Save window state on move/resize (debounced to avoid excessive writes) - let saveTimeout: ReturnType | undefined - const saveState = () => { - clearTimeout(saveTimeout) - saveTimeout = setTimeout(() => { - const bounds = window.getBounds?.() - const maximized = window.isMaximized?.() ?? false - if (bounds) { - void ctx.windowStatePersistence.save({ - x: bounds.x, - y: bounds.y, - width: bounds.width, - height: bounds.height, - maximized, - }) - } - }, 500) - } - - window.on('resize', saveState) - window.on('move', saveState) - - // 5. Register global hotkey (quake-style toggle) - ctx.hotkeyManager.register(desktopConfig.globalHotkey, () => { - if (window.isVisible() && window.isFocused()) { - window.hide() - } else { - window.show() - window.focus() - } - }) - - // 6. Create system tray (non-fatal — icon may be missing in unpackaged mode) - try { - ctx.createTray() - } catch (err) { - console.warn('Failed to create system tray:', err) - } - - // 7. Schedule update check (10s delay) - const updateCheckTimer = setTimeout(() => { - void ctx.updateManager.checkForUpdates() - }, 10_000) - - return { type: 'main', serverUrl, window, updateCheckTimer } + return loadMainWindow(ctx, serverUrl, authToken) } diff --git a/electron/token-resolver.ts b/electron/token-resolver.ts new file mode 100644 index 000000000..6f63c1cf5 --- /dev/null +++ b/electron/token-resolver.ts @@ -0,0 +1,75 @@ +import fsp from 'fs/promises' +import path from 'path' +import { isLoopbackUrl, normalizeServerUrl } from './launch-discovery.js' +import type { DesktopConfig, LaunchServerCandidate } from './types.js' + +export interface ResolveCandidateTokenOptions { + candidate: LaunchServerCandidate + desktopConfig: DesktopConfig + configDir: string + readTextFile?: (filePath: string) => Promise +} + +export function extractTokenFromEnv(content: string): string | undefined { + for (const line of content.split(/\r?\n/)) { + const match = line.match(/^AUTH_TOKEN=(.*)$/) + if (!match) continue + + const raw = match[1].trim() + return raw.replace(/^"(.*)"$/, '$1') + } + + return undefined +} + +export function extractTokenFromConfigJson(content: string): string | undefined { + try { + const parsed = JSON.parse(content) as { authToken?: unknown; token?: unknown } + if (typeof parsed.authToken === 'string' && parsed.authToken.length > 0) { + return parsed.authToken + } + if (typeof parsed.token === 'string' && parsed.token.length > 0) { + return parsed.token + } + } catch { + return undefined + } + + return undefined +} + +async function readOptional( + filePath: string, + readTextFile: (filePath: string) => Promise, +): Promise { + try { + return await readTextFile(filePath) + } catch { + return undefined + } +} + +export async function resolveCandidateToken( + options: ResolveCandidateTokenOptions, +): Promise { + const readTextFile = options.readTextFile ?? ((filePath: string) => fsp.readFile(filePath, 'utf-8')) + const candidateUrl = normalizeServerUrl(options.candidate.url) + const remoteUrl = options.desktopConfig.remoteUrl + ? normalizeServerUrl(options.desktopConfig.remoteUrl) + : undefined + + if (remoteUrl === candidateUrl && options.desktopConfig.remoteToken) { + return options.desktopConfig.remoteToken + } + + if (!isLoopbackUrl(candidateUrl)) { + return undefined + } + + const envContent = await readOptional(path.join(options.configDir, '.env'), readTextFile) + const envToken = envContent ? extractTokenFromEnv(envContent) : undefined + if (envToken) return envToken + + const configContent = await readOptional(path.join(options.configDir, 'config.json'), readTextFile) + return configContent ? extractTokenFromConfigJson(configContent) : undefined +} diff --git a/electron/types.ts b/electron/types.ts index 5d731743f..a3781c891 100644 --- a/electron/types.ts +++ b/electron/types.ts @@ -1,10 +1,18 @@ import { z } from 'zod' +export const KnownServerSchema = z.object({ + url: z.string().url(), + label: z.string().optional(), + lastConnectedAt: z.string().datetime().optional(), +}) + export const DesktopConfigSchema = z.object({ serverMode: z.enum(['daemon', 'app-bound', 'remote']), port: z.number().default(3001), remoteUrl: z.string().url().optional(), remoteToken: z.string().optional(), + knownServers: z.array(KnownServerSchema).default([]), + alwaysAskOnLaunch: z.boolean().default(false), globalHotkey: z.string().default('CommandOrControl+`'), startOnLogin: z.boolean().default(false), minimizeToTray: z.boolean().default(true), @@ -18,4 +26,35 @@ export const DesktopConfigSchema = z.object({ }).optional(), }) +export type KnownServer = z.infer export type DesktopConfig = z.infer + +export type ServerOwnership = 'owned' | 'detected-local' | 'remote' + +export interface LaunchServerCandidate { + id: string + url: string + origin: 'configured' | 'known' | 'port-scan' | 'manual' + ownership: ServerOwnership + label?: string + version?: string + instanceId?: string + startedAt?: string + ready?: boolean + requiresAuth?: boolean + token?: string +} + +export interface LaunchChoice { + kind: 'connect' | 'remote' | 'start-local' + url?: string + token?: string + port?: number + requiresAuth?: boolean + alwaysAskOnLaunch: boolean + remember: boolean +} + +export type LaunchChoiceResult = + | { ok: true } + | { ok: false; error: string } diff --git a/package-lock.json b/package-lock.json index e5166f884..475f77427 100644 --- a/package-lock.json +++ b/package-lock.json @@ -81,6 +81,7 @@ "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^5.1.0", + "extract-zip": "^2.0.1", "globals": "^17.3.0", "jsdom": "^25.0.1", "pino-pretty": "^11.3.0", @@ -88,6 +89,7 @@ "supertest": "^7.0.0", "superwstest": "^1.1.0", "tailwindcss": "^3.4.17", + "tar": "^6.2.1", "tsx": "^4.19.2", "typescript": "^5.7.2", "vite": "^6.4.1", diff --git a/package.json b/package.json index 66b9ec705..989265fe0 100644 --- a/package.json +++ b/package.json @@ -25,11 +25,14 @@ "build": "npm run typecheck:client && npm run build:client && npm run build:server", "build:client": "vite build", "build:server": "tsc -p tsconfig.server.json", - "build:electron": "tsc -p tsconfig.electron.json && tsc -p tsconfig.electron-preload.json", + "build:electron": "node -e \"const fs=require('fs');fs.rmSync('dist/electron',{recursive:true,force:true});fs.rmSync('node_modules/.cache/tsconfig.electron.tsbuildinfo',{force:true});fs.rmSync('node_modules/.cache/tsconfig.electron-preload.tsbuildinfo',{force:true})\" && tsc -p tsconfig.electron.json && tsc -p tsconfig.electron-preload.json", "build:wizard": "vite build --config vite.wizard.config.ts", + "build:launch-chooser": "vite build --config vite.launch-chooser.config.ts", "dev:wizard": "vite --config vite.wizard.config.ts", - "electron:dev": "npm run build:electron && cross-env ELECTRON_DEV=1 concurrently -n client,wizard,electron \"vite\" \"vite --config vite.wizard.config.ts\" \"electron .\"", - "electron:build": "npm run build && npm run build:electron && npm run build:wizard && npm run prepare:bundled-node && electron-builder", + "dev:launch-chooser": "vite --config vite.launch-chooser.config.ts", + "electron:dev": "npm run build:electron && cross-env ELECTRON_DEV=1 concurrently -n client,wizard,chooser,electron \"vite\" \"vite --config vite.wizard.config.ts\" \"vite --config vite.launch-chooser.config.ts\" \"electron .\"", + "electron:build": "npm run build && npm run build:electron && npm run build:wizard && npm run build:launch-chooser && npm run prepare:bundled-node && electron-builder", + "electron:build:win": "tsx scripts/assert-native-windows-build.ts && npm run build && npm run build:electron && npm run build:wizard && npm run build:launch-chooser && npm run prepare:bundled-node && electron-builder --win nsis --publish never", "prepare:bundled-node": "tsx scripts/prepare-bundled-node.ts", "start": "cross-env NODE_ENV=production node dist/server/index.js", "serve": "npm run build && npm run start", @@ -144,6 +147,7 @@ "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^5.1.0", + "extract-zip": "^2.0.1", "globals": "^17.3.0", "jsdom": "^25.0.1", "pino-pretty": "^11.3.0", @@ -151,6 +155,7 @@ "supertest": "^7.0.0", "superwstest": "^1.1.0", "tailwindcss": "^3.4.17", + "tar": "^6.2.1", "tsx": "^4.19.2", "typescript": "^5.7.2", "vite": "^6.4.1", diff --git a/scripts/assert-native-windows-build.ts b/scripts/assert-native-windows-build.ts new file mode 100644 index 000000000..1c732d4f5 --- /dev/null +++ b/scripts/assert-native-windows-build.ts @@ -0,0 +1,39 @@ +export interface NativeWindowsBuildCheck { + ok: boolean + message?: string +} + +export function checkNativeWindowsBuildPlatform( + platform: NodeJS.Platform = process.platform, +): NativeWindowsBuildCheck { + if (platform === 'win32') { + return { ok: true } + } + + return { + ok: false, + message: 'electron:build:win must run on native Windows so node-pty is compiled for win32.', + } +} + +function logFailure(platform: NodeJS.Platform, message: string): void { + console.error(JSON.stringify({ + severity: 'error', + event: 'electron_native_windows_build_wrong_platform', + platform, + message, + })) +} + +const isMainModule = + process.argv[1] && + (process.argv[1].endsWith('assert-native-windows-build.ts') || + process.argv[1].endsWith('assert-native-windows-build.js')) + +if (isMainModule) { + const result = checkNativeWindowsBuildPlatform() + if (!result.ok) { + logFailure(process.platform, result.message ?? 'Native Windows build check failed.') + process.exit(1) + } +} diff --git a/scripts/prepare-bundled-node.ts b/scripts/prepare-bundled-node.ts index e1ea162dc..9d4476c19 100644 --- a/scripts/prepare-bundled-node.ts +++ b/scripts/prepare-bundled-node.ts @@ -14,7 +14,7 @@ * Helper functions are exported for unit testing. */ -import { execSync } from 'child_process' +import { execFileSync } from 'child_process' import { existsSync, readFileSync, @@ -22,14 +22,33 @@ import { mkdirSync, cpSync, rmSync, + createWriteStream, + readdirSync, } from 'fs' +import http from 'http' +import https from 'https' +import { createRequire } from 'module' import path from 'path' +import { pipeline } from 'stream/promises' import { fileURLToPath } from 'url' +import tar from 'tar' + +const extractZip = (await import('extract-zip')).default +const require = createRequire(import.meta.url) const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) const PROJECT_ROOT = path.resolve(__dirname, '..') +function removePath(targetPath: string): void { + rmSync(targetPath, { + recursive: true, + force: true, + maxRetries: 5, + retryDelay: 250, + }) +} + // --- Exported helper functions (testable) --- /** @@ -93,6 +112,52 @@ export function getHeadersDownloadUrl(version: string): string { return `https://nodejs.org/dist/v${version}/node-v${version}-headers.tar.gz` } +/** + * electron-builder expands ${os} to win/mac/linux, not Node's win32/darwin. + * Stage bundled binaries under those directory names so extraResources can + * resolve them consistently on native Windows builds. + */ +export function getElectronBuilderOs(platform: string): string { + if (platform === 'win32') return 'win' + if (platform === 'darwin') return 'mac' + return platform +} + +export function getBundledNodeBinaryName(platform: string): string { + return platform === 'win32' ? 'node.exe' : 'node' +} + +export function getBundledNodeBinaryPath( + bundledNodeDir: string, + platform: string, + arch: string, +): string { + return path.join( + bundledNodeDir, + getElectronBuilderOs(platform), + arch, + getBundledNodeBinaryName(platform), + ) +} + +export function getWindowsNodeImportLibraryPath(headersDir: string): string { + return path.join(headersDir, 'Release', 'node.lib') +} + +export function getWindowsNodeImportLibraryDownloadUrl( + version: string, + arch: string, +): string { + return `https://nodejs.org/dist/v${version}/win-${arch}/node.lib` +} + +export function getCompiledNativeModuleFilenames( + releaseDir: string, + readDirFn: (dir: string) => string[] = readdirSync, +): string[] { + return readDirFn(releaseDir).filter((entry) => entry.endsWith('.node')) +} + /** * Get paths for staging native modules. */ @@ -107,6 +172,165 @@ export function getStagingPaths(): { return { nativeModulesDir, nodePtyTarget, bundledNodeDir } } +function resolvePackageRoot(packageName: string): string { + const localPackageRoot = path.join(PROJECT_ROOT, 'node_modules', packageName) + if (existsSync(path.join(localPackageRoot, 'package.json'))) { + return localPackageRoot + } + + return path.dirname(require.resolve(`${packageName}/package.json`)) +} + +function resolveNodeGypBin(): string { + const localNodeGypBin = path.join( + PROJECT_ROOT, + 'node_modules', + 'node-gyp', + 'bin', + 'node-gyp.js', + ) + if (existsSync(localNodeGypBin)) { + return localNodeGypBin + } + + return require.resolve('node-gyp/bin/node-gyp.js') +} + +export function resolveNpmCli( + npmExecPath = process.env.npm_execpath, + existsFn = existsSync, +): string { + if (npmExecPath && existsFn(npmExecPath)) { + return npmExecPath + } + + const localNpmCli = path.join( + PROJECT_ROOT, + 'node_modules', + 'npm', + 'bin', + 'npm-cli.js', + ) + if (existsFn(localNpmCli)) { + return localNpmCli + } + + return require.resolve('npm/bin/npm-cli.js') +} + +async function downloadFile(url: string, destination: string): Promise { + mkdirSync(path.dirname(destination), { recursive: true }) + + await new Promise((resolve, reject) => { + const request = (sourceUrl: string): void => { + const client = sourceUrl.startsWith('https:') ? https : http + const req = client.get(sourceUrl, (res) => { + const statusCode = res.statusCode ?? 0 + const location = res.headers.location + + if (statusCode >= 300 && statusCode < 400 && location) { + res.resume() + request(new URL(location, sourceUrl).toString()) + return + } + + if (statusCode !== 200) { + res.resume() + reject(new Error(`Download failed for ${sourceUrl}: HTTP ${statusCode}`)) + return + } + + pipeline(res, createWriteStream(destination)).then(resolve, reject) + }) + + req.on('error', reject) + } + + request(url) + }) +} + +async function extractNodeBinary( + version: string, + platform: string, + arch: string, + archivePath: string, + binaryPath: string, +): Promise { + const tmpDir = path.join(path.dirname(archivePath), `extract-${platform}-${arch}`) + removePath(tmpDir) + mkdirSync(tmpDir, { recursive: true }) + mkdirSync(path.dirname(binaryPath), { recursive: true }) + + try { + if (platform === 'win32') { + await extractZip(archivePath, { dir: tmpDir }) + cpSync( + path.join(tmpDir, `node-v${version}-win-${arch}`, 'node.exe'), + binaryPath, + ) + return + } + + const member = `node-v${version}-${platform}-${arch}/bin/node` + await tar.x({ + file: archivePath, + cwd: tmpDir, + strip: 2, + filter: (entryPath: string) => entryPath === member, + }) + cpSync(path.join(tmpDir, 'node'), binaryPath) + } finally { + removePath(tmpDir) + } +} + +async function prepareWindowsNodeImportLibrary( + version: string, + arch: string, + bundledNodeDir: string, + headersDir: string, +): Promise { + if (process.platform !== 'win32') return + + const nodeLibPath = getWindowsNodeImportLibraryPath(headersDir) + if (existsSync(nodeLibPath)) { + console.log(`Windows Node.js import library already exists at ${nodeLibPath}, skipping`) + return + } + + const downloadUrl = getWindowsNodeImportLibraryDownloadUrl(version, arch) + + console.log(`Downloading Windows Node.js import library from ${downloadUrl}...`) + await downloadFile(downloadUrl, nodeLibPath) + console.log(`Windows Node.js import library placed at ${nodeLibPath}`) +} + +async function prepareNodeBinary( + version: string, + platform: string, + arch: string, + bundledNodeDir: string, +): Promise { + const binaryPath = getBundledNodeBinaryPath(bundledNodeDir, platform, arch) + if (existsSync(binaryPath)) { + console.log(`Bundled Node.js binary already exists at ${binaryPath}, skipping`) + return + } + + const downloadUrl = getNodeDownloadUrl(version, platform, arch) + const archivePath = path.join( + bundledNodeDir, + `node-${platform}-${arch}${platform === 'win32' ? '.zip' : '.tar.gz'}`, + ) + + console.log(`Downloading Node.js binary from ${downloadUrl}...`) + await downloadFile(downloadUrl, archivePath) + await extractNodeBinary(version, platform, arch, archivePath, binaryPath) + removePath(archivePath) + console.log(`Node.js binary placed at ${binaryPath}`) +} + // --- Main script execution --- async function main(): Promise { @@ -118,70 +342,18 @@ async function main(): Promise { const { bundledNodeDir, nativeModulesDir, nodePtyTarget } = getStagingPaths() - // Step 1: Download Node.js binary - const binaryDir = path.join(bundledNodeDir, platform, arch) - mkdirSync(binaryDir, { recursive: true }) - - const downloadUrl = getNodeDownloadUrl(version, platform, arch) - console.log(`Downloading Node.js binary from ${downloadUrl}...`) - - if (platform === 'win32') { - // Download and extract zip on Windows - execSync( - `curl -sL "${downloadUrl}" -o "${path.join(bundledNodeDir, 'node.zip')}" && ` + - `cd "${bundledNodeDir}" && unzip -o node.zip "node-v${version}-win-${arch}/node.exe" -d tmp && ` + - `mv "tmp/node-v${version}-win-${arch}/node.exe" "${binaryDir}/node.exe" && ` + - 'rm -rf tmp node.zip', - { stdio: 'inherit' } - ) - } else { - // Download and extract tar.gz on macOS/Linux - execSync( - `curl -sL "${downloadUrl}" | tar xz -C "${bundledNodeDir}" --strip-components=1 ` + - `"node-v${version}-${platform}-${arch}/bin/node" && ` + - `mkdir -p "${binaryDir}" && mv "${path.join(bundledNodeDir, 'bin', 'node')}" "${binaryDir}/node" && ` + - `rmdir "${path.join(bundledNodeDir, 'bin')}" 2>/dev/null || true`, - { stdio: 'inherit' } - ) - } - - console.log(`Node.js binary placed at ${binaryDir}`) + // Step 1: Download Node.js binary for the current native platform. + await prepareNodeBinary(version, platform, arch, bundledNodeDir) // Step 1b: Download Node.js binaries for cross-build targets // When building on Linux, also download the Windows binary so // electron-builder can package it for Windows. - const crossTargets: Array<{ plat: string; ar: string }> = [] - if (platform !== 'win32') crossTargets.push({ plat: 'win32', ar: arch }) - if (platform !== 'linux') crossTargets.push({ plat: 'linux', ar: arch }) + const crossTargets: Array<{ platform: string; arch: string }> = [] + if (platform !== 'win32') crossTargets.push({ platform: 'win32', arch }) + if (platform !== 'linux') crossTargets.push({ platform: 'linux', arch }) for (const target of crossTargets) { - const targetDir = path.join(bundledNodeDir, target.plat === 'win32' ? 'win' : target.plat, target.ar) - if (existsSync(path.join(targetDir, target.plat === 'win32' ? 'node.exe' : 'node'))) { - console.log(`Cross-target ${target.plat}-${target.ar} already exists, skipping`) - continue - } - mkdirSync(targetDir, { recursive: true }) - const targetUrl = getNodeDownloadUrl(version, target.plat, target.ar) - console.log(`Downloading cross-target Node.js for ${target.plat}-${target.ar} from ${targetUrl}...`) - - if (target.plat === 'win32') { - execSync( - `curl -sL "${targetUrl}" -o "${path.join(bundledNodeDir, 'node-cross.zip')}" && ` + - `cd "${bundledNodeDir}" && unzip -o node-cross.zip "node-v${version}-win-${target.ar}/node.exe" -d tmp-cross && ` + - `mv "tmp-cross/node-v${version}-win-${target.ar}/node.exe" "${targetDir}/node.exe" && ` + - 'rm -rf tmp-cross node-cross.zip', - { stdio: 'inherit' } - ) - } else { - execSync( - `curl -sL "${targetUrl}" | tar xz -C "${bundledNodeDir}" --strip-components=1 ` + - `"node-v${version}-${target.plat}-${target.ar}/bin/node" && ` + - `mkdir -p "${targetDir}" && mv "${path.join(bundledNodeDir, 'bin', 'node')}" "${targetDir}/node" && ` + - `rmdir "${path.join(bundledNodeDir, 'bin')}" 2>/dev/null || true`, - { stdio: 'inherit' } - ) - } - console.log(`Cross-target Node.js binary placed at ${targetDir}`) + await prepareNodeBinary(version, target.platform, target.arch, bundledNodeDir) } // Step 2: Download Node.js headers @@ -191,30 +363,43 @@ async function main(): Promise { const headersUrl = getHeadersDownloadUrl(version) console.log(`Downloading Node.js headers from ${headersUrl}...`) - execSync( - `curl -sL "${headersUrl}" | tar xz -C "${headersBaseDir}"`, - { stdio: 'inherit' } - ) + const headersArchivePath = path.join(headersBaseDir, `node-v${version}-headers.tar.gz`) + await downloadFile(headersUrl, headersArchivePath) + await tar.x({ file: headersArchivePath, cwd: headersBaseDir }) + removePath(headersArchivePath) const headersDir = path.join(headersBaseDir, `node-v${version}`) validateHeaders(headersDir) console.log(`Node.js headers extracted to ${headersDir}`) + await prepareWindowsNodeImportLibrary(version, arch, bundledNodeDir, headersDir) // Step 3: Recompile node-pty against bundled Node headers - const nodePtyDir = path.resolve(PROJECT_ROOT, 'node_modules', 'node-pty') + const nodePtyDir = resolvePackageRoot('node-pty') const gypCmd = buildNodeGypCommand(version, headersDir) console.log(`Recompiling node-pty with: ${gypCmd}`) - execSync(gypCmd, { cwd: nodePtyDir, stdio: 'inherit' }) + execFileSync(process.execPath, [ + resolveNodeGypBin(), + 'rebuild', + `--target=${version}`, + `--nodedir=${headersDir}`, + ], { cwd: nodePtyDir, stdio: 'inherit' }) // Stage the compiled native module mkdirSync(path.join(nodePtyTarget, 'build', 'Release'), { recursive: true }) - // Copy the compiled .node file - cpSync( - path.join(nodePtyDir, 'build', 'Release', 'pty.node'), - path.join(nodePtyTarget, 'build', 'Release', 'pty.node') - ) + const nodePtyReleaseDir = path.join(nodePtyDir, 'build', 'Release') + const compiledNativeModules = getCompiledNativeModuleFilenames(nodePtyReleaseDir) + if (compiledNativeModules.length === 0) { + throw new Error(`No compiled node-pty .node files found in ${nodePtyReleaseDir}`) + } + + for (const filename of compiledNativeModules) { + cpSync( + path.join(nodePtyReleaseDir, filename), + path.join(nodePtyTarget, 'build', 'Release', filename) + ) + } // Copy node-pty JS files (excluding the build directory, except for the Release binary) cpSync(nodePtyDir, nodePtyTarget, { @@ -234,8 +419,8 @@ async function main(): Promise { console.log('Pruning and staging server node_modules...') // Clean up any previous staging - rmSync(serverNodeModulesDir, { recursive: true, force: true }) - rmSync(stagingDir, { recursive: true, force: true }) + removePath(serverNodeModulesDir) + removePath(stagingDir) mkdirSync(stagingDir, { recursive: true }) // Copy package.json to staging, stripping comment entries (keys starting @@ -258,7 +443,7 @@ async function main(): Promise { } // Install production-only dependencies - execSync('npm ci --omit=dev', { cwd: stagingDir, stdio: 'inherit' }) + execFileSync(process.execPath, [resolveNpmCli(), 'ci', '--omit=dev'], { cwd: stagingDir, stdio: 'inherit' }) // Move the resulting node_modules cpSync( @@ -271,11 +456,11 @@ async function main(): Promise { // (it was compiled against the dev machine's Node, not the bundled one) const prunedNodePtyBuild = path.join(serverNodeModulesDir, 'node-pty', 'build') if (existsSync(prunedNodePtyBuild)) { - rmSync(prunedNodePtyBuild, { recursive: true, force: true }) + removePath(prunedNodePtyBuild) } // Clean up staging - rmSync(stagingDir, { recursive: true, force: true }) + removePath(stagingDir) console.log(`Server node_modules staged at ${serverNodeModulesDir}`) console.log('Bundled Node.js preparation complete!') diff --git a/server/health-router.ts b/server/health-router.ts new file mode 100644 index 000000000..452709029 --- /dev/null +++ b/server/health-router.ts @@ -0,0 +1,26 @@ +import { Router } from 'express' + +export interface HealthRouterOptions { + appVersion: string + instanceId: string + isReady: () => boolean + startedAt: Date +} + +export function createHealthRouter(options: HealthRouterOptions): Router { + const router = Router() + + router.get('/', (_req, res) => { + res.json({ + app: 'freshell', + ok: true, + requiresAuth: true, + version: options.appVersion, + ready: options.isReady(), + instanceId: options.instanceId, + startedAt: options.startedAt.toISOString(), + }) + }) + + return router +} diff --git a/server/index.ts b/server/index.ts index 9d5e9dc91..b361880cc 100644 --- a/server/index.ts +++ b/server/index.ts @@ -67,6 +67,7 @@ import { createExtensionRouter } from './extension-routes.js' import { createServerInfoRouter } from './server-info-router.js' import { SessionMetadataStore } from './session-metadata-store.js' import { createShellBootstrapRouter } from './shell-bootstrap-router.js' +import { createHealthRouter } from './health-router.js' import { loadSessionHistory } from './session-history-loader.js' import { SessionContentCache } from './session-content-cache.js' import { createAgentTimelineService } from './agent-timeline/service.js' @@ -140,16 +141,15 @@ async function main() { app.use('/local-file', createLocalFileRouter()) const startupState = createStartupState() + const serverInstanceId = await loadOrCreateServerInstanceId() // Health check endpoint (no auth required - used by precheck script) - app.get('/api/health', (_req, res) => { - res.json({ - app: 'freshell', - ok: true, - version: APP_VERSION, - ready: startupState.isReady(), - }) - }) + app.use('/api/health', createHealthRouter({ + appVersion: APP_VERSION, + instanceId: serverInstanceId, + isReady: () => startupState.isReady(), + startedAt: new Date(SERVER_STARTED_AT), + })) // Basic rate limiting for /api app.use( @@ -209,7 +209,6 @@ async function main() { const opencodeActivity = createOpencodeActivityIntegration({ registry, opencodeProvider }) const sessionRepairService = getSessionRepairService({ skipDiscovery: true }) - const serverInstanceId = await loadOrCreateServerInstanceId() await runCodexStartupReaper({ serverInstanceId }) const agentChatCapabilityRegistry = new AgentChatCapabilityRegistry() diff --git a/test/e2e-electron/electron-app.test.ts b/test/e2e-electron/electron-app.test.ts index d66602a7a..1651bc6be 100644 --- a/test/e2e-electron/electron-app.test.ts +++ b/test/e2e-electron/electron-app.test.ts @@ -49,6 +49,13 @@ function createTempHome(desktopConfig?: Record): string { return tmpHome } +function writeDesktopEnv(tmpHome: string): void { + fs.writeFileSync( + path.join(tmpHome, '.freshell', '.env'), + `AUTH_TOKEN=${getRunningServerToken()}\n`, + ) +} + async function launchApp(tmpHome: string, captureOutput = false): Promise { const app = await electron.launch({ args: [PROJECT_ROOT], @@ -90,12 +97,31 @@ async function waitForNewWindow( throw new Error(`Timed out waiting for new window (current: ${currentCount})`) } +async function waitForWindowUrl( + app: ElectronApplication, + pattern: RegExp, + timeoutMs = 60_000, +): Promise { + const start = Date.now() + while (Date.now() - start < timeoutMs) { + for (const win of app.windows()) { + if (pattern.test(win.url())) { + return win + } + } + await new Promise(r => setTimeout(r, 500)) + } + throw new Error(`Timed out waiting for window URL matching ${pattern}`) +} + function remoteConfig() { return { serverMode: 'remote', port: 3001, remoteUrl: 'http://localhost:3001', remoteToken: getRunningServerToken(), + knownServers: [], + alwaysAskOnLaunch: false, globalHotkey: 'CommandOrControl+`', startOnLogin: false, minimizeToTray: true, @@ -186,6 +212,67 @@ test.describe('Wizard flow', () => { }) }) +// --------------------------------------------------------------------------- +// Test: Launch chooser +// --------------------------------------------------------------------------- + +test.describe('Launch chooser', () => { + let app: ElectronApplication + let tmpHome: string + + test.afterEach(async () => { + if (app) await app.close().catch(() => {}) + if (tmpHome) fs.rmSync(tmpHome, { recursive: true, force: true }) + }) + + test('shows launch chooser when alwaysAskOnLaunch is true', async () => { + tmpHome = createTempHome({ + serverMode: 'remote', + port: 3001, + remoteUrl: 'http://localhost:3001', + remoteToken: getRunningServerToken(), + knownServers: [], + alwaysAskOnLaunch: true, + globalHotkey: 'CommandOrControl+`', + startOnLogin: false, + minimizeToTray: true, + setupCompleted: true, + }) + + app = await launchApp(tmpHome) + const chooser = await app.firstWindow() + await chooser.waitForLoadState('domcontentloaded') + + await expect(chooser.getByRole('heading', { name: 'Choose Freshell server' })).toBeVisible() + await expect(chooser.getByRole('checkbox', { name: 'Always ask on launch' })).toBeChecked() + }) + + test('connects to an existing server from chooser', async () => { + tmpHome = createTempHome({ + serverMode: 'app-bound', + port: 3001, + knownServers: [{ url: 'http://localhost:3001', label: 'Test server' }], + alwaysAskOnLaunch: true, + globalHotkey: 'CommandOrControl+`', + startOnLogin: false, + minimizeToTray: true, + setupCompleted: true, + }) + writeDesktopEnv(tmpHome) + + app = await launchApp(tmpHome) + 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() + const mainPage = await waitForWindowUrl(app, /http:\/\/localhost:3001/, 60_000) + await mainPage.waitForLoadState('domcontentloaded') + + await expect(mainPage).toHaveURL(/http:\/\/localhost:3001/) + }) +}) + // --------------------------------------------------------------------------- // Test: Main Window (pre-seeded config, remote mode) // --------------------------------------------------------------------------- diff --git a/test/unit/electron/desktop-config.test.ts b/test/unit/electron/desktop-config.test.ts index d42be160a..222c174cd 100644 --- a/test/unit/electron/desktop-config.test.ts +++ b/test/unit/electron/desktop-config.test.ts @@ -235,6 +235,81 @@ describe('DesktopConfig', () => { }) }) + describe('launch chooser fields', () => { + it('defaults alwaysAskOnLaunch to false', () => { + const config = getDefaultDesktopConfig() + expect(config.alwaysAskOnLaunch).toBe(false) + }) + + it('schema defaults alwaysAskOnLaunch to false when omitted', () => { + const result = DesktopConfigSchema.parse({ + serverMode: 'app-bound', + }) + expect(result.alwaysAskOnLaunch).toBe(false) + }) + + it('preserves known servers from config file', async () => { + const freshellDir = path.join(tempDir, '.freshell') + await fsp.mkdir(freshellDir, { recursive: true }) + await fsp.writeFile( + path.join(freshellDir, 'desktop.json'), + JSON.stringify({ + serverMode: 'remote', + port: 3001, + remoteUrl: 'http://10.0.0.5:3001', + remoteToken: 'vpn-token', + knownServers: [ + { + url: 'http://localhost:3001', + label: 'Local dev server', + lastConnectedAt: '2026-05-24T18:00:00.000Z', + }, + ], + alwaysAskOnLaunch: true, + globalHotkey: 'CommandOrControl+`', + startOnLogin: false, + minimizeToTray: true, + setupCompleted: true, + }), + ) + + const config = await readDesktopConfig() + expect(config).not.toBeNull() + expect(config!.alwaysAskOnLaunch).toBe(true) + expect(config!.knownServers).toEqual([ + { + url: 'http://localhost:3001', + label: 'Local dev server', + lastConnectedAt: '2026-05-24T18:00:00.000Z', + }, + ]) + }) + + it('patches alwaysAskOnLaunch and knownServers', async () => { + await writeDesktopConfig(getDefaultDesktopConfig()) + + const patched = await patchDesktopConfig({ + alwaysAskOnLaunch: true, + knownServers: [ + { + url: 'http://localhost:3002', + label: 'Local 3002', + lastConnectedAt: '2026-05-24T18:05:00.000Z', + }, + ], + }) + + expect(patched.alwaysAskOnLaunch).toBe(true) + expect(patched.knownServers).toEqual([ + { + url: 'http://localhost:3002', + label: 'Local 3002', + lastConnectedAt: '2026-05-24T18:05:00.000Z', + }, + ]) + }) + }) + describe('schema validation (invariant)', () => { it('rejects invalid serverMode', () => { const result = DesktopConfigSchema.safeParse({ serverMode: 'invalid-mode' }) diff --git a/test/unit/electron/electron-builder-config.test.ts b/test/unit/electron/electron-builder-config.test.ts new file mode 100644 index 000000000..852163b9c --- /dev/null +++ b/test/unit/electron/electron-builder-config.test.ts @@ -0,0 +1,83 @@ +import { readFileSync } from 'fs' +import path from 'path' +import { describe, expect, it } from 'vitest' + +const PROJECT_ROOT = path.resolve(import.meta.dirname, '..', '..', '..') + +// Repo files are LF, but a Windows checkout may convert them to CRLF. +// Normalize so `\n`-based regex/content assertions are EOL-agnostic. +const readText = (filePath: string): string => + readFileSync(filePath, 'utf-8').replace(/\r\n/g, '\n') + +describe('electron-builder Windows config', () => { + it('builds the Windows installer without the portable self-extracting target', () => { + const config = readText(path.join(PROJECT_ROOT, 'electron-builder.yml')) + + expect(config).toMatch(/win:\n(?:.*\n)*? target:\n(?:.*\n)*? - nsis/) + expect(config).not.toMatch(/win:\n(?:.*\n)*? target:\n(?:.*\n)*? - portable/) + expect(config).not.toMatch(/^portable:/m) + }) + + it('does not request the portable target from the Windows package script', () => { + const packageJson = JSON.parse( + readFileSync(path.join(PROJECT_ROOT, 'package.json'), 'utf-8'), + ) as { scripts: Record } + + expect(packageJson.scripts['electron:build:win']).toContain('--win nsis') + expect(packageJson.scripts['electron:build:win']).not.toContain('portable') + }) + + it('does not require publish metadata for local package builds', () => { + const config = readText(path.join(PROJECT_ROOT, 'electron-builder.yml')) + + expect(config).toMatch(/^publish: null$/m) + }) + + it('packages launch chooser assets as extra resources', () => { + const config = readText(path.join(PROJECT_ROOT, 'electron-builder.yml')) + + expect(config).toMatch( + /extraResources:\n(?:.*\n)*? - from: dist\/launch-chooser\n to: launch-chooser/, + ) + }) + + it('uses a silent-install friendly NSIS flow', () => { + const config = readText(path.join(PROJECT_ROOT, 'electron-builder.yml')) + + expect(config).toMatch( + /^nsis:\n oneClick: true\n runAfterFinish: true\n include: assets\/electron\/installer\.nsh$/m, + ) + expect(config).not.toContain('allowToChangeInstallationDirectory') + }) + + it('lets the built-in NSIS completion flow launch the installed app', () => { + const include = readText(path.join(PROJECT_ROOT, 'assets', 'electron', 'installer.nsh')) + + expect(include).toContain('!macro customInstall') + expect(include).not.toContain('SetErrorLevel 0') + expect(include).not.toContain("System::Call 'kernel32::ExitProcess(i 0)'") + }) + + it('quits before installation when Freshell is already running', () => { + const include = readText(path.join(PROJECT_ROOT, 'assets', 'electron', 'installer.nsh')) + + expect(include).toContain('!macro customInit') + expect(include).toContain('!macro customCheckAppRunning') + expect(include).toContain('${nsProcess::FindProcess} "${APP_EXECUTABLE_FILENAME}" $R0') + expect(include).toContain('Quit ${PRODUCT_NAME} before running this installer.') + expect(include).toContain('SetErrorLevel 1') + expect(include).not.toContain('taskkill') + }) + + it('can provision remote desktop config from silent installer args', () => { + 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') + }) +}) diff --git a/test/unit/electron/icon-path.test.ts b/test/unit/electron/icon-path.test.ts new file mode 100644 index 000000000..4f8f308ba --- /dev/null +++ b/test/unit/electron/icon-path.test.ts @@ -0,0 +1,69 @@ +import path from 'path' +import { describe, expect, it } from 'vitest' +import { getTrayIconName, resolveTrayIconPath } from '../../../electron/icon-path.js' + +describe('Electron icon paths', () => { + it('uses a Windows ICO for the Windows tray', () => { + expect(getTrayIconName('win32')).toBe('tray-icon-win.ico') + }) + + it('uses a PNG tray icon on non-Windows platforms', () => { + expect(getTrayIconName('linux')).toBe('tray-icon.png') + expect(getTrayIconName('darwin')).toBe('tray-icon.png') + }) + + it('resolves the dev tray icon from the repository assets directory', () => { + const moduleDir = path.join('/repo', 'dist', 'electron', 'electron') + + expect( + resolveTrayIconPath({ + platform: 'win32', + isDev: true, + moduleDir, + resourcesPath: undefined, + existsSync: () => false, + }), + ).toBe(path.join('/repo', 'assets', 'electron', 'tray-icon-win.ico')) + }) + + it('resolves packaged tray icons from extraResources', () => { + const moduleDir = path.join('/app', 'resources', 'app.asar', 'dist', 'electron', 'electron') + + expect( + resolveTrayIconPath({ + platform: 'win32', + isDev: false, + moduleDir, + resourcesPath: path.join('/app', 'resources'), + existsSync: () => false, + }), + ).toBe(path.join('/app', 'resources', 'assets', 'tray-icon-win.ico')) + }) + + it('supports local packaged-like runs from dist without ELECTRON_DEV', () => { + const moduleDir = path.join('/repo', 'dist', 'electron', 'electron') + + expect( + resolveTrayIconPath({ + platform: 'linux', + isDev: false, + moduleDir, + resourcesPath: undefined, + existsSync: (candidate) => + candidate === path.join('/repo', 'assets', 'electron', 'tray-icon.png'), + }), + ).toBe(path.join('/repo', 'assets', 'electron', 'tray-icon.png')) + }) + + it('throws when a packaged tray icon cannot be resolved without resourcesPath', () => { + expect(() => + resolveTrayIconPath({ + platform: 'linux', + isDev: false, + moduleDir: path.join('/app', 'resources', 'app.asar', 'dist', 'electron', 'electron'), + resourcesPath: undefined, + existsSync: () => false, + }), + ).toThrow('resourcesPath is required') + }) +}) diff --git a/test/unit/electron/launch-choice-handler.test.ts b/test/unit/electron/launch-choice-handler.test.ts new file mode 100644 index 000000000..bd79a80b5 --- /dev/null +++ b/test/unit/electron/launch-choice-handler.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it, vi } from 'vitest' +import { createChooseLaunchOptionHandler } from '../../../electron/launch-choice-handler.js' + +describe('launch choice handler', () => { + it('persists remote launch choice and restarts startup', async () => { + const patchDesktopConfig = vi.fn().mockResolvedValue(undefined) + const restartMain = vi.fn().mockResolvedValue(undefined) + const handler = createChooseLaunchOptionHandler({ + patchDesktopConfig, + restartMain, + getCurrentPort: () => 3001, + }) + + await handler({}, { + kind: 'remote', + url: 'http://10.0.0.5:3001', + token: 'vpn-token', + requiresAuth: true, + alwaysAskOnLaunch: true, + remember: true, + }) + + expect(patchDesktopConfig).toHaveBeenCalledWith({ + serverMode: 'remote', + remoteUrl: 'http://10.0.0.5:3001', + remoteToken: 'vpn-token', + alwaysAskOnLaunch: true, + setupCompleted: true, + }) + expect(restartMain).toHaveBeenCalled() + }) + + it('rejects auth-required server choices without a token before restart', async () => { + const patchDesktopConfig = vi.fn().mockResolvedValue(undefined) + const restartMain = vi.fn().mockResolvedValue(undefined) + const validateServerAuth = vi.fn() + const handler = createChooseLaunchOptionHandler({ + patchDesktopConfig, + restartMain, + getCurrentPort: () => 3001, + validateServerAuth, + }) + + const result = await handler({}, { + kind: 'connect', + url: 'http://localhost:3001', + requiresAuth: true, + alwaysAskOnLaunch: false, + remember: true, + }) + + expect(result).toEqual({ + ok: false, + error: 'Enter a token for http://localhost:3001', + }) + expect(validateServerAuth).not.toHaveBeenCalled() + expect(patchDesktopConfig).not.toHaveBeenCalled() + expect(restartMain).not.toHaveBeenCalled() + }) + + it('rejects server choices with invalid tokens before restart', async () => { + const patchDesktopConfig = vi.fn().mockResolvedValue(undefined) + const restartMain = vi.fn().mockResolvedValue(undefined) + const validateServerAuth = vi.fn().mockResolvedValue(false) + const handler = createChooseLaunchOptionHandler({ + patchDesktopConfig, + restartMain, + getCurrentPort: () => 3001, + validateServerAuth, + }) + + const result = await handler({}, { + kind: 'connect', + url: 'http://localhost:3001', + token: 'bad-token', + requiresAuth: true, + alwaysAskOnLaunch: false, + remember: true, + }) + + expect(result).toEqual({ + ok: false, + error: 'The server rejected that token.', + }) + expect(validateServerAuth).toHaveBeenCalledWith('http://localhost:3001', 'bad-token') + expect(patchDesktopConfig).not.toHaveBeenCalled() + expect(restartMain).not.toHaveBeenCalled() + }) + + it('persists and restarts after validating an auth-required server choice', 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: ' local-token ', + requiresAuth: true, + alwaysAskOnLaunch: false, + remember: true, + }) + + expect(result).toEqual({ ok: true }) + expect(validateServerAuth).toHaveBeenCalledWith('http://localhost:3001', 'local-token') + expect(patchDesktopConfig).toHaveBeenCalledWith({ + serverMode: 'remote', + remoteUrl: 'http://localhost:3001', + remoteToken: 'local-token', + alwaysAskOnLaunch: false, + setupCompleted: true, + }) + expect(restartMain).toHaveBeenCalled() + }) + + it('persists start-local launch choice with selected port', 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: 3003, + alwaysAskOnLaunch: false, + remember: true, + }) + + expect(patchDesktopConfig).toHaveBeenCalledWith({ + serverMode: 'app-bound', + port: 3003, + alwaysAskOnLaunch: false, + setupCompleted: true, + }) + expect(restartMain).toHaveBeenCalled() + }) +}) diff --git a/test/unit/electron/launch-chooser/chooser-logic.test.ts b/test/unit/electron/launch-chooser/chooser-logic.test.ts new file mode 100644 index 000000000..32e58cc82 --- /dev/null +++ b/test/unit/electron/launch-chooser/chooser-logic.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' +import { + buildConnectChoice, + buildRemoteChoice, + buildStartLocalChoice, + formatLaunchReason, + validateRemoteLaunchUrl, +} from '../../../../electron/launch-chooser/chooser-logic.js' + +describe('launch chooser logic', () => { + it('validates remote launch URLs', () => { + expect(validateRemoteLaunchUrl('http://10.0.0.5:3001')).toBe('') + expect(validateRemoteLaunchUrl('https://freshell.internal')).toBe('') + expect(validateRemoteLaunchUrl('localhost:3001')).toBe('Enter a valid http or https URL') + expect(validateRemoteLaunchUrl('ftp://example.com')).toBe('Enter a valid http or https URL') + }) + + it('builds a connect choice', () => { + expect(buildConnectChoice({ + url: 'http://localhost:3001', + token: 'local-token', + alwaysAskOnLaunch: true, + remember: true, + })).toEqual({ + kind: 'connect', + url: 'http://localhost:3001', + token: 'local-token', + alwaysAskOnLaunch: true, + remember: true, + }) + }) + + it('builds a remote choice', () => { + expect(buildRemoteChoice({ + url: 'http://10.0.0.5:3001/', + token: 'vpn-token', + alwaysAskOnLaunch: false, + remember: true, + })).toEqual({ + kind: 'remote', + url: 'http://10.0.0.5:3001', + token: 'vpn-token', + requiresAuth: true, + alwaysAskOnLaunch: false, + remember: true, + }) + }) + + it('builds a start-local choice', () => { + expect(buildStartLocalChoice({ + port: 3003, + alwaysAskOnLaunch: false, + remember: true, + })).toEqual({ + kind: 'start-local', + port: 3003, + alwaysAskOnLaunch: false, + remember: true, + }) + }) + + it('formats launch reasons for users', () => { + expect(formatLaunchReason('saved-remote-token-invalid')).toContain('rejected its stored token') + expect(formatLaunchReason('missing-token')).toContain('needs a token') + expect(formatLaunchReason('unknown')).toContain('connect to an existing server') + }) +}) diff --git a/test/unit/electron/launch-chooser/chooser.test.tsx b/test/unit/electron/launch-chooser/chooser.test.tsx new file mode 100644 index 000000000..5fe826b7d --- /dev/null +++ b/test/unit/electron/launch-chooser/chooser.test.tsx @@ -0,0 +1,93 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { LaunchChooser } from '../../../../electron/launch-chooser/chooser.js' +import type { LaunchServerCandidate } from '../../../../electron/types.js' + +function localCandidate(overrides: Partial = {}): LaunchServerCandidate { + return { + id: 'local-3001', + url: 'http://localhost:3001', + origin: 'port-scan', + ownership: 'detected-local', + label: 'localhost:3001', + requiresAuth: true, + ...overrides, + } +} + +function installDesktopApi(options: { + candidates: LaunchServerCandidate[] + chooseLaunchOption?: ReturnType +}) { + const chooseLaunchOption = options.chooseLaunchOption ?? vi.fn().mockResolvedValue(undefined) + window.freshellDesktop = { + getLaunchOptions: vi.fn().mockResolvedValue({ + candidates: options.candidates, + reason: 'manual-choice', + alwaysAskOnLaunch: false, + port: 3001, + }), + chooseLaunchOption, + } + return { chooseLaunchOption } +} + +afterEach(() => { + cleanup() + delete window.freshellDesktop +}) + +describe('LaunchChooser', () => { + it('keeps the chooser open when a detected auth-required server has no token', async () => { + const { chooseLaunchOption } = installDesktopApi({ + candidates: [localCandidate()], + }) + + render() + + fireEvent.click(await screen.findByRole('button', { name: 'Connect to localhost:3001' })) + + expect((await screen.findByRole('alert')).textContent).toContain('Enter a token for localhost:3001') + expect(chooseLaunchOption).not.toHaveBeenCalled() + }) + + it('connects to a detected auth-required server with the entered token', async () => { + const { chooseLaunchOption } = installDesktopApi({ + candidates: [localCandidate()], + }) + + render() + + fireEvent.change(await screen.findByLabelText('Token for localhost:3001'), { + target: { value: 'typed-token' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Connect to localhost:3001' })) + + await waitFor(() => expect(chooseLaunchOption).toHaveBeenCalledWith({ + kind: 'connect', + url: 'http://localhost:3001', + token: 'typed-token', + requiresAuth: true, + alwaysAskOnLaunch: false, + remember: true, + })) + }) + + it('keeps the chooser open when a manual remote server has no token', async () => { + const { chooseLaunchOption } = installDesktopApi({ + candidates: [], + }) + + render() + + fireEvent.change(await screen.findByLabelText('URL'), { + target: { value: 'http://10.0.0.5:3001' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Connect remote' })) + + expect((await screen.findByRole('alert')).textContent).toContain('Enter a token for the remote server') + expect(chooseLaunchOption).not.toHaveBeenCalled() + }) +}) diff --git a/test/unit/electron/launch-discovery.test.ts b/test/unit/electron/launch-discovery.test.ts new file mode 100644 index 000000000..d8016829b --- /dev/null +++ b/test/unit/electron/launch-discovery.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it, vi } from 'vitest' +import { + buildLocalProbeUrls, + discoverLocalServers, + isLoopbackUrl, + normalizeServerUrl, +} from '../../../electron/launch-discovery.js' +import type { DesktopConfig } from '../../../electron/types.js' + +function config(overrides: Partial = {}): DesktopConfig { + return { + serverMode: 'app-bound', + port: 3001, + knownServers: [], + alwaysAskOnLaunch: false, + globalHotkey: 'CommandOrControl+`', + startOnLogin: false, + minimizeToTray: true, + setupCompleted: true, + ...overrides, + } +} + +describe('launch discovery', () => { + it('normalizes server URLs by trimming trailing slashes', () => { + expect(normalizeServerUrl('http://localhost:3001/')).toBe('http://localhost:3001') + expect(normalizeServerUrl('http://localhost:3001///')).toBe('http://localhost:3001') + }) + + it('recognizes only loopback URLs as local token-readable URLs', () => { + expect(isLoopbackUrl('http://localhost:3001')).toBe(true) + expect(isLoopbackUrl('http://127.0.0.1:3001')).toBe(true) + expect(isLoopbackUrl('http://[::1]:3001')).toBe(true) + expect(isLoopbackUrl('http://10.0.0.5:3001')).toBe(false) + expect(isLoopbackUrl('not a url')).toBe(false) + }) + + it('builds unique local probe URLs from config port, defaults, range, and known local servers', () => { + const urls = buildLocalProbeUrls(config({ + port: 3004, + knownServers: [ + { url: 'http://localhost:3002', label: 'Known local' }, + { url: 'http://10.0.0.5:3001', label: 'Remote VPN' }, + ], + })) + + expect(urls[0]).toBe('http://localhost:3004') + expect(urls).toContain('http://localhost:3001') + expect(urls).toContain('http://localhost:3010') + expect(urls).toContain('http://localhost:3002') + expect(urls).not.toContain('http://10.0.0.5:3001') + expect(new Set(urls).size).toBe(urls.length) + }) + + it('returns only healthy Freshell servers', async () => { + const fetchHealth = vi.fn(async (url: string) => { + if (url === 'http://localhost:3001/api/health') { + return { + ok: true, + app: 'freshell', + version: '0.7.0', + ready: true, + instanceId: 'local-a', + startedAt: '2026-05-24T18:00:00.000Z', + requiresAuth: true, + } + } + if (url === 'http://localhost:3002/api/health') { + return { ok: true, app: 'not-freshell' } + } + throw new Error('ECONNREFUSED') + }) + + const candidates = await discoverLocalServers({ + urls: ['http://localhost:3001', 'http://localhost:3002', 'http://localhost:3003'], + fetchHealth, + }) + + expect(candidates).toEqual([ + { + id: 'local-a', + url: 'http://localhost:3001', + origin: 'port-scan', + ownership: 'detected-local', + label: 'localhost:3001', + version: '0.7.0', + ready: true, + instanceId: 'local-a', + startedAt: '2026-05-24T18:00:00.000Z', + requiresAuth: true, + }, + ]) + }) +}) diff --git a/test/unit/electron/launch-policy.test.ts b/test/unit/electron/launch-policy.test.ts new file mode 100644 index 000000000..c5dbdd18e --- /dev/null +++ b/test/unit/electron/launch-policy.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from 'vitest' +import { chooseLaunchAction } from '../../../electron/launch-policy.js' +import type { DesktopConfig, LaunchServerCandidate } from '../../../electron/types.js' + +function config(overrides: Partial = {}): DesktopConfig { + return { + serverMode: 'app-bound', + port: 3001, + knownServers: [], + alwaysAskOnLaunch: false, + globalHotkey: 'CommandOrControl+`', + startOnLogin: false, + minimizeToTray: true, + setupCompleted: true, + ...overrides, + } +} + +function candidate(url: string, token?: string): LaunchServerCandidate { + return { + id: url, + url, + origin: 'port-scan', + ownership: 'detected-local', + label: url, + ready: true, + requiresAuth: true, + token, + } +} + +describe('launch policy', () => { + it('shows setup when setup is incomplete', () => { + expect(chooseLaunchAction({ + desktopConfig: config({ setupCompleted: false }), + candidates: [], + savedRemoteReachable: false, + })).toEqual({ type: 'show-setup' }) + }) + + it('shows chooser when alwaysAskOnLaunch is true even with one candidate', () => { + const candidates = [candidate('http://localhost:3001', 'token')] + expect(chooseLaunchAction({ + desktopConfig: config({ alwaysAskOnLaunch: true }), + candidates, + savedRemoteReachable: false, + })).toEqual({ + type: 'show-chooser', + candidates, + reason: 'always-ask', + }) + }) + + it('auto-connects to one detected candidate with a token', () => { + const candidates = [candidate('http://localhost:3001', 'token')] + expect(chooseLaunchAction({ + desktopConfig: config(), + candidates, + savedRemoteReachable: false, + })).toEqual({ + type: 'auto-connect', + candidate: candidates[0], + }) + }) + + it('shows chooser for multiple candidates', () => { + const candidates = [ + candidate('http://localhost:3001', 'token-a'), + candidate('http://localhost:3002', 'token-b'), + ] + expect(chooseLaunchAction({ + desktopConfig: config(), + candidates, + savedRemoteReachable: false, + })).toEqual({ + type: 'show-chooser', + candidates, + reason: 'multiple-candidates', + }) + }) + + it('starts local for app-bound config when no candidates exist', () => { + expect(chooseLaunchAction({ + desktopConfig: config({ serverMode: 'app-bound' }), + candidates: [], + savedRemoteReachable: false, + })).toEqual({ type: 'start-local' }) + }) + + it('continues configured daemon startup when no candidates exist', () => { + expect(chooseLaunchAction({ + desktopConfig: config({ serverMode: 'daemon' }), + candidates: [], + savedRemoteReachable: false, + })).toEqual({ type: 'start-local' }) + }) + + it('auto-connects to reachable saved remote config', () => { + expect(chooseLaunchAction({ + desktopConfig: config({ + serverMode: 'remote', + remoteUrl: 'http://10.0.0.5:3001', + remoteToken: 'vpn-token', + }), + candidates: [], + savedRemoteReachable: true, + savedRemoteAuthenticated: true, + })).toEqual({ + type: 'auto-connect', + candidate: { + id: 'http://10.0.0.5:3001', + url: 'http://10.0.0.5:3001', + origin: 'configured', + ownership: 'remote', + label: 'http://10.0.0.5:3001', + token: 'vpn-token', + }, + }) + }) + + it('shows chooser for reachable saved remote config with no token', () => { + expect(chooseLaunchAction({ + desktopConfig: config({ + serverMode: 'remote', + remoteUrl: 'http://10.0.0.5:3001', + }), + candidates: [], + savedRemoteReachable: true, + savedRemoteAuthenticated: false, + })).toEqual({ + type: 'show-chooser', + candidates: [], + reason: 'missing-token', + }) + }) + + it('shows chooser for reachable saved remote config with an invalid token', () => { + expect(chooseLaunchAction({ + desktopConfig: config({ + serverMode: 'remote', + remoteUrl: 'http://10.0.0.5:3001', + remoteToken: 'stale-token', + }), + candidates: [], + savedRemoteReachable: true, + savedRemoteAuthenticated: false, + })).toEqual({ + type: 'show-chooser', + candidates: [], + reason: 'saved-remote-token-invalid', + }) + }) + + it('shows chooser for unreachable saved remote config', () => { + expect(chooseLaunchAction({ + desktopConfig: config({ + serverMode: 'remote', + remoteUrl: 'http://10.0.0.5:3001', + remoteToken: 'vpn-token', + }), + candidates: [], + savedRemoteReachable: false, + })).toEqual({ + type: 'show-chooser', + candidates: [], + reason: 'saved-remote-unreachable', + }) + }) +}) diff --git a/test/unit/electron/native-windows-build-script.test.ts b/test/unit/electron/native-windows-build-script.test.ts new file mode 100644 index 000000000..d8e18b137 --- /dev/null +++ b/test/unit/electron/native-windows-build-script.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { readFileSync } from 'fs' +import path from 'path' +import { checkNativeWindowsBuildPlatform } from '../../../scripts/assert-native-windows-build.js' + +const PROJECT_ROOT = path.resolve(import.meta.dirname, '..', '..', '..') + +describe('native Windows Electron build guard', () => { + it('allows native Windows builds', () => { + expect(checkNativeWindowsBuildPlatform('win32')).toEqual({ ok: true }) + }) + + it('blocks non-Windows builds because node-pty must be compiled natively', () => { + expect(checkNativeWindowsBuildPlatform('linux')).toEqual({ + ok: false, + message: 'electron:build:win must run on native Windows so node-pty is compiled for win32.', + }) + }) + + it('builds local Windows artifacts without trying to publish from CI worktrees', () => { + const packageJson = JSON.parse( + readFileSync(path.join(PROJECT_ROOT, 'package.json'), 'utf-8'), + ) + + expect(packageJson.scripts['electron:build:win']).toContain('--publish never') + }) +}) diff --git a/test/unit/electron/preload.test.ts b/test/unit/electron/preload.test.ts index 19544c6d3..f3304e348 100644 --- a/test/unit/electron/preload.test.ts +++ b/test/unit/electron/preload.test.ts @@ -34,7 +34,9 @@ describe('Preload API', () => { it('has exactly the expected keys', () => { const keys = Object.keys(exposedApi).sort() expect(keys).toEqual([ + 'chooseLaunchOption', 'completeSetup', + 'getLaunchOptions', 'getServerMode', 'getServerStatus', 'installUpdate', @@ -58,6 +60,8 @@ describe('Preload API', () => { expect(typeof exposedApi.onUpdateDownloaded).toBe('function') expect(typeof exposedApi.installUpdate).toBe('function') expect(typeof exposedApi.completeSetup).toBe('function') + expect(typeof exposedApi.getLaunchOptions).toBe('function') + expect(typeof exposedApi.chooseLaunchOption).toBe('function') }) it('getServerMode invokes correct IPC channel', () => { @@ -87,4 +91,21 @@ describe('Preload API', () => { exposedApi.completeSetup(config) expect(mockIpcRenderer.invoke).toHaveBeenCalledWith('complete-setup', config) }) + + it('getLaunchOptions invokes correct IPC channel', () => { + exposedApi.getLaunchOptions() + expect(mockIpcRenderer.invoke).toHaveBeenCalledWith('get-launch-options') + }) + + it('chooseLaunchOption invokes correct IPC channel with choice', () => { + const choice = { + kind: 'remote' as const, + url: 'http://10.0.0.5:3001', + token: 'vpn-token', + alwaysAskOnLaunch: true, + remember: true, + } + exposedApi.chooseLaunchOption(choice) + expect(mockIpcRenderer.invoke).toHaveBeenCalledWith('choose-launch-option', choice) + }) }) diff --git a/test/unit/electron/prepare-bundled-node.test.ts b/test/unit/electron/prepare-bundled-node.test.ts index 6fbcbc6e1..b0599adb2 100644 --- a/test/unit/electron/prepare-bundled-node.test.ts +++ b/test/unit/electron/prepare-bundled-node.test.ts @@ -2,6 +2,7 @@ // Tests verify headers validation and node-gyp rebuild flag construction // using mocked filesystem and child_process. import { describe, it, expect, vi, beforeEach } from 'vitest' +import path from 'path' // We test the individual helper functions exported from the module, // not the full script execution (which would download from the internet). @@ -111,9 +112,98 @@ describe('prepare-bundled-node helpers', () => { '../../../scripts/prepare-bundled-node.js' ) const paths = getStagingPaths() - expect(paths.nativeModulesDir).toContain('bundled-node/native-modules') + expect(paths.nativeModulesDir).toContain( + path.join('bundled-node', 'native-modules') + ) expect(paths.nodePtyTarget).toContain( - 'bundled-node/native-modules/node-pty' + path.join('bundled-node', 'native-modules', 'node-pty') + ) + }) + }) + + describe('electron-builder resource paths', () => { + it('uses electron-builder os directory names', async () => { + const { getElectronBuilderOs } = await import( + '../../../scripts/prepare-bundled-node.js' + ) + + expect(getElectronBuilderOs('win32')).toBe('win') + expect(getElectronBuilderOs('darwin')).toBe('mac') + expect(getElectronBuilderOs('linux')).toBe('linux') + }) + + it('stages Windows Node where electron-builder extraResources will look', async () => { + const { getBundledNodeBinaryPath } = await import( + '../../../scripts/prepare-bundled-node.js' + ) + + const binaryPath = getBundledNodeBinaryPath( + '/repo/bundled-node', + 'win32', + 'x64', + ) + + expect(binaryPath).toBe( + path.join('/repo/bundled-node', 'win', 'x64', 'node.exe'), + ) + }) + + it('stages Linux Node without an exe suffix', async () => { + const { getBundledNodeBinaryPath } = await import( + '../../../scripts/prepare-bundled-node.js' + ) + + const binaryPath = getBundledNodeBinaryPath( + '/repo/bundled-node', + 'linux', + 'x64', + ) + + expect(binaryPath).toBe( + path.join('/repo/bundled-node', 'linux', 'x64', 'node'), + ) + }) + + it('places the Windows node.lib where node-gyp expects it', async () => { + const { getWindowsNodeImportLibraryPath } = await import( + '../../../scripts/prepare-bundled-node.js' + ) + + expect(getWindowsNodeImportLibraryPath('C:\\headers\\node-v22.12.0')).toBe( + path.join('C:\\headers\\node-v22.12.0', 'Release', 'node.lib'), + ) + }) + + it('downloads the Windows node.lib from the standalone Node import-library URL', async () => { + const { getWindowsNodeImportLibraryDownloadUrl } = await import( + '../../../scripts/prepare-bundled-node.js' + ) + + expect(getWindowsNodeImportLibraryDownloadUrl('22.12.0', 'x64')).toBe( + 'https://nodejs.org/dist/v22.12.0/win-x64/node.lib', + ) + }) + + it('stages every compiled native module from node-pty Release output', async () => { + const { getCompiledNativeModuleFilenames } = await import( + '../../../scripts/prepare-bundled-node.js' + ) + + expect(getCompiledNativeModuleFilenames('/release', () => [ + 'conpty.node', + 'conpty_console_list.node', + 'conpty.lib', + 'obj', + ])).toEqual(['conpty.node', 'conpty_console_list.node']) + }) + + it('uses npm_execpath when npm launches the prepare script', async () => { + const { resolveNpmCli } = await import( + '../../../scripts/prepare-bundled-node.js' + ) + + expect(resolveNpmCli('C:\\Program Files\\nodejs\\node_modules\\npm\\bin\\npm-cli.js', () => true)).toBe( + 'C:\\Program Files\\nodejs\\node_modules\\npm\\bin\\npm-cli.js', ) }) }) diff --git a/test/unit/electron/startup.test.ts b/test/unit/electron/startup.test.ts index e047ec3c5..808d46a18 100644 --- a/test/unit/electron/startup.test.ts +++ b/test/unit/electron/startup.test.ts @@ -1,7 +1,13 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import path from 'path' import { runStartup, type StartupContext, type BrowserWindowLike } from '../../../electron/startup.js' import type { DesktopConfig } from '../../../electron/types.js' +// Spawn paths are built with path.join, so separators are OS-native +// (backslashes on Windows). Normalize to forward slashes so structural +// assertions hold on every platform. +const norm = (p: string): string => p.replace(/\\/g, '/') + function createMockWindow(): BrowserWindowLike { let visible = false let focused = false @@ -21,6 +27,9 @@ function createDefaultContext(overrides: Partial = {}): StartupC return { desktopConfig: { serverMode: 'app-bound', + port: 3001, + knownServers: [], + alwaysAskOnLaunch: false, globalHotkey: 'CommandOrControl+`', startOnLogin: false, minimizeToTray: true, @@ -64,6 +73,7 @@ function createDefaultContext(overrides: Partial = {}): StartupC platform: 'linux' as NodeJS.Platform, createBrowserWindow: vi.fn().mockReturnValue(createMockWindow()), createTray: vi.fn(), + discoverLaunchCandidates: vi.fn().mockResolvedValue([]), ...overrides, } } @@ -81,6 +91,9 @@ describe('runStartup', () => { const ctx = createDefaultContext({ desktopConfig: { serverMode: 'app-bound', + port: 3001, + knownServers: [], + alwaysAskOnLaunch: false, globalHotkey: 'CommandOrControl+`', startOnLogin: false, minimizeToTray: true, @@ -97,6 +110,9 @@ describe('runStartup', () => { const ctx = createDefaultContext({ desktopConfig: { serverMode: 'daemon', + port: 3001, + knownServers: [], + alwaysAskOnLaunch: false, globalHotkey: 'CommandOrControl+`', startOnLogin: false, minimizeToTray: true, @@ -118,6 +134,9 @@ describe('runStartup', () => { const ctx = createDefaultContext({ desktopConfig: { serverMode: 'daemon', + port: 3001, + knownServers: [], + alwaysAskOnLaunch: false, globalHotkey: 'CommandOrControl+`', startOnLogin: false, minimizeToTray: true, @@ -137,6 +156,9 @@ describe('runStartup', () => { const ctx = createDefaultContext({ desktopConfig: { serverMode: 'daemon', + port: 3001, + knownServers: [], + alwaysAskOnLaunch: false, globalHotkey: 'CommandOrControl+`', startOnLogin: false, minimizeToTray: true, @@ -160,10 +182,10 @@ describe('runStartup', () => { expect(ctx.serverSpawner.start).toHaveBeenCalledTimes(1) const startArgs = (ctx.serverSpawner.start as ReturnType).mock.calls[0][0] expect(startArgs.spawn.mode).toBe('production') - expect(startArgs.spawn.nodeBinary).toContain('/app/resources/bundled-node/bin/node') - expect(startArgs.spawn.serverEntry).toContain('/app/resources/server/index.js') - expect(startArgs.spawn.nativeModulesDir).toContain('/app/resources/bundled-node/native-modules') - expect(startArgs.spawn.serverNodeModulesDir).toContain('/app/resources/server-node-modules') + expect(norm(startArgs.spawn.nodeBinary)).toContain('/app/resources/bundled-node/bin/node') + expect(norm(startArgs.spawn.serverEntry)).toContain('/app/resources/server/index.js') + expect(norm(startArgs.spawn.nativeModulesDir)).toContain('/app/resources/bundled-node/native-modules') + expect(norm(startArgs.spawn.serverNodeModulesDir)).toContain('/app/resources/server-node-modules') expect(result.type).toBe('main') if (result.type === 'main') { expect(result.serverUrl).toBe('http://localhost:3001') @@ -191,7 +213,7 @@ describe('runStartup', () => { const result = await runStartup(ctx) expect(result.type).toBe('main') const startArgs = (ctx.serverSpawner.start as ReturnType).mock.calls[0][0] - expect(startArgs.spawn.nodeBinary).toMatch(/\/node$/) + expect(norm(startArgs.spawn.nodeBinary)).toMatch(/\/node$/) expect(startArgs.spawn.nodeBinary).not.toMatch(/\.exe$/) }) @@ -214,10 +236,13 @@ describe('runStartup', () => { }) describe('remote mode', () => { - it('throws when remoteUrl is not configured', async () => { + it('opens chooser when remoteUrl is not configured', async () => { const ctx = createDefaultContext({ desktopConfig: { serverMode: 'remote', + port: 3001, + knownServers: [], + alwaysAskOnLaunch: false, // remoteUrl intentionally omitted globalHotkey: 'CommandOrControl+`', startOnLogin: false, @@ -226,38 +251,144 @@ describe('runStartup', () => { }, }) - await expect(runStartup(ctx)).rejects.toThrow('Remote URL not configured. Please re-run setup.') + const result = await runStartup(ctx) + expect(result).toEqual({ + type: 'chooser', + candidates: [], + reason: 'manual-choice', + }) }) - it('validates connectivity and opens remote URL', async () => { + it('validates connectivity and saved token before opening remote URL', async () => { const fetchHealthCheck = vi.fn().mockResolvedValue(true) + const fetchAuthenticated = vi.fn().mockResolvedValue(true) const ctx = createDefaultContext({ desktopConfig: { serverMode: 'remote', + port: 3001, remoteUrl: 'http://10.0.0.5:3001', + remoteToken: 'vpn-token', + knownServers: [], + alwaysAskOnLaunch: false, globalHotkey: 'CommandOrControl+`', startOnLogin: false, minimizeToTray: true, setupCompleted: true, }, fetchHealthCheck, + fetchAuthenticated, }) const result = await runStartup(ctx) expect(fetchHealthCheck).toHaveBeenCalledWith('http://10.0.0.5:3001/api/health') + expect(fetchAuthenticated).toHaveBeenCalledWith('http://10.0.0.5:3001/api/settings', 'vpn-token') expect(ctx.serverSpawner.start).not.toHaveBeenCalled() expect(ctx.daemonManager.status).not.toHaveBeenCalled() if (result.type === 'main') { expect(result.serverUrl).toBe('http://10.0.0.5:3001') + const window = (ctx.createBrowserWindow as ReturnType).mock.results[0].value + expect(window.loadURL).toHaveBeenCalledWith('http://10.0.0.5:3001?token=vpn-token') } }) + it('opens chooser when saved remote token is missing', async () => { + const fetchHealthCheck = vi.fn().mockResolvedValue(true) + const fetchAuthenticated = vi.fn() + const ctx = createDefaultContext({ + desktopConfig: { + serverMode: 'remote', + port: 3001, + remoteUrl: 'http://10.0.0.5:3001', + knownServers: [], + alwaysAskOnLaunch: false, + globalHotkey: 'CommandOrControl+`', + startOnLogin: false, + minimizeToTray: true, + setupCompleted: true, + }, + fetchHealthCheck, + fetchAuthenticated, + }) + + const result = await runStartup(ctx) + expect(fetchHealthCheck).toHaveBeenCalledWith('http://10.0.0.5:3001/api/health') + expect(fetchAuthenticated).not.toHaveBeenCalled() + expect(result).toEqual({ + type: 'chooser', + candidates: [], + reason: 'missing-token', + }) + }) + + it('opens chooser when saved remote token is invalid', async () => { + const fetchHealthCheck = vi.fn().mockResolvedValue(true) + const fetchAuthenticated = vi.fn().mockResolvedValue(false) + const ctx = createDefaultContext({ + desktopConfig: { + serverMode: 'remote', + port: 3001, + remoteUrl: 'http://10.0.0.5:3001', + remoteToken: 'stale-token', + knownServers: [], + alwaysAskOnLaunch: false, + globalHotkey: 'CommandOrControl+`', + startOnLogin: false, + minimizeToTray: true, + setupCompleted: true, + }, + fetchHealthCheck, + fetchAuthenticated, + }) + + const result = await runStartup(ctx) + expect(fetchHealthCheck).toHaveBeenCalledWith('http://10.0.0.5:3001/api/health') + expect(fetchAuthenticated).toHaveBeenCalledWith('http://10.0.0.5:3001/api/settings', 'stale-token') + expect(result).toEqual({ + type: 'chooser', + candidates: [], + reason: 'saved-remote-token-invalid', + }) + }) + + it('normalizes trailing slash in saved remote URL before health and auth probes', async () => { + const fetchHealthCheck = vi.fn().mockResolvedValue(true) + const fetchAuthenticated = vi.fn().mockResolvedValue(false) + const ctx = createDefaultContext({ + desktopConfig: { + serverMode: 'remote', + port: 3001, + remoteUrl: 'http://10.0.0.5:3001/', + remoteToken: 'stale-token', + knownServers: [], + alwaysAskOnLaunch: false, + globalHotkey: 'CommandOrControl+`', + startOnLogin: false, + minimizeToTray: true, + setupCompleted: true, + }, + fetchHealthCheck, + fetchAuthenticated, + }) + + const result = await runStartup(ctx) + expect(fetchHealthCheck).toHaveBeenCalledWith('http://10.0.0.5:3001/api/health') + expect(fetchAuthenticated).toHaveBeenCalledWith('http://10.0.0.5:3001/api/settings', 'stale-token') + expect(result).toEqual({ + type: 'chooser', + candidates: [], + reason: 'saved-remote-token-invalid', + }) + }) + it('throws user-friendly error when health check returns false', async () => { const fetchHealthCheck = vi.fn().mockResolvedValue(false) const ctx = createDefaultContext({ desktopConfig: { serverMode: 'remote', + port: 3001, remoteUrl: 'http://10.0.0.5:3001', + knownServers: [], + alwaysAskOnLaunch: false, globalHotkey: 'CommandOrControl+`', startOnLogin: false, minimizeToTray: true, @@ -266,16 +397,24 @@ describe('runStartup', () => { fetchHealthCheck, }) - await expect(runStartup(ctx)).rejects.toThrow('Cannot connect to remote server at http://10.0.0.5:3001') + const result = await runStartup(ctx) + expect(result).toEqual({ + type: 'chooser', + candidates: [], + reason: 'saved-remote-unreachable', + }) }) - it('catches fetch TypeError and throws user-friendly error for unreachable hosts', async () => { + it('catches fetch TypeError and opens chooser for unreachable hosts', async () => { // Simulates what happens when fetch() throws on unreachable host const fetchHealthCheck = vi.fn().mockRejectedValue(new TypeError('fetch failed')) const ctx = createDefaultContext({ desktopConfig: { serverMode: 'remote', + port: 3001, remoteUrl: 'http://192.168.99.99:3001', + knownServers: [], + alwaysAskOnLaunch: false, globalHotkey: 'CommandOrControl+`', startOnLogin: false, minimizeToTray: true, @@ -284,16 +423,24 @@ describe('runStartup', () => { fetchHealthCheck, }) - await expect(runStartup(ctx)).rejects.toThrow('Cannot connect to remote server at http://192.168.99.99:3001') + const result = await runStartup(ctx) + expect(result).toEqual({ + type: 'chooser', + candidates: [], + reason: 'saved-remote-unreachable', + }) }) - it('catches network errors and throws user-friendly error', async () => { + it('catches network errors and opens chooser', async () => { // Simulates ECONNREFUSED or similar network error const fetchHealthCheck = vi.fn().mockRejectedValue(new Error('ECONNREFUSED')) const ctx = createDefaultContext({ desktopConfig: { serverMode: 'remote', + port: 3001, remoteUrl: 'http://10.0.0.5:3001', + knownServers: [], + alwaysAskOnLaunch: false, globalHotkey: 'CommandOrControl+`', startOnLogin: false, minimizeToTray: true, @@ -302,7 +449,86 @@ describe('runStartup', () => { fetchHealthCheck, }) - await expect(runStartup(ctx)).rejects.toThrow('Cannot connect to remote server at http://10.0.0.5:3001') + const result = await runStartup(ctx) + expect(result).toEqual({ + type: 'chooser', + candidates: [], + reason: 'saved-remote-unreachable', + }) + }) + }) + + describe('launch discovery integration', () => { + it('returns chooser when alwaysAskOnLaunch is enabled', async () => { + const ctx = createDefaultContext({ + desktopConfig: { + serverMode: 'app-bound', + port: 3001, + knownServers: [], + alwaysAskOnLaunch: true, + globalHotkey: 'CommandOrControl+`', + startOnLogin: false, + minimizeToTray: true, + setupCompleted: true, + }, + discoverLaunchCandidates: vi.fn().mockResolvedValue([ + { + id: 'local-a', + url: 'http://localhost:3001', + origin: 'port-scan', + ownership: 'detected-local', + label: 'localhost:3001', + token: 'local-token', + requiresAuth: true, + }, + ]), + }) + + const result = await runStartup(ctx) + + expect(result).toEqual({ + type: 'chooser', + candidates: [ + { + id: 'local-a', + url: 'http://localhost:3001', + origin: 'port-scan', + ownership: 'detected-local', + label: 'localhost:3001', + token: 'local-token', + requiresAuth: true, + }, + ], + reason: 'always-ask', + }) + expect(ctx.serverSpawner.start).not.toHaveBeenCalled() + expect(ctx.createBrowserWindow).not.toHaveBeenCalled() + }) + + it('auto-connects to one discovered local server without spawning a new server', async () => { + const ctx = createDefaultContext({ + discoverLaunchCandidates: vi.fn().mockResolvedValue([ + { + id: 'local-a', + url: 'http://localhost:3001', + origin: 'port-scan', + ownership: 'detected-local', + label: 'localhost:3001', + token: 'local-token', + requiresAuth: true, + }, + ]), + }) + + const result = await runStartup(ctx) + + expect(ctx.serverSpawner.start).not.toHaveBeenCalled() + expect(result.type).toBe('main') + if (result.type === 'main') { + expect(result.serverUrl).toBe('http://localhost:3001') + } + const window = (ctx.createBrowserWindow as ReturnType).mock.results[0].value + expect(window.loadURL).toHaveBeenCalledWith('http://localhost:3001?token=local-token') }) }) @@ -552,6 +778,7 @@ describe('runStartup', () => { it('appends ?token= to URL for remote mode using remoteToken', async () => { const mockWindow = createMockWindow() const fetchHealthCheck = vi.fn().mockResolvedValue(true) + const fetchAuthenticated = vi.fn().mockResolvedValue(true) const ctx = createDefaultContext({ desktopConfig: { serverMode: 'remote', @@ -564,6 +791,7 @@ describe('runStartup', () => { }, createBrowserWindow: vi.fn().mockReturnValue(mockWindow), fetchHealthCheck, + fetchAuthenticated, }) await runStartup(ctx) expect(mockWindow.loadURL).toHaveBeenCalledWith('http://10.0.0.5:3001?token=remote-secret-123') @@ -595,12 +823,13 @@ describe('runStartup', () => { readEnvToken, }) await runStartup(ctx) - expect(readEnvToken).toHaveBeenCalledWith('/home/user/.freshell/.env') + expect(readEnvToken).toHaveBeenCalledWith(path.join('/home/user/.freshell', '.env')) }) it('does not call readEnvToken for remote mode', async () => { const readEnvToken = vi.fn().mockResolvedValue('should-not-be-used') const fetchHealthCheck = vi.fn().mockResolvedValue(true) + const fetchAuthenticated = vi.fn().mockResolvedValue(true) const ctx = createDefaultContext({ desktopConfig: { serverMode: 'remote', @@ -613,12 +842,13 @@ describe('runStartup', () => { }, readEnvToken, fetchHealthCheck, + fetchAuthenticated, }) await runStartup(ctx) expect(readEnvToken).not.toHaveBeenCalled() }) - it('loads URL without token for remote mode when remoteToken is absent', async () => { + it('opens chooser for remote mode when remoteToken is absent', async () => { const mockWindow = createMockWindow() const fetchHealthCheck = vi.fn().mockResolvedValue(true) const ctx = createDefaultContext({ @@ -633,8 +863,13 @@ describe('runStartup', () => { createBrowserWindow: vi.fn().mockReturnValue(mockWindow), fetchHealthCheck, }) - await runStartup(ctx) - expect(mockWindow.loadURL).toHaveBeenCalledWith('http://10.0.0.5:3001') + const result = await runStartup(ctx) + expect(result).toEqual({ + type: 'chooser', + candidates: [], + reason: 'missing-token', + }) + expect(mockWindow.loadURL).not.toHaveBeenCalled() }) }) diff --git a/test/unit/electron/token-resolver.test.ts b/test/unit/electron/token-resolver.test.ts new file mode 100644 index 000000000..35ce5fd90 --- /dev/null +++ b/test/unit/electron/token-resolver.test.ts @@ -0,0 +1,112 @@ +import path from 'path' +import { describe, expect, it, vi } from 'vitest' +import { + extractTokenFromConfigJson, + extractTokenFromEnv, + resolveCandidateToken, +} from '../../../electron/token-resolver.js' +import type { DesktopConfig, LaunchServerCandidate } from '../../../electron/types.js' + +function localCandidate(url = 'http://localhost:3001'): LaunchServerCandidate { + return { + id: url, + url, + origin: 'port-scan', + ownership: 'detected-local', + label: url, + requiresAuth: true, + } +} + +function config(overrides: Partial = {}): DesktopConfig { + return { + serverMode: 'app-bound', + port: 3001, + knownServers: [], + alwaysAskOnLaunch: false, + globalHotkey: 'CommandOrControl+`', + startOnLogin: false, + minimizeToTray: true, + setupCompleted: true, + ...overrides, + } +} + +describe('token resolver', () => { + it('extracts AUTH_TOKEN from env content', () => { + expect(extractTokenFromEnv('AUTH_TOKEN=abc123\nPORT=3001\n')).toBe('abc123') + expect(extractTokenFromEnv('AUTH_TOKEN="quoted-token"\n')).toBe('quoted-token') + expect(extractTokenFromEnv('PORT=3001\n')).toBeUndefined() + }) + + it('extracts token from config json using supported keys', () => { + expect(extractTokenFromConfigJson(JSON.stringify({ authToken: 'config-a' }))).toBe('config-a') + expect(extractTokenFromConfigJson(JSON.stringify({ token: 'config-b' }))).toBe('config-b') + expect(extractTokenFromConfigJson('{bad json')).toBeUndefined() + }) + + it('uses matching saved remote token first', async () => { + const readTextFile = vi.fn() + const token = await resolveCandidateToken({ + candidate: localCandidate('http://localhost:3001'), + desktopConfig: config({ + remoteUrl: 'http://localhost:3001', + remoteToken: 'saved-token', + }), + configDir: '/home/user/.freshell', + readTextFile, + }) + + expect(token).toBe('saved-token') + expect(readTextFile).not.toHaveBeenCalled() + }) + + it('reads loopback token from .env when no saved token exists', async () => { + const readTextFile = vi.fn(async (filePath: string) => { + expect(filePath).toBe(path.join('/home/user/.freshell', '.env')) + return 'AUTH_TOKEN=env-token\n' + }) + + const token = await resolveCandidateToken({ + candidate: localCandidate('http://127.0.0.1:3001'), + desktopConfig: config(), + configDir: '/home/user/.freshell', + readTextFile, + }) + + expect(token).toBe('env-token') + }) + + it('falls back to config.json for loopback token', async () => { + const readTextFile = vi.fn(async (filePath: string) => { + if (filePath.endsWith('.env')) throw new Error('missing env') + return JSON.stringify({ authToken: 'json-token' }) + }) + + const token = await resolveCandidateToken({ + candidate: localCandidate(), + desktopConfig: config(), + configDir: '/home/user/.freshell', + readTextFile, + }) + + expect(token).toBe('json-token') + }) + + it('does not read local token files for remote candidates', async () => { + const readTextFile = vi.fn(async () => 'AUTH_TOKEN=local-token\n') + + const token = await resolveCandidateToken({ + candidate: { + ...localCandidate('http://10.0.0.5:3001'), + ownership: 'remote', + }, + desktopConfig: config(), + configDir: '/home/user/.freshell', + readTextFile, + }) + + expect(token).toBeUndefined() + expect(readTextFile).not.toHaveBeenCalled() + }) +}) diff --git a/test/unit/server/api.test.ts b/test/unit/server/api.test.ts new file mode 100644 index 000000000..721f28e44 --- /dev/null +++ b/test/unit/server/api.test.ts @@ -0,0 +1,39 @@ +// @vitest-environment node +import express from 'express' +import request from 'supertest' +import { describe, expect, it } from 'vitest' +import { createHealthRouter } from '../../../server/health-router.js' + +describe('GET /api/health', () => { + it('returns unauthenticated launch discovery metadata', async () => { + const app = express() + + app.use('/api/health', createHealthRouter({ + appVersion: '1.2.3', + instanceId: 'instance-test-id', + isReady: () => true, + startedAt: new Date('2026-05-24T12:00:00.000Z'), + })) + + app.use('/api', (_req, res) => { + res.status(401).json({ error: 'Unauthorized' }) + }) + + const res = await request(app).get('/api/health') + + expect(res.status).toBe(200) + expect(res.body).toEqual({ + app: 'freshell', + ok: true, + requiresAuth: true, + version: '1.2.3', + ready: true, + instanceId: 'instance-test-id', + startedAt: '2026-05-24T12:00:00.000Z', + }) + expect(typeof res.body.version).toBe('string') + expect(typeof res.body.ready).toBe('boolean') + expect(typeof res.body.instanceId).toBe('string') + expect(typeof res.body.startedAt).toBe('string') + }) +}) diff --git a/tsconfig.electron.json b/tsconfig.electron.json index 24b4732d2..84f6d71d8 100644 --- a/tsconfig.electron.json +++ b/tsconfig.electron.json @@ -17,7 +17,10 @@ "electron/**/*" ], "exclude": [ + "electron/preload.ts", "electron/setup-wizard/**/*.tsx", - "electron/setup-wizard/index.html" + "electron/setup-wizard/index.html", + "electron/launch-chooser/**/*.tsx", + "electron/launch-chooser/index.html" ] } diff --git a/vite.launch-chooser.config.ts b/vite.launch-chooser.config.ts new file mode 100644 index 000000000..c8411a869 --- /dev/null +++ b/vite.launch-chooser.config.ts @@ -0,0 +1,35 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import path from 'path' +import { fileURLToPath } from 'url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +export default defineConfig({ + plugins: [react()], + root: path.resolve(__dirname, 'electron/launch-chooser'), + base: './', + build: { + outDir: path.resolve(__dirname, 'dist/launch-chooser'), + emptyOutDir: true, + sourcemap: true, + }, + server: { + port: 5175, + }, + resolve: { + alias: { + '@electron': path.resolve(__dirname, './electron'), + }, + }, + css: { + postcss: { + plugins: [ + (await import('tailwindcss')).default({ + config: path.resolve(__dirname, 'tailwind.config.wizard.js'), + }), + (await import('autoprefixer')).default, + ], + }, + }, +})