diff --git a/src-tauri/sidecar/local-api-server.mjs b/src-tauri/sidecar/local-api-server.mjs index 22646ebe8..abda2a031 100644 --- a/src-tauri/sidecar/local-api-server.mjs +++ b/src-tauri/sidecar/local-api-server.mjs @@ -1201,6 +1201,37 @@ async function dispatch(requestUrl, req, routes, context) { return json({ error: 'POST required' }, 405); } + if (requestUrl.pathname === '/api/local-env-update-batch') { + if (req.method !== 'POST') return json({ error: 'POST required' }, 405); + const body = await readBody(req); + if (!body) return json({ error: 'expected { entries: [{key, value}, ...] }' }, 400); + try { + const { entries } = JSON.parse(body.toString()); + if (!Array.isArray(entries)) return json({ error: 'entries must be an array' }, 400); + if (entries.length > 50) return json({ error: 'too many entries (max 50)' }, 400); + const results = []; + for (const { key, value } of entries) { + if (typeof key !== 'string' || !key.length || !ALLOWED_ENV_KEYS.has(key)) { + results.push({ key, ok: false, error: 'not in allowlist' }); + continue; + } + if (value == null || value === '') { + delete process.env[key]; + context.logger.log(`[local-api] env unset: ${key}`); + } else { + process.env[key] = String(value); + context.logger.log(`[local-api] env set: ${key}`); + } + results.push({ key, ok: true }); + } + moduleCache.clear(); + failedImports.clear(); + cloudPreferred.clear(); + return json({ ok: true, results }); + } catch { /* bad JSON */ } + return json({ error: 'invalid JSON' }, 400); + } + if (requestUrl.pathname === '/api/local-validate-secret') { if (req.method !== 'POST') { return json({ error: 'POST required' }, 405); @@ -1301,6 +1332,7 @@ export async function createLocalApiServer(options = {}) { || requestUrl.pathname === '/api/local-traffic-log' || requestUrl.pathname === '/api/local-debug-toggle' || requestUrl.pathname === '/api/local-env-update' + || requestUrl.pathname === '/api/local-env-update-batch' || requestUrl.pathname === '/api/local-validate-secret'; try { diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 664eb3c7a..8ce808167 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -75,6 +75,8 @@ struct PersistentCache { data: Mutex>, dirty: Mutex, write_lock: Mutex<()>, + generation: Mutex, + flush_scheduled: Mutex, } impl SecretsCache { @@ -147,6 +149,8 @@ impl PersistentCache { data: Mutex::new(data), dirty: Mutex::new(false), write_lock: Mutex::new(()), + generation: Mutex::new(0), + flush_scheduled: Mutex::new(false), } } @@ -156,6 +160,7 @@ impl PersistentCache { } /// Flush to disk only if dirty. Returns Ok(true) if written. + /// Uses atomic write (temp file + rename) to prevent corruption on crash. fn flush(&self, path: &Path) -> Result { let _write_guard = self.write_lock.lock().unwrap_or_else(|e| e.into_inner()); @@ -171,8 +176,13 @@ impl PersistentCache { let serialized = serde_json::to_string(&Value::Object(data.clone())) .map_err(|e| format!("Failed to serialize cache: {e}"))?; drop(data); - std::fs::write(path, serialized) - .map_err(|e| format!("Failed to write cache {}: {e}", path.display()))?; + + let tmp = path.with_extension("tmp"); + std::fs::write(&tmp, &serialized) + .map_err(|e| format!("Failed to write cache tmp {}: {e}", tmp.display()))?; + std::fs::rename(&tmp, path) + .map_err(|e| format!("Failed to rename cache {}: {e}", path.display()))?; + let mut dirty = self.dirty.lock().unwrap_or_else(|e| e.into_inner()); *dirty = false; Ok(true) @@ -338,7 +348,7 @@ fn read_cache_entry(webview: Webview, cache: tauri::State<'_, PersistentCache>, } #[tauri::command] -fn delete_cache_entry(webview: Webview, cache: tauri::State<'_, PersistentCache>, key: String) -> Result<(), String> { +fn delete_cache_entry(webview: Webview, app: AppHandle, cache: tauri::State<'_, PersistentCache>, key: String) -> Result<(), String> { require_trusted_window(webview.label())?; { let mut data = cache.data.lock().unwrap_or_else(|e| e.into_inner()); @@ -348,7 +358,39 @@ fn delete_cache_entry(webview: Webview, cache: tauri::State<'_, PersistentCache> let mut dirty = cache.dirty.lock().unwrap_or_else(|e| e.into_inner()); *dirty = true; } - // Disk flush deferred to exit handler (cache.flush) — avoids blocking main thread + { + let mut gen = cache.generation.lock().unwrap_or_else(|e| e.into_inner()); + *gen += 1; + } + + let mut sched = cache.flush_scheduled.lock().unwrap_or_else(|e| e.into_inner()); + if !*sched { + *sched = true; + let handle = app.app_handle().clone(); + std::thread::spawn(move || { + loop { + std::thread::sleep(std::time::Duration::from_secs(2)); + let Some(c) = handle.try_state::() else { break }; + let Ok(path) = cache_file_path(&handle) else { break }; + let gen_before = *c.generation.lock().unwrap_or_else(|e| e.into_inner()); + match c.flush(&path) { + Ok(_) => { + let gen_after = *c.generation.lock().unwrap_or_else(|e| e.into_inner()); + if gen_after > gen_before { + continue; + } + *c.flush_scheduled.lock().unwrap_or_else(|e| e.into_inner()) = false; + break; + } + Err(e) => { + eprintln!("[cache] flush error: {e}"); + continue; + } + } + } + }); + } + Ok(()) } @@ -357,7 +399,6 @@ fn write_cache_entry(webview: Webview, app: AppHandle, cache: tauri::State<'_, P require_trusted_window(webview.label())?; let parsed_value: Value = serde_json::from_str(&value) .map_err(|e| format!("Invalid cache payload JSON: {e}"))?; - let _write_guard = cache.write_lock.lock().unwrap_or_else(|e| e.into_inner()); { let mut data = cache.data.lock().unwrap_or_else(|e| e.into_inner()); data.insert(key, parsed_value); @@ -366,19 +407,39 @@ fn write_cache_entry(webview: Webview, app: AppHandle, cache: tauri::State<'_, P let mut dirty = cache.dirty.lock().unwrap_or_else(|e| e.into_inner()); *dirty = true; } - - // Flush synchronously under write lock so concurrent writes cannot reorder. - let path = cache_file_path(&app)?; - let data = cache.data.lock().unwrap_or_else(|e| e.into_inner()); - let serialized = serde_json::to_string(&Value::Object(data.clone())) - .map_err(|e| format!("Failed to serialize cache: {e}"))?; - drop(data); - std::fs::write(&path, &serialized) - .map_err(|e| format!("Failed to write cache {}: {e}", path.display()))?; { - let mut dirty = cache.dirty.lock().unwrap_or_else(|e| e.into_inner()); - *dirty = false; + let mut gen = cache.generation.lock().unwrap_or_else(|e| e.into_inner()); + *gen += 1; } + + let mut sched = cache.flush_scheduled.lock().unwrap_or_else(|e| e.into_inner()); + if !*sched { + *sched = true; + let handle = app.app_handle().clone(); + std::thread::spawn(move || { + loop { + std::thread::sleep(std::time::Duration::from_secs(2)); + let Some(c) = handle.try_state::() else { break }; + let Ok(path) = cache_file_path(&handle) else { break }; + let gen_before = *c.generation.lock().unwrap_or_else(|e| e.into_inner()); + match c.flush(&path) { + Ok(_) => { + let gen_after = *c.generation.lock().unwrap_or_else(|e| e.into_inner()); + if gen_after > gen_before { + continue; + } + *c.flush_scheduled.lock().unwrap_or_else(|e| e.into_inner()) = false; + break; + } + Err(e) => { + eprintln!("[cache] flush error: {e}"); + continue; + } + } + } + }); + } + Ok(()) } diff --git a/src/App.ts b/src/App.ts index cc6c154dd..54eccb942 100644 --- a/src/App.ts +++ b/src/App.ts @@ -22,7 +22,7 @@ import type { ETFFlowsPanel } from '@/components/ETFFlowsPanel'; import type { MacroSignalsPanel } from '@/components/MacroSignalsPanel'; import type { StrategicPosturePanel } from '@/components/StrategicPosturePanel'; import type { StrategicRiskPanel } from '@/components/StrategicRiskPanel'; -import { isDesktopRuntime } from '@/services/runtime'; +import { isDesktopRuntime, waitForSidecarReady } from '@/services/runtime'; import { BETA_MODE } from '@/config/beta'; import { trackEvent, trackDeeplinkOpened } from '@/services/analytics'; import { preloadCountryGeometry, getCountryNameByCode } from '@/services/country-geometry'; @@ -382,6 +382,11 @@ export class App { initAisStream(); } + // Wait for sidecar readiness on desktop so bootstrap hits a live server + if (isDesktopRuntime()) { + await waitForSidecarReady(3000); + } + // Hydrate in-memory cache from bootstrap endpoint (before panels construct and fetch) await fetchBootstrapData(); diff --git a/src/app/panel-layout.ts b/src/app/panel-layout.ts index e53aceed1..4657f6276 100644 --- a/src/app/panel-layout.ts +++ b/src/app/panel-layout.ts @@ -22,35 +22,19 @@ import { ServiceStatusPanel, RuntimeConfigPanel, InsightsPanel, - TechReadinessPanel, MacroSignalsPanel, ETFFlowsPanel, StablecoinPanel, UcdpEventsPanel, - DisplacementPanel, - ClimateAnomalyPanel, - PopulationExposurePanel, InvestmentsPanel, TradePolicyPanel, SupplyChainPanel, - SecurityAdvisoriesPanel, - OrefSirensPanel, - TelegramIntelPanel, GulfEconomiesPanel, WorldClockPanel, AirlineIntelPanel, AviationCommandBar, } from '@/components'; import { SatelliteFiresPanel } from '@/components/SatelliteFiresPanel'; -import { PositiveNewsFeedPanel } from '@/components/PositiveNewsFeedPanel'; -import { CountersPanel } from '@/components/CountersPanel'; -import { ProgressChartsPanel } from '@/components/ProgressChartsPanel'; -import { BreakthroughsTickerPanel } from '@/components/BreakthroughsTickerPanel'; -import { HeroSpotlightPanel } from '@/components/HeroSpotlightPanel'; -import { GoodThingsDigestPanel } from '@/components/GoodThingsDigestPanel'; -import { SpeciesComebackPanel } from '@/components/SpeciesComebackPanel'; -import { RenewableEnergyPanel } from '@/components/RenewableEnergyPanel'; -import { GivingPanel } from '@/components'; import { focusInvestmentOnMap } from '@/services/investments-focus'; import { debounce, saveToStorage, loadFromStorage } from '@/utils'; import { escapeHtml } from '@/utils/sanitize'; @@ -688,32 +672,41 @@ export class PanelLayoutManager implements AppModule { }); this.ctx.panels['ucdp-events'] = ucdpEventsPanel; - const displacementPanel = new DisplacementPanel(); - displacementPanel.setCountryClickHandler((lat, lon) => { - this.ctx.map?.setCenter(lat, lon, 4); - }); - this.ctx.panels['displacement'] = displacementPanel; + this.lazyPanel('displacement', () => + import('@/components/DisplacementPanel').then(m => { + const p = new m.DisplacementPanel(); + p.setCountryClickHandler((lat: number, lon: number) => { this.ctx.map?.setCenter(lat, lon, 4); }); + return p; + }), + ); - const climatePanel = new ClimateAnomalyPanel(); - climatePanel.setZoneClickHandler((lat, lon) => { - this.ctx.map?.setCenter(lat, lon, 4); - }); - this.ctx.panels['climate'] = climatePanel; + this.lazyPanel('climate', () => + import('@/components/ClimateAnomalyPanel').then(m => { + const p = new m.ClimateAnomalyPanel(); + p.setZoneClickHandler((lat: number, lon: number) => { this.ctx.map?.setCenter(lat, lon, 4); }); + return p; + }), + ); - const populationExposurePanel = new PopulationExposurePanel(); - this.ctx.panels['population-exposure'] = populationExposurePanel; + this.lazyPanel('population-exposure', () => + import('@/components/PopulationExposurePanel').then(m => new m.PopulationExposurePanel()), + ); - const securityAdvisoriesPanel = new SecurityAdvisoriesPanel(); - securityAdvisoriesPanel.setRefreshHandler(() => { - void this.callbacks.loadSecurityAdvisories?.(); - }); - this.ctx.panels['security-advisories'] = securityAdvisoriesPanel; + this.lazyPanel('security-advisories', () => + import('@/components/SecurityAdvisoriesPanel').then(m => { + const p = new m.SecurityAdvisoriesPanel(); + p.setRefreshHandler(() => { void this.callbacks.loadSecurityAdvisories?.(); }); + return p; + }), + ); - const orefSirensPanel = new OrefSirensPanel(); - this.ctx.panels['oref-sirens'] = orefSirensPanel; + this.lazyPanel('oref-sirens', () => + import('@/components/OrefSirensPanel').then(m => new m.OrefSirensPanel()), + ); - const telegramIntelPanel = new TelegramIntelPanel(); - this.ctx.panels['telegram-intel'] = telegramIntelPanel; + this.lazyPanel('telegram-intel', () => + import('@/components/TelegramIntelPanel').then(m => new m.TelegramIntelPanel()), + ); } if (SITE_VARIANT === 'finance') { @@ -752,8 +745,9 @@ export class PanelLayoutManager implements AppModule { const serviceStatusPanel = new ServiceStatusPanel(); this.ctx.panels['service-status'] = serviceStatusPanel; - const techReadinessPanel = new TechReadinessPanel(); - this.ctx.panels['tech-readiness'] = techReadinessPanel; + this.lazyPanel('tech-readiness', () => + import('@/components/TechReadinessPanel').then(m => new m.TechReadinessPanel()), + ); this.ctx.panels['macro-signals'] = new MacroSignalsPanel(); this.ctx.panels['etf-flows'] = new ETFFlowsPanel(); @@ -769,38 +763,56 @@ export class PanelLayoutManager implements AppModule { this.ctx.panels['insights'] = insightsPanel; // Global Giving panel (all variants) - this.ctx.panels['giving'] = new GivingPanel(); + this.lazyPanel('giving', () => + import('@/components/GivingPanel').then(m => new m.GivingPanel()), + ); - // Happy variant panels + // Happy variant panels (lazy-loaded — only relevant for happy variant) if (SITE_VARIANT === 'happy') { - this.ctx.positivePanel = new PositiveNewsFeedPanel(); - this.ctx.panels['positive-feed'] = this.ctx.positivePanel; + import('@/components/PositiveNewsFeedPanel').then(m => { + this.ctx.positivePanel = new m.PositiveNewsFeedPanel(); + this.ctx.panels['positive-feed'] = this.ctx.positivePanel; + }); - this.ctx.countersPanel = new CountersPanel(); - this.ctx.panels['counters'] = this.ctx.countersPanel; - this.ctx.countersPanel.startTicking(); + import('@/components/CountersPanel').then(m => { + this.ctx.countersPanel = new m.CountersPanel(); + this.ctx.panels['counters'] = this.ctx.countersPanel; + this.ctx.countersPanel.startTicking(); + }); - this.ctx.progressPanel = new ProgressChartsPanel(); - this.ctx.panels['progress'] = this.ctx.progressPanel; + import('@/components/ProgressChartsPanel').then(m => { + this.ctx.progressPanel = new m.ProgressChartsPanel(); + this.ctx.panels['progress'] = this.ctx.progressPanel; + }); - this.ctx.breakthroughsPanel = new BreakthroughsTickerPanel(); - this.ctx.panels['breakthroughs'] = this.ctx.breakthroughsPanel; + import('@/components/BreakthroughsTickerPanel').then(m => { + this.ctx.breakthroughsPanel = new m.BreakthroughsTickerPanel(); + this.ctx.panels['breakthroughs'] = this.ctx.breakthroughsPanel; + }); - this.ctx.heroPanel = new HeroSpotlightPanel(); - this.ctx.panels['spotlight'] = this.ctx.heroPanel; - this.ctx.heroPanel.onLocationRequest = (lat: number, lon: number) => { - this.ctx.map?.setCenter(lat, lon, 4); - this.ctx.map?.flashLocation(lat, lon, 3000); - }; + import('@/components/HeroSpotlightPanel').then(m => { + this.ctx.heroPanel = new m.HeroSpotlightPanel(); + this.ctx.panels['spotlight'] = this.ctx.heroPanel; + this.ctx.heroPanel.onLocationRequest = (lat: number, lon: number) => { + this.ctx.map?.setCenter(lat, lon, 4); + this.ctx.map?.flashLocation(lat, lon, 3000); + }; + }); - this.ctx.digestPanel = new GoodThingsDigestPanel(); - this.ctx.panels['digest'] = this.ctx.digestPanel; + import('@/components/GoodThingsDigestPanel').then(m => { + this.ctx.digestPanel = new m.GoodThingsDigestPanel(); + this.ctx.panels['digest'] = this.ctx.digestPanel; + }); - this.ctx.speciesPanel = new SpeciesComebackPanel(); - this.ctx.panels['species'] = this.ctx.speciesPanel; + import('@/components/SpeciesComebackPanel').then(m => { + this.ctx.speciesPanel = new m.SpeciesComebackPanel(); + this.ctx.panels['species'] = this.ctx.speciesPanel; + }); - this.ctx.renewablePanel = new RenewableEnergyPanel(); - this.ctx.panels['renewable'] = this.ctx.renewablePanel; + import('@/components/RenewableEnergyPanel').then(m => { + this.ctx.renewablePanel = new m.RenewableEnergyPanel(); + this.ctx.panels['renewable'] = this.ctx.renewablePanel; + }); } const defaultOrder = Object.keys(DEFAULT_PANELS).filter(k => k !== 'map'); @@ -1092,6 +1104,21 @@ export class PanelLayoutManager implements AppModule { } } + private lazyPanel( + key: string, + loader: () => Promise, + setup?: (panel: T) => void, + ): void { + loader().then((panel) => { + this.ctx.panels[key] = panel as unknown as import('@/components/Panel').Panel; + if (setup) setup(panel); + const el = panel.getElement(); + this.makeDraggable(el, key); + const grid = document.getElementById('panelsGrid'); + if (grid) grid.appendChild(el); + }); + } + private makeDraggable(el: HTMLElement, key: string): void { el.dataset.panel = key; let isDragging = false; diff --git a/src/app/refresh-scheduler.ts b/src/app/refresh-scheduler.ts index ca0351622..003ad1680 100644 --- a/src/app/refresh-scheduler.ts +++ b/src/app/refresh-scheduler.ts @@ -60,8 +60,7 @@ export class RefreshScheduler implements AppModule { } }, { intervalMs, - // De-escalate global refresh loops in background tabs to cut API volume. - hiddenMultiplier: 30, + pauseWhenHidden: true, refreshOnVisible: false, runImmediately: false, maxBackoffMultiplier: 4, diff --git a/src/components/GulfEconomiesPanel.ts b/src/components/GulfEconomiesPanel.ts index 8f8cc5941..70ce77436 100644 --- a/src/components/GulfEconomiesPanel.ts +++ b/src/components/GulfEconomiesPanel.ts @@ -35,7 +35,7 @@ export class GulfEconomiesPanel extends Panel { super({ id: 'gulf-economies', title: t('panels.gulfEconomies') }); this.pollLoop = startSmartPollLoop(() => this.fetchData(), { intervalMs: 60_000, - hiddenMultiplier: 30, + pauseWhenHidden: true, refreshOnVisible: true, runImmediately: false, }); diff --git a/src/services/oref-alerts.ts b/src/services/oref-alerts.ts index 1516e41f7..88c71f777 100644 --- a/src/services/oref-alerts.ts +++ b/src/services/oref-alerts.ts @@ -303,8 +303,7 @@ export function startOrefPolling(): void { for (const cb of updateCallbacks) cb(data); }, { intervalMs: 120_000, - // 2m -> 60m while hidden; restore with immediate refresh when visible. - hiddenMultiplier: 30, + pauseWhenHidden: true, refreshOnVisible: true, runImmediately: false, }); diff --git a/src/services/runtime-config.ts b/src/services/runtime-config.ts index 0741f4e5e..113f5cbb1 100644 --- a/src/services/runtime-config.ts +++ b/src/services/runtime-config.ts @@ -73,6 +73,9 @@ const TOGGLES_STORAGE_KEY = 'worldmonitor-runtime-feature-toggles'; function getSidecarEnvUpdateUrl(): string { return `${getApiBaseUrl()}/api/local-env-update`; } +function getSidecarEnvUpdateBatchUrl(): string { + return `${getApiBaseUrl()}/api/local-env-update-batch`; +} function getSidecarSecretValidateUrl(): string { return `${getApiBaseUrl()}/api/local-validate-secret`; } @@ -537,24 +540,29 @@ export async function loadDesktopSecrets(): Promise { if (!isDesktopRuntime()) return; try { - // Single batch call to read all keychain secrets at once. - // This triggers only ONE macOS Keychain prompt instead of 18 individual ones. const allSecrets = await invokeTauri>('get_all_secrets'); - const syncResults = await Promise.allSettled( - Object.entries(allSecrets).filter(([, value]) => value && value.trim().length > 0).map(async ([key, value]) => { + const entries: { key: string; value: string }[] = []; + for (const [key, value] of Object.entries(allSecrets)) { + if (value && value.trim().length > 0) { runtimeConfig.secrets[key as RuntimeSecretKey] = { value, source: 'vault' }; - try { - await pushSecretToSidecar(key as RuntimeSecretKey, value); - } catch (error) { - console.warn(`[runtime-config] Failed to sync ${key} to sidecar`, error); - } - }) - ); + entries.push({ key, value }); + } + } - const failures = syncResults.filter((r) => r.status === 'rejected'); - if (failures.length > 0) { - console.warn(`[runtime-config] ${failures.length} key(s) failed to sync to sidecar`); + if (entries.length > 0) { + try { + await pushSecretBatchToSidecar(entries); + } catch { + // Batch endpoint unavailable (older sidecar) — fall back to individual pushes + await Promise.allSettled( + entries.map(({ key, value }) => + pushSecretToSidecar(key as RuntimeSecretKey, value).catch((error) => { + console.warn(`[runtime-config] Failed to sync ${key} to sidecar`, error); + }) + ) + ); + } } notifyConfigChanged(); @@ -564,3 +572,21 @@ export async function loadDesktopSecrets(): Promise { secretsReadyResolve(); } } + +async function pushSecretBatchToSidecar(entries: { key: string; value: string }[]): Promise { + const headers = new Headers({ 'Content-Type': 'application/json' }); + const token = await getLocalApiToken(); + if (token) { + headers.set('Authorization', `Bearer ${token}`); + } + + const response = await fetch(getSidecarEnvUpdateBatchUrl(), { + method: 'POST', + headers, + body: JSON.stringify({ entries }), + }); + + if (!response.ok) { + throw new Error(`Batch env update failed (${response.status})`); + } +} diff --git a/src/services/runtime.ts b/src/services/runtime.ts index 0d23a0455..32da908d7 100644 --- a/src/services/runtime.ts +++ b/src/services/runtime.ts @@ -416,6 +416,23 @@ export function startSmartPollLoop( }; } +export async function waitForSidecarReady(timeoutMs = 3000): Promise { + const baseUrl = getApiBaseUrl(); + if (!baseUrl) return false; + const pollInterval = 200; + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const res = await fetch(`${baseUrl}/api/service-status`, { method: 'GET' }); + if (res.ok) return true; + } catch { + // sidecar not ready yet + } + await sleep(pollInterval); + } + return false; +} + function isLocalOnlyApiTarget(target: string): boolean { // Security boundary: endpoints that can carry local secrets must use the // `/api/local-*` prefix so cloud fallback is automatically blocked.