fix: normalize platform-specific config paths

This commit is contained in:
2026-03-08 18:37:25 -07:00
parent 9af0264792
commit 4b85a82352
6 changed files with 93 additions and 58 deletions

View File

@@ -5,6 +5,10 @@ import { log } from '../log.js';
import type { LogLevel, PluginRuntimeConfig } from '../types.js'; import type { LogLevel, PluginRuntimeConfig } from '../types.js';
import { DEFAULT_SOCKET_PATH } from '../types.js'; import { DEFAULT_SOCKET_PATH } from '../types.js';
function getPlatformPath(platform: NodeJS.Platform): typeof path.posix | typeof path.win32 {
return platform === 'win32' ? path.win32 : path.posix;
}
export function getPluginConfigCandidates(options?: { export function getPluginConfigCandidates(options?: {
platform?: NodeJS.Platform; platform?: NodeJS.Platform;
homeDir?: string; homeDir?: string;
@@ -13,21 +17,24 @@ export function getPluginConfigCandidates(options?: {
}): string[] { }): string[] {
const platform = options?.platform ?? process.platform; const platform = options?.platform ?? process.platform;
const homeDir = options?.homeDir ?? os.homedir(); const homeDir = options?.homeDir ?? os.homedir();
const platformPath = getPlatformPath(platform);
if (platform === 'win32') { if (platform === 'win32') {
const appDataDir = const appDataDir =
options?.appDataDir?.trim() || options?.appDataDir?.trim() ||
process.env.APPDATA?.trim() || process.env.APPDATA?.trim() ||
path.join(homeDir, 'AppData', 'Roaming'); platformPath.join(homeDir, 'AppData', 'Roaming');
return [path.join(appDataDir, 'mpv', 'script-opts', 'subminer.conf')]; return [platformPath.join(appDataDir, 'mpv', 'script-opts', 'subminer.conf')];
} }
const xdgConfigHome = const xdgConfigHome =
options?.xdgConfigHome?.trim() || process.env.XDG_CONFIG_HOME || path.join(homeDir, '.config'); options?.xdgConfigHome?.trim() ||
process.env.XDG_CONFIG_HOME ||
platformPath.join(homeDir, '.config');
return Array.from( return Array.from(
new Set([ new Set([
path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'), platformPath.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
path.join(homeDir, '.config', 'mpv', 'script-opts', 'subminer.conf'), platformPath.join(homeDir, '.config', 'mpv', 'script-opts', 'subminer.conf'),
]), ]),
); );
} }

View File

@@ -81,14 +81,14 @@ test('config path uses XDG_CONFIG_HOME override', () => {
test('config discovery ignores lowercase subminer candidate', () => { test('config discovery ignores lowercase subminer candidate', () => {
const homeDir = '/home/tester'; const homeDir = '/home/tester';
const xdgConfigHome = '/tmp/xdg-config'; const xdgConfigHome = '/tmp/xdg-config';
const expected = path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'); const expected = path.posix.join(xdgConfigHome, 'SubMiner', 'config.jsonc');
const foundPaths = new Set([path.join(xdgConfigHome, 'subminer', 'config.json')]); const foundPaths = new Set([path.posix.join(xdgConfigHome, 'subminer', 'config.json')]);
const resolved = resolveConfigFilePath({ const resolved = resolveConfigFilePath({
xdgConfigHome, xdgConfigHome,
homeDir, homeDir,
platform: 'linux', platform: 'linux',
existsSync: (candidate) => foundPaths.has(path.normalize(candidate)), existsSync: (candidate) => foundPaths.has(path.posix.normalize(candidate)),
}); });
assert.equal(resolved, expected); assert.equal(resolved, expected);

View File

@@ -11,7 +11,7 @@ function existsSyncFrom(paths: string[]): (candidate: string) => boolean {
test('resolveConfigBaseDirs trims xdg value and deduplicates fallback dir', () => { test('resolveConfigBaseDirs trims xdg value and deduplicates fallback dir', () => {
const homeDir = '/home/tester'; const homeDir = '/home/tester';
const trimmedXdgConfigHome = '/home/tester/.config'; const trimmedXdgConfigHome = '/home/tester/.config';
const fallbackDir = path.join(homeDir, '.config'); const fallbackDir = path.posix.join(homeDir, '.config');
const baseDirs = resolveConfigBaseDirs(` ${trimmedXdgConfigHome} `, homeDir, 'linux'); const baseDirs = resolveConfigBaseDirs(` ${trimmedXdgConfigHome} `, homeDir, 'linux');
const expected = Array.from(new Set([trimmedXdgConfigHome, fallbackDir])); const expected = Array.from(new Set([trimmedXdgConfigHome, fallbackDir]));
assert.deepEqual(baseDirs, expected); assert.deepEqual(baseDirs, expected);
@@ -29,8 +29,8 @@ test('resolveConfigBaseDirs prefers APPDATA on windows and deduplicates fallback
test('resolveConfigDir prefers xdg SubMiner config when present', () => { test('resolveConfigDir prefers xdg SubMiner config when present', () => {
const homeDir = '/home/tester'; const homeDir = '/home/tester';
const xdgConfigHome = '/tmp/xdg-config'; const xdgConfigHome = '/tmp/xdg-config';
const configDir = path.join(xdgConfigHome, 'SubMiner'); const configDir = path.posix.join(xdgConfigHome, 'SubMiner');
const existsSync = existsSyncFrom([path.join(configDir, 'config.jsonc')]); const existsSync = existsSyncFrom([path.posix.join(configDir, 'config.jsonc')]);
const resolved = resolveConfigDir({ const resolved = resolveConfigDir({
xdgConfigHome, xdgConfigHome,
@@ -54,12 +54,12 @@ test('resolveConfigDir ignores lowercase subminer candidate', () => {
existsSync, existsSync,
}); });
assert.equal(resolved, path.join('/tmp/missing-xdg', 'SubMiner')); assert.equal(resolved, path.posix.join('/tmp/missing-xdg', 'SubMiner'));
}); });
test('resolveConfigDir falls back to existing directory when file is missing', () => { test('resolveConfigDir falls back to existing directory when file is missing', () => {
const homeDir = '/home/tester'; const homeDir = '/home/tester';
const configDir = path.join(homeDir, '.config', 'SubMiner'); const configDir = path.posix.join(homeDir, '.config', 'SubMiner');
const existsSync = existsSyncFrom([configDir]); const existsSync = existsSyncFrom([configDir]);
const resolved = resolveConfigDir({ const resolved = resolveConfigDir({
@@ -76,8 +76,8 @@ test('resolveConfigFilePath prefers jsonc before json', () => {
const homeDir = '/home/tester'; const homeDir = '/home/tester';
const xdgConfigHome = '/tmp/xdg-config'; const xdgConfigHome = '/tmp/xdg-config';
const existsSync = existsSyncFrom([ const existsSync = existsSyncFrom([
path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'), path.posix.join(xdgConfigHome, 'SubMiner', 'config.jsonc'),
path.join(xdgConfigHome, 'SubMiner', 'config.json'), path.posix.join(xdgConfigHome, 'SubMiner', 'config.json'),
]); ]);
const resolved = resolveConfigFilePath({ const resolved = resolveConfigFilePath({
@@ -87,7 +87,7 @@ test('resolveConfigFilePath prefers jsonc before json', () => {
existsSync, existsSync,
}); });
assert.equal(resolved, path.join(xdgConfigHome, 'SubMiner', 'config.jsonc')); assert.equal(resolved, path.posix.join(xdgConfigHome, 'SubMiner', 'config.jsonc'));
}); });
test('resolveConfigFilePath keeps legacy fallback output path', () => { test('resolveConfigFilePath keeps legacy fallback output path', () => {
@@ -102,14 +102,14 @@ test('resolveConfigFilePath keeps legacy fallback output path', () => {
existsSync, existsSync,
}); });
assert.equal(resolved, path.join(xdgConfigHome, 'SubMiner', 'config.jsonc')); assert.equal(resolved, path.posix.join(xdgConfigHome, 'SubMiner', 'config.jsonc'));
}); });
test('resolveConfigDir prefers APPDATA SubMiner config on windows when present', () => { test('resolveConfigDir prefers APPDATA SubMiner config on windows when present', () => {
const homeDir = 'C:\\Users\\tester'; const homeDir = 'C:\\Users\\tester';
const appDataDir = 'C:\\Users\\tester\\AppData\\Roaming'; const appDataDir = 'C:\\Users\\tester\\AppData\\Roaming';
const configDir = path.join(appDataDir, 'SubMiner'); const configDir = path.win32.join(appDataDir, 'SubMiner');
const existsSync = existsSyncFrom([path.join(configDir, 'config.jsonc')]); const existsSync = existsSyncFrom([path.win32.join(configDir, 'config.jsonc')]);
const resolved = resolveConfigDir({ const resolved = resolveConfigDir({
platform: 'win32', platform: 'win32',
@@ -133,5 +133,5 @@ test('resolveConfigFilePath uses APPDATA fallback output path on windows', () =>
existsSync, existsSync,
}); });
assert.equal(resolved, path.join(appDataDir, 'SubMiner', 'config.jsonc')); assert.equal(resolved, path.win32.join(appDataDir, 'SubMiner', 'config.jsonc'));
}); });

View File

@@ -15,19 +15,24 @@ type ConfigPathOptions = {
const DEFAULT_APP_NAMES = ['SubMiner'] as const; const DEFAULT_APP_NAMES = ['SubMiner'] as const;
const DEFAULT_FILE_NAMES = ['config.jsonc', 'config.json'] as const; const DEFAULT_FILE_NAMES = ['config.jsonc', 'config.json'] as const;
function getPlatformPath(platform: NodeJS.Platform): typeof path.posix | typeof path.win32 {
return platform === 'win32' ? path.win32 : path.posix;
}
export function resolveConfigBaseDirs( export function resolveConfigBaseDirs(
xdgConfigHome: string | undefined, xdgConfigHome: string | undefined,
homeDir: string, homeDir: string,
platform: NodeJS.Platform = process.platform, platform: NodeJS.Platform = process.platform,
appDataDir?: string, appDataDir?: string,
): string[] { ): string[] {
const platformPath = getPlatformPath(platform);
if (platform === 'win32') { if (platform === 'win32') {
const roamingBaseDir = path.join(homeDir, 'AppData', 'Roaming'); const roamingBaseDir = platformPath.join(homeDir, 'AppData', 'Roaming');
const primaryBaseDir = appDataDir?.trim() || roamingBaseDir; const primaryBaseDir = appDataDir?.trim() || roamingBaseDir;
return Array.from(new Set([primaryBaseDir, roamingBaseDir])); return Array.from(new Set([primaryBaseDir, roamingBaseDir]));
} }
const fallbackBaseDir = path.join(homeDir, '.config'); const fallbackBaseDir = platformPath.join(homeDir, '.config');
const primaryBaseDir = xdgConfigHome?.trim() || fallbackBaseDir; const primaryBaseDir = xdgConfigHome?.trim() || fallbackBaseDir;
return Array.from(new Set([primaryBaseDir, fallbackBaseDir])); return Array.from(new Set([primaryBaseDir, fallbackBaseDir]));
} }
@@ -41,19 +46,21 @@ function getDefaultAppName(options: ConfigPathOptions): string {
} }
export function resolveConfigDir(options: ConfigPathOptions): string { export function resolveConfigDir(options: ConfigPathOptions): string {
const platform = options.platform ?? process.platform;
const platformPath = getPlatformPath(platform);
const baseDirs = resolveConfigBaseDirs( const baseDirs = resolveConfigBaseDirs(
options.xdgConfigHome, options.xdgConfigHome,
options.homeDir, options.homeDir,
options.platform, platform,
options.appDataDir, options.appDataDir,
); );
const appNames = getAppNames(options); const appNames = getAppNames(options);
for (const baseDir of baseDirs) { for (const baseDir of baseDirs) {
for (const appName of appNames) { for (const appName of appNames) {
const dir = path.join(baseDir, appName); const dir = platformPath.join(baseDir, appName);
for (const fileName of DEFAULT_FILE_NAMES) { for (const fileName of DEFAULT_FILE_NAMES) {
if (options.existsSync(path.join(dir, fileName))) { if (options.existsSync(platformPath.join(dir, fileName))) {
return dir; return dir;
} }
} }
@@ -62,21 +69,23 @@ export function resolveConfigDir(options: ConfigPathOptions): string {
for (const baseDir of baseDirs) { for (const baseDir of baseDirs) {
for (const appName of appNames) { for (const appName of appNames) {
const dir = path.join(baseDir, appName); const dir = platformPath.join(baseDir, appName);
if (options.existsSync(dir)) { if (options.existsSync(dir)) {
return dir; return dir;
} }
} }
} }
return path.join(baseDirs[0]!, getDefaultAppName(options)); return platformPath.join(baseDirs[0]!, getDefaultAppName(options));
} }
export function resolveConfigFilePath(options: ConfigPathOptions): string { export function resolveConfigFilePath(options: ConfigPathOptions): string {
const platform = options.platform ?? process.platform;
const platformPath = getPlatformPath(platform);
const baseDirs = resolveConfigBaseDirs( const baseDirs = resolveConfigBaseDirs(
options.xdgConfigHome, options.xdgConfigHome,
options.homeDir, options.homeDir,
options.platform, platform,
options.appDataDir, options.appDataDir,
); );
const appNames = getAppNames(options); const appNames = getAppNames(options);
@@ -84,7 +93,7 @@ export function resolveConfigFilePath(options: ConfigPathOptions): string {
for (const baseDir of baseDirs) { for (const baseDir of baseDirs) {
for (const appName of appNames) { for (const appName of appNames) {
for (const fileName of DEFAULT_FILE_NAMES) { for (const fileName of DEFAULT_FILE_NAMES) {
const candidate = path.join(baseDir, appName, fileName); const candidate = platformPath.join(baseDir, appName, fileName);
if (options.existsSync(candidate)) { if (options.existsSync(candidate)) {
return candidate; return candidate;
} }
@@ -92,5 +101,5 @@ export function resolveConfigFilePath(options: ConfigPathOptions): string {
} }
} }
return path.join(baseDirs[0]!, getDefaultAppName(options), DEFAULT_FILE_NAMES[0]!); return platformPath.join(baseDirs[0]!, getDefaultAppName(options), DEFAULT_FILE_NAMES[0]!);
} }

View File

@@ -30,10 +30,11 @@ test('getDefaultConfigDir prefers existing SubMiner config directory', () => {
platform: 'linux', platform: 'linux',
xdgConfigHome, xdgConfigHome,
homeDir, homeDir,
existsSync: (candidate) => candidate === path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'), existsSync: (candidate) =>
candidate === path.posix.join(xdgConfigHome, 'SubMiner', 'config.jsonc'),
}); });
assert.equal(dir, path.join(xdgConfigHome, 'SubMiner')); assert.equal(dir, path.posix.join(xdgConfigHome, 'SubMiner'));
}); });
test('ensureDefaultConfigBootstrap creates config dir and default jsonc only when missing', () => { test('ensureDefaultConfigBootstrap creates config dir and default jsonc only when missing', () => {
@@ -138,27 +139,27 @@ test('resolveDefaultMpvInstallPaths resolves linux, macOS, and Windows defaults'
const xdgConfigHome = path.join(path.sep, 'tmp', 'xdg'); const xdgConfigHome = path.join(path.sep, 'tmp', 'xdg');
assert.deepEqual(resolveDefaultMpvInstallPaths('linux', linuxHomeDir, xdgConfigHome), { assert.deepEqual(resolveDefaultMpvInstallPaths('linux', linuxHomeDir, xdgConfigHome), {
supported: true, supported: true,
mpvConfigDir: path.join(xdgConfigHome, 'mpv'), mpvConfigDir: path.posix.join(xdgConfigHome, 'mpv'),
scriptsDir: path.join(xdgConfigHome, 'mpv', 'scripts'), scriptsDir: path.posix.join(xdgConfigHome, 'mpv', 'scripts'),
scriptOptsDir: path.join(xdgConfigHome, 'mpv', 'script-opts'), scriptOptsDir: path.posix.join(xdgConfigHome, 'mpv', 'script-opts'),
pluginEntrypointPath: path.join(xdgConfigHome, 'mpv', 'scripts', 'subminer', 'main.lua'), pluginEntrypointPath: path.posix.join(xdgConfigHome, 'mpv', 'scripts', 'subminer', 'main.lua'),
pluginDir: path.join(xdgConfigHome, 'mpv', 'scripts', 'subminer'), pluginDir: path.posix.join(xdgConfigHome, 'mpv', 'scripts', 'subminer'),
pluginConfigPath: path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'), pluginConfigPath: path.posix.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
}); });
const macHomeDir = path.join(path.sep, 'Users', 'tester'); const macHomeDir = path.join(path.sep, 'Users', 'tester');
assert.deepEqual(resolveDefaultMpvInstallPaths('darwin', macHomeDir, undefined), { assert.deepEqual(resolveDefaultMpvInstallPaths('darwin', macHomeDir, undefined), {
supported: true, supported: true,
mpvConfigDir: path.join(macHomeDir, 'Library', 'Application Support', 'mpv'), mpvConfigDir: path.posix.join(macHomeDir, 'Library', 'Application Support', 'mpv'),
scriptsDir: path.join(macHomeDir, 'Library', 'Application Support', 'mpv', 'scripts'), scriptsDir: path.posix.join(macHomeDir, 'Library', 'Application Support', 'mpv', 'scripts'),
scriptOptsDir: path.join( scriptOptsDir: path.posix.join(
macHomeDir, macHomeDir,
'Library', 'Library',
'Application Support', 'Application Support',
'mpv', 'mpv',
'script-opts', 'script-opts',
), ),
pluginEntrypointPath: path.join( pluginEntrypointPath: path.posix.join(
macHomeDir, macHomeDir,
'Library', 'Library',
'Application Support', 'Application Support',
@@ -167,7 +168,7 @@ test('resolveDefaultMpvInstallPaths resolves linux, macOS, and Windows defaults'
'subminer', 'subminer',
'main.lua', 'main.lua',
), ),
pluginDir: path.join( pluginDir: path.posix.join(
macHomeDir, macHomeDir,
'Library', 'Library',
'Application Support', 'Application Support',
@@ -175,7 +176,7 @@ test('resolveDefaultMpvInstallPaths resolves linux, macOS, and Windows defaults'
'scripts', 'scripts',
'subminer', 'subminer',
), ),
pluginConfigPath: path.join( pluginConfigPath: path.posix.join(
macHomeDir, macHomeDir,
'Library', 'Library',
'Application Support', 'Application Support',
@@ -187,10 +188,16 @@ test('resolveDefaultMpvInstallPaths resolves linux, macOS, and Windows defaults'
assert.deepEqual(resolveDefaultMpvInstallPaths('win32', 'C:\\Users\\tester', undefined), { assert.deepEqual(resolveDefaultMpvInstallPaths('win32', 'C:\\Users\\tester', undefined), {
supported: true, supported: true,
mpvConfigDir: path.join('C:\\Users\\tester', 'AppData', 'Roaming', 'mpv'), mpvConfigDir: path.win32.join('C:\\Users\\tester', 'AppData', 'Roaming', 'mpv'),
scriptsDir: path.join('C:\\Users\\tester', 'AppData', 'Roaming', 'mpv', 'scripts'), scriptsDir: path.win32.join('C:\\Users\\tester', 'AppData', 'Roaming', 'mpv', 'scripts'),
scriptOptsDir: path.join('C:\\Users\\tester', 'AppData', 'Roaming', 'mpv', 'script-opts'), scriptOptsDir: path.win32.join(
pluginEntrypointPath: path.join( 'C:\\Users\\tester',
'AppData',
'Roaming',
'mpv',
'script-opts',
),
pluginEntrypointPath: path.win32.join(
'C:\\Users\\tester', 'C:\\Users\\tester',
'AppData', 'AppData',
'Roaming', 'Roaming',
@@ -199,8 +206,15 @@ test('resolveDefaultMpvInstallPaths resolves linux, macOS, and Windows defaults'
'subminer', 'subminer',
'main.lua', 'main.lua',
), ),
pluginDir: path.join('C:\\Users\\tester', 'AppData', 'Roaming', 'mpv', 'scripts', 'subminer'), pluginDir: path.win32.join(
pluginConfigPath: path.join( 'C:\\Users\\tester',
'AppData',
'Roaming',
'mpv',
'scripts',
'subminer',
),
pluginConfigPath: path.win32.join(
'C:\\Users\\tester', 'C:\\Users\\tester',
'AppData', 'AppData',
'Roaming', 'Roaming',

View File

@@ -40,6 +40,10 @@ export interface MpvInstallPaths {
pluginConfigPath: string; pluginConfigPath: string;
} }
function getPlatformPath(platform: NodeJS.Platform): typeof path.posix | typeof path.win32 {
return platform === 'win32' ? path.win32 : path.posix;
}
function asObject(value: unknown): Record<string, unknown> | null { function asObject(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' && !Array.isArray(value) return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>) ? (value as Record<string, unknown>)
@@ -223,20 +227,21 @@ export function resolveDefaultMpvInstallPaths(
homeDir: string, homeDir: string,
xdgConfigHome?: string, xdgConfigHome?: string,
): MpvInstallPaths { ): MpvInstallPaths {
const platformPath = getPlatformPath(platform);
const mpvConfigDir = const mpvConfigDir =
platform === 'darwin' platform === 'darwin'
? path.join(homeDir, 'Library', 'Application Support', 'mpv') ? platformPath.join(homeDir, 'Library', 'Application Support', 'mpv')
: platform === 'linux' : platform === 'linux'
? path.join(xdgConfigHome?.trim() || path.join(homeDir, '.config'), 'mpv') ? platformPath.join(xdgConfigHome?.trim() || platformPath.join(homeDir, '.config'), 'mpv')
: path.join(homeDir, 'AppData', 'Roaming', 'mpv'); : platformPath.join(homeDir, 'AppData', 'Roaming', 'mpv');
return { return {
supported: platform === 'linux' || platform === 'darwin' || platform === 'win32', supported: platform === 'linux' || platform === 'darwin' || platform === 'win32',
mpvConfigDir, mpvConfigDir,
scriptsDir: path.join(mpvConfigDir, 'scripts'), scriptsDir: platformPath.join(mpvConfigDir, 'scripts'),
scriptOptsDir: path.join(mpvConfigDir, 'script-opts'), scriptOptsDir: platformPath.join(mpvConfigDir, 'script-opts'),
pluginEntrypointPath: path.join(mpvConfigDir, 'scripts', 'subminer', 'main.lua'), pluginEntrypointPath: platformPath.join(mpvConfigDir, 'scripts', 'subminer', 'main.lua'),
pluginDir: path.join(mpvConfigDir, 'scripts', 'subminer'), pluginDir: platformPath.join(mpvConfigDir, 'scripts', 'subminer'),
pluginConfigPath: path.join(mpvConfigDir, 'script-opts', 'subminer.conf'), pluginConfigPath: platformPath.join(mpvConfigDir, 'script-opts', 'subminer.conf'),
}; };
} }