File size: 30,369 Bytes
9d2d895 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 | 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();
// });
// }
|