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 crate::app::dom;
use super::bridge::{audio, chat, compose, feed, http, mp, receipts};
use super::{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) {
if let RunOutcome::Failed { code, detail } = &outcome {
if crate::app::telemetry::enabled() {
let code = *code;
let detail = detail.clone();
let fp: String = detail
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.take(40)
.collect();
let signature = format!("cartridge-{fp}");
let title = format!(
"cartridge failed: {}",
detail.chars().take(100).collect::<String>()
);
let freeform = match super::cartridge_ref() {
Some(r) if !r.is_empty() => format!("{detail}\n\n{r}"),
_ => detail,
};
wasm_bindgen_futures::spawn_local(crate::app::telemetry::report_event(
"cartridge".to_string(),
code,
title,
signature,
freeform,
String::new(),
));
}
}
*o = outcome;
}
});
}
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> {
let bytes = wasm_bytes.to_vec();
spawn_worker(ctx, move |worker| {
let arr = Uint8Array::from(&bytes[..]);
let msg = Object::new();
Reflect::set(&msg, &JsValue::from_str("type"), &JsValue::from_str("load"))?;
Reflect::set(&msg, &JsValue::from_str("wasm"), &arr.buffer())?;
attach_viewer_context(&msg)?;
worker
.post_message(&msg)
.map_err(|e| JsValue::from_str(&format!("worker post failed: {e:?}")))
})
}
pub(super) fn spawn_composition(
slots: Vec<(String, crate::raster::Viewport)>,
ctx: CanvasRenderingContext2d,
) -> Result<(), JsValue> {
spawn_worker(ctx, move |worker| {
let arr = js_sys::Array::new();
for (name, vp) in &slots {
let s = Object::new();
Reflect::set(&s, &JsValue::from_str("name"), &JsValue::from_str(name))?;
Reflect::set(&s, &JsValue::from_str("x"), &JsValue::from_f64(vp.ox as f64))?;
Reflect::set(&s, &JsValue::from_str("y"), &JsValue::from_f64(vp.oy as f64))?;
Reflect::set(&s, &JsValue::from_str("w"), &JsValue::from_f64(vp.w as f64))?;
Reflect::set(&s, &JsValue::from_str("h"), &JsValue::from_f64(vp.h as f64))?;
arr.push(&s);
}
let msg = Object::new();
Reflect::set(&msg, &JsValue::from_str("type"), &JsValue::from_str("compose_load"))?;
Reflect::set(&msg, &JsValue::from_str("slots"), &arr)?;
attach_viewer_context(&msg)?;
worker
.post_message(&msg)
.map_err(|e| JsValue::from_str(&format!("worker post failed: {e:?}")))
})
}
fn attach_viewer_context(msg: &Object) -> Result<(), JsValue> {
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 }),
)?;
Ok(())
}
fn spawn_worker(
ctx: CanvasRenderingContext2d,
post_load: impl FnOnce(&Worker) -> Result<(), JsValue>,
) -> 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);
receipts::handle_frame_calls(&data);
}
"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" => feed::set_feed_cartridge_active(true),
"agent_subscribe" => {
let w = worker_for_msg.clone();
wasm_bindgen_futures::spawn_local(feed::do_feed_subscribe(w, true));
}
"agent_unsubscribe" => {
let w = worker_for_msg.clone();
wasm_bindgen_futures::spawn_local(feed::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(feed::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::surface::open_broadcast_composer(&title, &body);
}
}
"agent_request_identity" => {
let w = worker_for_msg.clone();
wasm_bindgen_futures::spawn_local(feed::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(compose::do_compose_spawn(w, uid, name));
}
}
"http_fetch" => {
let id = Reflect::get(&data, &JsValue::from_str("id"))
.ok().and_then(|v| v.as_f64()).map(|n| n as i32).unwrap_or(-1);
let url = Reflect::get(&data, &JsValue::from_str("url"))
.ok().and_then(|v| v.as_string()).unwrap_or_default();
if id >= 0 && !url.is_empty() {
let w = worker_for_msg.clone();
wasm_bindgen_futures::spawn_local(http::do_http_fetch(w, id, url));
}
}
"mp:host" | "mp:join" => {
let code = Reflect::get(&data, &JsValue::from_str("room"))
.ok().and_then(|v| v.as_f64()).map(|n| n as i32).unwrap_or(0);
let is_host = ty == "mp:host";
wasm_bindgen_futures::spawn_local(mp::mp_connect(
worker_for_msg.clone(), code, is_host,
));
}
"mp:auto" => {
let code = Reflect::get(&data, &JsValue::from_str("room"))
.ok().and_then(|v| v.as_f64()).map(|n| n as i32).unwrap_or(0);
wasm_bindgen_futures::spawn_local(mp::mp_connect_mesh(
worker_for_msg.clone(), code,
));
}
"mp:deltas" => mp::mp_send(Some(mp::mp_read_int_array(&data, "deltas")), None),
"mp:events" => mp::mp_send(None, Some(mp::mp_read_int_array(&data, "events"))),
"mp:leave" => mp::mp_teardown(),
"chat:start" => chat::chat_start(worker_for_msg.clone()),
"chat:send" => {
let text = Reflect::get(&data, &JsValue::from_str("text"))
.ok().and_then(|v| v.as_string()).unwrap_or_default();
if !text.is_empty() {
chat::chat_send(text);
}
}
"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()));
post_load(&worker)?;
{
let w = worker.clone();
wasm_bindgen_futures::spawn_local(feed::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() {
mp::mp_teardown(); chat::chat_stop(); 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()
}
pub(crate) async fn one_shot_lib_call(
wasm: &[u8],
fn_name: &str,
args: &[i32],
) -> Result<(i32, Option<i32>), String> {
let worker = Worker::new(&worker_url()).map_err(|e| format!("worker spawn: {e:?}"))?;
type CallOutcome = Option<(i32, Option<i32>)>;
let outcome: Rc<RefCell<CallOutcome>> = Rc::new(RefCell::new(None));
let outcome_w = outcome.clone();
let onmessage = 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();
if ty == "lib_call_result" {
let status = Reflect::get(&data, &JsValue::from_str("status"))
.ok()
.and_then(|v| v.as_f64())
.unwrap_or(-2.0) as i32;
let result = Reflect::get(&data, &JsValue::from_str("result"))
.ok()
.and_then(|v| v.as_f64())
.map(|f| f as i32);
*outcome_w.borrow_mut() = Some((status, result));
}
});
worker.set_onmessage(Some(onmessage.as_ref().unchecked_ref()));
let msg = Object::new();
let _ = Reflect::set(&msg, &JsValue::from_str("type"), &JsValue::from_str("lib_call"));
let _ = Reflect::set(&msg, &JsValue::from_str("fn"), &JsValue::from_str(fn_name));
let arr = js_sys::Array::new();
for a in args {
arr.push(&JsValue::from_f64(*a as f64));
}
let _ = Reflect::set(&msg, &JsValue::from_str("args"), &arr);
let bytes = Uint8Array::from(wasm);
let buf = bytes.buffer();
let _ = Reflect::set(&msg, &JsValue::from_str("wasm"), &buf);
let transfer = js_sys::Array::new();
transfer.push(&buf);
if let Err(e) = worker.post_message_with_transfer(&msg, &transfer) {
worker.terminate();
return Err(format!("post: {e:?}"));
}
let deadline_ms = 4_000u32;
let step_ms = 50u32;
let mut waited = 0u32;
let result = loop {
if let Some(out) = *outcome.borrow() {
break Ok(out);
}
if waited >= deadline_ms {
break Err("no answer within 4s (hung or invalid module)".to_string());
}
crate::runtime::sleep_ms(step_ms).await;
waited += step_ms;
};
worker.terminate();
drop(onmessage);
result
}
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);
if clamped.length() as usize > 4 * 1024 * 1024 {
return;
}
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);
}
}