settings-store.jsjavascript
/**
* @file Pure settings-store logic — plain Node, no Electron. src/settings.js owns the
* Electron-resolved file location (app.getPath('userData')) and the in-memory cache;
* this module owns the default schema, the deep-merge rules, and the never-throw read /
* atomic write, so the durability guarantees run anywhere Node runs. Callers pass the
* settings.json path explicitly.
* @module settings-store
*/
const path = require('path');
const fs = require('fs');
/**
* Default shape. Every key the app reads must exist here so a fresh install — or a
* settings file written by an older version — always resolves to a complete object after
* the deep-merge in read(). Bump `version` when the shape changes incompatibly.
*
* Field notes:
* files.defaultSaveFolder — null means "no explicit choice — fall back to ~/Downloads";
* kept null rather than baking in the Downloads path so the fallback follows the user
* across machines.
* files.nameAffix — text added to every exported layback name, e.g. "NB_" →
* NB_SummerNights.mp4; position is 'before' (prefix) or 'after' (suffix).
* metadata — optional tags written into export metadata (ticket F2); empty strings = not
* written. artworkPath is an absolute path to a JPEG/PNG used as cover artwork, or null;
* stored as a path for now — a future pass may copy the image into userData so it
* survives the source moving.
* integrations — non-secret connection state only (see src/settings.js header); the
* actual tokens live in the keychain via safeStorage, keyed by service name.
* app.checkUpdatesOnLaunch — background update checks (launch + every 4h). ON by
* default: staying current is the behavior every install has had, and a stranded old
* version is a support and security liability. Turning it off silences the BACKGROUND
* checks only — the "Check for Updates…" menu item always works.
* app.promptedUpdateVersion — last update version the restart dialog was shown for.
* update-downloaded re-fires every launch until the update installs; this is what keeps
* the dialog to one appearance per version (see updater.js shouldPrompt).
* @type {Object}
*/
const DEFAULTS = {
version: 1,
files: {
defaultSaveFolder: null,
nameAffix: { text: '', position: 'before' },
},
metadata: {
artist: '',
album: '',
composer: '',
grouping: '',
comments: '',
artworkPath: null,
},
integrations: {
frameio: { connected: false, account: null },
disco: { connected: false, account: null },
},
app: {
checkUpdatesOnLaunch: true,
promptedUpdateVersion: null,
},
};
/**
* Recursively fill any keys missing from `target` with values from `defaults`. This is
* what makes the store forward-compatible: a settings.json written before a new field
* existed still yields a complete object, with new fields at their defaults. Plain
* objects are merged key-by-key; arrays and primitives are taken wholesale from target.
* @param {*} defaults Default value or schema branch.
* @param {*} target Stored value or patch branch; undefined falls back to the default.
* @returns {*} The merged result.
*/
function mergeDefaults(defaults, target) {
if (!isPlainObject(defaults)) return target === undefined ? defaults : target;
const out = {};
for (const key of Object.keys(defaults)) {
const d = defaults[key];
const t = target ? target[key] : undefined;
out[key] = isPlainObject(d) ? mergeDefaults(d, t) : (t === undefined ? d : t);
}
return out;
}
/**
* Whether a value is a plain object (not null, not an array).
* @param {*} v Candidate value.
* @returns {boolean} True for non-null, non-array objects.
*/
function isPlainObject(v) {
return v !== null && typeof v === 'object' && !Array.isArray(v);
}
/**
* Read settings from disk and merge against the defaults. A missing file (fresh install)
* or a corrupt/unparseable file both fall back to a deep copy of the defaults — read()
* never throws, so a bad file can't brick app launch.
* @param {string} file Absolute path of settings.json.
* @returns {Object} Complete settings object.
*/
function read(file) {
try {
const raw = fs.readFileSync(file, 'utf8');
return mergeDefaults(DEFAULTS, JSON.parse(raw));
} catch (err) {
if (err.code !== 'ENOENT') {
console.error('[settings] unreadable, using defaults:', err.message);
}
return mergeDefaults(DEFAULTS, {});
}
}
/**
* Atomic write: serialize to a temp file in the same directory, then rename over the
* real file. rename() is atomic on a single filesystem, so a crash mid-write can't leave
* a half-written settings.json — readers see either the old file or the complete new
* one. Creates the parent directory first (userData may not exist on first run).
* @param {string} file Absolute path of settings.json.
* @param {Object} data Settings object to persist.
* @returns {void}
*/
function persist(file, data) {
const tmp = `${file}.${process.pid}.tmp`;
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf8');
fs.renameSync(tmp, file);
}
module.exports = { DEFAULTS, mergeDefaults, read, persist };