/* * openvibe-shared/release-watch.js — keeps open tabs on a supported release (ADR-016, Track R). * Reads /release.json on focus, visibility, reconnect and every 10 min. Changes with components or contract * ranges are planned by release-update.js (loaded on demand from this directory): style, content and server * go in place, anything else prompts. It reloads by itself only when it must (min_client_release or * min_client_generation, the mixed-version window, contracts) and only when safe: hidden or idle 2 min, no * focused field, nothing protected (form[data-dirty="true"], [data-ov-protected], playing media, a live * camera/mic, OVProtected()). Outcomes and a 5-minute beat go to the metrics URL (D46). * An optional release applies itself after 30 s hidden with nothing protected, at the same scroll position (4.16.0). * OVReleaseConfig: { url, metricsUrl, updateUrl, inPlace: false, autoApply: false, service, eventsUrl (false: off) }; the meta * tag may carry data-service, data-events and data-generation. See README "Releases". */ (function (root) { if (typeof document === 'undefined' || root.OVRelease) return; const cfg = root.OVReleaseConfig || {}; const self = (document.currentScript && document.currentScript.src) || ''; const SELF_RE = /release-watch\.js(?:[?#].*)?$/; const updateUrl = cfg.updateUrl || `${SELF_RE.test(self) ? self.replace(SELF_RE, '') : '/shared/'}release-update.js`; const meta = document.querySelector('meta[name="ov-release"]'); let current = (meta && meta.content) || null; const gen = meta && meta.hasAttribute('data-generation') ? +meta.getAttribute('data-generation') : undefined; const url = cfg.url || (meta && meta.getAttribute('data-url')) || '/release.json'; const IDLE_MS = 2 * 60 * 1000; const MIN_GAP_MS = 60 * 1000; let base = null; // the manifest of the release this page runs let latest = null; let lastCheck = 0; let lastInput = Date.now(); let prompted = false; let mustReload = false; let reloadWhy = null; let busyWith = null; // the plan/apply in flight let pending = null; let waiting = []; let kinds = ''; let failedRelease = null; let lib = null; let acct = 0; let counts = {}; const totals = {}; const noted = {}; let session = null; let lastBeat = 0; try { session = Array.from(root.crypto.getRandomValues(new Uint8Array(8)), (b) => (b | 256).toString(16).slice(1)).join(''); } catch { /* no beats */ } ['keydown', 'pointerdown', 'input', 'wheel', 'touchstart'].forEach((t) => root.addEventListener(t, () => { lastInput = Date.now(); }, { passive: true, capture: true })); // ── Update metrics (D46): counted here, sent on hide, after an update and before a reload ── function record(outcome, reason, once) { const k = `${latest && latest.release}|${outcome}|${reason}`; if (once) { if (noted[k]) return; noted[k] = 1; } for (const o of [counts, totals]) { const r = o[outcome] || (o[outcome] = {}); r[reason] = (r[reason] || 0) + 1; } } // beat: send even with nothing counted; ended: the tab is going function flush(beat, ended) { const to = cfg.metricsUrl || (meta && meta.getAttribute('data-metrics')) || (latest && latest.metrics_url); const any = Object.keys(counts).length > 0; if (!to || (!any && !(beat === true && session && current))) return; try { if (new URL(to, root.location.href).origin !== new URL(root.location.href).origin) return; const report = { service: latest && latest.service, release: current, to: latest && latest.release }; if (any) report.counts = counts; if (session) { report.session = session; lastBeat = Date.now(); if (ended === true) report.ended = true; } const body = JSON.stringify(report); counts = {}; const n = root.navigator; if (!(n && n.sendBeacon && n.sendBeacon(to, body))) root.fetch(to, { method: 'POST', body, keepalive: true, credentials: 'same-origin', headers: { 'Content-Type': 'text/plain' } }).catch(() => {}); } catch { /* best effort */ } } /** Why acting now is not safe, or null. With `scope`, only what is inside that element counts. */ function busy(scope) { const d = scope || document; const a = document.activeElement; const typing = a && (a.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(a.tagName || '')); if (scope ? a && a !== document.body && scope.contains(a) : typing) return typing ? 'typing' : 'active'; if (d.querySelector('form[data-dirty="true"]') || (scope && scope.matches('form[data-dirty="true"]'))) return 'dirty'; if (d.querySelector('[data-ov-protected]') || (scope && scope.hasAttribute('data-ov-protected'))) return 'protected'; // Someone watching or broadcasting: a playing