diff --git a/src/main/index.ts b/src/main/index.ts index 57d50287f49..cb4c803105f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -35,7 +35,8 @@ import { ProfileStateRecoveryRequiredError } from './persistence/profile-state/p import { getProfileUserDataPath } from './orca-profiles/profile-storage-paths' import { leaseAppImageGenerationForCurrentProcess } from './cli/appimage-generation-lease' import { wakeRememberedWslHosts } from './wsl/wsl-host-early-wake' -import { wslHostsGateOpen } from './wsl/wsl-host-gate' +import { wslHostsServing } from './wsl/wsl-host-gate' +import { configureWslHostsGateFromDisk } from './wsl/wsl-host-gate-startup' function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}): BrowserWindow { return openMainWindowController(options) @@ -112,8 +113,10 @@ const preflightReady = if (preflightReady) { enableLinearRegexpFallback() void leaseAppImageGenerationForCurrentProcess() + // Before any gate check: saved WSL work keeps a closed gate serving it. + configureWslHostsGateFromDisk(app.getPath('userData')) // Why this early: a cold distro takes seconds to boot; starting now overlaps it with app startup. - if (process.platform === 'win32' && wslHostsGateOpen()) { + if (process.platform === 'win32' && wslHostsServing()) { wakeRememberedWslHosts(app.getPath('userData')) } app.on('open-url', (event, url) => { diff --git a/src/main/ipc/wsl-host-handlers-user-actions.test.ts b/src/main/ipc/wsl-host-handlers-user-actions.test.ts index 012dbc68874..dff18aeb3b5 100644 --- a/src/main/ipc/wsl-host-handlers-user-actions.test.ts +++ b/src/main/ipc/wsl-host-handlers-user-actions.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' type Handler = (event: unknown, request?: unknown) => unknown @@ -42,6 +42,7 @@ const service = vi.hoisted(() => ({ }) ), remove: vi.fn(async () => ({ stopped: true })), + hasServerFiles: vi.fn(() => true), recordForEnvironment: vi.fn(() => ({ distro: 'Ubuntu', environmentId: 'wsl:Ubuntu' })) })) vi.mock('../wsl/wsl-host-service-instance', () => ({ @@ -64,7 +65,13 @@ function register(): void { describe('WSL host IPC: the user Add and Remove (D3a)', () => { // Removes are reported by the host service itself (see wsl-host-service.test.ts), so both the // sidebar and Settings paths count; here the service is mocked and only the Add is the IPC's. + afterEach(() => { + vi.unstubAllEnvs() + service.add.mockClear() + }) + it('reports a successful Add, never a failed one', async () => { + vi.stubEnv('ORCA_INTERNAL_WSL_HOSTS', '1') register() const heard: WslHostUserAction[] = [] const stop = onWslHostUserAction((action) => heard.push(action)) @@ -75,4 +82,17 @@ describe('WSL host IPC: the user Add and Remove (D3a)', () => { stop() expect(heard).toEqual([{ kind: 'added', distro: 'Ubuntu' }]) }) + + it('refuses an Add in serve-only, keeping saved hosts usable', async () => { + vi.stubEnv('ORCA_INTERNAL_WSL_HOSTS', undefined) + register() + + expect(await handlers.get('wslHosts:add')?.({}, { distro: 'Ubuntu' })).toMatchObject({ + ok: false, + kind: 'disabled' + }) + expect(service.add).not.toHaveBeenCalled() + await handlers.get('wslHosts:remove')?.({}, { environmentId: 'wsl:Ubuntu' }) + expect(service.remove).toHaveBeenCalledWith('wsl:Ubuntu') + }) }) diff --git a/src/main/ipc/wsl-host-handlers.ts b/src/main/ipc/wsl-host-handlers.ts index 331621af58d..28496d6306b 100644 --- a/src/main/ipc/wsl-host-handlers.ts +++ b/src/main/ipc/wsl-host-handlers.ts @@ -18,7 +18,11 @@ import { WslHostService } from '../wsl/wsl-host-service' import { WslHostVisibleWorkByWindow } from './wsl-host-visible-work-windows' import type { WslHostAutomationWake } from '../wsl/wsl-host-automation-wake' import { createWslHostAutomationWake } from './wsl-host-automation-wake-wiring' -import { wslHostsGateOpen } from '../wsl/wsl-host-gate' +import { + WSL_HOSTS_NEW_WORK_PAUSED_MESSAGE, + wslHostsGateOpen, + wslHostsServing +} from '../wsl/wsl-host-gate' import { getWslHostService, setWslHostService } from '../wsl/wsl-host-service-instance' import { retireRemovedRuntimeEnvironment } from './runtime-environments/runtime-environment-removal-cleanup' import { getRuntimeEnvironmentStatus } from './runtime-environments/runtime-environment-status-probe' @@ -108,8 +112,8 @@ type WslHostHandlerArgs = { } function createService(args: WslHostHandlerArgs): WslHostService | null { - // A closed gate must prevent service adoption, WSL spawns and reconnects. - if (!wslHostsGateOpen()) { + // A closed gate must prevent service adoption, WSL spawns and reconnects; serve-only keeps saved hosts. + if (!wslHostsServing()) { return null } const fake = readFakeWslHostsConfig() @@ -224,6 +228,10 @@ export function registerWslHostHandlers(args: WslHostHandlerArgs): void { _event, request: { distro: string; startIfStopped?: boolean } ): Promise => { + // Serve-only keeps saved hosts working but admits none. + if (!wslHostsGateOpen()) { + return { ok: false, kind: 'disabled', message: WSL_HOSTS_NEW_WORK_PAUSED_MESSAGE } + } const result = await wsl.add(request) if (result.ok) { noteWslHostUserAction({ kind: 'added', distro: request.distro }) diff --git a/src/main/ipc/wsl-move-handlers.test.ts b/src/main/ipc/wsl-move-handlers.test.ts index 2ba5d90fb64..7bf7ecd9efd 100644 --- a/src/main/ipc/wsl-move-handlers.test.ts +++ b/src/main/ipc/wsl-move-handlers.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { WSL_HOSTS_INTERNAL_GATE_ENV } from '../../shared/wsl-host-internal-gate' +import { configureWslHostsGate, resetWslHostsGateForTests } from '../wsl/wsl-host-gate' import { wslMoveCutoverFixture as cutover } from '../persistence/__fixtures__/wsl-mover/wsl-move-cutover-fixture' import { seedWslMoveCutover } from '../persistence/__fixtures__/wsl-mover/wsl-move-journal-seeding' import { listWslMoveCutovers } from '../persistence/moving-wsl-projects/wsl-move-cutover-journal' @@ -95,6 +96,28 @@ describe('WSL move IPC', () => { expect(listWslMoveCutovers(userDataPath)[0]?.rewrittenStores).toEqual([]) }) + it('keeps listing unfinished moves and pending rewrites in serve-only, and nothing when closed', () => { + rmSync(userDataPath, { recursive: true, force: true }) + userDataPath = mkdtempSync(join(tmpdir(), 'wsl-move-ipc-')) + seedWslMoveCutover( + userDataPath, + cutover('move-2', 'destination-committed', { automationsToReenable: ['automation-1'] }) + ) + const open = invoke('wslMove:listUnfinished') + expect(open).toEqual([expect.objectContaining({ migrationId: 'move-2' })]) + const pending = invoke('wslMove:listPendingRendererRewrites') + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '') + expect(invoke('wslMove:listUnfinished')).toEqual([]) + + configureWslHostsGate({ hasSavedWslHost: () => false, hasWslMoveCutover: () => true }) + try { + expect(invoke('wslMove:listUnfinished')).toEqual(open) + expect(invoke('wslMove:listPendingRendererRewrites')).toEqual(pending) + } finally { + resetWslHostsGateForTests() + } + }) + it('writes the window storage to disk before the ledger entry', () => { flushStorageData.mockImplementation(() => { expect(listWslMoveCutovers(userDataPath)[0]?.rewrittenStores).toEqual([]) diff --git a/src/main/ipc/wsl-move-handlers.ts b/src/main/ipc/wsl-move-handlers.ts index 9a3ab754a50..54452117ca8 100644 --- a/src/main/ipc/wsl-move-handlers.ts +++ b/src/main/ipc/wsl-move-handlers.ts @@ -28,7 +28,7 @@ import { import type { WslMoveDriverDeps } from '../persistence/moving-wsl-projects/wsl-move-driver-ports' import { listUnfinishedWslMoves } from '../persistence/moving-wsl-projects/wsl-move-host-automations-rewrite' import { runTrackedWslMove } from '../telemetry/wsl-move-telemetry' -import { wslHostsGateOpen } from '../wsl/wsl-host-gate' +import { wslHostsGateOpen, wslHostsServing } from '../wsl/wsl-host-gate' import type { WslMoveUndoPorts } from './wsl-move-undo-wiring' import { releaseWslMovedProjectHere } from './wsl-move-release-here' @@ -126,7 +126,7 @@ function registerWslMoveDriverHandlers( } ) ipcMain.handle('wslMove:listUnfinished', (): WslMoveUnfinished[] => - wslHostsGateOpen() ? listUnfinishedWslMoves(getUserDataPath()) : [] + wslHostsServing() ? listUnfinishedWslMoves(getUserDataPath()) : [] ) ipcMain.handle( 'wslMove:cancel', @@ -147,8 +147,8 @@ function registerWslMoveUseHereHandler( ipcMain.handle( 'wslMove:useProjectHereAgain', async (event: { sender?: WebContents }, request: unknown): Promise => { - // Gate first: with it closed, nothing is read. - if (!wslHostsGateOpen()) { + // Gate first: with it closed, nothing is read. Serve-only keeps the way back. + if (!wslHostsServing()) { return { released: false, reason: 'gated-off' } } const sender = event.sender diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-desktop-rewrite.ts b/src/main/persistence/moving-wsl-projects/wsl-move-desktop-rewrite.ts index aaa47fbb0bf..dd25e602e83 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-desktop-rewrite.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-desktop-rewrite.ts @@ -9,7 +9,7 @@ import type { WslMoveRecordStoreRewrittenRequest, WslMoveRecordStoreRewrittenResult } from '../../../shared/wsl-move/wsl-move-renderer-rewrite-types' -import { wslHostsGateOpen } from '../../wsl/wsl-host-gate' +import { wslHostsServing } from '../../wsl/wsl-host-gate' import { listWslMoveCutovers, writeWslMoveCutover } from './wsl-move-cutover-journal' import { isWslMoveDesktopRewriteRegistryComplete, @@ -189,7 +189,7 @@ export function runWslMoveDesktopRewritePass( export function runGatedWslMoveDesktopRewritePass( options: WslMoveDesktopRewritePassOptions ): Promise { - return wslHostsGateOpen() ? runWslMoveDesktopRewritePass(options) : Promise.resolve() + return wslHostsServing() ? runWslMoveDesktopRewritePass(options) : Promise.resolve() } const COMMITTED_PHASE_INDEX = WSL_MOVE_CUTOVER_PHASES.indexOf('destination-committed') @@ -202,7 +202,7 @@ const COMMITTED_PHASE_INDEX = WSL_MOVE_CUTOVER_PHASES.indexOf('destination-commi export function listPendingWslMoveRendererRewrites( userDataPath: string ): WslMovePendingRendererRewrite[] { - if (!wslHostsGateOpen()) { + if (!wslHostsServing()) { return [] } try { @@ -244,7 +244,7 @@ export function recordWslMoveRendererStoreRewritten( * the write, so the renderer re-applies its stores at every startup regardless. */ flushRendererStorage: () => void = () => {} ): WslMoveRecordStoreRewrittenResult { - if (!wslHostsGateOpen()) { + if (!wslHostsServing()) { return { recorded: false, reason: 'gated-off' } } const store = listWslMoveDesktopRewriteStores().find((entry) => entry.id === request.storeId) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-reset.ts b/src/main/persistence/moving-wsl-projects/wsl-move-reset.ts index 71b5826c9b8..ce1ff6ef2fa 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-reset.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-reset.ts @@ -22,7 +22,7 @@ import { type OrcadMigrationDestinationCatalog } from '../../ssh/orcad-migration-cutover-coordinator' import { orcadMigrationRequestFits } from '../../ssh/orcad-migration-manifest-export' -import { wslHostsGateOpen } from '../../wsl/wsl-host-gate' +import { wslHostsServing } from '../../wsl/wsl-host-gate' import { listWslMoveCutovers, removeUncommittedWslMoveCutover, @@ -208,7 +208,7 @@ export async function recoverInterruptedWslMoves( isDistroRegistered?: (distro: string, distroGuid?: string) => Promise ): Promise { const result: WslMoveStartupRecovery = { reset: [], waitingForHost: [] } - if (!wslHostsGateOpen()) { + if (!wslHostsServing()) { return result } let inFlight: WslMoveCutover[] diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-retention.ts b/src/main/persistence/moving-wsl-projects/wsl-move-retention.ts index ac971bfe4c8..b905584fa74 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-retention.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-retention.ts @@ -7,7 +7,7 @@ * flag rows whose identity it changed (a later move offers them; none runs on its own), and drop * marks no committed move explains, such as after "Use on this computer again". */ -import { wslHostsGateOpen } from '../../wsl/wsl-host-gate' +import { wslHostsServing } from '../../wsl/wsl-host-gate' import type { Store } from '../loading-store/store' import { listWslMoveCutoverChain, @@ -104,7 +104,7 @@ let passQueue: Promise = Promise.resolve() export function runWslMoveRetentionPass( options: WslMoveRetentionOptions ): Promise { - if (!wslHostsGateOpen()) { + if (!wslHostsServing()) { return Promise.resolve(null) } const next = passQueue.then(() => diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-rewrite-retry.ts b/src/main/persistence/moving-wsl-projects/wsl-move-rewrite-retry.ts index a07bd39f208..2c010873a2f 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-rewrite-retry.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-rewrite-retry.ts @@ -3,7 +3,7 @@ * the host could not turn back on yet. A pass runs at startup and after each move; this covers the * host coming back later in the same run. Backs off, and stops once nothing is left. */ -import { wslHostsGateOpen } from '../../wsl/wsl-host-gate' +import { wslHostsServing } from '../../wsl/wsl-host-gate' import { listWslMoveCutovers } from './wsl-move-cutover-journal' import { runWslMoveDesktopRewritePass } from './wsl-move-desktop-rewrite' import { pendingWslMoveDesktopRewriteStores } from './wsl-move-desktop-rewrite-stores' @@ -45,7 +45,7 @@ export function scheduleWslMoveRewriteRetry( delaysMs: readonly number[] = WSL_MOVE_REWRITE_RETRY_DELAYS_MS, attempt = 0 ): void { - if (timer || !wslHostsGateOpen() || !hasUnfinishedMove(userDataPath)) { + if (timer || !wslHostsServing() || !hasUnfinishedMove(userDataPath)) { return } const delay = delaysMs[Math.min(attempt, delaysMs.length - 1)] ?? 0 diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-startup-rewrite.ts b/src/main/persistence/moving-wsl-projects/wsl-move-startup-rewrite.ts index 1b8965d9170..7e109ba6069 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-startup-rewrite.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-startup-rewrite.ts @@ -3,7 +3,7 @@ * see a committed move's rewrite, or the renderer hydrates the old routing and later writes it * back over the rewrite. So startup waits for the pass, but never long. */ -import { wslHostsGateOpen } from '../../wsl/wsl-host-gate' +import { wslHostsServing } from '../../wsl/wsl-host-gate' import { runWslMoveDesktopRewritePass, type WslMoveDesktopRewritePassOptions @@ -26,7 +26,7 @@ export function runWslMoveStartupRewrite( options: WslMoveDesktopRewritePassOptions, steps: WslMoveStartupRewriteSteps = {} ): Promise { - if (!wslHostsGateOpen()) { + if (!wslHostsServing()) { return Promise.resolve() } let timer: ReturnType | undefined diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-use-locally.ts b/src/main/persistence/moving-wsl-projects/wsl-move-use-locally.ts index aaaee818f6e..3f94be3251c 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-use-locally.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-use-locally.ts @@ -9,7 +9,7 @@ import { type WslMoveCutover } from '../../../shared/wsl-move/wsl-move-cutover' import type { WslMoveUseLocallyResult } from '../../../shared/wsl-move/wsl-moved-project' -import { wslHostsGateOpen } from '../../wsl/wsl-host-gate' +import { wslHostsServing } from '../../wsl/wsl-host-gate' import { isWslMoveRunning } from './wsl-move-running-moves' import { listWslMoveCutoverChain, @@ -43,7 +43,7 @@ export function findWslMovedProjectMove( userDataPath: string, ref: WslMoveProjectRef ): { move: WslMoveCutover } | Extract { - if (!wslHostsGateOpen()) { + if (!wslHostsServing()) { return { released: false, reason: 'gated-off' } } let move: WslMoveCutover | undefined diff --git a/src/main/telemetry/wsl-usage-reporter.ts b/src/main/telemetry/wsl-usage-reporter.ts index 9c8bad1422a..9f9a9fbf97b 100644 --- a/src/main/telemetry/wsl-usage-reporter.ts +++ b/src/main/telemetry/wsl-usage-reporter.ts @@ -15,7 +15,7 @@ import { peekConfirmedRunningWslDistros } from '../wsl-running-distro-cache' import { loadWindowsNativeRegistry, WINDOWS_REG_SZ } from '../windows-native-registry' import { readWslDistroRegistrations } from '../wsl/wsl-distro-registry-id' import { listWslHostDistros } from '../wsl/wsl-host-distro-list' -import { wslHostsGateOpen } from '../wsl/wsl-host-gate' +import { wslHostsGateOpen, wslHostsGateState } from '../wsl/wsl-host-gate' import { isForeignLink, readWorktreeLinks } from '../git/worktree-prune-guard' import { isTelemetryEnabled, track } from './client' import { @@ -161,7 +161,8 @@ export async function gatherWslUsageSnapshotInput( getCachedWslDistros()?.[0] ?? null ), windowsDriveReposWithWslWorktrees: await countWindowsDriveReposWithWslWorktrees(store), - wslHostsGateOpen: gateOpen + wslHostsGateOpen: gateOpen, + wslHostsGateState: wslHostsGateState() } } diff --git a/src/main/telemetry/wsl-usage-snapshot.test.ts b/src/main/telemetry/wsl-usage-snapshot.test.ts index 962312ddfb2..9e3f8f4ccea 100644 --- a/src/main/telemetry/wsl-usage-snapshot.test.ts +++ b/src/main/telemetry/wsl-usage-snapshot.test.ts @@ -118,6 +118,13 @@ describe('buildWslUsageSnapshot', () => { expect(validate('wsl_usage_snapshot', props).ok).toBe(true) }) + it('reports the gate state when known, as an optional field', () => { + const props = buildWslUsageSnapshot({ ...baseInput, wslHostsGateState: 'serve-only' }) + expect(props.wsl_hosts_gate_state).toBe('serve-only') + expect(validate('wsl_usage_snapshot', props).ok).toBe(true) + expect(buildWslUsageSnapshot(baseInput)).not.toHaveProperty('wsl_hosts_gate_state') + }) + it('reports no WSL and an unreadable registry without a version', () => { const props = buildWslUsageSnapshot({ ...baseInput, diff --git a/src/main/telemetry/wsl-usage-snapshot.ts b/src/main/telemetry/wsl-usage-snapshot.ts index fe0014217fd..83e3397c430 100644 --- a/src/main/telemetry/wsl-usage-snapshot.ts +++ b/src/main/telemetry/wsl-usage-snapshot.ts @@ -1,4 +1,5 @@ // Count distro identities locally; only buckets/booleans reach the telemetry payload. +import type { WslHostsGateState } from '../../shared/wsl-host-internal-gate' import type { Repo } from '../../shared/repo-types' import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' import { isDrvfsLinuxPath, parseWslUncPath } from '../../shared/wsl-paths' @@ -32,6 +33,7 @@ export type WslUsageSnapshotInput = { /** Of the Windows-drive WSL git repos, those with a linked worktree WSL Git made. */ windowsDriveReposWithWslWorktrees: number wslHostsGateOpen: boolean + wslHostsGateState?: WslHostsGateState } /** `2.4.13.0` → `2.4`; anything else is dropped rather than sent. */ @@ -113,6 +115,7 @@ export function buildWslUsageSnapshot(input: WslUsageSnapshotInput): WslUsageSna input.windowsDriveReposWithWslWorktrees ), project_distros_used: bucketWslUsageCount(projectDistros.size), - wsl_hosts_gate_open: input.wslHostsGateOpen + wsl_hosts_gate_open: input.wslHostsGateOpen, + ...(input.wslHostsGateState ? { wsl_hosts_gate_state: input.wslHostsGateState } : {}) } } diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index 0e6a5a72b04..ebec2fb119a 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -21,6 +21,7 @@ import { installMainWindowFocusLifecycle } from './main-window-focus-lifecycle' import { installMainWindowShortcutRouting } from './main-window-shortcut-routing' import { installMainWindowStateLifecycle } from './main-window-state-lifecycle' import { stopHiddenWindowFramePumpOnShow } from './hidden-window-frame-pump' +import { wslHostsWindowArguments } from '../wsl/wsl-host-gate' import { formatHiddenWindowFramePumpArgument, shouldPumpHiddenWindowFrames @@ -143,7 +144,8 @@ export function createMainWindow( // Pass host identity at birth so the renderer has it before first snapshot, without waiting for IPC registration. additionalArguments: [ formatBrowserClientHostIdArgument(getBrowserClientHostId()), - ...(pumpHiddenFrames ? [formatHiddenWindowFramePumpArgument()] : []) + ...(pumpHiddenFrames ? [formatHiddenWindowFramePumpArgument()] : []), + ...wslHostsWindowArguments() ] } }) diff --git a/src/main/window/dashboard-popout-window.test.ts b/src/main/window/dashboard-popout-window.test.ts index 283a591e0db..0be72b274e7 100644 --- a/src/main/window/dashboard-popout-window.test.ts +++ b/src/main/window/dashboard-popout-window.test.ts @@ -145,6 +145,8 @@ import { zoomDashboardPopoutIfFocused } from './dashboard-popout-window' import { readBrowserClientHostIdArgument } from '../../shared/browser-client-host-id-argument' +import { hasWslHostsServeOnlyArgument } from '../../shared/wsl-host-internal-gate' +import { configureWslHostsGate, resetWslHostsGateForTests } from '../wsl/wsl-host-gate' type FakeWindow = InstanceType @@ -198,6 +200,23 @@ describe('createOrFocusDashboardPopout', () => { vi.clearAllMocks() }) + it('lets its preload expose saved WSL hosts in serve-only, and passes nothing otherwise', () => { + vi.stubEnv('ORCA_INTERNAL_WSL_HOSTS', undefined) + createOrFocusDashboardPopout(makeStore() as never) + expect(instances[0].options.webPreferences?.additionalArguments).toBeUndefined() + closeDashboardPopout() + + configureWslHostsGate({ hasSavedWslHost: () => true, hasWslMoveCutover: () => false }) + try { + createOrFocusDashboardPopout(makeStore() as never) + expect( + hasWslHostsServeOnlyArgument(instances[1].options.webPreferences?.additionalArguments ?? []) + ).toBe(true) + } finally { + resetWslHostsGateForTests() + } + }) + it('creates a native-frame window with the shared preload and no webview surface', () => { createOrFocusDashboardPopout(makeStore() as never) diff --git a/src/main/window/dashboard-popout-window.ts b/src/main/window/dashboard-popout-window.ts index 2c327a3a003..31c3687d4fd 100644 --- a/src/main/window/dashboard-popout-window.ts +++ b/src/main/window/dashboard-popout-window.ts @@ -13,6 +13,7 @@ import { type KeybindingInput, type KeybindingOverrides } from '../../shared/keybindings' +import { wslHostsWindowArguments } from '../wsl/wsl-host-gate' const MIN_WIDTH = 480 const MIN_HEIGHT = 360 @@ -136,6 +137,8 @@ export function createOrFocusDashboardPopout( const savedBounds = resolveRestoredBounds(store) + // Serve-only: this window lists saved WSL hosts and their Connect, as the main window does. + const wslArguments = wslHostsWindowArguments() const window = new BrowserWindow({ width: savedBounds?.width ?? DEFAULT_WIDTH, height: savedBounds?.height ?? DEFAULT_HEIGHT, @@ -153,7 +156,8 @@ export function createOrFocusDashboardPopout( // Chromium shares zoom by origin; isolate the session to keep popout zoom local. partition: DASHBOARD_POPOUT_PARTITION, // No webviews run here; disable embedding and omit host identity so client-placed pages stay mirrors. - webviewTag: false + webviewTag: false, + ...(wslArguments.length > 0 ? { additionalArguments: wslArguments } : {}) } }) installPrivilegedWindowNavigationPolicy(window.webContents) diff --git a/src/main/window/workspace-space-window.test.ts b/src/main/window/workspace-space-window.test.ts new file mode 100644 index 00000000000..2c9c7c3c72b --- /dev/null +++ b/src/main/window/workspace-space-window.test.ts @@ -0,0 +1,58 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { instances } = vi.hoisted(() => { + const instances: { options: { webPreferences?: { additionalArguments?: string[] } } }[] = [] + return { instances } +}) + +vi.mock('electron', () => { + class BrowserWindow { + readonly webContents = { + isDestroyed: () => false, + on: vi.fn(), + setZoomLevel: vi.fn(), + session: { setPermissionRequestHandler: vi.fn(), setPermissionCheckHandler: vi.fn() } + } + constructor(readonly options: { webPreferences?: { additionalArguments?: string[] } }) { + instances.push(this) + } + isDestroyed = () => false + once = vi.fn() + on = vi.fn() + close = vi.fn() + loadFile = vi.fn(async () => {}) + loadURL = vi.fn(async () => {}) + } + return { app: { isPackaged: true }, BrowserWindow, nativeTheme: { shouldUseDarkColors: true } } +}) +vi.mock('./privileged-window-navigation', () => ({ + installPrivilegedWindowNavigationPolicy: vi.fn() +})) +vi.mock('../ipc/ui', () => ({ sendToTrustedUIRenderer: vi.fn() })) + +import { createOrFocusWorkspaceSpaceWindow } from './workspace-space-window' +import { hasWslHostsServeOnlyArgument } from '../../shared/wsl-host-internal-gate' +import { configureWslHostsGate, resetWslHostsGateForTests } from '../wsl/wsl-host-gate' + +const store = { + getUI: () => ({ uiZoomLevel: 0 }), + onUIChanged: () => () => {} +} + +afterEach(() => { + vi.unstubAllEnvs() + resetWslHostsGateForTests() +}) + +describe('Space window and the WSL hosts gate', () => { + it('lets its preload expose saved WSL hosts in serve-only', () => { + vi.stubEnv('ORCA_INTERNAL_WSL_HOSTS', undefined) + configureWslHostsGate({ hasSavedWslHost: () => true, hasWslMoveCutover: () => false }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the window reads only getUI and onUIChanged from the store. + createOrFocusWorkspaceSpaceWindow(store as never) + + expect( + hasWslHostsServeOnlyArgument(instances[0].options.webPreferences?.additionalArguments ?? []) + ).toBe(true) + }) +}) diff --git a/src/main/window/workspace-space-window.ts b/src/main/window/workspace-space-window.ts index 684f0fc08be..a88c1ea6a15 100644 --- a/src/main/window/workspace-space-window.ts +++ b/src/main/window/workspace-space-window.ts @@ -5,6 +5,7 @@ import { installPrivilegedWindowNavigationPolicy } from './privileged-window-nav import { sendToTrustedUIRenderer } from '../ipc/ui' import type { Store } from '../persistence' import { stepUIZoomLevel, type UIZoomDirection } from '../../shared/ui-zoom-level' +import { wslHostsWindowArguments } from '../wsl/wsl-host-gate' let spaceWindow: BrowserWindow | null = null @@ -40,6 +41,8 @@ export function createOrFocusWorkspaceSpaceWindow(store: Store): BrowserWindow { return existing } + // Serve-only: this window lists saved WSL hosts and their Connect, as the main window does. + const wslArguments = wslHostsWindowArguments() const window = new BrowserWindow({ width: 1180, height: 820, @@ -53,7 +56,8 @@ export function createOrFocusWorkspaceSpaceWindow(store: Store): BrowserWindow { preload: join(__dirname, '../preload/index.js'), sandbox: true, partition: 'orca-workspace-space-window', - webviewTag: false + webviewTag: false, + ...(wslArguments.length > 0 ? { additionalArguments: wslArguments } : {}) } }) spaceWindow = window diff --git a/src/main/wsl/wsl-host-gate-startup.test.ts b/src/main/wsl/wsl-host-gate-startup.test.ts new file mode 100644 index 00000000000..54552fdad49 --- /dev/null +++ b/src/main/wsl/wsl-host-gate-startup.test.ts @@ -0,0 +1,59 @@ +import { existsSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { writeWslHostDeployment } from '../../shared/wsl-host-deployment-store' +import { wslMoveCutoverFixture as cutover } from '../persistence/__fixtures__/wsl-mover/wsl-move-cutover-fixture' +import { seedWslMoveCutover } from '../persistence/__fixtures__/wsl-mover/wsl-move-journal-seeding' +import { wslMoveJournalDirectory } from '../persistence/moving-wsl-projects/wsl-move-cutover-journal' +import { resetWslHostsGateForTests, wslHostsGateState } from './wsl-host-gate' +import { configureWslHostsGateFromDisk } from './wsl-host-gate-startup' + +describe('WSL hosts gate startup promotion', () => { + let userDataPath: string + beforeEach(() => { + vi.stubEnv('ORCA_INTERNAL_WSL_HOSTS', undefined) + userDataPath = mkdtempSync(join(tmpdir(), 'orca-wsl-gate-')) + }) + afterEach(() => { + vi.unstubAllEnvs() + resetWslHostsGateForTests() + rmSync(userDataPath, { recursive: true, force: true }) + }) + + it('stays closed and creates nothing for a profile that never had WSL work', () => { + configureWslHostsGateFromDisk(userDataPath) + + expect(wslHostsGateState()).toBe('closed') + expect(existsSync(wslMoveJournalDirectory(userDataPath))).toBe(false) + }) + + it('serves a saved WSL host', () => { + writeWslHostDeployment(userDataPath, 'wsl:ubuntu', { + kind: 'wsl', + distro: 'Ubuntu', + localPort: 41000, + remotePort: 6768, + environmentId: 'wsl:ubuntu', + createdAt: 1 + }) + configureWslHostsGateFromDisk(userDataPath) + + expect(wslHostsGateState()).toBe('serve-only') + }) + + it('serves a committed move', () => { + seedWslMoveCutover(userDataPath, cutover('move-1', 'destination-committed')) + configureWslHostsGateFromDisk(userDataPath) + + expect(wslHostsGateState()).toBe('serve-only') + }) + + // Recovery must still reset an in-flight move whose host record is already gone. + it('serves a move that never committed', () => { + seedWslMoveCutover(userDataPath, cutover('move-1', 'prepared')) + configureWslHostsGateFromDisk(userDataPath) + + expect(wslHostsGateState()).toBe('serve-only') + }) +}) diff --git a/src/main/wsl/wsl-host-gate-startup.ts b/src/main/wsl/wsl-host-gate-startup.ts new file mode 100644 index 00000000000..df710a5ceff --- /dev/null +++ b/src/main/wsl/wsl-host-gate-startup.ts @@ -0,0 +1,12 @@ +import { readWslHostDeployments } from '../../shared/wsl-host-deployment-store' +import { listWslMoveCutovers } from '../persistence/moving-wsl-projects/wsl-move-cutover-journal' +import { configureWslHostsGate } from './wsl-host-gate' + +/** Reads only with the internal gate closed: an open gate needs no promotion. */ +export function configureWslHostsGateFromDisk(userDataPath: string): void { + configureWslHostsGate({ + hasSavedWslHost: () => readWslHostDeployments(userDataPath).deployments.length > 0, + // Any journal entry, committed or not: recovery must reset an in-flight move whose host is gone. + hasWslMoveCutover: () => listWslMoveCutovers(userDataPath).length > 0 + }) +} diff --git a/src/main/wsl/wsl-host-gate.test.ts b/src/main/wsl/wsl-host-gate.test.ts new file mode 100644 index 00000000000..06520333e6c --- /dev/null +++ b/src/main/wsl/wsl-host-gate.test.ts @@ -0,0 +1,95 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + assertWslHostReachable, + configureWslHostsGate, + isGatedOffWslHost, + resetWslHostsGateForTests, + wslHostsGateOpen, + wslHostsGateState, + wslHostsServing, + wslHostsWindowArguments, + WSL_HOSTS_GATED_OFF_MESSAGE +} from './wsl-host-gate' + +const wslHost = { + orcadDeployment: { kind: 'wsl' as const, distro: 'Ubuntu', localPort: 41000, remotePort: 6768 } +} +const sshHost = { orcadDeployment: null } +const facts = (hasSavedWslHost: boolean, hasWslMoveCutover: boolean) => ({ + hasSavedWslHost: () => hasSavedWslHost, + hasWslMoveCutover: () => hasWslMoveCutover +}) + +afterEach(() => { + vi.unstubAllEnvs() + resetWslHostsGateForTests() +}) + +describe('WSL hosts gate', () => { + it('flag-off with nothing saved: closed, exactly as before', () => { + vi.stubEnv('ORCA_INTERNAL_WSL_HOSTS', undefined) + configureWslHostsGate(facts(false, false)) + + expect(wslHostsGateState()).toBe('closed') + expect(wslHostsServing()).toBe(false) + expect(isGatedOffWslHost(wslHost)).toBe(true) + expect(() => assertWslHostReachable(wslHost)).toThrow(WSL_HOSTS_GATED_OFF_MESSAGE) + expect(isGatedOffWslHost(sshHost)).toBe(false) + }) + + it.each([ + ['a saved host', facts(true, false)], + ['a move journal entry', facts(false, true)] + ])('serves %s behind a closed gate without opening it', (_label, saved) => { + vi.stubEnv('ORCA_INTERNAL_WSL_HOSTS', undefined) + configureWslHostsGate(saved) + + expect(wslHostsGateState()).toBe('serve-only') + expect(wslHostsServing()).toBe(true) + expect(wslHostsGateOpen()).toBe(false) + expect(isGatedOffWslHost(wslHost)).toBe(false) + expect(() => assertWslHostReachable(wslHost)).not.toThrow() + }) + + it('treats unreadable saved state as absent', () => { + vi.stubEnv('ORCA_INTERNAL_WSL_HOSTS', undefined) + configureWslHostsGate({ + hasSavedWslHost: () => { + throw new Error('unreadable') + }, + hasWslMoveCutover: () => { + throw new Error('unreadable') + } + }) + + expect(wslHostsGateState()).toBe('closed') + }) + + it('stays open with the internal gate on, and reads nothing to decide', () => { + vi.stubEnv('ORCA_INTERNAL_WSL_HOSTS', '1') + const hasSavedWslHost = vi.fn(() => false) + const hasWslMoveCutover = vi.fn(() => false) + configureWslHostsGate({ hasSavedWslHost, hasWslMoveCutover }) + + expect(wslHostsGateState()).toBe('open') + expect(hasSavedWslHost).not.toHaveBeenCalled() + expect(hasWslMoveCutover).not.toHaveBeenCalled() + }) + + it('is closed before startup configures it', () => { + vi.stubEnv('ORCA_INTERNAL_WSL_HOSTS', undefined) + expect(wslHostsGateState()).toBe('closed') + }) + + it('stamps window argv only in serve-only', () => { + vi.stubEnv('ORCA_INTERNAL_WSL_HOSTS', undefined) + configureWslHostsGate(facts(false, false)) + expect(wslHostsWindowArguments()).toEqual([]) + + configureWslHostsGate(facts(true, false)) + expect(wslHostsWindowArguments()).toEqual(['--orca-wsl-hosts-serve-only']) + + vi.stubEnv('ORCA_INTERNAL_WSL_HOSTS', '1') + expect(wslHostsWindowArguments()).toEqual([]) + }) +}) diff --git a/src/main/wsl/wsl-host-gate.ts b/src/main/wsl/wsl-host-gate.ts index b4951629045..0256e473378 100644 --- a/src/main/wsl/wsl-host-gate.ts +++ b/src/main/wsl/wsl-host-gate.ts @@ -1,17 +1,77 @@ -import { isWslHostsInternalGateOpen } from '../../shared/wsl-host-internal-gate' +import { + formatWslHostsServeOnlyArgument, + isWslHostsInternalGateOpen, + noteWslHostsServeOnly, + resolveWslHostsGateState, + type WslHostsGateState +} from '../../shared/wsl-host-internal-gate' import type { OrcadDeploymentLink } from '../../shared/runtime-environments' export const WSL_HOSTS_GATED_OFF_MESSAGE = 'WSL hosts are not available in this build.' +export const WSL_HOSTS_NEW_WORK_PAUSED_MESSAGE = + 'Adding WSL hosts and moving projects onto them is paused in this build. Your saved WSL hosts keep working.' +// Fixed for the run once main reads the disk; nothing can create a host or a move while not open. +let promoted: { hasSavedWslHost: boolean; hasWslMoveCutover: boolean } | null = null + +/** Startup, before any gate check: a closed gate with saved WSL work becomes `serve-only`. */ +export function configureWslHostsGate(facts: { + hasSavedWslHost: () => boolean + hasWslMoveCutover: () => boolean +}): void { + if (wslHostsGateOpen()) { + promoted = null + noteWslHostsServeOnly(false) + return + } + // Unreadable state counts as absent: today's closed behavior. + const read = (fact: () => boolean): boolean => { + try { + return fact() + } catch { + return false + } + } + promoted = { + hasSavedWslHost: read(facts.hasSavedWslHost), + hasWslMoveCutover: read(facts.hasWslMoveCutover) + } + noteWslHostsServeOnly(wslHostsGateState() === 'serve-only') +} + +export function resetWslHostsGateForTests(): void { + promoted = null + noteWslHostsServeOnly(false) +} + +export function wslHostsGateState(): WslHostsGateState { + return resolveWslHostsGateState({ + gateOpen: wslHostsGateOpen(), + hasSavedWslHost: promoted?.hasSavedWslHost ?? false, + hasWslMoveCutover: promoted?.hasWslMoveCutover ?? false + }) +} + +/** Argv for any window on the shared preload, which cannot read the disk to learn serve-only. */ +export function wslHostsWindowArguments(): string[] { + return wslHostsGateState() === 'serve-only' ? [formatWslHostsServeOnlyArgument()] : [] +} + +/** The internal gate itself: adding hosts and starting moves need it. */ export function wslHostsGateOpen(): boolean { return isWslHostsInternalGateOpen(process.env) } +/** Saved hosts and moved projects stay reachable: `open` or `serve-only`. */ +export function wslHostsServing(): boolean { + return wslHostsGateState() !== 'closed' +} + /** A persisted WSL host while the gate is closed: left on disk, but never listed or reached. */ export function isGatedOffWslHost(environment: { orcadDeployment?: OrcadDeploymentLink | null }): boolean { - return environment.orcadDeployment?.kind === 'wsl' && !wslHostsGateOpen() + return environment.orcadDeployment?.kind === 'wsl' && !wslHostsServing() } export function assertWslHostReachable(environment: { diff --git a/src/preload/api/wsl-hosts-api.test.ts b/src/preload/api/wsl-hosts-api.test.ts index bfc17017ae9..bfa5410c495 100644 --- a/src/preload/api/wsl-hosts-api.test.ts +++ b/src/preload/api/wsl-hosts-api.test.ts @@ -6,9 +6,9 @@ import { getWslHostsApi } from './wsl-hosts-api' describe('WSL hosts preload API', () => { it('is absent on Windows and under the e2e fake while the internal gate is closed', () => { - expect(getWslHostsApi({}, 'win32')).toBeUndefined() - expect(getWslHostsApi({ ORCA_INTERNAL_WSL_HOSTS: 'true' }, 'win32')).toBeUndefined() - expect(getWslHostsApi({ ORCA_E2E_FAKE_WSL_HOSTS: '{}' }, 'darwin')).toBeUndefined() + expect(getWslHostsApi({}, 'win32', [])).toBeUndefined() + expect(getWslHostsApi({ ORCA_INTERNAL_WSL_HOSTS: 'true' }, 'win32', [])).toBeUndefined() + expect(getWslHostsApi({ ORCA_E2E_FAKE_WSL_HOSTS: '{}' }, 'darwin', [])).toBeUndefined() }) it('is present behind the open gate only where WSL hosts can run', () => { @@ -18,4 +18,12 @@ describe('WSL hosts preload API', () => { ).toBeDefined() expect(getWslHostsApi({ ORCA_INTERNAL_WSL_HOSTS: '1' }, 'darwin')).toBeUndefined() }) + + it('is present behind a closed gate when main stamped serve-only, still only where hosts run', () => { + const argv = ['--orca-wsl-hosts-serve-only'] + expect(getWslHostsApi({}, 'win32', argv)).toBeDefined() + expect(getWslHostsApi({ ORCA_E2E_FAKE_WSL_HOSTS: '{}' }, 'linux', argv)).toBeDefined() + expect(getWslHostsApi({}, 'darwin', argv)).toBeUndefined() + expect(getWslHostsApi({}, 'win32', [])).toBeUndefined() + }) }) diff --git a/src/preload/api/wsl-hosts-api.ts b/src/preload/api/wsl-hosts-api.ts index daea4a235c5..cacb10dae86 100644 --- a/src/preload/api/wsl-hosts-api.ts +++ b/src/preload/api/wsl-hosts-api.ts @@ -9,7 +9,10 @@ import { type WslHostServerStop, type WslHostStatus } from '../../shared/wsl-host-types' -import { isWslHostsInternalGateOpen } from '../../shared/wsl-host-internal-gate' +import { + hasWslHostsServeOnlyArgument, + isWslHostsInternalGateOpen +} from '../../shared/wsl-host-internal-gate' export type WslHostsPreloadApi = { getCapability: () => Promise @@ -51,9 +54,11 @@ const wslHostsApi: WslHostsPreloadApi = { /** Undefined hides every WSL host surface in the renderer; main refuses the calls regardless. */ export function getWslHostsApi( env: Readonly> = process.env, - platform: NodeJS.Platform = process.platform + platform: NodeJS.Platform = process.platform, + argv: readonly string[] = process.argv ): WslHostsPreloadApi | undefined { - if (!isWslHostsInternalGateOpen(env)) { + // Serve-only: main found saved WSL work behind the closed gate and stamped this window's argv. + if (!isWslHostsInternalGateOpen(env) && !hasWslHostsServeOnlyArgument(argv)) { return undefined } return platform === 'win32' || env.ORCA_E2E_FAKE_WSL_HOSTS ? wslHostsApi : undefined diff --git a/src/shared/runtime-environment-store-file.ts b/src/shared/runtime-environment-store-file.ts index 24d259366ee..233209aa606 100644 --- a/src/shared/runtime-environment-store-file.ts +++ b/src/shared/runtime-environment-store-file.ts @@ -27,7 +27,7 @@ import { readWslHostDeployments, WslHostDeploymentStoreInvalidError } from './wsl-host-deployment-store' -import { isWslHostsInternalGateOpen } from './wsl-host-internal-gate' +import { isWslHostsServing } from './wsl-host-internal-gate' export { getEnvironmentStorePath, @@ -53,11 +53,11 @@ export function readPersistedEnvironmentStore( } } -/** Unreadable WSL state fails closed only when its internal gate is enabled; otherwise it cannot hide SSH/paired servers. */ +/** Unreadable WSL state fails closed only while WSL hosts are served; otherwise it cannot hide SSH/paired servers. */ function readWslHostDeploymentsForRegistry( userDataPath: string ): ReturnType { - const gateOpen = isWslHostsInternalGateOpen(process.env) + const gateOpen = isWslHostsServing(process.env) try { const read = readWslHostDeployments(userDataPath) return gateOpen ? read : { deployments: read.deployments, unreadableEnvironmentIds: new Set() } diff --git a/src/shared/telemetry-wsl-usage-event-schemas.ts b/src/shared/telemetry-wsl-usage-event-schemas.ts index 7fde4b666ed..89394b6fd2b 100644 --- a/src/shared/telemetry-wsl-usage-event-schemas.ts +++ b/src/shared/telemetry-wsl-usage-event-schemas.ts @@ -45,7 +45,9 @@ export const wslUsageSnapshotSchema = z projects_wsl_git: countBucketSchema, projects_wsl_folder: countBucketSchema, project_distros_used: countBucketSchema, - wsl_hosts_gate_open: z.boolean() + wsl_hosts_gate_open: z.boolean(), + /** open / serve-only / closed. Optional, so older builds' payloads still validate. */ + wsl_hosts_gate_state: z.enum(['open', 'serve-only', 'closed']).optional() }) .strict() diff --git a/src/shared/wsl-host-deployment-store.test.ts b/src/shared/wsl-host-deployment-store.test.ts index 3662dfc2cd1..fb6b1c7b11d 100644 --- a/src/shared/wsl-host-deployment-store.test.ts +++ b/src/shared/wsl-host-deployment-store.test.ts @@ -31,7 +31,7 @@ import { WslHostDeploymentStoreInvalidError, type WslHostDeploymentRecord } from './wsl-host-deployment-store' -import { WSL_HOSTS_INTERNAL_GATE_ENV } from './wsl-host-internal-gate' +import { noteWslHostsServeOnly, WSL_HOSTS_INTERNAL_GATE_ENV } from './wsl-host-internal-gate' const pairing = { v: 2 as const, @@ -257,6 +257,18 @@ describe('WSL host deployment store', () => { ) }) + it('fails closed when the file itself is corrupt in serve-only too', () => { + vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '') + noteWslHostsServeOnly(true) + try { + addSshManaged() + writeFileSync(getWslHostDeploymentStorePath(userDataPath), '{"version":1,"deployments":{}}') + expect(() => listEnvironments(userDataPath)).toThrow(RuntimeEnvironmentStoreError) + } finally { + noteWslHostsServeOnly(false) + } + }) + it('keeps every other server listed when the file is corrupt and the gate is closed', () => { vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '') addSshManaged() diff --git a/src/shared/wsl-host-internal-gate.test.ts b/src/shared/wsl-host-internal-gate.test.ts new file mode 100644 index 00000000000..befd587809b --- /dev/null +++ b/src/shared/wsl-host-internal-gate.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { + formatWslHostsServeOnlyArgument, + hasWslHostsServeOnlyArgument, + resolveWslHostsGateState +} from './wsl-host-internal-gate' + +describe('WSL hosts gate state', () => { + it('is open whenever the internal gate is, whatever is saved', () => { + for (const hasSavedWslHost of [false, true]) { + for (const hasWslMoveCutover of [false, true]) { + expect( + resolveWslHostsGateState({ gateOpen: true, hasSavedWslHost, hasWslMoveCutover }) + ).toBe('open') + } + } + }) + + it('promotes a closed gate to serve-only for a saved host or a move journal entry, else stays closed', () => { + const closed = { gateOpen: false, hasSavedWslHost: false, hasWslMoveCutover: false } + expect(resolveWslHostsGateState(closed)).toBe('closed') + expect(resolveWslHostsGateState({ ...closed, hasSavedWslHost: true })).toBe('serve-only') + expect(resolveWslHostsGateState({ ...closed, hasWslMoveCutover: true })).toBe('serve-only') + }) + + it('reads back only the serve-only argument main stamps', () => { + expect(hasWslHostsServeOnlyArgument(['--x', formatWslHostsServeOnlyArgument()])).toBe(true) + expect(hasWslHostsServeOnlyArgument(['--orca-wsl-hosts-serve-only=0'])).toBe(false) + expect(hasWslHostsServeOnlyArgument([])).toBe(false) + }) +}) diff --git a/src/shared/wsl-host-internal-gate.ts b/src/shared/wsl-host-internal-gate.ts index ac4bceb6305..5f6a7f140c4 100644 --- a/src/shared/wsl-host-internal-gate.ts +++ b/src/shared/wsl-host-internal-gate.ts @@ -6,3 +6,43 @@ export function isWslHostsInternalGateOpen( ): boolean { return env[WSL_HOSTS_INTERNAL_GATE_ENV] === '1' } + +/** + * `open`: everything. `serve-only`: the gate is closed, but a saved host or a move journal exists, + * so its projects stay reachable while nothing new is added or moved. `closed`: no WSL host surface. + */ +export type WslHostsGateState = 'open' | 'serve-only' | 'closed' + +export function resolveWslHostsGateState(facts: { + gateOpen: boolean + hasSavedWslHost: boolean + hasWslMoveCutover: boolean +}): WslHostsGateState { + if (facts.gateOpen) { + return 'open' + } + return facts.hasSavedWslHost || facts.hasWslMoveCutover ? 'serve-only' : 'closed' +} + +// Main records serve-only here so shared readers in its process agree; other processes never set it. +let serveOnlyNoted = false + +export function noteWslHostsServeOnly(serveOnly: boolean): void { + serveOnlyNoted = serveOnly +} + +/** Open, or main found saved WSL work behind the closed gate. */ +export function isWslHostsServing(env: Readonly>): boolean { + return isWslHostsInternalGateOpen(env) || serveOnlyNoted +} + +// Preload has no disk access, so main stamps the promoted state into the window's argv at birth. +const SERVE_ONLY_ARGUMENT = '--orca-wsl-hosts-serve-only' + +export function formatWslHostsServeOnlyArgument(): string { + return SERVE_ONLY_ARGUMENT +} + +export function hasWslHostsServeOnlyArgument(argv: readonly string[]): boolean { + return argv.includes(SERVE_ONLY_ARGUMENT) +} diff --git a/tests/e2e/wsl-host-fake-transport.spec.ts b/tests/e2e/wsl-host-fake-transport.spec.ts index d674eba0884..377fb90bf0b 100644 --- a/tests/e2e/wsl-host-fake-transport.spec.ts +++ b/tests/e2e/wsl-host-fake-transport.spec.ts @@ -20,7 +20,6 @@ import { expect, forwardElectronProcessLogs, test } from './helpers/orca-app' import { RuntimeClient } from '../../src/cli/runtime/client' import { createRuntimeDesktopPairingOffer } from './helpers/paired-electron-client' import { encodePairingOffer } from '../../src/shared/pairing' -import { listEnvironments } from '../../src/shared/runtime-environment-store' import { addWslHostEnvironment, findWslHostDeployment @@ -548,7 +547,7 @@ test('a build without the WSL host server files says so instead of failing an ad } }) -test('with the internal gate closed, no WSL host surface shows and a saved host stays untouched', async ({ +test('with the internal gate closed and no saved WSL work, no WSL host surface shows', async ({ orcaPage }, testInfo) => { test.setTimeout(180_000) @@ -560,28 +559,7 @@ test('with the internal gate closed, no WSL host surface shows and a saved host offer.pairingUrl, testInfo, { connectLogFile }, - { - internalGate: false, - // A host saved during internal testing: it must be ignored, never deleted. - beforeLaunch: (userDataDir) => - addWslHostEnvironment(userDataDir, { - id: WSL_ENVIRONMENT_ID, - name: WSL_HOST_LABEL, - pairingCode: encodePairingOffer({ - v: 2, - endpoint: 'ws://127.0.0.1:42000', - deviceToken: 'gated-token', - publicKeyB64: Buffer.alloc(32, 7).toString('base64') - }), - record: { - kind: 'wsl', - distro: 'Ubuntu', - distroId: '0e3a7f2c-1b4d-4c5e-9f60-7a8b9c0d1e2f', - localPort: 42000, - remotePort: 6768 - } - }) - } + { internalGate: false } ) try { const { page, userDataDir } = desktop @@ -618,13 +596,74 @@ test('with the internal gate closed, no WSL host surface shows and a saved host await expect(page.getByText(WSL_HOST_LABEL)).toHaveCount(0) expect(fakeWslConnects(connectLogFile)).toEqual([]) - expect(listEnvironments(userDataDir).map((environment) => environment.id)).toContain( - WSL_ENVIRONMENT_ID - ) - expect(findWslHostDeployment(userDataDir, WSL_ENVIRONMENT_ID)).not.toBeNull() + expect(findWslHostDeployment(userDataDir, WSL_ENVIRONMENT_ID)).toBeNull() await page.screenshot({ path: testInfo.outputPath('wsl-hosts-gated-off-settings.png') }) } finally { await desktop.dispose() rmSync(logDir, { recursive: true, force: true }) } }) +test('with the internal gate closed, a saved WSL host stays listed and reachable (serve-only)', async ({ + orcaPage +}, testInfo) => { + // Launches a second desktop beside the paired server, like the gate-closed case above. + test.setTimeout(180_000) + await waitForSessionReady(orcaPage) + const offer = await createRuntimeDesktopPairingOffer(orcaPage) + const logDir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-wsl-serve-only-')) + const connectLogFile = path.join(logDir, 'connects.log') + const desktop = await launchDesktopWithFakeWsl( + offer.pairingUrl, + testInfo, + { connectLogFile }, + { + internalGate: false, + // A host saved while the gate was open: closing the gate must not strand it. + beforeLaunch: (userDataDir) => + addWslHostEnvironment(userDataDir, { + id: WSL_ENVIRONMENT_ID, + name: WSL_HOST_LABEL, + pairingCode: encodePairingOffer({ + v: 2, + endpoint: 'ws://127.0.0.1:42000', + deviceToken: 'gated-token', + publicKeyB64: Buffer.alloc(32, 7).toString('base64') + }), + record: { + kind: 'wsl', + distro: 'Ubuntu', + distroId: '0e3a7f2c-1b4d-4c5e-9f60-7a8b9c0d1e2f', + localPort: 42000, + remotePort: 6768 + } + }) + } + ) + try { + const { page, userDataDir } = desktop + await setWslHostsEnabled(page, true) + expect(await page.evaluate(() => Boolean(window.api.runtimeEnvironments.wslHosts))).toBe(true) + const listed = await page.evaluate(() => window.api.runtimeEnvironments.list()) + expect(listed.map((environment) => environment.id)).toContain(WSL_ENVIRONMENT_ID) + + await page.evaluate( + (environmentId) => window.api.runtimeEnvironments.wslHosts?.connect({ environmentId }), + WSL_ENVIRONMENT_ID + ) + await expect + .poll(() => fakeWslConnects(connectLogFile), { timeout: 30_000 }) + .toContain('Ubuntu') + + await page.evaluate(() => { + const state = window.__store?.getState() + state?.openSettingsPage() + state?.openSettingsTarget({ pane: 'servers', repoId: null }) + }) + await expect(page.getByText(WSL_HOST_LABEL).first()).toBeVisible({ timeout: 30_000 }) + expect(findWslHostDeployment(userDataDir, WSL_ENVIRONMENT_ID)).not.toBeNull() + await page.screenshot({ path: testInfo.outputPath('wsl-hosts-serve-only-settings.png') }) + } finally { + await desktop.dispose() + rmSync(logDir, { recursive: true, force: true }) + } +})