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, parse_token_amount, short_addr, tx_short_hash,
};
use crate::filesystem::Filesystem;
use super::dom;
use super::templates;
#[derive(Debug, Clone)]
enum Action {
Send,
SyncDevices,
ClearKey,
OpfsRefresh,
OpfsWipe,
OpfsWipeConfirm,
OpfsWipeCancel,
OpfsDelete(String),
OpfsCloseViewer,
OpfsNav(String),
OpfsOpen(String),
OpfsEdit(String),
OpfsSave(String),
ApexClaim,
ClaimHere,
ClaimOnChain,
ImportOwner,
RevealSeed,
HideSeed,
ImportSeed,
CreateIdentity,
ShowImport,
CancelImport,
HeaderAdminToggle,
HeaderAdminClose,
ShowAdminTab(String),
SyncKey,
RestoreKey,
RevealSecurity,
HideSecurity,
ResetArm,
ResetConfirm,
ResetCancel,
PricingSave,
ToggleFiles,
ToggleFinancial,
ToggleTerminal,
ToggleView,
ShowTab(String),
FeedbackSubmit,
PairStart,
PairCancel,
PairApprove,
PairReject,
PairJoin,
AddDevice,
AdoptDevice,
CreateNewClaim(String),
AgentActToggle(String),
AgentSendLh(String),
SavePrompt,
SaveToolAllowlist,
ResetToolAllowlist,
SaveApiKey,
ToggleDisplay,
StopTurn,
SetPublicFace(String),
SetModelAccess(String),
SetModel(String),
DownloadLocalModel,
OpenSession,
RedeemCode,
RedeemBanner,
DepositCredits,
CreateInvite,
ScheduleJob,
CancelJob(String),
PostBounty,
ClaimBounty(String),
SaveX402Price,
Consolidate,
UnlinkDevice(String),
UnlinkConfirm(String),
UnlinkCancel,
}
impl Action {
fn parse(name: &str, arg: Option<String>) -> Option<Action> {
Some(match name {
"send" => Action::Send,
"clear-key" => Action::ClearKey,
"opfs-refresh" => Action::OpfsRefresh,
"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-here" => Action::ClaimHere,
"claim-on-chain" => Action::ClaimOnChain,
"import-owner" => Action::ImportOwner,
"reveal-seed" => Action::RevealSeed,
"hide-seed" => Action::HideSeed,
"import-seed" => Action::ImportSeed,
"create-identity" => Action::CreateIdentity,
"show-import" => Action::ShowImport,
"cancel-import" => Action::CancelImport,
"header-admin-toggle" => Action::HeaderAdminToggle,
"header-admin-close" => Action::HeaderAdminClose,
"show-admin-tab" => Action::ShowAdminTab(arg.unwrap_or_default()),
"sync-key" => Action::SyncKey,
"restore-key" => Action::RestoreKey,
"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-financial" => Action::ToggleFinancial,
"toggle-terminal" => Action::ToggleTerminal,
"toggle-view" => Action::ToggleView,
"show-tab" => Action::ShowTab(arg.unwrap_or_default()),
"feedback-submit" => Action::FeedbackSubmit,
"pair-start" => Action::PairStart,
"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,
"pair-approve" => Action::PairApprove,
"pair-reject" => Action::PairReject,
"pair-join" => Action::PairJoin,
"agent-act-toggle" => Action::AgentActToggle(arg.unwrap_or_default()),
"agent-send-lh" => Action::AgentSendLh(arg.unwrap_or_default()),
"save-prompt" => Action::SavePrompt,
"save-tool-allowlist" => Action::SaveToolAllowlist,
"reset-tool-allowlist" => Action::ResetToolAllowlist,
"save-api-key" => Action::SaveApiKey,
"toggle-display" => Action::ToggleDisplay,
"stop-turn" => Action::StopTurn,
"set-public-face" => Action::SetPublicFace(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,
"open-session" => Action::OpenSession,
"redeem-code" => Action::RedeemCode,
"redeem-banner" => Action::RedeemBanner,
"deposit-credits" => Action::DepositCredits,
"create-invite" => Action::CreateInvite,
"schedule-job" => Action::ScheduleJob,
"cancel-job" => Action::CancelJob(arg.unwrap_or_default()),
"post-bounty" => Action::PostBounty,
"claim-bounty" => Action::ClaimBounty(arg.unwrap_or_default()),
"save-x402-price" => Action::SaveX402Price,
"consolidate" => Action::Consolidate,
"unlink-device" => Action::UnlinkDevice(arg.unwrap_or_default()),
"unlink-confirm" => Action::UnlinkConfirm(arg.unwrap_or_default()),
"unlink-cancel" => Action::UnlinkCancel,
_ => 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 };
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" => on_apex_input(),
_ => {}
}
});
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 != "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() != "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 dom::by_id("display-canvas").is_some() {
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 el.id() == "display-canvas" {
super::display::set_pointer(event.client_x() as f64, event.client_y() as f64);
super::display::set_pointer_down(true);
}
}
}
});
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 el.id() == "display-canvas" {
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);
}
}
}
}
});
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 dom::by_id("display-canvas").is_some() {
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(())
}
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");
}
};
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;
});
}
}
enum CreateBtnState {
Disabled,
Ready,
Failed,
}
fn set_create_button_state(state: CreateBtnState) {
match state {
CreateBtnState::Disabled => set_create_button_classes(false, false, "create"),
CreateBtnState::Ready => set_create_button_classes(true, false, "create"),
CreateBtnState::Failed => set_create_button_classes(false, true, "✗ failed"),
}
}
fn set_create_button_failed_with(label: &str) {
set_create_button_classes(false, true, label);
}
fn set_create_button_classes(enabled: bool, failed: bool, label: &str) {
let Some(btn) = dom::by_id("create-btn") else { return };
let stripped: String = btn
.class_name()
.split_whitespace()
.filter(|c| *c != "ready" && *c != "failed")
.collect::<Vec<_>>()
.join(" ");
if enabled {
let _ = btn.remove_attribute("disabled");
} else {
let _ = btn.set_attribute("disabled", "");
}
let class = if enabled {
format!("{stripped} ready")
} else if failed {
format!("{stripped} failed")
} else {
stripped
};
btn.set_class_name(&class);
btn.set_inner_html(label);
}
fn on_apex_input() {
let Some(input) = dom::input_by_id("apex-input") else { return };
let raw = input.value();
let cleaned = super::tenant::sanitize(&raw);
if cleaned != raw {
input.set_value(&cleaned);
}
if cleaned.len() < 3 || cleaned.len() > 32 {
set_create_button_state(CreateBtnState::Disabled);
return;
}
set_create_button_state(CreateBtnState::Disabled);
let pending = cleaned.clone();
wasm_bindgen_futures::spawn_local(async move {
let result = super::registry::check_name(&pending).await;
let still_pending = dom::input_by_id("apex-input")
.map(|i| super::tenant::sanitize(&i.value()) == pending)
.unwrap_or(false);
if !still_pending {
return;
}
match result {
Ok(super::registry::Status::Available) => {
set_create_button_state(CreateBtnState::Ready);
}
_ => {
set_create_button_state(CreateBtnState::Disabled);
}
}
});
}
async fn run_apex_claim(name: String, create_if_missing: bool) {
set_create_button_busy(true);
let has_wallet = super::APP.with(|cell| cell.borrow().wallet.is_some());
if !has_wallet && !create_if_missing {
set_create_button_busy(false);
dom::swap_outer("agents-list", &templates::identity_choice(&name).into_string());
return;
}
let result: Result<String, String> = async {
match super::registry::check_name(&name).await {
Ok(super::registry::Status::Available) => {}
Ok(other) => return Err(format!("name not available: {other:?}")),
Err(err) => return Err(format!("check_name: {err}")),
}
let cached = super::APP.with(|cell| {
cell.borrow()
.wallet
.as_ref()
.map(|w| (w.signer.clone(), wallet_address_hex(&w.address)))
});
let (signer, addr_hex) = match cached {
Some(pair) => pair,
None => match super::wallet_store::create_and_persist().await {
Ok(wallet) => {
let pair = (wallet.signer.clone(), wallet.address_hex());
super::APP.with(|cell| cell.borrow_mut().wallet = Some(wallet));
pair
}
Err(err) => return Err(format!("wallet: {err}")),
},
};
let cost = super::registry::registration_cost().await.unwrap_or(0);
if cost > 0 {
let bal = super::registry::token_balance_of(&addr_hex).await.unwrap_or(0);
if bal < cost {
let deficit_lh = (cost - bal) / 1_000_000_000_000_000_000u128;
return Err(format!("__NEED_LH__{deficit_lh}"));
}
}
let fee_payer = super::sponsor::signer()
.map_err(|e| format!("sponsor key: {e}"))?;
super::registry::claim_and_maybe_set_main_sponsored(
&signer,
&fee_payer,
&name,
super::registry::ALPHA_USD_ADDRESS,
)
.await
.map_err(|e| format!("claim_name: {e}"))
}
.await;
match result {
Ok(tx_hash) => {
web_sys::console::log_1(&JsValue::from_str(&format!(
"claimed {name} (tx {})",
short_hash(&tx_hash)
)));
let target = format!("https://{name}.localharness.xyz/?claim=1");
if let Ok(window) = dom::window() {
let _ = window.location().assign(&target);
}
}
Err(err) => {
web_sys::console::warn_1(&JsValue::from_str(&format!("apex claim failed: {err}")));
if let Some(rest) = err.strip_prefix("__NEED_LH__") {
set_create_button_failed_with(&format!("need {rest} more LH"));
} else {
set_create_button_state(CreateBtnState::Failed);
}
}
}
}
fn set_create_button_busy(busy: bool) {
let Some(btn) = dom::by_id("create-btn") else { return };
if busy {
btn.set_inner_html("creating…");
let _ = btn.set_attribute("disabled", "");
} else {
btn.set_inner_html("create");
}
}
fn wallet_address_hex(addr: &[u8; 20]) -> String {
let mut s = String::with_capacity(42);
s.push_str("0x");
for b in addr {
s.push_str(&format!("{b:02x}"));
}
s
}
fn short_hash(tx_hash: &str) -> String {
let stripped = tx_hash.trim_start_matches("0x");
if stripped.len() < 12 {
return tx_hash.to_string();
}
format!("{}…{}", &stripped[..6], &stripped[stripped.len() - 4..])
}
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::ClearKey => clear_key_pressed(),
Action::OpfsRefresh => {
wasm_bindgen_futures::spawn_local(async move {
super::opfs::refresh().await;
});
}
Action::OpfsCloseViewer => super::opfs::close_viewer(),
Action::ToggleDisplay => super::opfs::toggle_display(),
Action::StopTurn => super::chat::request_stop_turn(),
Action::SetPublicFace(choice) => {
wasm_bindgen_futures::spawn_local(async move {
run_set_public_face(&choice).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 {
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 {
run_apex_claim(cleaned, true).await;
});
}
Action::ClaimHere => {
wasm_bindgen_futures::spawn_local(async move {
let super::tenant::Host::Tenant(name) = super::tenant::current() else {
return;
};
let addr = super::registry::owner_of_name(&name)
.await
.ok()
.flatten();
match addr {
Some(addr) => {
let _ = super::owner::remember(&addr).await;
super::paint_tenant(
super::tenant::Host::Tenant(name.clone()),
name,
)
.await;
}
None => {
dom::swap_inner(
"claim-msg",
&dom::msg_span(dom::Msg::Error, "claim failed: name has no on-chain owner"),
);
}
}
});
}
Action::ClaimOnChain => {
let Some(name) = (match super::tenant::current() {
super::tenant::Host::Tenant(n) => Some(n),
_ => None,
}) else {
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::ImportOwner => {
let raw = dom::input_by_id("import-uuid")
.map(|i| i.value().trim().to_string())
.unwrap_or_default();
if raw.len() < 32 {
dom::swap_inner(
"claim-msg",
"<span style=\"color:var(--error)\">paste a full UUID (36 chars with dashes)</span>",
);
return;
}
wasm_bindgen_futures::spawn_local(async move {
let fs = super::shared_opfs();
if let Err(err) = fs.write_atomic(".lh_owner", raw.as_bytes()).await {
dom::swap_inner(
"claim-msg",
&dom::msg_span(dom::Msg::Error, &format!("import failed: {err}")),
);
return;
}
if let super::tenant::Host::Tenant(name) = super::tenant::current() {
super::paint_tenant(super::tenant::Host::Tenant(name.clone()), name).await;
}
});
}
Action::RevealSeed => {
match super::tenant::current() {
super::tenant::Host::Apex => {
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(),
);
}
}
_ => {
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::CreateIdentity => {
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 {
if let Err(err) = super::wallet_store::create_and_persist().await {
dom::swap_inner(
"identity-msg",
&dom::msg_span(dom::Msg::Error, &format!("create failed: {err}")),
);
return;
}
super::paint_apex(super::tenant::Host::Apex).await;
});
}
host => {
wasm_bindgen_futures::spawn_local(async move {
match super::verify::create_wallet_via_iframe(true).await {
Ok(_addr) => {
if let super::tenant::Host::Tenant(name) = &host {
super::paint_tenant(host.clone(), name.clone()).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::verify::import_seed_via_iframe(&phrase).await {
Ok(_addr) => {
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(),
);
}
Action::OpfsWipeConfirm => {
dom::swap_outer(
"opfs-wipe-slot",
&templates::opfs_wipe_armed_inline().into_string(),
);
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(),
);
}
Action::CancelImport => {
dom::swap_outer("import-slot", r#"<div id="import-slot"></div>"#);
}
Action::HeaderAdminToggle => header_admin_toggle(),
Action::HeaderAdminClose => header_admin_close(),
Action::ShowAdminTab(name) => show_admin_tab(&name),
Action::SyncKey => {
wasm_bindgen_futures::spawn_local(async move { run_sync_key().await });
}
Action::RestoreKey => {
wasm_bindgen_futures::spawn_local(async move { run_restore_key().await });
}
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::swap_outer(
"reset-confirm-slot",
&templates::reset_confirm_inline().into_string(),
);
}
Action::ResetCancel => {
dom::swap_outer(
"reset-confirm-slot",
&templates::reset_armed_inline().into_string(),
);
}
Action::ResetConfirm => reset_confirm_pressed(),
Action::PricingSave => pricing_save_pressed(),
Action::ToggleFiles => toggle_layout_class("files-collapsed"),
Action::ToggleFinancial => toggle_layout_class("financial-collapsed"),
Action::ToggleTerminal => toggle_layout_class("terminal-collapsed"),
Action::ToggleView => toggle_layout_class("view-collapsed"),
Action::ShowTab(name) => show_mobile_tab(&name),
Action::FeedbackSubmit => super::feedback::feedback_submit(),
Action::PairStart => pair_start_pressed(),
Action::AddDevice => add_device_pressed(),
Action::SyncDevices => run_sync_devices(),
Action::AdoptDevice => adopt_device_pressed(),
Action::PairCancel => pair_cancel_pressed(),
Action::PairApprove => pair_approve_pressed(),
Action::PairReject => pair_reject_pressed(),
Action::PairJoin => {
if let Some(code) = super::read_query_param("pair") {
pair_join_pressed(code);
}
}
Action::AgentActToggle(token_id) => agent_act_toggle_pressed(token_id),
Action::AgentSendLh(token_id) => agent_send_lh_pressed(token_id),
Action::SavePrompt => save_prompt_pressed(),
Action::SaveToolAllowlist => save_tool_allowlist_pressed(),
Action::ResetToolAllowlist => reset_tool_allowlist_pressed(),
Action::SaveApiKey => save_api_key_pressed(),
Action::SetModelAccess(mode) => run_set_model_access(mode),
Action::SetModel(model) => run_set_model(model),
Action::DownloadLocalModel => run_download_local_model(),
Action::OpenSession => open_session_pressed(),
Action::RedeemCode => redeem_code_pressed(),
Action::RedeemBanner => redeem_banner_pressed(),
Action::DepositCredits => deposit_credits_pressed(),
Action::CreateInvite => create_invite_pressed(),
Action::ScheduleJob => schedule_job_pressed(),
Action::CancelJob(id) => cancel_job_pressed(id),
Action::PostBounty => post_bounty_pressed(),
Action::ClaimBounty(id) => claim_bounty_pressed(id),
Action::SaveX402Price => save_x402_price_pressed(),
Action::Consolidate => consolidate_pressed(),
Action::UnlinkDevice(addr) => unlink_device_prompt(addr),
Action::UnlinkConfirm(addr) => unlink_confirm_pressed(addr),
Action::UnlinkCancel => unlink_cancel_pressed(),
}
}
fn save_prompt_pressed() {
let Some(textarea) = dom::textarea_by_id("prompt-input") else { return };
let content = textarea.value();
dom::swap_inner(
"prompt-msg",
"<span style=\"color:var(--muted)\">saving…</span>",
);
wasm_bindgen_futures::spawn_local(async move {
match super::system_prompt::save(&content).await {
Ok(()) => {
let trimmed = content.trim();
let summary = if trimmed.is_empty() {
"✓ saved · using default on next session"
} else {
"✓ saved · takes effect on next session"
};
dom::swap_inner(
"prompt-msg",
&dom::msg_span(dom::Msg::Accent, &format!("{summary}")),
);
}
Err(err) => {
dom::swap_inner(
"prompt-msg",
&dom::msg_span(dom::Msg::Error, &format!("{err}")),
);
}
}
});
}
fn save_tool_allowlist_pressed() {
use crate::types::BuiltinTool;
let mut enabled = Vec::new();
if let Some(doc) = web_sys::window().and_then(|w| w.document()) {
if let Ok(checkboxes) = doc.query_selector_all(".tool-checkbox") {
for i in 0..checkboxes.length() {
if let Some(el) = checkboxes.get(i) {
let input: web_sys::HtmlInputElement = JsCast::unchecked_into(el);
if input.checked() {
if let Some(name) = input.get_attribute("data-tool") {
if let Some(tool) = BuiltinTool::ALL.iter().find(|t| t.wire_name() == name) {
enabled.push(*tool);
}
}
}
}
}
}
}
dom::swap_inner(
"tool-allowlist-msg",
"<span style=\"color:var(--muted)\">saving…</span>",
);
wasm_bindgen_futures::spawn_local(async move {
match super::tool_allowlist::save(&enabled).await {
Ok(()) => {
let summary = super::tool_allowlist::summary(&enabled);
dom::swap_inner(
"tool-allowlist-msg",
&dom::msg_span(dom::Msg::Accent, &format!("✓ saved · {summary} · takes effect on next session")),
);
}
Err(err) => {
dom::swap_inner(
"tool-allowlist-msg",
&dom::msg_span(dom::Msg::Error, &format!("{err}")),
);
}
}
});
}
fn reset_tool_allowlist_pressed() {
dom::swap_inner(
"tool-allowlist-msg",
"<span style=\"color:var(--muted)\">resetting…</span>",
);
if let Some(doc) = web_sys::window().and_then(|w| w.document()) {
if let Ok(checkboxes) = doc.query_selector_all(".tool-checkbox") {
for i in 0..checkboxes.length() {
if let Some(el) = checkboxes.get(i) {
let input: web_sys::HtmlInputElement = JsCast::unchecked_into(el);
input.set_checked(true);
}
}
}
}
wasm_bindgen_futures::spawn_local(async move {
match super::tool_allowlist::save(&[]).await {
Ok(()) => {
dom::swap_inner(
"tool-allowlist-msg",
"<span style=\"color:var(--accent)\">✓ reset · all tools enabled · takes effect on next session</span>",
);
}
Err(err) => {
dom::swap_inner(
"tool-allowlist-msg",
&dom::msg_span(dom::Msg::Error, &format!("{err}")),
);
}
}
});
}
fn save_api_key_pressed() {
let Some(input) = dom::input_by_id("api-key-input") else { return };
let value = input.value().trim().to_string();
if value.is_empty() {
return;
}
if let Ok(Some(storage)) = dom::session_storage() {
let _ = storage.set_item("gemini_api_key", &value);
}
dom::swap_inner(
"api-key-msg",
"<span style=\"color:var(--muted)\">checking…</span>",
);
wasm_bindgen_futures::spawn_local(async move {
super::key_store::save(&value).await;
super::opfs::refresh().await;
if let Some(false) = gemini_key_is_valid(&value).await {
dom::swap_inner(
"api-key-msg",
"<span style=\"color:var(--error)\">key rejected — check it</span>",
);
return;
}
if let Some(el) = dom::by_id("api-key-modal") {
if let Some(parent) = el.parent_element() {
let _ = parent.remove_child(&el);
}
}
if let super::tenant::Host::Tenant(name) = super::tenant::current() {
auto_sync_gemini_key(name, value).await;
}
});
}
async fn gemini_key_is_valid(key: &str) -> Option<bool> {
let url = format!("https://generativelanguage.googleapis.com/v1beta/models?key={key}");
match reqwest::Client::new().get(&url).send().await {
Ok(resp) => Some(resp.status().is_success()),
Err(_) => None,
}
}
fn agent_act_toggle_pressed(token_id_str: String) {
let Ok(token_id) = token_id_str.parse::<u64>() else { return };
let panel_id = format!("agent-act-{token_id}");
let Some(panel) = dom::by_id(&panel_id) else { return };
let was_hidden = panel.has_attribute("hidden");
if was_hidden {
panel.set_inner_html(
"<div class=\"admin-msg-slot\"><span style=\"color:var(--muted)\">loading…</span></div>",
);
let _ = panel.remove_attribute("hidden");
wasm_bindgen_futures::spawn_local(async move {
if let Err(err) = paint_agent_act_panel(token_id).await {
let panel_id = format!("agent-act-{token_id}");
dom::swap_inner(
&panel_id,
&maud::html! {
div class="admin-msg-slot" {
span style="color:var(--error)" { (err) }
}
}
.into_string(),
);
}
});
} else {
let _ = panel.set_attribute("hidden", "");
}
}
async fn paint_agent_act_panel(token_id: u64) -> Result<(), String> {
let tba = super::registry::tba_of_token_id(token_id)
.await
.map_err(|e| format!("tba: {e}"))?
.ok_or_else(|| "no TBA".to_string())?;
let balance = super::registry::token_balance_of(&tba).await.unwrap_or(0);
let html = templates::agent_act_panel(token_id, &tba, balance).into_string();
let panel_id = format!("agent-act-{token_id}");
dom::swap_inner(&panel_id, &html);
Ok(())
}
fn agent_send_lh_pressed(token_id_str: String) {
let Ok(token_id) = token_id_str.parse::<u64>() else { return };
let msg_id = format!("agent-act-msg-{token_id}");
let to_raw = dom::input_by_id(&format!("agent-send-to-{token_id}"))
.map(|i| i.value().trim().to_string())
.unwrap_or_default();
let amt_raw = dom::input_by_id(&format!("agent-send-amt-{token_id}"))
.map(|i| i.value().trim().to_string())
.unwrap_or_default();
let Ok(crate::encoding::Recipient::Address(to_raw)) =
crate::encoding::classify_recipient(&to_raw)
else {
return; };
let Some(amount_wei) = parse_token_amount(&amt_raw) else { return };
if amount_wei == 0 {
return;
}
let signer = super::APP.with(|cell| {
cell.borrow().wallet.as_ref().map(|w| w.signer.clone())
});
let Some(signer) = signer else { return };
dom::swap_inner(
&msg_id,
"<span style=\"color:var(--muted)\">signing + submitting…</span>",
);
wasm_bindgen_futures::spawn_local(async move {
let msg_id = format!("agent-act-msg-{token_id}");
let result = async {
let tba = super::registry::tba_of_token_id(token_id)
.await
.map_err(|e| format!("tba: {e}"))?
.ok_or_else(|| "no TBA".to_string())?;
let fee_payer = super::sponsor::signer()?;
super::registry::tba_transfer_lh_sponsored(
&signer,
&fee_payer,
token_id,
&tba,
&to_raw,
amount_wei,
super::registry::ALPHA_USD_ADDRESS,
)
.await
}
.await;
match result {
Ok(tx_hash) => {
let short = tx_short_hash(&tx_hash);
dom::swap_inner(
&msg_id,
&dom::msg_span(dom::Msg::Accent, &format!("✓ sent (tx {short})")),
);
let _ = paint_agent_act_panel(token_id).await;
}
Err(err) => {
dom::swap_inner(
&msg_id,
&dom::msg_span(dom::Msg::Error, &format!("{err}")),
);
}
}
});
}
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;
}
pub(crate) async fn refresh_fund_banner() {
if dom::by_id("fund-banner").is_none() {
return;
}
let is_credits = local_storage()
.and_then(|s| s.get_item("lh_model_access").ok().flatten())
.map(|m| m != "byok")
.unwrap_or(true);
if !is_credits {
dom::swap_inner("fund-banner", "");
return;
}
let Some(addr) = super::chat::credit_address_existing().await else {
dom::swap_inner("fund-banner", "");
return;
};
let wallet = super::registry::token_balance_of(&addr).await.unwrap_or(0);
let meter = super::registry::credit_balance_of(&addr).await.unwrap_or(0);
if wallet == 0 && meter == 0 {
dom::swap_inner("fund-banner", &templates::fund_banner_body().into_string());
} else {
dom::swap_inner("fund-banner", "");
}
}
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() {
const LOW_THRESHOLD_WEI: u128 = 5_000_000_000_000_000_000; let Ok(signer) = super::sponsor::signer() else {
return;
};
let addr = crate::wallet::address(&signer);
let addr_hex = format!(
"0x{}",
addr.iter().map(|b| format!("{b:02x}")).collect::<String>()
);
if let Ok(bal) =
super::registry::erc20_balance_of(super::registry::ALPHA_USD_ADDRESS, &addr_hex).await
{
if bal < LOW_THRESHOLD_WEI {
let whole = bal / 1_000_000_000_000_000_000u128;
web_sys::console::warn_1(&JsValue::from_str(&format!(
"sponsor fee-token LOW: ~{whole} AlphaUSD at {addr_hex} — refill soon"
)));
}
}
}
fn run_set_model_access(mode: String) {
if let Some(storage) =
web_sys::window().and_then(|w| w.local_storage().ok().flatten())
{
let _ = storage.set_item("lh_model_access", &mode);
}
if dom::by_id("credits-section").is_some() {
dom::swap_outer(
"credits-section",
&super::templates::admin_credits_section().into_string(),
);
}
if mode == "credits" {
if let Some(el) = dom::by_id("api-key-modal") {
if let Some(parent) = el.parent_element() {
let _ = parent.remove_child(&el);
}
}
}
wasm_bindgen_futures::spawn_local(async {
refresh_credits_pill().await;
});
}
fn run_set_model(model: String) {
wasm_bindgen_futures::spawn_local(async move {
super::model::save(&model).await;
refresh_model_selector().await;
let label = super::model::MODELS
.iter()
.find(|(id, _)| *id == model)
.map(|(_, l)| *l)
.unwrap_or("model");
dom::swap_inner(
"model-msg",
&format!("{label} — applies on your next message"),
);
});
}
const LOCAL_WEIGHTS_URL: &str =
"https://huggingface.co/unsloth/gemma-3-270m/resolve/main/model.safetensors";
const LOCAL_TOKENIZER_URL: &str =
"https://huggingface.co/unsloth/gemma-3-270m/resolve/main/tokenizer.json";
const LOCAL_WEIGHTS_OPFS: &str = ".lh_local_model.safetensors";
const LOCAL_TOKENIZER_OPFS: &str = ".lh_local_tokenizer.json";
fn run_download_local_model() {
use futures_util::StreamExt as _;
wasm_bindgen_futures::spawn_local(async move {
let fs = super::shared_opfs();
async fn fetch_to_opfs(
fs: &std::sync::Arc<crate::filesystem::OpfsFilesystem>,
url: &str,
opfs_path: &str,
label: &str,
) -> Result<(), String> {
use crate::filesystem::Filesystem as _;
let resp = reqwest::Client::new()
.get(url)
.send()
.await
.map_err(|e| format!("fetch {label}: {e}"))?;
if !resp.status().is_success() {
return Err(format!("fetch {label}: HTTP {}", resp.status().as_u16()));
}
let total = resp.content_length();
let mut buf: Vec<u8> = Vec::with_capacity(total.unwrap_or(0) as usize);
let mut stream = resp.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| format!("download {label}: {e}"))?;
buf.extend_from_slice(&chunk);
let got_mb = buf.len() / (1024 * 1024);
let msg = match total {
Some(t) => {
let pct = (buf.len() as f64 / t as f64 * 100.0) as u32;
format!("downloading {label}: {got_mb} MB ({pct}%)")
}
None => format!("downloading {label}: {got_mb} MB"),
};
dom::swap_inner("local-model-msg", &msg);
}
fs.write_atomic(opfs_path, &buf)
.await
.map_err(|e| format!("save {label}: {e}"))?;
Ok(())
}
dom::swap_inner("local-model-msg", "starting download…");
let result = async {
fetch_to_opfs(&fs, LOCAL_TOKENIZER_URL, LOCAL_TOKENIZER_OPFS, "tokenizer").await?;
fetch_to_opfs(&fs, LOCAL_WEIGHTS_URL, LOCAL_WEIGHTS_OPFS, "weights").await?;
Ok::<(), String>(())
}
.await;
match result {
Ok(()) => dom::swap_inner(
"local-model-msg",
"local model ready — select Local (Gemma) and send a message",
),
Err(e) => {
web_sys::console::warn_1(&JsValue::from_str(&format!("local model download: {e}")));
dom::swap_inner("local-model-msg", &dom::msg_span(dom::Msg::Error, &e));
}
}
});
}
async fn refresh_model_selector() {
if dom::by_id("model-selector-row").is_none() {
return;
}
let chosen = super::model::load().await;
if let Some(doc) = web_sys::window().and_then(|w| w.document()) {
if let Ok(buttons) = doc.query_selector_all("#model-selector-row button[data-model]") {
for i in 0..buttons.length() {
if let Some(el) = buttons.get(i) {
let btn: web_sys::Element = JsCast::unchecked_into(el);
let is_active = btn.get_attribute("data-model").as_deref() == Some(&chosen);
btn.set_class_name(if is_active { "ghost active" } else { "ghost" });
}
}
}
}
}
fn open_session_pressed() {
dom::swap_inner(
"credits-msg",
"<span style=\"color:var(--muted)\">opening session…</span>",
);
wasm_bindgen_futures::spawn_local(async move {
let result = async {
sponsor_rate_guard()?;
let (signer, _) = super::chat::credit_signer()
.await
.ok_or_else(|| "no identity".to_string())?;
let fee_payer = super::sponsor::signer()?;
super::registry::open_session_sponsored(
&signer,
&fee_payer,
super::registry::ALPHA_USD_ADDRESS,
)
.await
}
.await;
match result {
Ok(_) => {
dom::swap_inner(
"credits-msg",
"<span style=\"color:var(--muted)\">session opened</span>",
);
refresh_credits_pill().await;
}
Err(e) => {
web_sys::console::warn_1(&JsValue::from_str(&format!("open session: {e}")));
dom::swap_inner(
"credits-msg",
"<span style=\"color:var(--error)\">couldn't open session</span>",
);
}
}
});
}
fn redeem_code_pressed() {
redeem_from("redeem-code", "credits-msg");
}
fn redeem_banner_pressed() {
redeem_from("fund-redeem-code", "fund-msg");
}
fn redeem_from(input_id: &'static str, msg_id: &'static str) {
let Some(input) = dom::input_by_id(input_id) else { return };
let code = input.value().trim().to_string();
if code.is_empty() {
return;
}
dom::swap_inner(
msg_id,
"<span style=\"color:var(--muted)\">redeeming…</span>",
);
wasm_bindgen_futures::spawn_local(async move {
let result = async {
let (signer, _) = super::chat::credit_signer()
.await
.ok_or_else(|| "no identity".to_string())?;
let fee_payer = super::sponsor::signer()?;
super::registry::redeem_sponsored(
&signer,
&fee_payer,
&code,
super::registry::ALPHA_USD_ADDRESS,
)
.await
}
.await;
match result {
Ok(_) => {
dom::swap_inner(
msg_id,
"<span style=\"color:var(--muted)\">redeemed</span>",
);
super::chat::ensure_credit_meter().await;
refresh_credits_pill().await;
refresh_fund_banner().await;
}
Err(e) => {
web_sys::console::warn_1(&JsValue::from_str(&format!("redeem: {e}")));
dom::swap_inner(
msg_id,
&dom::msg_span(dom::Msg::Error, &format!("redeem failed: {e}")),
);
}
}
});
}
fn local_storage() -> Option<web_sys::Storage> {
web_sys::window().and_then(|w| w.local_storage().ok().flatten())
}
fn pending_invite_code() -> Option<String> {
local_storage()?
.get_item("lh_pending_invite")
.ok()
.flatten()
.filter(|s| !s.is_empty())
}
pub(crate) async fn try_redeem_pending_invite(allow_generate: bool) {
let Some(code) = pending_invite_code() else {
return;
};
if !allow_generate && super::chat::credit_address_existing().await.is_none() {
return;
}
let Some((signer, _)) = super::chat::credit_signer().await else {
return;
};
let Ok(fee_payer) = super::sponsor::signer() else {
return;
};
if let Some(s) = local_storage() {
let _ = s.remove_item("lh_pending_invite");
}
let is_invite = code.starts_with("inv-");
dom::set_status(
if is_invite { "accepting invite…" } else { "redeeming invite…" },
false,
);
let result = if is_invite {
super::registry::accept_invite_sponsored(
&signer,
&fee_payer,
&code,
super::registry::ALPHA_USD_ADDRESS,
)
.await
} else {
super::registry::redeem_sponsored(
&signer,
&fee_payer,
&code,
super::registry::ALPHA_USD_ADDRESS,
)
.await
};
match result {
Ok(_) => {
if let Some(s) = local_storage() {
let _ = s.set_item("lh_model_access", "credits");
}
dom::set_status(
if is_invite {
"invite accepted — $LH added"
} else {
"invite redeemed — platform credits added"
},
false,
);
refresh_credits_pill().await;
refresh_fund_banner().await;
}
Err(e) => {
web_sys::console::warn_1(&JsValue::from_str(&format!("invite claim: {e}")));
dom::set_status(
"invite couldn't be claimed (it may be used or expired)",
true,
);
refresh_fund_banner().await;
}
}
}
fn deposit_credits_pressed() {
let Some(input) = dom::input_by_id("deposit-amount") else {
return;
};
let Ok(whole) = input.value().trim().parse::<u128>() else {
return;
};
if whole == 0 {
return;
}
let Some(amount_wei) = whole.checked_mul(1_000_000_000_000_000_000u128) else {
return;
};
dom::swap_inner(
"credits-msg",
"<span style=\"color:var(--muted)\">depositing…</span>",
);
wasm_bindgen_futures::spawn_local(async move {
let result = async {
sponsor_rate_guard()?;
let (signer, _) = super::chat::credit_signer()
.await
.ok_or_else(|| "no identity".to_string())?;
let fee_payer = super::sponsor::signer()?;
super::registry::deposit_credits_sponsored(
&signer,
&fee_payer,
amount_wei,
super::registry::ALPHA_USD_ADDRESS,
)
.await
}
.await;
match result {
Ok(_) => {
dom::swap_inner(
"credits-msg",
"<span style=\"color:var(--muted)\">credits added</span>",
);
refresh_credits_pill().await;
}
Err(e) => {
web_sys::console::warn_1(&JsValue::from_str(&format!("deposit: {e}")));
dom::swap_inner(
"credits-msg",
"<span style=\"color:var(--error)\">deposit failed</span>",
);
}
}
});
}
const INVITE_DEFAULT_TTL_SECS: u64 = 7 * 24 * 3600;
fn gen_invite_code(amount_label: &str) -> String {
const ALPHABET: &[u8; 32] = b"abcdefghjkmnpqrstvwxyz23456789ab";
let bytes = super::registry::random_x402_nonce(); let mut tail = String::with_capacity(10);
for &b in bytes.iter().take(10) {
tail.push(ALPHABET[(b & 0x1f) as usize] as char);
}
format!("inv-{amount_label}-{tail}")
}
fn create_invite_pressed() {
let Some(input) = dom::input_by_id("invite-amount") else {
return;
};
let raw = input.value().trim().to_string();
let Some(amount_wei) = crate::encoding::parse_token_amount(&raw) else {
return;
};
if amount_wei == 0 {
return;
}
let amount_label: String = raw
.chars()
.filter(|c| c.is_ascii_digit() || *c == '.')
.collect();
let code = gen_invite_code(&amount_label);
let code_hash = super::registry::invite_code_hash(&code);
dom::swap_inner(
"invite-result",
"<span style=\"color:var(--muted)\">creating invite…</span>",
);
wasm_bindgen_futures::spawn_local(async move {
let result = async {
sponsor_rate_guard()?;
let (signer, _) = super::chat::credit_signer()
.await
.ok_or_else(|| "no identity".to_string())?;
let fee_payer = super::sponsor::signer()?;
super::registry::create_invite_sponsored(
&signer,
&fee_payer,
code_hash,
amount_wei,
INVITE_DEFAULT_TTL_SECS,
super::registry::ALPHA_USD_ADDRESS,
)
.await
}
.await;
match result {
Ok(_) => {
refresh_credits_pill().await;
let link = format!("https://localharness.xyz/?invite={code}");
dom::swap_inner(
"invite-result",
&templates::invite_result_panel(&code, &link).into_string(),
);
}
Err(e) => {
web_sys::console::warn_1(&JsValue::from_str(&format!("create invite: {e}")));
dom::swap_inner(
"invite-result",
&dom::msg_span(dom::Msg::Error, "invite couldn't be created (need $LH to escrow)"),
);
}
}
});
}
const SCHEDULE_MIN_INTERVAL_SECS: u64 = 60;
const SCHEDULE_DEFAULT_RUNS: u32 = 100;
fn parse_schedule_interval(raw: &str) -> Option<u64> {
let s = raw.trim().to_ascii_lowercase();
if s.is_empty() {
return None;
}
let (num_part, mult) = match s.strip_suffix('s') {
Some(n) => (n, 1u64),
None => match s.strip_suffix('m') {
Some(n) => (n, 60u64),
None => match s.strip_suffix('h') {
Some(n) => (n, 3600u64),
None => (s.as_str(), 1u64), },
},
};
let secs = num_part.parse::<u64>().ok()?.checked_mul(mult)?;
(secs >= SCHEDULE_MIN_INTERVAL_SECS).then_some(secs)
}
fn fmt_schedule_interval(secs: u64) -> String {
if secs == 0 {
return "0s".to_string();
}
if secs % 3600 == 0 {
return format!("{}h", secs / 3600);
}
if secs >= 3600 {
let h = secs / 3600;
let m = (secs % 3600) / 60;
let rest_s = secs % 60;
if rest_s == 0 {
return format!("{h}h{m}m");
}
}
if secs % 60 == 0 {
return format!("{}m", secs / 60);
}
format!("{secs}s")
}
fn schedule_job_pressed() {
let target = dom::input_by_id("schedule-target")
.map(|i| i.value().trim().to_string())
.unwrap_or_default();
let task = dom::input_by_id("schedule-task")
.map(|i| i.value().trim().to_string())
.unwrap_or_default();
let interval_raw = dom::input_by_id("schedule-interval")
.map(|i| i.value())
.unwrap_or_default();
let budget_raw = dom::input_by_id("schedule-budget")
.map(|i| i.value())
.unwrap_or_default();
let runs_raw = dom::input_by_id("schedule-runs")
.map(|i| i.value().trim().to_string())
.unwrap_or_default();
if target.is_empty() || task.is_empty() {
return;
}
let Some(interval_secs) = parse_schedule_interval(&interval_raw) else {
return;
};
let Some(budget_wei) = crate::encoding::parse_token_amount(&budget_raw) else {
return;
};
if budget_wei == 0 {
return;
}
let max_runs = if runs_raw.is_empty() {
SCHEDULE_DEFAULT_RUNS
} else {
match runs_raw.parse::<u32>() {
Ok(n) if n > 0 => n,
_ => return,
}
};
dom::swap_inner(
"schedule-result",
"<span style=\"color:var(--muted)\">scheduling…</span>",
);
wasm_bindgen_futures::spawn_local(async move {
let result = async {
sponsor_rate_guard()?;
let target_id = super::registry::id_of_name(&target).await?;
if target_id == 0 {
return Err("target agent not found".to_string());
}
let (signer, _) = super::chat::credit_signer()
.await
.ok_or_else(|| "no identity".to_string())?;
let fee_payer = super::sponsor::signer()?;
super::registry::schedule_job_sponsored(
&signer,
&fee_payer,
target_id,
task.as_bytes(),
interval_secs,
budget_wei,
max_runs,
super::registry::ALPHA_USD_ADDRESS,
)
.await
}
.await;
match result {
Ok(_) => {
refresh_credits_pill().await;
let new_id = match super::chat::credit_address_existing().await {
Some(addr) => super::registry::jobs_of(&addr)
.await
.ok()
.and_then(|ids| ids.last().copied())
.unwrap_or(0),
None => 0,
};
dom::swap_inner(
"schedule-result",
&templates::schedule_result_panel(new_id).into_string(),
);
refresh_jobs_list().await;
}
Err(e) => {
web_sys::console::warn_1(&JsValue::from_str(&format!("schedule job: {e}")));
dom::swap_inner(
"schedule-result",
&dom::msg_span(
dom::Msg::Error,
"job couldn't be scheduled (need $LH to escrow)",
),
);
}
}
});
}
fn cancel_job_pressed(job_id_raw: String) {
let Ok(job_id) = job_id_raw.trim().parse::<u64>() else {
return;
};
dom::swap_inner(
"schedule-result",
"<span style=\"color:var(--muted)\">cancelling…</span>",
);
wasm_bindgen_futures::spawn_local(async move {
let result = async {
sponsor_rate_guard()?;
let (signer, _) = super::chat::credit_signer()
.await
.ok_or_else(|| "no identity".to_string())?;
let fee_payer = super::sponsor::signer()?;
super::registry::cancel_job_sponsored(
&signer,
&fee_payer,
job_id,
super::registry::ALPHA_USD_ADDRESS,
)
.await
}
.await;
match result {
Ok(_) => {
dom::swap_inner(
"schedule-result",
&dom::msg_span(dom::Msg::Muted, "cancelled — remaining $LH refunded"),
);
refresh_credits_pill().await;
refresh_jobs_list().await;
}
Err(e) => {
web_sys::console::warn_1(&JsValue::from_str(&format!("cancel job: {e}")));
dom::swap_inner(
"schedule-result",
&dom::msg_span(dom::Msg::Error, "couldn't cancel that job"),
);
}
}
});
}
pub(crate) async fn refresh_jobs_list() {
if dom::by_id("schedule-jobs").is_none() {
return;
}
let Some(addr) = super::chat::credit_address_existing().await else {
return;
};
let ids = match super::registry::jobs_of(&addr).await {
Ok(v) => v,
Err(_) => {
dom::swap_inner("schedule-jobs", "");
return;
}
};
if ids.is_empty() {
dom::swap_inner(
"schedule-jobs",
&dom::msg_span(dom::Msg::Muted, "no scheduled jobs"),
);
return;
}
let now = (js_sys::Date::now() / 1000.0) as u64;
let mut rows: Vec<maud::Markup> = Vec::new();
for id in ids {
let Ok(job) = super::registry::get_job(id).await else {
continue;
};
let target = super::registry::name_of_id(job.target_id)
.await
.ok()
.filter(|n| !n.is_empty())
.unwrap_or_else(|| format!("token#{}", job.target_id));
let budget_whole = job.budget_wei / 1_000_000_000_000_000_000u128;
let budget_cents =
(job.budget_wei % 1_000_000_000_000_000_000u128) / 10_000_000_000_000_000u128;
let cadence = fmt_schedule_interval(job.interval);
let status = job.status_label();
let next = if job.next_run == 0 {
"—".to_string()
} else if job.next_run <= now {
"due".to_string()
} else {
let delta = job.next_run - now;
format!("in {}", fmt_schedule_interval(delta.max(1)))
};
let cancellable = matches!(job.status, 0 | 1);
rows.push(maud::html! {
div style="border-top:1px solid var(--border);padding:6px 0;font-size:11px;color:var(--fg)" {
div style="display:flex;align-items:center;gap:8px" {
code style="color:var(--muted)" { "#" (id) }
span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" { (target) }
span style="color:var(--muted)" { (status) }
@if cancellable {
button type="button" data-action="cancel-job" data-arg=(id.to_string())
.ghost style="padding:0 6px" { "cancel" }
}
}
div style="display:flex;flex-wrap:wrap;gap:10px;color:var(--muted);margin-top:2px" {
span { "every " (cadence) }
span { "next " (next) }
span { (budget_whole) "." (format!("{budget_cents:02}")) " LH" }
span { (job.runs_left) " runs left" }
}
}
});
}
let html = maud::html! {
div style="margin-top:8px" { @for r in &rows { (r) } }
}
.into_string();
dom::swap_inner("schedule-jobs", &html);
}
const BOUNTY_DEFAULT_TTL_HOURS: u64 = 24;
const BOUNTY_LIST_LIMIT: u64 = 25;
fn post_bounty_pressed() {
let task = dom::input_by_id("bounty-task")
.map(|i| i.value().trim().to_string())
.unwrap_or_default();
let reward_raw = dom::input_by_id("bounty-reward")
.map(|i| i.value())
.unwrap_or_default();
let ttl_raw = dom::input_by_id("bounty-ttl")
.map(|i| i.value().trim().to_string())
.unwrap_or_default();
if task.is_empty() {
return;
}
let Some(reward_wei) = crate::encoding::parse_token_amount(&reward_raw) else {
return;
};
if reward_wei == 0 {
return;
}
let ttl_secs = if ttl_raw.is_empty() {
BOUNTY_DEFAULT_TTL_HOURS * 3600
} else {
match ttl_raw.parse::<u64>() {
Ok(h) if h > 0 => h * 3600,
_ => return,
}
};
let reward_label: String = reward_raw
.chars()
.filter(|c| c.is_ascii_digit() || *c == '.')
.collect();
dom::swap_inner(
"bounty-result",
"<span style=\"color:var(--muted)\">posting…</span>",
);
wasm_bindgen_futures::spawn_local(async move {
let result = async {
sponsor_rate_guard()?;
let (signer, _) = super::chat::credit_signer()
.await
.ok_or_else(|| "no identity".to_string())?;
let fee_payer = super::sponsor::signer()?;
super::registry::post_bounty_sponsored(
&signer,
&fee_payer,
task.as_bytes(),
reward_wei,
ttl_secs,
super::registry::ALPHA_USD_ADDRESS,
)
.await
}
.await;
match result {
Ok(_) => {
refresh_credits_pill().await;
let new_id = match super::chat::credit_address_existing().await {
Some(addr) => super::registry::bounties_of(&addr)
.await
.ok()
.and_then(|ids| ids.last().copied())
.unwrap_or(0),
None => 0,
};
dom::swap_inner(
"bounty-result",
&templates::bounty_result_panel(new_id, &reward_label).into_string(),
);
refresh_bounty_list().await;
}
Err(e) => {
web_sys::console::warn_1(&JsValue::from_str(&format!("post bounty: {e}")));
dom::swap_inner(
"bounty-result",
&dom::msg_span(dom::Msg::Error, "bounty couldn't be posted (need $LH to escrow)"),
);
}
}
});
}
fn claim_bounty_pressed(bounty_id_raw: String) {
let Ok(bounty_id) = bounty_id_raw.trim().parse::<u64>() else {
return;
};
dom::swap_inner(
"bounty-result",
"<span style=\"color:var(--muted)\">claiming…</span>",
);
wasm_bindgen_futures::spawn_local(async move {
let result = async {
sponsor_rate_guard()?;
let tenant = match super::tenant::current() {
super::tenant::Host::Tenant(n) => n,
_ => return Err("not running on a subdomain".to_string()),
};
let claimant_token_id = super::registry::id_of_name(&tenant).await?;
if claimant_token_id == 0 {
return Err("this subdomain isn't registered on-chain yet".to_string());
}
let (signer, _) = super::chat::credit_signer()
.await
.ok_or_else(|| "no identity".to_string())?;
let fee_payer = super::sponsor::signer()?;
super::registry::claim_bounty_sponsored(
&signer,
&fee_payer,
bounty_id,
claimant_token_id,
super::registry::ALPHA_USD_ADDRESS,
)
.await
}
.await;
match result {
Ok(_) => {
dom::swap_inner(
"bounty-result",
&dom::msg_span(
dom::Msg::Muted,
"claimed — work the task, then submit_result via chat",
),
);
refresh_bounty_list().await;
}
Err(e) => {
web_sys::console::warn_1(&JsValue::from_str(&format!("claim bounty: {e}")));
dom::swap_inner(
"bounty-result",
&dom::msg_span(dom::Msg::Error, "couldn't claim that bounty"),
);
}
}
});
}
pub(crate) async fn refresh_bounty_list() {
if dom::by_id("bounty-list").is_none() {
return;
}
let ids = match super::registry::open_bounties(0, BOUNTY_LIST_LIMIT).await {
Ok(v) => v,
Err(_) => {
dom::swap_inner("bounty-list", "");
return;
}
};
if ids.is_empty() {
dom::swap_inner(
"bounty-list",
&dom::msg_span(dom::Msg::Muted, "no open bounties"),
);
return;
}
let mut rows: Vec<maud::Markup> = Vec::new();
for id in ids {
let Ok(b) = super::registry::get_bounty(id).await else {
continue;
};
let reward_wei = b.reward_wei;
let claimant = b.claimant_token_id;
let task = super::registry::task_of_bounty(id)
.await
.ok()
.filter(|t| !t.is_empty())
.unwrap_or_else(|| format!("bounty#{id}"));
let reward_whole = reward_wei / 1_000_000_000_000_000_000u128;
let reward_cents =
(reward_wei % 1_000_000_000_000_000_000u128) / 10_000_000_000_000_000u128;
let claimable = claimant == 0;
rows.push(maud::html! {
div style="border-top:1px solid var(--border);padding:6px 0;font-size:11px;color:var(--fg)" {
div style="display:flex;align-items:center;gap:8px" {
code style="color:var(--muted)" { "#" (id) }
span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" { (task) }
span style="color:var(--muted)" { (reward_whole) "." (format!("{reward_cents:02}")) " LH" }
@if claimable {
button type="button" data-action="claim-bounty" data-arg=(id.to_string())
.ghost style="padding:0 6px" { "claim" }
} @else {
span style="color:var(--muted)" { "claimed" }
}
}
}
});
}
let html = maud::html! {
div style="margin-top:8px" { @for r in &rows { (r) } }
}
.into_string();
dom::swap_inner("bounty-list", &html);
}
fn save_x402_price_pressed() {
let Some(input) = dom::input_by_id("x402-price-input") else {
return;
};
let raw = input.value().trim().to_string();
wasm_bindgen_futures::spawn_local(async move {
use crate::filesystem::Filesystem;
let fs = super::shared_opfs();
let result: Result<(), String> = async {
if raw.is_empty() || raw == "0" {
let _ = fs.delete(".lh_x402_price").await;
return Ok(());
}
let whole: u128 = raw.parse().map_err(|_| "bad amount".to_string())?;
let wei = whole
.checked_mul(1_000_000_000_000_000_000u128)
.ok_or_else(|| "overflow".to_string())?;
fs.write_atomic(".lh_x402_price", wei.to_string().as_bytes())
.await
.map_err(|e| e.to_string())
}
.await;
match result {
Ok(()) => dom::swap_inner(
"x402-price-msg",
"<span style=\"color:var(--muted)\">saved</span>",
),
Err(e) => {
web_sys::console::warn_1(&JsValue::from_str(&format!("x402 price: {e}")));
dom::swap_inner(
"x402-price-msg",
"<span style=\"color:var(--error)\">save failed</span>",
);
}
}
});
}
async fn refresh_signer_list() {
let addr = super::APP.with(|cell| {
cell.borrow().wallet.as_ref().map(|w| w.address_hex())
});
let addr = match addr {
Some(a) => a,
None => match super::wallet_store::load_linked_owner().await {
Some(o) => o,
None => return,
},
};
let main_id = match super::registry::main_of(&addr).await {
Ok(id) if id > 0 => id,
_ => {
dom::swap_inner("signer-list", "no MAIN set");
return;
}
};
match super::registry::devices_of(main_id).await {
Ok(signers) if signers.is_empty() => {
dom::swap_inner("signer-list", "owner only (no linked devices)");
}
Ok(signers) => {
let html = maud::html! {
@for s in &signers {
@let short = if s.len() > 10 {
format!("{}…{}", &s[..6], &s[s.len()-4..])
} else {
s.clone()
};
div style="display:flex;justify-content:center;align-items:center;gap:8px;color:var(--fg);font-size:11px;margin:2px 0" {
code { (short) }
button type="button" class="modal-close" data-action="unlink-device"
data-arg=(s) title="unlink" { "×" }
}
}
}
.into_string();
dom::swap_inner("signer-list", &html);
}
Err(_) => {
dom::swap_inner("signer-list", "");
}
}
}
struct PendingPair {
device: String,
device_pubkey: Vec<u8>,
token_id: u64,
owner_hex: String,
}
thread_local! {
static PENDING_PAIR: std::cell::RefCell<Option<PendingPair>> =
const { std::cell::RefCell::new(None) };
}
fn pair_start_pressed() {
wasm_bindgen_futures::spawn_local(async move {
let owner_hex = super::APP
.with(|cell| cell.borrow().wallet.as_ref().map(|w| w.address_hex()));
let Some(owner_hex) = owner_hex else {
dom::swap_inner("pair-msg", &dom::msg_span(dom::Msg::Error, "no identity"));
return;
};
let token_id = match super::registry::main_of(&owner_hex).await {
Ok(id) if id != 0 => id,
_ => {
dom::swap_inner(
"pair-msg",
&dom::msg_span(dom::Msg::Error, "claim a subdomain first"),
);
return;
}
};
let name = match super::registry::name_of_id(token_id).await {
Ok(n) if !n.is_empty() => n,
_ => {
dom::swap_inner("pair-msg", &dom::msg_span(dom::Msg::Error, "no MAIN name"));
return;
}
};
let code = generate_pair_code();
let pair_url = format!("https://{name}.localharness.xyz/?pair={code}");
dom::swap_outer(
"pair-slot",
&templates::pair_panel(&code, &pair_url).into_string(),
);
dom::swap_inner("pair-msg", "");
let code_hash = super::registry::pairing_code_hash(&code);
let mut found: Option<(String, Vec<u8>)> = None;
for _ in 0..100 {
if dom::by_id("pair-slot")
.map(|el| !el.class_name().contains("pair-active"))
.unwrap_or(true)
{
return;
}
match super::registry::find_pairing_device(&code_hash).await {
Ok(Some(pair)) => {
found = Some(pair);
break;
}
_ => {}
}
super::registry::sleep_ms(3000).await;
}
let Some((device, device_pubkey)) = found else {
dom::swap_inner(
"pair-msg",
&dom::msg_span(dom::Msg::Error, "timed out — try again"),
);
dom::swap_outer(
"pair-slot",
&r#"<div id="pair-slot" class="pair-slot"><button id="pair-btn" type="button" data-action="add-device" class="ghost">add a device</button></div>"#,
);
return;
};
PENDING_PAIR.with(|p| {
*p.borrow_mut() = Some(PendingPair {
device: device.clone(),
device_pubkey,
token_id,
owner_hex,
});
});
dom::swap_outer(
"pair-slot",
&templates::pair_confirm_panel(&device).into_string(),
);
dom::swap_inner("pair-msg", "");
});
}
fn pair_approve_pressed() {
let Some(pending) = PENDING_PAIR.with(|p| p.borrow_mut().take()) else {
return;
};
dom::swap_inner("pair-msg", &dom::msg_span(dom::Msg::Accent, "enrolling…"));
wasm_bindgen_futures::spawn_local(async move {
let PendingPair { device, device_pubkey, token_id, owner_hex } = pending;
match run_add_device(device.clone()).await {
Ok(tx_hash) => {
let short = tx_short_hash(&tx_hash);
dom::swap_inner(
"pair-msg",
&dom::msg_span(
dom::Msg::Accent,
&format!("✓ linked {} (tx {short})", short_addr(&device)),
),
);
dom::swap_outer(
"pair-slot",
&r#"<div id="pair-slot" class="pair-slot"><button id="pair-btn" type="button" data-action="pair-start" class="ghost">link another device</button></div>"#,
);
if !device_pubkey.is_empty() {
if wrap_and_post_key_to_device(token_id, &owner_hex, &device, &device_pubkey)
.await
.is_ok()
{
dom::swap_inner(
"pair-msg",
&dom::msg_span(
dom::Msg::Accent,
&format!(
"✓ linked {} + shared your key — it's ready to use",
short_addr(&device)
),
),
);
}
}
refresh_signer_list().await;
}
Err(err) => {
dom::swap_inner("pair-msg", &dom::msg_span(dom::Msg::Error, &format!("{err}")));
}
}
});
}
fn pair_reject_pressed() {
PENDING_PAIR.with(|p| *p.borrow_mut() = None);
dom::swap_outer(
"pair-slot",
&r#"<div id="pair-slot" class="pair-slot"><button id="pair-btn" type="button" data-action="add-device" class="ghost">add a device</button></div>"#,
);
dom::swap_inner("pair-msg", &dom::msg_span(dom::Msg::Error, "rejected that device"));
}
fn pair_cancel_pressed() {
dom::swap_outer(
"pair-slot",
&r#"<div id="pair-slot" class="pair-slot"><button id="pair-btn" type="button" data-action="add-device" class="ghost">add a device</button></div>"#,
);
dom::swap_inner("pair-msg", "");
}
fn code_key(code: &str) -> [u8; 32] {
use sha3::{Digest, Keccak256};
let mut h = Keccak256::new();
h.update(b"localharness/v0/adopt");
h.update(code.trim().to_uppercase().as_bytes());
let mut out = [0u8; 32];
out.copy_from_slice(&h.finalize());
out
}
fn hex_to_bytes(s: &str) -> Option<Vec<u8>> {
let s = s.trim().trim_start_matches("0x");
if s.is_empty() || s.len() % 2 != 0 {
return None;
}
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(s.get(i..i + 2)?, 16).ok())
.collect()
}
fn run_sync_devices() {
dom::swap_inner(
"pair-msg",
"<span style=\"color:var(--muted)\">discovering devices…</span>",
);
wasm_bindgen_futures::spawn_local(async move {
let msg = match super::teams_sync::sync_my_devices().await {
Ok(0) => {
"no other devices online — open this agent on another device and sync there too"
.to_string()
}
Ok(n) => format!("connected — syncing with {n} device(s)"),
Err(e) => format!("sync failed: {e}"),
};
dom::swap_inner("pair-msg", &dom::msg_span(dom::Msg::Muted, &msg));
});
}
fn add_device_pressed() {
let phrase = super::APP
.with(|cell| cell.borrow().wallet.as_ref().map(|w| w.mnemonic.to_string()));
let Some(phrase) = phrase else {
dom::swap_inner("pair-msg", &dom::msg_span(dom::Msg::Error, "no identity on this device"));
return;
};
wasm_bindgen_futures::spawn_local(async move {
let code = generate_pair_code();
let Some(ct) = super::encryption::seal_with_raw_key(&code_key(&code), phrase.as_bytes()).await
else {
dom::swap_inner("pair-msg", &dom::msg_span(dom::Msg::Error, "encrypt failed"));
return;
};
let hex: String = ct.iter().map(|b| format!("{b:02x}")).collect();
let url = format!("https://localharness.xyz/?adopt=1#s={hex}");
dom::swap_outer("pair-slot", &templates::adopt_panel(&code, &url).into_string());
dom::swap_inner("pair-msg", "");
});
}
fn adopt_device_pressed() {
let code = dom::input_by_id("adopt-code").map(|i| i.value()).unwrap_or_default();
let ct_hex = dom::input_by_id("adopt-ct").map(|i| i.value()).unwrap_or_default();
if code.trim().is_empty() {
return;
}
wasm_bindgen_futures::spawn_local(async move {
let Some(ct) = hex_to_bytes(&ct_hex) else {
dom::swap_inner("adopt-msg", &dom::msg_span(dom::Msg::Error, "bad link — rescan the QR"));
return;
};
match super::encryption::open_with_raw_key(&code_key(&code), &ct).await {
Some(bytes) => {
let phrase = String::from_utf8_lossy(&bytes).into_owned();
match super::wallet_store::import(phrase.trim()).await {
Ok(_) => {
if let Ok(window) = dom::window() {
let _ = window.location().set_href("https://localharness.xyz/");
}
}
Err(err) => {
dom::swap_inner("adopt-msg", &dom::msg_span(dom::Msg::Error, &format!("import failed: {err}")));
}
}
}
None => {
dom::swap_inner("adopt-msg", &dom::msg_span(dom::Msg::Error, "wrong code"));
}
}
});
}
pub(crate) fn pair_join_pressed(code: String) {
dom::swap_inner(
"pair-join-msg",
"<span style=\"color:var(--muted)\">generating device key + announcing…</span>",
);
wasm_bindgen_futures::spawn_local(async move {
match run_pair_join(&code).await {
Ok(addr) => {
dom::swap_inner(
"pair-join-msg",
&dom::msg_span(
dom::Msg::Accent,
&format!(
"✓ this device is {addr} — approve it on your other \
device (check the address matches), and you'll be \
redirected automatically."
),
),
);
}
Err(err) => {
dom::swap_inner(
"pair-join-msg",
&dom::msg_span(dom::Msg::Error, &format!("{err}")),
);
}
}
});
}
async fn run_pair_join(code: &str) -> Result<String, String> {
let wallet = crate::wallet::generate();
let device_hex = wallet.address_hex();
super::wallet_store::persist_device_key(&wallet.private_key_hex)
.await
.map_err(|e| format!("save device key: {e}"))?;
let code_hash = super::registry::pairing_code_hash(code);
let pubkey = crate::wallet::pubkey_compressed(&wallet.signer);
let fee_payer = super::sponsor::signer()?;
super::registry::announce_pairing_sponsored(
&wallet.signer,
&fee_payer,
&code_hash,
&pubkey,
super::registry::ALPHA_USD_ADDRESS,
)
.await?;
let device_signer = wallet.signer.clone();
let device_addr = device_hex.clone();
wasm_bindgen_futures::spawn_local(async move {
let name = match super::tenant::current() {
super::tenant::Host::Tenant(n) => n,
_ => return,
};
let (main_id, owner_hex) = match super::registry::owner_of_name(&name).await {
Ok(Some(owner)) => (super::registry::main_of(&owner).await.unwrap_or(0), owner),
_ => (0, String::new()),
};
let slot_id = gemini_key_slot_id(&name).await.ok();
let mut enrolled = false;
let mut got_key = false;
let mut post_enroll = 0u32;
for _ in 0..60 {
if !enrolled && main_id != 0 {
if let Ok(devs) = super::registry::devices_of(main_id).await {
if devs.iter().any(|d| d.eq_ignore_ascii_case(&device_addr)) {
enrolled = true;
dom::swap_inner(
"pair-join-msg",
&dom::msg_span(dom::Msg::Accent, "✓ this device is now linked"),
);
}
}
}
if !got_key {
if let Some(slot_id) = slot_id {
if let Ok(Some(blob)) =
super::registry::wrapped_device_key_of(slot_id, &device_addr).await
{
if let Some(pt) =
super::encryption::ecies_open(&device_signer, &blob).await
{
if let Ok(key) = String::from_utf8(pt) {
super::key_store::save(&key).await;
if let Ok(Some(storage)) = dom::session_storage() {
let _ = storage.set_item("gemini_api_key", &key);
}
got_key = true;
}
}
}
}
}
if enrolled {
if got_key || post_enroll >= 2 {
dom::swap_inner(
"pair-join-msg",
&dom::msg_span(dom::Msg::Accent, "✓ linked — opening your subdomain…"),
);
if let Ok(window) = dom::window() {
let url = if owner_hex.is_empty() {
format!("https://{name}.localharness.xyz/")
} else {
format!(
"https://localharness.xyz/?link_device={owner_hex}&then={name}"
)
};
let _ = window.location().set_href(&url);
}
return;
}
post_enroll += 1;
dom::swap_inner(
"pair-join-msg",
&dom::msg_span(dom::Msg::Accent, "✓ linked — opening your subdomain…"),
);
}
super::registry::sleep_ms(3000).await;
}
});
Ok(device_hex)
}
fn apex_seed_sync_key() -> Option<[u8; 32]> {
use sha3::{Digest, Keccak256};
super::APP.with(|cell| {
let app = cell.borrow();
let wallet = app.wallet.as_ref()?;
let entropy = wallet.mnemonic.to_entropy();
let mut hasher = Keccak256::new();
hasher.update(b"localharness/v0/keysync");
hasher.update(&entropy);
let mut out = [0u8; 32];
out.copy_from_slice(&hasher.finalize());
Some(out)
})
}
async fn wrap_and_post_key_to_device(
main_id: u64,
owner_hex: &str,
device_hex: &str,
device_pubkey: &[u8],
) -> Result<(), String> {
let ct = super::registry::gemini_key_of(main_id)
.await
.map_err(|e| format!("read key: {e}"))?
.ok_or_else(|| "no synced key".to_string())?;
let seed_key = apex_seed_sync_key().ok_or_else(|| "no seed on this device".to_string())?;
let plaintext = super::encryption::open_with_raw_key(&seed_key, &ct)
.await
.ok_or_else(|| "decrypt failed".to_string())?;
let blob = super::encryption::ecies_seal(device_pubkey, &plaintext)
.await
.ok_or_else(|| "wrap failed".to_string())?;
let signer = super::APP
.with(|cell| cell.borrow().wallet.as_ref().map(|w| w.signer.clone()))
.ok_or_else(|| "no wallet".to_string())?;
let fee_payer = super::sponsor::signer()?;
super::registry::set_device_wrapped_key_sponsored(
&signer,
&fee_payer,
main_id,
device_hex,
&blob,
super::registry::ALPHA_USD_ADDRESS,
)
.await?;
let _ = owner_hex; Ok(())
}
fn generate_pair_code() -> String {
const ALPHABET: &[u8] = b"23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
let mut bytes = [0u8; 6];
let _ = getrandom::getrandom(&mut bytes);
bytes
.iter()
.map(|b| ALPHABET[(*b as usize) % ALPHABET.len()] as char)
.collect()
}
async fn run_add_device(new_signer_hex: String) -> Result<String, String> {
let (signer, owner_hex) = super::APP
.with(|cell| {
cell.borrow()
.wallet
.as_ref()
.map(|w| (w.signer.clone(), w.address_hex()))
})
.ok_or_else(|| "no apex identity".to_string())?;
let token_id = super::registry::main_of(&owner_hex)
.await
.map_err(|e| format!("mainOf: {e}"))?;
if token_id == 0 {
return Err("claim a subdomain first — it becomes your MAIN".into());
}
let tba_addr = super::registry::tba_of_token_id(token_id)
.await
.map_err(|e| format!("tba lookup: {e}"))?
.ok_or_else(|| "no TBA for MAIN".to_string())?;
let fee_payer = super::sponsor::signer()?;
super::registry::add_signer_sponsored(
&signer,
&fee_payer,
token_id,
&tba_addr,
&new_signer_hex,
super::registry::ALPHA_USD_ADDRESS,
)
.await
}
async fn owner_main_tba() -> Result<(k256::ecdsa::SigningKey, String, u64, String), String> {
let (signer, owner_hex) = super::APP
.with(|c| {
c.borrow()
.wallet
.as_ref()
.map(|w| (w.signer.clone(), w.address_hex()))
})
.ok_or_else(|| "no identity".to_string())?;
let main_id = super::registry::main_of(&owner_hex)
.await
.map_err(|e| format!("mainOf: {e}"))?;
if main_id == 0 {
return Err("set a MAIN first".into());
}
let main_name = super::registry::name_of_id(main_id)
.await
.map_err(|e| format!("name: {e}"))?;
let main_tba = super::registry::tba_of_name(&main_name)
.await
.map_err(|e| format!("tba: {e}"))?
.ok_or_else(|| "no MAIN TBA".to_string())?;
Ok((signer, owner_hex, main_id, main_tba))
}
fn consolidate_pressed() {
dom::swap_inner(
"consolidate-msg",
"<span style=\"color:var(--muted)\">consolidating…</span>",
);
wasm_bindgen_futures::spawn_local(async move {
let result = async {
let (signer, owner_hex, main_id, main_tba) = owner_main_tba().await?;
let tokens = super::registry::list_owned_tokens(&owner_hex)
.await
.map_err(|e| format!("list: {e}"))?;
let ids: Vec<u64> = tokens
.iter()
.map(|t| t.token_id)
.filter(|id| *id != main_id)
.collect();
if ids.is_empty() {
return Err("nothing to consolidate — only your MAIN".into());
}
let fee_payer = super::sponsor::signer()?;
super::registry::consolidate_into_main_sponsored(
&signer,
&fee_payer,
&main_tba,
&ids,
super::registry::ALPHA_USD_ADDRESS,
)
.await
}
.await;
match result {
Ok(_) => dom::swap_inner(
"consolidate-msg",
"<span style=\"color:var(--muted)\">✓ subdomains consolidated under your MAIN</span>",
),
Err(e) => {
web_sys::console::warn_1(&JsValue::from_str(&format!("consolidate: {e}")));
dom::swap_inner(
"consolidate-msg",
"<span style=\"color:var(--error)\">consolidate failed</span>",
);
}
}
});
}
fn unlink_device_prompt(device_hex: String) {
let short = short_addr(&device_hex);
dom::swap_inner(
"pair-msg",
&format!(
"<div class=\"unlink-confirm\">\
<div>remove <code>{short}</code>? type <b>yes</b> to confirm.</div>\
<input id=\"unlink-confirm-input\" type=\"text\" autocomplete=\"off\" \
placeholder=\"yes\">\
<div class=\"pair-confirm-actions\">\
<button type=\"button\" class=\"ghost\" data-action=\"unlink-cancel\">cancel</button>\
<button type=\"button\" class=\"button-link\" data-action=\"unlink-confirm\" \
data-arg=\"{device_hex}\">remove</button>\
</div>\
</div>"
),
);
}
fn unlink_cancel_pressed() {
dom::swap_inner("pair-msg", "");
}
fn unlink_confirm_pressed(device_hex: String) {
let typed = dom::input_by_id("unlink-confirm-input")
.map(|i| i.value().trim().to_lowercase())
.unwrap_or_default();
if typed != "yes" {
dom::swap_inner(
"pair-msg",
&dom::msg_span(dom::Msg::Error, "type yes to remove that device"),
);
return;
}
dom::swap_inner("pair-msg", &dom::msg_span(dom::Msg::Accent, "removing…"));
wasm_bindgen_futures::spawn_local(async move {
let result = async {
let (signer, _owner, main_id, main_tba) = owner_main_tba().await?;
let fee_payer = super::sponsor::signer()?;
super::registry::remove_signer_sponsored(
&signer,
&fee_payer,
main_id,
&main_tba,
&device_hex,
super::registry::ALPHA_USD_ADDRESS,
)
.await
}
.await;
match result {
Ok(_) => {
dom::swap_inner("pair-msg", "");
refresh_signer_list().await
}
Err(e) => {
dom::swap_inner(
"pair-msg",
&dom::msg_span(dom::Msg::Error, &format!("unlink failed: {e}")),
);
}
}
});
}
pub(crate) async fn run_release_subdomain(name: &str) -> Result<String, String> {
let token_id = match super::registry::check_name(name).await? {
super::registry::Status::Taken { agent_id } => agent_id,
_ => return Err(format!("'{name}' is not registered")),
};
let owner = super::registry::owner_of_name(name)
.await
.map_err(|e| format!("owner: {e}"))?
.ok_or_else(|| "no on-chain owner".to_string())?;
let diamond = parse_address(super::registry::REGISTRY_ADDRESS)?;
let call = crate::tempo_tx::TempoCall {
to: diamond,
value_wei: 0,
input: super::registry::release_name_calldata(token_id),
};
run_sponsored_tempo_call(&owner, vec![call], 1_000_000, "release subdomain").await
}
pub(crate) async fn run_bulk_release(
names: &[String],
) -> Result<(Vec<String>, String), String> {
if names.is_empty() {
return Err("no subdomains to release".into());
}
let tenant = match super::tenant::current() {
super::tenant::Host::Tenant(n) => n,
_ => return Err("not running on a subdomain".into()),
};
let owner = super::registry::owner_of_name(&tenant)
.await
.map_err(|e| format!("owner: {e}"))?
.ok_or_else(|| "no on-chain owner".to_string())?;
let main_id = super::registry::main_of(&owner)
.await
.map_err(|e| format!("mainOf: {e}"))?;
let diamond = parse_address(super::registry::REGISTRY_ADDRESS)?;
let mut released: Vec<String> = Vec::with_capacity(names.len());
let mut calls: Vec<crate::tempo_tx::TempoCall> = Vec::with_capacity(names.len());
for raw in names {
let name = raw.trim();
if name.is_empty() {
continue;
}
let token_id = match super::registry::check_name(name).await? {
super::registry::Status::Taken { agent_id } => agent_id,
_ => return Err(format!("'{name}' is not registered")),
};
if main_id != 0 && token_id == main_id {
return Err(format!(
"'{name}' is your MAIN identity and cannot be released"
));
}
let holder = super::registry::owner_of_name(name)
.await
.map_err(|e| format!("owner of {name}: {e}"))?
.ok_or_else(|| format!("no on-chain owner for '{name}'"))?;
if holder.to_lowercase() != owner.to_lowercase() {
return Err(format!("'{name}' is not owned by this identity"));
}
calls.push(crate::tempo_tx::TempoCall {
to: diamond,
value_wei: 0,
input: super::registry::release_name_calldata(token_id),
});
released.push(name.to_string());
}
if calls.is_empty() {
return Err("no subdomains to release after filtering".into());
}
let gas = 1_000_000 + (calls.len() as u128).saturating_sub(1) * 250_000;
let tx = run_sponsored_tempo_call(&owner, calls, gas, "bulk release subdomains").await?;
Ok((released, tx))
}
pub(crate) async fn run_batch_create_subdomains(
names: &[String],
) -> Result<(Vec<String>, String), String> {
if names.is_empty() {
return Err("no names to register".into());
}
let tenant = match super::tenant::current() {
super::tenant::Host::Tenant(n) => n,
_ => return Err("not running on a subdomain".into()),
};
let owner = super::registry::owner_of_name(&tenant)
.await
.map_err(|e| format!("owner: {e}"))?
.ok_or_else(|| "no on-chain owner".to_string())?;
let diamond = parse_address(super::registry::REGISTRY_ADDRESS)?;
let mut registered: Vec<String> = Vec::with_capacity(names.len());
let mut calls: Vec<crate::tempo_tx::TempoCall> = Vec::with_capacity(names.len());
for raw in names {
let cleaned = super::tenant::sanitize(raw);
if cleaned.len() < 3
|| cleaned.len() > 32
|| cleaned != raw.trim().to_ascii_lowercase()
{
continue;
}
if registered.iter().any(|n| n == &cleaned) {
continue; }
match super::registry::check_name(&cleaned).await? {
super::registry::Status::Available => {}
_ => continue,
}
calls.push(crate::tempo_tx::TempoCall {
to: diamond,
value_wei: 0,
input: super::registry::register_calldata(&cleaned),
});
registered.push(cleaned);
}
if calls.is_empty() {
return Err("no valid, available names to register".into());
}
let gas = 400_000 + (calls.len() as u128) * 1_500_000;
let tx = run_sponsored_tempo_call(&owner, calls, gas, "batch create subdomains").await?;
for name in ®istered {
let n = name.clone();
wasm_bindgen_futures::spawn_local(async move {
sync_local_key_to_main(&n).await;
});
}
Ok((registered, tx))
}
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 0x{} but expected {claimed_addr} ({from_hex})",
recovered.iter().map(|b| format!("{b:02x}")).collect::<String>(),
));
}
let fee_payer = super::sponsor::signer()?;
let fp_hash = tx.fee_payer_hash(&sender_address);
let fp_sig = crate::wallet::sign_hash(&fee_payer, &fp_hash);
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}"))
}
fn header_admin_toggle() {
let body = match super::tenant::current() {
super::tenant::Host::Apex => templates::admin_dropdown_apex().into_string(),
super::tenant::Host::Tenant(_) | super::tenant::Host::Other(_) => {
templates::admin_dropdown_tenant().into_string()
}
};
dom::swap_outer("header-admin-panel", &body);
if let Some(card) = super::APP.with(|c| c.borrow().financial_card_html.clone()) {
if dom::by_id("financial-slot").is_some() {
dom::swap_outer("financial-slot", &card);
}
}
wasm_bindgen_futures::spawn_local(async move {
refresh_usage_slot().await;
});
wasm_bindgen_futures::spawn_local(async move {
refresh_credits_pill().await;
});
wasm_bindgen_futures::spawn_local(async move {
refresh_jobs_list().await;
});
wasm_bindgen_futures::spawn_local(async move {
refresh_bounty_list().await;
});
if matches!(super::tenant::current(), super::tenant::Host::Apex) {
wasm_bindgen_futures::spawn_local(async move {
refresh_signer_list().await;
});
}
if matches!(
super::tenant::current(),
super::tenant::Host::Tenant(_) | super::tenant::Host::Other(_)
) {
if let Ok(Some(storage)) = dom::session_storage() {
if let Ok(Some(cached)) = storage.get_item("gemini_api_key") {
if let Some(input) = dom::input_by_id("key") {
input.set_value(&cached);
refresh_keymeta();
}
}
}
wasm_bindgen_futures::spawn_local(async move {
if let Some(persisted) = super::key_store::load().await {
if let Some(input) = dom::input_by_id("key") {
input.set_value(&persisted);
refresh_keymeta();
}
}
if let Some(prompt) = super::system_prompt::load().await {
if let Some(textarea) = dom::textarea_by_id("prompt-input") {
textarea.set_value(&prompt);
}
}
{
use crate::filesystem::Filesystem;
if let Ok(bytes) = super::shared_opfs().read(".lh_x402_price").await {
if let Some(wei) = String::from_utf8(bytes)
.ok()
.and_then(|s| s.trim().parse::<u128>().ok())
{
if let Some(input) = dom::input_by_id("x402-price-input") {
input.set_value(&(wei / 1_000_000_000_000_000_000u128).to_string());
}
}
}
}
if let Some(allowed) = super::tool_allowlist::load().await {
if let Some(doc) = web_sys::window().and_then(|w| w.document()) {
if let Ok(checkboxes) = doc.query_selector_all(".tool-checkbox") {
for i in 0..checkboxes.length() {
if let Some(el) = checkboxes.get(i) {
let input: web_sys::HtmlInputElement = JsCast::unchecked_into(el);
if let Some(name) = input.get_attribute("data-tool") {
let is_allowed = allowed.iter().any(|t| t.wire_name() == name);
input.set_checked(is_allowed);
}
}
}
}
}
let summary = super::tool_allowlist::summary(&allowed);
dom::swap_inner("tool-allowlist-status", &summary);
} else {
dom::swap_inner("tool-allowlist-status", "all tools enabled");
}
refresh_public_face_status().await;
refresh_model_selector().await;
});
}
}
async fn refresh_public_face_status() {
let name = match super::tenant::current() {
super::tenant::Host::Tenant(n) => n,
_ => return,
};
if dom::by_id("public-face-status").is_none() {
return;
}
let face = match super::net::read(super::registry::id_of_name(&name)).await {
Ok(Ok(id)) if id != 0 => super::net::read(super::registry::public_face_of(id))
.await
.ok()
.and_then(Result::ok)
.flatten(),
_ => None,
};
let label = match face.as_deref() {
Some("app") => "currently: app",
Some("html") => "currently: html",
_ => "currently: directory (default)",
};
dom::swap_inner("public-face-status", label);
}
fn header_admin_close() {
dom::swap_outer(
"header-admin-panel",
r#"<div id="header-admin-panel" hidden></div>"#,
);
}
fn show_admin_tab(name: &str) {
let Some(dialog) = dom::by_id("admin-dialog") else { return };
let mut cls: Vec<String> = dialog
.class_name()
.split_whitespace()
.filter(|c| !c.starts_with("tab-"))
.map(String::from)
.collect();
cls.push(format!("tab-{name}"));
dialog.set_class_name(&cls.join(" "));
for tab in ["agent", "account", "usage", "feedback"] {
let Some(el) = dom::by_id(&format!("admin-tab-btn-{tab}")) else { continue };
let c = el.class_name();
let mut classes: Vec<&str> = c.split_whitespace().filter(|x| *x != "active").collect();
if tab == name {
classes.push("active");
}
el.set_class_name(&classes.join(" "));
}
}
pub(crate) async fn refresh_usage_slot() {
if dom::by_id("usage-tokens").is_some() {
let total = super::APP.with(|c| c.borrow().total_tokens);
dom::swap_inner("usage-tokens", &format!("{total}"));
}
if dom::by_id("usage-subdomains").is_none() {
return;
}
let owner = match super::tenant::current() {
super::tenant::Host::Tenant(name) => {
super::registry::owner_of_name(&name).await.ok().flatten()
}
_ => super::APP.with(|c| c.borrow().wallet.as_ref().map(|w| w.address_hex())),
};
let count = match owner {
Some(owner_hex) => super::registry::list_owned_tokens(&owner_hex)
.await
.map(|t| t.len()),
None => Ok(0),
};
match count {
Ok(n) => dom::swap_inner("usage-subdomains", &format!("{n}")),
Err(_) => dom::swap_inner("usage-subdomains", "—"),
}
}
fn show_mobile_tab(name: &str) {
let Some(layout) = dom::by_id("layout") else { return };
let parts: Vec<String> = layout
.class_name()
.split_whitespace()
.filter(|c| !c.starts_with("tab-"))
.map(String::from)
.collect();
let mut new_cls = parts.join(" ");
if !new_cls.is_empty() {
new_cls.push(' ');
}
new_cls.push_str(&format!("tab-{name}"));
layout.set_class_name(&new_cls);
if name == "display" && dom::by_id("display-canvas").is_none() {
dom::swap_inner(
"view-content",
&super::templates::display_surface().into_string(),
);
}
for tab in ["files", "chat", "display", "agent"] {
let id = format!("tab-btn-{tab}");
let Some(el) = dom::by_id(&id) else { continue };
let cls = el.class_name();
let mut classes: Vec<&str> =
cls.split_whitespace().filter(|c| *c != "active").collect();
if tab == name {
classes.push("active");
}
el.set_class_name(&classes.join(" "));
}
}
fn decode_hex_local(s: &str) -> Option<Vec<u8>> {
let t = s.trim().trim_start_matches("0x").trim_start_matches("0X");
if t.len() % 2 != 0 {
return None;
}
(0..t.len())
.step_by(2)
.map(|i| u8::from_str_radix(t.get(i..i + 2)?, 16).ok())
.collect()
}
async fn run_sync_key() {
let msg = "key-sync-msg";
let set_err = |m: &str| dom::swap_inner(msg, &dom::msg_span(dom::Msg::Error, &format!("{m}")));
let name = match super::tenant::current() {
super::tenant::Host::Tenant(n) => n,
_ => {
set_err("only on a subdomain");
return;
}
};
let owner_hex = super::APP.with(|cell| {
use super::VerifyState;
match &cell.borrow().verify_state {
VerifyState::Verified { address } => Some(address.clone()),
_ => None,
}
});
let Some(owner_hex) = owner_hex else {
set_err("verify as owner first");
return;
};
let key = dom::input_by_id("key").map(|i| i.value()).unwrap_or_default();
if key.trim().is_empty() {
set_err("enter your key first");
return;
}
dom::swap_inner(msg, "<span style=\"color:var(--muted)\">sealing…</span>");
let ct_hex = match super::verify::seal_key_via_iframe(&key).await {
Ok(h) => h,
Err(e) => {
set_err(&format!("seal: {e}"));
return;
}
};
let Some(ct) = decode_hex_local(&ct_hex) else {
set_err("bad ciphertext from signer");
return;
};
let id = match gemini_key_slot_id(&name).await {
Ok(id) => id,
Err(e) => {
set_err(&e);
return;
}
};
let registry_addr = match parse_address(super::registry::REGISTRY_ADDRESS) {
Ok(a) => a,
Err(e) => {
set_err(&e);
return;
}
};
let call = crate::tempo_tx::TempoCall {
to: registry_addr,
value_wei: 0,
input: super::registry::encode_set_gemini_key(id, &ct),
};
let words = (ct.len() / 32 + 1) as u128;
let gas = 1_200_000 + words * 40_000;
dom::swap_inner(msg, "<span style=\"color:var(--muted)\">syncing on-chain…</span>");
match run_sponsored_tempo_call(&owner_hex, vec![call], gas, "sync key").await {
Ok(_) => dom::swap_inner(
msg,
&dom::msg_span(dom::Msg::Accent, "synced ✓ — import your seed on another device to restore"),
),
Err(e) => set_err(&format!("sync failed: {e}")),
}
}
async fn run_restore_key() {
let msg = "key-sync-msg";
let set_err = |m: &str| dom::swap_inner(msg, &dom::msg_span(dom::Msg::Error, &format!("{m}")));
let name = match super::tenant::current() {
super::tenant::Host::Tenant(n) => n,
_ => {
set_err("only on a subdomain");
return;
}
};
let id = match gemini_key_slot_id(&name).await {
Ok(id) => id,
Err(e) => {
set_err(&e);
return;
}
};
dom::swap_inner(msg, "<span style=\"color:var(--muted)\">fetching…</span>");
let ct = match super::registry::gemini_key_of(id).await {
Ok(Some(b)) => b,
Ok(None) => {
set_err("no synced key on-chain yet");
return;
}
Err(e) => {
set_err(&format!("read: {e}"));
return;
}
};
let ct_hex = format!(
"0x{}",
ct.iter().map(|b| format!("{b:02x}")).collect::<String>()
);
let plaintext = match super::verify::open_key_via_iframe(&ct_hex).await {
Ok(p) => p,
Err(e) => {
set_err(&format!("open: {e} — import your seed on this device first"));
return;
}
};
if let Some(input) = dom::input_by_id("key") {
input.set_value(&plaintext);
}
super::key_store::save(&plaintext).await;
refresh_keymeta();
dom::swap_inner(
msg,
&dom::msg_span(dom::Msg::Accent, "restored ✓ — applies on next session"),
);
}
async fn gemini_key_slot_id(name: &str) -> Result<u64, String> {
let owner = super::registry::owner_of_name(name)
.await
.map_err(|e| format!("owner: {e}"))?
.ok_or_else(|| "name not registered on-chain".to_string())?;
let main_id = super::registry::main_of(&owner).await.unwrap_or(0);
if main_id != 0 {
return Ok(main_id);
}
match super::registry::id_of_name(name).await {
Ok(id) if id != 0 => Ok(id),
_ => Err("no token id for name".into()),
}
}
async fn auto_sync_gemini_key(name: String, key: String) {
let owner = match super::registry::owner_of_name(&name).await {
Ok(Some(o)) => o,
_ => return,
};
let slot_id = match gemini_key_slot_id(&name).await {
Ok(id) => id,
Err(_) => return,
};
let ct_hex = match super::verify::seal_key_via_iframe(&key).await {
Ok(h) => h,
Err(_) => return,
};
let Some(ct) = decode_hex_local(&ct_hex) else { return };
let Ok(registry_addr) = parse_address(super::registry::REGISTRY_ADDRESS) else { return };
let call = crate::tempo_tx::TempoCall {
to: registry_addr,
value_wei: 0,
input: super::registry::encode_set_gemini_key(slot_id, &ct),
};
let words = (ct.len() / 32 + 1) as u128;
let gas = 1_200_000 + words * 40_000;
let _ = run_sponsored_tempo_call(&owner, vec![call], gas, "auto-sync key").await;
}
pub(crate) async fn sync_local_key_to_main(name: &str) {
if let Some(key) = super::key_store::load().await {
auto_sync_gemini_key(name.to_string(), key).await;
}
}
pub(crate) async fn try_auto_restore_gemini_key(name: &str) -> bool {
if super::key_store::load().await.is_some() {
return true;
}
let slot_id = match gemini_key_slot_id(name).await {
Ok(id) => id,
Err(_) => return false,
};
let ct = match super::registry::gemini_key_of(slot_id).await {
Ok(Some(b)) => b,
_ => return false,
};
let ct_hex = format!(
"0x{}",
ct.iter().map(|b| format!("{b:02x}")).collect::<String>()
);
let plaintext = match super::verify::open_key_via_iframe(&ct_hex).await {
Ok(p) => p,
Err(_) => return false,
};
super::key_store::save(&plaintext).await;
if let Ok(Some(storage)) = dom::session_storage() {
let _ = storage.set_item("gemini_api_key", &plaintext);
}
true
}
async fn run_set_public_face(choice: &str) {
let msg = "publish-app-msg";
let set_err = |m: &str| {
dom::swap_inner(msg, &dom::msg_span(dom::Msg::Error, m));
};
let name = match super::tenant::current() {
super::tenant::Host::Tenant(n) => n,
_ => {
set_err("only on a subdomain");
return;
}
};
let verified_eoa = super::APP.with(|cell| {
use super::VerifyState;
match &cell.borrow().verify_state {
VerifyState::Verified { address } => Some(address.clone()),
_ => None,
}
});
let id = match super::registry::id_of_name(&name).await {
Ok(id) if id != 0 => id,
_ => {
set_err("name isn't registered on-chain");
return;
}
};
let registry_addr = match parse_address(super::registry::REGISTRY_ADDRESS) {
Ok(a) => a,
Err(e) => {
set_err(&e);
return;
}
};
let mk = |input: Vec<u8>| crate::tempo_tx::TempoCall {
to: registry_addr,
value_wei: 0,
input,
};
let (calls, gas): (Vec<crate::tempo_tx::TempoCall>, u128) = match choice {
"directory" => (
vec![mk(super::registry::encode_set_public_face(id, "directory"))],
500_000,
),
"app" => {
let fs = super::shared_opfs();
let src = match fs.read("app.rl").await {
Ok(b) if !b.is_empty() => String::from_utf8_lossy(&b).into_owned(),
_ => {
set_err("no app.rl on this device — build one first (run_cartridge)");
return;
}
};
let wasm = match crate::rustlite::compile(&src) {
Ok(w) => w,
Err(e) => {
set_err(&format!("compile: {e}"));
return;
}
};
if wasm.len() > 16_384 {
set_err("app wasm too large to publish (max 16 KB)");
return;
}
(
vec![
mk(super::registry::encode_set_app_wasm(id, &wasm)),
mk(super::registry::encode_set_public_face(id, "app")),
],
1_200_000 + wasm.len() as u128 * 8_500,
)
}
"html" => {
let fs = super::shared_opfs();
let html = match fs.read("index.html").await {
Ok(b) if !b.is_empty() => b,
_ => {
set_err("no index.html on this device — create one first");
return;
}
};
if html.len() > 24_576 {
set_err("index.html too large to publish (max 24 KB)");
return;
}
(
vec![
mk(super::registry::encode_set_public_html(id, &html)),
mk(super::registry::encode_set_public_face(id, "html")),
],
1_200_000 + html.len() as u128 * 8_500,
)
}
_ => {
set_err("unknown public face");
return;
}
};
dom::swap_inner(msg, "<span style=\"color:var(--muted)\">saving…</span>");
let on_chain_owner = match super::registry::owner_of_name(&name).await {
Ok(Some(o)) => o,
_ => {
set_err("name isn't registered on-chain");
return;
}
};
let local = super::chat::credit_signer().await;
let is_signer = match &local {
Some((_, addr)) => {
let addr_hex = format!(
"0x{}",
addr.iter().map(|b| format!("{b:02x}")).collect::<String>()
);
super::registry::is_authorized_signer(&on_chain_owner, &addr_hex)
.await
.unwrap_or(false)
}
None => false,
};
let result = if is_signer {
let (signer, _) = local.unwrap();
let fee_payer = match super::sponsor::signer() {
Ok(s) => s,
Err(e) => {
set_err(&e);
return;
}
};
let token_id = match super::registry::tba_token_id_of(&on_chain_owner).await {
Ok(t) => t,
Err(e) => {
set_err(&e);
return;
}
};
let targets: Vec<([u8; 20], Vec<u8>)> =
calls.iter().map(|c| (registry_addr, c.input.clone())).collect();
super::registry::tba_execute_batch_sponsored(
&signer,
&fee_payer,
token_id,
&on_chain_owner,
&targets,
super::registry::ALPHA_USD_ADDRESS,
gas + 800_000,
)
.await
} else if let Some(owner_hex) =
verified_eoa.filter(|a| a.eq_ignore_ascii_case(&on_chain_owner))
{
run_sponsored_tempo_call(&owner_hex, calls, gas, "public face").await
} else {
set_err("verify as owner first");
return;
};
match result {
Ok(_tx) => {
let head = if choice == "directory" {
"public face → directory ✓".to_string()
} else {
format!("published ✓ — {name}.localharness.xyz")
};
dom::swap_inner(
msg,
&maud::html! {
span style="color:var(--fg)" {
(head) " "
a href=(format!("https://{name}.localharness.xyz/"))
target="_blank" rel="noopener" style="color:var(--accent)" {
"open →"
}
}
}
.into_string(),
);
refresh_public_face_status().await;
}
Err(e) => set_err(&format!("failed: {e}")),
}
}
fn toggle_layout_class(class: &str) {
let Some(layout) = dom::by_id("layout") else { return };
let current = layout.class_name();
let trimmed = current.trim();
let parts: Vec<&str> = trimmed.split_whitespace().collect();
let new_cls = if parts.contains(&class) {
parts.iter().filter(|c| **c != class).copied().collect::<Vec<_>>().join(" ")
} else if parts.is_empty() {
class.to_string()
} else {
format!("{} {class}", parts.join(" "))
};
layout.set_class_name(&new_cls);
}
fn reset_confirm_pressed() {
let typed = dom::input_by_id("reset-confirm-text")
.map(|i| i.value().trim().to_string())
.unwrap_or_default();
if !typed.eq_ignore_ascii_case("RESET") {
dom::swap_inner(
"reset-confirm-msg",
"<span style=\"color:var(--error)\">type RESET to confirm</span>",
);
return;
}
wasm_bindgen_futures::spawn_local(async move {
let fs = super::shared_opfs();
if let Ok(entries) = fs.read_dir("").await {
for entry in entries {
if entry.name == ".lh_wallet" || entry.name == ".lh_owner" {
continue;
}
let _ = fs.delete(&entry.name).await;
}
}
if let Ok(window) = dom::window() {
let _ = window.location().reload();
}
});
}
fn pricing_save_pressed() {
let Some(input) = dom::input_by_id("pricing-input") else {
return;
};
let raw = input.value().trim().to_string();
let wei = match parse_eth_to_wei(&raw) {
Ok(w) => w,
Err(err) => {
dom::swap_inner(
"pricing-msg",
&dom::msg_span(dom::Msg::Error, &format!("{err}")),
);
return;
}
};
let is_owner = super::APP.with(|cell| {
matches!(cell.borrow().verify_state, super::VerifyState::Verified { .. })
});
if !is_owner {
dom::swap_inner(
"pricing-msg",
"<span style=\"color:var(--error)\">only the verified owner can change pricing</span>",
);
return;
}
dom::swap_inner(
"pricing-msg",
"<span style=\"color:var(--muted)\">saving…</span>",
);
wasm_bindgen_futures::spawn_local(async move {
match super::pricing::save(wei).await {
Ok(()) => {
super::APP
.with(|cell| cell.borrow_mut().pricing_wei = Some(wei));
let html = templates::pricing_card_body(wei, true).into_string();
dom::swap_outer("pricing-body", &html);
}
Err(err) => {
dom::swap_inner(
"pricing-msg",
&dom::msg_span(dom::Msg::Error, &format!("save failed: {err}")),
);
}
}
});
}
fn parse_eth_to_wei(s: &str) -> Result<u128, String> {
if s.is_empty() {
return Ok(0);
}
let (whole_str, frac_str) = match s.split_once('.') {
Some((w, f)) => (w, f),
None => (s, ""),
};
if !whole_str.bytes().all(|b| b.is_ascii_digit()) {
return Err("price must be a positive decimal".into());
}
if !frac_str.bytes().all(|b| b.is_ascii_digit()) {
return Err("price must be a positive decimal".into());
}
if frac_str.len() > 18 {
return Err("price has more precision than wei (18 decimals max)".into());
}
let whole: u128 = whole_str.parse().map_err(|e| format!("whole: {e}"))?;
let mut padded = String::with_capacity(18);
padded.push_str(frac_str);
while padded.len() < 18 {
padded.push('0');
}
let frac: u128 = if padded.is_empty() {
0
} else {
padded.parse().map_err(|e| format!("frac: {e}"))?
};
whole
.checked_mul(1_000_000_000_000_000_000)
.and_then(|w| w.checked_add(frac))
.ok_or_else(|| "price too large".into())
}
fn clear_key_pressed() {
if let Some(input) = dom::input_by_id("key") {
input.set_value("");
}
if let Ok(Some(storage)) = dom::session_storage() {
let _ = storage.remove_item("gemini_api_key");
}
refresh_keymeta();
if let Some(input) = dom::input_by_id("key") {
input.focus().ok();
}
wasm_bindgen_futures::spawn_local(async move {
super::key_store::clear().await;
});
dom::set_status("key cleared (sessionStorage + OPFS)", false);
}