Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 7 additions & 11 deletions assets/electron/installer.nsh
Original file line number Diff line number Diff line change
Expand Up @@ -31,18 +31,14 @@

${if} $0 != ""
${andIf} $1 != ""
; Write raw values to a line-based provisioning file rather than JSON: NSIS
; has no string-escaping, so a URL or token containing a quote or backslash
; would corrupt hand-written JSON. The app converts this into a properly
; serialized desktop.json on first launch (see electron/desktop-provisioning.ts).
CreateDirectory "$PROFILE\.freshell"
FileOpen $2 "$PROFILE\.freshell\desktop.json" w
FileWrite $2 "{$\r$\n"
FileWrite $2 " $\"serverMode$\": $\"remote$\",$\r$\n"
FileWrite $2 " $\"port$\": 3001,$\r$\n"
FileWrite $2 " $\"remoteUrl$\": $\"$0$\",$\r$\n"
FileWrite $2 " $\"remoteToken$\": $\"$1$\",$\r$\n"
FileWrite $2 " $\"globalHotkey$\": $\"CommandOrControl+`$\",$\r$\n"
FileWrite $2 " $\"startOnLogin$\": false,$\r$\n"
FileWrite $2 " $\"minimizeToTray$\": true,$\r$\n"
FileWrite $2 " $\"setupCompleted$\": true$\r$\n"
FileWrite $2 "}$\r$\n"
FileOpen $2 "$PROFILE\.freshell\desktop.provision" w
FileWrite $2 "FRESHELL_REMOTE_URL=$0$\r$\n"
FileWrite $2 "FRESHELL_TOKEN=$1$\r$\n"
FileClose $2
${endIf}
!macroend
7 changes: 4 additions & 3 deletions docs/development/windows-electron-build.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@ on the wrong platform.
- Node.js (matching `engines.node`, currently `>=22.5.0`) and npm.
- Visual Studio Build Tools with the **Desktop development with C++** workload,
and Python 3 — required for `node-gyp` to compile `node-pty`.
- `curl`, `tar`, and `unzip` on `PATH` — `scripts/prepare-bundled-node.ts` shells
out to them to download the standalone Node binary and headers. Windows 10+
ships `curl`/`tar`; `unzip` is provided by Git for Windows (`usr/bin`).
- No extra download tools are needed: `scripts/prepare-bundled-node.ts` fetches
the standalone Node binary and headers over Node's own `http`/`https` and
extracts them with the bundled `tar` and `extract-zip` packages (not external
`curl`/`tar`/`unzip`).

## Option A — from a native Windows shell

Expand Down
81 changes: 81 additions & 0 deletions electron/desktop-provisioning.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import type { DesktopConfig } from './types.js'

/**
* One-time provisioning from a silent installer.
*
* The installer cannot safely emit JSON (it has no string-escaping), so it
* writes raw values to a line-based `desktop.provision` file instead. We parse
* that file here and persist a real config via `patchDesktopConfig`, whose
* `JSON.stringify` serialization escapes quotes/backslashes correctly.
*/

export interface ParsedProvisioning {
remoteUrl?: string
remoteToken?: string
}

/**
* Parse `KEY=value` lines. The value keeps every character after the first `=`
* verbatim (so a token may contain `=`, `"`, `\`, or meaningful surrounding
* whitespace); only the line ending is stripped, by the split. The key is
* trimmed for tolerance. Unknown or malformed lines are ignored.
*/
export function parseProvisioning(content: string): ParsedProvisioning {
const result: ParsedProvisioning = {}
for (const line of content.split(/\r?\n/)) {
const idx = line.indexOf('=')
if (idx === -1) continue
const key = line.slice(0, idx).trim()
const value = line.slice(idx + 1)
if (key === 'FRESHELL_REMOTE_URL') result.remoteUrl = value
else if (key === 'FRESHELL_TOKEN') result.remoteToken = value
}
return result
}

export interface ProvisioningDeps {
/** Returns the file contents, or undefined if the provision file is absent. */
readFile: (path: string) => string | undefined
deleteFile: (path: string) => void
patchDesktopConfig: (patch: Partial<DesktopConfig>) => Promise<DesktopConfig | void>
}

