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();
//   });
// }