feat(config): real config reset/repair + migration framework (drop manual-delete) (#38)

Eliminates the fragile "delete the config folder before upgrading" tester
instruction, which was wrong-by-OS because the userData folder name was
derived inconsistently per platform (fee[dB]ack on macOS, slopsmith-desktop
on Linux/Windows).

A. Deterministic paths + migration framework
- Pin the userData name on every OS via app.setName('feedback-desktop') +
  build.extraMetadata.name; brand (productName 'fee[dB]ack') unchanged.
- One-time userData migration copies a legacy folder into the new one so
  upgraded users don't start fresh (atomic copy-then-rename, fail-soft).
  Runs before the single-instance lock / crashReporter, which would otherwise
  create userData and defeat the "new dir doesn't exist" gate.
- config-migrations.ts: versioned, ordered, idempotent, fail-soft migration
  runner stamped in CONFIG_DIR/config_version.json; logs the active CONFIG_DIR
  at startup (closes the Linux ~/.local/share/slopsmith shared-config gap).

B. In-app "Reset / repair configuration" (Settings panel)
- Granular options: reset app settings & caches, clear plugin state & cached
  Python deps, and full reset with default-OFF opt-ins for installed plugins /
  song library / ML caches.
- config-paths.ts is the single source of truth for per-OS path enumeration;
  the song library, installed plugins and ML caches are structurally confined
  to optInExtras and never wiped by the safe/full categories.
- Reset stops the backend, deletes immediate paths, includes SQLite WAL/SHM
  sidecars + the migration stamp on full reset, and defers Chromium/Crashpad
  state to next launch (consumed before any window reopens it). ML caches honor
  TORCH_HOME/HF_HOME. Empty selection is a no-op (backend left running).
- SECURITY: destructive resets require a native main-process confirmation
  dialog — the renderer bridge is reachable by plugin scripts, so a
  renderer-only confirm is not a sufficient gate.

Tests: node:test suites for path enumeration (per-OS + library/plugins
preserved), migration idempotency/fail-soft, reset delete pipeline guarantees,
userData migration, and deferred-deletion schedule/consume. `npm test` green
(adds a test script). codex review --base origin/main clean.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Byron Gamatos
2026-06-26 22:13:24 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent cc0aceb365
commit 5188aab938
16 changed files with 1537 additions and 5 deletions
+189
View File
@@ -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 <appData>/feedback-desktop
// on every OS. Before this, the folder name was OS-derived and inconsistent:
// - macOS: <appData>/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 <appData> 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
// <appData> 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'));
}
+141
View File
@@ -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<VersionStamp>;
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;
}
+226
View File
@@ -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 || <cacheBase>/torch) — opt-in delete only. */
torchHome: string;
/** Resolved HF cache ($HF_HOME || <cacheBase>/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<string>();
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);
}
+217
View File
@@ -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<ResetSummary> {
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<ResetSummary> => {
// 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 };
});
}
+8
View File
@@ -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
+60 -4
View File
@@ -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 (<appData>/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 <userData>/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 <userData>/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<void> {
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<void> {
// 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) {
+29
View File
@@ -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<MaintenancePaths> => ipcRenderer.invoke(IPC_MAINTENANCE_GET_PATHS),
reset: (selection: ResetSelection): Promise<ResetSummary> =>
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),
+1 -1
View File
@@ -911,4 +911,4 @@ export function restartPython(): void {
setTimeout(() => startPython(), 1000);
}
export { getPluginsDir, getConfigDir };
export { getPluginsDir, getConfigDir, getDLCDir };
+105
View File
@@ -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;
+54
View File
@@ -107,4 +107,58 @@
<div id="ae-ta-targets" class="space-y-1.5"></div>
</div>
</div>
<div id="maint-section" class="border border-slate-700 rounded-lg p-3 bg-slate-800/30">
<div class="text-xs font-semibold text-slate-300 mb-2">Reset / repair configuration</div>
<p class="text-xs text-slate-500 mb-2">
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.
</p>
<p id="maint-config-dir" class="text-xs text-slate-500 mb-1 break-all"></p>
<p id="maint-shared-warning" class="hidden text-xs text-amber-300 mb-2"></p>
<div class="space-y-2">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-sm text-slate-200">Reset app settings &amp; caches</div>
<div class="text-xs text-slate-500">Audio device settings, soundfont state, window/UI prefs and Electron caches. Safe — rebuilt on next launch.</div>
</div>
<button id="maint-reset-app" class="shrink-0 px-3 py-1.5 rounded bg-slate-600 hover:bg-slate-500 text-sm">Reset</button>
</div>
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-sm text-slate-200">Clear plugin state &amp; cached Python deps</div>
<div class="text-xs text-slate-500">Enabled/disabled state, plugin data, and plugin-installed Python packages. Does not remove installed plugins.</div>
</div>
<button id="maint-reset-plugins" class="shrink-0 px-3 py-1.5 rounded bg-slate-600 hover:bg-slate-500 text-sm">Clear</button>
</div>
<div class="border-t border-slate-700 pt-2">
<div class="text-sm text-rose-300">Full reset</div>
<div class="text-xs text-slate-500 mb-2">
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.
</div>
<div class="space-y-1 mb-2">
<label class="flex items-start gap-2 text-xs text-slate-300 cursor-pointer">
<input type="checkbox" id="maint-opt-plugins" class="mt-0.5 accent-rose-500">
<span>Also remove installed plugins</span>
</label>
<label class="flex items-start gap-2 text-xs text-slate-300 cursor-pointer">
<input type="checkbox" id="maint-opt-library" class="mt-0.5 accent-rose-500">
<span>Also delete the song library <span id="maint-library-path" class="text-slate-500 break-all"></span></span>
</label>
<label class="flex items-start gap-2 text-xs text-slate-300 cursor-pointer">
<input type="checkbox" id="maint-opt-ml" class="mt-0.5 accent-rose-500">
<span>Also clear ML model caches (torch / huggingface)</span>
</label>
</div>
<button id="maint-reset-full" class="px-3 py-1.5 rounded bg-rose-700 hover:bg-rose-600 text-sm">Full reset…</button>
</div>
</div>
<p class="text-xs text-slate-500 mt-2">You'll be asked to confirm in a system dialog before anything is deleted.</p>
<p id="maint-status" class="hidden text-xs mt-3"></p>
<button id="maint-restart" class="hidden mt-2 px-3 py-1.5 rounded bg-blue-600 hover:bg-blue-500 text-sm font-medium">Restart now</button>
</div>
</div>