use std::cell::{Cell, RefCell};
use std::rc::Rc;
use js_sys::{Function, Object, Reflect, WebAssembly};
use wasm_bindgen::prelude::*;
use wasm_bindgen::{Clamped, JsCast};
use wasm_bindgen_futures::JsFuture;
use web_sys::{CanvasRenderingContext2d, HtmlCanvasElement, ImageData};
use super::dom;
use super::templates;
const FB_W: u32 = 256;
const FB_H: u32 = 144;
const FB_BYTES: usize = (FB_W * FB_H * 4) as usize;
type Framebuffer = Rc<RefCell<Vec<u8>>>;
type FrameLoopHolder = Rc<RefCell<Option<Closure<dyn FnMut()>>>>;
thread_local! {
static FRAME_GEN: Cell<u32> = const { Cell::new(0) };
static RUNTIME: RefCell<Option<CartridgeRuntime>> = const { RefCell::new(None) };
static POINTER: Cell<(i32, i32)> = const { Cell::new((0, 0)) };
static POINTER_DOWN: Cell<i32> = const { Cell::new(0) };
static ACTIVE_CANVAS_ID: RefCell<String> = RefCell::new(String::from("display-canvas"));
static STATE: RefCell<[i32; 64]> = const { RefCell::new([0; 64]) };
}
#[allow(dead_code)]
struct CartridgeRuntime {
clear: Closure<dyn FnMut(i32)>,
set_pixel: Closure<dyn FnMut(i32, i32, i32)>,
fill_rect: Closure<dyn FnMut(i32, i32, i32, i32, i32)>,
draw_char: Closure<dyn FnMut(i32, i32, i32, i32, i32)>,
draw_number: Closure<dyn FnMut(i32, i32, i32, i32, i32)>,
draw_line: Closure<dyn FnMut(i32, i32, i32, i32, i32)>,
#[allow(clippy::type_complexity)]
fill_triangle: Closure<dyn FnMut(i32, i32, i32, i32, i32, i32, i32)>,
present: Closure<dyn FnMut()>,
width: Closure<dyn FnMut() -> i32>,
height: Closure<dyn FnMut() -> i32>,
pointer_x: Closure<dyn FnMut() -> i32>,
pointer_y: Closure<dyn FnMut() -> i32>,
pointer_down: Closure<dyn FnMut() -> i32>,
state_get: Closure<dyn FnMut(i32) -> i32>,
state_set: Closure<dyn FnMut(i32, i32)>,
net: net::NetRuntime,
audio: audio::AudioRuntime,
}
enum InputSource {
#[allow(dead_code)]
Global,
Local {
pointer: Rc<Cell<(i32, i32)>>,
down: Rc<Cell<i32>>,
state: Rc<RefCell<[i32; 64]>>,
},
}
struct ChildHandle {
frame: Function,
pointer: Rc<Cell<(i32, i32)>>,
down: Rc<Cell<i32>>,
_runtime: CartridgeRuntime,
_mem: SharedMemory,
}
type SharedMemory = Rc<RefCell<JsValue>>;
pub(crate) async fn run_wasm(wasm_bytes: &[u8]) -> Result<(), JsValue> {
let ctx = mount_canvas()?;
run_with_ctx(wasm_bytes, ctx, "display-canvas").await
}
pub(crate) struct RunFailure {
pub code: Option<u16>,
pub detail: String,
}
const FIRST_SIGNAL_MS: u32 = 2600;
pub(crate) async fn run_wasm_reporting(wasm_bytes: &[u8]) -> Result<(), RunFailure> {
run_wasm(wasm_bytes).await.map_err(|e| RunFailure {
code: None,
detail: format!("worker spawn failed: {e:?}"),
})?;
let mut waited = 0u32;
loop {
match worker::current_outcome() {
worker::RunOutcome::Live => return Ok(()),
worker::RunOutcome::Failed { code, detail } => {
return Err(RunFailure { code, detail })
}
worker::RunOutcome::Pending => {}
}
if waited >= FIRST_SIGNAL_MS {
return Err(RunFailure {
code: None,
detail: format!(
"no frame and no error within {FIRST_SIGNAL_MS}ms of spawning \
the cartridge worker"
),
});
}
crate::runtime::sleep_ms(50).await;
waited += 50;
}
}
pub(crate) async fn run_in_root_canvas(wasm_bytes: &[u8]) -> Result<(), JsValue> {
let ctx = size_and_get_ctx()?;
run_with_ctx(wasm_bytes, ctx, "display-canvas").await
}
pub(crate) async fn run_in_canvas(
canvas: HtmlCanvasElement,
wasm_bytes: &[u8],
) -> Result<(), JsValue> {
canvas.set_width(FB_W);
canvas.set_height(FB_H);
let id = canvas.id();
let ctx = canvas
.get_context("2d")?
.ok_or_else(|| JsValue::from_str("no 2d context"))?
.dyn_into::<CanvasRenderingContext2d>()?;
run_with_ctx(wasm_bytes, ctx, &id).await
}
pub(crate) fn render_html(source: &str) -> Result<(), JsValue> {
stop();
let ctx = mount_canvas()?;
let blocks = html_to_blocks(source);
let buf = paint_html_fb(&blocks);
let img = ImageData::new_with_u8_clamped_array_and_sh(Clamped(&buf[..]), FB_W, FB_H)?;
ctx.put_image_data(&img, 0.0, 0.0)?;
Ok(())
}
pub(crate) fn render_html_in_root_canvas(source: &str) -> Result<(), JsValue> {
stop();
let ctx = size_and_get_ctx()?;
let blocks = html_to_blocks(source);
let buf = paint_html_fb(&blocks);
let img = ImageData::new_with_u8_clamped_array_and_sh(Clamped(&buf[..]), FB_W, FB_H)?;
ctx.put_image_data(&img, 0.0, 0.0)?;
Ok(())
}
async fn run_with_ctx(
wasm_bytes: &[u8],
ctx: CanvasRenderingContext2d,
canvas_id: &str,
) -> Result<(), JsValue> {
ACTIVE_CANVAS_ID.with(|c| *c.borrow_mut() = canvas_id.to_string());
FRAME_GEN.with(|g| g.set(g.get().wrapping_add(1)));
worker::stop_worker();
RUNTIME.with(|cell| *cell.borrow_mut() = None);
audio::stop_all();
POINTER_DOWN.with(|d| d.set(0));
STATE.with(|s| *s.borrow_mut() = [0; 64]);
worker::spawn_cartridge(wasm_bytes, ctx)
}
pub(crate) async fn mount_composition(modules: Vec<Option<Vec<u8>>>) -> Result<(), JsValue> {
let ctx = size_and_get_ctx()?;
let generation = FRAME_GEN.with(|g| {
let n = g.get().wrapping_add(1);
g.set(n);
n
});
RUNTIME.with(|cell| *cell.borrow_mut() = None);
audio::stop_all();
let fb: Framebuffer = Rc::new(RefCell::new(black_framebuffer()));
POINTER_DOWN.with(|d| d.set(0));
let viewports = crate::compose::grid_viewports(modules.len(), FB_W as i32, FB_H as i32);
let budget = crate::compose::ComposeBudget::v1();
let mut table: crate::compose::ModuleTable<ChildHandle> = crate::compose::ModuleTable::new();
let mut total_bytes = 0usize;
for (slot, vp) in modules.into_iter().zip(viewports) {
let Some(bytes) = slot else { continue }; if let Err(reason) = budget.admit(table.len(), total_bytes, bytes.len()) {
web_sys::console::warn_1(&JsValue::from_str(&reason));
continue;
}
let pointer = Rc::new(Cell::new((-1, -1)));
let down = Rc::new(Cell::new(0));
let state = Rc::new(RefCell::new([0i32; 64]));
let mem: SharedMemory = Rc::new(RefCell::new(JsValue::NULL));
let input = InputSource::Local { pointer: pointer.clone(), down: down.clone(), state };
let (imports, runtime) = build_host_display(&fb, &mem, vp, input)?;
let result = JsFuture::from(WebAssembly::instantiate_buffer(&bytes, &imports)).await?;
let instance = Reflect::get(&result, &JsValue::from_str("instance"))?;
let exports = Reflect::get(&instance, &JsValue::from_str("exports"))?;
*mem.borrow_mut() = Reflect::get(&exports, &JsValue::from_str("memory"))?;
let Some(frame) = export_fn(&exports, "frame").or_else(|| export_fn(&exports, "render"))
else {
web_sys::console::warn_1(&JsValue::from_str("compose: a module exports neither frame nor render — skipped"));
continue;
};
total_bytes += bytes.len();
table.push(ChildHandle { frame, pointer, down, _runtime: runtime, _mem: mem }, vp);
}
if table.is_empty() {
return Err(JsValue::from_str("compose: no module could be mounted"));
}
start_compose_loop(table, generation, fb, ctx);
Ok(())
}
fn clear_black(buf: &mut [u8]) {
for px in buf.chunks_exact_mut(4) {
px[0] = 0;
px[1] = 0;
px[2] = 0;
px[3] = 255;
}
}
fn start_compose_loop(
table: crate::compose::ModuleTable<ChildHandle>,
generation: u32,
fb: Framebuffer,
ctx: CanvasRenderingContext2d,
) {
let start = js_sys::Date::now();
let table = Rc::new(RefCell::new(table));
let holder: FrameLoopHolder = Rc::new(RefCell::new(None));
let holder2 = holder.clone();
*holder.borrow_mut() = Some(Closure::wrap(Box::new(move || {
if FRAME_GEN.with(|g| g.get()) != generation {
let _ = holder2.borrow_mut().take();
return;
}
let t = (js_sys::Date::now() - start) as i32;
let (gx, gy) = POINTER.with(|p| p.get());
let gdown = POINTER_DOWN.with(|d| d.get());
let mut tb = table.borrow_mut();
let focus = tb.focus_at(gx, gy);
{
let mut buf = fb.borrow_mut();
clear_black(&mut buf);
}
tb.tick(|i, child, _vp, _pending| {
match focus {
Some((fi, lx, ly)) if fi == i => {
child.pointer.set((lx, ly));
child.down.set(gdown);
}
_ => {
child.pointer.set((-1, -1));
child.down.set(0);
}
}
let _ = child.frame.call1(&JsValue::NULL, &JsValue::from(t));
});
drop(tb);
present_framebuffer(&fb, &ctx);
if let Some(cb) = holder2.borrow().as_ref() {
let _ = request_af(cb);
}
}) as Box<dyn FnMut()>));
if let Some(cb) = holder.borrow().as_ref() {
let _ = request_af(cb);
}
}
fn present_framebuffer(fb: &Framebuffer, ctx: &CanvasRenderingContext2d) {
let buf = fb.borrow();
if let Ok(img) = ImageData::new_with_u8_clamped_array_and_sh(Clamped(&buf[..]), FB_W, FB_H) {
let _ = ctx.put_image_data(&img, 0.0, 0.0);
}
}
fn build_host_display(
fb: &Framebuffer,
mem: &SharedMemory,
vp: crate::raster::Viewport,
input: InputSource,
) -> Result<(Object, CartridgeRuntime), JsValue> {
let clear = {
let fb = fb.clone();
Closure::<dyn FnMut(i32)>::new(move |rgb: i32| {
let mut buf = fb.borrow_mut();
crate::raster::clear(&mut buf, FB_W as i32, &vp, rgb_components(rgb));
})
};
let set_pixel = {
let fb = fb.clone();
Closure::<dyn FnMut(i32, i32, i32)>::new(move |x: i32, y: i32, rgb: i32| {
let mut buf = fb.borrow_mut();
crate::raster::set_pixel(&mut buf, FB_W as i32, &vp, x, y, rgb_components(rgb));
})
};
let fill_rect = {
let fb = fb.clone();
Closure::<dyn FnMut(i32, i32, i32, i32, i32)>::new(
move |x: i32, y: i32, w: i32, h: i32, rgb: i32| {
let mut buf = fb.borrow_mut();
crate::raster::fill_rect(&mut buf, FB_W as i32, &vp, x, y, w, h, rgb_components(rgb));
},
)
};
let present = Closure::<dyn FnMut()>::new(move || {});
let draw_char = {
let fb = fb.clone();
Closure::<dyn FnMut(i32, i32, i32, i32, i32)>::new(
move |x: i32, y: i32, code: i32, rgb: i32, scale: i32| {
let mut buf = fb.borrow_mut();
crate::raster::blit_glyph(
&mut buf, FB_W as i32, &vp, x, y, code as u32, rgb_components(rgb), scale,
);
},
)
};
let draw_number = {
let fb = fb.clone();
Closure::<dyn FnMut(i32, i32, i32, i32, i32)>::new(
move |x: i32, y: i32, value: i32, rgb: i32, scale: i32| {
let mut buf = fb.borrow_mut();
crate::raster::draw_number(
&mut buf, FB_W as i32, &vp, x, y, value, rgb_components(rgb), scale,
);
},
)
};
let draw_line = {
let fb = fb.clone();
Closure::<dyn FnMut(i32, i32, i32, i32, i32)>::new(
move |x0: i32, y0: i32, x1: i32, y1: i32, rgb: i32| {
let mut buf = fb.borrow_mut();
crate::raster::draw_line(
&mut buf, FB_W as i32, &vp, x0, y0, x1, y1, rgb_components(rgb),
);
},
)
};
let fill_triangle = {
let fb = fb.clone();
Closure::<dyn FnMut(i32, i32, i32, i32, i32, i32, i32)>::new(
move |x0: i32, y0: i32, x1: i32, y1: i32, x2: i32, y2: i32, rgb: i32| {
let mut buf = fb.borrow_mut();
crate::raster::fill_triangle(
&mut buf, FB_W as i32, &vp, x0, y0, x1, y1, x2, y2, rgb_components(rgb),
);
},
)
};
let width = Closure::<dyn FnMut() -> i32>::new(move || vp.w);
let height = Closure::<dyn FnMut() -> i32>::new(move || vp.h);
#[allow(clippy::type_complexity)] let (pointer_x, pointer_y, pointer_down, state_get, state_set): (
Closure<dyn FnMut() -> i32>,
Closure<dyn FnMut() -> i32>,
Closure<dyn FnMut() -> i32>,
Closure<dyn FnMut(i32) -> i32>,
Closure<dyn FnMut(i32, i32)>,
) = match input {
InputSource::Global => (
Closure::<dyn FnMut() -> i32>::new(move || POINTER.with(|p| p.get().0)),
Closure::<dyn FnMut() -> i32>::new(move || POINTER.with(|p| p.get().1)),
Closure::<dyn FnMut() -> i32>::new(move || POINTER_DOWN.with(|d| d.get())),
Closure::<dyn FnMut(i32) -> i32>::new(move |slot: i32| {
if !(0..64).contains(&slot) {
return 0;
}
STATE.with(|s| s.borrow()[slot as usize])
}),
Closure::<dyn FnMut(i32, i32)>::new(move |slot: i32, value: i32| {
if !(0..64).contains(&slot) {
return;
}
STATE.with(|s| s.borrow_mut()[slot as usize] = value);
}),
),
InputSource::Local { pointer, down, state } => {
let (px, py, pd) = (pointer.clone(), pointer.clone(), down);
let (sg, ss) = (state.clone(), state);
(
Closure::<dyn FnMut() -> i32>::new(move || px.get().0),
Closure::<dyn FnMut() -> i32>::new(move || py.get().1),
Closure::<dyn FnMut() -> i32>::new(move || pd.get()),
Closure::<dyn FnMut(i32) -> i32>::new(move |slot: i32| {
if !(0..64).contains(&slot) {
return 0;
}
sg.borrow()[slot as usize]
}),
Closure::<dyn FnMut(i32, i32)>::new(move |slot: i32, value: i32| {
if !(0..64).contains(&slot) {
return;
}
ss.borrow_mut()[slot as usize] = value;
}),
)
}
};
let host_display = Object::new();
set_fn(&host_display, "clear", &clear)?;
set_fn(&host_display, "set_pixel", &set_pixel)?;
set_fn(&host_display, "fill_rect", &fill_rect)?;
set_fn(&host_display, "draw_char", &draw_char)?;
set_fn(&host_display, "draw_number", &draw_number)?;
set_fn(&host_display, "draw_line", &draw_line)?;
set_fn(&host_display, "fill_triangle", &fill_triangle)?;
set_fn(&host_display, "present", &present)?;
set_fn(&host_display, "width", &width)?;
set_fn(&host_display, "height", &height)?;
set_fn(&host_display, "pointer_x", &pointer_x)?;
set_fn(&host_display, "pointer_y", &pointer_y)?;
set_fn(&host_display, "pointer_down", &pointer_down)?;
set_fn(&host_display, "state_get", &state_get)?;
set_fn(&host_display, "state_set", &state_set)?;
let imports = Object::new();
Reflect::set(&imports, &JsValue::from_str("host_display"), &host_display)?;
let net = net::build_host_net(&imports, mem)?;
let audio = audio::build_host_audio(&imports)?;
Ok((
imports,
CartridgeRuntime {
clear, set_pixel, fill_rect, draw_char, draw_number, draw_line, fill_triangle,
present, width, height,
pointer_x, pointer_y, pointer_down, state_get, state_set, net, audio,
},
))
}
pub(crate) fn set_pointer_down(down: bool) {
POINTER_DOWN.with(|d| d.set(if down { 1 } else { 0 }));
forward_pointer_to_worker();
}
fn forward_pointer_to_worker() {
if worker::is_active() {
let (x, y) = POINTER.with(|p| p.get());
let down = POINTER_DOWN.with(|d| d.get());
worker::post_input(x, y, down);
}
}
pub(crate) fn set_pointer(client_x: f64, client_y: f64) {
let active_id = ACTIVE_CANVAS_ID.with(|c| c.borrow().clone());
let Some(el) = dom::by_id(&active_id) else { return };
let Ok(canvas) = el.dyn_into::<HtmlCanvasElement>() else { return };
let rect = canvas.get_bounding_client_rect();
let (w, h) = (rect.width(), rect.height());
if w <= 0.0 || h <= 0.0 {
return;
}
let fb_w = if canvas.width() > 0 { canvas.width() } else { FB_W };
let fb_h = if canvas.height() > 0 { canvas.height() } else { FB_H };
let fx = (((client_x - rect.left()) / w) * fb_w as f64).clamp(0.0, (fb_w - 1) as f64) as i32;
let fy = (((client_y - rect.top()) / h) * fb_h as f64).clamp(0.0, (fb_h - 1) as f64) as i32;
POINTER.with(|p| p.set((fx, fy)));
forward_pointer_to_worker();
}
thread_local! {
static PENDING_EMBED: RefCell<Option<Vec<u8>>> = const { RefCell::new(None) };
}
pub(crate) fn stash_pending_embed(wasm: Vec<u8>) {
PENDING_EMBED.with(|c| *c.borrow_mut() = Some(wasm));
}
thread_local! {
static EMBED_CANVAS_SEQ: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
}
pub(crate) fn next_embed_canvas_id() -> String {
EMBED_CANVAS_SEQ.with(|c| {
let n = c.get().wrapping_add(1);
c.set(n);
format!("embed-canvas-{n}")
})
}
pub(crate) async fn launch_pending_embed(card_id: &str) {
let Some(wasm) = PENDING_EMBED.with(|c| c.borrow_mut().take()) else {
embed_trace(&format!("no-stash for #{card_id}"));
return;
};
let Some(doc) = web_sys::window().and_then(|w| w.document()) else { return };
let Ok(Some(el)) = doc.query_selector(&format!("#{card_id} canvas.embed-app-canvas")) else {
embed_trace(&format!("no-canvas inside #{card_id}"));
return;
};
let Ok(canvas) = el.dyn_into::<HtmlCanvasElement>() else { return };
match run_in_canvas(canvas, &wasm).await {
Ok(()) => embed_trace(&format!("launched into #{card_id}")),
Err(e) => embed_trace(&format!("run failed in #{card_id}: {e:?}")),
}
}
fn embed_trace(msg: &str) {
web_sys::console::warn_1(&JsValue::from_str(&format!("[embed] {msg}")));
let _ = js_sys::Reflect::set(
&js_sys::global(),
&JsValue::from_str("__lhEmbedTrace"),
&JsValue::from_str(msg),
);
}
pub(crate) fn is_cartridge_canvas_id(id: &str) -> bool {
id == "display-canvas" || id.starts_with("embed-canvas")
}
pub(crate) fn cartridge_canvas_present() -> bool {
if dom::by_id("display-canvas").is_some() {
return true;
}
web_sys::window()
.and_then(|w| w.document())
.and_then(|d| d.query_selector("canvas.embed-app-canvas").ok().flatten())
.is_some()
}
fn set_fn<T: ?Sized>(obj: &Object, name: &str, closure: &Closure<T>) -> Result<(), JsValue> {
Reflect::set(obj, &JsValue::from_str(name), closure.as_ref().unchecked_ref())?;
Ok(())
}
fn rgb_components(c: i32) -> (u8, u8, u8) {
let u = c as u32;
(((u >> 16) & 0xff) as u8, ((u >> 8) & 0xff) as u8, (u & 0xff) as u8)
}
fn black_framebuffer() -> Vec<u8> {
let mut buf = vec![0u8; FB_BYTES];
let mut i = 3;
while i < buf.len() {
buf[i] = 255;
i += 4;
}
buf
}
fn export_fn(exports: &JsValue, name: &str) -> Option<Function> {
Reflect::get(exports, &JsValue::from_str(name))
.ok()
.and_then(|v| v.dyn_into::<Function>().ok())
}
fn request_af(cb: &Closure<dyn FnMut()>) -> Result<i32, JsValue> {
dom::window()?.request_animation_frame(cb.as_ref().unchecked_ref())
}
pub(crate) fn stop() {
FRAME_GEN.with(|g| g.set(g.get().wrapping_add(1)));
RUNTIME.with(|cell| *cell.borrow_mut() = None);
worker::stop_worker();
audio::stop_all();
}
fn mount_canvas() -> Result<CanvasRenderingContext2d, JsValue> {
dom::swap_outer("display-overlay", &templates::display_overlay().into_string());
size_and_get_ctx()
}
pub(crate) fn snapshot_data_url() -> Option<String> {
let canvas = dom::by_id("display-canvas")?
.dyn_into::<HtmlCanvasElement>()
.ok()?;
canvas.to_data_url().ok()
}
fn size_and_get_ctx() -> Result<CanvasRenderingContext2d, JsValue> {
let canvas = dom::by_id("display-canvas")
.ok_or_else(|| JsValue::from_str("display-canvas missing"))?
.dyn_into::<HtmlCanvasElement>()?;
canvas.set_width(FB_W);
canvas.set_height(FB_H);
let ctx = canvas
.get_context("2d")?
.ok_or_else(|| JsValue::from_str("no 2d context"))?
.dyn_into::<CanvasRenderingContext2d>()?;
Ok(ctx)
}
async fn feed_token_id() -> Option<u64> {
let name = crate::app::tenant::current_name()?;
match crate::app::registry::id_of_name(&name).await {
Ok(id) if id != 0 => Some(id),
_ => None,
}
}
fn post_agent_context(
worker: &web_sys::Worker,
has_identity: Option<bool>,
is_subscribed: Option<bool>,
subscriber_count: Option<u32>,
) {
let msg = Object::new();
let _ = Reflect::set(&msg, &JsValue::from_str("type"), &JsValue::from_str("agent_context"));
if let Some(h) = has_identity {
let _ = Reflect::set(&msg, &JsValue::from_str("viewerHasIdentity"), &JsValue::from_f64(if h { 1.0 } else { 0.0 }));
}
if let Some(sub) = is_subscribed {
let _ = Reflect::set(&msg, &JsValue::from_str("feedIsSubscribed"), &JsValue::from_f64(if sub { 1.0 } else { 0.0 }));
}
if let Some(c) = subscriber_count {
let _ = Reflect::set(&msg, &JsValue::from_str("feedSubscriberCount"), &JsValue::from_f64(c as f64));
}
let _ = worker.post_message(&msg);
}
async fn refresh_feed_context(worker: web_sys::Worker) {
let addr = crate::app::chat::credit_address_existing().await;
if addr.is_none() {
return;
}
let Some(feed_id) = feed_token_id().await else { return };
let has_identity = addr.is_some();
let is_sub = match &addr {
Some(a) => crate::app::registry::is_subscribed(feed_id, a).await.unwrap_or(false),
None => false,
};
let count = crate::app::registry::subscriber_count(feed_id).await.unwrap_or(0) as u32;
post_agent_context(&worker, Some(has_identity), Some(is_sub), Some(count));
}
async fn do_feed_subscribe(worker: web_sys::Worker, subscribe: bool) {
let Some(feed_id) = feed_token_id().await else { return };
let Some((signer, _)) = crate::app::chat::credit_signer().await else { return };
let Ok(sponsor) = crate::app::sponsor::signer() else { return };
let token = crate::app::registry::ALPHA_USD_ADDRESS;
let res = if subscribe {
crate::app::registry::subscribe_sponsored(&signer, &sponsor, feed_id, token).await
} else {
crate::app::registry::unsubscribe_sponsored(&signer, &sponsor, feed_id, token).await
};
if let Err(e) = res {
web_sys::console::warn_1(&JsValue::from_str(&format!("feed subscribe: {e}")));
} else if subscribe {
if crate::app::notifications::ensure_permission().await.unwrap_or(false) {
publish_viewer_push_sub().await;
}
}
refresh_feed_context(worker).await;
}
async fn publish_viewer_push_sub() {
let Ok(sub_json) = crate::app::notifications::subscribe_push().await else { return };
let Some((signer, _)) = crate::app::chat::credit_signer().await else { return };
let Ok(sponsor) = crate::app::sponsor::signer() else { return };
let token = crate::app::registry::ALPHA_USD_ADDRESS;
if let Err(e) = crate::registry::set_push_sub_sponsored(
&signer,
&sponsor,
sub_json.as_bytes(),
token,
)
.await
{
web_sys::console::warn_1(&JsValue::from_str(&format!("publish push_sub: {e}")));
}
}
async fn do_feed_broadcast(title: String, body: String) {
let Some(feed_id) = feed_token_id().await else { return };
let Some((signer, _)) = crate::app::chat::credit_signer().await else { return };
let now = (js_sys::Date::now() / 1000.0) as u64;
let token = crate::registry::proxy_auth_token(&signer, now);
let url = format!(
"{}api/broadcast",
crate::registry::CREDIT_PROXY_URL
);
let payload = serde_json::json!({ "targetId": feed_id, "title": title, "body": body });
let send = async {
reqwest::Client::new()
.post(&url)
.header("content-type", "application/json")
.header("x-goog-api-key", token)
.json(&payload)
.send()
.await
.map_err(|e| format!("broadcast request: {e}"))
};
match crate::app::net::with_timeout(20_000, send).await {
Ok(Ok(_resp)) => {}
Ok(Err(e)) => web_sys::console::warn_1(&JsValue::from_str(&format!("broadcast: {e}"))),
Err(e) => web_sys::console::warn_1(&JsValue::from_str(&format!("broadcast timeout: {e}"))),
}
crate::app::notifications::push_to_bell(&title, &body);
if crate::app::notifications::ensure_permission().await.unwrap_or(false) {
let _ = crate::app::notifications::show(&title, &body).await;
}
crate::app::notifications::vibrate(120);
}
fn open_broadcast_composer(title: &str, default_body: &str) {
dom::swap_outer(
"broadcast-composer",
&templates::broadcast_composer(title, default_body).into_string(),
);
if let Some(input) = dom::by_id("broadcast-input")
.and_then(|el| el.dyn_into::<web_sys::HtmlInputElement>().ok())
{
let _ = input.focus();
input.select();
}
}
pub(crate) fn broadcast_composer_open() -> bool {
dom::by_id("broadcast-composer")
.map(|el| !el.has_attribute("hidden"))
.unwrap_or(false)
}
pub(crate) fn close_broadcast_composer() {
dom::swap_outer(
"broadcast-composer",
&templates::broadcast_composer_closed().into_string(),
);
}
pub(crate) fn broadcast_send(title: String) {
let body: String = dom::by_id("broadcast-input")
.and_then(|el| el.dyn_into::<web_sys::HtmlInputElement>().ok())
.map(|i| i.value())
.unwrap_or_default()
.trim()
.chars()
.take(200)
.collect();
close_broadcast_composer();
if title.is_empty() {
return;
}
wasm_bindgen_futures::spawn_local(do_feed_broadcast(title, body));
}
async fn do_feed_request_identity(worker: web_sys::Worker) {
let _ = crate::app::chat::credit_signer().await;
refresh_feed_context(worker).await;
}
thread_local! {
static FEED_CARTRIDGE_ACTIVE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
static FEED_PRIMED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
pub(crate) fn set_feed_cartridge_active(on: bool) {
FEED_CARTRIDGE_ACTIVE.with(|c| c.set(on));
if on {
FEED_PRIMED.with(|c| c.set(false));
}
}
pub(crate) fn prime_feed_permission_on_gesture() {
if !FEED_CARTRIDGE_ACTIVE.with(|c| c.get()) || FEED_PRIMED.with(|c| c.get()) {
return;
}
if matches!(
web_sys::Notification::permission(),
web_sys::NotificationPermission::Denied
) {
return; }
FEED_PRIMED.with(|c| c.set(true));
wasm_bindgen_futures::spawn_local(async {
if crate::app::notifications::ensure_permission().await.unwrap_or(false) {
publish_viewer_push_sub().await;
} else {
FEED_PRIMED.with(|c| c.set(false));
}
});
}
thread_local! {
static COMPOSE_WASM_CACHE: RefCell<std::collections::HashMap<String, Vec<u8>>> =
RefCell::new(std::collections::HashMap::new());
}
async fn do_compose_spawn(worker: web_sys::Worker, uid: i32, name: String) {
let cached = COMPOSE_WASM_CACHE.with(|c| c.borrow().get(&name).cloned());
let bytes = match cached {
Some(b) => Some(b),
None => {
let fetched = super::compose_module_wasm(&name).await;
if let Some(ref b) = fetched {
COMPOSE_WASM_CACHE.with(|c| {
c.borrow_mut().insert(name.clone(), b.clone());
});
}
fetched
}
};
post_compose_bytes(&worker, uid, bytes.as_deref());
}
fn post_compose_bytes(worker: &web_sys::Worker, uid: i32, bytes: Option<&[u8]>) {
use js_sys::{Object, Reflect, Uint8Array};
let msg = Object::new();
let _ = Reflect::set(&msg, &JsValue::from_str("type"), &JsValue::from_str("compose_bytes"));
let _ = Reflect::set(&msg, &JsValue::from_str("uid"), &JsValue::from_f64(uid as f64));
match bytes {
Some(b) => {
let arr = Uint8Array::from(b);
let buf = arr.buffer();
let _ = Reflect::set(&msg, &JsValue::from_str("wasm"), &buf);
let transfer = js_sys::Array::new();
transfer.push(&buf);
let _ = worker.post_message_with_transfer(&msg, &transfer);
}
None => {
let _ = Reflect::set(&msg, &JsValue::from_str("wasm"), &JsValue::NULL);
let _ = worker.post_message(&msg);
}
}
}
mod worker {
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use js_sys::{ArrayBuffer, Object, Reflect, Uint8Array, Uint8ClampedArray};
use wasm_bindgen::prelude::*;
use wasm_bindgen::{Clamped, JsCast};
use web_sys::{CanvasRenderingContext2d, ImageData, MessageEvent, Worker};
use super::dom;
use super::{audio, FB_H, FB_W};
const WATCHDOG_MS: f64 = 1500.0;
const WATCHDOG_TICK_MS: i32 = 500;
thread_local! {
static WORKER: RefCell<Option<WorkerHandle>> = const { RefCell::new(None) };
static RUN_GEN: Cell<u32> = const { Cell::new(0) };
static RUN_OUTCOME: RefCell<RunOutcome> = const { RefCell::new(RunOutcome::Pending) };
}
#[derive(Clone)]
pub(super) enum RunOutcome {
Pending,
Live,
Failed { code: Option<u16>, detail: String },
}
fn record_outcome(generation: u32, outcome: RunOutcome) {
if RUN_GEN.with(|g| g.get()) != generation {
return;
}
RUN_OUTCOME.with(|o| {
let mut o = o.borrow_mut();
if matches!(*o, RunOutcome::Pending) {
*o = outcome;
}
});
}
pub(super) fn current_outcome() -> RunOutcome {
RUN_OUTCOME.with(|o| o.borrow().clone())
}
struct WorkerHandle {
worker: Worker,
_onmessage: Closure<dyn FnMut(MessageEvent)>,
watchdog: Rc<Cell<Option<i32>>>,
_watchdog_cb: Option<Closure<dyn FnMut()>>,
terminated: Rc<Cell<bool>>,
}
impl Drop for WorkerHandle {
fn drop(&mut self) {
if let Some(id) = self.watchdog.take() {
if let Ok(win) = dom::window() {
win.clear_interval_with_handle(id);
}
}
self.worker.terminate();
}
}
pub(super) fn spawn_cartridge(
wasm_bytes: &[u8],
ctx: CanvasRenderingContext2d,
) -> Result<(), JsValue> {
stop_worker();
let run_gen = RUN_GEN.with(|g| {
let n = g.get().wrapping_add(1);
g.set(n);
n
});
RUN_OUTCOME.with(|o| *o.borrow_mut() = RunOutcome::Pending);
let worker = Worker::new(&worker_url())
.map_err(|e| JsValue::from_str(&format!("worker spawn failed: {e:?}")))?;
let last_frame = Rc::new(Cell::new(js_sys::Date::now()));
let terminated = Rc::new(Cell::new(false));
let watchdog_id: Rc<Cell<Option<i32>>> = Rc::new(Cell::new(None));
let onmessage = {
let ctx = ctx.clone();
let last_frame = last_frame.clone();
let watchdog_id = watchdog_id.clone();
let worker_for_msg = worker.clone();
Closure::<dyn FnMut(MessageEvent)>::new(move |e: MessageEvent| {
let data = e.data();
let ty = Reflect::get(&data, &JsValue::from_str("type"))
.ok()
.and_then(|v| v.as_string())
.unwrap_or_default();
match ty.as_str() {
"frame" => {
last_frame.set(js_sys::Date::now());
record_outcome(run_gen, RunOutcome::Live);
blit_frame(&data, &ctx);
}
"audio" => handle_audio(&data),
"error" => {
let detail = Reflect::get(&data, &JsValue::from_str("detail"))
.ok()
.and_then(|v| v.as_string())
.unwrap_or_default();
if let Some(id) = watchdog_id.take() {
if let Ok(win) = dom::window() {
win.clear_interval_with_handle(id);
}
}
let code = Reflect::get(&data, &JsValue::from_str("code"))
.ok()
.and_then(|v| v.as_f64())
.map(|n| n as u16);
record_outcome(
run_gen,
RunOutcome::Failed { code, detail: detail.clone() },
);
if let Some(code) = code {
paint_stopped_overlay_coded(&ctx, code);
}
web_sys::console::warn_1(&JsValue::from_str(&format!(
"cartridge error{}: {detail}",
code.map(|c| format!(" {}", crate::error_codes::fmt_label(c)))
.unwrap_or_default()
)));
}
"log" => {
let msg = Reflect::get(&data, &JsValue::from_str("msg"))
.ok()
.and_then(|v| v.as_string())
.unwrap_or_default();
web_sys::console::log_1(&JsValue::from_str(&msg));
}
"agent_notify" => {
if matches!(
web_sys::Notification::permission(),
web_sys::NotificationPermission::Granted
) {
let title = Reflect::get(&data, &JsValue::from_str("title"))
.ok()
.and_then(|v| v.as_string())
.unwrap_or_default();
let body = Reflect::get(&data, &JsValue::from_str("body"))
.ok()
.and_then(|v| v.as_string())
.unwrap_or_default();
if !title.is_empty() {
wasm_bindgen_futures::spawn_local(async move {
let _ = crate::app::notifications::show(&title, &body).await;
});
}
}
}
"cartridge_uses_feed" => super::set_feed_cartridge_active(true),
"agent_subscribe" => {
let w = worker_for_msg.clone();
wasm_bindgen_futures::spawn_local(super::do_feed_subscribe(w, true));
}
"agent_unsubscribe" => {
let w = worker_for_msg.clone();
wasm_bindgen_futures::spawn_local(super::do_feed_subscribe(w, false));
}
"agent_broadcast" => {
let title = Reflect::get(&data, &JsValue::from_str("title"))
.ok().and_then(|v| v.as_string()).unwrap_or_default();
let body = Reflect::get(&data, &JsValue::from_str("body"))
.ok().and_then(|v| v.as_string()).unwrap_or_default();
if !title.is_empty() {
wasm_bindgen_futures::spawn_local(super::do_feed_broadcast(title, body));
}
}
"agent_broadcast_compose" => {
let title = Reflect::get(&data, &JsValue::from_str("title"))
.ok().and_then(|v| v.as_string()).unwrap_or_default();
let body = Reflect::get(&data, &JsValue::from_str("body"))
.ok().and_then(|v| v.as_string()).unwrap_or_default();
if !title.is_empty() {
super::open_broadcast_composer(&title, &body);
}
}
"agent_request_identity" => {
let w = worker_for_msg.clone();
wasm_bindgen_futures::spawn_local(super::do_feed_request_identity(w));
}
"compose_spawn" => {
let uid = Reflect::get(&data, &JsValue::from_str("uid"))
.ok().and_then(|v| v.as_f64()).map(|n| n as i32).unwrap_or(-1);
let name = Reflect::get(&data, &JsValue::from_str("name"))
.ok().and_then(|v| v.as_string()).unwrap_or_default();
if uid >= 0 && !name.is_empty() {
let w = worker_for_msg.clone();
wasm_bindgen_futures::spawn_local(super::do_compose_spawn(w, uid, name));
}
}
"done" => {
record_outcome(run_gen, RunOutcome::Live);
if let Some(id) = watchdog_id.take() {
if let Ok(win) = dom::window() {
win.clear_interval_with_handle(id);
}
}
}
_ => {}
}
})
};
worker.set_onmessage(Some(onmessage.as_ref().unchecked_ref()));
let bytes = Uint8Array::from(wasm_bytes);
let msg = Object::new();
Reflect::set(&msg, &JsValue::from_str("type"), &JsValue::from_str("load"))?;
Reflect::set(&msg, &JsValue::from_str("wasm"), &bytes.buffer())?;
let (is_owner, has_identity) = crate::app::APP.with(|c| {
let app = c.borrow();
(
matches!(app.verify_state, crate::app::VerifyState::Verified { .. }),
app.wallet.is_some(),
)
});
Reflect::set(
&msg,
&JsValue::from_str("viewerIsOwner"),
&JsValue::from_f64(if is_owner { 1.0 } else { 0.0 }),
)?;
Reflect::set(
&msg,
&JsValue::from_str("viewerHasIdentity"),
&JsValue::from_f64(if has_identity { 1.0 } else { 0.0 }),
)?;
worker
.post_message(&msg)
.map_err(|e| JsValue::from_str(&format!("worker post failed: {e:?}")))?;
{
let w = worker.clone();
wasm_bindgen_futures::spawn_local(super::refresh_feed_context(w));
}
let watchdog_cb = arm_watchdog(
worker.clone(),
ctx,
last_frame.clone(),
terminated.clone(),
watchdog_id.clone(),
run_gen,
);
WORKER.with(|cell| {
*cell.borrow_mut() = Some(WorkerHandle {
worker,
_onmessage: onmessage,
watchdog: watchdog_id,
_watchdog_cb: watchdog_cb,
terminated,
});
});
Ok(())
}
pub(super) fn stop_worker() {
WORKER.with(|cell| {
if let Some(h) = cell.borrow().as_ref() {
h.terminated.set(true);
}
*cell.borrow_mut() = None;
});
}
pub(super) fn post_input(x: i32, y: i32, down: i32) {
WORKER.with(|cell| {
if let Some(h) = cell.borrow().as_ref() {
let msg = Object::new();
let _ = Reflect::set(&msg, &JsValue::from_str("type"), &JsValue::from_str("input"));
let _ = Reflect::set(&msg, &JsValue::from_str("x"), &JsValue::from_f64(x as f64));
let _ = Reflect::set(&msg, &JsValue::from_str("y"), &JsValue::from_f64(y as f64));
let _ = Reflect::set(&msg, &JsValue::from_str("down"), &JsValue::from_f64(down as f64));
let _ = h.worker.post_message(&msg);
}
});
}
pub(super) fn is_active() -> bool {
WORKER.with(|cell| {
cell.borrow()
.as_ref()
.map(|h| !h.terminated.get())
.unwrap_or(false)
})
}
fn worker_url() -> String {
"/cartridge-worker.js".to_string()
}
fn blit_frame(data: &JsValue, ctx: &CanvasRenderingContext2d) {
let Ok(fb) = Reflect::get(data, &JsValue::from_str("fb")) else { return };
let Ok(buffer) = fb.dyn_into::<ArrayBuffer>() else { return };
let w = Reflect::get(data, &JsValue::from_str("w"))
.ok()
.and_then(|v| v.as_f64())
.map(|n| n as u32)
.filter(|&n| n > 0)
.unwrap_or(FB_W);
let h = Reflect::get(data, &JsValue::from_str("h"))
.ok()
.and_then(|v| v.as_f64())
.map(|n| n as u32)
.filter(|&n| n > 0)
.unwrap_or(FB_H);
let clamped = Uint8ClampedArray::new(&buffer);
let mut bytes = vec![0u8; clamped.length() as usize];
clamped.copy_to(&mut bytes[..]);
let canvas = ctx.canvas();
if let Some(canvas) = canvas {
if canvas.width() != w {
canvas.set_width(w);
}
if canvas.height() != h {
canvas.set_height(h);
}
}
if let Ok(img) =
ImageData::new_with_u8_clamped_array_and_sh(Clamped(&bytes[..]), w, h)
{
let _ = ctx.put_image_data(&img, 0.0, 0.0);
}
}
fn handle_audio(data: &JsValue) {
let op = Reflect::get(data, &JsValue::from_str("op"))
.ok()
.and_then(|v| v.as_string())
.unwrap_or_default();
let args = Reflect::get(data, &JsValue::from_str("args")).unwrap_or(JsValue::NULL);
let arg = |i: u32| -> i32 {
Reflect::get_u32(&args, i)
.ok()
.and_then(|v| v.as_f64())
.unwrap_or(0.0) as i32
};
match op.as_str() {
"tone" => { audio::play_tone(arg(0), arg(1), arg(2), 0); }
"tone_at" => { audio::play_tone(arg(0), arg(1), arg(2), arg(3)); }
"noise" => { audio::play_noise(arg(0)); }
"stop" => audio::stop_handle(arg(0)),
"set_volume" => audio::set_master_volume(arg(0)),
_ => {}
}
}
fn arm_watchdog(
worker: Worker,
ctx: CanvasRenderingContext2d,
last_frame: Rc<Cell<f64>>,
terminated: Rc<Cell<bool>>,
interval_id: Rc<Cell<Option<i32>>>,
run_gen: u32,
) -> Option<Closure<dyn FnMut()>> {
let cb = {
let interval_id = interval_id.clone();
Closure::<dyn FnMut()>::new(move || {
if terminated.get() {
return;
}
if js_sys::Date::now() - last_frame.get() > WATCHDOG_MS {
terminated.set(true);
worker.terminate();
record_outcome(
run_gen,
RunOutcome::Failed {
code: Some(crate::error_codes::FRAME_TIMEOUT),
detail: format!(
"no frame within {WATCHDOG_MS}ms — the watchdog \
terminated the hung cartridge"
),
},
);
paint_stopped_overlay_coded(&ctx, crate::error_codes::FRAME_TIMEOUT);
if let Some(id) = interval_id.take() {
if let Ok(win) = dom::window() {
win.clear_interval_with_handle(id);
}
}
}
})
};
let id = dom::window().ok().and_then(|win| {
win.set_interval_with_callback_and_timeout_and_arguments_0(
cb.as_ref().unchecked_ref(),
WATCHDOG_TICK_MS,
)
.ok()
});
interval_id.set(id);
Some(cb)
}
fn paint_stopped_overlay_coded(ctx: &CanvasRenderingContext2d, code: u16) {
let mut buf = vec![0u8; (FB_W * FB_H * 4) as usize];
for px in buf.chunks_exact_mut(4) {
px[3] = 255; }
let vp = crate::raster::Viewport::full(FB_W as i32, FB_H as i32);
let label = crate::error_codes::fmt_label(code);
let meaning = crate::error_codes::lookup(code)
.map(|e| e.meaning.to_uppercase())
.unwrap_or_else(|| "RELOAD TO RETRY".to_string());
let header = format!("CARTRIDGE STOPPED {label}");
let owned = [header, meaning];
let lines: [&str; 2] = [owned[0].as_str(), owned[1].as_str()];
let mut y = (FB_H as i32) / 2 - 8;
for line in lines {
let advance = 6; let width = line.len() as i32 * advance;
let mut x = ((FB_W as i32) - width) / 2;
for ch in line.chars() {
crate::raster::blit_glyph(
&mut buf, FB_W as i32, &vp, x, y, ch as u32, (200, 200, 200), 1,
);
x += advance;
}
y += 12;
}
if let Ok(img) =
ImageData::new_with_u8_clamped_array_and_sh(Clamped(&buf[..]), FB_W, FB_H)
{
let _ = ctx.put_image_data(&img, 0.0, 0.0);
}
}
}
struct HtmlBlock {
text: String,
scale: i32,
bullet: bool,
}
fn tag_name(inner: &str) -> String {
let t = inner.trim().trim_start_matches('/').trim_start();
let end = t
.find(|ch: char| ch.is_whitespace() || ch == '/')
.unwrap_or(t.len());
t[..end].to_ascii_lowercase()
}
fn decode_entities(s: &str) -> String {
s.replace("<", "<")
.replace(">", ">")
.replace(""", "\"")
.replace("'", "'")
.replace("'", "'")
.replace(" ", " ")
.replace("&", "&")
}
fn collapse_ws(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut prev_space = false;
for ch in s.chars() {
if ch.is_whitespace() {
if !prev_space && !out.is_empty() {
out.push(' ');
}
prev_space = true;
} else {
out.push(ch);
prev_space = false;
}
}
out.trim_end().to_string()
}
fn flush_block(blocks: &mut Vec<HtmlBlock>, cur: &mut String, scale: i32, bullet: bool) {
let text = collapse_ws(&decode_entities(cur));
cur.clear();
if !text.is_empty() {
blocks.push(HtmlBlock { text, scale, bullet });
}
}
fn html_to_blocks(src: &str) -> Vec<HtmlBlock> {
let chars: Vec<char> = src.chars().collect();
let mut blocks: Vec<HtmlBlock> = Vec::new();
let mut cur = String::new();
let mut scale: i32 = 1;
let mut bullet = false;
let mut skip_tag: Option<String> = None;
let mut i = 0;
while i < chars.len() {
let c = chars[i];
if c == '<' {
let mut j = i + 1;
let mut inner = String::new();
while j < chars.len() && chars[j] != '>' {
inner.push(chars[j]);
j += 1;
}
i = if j < chars.len() { j + 1 } else { j };
let closing = inner.trim_start().starts_with('/');
let name = tag_name(&inner);
if let Some(skip) = skip_tag.clone() {
if closing && name == skip {
skip_tag = None;
}
continue;
}
match name.as_str() {
"script" | "style" | "head" => {
if !closing {
skip_tag = Some(name);
}
}
"br" | "hr" => {
flush_block(&mut blocks, &mut cur, scale, bullet);
bullet = false;
}
"h1" | "h2" | "h3" | "h4" | "h5" | "h6" => {
flush_block(&mut blocks, &mut cur, scale, bullet);
bullet = false;
scale = if closing {
1
} else if name == "h1" {
3
} else {
2
};
}
"li" => {
flush_block(&mut blocks, &mut cur, scale, bullet);
scale = 1;
bullet = !closing;
}
"p" | "div" | "ul" | "ol" | "section" | "article" | "header" | "footer"
| "nav" | "main" | "blockquote" | "pre" | "table" | "tr" | "title" | "body"
| "html" | "figure" | "figcaption" => {
flush_block(&mut blocks, &mut cur, scale, bullet);
bullet = false;
scale = 1;
}
_ => { }
}
continue;
}
if skip_tag.is_some() {
i += 1;
continue;
}
cur.push(c);
i += 1;
}
flush_block(&mut blocks, &mut cur, scale, bullet);
blocks
}
fn wrap_text(text: &str, max_chars: usize) -> Vec<String> {
let max_chars = max_chars.max(1);
let mut lines: Vec<String> = Vec::new();
let mut line = String::new();
for word in text.split_whitespace() {
if line.is_empty() {
line.push_str(word);
} else if line.chars().count() + 1 + word.chars().count() <= max_chars {
line.push(' ');
line.push_str(word);
} else {
lines.push(std::mem::take(&mut line));
line.push_str(word);
}
while line.chars().count() > max_chars {
let head: String = line.chars().take(max_chars).collect();
let tail: String = line.chars().skip(max_chars).collect();
lines.push(head);
line = tail;
}
}
if !line.is_empty() {
lines.push(line);
}
lines
}
fn filled_framebuffer(color: (u8, u8, u8)) -> Vec<u8> {
let (r, g, b) = color;
let mut buf = vec![0u8; FB_BYTES];
let mut i = 0;
while i + 3 < buf.len() {
buf[i] = r;
buf[i + 1] = g;
buf[i + 2] = b;
buf[i + 3] = 255;
i += 4;
}
buf
}
fn paint_html_fb(blocks: &[HtmlBlock]) -> Vec<u8> {
let mut buf = filled_framebuffer((13, 13, 13));
let left = 6i32;
let right = FB_W as i32 - 6;
let mut y = 6i32;
for block in blocks {
let scale = block.scale.clamp(1, 3);
let advance = 6 * scale; let line_h = 8 * scale; let max_chars = (((right - left) / advance).max(1)) as usize;
let color = if scale > 1 { (245, 245, 245) } else { (205, 205, 205) };
let text = if block.bullet {
format!("- {}", block.text)
} else {
block.text.clone()
};
for line in wrap_text(&text, max_chars) {
if y + line_h > FB_H as i32 {
return buf; }
let mut x = left;
let vp = crate::raster::Viewport::full(FB_W as i32, FB_H as i32);
for ch in line.chars() {
crate::raster::blit_glyph(&mut buf, FB_W as i32, &vp, x, y, ch as u32, color, scale);
x += advance;
}
y += line_h;
}
y += 3; }
buf
}
mod net {
use std::cell::RefCell;
use std::collections::VecDeque;
use std::rc::Rc;
use js_sys::{Object, Reflect};
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use web_sys::{MessageEvent, WebSocket};
use super::SharedMemory;
const MAX_INBOX: usize = 256;
const MAX_SOCKETS: usize = 8;
fn url_is_allowed(url: &str) -> bool {
let rest = match url
.split_once("://")
.filter(|(scheme, _)| scheme.eq_ignore_ascii_case("wss"))
{
Some((_, rest)) => rest,
None => return false,
};
let authority = rest
.split(['/', '?', '#'])
.next()
.unwrap_or("");
let hostport = authority.rsplit_once('@').map(|(_, h)| h).unwrap_or(authority);
if hostport.starts_with('[') {
return false; }
let host = hostport.split(':').next().unwrap_or("");
if host.is_empty() {
return false;
}
let lower = host.to_ascii_lowercase();
if lower == "localhost"
|| lower.ends_with(".localhost")
|| lower.ends_with(".local")
{
return false;
}
if lower.split('.').count() == 4
&& lower.split('.').all(|o| !o.is_empty() && o.bytes().all(|b| b.is_ascii_digit()))
{
return false;
}
lower.contains('.')
}
struct Socket {
ws: WebSocket,
inbox: Rc<RefCell<VecDeque<String>>>,
_on_message: Closure<dyn FnMut(MessageEvent)>,
}
type SocketTable = Rc<RefCell<Vec<Option<Socket>>>>;
#[allow(dead_code)]
pub(super) struct NetRuntime {
sockets: SocketTable,
open: Closure<dyn FnMut(i32) -> i32>,
send: Closure<dyn FnMut(i32, i32) -> i32>,
poll: Closure<dyn FnMut(i32, i32, i32) -> i32>,
status: Closure<dyn FnMut(i32) -> i32>,
close: Closure<dyn FnMut(i32)>,
}
pub(super) fn build_host_net(
imports: &Object,
mem: &SharedMemory,
) -> Result<NetRuntime, JsValue> {
let sockets: SocketTable = Rc::new(RefCell::new(Vec::new()));
let open = {
let sockets = sockets.clone();
let mem = mem.clone();
Closure::<dyn FnMut(i32) -> i32>::new(move |url_ptr: i32| {
let url = match read_string(&mem.borrow(), url_ptr) {
Some(u) => u,
None => return -1,
};
if !url_is_allowed(&url) {
return -1;
}
let free_slot = {
let table = sockets.borrow();
let live = table.iter().filter(|s| s.is_some()).count();
if live >= MAX_SOCKETS {
return -1;
}
table.iter().position(|s| s.is_none())
};
let ws = match WebSocket::new(&url) {
Ok(ws) => ws,
Err(_) => return -1,
};
ws.set_binary_type(web_sys::BinaryType::Arraybuffer);
let inbox: Rc<RefCell<VecDeque<String>>> =
Rc::new(RefCell::new(VecDeque::new()));
let on_message = {
let inbox = inbox.clone();
Closure::<dyn FnMut(MessageEvent)>::new(move |e: MessageEvent| {
if let Some(text) = e.data().as_string() {
let mut q = inbox.borrow_mut();
if q.len() >= MAX_INBOX {
q.pop_front();
}
q.push_back(text);
}
})
};
ws.set_onmessage(Some(on_message.as_ref().unchecked_ref()));
let socket = Socket { ws, inbox, _on_message: on_message };
let mut table = sockets.borrow_mut();
match free_slot {
Some(i) => {
table[i] = Some(socket);
i as i32
}
None => {
let handle = table.len() as i32;
table.push(Some(socket));
handle
}
}
})
};
let send = {
let sockets = sockets.clone();
let mem = mem.clone();
Closure::<dyn FnMut(i32, i32) -> i32>::new(move |handle: i32, ptr: i32| {
let msg = match read_string(&mem.borrow(), ptr) {
Some(m) => m,
None => return 0,
};
let table = sockets.borrow();
match table.get(handle as usize).and_then(|s| s.as_ref()) {
Some(sock) => match sock.ws.send_with_str(&msg) {
Ok(()) => 1,
Err(_) => 0,
},
None => 0,
}
})
};
let poll = {
let sockets = sockets.clone();
let mem = mem.clone();
Closure::<dyn FnMut(i32, i32, i32) -> i32>::new(
move |handle: i32, out_ptr: i32, max: i32| {
let table = sockets.borrow();
let sock = match table.get(handle as usize).and_then(|s| s.as_ref()) {
Some(s) => s,
None => return -1,
};
let msg = match sock.inbox.borrow_mut().pop_front() {
Some(m) => m,
None => return 0,
};
write_string(&mem.borrow(), out_ptr, &msg, max.max(0) as usize)
},
)
};
let status = {
let sockets = sockets.clone();
Closure::<dyn FnMut(i32) -> i32>::new(move |handle: i32| {
let table = sockets.borrow();
match table.get(handle as usize).and_then(|s| s.as_ref()) {
Some(sock) => sock.ws.ready_state() as i32,
None => -1,
}
})
};
let close = {
let sockets = sockets.clone();
Closure::<dyn FnMut(i32)>::new(move |handle: i32| {
let mut table = sockets.borrow_mut();
if let Some(slot) = table.get_mut(handle as usize) {
if let Some(sock) = slot.take() {
let _ = sock.ws.close();
}
}
})
};
let host_net = Object::new();
super::set_fn(&host_net, "open", &open)?;
super::set_fn(&host_net, "send", &send)?;
super::set_fn(&host_net, "poll", &poll)?;
super::set_fn(&host_net, "status", &status)?;
super::set_fn(&host_net, "close", &close)?;
Reflect::set(imports, &JsValue::from_str("host_net"), &host_net)?;
Ok(NetRuntime { sockets, open, send, poll, status, close })
}
fn read_string(memory: &JsValue, ptr: i32) -> Option<String> {
if ptr < 0 || memory.is_null() {
return None;
}
let buffer = Reflect::get(memory, &JsValue::from_str("buffer")).ok()?;
let array = js_sys::Uint8Array::new(&buffer);
let cap = array.length() as u64;
let ptr = ptr as u64;
if ptr + 4 > cap {
return None;
}
let mut len_bytes = [0u8; 4];
for (i, b) in len_bytes.iter_mut().enumerate() {
*b = array.get_index(ptr as u32 + i as u32);
}
let len = u32::from_le_bytes(len_bytes) as u64;
if len > 65536 || ptr + 4 + len > cap {
return None;
}
let mut bytes = vec![0u8; len as usize];
for (i, b) in bytes.iter_mut().enumerate() {
*b = array.get_index(ptr as u32 + 4 + i as u32);
}
String::from_utf8(bytes).ok()
}
fn write_string(memory: &JsValue, out_ptr: i32, s: &str, max: usize) -> i32 {
if out_ptr < 0 || memory.is_null() {
return -1;
}
let buffer = match Reflect::get(memory, &JsValue::from_str("buffer")) {
Ok(b) => b,
Err(_) => return -1,
};
let array = js_sys::Uint8Array::new(&buffer);
let cap = array.length() as u64;
let ptr = out_ptr as u64;
let mut end = s.len().min(max);
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
let bytes = &s.as_bytes()[..end];
let len = bytes.len() as u32;
if ptr + 4 + len as u64 > cap {
return -1;
}
let ptr = ptr as u32;
for (i, b) in len.to_le_bytes().iter().enumerate() {
array.set_index(ptr + i as u32, *b);
}
for (i, b) in bytes.iter().enumerate() {
array.set_index(ptr + 4 + i as u32, *b);
}
len as i32
}
}
mod audio {
use std::cell::RefCell;
use js_sys::{Function, Object, Reflect};
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use web_sys::{AudioContext, GainNode, OscillatorType};
const MAX_VOICES: usize = 64;
thread_local! {
static ENGINE: RefCell<Option<Engine>> = const { RefCell::new(None) };
}
struct Engine {
ctx: AudioContext,
master: GainNode,
voices: Vec<Option<Voice>>,
}
struct Voice {
node: JsValue,
_onended: Closure<dyn FnMut()>,
}
#[allow(dead_code)]
pub(super) struct AudioRuntime {
tone: Closure<dyn FnMut(i32, i32, i32) -> i32>,
tone_at: Closure<dyn FnMut(i32, i32, i32, i32) -> i32>,
noise: Closure<dyn FnMut(i32) -> i32>,
stop: Closure<dyn FnMut(i32)>,
set_volume: Closure<dyn FnMut(i32)>,
}
fn with_engine<R>(f: impl FnOnce(&mut Engine) -> R) -> Option<R> {
ENGINE.with(|cell| {
let mut slot = cell.borrow_mut();
if slot.is_none() {
let ctx = AudioContext::new().ok()?;
let master = ctx.create_gain().ok()?;
master.gain().set_value(0.3);
let _ = master.connect_with_audio_node(&ctx.destination());
*slot = Some(Engine { ctx, master, voices: Vec::new() });
}
let eng = slot.as_mut()?;
let _ = eng.ctx.resume();
Some(f(eng))
})
}
fn push_voice(eng: &mut Engine, voice: Voice) -> i32 {
let live = eng.voices.iter().filter(|v| v.is_some()).count();
if live >= MAX_VOICES {
if let Some(slot) = eng.voices.iter_mut().find(|s| s.is_some()) {
if let Some(old) = slot.take() {
stop_node(&old.node);
}
}
}
if let Some(i) = eng.voices.iter().position(|s| s.is_none()) {
eng.voices[i] = Some(voice);
i as i32
} else {
eng.voices.push(Some(voice));
(eng.voices.len() - 1) as i32
}
}
fn stop_node(node: &JsValue) {
if let Ok(f) = Reflect::get(node, &JsValue::from_str("stop")) {
if let Ok(f) = f.dyn_into::<Function>() {
let _ = f.call0(node);
}
}
}
fn osc_type(wave: i32) -> OscillatorType {
match wave {
1 => OscillatorType::Square,
2 => OscillatorType::Sawtooth,
3 => OscillatorType::Triangle,
_ => OscillatorType::Sine,
}
}
pub(super) fn play_tone(freq: i32, dur_ms: i32, wave: i32, delay_ms: i32) -> i32 {
with_engine(|eng| {
let osc = match eng.ctx.create_oscillator() {
Ok(o) => o,
Err(_) => return -1,
};
let gain = match eng.ctx.create_gain() {
Ok(g) => g,
Err(_) => return -1,
};
osc.set_type(osc_type(wave));
osc.frequency().set_value(freq.max(1) as f32);
let t0 = eng.ctx.current_time() + (delay_ms.max(0) as f64) / 1000.0;
let dur = (dur_ms.max(1) as f64) / 1000.0;
let g = gain.gain();
let _ = g.set_value_at_time(0.0, t0);
let _ = g.linear_ramp_to_value_at_time(1.0, t0 + 0.004);
let _ = g.set_value_at_time(1.0, (t0 + dur - 0.004).max(t0 + 0.004));
let _ = g.linear_ramp_to_value_at_time(0.0, t0 + dur);
let _ = osc.connect_with_audio_node(&gain);
let _ = gain.connect_with_audio_node(&eng.master);
let _ = osc.start_with_when(t0);
let _ = osc.stop_with_when(t0 + dur);
let node: JsValue = osc.clone().into();
let onended = Closure::<dyn FnMut()>::new(move || {});
osc.set_onended(Some(onended.as_ref().unchecked_ref()));
push_voice(eng, Voice { node, _onended: onended })
})
.unwrap_or(-1)
}
pub(super) fn play_noise(dur_ms: i32) -> i32 {
with_engine(|eng| {
let sr = eng.ctx.sample_rate();
let frames = sr as u32; let buf = match eng.ctx.create_buffer(1, frames, sr) {
Ok(b) => b,
Err(_) => return -1,
};
let mut data = vec![0f32; frames as usize];
let mut s: u32 = 0x2545_F491;
for x in data.iter_mut() {
s = s.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
*x = ((s >> 8) as f32 / 8_388_608.0) - 1.0;
}
if buf.copy_to_channel(&data, 0).is_err() {
return -1;
}
let src = match eng.ctx.create_buffer_source() {
Ok(s) => s,
Err(_) => return -1,
};
src.set_buffer(Some(&buf));
let gain = match eng.ctx.create_gain() {
Ok(g) => g,
Err(_) => return -1,
};
let t0 = eng.ctx.current_time();
let dur = (dur_ms.max(1) as f64) / 1000.0;
let g = gain.gain();
let _ = g.set_value_at_time(0.8, t0);
let _ = g.linear_ramp_to_value_at_time(0.0, t0 + dur);
let _ = src.connect_with_audio_node(&gain);
let _ = gain.connect_with_audio_node(&eng.master);
let _ = src.start_with_when(t0);
let scheduled: &web_sys::AudioScheduledSourceNode = src.as_ref();
let _ = scheduled.stop_with_when(t0 + dur);
let node: JsValue = src.clone().into();
let onended = Closure::<dyn FnMut()>::new(move || {});
scheduled.set_onended(Some(onended.as_ref().unchecked_ref()));
push_voice(eng, Voice { node, _onended: onended })
})
.unwrap_or(-1)
}
pub(super) fn stop_handle(handle: i32) {
ENGINE.with(|cell| {
if let Some(eng) = cell.borrow_mut().as_mut() {
if handle < 0 {
for slot in eng.voices.iter_mut() {
if let Some(v) = slot.take() {
stop_node(&v.node);
}
}
} else if let Some(slot) = eng.voices.get_mut(handle as usize) {
if let Some(v) = slot.take() {
stop_node(&v.node);
}
}
}
});
}
pub(super) fn set_master_volume(pct: i32) {
with_engine(|eng| {
eng.master.gain().set_value((pct.clamp(0, 100) as f32) / 100.0);
});
}
pub(super) fn build_host_audio(imports: &Object) -> Result<AudioRuntime, JsValue> {
let tone = Closure::<dyn FnMut(i32, i32, i32) -> i32>::new(
move |freq: i32, dur_ms: i32, wave: i32| play_tone(freq, dur_ms, wave, 0),
);
let tone_at = Closure::<dyn FnMut(i32, i32, i32, i32) -> i32>::new(
move |freq: i32, dur_ms: i32, wave: i32, delay_ms: i32| {
play_tone(freq, dur_ms, wave, delay_ms)
},
);
let noise = Closure::<dyn FnMut(i32) -> i32>::new(move |dur_ms: i32| play_noise(dur_ms));
let stop = Closure::<dyn FnMut(i32)>::new(move |handle: i32| stop_handle(handle));
let set_volume = Closure::<dyn FnMut(i32)>::new(move |pct: i32| set_master_volume(pct));
let host_audio = Object::new();
super::set_fn(&host_audio, "tone", &tone)?;
super::set_fn(&host_audio, "tone_at", &tone_at)?;
super::set_fn(&host_audio, "noise", &noise)?;
super::set_fn(&host_audio, "stop", &stop)?;
super::set_fn(&host_audio, "set_volume", &set_volume)?;
Reflect::set(imports, &JsValue::from_str("host_audio"), &host_audio)?;
Ok(AudioRuntime { tone, tone_at, noise, stop, set_volume })
}
pub(super) fn stop_all() {
ENGINE.with(|cell| {
if let Some(eng) = cell.borrow_mut().as_mut() {
for slot in eng.voices.iter_mut() {
if let Some(v) = slot.take() {
stop_node(&v.node);
}
}
let _ = eng.ctx.suspend();
}
});
}
}