spendwise 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,187 @@
1
+ import { app, ipcMain, dialog, shell, BrowserWindow } from 'electron';
2
+ import path from 'node:path';
3
+ import fs from 'node:fs';
4
+ import { openDb, resolveDbPath, loadConfig, saveConfig, migrateLegacyUserData } from './storage/local.js';
5
+ import { backupDb, backupsDir } from './backup.js';
6
+ import { importLegacy } from './migrate.js';
7
+ import { createCore } from './api-core.js';
8
+ import { createWebServer } from './webserver.js';
9
+ import { RELEASES_URL } from './update-check.js';
10
+ import '../shared/engine.js';
11
+
12
+ const E = globalThis.FinEngine;
13
+
14
+ // Live db context shared with the api core (and the web server, which the
15
+ // main process starts against the same ctx - one process owns db.json).
16
+ export const ctx = { db: null, dbPath: null };
17
+ export let core = null;
18
+ export let webServer = null;
19
+
20
+ const ok = (extra = {}) => ({ ok: true, ...extra });
21
+
22
+ // Reverse-proxy settings resolve config-over-env: a value saved from the
23
+ // Settings panel wins; otherwise fall back to the FINANCES_* env vars the CLI
24
+ // uses; otherwise the safe default (no trusted proxy, cookie auto-detected).
25
+ // ?? not || so an explicit saved 0 / false isn't overridden by the env value.
26
+ function resolveWebProxy (config) {
27
+ const w = (config && config.webServer) || {};
28
+ const envTP = process.env.FINANCES_TRUST_PROXY;
29
+ const envSC = process.env.FINANCES_SECURE_COOKIE === '1' || process.env.FINANCES_SECURE_COOKIE === 'true';
30
+ return {
31
+ trustProxy: w.trustProxy ?? (envTP !== undefined ? Math.max(0, Number(envTP) || 0) : 0),
32
+ secureCookie: w.secureCookie ?? envSC,
33
+ };
34
+ }
35
+ const fail = (error) => ({ ok: false, error: String(error && error.message || error) });
36
+
37
+ export async function registerIpc () {
38
+ migrateLegacyUserData(); // must run before the config is read
39
+ ctx.dbPath = resolveDbPath();
40
+ ctx.db = await openDb(ctx.dbPath);
41
+ ctx.version = app.getVersion();
42
+ core = createCore(ctx);
43
+
44
+ const handle = (channel, fn) => ipcMain.handle(channel, async (event, payload) => {
45
+ try {
46
+ return await fn(payload || {}, event);
47
+ } catch (err) {
48
+ return fail(err);
49
+ }
50
+ });
51
+
52
+ // core-backed handlers (same handlers serve the web clients)
53
+ handle('db:load', async () => ok(await core.loadDb()));
54
+ handle('db:rev', async () => ok(core.rev()));
55
+ handle('db:saveMonth', async (p) => ok(await core.saveMonth(p)));
56
+ handle('db:saveClosedMonth', async (p) => ok(await core.saveClosedMonth(p)));
57
+ handle('db:closeMonth', async (p) => ok(await core.closeMonth(p)));
58
+ handle('db:saveSettings', async (p) => ok(await core.saveSettings(p)));
59
+ handle('db:apply-tags', async (p) => ok(await core.applyTagsEverywhere(p)));
60
+ handle('backups:list', async () => ok(await core.listBackups()));
61
+ handle('backups:restore', async (p) => ok(await core.restoreBackup(p)));
62
+ handle('db:migrate-legacy', async (p) => ok(await core.migrateLegacy(p)));
63
+ handle('auth:has', async () => ok(core.authHas()));
64
+ handle('auth:verify', async (p) => ok(core.authVerify(p)));
65
+ handle('auth:set', async (p) => ok(await core.authSet(p)));
66
+ handle('update:check', async (p) => ok(await core.checkUpdate(p)));
67
+
68
+ // Opening the release page is the ONLY external URL the app will launch,
69
+ // so it's pinned to the project's own releases rather than taking a URL
70
+ // from the renderer - a compromised page can't turn this into a launcher.
71
+ handle('update:open', async () => {
72
+ await shell.openExternal(RELEASES_URL);
73
+ return ok();
74
+ });
75
+
76
+ // ------------------------------------------------------------ web access
77
+
78
+ webServer = createWebServer(ctx, core);
79
+ const bootConfig = loadConfig();
80
+ if (bootConfig.webServer && bootConfig.webServer.enabled && core.authHas().hasPassword) {
81
+ try {
82
+ await webServer.start(bootConfig.webServer.port || 4180, undefined, resolveWebProxy(bootConfig));
83
+ console.log('[web] serving on port ' + (bootConfig.webServer.port || 4180));
84
+ } catch (e) {
85
+ console.error('[web] failed to start: ' + e.message);
86
+ }
87
+ }
88
+
89
+ handle('web:status', async () => {
90
+ const config = loadConfig();
91
+ return ok({
92
+ enabled: !!(config.webServer && config.webServer.enabled),
93
+ port: (config.webServer && config.webServer.port) || 4180,
94
+ hasPassword: core.authHas().hasPassword,
95
+ ...webServer.status(), // running/urls/sessions
96
+ // config intent wins for the proxy fields: while stopped the server
97
+ // still holds its boot default (0), which would misreport a saved
98
+ // setup. When running the two agree - start() derives from the same
99
+ // config - so overriding here is only ever a correction, never a lie.
100
+ ...resolveWebProxy(config),
101
+ });
102
+ });
103
+
104
+ handle('web:set', async ({ enabled, port, trustProxy, secureCookie }) => {
105
+ port = Math.max(1024, Math.min(65535, E.num(port) || 4180));
106
+ if (enabled && !core.authHas().hasPassword) {
107
+ throw new Error('Set an app password first - web access requires a login.');
108
+ }
109
+ const prev = loadConfig();
110
+ const webCfg = { ...(prev.webServer || {}), enabled: !!enabled, port };
111
+ // only persist proxy fields the caller actually sent, so unrelated
112
+ // toggles (on/off, port) don't wipe a saved reverse-proxy setup
113
+ if (trustProxy !== undefined) webCfg.trustProxy = Math.max(0, Math.min(10, Math.floor(Number(trustProxy) || 0)));
114
+ if (secureCookie !== undefined) webCfg.secureCookie = !!secureCookie;
115
+ saveConfig({ ...prev, webServer: webCfg });
116
+ const resolved = resolveWebProxy({ webServer: webCfg });
117
+ await webServer.stop();
118
+ if (enabled) await webServer.start(port, undefined, resolved);
119
+ // resolved last: while web access is off the server keeps its boot
120
+ // default (0), which would otherwise stomp the value just saved
121
+ return ok({ enabled: !!enabled, port, hasPassword: core.authHas().hasPassword, ...webServer.status(), ...resolved });
122
+ });
123
+
124
+ // ------------------------------------------- dialog-based (desktop only)
125
+
126
+ handle('backups:open-folder', async () => {
127
+ const dir = backupsDir(ctx.dbPath);
128
+ fs.mkdirSync(dir, { recursive: true });
129
+ await shell.openPath(dir);
130
+ return ok();
131
+ });
132
+
133
+ handle('db:export', async (payload, event) => {
134
+ const win = BrowserWindow.fromWebContents(event.sender);
135
+ const res = await dialog.showSaveDialog(win, {
136
+ title: 'Export a copy of your data',
137
+ defaultPath: 'spend-wise-export.json',
138
+ filters: [{ name: 'JSON', extensions: ['json'] }],
139
+ });
140
+ if (res.canceled || !res.filePath) return ok({ exported: null });
141
+ fs.copyFileSync(ctx.dbPath, res.filePath);
142
+ return ok({ exported: res.filePath });
143
+ });
144
+
145
+ // Move the database to a new location (e.g. a synced folder).
146
+ handle('db:change-location', async (payload, event) => {
147
+ const win = BrowserWindow.fromWebContents(event.sender);
148
+ const res = await dialog.showSaveDialog(win, {
149
+ title: 'Choose where to keep db.json',
150
+ defaultPath: path.join(path.dirname(ctx.dbPath), 'db.json'),
151
+ filters: [{ name: 'JSON', extensions: ['json'] }],
152
+ });
153
+ const snapshot = () => ({ data: E.clone(ctx.db.data), path: ctx.dbPath });
154
+ if (res.canceled || !res.filePath) return ok(snapshot());
155
+ const newPath = res.filePath;
156
+ if (path.resolve(newPath) !== path.resolve(ctx.dbPath)) {
157
+ if (fs.existsSync(newPath)) {
158
+ // adopting an existing file (multi-machine case): keep it, don't overwrite
159
+ backupDb(ctx.dbPath, 'pre-move');
160
+ } else {
161
+ fs.mkdirSync(path.dirname(newPath), { recursive: true });
162
+ fs.copyFileSync(ctx.dbPath, newPath);
163
+ }
164
+ saveConfig({ ...loadConfig(), dbPath: newPath });
165
+ ctx.dbPath = newPath;
166
+ ctx.db = await openDb(ctx.dbPath);
167
+ }
168
+ return ok(snapshot());
169
+ });
170
+
171
+ // Phase 1 of legacy import: pick the folder and report the groups found,
172
+ // so the user can adjust each group's kind before anything is written.
173
+ handle('db:migrate-scan', async (payload, event) => {
174
+ const win = BrowserWindow.fromWebContents(event.sender);
175
+ const res = await dialog.showOpenDialog(win, {
176
+ title: 'Select the legacy data folder (contains files like "january-26")',
177
+ properties: ['openDirectory'],
178
+ });
179
+ if (res.canceled || !res.filePaths.length) return ok({ scanned: false });
180
+ const { data } = importLegacy(res.filePaths[0]);
181
+ return ok({
182
+ scanned: true,
183
+ folder: res.filePaths[0],
184
+ groups: data.settings.groups.map((g) => ({ title: g.title, kind: g.kind })),
185
+ });
186
+ });
187
+ }
@@ -0,0 +1,125 @@
1
+ import { app, BrowserWindow, dialog } from 'electron';
2
+ import path from 'node:path';
3
+ import fs from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { registerIpc } from './ipc.js';
6
+
7
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
+
9
+ /**
10
+ * Closing with edits in flight would drop them silently — nothing is written
11
+ * until Update. This has to live here rather than in the renderer: Electron
12
+ * does not prompt for beforeunload, it just cancels the close without telling
13
+ * anyone. So intercept, ask the page what's unsaved, and confirm natively.
14
+ */
15
+ function guardClose (win) {
16
+ let allowClose = false;
17
+ // tests exit unattended, so the gate is off under FINANCES_E2E — unless
18
+ // FINANCES_CLOSE_ANSWER names the button to auto-click, which is how the
19
+ // gate itself gets tested without leaving a modal on screen
20
+ const autoAnswer = process.env.FINANCES_E2E && process.env.FINANCES_CLOSE_ANSWER;
21
+
22
+ win.on('close', (e) => {
23
+ if (allowClose || (process.env.FINANCES_E2E && !autoAnswer)) return;
24
+ e.preventDefault();
25
+ win.webContents
26
+ .executeJavaScript('window.__unsavedSummary ? window.__unsavedSummary() : null')
27
+ .catch(() => null) // a broken page must never trap the user inside the app
28
+ .then(async (unsaved) => {
29
+ if (!unsaved) { allowClose = true; win.close(); return; }
30
+ const { response } = autoAnswer
31
+ ? { response: autoAnswer === 'discard' ? 0 : 1 }
32
+ : await dialog.showMessageBox(win, {
33
+ type: 'warning',
34
+ buttons: ['Discard & quit', 'Keep editing'],
35
+ defaultId: 1,
36
+ cancelId: 1,
37
+ title: 'Unsaved changes',
38
+ message: `Discard unsaved changes to ${unsaved}?`,
39
+ detail: 'They have not been written to your database yet. Closing now loses them.',
40
+ });
41
+ if (response === 0) { allowClose = true; win.close(); }
42
+ });
43
+ });
44
+ }
45
+
46
+ function createWindow () {
47
+ const win = new BrowserWindow({
48
+ width: 1500,
49
+ height: 960,
50
+ minWidth: 900,
51
+ minHeight: 600,
52
+ icon: path.join(__dirname, '../renderer/assets/icon.ico'),
53
+ backgroundColor: '#fafafa',
54
+ webPreferences: {
55
+ preload: path.join(__dirname, '../preload.cjs'),
56
+ contextIsolation: true,
57
+ nodeIntegration: false,
58
+ sandbox: true,
59
+ },
60
+ });
61
+ win.removeMenu();
62
+ guardClose(win);
63
+ win.loadFile(path.join(__dirname, '../renderer/index.html'));
64
+ if (process.env.FINANCES_DEVTOOLS) win.webContents.openDevTools({ mode: 'detach' });
65
+ if (process.env.FINANCES_LOG_CONSOLE) {
66
+ win.webContents.on('console-message', (e, level, message, line, sourceId) => {
67
+ console.log(`[renderer:${level}] ${message} (${sourceId}:${line})`);
68
+ });
69
+ }
70
+ if (process.env.FINANCES_SCREENSHOT) {
71
+ win.webContents.once('did-finish-load', () => {
72
+ setTimeout(async () => {
73
+ const image = await win.webContents.capturePage();
74
+ fs.writeFileSync(process.env.FINANCES_SCREENSHOT, image.toPNG());
75
+ console.log('[screenshot] written to ' + process.env.FINANCES_SCREENSHOT);
76
+ }, 2000);
77
+ });
78
+ }
79
+ if (process.env.FINANCES_E2E) {
80
+ win.webContents.setBackgroundThrottling(false); // keep painting for capturePage
81
+ win.webContents.once('did-finish-load', () => {
82
+ setTimeout(async () => {
83
+ const script = fs.readFileSync(process.env.FINANCES_E2E, 'utf8');
84
+ let dbg = null;
85
+ if (process.env.FINANCES_PROFILE) {
86
+ dbg = win.webContents.debugger;
87
+ dbg.attach('1.3');
88
+ await dbg.sendCommand('Profiler.enable');
89
+ await dbg.sendCommand('Profiler.start');
90
+ }
91
+ try {
92
+ const result = await win.webContents.executeJavaScript(script);
93
+ console.log('[e2e] ' + JSON.stringify(result, null, 2));
94
+ } catch (err) {
95
+ console.log('[e2e:error] ' + (err && err.message || err));
96
+ }
97
+ if (dbg) {
98
+ const { profile } = await dbg.sendCommand('Profiler.stop');
99
+ fs.writeFileSync(process.env.FINANCES_PROFILE, JSON.stringify(profile));
100
+ console.log('[profile] written to ' + process.env.FINANCES_PROFILE);
101
+ }
102
+ if (process.env.FINANCES_E2E_SCREENSHOT) {
103
+ const image = await win.webContents.capturePage();
104
+ fs.writeFileSync(process.env.FINANCES_E2E_SCREENSHOT, image.toPNG());
105
+ console.log('[screenshot] written to ' + process.env.FINANCES_E2E_SCREENSHOT);
106
+ }
107
+ app.quit();
108
+ }, 2500);
109
+ });
110
+ }
111
+ return win;
112
+ }
113
+
114
+ app.whenReady().then(async () => {
115
+ await registerIpc();
116
+ createWindow();
117
+
118
+ app.on('activate', () => {
119
+ if (BrowserWindow.getAllWindows().length === 0) createWindow();
120
+ });
121
+ });
122
+
123
+ app.on('window-all-closed', () => {
124
+ if (process.platform !== 'darwin') app.quit();
125
+ });
@@ -0,0 +1,181 @@
1
+ import path from 'node:path';
2
+ import fs from 'node:fs';
3
+ import '../shared/engine.js';
4
+
5
+ const E = globalThis.FinEngine;
6
+
7
+ const MONTH_NUM = {
8
+ january: '01', february: '02', march: '03', april: '04',
9
+ may: '05', june: '06', july: '07', august: '08',
10
+ september: '09', october: '10', november: '11', december: '12',
11
+ };
12
+
13
+ const KIND_MAP = {
14
+ income: 'income',
15
+ budgets: 'envelope',
16
+ expenses: 'expense',
17
+ miscBudgets: 'goal',
18
+ };
19
+
20
+ /**
21
+ * Import a legacy old-finances-app data directory into a fresh db shape.
22
+ *
23
+ * Legacy layout: one JSON file per month named `<monthname>-<yy>` containing
24
+ * `{ form: { data: [groups], savings } }`, plus a `savings_history` file
25
+ * keyed like "June '18". Field ids are 13-char uniqids reused across months
26
+ * for pinned fields - we map each legacy id to one uuid so cross-month
27
+ * identity (and budget links) survive.
28
+ *
29
+ * kindOverrides maps a legacy group TITLE to a kind, overriding the default
30
+ * type mapping everywhere that group appears.
31
+ */
32
+ export function importLegacy (dataDir, kindOverrides = {}) {
33
+ const files = fs.readdirSync(dataDir);
34
+ const idMap = new Map();
35
+ const mapId = (oldId) => {
36
+ if (!oldId) return null;
37
+ if (!idMap.has(oldId)) idMap.set(oldId, E.uuid());
38
+ return idMap.get(oldId);
39
+ };
40
+
41
+ const groupDefs = [];
42
+ const defFor = (title, mappedKind) => {
43
+ let def = groupDefs.find((d) => d.title === title);
44
+ if (!def) {
45
+ const kind = kindOverrides[title] || mappedKind;
46
+ def = { id: E.uuid(), title, kind, order: groupDefs.length };
47
+ if (E.isEnvelopeKind(kind)) def.strictOverspend = false;
48
+ groupDefs.push(def);
49
+ }
50
+ return def;
51
+ };
52
+
53
+ const months = {};
54
+ let parsed = 0;
55
+ const warnings = [];
56
+
57
+ for (const file of files) {
58
+ const m = /^([a-z]+)-(\d{2})$/.exec(file);
59
+ if (!m || !MONTH_NUM[m[1]]) continue;
60
+ const key = `20${m[2]}-${MONTH_NUM[m[1]]}`;
61
+
62
+ let json;
63
+ try {
64
+ json = JSON.parse(fs.readFileSync(path.join(dataDir, file), 'utf8'));
65
+ } catch {
66
+ warnings.push(`${file}: unreadable JSON, skipped`);
67
+ continue;
68
+ }
69
+ const form = json && json.form;
70
+ if (!form || !Array.isArray(form.data)) {
71
+ warnings.push(`${file}: no form data, skipped`);
72
+ continue;
73
+ }
74
+
75
+ const month = {
76
+ key,
77
+ status: 'closed',
78
+ startingSavings: E.num(form.savings),
79
+ closingSavings: null,
80
+ groups: [],
81
+ };
82
+
83
+ for (const legacyGroup of form.data) {
84
+ const def = defFor(legacyGroup.title || 'Untitled', KIND_MAP[legacyGroup.type] || 'expense');
85
+ const kind = def.kind; // may differ from the legacy type via overrides
86
+ const group = { groupId: def.id, title: def.title, kind, fields: [] };
87
+
88
+ for (const lf of legacyGroup.fields || []) {
89
+ const field = {
90
+ id: mapId(lf.id) || E.uuid(),
91
+ label: lf.label || '',
92
+ value: E.num(lf.value),
93
+ pinned: E.truthy(lf.default),
94
+ accounted: E.truthy(lf.accounted),
95
+ tags: [],
96
+ };
97
+ if (kind === 'expense') field.budgetId = mapId(lf.budget);
98
+ if (E.isEnvelopeKind(kind)) {
99
+ field.avail = E.num(lf.avail);
100
+ field.spent = E.num(lf.spent);
101
+ }
102
+ if (kind === 'goal') field.target = null;
103
+ group.fields.push(field);
104
+ }
105
+ month.groups.push(group);
106
+ }
107
+
108
+ // Legacy 'budgets' stored spent as the DERIVED sum of linked expenses;
109
+ // our model stores only direct/manual spending and derives the linked
110
+ // part live - strip it so it isn't counted twice.
111
+ for (const group of month.groups) {
112
+ if (!E.isEnvelopeKind(group.kind)) continue;
113
+ for (const field of group.fields) {
114
+ field.spent = Math.max(0, E.num(field.spent) - E.linkedSpent(month, field.id));
115
+ }
116
+ }
117
+
118
+ months[key] = month;
119
+ parsed += 1;
120
+ }
121
+
122
+ if (!parsed) throw new Error('No legacy month files found in that folder.');
123
+
124
+ // most recent month stays open; every closed month gets a derived closing figure
125
+ const keys = Object.keys(months).sort();
126
+ months[keys[keys.length - 1]].status = 'open';
127
+ for (const key of keys) {
128
+ if (months[key].status === 'closed') {
129
+ months[key].closingSavings = E.savings(months[key]);
130
+ }
131
+ }
132
+
133
+ // savings_history: { "June '18": "3800" } → { "2018-06": 3800 }
134
+ const savingsHistory = {};
135
+ try {
136
+ const raw = JSON.parse(fs.readFileSync(path.join(dataDir, 'savings_history'), 'utf8'));
137
+ for (const [label, value] of Object.entries(raw)) {
138
+ const hm = /^([A-Za-z]+) '(\d{2})$/.exec(label.trim());
139
+ if (!hm) continue;
140
+ const mm = MONTH_NUM[hm[1].toLowerCase()];
141
+ if (!mm) continue;
142
+ savingsHistory[`20${hm[2]}-${mm}`] = E.num(value);
143
+ }
144
+ } catch {
145
+ warnings.push('savings_history missing or unreadable - rebuilt from month files');
146
+ }
147
+ // month files are authoritative where both exist
148
+ for (const key of keys) savingsHistory[key] = E.num(months[key].startingSavings);
149
+
150
+ // Only groups still present in the latest month become the go-forward
151
+ // settings; groups the user renamed/retired years ago live on inside the
152
+ // month snapshots (months are self-describing) without cluttering new months.
153
+ const latest = months[keys[keys.length - 1]];
154
+ const activeGroups = latest.groups.map((g, i) => {
155
+ const def = groupDefs.find((d) => d.id === g.groupId);
156
+ return { ...def, order: i };
157
+ });
158
+
159
+ return {
160
+ data: {
161
+ meta: { schemaVersion: 1, rev: 0 },
162
+ settings: {
163
+ currency: 'USD',
164
+ initialSavings: E.num(months[keys[0]].startingSavings),
165
+ groups: activeGroups,
166
+ },
167
+ months,
168
+ savingsHistory,
169
+ },
170
+ summary: {
171
+ months: parsed,
172
+ firstMonth: keys[0],
173
+ lastMonth: keys[keys.length - 1],
174
+ groups: activeGroups.map((g) => `${g.title} (${g.kind})`),
175
+ retiredGroups: groupDefs.filter((d) => !activeGroups.some((a) => a.id === d.id))
176
+ .map((g) => `${g.title} (${g.kind})`),
177
+ historyEntries: Object.keys(savingsHistory).length,
178
+ warnings,
179
+ },
180
+ };
181
+ }
@@ -0,0 +1,55 @@
1
+ import path from 'node:path';
2
+ import fs from 'node:fs';
3
+ import { app } from 'electron';
4
+ export { openDb, currentMonthKey } from '../db-open.js';
5
+
6
+ const configPath = () => path.join(app.getPath('userData'), 'config.json');
7
+
8
+ // The app was called "Finances" before 1.1; Electron derives userData from the
9
+ // app name, so the rename would strand the old folder's database, settings and
10
+ // backups. Adopt them once, on first run of the renamed app.
11
+ const LEGACY_APP_NAME = 'Finances';
12
+
13
+ export function migrateLegacyUserData () {
14
+ const current = app.getPath('userData');
15
+ const legacy = path.join(path.dirname(current), LEGACY_APP_NAME);
16
+ if (path.resolve(legacy) === path.resolve(current) || !fs.existsSync(legacy)) return null;
17
+ // only ever adopt into a folder that has no data of its own
18
+ if (fs.existsSync(path.join(current, 'db.json')) || fs.existsSync(path.join(current, 'config.json'))) return null;
19
+
20
+ const copied = [];
21
+ fs.mkdirSync(current, { recursive: true });
22
+ for (const name of ['config.json', 'db.json']) {
23
+ const from = path.join(legacy, name);
24
+ if (fs.existsSync(from)) { fs.copyFileSync(from, path.join(current, name)); copied.push(name); }
25
+ }
26
+ const legacyBackups = path.join(legacy, 'backups');
27
+ if (fs.existsSync(legacyBackups)) {
28
+ // the user's history lives here - Settings → Backups must still find it
29
+ fs.cpSync(legacyBackups, path.join(current, 'backups'), { recursive: true });
30
+ copied.push(`backups/ (${fs.readdirSync(legacyBackups).length} files)`);
31
+ }
32
+ if (!copied.length) return null;
33
+ // copy, never move: the old folder stays as a safety net
34
+ console.log(`[migrate] adopted data from "${LEGACY_APP_NAME}": ${copied.join(', ')}`);
35
+ return { from: legacy, to: current, copied };
36
+ }
37
+
38
+ export function loadConfig () {
39
+ try {
40
+ return JSON.parse(fs.readFileSync(configPath(), 'utf8'));
41
+ } catch {
42
+ return {};
43
+ }
44
+ }
45
+
46
+ export function saveConfig (config) {
47
+ fs.mkdirSync(path.dirname(configPath()), { recursive: true });
48
+ fs.writeFileSync(configPath(), JSON.stringify(config, null, 2));
49
+ }
50
+
51
+ export function resolveDbPath () {
52
+ if (process.env.FINANCES_DB_PATH) return process.env.FINANCES_DB_PATH; // tests
53
+ const config = loadConfig();
54
+ return config.dbPath || path.join(app.getPath('userData'), 'db.json');
55
+ }
@@ -0,0 +1,99 @@
1
+ /*
2
+ * Update check against the GitHub Releases API.
3
+ *
4
+ * Privacy: this is the ONLY outbound request the app ever makes, and it is a
5
+ * plain unauthenticated GET of a public URL - no query string, no body, no
6
+ * identifier, nothing about the user or their finances. GitHub sees an IP and
7
+ * a timestamp, the same as opening the releases page in a browser. It is off
8
+ * with one toggle (Settings → updateCheck: false) and silently skipped when
9
+ * offline, so the app still runs with the network unplugged.
10
+ *
11
+ * It runs in the main/server process, never the renderer: one check per
12
+ * install rather than one per open browser tab, and the renderer's CSP does
13
+ * not have to allow an external origin.
14
+ */
15
+ 'use strict';
16
+
17
+ const REPO = 'blindmikey/spendwise';
18
+ const ENDPOINT = `https://api.github.com/repos/${REPO}/releases/latest`;
19
+ const RELEASES_URL = `https://github.com/${REPO}/releases/latest`;
20
+
21
+ const TTL_MS = 24 * 60 * 60 * 1000; // a long-running server checks once a day
22
+ const TIMEOUT_MS = 5000; // never let a hung request stall the UI
23
+
24
+ /**
25
+ * "v1.2.3" / "1.2.3" / "1.2.3-beta.1" → { parts:[1,2,3], pre:'beta.1'|null }.
26
+ * Returns null for anything unparseable, so a garbage tag can never be read
27
+ * as an update.
28
+ */
29
+ export function parseVersion (v) {
30
+ const m = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(String(v || '').trim());
31
+ if (!m) return null;
32
+ return { parts: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] || null };
33
+ }
34
+
35
+ /** Is `latest` a newer release than `current`? Unparseable → false (never nag). */
36
+ export function isNewer (latest, current) {
37
+ const a = parseVersion(latest);
38
+ const b = parseVersion(current);
39
+ if (!a || !b) return false;
40
+ for (let i = 0; i < 3; i++) {
41
+ if (a.parts[i] > b.parts[i]) return true;
42
+ if (a.parts[i] < b.parts[i]) return false;
43
+ }
44
+ // same x.y.z: a prerelease is OLDER than its final, and we never offer to
45
+ // "update" someone from a final onto a prerelease
46
+ if (a.pre && !b.pre) return false;
47
+ if (!a.pre && b.pre) return true;
48
+ return false;
49
+ }
50
+
51
+ /**
52
+ * `fetchImpl` / `now` are injectable so tests never touch the network.
53
+ */
54
+ export function createUpdateChecker ({ currentVersion, fetchImpl, now } = {}) {
55
+ const doFetch = fetchImpl || globalThis.fetch;
56
+ const clock = now || (() => Date.now());
57
+ let cache = null; // { at, result }
58
+
59
+ async function fetchLatest () {
60
+ // AbortSignal.timeout needs node 17.3+; we support 18+
61
+ const res = await doFetch(ENDPOINT, {
62
+ headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'spendwise' },
63
+ signal: AbortSignal.timeout(TIMEOUT_MS),
64
+ });
65
+ if (!res.ok) throw new Error(`GitHub returned ${res.status}`);
66
+ const body = await res.json();
67
+ const tag = body && (body.tag_name || body.name);
68
+ if (!parseVersion(tag)) throw new Error('No usable release tag');
69
+ return { tag: String(tag), url: (body && body.html_url) || RELEASES_URL };
70
+ }
71
+
72
+ return {
73
+ /** `force` bypasses the cache (the Settings "Check now" button). */
74
+ async check ({ force = false } = {}) {
75
+ if (!force && cache && clock() - cache.at < TTL_MS) return cache.result;
76
+
77
+ const base = { current: currentVersion || '', checkedAt: clock() };
78
+ let result;
79
+ try {
80
+ const { tag, url } = await fetchLatest();
81
+ result = {
82
+ ...base,
83
+ latest: tag,
84
+ url,
85
+ available: isNewer(tag, currentVersion),
86
+ };
87
+ } catch (err) {
88
+ // offline, rate-limited, no releases yet, DNS blocked: all the
89
+ // same non-event. Never surfaced as an error the user must act
90
+ // on - the app does not need GitHub to work.
91
+ result = { ...base, latest: null, url: RELEASES_URL, available: false, unreachable: true };
92
+ }
93
+ cache = { at: clock(), result };
94
+ return result;
95
+ },
96
+ };
97
+ }
98
+
99
+ export { RELEASES_URL };