/**
* Apply a provision file if present, then always remove it (so it only takes
* effect once). A malformed file must never block startup, so persistence
* errors are swallowed. Returns true when a file was found and consumed.
*/
export async function applyProvisioningFile(
provisionPath: string,
deps: ProvisioningDeps,
): Promise<boolean> {
let content: string | undefined
try {
content = deps.readFile(provisionPath)
} catch {
// The file exists but is unreadable (locked, a directory, bad perms).
// It must not brick startup, and it must not wedge every launch, so make a
// best-effort attempt to clear it and bail.
deps.deleteFile(provisionPath)
return true
}

if (content === undefined) return false

try {
const { remoteUrl, remoteToken } = parseProvisioning(content)
if (remoteUrl && remoteToken) {
await deps.patchDesktopConfig({
serverMode: 'remote',
remoteUrl,
remoteToken,
setupCompleted: true,
Comment on lines +68 to +72
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear always-ask when applying silent provisioning

When a silent installer provisions an already-configured desktop that previously enabled alwaysAskOnLaunch, this patch merges only the remote fields into the existing config, so the preserved alwaysAskOnLaunch: true makes chooseLaunchAction() show the chooser instead of connecting to the newly provisioned remote server on first launch. The old installer overwrote desktop.json without this field, causing the schema default (false) to apply, so this is a regression for managed reinstall/update flows; include alwaysAskOnLaunch: false (or otherwise force the provisioned launch) with the provisioning patch.

Useful? React with 👍 / 👎.

})
}
} catch {
// A malformed provision file or persist failure must not brick startup.
} finally {
deps.deleteFile(provisionPath)
}
return true
}
57 changes: 49 additions & 8 deletions electron/entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,26 @@ import { runStartup, type StartupContext, type BrowserWindowLike } from './start
import { initMainProcess } from './main.js'
import { createWizardWindow } from './setup-wizard/wizard-window.js'
import { createChooseLaunchOptionHandler } from './launch-choice-handler.js'
import type { LaunchServerCandidate } from './types.js'
import { buildLaunchOptions } from './launch-options.js'
import { applyProvisioningFile } from './desktop-provisioning.js'
import { createPortAvailabilityCheck } from './port-check.js'
import type { ForcedLaunch, LaunchServerCandidate } from './types.js'

const isPortAvailable = createPortAvailabilityCheck()

const isDev = process.env.ELECTRON_DEV === '1'
const configDir = path.join(os.homedir(), '.freshell')

/** True during the wizard flow; prevents app.quit() on window-all-closed. */
let wizardPhase = true

/**
* An explicit chooser selection to honor on the next main() pass. Set by the
* choose-launch-option handler before it restarts the launch flow, consumed
* once at the top of main().
*/
let pendingForcedLaunch: ForcedLaunch | undefined

async function main(): Promise<void> {
// Wait for Electron to be ready before creating any BrowserWindow or using
// Electron APIs that require the app to be initialized.
Expand All @@ -59,6 +71,26 @@ async function main(): Promise<void> {
})
}

// Apply one-time provisioning from a silent install. The installer writes raw
// values to desktop.provision (it cannot escape JSON); we convert them into a
// properly-serialized desktop.json here, then remove the provision file.
await applyProvisioningFile(path.join(configDir, 'desktop.provision'), {
readFile: (p) => (fs.existsSync(p) ? fs.readFileSync(p, 'utf-8') : undefined),
deleteFile: (p) => {
try {
fs.rmSync(p, { force: true })
} catch {
/* best-effort cleanup */
}
},
patchDesktopConfig,
})

// Consume any pending forced launch (set by the chooser handler before it
// restarted main). It must apply only to this pass.
const forcedLaunch = pendingForcedLaunch
pendingForcedLaunch = undefined

