use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use web_sys::{Document, Element, HtmlElement, KeyboardEvent, MouseEvent};
use crate::encoding::{bytes_to_hex_str, parse_address};
use super::dom;
use super::templates;
mod admin;
mod claim;
mod credits;
mod devices;
mod key_sync;
mod layout;
mod public_face;
pub(crate) mod schedule;
mod subdomains;
pub(crate) use credits::{finalize_after_payment, refresh_fund_banner, try_redeem_pending_invite};
thread_local! {
static ONBOARD_BUSY: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
static ONBOARD_NAME: std::cell::RefCell<Option<String>> =
const { std::cell::RefCell::new(None) };
}
pub(super) fn set_onboard_name(name: &str) {
ONBOARD_NAME.with(|c| *c.borrow_mut() = Some(name.to_string()));
}
pub(super) fn take_onboard_name() -> Option<String> {
ONBOARD_NAME.with(|c| c.borrow_mut().take())
}
pub(super) fn onboard_flow_begin() -> Option<OnboardFlowGuard> {
ONBOARD_BUSY.with(|b| {
if b.get() {
None
} else {
b.set(true);
Some(OnboardFlowGuard)
}
})
}
pub(super) struct OnboardFlowGuard;
impl Drop for OnboardFlowGuard {
fn drop(&mut self) {
ONBOARD_BUSY.with(|b| b.set(false));
}
}
pub(super) fn defer_onboard_repaint<F>(guard: OnboardFlowGuard, repaint: F)
where
F: std::future::Future<Output = ()> + 'static,
{
wasm_bindgen_futures::spawn_local(async move {
let _flow_guard = guard;
repaint.await;
});
}
pub(super) async fn warn_if_storage_volatile() {
if super::wallet_store::storage_is_volatile().await {
dom::swap_inner(
"storage-warn-slot",
&templates::volatile_storage_warning().into_string(),
);
}
}
pub(crate) use key_sync::{sync_local_key_to_main, try_auto_restore_gemini_key};
pub(crate) use subdomains::{run_batch_create_subdomains, run_bulk_release, run_release_subdomain};
#[derive(Debug, Clone)]
enum Action {
Send,
SyncDevices,
OpfsWipe,
OpfsWipeConfirm,
OpfsWipeCancel,
OpfsDelete(String),
OpfsCloseViewer,
OpfsNav(String),
OpfsOpen(String),
OpfsEdit(String),
OpfsSave(String),
ApexClaim,
ClaimOnChain,
RevealSeed,
HideSeed,
ImportSeed,
CreateIdentity,
OnboardCreate,
ShowImport,
CancelImport,
HeaderAdminToggle,
HeaderAdminClose,
ToggleTheme,
TogglePreview,
ShowAdminTab(String),
RevealSecurity,
HideSecurity,
ResetArm,
ResetConfirm,
ResetCancel,
PricingSave,
ToggleFiles,
ToggleFeedback,
FeedbackSubmit,
PairCancel,
AddDevice,
AdoptDevice,
CreateNewClaim(String),
SavePrompt,
SaveToolAllowlist,
ResetToolAllowlist,
SaveApiKey,
ToggleDisplay,
RunInDisplay,
ToggleTerminal,
StopTurn,
BroadcastSend(String),
BroadcastCancel,
SetPublicFace(String),
CopyShareUrl(String),
CopySeed(String),
SetModelAccess(String),
SetModel(String),
DownloadLocalModel,
RedeemInviteOnboard,
RedeemCode,
BuyLh,
CancelBuy,
RedeemBanner,
CreateInvite,
SaveX402Price,
UnlinkDevice(String),
UnlinkConfirm(String),
UnlinkCancel,
EnableNotifications,
ToggleTelemetry,
ToggleFeedbackOnchain,
NotifBell,
NotifClearAll,
NotifClearConfirm,
NotifClearCancel,
TestNotification,
InstallApp,
}
impl Action {
fn parse(name: &str, arg: Option<String>) -> Option<Action> {
Some(match name {
"send" => Action::Send,
"opfs-wipe" => Action::OpfsWipe,
"opfs-wipe-confirm" => Action::OpfsWipeConfirm,
"opfs-wipe-cancel" => Action::OpfsWipeCancel,
"opfs-delete" => Action::OpfsDelete(arg.unwrap_or_default()),
"opfs-close-viewer" => Action::OpfsCloseViewer,
"opfs-nav" => Action::OpfsNav(arg.unwrap_or_default()),
"opfs-open" => Action::OpfsOpen(arg.unwrap_or_default()),
"opfs-edit" => Action::OpfsEdit(arg.unwrap_or_default()),
"opfs-save" => Action::OpfsSave(arg.unwrap_or_default()),
"apex-claim" => Action::ApexClaim,
"claim-on-chain" => Action::ClaimOnChain,
"reveal-seed" => Action::RevealSeed,
"hide-seed" => Action::HideSeed,
"import-seed" => Action::ImportSeed,
"create-identity" => Action::CreateIdentity,
"onboard-create" => Action::OnboardCreate,
"show-import" => Action::ShowImport,
"cancel-import" => Action::CancelImport,
"header-admin-toggle" => Action::HeaderAdminToggle,
"header-admin-close" => Action::HeaderAdminClose,
"toggle-theme" => Action::ToggleTheme,
"toggle-preview" => Action::TogglePreview,
"show-admin-tab" => Action::ShowAdminTab(arg.unwrap_or_default()),
"reveal-security" => Action::RevealSecurity,
"hide-security" => Action::HideSecurity,
"reset-arm" => Action::ResetArm,
"reset-confirm" => Action::ResetConfirm,
"reset-cancel" => Action::ResetCancel,
"pricing-save" => Action::PricingSave,
"toggle-files" => Action::ToggleFiles,
"toggle-feedback" => Action::ToggleFeedback,
"feedback-submit" => Action::FeedbackSubmit,
"add-device" => Action::AddDevice,
"sync-devices" => Action::SyncDevices,
"adopt-device" => Action::AdoptDevice,
"create-new-claim" => Action::CreateNewClaim(arg.unwrap_or_default()),
"pair-cancel" => Action::PairCancel,
"save-prompt" => Action::SavePrompt,
"save-tool-allowlist" => Action::SaveToolAllowlist,
"reset-tool-allowlist" => Action::ResetToolAllowlist,
"save-api-key" => Action::SaveApiKey,
"toggle-display" => Action::ToggleDisplay,
"run-in-display" => Action::RunInDisplay,
"toggle-terminal" => Action::ToggleTerminal,
"stop-turn" => Action::StopTurn,
"broadcast-send" => Action::BroadcastSend(arg.unwrap_or_default()),
"broadcast-cancel" => Action::BroadcastCancel,
"set-public-face" => Action::SetPublicFace(arg.unwrap_or_default()),
"copy-share-url" => Action::CopyShareUrl(arg.unwrap_or_default()),
"copy-seed" => Action::CopySeed(arg.unwrap_or_default()),
"set-model-access" => Action::SetModelAccess(arg.unwrap_or_default()),
"set-model" => Action::SetModel(arg.unwrap_or_default()),
"download-local-model" => Action::DownloadLocalModel,
"redeem-invite-onboard" => Action::RedeemInviteOnboard,
"redeem-code" => Action::RedeemCode,
"buy-lh" => Action::BuyLh,
"cancel-buy" => Action::CancelBuy,
"redeem-banner" => Action::RedeemBanner,
"create-invite" => Action::CreateInvite,
"save-x402-price" => Action::SaveX402Price,
"unlink-device" => Action::UnlinkDevice(arg.unwrap_or_default()),
"unlink-confirm" => Action::UnlinkConfirm(arg.unwrap_or_default()),
"unlink-cancel" => Action::UnlinkCancel,
"enable-notifications" => Action::EnableNotifications,
"toggle-telemetry" => Action::ToggleTelemetry,
"toggle-feedback-onchain" => Action::ToggleFeedbackOnchain,
"notif-bell" => Action::NotifBell,
"notif-clear-all" => Action::NotifClearAll,
"notif-clear-confirm" => Action::NotifClearConfirm,
"notif-clear-cancel" => Action::NotifClearCancel,
"test-notification" => Action::TestNotification,
"install-app" => Action::InstallApp,
_ => return None,
})
}
}
pub(crate) fn install_delegated_listeners(doc: &Document) -> Result<(), JsValue> {
let click = Closure::<dyn FnMut(_)>::new(move |event: MouseEvent| {
let Some(target) = event.target() else { return };
let Ok(mut node) = target.dyn_into::<Element>() else { return };
if admin::notif_panel_open() && node.closest(".notif-bell-wrap").ok().flatten().is_none() {
admin::close_notif_panel();
}
if admin::feedback_panel_open() && node.closest(".feedback-bug-wrap").ok().flatten().is_none() {
admin::close_feedback_panel();
}
if admin::brand_menu_open() && node.closest(".brand-menu").ok().flatten().is_none() {
admin::close_brand_menu();
}
if dom::by_id("header-admin-panel")
.map(|e| !e.has_attribute("hidden"))
.unwrap_or(false)
&& node.closest(".header-admin").ok().flatten().is_none()
{
dispatch(Action::HeaderAdminClose);
}
if node.id().as_str() == "files-modal" {
event.prevent_default();
dispatch(Action::ToggleFiles);
return;
}
let action = loop {
if let Some(name) = node.get_attribute("data-action") {
let arg = node.get_attribute("data-arg");
break Action::parse(&name, arg);
}
match node.parent_element() {
Some(parent) => node = parent,
None => break None,
}
};
if let Some(action) = action {
event.prevent_default();
dispatch(action);
}
});
doc.add_event_listener_with_callback("click", click.as_ref().unchecked_ref())?;
click.forget();
let input_handler = Closure::<dyn FnMut(_)>::new(move |event: web_sys::Event| {
let Some(target) = event.target() else { return };
let Ok(el) = target.dyn_into::<Element>() else { return };
match el.id().as_str() {
"key" => on_key_input(),
"apex-input" => claim::on_apex_input(),
"prompt" => autogrow_textarea(&el),
_ => {}
}
});
doc.add_event_listener_with_callback("input", input_handler.as_ref().unchecked_ref())?;
input_handler.forget();
let submit_handler = Closure::<dyn FnMut(_)>::new(move |event: web_sys::Event| {
let Some(target) = event.target() else { return };
let Ok(form) = target.dyn_into::<Element>() else { return };
if let Some(name) = form.get_attribute("data-action") {
if let Some(action) = Action::parse(&name, form.get_attribute("data-arg")) {
event.prevent_default();
dispatch(action);
}
}
});
doc.add_event_listener_with_callback("submit", submit_handler.as_ref().unchecked_ref())?;
submit_handler.forget();
let keydown = Closure::<dyn FnMut(_)>::new(move |event: KeyboardEvent| {
let key = event.key();
if key == "Escape" {
if let Some(id) = dom::open_modal_trap() {
if let Some(cancel) = dom::by_id(&id)
.and_then(|el| el.get_attribute("data-modal-cancel"))
.and_then(|name| Action::parse(&name, None))
{
event.prevent_default();
dispatch(cancel);
return;
}
}
if admin::notif_panel_open() {
event.prevent_default();
admin::close_notif_panel();
} else if admin::feedback_panel_open() {
event.prevent_default();
admin::close_feedback_panel();
} else if admin::brand_menu_open() {
event.prevent_default();
admin::close_brand_menu();
} else if super::display::broadcast_composer_open() {
event.prevent_default();
super::display::close_broadcast_composer();
} else if dom::by_id("display-canvas").is_some() {
event.prevent_default();
dispatch(Action::ToggleDisplay);
} else if dom::by_id("terminal-surface").is_some() {
event.prevent_default();
dispatch(Action::ToggleTerminal);
} else if dom::by_id("fs-list").is_some() {
event.prevent_default();
dispatch(Action::ToggleFiles);
} else if dom::by_id("header-admin-panel")
.map(|e| !e.has_attribute("hidden"))
.unwrap_or(false)
{
event.prevent_default();
dispatch(Action::HeaderAdminClose);
}
return;
}
if key == "Tab" {
if let Some(id) = dom::open_modal_trap() {
if dom::trap_tab_in(&id, event.shift_key()) {
event.prevent_default();
}
}
return;
}
if key != "Enter" && key != " " {
return;
}
let Some(target) = event.target() else { return };
let Ok(el) = target.dyn_into::<Element>() else { return };
if el.get_attribute("role").as_deref() == Some("button") {
let mut node = el.clone();
let action = loop {
if let Some(name) = node.get_attribute("data-action") {
break Action::parse(&name, node.get_attribute("data-arg"));
}
match node.parent_element() {
Some(parent) => node = parent,
None => break None,
}
};
if let Some(action) = action {
event.prevent_default();
dispatch(action);
return;
}
}
if key == "Enter" && el.id() == "broadcast-input" {
event.prevent_default();
if let Some(btn) = dom::by_id("broadcast-send-btn")
.and_then(|b| b.dyn_into::<HtmlElement>().ok())
{
btn.click();
}
return;
}
if key != "Enter" || el.id() != "prompt" {
return;
}
let mod_held = event.meta_key() || event.ctrl_key();
let allow_newline = event.shift_key();
if mod_held || !allow_newline {
event.prevent_default();
dispatch(Action::Send);
}
});
doc.add_event_listener_with_callback("keydown", keydown.as_ref().unchecked_ref())?;
keydown.forget();
let mousemove = Closure::<dyn FnMut(_)>::new(move |event: MouseEvent| {
if super::display::cartridge_canvas_present() {
super::display::set_pointer(event.client_x() as f64, event.client_y() as f64);
}
});
doc.add_event_listener_with_callback("mousemove", mousemove.as_ref().unchecked_ref())?;
mousemove.forget();
let mousedown = Closure::<dyn FnMut(_)>::new(move |event: MouseEvent| {
if let Some(target) = event.target() {
if let Ok(el) = target.dyn_into::<Element>() {
if super::display::is_cartridge_canvas_id(&el.id()) {
super::display::set_pointer(event.client_x() as f64, event.client_y() as f64);
super::display::set_pointer_down(true);
super::display::prime_feed_permission_on_gesture();
}
}
}
});
doc.add_event_listener_with_callback("mousedown", mousedown.as_ref().unchecked_ref())?;
mousedown.forget();
let mouseup = Closure::<dyn FnMut(_)>::new(move |_event: MouseEvent| {
super::display::set_pointer_down(false);
});
doc.add_event_listener_with_callback("mouseup", mouseup.as_ref().unchecked_ref())?;
mouseup.forget();
let touchstart = Closure::<dyn FnMut(_)>::new(move |event: web_sys::TouchEvent| {
if let Some(target) = event.target() {
if let Ok(el) = target.dyn_into::<Element>() {
if super::display::is_cartridge_canvas_id(&el.id()) {
if let Some(t) = event.touches().get(0) {
super::display::set_pointer(t.client_x() as f64, t.client_y() as f64);
super::display::set_pointer_down(true);
super::display::prime_feed_permission_on_gesture();
}
}
}
}
});
doc.add_event_listener_with_callback("touchstart", touchstart.as_ref().unchecked_ref())?;
touchstart.forget();
let touchmove = Closure::<dyn FnMut(_)>::new(move |event: web_sys::TouchEvent| {
if super::display::cartridge_canvas_present() {
if let Some(t) = event.touches().get(0) {
super::display::set_pointer(t.client_x() as f64, t.client_y() as f64);
}
}
});
doc.add_event_listener_with_callback("touchmove", touchmove.as_ref().unchecked_ref())?;
touchmove.forget();
let touchend = Closure::<dyn FnMut(_)>::new(move |_event: web_sys::TouchEvent| {
super::display::set_pointer_down(false);
});
doc.add_event_listener_with_callback("touchend", touchend.as_ref().unchecked_ref())?;
touchend.forget();
install_keyboard_viewport_fix();
Ok(())
}
thread_local! {
static PREV_VV_HEIGHT: std::cell::Cell<f64> = const { std::cell::Cell::new(0.0) };
}
fn install_keyboard_viewport_fix() {
let Some(win) = web_sys::window() else { return };
let Some(vv) = win.visual_viewport() else { return };
let apply = move || {
let Some(win) = web_sys::window() else { return };
let Some(vv) = win.visual_viewport() else { return };
let Some(doc) = win.document() else { return };
let Some(root) = doc.document_element() else { return };
let Ok(html) = root.dyn_into::<HtmlElement>() else { return };
let style = html.style();
let layout_h = win
.inner_height()
.ok()
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
let visible_h = vv.height();
let offset_top = vv.offset_top();
let occluded = layout_h - visible_h > 120.0;
if occluded {
let _ = style.set_property("--lh-vh", &format!("{visible_h}px"));
let _ = style.set_property("--lh-vv-top", &format!("{offset_top}px"));
let _ = html.class_list().add_1("lh-kb");
} else {
let _ = html.class_list().remove_1("lh-kb");
let _ = style.remove_property("--lh-vh");
let _ = style.remove_property("--lh-vv-top");
}
let prev = PREV_VV_HEIGHT.with(|c| c.replace(visible_h));
if prev > 0.0 && prev - visible_h > 120.0 {
dom::scroll_to_bottom("transcript");
}
};
apply();
let cb = Closure::<dyn FnMut()>::new(apply);
let _ = vv.add_event_listener_with_callback("resize", cb.as_ref().unchecked_ref());
let _ = vv.add_event_listener_with_callback("scroll", cb.as_ref().unchecked_ref());
cb.forget(); }
fn on_key_input() {
if let Some(input) = dom::input_by_id("key") {
let value = input.value();
if let Ok(Some(storage)) = dom::session_storage() {
let _ = storage.set_item("gemini_api_key", &value);
}
refresh_keymeta();
wasm_bindgen_futures::spawn_local(async move {
super::key_store::save(&value).await;
});
}
}
const CTRL_BOX_PX: i32 = 38;
const MAX_ROW_BOXES: u32 = 3;
fn autogrow_textarea(el: &Element) {
let Some(ta) = el.dyn_ref::<HtmlElement>() else { return };
let style = ta.style();
let _ = style.set_property("height", "auto");
let content = ta.scroll_height();
let _ = style.set_property("height", &format!("{content}px"));
if let Some(row) = ta.parent_element().and_then(|p| p.dyn_into::<HtmlElement>().ok()) {
let boxes = (content.max(CTRL_BOX_PX) as u32)
.div_ceil(CTRL_BOX_PX as u32)
.min(MAX_ROW_BOXES);
let h = boxes * CTRL_BOX_PX as u32;
let _ = row.style().set_property("height", &format!("{h}px"));
}
}
pub(crate) fn refresh_keymeta() {
if let Some(input) = dom::input_by_id("key") {
let html = templates::keymeta(&input.value()).into_string();
dom::swap_inner("keymeta", &html);
}
}
fn dispatch(action: Action) {
match action {
Action::Send => {
wasm_bindgen_futures::spawn_local(async move {
super::chat::run_send().await;
});
}
Action::OpfsCloseViewer => super::opfs::close_viewer(),
Action::ToggleDisplay => super::opfs::toggle_display(),
Action::RunInDisplay => {
wasm_bindgen_futures::spawn_local(async move {
super::display::relaunch_last_in_fullscreen().await;
});
}
Action::ToggleTerminal => super::cli::toggle_terminal(),
Action::BroadcastSend(title) => super::display::broadcast_send(title),
Action::BroadcastCancel => super::display::close_broadcast_composer(),
Action::StopTurn => super::chat::request_stop_turn(),
Action::SetPublicFace(choice) => {
wasm_bindgen_futures::spawn_local(async move {
public_face::run_set_public_face(&choice).await;
});
}
Action::CopyShareUrl(url) => {
wasm_bindgen_futures::spawn_local(async move {
public_face::run_copy_to_clipboard(&url, "share-copy").await;
});
}
Action::CopySeed(phrase) => {
wasm_bindgen_futures::spawn_local(async move {
public_face::run_copy_to_clipboard(&phrase, "seed-copy").await;
});
}
Action::OpfsNav(target) => {
wasm_bindgen_futures::spawn_local(async move {
super::opfs::navigate(&target).await;
});
}
Action::OpfsOpen(name) => {
wasm_bindgen_futures::spawn_local(async move {
super::opfs::open_file(&name).await;
});
}
Action::OpfsEdit(name) => {
wasm_bindgen_futures::spawn_local(async move {
super::opfs::edit_file(&name).await;
});
}
Action::OpfsSave(name) => {
wasm_bindgen_futures::spawn_local(async move {
super::opfs::save_file(&name).await;
});
}
Action::ApexClaim => {
let raw = dom::input_by_id("apex-input")
.map(|i| i.value())
.unwrap_or_default();
let cleaned = super::tenant::sanitize(&raw);
if cleaned.len() < 3 || cleaned.len() > 32 {
return;
}
wasm_bindgen_futures::spawn_local(async move {
claim::run_apex_claim(cleaned, false).await;
});
}
Action::CreateNewClaim(name) => {
let cleaned = super::tenant::sanitize(&name);
if cleaned.len() < 3 || cleaned.len() > 32 {
return;
}
wasm_bindgen_futures::spawn_local(async move {
claim::run_apex_claim(cleaned, true).await;
});
}
Action::ClaimOnChain => {
let Some(name) = super::tenant::current_name() else {
return;
};
if !crate::subdomain::is_valid_subdomain_label(&name) {
dom::swap_inner(
"claim-msg",
&dom::msg_span(dom::Msg::Error, "invalid name"),
);
return;
}
dom::swap_inner(
"claim-msg",
"<span style=\"color:var(--muted)\">ensuring identity at apex…</span>",
);
wasm_bindgen_futures::spawn_local(async move {
if let Err(err) = super::verify::create_wallet_via_iframe(false).await {
dom::swap_inner(
"claim-msg",
&dom::msg_span(dom::Msg::Error, &format!("identity setup failed: {err}")),
);
return;
}
dom::swap_inner(
"claim-msg",
"<span style=\"color:var(--muted)\">claiming on-chain…</span>",
);
match super::verify::claim_name_via_iframe(&name).await {
Ok((owner_addr, _tx)) => {
let _ = super::owner::remember(&owner_addr).await;
super::paint_tenant(
super::tenant::Host::Tenant(name.clone()),
name,
)
.await;
}
Err(err) => {
dom::swap_inner(
"claim-msg",
&dom::msg_span(dom::Msg::Error, &format!("claim failed: {err}")),
);
}
}
});
}
Action::RevealSeed => {
let phrase = super::APP.with(|cell| {
cell.borrow()
.wallet
.as_ref()
.map(|w| w.mnemonic.to_string())
});
if let Some(p) = phrase {
dom::swap_inner(
"seed-reveal",
&super::templates::seed_phrase(&p).into_string(),
);
} else if !matches!(super::tenant::current(), super::tenant::Host::Apex) {
dom::swap_inner(
"seed-reveal",
"<span style=\"color:var(--muted)\">fetching…</span>",
);
wasm_bindgen_futures::spawn_local(async move {
match super::verify::reveal_seed_via_iframe().await {
Ok(phrase) => dom::swap_inner(
"seed-reveal",
&super::templates::seed_phrase(&phrase).into_string(),
),
Err(err) => dom::swap_inner(
"seed-reveal",
&maud::html! {
span style="color:var(--error)" { "reveal failed: " (err) }
button type="button" data-action="reveal-seed" class="ghost" { "retry" }
}
.into_string(),
),
}
});
}
}
Action::HideSeed => {
dom::swap_inner(
"seed-reveal",
r#"<button type="button" data-action="reveal-seed">I have a pen and paper — reveal</button>"#,
);
}
Action::OnboardCreate => {
let raw = dom::input_by_id("apex-input")
.map(|i| i.value())
.unwrap_or_default();
let cleaned = super::tenant::sanitize(&raw);
if cleaned.len() < 3 || cleaned.len() > 32 {
return;
}
let Some(flow_guard) = onboard_flow_begin() else {
return;
};
set_onboard_name(&cleaned);
dom::swap_outer(
"apex-onboard",
&templates::onboard_checkout().into_string(),
);
wasm_bindgen_futures::spawn_local(async move {
let _flow_guard = flow_guard;
match super::wallet_store::generate_in_memory().await {
Err(err) => {
let _ = take_onboard_name();
dom::swap_outer(
"apex-onboard",
&crate::landing::create_wallet_cta().into_string(),
);
if let Some(input) = dom::input_by_id("apex-input") {
input.set_value(&cleaned);
claim::on_apex_input();
}
dom::swap_inner(
"onboard-msg",
&dom::msg_span(dom::Msg::Error, &format!("create failed: {err}")),
);
}
Ok(_) => {
credits::buy_lh_pressed(true);
}
}
});
}
Action::CreateIdentity => {
let Some(flow_guard) = onboard_flow_begin() else {
return;
};
dom::swap_inner(
"identity-msg",
"<span style=\"color:var(--muted)\">generating identity…</span>",
);
match super::tenant::current() {
super::tenant::Host::Apex => {
wasm_bindgen_futures::spawn_local(async move {
let _flow_guard = flow_guard;
match super::net::with_timeout(
15_000,
super::wallet_store::create_and_persist(),
)
.await
{
Err(_) => {
dom::swap_inner(
"identity-msg",
&dom::msg_span(
dom::Msg::Error,
"create timed out — reload and try again",
),
);
return;
}
Ok(Err(err)) => {
dom::swap_inner(
"identity-msg",
&dom::msg_span(dom::Msg::Error, &format!("create failed: {err}")),
);
return;
}
Ok(Ok(_)) => {}
}
warn_if_storage_volatile().await;
dom::swap_inner(
"identity-msg",
"<span style=\"color:var(--muted)\">identity created — loading…</span>",
);
defer_onboard_repaint(_flow_guard, async {
super::paint_apex(super::tenant::Host::Apex).await;
});
});
}
host => {
wasm_bindgen_futures::spawn_local(async move {
let flow_guard = flow_guard;
match super::verify::create_wallet_via_iframe(false).await {
Ok(_addr) => {
warn_if_storage_volatile().await;
if let super::tenant::Host::Tenant(name) = &host {
let host = host.clone();
let name = name.clone();
defer_onboard_repaint(flow_guard, async move {
super::paint_tenant(host, name).await;
});
}
}
Err(err) => {
dom::swap_inner(
"identity-msg",
&dom::msg_span(dom::Msg::Error, &format!("create failed: {err}")),
);
}
}
});
}
}
}
Action::ShowImport => {
dom::swap_outer(
"import-slot",
&templates::import_seed_inline().into_string(),
);
if let Some(textarea) = dom::textarea_by_id("import-seed") {
let _ = textarea.focus();
}
}
Action::ImportSeed => {
let phrase = dom::textarea_by_id("import-seed")
.map(|t| t.value())
.unwrap_or_default();
if phrase.split_whitespace().count() != 12 {
dom::swap_inner(
"seed-msg",
"<span style=\"color:var(--error)\">expected exactly 12 words</span>",
);
return;
}
match super::tenant::current() {
super::tenant::Host::Apex => {
wasm_bindgen_futures::spawn_local(async move {
match super::wallet_store::import(&phrase).await {
Ok(_) => {
super::paint_apex(super::tenant::Host::Apex).await;
}
Err(err) => {
dom::swap_inner(
"seed-msg",
&dom::msg_span(dom::Msg::Error, &format!("import failed: {err}")),
);
}
}
});
}
host => {
wasm_bindgen_futures::spawn_local(async move {
match super::wallet_store::import(&phrase).await {
Ok(wallet) => {
super::APP
.with(|cell| cell.borrow_mut().wallet = Some(wallet));
if let super::tenant::Host::Tenant(name) = &host {
super::paint_tenant(host.clone(), name.clone()).await;
}
}
Err(err) => {
dom::swap_inner(
"seed-msg",
&dom::msg_span(dom::Msg::Error, &format!("import failed: {err}")),
);
}
}
});
}
}
}
Action::OpfsDelete(name) => {
wasm_bindgen_futures::spawn_local(async move {
let fs = super::shared_opfs();
if let Err(err) = fs.delete(&name).await {
web_sys::console::warn_1(&JsValue::from_str(&format!(
"delete({name}): {err}"
)));
}
if name == ".lh_history.json" {
dom::swap_inner("transcript", "");
}
super::opfs::refresh().await;
});
}
Action::OpfsWipe => {
dom::swap_outer(
"opfs-wipe-slot",
&templates::opfs_wipe_confirm_inline().into_string(),
);
dom::focus_first_in("opfs-wipe-slot");
}
Action::OpfsWipeConfirm => {
dom::swap_outer(
"opfs-wipe-slot",
&templates::opfs_wipe_armed_inline().into_string(),
);
dom::focus_first_in("opfs-wipe-slot");
wasm_bindgen_futures::spawn_local(async move {
super::opfs::wipe().await;
});
}
Action::OpfsWipeCancel => {
dom::swap_outer(
"opfs-wipe-slot",
&templates::opfs_wipe_armed_inline().into_string(),
);
dom::focus_first_in("opfs-wipe-slot");
}
Action::CancelImport => {
dom::swap_outer("import-slot", r#"<div id="import-slot"></div>"#);
}
Action::HeaderAdminToggle => admin::header_admin_toggle(),
Action::HeaderAdminClose => admin::header_admin_close(),
Action::ToggleTheme => layout::toggle_theme(),
Action::TogglePreview => layout::toggle_preview(),
Action::ShowAdminTab(name) => admin::show_admin_tab(&name),
Action::RevealSecurity => {
dom::swap_outer(
"security-slot",
&templates::admin_security_expanded().into_string(),
);
}
Action::HideSecurity => {
dom::swap_outer(
"security-slot",
&templates::admin_security_collapsed().into_string(),
);
}
Action::ResetArm => {
dom::remember_focus();
dom::swap_outer(
"reset-confirm-slot",
&templates::reset_confirm_inline().into_string(),
);
dom::focus_first_in("reset-confirm-slot");
}
Action::ResetCancel => {
dom::swap_outer(
"reset-confirm-slot",
&templates::reset_armed_inline().into_string(),
);
dom::restore_focus();
}
Action::ResetConfirm => layout::reset_confirm_pressed(),
Action::PricingSave => layout::pricing_save_pressed(),
Action::ToggleFiles => {
wasm_bindgen_futures::spawn_local(async move {
super::opfs::toggle_files_modal().await;
});
}
Action::ToggleFeedback => admin::toggle_feedback_panel(),
Action::FeedbackSubmit => super::feedback::feedback_submit(),
Action::AddDevice => devices::add_device_pressed(),
Action::SyncDevices => devices::run_sync_devices(),
Action::AdoptDevice => devices::adopt_device_pressed(),
Action::PairCancel => devices::pair_cancel_pressed(),
Action::SavePrompt => admin::save_prompt_pressed(),
Action::SaveToolAllowlist => admin::save_tool_allowlist_pressed(),
Action::ResetToolAllowlist => admin::reset_tool_allowlist_pressed(),
Action::SaveApiKey => admin::save_api_key_pressed(),
Action::SetModelAccess(mode) => credits::run_set_model_access(mode),
Action::SetModel(model) => credits::run_set_model(model),
Action::DownloadLocalModel => credits::run_download_local_model(),
Action::RedeemInviteOnboard => credits::redeem_invite_onboard_pressed(),
Action::RedeemCode => credits::redeem_code_pressed(),
Action::BuyLh => credits::buy_lh_pressed(false),
Action::CancelBuy => credits::cancel_buy_pressed(),
Action::RedeemBanner => credits::redeem_banner_pressed(),
Action::CreateInvite => credits::create_invite_pressed(),
Action::SaveX402Price => admin::save_x402_price_pressed(),
Action::UnlinkDevice(addr) => devices::unlink_device_prompt(addr),
Action::UnlinkConfirm(addr) => devices::unlink_confirm_pressed(addr),
Action::UnlinkCancel => devices::unlink_cancel_pressed(),
Action::EnableNotifications => admin::enable_notifications_pressed(),
Action::ToggleTelemetry => admin::toggle_telemetry_pressed(),
Action::ToggleFeedbackOnchain => admin::toggle_feedback_onchain_pressed(),
Action::NotifBell => admin::notif_bell_pressed(),
Action::NotifClearAll => admin::notif_clear_all_pressed(),
Action::NotifClearConfirm => admin::notif_clear_confirmed(),
Action::NotifClearCancel => admin::notif_clear_cancelled(),
Action::TestNotification => admin::test_notification_pressed(),
Action::InstallApp => admin::install_app_pressed(),
}
}
pub(crate) async fn refresh_credits_pill() {
let Some(addr) = super::chat::credit_address_existing().await else { return };
let wallet = super::net::read(super::registry::token_balance_of(&addr))
.await
.ok()
.and_then(Result::ok);
let meter = super::net::read(super::registry::credit_balance_of(&addr))
.await
.ok()
.and_then(Result::ok);
match (wallet, meter) {
(Some(wallet), Some(meter)) => {
let total = wallet + meter;
let whole = total / 1_000_000_000_000_000_000u128;
let cents = (total % 1_000_000_000_000_000_000u128) / 10_000_000_000_000_000u128;
dom::swap_inner("credits-balance", &format!("{whole}.{cents:02} LH"));
}
_ => dom::swap_inner("credits-balance", "—"),
}
warn_if_sponsor_low().await;
}
const SPONSOR_RL_WINDOW_SECS: u64 = 3600;
const SPONSOR_RL_MAX: usize = 60;
fn sponsor_rate_guard() -> Result<(), String> {
let Some(storage) = web_sys::window().and_then(|w| w.local_storage().ok().flatten()) else {
return Ok(()); };
let now = (js_sys::Date::now() / 1000.0) as u64;
let prev = storage
.get_item("lh_sponsor_rl")
.ok()
.flatten()
.unwrap_or_default();
let mut stamps: Vec<u64> = prev
.split(',')
.filter_map(|s| s.trim().parse::<u64>().ok())
.filter(|t| now.saturating_sub(*t) < SPONSOR_RL_WINDOW_SECS)
.collect();
if stamps.len() >= SPONSOR_RL_MAX {
return Err("too many sponsored actions in a short window — wait a bit".into());
}
stamps.push(now);
let joined = stamps
.iter()
.map(|t| t.to_string())
.collect::<Vec<_>>()
.join(",");
let _ = storage.set_item("lh_sponsor_rl", &joined);
Ok(())
}
pub(crate) async fn warn_if_sponsor_low() {
if super::registry::is_mainnet() {
return;
}
const ALPHA_USD_UNIT: u128 = 1_000_000;
const LOW_THRESHOLD: u128 = 5 * ALPHA_USD_UNIT; let Ok(signer) = super::sponsor::signer() else {
return;
};
let addr = crate::wallet::address(&signer);
let addr_hex = bytes_to_hex_str(&addr);
if let Ok(bal) =
super::registry::erc20_balance_of(super::registry::ALPHA_USD_ADDRESS(), &addr_hex).await
{
if bal < LOW_THRESHOLD {
let whole = bal / ALPHA_USD_UNIT;
web_sys::console::warn_1(&JsValue::from_str(&format!(
"sponsor fee-token LOW: ~{whole} AlphaUSD at {addr_hex} — refill soon"
)));
}
}
}
pub(crate) async fn run_sponsored_tempo_call(
from_hex: &str,
calls: Vec<crate::tempo_tx::TempoCall>,
gas_limit: u128,
purpose: &str,
) -> Result<String, String> {
sponsor_rate_guard()?;
let sender_address = parse_address(from_hex)?;
let fee_token_addr = parse_address(super::registry::ALPHA_USD_ADDRESS())?;
let nonce = super::registry::next_nonce(from_hex).await
.map_err(|e| format!("nonce: {e}"))?;
let gas_price = super::registry::current_gas_price().await
.map_err(|e| format!("gas price: {e}"))?;
let tx = crate::tempo_tx::TempoTxBuilder::new(super::registry::CHAIN_ID())
.max_priority_fee_per_gas(gas_price)
.max_fee_per_gas(gas_price)
.gas_limit(gas_limit)
.nonce(nonce)
.calls(calls)
.fee_token(fee_token_addr)
.sponsored()
.build();
let sender_hash = tx.sender_hash();
let (claimed_addr, sender_sig) =
super::verify::sign_tempo_tx_via_iframe(&tx, purpose)
.await
.map_err(|e| format!("signer: {e}"))?;
let recovered = crate::wallet::recover_address(&sender_sig, &sender_hash)
.map_err(|e| format!("recover: {e}"))?;
if recovered != sender_address {
return Err(format!(
"sender sig recovered {} but expected {claimed_addr} ({from_hex})",
bytes_to_hex_str(&recovered),
));
}
let fp_sig = if super::registry::is_mainnet() {
let signer = super::APP
.with(|c| c.borrow().wallet.as_ref().map(|w| w.signer.clone()))
.ok_or_else(|| {
"mainnet sponsorship needs your identity on this device".to_string()
})?;
if crate::wallet::address(&signer) != sender_address {
return Err("local signer does not match the tx sender".to_string());
}
super::registry::request_fee_payer_signature(&signer, &tx, &sender_address, &sender_sig)
.await?
} else {
let fee_payer = super::sponsor::signer()?;
crate::wallet::sign_hash(&fee_payer, &tx.fee_payer_hash(&sender_address))
};
let raw = tx.serialize_signed(&sender_sig, Some(&fp_sig));
let raw_hex = bytes_to_hex_str(&raw);
super::registry::submit_and_wait_receipt(&raw_hex).await
.map_err(|e| format!("submit: {e}"))
}