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();
}