max / goingson
21 files changed,
+483 insertions,
-30 deletions
| @@ -2289,6 +2289,7 @@ dependencies = [ | |||
| 2289 | 2289 | "sha2 0.11.0", | |
| 2290 | 2290 | "sqlx", | |
| 2291 | 2291 | "synckit-client", | |
| 2292 | + | "synckit-config", | |
| 2292 | 2293 | "tauri", | |
| 2293 | 2294 | "tauri-build", | |
| 2294 | 2295 | "tauri-plugin-dialog", | |
| @@ -6193,6 +6194,7 @@ dependencies = [ | |||
| 6193 | 6194 | "serde", | |
| 6194 | 6195 | "serde_json", | |
| 6195 | 6196 | "sha2 0.11.0", | |
| 6197 | + | "synckit-config", | |
| 6196 | 6198 | "thiserror 2.0.18", | |
| 6197 | 6199 | "tokio", | |
| 6198 | 6200 | "tokio-stream", | |
| @@ -6205,6 +6207,14 @@ dependencies = [ | |||
| 6205 | 6207 | ] | |
| 6206 | 6208 | ||
| 6207 | 6209 | [[package]] | |
| 6210 | + | name = "synckit-config" | |
| 6211 | + | version = "0.1.2" | |
| 6212 | + | dependencies = [ | |
| 6213 | + | "rusqlite", | |
| 6214 | + | "thiserror 2.0.18", | |
| 6215 | + | ] | |
| 6216 | + | ||
| 6217 | + | [[package]] | |
| 6208 | 6218 | name = "synstructure" | |
| 6209 | 6219 | version = "0.13.2" | |
| 6210 | 6220 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -63,6 +63,7 @@ pub const EXCLUDED_TABLES: &[&str] = &[ | |||
| 63 | 63 | "sync_state", // sync cursors/flags, internal | |
| 64 | 64 | "hlc_state", // hybrid logical clock, device-local sync internal | |
| 65 | 65 | "sync_committed_hlc", // per-row committed HLC clock store, device-local sync internal | |
| 66 | + | "user_config", // app settings; synced keys recover via cloud sync, device-local keys re-derive per device | |
| 66 | 67 | ]; | |
| 67 | 68 | ||
| 68 | 69 | /// Restore a backup into the database as a single transaction. |
| @@ -0,0 +1,11 @@ | |||
| 1 | + | -- App settings as key/value rows, moved off the frontend's localStorage onto the | |
| 2 | + | -- shared config store (step 6 of the portable-config migration). Which keys sync | |
| 3 | + | -- is governed by posture: the SyncStore engine generates this table's triggers | |
| 4 | + | -- from the config manifest (config_sync_table in syncstore/manifest.rs), and they | |
| 5 | + | -- enqueue a changelog row only for keys the config_key_policy allowlist marks | |
| 6 | + | -- replicated. The policy is seeded from the ConfigSpec at every launch | |
| 7 | + | -- (syncstore::seed_config_policy); an undeclared key has no row and never syncs. | |
| 8 | + | CREATE TABLE IF NOT EXISTS user_config ( | |
| 9 | + | key TEXT PRIMARY KEY, | |
| 10 | + | value TEXT NOT NULL | |
| 11 | + | ); |
| @@ -59,4 +59,5 @@ | |||
| 59 | 59 | 056 30e200908dd4f6d2ebdf5b8f1419207d9c9af430f99df471b61cfd3c9659851a0f8a9b9596aeb682c6567af76bd39ff6 | |
| 60 | 60 | 057 f43f541166c9beb552e22bc5170fcb7bb95db580a6f82fc5dca88e74edeb433ff22e0bd72ddf3913800075a53d360978 | |
| 61 | 61 | 058 8c800b52c85af7f107d7706f4d5c3dfcc3a44f5877a606da23bb3e1df0fa4812f34f2fc78999b16bcab9a6491ff78512 | |
| 62 | - | 059 a493b5976c6e2af49213f9d2e2ec1fb052d88e150b98a4dcaea34539472bb92ebc06ed2d66fb69738e522f208efeedd9 | |
| 62 | + | 059 32140c4ee5bb75b9d6530a0b7c60cef872acc5fa50bb969eb9368036131d4f84931cddbcf6f8b41038fb0d5fc175af7b | |
| 63 | + | 060 e5b833cff6c768710b0daf88fc9cf087938278ec158c99c0789242878832e802ae00e82bef2cc45efe21644de8d9fa57 |
| @@ -20,6 +20,7 @@ makeover.workspace = true | |||
| 20 | 20 | goingson-core = { workspace = true } | |
| 21 | 21 | goingson-db-sqlite = { workspace = true } | |
| 22 | 22 | synckit-client = { path = "../../../synckit/synckit-client" } | |
| 23 | + | synckit-config = { path = "../../../synckit/synckit-config" } | |
| 23 | 24 | ||
| 24 | 25 | # Tauri | |
| 25 | 26 | tauri = { workspace = true, features = ["image-png"] } |
| @@ -614,6 +614,7 @@ | |||
| 614 | 614 | ||
| 615 | 615 | <!-- Namespace (must load first) --> | |
| 616 | 616 | <script src="js/goingson.js"></script> | |
| 617 | + | <script src="js/config.js"></script> | |
| 617 | 618 | <script src="js/dispatch.js"></script> | |
| 618 | 619 | <script src="js/viewport.js"></script> | |
| 619 | 620 |
| @@ -71,10 +71,12 @@ document.addEventListener('DOMContentLoaded', async () => { | |||
| 71 | 71 | GoingsOn.tasks.load(); | |
| 72 | 72 | } | |
| 73 | 73 | ||
| 74 | - | // First-run welcome | |
| 75 | - | if (!localStorage.getItem('go-welcomed')) { | |
| 74 | + | // First-run welcome. These flags live in the config store now; wait for the | |
| 75 | + | // cache so a first run is not misread as a returning one. | |
| 76 | + | await GoingsOn.config.ready; | |
| 77 | + | if (!GoingsOn.config.get('go-welcomed')) { | |
| 76 | 78 | showWelcome(); | |
| 77 | - | } else if (!localStorage.getItem('go-hint-shortcuts')) { | |
| 79 | + | } else if (!GoingsOn.config.get('go-hint-shortcuts')) { | |
| 78 | 80 | // One-time hint after first session | |
| 79 | 81 | setTimeout(() => showHint('go-hint-shortcuts', 'Press ? anytime to see keyboard shortcuts'), 2000); | |
| 80 | 82 | } | |
| @@ -376,11 +378,11 @@ function showWelcome() { | |||
| 376 | 378 | } | |
| 377 | 379 | ||
| 378 | 380 | /** | |
| 379 | - | * Show a one-time dismissible hint toast. Sets localStorage key so it only shows once. | |
| 381 | + | * Show a one-time dismissible hint toast. Sets a config flag so it only shows once. | |
| 380 | 382 | */ | |
| 381 | 383 | function showHint(storageKey, message) { | |
| 382 | - | if (localStorage.getItem(storageKey)) return; | |
| 383 | - | localStorage.setItem(storageKey, '1'); | |
| 384 | + | if (GoingsOn.config.get(storageKey)) return; | |
| 385 | + | GoingsOn.config.set(storageKey, '1'); | |
| 384 | 386 | GoingsOn.ui.showToast(message, 'info', { duration: 5000 }); | |
| 385 | 387 | } | |
| 386 | 388 |
| @@ -56,9 +56,11 @@ function toggleExpand(el) { | |||
| 56 | 56 | if (on && off) el.textContent = el.classList.contains('expanded') ? on : off; | |
| 57 | 57 | } | |
| 58 | 58 | ||
| 59 | - | /** Set a localStorage key (replaces inline `localStorage.setItem(...)`). */ | |
| 59 | + | /** Set a config value. Routes through `GoingsOn.config`, so a known config key | |
| 60 | + | * (e.g. the nudge prefs and the welcome/hint flags) lands in the backend | |
| 61 | + | * `user_config` store while any other key passes through to localStorage. */ | |
| 60 | 62 | function setLocalStorage(key, value) { | |
| 61 | - | try { localStorage.setItem(key, value); } catch (e) { /* storage disabled */ } | |
| 63 | + | GoingsOn.config.set(key, value); | |
| 62 | 64 | } | |
| 63 | 65 | ||
| 64 | 66 | /** Run a `GoingsOn` action only when Enter was pressed (for keydown handlers). */ |
| @@ -0,0 +1,138 @@ | |||
| 1 | + | /** | |
| 2 | + | * GoingsOn - App config cache. | |
| 3 | + | * | |
| 4 | + | * Settings used to live in localStorage. Step 6 of the portable-config migration | |
| 5 | + | * moves the known config keys into the backend `user_config` table (synced by | |
| 6 | + | * posture), while keeping a synchronous read API so the rest of the frontend does | |
| 7 | + | * not have to become async. | |
| 8 | + | * | |
| 9 | + | * `GoingsOn.config` is a superset drop-in for the old localStorage calls: | |
| 10 | + | * - a KNOWN config key (by its clean store name or its legacy localStorage | |
| 11 | + | * name) is read from an in-memory cache and written through to the backend; | |
| 12 | + | * - any other key passes through to localStorage unchanged. | |
| 13 | + | * | |
| 14 | + | * The cache is filled once at startup from `get_all_config`; consumers that read | |
| 15 | + | * during app init must `await GoingsOn.config.ready` first. The one paint-critical | |
| 16 | + | * key, `ui_mode`, is mirrored back to localStorage on every write so the | |
| 17 | + | * pre-stylesheet `bootstrap-uimode.js` can still read it synchronously before the | |
| 18 | + | * first paint. | |
| 19 | + | */ | |
| 20 | + | (function () { | |
| 21 | + | 'use strict'; | |
| 22 | + | ||
| 23 | + | const invoke = window.__TAURI__.core.invoke; | |
| 24 | + | ||
| 25 | + | // Legacy localStorage key -> clean store key. Callers may pass either. | |
| 26 | + | const LEGACY = { | |
| 27 | + | 'goingson-theme': 'theme', | |
| 28 | + | 'goingson.uiMode': 'ui_mode', | |
| 29 | + | 'go-welcomed': 'welcomed', | |
| 30 | + | 'go-hint-shortcuts': 'hint_shortcuts', | |
| 31 | + | 'goingson-event-lead-minutes': 'event_lead_minutes', | |
| 32 | + | 'goingson-plan-nudges': 'plan_nudges', | |
| 33 | + | 'goingson-review-nudges': 'review_nudges', | |
| 34 | + | 'goingson-work-start-hour': 'work_start_hour', | |
| 35 | + | 'goingson-work-end-hour': 'work_end_hour', | |
| 36 | + | }; | |
| 37 | + | // Store key -> its localStorage mirror key, for paint-critical keys the | |
| 38 | + | // pre-paint bootstrap reads synchronously. | |
| 39 | + | const MIRROR = { ui_mode: 'goingson.uiMode' }; | |
| 40 | + | ||
| 41 | + | const STORE_KEYS = new Set(Object.values(LEGACY)); | |
| 42 | + | const cache = new Map(); | |
| 43 | + | let resolveReady; | |
| 44 | + | const ready = new Promise((r) => { resolveReady = r; }); | |
| 45 | + | ||
| 46 | + | /** The clean store key for a caller key (store name or legacy name), or null | |
| 47 | + | * when the key is not a known config key. */ | |
| 48 | + | function norm(key) { | |
| 49 | + | if (STORE_KEYS.has(key)) return key; | |
| 50 | + | return LEGACY[key] || null; | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | function readLocal(key) { | |
| 54 | + | try { return localStorage.getItem(key); } catch (e) { return null; } | |
| 55 | + | } | |
| 56 | + | function writeLocal(key, value) { | |
| 57 | + | try { localStorage.setItem(key, value); } catch (e) { /* storage disabled */ } | |
| 58 | + | } | |
| 59 | + | function removeLocal(key) { | |
| 60 | + | try { localStorage.removeItem(key); } catch (e) { /* storage disabled */ } | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | async function load() { | |
| 64 | + | try { | |
| 65 | + | const all = await invoke('get_all_config'); | |
| 66 | + | for (const k of Object.keys(all)) cache.set(k, all[k]); | |
| 67 | + | // One-time migration: for any known key the backend does not yet have, | |
| 68 | + | // copy the old localStorage value up. Idempotent (skipped once the | |
| 69 | + | // backend has the key); localStorage is left intact as the ui_mode | |
| 70 | + | // mirror and a harmless fallback. | |
| 71 | + | for (const legacyKey of Object.keys(LEGACY)) { | |
| 72 | + | const storeKey = LEGACY[legacyKey]; | |
| 73 | + | if (cache.has(storeKey)) continue; | |
| 74 | + | const v = readLocal(legacyKey); | |
| 75 | + | if (v === null) continue; | |
| 76 | + | cache.set(storeKey, v); | |
| 77 | + | try { | |
| 78 | + | await invoke('set_config', { key: storeKey, value: v }); | |
| 79 | + | } catch (e) { | |
| 80 | + | console.error('config migrate failed for', storeKey, e); | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | } catch (e) { | |
| 84 | + | console.error('Failed to load config:', e); | |
| 85 | + | } finally { | |
| 86 | + | resolveReady(); | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | GoingsOn.config = { | |
| 91 | + | /** Resolves once the cache is populated. Await before reading at init. */ | |
| 92 | + | ready, | |
| 93 | + | ||
| 94 | + | /** Synchronous read. Known keys come from the cache (falling back to the | |
| 95 | + | * localStorage mirror, then `fallback`); unknown keys pass through to | |
| 96 | + | * localStorage. Returns a string or `fallback` (default null). */ | |
| 97 | + | get(key, fallback = null) { | |
| 98 | + | const storeKey = norm(key); | |
| 99 | + | if (storeKey) { | |
| 100 | + | if (cache.has(storeKey)) return cache.get(storeKey); | |
| 101 | + | const mirrored = MIRROR[storeKey] ? readLocal(MIRROR[storeKey]) : null; | |
| 102 | + | return mirrored !== null ? mirrored : fallback; | |
| 103 | + | } | |
| 104 | + | const v = readLocal(key); | |
| 105 | + | return v !== null ? v : fallback; | |
| 106 | + | }, | |
| 107 | + | ||
| 108 | + | /** Write. Known keys go to the cache + backend (+ localStorage mirror for | |
| 109 | + | * paint-critical keys); unknown keys pass through to localStorage. */ | |
| 110 | + | set(key, value) { | |
| 111 | + | value = String(value); | |
| 112 | + | const storeKey = norm(key); | |
| 113 | + | if (storeKey) { | |
| 114 | + | cache.set(storeKey, value); | |
| 115 | + | if (MIRROR[storeKey]) writeLocal(MIRROR[storeKey], value); | |
| 116 | + | invoke('set_config', { key: storeKey, value }).catch((e) => | |
| 117 | + | console.error('set_config failed for', storeKey, e)); | |
| 118 | + | return; | |
| 119 | + | } | |
| 120 | + | writeLocal(key, value); | |
| 121 | + | }, | |
| 122 | + | ||
| 123 | + | /** Remove a key. */ | |
| 124 | + | remove(key) { | |
| 125 | + | const storeKey = norm(key); | |
| 126 | + | if (storeKey) { | |
| 127 | + | cache.delete(storeKey); | |
| 128 | + | if (MIRROR[storeKey]) removeLocal(MIRROR[storeKey]); | |
| 129 | + | invoke('delete_config', { key: storeKey }).catch((e) => | |
| 130 | + | console.error('delete_config failed for', storeKey, e)); | |
| 131 | + | return; | |
| 132 | + | } | |
| 133 | + | removeLocal(key); | |
| 134 | + | }, | |
| 135 | + | }; | |
| 136 | + | ||
| 137 | + | load(); | |
| 138 | + | })(); |
| @@ -764,7 +764,7 @@ | |||
| 764 | 764 | * and applies it to the UI status dots. All date math is done in Rust. | |
| 765 | 765 | */ | |
| 766 | 766 | async function updateEventStatusDot() { | |
| 767 | - | const leadMinutes = parseInt(localStorage.getItem('goingson-event-lead-minutes') || '15', 10); | |
| 767 | + | const leadMinutes = parseInt(GoingsOn.config.get('goingson-event-lead-minutes', '15'), 10); | |
| 768 | 768 | let status, label; | |
| 769 | 769 | ||
| 770 | 770 | try { |
| @@ -11,10 +11,10 @@ | |||
| 11 | 11 | ||
| 12 | 12 | function getSettings() { | |
| 13 | 13 | return { | |
| 14 | - | workStartHour: parseInt(localStorage.getItem('goingson-work-start-hour') || '9', 10), | |
| 15 | - | workEndHour: parseInt(localStorage.getItem('goingson-work-end-hour') || '17', 10), | |
| 16 | - | planNudges: localStorage.getItem('goingson-plan-nudges') !== 'disabled', | |
| 17 | - | reviewNudges: localStorage.getItem('goingson-review-nudges') !== 'disabled', | |
| 14 | + | workStartHour: parseInt(GoingsOn.config.get('goingson-work-start-hour', '9'), 10), | |
| 15 | + | workEndHour: parseInt(GoingsOn.config.get('goingson-work-end-hour', '17'), 10), | |
| 16 | + | planNudges: GoingsOn.config.get('goingson-plan-nudges') !== 'disabled', | |
| 17 | + | reviewNudges: GoingsOn.config.get('goingson-review-nudges') !== 'disabled', | |
| 18 | 18 | }; | |
| 19 | 19 | } | |
| 20 | 20 |
| @@ -86,7 +86,7 @@ | |||
| 86 | 86 | // Section Renderers | |
| 87 | 87 | ||
| 88 | 88 | async function renderAppearance(container) { | |
| 89 | - | const savedTheme = localStorage.getItem('goingson-theme') || 'system'; | |
| 89 | + | const savedTheme = GoingsOn.config.get('goingson-theme') || 'system'; | |
| 90 | 90 | const { light: lightThemes, dark: darkThemes, highContrast: highContrastThemes } = await GoingsOn.themes.getByType(); | |
| 91 | 91 | ||
| 92 | 92 | const highContrastGroup = highContrastThemes.length > 0 | |
| @@ -130,7 +130,7 @@ | |||
| 130 | 130 | <label class="form-label">Event indicator lead time</label> | |
| 131 | 131 | <select id="event-lead-time-selector" class="form-select" data-change="settings.onEventLeadTimeChange" data-a1="@value"> | |
| 132 | 132 | ${[5, 10, 15, 30, 60].map(m => { | |
| 133 | - | const currentLead = parseInt(localStorage.getItem('goingson-event-lead-minutes') || '15', 10); | |
| 133 | + | const currentLead = parseInt(GoingsOn.config.get('goingson-event-lead-minutes') || '15', 10); | |
| 134 | 134 | const label = m === 60 ? '1 hour' : m + ' minutes'; | |
| 135 | 135 | const suffix = m === 15 ? ' (default)' : ''; | |
| 136 | 136 | return '<option value="' + m + '"' + (currentLead === m ? ' selected' : '') + '>' + label + suffix + '</option>'; | |
| @@ -150,11 +150,11 @@ | |||
| 150 | 150 | <label class="form-label">Work hours</label> | |
| 151 | 151 | <div class="work-hours-row"> | |
| 152 | 152 | <select class="form-select" data-change="settings.onWorkHoursChange" data-a1="start" data-a2="@value"> | |
| 153 | - | ${buildHourOptions(parseInt(localStorage.getItem('goingson-work-start-hour') || '9', 10))} | |
| 153 | + | ${buildHourOptions(parseInt(GoingsOn.config.get('goingson-work-start-hour') || '9', 10))} | |
| 154 | 154 | </select> | |
| 155 | 155 | <span>to</span> | |
| 156 | 156 | <select class="form-select" data-change="settings.onWorkHoursChange" data-a1="end" data-a2="@value"> | |
| 157 | - | ${buildHourOptions(parseInt(localStorage.getItem('goingson-work-end-hour') || '17', 10))} | |
| 157 | + | ${buildHourOptions(parseInt(GoingsOn.config.get('goingson-work-end-hour') || '17', 10))} | |
| 158 | 158 | </select> | |
| 159 | 159 | </div> | |
| 160 | 160 | <p class="form-hint" style="margin-top: 0.5rem;">Controls when plan/review nudge dots appear.</p> | |
| @@ -162,15 +162,15 @@ | |||
| 162 | 162 | <div class="form-group"> | |
| 163 | 163 | <label class="form-label">Plan nudges</label> | |
| 164 | 164 | <select class="form-select" data-change="ui.setLocalStorage" data-a1="goingson-plan-nudges" data-a2="@value"> | |
| 165 | - | <option value="enabled" ${localStorage.getItem('goingson-plan-nudges') !== 'disabled' ? 'selected' : ''}>Enabled (default)</option> | |
| 166 | - | <option value="disabled" ${localStorage.getItem('goingson-plan-nudges') === 'disabled' ? 'selected' : ''}>Disabled</option> | |
| 165 | + | <option value="enabled" ${GoingsOn.config.get('goingson-plan-nudges') !== 'disabled' ? 'selected' : ''}>Enabled (default)</option> | |
| 166 | + | <option value="disabled" ${GoingsOn.config.get('goingson-plan-nudges') === 'disabled' ? 'selected' : ''}>Disabled</option> | |
| 167 | 167 | </select> | |
| 168 | 168 | </div> | |
| 169 | 169 | <div class="form-group"> | |
| 170 | 170 | <label class="form-label">Review nudges</label> | |
| 171 | 171 | <select class="form-select" data-change="ui.setLocalStorage" data-a1="goingson-review-nudges" data-a2="@value"> | |
| 172 | - | <option value="enabled" ${localStorage.getItem('goingson-review-nudges') !== 'disabled' ? 'selected' : ''}>Enabled (default)</option> | |
| 173 | - | <option value="disabled" ${localStorage.getItem('goingson-review-nudges') === 'disabled' ? 'selected' : ''}>Disabled</option> | |
| 172 | + | <option value="enabled" ${GoingsOn.config.get('goingson-review-nudges') !== 'disabled' ? 'selected' : ''}>Enabled (default)</option> | |
| 173 | + | <option value="disabled" ${GoingsOn.config.get('goingson-review-nudges') === 'disabled' ? 'selected' : ''}>Disabled</option> | |
| 174 | 174 | </select> | |
| 175 | 175 | </div> | |
| 176 | 176 | </div> | |
| @@ -369,7 +369,7 @@ | |||
| 369 | 369 | // Helpers | |
| 370 | 370 | ||
| 371 | 371 | function onEventLeadTimeChange(value) { | |
| 372 | - | localStorage.setItem('goingson-event-lead-minutes', value); | |
| 372 | + | GoingsOn.config.set('goingson-event-lead-minutes', value); | |
| 373 | 373 | if (GoingsOn.events && GoingsOn.events.updateEventStatusDot) { | |
| 374 | 374 | GoingsOn.events.updateEventStatusDot(); | |
| 375 | 375 | } | |
| @@ -386,7 +386,7 @@ | |||
| 386 | 386 | ||
| 387 | 387 | function onWorkHoursChange(which, value) { | |
| 388 | 388 | const key = which === 'start' ? 'goingson-work-start-hour' : 'goingson-work-end-hour'; | |
| 389 | - | localStorage.setItem(key, value); | |
| 389 | + | GoingsOn.config.set(key, value); | |
| 390 | 390 | } | |
| 391 | 391 | ||
| 392 | 392 | // Populate GoingsOn Namespace |
| @@ -87,7 +87,7 @@ | |||
| 87 | 87 | ||
| 88 | 88 | applyColors(theme.intents); | |
| 89 | 89 | GoingsOn.state.set('currentThemeId', themeId); | |
| 90 | - | localStorage.setItem('goingson-theme', themeId); | |
| 90 | + | GoingsOn.config.set('goingson-theme', themeId); | |
| 91 | 91 | ||
| 92 | 92 | // Update the theme selector if it exists | |
| 93 | 93 | const selector = document.getElementById('theme-selector'); | |
| @@ -111,10 +111,12 @@ | |||
| 111 | 111 | * Load theme from localStorage or use system preference | |
| 112 | 112 | */ | |
| 113 | 113 | async function loadThemeFromStorage() { | |
| 114 | + | // The chosen theme lives in the config store now; wait for the cache. | |
| 115 | + | await GoingsOn.config.ready; | |
| 114 | 116 | // Pre-fetch the theme list so it's cached for the settings UI | |
| 115 | 117 | await fetchThemeList(); | |
| 116 | 118 | ||
| 117 | - | const savedTheme = localStorage.getItem('goingson-theme'); | |
| 119 | + | const savedTheme = GoingsOn.config.get('goingson-theme'); | |
| 118 | 120 | if (savedTheme === 'system') { | |
| 119 | 121 | await applySystemTheme(); | |
| 120 | 122 | } else if (savedTheme) { | |
| @@ -134,7 +136,7 @@ | |||
| 134 | 136 | } else { | |
| 135 | 137 | await loadTheme('goingson'); | |
| 136 | 138 | } | |
| 137 | - | localStorage.setItem('goingson-theme', 'system'); | |
| 139 | + | GoingsOn.config.set('goingson-theme', 'system'); | |
| 138 | 140 | } | |
| 139 | 141 | ||
| 140 | 142 | /** | |
| @@ -181,7 +183,7 @@ | |||
| 181 | 183 | // Listen for system theme changes | |
| 182 | 184 | if (window.matchMedia) { | |
| 183 | 185 | window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { | |
| 184 | - | if (localStorage.getItem('goingson-theme') === 'system') { | |
| 186 | + | if (GoingsOn.config.get('goingson-theme') === 'system') { | |
| 185 | 187 | applySystemTheme(); | |
| 186 | 188 | } | |
| 187 | 189 | }); |
| @@ -32,9 +32,9 @@ | |||
| 32 | 32 | */ | |
| 33 | 33 | setOverride: function (m) { | |
| 34 | 34 | if (m === null) { | |
| 35 | - | localStorage.removeItem('goingson.uiMode'); | |
| 35 | + | GoingsOn.config.remove('goingson.uiMode'); | |
| 36 | 36 | } else if (m === 'mobile' || m === 'desktop') { | |
| 37 | - | localStorage.setItem('goingson.uiMode', m); | |
| 37 | + | GoingsOn.config.set('goingson.uiMode', m); | |
| 38 | 38 | } else { | |
| 39 | 39 | return; | |
| 40 | 40 | } |
| @@ -0,0 +1,99 @@ | |||
| 1 | + | //! App config (`user_config`) key/value commands. | |
| 2 | + | //! | |
| 3 | + | //! The SQLite side of settings that used to live in the frontend's | |
| 4 | + | //! `localStorage` (step 6 of the portable-config migration). Writes land in the | |
| 5 | + | //! `user_config` table, whose SyncStore-generated triggers replicate the keys | |
| 6 | + | //! the config spec ([`crate::config_key::CONFIG`]) marks `Synced`. Keys are | |
| 7 | + | //! validated against the spec on write, so the table stays the declared closed | |
| 8 | + | //! set rather than accumulating whatever the frontend sends. | |
| 9 | + | ||
| 10 | + | use std::collections::HashMap; | |
| 11 | + | use std::sync::Arc; | |
| 12 | + | ||
| 13 | + | use tauri::State; | |
| 14 | + | use tracing::instrument; | |
| 15 | + | ||
| 16 | + | use super::ApiError; | |
| 17 | + | use crate::config_key::CONFIG; | |
| 18 | + | use crate::state::AppState; | |
| 19 | + | ||
| 20 | + | #[allow( | |
| 21 | + | clippy::needless_pass_by_value, | |
| 22 | + | reason = "passed to .map_err(db_err), which supplies the error by value" | |
| 23 | + | )] | |
| 24 | + | fn db_err(e: sqlx::Error) -> ApiError { | |
| 25 | + | ApiError::database(e.to_string()) | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | /// Reject a key the spec does not declare, so `user_config` mirrors the spec's | |
| 29 | + | /// closed set and a typo cannot create a stray, never-synced row. | |
| 30 | + | fn ensure_known(key: &str) -> Result<(), ApiError> { | |
| 31 | + | if CONFIG.keys().any(|(known, _)| known == key) { | |
| 32 | + | Ok(()) | |
| 33 | + | } else { | |
| 34 | + | Err(ApiError::validation("key", "unknown config key")) | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | /// One config value, or `None` when unset. | |
| 39 | + | #[tauri::command] | |
| 40 | + | #[instrument(skip_all)] | |
| 41 | + | pub async fn get_config( | |
| 42 | + | state: State<'_, Arc<AppState>>, | |
| 43 | + | key: String, | |
| 44 | + | ) -> Result<Option<String>, ApiError> { | |
| 45 | + | let row: Option<(String,)> = sqlx::query_as("SELECT value FROM user_config WHERE key = ?1") | |
| 46 | + | .bind(&key) | |
| 47 | + | .fetch_optional(&state.pool) | |
| 48 | + | .await | |
| 49 | + | .map_err(db_err)?; | |
| 50 | + | Ok(row.map(|(v,)| v)) | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | /// Every config key and value, for the frontend to preload into its cache so its | |
| 54 | + | /// synchronous reads stay synchronous. | |
| 55 | + | #[tauri::command] | |
| 56 | + | #[instrument(skip_all)] | |
| 57 | + | pub async fn get_all_config( | |
| 58 | + | state: State<'_, Arc<AppState>>, | |
| 59 | + | ) -> Result<HashMap<String, String>, ApiError> { | |
| 60 | + | let rows: Vec<(String, String)> = sqlx::query_as("SELECT key, value FROM user_config") | |
| 61 | + | .fetch_all(&state.pool) | |
| 62 | + | .await | |
| 63 | + | .map_err(db_err)?; | |
| 64 | + | Ok(rows.into_iter().collect()) | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | /// Set a config value (upsert). A synced key's write is captured by the engine's | |
| 68 | + | /// export trigger; a device-local key's is not. | |
| 69 | + | #[tauri::command] | |
| 70 | + | #[instrument(skip_all)] | |
| 71 | + | pub async fn set_config( | |
| 72 | + | state: State<'_, Arc<AppState>>, | |
| 73 | + | key: String, | |
| 74 | + | value: String, | |
| 75 | + | ) -> Result<(), ApiError> { | |
| 76 | + | ensure_known(&key)?; | |
| 77 | + | sqlx::query( | |
| 78 | + | "INSERT INTO user_config (key, value) VALUES (?1, ?2) \ | |
| 79 | + | ON CONFLICT(key) DO UPDATE SET value = excluded.value", | |
| 80 | + | ) | |
| 81 | + | .bind(&key) | |
| 82 | + | .bind(&value) | |
| 83 | + | .execute(&state.pool) | |
| 84 | + | .await | |
| 85 | + | .map_err(db_err)?; | |
| 86 | + | Ok(()) | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | /// Remove a config key. Absent already is not an error. | |
| 90 | + | #[tauri::command] | |
| 91 | + | #[instrument(skip_all)] | |
| 92 | + | pub async fn delete_config(state: State<'_, Arc<AppState>>, key: String) -> Result<(), ApiError> { | |
| 93 | + | sqlx::query("DELETE FROM user_config WHERE key = ?1") | |
| 94 | + | .bind(&key) | |
| 95 | + | .execute(&state.pool) | |
| 96 | + | .await | |
| 97 | + | .map_err(db_err)?; | |
| 98 | + | Ok(()) | |
| 99 | + | } |
| @@ -18,6 +18,7 @@ | |||
| 18 | 18 | ||
| 19 | 19 | mod app_info; | |
| 20 | 20 | pub(crate) mod attachment; | |
| 21 | + | mod config; | |
| 21 | 22 | mod contact; | |
| 22 | 23 | mod daily_note; | |
| 23 | 24 | mod day_planning; | |
| @@ -115,6 +116,7 @@ pub(crate) fn write_private_temp(path: &std::path::Path, bytes: &[u8]) -> std::i | |||
| 115 | 116 | // Re-export all commands for registration in main.rs | |
| 116 | 117 | pub use app_info::*; | |
| 117 | 118 | pub use attachment::*; | |
| 119 | + | pub use config::*; | |
| 118 | 120 | pub use contact::*; | |
| 119 | 121 | pub use daily_note::*; | |
| 120 | 122 | pub use day_planning::*; |
| @@ -0,0 +1,104 @@ | |||
| 1 | + | //! GoingsOn's `user_config` keys and their sync posture. | |
| 2 | + | //! | |
| 3 | + | //! Config used to live in the frontend's `localStorage`, unsynced. Step 6 of the | |
| 4 | + | //! portable-config migration moves it onto the shared config store: a | |
| 5 | + | //! `user_config` key/value table (migration 060) whose sync is governed by | |
| 6 | + | //! posture. GoingsOn is on synckit's `SyncStore` engine, so unlike audiofiles and | |
| 7 | + | //! Balanced Breakfast it uses the real config adapter: | |
| 8 | + | //! [`config_sync_table`](synckit_client::config_sync_table) folds this spec's | |
| 9 | + | //! table into [`goingson_schema`](crate::syncstore::manifest::goingson_schema) | |
| 10 | + | //! and the engine generates the export/import triggers, gated on the | |
| 11 | + | //! `config_key_policy` allowlist seeded from [`CONFIG`]. The engine applies the | |
| 12 | + | //! posture predicate symmetrically, so the import boundary (a remote peer must | |
| 13 | + | //! not set a device-local key) is enforced by the engine, not by hand. | |
| 14 | + | //! | |
| 15 | + | //! Posture is [`synckit_config::Posture`]; `Synced` keys travel, `Local` keys | |
| 16 | + | //! stay on the device, and an undeclared key is `Local` (fail-closed). | |
| 17 | + | //! | |
| 18 | + | //! The store keys are the clean, unprefixed names (the family convention: theme | |
| 19 | + | //! is `theme`, not `goingson-theme`); the frontend maps its old `localStorage` | |
| 20 | + | //! keys to these once, on migration. | |
| 21 | + | //! | |
| 22 | + | //! <!-- wiki: go-config --> | |
| 23 | + | ||
| 24 | + | use synckit_config::{ConfigSpec, Posture}; | |
| 25 | + | ||
| 26 | + | /// GoingsOn's `user_config` posture declaration. | |
| 27 | + | /// | |
| 28 | + | /// Preferences worth carrying across a user's devices are `Synced`; per-device | |
| 29 | + | /// state is `Local`. `ui_mode` (desktop/mobile form factor) is `Local` and also | |
| 30 | + | /// the one paint-critical key, kept mirrored in `localStorage` for the pre-paint | |
| 31 | + | /// bootstrap. `welcomed` (first-run) and `hint_shortcuts` (a keyboard-hint | |
| 32 | + | /// dismissal) are per-device. | |
| 33 | + | pub const CONFIG: ConfigSpec = ConfigSpec::new( | |
| 34 | + | "user_config", | |
| 35 | + | &[ | |
| 36 | + | // Synced: preferences carried across the user's devices. | |
| 37 | + | ("theme", Posture::Synced), | |
| 38 | + | ("event_lead_minutes", Posture::Synced), | |
| 39 | + | ("plan_nudges", Posture::Synced), | |
| 40 | + | ("review_nudges", Posture::Synced), | |
| 41 | + | ("work_start_hour", Posture::Synced), | |
| 42 | + | ("work_end_hour", Posture::Synced), | |
| 43 | + | // Local: per-device state, never synced. | |
| 44 | + | ("ui_mode", Posture::Local), | |
| 45 | + | ("welcomed", Posture::Local), | |
| 46 | + | ("hint_shortcuts", Posture::Local), | |
| 47 | + | ], | |
| 48 | + | ); | |
| 49 | + | ||
| 50 | + | #[cfg(test)] | |
| 51 | + | mod tests { | |
| 52 | + | use super::*; | |
| 53 | + | ||
| 54 | + | #[test] | |
| 55 | + | fn only_preferences_sync() { | |
| 56 | + | for key in [ | |
| 57 | + | "theme", | |
| 58 | + | "event_lead_minutes", | |
| 59 | + | "plan_nudges", | |
| 60 | + | "review_nudges", | |
| 61 | + | "work_start_hour", | |
| 62 | + | "work_end_hour", | |
| 63 | + | ] { | |
| 64 | + | assert!( | |
| 65 | + | CONFIG.is_synced(key), | |
| 66 | + | "{key} is a preference and should sync" | |
| 67 | + | ); | |
| 68 | + | } | |
| 69 | + | for key in ["ui_mode", "welcomed", "hint_shortcuts"] { | |
| 70 | + | assert!( | |
| 71 | + | !CONFIG.is_synced(key), | |
| 72 | + | "{key} is per-device and must not sync" | |
| 73 | + | ); | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | #[test] | |
| 78 | + | fn an_undeclared_key_is_local() { | |
| 79 | + | assert!(!CONFIG.is_synced("something_new"), "fail closed"); | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // The policy rows the engine seeds and joins: every declared key, the synced | |
| 83 | + | // ones marked replicated. Drift here would desync the export/import filter | |
| 84 | + | // from the declaration. | |
| 85 | + | #[test] | |
| 86 | + | fn policy_rows_mark_only_synced_keys_replicated() { | |
| 87 | + | let replicated: std::collections::BTreeSet<&str> = CONFIG | |
| 88 | + | .policy_rows() | |
| 89 | + | .filter(|r| r.replicated) | |
| 90 | + | .map(|r| r.key) | |
| 91 | + | .collect(); | |
| 92 | + | let expected: std::collections::BTreeSet<&str> = [ | |
| 93 | + | "theme", | |
| 94 | + | "event_lead_minutes", | |
| 95 | + | "plan_nudges", | |
| 96 | + | "review_nudges", | |
| 97 | + | "work_start_hour", | |
| 98 | + | "work_end_hour", | |
| 99 | + | ] | |
| 100 | + | .into_iter() | |
| 101 | + | .collect(); | |
| 102 | + | assert_eq!(replicated, expected); | |
| 103 | + | } | |
| 104 | + | } |
| @@ -7,6 +7,7 @@ | |||
| 7 | 7 | pub mod backup_scheduler; | |
| 8 | 8 | pub mod blob_gc; | |
| 9 | 9 | pub mod commands; | |
| 10 | + | pub mod config_key; | |
| 10 | 11 | pub mod email; | |
| 11 | 12 | pub mod email_sync_scheduler; | |
| 12 | 13 | pub mod export; | |
| @@ -52,6 +53,11 @@ macro_rules! all_commands { | |||
| 52 | 53 | $crate::commands::open_email_blob, | |
| 53 | 54 | $crate::commands::save_email_blob, | |
| 54 | 55 | $crate::commands::get_file_size, | |
| 56 | + | // Config (user_config key/value) | |
| 57 | + | $crate::commands::get_config, | |
| 58 | + | $crate::commands::get_all_config, | |
| 59 | + | $crate::commands::set_config, | |
| 60 | + | $crate::commands::delete_config, | |
| 55 | 61 | // Projects | |
| 56 | 62 | $crate::commands::list_projects, | |
| 57 | 63 | $crate::commands::get_project, |
| @@ -149,6 +149,12 @@ impl AppState { | |||
| 149 | 149 | let backup_settings = Arc::new(SqliteBackupSettingsRepository::new(pool.clone())); | |
| 150 | 150 | let sync_accounts = Arc::new(SqliteSyncAccountRepository::new(pool.clone())); | |
| 151 | 151 | ||
| 152 | + | // Config (step 6): seed config_key_policy from the config spec every | |
| 153 | + | // launch, before any engine trigger regeneration below joins it. Idempotent. | |
| 154 | + | if let Err(e) = crate::syncstore::seed_config_policy(&db_path) { | |
| 155 | + | warn!("config policy seed failed (will retry next launch): {e}"); | |
| 156 | + | } | |
| 157 | + | ||
| 152 | 158 | // Groups p4 / M2: migrate this device's sync bookkeeping to the SyncStore | |
| 153 | 159 | // engine (idempotent, guarded by the `syncstore_migrated` flag). Non-fatal | |
| 154 | 160 | // and transactional; a failure leaves the legacy state intact and retries | |
| @@ -164,6 +170,13 @@ impl AppState { | |||
| 164 | 170 | warn!("SyncStore group migration failed (will retry next launch): {e}"); | |
| 165 | 171 | } | |
| 166 | 172 | ||
| 173 | + | // Config (step 6): regenerate triggers so an already-migrated device gains | |
| 174 | + | // the user_config config triggers. No-op on a fresh install (the cutover | |
| 175 | + | // already generated them) and once applied. Non-fatal. | |
| 176 | + | if let Err(e) = crate::syncstore::run_config_migration(&db_path) { | |
| 177 | + | warn!("SyncStore config migration failed (will retry next launch): {e}"); | |
| 178 | + | } | |
| 179 | + | ||
| 167 | 180 | // Build the SyncStore over goingson.db (its own WAL rusqlite connection, | |
| 168 | 181 | // coexisting with the sqlx pool) when a sync client is configured. | |
| 169 | 182 | let sync_store = load_sync_client(&app_data_dir) |
| @@ -25,6 +25,7 @@ | |||
| 25 | 25 | //! config/credential table can never leak into a group scope. `group_id` is | |
| 26 | 26 | //! provenance only, never a synced column. | |
| 27 | 27 | ||
| 28 | + | use synckit_client::store::config_sync_table; | |
| 28 | 29 | use synckit_client::{ConflictStrategy, SyncSchema, SyncTable}; | |
| 29 | 30 | ||
| 30 | 31 | /// `email_accounts` synced columns: config only, never credentials. The secret | |
| @@ -338,6 +339,11 @@ pub(crate) fn goingson_schema() -> SyncSchema { | |||
| 338 | 339 | "completed_at", | |
| 339 | 340 | ], | |
| 340 | 341 | ), | |
| 342 | + | // App settings (step 6): a key/value table filtered by posture. The | |
| 343 | + | // engine generates its export/import triggers gated on the | |
| 344 | + | // `config_key_policy` allowlist, seeded from the spec by | |
| 345 | + | // `seed_config_policy`. Personal, never group-scoped. | |
| 346 | + | config_sync_table(&crate::config_key::CONFIG), | |
| 341 | 347 | ]) | |
| 342 | 348 | // GO's current model; also the engine default. | |
| 343 | 349 | .conflict_strategy(ConflictStrategy::HybridLogicalClock) | |
| @@ -371,6 +377,8 @@ mod tests { | |||
| 371 | 377 | "weekly_reviews", | |
| 372 | 378 | "monthly_goals", | |
| 373 | 379 | "monthly_reflections", | |
| 380 | + | // Step 6: app settings, key/value (not id-keyed). Always last. | |
| 381 | + | "user_config", | |
| 374 | 382 | ]; | |
| 375 | 383 | ||
| 376 | 384 | /// The project subtree that can be shared into a group (M3). Everything else | |
| @@ -417,6 +425,11 @@ mod tests { | |||
| 417 | 425 | #[test] | |
| 418 | 426 | fn every_table_is_id_keyed_and_id_first() { | |
| 419 | 427 | for table in goingson_schema().tables() { | |
| 428 | + | // user_config is the one key/value table (key-keyed by design); every | |
| 429 | + | // domain table is id-keyed and emits id first. | |
| 430 | + | if table.name() == "user_config" { | |
| 431 | + | continue; | |
| 432 | + | } | |
| 420 | 433 | let cols = table.emitted_columns(); | |
| 421 | 434 | assert_eq!( | |
| 422 | 435 | cols.first(), |