From 9d4043270051e82f8621fbfd76c1a00ca35ada0b Mon Sep 17 00:00:00 2001 From: Viktor Olausson Date: Fri, 17 Jul 2026 16:04:28 +0200 Subject: [PATCH 1/4] fix(library): apply saved path without restart --- src/main/library-path-config.ts | 91 ++++++++++++++++++++++++++++++ src/main/python.ts | 21 ++++++- tests/library-path-config.test.js | 92 +++++++++++++++++++++++++++++++ 3 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 src/main/library-path-config.ts create mode 100644 tests/library-path-config.test.js diff --git a/src/main/library-path-config.ts b/src/main/library-path-config.ts new file mode 100644 index 0000000..3dbf49f --- /dev/null +++ b/src/main/library-path-config.ts @@ -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 { + 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 = {}; + + 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' }; +} diff --git a/src/main/python.ts b/src/main/python.ts index 25b99fb..0cc07ab 100644 --- a/src/main/python.ts +++ b/src/main/python.ts @@ -11,6 +11,7 @@ import * as net from 'net'; import * as os from 'os'; import { getActiveSoundfontPath, getDesktopConfig } from './soundfont-manager'; import { isDebugEnabled } from './debug-log'; +import { prepareLibraryPathForPython } from './library-path-config'; let pythonProcess: ChildProcess | null = null; // A backend that is being *gracefully* stopped (SIGTERM sent, async SIGKILL @@ -508,6 +509,18 @@ export async function startPython(): Promise { } catch (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 slopsmithPlugins = path.join(slopsmithDir, 'plugins'); @@ -558,7 +571,6 @@ export async function startPython(): Promise { ...process.env as Record, PYTHONPATH: pythonPathEnv, CONFIG_DIR: configDir, - DLC_DIR: dlcDir, SLOPSMITH_PLUGINS_DIR: pluginsDir, HOME: homeDir, XDG_CACHE_HOME: cacheBase, @@ -572,6 +584,13 @@ export async function startPython(): Promise { : path.join(__dirname, '..', '..', 'resources', 'bin') + path.delimiter ) + (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 // structured logs to a file. lib/logging_setup.py reads LOG_LEVEL and diff --git a/tests/library-path-config.test.js b/tests/library-path-config.test.js new file mode 100644 index 0000000..28e1c65 --- /dev/null +++ b/tests/library-path-config.test.js @@ -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/); +}); From 2dfa414e6446fda60b0ead08d3850c53b8b51e35 Mon Sep 17 00:00:00 2001 From: Viktor Olausson Date: Fri, 17 Jul 2026 16:24:39 +0200 Subject: [PATCH 2/4] fix(library): validate DLC_DIR overrides --- src/main/library-path-config.ts | 17 ++++++++++++++++- src/main/python.ts | 12 +++++------- tests/library-path-config.test.js | 27 +++++++++++++++++++++++++-- 3 files changed, 46 insertions(+), 10 deletions(-) diff --git a/src/main/library-path-config.ts b/src/main/library-path-config.ts index 3dbf49f..422d1e5 100644 --- a/src/main/library-path-config.ts +++ b/src/main/library-path-config.ts @@ -18,6 +18,21 @@ function isPlainObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } +/** + * Normalize an administrator-supplied DLC_DIR and accept it only when it + * already names a directory. Invalid overrides must not shadow config.json. + */ +export function normalizeExplicitLibraryPath(rawPath?: string): string | undefined { + const candidate = (rawPath || '').trim(); + if (!candidate) return undefined; + + try { + return fs.statSync(candidate).isDirectory() ? candidate : undefined; + } catch { + return undefined; + } +} + /** * Prepare the library path contract for the Python backend. * @@ -31,7 +46,7 @@ export function prepareLibraryPathForPython( resolvedDlcDir: string, explicitDlcDir?: string, ): LibraryPathPreparation { - const override = (explicitDlcDir || '').trim(); + const override = normalizeExplicitLibraryPath(explicitDlcDir); if (override) { return { status: 'explicit-override', diff --git a/src/main/python.ts b/src/main/python.ts index 0cc07ab..a3eee35 100644 --- a/src/main/python.ts +++ b/src/main/python.ts @@ -11,7 +11,7 @@ import * as net from 'net'; import * as os from 'os'; import { getActiveSoundfontPath, getDesktopConfig } from './soundfont-manager'; import { isDebugEnabled } from './debug-log'; -import { prepareLibraryPathForPython } from './library-path-config'; +import { normalizeExplicitLibraryPath, prepareLibraryPathForPython } from './library-path-config'; let pythonProcess: ChildProcess | null = null; // A backend that is being *gracefully* stopped (SIGTERM sent, async SIGKILL @@ -424,8 +424,8 @@ function getPluginsDir(): string { return pluginsDir; } -function getDLCDir(): string { - if (process.env.DLC_DIR && fs.existsSync(process.env.DLC_DIR)) return process.env.DLC_DIR; +function getDLCDir(explicitDlcDir = normalizeExplicitLibraryPath(process.env.DLC_DIR)): string { + if (explicitDlcDir) return explicitDlcDir; // Read from shared config const configFile = path.join(getConfigDir(), 'config.json'); @@ -497,7 +497,8 @@ export async function startPython(): Promise { } serverPort = await findPort(PREFERRED_PORT); const configDir = getConfigDir(); - const dlcDir = getDLCDir(); + const explicitDlcDir = normalizeExplicitLibraryPath(process.env.DLC_DIR); + const dlcDir = getDLCDir(explicitDlcDir); // Ensure the resolved library folder exists before the server starts. The // Python side only seeds starter content (and scans) when DLC_DIR.is_dir() // is true, and it can't bootstrap the folder itself (the seed's mkdir runs @@ -514,9 +515,6 @@ export async function startPython(): Promise { // 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}`); diff --git a/tests/library-path-config.test.js b/tests/library-path-config.test.js index 28e1c65..6c786fb 100644 --- a/tests/library-path-config.test.js +++ b/tests/library-path-config.test.js @@ -8,6 +8,7 @@ const path = require('node:path'); const { loadTs, ROOT } = require('./_load-ts'); const { + normalizeExplicitLibraryPath, prepareLibraryPathForPython, } = loadTs('src/main/library-path-config.ts'); @@ -58,19 +59,39 @@ test('an existing saved library stays config-owned and can change between scans' test('an explicit valid DLC_DIR remains an environment override', () => { const configDir = tmpConfigDir(); + const managedSongs = path.join(configDir, 'Managed Songs'); + fs.mkdirSync(managedSongs); const result = prepareLibraryPathForPython( configDir, 'C:\\Default Songs', - ' D:\\Managed Songs ', + ` ${managedSongs} `, ); assert.deepEqual(result, { status: 'explicit-override', - environmentDlcDir: 'D:\\Managed Songs', + environmentDlcDir: managedSongs, }); assert.equal(fs.existsSync(path.join(configDir, 'config.json')), false); }); +test('an explicit DLC_DIR rejects whitespace, files, and missing paths', () => { + const root = tmpConfigDir(); + const file = path.join(root, 'not-a-directory'); + fs.writeFileSync(file, 'x'); + + assert.equal(normalizeExplicitLibraryPath(' '), undefined); + assert.equal(normalizeExplicitLibraryPath(file), undefined); + assert.equal(normalizeExplicitLibraryPath(path.join(root, 'missing')), undefined); +}); + +test('an explicit DLC_DIR is trimmed before directory validation', () => { + const root = tmpConfigDir(); + const directory = path.join(root, 'Managed Songs'); + fs.mkdirSync(directory); + + assert.equal(normalizeExplicitLibraryPath(` ${directory} `), directory); +}); + test('a corrupt config is never overwritten during bootstrap', () => { const configDir = tmpConfigDir(); const configFile = path.join(configDir, 'config.json'); @@ -86,6 +107,8 @@ test('a corrupt config is never overwritten during bootstrap', () => { 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, /normalizeExplicitLibraryPath\(process\.env\.DLC_DIR\)/); + assert.doesNotMatch(source, /existsSync\(process\.env\.DLC_DIR\)/); assert.match(source, /prepareLibraryPathForPython\(configDir, dlcDir, explicitDlcDir\)/); assert.doesNotMatch(source, /DLC_DIR:\s*dlcDir/); assert.match(source, /delete pythonEnv\.DLC_DIR/); From aa388b87d52024f4bf9e7bffee10fda5162fbadf Mon Sep 17 00:00:00 2001 From: Viktor Olausson Date: Fri, 17 Jul 2026 18:42:43 +0200 Subject: [PATCH 3/4] fix(library): validate saved library paths --- src/main/library-path-config.ts | 32 +++++++++-- src/main/python.ts | 20 +++---- tests/library-path-config.test.js | 88 +++++++++++++++++++++++++------ 3 files changed, 110 insertions(+), 30 deletions(-) diff --git a/src/main/library-path-config.ts b/src/main/library-path-config.ts index 422d1e5..30a15be 100644 --- a/src/main/library-path-config.ts +++ b/src/main/library-path-config.ts @@ -19,10 +19,10 @@ function isPlainObject(value: unknown): value is Record { } /** - * Normalize an administrator-supplied DLC_DIR and accept it only when it - * already names a directory. Invalid overrides must not shadow config.json. + * Normalize a configured library path and accept it only when it already + * names a directory. */ -export function normalizeExplicitLibraryPath(rawPath?: string): string | undefined { +export function normalizeExistingLibraryDirectory(rawPath?: string): string | undefined { const candidate = (rawPath || '').trim(); if (!candidate) return undefined; @@ -33,6 +33,22 @@ export function normalizeExplicitLibraryPath(rawPath?: string): string | undefin } } +/** + * Apply the prepared library-path contract to the Python child environment. + * Starting from a copy of process.env means an invalid or stale parent value + * must be removed explicitly when config.json owns the path. + */ +export function applyLibraryPathToPythonEnvironment( + environment: Record, + preparation: LibraryPathPreparation, +): void { + if (preparation.environmentDlcDir) { + environment.DLC_DIR = preparation.environmentDlcDir; + } else { + delete environment.DLC_DIR; + } +} + /** * Prepare the library path contract for the Python backend. * @@ -46,7 +62,7 @@ export function prepareLibraryPathForPython( resolvedDlcDir: string, explicitDlcDir?: string, ): LibraryPathPreparation { - const override = normalizeExplicitLibraryPath(explicitDlcDir); + const override = normalizeExistingLibraryDirectory(explicitDlcDir); if (override) { return { status: 'explicit-override', @@ -76,7 +92,13 @@ export function prepareLibraryPathForPython( const configured = config.dlc_dir; if (typeof configured === 'string' && configured.trim()) { - return { status: 'configured' }; + if (normalizeExistingLibraryDirectory(configured)) { + return { status: 'configured' }; + } + return { + status: 'invalid-config', + error: 'config.json dlc_dir is not an existing directory', + }; } if (configured !== undefined && configured !== null && configured !== '') { return { diff --git a/src/main/python.ts b/src/main/python.ts index a3eee35..3733afa 100644 --- a/src/main/python.ts +++ b/src/main/python.ts @@ -11,7 +11,11 @@ import * as net from 'net'; import * as os from 'os'; import { getActiveSoundfontPath, getDesktopConfig } from './soundfont-manager'; import { isDebugEnabled } from './debug-log'; -import { normalizeExplicitLibraryPath, prepareLibraryPathForPython } from './library-path-config'; +import { + applyLibraryPathToPythonEnvironment, + normalizeExistingLibraryDirectory, + prepareLibraryPathForPython, +} from './library-path-config'; let pythonProcess: ChildProcess | null = null; // A backend that is being *gracefully* stopped (SIGTERM sent, async SIGKILL @@ -424,7 +428,7 @@ function getPluginsDir(): string { return pluginsDir; } -function getDLCDir(explicitDlcDir = normalizeExplicitLibraryPath(process.env.DLC_DIR)): string { +function getDLCDir(explicitDlcDir = normalizeExistingLibraryDirectory(process.env.DLC_DIR)): string { if (explicitDlcDir) return explicitDlcDir; // Read from shared config @@ -497,7 +501,7 @@ export async function startPython(): Promise { } serverPort = await findPort(PREFERRED_PORT); const configDir = getConfigDir(); - const explicitDlcDir = normalizeExplicitLibraryPath(process.env.DLC_DIR); + const explicitDlcDir = normalizeExistingLibraryDirectory(process.env.DLC_DIR); const dlcDir = getDLCDir(explicitDlcDir); // Ensure the resolved library folder exists before the server starts. The // Python side only seeds starter content (and scans) when DLC_DIR.is_dir() @@ -582,13 +586,9 @@ export async function startPython(): Promise { : path.join(__dirname, '..', '..', 'resources', 'bin') + path.delimiter ) + (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; - } + // `...process.env` may carry an empty/invalid value. Do not let it shadow + // config.json in the normal dynamic-settings path. + applyLibraryPathToPythonEnvironment(pythonEnv, libraryPath); // 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 diff --git a/tests/library-path-config.test.js b/tests/library-path-config.test.js index 6c786fb..fa9981b 100644 --- a/tests/library-path-config.test.js +++ b/tests/library-path-config.test.js @@ -5,10 +5,11 @@ 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 { loadTs } = require('./_load-ts'); const { - normalizeExplicitLibraryPath, + applyLibraryPathToPythonEnvironment, + normalizeExistingLibraryDirectory, prepareLibraryPathForPython, } = loadTs('src/main/library-path-config.ts'); @@ -45,18 +46,47 @@ test('bootstrap merges the fallback into an existing config without losing setti 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 savedSongs = path.join(configDir, 'Saved Songs'); + fs.mkdirSync(savedSongs); + fs.writeFileSync(configFile, JSON.stringify({ dlc_dir: savedSongs })); 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' }, + { dlc_dir: savedSongs }, ); assert.equal(result.environmentDlcDir, undefined); }); +test('a missing saved library is reported without overwriting the intended path', () => { + const configDir = tmpConfigDir(); + const configFile = path.join(configDir, 'config.json'); + const missingSongs = path.join(configDir, 'Disconnected Songs'); + fs.writeFileSync(configFile, JSON.stringify({ dlc_dir: missingSongs })); + + const result = prepareLibraryPathForPython(configDir, 'C:\\Default Songs'); + + assert.equal(result.status, 'invalid-config'); + assert.match(result.error, /not an existing directory/); + assert.deepEqual(JSON.parse(fs.readFileSync(configFile, 'utf8')), { dlc_dir: missingSongs }); +}); + +test('a saved library file is rejected without overwriting the configured value', () => { + const configDir = tmpConfigDir(); + const configFile = path.join(configDir, 'config.json'); + const file = path.join(configDir, 'not-a-library'); + fs.writeFileSync(file, 'x'); + fs.writeFileSync(configFile, JSON.stringify({ dlc_dir: file })); + + const result = prepareLibraryPathForPython(configDir, 'C:\\Default Songs'); + + assert.equal(result.status, 'invalid-config'); + assert.match(result.error, /not an existing directory/); + assert.deepEqual(JSON.parse(fs.readFileSync(configFile, 'utf8')), { dlc_dir: file }); +}); + test('an explicit valid DLC_DIR remains an environment override', () => { const configDir = tmpConfigDir(); const managedSongs = path.join(configDir, 'Managed Songs'); @@ -79,9 +109,9 @@ test('an explicit DLC_DIR rejects whitespace, files, and missing paths', () => { const file = path.join(root, 'not-a-directory'); fs.writeFileSync(file, 'x'); - assert.equal(normalizeExplicitLibraryPath(' '), undefined); - assert.equal(normalizeExplicitLibraryPath(file), undefined); - assert.equal(normalizeExplicitLibraryPath(path.join(root, 'missing')), undefined); + assert.equal(normalizeExistingLibraryDirectory(' '), undefined); + assert.equal(normalizeExistingLibraryDirectory(file), undefined); + assert.equal(normalizeExistingLibraryDirectory(path.join(root, 'missing')), undefined); }); test('an explicit DLC_DIR is trimmed before directory validation', () => { @@ -89,7 +119,7 @@ test('an explicit DLC_DIR is trimmed before directory validation', () => { const directory = path.join(root, 'Managed Songs'); fs.mkdirSync(directory); - assert.equal(normalizeExplicitLibraryPath(` ${directory} `), directory); + assert.equal(normalizeExistingLibraryDirectory(` ${directory} `), directory); }); test('a corrupt config is never overwritten during bootstrap', () => { @@ -104,12 +134,40 @@ test('a corrupt config is never overwritten during bootstrap', () => { 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'); +test('Python environment omits DLC_DIR when config owns the library path', () => { + const environment = { DLC_DIR: 'C:\\Stale Parent Value', KEEP: 'yes' }; - assert.match(source, /normalizeExplicitLibraryPath\(process\.env\.DLC_DIR\)/); - assert.doesNotMatch(source, /existsSync\(process\.env\.DLC_DIR\)/); - assert.match(source, /prepareLibraryPathForPython\(configDir, dlcDir, explicitDlcDir\)/); - assert.doesNotMatch(source, /DLC_DIR:\s*dlcDir/); - assert.match(source, /delete pythonEnv\.DLC_DIR/); + applyLibraryPathToPythonEnvironment(environment, { status: 'configured' }); + + assert.deepEqual(environment, { KEEP: 'yes' }); +}); + +test('Python environment exports only a validated explicit DLC_DIR', () => { + const configDir = tmpConfigDir(); + const managedSongs = path.join(configDir, 'Managed Songs'); + fs.mkdirSync(managedSongs); + const preparation = prepareLibraryPathForPython( + configDir, + 'C:\\Default Songs', + ` ${managedSongs} `, + ); + const environment = { DLC_DIR: 'C:\\Stale Parent Value', KEEP: 'yes' }; + + applyLibraryPathToPythonEnvironment(environment, preparation); + + assert.deepEqual(environment, { DLC_DIR: managedSongs, KEEP: 'yes' }); +}); + +test('Python environment removes an invalid explicit DLC_DIR', () => { + const configDir = tmpConfigDir(); + const fallback = path.join(configDir, 'Fallback Songs'); + fs.mkdirSync(fallback); + const file = path.join(configDir, 'not-a-directory'); + fs.writeFileSync(file, 'x'); + const preparation = prepareLibraryPathForPython(configDir, fallback, file); + const environment = { DLC_DIR: file }; + + applyLibraryPathToPythonEnvironment(environment, preparation); + + assert.deepEqual(environment, {}); }); From 6eeec8e2a6bf6d8c6f9292faf499b39940595390 Mon Sep 17 00:00:00 2001 From: OmikronApex Date: Fri, 17 Jul 2026 23:16:55 +0200 Subject: [PATCH 4/4] fix(library): keep the resolved env fallback on failure statuses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backend has no built-in library default — _get_dlc_dir() returns None when DLC_DIR is unset and config.json is unusable — so deleting DLC_DIR on invalid-config/write-failed stranded the user with an empty library and only a console warn (corrupt config.json, or a saved dlc_dir pointing at an unplugged drive, previously still produced a working library via the env fallback). invalid-config and write-failed now export the resolved fallback as DLC_DIR, restoring pre-#117 behaviour exactly and only where config.json cannot own the path. Config-owned dynamic refresh is unchanged for the configured/bootstrapped paths. Co-Authored-By: Claude Fable 5 --- src/main/library-path-config.ts | 11 ++++++++ tests/library-path-config.test.js | 43 +++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/src/main/library-path-config.ts b/src/main/library-path-config.ts index 30a15be..ab0ea3f 100644 --- a/src/main/library-path-config.ts +++ b/src/main/library-path-config.ts @@ -56,6 +56,12 @@ export function applyLibraryPathToPythonEnvironment( * 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. + * + * Failure statuses (invalid-config, write-failed) still export the resolved + * fallback as DLC_DIR: the backend has NO built-in default (_get_dlc_dir + * returns None), so removing the env var when config.json cannot own the + * path would strand the user with an empty library and only a console warn. + * Config-owned dynamic behaviour applies exactly when config.json is usable. */ export function prepareLibraryPathForPython( configDir: string, @@ -79,6 +85,7 @@ export function prepareLibraryPathForPython( if (!isPlainObject(parsed)) { return { status: 'invalid-config', + environmentDlcDir: resolvedDlcDir, error: 'config.json is not a JSON object', }; } @@ -86,6 +93,7 @@ export function prepareLibraryPathForPython( } catch (err) { return { status: 'invalid-config', + environmentDlcDir: resolvedDlcDir, error: err instanceof Error ? err.message : String(err), }; } @@ -97,12 +105,14 @@ export function prepareLibraryPathForPython( } return { status: 'invalid-config', + environmentDlcDir: resolvedDlcDir, error: 'config.json dlc_dir is not an existing directory', }; } if (configured !== undefined && configured !== null && configured !== '') { return { status: 'invalid-config', + environmentDlcDir: resolvedDlcDir, error: 'config.json dlc_dir is not a string', }; } @@ -120,6 +130,7 @@ export function prepareLibraryPathForPython( } catch { /* best-effort temporary-file cleanup */ } return { status: 'write-failed', + environmentDlcDir: resolvedDlcDir, error: err instanceof Error ? err.message : String(err), }; } diff --git a/tests/library-path-config.test.js b/tests/library-path-config.test.js index fa9981b..051df4d 100644 --- a/tests/library-path-config.test.js +++ b/tests/library-path-config.test.js @@ -134,6 +134,49 @@ test('a corrupt config is never overwritten during bootstrap', () => { assert.equal(fs.readFileSync(configFile, 'utf8'), '{broken'); }); +test('invalid-config exports the resolved fallback so the backend keeps a library', () => { + // The backend has no built-in default (_get_dlc_dir returns None): with + // the env var deleted AND config.json unusable, scans and starter + // seeding would silently skip. Failure statuses must keep the pre-#117 + // env fallback. + const configDir = tmpConfigDir(); + const configFile = path.join(configDir, 'config.json'); + fs.writeFileSync(configFile, JSON.stringify({ dlc_dir: path.join(configDir, 'gone') })); + + const result = prepareLibraryPathForPython(configDir, 'C:\\Default Songs'); + + assert.equal(result.status, 'invalid-config'); + assert.equal(result.environmentDlcDir, 'C:\\Default Songs'); + + const environment = {}; + applyLibraryPathToPythonEnvironment(environment, result); + assert.deepEqual(environment, { DLC_DIR: 'C:\\Default Songs' }); +}); + +test('corrupt config exports the resolved fallback without being overwritten', () => { + 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.equal(result.environmentDlcDir, 'C:\\Default Songs'); + assert.equal(fs.readFileSync(configFile, 'utf8'), '{broken'); +}); + +test('write-failed exports the resolved fallback so the backend keeps a library', () => { + // Force the config write to fail by making config.json's parent a file. + const root = tmpConfigDir(); + const configDir = path.join(root, 'not-a-dir'); + fs.writeFileSync(configDir, 'x'); + + const result = prepareLibraryPathForPython(configDir, 'C:\\Default Songs'); + + assert.equal(result.status, 'write-failed'); + assert.equal(result.environmentDlcDir, 'C:\\Default Songs'); +}); + test('Python environment omits DLC_DIR when config owns the library path', () => { const environment = { DLC_DIR: 'C:\\Stale Parent Value', KEEP: 'yes' };