mirror of
https://github.com/got-feedBack/feedBack-desktop.git
synced 2026-09-11 05:44:10 +00:00
fix(library): apply saved path without restart
This commit is contained in:
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user