mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
feat(wsl): serve saved WSL work behind a closed hosts gate (gate states, kill switch 1/2) (#34683)
* feat(wsl): serve saved WSL work behind a closed hosts gate (serve-only) The WSL hosts gate gains three states. A closed gate with a saved WSL host or a committed move becomes serve-only: saved hosts stay listed and reachable, committed rewrites, retention, interrupted-move recovery and the way back keep running, and the preload API is exposed through a stamped window argument. Adding hosts and starting moves still need the open gate. With nothing saved the gate stays closed exactly as before. * fix(wsl): stamp serve-only into the dashboard popout and Space windows too They load the same preload, so without the argument they hid saved WSL hosts' status and Connect in serve-only. One helper now supplies the argument for every window on the shared preload. * fix(wsl): reviewer follow-ups for the serve-only gate - Refuse wslHosts:add unless the internal gate is open here, so this PR is safe on its own. - Promote on any move journal entry, so recovery can reset an in-flight move whose host record is gone. - List unfinished moves in serve-only. - An unreadable WSL deployment store fails closed in serve-only too, through a shared serving flag main sets at startup. - Report the gate state in wsl_usage_snapshot as an optional field.
This commit is contained in:
+5
-2
@@ -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) => {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<WslHostAddResult> => {
|
||||
// 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 })
|
||||
|
||||
@@ -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([])
|
||||
|
||||
@@ -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<WslMoveUseLocallyResult> => {
|
||||
// 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
|
||||
|
||||
@@ -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<void> {
|
||||
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)
|
||||
|
||||
@@ -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<boolean | null>
|
||||
): Promise<WslMoveStartupRecovery> {
|
||||
const result: WslMoveStartupRecovery = { reset: [], waitingForHost: [] }
|
||||
if (!wslHostsGateOpen()) {
|
||||
if (!wslHostsServing()) {
|
||||
return result
|
||||
}
|
||||
let inFlight: WslMoveCutover[]
|
||||
|
||||
@@ -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<unknown> = Promise.resolve()
|
||||
export function runWslMoveRetentionPass(
|
||||
options: WslMoveRetentionOptions
|
||||
): Promise<WslMoveRetentionReport | null> {
|
||||
if (!wslHostsGateOpen()) {
|
||||
if (!wslHostsServing()) {
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
const next = passQueue.then(() =>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<void> {
|
||||
if (!wslHostsGateOpen()) {
|
||||
if (!wslHostsServing()) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
@@ -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<WslMoveUseLocallyResult, { released: false }> {
|
||||
if (!wslHostsGateOpen()) {
|
||||
if (!wslHostsServing()) {
|
||||
return { released: false, reason: 'gated-off' }
|
||||
}
|
||||
let move: WslMoveCutover | undefined
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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<typeof BrowserWindowMock>
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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: {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<WslHostCapability>
|
||||
@@ -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<Record<string, string | undefined>> = 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
|
||||
|
||||
@@ -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<typeof readWslHostDeployments> {
|
||||
const gateOpen = isWslHostsInternalGateOpen(process.env)
|
||||
const gateOpen = isWslHostsServing(process.env)
|
||||
try {
|
||||
const read = readWslHostDeployments(userDataPath)
|
||||
return gateOpen ? read : { deployments: read.deployments, unreadableEnvironmentIds: new Set() }
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<Record<string, string | undefined>>): 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)
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user