fix(library): apply saved path without restart

This commit is contained in:
Viktor Olausson
2026-07-17 16:04:28 +02:00
parent dee918e705
commit 9d40432700
3 changed files with 203 additions and 1 deletions
+91
View File
@@ -0,0 +1,91 @@
import * as fs from 'fs';
import * as path from 'path';
export type LibraryPathPreparationStatus =
| 'explicit-override'
| 'configured'
| 'bootstrapped'
| 'invalid-config'
| 'write-failed';
export interface LibraryPathPreparation {
status: LibraryPathPreparationStatus;
environmentDlcDir?: string;
error?: string;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/**
* Prepare the library path contract for the Python backend.
*
* An explicit DLC_DIR remains an administrator-owned environment override.
* Normal desktop launches instead keep the selected path in config.json so
* the backend can re-read a Settings change on the next manual scan without
* requiring a process restart.
*/
export function prepareLibraryPathForPython(
configDir: string,
resolvedDlcDir: string,
explicitDlcDir?: string,
): LibraryPathPreparation {
const override = (explicitDlcDir || '').trim();
if (override) {
return {
status: 'explicit-override',
environmentDlcDir: override,
};
}
const configFile = path.join(configDir, 'config.json');
let config: Record<string, unknown> = {};
if (fs.existsSync(configFile)) {
try {
const parsed: unknown = JSON.parse(fs.readFileSync(configFile, 'utf8'));
if (!isPlainObject(parsed)) {
return {
status: 'invalid-config',
error: 'config.json is not a JSON object',
};
}
config = parsed;
} catch (err) {
return {
status: 'invalid-config',
error: err instanceof Error ? err.message : String(err),
};
}
const configured = config.dlc_dir;
if (typeof configured === 'string' && configured.trim()) {
return { status: 'configured' };
}
if (configured !== undefined && configured !== null && configured !== '') {
return {
status: 'invalid-config',
error: 'config.json dlc_dir is not a string',
};
}
}
config.dlc_dir = resolvedDlcDir;
const tmpFile = configFile + '.tmp';
try {
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(tmpFile, JSON.stringify(config, null, 2), 'utf8');
fs.renameSync(tmpFile, configFile);
} catch (err) {
try {
if (fs.existsSync(tmpFile)) fs.rmSync(tmpFile, { force: true });
} catch { /* best-effort temporary-file cleanup */ }
return {
status: 'write-failed',
error: err instanceof Error ? err.message : String(err),
};
}
return { status: 'bootstrapped' };
}
+20 -1
View File
@@ -11,6 +11,7 @@ import * as net from 'net';
import * as os from 'os'; import * as os from 'os';
import { getActiveSoundfontPath, getDesktopConfig } from './soundfont-manager'; import { getActiveSoundfontPath, getDesktopConfig } from './soundfont-manager';
import { isDebugEnabled } from './debug-log'; import { isDebugEnabled } from './debug-log';
import { prepareLibraryPathForPython } from './library-path-config';
let pythonProcess: ChildProcess | null = null; let pythonProcess: ChildProcess | null = null;
// A backend that is being *gracefully* stopped (SIGTERM sent, async SIGKILL // A backend that is being *gracefully* stopped (SIGTERM sent, async SIGKILL
@@ -508,6 +509,18 @@ export async function startPython(): Promise<void> {
} catch (err) { } catch (err) {
console.warn(`[python] could not create DLC dir ${dlcDir}:`, err); console.warn(`[python] could not create DLC dir ${dlcDir}:`, err);
} }
// Preserve a caller-supplied DLC_DIR as an explicit administrator override.
// Normal desktop launches bootstrap the initial/default path into config.json
// instead. The backend re-reads that file for every scan, so a path saved in
// Settings takes effect immediately rather than being shadowed by the
// startup path until the Python process restarts.
const explicitDlcDir = process.env.DLC_DIR && fs.existsSync(process.env.DLC_DIR)
? process.env.DLC_DIR
: undefined;
const libraryPath = prepareLibraryPathForPython(configDir, dlcDir, explicitDlcDir);
if (libraryPath.error) {
console.warn(`[python] could not prepare dynamic library path (${libraryPath.status}): ${libraryPath.error}`);
}
const pluginsDir = getPluginsDir(); const pluginsDir = getPluginsDir();
const slopsmithPlugins = path.join(slopsmithDir, 'plugins'); const slopsmithPlugins = path.join(slopsmithDir, 'plugins');
@@ -558,7 +571,6 @@ export async function startPython(): Promise<void> {
...process.env as Record<string, string>, ...process.env as Record<string, string>,
PYTHONPATH: pythonPathEnv, PYTHONPATH: pythonPathEnv,
CONFIG_DIR: configDir, CONFIG_DIR: configDir,
DLC_DIR: dlcDir,
SLOPSMITH_PLUGINS_DIR: pluginsDir, SLOPSMITH_PLUGINS_DIR: pluginsDir,
HOME: homeDir, HOME: homeDir,
XDG_CACHE_HOME: cacheBase, XDG_CACHE_HOME: cacheBase,
@@ -572,6 +584,13 @@ export async function startPython(): Promise<void> {
: path.join(__dirname, '..', '..', 'resources', 'bin') + path.delimiter : path.join(__dirname, '..', '..', 'resources', 'bin') + path.delimiter
) + (process.env.PATH || ''), ) + (process.env.PATH || ''),
}; };
if (libraryPath.environmentDlcDir) {
pythonEnv.DLC_DIR = libraryPath.environmentDlcDir;
} else {
// `...process.env` may carry an empty/invalid value. Do not let it
// shadow config.json in the normal dynamic-settings path.
delete pythonEnv.DLC_DIR;
}
// Debug mode: raise the Slopsmith server's log level and tee its // Debug mode: raise the Slopsmith server's log level and tee its
// structured logs to a file. lib/logging_setup.py reads LOG_LEVEL and // structured logs to a file. lib/logging_setup.py reads LOG_LEVEL and
+92
View File
@@ -0,0 +1,92 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { loadTs, ROOT } = require('./_load-ts');
const {
prepareLibraryPathForPython,
} = loadTs('src/main/library-path-config.ts');
function tmpConfigDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'feedback-library-path-'));
}
test('normal desktop startup bootstraps the fallback into config instead of DLC_DIR', () => {
const configDir = tmpConfigDir();
const result = prepareLibraryPathForPython(configDir, 'C:\\Music\\fee[dB]ack');
assert.deepEqual(result, { status: 'bootstrapped' });
assert.deepEqual(
JSON.parse(fs.readFileSync(path.join(configDir, 'config.json'), 'utf8')),
{ dlc_dir: 'C:\\Music\\fee[dB]ack' },
);
assert.equal(result.environmentDlcDir, undefined);
});
test('bootstrap merges the fallback into an existing config without losing settings', () => {
const configDir = tmpConfigDir();
const configFile = path.join(configDir, 'config.json');
fs.writeFileSync(configFile, JSON.stringify({ master_difficulty: 75 }));
const result = prepareLibraryPathForPython(configDir, 'D:\\Songs');
assert.equal(result.status, 'bootstrapped');
assert.deepEqual(
JSON.parse(fs.readFileSync(configFile, 'utf8')),
{ master_difficulty: 75, dlc_dir: 'D:\\Songs' },
);
});
test('an existing saved library stays config-owned and can change between scans', () => {
const configDir = tmpConfigDir();
const configFile = path.join(configDir, 'config.json');
fs.writeFileSync(configFile, JSON.stringify({ dlc_dir: 'D:\\Saved Songs' }));
const result = prepareLibraryPathForPython(configDir, 'C:\\Default Songs');
assert.deepEqual(result, { status: 'configured' });
assert.deepEqual(
JSON.parse(fs.readFileSync(configFile, 'utf8')),
{ dlc_dir: 'D:\\Saved Songs' },
);
assert.equal(result.environmentDlcDir, undefined);
});
test('an explicit valid DLC_DIR remains an environment override', () => {
const configDir = tmpConfigDir();
const result = prepareLibraryPathForPython(
configDir,
'C:\\Default Songs',
' D:\\Managed Songs ',
);
assert.deepEqual(result, {
status: 'explicit-override',
environmentDlcDir: 'D:\\Managed Songs',
});
assert.equal(fs.existsSync(path.join(configDir, 'config.json')), false);
});
test('a corrupt config is never overwritten during bootstrap', () => {
const configDir = tmpConfigDir();
const configFile = path.join(configDir, 'config.json');
fs.writeFileSync(configFile, '{broken');
const result = prepareLibraryPathForPython(configDir, 'C:\\Default Songs');
assert.equal(result.status, 'invalid-config');
assert.match(result.error, /JSON/);
assert.equal(fs.readFileSync(configFile, 'utf8'), '{broken');
});
test('python startup does not pin its resolved fallback as DLC_DIR', () => {
const source = fs.readFileSync(path.join(ROOT, 'src', 'main', 'python.ts'), 'utf8');
assert.match(source, /prepareLibraryPathForPython\(configDir, dlcDir, explicitDlcDir\)/);
assert.doesNotMatch(source, /DLC_DIR:\s*dlcDir/);
assert.match(source, /delete pythonEnv\.DLC_DIR/);
});