@omega.js/client 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/LICENSE +98 -0
  2. package/README.md +874 -0
  3. package/dist/index.js +999 -0
  4. package/dist/modules/analytics.js +584 -0
  5. package/dist/modules/auth.js +469 -0
  6. package/dist/modules/bindings.js +319 -0
  7. package/dist/modules/device.js +282 -0
  8. package/dist/modules/dom.js +96 -0
  9. package/dist/modules/features.js +30 -0
  10. package/dist/modules/firestore.js +313 -0
  11. package/dist/modules/form-manager.js +1577 -0
  12. package/dist/modules/icon-core.js +226 -0
  13. package/dist/modules/icon-renderer.js +149 -0
  14. package/dist/modules/live-page.js +235 -0
  15. package/dist/modules/logger.js +36 -0
  16. package/dist/modules/motion.js +853 -0
  17. package/dist/modules/notifications.js +433 -0
  18. package/dist/modules/path-prefix.js +22 -0
  19. package/dist/modules/request.js +223 -0
  20. package/dist/modules/sentry.js +108 -0
  21. package/dist/modules/service-worker.js +237 -0
  22. package/dist/modules/storage.js +133 -0
  23. package/dist/modules/triggers.js +117 -0
  24. package/dist/modules/utilities.js +479 -0
  25. package/dist/modules/vert-document.js +354 -0
  26. package/dist/modules/verts.js +1133 -0
  27. package/dist/vendor/account/engine.js +182 -0
  28. package/dist/vendor/account/features.js +220 -0
  29. package/dist/vendor/account/index.js +53 -0
  30. package/dist/vendor/account/schema.js +272 -0
  31. package/dist/vendor/account/subscription.js +38 -0
  32. package/dist/vendor/analytics/adapters/ga4.js +26 -0
  33. package/dist/vendor/analytics/adapters/meta.js +26 -0
  34. package/dist/vendor/analytics/adapters/resolve.js +130 -0
  35. package/dist/vendor/analytics/adapters/tiktok.js +27 -0
  36. package/dist/vendor/analytics/catalog.js +908 -0
  37. package/dist/vendor/analytics/consent.js +49 -0
  38. package/dist/vendor/analytics/core.js +141 -0
  39. package/dist/vendor/analytics/identity.js +136 -0
  40. package/dist/vendor/analytics/index.js +170 -0
  41. package/dist/vendor/analytics/logger.js +40 -0
  42. package/dist/vendor/analytics/transports/browser.js +110 -0
  43. package/dist/vendor/monitoring/browser.js +207 -0
  44. package/dist/vendor/monitoring/core.js +180 -0
  45. package/dist/vendor/monitoring/logger.js +39 -0
  46. package/docs/architecture.md +59 -0
  47. package/docs/bindings.md +235 -0
  48. package/docs/build-system.md +32 -0
  49. package/docs/cdp-debugging.md +29 -0
  50. package/docs/code-patterns.md +96 -0
  51. package/docs/common-tasks.md +36 -0
  52. package/docs/dependencies.md +19 -0
  53. package/docs/index.md +159 -0
  54. package/docs/modules.md +180 -0
  55. package/docs/shared/agent-docs.md +89 -0
  56. package/docs/shared/analytics.md +612 -0
  57. package/docs/shared/brands.md +51 -0
  58. package/docs/shared/breaking-changes.md +497 -0
  59. package/docs/shared/config.md +1387 -0
  60. package/docs/shared/deploys.md +215 -0
  61. package/docs/shared/icons.md +201 -0
  62. package/docs/shared/local-dev.md +147 -0
  63. package/docs/shared/logging.md +202 -0
  64. package/docs/shared/monitoring.md +153 -0
  65. package/docs/shared/publishing.md +183 -0
  66. package/docs/shared/rulings.md +34 -0
  67. package/docs/shared/testing.md +147 -0
  68. package/docs/shared/theming.md +604 -0
  69. package/docs/shared/translation.md +291 -0
  70. package/docs/shared/updates.md +61 -0
  71. package/docs/testing.md +9 -0
  72. package/package.json +65 -0
