GenerAI / worldmonitor /src /main.ts
amogaddy's picture
Integra World Monitor (AGPL-3.0, self-hosted) nello Space: pagina, menu, e arricchimento notizie per la AI (part 7)
9d2d895 verified
Raw
History Blame Contribute Delete
30.4 kB
import './styles/base-layer.css';
import './bootstrap/zod-csp';
import { SITE_VARIANT } from '@/config/variant';
import { installLcpAttributionDebug } from '@/bootstrap/lcp-attribution';
import { markLcpDebug } from '@/utils/lcp-debug';
import { enqueueSentryCall, installPreInitErrorQueue, scheduleSentryInit } from '@/bootstrap/sentry-defer';
import { registerClsReporting } from '@/bootstrap/cls-report';
import { registerInpReporting } from '@/bootstrap/inp-report';
import { registerLcpReporting } from '@/bootstrap/lcp-report';
import { initVercelAnalytics } from '@/bootstrap/secondary-startup';
import { loadVariantThemeStylesheet } from '@/bootstrap/variant-theme';
import { App } from './App';
import { installUtmInterceptor } from './utils/utm';
if (SITE_VARIANT === 'happy') {
// Keeps happy-theme.css off other variants' eager CSS graph. On happy, the
// stylesheet applies asynchronously, so a brief base-theme flash is possible.
// The import is fire-and-forget, so its rejection must be consumed: Vite's
// preload helper rejects with `Unable to preload CSS for <url>` when the
// injected <link> errors, and a bare `void import(...)` let that escape to
// onunhandledrejection (WORLDMONITOR-XT). See bootstrap/variant-theme.ts.
void loadVariantThemeStylesheet('happy', () => import('./styles/happy-theme.css'));
}
// Activate the deferred dashboard app stylesheet. The build
// (deferDashboardStylesheetLinks in vite.config.ts) emits the large dashboard
// CSS as <link media="print" data-wm-deferred-style="dashboard"> + a <noscript>
// blocking copy, so it does not block first paint; flipping media to "all" here
// applies it once main.js runs. The selector below MUST stay in lockstep with
// the attribute/value the build writes (data-wm-deferred-style="dashboard" +
// media="print"). No-JS users get the <noscript> fallback; if main.js fails to
// execute (e.g. an /assets 404 after a redeploy) the wm-sw-nuke handler in
// index.html reloads. Kept as the first body statement so it runs before the
// rest of startup.
function activateDeferredDashboardStyles(): void {
document
.querySelectorAll<HTMLLinkElement>('link[data-wm-deferred-style="dashboard"][media="print"]')
.forEach((link) => {
link.media = 'all';
});
}
activateDeferredDashboardStyles();
installLcpAttributionDebug();
// perf G — defer @sentry/browser off the critical path (#3994).
// The eager `Sentry.init({...})` previously ran here cost ~1.96 s of pre-LCP
// CPU. Install a lightweight error-buffering queue synchronously so any error
// thrown before the SDK lands is captured + flushed on init, then schedule
// the actual SDK load via requestIdleCallback. The init options + SDK ship in
// the deferred sentry-*.js chunk, not the main entry.
installPreInitErrorQueue();
scheduleSentryInit();
// Report field INP attribution to Sentry (through the deferred-Sentry queue) so
// we can see which real interaction is slow and whether the cost is input delay,
// processing, or presentation (#4537). web-vitals loads in its own post-paint chunk.
registerInpReporting();
// Report field CLS attribution to Sentry so field-only layout shifts can name
// their largest shifting element before we scope the layout fix (#4580).
registerClsReporting();
// Report field LCP attribution to Sentry so the last-mile render-delay work can
// see the real LCP element plus TTFB / load-delay / load-time / render-delay parts (#5079).
registerLcpReporting();
// Suppress NotAllowedError from YouTube IFrame API's internal play() — browser autoplay policy,
// not actionable. The YT IFrame API doesn't expose the play() promise so it leaks as unhandled.
window.addEventListener('unhandledrejection', (e) => {
if (e.reason?.name === 'NotAllowedError') e.preventDefault();
});
// CSP violation filter — exported for testability.
// Returns true if the violation should be suppressed (not reported to Sentry).
function shouldSuppressCspViolation(
disposition: string,
directive: string,
blockedURI: string,
sourceFile: string,
cspConnectSrcAllowsHttps: boolean,
firstPartyConvexHost: string | null,
cspMediaSrcAllowsHttps: boolean = false,
): boolean {
// Skip non-enforced violations (report-only from dual-CSP interaction).
if (disposition && disposition !== 'enforce') return true;
// connect-src + HTTPS: only suppress when the page CSP actually allows https: scheme.
// This is scoped to the current policy state, not a blanket protocol assumption.
if (directive === 'connect-src' && cspConnectSrcAllowsHttps) {
try {
if (new URL(blockedURI).protocol === 'https:') return true;
} catch { /* scheme-only values like "blob" fall through */ }
}
// media-src + HTTPS: HLS / live-stream media-element loads. Our header CSP
// allows the `https:` scheme (`media-src 'self' data: blob: https:`), so an
// *enforced* https: media-src block means a corporate proxy / privacy extension
// stripped `https:` from the user's effective media-src — the same environmental
// policy mutation as the connect-src case above. The HLS *manifest* fetch is
// connect-src (already suppressed via the foxnews-style rule); this covers the
// media element load of that same stream. Built-in and user-added custom HLS
// channels (LiveNewsPanel) both hit this — WORLDMONITOR-HV (bloomberg.com
// us.m3u8, 4 users). Gated on policy detection so it stays scoped to the
// current policy state, not a blanket protocol assumption. http: media-src
// blocks (real mixed-content) still surface.
if (directive === 'media-src' && cspMediaSrcAllowsHttps) {
try {
if (new URL(blockedURI).protocol === 'https:') return true;
} catch { /* scheme-only values fall through */ }
}
// Baidu read-aloud / TTS browser extensions (common in the Chinese market)
// inject an `<audio src="http://tts.baidu.com/text2audio?...&text=<selected
// text>">` element to speak page content when the user clicks/selects it. We
// never load tts.baidu.com (it appears nowhere in src) and our media-src
// allows only `'self' data: blob: https:`, so this http: load is third-party
// mixed-content the CSP correctly blocks — the audio never plays regardless of
// our code. UNLIKE the https: media-src rule above this is NOT protocol-gated
// on policy detection: it is host-pinned to an exact third-party hostname we
// provably never reference, so suppressing its http: block cannot mask a
// first-party mixed-content regression (we ship no http:// media). Parsed
// hostname match (not substring) so a `tts.baidu.com.evil.com` lookalike still
// surfaces (WORLDMONITOR-TW — map-popup description read-aloud, 1 user).
if (directive === 'media-src') {
try {
if (new URL(blockedURI).hostname === 'tts.baidu.com') return true;
} catch { /* scheme-only values fall through */ }
}
// default-src + HTTP: mixed-content block on a fetch type we set no explicit
// directive for — i.e. browser link-prefetch ("Preload pages" speculation) or
// an extension article-prefetcher. News article links render as plain
// <a target="_blank"> navigations (NewsPanel/ClimateNewsPanel/etc.) carrying
// feed-supplied URLs; some sources / downgrading proxies emit them over http:,
// and the browser/extension speculatively fetches them — the load falls to the
// default-src fallback because we set no prefetch-src. Our app is HTTPS-only and
// ships no http:// subresource loads, and every fetch directive we DO use
// (connect-src, img-src, script-src, media-src) is set explicitly, so a genuine
// first-party mixed-content fetch surfaces under its specific directive — never
// this default-src fallback. Preserve first-party worldmonitor.app http blocks
// so a real mixed-content regression on our own assets still surfaces
// (WORLDMONITOR-S0 — http://www.euronews.com article prefetch, 1 user/775 ev).
if (directive === 'default-src') {
try {
const u = new URL(blockedURI);
if (u.protocol === 'http:'
&& u.hostname !== 'worldmonitor.app'
&& !u.hostname.endsWith('.worldmonitor.app')) return true;
} catch { /* scheme-only values fall through */ }
}
// First-party Convex backend: corporate proxies / privacy extensions that mutate the
// page CSP (stripping bare `https:` from connect-src) cause our Convex sync calls to
// be CSP-blocked even though our policy allows them. Suppress unconditionally for OUR
// configured Convex deployment hostname (`VITE_CONVEX_URL`) so we don't drown Sentry
// in 1M+ events/month from those users (WORLDMONITOR-HN). Convex is multi-tenant —
// do NOT suppress all `*.convex.cloud`, that would silently swallow blocks to foreign/
// attacker-controlled Convex projects. Match by exact hostname only. Real first-party
// CSP regressions on this host are caught by the staging deploy + uptime check.
if (directive === 'connect-src' && firstPartyConvexHost) {
try {
if (new URL(blockedURI).hostname === firstPartyConvexHost) return true;
} catch { /* scheme-only values fall through */ }
}
// First-party img-src block on OUR registrable domain: same pattern as the Convex
// connect-src case above. Corporate proxies / privacy extensions (Zscaler, Symantec
// CloudSOC, school content-filters) can strip both `'self'` and `https:` from img-src
// in the user's effective policy, causing our own favicon and panel icons to be
// CSP-blocked even though our policy (`img-src 'self' data: blob: https:`) allows
// them. Scope to `worldmonitor.app` and its subdomains — img-src blocks to foreign
// hosts (a third-party CDN we never load, attacker-controlled host) still surface
// (WORLDMONITOR-JP). Suffix check uses a leading `.` so lookalikes like
// `worldmonitor.app.evil.com` do NOT match.
//
// REQUIRE https: protocol — our CSP only allows https: for img-src, so a real
// mixed-content regression (`<img src="http://worldmonitor.app/...">`) would be
// blocked by the browser. Suppressing http: blocks on first-party hosts would mask
// that regression in Sentry. The `cspConnectSrcAllowsHttps` block above uses the
// same protocol gate for connect-src.
if (directive === 'img-src') {
try {
const url = new URL(blockedURI);
if (url.protocol === 'https:'
&& (url.hostname === 'worldmonitor.app' || url.hostname.endsWith('.worldmonitor.app'))) return true;
} catch { /* scheme-only values fall through */ }
}
// YouTube IFrame API loader: explicitly allowed by our script-src
// (`https://www.youtube.com`), so a block here means a third party (extension,
// corporate proxy, in-app webview) mutated the policy. Not actionable — embedded
// video remains broken in that user's environment regardless of our code
// (WORLDMONITOR-HP).
if (
(directive === 'script-src-elem' || directive === 'script-src')
&& /^https:\/\/www\.youtube\.com\/iframe_api(?:\?|$)/.test(blockedURI)
) return true;
// Zscaler enterprise content-filter proxy: `gateway.zscloud.net` is injected into
// corporate users' frames by Zscaler's web filter agent. We never load it ourselves;
// it's inserted into the host page outside our control (WORLDMONITOR-HT). Match by
// parsed hostname so a `gateway.zscloud.net.evil.com` lookalike doesn't bypass the
// surrounding signal filters.
if (directive === 'frame-src') {
try {
const frameHost = new URL(blockedURI).hostname;
if (frameHost === 'gateway.zscloud.net') return true;
// Same class, other vendors (WORLDMONITOR-HT long tail): NetSTAR inSITE
// (gw-*.iss.netstar-inc.com), Techloq (filter.techloq.com — kosher
// content filter), Trend Micro password-manager/agent asset frames
// (pwm-image.trendmicro.com). All are filter/security agents framing
// their own vendor hosts into every page; we never frame any of them.
// Parsed-hostname suffix match with a leading `.` so lookalike
// registrable domains (netstar-inc.com.evil.com) do not match.
if (frameHost === 'netstar-inc.com' || frameHost.endsWith('.netstar-inc.com')) return true;
if (frameHost === 'techloq.com' || frameHost.endsWith('.techloq.com')) return true;
if (frameHost === 'trendmicro.com' || frameHost.endsWith('.trendmicro.com')) return true;
// Google-internal extension/API hosts (`*.clients6.google.com`, e.g.
// toolytics.pa.clients6.google.com) framed by Google-account browser
// surfaces and extensions. We never frame Google API hosts — but keep
// accounts.google.com / support.google.com SURFACED: a future first-party
// Google sign-in embed regression must not be masked.
if (frameHost.endsWith('.clients6.google.com')) return true;
// Tampermonkey "h5player" video-enhancement userscript (large Chinese
// install base) frames its own vendor host into every page with a
// <video> element. We never reference anzz.site; exact parsed-hostname
// match like the vendor rules above so lookalikes still surface
// (WORLDMONITOR-HT long tail — 5.8k events / 1.2k users since March).
if (frameHost === 'h5player.anzz.site') return true;
} catch { /* scheme-only values fall through */ }
}
// Browser extensions or injected scripts. `ms-browser-extension://` is Edge's
// scheme for legacy/internal extensions (WORLDMONITOR-JM).
if (/^(?:chrome|moz|safari(?:-web)?|ms-browser)-extension/.test(sourceFile) || /^(?:chrome|moz|safari(?:-web)?|ms-browser)-extension/.test(blockedURI)) return true;
// blob: — browsers report "blob" (scheme-only) or "blob:https://...".
if (blockedURI === 'blob' || /^blob:/.test(sourceFile) || /^blob:/.test(blockedURI)) return true;
// eval/inline/data.
if (blockedURI === 'eval' || blockedURI === 'inline' || blockedURI === 'data' || /^data:/.test(blockedURI)) return true;
// about: — browsers report "about" (scheme-only) or "about:blank" / "about:srcdoc"
// for iframes created by extensions, ad-injectors, or Smart TV browsers (Samsung
// Internet on Tizen). We never set frame src to about:* ourselves (WORLDMONITOR-JQ).
if (blockedURI === 'about' || /^about:/.test(blockedURI)) return true;
// Android WebView video poster injection.
if (blockedURI === 'android-webview-video-poster') return true;
// Own manifest.webmanifest — stale CSP cache hit.
if (/manifest\.webmanifest$/.test(blockedURI)) return true;
// Third-party injectors: Google Translate, Facebook Pixel.
if (/gstatic\.com\/_\/translate/.test(blockedURI) || /facebook\.net/.test(blockedURI)) return true;
// Google Fonts font files from stale or injected stylesheets. The dashboard now
// self-hosts its own fonts and the deploy/config tests keep Google Fonts out of
// dashboard CSP/source surfaces; if a user's browser still tries
// fonts.gstatic.com/s/*.woff2, the strict font-src block is expected noise.
if (directive === 'font-src') {
try {
const url = new URL(blockedURI);
if (url.protocol === 'https:' && url.hostname === 'fonts.gstatic.com' && /^\/s\/.+\.woff2$/.test(url.pathname)) return true;
// Perplexity's Comet browser / extension injects its own UI webfont
// (frontend-cdn.perplexity.ai/_agi_assets/fonts/*.woff2) into every page.
// We never load it; the block is the overlay's font failing regardless of
// our code. Allowlisted by exact host like gstatic above — NOT a blanket
// third-party suppression, so an unexpected font injection from any other
// host still surfaces (WORLDMONITOR-TR: 1065 events / 83 users).
if (url.protocol === 'https:' && url.hostname === 'frontend-cdn.perplexity.ai' && /\.woff2?$/.test(url.pathname)) return true;
// ByteDance's Doubao AI-assistant browser/extension injects its overlay's
// KaTeX math fonts (lf-flow-web-cdn.doubao.com/obj/flow-doubao/...) into
// every page — .woff2/.woff/.ttf fallback chain, so all three extensions
// appear. We never load it; exact host + font-file path like the rules
// above, NOT a blanket third-party suppression (WORLDMONITOR-TR round 2:
// 310k events / 308 users in 11 days).
if (url.protocol === 'https:' && url.hostname === 'lf-flow-web-cdn.doubao.com' && /\.(?:woff2?|ttf)$/.test(url.pathname)) return true;
} catch { /* scheme-only values fall through */ }
}
// YouTube live stream manifests.
if (/googlevideo\.com|youtube\.com\/generate_204/.test(blockedURI)) return true;
// Corporate/school content filter injections.
if (/securly\.com|goguardian\.com|contentkeeper\.com/.test(blockedURI)) return true;
// Vercel Analytics script.
if (/_vercel\/insights\/script\.js/.test(blockedURI)) return true;
// Third-party stylesheet injection from public CDNs (browser extensions,
// bookmarklets, "inspect element" UI tools loading antd/bootstrap/etc.).
// We legitimately load JS from `cdn.jsdelivr.net` (chart.js in the
// widget-sanitizer iframe), but never CSS — so a `style-src*` block on
// jsDelivr is by definition third-party
// injection (WORLDMONITOR-J0 — antd@4 CSS injection, 270 events / 26
// users on finance.worldmonitor.app).
if (/^style-src(-elem)?$/.test(directive) && /^https:\/\/cdn\.jsdelivr\.net\//.test(blockedURI)) return true;
// Google Fonts CSS injected by extensions/user-style themes (DM Sans, Syne,
// Roboto… — families we never reference). The dashboard self-hosts all fonts
// and the deploy/config tests keep Google Fonts out of our source/CSP
// surfaces, so a style-src* block on fonts.googleapis.com/css* is by
// definition third-party injection — the stylesheet counterpart of the
// fonts.gstatic.com font-src rule above (WORLDMONITOR-J0 round 2). Exact
// host + /css path; Google Fonts under any other directive still surfaces.
if (/^style-src(-elem)?$/.test(directive)) {
try {
const url = new URL(blockedURI);
if (url.protocol === 'https:' && url.hostname === 'fonts.googleapis.com' && /^\/css2?$/.test(url.pathname)) return true;
// Chinese-market extension CDN injecting its overlay stylesheet
// (www.6ppn.com/ext/assets/style.<hash>.css — the /ext/ path is the
// extension's own asset root). Exact host + .css path (WORLDMONITOR-J0).
if (url.protocol === 'https:' && url.hostname === 'www.6ppn.com' && /\.css$/.test(url.pathname)) return true;
} catch { /* unparseable values fall through */ }
// Extension bug: a literal unsubstituted `[email]` template placeholder as
// the stylesheet URL. Not a parseable host; can never be first-party.
if (blockedURI === 'https://[email]') return true;
}
// Inline script blocks from extensions/in-app browsers.
if (blockedURI === 'inline' && directive === 'script-src-elem') return true;
// Null blocked URI from in-app browsers.
if (blockedURI === 'null') return true;
// localhost/loopback — Smart TV browsers (Tizen, webOS) and dev tools inject local service calls.
if (/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?\//.test(blockedURI)) return true;
return false;
}
// Detect once whether the effective dashboard CSP allows https: in connect-src.
// The dashboard policy now ships as an HTTP header only; older/stale documents
// may still carry a meta CSP, so if one exists, honor it as the stricter local
// signal. Otherwise the deployed header is the source of truth.
const _cspAllowsHttps = (() => {
const metaEl = document.querySelector('meta[http-equiv="Content-Security-Policy"]');
if (!metaEl) return true;
const metaCsp = metaEl.getAttribute('content') ?? '';
const metaConnectSrc = metaCsp.match(/connect-src\s+([^;]*)/)?.[1] ?? '';
return metaConnectSrc.split(/\s+/).includes('https:');
})();
// media-src counterpart of `_cspAllowsHttps`.
const _cspMediaSrcAllowsHttps = (() => {
const metaEl = document.querySelector('meta[http-equiv="Content-Security-Policy"]');
if (!metaEl) return true;
const metaCsp = metaEl.getAttribute('content') ?? '';
const metaMediaSrc = metaCsp.match(/media-src\s+([^;]*)/)?.[1] ?? '';
return metaMediaSrc.split(/\s+/).includes('https:');
})();
// Resolve our configured Convex deployment hostname once. Convex is multi-tenant —
// the CSP filter must scope its first-party suppression to OUR specific hostname,
// not all *.convex.cloud, otherwise blocks to foreign/attacker tenants get silently
// dropped too. Returns null when the env var is missing (dev/test); the filter
// then leaves connect-src violations to fall through to the next rule.
const _firstPartyConvexHost = ((): string | null => {
const url = import.meta.env.VITE_CONVEX_URL;
if (typeof url !== 'string' || url.length === 0) return null;
try { return new URL(url).hostname; } catch { return null; }
})();
// @ts-expect-error — expose for tests
window.__shouldSuppressCspViolation = shouldSuppressCspViolation;
// Report CSP violations in the parent page to Sentry.
// Sandbox iframe violations are isolated and not captured here.
// The listener stays installed eagerly so early violations (during the
// deferred-Sentry-init window) are still observed; `enqueueSentryCall`
// forwards immediately if the SDK is up, otherwise buffers until drain.
window.addEventListener('securitypolicyviolation', (e) => {
const blocked = e.blockedURI ?? '';
if (shouldSuppressCspViolation(
e.disposition ?? '',
e.effectiveDirective ?? '',
blocked,
e.sourceFile ?? '',
_cspAllowsHttps,
_firstPartyConvexHost,
_cspMediaSrcAllowsHttps,
)) return;
const message = `CSP: ${e.effectiveDirective} blocked ${blocked || '(inline)'}`;
const extra = {
violatedDirective: e.violatedDirective,
effectiveDirective: e.effectiveDirective,
blockedURI: blocked,
sourceFile: e.sourceFile,
lineNumber: e.lineNumber,
disposition: e.disposition,
};
enqueueSentryCall((s) => {
s.captureMessage(message, {
level: 'warning',
tags: { kind: 'csp_violation' },
extra,
});
});
});
import { debugGetCells, getCellCount } from '@/services/geo-convergence';
import { initMetaTags } from '@/services/meta-tags';
import { installRuntimeFetchPatch, installWebApiRedirect } from '@/services/runtime';
import { loadDesktopSecrets } from '@/services/runtime-config';
import { applyStoredTheme } from '@/utils/theme-manager';
import { applyFont } from '@/services/font-settings';
import { initAnalytics } from '@/services/analytics';
import { clearChunkReloadGuard, installChunkReloadGuard } from '@/bootstrap/chunk-reload';
import { initDebugBearRum } from '@/bootstrap/debugbear-rum';
import { installStaleBundleCheck } from '@/bootstrap/stale-bundle-check';
import { installSwUpdateHandler } from '@/bootstrap/sw-update';
// Auto-reload on stale chunk 404s after deployment (Vite fires this for modulepreload failures).
const chunkReloadStorageKey = installChunkReloadGuard(__APP_VERSION__);
// Product analytics are secondary startup work; RUM starts once the trusted
// dashboard entry executes so it can observe page-load vitals.
void initAnalytics();
initVercelAnalytics();
initDebugBearRum();
// Initialize dynamic meta tags for sharing
initMetaTags();
// In desktop mode, route /api/* calls to the local Tauri sidecar backend.
installRuntimeFetchPatch();
// In web production, route RPC calls through api.worldmonitor.app (Cloudflare edge).
installWebApiRedirect();
// Force-reload tabs running a stale bundle (catches the class of bug where
// users keep a tab open across a wire-shape change). Skips when build-hash
// is the 'dev' marker.
installStaleBundleCheck();
loadDesktopSecrets().catch(() => {});
// Apply stored theme preference before app initialization (safety net for inline script)
applyStoredTheme();
applyFont();
// Set data-variant on <html> so CSS theme overrides activate
if (SITE_VARIANT && SITE_VARIANT !== 'full') {
document.documentElement.dataset.variant = SITE_VARIANT;
// Swap favicons to variant-specific versions before browser finishes fetching defaults
document.querySelectorAll<HTMLLinkElement>('link[rel="icon"], link[rel="apple-touch-icon"]').forEach(link => {
link.href = link.href
.replace(/\/favico\/favicon/g, `/favico/${SITE_VARIANT}/favicon`)
.replace(/\/favico\/apple-touch-icon/g, `/favico/${SITE_VARIANT}/apple-touch-icon`);
});
}
// Remove no-transition class after first paint to enable smooth theme transitions
requestAnimationFrame(() => {
document.documentElement.classList.remove('no-transition');
});
// Clear stale settings-open flag (survives ungraceful shutdown)
try {
localStorage.removeItem('wm-settings-open');
} catch {
// Storage may be unavailable (blocked cookies, sandboxed iframe). The flag is
// only a convenience hint, so boot must continue with the in-memory default.
}
// Standalone windows: ?settings=1 = panel display settings, ?live-channels=1 = channel management
// Both need i18n initialized so t() does not return undefined.
const urlParams = new URL(location.href).searchParams;
if (urlParams.get('settings') === '1') {
void Promise.all([import('./services/i18n'), import('./settings-window')]).then(
async ([i18n, m]) => {
await i18n.initI18n();
m.initSettingsWindow();
}
);
} else if (urlParams.get('live-channels') === '1') {
void Promise.all([import('./services/i18n'), import('./live-channels-window')]).then(
async ([i18n, m]) => {
await i18n.initI18n();
m.initLiveChannelsWindow();
}
);
} else {
installUtmInterceptor();
markLcpDebug('wm:boot:app-construct');
const app = new App('app');
app
.init()
.then(() => {
clearChunkReloadGuard(chunkReloadStorageKey);
})
.catch(console.error);
}
// Debug helpers for geo-convergence testing (remove in production)
(window as unknown as Record<string, unknown>).geoDebug = {
cells: debugGetCells,
count: getCellCount,
};
// Beta mode toggle: type `beta=true` / `beta=false` in console
Object.defineProperty(window, 'beta', {
get() {
const on = localStorage.getItem('worldmonitor-beta-mode') === 'true';
console.log(`[Beta] ${on ? 'ON' : 'OFF'}`);
return on;
},
set(v: boolean) {
if (v) localStorage.setItem('worldmonitor-beta-mode', 'true');
else localStorage.removeItem('worldmonitor-beta-mode');
location.reload();
},
});
// Suppress native WKWebView context menu in Tauri — allows custom JS context menus
if ('__TAURI_INTERNALS__' in window || '__TAURI__' in window) {
document.addEventListener('contextmenu', (e) => {
const target = e.target as HTMLElement;
// Allow native menu on text inputs/textareas for copy/paste
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return;
e.preventDefault();
});
}
if (!('__TAURI_INTERNALS__' in window) && !('__TAURI__' in window) && 'serviceWorker' in navigator) {
installSwUpdateHandler({ version: __APP_VERSION__ });
const SW_UPDATE_SUCCESS_INTERVAL_MS = 60 * 60 * 1000;
const SW_UPDATE_FAILURE_INTERVAL_MS = 5 * 60 * 1000;
const SW_UPDATE_LAST_CHECK_KEY = 'wm-sw-last-update-check';
const SW_UPDATE_LAST_RESULT_KEY = 'wm-sw-last-update-ok';
const readStorageNum = (key: string): number => {
try {
const raw = localStorage.getItem(key);
const parsed = raw ? Number(raw) : 0;
return Number.isFinite(parsed) ? parsed : 0;
} catch {
return 0;
}
};
const writeStorageNum = (key: string, value: number): void => {
try {
localStorage.setItem(key, String(value));
} catch {}
};
navigator.serviceWorker.register('/sw.js', { scope: '/' })
.then((registration) => {
console.log('[PWA] Service worker registered');
let swUpdateInFlight = false;
const maybeCheckForSwUpdate = async (
reason: 'initial' | 'visible' | 'online' | 'interval'
): Promise<void> => {
if (swUpdateInFlight) return;
if (!navigator.onLine) return;
if (reason === 'interval' && document.visibilityState !== 'visible') return;
const now = Date.now();
const lastCheck = readStorageNum(SW_UPDATE_LAST_CHECK_KEY);
const lastOk = readStorageNum(SW_UPDATE_LAST_RESULT_KEY);
const interval = lastOk >= lastCheck ? SW_UPDATE_SUCCESS_INTERVAL_MS : SW_UPDATE_FAILURE_INTERVAL_MS;
if (now - lastCheck < interval) return;
swUpdateInFlight = true;
writeStorageNum(SW_UPDATE_LAST_CHECK_KEY, now);
try {
await registration.update();
writeStorageNum(SW_UPDATE_LAST_RESULT_KEY, now);
} catch (e) {
console.warn('[PWA] SW update check failed:', e);
} finally {
swUpdateInFlight = false;
}
};
void maybeCheckForSwUpdate('initial');
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
void maybeCheckForSwUpdate('visible');
}
});
window.addEventListener('online', () => {
void maybeCheckForSwUpdate('online');
});
const swUpdateInterval = window.setInterval(() => {
void maybeCheckForSwUpdate('interval');
}, 15 * 60 * 1000);
(window as unknown as Record<string, unknown>).__swUpdateInterval = swUpdateInterval;
})
.catch((err) => {
console.warn('[PWA] Service worker registration failed:', err);
});
}
// --- SW/Cache Nuke Template ---
// If stale service workers or caches cause issues after a major deploy, re-enable this block.
// It runs once per user (guarded by a localStorage key), nukes all SWs and caches, then reloads.
// IMPORTANT: This causes a visible double-load for every new/unkeyed user. Remove once rollout is complete.
//
// const nukeKey = 'wm-sw-nuked-v3';
// let alreadyNuked = false;
// try { alreadyNuked = !!localStorage.getItem(nukeKey); } catch {}
// if (!alreadyNuked) {
// try { localStorage.setItem(nukeKey, '1'); } catch {}
// navigator.serviceWorker.getRegistrations().then(async (regs) => {
// await Promise.all(regs.map(r => r.unregister()));
// const keys = await caches.keys();
// await Promise.all(keys.map(k => caches.delete(k)));
// console.log('[PWA] Nuked stale service workers and caches');
// window.location.reload();
// });
// }