// Read desktop config (or use defaults for first run)
const desktopConfig = (await readDesktopConfig()) ?? getDefaultDesktopConfig()
const port = desktopConfig.port ?? 3001
Expand Down Expand Up @@ -100,6 +132,7 @@ async function main(): Promise<void> {
// Construct the startup context
const ctx: StartupContext = {
desktopConfig,
forcedLaunch,
daemonManager,
serverSpawner,
hotkeyManager,
Expand Down Expand Up @@ -244,6 +277,9 @@ async function main(): Promise<void> {
ipcMain.removeHandler('choose-launch-option')

let pendingLaunchChooser: { candidates: LaunchServerCandidate[]; reason: string } | undefined
// webContents id of the launch chooser window, so choose-launch-option only
// honors requests originating from it (the API is exposed to every window).
let chooserWebContentsId: number | undefined

// Register the complete-setup handler before runStartup so it is available
// when the wizard renderer calls it via the preload API.
Expand All @@ -264,18 +300,21 @@ async function main(): Promise<void> {
})
})

ipcMain.handle('get-launch-options', () => ({
candidates: pendingLaunchChooser?.candidates ?? [],
reason: pendingLaunchChooser?.reason ?? 'Choose how Freshell should connect.',
alwaysAskOnLaunch: desktopConfig.alwaysAskOnLaunch,
port: desktopConfig.port,
}))
ipcMain.handle('get-launch-options', () =>
buildLaunchOptions({ pending: pendingLaunchChooser, desktopConfig }),
)

