mirror of
https://github.com/got-feedBack/feedBack-desktop.git
synced 2026-08-11 03:09:56 +00:00
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:
co-authored by
Claude Opus 4.8
parent
cc0aceb365
commit
5188aab938
@@ -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_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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -911,4 +911,4 @@ export function restartPython(): void {
|
||||
setTimeout(() => startPython(), 1000);
|
||||
}
|
||||
|
||||
export { getPluginsDir, getConfigDir };
|
||||
export { getPluginsDir, getConfigDir, getDLCDir };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
Reference in New Issue
Block a user