mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
250 lines
7.9 KiB
JavaScript
250 lines
7.9 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Read Psysonic app config from WebKit localStorage + XDG dirs.
|
|
* Emits key=value lines (passwords/secrets redacted; header values hashed).
|
|
*
|
|
* Usage: node scripts/lib/extract-app-config.mjs [--app-id ID] [--repo-root PATH]
|
|
*/
|
|
|
|
import { createHash } from 'node:crypto';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { DatabaseSync } from 'node:sqlite';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
|
|
function parseArgs(argv) {
|
|
const out = { appId: process.env.PSYSONIC_APP_ID || '', repoRoot: '' };
|
|
for (let i = 2; i < argv.length; i++) {
|
|
if (argv[i] === '--app-id' && argv[i + 1]) {
|
|
out.appId = argv[++i];
|
|
} else if (argv[i] === '--repo-root' && argv[i + 1]) {
|
|
out.repoRoot = argv[++i];
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function sha256(text) {
|
|
return createHash('sha256').update(text, 'utf8').digest('hex').slice(0, 16);
|
|
}
|
|
|
|
function emitSection(name) {
|
|
process.stdout.write(`\n[${name}]\n`);
|
|
}
|
|
|
|
function emit(key, value) {
|
|
const v = String(value ?? '').replace(/\n/g, '\\n');
|
|
process.stdout.write(`${key}=${v}\n`);
|
|
}
|
|
|
|
function readAppIdFromRepo(repoRoot) {
|
|
const conf = path.join(repoRoot, 'src-tauri', 'tauri.conf.json');
|
|
if (!fs.existsSync(conf)) return '';
|
|
try {
|
|
const j = JSON.parse(fs.readFileSync(conf, 'utf8'));
|
|
return typeof j.identifier === 'string' ? j.identifier : '';
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
function xdgDataHome() {
|
|
return process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
|
|
}
|
|
|
|
function xdgConfigHome() {
|
|
return process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
|
|
}
|
|
|
|
function decodeWebKitValue(raw) {
|
|
if (raw == null) return null;
|
|
const buf = Buffer.isBuffer(raw) ? raw : raw instanceof Uint8Array ? Buffer.from(raw) : null;
|
|
if (buf) {
|
|
if (buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xfe) {
|
|
return new TextDecoder('utf-16le').decode(buf.subarray(2));
|
|
}
|
|
if (buf.length >= 2 && buf[0] === 0xfe && buf[1] === 0xff) {
|
|
return new TextDecoder('utf-16be').decode(buf.subarray(2));
|
|
}
|
|
// WebKit often stores UTF-16LE without BOM
|
|
if (buf.length >= 4 && buf[1] === 0 && buf[3] === 0) {
|
|
return new TextDecoder('utf-16le').decode(buf);
|
|
}
|
|
return buf.toString('utf8');
|
|
}
|
|
if (typeof raw === 'string') return raw;
|
|
return String(raw);
|
|
}
|
|
|
|
function readLocalStorageRaw(dbPath, storageKey) {
|
|
try {
|
|
const db = new DatabaseSync(dbPath, { readOnly: true });
|
|
const row = db.prepare('SELECT value FROM ItemTable WHERE key = ?').get(storageKey);
|
|
db.close();
|
|
if (!row?.value) return null;
|
|
return decodeWebKitValue(row.value);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function readLocalStorageKey(dbPath, storageKey) {
|
|
const text = readLocalStorageRaw(dbPath, storageKey);
|
|
if (text == null) return null;
|
|
try {
|
|
return JSON.parse(text);
|
|
} catch {
|
|
return text;
|
|
}
|
|
}
|
|
|
|
function pickLocalStorageFile(dataDir) {
|
|
const dir = path.join(dataDir, 'localstorage');
|
|
if (!fs.existsSync(dir)) return null;
|
|
const files = fs
|
|
.readdirSync(dir)
|
|
.filter(f => f.endsWith('.localstorage') && !f.includes('-wal') && !f.includes('-shm'))
|
|
.map(f => path.join(dir, f));
|
|
if (files.length === 0) return null;
|
|
|
|
// Prefer packaged app origin over vite dev (1420) when both exist.
|
|
const ranked = files.sort((a, b) => {
|
|
const score = p => {
|
|
const base = path.basename(p);
|
|
if (base.includes('tauri_localhost')) return 0;
|
|
if (base.includes('1420')) return 2;
|
|
return 1;
|
|
};
|
|
return score(a) - score(b);
|
|
});
|
|
|
|
for (const file of ranked) {
|
|
const auth = readLocalStorageKey(file, 'psysonic-auth');
|
|
if (auth?.state?.servers?.length) return file;
|
|
}
|
|
return ranked[0];
|
|
}
|
|
|
|
function probeHttpReachability(rawUrl) {
|
|
if (!rawUrl) return 'empty';
|
|
const url = rawUrl.startsWith('http') ? rawUrl : `http://${rawUrl}`;
|
|
const r = spawnSync(
|
|
'curl',
|
|
['-sS', '-o', '/dev/null', '-w', '%{http_code}', '--connect-timeout', '5', '--max-time', '10', url],
|
|
{ encoding: 'utf8', timeout: 15000 },
|
|
);
|
|
if (r.error) return `error:${r.error.code ?? 'unknown'}`;
|
|
if (r.status !== 0) return `curl_exit_${r.status}`;
|
|
return `http_${r.stdout.trim() || '000'}`;
|
|
}
|
|
|
|
function dumpNetworkProbes(servers) {
|
|
emitSection('app_network_probe');
|
|
const seen = new Set();
|
|
servers.forEach((srv, i) => {
|
|
for (const [kind, raw] of [
|
|
['url', srv.url],
|
|
['alternateUrl', srv.alternateUrl],
|
|
]) {
|
|
if (!raw || seen.has(raw)) continue;
|
|
seen.add(raw);
|
|
emit(`probe.${i}.${kind}`, probeHttpReachability(raw));
|
|
emit(`probe.${i}.${kind}_target`, raw);
|
|
}
|
|
});
|
|
if (seen.size === 0) emit('probe_status', 'no server URLs configured');
|
|
}
|
|
|
|
function dumpServerProfiles(state) {
|
|
const servers = state?.servers ?? [];
|
|
emit('active_server_id', state?.activeServerId ?? '');
|
|
emit('server_count', servers.length);
|
|
servers.forEach((srv, i) => {
|
|
const p = `server.${i}`;
|
|
emit(`${p}.id`, srv.id ?? '');
|
|
emit(`${p}.name`, srv.name ?? '');
|
|
emit(`${p}.url`, srv.url ?? '');
|
|
emit(`${p}.alternateUrl`, srv.alternateUrl ?? '');
|
|
emit(`${p}.shareUsesLocalUrl`, srv.shareUsesLocalUrl === true ? 'true' : 'false');
|
|
emit(`${p}.username`, srv.username ?? '');
|
|
emit(`${p}.password_set`, srv.password ? 'yes' : 'no');
|
|
emit(`${p}.password_sha256`, srv.password ? sha256(srv.password) : 'none');
|
|
emit(`${p}.customHeadersApplyTo`, srv.customHeadersApplyTo ?? 'public');
|
|
const headers = srv.customHeaders ?? [];
|
|
emit(`${p}.customHeaders_count`, headers.length);
|
|
headers.forEach((h, hi) => {
|
|
emit(`${p}.customHeaders.${hi}.name`, h.name ?? '');
|
|
emit(`${p}.customHeaders.${hi}.value_sha256`, h.value ? sha256(h.value) : 'empty');
|
|
});
|
|
});
|
|
dumpNetworkProbes(servers);
|
|
}
|
|
|
|
function fileMeta(label, filePath) {
|
|
if (!filePath || !fs.existsSync(filePath)) {
|
|
emit(`${label}_exists`, 'no');
|
|
return;
|
|
}
|
|
const st = fs.statSync(filePath);
|
|
emit(`${label}_exists`, 'yes');
|
|
emit(`${label}_path`, filePath);
|
|
emit(`${label}_size`, st.size);
|
|
emit(`${label}_mtime`, st.mtime.toISOString());
|
|
}
|
|
|
|
function listDir(label, dirPath, max = 12) {
|
|
if (!fs.existsSync(dirPath)) {
|
|
emit(`${label}_exists`, 'no');
|
|
return;
|
|
}
|
|
emit(`${label}_exists`, 'yes');
|
|
emit(`${label}_path`, dirPath);
|
|
const entries = fs.readdirSync(dirPath).sort();
|
|
emit(`${label}_entry_count`, entries.length);
|
|
entries.slice(0, max).forEach((name, i) => emit(`${label}.entry.${i}`, name));
|
|
}
|
|
|
|
const { appId: appIdArg, repoRoot } = parseArgs(process.argv);
|
|
let appId = appIdArg || (repoRoot ? readAppIdFromRepo(repoRoot) : '');
|
|
if (!appId) appId = readAppIdFromRepo(process.cwd()) || 'dev.psysonic.player';
|
|
|
|
const dataDir = path.join(xdgDataHome(), appId);
|
|
const configDir = path.join(xdgConfigHome(), appId);
|
|
|
|
emitSection('app_config_paths');
|
|
emit('app_id', appId);
|
|
emit('data_dir', dataDir);
|
|
emit('config_dir', configDir);
|
|
emit('data_dir_exists', fs.existsSync(dataDir) ? 'yes' : 'no');
|
|
emit('config_dir_exists', fs.existsSync(configDir) ? 'yes' : 'no');
|
|
|
|
const lsFile = pickLocalStorageFile(dataDir);
|
|
fileMeta('localstorage_db', lsFile);
|
|
|
|
emitSection('app_preferences');
|
|
if (lsFile) {
|
|
const lang = readLocalStorageRaw(lsFile, 'psysonic_language');
|
|
emit('language', lang ?? 'unknown');
|
|
|
|
const authWrap = readLocalStorageKey(lsFile, 'psysonic-auth');
|
|
if (authWrap?.state) {
|
|
emitSection('app_servers');
|
|
dumpServerProfiles(authWrap.state);
|
|
} else {
|
|
emit('app_servers_status', 'psysonic-auth not found or empty');
|
|
}
|
|
} else {
|
|
emit('app_servers_status', 'no localstorage database found');
|
|
}
|
|
|
|
emitSection('app_config_files');
|
|
for (const rel of ['linux_wayland_text_profile', 'mini_player_pos.json', '.window-state.json']) {
|
|
fileMeta(rel.replace(/\./g, '_'), path.join(configDir, rel));
|
|
}
|
|
|
|
emitSection('app_data_artifacts');
|
|
fileMeta('hsts_storage', path.join(dataDir, 'hsts-storage.sqlite'));
|
|
fileMeta('library_db', path.join(dataDir, 'databases', 'library', 'library.sqlite'));
|
|
listDir('localstorage_dir', path.join(dataDir, 'localstorage'));
|