ipcMain.handle('choose-launch-option', createChooseLaunchOptionHandler({
patchDesktopConfig,
getCurrentPort: () => desktopConfig.port,
validateServerAuth: (url: string, token: string) => ctx.fetchAuthenticated?.(`${url}/api/settings`, token) ?? Promise.resolve(false),
restartMain: async () => {
isAllowedSender: (event) => {
const senderId = (event as { sender?: { id?: number } }).sender?.id
return chooserWebContentsId !== undefined && senderId === chooserWebContentsId
},
isPortAvailable,
restartMain: async (forced: ForcedLaunch) => {
pendingForcedLaunch = forced
wizardPhase = true
for (const win of BrowserWindow.getAllWindows()) {
win.close()
Expand Down Expand Up @@ -329,6 +368,8 @@ async function main(): Promise<void> {
contextIsolation: true,
},
})
// Only this window may drive choose-launch-option (see isAllowedSender).
chooserWebContentsId = chooserWin.webContents.id

if (isDev) {
await chooserWin.loadURL('http://localhost:5175')
Expand Down
105 changes: 92 additions & 13 deletions electron/launch-choice-handler.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,58 @@
import { normalizeServerUrl } from './launch-discovery.js'
import type { DesktopConfig, LaunchChoice, LaunchChoiceResult } from './types.js'
import { validateLaunchPort, validateRemoteLaunchUrl } from './launch-chooser/chooser-logic.js'
import { LaunchChoiceSchema } from './types.js'
import type { DesktopConfig, ForcedLaunch, LaunchChoiceResult } from './types.js'

export interface ChooseLaunchOptionHandlerOptions {
patchDesktopConfig: (patch: Partial<DesktopConfig>) => Promise<DesktopConfig | void>
restartMain: () => Promise<void> | void
/**
* Restart the launch flow, forcing the just-chosen action so it is honored
* this launch regardless of saved config, `alwaysAskOnLaunch`, or
* re-discovered servers.
*/
restartMain: (forced: ForcedLaunch) => Promise<void> | void
getCurrentPort: () => number
validateServerAuth?: (url: string, token: string) => Promise<boolean>
/**
* Defense-in-depth: reject choices from any renderer other than the launch
* chooser window. `choose-launch-option` is exposed via preload to every
* window, so without this an untrusted renderer could force a launch.
*/
isAllowedSender?: (event: unknown) => boolean
/**
* Authoritative (main-process) check that nothing is already listening on a
* "Start local" port before we close the chooser and spawn. The renderer
* guard is only advisory (stale candidate list, races, crafted requests).
*/
isPortAvailable?: (port: number) => Promise<boolean>
}

export function createChooseLaunchOptionHandler(options: ChooseLaunchOptionHandlerOptions) {
return async (_event: unknown, choice: LaunchChoice): Promise<LaunchChoiceResult> => {
return async (event: unknown, rawChoice: unknown): Promise<LaunchChoiceResult> => {
if (options.isAllowedSender && !options.isAllowedSender(event)) {
return { ok: false, error: 'Unexpected launch request.' }
}

// The payload comes from a renderer over IPC, so validate its shape at
// runtime — TypeScript's union does not survive the boundary.
const parsed = LaunchChoiceSchema.safeParse(rawChoice)
if (!parsed.success) {
return { ok: false, error: 'Invalid launch request.' }
}
const choice = parsed.data

if (choice.kind === 'remote' || choice.kind === 'connect') {
if (!choice.url) {
return { ok: false, error: 'Choose a server URL.' }
}

const url = normalizeServerUrl(choice.url)
// Validate the scheme server-side (not just in the renderer) so a crafted
// choice can never make the app load a file:// or other non-web URL.
const urlError = validateRemoteLaunchUrl(url)
if (urlError) {
return { ok: false, error: urlError }
}
const token = choice.token?.trim()
if (choice.requiresAuth !== false) {
if (!token) {
Expand All @@ -35,23 +72,65 @@ export function createChooseLaunchOptionHandler(options: ChooseLaunchOptionHandl
}
}

await options.patchDesktopConfig({
serverMode: 'remote',
remoteUrl: url,
remoteToken: token,
alwaysAskOnLaunch: choice.alwaysAskOnLaunch,
setupCompleted: true,
})
} else {
// "Remember this choice" gates whether the server selection is saved as
// the new default. The always-ask preference is standalone and always
// persisted so the user can leave (or stay in) the chooser next launch.
if (choice.remember) {
await options.patchDesktopConfig({
serverMode: 'remote',
remoteUrl: url,
remoteToken: token,
alwaysAskOnLaunch: choice.alwaysAskOnLaunch,
setupCompleted: true,
})
} else {
await options.patchDesktopConfig({ alwaysAskOnLaunch: choice.alwaysAskOnLaunch })
}

await options.restartMain({ kind: 'connect', url, token })
return { ok: true }
}

// Defensive default: the schema only allows connect/remote/start-local, and
// connect/remote are handled above, so anything else is rejected outright
// rather than silently treated as start-local.
if (choice.kind !== 'start-local') {
return { ok: false, error: 'Invalid launch request.' }
}

const port = choice.port ?? options.getCurrentPort()
const portError = validateLaunchPort(port)
if (portError) {
return { ok: false, error: portError }
}

// Authoritatively confirm the port is free before closing the chooser and
// spawning. If we cannot determine availability, refuse rather than risk
// spawning onto an occupied port (which could load the wrong process).
if (options.isPortAvailable) {
let available = false
try {
available = await options.isPortAvailable(port)
} catch {
available = false
}
if (!available) {
return { ok: false, error: `Port ${port} is already in use. Choose a different port, or connect to that server.` }
}
}

if (choice.remember) {
await options.patchDesktopConfig({
serverMode: 'app-bound',
port: choice.port ?? options.getCurrentPort(),
port,
alwaysAskOnLaunch: choice.alwaysAskOnLaunch,
setupCompleted: true,
})
} else {
await options.patchDesktopConfig({ alwaysAskOnLaunch: choice.alwaysAskOnLaunch })
}

await options.restartMain()
await options.restartMain({ kind: 'start-local', port })
return { ok: true }
}
}
12 changes: 12 additions & 0 deletions electron/launch-chooser/chooser-logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,18 @@ export function validateRemoteLaunchUrl(value: string): string {
}
}

/**
* Validate a local-server port. Mirrors the chooser's number-input bounds
* (1024–65535) and rejects non-integers (e.g. NaN from an empty field).
* Returns an error message, or null when the port is acceptable.
*/
export function validateLaunchPort(port: number): string | null {
if (!Number.isInteger(port) || port < 1024 || port > 65535) {
return 'Enter a port between 1024 and 65535'
}
return null
}

export function buildConnectChoice(input: {
url: string
token?: string
Expand Down
Loading
Loading