openvibers/openvibe.shared release-watch.js
release-watch.js
| 1 | /* |
| 2 | * openvibe-shared/release-watch.js — keeps open tabs on a supported release (ADR-016, Track R). |
| 3 | * Reads /release.json on focus, visibility, reconnect and every 10 min. Changes with components or contract |
| 4 | * ranges are planned by release-update.js (loaded on demand from this directory): style, content and server |
| 5 | * go in place, anything else prompts. It reloads by itself only when it must (min_client_release or |
| 6 | * min_client_generation, the mixed-version window, contracts) and only when safe: hidden or idle 2 min, no |
| 7 | * focused field, nothing protected (form[data-dirty="true"], [data-ov-protected], playing media, a live |
| 8 | * camera/mic, OVProtected()). Outcomes and a 5-minute beat go to the metrics URL (D46). |
| 9 | * An optional release applies itself after 30 s hidden with nothing protected, at the same scroll position (4.16.0). |
| 10 | * OVReleaseConfig: { url, metricsUrl, updateUrl, inPlace: false, autoApply: false, service, eventsUrl (false: off) }; the meta |
| 11 | * tag may carry data-service, data-events and data-generation. See README "Releases". |
| 12 | */ |
| 13 | (function (root) { |
| 14 | if (typeof document === 'undefined' || root.OVRelease) return; |
| 15 | const cfg = root.OVReleaseConfig || {}; |
| 16 | const self = (document.currentScript && document.currentScript.src) || ''; |
| 17 | const SELF_RE = /release-watch\.js(?:[?#].*)?$/; |
| 18 | const updateUrl = cfg.updateUrl || `${SELF_RE.test(self) ? self.replace(SELF_RE, '') : '/shared/'}release-update.js`; |
| 19 | const meta = document.querySelector('meta[name="ov-release"]'); |
| 20 | let current = (meta && meta.content) || null; |
| 21 | const gen = meta && meta.hasAttribute('data-generation') ? +meta.getAttribute('data-generation') : undefined; |
| 22 | const url = cfg.url || (meta && meta.getAttribute('data-url')) || '/release.json'; |
| 23 | const IDLE_MS = 2 * 60 * 1000; |
| 24 | const MIN_GAP_MS = 60 * 1000; |
| 25 | let base = null; // the manifest of the release this page runs |
| 26 | let latest = null; |
| 27 | let lastCheck = 0; |
| 28 | let lastInput = Date.now(); |
| 29 | let prompted = false; |
| 30 | let mustReload = false; let reloadWhy = null; |
| 31 | let busyWith = null; // the plan/apply in flight |
| 32 | let pending = null; let waiting = []; let kinds = ''; let failedRelease = null; let lib = null; let acct = 0; |
| 33 | let counts = {}; const totals = {}; const noted = {}; |
| 34 | let session = null; let lastBeat = 0; |
| 35 | try { session = Array.from(root.crypto.getRandomValues(new Uint8Array(8)), (b) => (b | 256).toString(16).slice(1)).join(''); } catch { /* no beats */ } |
| 36 | |
| 37 | ['keydown', 'pointerdown', 'input', 'wheel', 'touchstart'].forEach((t) => root.addEventListener(t, () => { lastInput = Date.now(); }, { passive: true, capture: true })); |
| 38 | |
| 39 | // ── Update metrics (D46): counted here, sent on hide, after an update and before a reload ── |
| 40 | function record(outcome, reason, once) { |
| 41 | const k = `${latest && latest.release}|${outcome}|${reason}`; |
| 42 | if (once) { if (noted[k]) return; noted[k] = 1; } |
| 43 | for (const o of [counts, totals]) { const r = o[outcome] || (o[outcome] = {}); r[reason] = (r[reason] || 0) + 1; } |
| 44 | } |
| 45 | // beat: send even with nothing counted; ended: the tab is going |
| 46 | function flush(beat, ended) { |
| 47 | const to = cfg.metricsUrl || (meta && meta.getAttribute('data-metrics')) || (latest && latest.metrics_url); |
| 48 | const any = Object.keys(counts).length > 0; |
| 49 | if (!to || (!any && !(beat === true && session && current))) return; |
| 50 | try { |
| 51 | if (new URL(to, root.location.href).origin !== new URL(root.location.href).origin) return; |
| 52 | const report = { service: latest && latest.service, release: current, to: latest && latest.release }; |
| 53 | if (any) report.counts = counts; |
| 54 | if (session) { report.session = session; lastBeat = Date.now(); if (ended === true) report.ended = true; } |
| 55 | const body = JSON.stringify(report); |
| 56 | counts = {}; |
| 57 | const n = root.navigator; |
| 58 | 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(() => {}); |
| 59 | } catch { /* best effort */ } |
| 60 | } |
| 61 | |
| 62 | /** Why acting now is not safe, or null. With `scope`, only what is inside that element counts. */ |
| 63 | function busy(scope) { |
| 64 | const d = scope || document; |
| 65 | const a = document.activeElement; |
| 66 | const typing = a && (a.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(a.tagName || '')); |
| 67 | if (scope ? a && a !== document.body && scope.contains(a) : typing) return typing ? 'typing' : 'active'; |
| 68 | if (d.querySelector('form[data-dirty="true"]') || (scope && scope.matches('form[data-dirty="true"]'))) return 'dirty'; |
| 69 | if (d.querySelector('[data-ov-protected]') || (scope && scope.hasAttribute('data-ov-protected'))) return 'protected'; |
| 70 | // Someone watching or broadcasting: a playing <video>/<audio>, or a live camera/mic stream. |
| 71 | try { |
| 72 | for (const m of d.querySelectorAll('video, audio')) { |
| 73 | if (!m.paused && !m.ended) return 'media'; |
| 74 | const so = m.srcObject; |
| 75 | if (so && typeof so.getTracks === 'function' && so.getTracks().some((t) => t.readyState === 'live')) return 'capture'; |
| 76 | } |
| 77 | } catch { return 'media'; } |
| 78 | if (scope) return null; |
| 79 | try { if (typeof root.OVProtected === 'function' && root.OVProtected()) return 'protected'; } catch { return 'protected'; } |
| 80 | return !document.hidden && Date.now() - lastInput < IDLE_MS ? 'active' : null; |
| 81 | } |
| 82 | |
| 83 | const SCROLL_KEY = 'ov-release-scroll'; |
| 84 | const here = () => { try { const u = new URL(root.location.href); return u.pathname + u.search; } catch { return ''; } }; |
| 85 | function reload(why) { |
| 86 | record('reloaded', why); flush(); |
| 87 | try { root.sessionStorage.setItem(SCROLL_KEY, JSON.stringify({ path: here(), y: Math.round(root.scrollY || 0), at: Date.now() })); } catch { /* */ } |
| 88 | root.location.reload(); |
| 89 | } |
| 90 | try { |
| 91 | const saved = JSON.parse(root.sessionStorage.getItem(SCROLL_KEY) || 'null'); |
| 92 | root.sessionStorage.removeItem(SCROLL_KEY); |
| 93 | if (saved && saved.y > 0 && saved.path === here() && Date.now() - saved.at < 10 * 60 * 1000) { |
| 94 | const back = () => root.scrollTo(0, saved.y); |
| 95 | if (document.readyState === 'complete') back(); else root.addEventListener('load', () => root.setTimeout(back, 50), { once: true }); |
| 96 | } |
| 97 | } catch { /* */ } |
| 98 | let optional = false; |
| 99 | function applyOptional() { |
| 100 | if (!optional || mustReload || cfg.autoApply === false || !document.hidden) return; |
| 101 | const why = busy(); |
| 102 | if (why) { record('deferred', why, true); return; } |
| 103 | reload('hidden'); |
| 104 | } |
| 105 | function prompt() { |
| 106 | if (prompted) return; |
| 107 | prompted = true; |
| 108 | record('prompted', mustReload ? reloadWhy : 'optional'); |
| 109 | if (root.OpenVibeUI && typeof root.OpenVibeUI.toast === 'function') { |
| 110 | root.OpenVibeUI.toast('A new version of this page is available.', { type: 'info', title: 'Update ready', ttl: 0, action: { label: 'Reload', onClick: () => reload('user') } }); |
| 111 | } |
| 112 | } |
| 113 | function maybeReload() { |
| 114 | if (!mustReload) return; |
| 115 | const why = busy(); |
| 116 | if (why) record('deferred', why, true); else reload(reloadWhy); |
| 117 | } |
| 118 | function emit(name, release) { try { root.dispatchEvent(new root.CustomEvent(name, { detail: { release } })); } catch { /* */ } } |
| 119 | |
| 120 | function loadUpdate() { |
| 121 | if (root.OVReleaseUpdate) return Promise.resolve(root.OVReleaseUpdate); |
| 122 | return lib || (lib = new Promise((ok, no) => { |
| 123 | const sc = document.createElement('script'); |
| 124 | sc.src = updateUrl; sc.async = true; |
| 125 | sc.onload = () => (root.OVReleaseUpdate ? ok(root.OVReleaseUpdate) : no('script')); |
| 126 | sc.onerror = () => { lib = null; no('script'); }; |
| 127 | document.head.appendChild(sc); |
| 128 | })); |
| 129 | } |
| 130 | /** Without release-update.js: the release id, min_client_release and the window only. */ |
| 131 | function legacy(m, pg) { |
| 132 | const w = (Number(m.mixed_version_window_hours) || 0) * 3600e3; |
| 133 | if (m.min_client_release === m.release || pg < m.min_client_generation) return { action: 'reload', reason: 'required' }; |
| 134 | return w > 0 && Date.now() - Date.parse(m.released_at) > w ? { action: 'reload', reason: 'window' } : { action: 'prompt' }; |
| 135 | } |
| 136 | function commitRegions(next) { |
| 137 | if (waiting.length) waiting = root.OVReleaseUpdate.commit(waiting, next, busy, record); |
| 138 | return !waiting.length; |
| 139 | } |
| 140 | function adopt(next) { |
| 141 | base = next; current = next.release; pending = null; mustReload = false; |
| 142 | record('applied', kinds || 'server'); |
| 143 | flush(); |
| 144 | emit('ov:release-applied', next.release); |
| 145 | } |
| 146 | |
| 147 | async function decide(m) { |
| 148 | const who = acct; |
| 149 | let x = null; |
| 150 | if (m.components || m.contract_ranges) { try { x = await loadUpdate(); } catch { record('failed', 'script'); flush(); } } |
| 151 | const page = base || { release: current, client_generation: gen }; |
| 152 | let p = x ? x.plan(page, m, Date.now()) : legacy(m, page.client_generation); |
| 153 | if (p.action === 'in-place' && (cfg.inPlace === false || failedRelease === m.release)) p = x.plan({ release: current, contract_ranges: page.contract_ranges }, m, Date.now()); |
| 154 | if (p.action === 'none') return; |
| 155 | if (p.action !== 'in-place') { |
| 156 | if (p.action === 'reload') { mustReload = true; reloadWhy = p.reason; } else optional = true; |
| 157 | if (optional && document.hidden) later('apply', applyOptional, 30 * 1000); |
| 158 | prompt(); |
| 159 | maybeReload(); |
| 160 | return; |
| 161 | } |
| 162 | try { |
| 163 | kinds = Array.from(new Set(p.changed.map((c) => c.kind))).sort().join('+'); |
| 164 | // An account switch meanwhile (openvibe-auth-changed): fetch the regions again. |
| 165 | waiting = await x.apply(p, m, (s) => (who !== acct ? 'account' : busy(s)), record); pending = m; |
| 166 | if (who !== acct) { waiting = []; pending = null; return decide(m); } |
| 167 | if (!waiting.length) adopt(m); |
| 168 | } catch (why) { |
| 169 | waiting = []; pending = null; |
| 170 | record('failed', typeof why === 'string' ? why : 'content'); |
| 171 | failedRelease = m.release; |
| 172 | flush(); |
| 173 | await decide(m); |
| 174 | } |
| 175 | } |
| 176 | function consider(m) { |
| 177 | latest = m; |
| 178 | if (m.release === current || busyWith || (pending && pending.release === m.release)) return; |
| 179 | waiting = []; pending = null; // a newer release supersedes regions still waiting for the last one |
| 180 | busyWith = decide(m).catch(() => {}).then(() => { busyWith = null; }); |
| 181 | } |
| 182 | |
| 183 | async function check(force) { |
| 184 | const now = Date.now(); |
| 185 | if (!force && now - lastCheck < MIN_GAP_MS) return latest; |
| 186 | lastCheck = now; |
| 187 | let m; |
| 188 | try { |
| 189 | const r = await root.fetch(url, { cache: 'no-store', credentials: 'omit' }); |
| 190 | if (!r.ok) return latest; |
| 191 | m = await r.json(); |
| 192 | } catch { return latest; } |
| 193 | if (!m || typeof m.release !== 'string') return latest; |
| 194 | if (!current) current = m.release; |
| 195 | if (!base && m.release === current) base = m; |
| 196 | consider(m); |
| 197 | live(); |
| 198 | if (busyWith) await busyWith; |
| 199 | return m; |
| 200 | } |
| 201 | |
| 202 | // ── Release notifications (host.release.published, public, via OpenVibe.Events; WS-P task 9) ── |
| 203 | // One credential-less EventSource per tab. Closed after 5 min hidden, reopened with last_event_id; errors |
| 204 | // back off 30 s to 15 min, after 6 failures only polling is left; online reconnects at once. |
| 205 | const TOPIC = 'host.release.published'; |
| 206 | const HEX = /^[0-9a-f]{7,40}$/; |
| 207 | const rt = { state: 'off', service: null, url: null, events: 0, ignored: 0, checks: 0, failures: 0, lastId: null }; |
| 208 | let es = null; let queued = null; let again = false; let seen = []; |
| 209 | const t = {}; // timers: run (the jittered check), cool (30 s after it), retry, hide |
| 210 | const later = (k, f, ms) => { root.clearTimeout(t[k]); t[k] = root.setTimeout(() => { t[k] = null; f(); }, ms); }; |
| 211 | const same = (a, b) => !!a && !!b && (a === b || (HEX.test(a) && HEX.test(b) && (a.startsWith(b) || b.startsWith(a)))); |
| 212 | function eventsUrl() { |
| 213 | const v = 'eventsUrl' in cfg ? cfg.eventsUrl : meta && meta.hasAttribute('data-events') ? meta.getAttribute('data-events') : undefined; |
| 214 | if (v !== undefined) return v && v !== 'off' ? String(v) : null; |
| 215 | try { return new URL(root.location.href).protocol === 'https:' ? 'https://openvibe.events/realtime/stream' : null; } catch { return null; } |
| 216 | } |
| 217 | function live() { |
| 218 | if (stopped || es || rt.state !== 'off' || typeof root.EventSource !== 'function') return; |
| 219 | const svc = cfg.service || (meta && meta.getAttribute('data-service')) || (base && base.service) || (latest && latest.service); |
| 220 | const at = eventsUrl(); |
| 221 | if (!at || !/^[a-z][a-z0-9-]{1,39}$/.test(String(svc || ''))) return; |
| 222 | rt.service = svc; rt.url = at; |
| 223 | open(); |
| 224 | if (document.hidden) later('hide', hide, 5 * 60 * 1000); |
| 225 | } |
| 226 | function open() { |
| 227 | let u; |
| 228 | try { u = new URL(rt.url, root.location.href); u.searchParams.set('topics', TOPIC); if (rt.lastId) u.searchParams.set('last_event_id', rt.lastId); } catch { rt.state = 'failed'; return; } |
| 229 | rt.state = 'connecting'; |
| 230 | let s; |
| 231 | try { s = es = new root.EventSource(u.href); } catch { es = null; fail(); return; } |
| 232 | s.onopen = () => { if (es === s) { rt.state = 'open'; rt.failures = 0; } }; |
| 233 | s.onmessage = (e) => { if (es === s) heard(e); }; |
| 234 | s.onerror = () => { if (es === s) fail(); }; |
| 235 | s.addEventListener('gap', () => { if (es === s) queue(); }); // events were missed: check anyway |
| 236 | } |
| 237 | function close() { const s = es; es = null; if (s) try { s.close(); } catch { /* */ } } |
| 238 | function fail() { |
| 239 | close(); |
| 240 | if (rt.state === 'blocked') return; |
| 241 | if (++rt.failures >= 6) { rt.state = 'failed'; return; } |
| 242 | rt.state = 'backoff'; |
| 243 | later('retry', () => { if (document.hidden) rt.state = 'hidden'; else open(); }, Math.min(9e5, 3e4 * 2 ** (rt.failures - 1)) * (0.5 + Math.random() / 2)); |
| 244 | } |
| 245 | function hide() { if (document.hidden && es) { close(); rt.state = 'hidden'; } } |
| 246 | // A CSP whose connect-src leaves Events out: one refusal, then polling only. |
| 247 | document.addEventListener('securitypolicyviolation', (e) => { |
| 248 | try { if (!rt.url || String(e.blockedURI || '').indexOf(new URL(rt.url, root.location.href).origin) !== 0) return; } catch { return; } |
| 249 | close(); root.clearTimeout(t.retry); t.retry = null; rt.state = 'blocked'; |
| 250 | }); |
| 251 | function heard(e) { |
| 252 | let m; try { m = JSON.parse(e.data); } catch { return; } |
| 253 | const ev = m && m.event; const p = ev && ev.payload; |
| 254 | if (!p || ev.event_type !== TOPIC) return; |
| 255 | rt.lastId = e.lastEventId || rt.lastId; |
| 256 | if (p.service !== rt.service || typeof p.release !== 'string') return; |
| 257 | // Ids repeat across origins (Sites' placeholders): a named origin must be ours. |
| 258 | if (p.origin) { try { if (new URL(p.origin).origin !== root.location.origin) { rt.ignored++; return; } } catch { return; } } |
| 259 | rt.events++; |
| 260 | if (seen.includes(ev.event_id) || same(p.release, current) || same(p.release, latest && latest.release) || same(p.release, queued)) { rt.ignored++; return; } |
| 261 | seen = seen.concat(ev.event_id).slice(-20); |
| 262 | queued = p.release; |
| 263 | queue(); |
| 264 | } |
| 265 | /** One check after a 0-20 s jitter; what arrives meanwhile or 30 s after collapses into one more. */ |
| 266 | function queue() { |
| 267 | if (t.run) return; |
| 268 | if (t.cool) { again = true; return; } |
| 269 | later('run', () => { |
| 270 | const q = queued; |
| 271 | rt.checks++; |
| 272 | later('cool', () => { if (again) { again = false; queue(); } }, 30 * 1000); |
| 273 | if (!stopped) check(true).then(() => { if (queued === q) queued = null; }); |
| 274 | }, Math.floor(Math.random() * 20 * 1000)); |
| 275 | } |
| 276 | |
| 277 | let stopped = false; |
| 278 | root.addEventListener('focus', () => { if (!stopped) check(false); }); |
| 279 | root.addEventListener('online', () => { if (stopped) return; if (rt.state === 'failed' || rt.state === 'backoff') { root.clearTimeout(t.retry); rt.state = 'off'; rt.failures = 0; live(); } check(true); }); |
| 280 | root.addEventListener('openvibe-auth-changed', () => { acct++; if (!stopped && pending && !busyWith) { waiting = []; pending = null; consider(latest); } }); |
| 281 | root.addEventListener('pagehide', () => flush(true, true)); |
| 282 | document.addEventListener('visibilitychange', () => { |
| 283 | if (stopped) return; |
| 284 | if (document.hidden) { flush(); maybeReload(); if (optional) later('apply', applyOptional, 30 * 1000); if (es) later('hide', hide, 5 * 60 * 1000); return; } |
| 285 | root.clearTimeout(t.hide); t.hide = null; |
| 286 | root.clearTimeout(t.apply); t.apply = null; |
| 287 | if (rt.state === 'hidden') open(); |
| 288 | check(false); |
| 289 | }); |
| 290 | const tick = root.setInterval(() => { |
| 291 | if (stopped) return; |
| 292 | if (pending && commitRegions(pending)) adopt(pending); |
| 293 | check(false); maybeReload(); |
| 294 | if (Date.now() - lastBeat >= 3e5) flush(true); |
| 295 | }, 30 * 1000); |
| 296 | const poll = root.setInterval(() => { if (!stopped) check(true); }, 10 * 60 * 1000); |
| 297 | function stop() { |
| 298 | stopped = true; root.clearInterval(tick); root.clearInterval(poll); |
| 299 | close(); Object.keys(t).forEach((k) => { root.clearTimeout(t[k]); t[k] = null; }); rt.state = 'off'; |
| 300 | } |
| 301 | root.OVRelease = { |
| 302 | get current() { return current; }, |
| 303 | check: () => check(true), |
| 304 | state: () => ({ current, latest, mustReload, waiting: waiting.length, metrics: totals, realtime: { ...rt } }), |
| 305 | flush, |
| 306 | stop, |
| 307 | }; |
| 308 | // The page's own manifest (if still served). No /release.json and no meta tag: left alone. |
| 309 | lastCheck = Date.now(); |
| 310 | root.fetch(url, { cache: 'no-store', credentials: 'omit' }) |
| 311 | .then((r) => (r.ok ? r.json() : null)) |
| 312 | .then((m) => { |
| 313 | if (!m || typeof m.release !== 'string') { if (!current) stop(); return; } |
| 314 | if (!current) current = m.release; |
| 315 | if (m.release === current) base = m; |
| 316 | consider(m); |
| 317 | live(); |
| 318 | flush(true); |
| 319 | }) |
| 320 | .catch(() => { if (!current) stop(); }); |
| 321 | })(typeof window !== 'undefined' ? window : globalThis); |
| 322 |