use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use web_sys::{Document, Element, KeyboardEvent, MouseEvent};
use crate::filesystem::Filesystem;
use super::dom;
use super::templates;
#[derive(Debug, Clone)]
enum Action {
Send,
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),
FeedbackOpen,
FeedbackClose,
FeedbackSubmit,
AddDevice,
AgentActToggle(String),
AgentSendLh(String),
SavePrompt,
SaveToolAllowlist,
ResetToolAllowlist,
SaveApiKey,
ToggleDisplay,
StopTurn,
PublishApp,
}
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-open" => Action::FeedbackOpen,
"feedback-close" => Action::FeedbackClose,
"feedback-submit" => Action::FeedbackSubmit,
"add-device" => Action::AddDevice,
"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,
"publish-app" => Action::PublishApp,
_ => 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| {
if event.key() != "Enter" {
return;
}
let Some(target) = event.target() else { return };
let Ok(el) = target.dyn_into::<Element>() else { return };
if 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();
Ok(())
}
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) {
set_create_button_busy(true);
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::PublishApp => {
wasm_bindgen_futures::spawn_local(async move {
run_publish_app().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).await;
});
}
Action::ClaimHere => {
wasm_bindgen_futures::spawn_local(async move {
match super::owner::claim().await {
Ok(id) => {
web_sys::console::log_1(&JsValue::from_str(&format!(
"claimed with owner id {id}"
)));
if let super::tenant::Host::Tenant(name) = super::tenant::current() {
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::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, _tx)) => {
let _ = super::owner::claim().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}"
)));
}
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::FeedbackOpen => feedback_open(),
Action::FeedbackClose => feedback_close(),
Action::FeedbackSubmit => feedback_submit(),
Action::AddDevice => add_device_pressed(),
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(),
}
}
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);
}
}
});
}
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();
if !is_address_hex(&to_raw) {
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 addr = super::APP.with(|cell| {
cell.borrow().wallet.as_ref().map(|w| w.address_hex())
});
let Some(addr) = addr else { return };
let Ok(balance_wei) = super::registry::token_balance_of(&addr).await else { return };
let lh = balance_wei / 1_000_000_000_000_000_000u128;
dom::swap_inner("credits-balance", &format!("{lh} LH"));
}
async fn refresh_signer_list() {
let addr = super::APP.with(|cell| {
cell.borrow().wallet.as_ref().map(|w| w.address_hex())
});
let Some(addr) = addr else { 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;
}
};
let main_name = match super::registry::name_of_id(main_id).await {
Ok(name) if !name.is_empty() => name,
_ => {
dom::swap_inner("signer-list", "");
return;
}
};
let tba = match super::registry::tba_of_name(&main_name).await {
Ok(Some(tba)) => tba,
_ => {
dom::swap_inner("signer-list", "no TBA");
return;
}
};
match super::registry::tba_signers(&tba).await {
Ok(signers) if signers.is_empty() => {
dom::swap_inner("signer-list", "owner only (no extra signers)");
}
Ok(signers) => {
let mut html = String::new();
for s in &signers {
let short = if s.len() > 10 {
format!("{}…{}", &s[..6], &s[s.len()-4..])
} else {
s.clone()
};
html.push_str(&format!(
"<div style=\"color:var(--fg);font-size:11px;margin:2px 0\">\
<code>{short}</code></div>"
));
}
dom::swap_inner("signer-list", &html);
}
Err(_) => {
dom::swap_inner("signer-list", "");
}
}
}
fn add_device_pressed() {
let raw = dom::input_by_id("add-device-input")
.map(|i| i.value().trim().to_string())
.unwrap_or_default();
if !is_address_hex(&raw) {
return;
}
dom::swap_inner(
"add-device-msg",
"<span style=\"color:var(--muted)\">signing + submitting…</span>",
);
wasm_bindgen_futures::spawn_local(async move {
match run_add_device(raw.clone()).await {
Ok(tx_hash) => {
let short = tx_short_hash(&tx_hash);
dom::swap_inner(
"add-device-msg",
&dom::msg_span(
dom::Msg::Accent,
&format!("✓ added {} (tx {short})", short_addr(&raw)),
),
);
if let Some(input) = dom::input_by_id("add-device-input") {
input.set_value("");
}
}
Err(err) => {
dom::swap_inner(
"add-device-msg",
&dom::msg_span(dom::Msg::Error, &format!("{err}")),
);
}
}
});
}
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
}
fn short_addr(addr: &str) -> String {
let stripped = addr.trim_start_matches("0x");
if stripped.len() < 8 {
return addr.to_string();
}
format!("0x{}…{}", &stripped[..4], &stripped[stripped.len() - 4..])
}
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> {
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 is_address_hex(s: &str) -> bool {
let stripped = s.trim_start_matches("0x").trim_start_matches("0X");
stripped.len() == 40 && stripped.bytes().all(|b| b.is_ascii_hexdigit())
}
fn parse_token_amount(raw: &str) -> Option<u128> {
let raw = raw.trim();
if raw.is_empty() {
return None;
}
let (whole_s, frac_s) = match raw.split_once('.') {
Some((w, f)) => (w, f),
None => (raw, ""),
};
let whole: u128 = if whole_s.is_empty() {
0
} else {
whole_s.parse().ok()?
};
if frac_s.bytes().any(|b| !b.is_ascii_digit()) {
return None;
}
let mut frac: u128 = 0;
let mut scale: u128 = 1_000_000_000_000_000_000;
for ch in frac_s.chars().take(18) {
let d = ch.to_digit(10)? as u128;
scale /= 10;
frac = frac.checked_add(d.checked_mul(scale)?)?;
}
let whole_wei = whole.checked_mul(1_000_000_000_000_000_000)?;
whole_wei.checked_add(frac)
}
fn parse_address(hex: &str) -> Result<[u8; 20], String> {
let stripped = hex.trim_start_matches("0x").trim_start_matches("0X");
if stripped.len() != 40 {
return Err(format!("address must be 40 hex chars, got {}", stripped.len()));
}
let mut out = [0u8; 20];
let bytes = stripped.as_bytes();
for i in 0..20 {
let hi = hex_nibble(bytes[i * 2])?;
let lo = hex_nibble(bytes[i * 2 + 1])?;
out[i] = (hi << 4) | lo;
}
Ok(out)
}
fn hex_nibble(b: u8) -> Result<u8, String> {
match b {
b'0'..=b'9' => Ok(b - b'0'),
b'a'..=b'f' => Ok(b - b'a' + 10),
b'A'..=b'F' => Ok(b - b'A' + 10),
_ => Err(format!("non-hex byte {b}")),
}
}
fn bytes_to_hex_str(bytes: &[u8]) -> String {
let mut s = String::with_capacity(2 + bytes.len() * 2);
s.push_str("0x");
for b in bytes {
s.push_str(&format!("{b:02x}"));
}
s
}
fn tx_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..])
}
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;
});
if matches!(super::tenant::current(), super::tenant::Host::Apex) {
wasm_bindgen_futures::spawn_local(async move {
refresh_credits_pill().await;
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);
}
}
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");
}
});
}
}
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"] {
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;
}
match super::registry::subdomain_count().await {
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 feedback_open() {
let Ok(doc) = dom::document() else { return };
let Some(body) = doc.body() else { return };
if let Some(_existing) = doc.get_element_by_id("feedback-modal") {
if let Some(t) = dom::textarea_by_id("feedback-text") {
let _ = t.focus();
}
return;
}
let _ = body.insert_adjacent_html(
"beforeend",
&templates::feedback_modal().into_string(),
);
if let Some(t) = dom::textarea_by_id("feedback-text") {
let _ = t.focus();
}
}
fn feedback_close() {
if let Some(el) = dom::by_id("feedback-modal") {
if let Some(parent) = el.parent_element() {
let _ = parent.remove_child(&el);
}
}
}
fn feedback_submit() {
let Some(textarea) = dom::textarea_by_id("feedback-text") else {
return;
};
let text = textarea.value().trim().to_string();
if text.is_empty() {
return; }
if text.len() > 2048 {
dom::swap_inner(
"feedback-msg",
&format!(
"<span style=\"color:var(--error)\">feedback too long: {} bytes (max 2048) — please shorten</span>",
text.len()
),
);
return;
}
thread_local! {
static LAST_FEEDBACK_MS: std::cell::Cell<f64> = const { std::cell::Cell::new(0.0) };
}
let now = js_sys::Date::now();
let elapsed = LAST_FEEDBACK_MS.with(|c| now - c.get());
if elapsed < 60_000.0 {
let remaining = ((60_000.0 - elapsed) / 1000.0).ceil() as u32;
dom::swap_inner(
"feedback-msg",
&dom::msg_span(dom::Msg::Muted, &format!("wait {remaining}s")),
);
return;
}
LAST_FEEDBACK_MS.with(|c| c.set(now));
let from_hex = super::APP.with(|cell| {
use super::VerifyState;
match &cell.borrow().verify_state {
VerifyState::Verified { address } => Some(address.clone()),
VerifyState::Visitor { visitor_address, .. } => Some(visitor_address.clone()),
_ => cell.borrow().wallet.as_ref().map(|w| w.address_hex()),
}
});
let Some(from_hex) = from_hex else {
dom::swap_inner(
"feedback-msg",
"<span style=\"color:var(--error)\">claim an identity first</span>",
);
return;
};
dom::swap_inner(
"feedback-msg",
"<span style=\"color:var(--muted)\">signing…</span>",
);
wasm_bindgen_futures::spawn_local(async move {
if let Err(err) = append_feedback_local(&text).await {
web_sys::console::warn_1(&JsValue::from_str(&format!(
"feedback local copy: {err}"
)));
}
match submit_feedback_onchain(&from_hex, &text).await {
Ok(tx_hash) => {
let short = tx_short_hash(&tx_hash);
dom::swap_inner(
"feedback-msg",
&dom::msg_span(dom::Msg::Accent, &format!("✓ on-chain (tx {short})")),
);
if let Some(window) = web_sys::window() {
let cb = Closure::<dyn FnMut()>::new(|| {
feedback_close();
});
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(
cb.as_ref().unchecked_ref(),
1200,
);
cb.forget();
}
}
Err(err) => {
web_sys::console::warn_1(&JsValue::from_str(&format!(
"feedback on-chain: {err}"
)));
dom::swap_inner(
"feedback-msg",
"<span style=\"color:var(--error)\">on-chain submit failed (saved locally)</span>",
);
}
}
});
}
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 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 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 super::registry::id_of_name(&name).await {
Ok(id) if id != 0 => id,
_ => {
set_err("name isn't registered on-chain");
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 run_publish_app() {
let msg = "publish-app-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 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 to publish");
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;
}
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;
}
};
dom::swap_inner(msg, "<span style=\"color:var(--muted)\">publishing…</span>");
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_app_wasm(id, &wasm),
};
let words = (wasm.len() / 32 + 1) as u128;
let gas = 1_200_000 + words * 40_000;
match run_sponsored_tempo_call(&owner_hex, vec![call], gas, "publish app").await {
Ok(_tx) => dom::swap_inner(
msg,
&maud::html! {
span style="color:var(--fg)" {
"published ✓ — live at "
a href=(format!("https://{name}.localharness.xyz/"))
target="_blank" rel="noopener" style="color:var(--accent)" {
(format!("{name}.localharness.xyz →"))
}
" (share it — anyone can open the app)"
}
}
.into_string(),
),
Err(e) => set_err(&format!("publish failed: {e}")),
}
}
pub(crate) async fn submit_feedback_onchain(from_hex: &str, text: &str) -> Result<String, String> {
let calldata = encode_submit_feedback_calldata(text);
let registry_addr = parse_address(super::registry::REGISTRY_ADDRESS)?;
let call = crate::tempo_tx::TempoCall {
to: registry_addr,
value_wei: 0,
input: calldata,
};
run_sponsored_tempo_call(from_hex, vec![call], 800_000, "submit feedback").await
}
fn encode_submit_feedback_calldata(text: &str) -> Vec<u8> {
use sha3::{Digest, Keccak256};
let mut hasher = Keccak256::new();
hasher.update(b"submitFeedback(string)");
let digest = hasher.finalize();
let mut selector = [0u8; 4];
selector.copy_from_slice(&digest[..4]);
let bytes = text.as_bytes();
let len = bytes.len();
let padded_len = len.div_ceil(32) * 32;
let mut out = Vec::with_capacity(4 + 32 + 32 + padded_len);
out.extend_from_slice(&selector);
let mut offset = [0u8; 32];
offset[31] = 0x20;
out.extend_from_slice(&offset);
let mut len_bytes = [0u8; 32];
len_bytes[24..].copy_from_slice(&(len as u64).to_be_bytes());
out.extend_from_slice(&len_bytes);
out.extend_from_slice(bytes);
out.resize(4 + 32 + 32 + padded_len, 0);
out
}
async fn append_feedback_local(text: &str) -> Result<(), String> {
use crate::filesystem::Filesystem;
let fs = super::shared_opfs();
let existing = fs.read(".lh_feedback.txt").await.unwrap_or_default();
let now = js_sys::Date::new_0().to_iso_string().as_string().unwrap_or_default();
let entry = format!("{now}\t{text}\n");
let mut combined = existing;
combined.extend_from_slice(entry.as_bytes());
fs.write_atomic(".lh_feedback.txt", &combined)
.await
.map_err(|e| format!("{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() {
wasm_bindgen_futures::spawn_local(async move {
let fs = super::shared_opfs();
if let Ok(entries) = fs.read_dir("").await {
for entry in entries {
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);
}