diff --git a/package.json b/package.json index ddc488c..f8d8827 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "main": "dist/main/main.js", "scripts": { "typecheck": "tsc --noEmit", + "test": "node --test \"tests/*.test.js\"", "build:ts": "tsc && node -e \"const fs=require('fs'),path=require('path');fs.copyFileSync('src/main/splash.html','dist/main/splash.html');fs.copyFileSync('src/main/spinner.json','dist/main/spinner.json');fs.copyFileSync('node_modules/lottie-web/build/player/lottie.min.js','dist/main/lottie.min.js');const dst='dist/main/images';if(fs.existsSync(dst))fs.rmSync(dst,{recursive:true});fs.mkdirSync(dst,{recursive:true});fs.readdirSync('src/main/images',{withFileTypes:true}).filter(d=>d.isFile()&&d.name.endsWith('.webp')).forEach(d=>fs.copyFileSync(path.join('src/main/images',d.name),path.join(dst,d.name)))\"", "build:audio": "bash scripts/build-audio.sh Release", "build:audio:debug": "bash scripts/build-audio.sh Debug", @@ -32,6 +33,9 @@ "appId": "com.byron.slopsmith-desktop", "productName": "fee[dB]ack", "executableName": "feedback", + "extraMetadata": { + "name": "feedback-desktop" + }, "artifactName": "feedback-${version}-${arch}.${ext}", "directories": { "output": "release" diff --git a/src/main/config-bootstrap.ts b/src/main/config-bootstrap.ts new file mode 100644 index 0000000..76aebdc --- /dev/null +++ b/src/main/config-bootstrap.ts @@ -0,0 +1,189 @@ +// One-time userData folder migration. +// +// The app now pins its name to `feedback-desktop` (app.setName in main.ts), so +// app.getPath('userData') resolves deterministically to /feedback-desktop +// on every OS. Before this, the folder name was OS-derived and inconsistent: +// - macOS: /fee[dB]ack (from build.productName) +// - Linux: ~/.config/slopsmith-desktop (from package.json name) +// - Windows: %APPDATA%\slopsmith-desktop (from package.json name) +// +// To stop existing testers from "starting fresh" after the rename, on first +// launch under the new name we copy a legacy folder into the new one. Old and +// new always share the same parent, so we derive legacy candidates +// from dirname(newUserData) rather than reconstructing platform-specific roots. +// +// This MUST run before crashReporter.start() / app.whenReady() — i.e. before +// anything creates the new userData dir — because the "should I migrate?" gate +// is simply "the new dir does not exist yet". +// +// Fail-soft (Constitution VII): any error is logged and swallowed; a failed +// migration must never block launch. The copy is atomic (copy to a temp sibling, +// then rename) so a crash mid-copy can't leave a half-populated new dir that the +// gate would then treat as "already migrated". +// +// Does NOT touch the Linux shared ~/.local/share/slopsmith config or ~/.cache/* +// ML caches — those are absolute paths unaffected by the userData rename. + +import * as fs from 'fs'; +import * as path from 'path'; +import { app } from 'electron'; + +// Legacy userData folder names, newest-intent first. Derived against the same +// parent as the new dir, so this works on every OS. +export const LEGACY_USERDATA_NAMES = ['fee[dB]ack', 'slopsmith-desktop']; + +const MIGRATION_MARKER = 'userdata-migrated.json'; + +export interface UserDataMigrationResult { + migrated: boolean; + from?: string; + to?: string; + reason?: string; +} + +/** + * Pure, testable core: migrate into `newUserData` from the first existing legacy + * sibling, but only if `newUserData` doesn't already exist. + */ +export function migrateUserData( + newUserData: string, + now: string, + legacyNames: string[] = LEGACY_USERDATA_NAMES, +): UserDataMigrationResult { + try { + if (fs.existsSync(newUserData)) { + return { migrated: false, reason: 'new userData already exists' }; + } + + const parent = path.dirname(newUserData); + const newName = path.basename(newUserData); + const legacy = legacyNames + .filter((n) => n !== newName) + .map((n) => path.join(parent, n)) + .find((p) => { + try { + return fs.existsSync(p) && fs.statSync(p).isDirectory(); + } catch { + return false; + } + }); + + if (!legacy) { + return { migrated: false, reason: 'no legacy userData found' }; + } + + // Copy atomically: populate a temp sibling, then rename into place. If + // the copy throws, remove the partial temp dir and bail (the new dir is + // never created, so a retry on next launch is clean). + const staging = newUserData + '.migrating'; + try { + fs.rmSync(staging, { recursive: true, force: true }); + } catch { + /* best-effort cleanup of a prior aborted attempt */ + } + try { + fs.cpSync(legacy, staging, { recursive: true }); + fs.writeFileSync( + path.join(staging, MIGRATION_MARKER), + JSON.stringify({ from: legacy, at: now }, null, 2), + ); + fs.renameSync(staging, newUserData); + } catch (err) { + try { + fs.rmSync(staging, { recursive: true, force: true }); + } catch { + /* leave it; nothing else we can do */ + } + throw err; + } + + console.log(`[config-bootstrap] migrated userData ${legacy} → ${newUserData}`); + return { migrated: true, from: legacy, to: newUserData }; + } catch (err) { + console.warn(`[config-bootstrap] userData migration failed (continuing): ${String(err)}`); + return { migrated: false, reason: String(err) }; + } +} + +/** + * Electron entry point. Call once, immediately after app.setName(...) and BEFORE + * crashReporter.start() / app.whenReady(). + */ +export function migrateUserDataIfNeeded(): UserDataMigrationResult { + // app.getPath('userData') is available before `ready` and reflects the name + // pinned by app.setName(); it does not create the directory. + const newUserData = app.getPath('userData'); + return migrateUserData(newUserData, new Date().toISOString()); +} + +// ── Deferred reset deletions ───────────────────────────────────────────────── +// Some reset targets (Chromium state, Crashpad) are held open while the app runs, +// so config-reset.resetConfig() writes them to this manifest instead of deleting +// them live. consumePendingReset() applies them at the very start of the next +// launch — before any BrowserWindow or crashReporter reopens them. + +const PENDING_RESET = 'pending-reset.json'; + +function pendingResetPath(userData: string): string { + return path.join(userData, PENDING_RESET); +} + +/** Append paths to the pending-deletion manifest (merged + de-duplicated). + * Returns true if the manifest is in place (or there was nothing to schedule), + * false if it could not be written — callers surface that so the user isn't + * told a deferred deletion will happen when it won't. */ +export function schedulePendingDeletion(userData: string, paths: string[]): boolean { + if (!paths.length) return true; + const file = pendingResetPath(userData); + let existing: string[] = []; + try { + if (fs.existsSync(file)) { + const parsed = JSON.parse(fs.readFileSync(file, 'utf-8')); + if (Array.isArray(parsed)) existing = parsed.filter((p) => typeof p === 'string'); + } + } catch { + /* corrupt manifest — overwrite */ + } + const merged = [...new Set([...existing, ...paths])]; + try { + fs.mkdirSync(userData, { recursive: true }); + const tmp = file + '.tmp'; + fs.writeFileSync(tmp, JSON.stringify(merged, null, 2)); + fs.renameSync(tmp, file); + return true; + } catch (err) { + console.warn(`[config-bootstrap] failed to write ${PENDING_RESET}: ${String(err)}`); + return false; + } +} + +/** Apply (delete) and clear any pending-deletion manifest. Returns the paths it + * acted on. Fail-soft: a bad manifest or a failed unlink never blocks launch. */ +export function consumePendingReset(userData: string): string[] { + const file = pendingResetPath(userData); + let paths: string[] = []; + try { + if (!fs.existsSync(file)) return []; + const parsed = JSON.parse(fs.readFileSync(file, 'utf-8')); + paths = Array.isArray(parsed) ? parsed.filter((p) => typeof p === 'string') : []; + for (const p of paths) { + try { + fs.rmSync(p, { recursive: true, force: true }); + } catch (err) { + console.warn(`[config-bootstrap] deferred delete failed for ${p}: ${String(err)}`); + } + } + fs.rmSync(file, { force: true }); + if (paths.length) { + console.log(`[config-bootstrap] applied ${paths.length} deferred reset deletion(s)`); + } + } catch (err) { + console.warn(`[config-bootstrap] consumePendingReset failed (continuing): ${String(err)}`); + } + return paths; +} + +/** Electron entry point — call at top of main, before crashReporter / windows. */ +export function consumePendingResetIfNeeded(): string[] { + return consumePendingReset(app.getPath('userData')); +} diff --git a/src/main/config-migrations.ts b/src/main/config-migrations.ts new file mode 100644 index 0000000..e257943 --- /dev/null +++ b/src/main/config-migrations.ts @@ -0,0 +1,141 @@ +// Versioned config migration framework. Replaces the old "delete the config +// folder before upgrading" instruction with targeted, ordered, idempotent +// migrations stamped into CONFIG_DIR/config_version.json. +// +// On startup main.ts calls runConfigMigrations(getConfigDir(), app.getVersion()). +// Each migration is run at most once (gated by the persisted schemaVersion), +// in ascending version order, and each is wrapped so a throwing migration is +// logged and skipped rather than crashing startup (Constitution VII fail-soft). +// +// PURE/INJECTABLE: the runner takes the configDir + registry as arguments and +// touches only the filesystem, so it unit-tests without electron. Register real +// migrations by appending to MIGRATIONS and bumping CURRENT_SCHEMA_VERSION. + +import * as fs from 'fs'; +import * as path from 'path'; + +// Bump this (and append a matching MIGRATIONS entry) whenever the on-disk config +// schema changes in a way that needs a one-time fix-up. +export const CURRENT_SCHEMA_VERSION = 1; + +export interface MigrationContext { + /** Active backend CONFIG_DIR the migration may read/modify. */ + configDir: string; + /** App version running the migration (for logging / conditional fixes). */ + appVersion: string; +} + +export interface Migration { + /** Target schema version this migration brings the config UP TO. */ + version: number; + /** Short human-readable name for logs. */ + name: string; + /** Idempotent fix-up. Must tolerate partial/already-applied state. */ + run(ctx: MigrationContext): void; +} + +// Ordered registry. v1 is an intentional no-op baseline: it just establishes the +// stamp on installs that predate this framework, so future migrations have a +// known floor. Real migrations append here with version 2, 3, …. +export const MIGRATIONS: Migration[] = [ + { + version: 1, + name: 'baseline-stamp', + run: () => { + /* no-op: establishes config_version.json at v1 */ + }, + }, +]; + +interface VersionStamp { + schemaVersion: number; + appVersion: string; + updatedAt: string; +} + +const STAMP_FILE = 'config_version.json'; + +function stampPath(configDir: string): string { + return path.join(configDir, STAMP_FILE); +} + +/** Read the persisted schema version; 0 if absent or unreadable (treat a + * corrupt/missing stamp as "pre-framework" so migrations re-run safely). */ +export function readSchemaVersion(configDir: string): number { + try { + const raw = fs.readFileSync(stampPath(configDir), 'utf-8'); + const parsed = JSON.parse(raw) as Partial; + const v = Number(parsed.schemaVersion); + return Number.isInteger(v) && v >= 0 ? v : 0; + } catch { + return 0; + } +} + +function writeStamp(configDir: string, schemaVersion: number, appVersion: string, now: string): void { + const stamp: VersionStamp = { schemaVersion, appVersion, updatedAt: now }; + try { + fs.mkdirSync(configDir, { recursive: true }); + // Atomic-ish write so a crash mid-write can't leave a truncated stamp. + const tmp = stampPath(configDir) + '.tmp'; + fs.writeFileSync(tmp, JSON.stringify(stamp, null, 2)); + fs.renameSync(tmp, stampPath(configDir)); + } catch (err) { + console.warn(`[config-migrations] failed to write ${STAMP_FILE}: ${String(err)}`); + } +} + +export interface MigrationResult { + from: number; + to: number; + ran: { version: number; name: string; ok: boolean; error?: string }[]; +} + +/** + * Run every registered migration whose version is > the persisted schema version + * and <= CURRENT_SCHEMA_VERSION, in ascending order, then update the stamp. + * + * - Idempotent: a second call with an up-to-date stamp runs nothing. + * - Fail-soft: a throwing migration is logged and skipped; later migrations + * still run. The stamp still advances to CURRENT_SCHEMA_VERSION so a single + * persistently-failing migration can't wedge startup forever (it's surfaced + * in the returned result / logs instead). + * + * `now` is injectable for deterministic tests (Date is unavailable in some + * sandboxes); callers in main.ts pass new Date().toISOString(). + */ +export function runConfigMigrations( + configDir: string, + appVersion: string, + now: string, + registry: Migration[] = MIGRATIONS, +): MigrationResult { + const from = readSchemaVersion(configDir); + const result: MigrationResult = { from, to: from, ran: [] }; + + if (from >= CURRENT_SCHEMA_VERSION) { + console.log(`[config-migrations] schema up to date (v${from}) at ${configDir}`); + return result; + } + + const pending = registry + .filter((m) => m.version > from && m.version <= CURRENT_SCHEMA_VERSION) + .sort((a, b) => a.version - b.version); + + for (const m of pending) { + try { + m.run({ configDir, appVersion }); + result.ran.push({ version: m.version, name: m.name, ok: true }); + console.log(`[config-migrations] applied v${m.version} (${m.name})`); + } catch (err) { + const error = String(err); + result.ran.push({ version: m.version, name: m.name, ok: false, error }); + console.warn(`[config-migrations] migration v${m.version} (${m.name}) failed, skipping: ${error}`); + } + } + + writeStamp(configDir, CURRENT_SCHEMA_VERSION, appVersion, now); + result.to = CURRENT_SCHEMA_VERSION; + console.log(`[config-migrations] config schema v${from} → v${CURRENT_SCHEMA_VERSION} at ${configDir}`); + return result; +} diff --git a/src/main/config-paths.ts b/src/main/config-paths.ts new file mode 100644 index 0000000..dfa7f2a --- /dev/null +++ b/src/main/config-paths.ts @@ -0,0 +1,226 @@ +// Single source of truth for the on-disk locations the desktop app and the +// bundled Slopsmith backend write to. The reset/repair feature (config-reset.ts) +// and its tests enumerate paths from here so the "what gets deleted" decision +// lives in exactly one place. +// +// This module is deliberately PURE — it takes a resolved `ConfigPathEnv` and +// returns categorized path lists, with no calls into electron's `app`. That +// keeps it unit-testable per-platform without an electron mock; the real wiring +// (config-reset.ts) builds the env from app.getPath(...) + python.ts's +// getConfigDir/getDLCDir/getPluginsDir. +// +// SAFETY INVARIANT (enforced by tests): the user's song library (dlcDir), +// installed plugins (pluginsDir), and ML model caches appear ONLY under +// `optInExtras` — never in the three "safe"/"full reset" categories. A reset +// must never wipe those unless the user explicitly opts in. (Constitution VI.) + +import * as fs from 'fs'; +import * as path from 'path'; + +export interface ConfigPathEnv { + /** process.platform — selects which Electron cache dirs are relevant. */ + platform: NodeJS.Platform; + /** Electron app.getPath('userData') — the per-OS app data root. */ + userData: string; + /** Electron app.getPath('home'). */ + home: string; + /** Active backend CONFIG_DIR (python.ts getConfigDir) — may be the shared + * ~/.local/share/slopsmith Docker dir on Linux, hence resolved, not derived. */ + configDir: string; + /** Song library (python.ts getDLCDir) — opt-in delete only. */ + dlcDir: string; + /** User-installed plugins dir (python.ts getPluginsDir) — opt-in delete only. */ + pluginsDir: string; + /** Cache root for ML weights ($XDG_CACHE_HOME || ~/.cache). */ + cacheBase: string; + /** Resolved torch cache ($TORCH_HOME || /torch) — opt-in delete only. */ + torchHome: string; + /** Resolved HF cache ($HF_HOME || /huggingface) — opt-in delete only. */ + hfHome: string; +} + +export interface ConfigPathCategories { + /** Desktop prefs + Electron caches. Safe default reset — all re-created or + * re-downloaded on next launch; loses device/soundfont/UI prefs only. */ + appSettingsAndCaches: string[]; + /** Plugin enable/disable state + plugin-installed Python deps + plugin data. + * Fixes most "stale after upgrade" symptoms; does NOT remove installed plugins. */ + pluginStateAndPyDeps: string[]; + /** Backend SQLite DBs + config.json + cached content dirs under CONFIG_DIR. + * Part of a full reset; never includes the song library. */ + configDbsAndState: string[]; + /** Explicit opt-ins, each off by default and each with its own warning. */ + optInExtras: { + installedPlugins: string[]; + songLibrary: string[]; + mlCaches: string[]; + }; +} + +// SQLite DBs written under CONFIG_DIR by the backend / bundled plugins. +const CONFIG_DB_FILES = [ + 'web_library.db', + 'audio_effects.db', + 'nam_tone.db', + 'studio.db', + 'practice_journal.db', + 'midi_mappings.db', + 'rig_builder_cache.db', +]; + +// Cached/generated content dirs under CONFIG_DIR (not the song library). +const CONFIG_STATE_DIRS = ['tutorials', 'minigames', 'achievements', 'sloppak_cache']; + +// Chromium/Electron state dirs under userData. Deleting these resets origin-keyed +// UI settings, GPU shader cache, HTTP cache, etc. — all rebuilt on next launch. +const ELECTRON_STATE = ['Preferences', 'Local Storage', 'Cache', 'GPUCache', 'Code Cache']; + +// Paths a live process holds open: Chromium rewrites these on quit and Windows +// blocks deleting open files, so deleting them while the app runs is unreliable. +// They are DEFERRED to the next launch and applied before any BrowserWindow / +// crashReporter is created (config-bootstrap.consumePendingReset). 'Crashpad' is +// included because crashReporter.start() reopens it at top of main. +export const DEFERRED_BASENAMES = [...ELECTRON_STATE, 'Crashpad']; + +// Expand a base SQLite filename to itself plus its WAL/SHM sidecars — an abrupt +// (SIGKILL) backend stop in WAL mode leaves *.db-wal / *.db-shm beside *.db, and +// a reset must clear those too or stale committed-but-uncheckpointed state lingers. +function withSqliteSidecars(dbFiles: string[]): string[] { + return dbFiles.flatMap((f) => [f, `${f}-wal`, `${f}-shm`]); +} + +export function enumerateConfigPaths(env: ConfigPathEnv): ConfigPathCategories { + const u = env.userData; + const c = env.configDir; + + const appSettingsAndCaches = [ + // Desktop prefs (soundfont quality, LAN access) — soundfont-manager.ts + path.join(u, 'slopsmith-desktop.json'), + // Audio device settings — audio-bridge.ts + path.join(u, 'slopsmith-audio-settings.json'), + // Downloaded high-quality soundfont cache — soundfont-manager.ts + path.join(u, 'soundfonts'), + // VST crash-guard sentinel + blocklist — vst-crash-guard.ts + path.join(u, 'vst-load-sentinel.json'), + path.join(u, 'vst-crash-blocklist.json'), + // Native VST plugin registry cache — audio-bridge.ts + path.join(u, 'known-plugins.xml'), + // Native crash dumps — main.ts crashReporter + path.join(u, 'Crashpad'), + ...ELECTRON_STATE.map((d) => path.join(u, d)), + ]; + + const pluginStateAndPyDeps = [ + path.join(c, 'plugin_state.json'), + path.join(c, 'pip_packages'), + path.join(c, 'plugin_data'), + ]; + + const configDbsAndState = [ + ...withSqliteSidecars(CONFIG_DB_FILES).map((f) => path.join(c, f)), + path.join(c, 'config.json'), + // The migration stamp must go too — otherwise a full reset leaves it at + // the latest schema and the next startup skips migrations that would + // recreate/repair the freshly-reset config (config-migrations.ts). + path.join(c, 'config_version.json'), + ...CONFIG_STATE_DIRS.map((d) => path.join(c, d)), + ]; + + const optInExtras = { + installedPlugins: [env.pluginsDir], + songLibrary: [env.dlcDir], + // Resolved from the env so a custom TORCH_HOME / HF_HOME is honored — the + // app sets those for the backend (python.ts startPython), so the reset + // must target the same locations, not just the cacheBase defaults. + mlCaches: [env.torchHome, env.hfHome], + }; + + return { appSettingsAndCaches, pluginStateAndPyDeps, configDbsAndState, optInExtras }; +} + +// ── Reset selection → delete set (pure, testable without electron) ──────────── + +export interface ResetSelection { + /** Desktop prefs + Electron caches (safe default). */ + appSettings?: boolean; + /** plugin_state.json + pip_packages + plugin_data. */ + pluginState?: boolean; + /** Everything above + backend DBs/config under CONFIG_DIR. */ + fullReset?: boolean; + /** Opt-in: also remove the user-installed plugins dir. */ + alsoInstalledPlugins?: boolean; + /** Opt-in: also delete the song library (DLC_DIR). */ + alsoSongLibrary?: boolean; + /** Opt-in: also clear ML model caches (torch / huggingface). */ + alsoMlCaches?: boolean; +} + +export interface ResetEntry { + path: string; + existed: boolean; + ok: boolean; + error?: string; + /** True when deletion was deferred to next launch (Chromium-held path). */ + deferred?: boolean; +} + +/** + * Assemble the de-duplicated delete set for a selection. The opt-in extras + * (installed plugins, song library, ML caches) are added ONLY on their explicit + * flag — never via appSettings or fullReset. This is the function the + * "library & plugins preserved" tests assert against. + */ +export function buildDeleteSet(selection: ResetSelection, cats: ConfigPathCategories): string[] { + const set = new Set(); + const add = (paths: string[]) => paths.forEach((p) => set.add(p)); + + if (selection.appSettings || selection.fullReset) add(cats.appSettingsAndCaches); + if (selection.pluginState || selection.fullReset) add(cats.pluginStateAndPyDeps); + if (selection.fullReset) add(cats.configDbsAndState); + + if (selection.alsoInstalledPlugins) add(cats.optInExtras.installedPlugins); + if (selection.alsoSongLibrary) add(cats.optInExtras.songLibrary); + if (selection.alsoMlCaches) add(cats.optInExtras.mlCaches); + + return [...set]; +} + +/** Delete a list of paths fail-soft, returning a per-path summary. */ +export function deletePaths(paths: string[]): ResetEntry[] { + return paths.map((p) => { + let existed = false; + try { + existed = fs.existsSync(p); + } catch { + /* treat unstatable as not-existing for the summary */ + } + try { + fs.rmSync(p, { recursive: true, force: true }); + return { path: p, existed, ok: true }; + } catch (err) { + return { path: p, existed, ok: false, error: String(err) }; + } + }); +} + +/** + * Split a delete set into paths safe to remove immediately vs paths held open by + * the live process (Chromium state / Crashpad), which must be deferred to the + * next launch. Matches on basename so it's independent of the userData root. + */ +export function partitionDeferred(paths: string[]): { immediate: string[]; deferred: string[] } { + const immediate: string[] = []; + const deferred: string[] = []; + for (const p of paths) { + if (DEFERRED_BASENAMES.includes(path.basename(p))) deferred.push(p); + else immediate.push(p); + } + return { immediate, deferred }; +} + +/** True when the active CONFIG_DIR is the Linux shared (~/.local/share/slopsmith) + * dir also used by a Docker Slopsmith — the UI warns before a full reset there. */ +export function isSharedDockerConfig(env: ConfigPathEnv): boolean { + const shared = path.join(env.home, '.local', 'share', 'slopsmith'); + return path.resolve(env.configDir) === path.resolve(shared); +} diff --git a/src/main/config-reset.ts b/src/main/config-reset.ts new file mode 100644 index 0000000..83246a8 --- /dev/null +++ b/src/main/config-reset.ts @@ -0,0 +1,217 @@ +// In-app "Reset / repair configuration" — the replacement for telling testers to +// manually delete the config folder. Enumerates the correct paths for the +// current OS (config-paths.ts), stops the Python backend so it releases DB/file +// handles, deletes the selected categories, and reports a per-path summary. +// +// SAFETY (Constitution VI): the song library and installed plugins live under +// optInExtras and are deleted ONLY when the matching flag is set. The three +// non-opt-in categories never include them. + +import * as path from 'path'; +import { app, ipcMain, dialog, BrowserWindow } from 'electron'; +import { stopPython, getConfigDir, getDLCDir, getPluginsDir } from './python'; +import { + enumerateConfigPaths, + buildDeleteSet, + deletePaths, + partitionDeferred, + isSharedDockerConfig, + ConfigPathEnv, + ResetSelection, + ResetEntry, +} from './config-paths'; +import { schedulePendingDeletion } from './config-bootstrap'; +import * as fs from 'fs'; +import { + IPC_MAINTENANCE_GET_PATHS, + IPC_MAINTENANCE_RESET, + IPC_MAINTENANCE_RESTART, +} from './ipc-channels'; + +// Re-export so the preload bridge (and other consumers) can import the reset +// types from this module's public surface. +export type { ResetSelection, ResetEntry } from './config-paths'; + +export interface ResetSummary { + selection: ResetSelection; + configDir: string; + deleted: ResetEntry[]; + /** True when the user dismissed the native confirmation; nothing was deleted. */ + canceled?: boolean; +} + +// Human-readable confirmation copy for the native dialog. Highlights the +// destructive opt-ins explicitly so the gate can't be glossed over. +function describeSelection(s: ResetSelection): { message: string; detail: string } { + const lines: string[] = []; + if (s.fullReset) { + lines.push('Delete the configuration and databases (settings, library index, tones, plugin state).'); + } else { + if (s.appSettings) lines.push('Reset app settings & caches.'); + if (s.pluginState) lines.push('Clear plugin state & cached Python deps.'); + } + const extras: string[] = []; + if (s.alsoInstalledPlugins) extras.push('installed plugins'); + if (s.alsoSongLibrary) extras.push('your song library'); + if (s.alsoMlCaches) extras.push('ML model caches'); + if (extras.length) lines.push(`ALSO permanently delete: ${extras.join(', ')}.`); + return { + message: 'Reset / repair configuration?', + detail: `${lines.join('\n')}\n\nThis cannot be undone. The app will restart afterwards.`, + }; +} + +// Cache root for ML weights — mirrors python.ts startPython's cacheBase. +function cacheBase(): string { + return process.env.XDG_CACHE_HOME || path.join(app.getPath('home'), '.cache'); +} + +/** Build the resolved path env from electron + python.ts getters. The ML cache + * paths mirror startPython()'s resolution exactly (TORCH_HOME / HF_HOME override + * the cacheBase defaults) so a reset targets the directories actually in use. */ +export function buildConfigPathEnv(): ConfigPathEnv { + const cb = cacheBase(); + return { + platform: process.platform, + userData: app.getPath('userData'), + home: app.getPath('home'), + configDir: getConfigDir(), + dlcDir: getDLCDir(), + pluginsDir: getPluginsDir(), + cacheBase: cb, + torchHome: process.env.TORCH_HOME || path.join(cb, 'torch'), + hfHome: process.env.HF_HOME || path.join(cb, 'huggingface'), + }; +} + +/** + * Stop the backend, then delete the selected config paths. Async so we can give + * the killed Python process a moment to release file/DB handles (mainly Windows, + * where an open handle blocks deletion) before unlinking. + */ +export async function resetConfig(selection: ResetSelection): Promise { + const env = buildConfigPathEnv(); + const cats = enumerateConfigPaths(env); + const targets = buildDeleteSet(selection, cats); + + // Nothing selected (malformed/all-false payload): no-op. Crucially, do NOT + // stop the backend — otherwise an empty request would leave the app broken + // until a manual restart for no benefit. + if (targets.length === 0) { + console.log('[config-reset] empty selection — nothing to reset; backend left running'); + return { selection, configDir: env.configDir, deleted: [] }; + } + + // Hard-stop the backend (SIGKILL group) so it isn't holding SQLite/WAL files + // while we delete them. WAL makes an abrupt stop crash-safe, and we restart + // right after, so abandoning in-flight work is intended. + try { + stopPython(true); + } catch (err) { + console.warn(`[config-reset] stopPython failed (continuing): ${String(err)}`); + } + // Give the OS a beat to actually reap the process and close its handles. + await new Promise((r) => setTimeout(r, 600)); + + // Chromium state / Crashpad are held open by this live process — Chromium + // rewrites them on quit and Windows blocks deleting open files, so deleting + // them now could "succeed" yet leave the state intact. Delete everything else + // immediately and defer those to the next launch (config-bootstrap applies the + // manifest before any window / crashReporter reopens them). + const { immediate, deferred } = partitionDeferred(targets); + const deleted = deletePaths(immediate); + + const scheduled = schedulePendingDeletion(env.userData, deferred); + const deferredEntries: ResetEntry[] = deferred.map((p) => { + let existed = false; + try { existed = fs.existsSync(p); } catch { /* ignore */ } + // If the manifest couldn't be written, the next launch won't delete these + // — report them as failures rather than a false "Restart to finish". + return scheduled + ? { path: p, existed, ok: true, deferred: true } + : { path: p, existed, ok: false, deferred: true, error: 'could not schedule deferred deletion' }; + }); + + const allEntries = [...deleted, ...deferredEntries]; + const summary: ResetSummary = { selection, configDir: env.configDir, deleted: allEntries }; + const failed = deleted.filter((d) => !d.ok); + console.log( + `[config-reset] reset done: ${allEntries.length} target(s), ` + + `${deleted.filter((d) => d.existed && d.ok).length} removed now, ` + + `${deferred.length} scheduled for next launch, ${failed.length} failed`, + ); + if (failed.length) { + for (const f of failed) console.warn(`[config-reset] failed to delete ${f.path}: ${f.error}`); + } + return summary; +} + +/** + * Register the maintenance IPC handlers. Call once from startup(). + * @param getMainWindow returns the window to parent the native confirm dialog to. + */ +export function registerMaintenanceHandlers(getMainWindow?: () => BrowserWindow | null): void { + ipcMain.handle(IPC_MAINTENANCE_GET_PATHS, () => { + const env = buildConfigPathEnv(); + const categories = enumerateConfigPaths(env); + return { + configDir: env.configDir, + userData: env.userData, + dlcDir: env.dlcDir, + pluginsDir: env.pluginsDir, + sharedDockerConfig: isSharedDockerConfig(env), + categories, + }; + }); + + ipcMain.handle(IPC_MAINTENANCE_RESET, async (_event, selection: ResetSelection): Promise => { + // Coerce to a known-good shape so a malformed payload can't widen scope. + const safe: ResetSelection = { + appSettings: selection?.appSettings === true, + pluginState: selection?.pluginState === true, + fullReset: selection?.fullReset === true, + alsoInstalledPlugins: selection?.alsoInstalledPlugins === true, + alsoSongLibrary: selection?.alsoSongLibrary === true, + alsoMlCaches: selection?.alsoMlCaches === true, + }; + + const env = buildConfigPathEnv(); + const anySelected = safe.appSettings || safe.pluginState || safe.fullReset + || safe.alsoInstalledPlugins || safe.alsoSongLibrary || safe.alsoMlCaches; + if (!anySelected) { + return { selection: safe, configDir: env.configDir, deleted: [] }; + } + + // SECURITY: this bridge is reachable by any renderer script (incl. + // community plugins), so the renderer-side confirm is NOT a sufficient + // gate. Require a native, main-process confirmation before deleting + // anything — destructive (and especially the library/plugin opt-in) + // resets must have explicit user intent that renderer content can't fake. + const { message, detail } = describeSelection(safe); + const parent = getMainWindow?.() ?? null; + const opts = { + type: 'warning' as const, + buttons: ['Cancel', 'Reset'], + defaultId: 0, // default to the safe choice + cancelId: 0, + title: 'fee[dB]ack', + message, + detail, + noLink: true, + }; + const { response } = parent && !parent.isDestroyed() + ? await dialog.showMessageBox(parent, opts) + : await dialog.showMessageBox(opts); + if (response !== 1) { + return { selection: safe, configDir: env.configDir, deleted: [], canceled: true }; + } + + return resetConfig(safe); + }); + + ipcMain.handle(IPC_MAINTENANCE_RESTART, () => { + app.relaunch(); + app.quit(); + return { restarting: true }; + }); +} diff --git a/src/main/ipc-channels.ts b/src/main/ipc-channels.ts index de6d7ba..66a1ab3 100644 --- a/src/main/ipc-channels.ts +++ b/src/main/ipc-channels.ts @@ -19,6 +19,14 @@ export const IPC_UPDATE_APPLY = 'update:apply' as const; export const IPC_UPDATE_EVENT_AVAILABLE = 'update:available' as const; export const IPC_UPDATE_EVENT_DOWNLOADED = 'update:downloaded' as const; +// Config maintenance — the in-app "Reset / repair configuration" action. The +// Settings panel reads the enumerated per-OS paths, runs a granular reset, and +// asks the main process to relaunch. Replaces the manual "delete the config +// folder" instruction. +export const IPC_MAINTENANCE_GET_PATHS = 'maintenance:getPaths' as const; +export const IPC_MAINTENANCE_RESET = 'maintenance:reset' as const; +export const IPC_MAINTENANCE_RESTART = 'maintenance:restart' as const; + // Screen wake lock. The renderer (slopsmith core app.js) asks the main process // to keep the display awake while a song plays — embedded Chromium does not // honour the renderer's navigator.wakeLock reliably, so we drive Electron's diff --git a/src/main/main.ts b/src/main/main.ts index 6084461..b5bc2ca 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -60,6 +60,39 @@ import { app, BrowserWindow, ipcMain, dialog, shell, session, crashReporter, pow import * as path from 'path'; import * as fs from 'fs'; import { execFileSync } from 'child_process'; +import { migrateUserDataIfNeeded, consumePendingResetIfNeeded } from './config-bootstrap'; + +// Pin the userData folder name on every OS. Before this it was derived from the +// build name and differed per-OS ('fee[dB]ack' on macOS, 'slopsmith-desktop' on +// Linux/Windows), so a single "delete the config folder" instruction could never +// be right everywhere. setName() must run before ANY app.getPath()/crashReporter/ +// whenReady so the deterministic path (/feedback-desktop) is used +// throughout. It does NOT change the user-facing brand (productName 'fee[dB]ack'); +// with the name pinned, the path-hostile brackets never reach the filesystem. +app.setName('feedback-desktop'); + +// One-time copy of an existing legacy userData folder into the new one, so +// testers don't start fresh after the rename. MUST run before BOTH crashReporter +// (which creates /Crashpad) AND requestSingleInstanceLock() (which +// writes a SingletonLock into userData) — the migration gate is "new userData +// doesn't exist yet", so anything that creates it first would silently skip the +// migration and start the upgraded user fresh. +migrateUserDataIfNeeded(); + +// Acquire the single-instance lock now so the primary-only deferred-reset cleanup +// runs ONLY in the instance that will actually boot: a losing second instance +// must not consume the pending-reset manifest while the primary still holds +// Chromium / Crashpad files open. requestSingleInstanceLock() must be called +// exactly once; the lock branch at the bottom of this file reuses this result. +// SLOPSMITH_ALLOW_MULTIPLE=1 opts out (two builds side-by-side). +const allowMultipleInstances = process.env.SLOPSMITH_ALLOW_MULTIPLE === '1'; +const hasSingleInstanceLock = allowMultipleInstances || app.requestSingleInstanceLock(); +if (hasSingleInstanceLock) { + // Apply any reset deletions deferred from a previous "Reset configuration" + // run, before crashReporter / any BrowserWindow reopens Chromium state & + // Crashpad, so those held-open paths can actually be removed. + consumePendingResetIfNeeded(); +} // Enable Electron's Crashpad to capture native crashes (incl. VST/JUCE C++ // access violations) into /Crashpad/reports/ as .dmp files. Must @@ -72,7 +105,9 @@ crashReporter.start({ uploadToServer: false, compress: false, }); -import { startPython, stopPython, waitForPython, getPythonPort, StartupStatus, restartPython, getLanUrls } from './python'; +import { startPython, stopPython, waitForPython, getPythonPort, StartupStatus, restartPython, getLanUrls, getConfigDir } from './python'; +import { runConfigMigrations } from './config-migrations'; +import { registerMaintenanceHandlers } from './config-reset'; import { IPC_STARTUP_STATUS, IPC_STARTUP_GET_STATUS, @@ -907,6 +942,25 @@ async function startup(): Promise { createSplashWindow(); publishStartupStatus({ message: 'Starting backend service...', phase: 'booting', running: true }); + // Run config-schema migrations against the active backend CONFIG_DIR before + // the backend starts. This replaces "delete the config folder before + // upgrading" with targeted, idempotent, fail-soft migrations. Logging the + // resolved CONFIG_DIR here also closes the visibility gap around the silent + // Linux ~/.local/share/slopsmith shared-config override (python.ts getConfigDir). + try { + const activeConfigDir = getConfigDir(); + console.log(`[main] Active CONFIG_DIR: ${activeConfigDir}`); + runConfigMigrations(activeConfigDir, app.getVersion(), new Date().toISOString()); + } catch (err) { + console.warn('[main] config migrations failed (continuing):', err); + } + + // Register the "Reset / repair configuration" IPC handlers (Settings panel). + // Pass the window getter so the destructive-reset confirmation is a native, + // main-process modal (the renderer bridge is reachable by plugin scripts, so + // a renderer-only confirm is not a sufficient gate). + registerMaintenanceHandlers(() => mainWindow); + // Start Python server (Slopsmith backend) startPython(); @@ -1076,9 +1130,11 @@ async function startup(): Promise { // another instance already owns it, surface that window (via 'second-instance' // on the primary) and quit THIS one before startup() boots a competing backend // or window. `SLOPSMITH_ALLOW_MULTIPLE=1` opts out so two builds can run -// side-by-side (e.g. A/B testing different versions). -const allowMultipleInstances = process.env.SLOPSMITH_ALLOW_MULTIPLE === '1'; -if (!allowMultipleInstances && !app.requestSingleInstanceLock()) { +// side-by-side (e.g. A/B testing different versions). The lock was already +// acquired at the top of this file (hasSingleInstanceLock) so primary-only reset +// side effects could run before crashReporter — reuse that result here rather +// than calling requestSingleInstanceLock() a second time. +if (!hasSingleInstanceLock) { app.quit(); } else { if (!allowMultipleInstances) { diff --git a/src/main/preload.ts b/src/main/preload.ts index 2772362..879b3f8 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -4,6 +4,21 @@ const { contextBridge, ipcRenderer } = require('electron'); import type { StartupStatus } from './python'; +// Type-only imports (erased at compile) — no runtime require, so the preload +// bundle never drags in config-reset's electron/python/fs dependencies. +import type { ResetSelection, ResetSummary } from './config-reset'; +import type { ConfigPathCategories } from './config-paths'; + +// Shape returned by maintenance.getPaths() — the enumerated per-OS categories +// plus the resolved active CONFIG_DIR and a flag for the shared Docker dir. +export interface MaintenancePaths { + configDir: string; + userData: string; + dlcDir: string; + pluginsDir: string; + sharedDockerConfig: boolean; + categories: ConfigPathCategories; +} import { IPC_STARTUP_STATUS, IPC_STARTUP_GET_STATUS, @@ -15,6 +30,9 @@ import { IPC_UPDATE_EVENT_AVAILABLE, IPC_UPDATE_EVENT_DOWNLOADED, IPC_POWER_SET_SCREEN_AWAKE, + IPC_MAINTENANCE_GET_PATHS, + IPC_MAINTENANCE_RESET, + IPC_MAINTENANCE_RESTART, } from './ipc-channels'; // Auto-update channel + event payloads. Kept here (rather than re-exported @@ -451,6 +469,17 @@ const slopsmithDesktopApi = { ipcRenderer.invoke('network:setLanAccess', enabled), }, + // Config maintenance — "Reset / repair configuration" (Settings panel). + // getPaths returns the per-OS enumerated categories + the resolved CONFIG_DIR + // (incl. a sharedDockerConfig flag); reset runs a granular delete; restart + // relaunches the app once the user confirms. + maintenance: { + getPaths: (): Promise => ipcRenderer.invoke(IPC_MAINTENANCE_GET_PATHS), + reset: (selection: ResetSelection): Promise => + ipcRenderer.invoke(IPC_MAINTENANCE_RESET, selection), + restart: (): Promise<{ restarting: boolean }> => ipcRenderer.invoke(IPC_MAINTENANCE_RESTART), + }, + // File dialogs pickFile: (filters?: { name: string; extensions: string[] }[]) => ipcRenderer.invoke('dialog:pickFile', filters), diff --git a/src/main/python.ts b/src/main/python.ts index 09caa45..6deaecc 100644 --- a/src/main/python.ts +++ b/src/main/python.ts @@ -911,4 +911,4 @@ export function restartPython(): void { setTimeout(() => startPython(), 1000); } -export { getPluginsDir, getConfigDir }; +export { getPluginsDir, getConfigDir, getDLCDir }; diff --git a/src/renderer/screen.js b/src/renderer/screen.js index 2a21a67..19f7647 100644 --- a/src/renderer/screen.js +++ b/src/renderer/screen.js @@ -1473,6 +1473,7 @@ window.__slopsmithDesktopAudioHooks = window.__slopsmithDesktopAudioHooks || {}; setupAudioQualityControls(); setupToneAutomationSettingsEvents(); setupUpdateChannelControls(); + setupMaintenanceControls(); } // ── Updater (Velopack) settings UI ──────────────────────────────────────── @@ -1644,6 +1645,110 @@ window.__slopsmithDesktopAudioHooks = window.__slopsmithDesktopAudioHooks || {}; renderStatus(); } + // ── Reset / repair configuration (Maintenance) ──────────────────────────── + // Replaces the old "delete the config folder before upgrading" instruction. + // Talks to the main process via window.slopsmithDesktop.maintenance, which + // enumerates the correct per-OS paths and performs the delete. Binds fresh on + // each settings render (the panel is injected via innerHTML, recreating the + // DOM), mirroring setupAudioQualityControls. Degrades gracefully when the + // maintenance IPC namespace is absent (browser / older build). + function setupMaintenanceControls() { + const api = window.slopsmithDesktop?.maintenance; + const section = document.getElementById('maint-section'); + if (!section) return; + + const resetAppBtn = document.getElementById('maint-reset-app'); + const resetPluginsBtn = document.getElementById('maint-reset-plugins'); + const resetFullBtn = document.getElementById('maint-reset-full'); + const optPlugins = document.getElementById('maint-opt-plugins'); + const optLibrary = document.getElementById('maint-opt-library'); + const optMl = document.getElementById('maint-opt-ml'); + const configDirEl = document.getElementById('maint-config-dir'); + const sharedWarn = document.getElementById('maint-shared-warning'); + const libraryPathEl = document.getElementById('maint-library-path'); + const statusEl = document.getElementById('maint-status'); + const restartBtn = document.getElementById('maint-restart'); + + const actionButtons = [resetAppBtn, resetPluginsBtn, resetFullBtn].filter(Boolean); + + if (!api) { + if (configDirEl) configDirEl.textContent = 'Configuration reset is only available in the desktop app.'; + actionButtons.forEach((b) => { b.disabled = true; }); + return; + } + + function setStatus(text, kind) { + if (!statusEl) return; + statusEl.textContent = text; + statusEl.classList.remove('hidden', 'text-slate-400', 'text-emerald-400', 'text-amber-300', 'text-red-400'); + const cls = kind === 'error' ? 'text-red-400' + : kind === 'success' ? 'text-emerald-400' + : kind === 'warn' ? 'text-amber-300' : 'text-slate-400'; + statusEl.classList.add(cls); + } + + // Show the resolved CONFIG_DIR + library path + shared-Docker warning. + (async () => { + try { + const info = await api.getPaths(); + if (configDirEl) configDirEl.textContent = `Active config: ${info.configDir}`; + if (libraryPathEl && info.dlcDir) libraryPathEl.textContent = `(${info.dlcDir})`; + if (sharedWarn && info.sharedDockerConfig) { + sharedWarn.textContent = 'Heads up: this config is shared with a Docker Slopsmith install — a full reset affects both.'; + sharedWarn.classList.remove('hidden'); + } + } catch (e) { + console.warn('[maintenance] getPaths failed:', e); + } + })(); + + // The authoritative confirmation is a NATIVE main-process dialog raised by + // the maintenance:reset handler — that's the real gate (this bridge is + // reachable by plugin scripts, so a renderer-only confirm isn't enough). + // We just kick the request; a canceled summary comes back if the user + // dismisses the native prompt. + async function doReset(selection) { + actionButtons.forEach((b) => { b.disabled = true; }); + setStatus('Awaiting confirmation…', 'warn'); + if (restartBtn) restartBtn.classList.add('hidden'); + try { + const summary = await api.reset(selection); + if (summary?.canceled) { + setStatus('Reset canceled.', 'default'); + return; + } + const removed = summary.deleted.filter((d) => d.existed && d.ok).length; + const failed = summary.deleted.filter((d) => !d.ok); + if (failed.length) { + console.warn('[maintenance] some paths could not be deleted:', failed); + setStatus(`Removed ${removed} item(s); ${failed.length} could not be deleted (see console). Restart to finish.`, 'warn'); + } else { + setStatus(`Removed ${removed} item(s). Restart to finish.`, 'success'); + } + if (restartBtn) restartBtn.classList.remove('hidden'); + } catch (e) { + console.warn('[maintenance] reset failed:', e); + setStatus(`Reset failed: ${e?.message || e}`, 'error'); + } finally { + actionButtons.forEach((b) => { b.disabled = false; }); + } + } + + resetAppBtn?.addEventListener('click', () => doReset({ appSettings: true })); + resetPluginsBtn?.addEventListener('click', () => doReset({ pluginState: true })); + resetFullBtn?.addEventListener('click', () => doReset({ + fullReset: true, + alsoInstalledPlugins: !!optPlugins?.checked, + alsoSongLibrary: !!optLibrary?.checked, + alsoMlCaches: !!optMl?.checked, + })); + + restartBtn?.addEventListener('click', () => { + setStatus('Restarting…', 'warn'); + try { void api.restart(); } catch (e) { console.warn('[maintenance] restart failed:', e); } + }); + } + // ── Audio Quality (soundfont) ───────────────────────────────────────────── function setupAudioQualityControls() { const api = window.slopsmithDesktop?.soundfont; diff --git a/src/renderer/settings.html b/src/renderer/settings.html index b78eb2b..8b9d754 100644 --- a/src/renderer/settings.html +++ b/src/renderer/settings.html @@ -107,4 +107,58 @@
+
+
Reset / repair configuration
+

+ Fixes a broken or stale configuration after an upgrade — you no longer need to delete any folders by hand. + Your song library and installed plugins are kept unless you explicitly opt in under Full reset. +

+

+ + +
+
+
+
Reset app settings & caches
+
Audio device settings, soundfont state, window/UI prefs and Electron caches. Safe — rebuilt on next launch.
+
+ +
+ +
+
+
Clear plugin state & cached Python deps
+
Enabled/disabled state, plugin data, and plugin-installed Python packages. Does not remove installed plugins.
+
+ +
+ +
+
Full reset
+
+ Everything above plus the backend databases and config. By default your song library and installed + plugins are preserved — opt in below only if you really want to delete them. +
+
+ + + +
+ +
+
+ +

You'll be asked to confirm in a system dialog before anything is deleted.

+ + +
diff --git a/tests/_load-ts.js b/tests/_load-ts.js new file mode 100644 index 0000000..9cc716b --- /dev/null +++ b/tests/_load-ts.js @@ -0,0 +1,30 @@ +// Shared helper: compile a TypeScript module on the fly and load it as CommonJS, +// matching the transpile-on-load pattern used by the other node:test suites +// (see audio-effects-executor.test.js). Lets us unit-test the pure config-*.ts +// modules without a build step or an electron runtime. +const fs = require('node:fs'); +const path = require('node:path'); +const Module = require('node:module'); +const ts = require('typescript'); + +const ROOT = path.join(__dirname, '..'); + +function loadTs(relPath) { + const file = path.join(ROOT, relPath); + const source = fs.readFileSync(file, 'utf8'); + const compiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + esModuleInterop: true, + }, + fileName: file, + }).outputText; + const mod = new Module(file, module); + mod.filename = file; + mod.paths = Module._nodeModulePaths(path.dirname(file)); + mod._compile(compiled, file); + return mod.exports; +} + +module.exports = { loadTs, ROOT }; diff --git a/tests/config-bootstrap.test.js b/tests/config-bootstrap.test.js new file mode 100644 index 0000000..f0c465b --- /dev/null +++ b/tests/config-bootstrap.test.js @@ -0,0 +1,91 @@ +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 } = require('./_load-ts'); + +// config-bootstrap imports `{ app } from 'electron'`, which in plain node resolves +// to the electron path string (no throw); migrateUserData never touches it. +const { + migrateUserData, + schedulePendingDeletion, + consumePendingReset, +} = loadTs('src/main/config-bootstrap.ts'); + +function tmpAppData() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'feedback-bootstrap-')); +} + +test('migrates a legacy userData folder into the new one when the new dir is missing', () => { + const parent = tmpAppData(); + const legacy = path.join(parent, 'slopsmith-desktop'); + fs.mkdirSync(path.join(legacy, 'slopsmith-config'), { recursive: true }); + fs.writeFileSync(path.join(legacy, 'slopsmith-config', 'config.json'), '{"dlc_dir":"/x"}'); + fs.writeFileSync(path.join(legacy, 'slopsmith-desktop.json'), '{"lanAccess":false}'); + + const newUserData = path.join(parent, 'feedback-desktop'); + const res = migrateUserData(newUserData, '2026-06-26T00:00:00.000Z'); + + assert.equal(res.migrated, true); + assert.equal(res.from, legacy); + assert.ok(fs.existsSync(path.join(newUserData, 'slopsmith-desktop.json')), 'top-level file copied'); + assert.ok(fs.existsSync(path.join(newUserData, 'slopsmith-config', 'config.json')), 'nested file copied'); + assert.ok(fs.existsSync(path.join(newUserData, 'userdata-migrated.json')), 'migration marker written'); + // Legacy left in place as a fallback (copy, not move). + assert.ok(fs.existsSync(path.join(legacy, 'slopsmith-desktop.json')), 'legacy preserved'); +}); + +test('does NOT overwrite when the new userData dir already exists', () => { + const parent = tmpAppData(); + const legacy = path.join(parent, 'fee[dB]ack'); + fs.mkdirSync(legacy, { recursive: true }); + fs.writeFileSync(path.join(legacy, 'old.json'), 'legacy'); + + const newUserData = path.join(parent, 'feedback-desktop'); + fs.mkdirSync(newUserData, { recursive: true }); + fs.writeFileSync(path.join(newUserData, 'keep.json'), 'mine'); + + const res = migrateUserData(newUserData, '2026-06-26T00:00:00.000Z'); + + assert.equal(res.migrated, false); + assert.match(res.reason, /already exists/); + assert.ok(fs.existsSync(path.join(newUserData, 'keep.json')), 'existing data untouched'); + assert.ok(!fs.existsSync(path.join(newUserData, 'old.json')), 'legacy not copied over existing dir'); +}); + +test('no-op when there is no legacy folder to migrate from', () => { + const parent = tmpAppData(); + const newUserData = path.join(parent, 'feedback-desktop'); + const res = migrateUserData(newUserData, '2026-06-26T00:00:00.000Z'); + assert.equal(res.migrated, false); + assert.match(res.reason, /no legacy/); + assert.ok(!fs.existsSync(newUserData), 'new dir not created when nothing to migrate'); +}); + +test('schedulePendingDeletion + consumePendingReset defer and then apply deletions', () => { + const userData = tmpAppData(); + const held1 = path.join(userData, 'Local Storage'); + const held2 = path.join(userData, 'Crashpad'); + fs.mkdirSync(held1, { recursive: true }); + fs.mkdirSync(held2, { recursive: true }); + fs.writeFileSync(path.join(held1, 'leveldb'), 'x'); + + assert.equal(schedulePendingDeletion(userData, [held1]), true); + assert.equal(schedulePendingDeletion(userData, [held2, held1]), true); // merge + de-dup + assert.equal(schedulePendingDeletion(userData, []), true); // nothing to do + const manifest = path.join(userData, 'pending-reset.json'); + assert.deepEqual(JSON.parse(fs.readFileSync(manifest, 'utf8')).sort(), [held1, held2].sort()); + + // Nothing deleted yet — deletion is deferred to next launch. + assert.ok(fs.existsSync(held1)); + + const applied = consumePendingReset(userData); + assert.deepEqual(applied.sort(), [held1, held2].sort()); + assert.ok(!fs.existsSync(held1), 'deferred path removed on consume'); + assert.ok(!fs.existsSync(held2), 'deferred path removed on consume'); + assert.ok(!fs.existsSync(manifest), 'manifest cleared after consume'); + + // Idempotent: a second consume with no manifest is a no-op. + assert.deepEqual(consumePendingReset(userData), []); +}); diff --git a/tests/config-migrations.test.js b/tests/config-migrations.test.js new file mode 100644 index 0000000..530d117 --- /dev/null +++ b/tests/config-migrations.test.js @@ -0,0 +1,66 @@ +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 } = require('./_load-ts'); + +const { + runConfigMigrations, + readSchemaVersion, + CURRENT_SCHEMA_VERSION, +} = loadTs('src/main/config-migrations.ts'); + +function tmpConfigDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'feedback-cfgmig-')); +} + +test('first run stamps the config to CURRENT_SCHEMA_VERSION and is idempotent', () => { + const dir = tmpConfigDir(); + assert.equal(readSchemaVersion(dir), 0); + + const first = runConfigMigrations(dir, '1.2.3', '2026-06-26T00:00:00.000Z'); + assert.equal(first.from, 0); + assert.equal(first.to, CURRENT_SCHEMA_VERSION); + assert.equal(readSchemaVersion(dir), CURRENT_SCHEMA_VERSION); + + const stamp = JSON.parse(fs.readFileSync(path.join(dir, 'config_version.json'), 'utf8')); + assert.equal(stamp.schemaVersion, CURRENT_SCHEMA_VERSION); + assert.equal(stamp.appVersion, '1.2.3'); + assert.equal(stamp.updatedAt, '2026-06-26T00:00:00.000Z'); + + // Second run is a no-op: nothing to migrate, stamp unchanged. + const second = runConfigMigrations(dir, '1.2.3', '2026-06-26T01:00:00.000Z'); + assert.equal(second.from, CURRENT_SCHEMA_VERSION); + assert.equal(second.to, CURRENT_SCHEMA_VERSION); + assert.deepEqual(second.ran, []); +}); + +test('fail-soft: a throwing migration is logged and skipped; later migrations still run', () => { + const dir = tmpConfigDir(); + const okMarker = path.join(dir, 'ok-ran.txt'); + + // Two version-1 migrations (both in range for a fresh dir). The first throws; + // the runner must not abort — the second must still execute. + const registry = [ + { version: 1, name: 'boom', run: () => { throw new Error('boom'); } }, + { version: 1, name: 'ok', run: () => fs.writeFileSync(okMarker, 'ran') }, + ]; + + const res = runConfigMigrations(dir, '9.9.9', '2026-06-26T00:00:00.000Z', registry); + + assert.equal(res.ran.length, 2); + assert.equal(res.ran[0].ok, false); + assert.match(res.ran[0].error, /boom/); + assert.equal(res.ran[1].ok, true); + assert.ok(fs.existsSync(okMarker), 'the second migration ran despite the first throwing'); + // The stamp still advances so a persistently-failing migration cannot wedge startup. + assert.equal(readSchemaVersion(dir), CURRENT_SCHEMA_VERSION); +}); + +test('readSchemaVersion treats a missing/corrupt stamp as version 0', () => { + const dir = tmpConfigDir(); + assert.equal(readSchemaVersion(dir), 0); + fs.writeFileSync(path.join(dir, 'config_version.json'), 'not json {'); + assert.equal(readSchemaVersion(dir), 0); +}); diff --git a/tests/config-paths.test.js b/tests/config-paths.test.js new file mode 100644 index 0000000..c194811 --- /dev/null +++ b/tests/config-paths.test.js @@ -0,0 +1,191 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const { loadTs } = require('./_load-ts'); + +const { + enumerateConfigPaths, + buildDeleteSet, + partitionDeferred, + isSharedDockerConfig, + DEFERRED_BASENAMES, +} = loadTs('src/main/config-paths.ts'); + +// Per-OS resolved path envs. enumerateConfigPaths builds everything from these +// values, so we simulate each platform by passing OS-shaped paths. +const ENVS = { + linux: { + platform: 'linux', + userData: '/home/u/.config/feedback-desktop', + home: '/home/u', + configDir: '/home/u/.config/feedback-desktop/slopsmith-config', + dlcDir: '/home/u/Music/Slopsmith', + pluginsDir: '/home/u/.config/feedback-desktop/plugins', + cacheBase: '/home/u/.cache', + torchHome: '/home/u/.cache/torch', + hfHome: '/home/u/.cache/huggingface', + }, + darwin: { + platform: 'darwin', + userData: '/Users/u/Library/Application Support/feedback-desktop', + home: '/Users/u', + configDir: '/Users/u/Library/Application Support/feedback-desktop/slopsmith-config', + dlcDir: '/Users/u/Music/Slopsmith', + pluginsDir: '/Users/u/Library/Application Support/feedback-desktop/plugins', + cacheBase: '/Users/u/.cache', + torchHome: '/Users/u/.cache/torch', + hfHome: '/Users/u/.cache/huggingface', + }, + win32: { + platform: 'win32', + userData: '/c/Users/u/AppData/Roaming/feedback-desktop', + home: '/c/Users/u', + configDir: '/c/Users/u/AppData/Roaming/feedback-desktop/slopsmith-config', + dlcDir: '/c/Users/u/Music/Slopsmith', + pluginsDir: '/c/Users/u/AppData/Roaming/feedback-desktop/plugins', + cacheBase: '/c/Users/u/.cache', + torchHome: '/c/Users/u/.cache/torch', + hfHome: '/c/Users/u/.cache/huggingface', + }, +}; + +test('enumerateConfigPaths places known desktop state under userData (each OS)', () => { + for (const [name, env] of Object.entries(ENVS)) { + const cats = enumerateConfigPaths(env); + const u = env.userData; + const c = env.configDir; + for (const expected of [ + path.join(u, 'slopsmith-desktop.json'), + path.join(u, 'slopsmith-audio-settings.json'), + path.join(u, 'soundfonts'), + path.join(u, 'vst-load-sentinel.json'), + path.join(u, 'vst-crash-blocklist.json'), + path.join(u, 'known-plugins.xml'), + path.join(u, 'Crashpad'), + ]) { + assert.ok(cats.appSettingsAndCaches.includes(expected), `${name}: missing ${expected}`); + } + assert.ok(cats.pluginStateAndPyDeps.includes(path.join(c, 'plugin_state.json')), `${name}: plugin_state`); + assert.ok(cats.pluginStateAndPyDeps.includes(path.join(c, 'pip_packages')), `${name}: pip_packages`); + assert.ok(cats.configDbsAndState.includes(path.join(c, 'web_library.db')), `${name}: web_library.db`); + assert.ok(cats.configDbsAndState.includes(path.join(c, 'config.json')), `${name}: config.json`); + // WAL/SHM sidecars must be cleared alongside each DB. + assert.ok(cats.configDbsAndState.includes(path.join(c, 'web_library.db-wal')), `${name}: db-wal`); + assert.ok(cats.configDbsAndState.includes(path.join(c, 'web_library.db-shm')), `${name}: db-shm`); + // The migration stamp is part of a full reset so migrations re-run after. + assert.ok(cats.configDbsAndState.includes(path.join(c, 'config_version.json')), `${name}: stamp`); + } +}); + +test('buildDeleteSet returns an empty set for an empty/all-false selection', () => { + const cats = enumerateConfigPaths(ENVS.linux); + assert.deepEqual(buildDeleteSet({}, cats), []); + assert.deepEqual(buildDeleteSet({ appSettings: false, fullReset: false }, cats), []); +}); + +test('partitionDeferred routes Chromium-held paths to deferred, the rest to immediate', () => { + const env = ENVS.linux; + const cats = enumerateConfigPaths(env); + const { immediate, deferred } = partitionDeferred(buildDeleteSet({ fullReset: true }, cats)); + + // Every deferred path has a Chromium/Crashpad basename. + for (const p of deferred) { + assert.ok(DEFERRED_BASENAMES.includes(path.basename(p)), `unexpected deferred ${p}`); + } + // Crashpad + the 5 Electron-state dirs are deferred; nothing else is. + assert.deepEqual( + deferred.map((p) => path.basename(p)).sort(), + [...DEFERRED_BASENAMES].sort(), + ); + // DBs and prefs go immediate, never deferred. + assert.ok(immediate.includes(path.join(env.configDir, 'web_library.db'))); + assert.ok(immediate.includes(path.join(env.userData, 'slopsmith-desktop.json'))); + assert.ok(!immediate.some((p) => DEFERRED_BASENAMES.includes(path.basename(p)))); +}); + +test('SAFETY: song library, installed plugins and ML caches are ONLY in optInExtras', () => { + for (const [name, env] of Object.entries(ENVS)) { + const cats = enumerateConfigPaths(env); + const safe = [ + ...cats.appSettingsAndCaches, + ...cats.pluginStateAndPyDeps, + ...cats.configDbsAndState, + ]; + // None of the safe categories may equal or be a child of the protected dirs. + const protectedRoots = [ + env.dlcDir, + env.pluginsDir, + path.join(env.cacheBase, 'torch'), + path.join(env.cacheBase, 'huggingface'), + ]; + for (const root of protectedRoots) { + assert.ok(!safe.includes(root), `${name}: ${root} leaked into a safe category`); + assert.ok( + !safe.some((p) => p === root || p.startsWith(root + path.sep)), + `${name}: a safe path lives under protected ${root}`, + ); + } + // And they ARE present in optInExtras. + assert.deepEqual(cats.optInExtras.songLibrary, [env.dlcDir], `${name}: songLibrary`); + assert.deepEqual(cats.optInExtras.installedPlugins, [env.pluginsDir], `${name}: installedPlugins`); + assert.deepEqual( + cats.optInExtras.mlCaches, + [path.join(env.cacheBase, 'torch'), path.join(env.cacheBase, 'huggingface')], + `${name}: mlCaches`, + ); + } +}); + +test('buildDeleteSet honors flags and never widens to opt-in extras implicitly', () => { + const env = ENVS.linux; + const cats = enumerateConfigPaths(env); + const extras = [ + env.dlcDir, + env.pluginsDir, + ...cats.optInExtras.mlCaches, + ]; + + const appOnly = buildDeleteSet({ appSettings: true }, cats); + assert.deepEqual(appOnly, cats.appSettingsAndCaches); + extras.forEach((p) => assert.ok(!appOnly.includes(p), `appSettings leaked ${p}`)); + + const pluginOnly = buildDeleteSet({ pluginState: true }, cats); + assert.deepEqual(pluginOnly, cats.pluginStateAndPyDeps); + + const full = buildDeleteSet({ fullReset: true }, cats); + for (const p of [...cats.appSettingsAndCaches, ...cats.pluginStateAndPyDeps, ...cats.configDbsAndState]) { + assert.ok(full.includes(p), `fullReset missing ${p}`); + } + extras.forEach((p) => assert.ok(!full.includes(p), `fullReset leaked ${p} without opt-in`)); + + const fullPlusAll = buildDeleteSet( + { fullReset: true, alsoInstalledPlugins: true, alsoSongLibrary: true, alsoMlCaches: true }, + cats, + ); + extras.forEach((p) => assert.ok(fullPlusAll.includes(p), `opt-in missing ${p}`)); + + const installedOnly = buildDeleteSet({ alsoInstalledPlugins: true }, cats); + assert.deepEqual(installedOnly, [env.pluginsDir]); + + // De-duplication: app + full must not double-list shared app paths. + const merged = buildDeleteSet({ appSettings: true, fullReset: true }, cats); + assert.equal(merged.length, new Set(merged).size); +}); + +test('mlCaches honors custom TORCH_HOME / HF_HOME locations', () => { + const env = { + ...ENVS.linux, + torchHome: '/mnt/big/torch', + hfHome: '/mnt/big/hf', + }; + const cats = enumerateConfigPaths(env); + assert.deepEqual(cats.optInExtras.mlCaches, ['/mnt/big/torch', '/mnt/big/hf']); +}); + +test('isSharedDockerConfig detects the Linux shared ~/.local/share/slopsmith dir', () => { + assert.equal( + isSharedDockerConfig({ ...ENVS.linux, configDir: '/home/u/.local/share/slopsmith' }), + true, + ); + assert.equal(isSharedDockerConfig(ENVS.linux), false); +}); diff --git a/tests/config-reset.test.js b/tests/config-reset.test.js new file mode 100644 index 0000000..d0fc5c8 --- /dev/null +++ b/tests/config-reset.test.js @@ -0,0 +1,125 @@ +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 } = require('./_load-ts'); + +// resetConfig itself is electron/python-bound, but its delete logic is the pure +// enumerateConfigPaths → buildDeleteSet → deletePaths pipeline (config-paths.ts). +// We exercise that pipeline against a real on-disk fake tree to prove the +// "library & plugins preserved" guarantees end-to-end. +const { enumerateConfigPaths, buildDeleteSet, deletePaths } = loadTs('src/main/config-paths.ts'); + +function buildFakeTree() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'feedback-reset-')); + const userData = path.join(root, 'feedback-desktop'); + const configDir = path.join(userData, 'slopsmith-config'); + const pluginsDir = path.join(userData, 'plugins'); + const dlcDir = path.join(root, 'Library'); // song library lives OUTSIDE userData + const cacheBase = path.join(root, '.cache'); + + const write = (p, c = 'x') => { + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, c); + }; + + // App settings & caches + write(path.join(userData, 'slopsmith-desktop.json')); + write(path.join(userData, 'slopsmith-audio-settings.json')); + write(path.join(userData, 'soundfonts', 'FluidR3_GM.sf2')); + write(path.join(userData, 'known-plugins.xml')); + // Plugin state & python deps + write(path.join(configDir, 'plugin_state.json')); + write(path.join(configDir, 'pip_packages', 'somepkg', '__init__.py')); + write(path.join(configDir, 'plugin_data', 'foo.json')); + // Backend DBs + config (incl. WAL/SHM sidecars left by an abrupt stop) + write(path.join(configDir, 'web_library.db')); + write(path.join(configDir, 'web_library.db-wal')); + write(path.join(configDir, 'web_library.db-shm')); + write(path.join(configDir, 'config.json')); + write(path.join(configDir, 'config_version.json')); + // Protected: installed plugin, song library, ML caches + write(path.join(pluginsDir, 'my-plugin', 'plugin.json')); + write(path.join(dlcDir, 'song.psarc')); + write(path.join(cacheBase, 'torch', 'model.pt')); + write(path.join(cacheBase, 'huggingface', 'blob')); + + const env = { + platform: process.platform, + userData, + home: root, + configDir, + dlcDir, + pluginsDir, + cacheBase, + torchHome: path.join(cacheBase, 'torch'), + hfHome: path.join(cacheBase, 'huggingface'), + }; + return { root, env, userData, configDir, pluginsDir, dlcDir, cacheBase }; +} + +function run(selection, env) { + const cats = enumerateConfigPaths(env); + return deletePaths(buildDeleteSet(selection, cats)); +} + +function protectedIntact(t) { + assert.ok(fs.existsSync(path.join(t.pluginsDir, 'my-plugin', 'plugin.json')), 'installed plugin preserved'); + assert.ok(fs.existsSync(path.join(t.dlcDir, 'song.psarc')), 'song library preserved'); + assert.ok(fs.existsSync(path.join(t.cacheBase, 'torch', 'model.pt')), 'ML cache preserved'); +} + +test('appSettings reset removes desktop prefs/caches but preserves DBs, plugins and library', () => { + const t = buildFakeTree(); + run({ appSettings: true }, t.env); + assert.ok(!fs.existsSync(path.join(t.userData, 'slopsmith-desktop.json')), 'pref deleted'); + assert.ok(!fs.existsSync(path.join(t.userData, 'soundfonts')), 'soundfont cache deleted'); + assert.ok(fs.existsSync(path.join(t.configDir, 'web_library.db')), 'DB preserved'); + assert.ok(fs.existsSync(path.join(t.configDir, 'plugin_state.json')), 'plugin state preserved'); + protectedIntact(t); +}); + +test('pluginState reset clears plugin state/py-deps but keeps installed plugins, DBs and library', () => { + const t = buildFakeTree(); + run({ pluginState: true }, t.env); + assert.ok(!fs.existsSync(path.join(t.configDir, 'plugin_state.json')), 'plugin_state deleted'); + assert.ok(!fs.existsSync(path.join(t.configDir, 'pip_packages')), 'pip_packages deleted'); + assert.ok(!fs.existsSync(path.join(t.configDir, 'plugin_data')), 'plugin_data deleted'); + assert.ok(fs.existsSync(path.join(t.configDir, 'web_library.db')), 'DB preserved'); + assert.ok(fs.existsSync(path.join(t.userData, 'slopsmith-desktop.json')), 'app prefs preserved'); + protectedIntact(t); +}); + +test('fullReset (no opt-ins) wipes config DBs but still preserves library and installed plugins', () => { + const t = buildFakeTree(); + run({ fullReset: true }, t.env); + assert.ok(!fs.existsSync(path.join(t.configDir, 'web_library.db')), 'DB deleted'); + assert.ok(!fs.existsSync(path.join(t.configDir, 'web_library.db-wal')), 'WAL sidecar deleted'); + assert.ok(!fs.existsSync(path.join(t.configDir, 'web_library.db-shm')), 'SHM sidecar deleted'); + assert.ok(!fs.existsSync(path.join(t.configDir, 'config.json')), 'config.json deleted'); + assert.ok(!fs.existsSync(path.join(t.configDir, 'config_version.json')), 'migration stamp deleted'); + assert.ok(!fs.existsSync(path.join(t.userData, 'slopsmith-desktop.json')), 'app prefs deleted'); + assert.ok(!fs.existsSync(path.join(t.configDir, 'plugin_state.json')), 'plugin state deleted'); + protectedIntact(t); +}); + +test('opt-in flags remove the protected trees', () => { + const t = buildFakeTree(); + run( + { fullReset: true, alsoInstalledPlugins: true, alsoSongLibrary: true, alsoMlCaches: true }, + t.env, + ); + assert.ok(!fs.existsSync(t.pluginsDir), 'installed plugins removed on opt-in'); + assert.ok(!fs.existsSync(t.dlcDir), 'song library removed on opt-in'); + assert.ok(!fs.existsSync(path.join(t.cacheBase, 'torch')), 'torch cache removed on opt-in'); + assert.ok(!fs.existsSync(path.join(t.cacheBase, 'huggingface')), 'hf cache removed on opt-in'); +}); + +test('deletePaths is fail-soft on a non-existent path', () => { + const t = buildFakeTree(); + const missing = path.join(t.root, 'does-not-exist'); + const [entry] = deletePaths([missing]); + assert.equal(entry.ok, true); + assert.equal(entry.existed, false); +});