dom.mjs raw

   1  // TinyJS Runtime — DOM Bridge
   2  // Provides Go-callable browser DOM operations.
   3  // Elements are tracked by integer handles.
   4  // Function names match Go signatures (PascalCase).
   5  
   6  import { setKey as _setEncKey, sealStr, openStr, hasKey as _encHasKey } from './aead.mjs';
   7  
   8  const _elements = new Map();
   9  let _nextId = 1; // Safe up to 2^53 in JS. No wrap needed.
  10  const _callbacks = new Map();
  11  let _nextCb = 1;
  12  
  13  // Bootstrap: map handle 0 to document.body (available after DOMContentLoaded).
  14  function ensureBody() {
  15    if (!_elements.has(0) && typeof document !== 'undefined' && document.body) {
  16      _elements.set(0, document.body);
  17    }
  18  }
  19  
  20  // Element creation and lookup.
  21  export function CreateElement(tag) {
  22    const el = document.createElement(tag);
  23    const id = _nextId++;
  24    _elements.set(id, el);
  25    return id;
  26  }
  27  
  28  export function CreateTextNode(text) {
  29    const node = document.createTextNode(text);
  30    const id = _nextId++;
  31    _elements.set(id, node);
  32    return id;
  33  }
  34  
  35  export function GetElementById(id) {
  36    const el = document.getElementById(id);
  37    if (!el) return 0;
  38    const handle = _nextId++;
  39    _elements.set(handle, el);
  40    return handle;
  41  }
  42  
  43  export function QuerySelector(sel) {
  44    const el = document.querySelector(sel);
  45    if (!el) return 0;
  46    const handle = _nextId++;
  47    _elements.set(handle, el);
  48    return handle;
  49  }
  50  
  51  export function Body() {
  52    ensureBody();
  53    return 0;
  54  }
  55  
  56  // Tree manipulation.
  57  export function AppendChild(parentId, childId) {
  58    const parent = _elements.get(parentId);
  59    const child = _elements.get(childId);
  60    if (parent && child) parent.appendChild(child);
  61  }
  62  
  63  export function RemoveChild(parentId, childId) {
  64    const parent = _elements.get(parentId);
  65    const child = _elements.get(childId);
  66    if (parent && child) parent.removeChild(child);
  67  }
  68  
  69  export function Remove(elId) {
  70    const el = _elements.get(elId);
  71    if (el && el.parentNode) el.parentNode.removeChild(el);
  72  }
  73  
  74  export function FirstChild(parentId) {
  75    const parent = _elements.get(parentId);
  76    if (parent && parent.firstChild) {
  77      const id = _nextId++;
  78      _elements.set(id, parent.firstChild);
  79      return id;
  80    }
  81    return 0;
  82  }
  83  
  84  export function FirstElementChild(parentId) {
  85    const parent = _elements.get(parentId);
  86    if (parent && parent.firstElementChild) {
  87      const id = _nextId++;
  88      _elements.set(id, parent.firstElementChild);
  89      return id;
  90    }
  91    return 0;
  92  }
  93  
  94  export function InsertBefore(parentId, newId, refId) {
  95    const parent = _elements.get(parentId);
  96    const newEl = _elements.get(newId);
  97    const ref = refId >= 0 ? _elements.get(refId) : null;
  98    if (parent && newEl) parent.insertBefore(newEl, ref);
  99  }
 100  
 101  export function ReplaceChild(parentId, newId, oldId) {
 102    const parent = _elements.get(parentId);
 103    const newEl = _elements.get(newId);
 104    const oldEl = _elements.get(oldId);
 105    if (parent && newEl && oldEl) parent.replaceChild(newEl, oldEl);
 106  }
 107  
 108  // Properties and attributes.
 109  export function SetAttribute(elId, name, value) {
 110    const el = _elements.get(elId);
 111    if (el && el.setAttribute) el.setAttribute(name, value);
 112  }
 113  
 114  export function RemoveAttribute(elId, name) {
 115    const el = _elements.get(elId);
 116    if (el && el.removeAttribute) el.removeAttribute(name);
 117  }
 118  
 119  export function SetTextContent(elId, text) {
 120    const el = _elements.get(elId);
 121    if (el) el.textContent = text;
 122  }
 123  
 124  export function SetInnerHTML(elId, html) {
 125    const el = _elements.get(elId);
 126    if (el && 'innerHTML' in el) el.innerHTML = html;
 127  }
 128  
 129  export function SetStyle(elId, prop, value) {
 130    const el = _elements.get(elId);
 131    if (el && el.style) el.style[prop] = value;
 132  }
 133  
 134  export function Focus(elId) {
 135    const el = _elements.get(elId);
 136    if (el && el.focus) el.focus();
 137  }
 138  
 139  export function SetProperty(elId, prop, value) {
 140    const el = _elements.get(elId);
 141    if (el) el[prop] = value;
 142  }
 143  
 144  export function GetProperty(elId, prop) {
 145    const el = _elements.get(elId);
 146    if (el) return String(el[prop] ?? '');
 147    return '';
 148  }
 149  
 150  export function AddClass(elId, cls) {
 151    const el = _elements.get(elId);
 152    if (el && el.classList) el.classList.add(cls);
 153  }
 154  
 155  export function RemoveClass(elId, cls) {
 156    const el = _elements.get(elId);
 157    if (el && el.classList) el.classList.remove(cls);
 158  }
 159  
 160  // Events.
 161  export function AddEventListener(elId, event, callbackId) {
 162    const el = _elements.get(elId);
 163    const cb = _callbacks.get(callbackId);
 164    if (el && cb) el.addEventListener(event, cb);
 165  }
 166  
 167  // AddEnterKeyListener fires callback when Enter is pressed on the element.
 168  export function AddEnterKeyListener(elId, callbackId) {
 169    const el = _elements.get(elId);
 170    const cb = _callbacks.get(callbackId);
 171    if (el && cb) el.addEventListener('keydown', function(e) {
 172      if (e.key === 'Enter') { e.preventDefault(); cb(); }
 173    });
 174  }
 175  
 176  // Like AddEventListener but only fires when e.target === e.currentTarget.
 177  // Useful for backdrop click-to-close without catching child clicks.
 178  export function AddSelfEventListener(elId, event, callbackId) {
 179    const el = _elements.get(elId);
 180    const cb = _callbacks.get(callbackId);
 181    if (el && cb) el.addEventListener(event, function(e) {
 182      if (e.target === e.currentTarget) cb();
 183    });
 184  }
 185  
 186  export function RemoveEventListener(elId, event, callbackId) {
 187    const el = _elements.get(elId);
 188    const cb = _callbacks.get(callbackId);
 189    if (el && cb) el.removeEventListener(event, cb);
 190  }
 191  
 192  // Register a Go function as a JS callback. Returns callback ID.
 193  export function RegisterCallback(fn) {
 194    const id = _nextCb++;
 195    _callbacks.set(id, fn);
 196    return id;
 197  }
 198  
 199  export function ReleaseCallback(id) {
 200    _callbacks.delete(id);
 201  }
 202  
 203  // Scheduling.
 204  export function RequestAnimationFrame(fn) {
 205    if (typeof window !== 'undefined') {
 206      window.requestAnimationFrame(fn);
 207    } else {
 208      setTimeout(fn, 16);
 209    }
 210  }
 211  
 212  export function SetTimeout(fn, ms) {
 213    return setTimeout(fn, ms);
 214  }
 215  
 216  export function SetInterval(fn, ms) {
 217    return setInterval(fn, ms);
 218  }
 219  
 220  export function ClearInterval(id) {
 221    clearInterval(id);
 222  }
 223  
 224  export function ClearTimeout(id) {
 225    clearTimeout(id);
 226  }
 227  
 228  // Cleanup: release element handle.
 229  export function ReleaseElement(id) {
 230    _elements.delete(id);
 231  }
 232  
 233  // Fetch a URL as text, call fn with result.
 234  export function FetchText(url, fn) {
 235    fetch(url).then(r => r.text()).then(t => { if (fn) fn(t); });
 236  }
 237  
 238  // Navigation.
 239  export function NextSibling(elId) {
 240    const el = _elements.get(elId);
 241    if (el && el.nextSibling) {
 242      const id = _nextId++;
 243      _elements.set(id, el.nextSibling);
 244      return id;
 245    }
 246    return 0;
 247  }
 248  
 249  // Console / browser.
 250  export function ConsoleLog(msg) {
 251    console.log(msg);
 252  }
 253  
 254  export function PrefersDark() {
 255    if (typeof window !== 'undefined' && window.matchMedia) {
 256      return window.matchMedia('(prefers-color-scheme: dark)').matches;
 257    }
 258    return false;
 259  }
 260  
 261  export function Confirm(msg) {
 262    if (typeof window !== 'undefined') return window.confirm(msg);
 263    return false;
 264  }
 265  
 266  // Service Worker communication — with direct WebSocket fallback for insecure contexts.
 267  let _swMessageHandler = null;
 268  let _swQueue = [];
 269  const _hasSW = typeof navigator !== 'undefined' && 'serviceWorker' in navigator &&
 270    (typeof self !== 'undefined' && self.isSecureContext);
 271  
 272  // ========================================================================
 273  // Direct WebSocket relay fallback (active when SW unavailable)
 274  // Handles PROXY, REQ, CLOSE, EVENT messages using NIP-01 WebSocket protocol.
 275  // ========================================================================
 276  
 277  let _wsConns = {};       // url -> WebSocket
 278  let _wsSubs = {};        // subID -> { filter: string, relays: Set<url> }
 279  let _wsSeen = {};        // subID -> { eventID: true }
 280  let _wsEoseCount = {};   // subID -> count of relays that sent EOSE
 281  let _wsWriteRelays = []; // relay URLs for EVENT publishing
 282  
 283  function _diag(msg) {
 284    console.log(msg);
 285  }
 286  let _wsPendingPub = {};  // url -> [json strings] queued while CONNECTING
 287  
 288  function _wsSend(handler, msg) {
 289    if (handler) handler(msg);
 290  }
 291  
 292  // Count an EOSE (or CLOSED) for a sub. Fire EOSE to the app on the FIRST
 293  // relay response — matches the SW behaviour and prevents auth-requiring
 294  // relays from blocking the threshold.
 295  let _wsEoseFired = {};
 296  function _wsCountEose(sid) {
 297    if (!_wsEoseCount[sid]) _wsEoseCount[sid] = 0;
 298    _wsEoseCount[sid]++;
 299    if (!_wsEoseFired[sid]) {
 300      _wsEoseFired[sid] = true;
 301      _wsSend(_swMessageHandler, '["EOSE",' + JSON.stringify(sid) + ']');
 302    }
 303  }
 304  
 305  function _wsEnsure(url) {
 306    if (_wsConns[url]) return _wsConns[url];
 307    var ws;
 308    try { ws = new WebSocket(url); } catch(e) { return null; }
 309    _wsConns[url] = ws;
 310    ws.onopen = function() {
 311      _diag('WS OPEN ' + url);
 312      // Replay active subs for this relay.
 313      for (var sid in _wsSubs) {
 314        var sub = _wsSubs[sid];
 315        if (sub.relays.has(url)) {
 316          ws.send(JSON.stringify(["REQ", sid, JSON.parse(sub.filter)]));
 317        }
 318      }
 319      // Flush queued publishes.
 320      var pending = _wsPendingPub[url];
 321      if (pending) {
 322        _diag('FLUSH ' + pending.length + ' queued to ' + url);
 323        delete _wsPendingPub[url];
 324        for (var p = 0; p < pending.length; p++) ws.send(pending[p]);
 325      }
 326    };
 327    ws.onmessage = function(e) {
 328      var msg;
 329      try { msg = JSON.parse(e.data); } catch(ex) { return; }
 330      if (!Array.isArray(msg)) return;
 331      if (msg[0] === "EVENT" && msg.length >= 3) {
 332        var sid = msg[1], ev = msg[2];
 333        if (!_wsSeen[sid]) _wsSeen[sid] = {};
 334        if (ev.id && _wsSeen[sid][ev.id]) return;
 335        if (ev.id) _wsSeen[sid][ev.id] = true;
 336        _wsSend(_swMessageHandler, '["EVENT",' + JSON.stringify(sid) + ',' + JSON.stringify(ev) + ']');
 337        if (ev.id) _wsSend(_swMessageHandler, '["SEEN_ON",' + JSON.stringify(ev.id) + ',' + JSON.stringify(url) + ']');
 338      } else if (msg[0] === "EOSE" && msg.length >= 2) {
 339        _wsCountEose(msg[1]);
 340      } else if (msg[0] === "CLOSED" && msg.length >= 2) {
 341        _diag('CLOSED ' + url + ' sub=' + msg[1] + ' reason=' + (msg[2] || ''));
 342        _wsCountEose(msg[1]);
 343      } else if (msg[0] === "OK" && msg.length >= 3) {
 344        var okId = msg[1], okSuccess = msg[2], okMsg = msg[3] || '';
 345        if (okSuccess) { _diag('OK ' + url + ' id=' + (okId && okId.substring(0,8))); }
 346        else { _diag('REJECTED ' + url + ' ' + (okId && okId.substring(0,8)) + ': ' + okMsg); }
 347      } else if (msg[0] === "NOTICE") {
 348        _diag('NOTICE ' + url + ': ' + msg[1]);
 349      }
 350    };
 351    ws.onclose = function() {
 352      _diag('WS CLOSE ' + url);
 353      delete _wsConns[url];
 354      // Trigger EOSE for subs that included this relay (connection died before EOSE).
 355      for (var sid in _wsSubs) {
 356        if (_wsSubs[sid].relays.has(url)) _wsCountEose(sid);
 357      }
 358    };
 359    ws.onerror = function() { ws.close(); };
 360    return ws;
 361  }
 362  
 363  function _wsCloseSub(sid) {
 364    var sub = _wsSubs[sid];
 365    if (!sub) return;
 366    sub.relays.forEach(function(url) {
 367      var ws = _wsConns[url];
 368      if (ws && ws.readyState === WebSocket.OPEN) {
 369        try { ws.send(JSON.stringify(["CLOSE", sid])); } catch(e) {}
 370      }
 371    });
 372    delete _wsSubs[sid];
 373    delete _wsSeen[sid];
 374    delete _wsEoseCount[sid];
 375    delete _wsEoseFired[sid];
 376  }
 377  
 378  function _wsHandleMsg(raw) {
 379    var arr;
 380    try { arr = JSON.parse(raw); } catch(e) { return; }
 381    if (!Array.isArray(arr)) return;
 382    var cmd = arr[0];
 383    if (cmd === "SET_PUBKEY") {
 384      // no-op for direct WS
 385    } else if (cmd === "SET_WRITE_RELAYS") {
 386      _wsWriteRelays = arr[1] || [];
 387      for (var i = 0; i < _wsWriteRelays.length; i++) _wsEnsure(_wsWriteRelays[i]);
 388    } else if (cmd === "PROXY") {
 389      var sid = arr[1], filter = arr[2], relays = arr[3] || [];
 390      _diag('PROXY ' + sid + ' relays=' + relays.length);
 391      _wsCloseSub(sid);
 392      _wsSubs[sid] = { filter: JSON.stringify(filter), relays: new Set(relays) };
 393      _wsSeen[sid] = {};
 394      _wsEoseCount[sid] = 0;
 395      for (var j = 0; j < relays.length; j++) {
 396        var url = relays[j];
 397        var ws = _wsConns[url];
 398        // Drop dead connections (CLOSING/CLOSED) so _wsEnsure creates a fresh one.
 399        if (ws && ws.readyState > 1) {
 400          _diag('PROXY ' + sid + ' drop dead ws ' + url + ' state=' + ws.readyState);
 401          delete _wsConns[url];
 402          ws = null;
 403        }
 404        ws = _wsEnsure(url);
 405        if (ws && ws.readyState === WebSocket.OPEN) {
 406          _diag('PROXY ' + sid + ' REQ ' + url);
 407          ws.send(JSON.stringify(["REQ", sid, filter]));
 408        } else {
 409          _diag('PROXY ' + sid + ' DEFER ' + url + ' state=' + (ws ? ws.readyState : 'null'));
 410        }
 411      }
 412    } else if (cmd === "REQ") {
 413      var sid2 = arr[1], filter2 = arr[2];
 414      var url = _wsWriteRelays[0];
 415      if (url) {
 416        _wsCloseSub(sid2);
 417        _wsSubs[sid2] = { filter: JSON.stringify(filter2), relays: new Set([url]) };
 418        _wsSeen[sid2] = {};
 419        _wsEoseCount[sid2] = 0;
 420        var ws2 = _wsEnsure(url);
 421        if (ws2 && ws2.readyState === WebSocket.OPEN) {
 422          ws2.send(JSON.stringify(["REQ", sid2, filter2]));
 423        }
 424      }
 425    } else if (cmd === "CLOSE") {
 426      _wsCloseSub(arr[1]);
 427    } else if (cmd === "PUBLISH_TO") {
 428      var ev2 = arr[1], relays2 = arr[2] || [];
 429      _diag('PUBLISH_TO kind=' + (ev2 && ev2.kind) + ' id=' + (ev2 && ev2.id && ev2.id.substring(0,8)) + ' relays=' + relays2.length);
 430      var data2 = JSON.stringify(["EVENT", ev2]);
 431      for (var m = 0; m < relays2.length; m++) {
 432        var ws4 = _wsEnsure(relays2[m]);
 433        if (ws4 && ws4.readyState === WebSocket.OPEN) { _diag('SEND ' + relays2[m]); ws4.send(data2); }
 434        else if (ws4) { _diag('QUEUE ' + relays2[m] + ' state=' + ws4.readyState); if (!_wsPendingPub[relays2[m]]) _wsPendingPub[relays2[m]] = []; _wsPendingPub[relays2[m]].push(data2); }
 435        else { _diag('FAIL no WS ' + relays2[m]); }
 436      }
 437    } else if (cmd === "EVENT") {
 438      _diag('EVENT kind=' + (arr[1] && arr[1].kind) + ' writeRelays=' + _wsWriteRelays.length);
 439      var ev = arr[1], data = JSON.stringify(["EVENT", ev]);
 440      for (var k = 0; k < _wsWriteRelays.length; k++) {
 441        var ws3 = _wsEnsure(_wsWriteRelays[k]);
 442        if (ws3 && ws3.readyState === WebSocket.OPEN) { _diag('SEND ' + _wsWriteRelays[k]); ws3.send(data); }
 443        else if (ws3) { var u3 = _wsWriteRelays[k]; _diag('QUEUE ' + u3 + ' state=' + ws3.readyState); if (!_wsPendingPub[u3]) _wsPendingPub[u3] = []; _wsPendingPub[u3].push(data); }
 444        else { _diag('FAIL no WS ' + _wsWriteRelays[k]); }
 445      }
 446    } else if (cmd === "CLEAR_KEY") {
 447      for (var sid3 in _wsSubs) _wsCloseSub(sid3);
 448      for (var u in _wsConns) { try { _wsConns[u].close(); } catch(e) {} }
 449      _wsConns = {};
 450      _wsWriteRelays = [];
 451      _wsPendingPub = {};
 452    }
 453  }
 454  
 455  // ========================================================================
 456  
 457  // All PostToSW messages, in order. Used to replay to a new SW on controllerchange.
 458  var _swSent = [];
 459  
 460  export function PostToSW(msg) {
 461    if (!_hasSW) {
 462      _wsHandleMsg('' + msg);
 463      return;
 464    }
 465    var s = '' + msg;
 466    _swSent.push(s);
 467    if (navigator.serviceWorker.controller) {
 468      navigator.serviceWorker.controller.postMessage(s);
 469    } else {
 470      _swQueue.push(s);
 471    }
 472  }
 473  
 474  function _sendToSW() {
 475    if (!navigator.serviceWorker.controller) return;
 476    // Prefer queued messages (never sent). If queue is empty, replay all sent
 477    // messages (they went to an old SW that was replaced).
 478    var msgs;
 479    if (_swQueue.length > 0) {
 480      msgs = _swQueue;
 481      _swQueue = [];
 482    } else if (_swSent.length > 0) {
 483      msgs = _swSent.slice();
 484      _swSent = [];
 485    } else {
 486      return;
 487    }
 488    for (var i = 0; i < msgs.length; i++) {
 489      navigator.serviceWorker.controller.postMessage(msgs[i]);
 490    }
 491  }
 492  
 493  if (_hasSW) {
 494    navigator.serviceWorker.addEventListener('controllerchange', _sendToSW);
 495    navigator.serviceWorker.ready.then(function(reg) {
 496      if (!navigator.serviceWorker.controller && reg.active) {
 497        reg.active.postMessage('CLAIM');
 498      }
 499      setTimeout(_sendToSW, 100);
 500    });
 501  }
 502  
 503  export function OnSWMessage(fn) {
 504    _swMessageHandler = fn;
 505    if (_hasSW) {
 506      navigator.serviceWorker.addEventListener('message', function(e) {
 507        var d = e.data;
 508        if (typeof d !== 'string') {
 509          // Backward compat: old SW may send Slice objects via structured clone.
 510          if (d && d.$array && typeof d.$length === 'number') {
 511            var buf = new Uint8Array(d.$length);
 512            var off = d.$offset || 0;
 513            for (var i = 0; i < d.$length; i++) buf[i] = d.$array[off + i];
 514            d = new TextDecoder().decode(buf);
 515          } else {
 516            return; // Not a string and not a Slice — skip.
 517          }
 518        }
 519        if (_swMessageHandler) _swMessageHandler(d);
 520      });
 521    }
 522  }
 523  
 524  export function ReadClipboard(fn) {
 525    if (navigator.clipboard && navigator.clipboard.readText) {
 526      navigator.clipboard.readText().then(function(t) { fn(t || ''); }).catch(function() {
 527        var r = prompt('Paste nsec:');
 528        fn(r || '');
 529      });
 530    } else {
 531      var r = prompt('Paste nsec:');
 532      fn(r || '');
 533    }
 534  }
 535  
 536  // IDB encryption key (set once per session).
 537  export function IDBSetEncKey(hexKey) {
 538    const hex = '' + hexKey;
 539    const key = new Uint8Array(32);
 540    for (let i = 0; i < 32; i++) key[i] = parseInt(hex.substr(i * 2, 2), 16);
 541    _setEncKey(key);
 542  }
 543  
 544  // IndexedDB (simple key-value).
 545  let _idb = null;
 546  const _idbName = 'smesh-kv';
 547  const _idbStores = ['profiles', 'events', 'cache'];
 548  
 549  function _openIDB() {
 550    return new Promise(function(resolve, reject) {
 551      if (_idb) { resolve(_idb); return; }
 552      const req = indexedDB.open(_idbName, 1);
 553      req.onupgradeneeded = function(e) {
 554        const db = e.target.result;
 555        for (const s of _idbStores) {
 556          if (!db.objectStoreNames.contains(s)) db.createObjectStore(s);
 557        }
 558      };
 559      req.onsuccess = function(e) { _idb = e.target.result; resolve(_idb); };
 560      req.onerror = function() { reject(req.error); };
 561    });
 562  }
 563  
 564  export function IDBGet(store, key, fn) {
 565    _openIDB().then(function(db) {
 566      const tx = db.transaction(store, 'readonly');
 567      const req = tx.objectStore(store).get(key);
 568      req.onsuccess = function() {
 569        const raw = req.result ?? '';
 570        fn(_encHasKey() ? openStr(raw) : raw);
 571      };
 572      req.onerror = function() { fn(''); };
 573    }).catch(function() { fn(''); });
 574  }
 575  
 576  export function IDBPut(store, key, value) {
 577    _openIDB().then(function(db) {
 578      const tx = db.transaction(store, 'readwrite');
 579      const v = _encHasKey() ? sealStr('' + value) : value;
 580      tx.objectStore(store).put(v, key);
 581    });
 582  }
 583  
 584  export function IDBGetAll(store, fn, done) {
 585    _openIDB().then(function(db) {
 586      const tx = db.transaction(store, 'readonly');
 587      const req = tx.objectStore(store).openCursor();
 588      req.onsuccess = function(e) {
 589        const cursor = e.target.result;
 590        if (cursor) {
 591          const val = _encHasKey() ? openStr(String(cursor.value)) : String(cursor.value);
 592          fn(String(cursor.key), val);
 593          cursor.continue();
 594        } else {
 595          if (done) done();
 596        }
 597      };
 598      req.onerror = function() { if (done) done(); };
 599    }).catch(function() { if (done) done(); });
 600  }
 601  
 602  // Fetch relay NIP-11 info.
 603  export function FetchRelayInfo(url, fn) {
 604    fetch(url, { headers: { 'Accept': 'application/nostr+json' } })
 605      .then(function(r) { return r.text(); })
 606      .then(function(t) { if (fn) fn(t); })
 607      .catch(function() { if (fn) fn(''); });
 608  }
 609  
 610  // History / location.
 611  export function PushState(path) {
 612    if (typeof history !== 'undefined') history.pushState(null, '', path);
 613  }
 614  
 615  export function ReplaceState(path) {
 616    if (typeof history !== 'undefined') history.replaceState(null, '', path);
 617  }
 618  
 619  export function Back() {
 620    if (typeof history !== 'undefined') history.back();
 621  }
 622  
 623  export function LocationReload() {
 624    if (typeof location !== 'undefined') location.reload();
 625  }
 626  
 627  export function HardRefresh() {
 628    var p = [];
 629    if (typeof caches !== 'undefined') {
 630      p.push(caches.keys().then(function(keys) {
 631        return Promise.all(keys.map(function(k) { return caches.delete(k); }));
 632      }));
 633    }
 634    if (typeof navigator !== 'undefined' && navigator.serviceWorker) {
 635      p.push(navigator.serviceWorker.getRegistrations().then(function(regs) {
 636        regs.forEach(function(r) { r.unregister(); });
 637      }));
 638    }
 639    Promise.all(p).then(function() { location.reload(); });
 640  }
 641  
 642  export function ClearStoragePrefix(prefix) {
 643    if (typeof localStorage === 'undefined') return;
 644    var keys = [];
 645    for (var i = 0; i < localStorage.length; i++) {
 646      var k = localStorage.key(i);
 647      if (k && k.indexOf(prefix) === 0) keys.push(k);
 648    }
 649    for (var j = 0; j < keys.length; j++) localStorage.removeItem(keys[j]);
 650  }
 651  
 652  export function TimezoneOffsetSeconds() {
 653    // getTimezoneOffset() returns minutes, positive west of UTC.
 654    // We return seconds to ADD to UTC to get local time.
 655    return -(new Date().getTimezoneOffset()) * 60;
 656  }
 657  
 658  export function GetPath() {
 659    if (typeof location !== 'undefined') return location.pathname;
 660    return '/';
 661  }
 662  
 663  export function Hostname() {
 664    if (typeof location !== 'undefined') return location.hostname;
 665    return '';
 666  }
 667  
 668  export function Port() {
 669    if (typeof location !== 'undefined') return location.port;
 670    return '';
 671  }
 672  
 673  export function UserAgent() {
 674    if (typeof navigator !== 'undefined') return navigator.userAgent;
 675    return '';
 676  }
 677  
 678  export function OnPopState(fn) {
 679    if (typeof window !== 'undefined') {
 680      window.addEventListener('popstate', function() {
 681        if (fn) fn(location.pathname);
 682      });
 683    }
 684  }
 685  
 686  // DownloadText triggers a file download with the given text content.
 687  export function DownloadText(filename, content, mimeType) {
 688    const blob = new Blob([content], { type: mimeType || 'text/plain' });
 689    const url = URL.createObjectURL(blob);
 690    const a = document.createElement('a');
 691    a.href = url;
 692    a.download = filename;
 693    document.body.appendChild(a);
 694    a.click();
 695    setTimeout(function() { a.remove(); URL.revokeObjectURL(url); }, 1000);
 696  }
 697  
 698  // PickFileText opens a file picker and reads the selected file as text.
 699  export function PickFileText(accept, fn) {
 700    const input = document.createElement('input');
 701    input.type = 'file';
 702    if (accept) input.accept = accept;
 703    input.style.display = 'none';
 704    input.addEventListener('change', function() {
 705      if (!input.files || !input.files[0]) { fn(''); return; }
 706      const reader = new FileReader();
 707      reader.onload = function() { fn(reader.result || ''); };
 708      reader.onerror = function() { fn(''); };
 709      reader.readAsText(input.files[0]);
 710    });
 711    document.body.appendChild(input);
 712    input.click();
 713    // Clean up after a delay (even if user cancels).
 714    setTimeout(function() { input.remove(); }, 60000);
 715  }
 716  
 717  // NowSeconds returns current Unix time in seconds.
 718  export function NowSeconds() {
 719    return BigInt(Math.floor(Date.now() / 1000));
 720  }
 721  
 722  // OnPullRefresh registers a handler that fires when the user pulls to refresh.
 723  // Handles both mouse wheel (accumulated up-scroll at top) and touch gestures.
 724  export function OnPullRefresh(elId, indicatorId, fn) {
 725    const el = _elements.get(elId);
 726    const ind = _elements.get(indicatorId);
 727    if (!el) return;
 728  
 729    var pullAccum = 0;
 730    var pullTimer = 0;
 731    var threshold = 200;
 732    var resetMs = 400;
 733  
 734    el.addEventListener('wheel', function(e) {
 735      if (el.scrollTop > 0 || e.deltaY >= 0) {
 736        pullAccum = 0;
 737        if (ind) ind.style.display = 'none';
 738        return;
 739      }
 740      pullAccum += Math.abs(e.deltaY);
 741      clearTimeout(pullTimer);
 742      pullTimer = setTimeout(function() {
 743        pullAccum = 0;
 744        if (ind) ind.style.display = 'none';
 745      }, resetMs);
 746      if (ind && pullAccum > 40) ind.style.display = 'flex';
 747      if (pullAccum >= threshold) {
 748        pullAccum = 0;
 749        fn();
 750      }
 751    }, { passive: true });
 752  
 753    var touchStartY = 0;
 754    el.addEventListener('touchstart', function(e) {
 755      if (el.scrollTop === 0 && e.touches.length === 1) {
 756        touchStartY = e.touches[0].clientY;
 757      }
 758    }, { passive: true });
 759    el.addEventListener('touchmove', function(e) {
 760      if (el.scrollTop > 0 || touchStartY === 0) return;
 761      var dy = e.touches[0].clientY - touchStartY;
 762      if (dy > 20 && ind) ind.style.display = 'flex';
 763      if (dy > 80) {
 764        touchStartY = 0;
 765        fn();
 766      }
 767    }, { passive: true });
 768    el.addEventListener('touchend', function() {
 769      touchStartY = 0;
 770      if (pullAccum < threshold && ind) ind.style.display = 'none';
 771    }, { passive: true });
 772  }
 773  
 774  // Get raw element (for advanced use within JS runtime only).
 775  export function getRawElement(id) {
 776    return _elements.get(id);
 777  }
 778