fix(electron): preserve selected runtime ownership during repair

This commit is contained in:
Palash Debnath
2026-09-17 20:23:05 +05:30
parent 31baa43650
commit d6efb14849
3 changed files with 87 additions and 9 deletions
+57 -1
View File
@@ -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<typeof import('node:fs')>();
return {
...original,
readFileSync: (...args: Parameters<typeof original.readFileSync>) =>
String(args[0]).endsWith('runtime-location.json') && mocks.runtimeConfig
? JSON.stringify(mocks.runtimeConfig)
: original.readFileSync(...args),
writeFileSync: (...args: Parameters<typeof original.writeFileSync>) => {
if (String(args[0]).endsWith('runtime-location.json')) {
mocks.runtimeConfig = JSON.parse(String(args[1]));
return;
}
return original.writeFileSync(...args);
},
mkdirSync: (...args: Parameters<typeof original.mkdirSync>) =>
String(args[0]).includes('private') ? undefined : original.mkdirSync(...args),
existsSync: (path: Parameters<typeof original.existsSync>[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();
});
+25 -8
View File
@@ -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))) &&