diff --git a/docs/electron-migration.md b/docs/electron-migration.md index 72d9f64d..8fae6c64 100644 --- a/docs/electron-migration.md +++ b/docs/electron-migration.md @@ -23,3 +23,8 @@ Electron checks required Python imports before reusing an existing runtime. An incomplete environment opens setup instead of repeatedly crashing; installation still requires your explicit action. Automatic selection skips broken legacy runtimes and uses the Electron runtime location, leaving Tauri data intact. + +An explicitly selected runtime is not silently replaced during startup. If that +location contains an incomplete environment Electron does not own, choosing +setup creates a separate runtime in Electron’s default location instead of +modifying or taking ownership of the existing environment. diff --git a/electron/src/main/backend-setup.test.ts b/electron/src/main/backend-setup.test.ts index 0e8a0887..f0736381 100644 --- a/electron/src/main/backend-setup.test.ts +++ b/electron/src/main/backend-setup.test.ts @@ -2,7 +2,9 @@ import { afterEach, expect, it, vi } from 'vitest'; import { EventEmitter } from 'node:events'; const mocks = vi.hoisted(() => ({ - dependencies: vi.fn(async () => true), + runtimeConfig: null as { root: string; owned: boolean } | null, + existingProject: false, + dependencies: vi.fn(async (_project?: string) => true), ready: vi.fn(async () => false), compatible: vi.fn(async () => false), interrupted: vi.fn(async () => false), @@ -14,6 +16,27 @@ const mocks = vi.hoisted(() => ({ })); vi.mock('electron', () => ({ app: { isPackaged: true, getPath: () => '/private/voicestudio' } })); vi.mock('node:child_process', () => ({ spawn: mocks.spawn, spawnSync: vi.fn() })); +vi.mock('node:fs', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + readFileSync: (...args: Parameters) => + String(args[0]).endsWith('runtime-location.json') && mocks.runtimeConfig + ? JSON.stringify(mocks.runtimeConfig) + : original.readFileSync(...args), + writeFileSync: (...args: Parameters) => { + if (String(args[0]).endsWith('runtime-location.json')) { + mocks.runtimeConfig = JSON.parse(String(args[1])); + return; + } + return original.writeFileSync(...args); + }, + mkdirSync: (...args: Parameters) => + String(args[0]).includes('private') ? undefined : original.mkdirSync(...args), + existsSync: (path: Parameters[0]) => + mocks.existingProject && String(path).includes('selected') ? true : original.existsSync(path), + }; +}); vi.mock('node:fs/promises', () => ({ rm: mocks.rm })); vi.mock('./runtime-project', () => ({ runtimeDependenciesReady: mocks.dependencies, @@ -36,6 +59,8 @@ afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); vi.clearAllMocks(); + mocks.runtimeConfig = null; + mocks.existingProject = false; mocks.dependencies.mockResolvedValue(true); mocks.ready.mockResolvedValue(false); mocks.compatible.mockResolvedValue(false); @@ -392,3 +417,34 @@ it.each(['ready', 'compatible'] as const)( await supervisor.shutdown(); }, ); + +it('keeps a selected broken environment selected until explicit setup, then preserves unowned files', async () => { + const { resolve, join } = await import('node:path'); + const selected = resolve('/selected/VoiceStudio'); + mocks.runtimeConfig = { root: selected, owned: false }; + mocks.existingProject = true; + mocks.ready.mockResolvedValue(true); + mocks.dependencies.mockImplementation(async (project?: string) => !project?.includes('selected')); + mocks.install.mockRejectedValue(new Error('offline')); + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw new Error('no backend'); + }), + ); + vi.stubEnv('OMNIVOICE_BACKEND_CMD', ''); + vi.stubEnv('VOICESTUDIO_SKIP_BACKEND', ''); + const supervisor = new BackendSupervisor(); + await supervisor.start(); + expect(supervisor.status.stage).toBe('setup_required'); + expect( + mocks.dependencies.mock.calls.every(([project]) => String(project).includes('selected')), + ).toBe(true); + expect(mocks.install).not.toHaveBeenCalled(); + await supervisor.setupRuntime(); + expect(mocks.install.mock.calls[0][1]).toBe(join('/private/voicestudio', 'runtime', 'project')); + expect(mocks.runtimeConfig?.root).not.toBe(selected); + expect(mocks.rm).not.toHaveBeenCalled(); + expect(mocks.stage).not.toHaveBeenCalled(); + await supervisor.shutdown(); +}); diff --git a/electron/src/main/backend.ts b/electron/src/main/backend.ts index e6babe95..b13e49ae 100644 --- a/electron/src/main/backend.ts +++ b/electron/src/main/backend.ts @@ -520,7 +520,7 @@ export class BackendSupervisor extends EventEmitter<{ this.stage !== 'setup_required' ) return; - const project = + let project = this.runtimeProject ?? join(storedRuntimeRoot() ?? defaultRuntimeRoot(), 'project'); this.runtimeProject = project; const controller = new AbortController(); @@ -543,8 +543,27 @@ export class BackendSupervisor extends EventEmitter<{ await this.start(); return; } - const runtimeRoot = dirname(project); + let runtimeRoot = dirname(project); const configured = storedRuntimeLocation(); + if ( + configured && + !configured.owned && + samePath(configured.root, runtimeRoot) && + !samePath(runtimeRoot, defaultRuntimeRoot()) && + existsSync(project) + ) { + // An explicit setup action may create a new runtime, but must never + // take ownership of (or repair in place) another installation's files. + runtimeRoot = defaultRuntimeRoot(); + project = join(runtimeRoot, 'project'); + this.runtimeProject = project; + writeRuntimeLocation(runtimeRoot, true); + this.pushLog( + 'out', + 'Creating a separate Electron runtime; existing environment preserved.', + ); + this.emitStatus(); + } if ( configured && samePath(configured.root, runtimeRoot) && @@ -801,12 +820,10 @@ export class BackendSupervisor extends EventEmitter<{ const own = join(defaultRuntimeRoot(), 'project'); const configuredRoot = storedRuntimeRoot(); const configured = configuredRoot ? join(configuredRoot, 'project') : null; - const candidates = [ - this.runtimeProject, - configured, - own, - ...legacyTauriRuntimeProjects(), - ].filter((candidate): candidate is string => Boolean(candidate)); + // Explicit selection is authoritative, including when it needs setup. + const candidates = ( + configured ? [configured] : [this.runtimeProject, own, ...legacyTauriRuntimeProjects()] + ).filter((candidate): candidate is string => Boolean(candidate)); for (const project of new Set(candidates.map((candidate) => resolve(candidate)))) { if ( ((await runtimeReady(bundle, project)) || (await runtimeCompatible(bundle, project))) &&