use std::cell::{Cell, RefCell};
use js_sys::{Object, Reflect};
use wasm_bindgen::prelude::*;
use wasm_bindgen::{Clamped, JsCast};
use web_sys::{CanvasRenderingContext2d, HtmlCanvasElement, ImageData};
use super::dom;
use super::templates;
const FB_W: u32 = 512;
const FB_H: u32 = 512;
const FB_BYTES: usize = (FB_W * FB_H * 4) as usize;
thread_local! {
static FRAME_GEN: Cell<u32> = const { Cell::new(0) };
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"));
}
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) fn run_wasm_inline(wasm_bytes: &[u8]) {
remember_last_cartridge(wasm_bytes);
stash_pending_embed(wasm_bytes.to_vec());
}
fn remember_last_cartridge(wasm: &[u8]) {
LAST_CARTRIDGE.with(|c| *c.borrow_mut() = Some(wasm.to_vec()));
}
thread_local! {
static LAST_CARTRIDGE: RefCell<Option<Vec<u8>>> = const { RefCell::new(None) };
}
pub(crate) async fn relaunch_last_in_fullscreen() {
let Some(wasm) = LAST_CARTRIDGE.with(|c| c.borrow().clone()) else {
crate::app::opfs::toggle_display();
return;
};
if let Err(e) = run_wasm(&wasm).await {
embed_trace(&format!("fullscreen relaunch failed: {e:?}"));
}
}
pub(crate) async fn run_wasm_reporting(wasm_bytes: &[u8]) -> Result<(), RunFailure> {
run_wasm_inline(wasm_bytes);
Ok(())
}
#[allow(dead_code)]
pub(crate) async fn run_wasm_reporting_fullscreen(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();
audio::stop_all();
POINTER_DOWN.with(|d| d.set(0));
worker::spawn_cartridge(wasm_bytes, ctx)
}
pub(crate) async fn mount_composition(names: Vec<String>) -> Result<(), JsValue> {
let ctx = size_and_get_ctx()?;
ACTIVE_CANVAS_ID.with(|c| *c.borrow_mut() = "display-canvas".to_string());
FRAME_GEN.with(|g| g.set(g.get().wrapping_add(1)));
audio::stop_all();
POINTER_DOWN.with(|d| d.set(0));
if names.is_empty() {
return Err(JsValue::from_str("compose: no module to composite"));
}
let viewports = crate::compose::grid_viewports(names.len(), FB_W as i32, FB_H as i32);
let slots: Vec<(String, crate::raster::Viewport)> =
names.into_iter().zip(viewports).collect();
worker::spawn_composition(slots, ctx)
}
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 (rect_w, rect_h) = (rect.width(), rect.height());
if rect_w <= 0.0 || rect_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()) * (fb_w as f64 / rect_w)).clamp(0.0, (fb_w - 1) as f64) as i32;
let fy = ((client_y - rect.top()) * (fb_h as f64 / rect_h)).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 CARTRIDGE_REF: RefCell<Option<String>> = const { RefCell::new(None) };
}
pub(crate) fn set_cartridge_ref(reference: Option<String>) {
let capped = reference.map(|r| {
if r.chars().count() > 6000 {
let mut s: String = r.chars().take(6000).collect();
s.push_str("\n…(truncated)");
s
} else {
r
}
});
CARTRIDGE_REF.with(|c| *c.borrow_mut() = capped);
}
pub(super) fn cartridge_ref() -> Option<String> {
CARTRIDGE_REF.with(|c| c.borrow().clone())
}
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()
}
pub(crate) fn stop() {
FRAME_GEN.with(|g| g.set(g.get().wrapping_add(1)));
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);
}
}
}
async fn do_http_fetch(worker: web_sys::Worker, id: i32, url: String) {
let Some((signer, _)) = crate::app::chat::credit_signer().await else {
post_http_result(&worker, id, None);
return;
};
let now = (js_sys::Date::now() / 1000.0) as u64;
let token = crate::registry::proxy_auth_token(&signer, now);
let endpoint = format!(
"{}api/fetch",
crate::registry::CREDIT_PROXY_URL
);
let send = async {
let resp = reqwest::Client::new()
.post(&endpoint)
.header("content-type", "application/json")
.header("x-goog-api-key", token)
.json(&serde_json::json!({ "url": url }))
.send()
.await
.map_err(|e| format!("proxy request: {e}"))?;
let status = resp.status();
let body = resp
.json::<serde_json::Value>()
.await
.map_err(|e| format!("proxy response decode: {e}"))?;
Ok::<_, String>((status, body))
};
match crate::app::net::with_timeout(20_000, send).await {
Ok(Ok((status, body))) if status.is_success() => {
let upstream = body.get("status").and_then(|v| v.as_i64()).unwrap_or(0) as i32;
let text = body.get("body").and_then(|v| v.as_str()).unwrap_or("");
post_http_result_ok(&worker, id, upstream, text);
}
_ => post_http_result(&worker, id, None),
}
}
fn post_http_result_ok(worker: &web_sys::Worker, id: i32, status: i32, body: &str) {
let msg = Object::new();
let _ = Reflect::set(&msg, &JsValue::from_str("type"), &JsValue::from_str("http_result"));
let _ = Reflect::set(&msg, &JsValue::from_str("id"), &JsValue::from_f64(id as f64));
let _ = Reflect::set(&msg, &JsValue::from_str("status"), &JsValue::from_f64(status as f64));
let _ = Reflect::set(&msg, &JsValue::from_str("body"), &JsValue::from_str(body));
let _ = worker.post_message(&msg);
}
fn post_http_result(worker: &web_sys::Worker, id: i32, ok: Option<(i32, &str)>) {
match ok {
Some((status, body)) => post_http_result_ok(worker, id, status, body),
None => {
let msg = Object::new();
let _ = Reflect::set(&msg, &JsValue::from_str("type"), &JsValue::from_str("http_result"));
let _ = Reflect::set(&msg, &JsValue::from_str("id"), &JsValue::from_f64(id as f64));
let _ = Reflect::set(&msg, &JsValue::from_str("error"), &JsValue::TRUE);
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) {
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;
}
});
}
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> {
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);
}
"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));
}
}
"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(super::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_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_connect_mesh(
worker_for_msg.clone(), code,
));
}
"mp:deltas" => mp_send(Some(mp_read_int_array(&data, "deltas")), None),
"mp:events" => mp_send(None, Some(mp_read_int_array(&data, "events"))),
"mp:leave" => mp_teardown(),
"chat:start" => 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_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(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() {
mp_teardown(); chat_stop(); WORKER.with(|cell| {
if let Some(h) = cell.borrow().as_ref() {
h.terminated.set(true);
}
*cell.borrow_mut() = None;
});
}
const MP_MAX_PEERS: i32 = 8;
const MESH_FRESH_SECS: u64 = 40; const MESH_BEAT_TICKS: u32 = 3;
enum MpRole {
Host {
peers: Vec<(String, i32, crate::app::webrtc::Peer)>,
},
Joiner {
peer: crate::app::webrtc::Peer,
},
Mesh {
peers: Vec<Option<(String, crate::app::webrtc::Peer)>>,
connecting: Vec<bool>,
},
}
struct MpSession {
role: MpRole,
_gw: crate::wallet::GeneratedWallet, #[allow(dead_code)]
room: String,
}
thread_local! {
static MP_SESSION: RefCell<Option<MpSession>> = const { RefCell::new(None) };
}
fn joiner_id_from(addr: &[u8; 20]) -> String {
let mut s = String::with_capacity(8);
for b in &addr[0..4] {
s.push_str(&format!("{b:02x}"));
}
s
}
async fn mp_connect(worker: Worker, code: i32, is_host: bool) {
mp_teardown(); let room = format!("mp-{code}");
let gw = crate::wallet::generate();
let signer = gw.signer.clone();
if is_host {
MP_SESSION.with(|s| {
*s.borrow_mut() = Some(MpSession {
role: MpRole::Host { peers: Vec::new() },
_gw: gw,
room: room.clone(),
});
});
mp_post_status(&worker, 0, 0, 1);
wasm_bindgen_futures::spawn_local(mp_host_accept_loop(worker, room, signer, 1, false));
return;
}
let joiner_id = joiner_id_from(&crate::wallet::address(&signer));
let worker_for_msg = worker.clone();
let on_msg = move |bytes: Vec<u8>| mp_dispatch_peer_frame(&worker_for_msg, 0, &bytes);
match crate::app::webrtc::Peer::offer_to_host(&room, &joiner_id, &signer, on_msg).await {
Ok(peer) => {
for _ in 0..150 {
if peer.is_open() {
break;
}
crate::runtime::sleep_ms(100).await;
}
let connected = i32::from(peer.is_open());
let mut self_index = 1;
for _ in 0..4 {
let roster =
crate::registry::signal_get_joiners(&room).await.unwrap_or_default();
if let Some(pos) = roster.iter().position(|id| id == &joiner_id) {
self_index = pos as i32 + 1;
break;
}
crate::runtime::sleep_ms(300).await;
}
MP_SESSION.with(|s| {
*s.borrow_mut() = Some(MpSession {
role: MpRole::Joiner { peer },
_gw: gw,
room: room.clone(),
});
});
mp_post_status(&worker, connected, self_index, if connected == 1 { 2 } else { 1 });
}
Err(e) => {
web_sys::console::warn_1(&JsValue::from_str(&format!("mp join failed: {e:?}")));
mp_post_status(&worker, 0, -1, 0);
}
}
}
async fn mp_host_accept_loop(
worker: Worker,
room: String,
signer: k256::ecdsa::SigningKey,
idx_base: i32,
skip_first: bool,
) {
loop {
let is_host = MP_SESSION
.with(|s| matches!(s.borrow().as_ref().map(|x| &x.role), Some(MpRole::Host { .. })));
if !is_host {
return;
}
let joiners = crate::registry::signal_get_joiners(&room).await.unwrap_or_default();
for (roster_pos, jid) in joiners.iter().enumerate() {
if skip_first && roster_pos == 0 {
continue; }
let idx = roster_pos as i32 + idx_base;
let known = MP_SESSION.with(|s| {
if let Some(MpSession { role: MpRole::Host { peers }, .. }) = s.borrow().as_ref() {
peers.iter().any(|(id, _, _)| id == jid)
} else {
true
}
});
if known || idx >= MP_MAX_PEERS {
continue;
}
let worker_for_msg = worker.clone();
let on_msg = move |bytes: Vec<u8>| mp_dispatch_peer_frame(&worker_for_msg, idx, &bytes);
match crate::app::webrtc::Peer::answer_joiner(&room, jid, &signer, on_msg).await {
Ok(peer) => {
let count = MP_SESSION.with(|s| {
if let Some(MpSession { role: MpRole::Host { peers }, .. }) =
s.borrow_mut().as_mut()
{
peers.push((jid.clone(), idx, peer));
peers.len() as i32 + 1
} else {
1
}
});
mp_post_status(&worker, i32::from(count >= 2), 0, count);
}
Err(e) => {
web_sys::console::warn_1(&JsValue::from_str(&format!(
"mp answer_joiner failed: {e:?}"
)));
}
}
}
crate::runtime::sleep_ms(2000).await;
}
}
async fn mp_connect_mesh(worker: Worker, code: i32) {
mp_teardown();
let room = format!("mp-{code}");
let gw = crate::wallet::generate();
let signer = gw.signer.clone();
let addr_bytes = crate::wallet::address(&signer);
let my_id = joiner_id_from(&addr_bytes);
let my_addr = crate::encoding::bytes_to_hex_str(&addr_bytes);
let my_slot = match mesh_claim_slot(&room, &signer, &my_id, &my_addr).await {
Ok(s) => s,
Err(e) => {
web_sys::console::warn_1(&JsValue::from_str(&format!("mesh claim failed: {e}")));
mp_post_status(&worker, 0, -1, 0);
return;
}
};
MP_SESSION.with(|s| {
*s.borrow_mut() = Some(MpSession {
role: MpRole::Mesh {
peers: (0..MP_MAX_PEERS).map(|_| None).collect(),
connecting: (0..MP_MAX_PEERS).map(|_| false).collect(),
},
_gw: gw,
room: room.clone(),
});
});
mp_post_status(&worker, 0, my_slot, 1); wasm_bindgen_futures::spawn_local(mesh_loop(worker, room, signer, my_id, my_addr, my_slot));
}
async fn mesh_claim_slot(
room: &str,
signer: &k256::ecdsa::SigningKey,
my_id: &str,
my_addr: &str,
) -> Result<i32, String> {
for _ in 0..6 {
let ms = crate::registry::signal_get_slots(room).await?;
if let Some(pos) = ms.slots.iter().position(|e| {
e.as_ref().map(|x| x.addr.eq_ignore_ascii_case(my_addr)).unwrap_or(false)
}) {
return Ok(pos as i32); }
let free = ms.slots.iter().position(|e| match e {
None => true,
Some(x) => ms.now.saturating_sub(x.ts) > MESH_FRESH_SECS,
});
let idx = free.ok_or_else(|| "arena full (8 players)".to_string())?;
let mut next = ms.slots.clone();
next[idx] = Some(crate::registry::SlotEntry {
id: my_id.to_string(),
addr: my_addr.to_string(),
ts: ms.now,
});
let now = (js_sys::Date::now() / 1000.0) as u64;
match crate::registry::signal_put_slots(signer, now, room, &next, idx, ms.sha.as_deref()).await {
Ok(crate::registry::PutSlots::Written) => return Ok(idx as i32),
Ok(crate::registry::PutSlots::Conflict) => crate::runtime::sleep_ms(250).await,
Err(e) => return Err(e),
}
}
Err("slot claim contention".to_string())
}
async fn mesh_loop(
worker: Worker,
room: String,
signer: k256::ecdsa::SigningKey,
my_id: String,
my_addr: String,
my_slot: i32,
) {
let mut tick: u32 = 0;
loop {
let is_mesh = MP_SESSION
.with(|s| matches!(s.borrow().as_ref().map(|x| &x.role), Some(MpRole::Mesh { .. })));
if !is_mesh {
return;
}
let ms = match crate::registry::signal_get_slots(&room).await {
Ok(m) => m,
Err(_) => {
crate::runtime::sleep_ms(4000).await;
tick += 1;
continue;
}
};
let still_mine = ms
.slots
.get(my_slot as usize)
.and_then(|e| e.as_ref())
.map(|x| x.addr.eq_ignore_ascii_case(&my_addr))
.unwrap_or(false);
if !still_mine && tick > 0 {
mp_teardown();
mp_post_status(&worker, 0, -1, 0);
return;
}
if tick % MESH_BEAT_TICKS == 0 {
let mut next = ms.slots.clone();
next[my_slot as usize] = Some(crate::registry::SlotEntry {
id: my_id.clone(),
addr: my_addr.clone(),
ts: ms.now,
});
let now = (js_sys::Date::now() / 1000.0) as u64;
let _ = crate::registry::signal_put_slots(
&signer, now, &room, &next, my_slot as usize, ms.sha.as_deref(),
)
.await;
}
for q in 0..(MP_MAX_PEERS as usize) {
if q as i32 == my_slot {
continue;
}
let entry = ms.slots[q].as_ref();
let fresh = entry
.map(|x| ms.now.saturating_sub(x.ts) <= MESH_FRESH_SECS)
.unwrap_or(false);
if !fresh {
continue;
}
let their_id = entry.map(|x| x.id.clone()).unwrap_or_default();
let skip = MP_SESSION.with(|s| {
if let Some(MpSession { role: MpRole::Mesh { peers, connecting, .. }, .. }) =
s.borrow().as_ref()
{
connecting[q] || peers[q].as_ref().map(|(id, _)| id == &their_id).unwrap_or(false)
} else {
true
}
});
if skip {
continue;
}
MP_SESSION.with(|s| {
if let Some(MpSession { role: MpRole::Mesh { connecting, .. }, .. }) =
s.borrow_mut().as_mut()
{
connecting[q] = true;
}
});
wasm_bindgen_futures::spawn_local(mesh_connect_one(
worker.clone(), room.clone(), signer.clone(), my_slot, q as i32, their_id,
));
}
tick += 1;
crate::runtime::sleep_ms(4000).await;
}
}
async fn mesh_connect_one(
worker: Worker,
room: String,
signer: k256::ecdsa::SigningKey,
my_slot: i32,
q: i32,
their_id: String,
) {
let worker_for_msg = worker.clone();
let on_msg = move |bytes: Vec<u8>| mp_dispatch_peer_frame(&worker_for_msg, q, &bytes);
let result = if my_slot < q {
crate::app::webrtc::Peer::mesh_offer(&room, my_slot, q, &signer, on_msg).await
} else {
crate::app::webrtc::Peer::mesh_answer(&room, q, my_slot, &signer, on_msg).await
};
MP_SESSION.with(|s| {
if let Some(MpSession { role: MpRole::Mesh { peers, connecting, .. }, .. }) =
s.borrow_mut().as_mut()
{
connecting[q as usize] = false;
if let Ok(peer) = result {
peers[q as usize] = Some((their_id, peer));
}
}
});
for _ in 0..100 {
let open = MP_SESSION.with(|s| {
matches!(s.borrow().as_ref().map(|x| &x.role), Some(MpRole::Mesh { peers, .. })
if peers.iter().flatten().any(|(_, p)| p.is_open()))
});
if open {
break;
}
crate::runtime::sleep_ms(100).await;
}
let (connected, total) = MP_SESSION.with(|s| {
if let Some(MpSession { role: MpRole::Mesh { peers, .. }, .. }) = s.borrow().as_ref() {
let open = peers.iter().flatten().filter(|(_, p)| p.is_open()).count() as i32;
(i32::from(open > 0), open + 1)
} else {
(0, 0)
}
});
mp_post_status(&worker, connected, my_slot, total);
}
fn mp_post_status(worker: &Worker, connected: i32, self_index: i32, peer_count: i32) {
let m = Object::new();
let _ = Reflect::set(&m, &JsValue::from_str("type"), &JsValue::from_str("mp:status"));
let _ = Reflect::set(&m, &JsValue::from_str("connected"), &JsValue::from_f64(connected as f64));
let _ = Reflect::set(&m, &JsValue::from_str("selfIndex"), &JsValue::from_f64(self_index as f64));
let _ = Reflect::set(&m, &JsValue::from_str("peerCount"), &JsValue::from_f64(peer_count as f64));
let _ = worker.post_message(&m);
}
fn mp_read_int_array(data: &JsValue, field: &str) -> Vec<i32> {
Reflect::get(data, &JsValue::from_str(field))
.ok()
.map(|v| {
js_sys::Array::from(&v)
.iter()
.map(|x| x.as_f64().unwrap_or(0.0) as i32)
.collect()
})
.unwrap_or_default()
}
fn mp_send(deltas: Option<Vec<i32>>, events: Option<Vec<i32>>) {
let json = if let Some(d) = deltas {
format!("{{\"d\":{}}}", mp_ints_json(&d))
} else if let Some(ev) = events {
format!("{{\"e\":{}}}", mp_ints_json(&ev))
} else {
return;
};
MP_SESSION.with(|s| {
if let Some(sess) = s.borrow().as_ref() {
match &sess.role {
MpRole::Host { peers } => {
for (_, _, p) in peers {
if p.is_open() {
let _ = p.send_game(json.as_bytes());
}
}
}
MpRole::Joiner { peer } => {
if peer.is_open() {
let _ = peer.send_game(json.as_bytes());
}
}
MpRole::Mesh { peers, .. } => {
for slot in peers.iter().flatten() {
if slot.1.is_open() {
let _ = slot.1.send_game(json.as_bytes());
}
}
}
}
}
});
}
fn mp_ints_json(v: &[i32]) -> String {
let mut s = String::from("[");
for (i, n) in v.iter().enumerate() {
if i > 0 {
s.push(',');
}
s.push_str(&n.to_string());
}
s.push(']');
s
}
fn mp_dispatch_peer_frame(worker: &Worker, peer_index: i32, bytes: &[u8]) {
let text = match std::str::from_utf8(bytes) {
Ok(t) => t,
Err(_) => return,
};
let v: serde_json::Value = match serde_json::from_str(text) {
Ok(v) => v,
Err(_) => return,
};
let origin = v
.get("p")
.and_then(|x| x.as_i64())
.map(|x| x as i32)
.unwrap_or(peer_index);
let m = Object::new();
let _ = Reflect::set(&m, &JsValue::from_str("type"), &JsValue::from_str("mp:peer"));
let _ = Reflect::set(&m, &JsValue::from_str("peer"), &JsValue::from_f64(origin as f64));
if let Some(d) = v.get("d").and_then(|x| x.as_array()) {
let arr = js_sys::Array::new();
for n in d {
arr.push(&JsValue::from_f64(n.as_i64().unwrap_or(0) as f64));
}
let _ = Reflect::set(&m, &JsValue::from_str("deltas"), &arr);
}
if let Some(ev) = v.get("e").and_then(|x| x.as_array()) {
let arr = js_sys::Array::new();
for n in ev {
arr.push(&JsValue::from_f64(n.as_i64().unwrap_or(0) as f64));
}
let _ = Reflect::set(&m, &JsValue::from_str("events"), &arr);
}
let _ = worker.post_message(&m);
if peer_index >= 1 && v.get("p").is_none() && v.is_object() {
mp_relay_from_host(peer_index, &v);
}
}
fn mp_relay_from_host(origin_idx: i32, v: &serde_json::Value) {
MP_SESSION.with(|s| {
if let Some(MpSession { role: MpRole::Host { peers }, .. }) = s.borrow().as_ref() {
let mut tagged = v.clone();
tagged["p"] = serde_json::json!(origin_idx);
let bytes = tagged.to_string();
for (_, pidx, p) in peers.iter() {
if *pidx != origin_idx && p.is_open() {
let _ = p.send_game(bytes.as_bytes());
}
}
}
});
}
fn mp_teardown() {
MP_SESSION.with(|s| *s.borrow_mut() = None);
}
thread_local! {
static CHAT_ACTIVE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
static CHAT_FAST: std::cell::Cell<i32> = const { std::cell::Cell::new(0) };
}
const CHAT_FAST_MS: u32 = 500; const CHAT_IDLE_MS: u32 = 2500; const CHAT_FAST_REFILL: i32 = 12;
fn chat_start(worker: Worker) {
if CHAT_ACTIVE.with(|a| a.get()) {
return;
}
let Some(room) = crate::app::tenant::current_name() else {
return;
};
CHAT_ACTIVE.with(|a| a.set(true));
wasm_bindgen_futures::spawn_local(chat_poll_loop(worker, room));
}
async fn chat_poll_loop(worker: Worker, room: String) {
let mut cursor: i64 = -1;
while CHAT_ACTIVE.with(|a| a.get()) {
if let Ok(msgs) = crate::registry::chat_poll(&room, cursor).await {
if !msgs.is_empty() {
CHAT_FAST.with(|f| f.set(CHAT_FAST_REFILL)); }
for (n, name, text) in msgs {
if n > cursor {
cursor = n;
}
chat_post_to_worker(&worker, &format!("{name}: {text}"));
}
}
let ms = CHAT_FAST.with(|f| {
let n = f.get();
if n > 0 {
f.set(n - 1);
CHAT_FAST_MS
} else {
CHAT_IDLE_MS
}
});
crate::runtime::sleep_ms(ms).await;
}
}
fn chat_post_to_worker(worker: &Worker, line: &str) {
let m = Object::new();
let _ = Reflect::set(&m, &JsValue::from_str("type"), &JsValue::from_str("chat:msg"));
let _ = Reflect::set(&m, &JsValue::from_str("text"), &JsValue::from_str(line));
let _ = worker.post_message(&m);
}
fn chat_send(text: String) {
CHAT_FAST.with(|f| f.set(CHAT_FAST_REFILL)); wasm_bindgen_futures::spawn_local(async move {
let Some(room) = crate::app::tenant::current_name() else {
return;
};
let Some((signer, _)) = crate::app::chat::credit_signer().await else {
return;
};
let now = (js_sys::Date::now() / 1000.0) as u64;
let _ = crate::registry::chat_post(&signer, now, &room, &text).await;
});
}
fn chat_stop() {
CHAT_ACTIVE.with(|a| a.set(false));
CHAT_FAST.with(|f| f.set(0));
}
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 audio {
use std::cell::RefCell;
use js_sys::{Function, 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()>,
}
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 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();
}
});
}
}