mirror of
https://github.com/got-feedBack/feedBack-desktop.git
synced 2026-09-12 20:28:12 +00:00
feat(config): real config reset/repair + migration framework (drop manual-delete)
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:
co-authored by
Claude Opus 4.8
parent
cc0aceb365
commit
48cd361dd3
@@ -10,6 +10,7 @@
|
|||||||
"main": "dist/main/main.js",
|
"main": "dist/main/main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"typecheck": "tsc --noEmit",
|
"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: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": "bash scripts/build-audio.sh Release",
|
||||||
"build:audio:debug": "bash scripts/build-audio.sh Debug",
|
"build:audio:debug": "bash scripts/build-audio.sh Debug",
|
||||||
@@ -32,6 +33,9 @@
|
|||||||
"appId": "com.byron.slopsmith-desktop",
|
"appId": "com.byron.slopsmith-desktop",
|
||||||
"productName": "fee[dB]ack",
|
"productName": "fee[dB]ack",
|
||||||
"executableName": "feedback",
|
"executableName": "feedback",
|
||||||
|
"extraMetadata": {
|
||||||
|
"name": "feedback-desktop"
|
||||||
|
},
|
||||||
"artifactName": "feedback-${version}-${arch}.${ext}",
|
"artifactName": "feedback-${version}-${arch}.${ext}",
|
||||||
"directories": {
|
"directories": {
|
||||||
"output": "release"
|
"output": "release"
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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_AVAILABLE = 'update:available' as const;
|
||||||
export const IPC_UPDATE_EVENT_DOWNLOADED = 'update:downloaded' 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
|
// 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
|
// 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
|
// honour the renderer's navigator.wakeLock reliably, so we drive Electron's
|
||||||
|
|||||||
+60
-4
@@ -60,6 +60,39 @@ import { app, BrowserWindow, ipcMain, dialog, shell, session, crashReporter, pow
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import { execFileSync } from 'child_process';
|
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++
|
// Enable Electron's Crashpad to capture native crashes (incl. VST/JUCE C++
|
||||||
// access violations) into <userData>/Crashpad/reports/ as .dmp files. Must
|
// access violations) into <userData>/Crashpad/reports/ as .dmp files. Must
|
||||||
@@ -72,7 +105,9 @@ crashReporter.start({
|
|||||||
uploadToServer: false,
|
uploadToServer: false,
|
||||||
compress: 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 {
|
import {
|
||||||
IPC_STARTUP_STATUS,
|
IPC_STARTUP_STATUS,
|
||||||
IPC_STARTUP_GET_STATUS,
|
IPC_STARTUP_GET_STATUS,
|
||||||
@@ -907,6 +942,25 @@ async function startup(): Promise<void> {
|
|||||||
createSplashWindow();
|
createSplashWindow();
|
||||||
publishStartupStatus({ message: 'Starting backend service...', phase: 'booting', running: true });
|
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)
|
// Start Python server (Slopsmith backend)
|
||||||
startPython();
|
startPython();
|
||||||
|
|
||||||
@@ -1076,9 +1130,11 @@ async function startup(): Promise<void> {
|
|||||||
// another instance already owns it, surface that window (via 'second-instance'
|
// another instance already owns it, surface that window (via 'second-instance'
|
||||||
// on the primary) and quit THIS one before startup() boots a competing backend
|
// 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
|
// or window. `SLOPSMITH_ALLOW_MULTIPLE=1` opts out so two builds can run
|
||||||
// side-by-side (e.g. A/B testing different versions).
|
// side-by-side (e.g. A/B testing different versions). The lock was already
|
||||||
const allowMultipleInstances = process.env.SLOPSMITH_ALLOW_MULTIPLE === '1';
|
// acquired at the top of this file (hasSingleInstanceLock) so primary-only reset
|
||||||
if (!allowMultipleInstances && !app.requestSingleInstanceLock()) {
|
// side effects could run before crashReporter — reuse that result here rather
|
||||||
|
// than calling requestSingleInstanceLock() a second time.
|
||||||
|
if (!hasSingleInstanceLock) {
|
||||||
app.quit();
|
app.quit();
|
||||||
} else {
|
} else {
|
||||||
if (!allowMultipleInstances) {
|
if (!allowMultipleInstances) {
|
||||||
|
|||||||
@@ -4,6 +4,21 @@
|
|||||||
|
|
||||||
const { contextBridge, ipcRenderer } = require('electron');
|
const { contextBridge, ipcRenderer } = require('electron');
|
||||||
import type { StartupStatus } from './python';
|
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 {
|
import {
|
||||||
IPC_STARTUP_STATUS,
|
IPC_STARTUP_STATUS,
|
||||||
IPC_STARTUP_GET_STATUS,
|
IPC_STARTUP_GET_STATUS,
|
||||||
@@ -15,6 +30,9 @@ import {
|
|||||||
IPC_UPDATE_EVENT_AVAILABLE,
|
IPC_UPDATE_EVENT_AVAILABLE,
|
||||||
IPC_UPDATE_EVENT_DOWNLOADED,
|
IPC_UPDATE_EVENT_DOWNLOADED,
|
||||||
IPC_POWER_SET_SCREEN_AWAKE,
|
IPC_POWER_SET_SCREEN_AWAKE,
|
||||||
|
IPC_MAINTENANCE_GET_PATHS,
|
||||||
|
IPC_MAINTENANCE_RESET,
|
||||||
|
IPC_MAINTENANCE_RESTART,
|
||||||
} from './ipc-channels';
|
} from './ipc-channels';
|
||||||
|
|
||||||
// Auto-update channel + event payloads. Kept here (rather than re-exported
|
// Auto-update channel + event payloads. Kept here (rather than re-exported
|
||||||
@@ -451,6 +469,17 @@ const slopsmithDesktopApi = {
|
|||||||
ipcRenderer.invoke('network:setLanAccess', enabled),
|
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
|
// File dialogs
|
||||||
pickFile: (filters?: { name: string; extensions: string[] }[]) =>
|
pickFile: (filters?: { name: string; extensions: string[] }[]) =>
|
||||||
ipcRenderer.invoke('dialog:pickFile', filters),
|
ipcRenderer.invoke('dialog:pickFile', filters),
|
||||||
|
|||||||
+1
-1
@@ -911,4 +911,4 @@ export function restartPython(): void {
|
|||||||
setTimeout(() => startPython(), 1000);
|
setTimeout(() => startPython(), 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { getPluginsDir, getConfigDir };
|
export { getPluginsDir, getConfigDir, getDLCDir };
|
||||||
|
|||||||
@@ -1473,6 +1473,7 @@ window.__slopsmithDesktopAudioHooks = window.__slopsmithDesktopAudioHooks || {};
|
|||||||
setupAudioQualityControls();
|
setupAudioQualityControls();
|
||||||
setupToneAutomationSettingsEvents();
|
setupToneAutomationSettingsEvents();
|
||||||
setupUpdateChannelControls();
|
setupUpdateChannelControls();
|
||||||
|
setupMaintenanceControls();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Updater (Velopack) settings UI ────────────────────────────────────────
|
// ── Updater (Velopack) settings UI ────────────────────────────────────────
|
||||||
@@ -1644,6 +1645,110 @@ window.__slopsmithDesktopAudioHooks = window.__slopsmithDesktopAudioHooks || {};
|
|||||||
renderStatus();
|
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) ─────────────────────────────────────────────
|
// ── Audio Quality (soundfont) ─────────────────────────────────────────────
|
||||||
function setupAudioQualityControls() {
|
function setupAudioQualityControls() {
|
||||||
const api = window.slopsmithDesktop?.soundfont;
|
const api = window.slopsmithDesktop?.soundfont;
|
||||||
|
|||||||
@@ -107,4 +107,58 @@
|
|||||||
<div id="ae-ta-targets" class="space-y-1.5"></div>
|
<div id="ae-ta-targets" class="space-y-1.5"></div>
|
||||||
</div>
|
</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 & 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 & 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>
|
</div>
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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), []);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user