AkurAI Build
Menu

Public repository / master

AkurAI-Framework / crates/ui/src/host.js

Latest change fee5b6c2cde89fa6bf8dc51068af7336b56a88b8 - akurai-ui: platform::open_url (hand a URL, e.g. a native shell's scheme, to the window) by Ólafur Búi Ólafsson

// akurai-ui browser host — the DOM side of the Rust UI runtime.
// Loads a wasm app built with `akurai_ui::app!`, owns the node handle table,
// and forwards events, fetches and timers back into Rust. No dependencies.
//
// Usage: <script type="module">import { boot } from "./akurai-ui.js"; boot("./app.wasm");</script>

const enc = new TextEncoder();
const dec = new TextDecoder();
const NONE = 0xffffffff;

export async function boot(url) {
  const nodes = new Map([[1, document], [2, document.body], [3, window]]);
  const events = new Map();
  const streams = new Map();
  let recorder = null; // the one MediaRecorder, while the app is listening
  let nextNode = 100;
  let nextEvent = 1;
  let nextStream = 1;
  let parked = new Uint8Array(0);
  let wasm;

  const mem = () => new Uint8Array(wasm.exports.memory.buffer);
  const str = (p, l) => dec.decode(mem().subarray(p, p + l));
  const park = (s) => {
    if (s === null || s === undefined) return NONE;
    parked = enc.encode(String(s));
    return parked.length;
  };
  const handle = (n) => {
    if (!n) return 0;
    if (n.__ak) return n.__ak;
    const h = nextNode++;
    n.__ak = h;
    nodes.set(h, n);
    return h;
  };
  const release = (n) => {
    if (n.__ak) nodes.delete(n.__ak);
    for (const c of n.childNodes) release(c);
  };
  const toRust = (s) => {
    const bytes = enc.encode(s);
    const ptr = wasm.exports.akurai_alloc(bytes.length);
    mem().set(bytes, ptr);
    return [ptr, bytes.length];
  };
  const FOCUSABLE =
    'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';

  const akurai = {
    ak_create_element: (p, l) => handle(document.createElement(str(p, l))),
    ak_create_element_ns: (np, nl, p, l) => handle(document.createElementNS(str(np, nl), str(p, l))),
    ak_create_text: (p, l) => handle(document.createTextNode(str(p, l))),
    ak_create_comment: (p, l) => handle(document.createComment(str(p, l))),
    ak_set_attr: (n, np, nl, vp, vl) => nodes.get(n)?.setAttribute(str(np, nl), str(vp, vl)),
    ak_remove_attr: (n, np, nl) => nodes.get(n)?.removeAttribute(str(np, nl)),
    ak_set_prop_str: (n, np, nl, vp, vl) => {
      const el = nodes.get(n), k = str(np, nl), v = str(vp, vl);
      if (el && el[k] !== v) el[k] = v;
    },
    ak_set_prop_bool: (n, np, nl, v) => { const el = nodes.get(n); if (el) el[str(np, nl)] = !!v; },
    ak_get_prop_str: (n, np, nl) => park(nodes.get(n)?.[str(np, nl)] ?? ""),
    ak_insert_before: (p, c, b) => nodes.get(p)?.insertBefore(nodes.get(c), b ? nodes.get(b) : null),
    ak_remove: (n) => { const el = nodes.get(n); if (el) { el.remove(); release(el); } },
    ak_clear_between: (s, e) => {
      const start = nodes.get(s), end = nodes.get(e);
      if (!start || !end) return;
      while (start.nextSibling && start.nextSibling !== end) {
        const x = start.nextSibling;
        x.remove();
        release(x);
      }
    },
    ak_move_range: (s, e, b) => {
      const start = nodes.get(s), end = nodes.get(e), before = nodes.get(b);
      if (!start || !end || !before) return;
      const parent = before.parentNode;
      let cur = start;
      for (;;) {
        const next = cur.nextSibling;
        parent.insertBefore(cur, before);
        if (cur === end || !next) break;
        cur = next;
      }
    },
    ak_parent: (n) => handle(nodes.get(n)?.parentNode),
    ak_next_sibling: (n) => handle(nodes.get(n)?.nextSibling),
    ak_listen: (n, ep, el, cb) => {
      nodes.get(n)?.addEventListener(str(ep, el), (e) => {
        const id = nextEvent++;
        events.set(id, e);
        try { wasm.exports.akurai_dispatch(cb, id); } finally { events.delete(id); }
      });
    },
    ak_get_by_id: (p, l) => handle(document.getElementById(str(p, l))),
    ak_focus: (n) => nodes.get(n)?.focus(),
    ak_focus_first: (n) => {
      const el = nodes.get(n)?.querySelector?.(FOCUSABLE);
      if (!el) return 0;
      el.focus();
      return 1;
    },
    ak_active_element: () => handle(document.activeElement),
    ak_contains: (a, n) => (nodes.get(a)?.contains?.(nodes.get(n)) ? 1 : 0),
    ak_event_value: (ev) => park(events.get(ev)?.target?.value ?? ""),
    ak_event_checked: (ev) => (events.get(ev)?.target?.checked ? 1 : 0),
    ak_event_key: (ev) => park(events.get(ev)?.key ?? ""),
    ak_event_target: (ev) => handle(events.get(ev)?.target),
    ak_event_shift: (ev) => (events.get(ev)?.shiftKey ? 1 : 0),
    ak_prevent_default: (ev) => events.get(ev)?.preventDefault(),
    ak_stop_propagation: (ev) => events.get(ev)?.stopPropagation(),
    ak_fetch: (mp, ml, up, ul, bp, bl, hasBody, cb) => {
      const init = { method: str(mp, ml), credentials: "same-origin", headers: {} };
      if (hasBody) {
        init.body = str(bp, bl);
        init.headers["content-type"] = "application/json";
      }
      fetch(str(up, ul), init)
        .then(async (r) => [r.status, await r.text()])
        .catch((e) => [0, String(e)])
        .then(([status, text]) => {
          const [ptr, len] = toRust(text);
          wasm.exports.akurai_complete(cb, status, ptr, len);
        });
    },
    ak_event_source: (up, ul, np, nl, cb) => {
      const id = nextStream++;
      const es = new EventSource(str(up, ul), { withCredentials: true });
      const deliver = (status, text) => {
        if (!streams.has(id)) return;
        const [ptr, len] = toRust(text);
        wasm.exports.akurai_complete(cb, status, ptr, len);
      };
      // Named events need explicit listeners; Rust passes the names it wants.
      const onAny = (e) => deliver(1, `${e.type}\n${e.data ?? ""}`);
      es.onmessage = onAny;
      for (const name of str(np, nl).split(",").filter(Boolean)) es.addEventListener(name, onAny);
      es.onerror = () => deliver(0, es.readyState === 2 ? "closed" : "reconnecting");
      streams.set(id, es);
      return id;
    },
    ak_event_source_close: (id) => { streams.get(id)?.close(); streams.delete(id); },
    // Microphone: start fills a MediaRecorder; stop hands the whole clip to
    // Rust as base64 (status 1) or a reason (status 0) via one completion.
    ak_record_start: () => {
      if (recorder) return 1;
      navigator.mediaDevices?.getUserMedia({ audio: true }).then((stream) => {
        const chunks = [];
        const mime = MediaRecorder.isTypeSupported("audio/webm;codecs=opus") ? "audio/webm;codecs=opus" : "";
        recorder = new MediaRecorder(stream, mime ? { mimeType: mime } : undefined);
        recorder.__chunks = chunks;
        recorder.__stream = stream;
        recorder.ondataavailable = (e) => { if (e.data.size) chunks.push(e.data); };
        recorder.start();
      }).catch((e) => { recorder = { failed: String(e) }; });
      return 1;
    },
    ak_record_stop: (cb) => {
      const done = (status, text) => { const [ptr, len] = toRust(text); wasm.exports.akurai_complete(cb, status, ptr, len); };
      const r = recorder; recorder = null;
      if (!r) return done(0, "not recording");
      if (r.failed) return done(0, r.failed);
      r.onstop = async () => {
        r.__stream.getTracks().forEach((t) => t.stop());
        const blob = new Blob(r.__chunks, { type: r.mimeType || "audio/webm" });
        const buf = new Uint8Array(await blob.arrayBuffer());
        let bin = ""; for (let i = 0; i < buf.length; i += 0x8000) bin += String.fromCharCode.apply(null, buf.subarray(i, i + 0x8000));
        done(1, `${blob.type}\n${btoa(bin)}`);
      };
      r.state === "inactive" ? r.onstop() : r.stop();
    },
    ak_play_audio: (p, l) => { try { new Audio(str(p, l)).play().catch(() => {}); } catch {} },
    // One long-lived stream (a radio): new src switches, same src = volume only, "" stops.
    ak_open_url: (p, l) => { try { location.href = str(p, l); } catch {} },
    // One fullscreen video layer (live TV): new src switches, same src = volume only, "" removes it.
    ak_stream_video: (p, l, volume) => {
      try {
        const src = str(p, l);
        let v = globalThis.__akVideo;
        if (!src) { if (v) { v.pause(); v.removeAttribute("src"); v.load(); v.remove(); globalThis.__akVideo = null; } return; }
        if (!v) {
          v = globalThis.__akVideo = document.createElement("video");
          v.setAttribute("data-ak-video", "");
          v.playsInline = true;
          v.style.cssText = "position:fixed;inset:0;width:100vw;height:100vh;background:#000;object-fit:contain;z-index:2147483000";
          document.body.appendChild(v);
        }
        v.volume = volume;
        if (v.dataset.src !== src) { v.dataset.src = src; v.src = src; }
        if (v.paused) v.play().catch(() => {});
      } catch {}
    },
    ak_stream_audio: (p, l, volume) => {
      try {
        const src = str(p, l);
        const a = (globalThis.__akStream ||= new Audio());
        a.volume = volume;
        if (!src) { a.pause(); delete a.dataset.src; a.removeAttribute("src"); a.load(); return; }
        if (a.dataset.src !== src) { a.dataset.src = src; a.src = src; }
        if (a.paused) a.play().catch(() => {});
      } catch {}
    },
    ak_set_timeout: (ms, cb) => setTimeout(() => {
      const [ptr, len] = toRust("");
      wasm.exports.akurai_complete(cb, 0, ptr, len);
    }, ms),
    ak_location_hash: () => park(location.hash.replace(/^#/, "")),
    ak_set_location_hash: (p, l) => { location.hash = str(p, l); },
    ak_now_ms: () => Date.now(),
    ak_tz_offset_minutes: () => -new Date().getTimezoneOffset(),
    ak_storage_get: (p, l) => { try { return park(localStorage.getItem(str(p, l))); } catch { return NONE; } },
    ak_storage_set: (kp, kl, vp, vl) => { try { localStorage.setItem(str(kp, kl), str(vp, vl)); } catch {} },
    ak_set_title: (p, l) => { document.title = str(p, l); },
    ak_set_root_attr: (np, nl, vp, vl) => document.documentElement.setAttribute(str(np, nl), str(vp, vl)),
    ak_log: (p, l) => console.log(str(p, l)),
    ak_take: (dst) => { mem().set(parked, dst); parked = new Uint8Array(0); },
  };

  const { instance } = await WebAssembly.instantiateStreaming(fetch(url), { akurai });
  wasm = instance;
  wasm.exports.akurai_start();
}