@@ -0,0 +1,584 @@
1
+ /**
2
+ * The client runtime's analytics — a HOST of `@omega.js/analytics`, never a
3
+ * second implementation of it ([#328](https://github.com/Omega-JS-Stack/omega/issues/328),
4
+ * stage E).
5
+ *
6
+ * `manager.analytics().event('<canonical>', params)` resolves through the shared
7
+ * catalog and adapters, exactly like a web page's call sites and the backend's
8
+ * webhook. What differs per runtime is the TRANSPORT, and this module is where
9
+ * each one is injected:
10
+ *
11
+ * web the PAGE's transport — the guarded gtag/fbq/ttq the
12
+ * web host wired (`core/js/libs/analytics.js`), which
13
+ * also owns the consent gate and the attribution
14
+ * context. Nothing is configured here beyond the
15
+ * environment: a page's seams are the page's.
16
+ * electron / extension the Measurement Protocol, fed by the GA4 descriptor.
17
+ * Meta and TikTok resolve and then skip — no pixel
18
+ * exists in these runtimes to receive them.
19
+ * desktop renderer NO transport at all: the injected IPC bridge
20
+ * ([#411](https://github.com/Omega-JS-Stack/omega/issues/411)).
21
+ * Events forward to the main process, whose sender
22
+ * owns the one device id, the one session id and the
23
+ * real engagement time — one install, one GA client.
24
+ *
25
+ * Identity is real here (#159 is closed): `setUserId` / `setUserProperties` SEND
26
+ * on web through the page's gtag instead of storing values only the Measurement
27
+ * Protocol payload ever read.
28
+ */
29
+ import analytics from '../vendor/analytics/index.js';
30
+ import core from '../vendor/analytics/core.js';
31
+ import { createLogger } from './logger.js';
32
+
33
+ const logger = createLogger('analytics');
34
+
35
+ // Supported runtimes for analytics
36
+ const SUPPORTED_RUNTIMES = ['browser-extension', 'electron', 'web'];
37
+
38
+ // Raw per-install device id (a plain UUID) — client_id derives from it
39
+ const DEVICE_ID_KEY = '_omega_device_id';
40
+
41
+ // The GA4 session cache: one id per visit, rolled after 30 minutes of inactivity
42
+ const SESSION_KEY = '_ga_session_id';
43
+ const SESSION_TIMEOUT = 30 * 60 * 1000;
44
+
45
+ class Analytics {
46
+ constructor(manager) {
47
+ this.manager = manager;
48
+ this.initialized = false;
49
+ this.devMode = false;
50
+ this.runtime = null;
51
+ this.config = null;
52
+ this.bridge = null;
53
+ this.measurementId = null;
54
+ this.secret = null;
55
+ this.projectId = null;
56
+ this.namespace = null;
57
+ this.clientId = null;
58
+ this.session = null;
59
+ this.userId = null;
60
+ this.userProperties = {};
61
+ this.authed = false;
62
+ }
63
+
64
+ // Check if runtime is supported
65
+ _isSupported() {
66
+ return SUPPORTED_RUNTIMES.includes(this.runtime);
67
+ }
68
+
69
+ // Web's transport is the page's own pixels, never the Measurement Protocol
70
+ _isWeb() {
71
+ return this.runtime === 'web';
72
+ }
73
+
74
+ // A popup / options page / sidepanel — a fresh top-level context every open,
75
+ // which is why the session cache cannot live in sessionStorage here (#412)
76
+ _isExtension() {
77
+ return this.runtime === 'browser-extension';
78
+ }
79
+
80
+ // Get extension storage API — the same seam `device` persists through
81
+ _getExtensionStorage() {
82
+ if (typeof chrome !== 'undefined' && chrome.storage?.local) {
83
+ return chrome.storage.local;
84
+ }
85
+ if (typeof browser !== 'undefined' && browser.storage?.local) {
86
+ return browser.storage.local;
87
+ }
88
+ return null;
89
+ }
90
+
91
+ // Inside desktop's renderer, where the host injected the preload's IPC bridge
92
+ // to the main process's sender (#411). It outranks every runtime branch
93
+ // below: an Electron window sets no `config.runtime`, so a desktop renderer
94
+ // reads as the WEB runtime and the bridge is the only thing that says
95
+ // otherwise. Bridged means this client NEVER sends — not the Measurement
96
+ // Protocol, not a page pixel — so it holds no secret and no device id.
97
+ _isBridged() {
98
+ return !!this.bridge;
99
+ }
100
+
101
+ // Initialize analytics
102
+ init(config = {}) {
103
+ // Store config
104
+ this.config = config;
105
+
106
+ // Get runtime
107
+ this.runtime = this.manager.utilities().getRuntime();
108
+
109
+ if (!this._isSupported()) {
110
+ logger.log(`Runtime "${this.runtime}" not supported yet, skipping`);
111
+ return;
112
+ }
113
+
114
+ // Skip if already initialized
115
+ if (this.initialized) {
116
+ logger.log('Already initialized');
117
+ return;
118
+ }
119
+
120
+ // Check for development mode — dev NEVER posts to a real property
121
+ // (consumers' dev traffic must not land in anyone's GA4; the baked-in
122
+ // fallback credentials are gone by design — C4 cp106a de-ITW).
123
+ this.devMode = this.manager.isDevelopment();
124
+
125
+ // The host's seam for a runtime whose events belong to another process
126
+ // (#411) — the desktop renderer's IPC bridge, null everywhere else.
127
+ this.bridge = config.bridge || null;
128
+
129
+ // Canonical handoff: analytics.providers.google.{id,secret} + the
130
+ // brand's projectId for the cross-surface identity namespace
131
+ this.measurementId = config.measurementId || config.id;
132
+ this.projectId = config.projectId || null;
133
+
134
+ // The Measurement Protocol api_secret is never read on web: the page's
135
+ // gtag is the transport there, and the secret must never reach a page. Nor
136
+ // is it read in a bridged renderer — and that is the guard behind desktop's
137
+ // rule that the secret must never be injected into a renderer's config: a
138
+ // second sender here would split one install into two GA devices (#396).
139
+ this.secret = (this._isWeb() || this._isBridged()) ? null : config.secret;
140
+
141
+ // Skip if no measurement ID. Web has none to require, since the gtag
142
+ // config is page-side (the consent-gated loader owns it), and a bridged
143
+ // renderer has none to require because main holds them.
144
+ if (!this.measurementId && !this._isWeb() && !this._isBridged()) {
145
+ logger.log('No measurement ID provided, skipping initialization');
146
+ return;
147
+ }
148
+
149
+ // Cross-surface identity — @omega.js/analytics core (the ONE place the
150
+ // uuidv5 math lives; desktop's main-process lib uses the same module)
151
+ this.namespace = core.deriveNamespace(this.projectId);
152
+
153
+ // Generate or retrieve client ID. A bridged renderer mints none: main's
154
+ // sender already resolved the install's device id from its OWN storage,
155
+ // and a second one here is the identity fork this bridge exists to prevent.
156
+ this.clientId = this._isBridged() ? null : this._getClientId();
157
+
158
+ // The facade's environment seam is the brand's own `config.environment` —
159
+ // it decides whether an unknown event name throws and whether the fire log
160
+ // prints. Injected for EVERY runtime, because it is the one thing a page
161
+ // host cannot know before the config has loaded.
162
+ analytics.configure({
163
+ environment: this.devMode ? 'development' : 'production',
164
+ });
165
+
166
+ // The transport per runtime. On web it is the package's guarded browser
167
+ // transport — the same object the page host wires, so a client-fired event
168
+ // (a vert click, a permission prompt) counts on a page whose own call sites
169
+ // never loaded. The page keeps the seams only a page can supply: the
170
+ // consent gate and the attribution context.
171
+ //
172
+ // A bridged renderer configures NONE: it never resolves a descriptor at
173
+ // all, because `event()` hands the canonical name to the bridge and main
174
+ // walks the catalog on the other side.
175
+ if (!this._isBridged()) {
176
+ analytics.configure(this._isWeb()
177
+ ? { transport: analytics.transports.browser }
178
+ : {
179
+ transport: { send: (descriptor) => this._sendViaMeasurementProtocol(descriptor) },
180
+ context: { runtime: this.runtime },
181
+ });
182
+ }
183
+
184
+ // Log initialization
185
+ logger.log(`Initializing with ${this._isBridged() ? 'the desktop IPC bridge (main is the sender)' : `measurement ID: ${this.measurementId || 'page-side gtag'}`}${this.devMode ? ' (dev mode)' : ''} [${this.runtime}]`);
186
+
187
+ // Mark as initialized
188
+ this.initialized = true;
189
+
190
+ // An extension's session cache lives in chrome.storage (#412) — the only
191
+ // storage a popup close does not wipe. That read is async while
192
+ // `_getSessionId()` must stay synchronous for the payload, so it hydrates
193
+ // ONCE here into the in-memory mirror the getter reads, and every later
194
+ // update writes through. Null when there is no extension storage to read.
195
+ const storage = this._isExtension() ? this._getExtensionStorage() : null;
196
+ const hydrating = storage ? this._loadSessionFromExtensionStorage(storage) : null;
197
+
198
+ // Send initial pageview, never on web: the page's own gtag config
199
+ // already fired one and a second would double-count. Nor from a bridged
200
+ // renderer: main fires the launch events for the whole app (app_launch,
201
+ // once per launch), and a per-window page_view here would be its own
202
+ // decision to make, not a side effect of wiring the bridge.
203
+ //
204
+ // It waits for the hydrate above, because the launch event of a reopened
205
+ // popup is exactly the one that must carry the session it is rejoining —
206
+ // minting a new id here is the bug (#412), not a detail.
207
+ if (!this._isWeb() && !this._isBridged()) {
208
+ if (hydrating) {
209
+ hydrating.then(() => this.event('page_view'));
210
+ } else {
211
+ this.event('page_view');
212
+ }
213
+ }
214
+ }
215
+
216
+ // Stable per-install device id, hashed into the project namespace so every
217
+ // event from this browser is the same GA client. The derivation is the
218
+ // package's ([#396](https://github.com/Omega-JS-Stack/omega/issues/396)) —
219
+ // all this runtime supplies is where it persists, since a page can read
220
+ // nothing about the machine to seed from. Without a projectId the raw (still
221
+ // stable) device id is used as-is.
222
+ _getClientId() {
223
+ const deviceId = core.deriveDeviceId({
224
+ get: () => {
225
+ try {
226
+ return localStorage.getItem(DEVICE_ID_KEY);
227
+ } catch (e) {
228
+ // localStorage not available
229
+ return null;
230
+ }
231
+ },
232
+ set: (value) => {
233
+ try {
234
+ localStorage.setItem(DEVICE_ID_KEY, value);
235
+ } catch (e) {
236
+ // localStorage not available
237
+ }
238
+ },
239
+ });
240
+
241
+ return core.deriveClientId(deviceId, this.namespace);
242
+ }
243
+
244
+ // Get page data to include with all events
245
+ _getPageData() {
246
+ return {
247
+ page_path: window.location.pathname,
248
+ page_title: document.title,
249
+ page_location: window.location.href,
250
+ };
251
+ }
252
+
253
+ /**
254
+ * Fire a canonical event.
255
+ *
256
+ * @param {string} eventName - A canonical name from the catalog — the SSOT
257
+ * for what each provider is told and in which dialect.
258
+ * @param {object} [params] - That event's canonical params.
259
+ * @param {object} [options] - `{ eventId, providers }` for an event whose
260
+ * other half fires server-side.
261
+ * @returns {void}
262
+ */
263
+ event(eventName, params = {}, options = {}) {
264
+ if (!this._isSupported()) {
265
+ return;
266
+ }
267
+
268
+ if (!this.initialized) {
269
+ return;
270
+ }
271
+
272
+ // Bridged (desktop's renderer): the canonical name and the caller's params
273
+ // cross to main, which resolves the catalog and enriches with ITS identity
274
+ // — device id, the session id minted once per launch, real engagement time
275
+ // — and with the app's own page context, which is main's to own and not a
276
+ // renderer's file:// href. `options` stays behind with the page-pixel
277
+ // providers it exists to deduplicate: GA4 through main is the one lane here.
278
+ if (this._isBridged()) {
279
+ // The catalog check happens HERE, on the facade's own rule (throw in
280
+ // development, log-and-skip in production): a typo is the CALL SITE's
281
+ // bug, and it must surface at that stack in that renderer's console —
282
+ // not as an unattributable warn in the main process's runtime.log.
283
+ if (!analytics.entryFor(eventName)) {
284
+ if (analytics.isDevelopment()) {
285
+ throw new Error(`Unknown analytics event "${eventName}" — every event is declared in the catalog (@omega.js/analytics/catalog)`);
286
+ }
287
+
288
+ logger.warn(`Unknown event "${eventName}" — not in the catalog, skipped`);
289
+ return;
290
+ }
291
+
292
+ try {
293
+ this.bridge.event(eventName, params);
294
+ } catch (e) {
295
+ // The forward crossing IPC is the one thing here that can fail on the
296
+ // caller's data (a param that structured-clone cannot carry). The
297
+ // facade never throws at a visitor mid-action, so neither does this.
298
+ logger.warn(`Failed to forward "${eventName}" to the main process:`, e.message);
299
+ }
300
+
301
+ return;
302
+ }
303
+
304
+ // Merge page data with provided params
305
+ const eventParams = {
306
+ ...this._getPageData(),
307
+ ...params,
308
+ };
309
+
310
+ // The facade walks consent → adapters → transport and never throws at a
311
+ // visitor; its own dev line is the per-fire trace.
312
+ analytics.event(eventName, eventParams, options);
313
+ }
314
+
315
+ /**
316
+ * The Measurement Protocol transport for the runtimes with no page pixels.
317
+ * GA4 is the only provider it can deliver: Meta's and TikTok's browser pixels
318
+ * do not exist in an Electron window or an extension context, so their
319
+ * descriptors report "blocked" and the fire log says so.
320
+ *
321
+ * @param {object} descriptor - The resolved provider descriptor.
322
+ * @returns {boolean} true when the descriptor was delivered.
323
+ */
324
+ _sendViaMeasurementProtocol(descriptor) {
325
+ if (descriptor.provider !== 'ga4') {
326
+ return false;
327
+ }
328
+
329
+ // Dev mode logs only — nothing posts
330
+ if (this.devMode) {
331
+ logger.log('Dev mode: event logged locally, not sent');
332
+ return true;
333
+ }
334
+
335
+ // Measurement Protocol requires api_secret
336
+ if (!this.secret) {
337
+ logger.warn('No API secret provided, cannot send via Measurement Protocol');
338
+ return false;
339
+ }
340
+
341
+ const url = core.buildCollectUrl(this.measurementId, this.secret);
342
+
343
+ const payload = core.buildPayload({
344
+ clientId: this.clientId,
345
+ userId: this.userId,
346
+ userProperties: this.userProperties,
347
+ eventName: descriptor.name,
348
+ params: {
349
+ ...descriptor.payload,
350
+ engagement_time_msec: 100,
351
+ session_id: this._getSessionId(),
352
+ },
353
+ });
354
+
355
+ // Send via fetch (fire and forget)
356
+ fetch(url, {
357
+ method: 'POST',
358
+ body: JSON.stringify(payload),
359
+ }).catch((err) => {
360
+ logger.warn('Failed to send event:', err);
361
+ });
362
+
363
+ return true;
364
+ }
365
+
366
+ // Get or generate session ID
367
+ _getSessionId() {
368
+ const now = Date.now();
369
+ const session = this._readSession();
370
+
371
+ // Check if session is still valid
372
+ if (session && (now - session.lastActive) < SESSION_TIMEOUT) {
373
+ this._writeSession({ ...session, lastActive: now });
374
+ return session.id;
375
+ }
376
+
377
+ // Create new session
378
+ const newSession = {
379
+ id: `${now}`,
380
+ lastActive: now,
381
+ };
382
+
383
+ this._writeSession(newSession);
384
+
385
+ return newSession.id;
386
+ }
387
+
388
+ // The session cache's two backings. An extension reads the in-memory mirror
389
+ // hydrated at init, since chrome.storage is async and this is not; every
390
+ // other runtime reads sessionStorage, which outlives nothing but its own
391
+ // page — exactly what a web session is.
392
+ _readSession() {
393
+ if (this._isExtension()) {
394
+ return this.session;
395
+ }
396
+
397
+ try {
398
+ return JSON.parse(sessionStorage.getItem(SESSION_KEY) || 'null');
399
+ } catch (e) {
400
+ // sessionStorage not available
401
+ return null;
402
+ }
403
+ }
404
+
405
+ _writeSession(session) {
406
+ if (this._isExtension()) {
407
+ this.session = session;
408
+
409
+ const storage = this._getExtensionStorage();
410
+ if (storage) {
411
+ // Write-through, fire and forget: the mirror already carries the value
412
+ // the event being built is about to send
413
+ this._saveSessionToExtensionStorage(storage, session);
414
+ }
415
+
416
+ return;
417
+ }
418
+
419
+ try {
420
+ sessionStorage.setItem(SESSION_KEY, JSON.stringify(session));
421
+ } catch (e) {
422
+ // sessionStorage not available
423
+ }
424
+ }
425
+
426
+ // Load the session from extension storage (async)
427
+ async _loadSessionFromExtensionStorage(storage) {
428
+ try {
429
+ const result = await storage.get(SESSION_KEY);
430
+ const stored = result?.[SESSION_KEY];
431
+
432
+ // A session minted while this read was in flight belongs to THIS context
433
+ // and already rode an event — hydrating over it would rewind a sent id
434
+ if (!this.session && stored && typeof stored === 'object' && stored.id) {
435
+ this.session = stored;
436
+ }
437
+ } catch (e) {
438
+ logger.warn('Failed to load session from extension storage:', e);
439
+ }
440
+ }
441
+
442
+ // Save the session to extension storage (async)
443
+ async _saveSessionToExtensionStorage(storage, session) {
444
+ try {
445
+ await storage.set({ [SESSION_KEY]: session });
446
+ } catch (e) {
447
+ logger.warn('Failed to save session to extension storage:', e);
448
+ }
449
+ }
450
+
451
+ // Set user properties — GA4 wraps each value as { value } — merged into
452
+ // every subsequent event's user_properties block, and SENT on web through
453
+ // the page's own gtag (#159: they used to be stored and never sent)
454
+ setUserProperties(properties = {}) {
455
+ if (!this._isSupported()) {
456
+ return;
457
+ }
458
+
459
+ if (!this.initialized) {
460
+ return;
461
+ }
462
+
463
+ // Bridged: main's sender owns the user_properties block that rides every
464
+ // event, so they cross raw (it does the GA4 { value } wrapping) and are
465
+ // never held here, where nothing would ever read them.
466
+ if (this._isBridged()) {
467
+ try {
468
+ this.bridge.setUserProperties(properties);
469
+ } catch (e) {
470
+ // Same rule as the event forward above: a value IPC cannot carry is
471
+ // never allowed to throw into the caller's action.
472
+ logger.warn('Failed to forward user properties to the main process:', e.message);
473
+ }
474
+
475
+ return;
476
+ }
477
+
478
+ this.userProperties = { ...this.userProperties, ...core.wrapUserProperties(properties) };
479
+
480
+ if (this._isWeb()) {
481
+ this._setOnGtag({ user_properties: this.userProperties });
482
+ }
483
+ }
484
+
485
+ // Set user ID — raw uid in, uuidv5 out (the same value desktop/backend
486
+ // emit for this uid). Without a namespace the raw uid is never sent.
487
+ setUserId(userId) {
488
+ if (!this._isSupported()) {
489
+ return;
490
+ }
491
+
492
+ if (!this.initialized) {
493
+ return;
494
+ }
495
+
496
+ // Bridged: identity is main's OWN, and this is the one place the bridge
497
+ // does not forward — the preload exposes no setUserId, because main's auth
498
+ // bridge already flips user_id off the same Firebase user (#411). Storing
499
+ // it here would set a value nothing in this process ever reads, so the
500
+ // call raises where it was made instead of quietly doing nothing (#480).
501
+ if (this._isBridged()) {
502
+ throw new Error('setUserId() is not a bridged renderer\'s to call — the main process owns identity (its auth bridge sets user_id off the same Firebase user). Call manager.analytics.setUserId(uid) in MAIN to set one by hand');
503
+ }
504
+
505
+ this.userId = core.deriveUserId(userId, this.namespace);
506
+
507
+ // Web sends it (#159). A null userId is sent as null, which is how GA4 is
508
+ // told to stop attributing to the person who just signed out.
509
+ if (this._isWeb()) {
510
+ this._setOnGtag({ user_id: this.userId });
511
+ }
512
+ }
513
+
514
+ /**
515
+ * GA4's `set` command through the page's own gtag, guarded: an ad blocker
516
+ * does not stub the global, it keeps it from existing, and a bare call would
517
+ * throw a ReferenceError (#306).
518
+ * @param {object} properties - The `set` payload.
519
+ */
520
+ _setOnGtag(properties) {
521
+ // `typeof` against an undeclared NAME is the one check that does not throw:
522
+ // a blocker leaves `gtag` undefined rather than stubbed, so `window.gtag`
523
+ // would be a miss on any page that never made a window object of it.
524
+ if (typeof gtag !== 'function') {
525
+ return;
526
+ }
527
+
528
+ gtag('set', properties);
529
+ }
530
+
531
+ /**
532
+ * Auth transitions → the catalog's `login` / `logout`.
533
+ *
534
+ * The audited asymmetry (#328 inventory gap 8): desktop's main-process
535
+ * singleton fired these off its auth bridge while web and the extension fired
536
+ * nothing. The wiring belongs here, in the class every runtime shares — with
537
+ * ONE owner per surface:
538
+ *
539
+ * web the auth pages own it (`libs/auth/tracking.js` fires `login`
540
+ * with the METHOD the visitor actually used, which an auth-state
541
+ * callback cannot know), so this wiring stays out of web's way
542
+ * entirely.
543
+ * bridged desktop's main-process singleton owns it: its auth bridge fires
544
+ * login/logout off the SAME Firebase user, so forwarding them
545
+ * from the renderer would double-count every sign-in (#411).
546
+ * other this is the only owner.
547
+ *
548
+ * @param {object|null} user - The auth user, or null when signed out.
549
+ * @returns {void}
550
+ */
551
+ handleAuthChange(user) {
552
+ const uid = user?.uid || null;
553
+
554
+ // Bridged: NEITHER half is this renderer's — main's auth bridge sets the
555
+ // identity and fires the events off the same user, so the setUserId below
556
+ // would hit the throw that guards main's ownership (#480).
557
+ if (this._isBridged()) {
558
+ return;
559
+ }
560
+
561
+ // Identity follows auth on every other runtime (user_id = uuidv5(uid, namespace))
562
+ this.setUserId(uid);
563
+
564
+ if (this._isWeb()) {
565
+ return;
566
+ }
567
+
568
+ if (uid && !this.authed) {
569
+ this.authed = true;
570
+ this.event('login', { method: user?.providerId || 'unknown', user_id: uid });
571
+ } else if (!uid && this.authed) {
572
+ this.authed = false;
573
+ this.event('logout');
574
+ }
575
+ }
576
+ }
577
+
578
+ export default Analytics;
579
+
580
+ // The facade itself, for the HOST that wires a runtime's seams — @omega.js/web's
581
+ // page module reaches the package through here, because `@omega.js/analytics` is
582
+ // private and exists in a consumer install only as the copy vendored into this
583
+ // package's dist (HARD RULE 3).
584
+ export { analytics };