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,
Reset,
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,
RevealSecurity,
HideSecurity,
ResetArm,
ResetConfirm,
ResetCancel,
PricingSave,
ToggleFiles,
ToggleFinancial,
ToggleTerminal,
ToggleView,
ShowTab(String),
FeedbackOpen,
FeedbackClose,
FeedbackSubmit,
LhTransfer,
AddDevice,
ClaimCredits,
AgentActToggle(String),
AgentSendLh(String),
SavePrompt,
SaveToolAllowlist,
ResetToolAllowlist,
}
impl Action {
fn parse(name: &str, arg: Option<String>) -> Option<Action> {
Some(match name {
"send" => Action::Send,
"reset" => Action::Reset,
"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,
"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,
"lh-transfer" => Action::LhTransfer,
"add-device" => Action::AddDevice,
"claim-credits" => Action::ClaimCredits,
"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,
_ => 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();
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::Reset => reset_pressed(),
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::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",
&format!(
"<span style=\"color:var(--error)\">claim failed: {err}</span>"
),
);
}
}
});
}
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",
&format!(
"<span style=\"color:var(--error)\">identity setup failed: {err}</span>"
),
);
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",
&format!(
"<span style=\"color:var(--error)\">claim failed: {err}</span>"
),
);
}
}
});
}
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",
&format!(
"<span style=\"color:var(--error)\">import failed: {err}</span>"
),
);
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",
&format!(
"<span style=\"color:var(--error)\">reveal failed: {err}</span>\
<button type=\"button\" data-action=\"reveal-seed\" class=\"ghost\">retry</button>"
),
),
}
});
}
}
}
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 {
let wallet = match super::wallet_store::create_and_persist().await {
Ok(w) => w,
Err(err) => {
dom::swap_inner(
"identity-msg",
&format!(
"<span style=\"color:var(--error)\">create failed: {err}</span>"
),
);
return;
}
};
run_initial_credit_claim(wallet.signer.clone()).await;
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",
&format!(
"<span style=\"color:var(--error)\">create failed: {err}</span>"
),
);
}
}
});
}
}
}
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.trim().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",
&format!(
"<span style=\"color:var(--error)\">import failed: {err}</span>"
),
);
}
}
});
}
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",
&format!(
"<span style=\"color:var(--error)\">import failed: {err}</span>"
),
);
}
}
});
}
}
}
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::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::LhTransfer => lh_transfer_pressed(),
Action::AddDevice => add_device_pressed(),
Action::ClaimCredits => claim_credits_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(),
}
}
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",
&format!("<span style=\"color:var(--accent)\">{summary}</span>"),
);
}
Err(err) => {
dom::swap_inner(
"prompt-msg",
&format!("<span style=\"color:var(--error)\">{err}</span>"),
);
}
}
});
}
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",
&format!("<span style=\"color:var(--accent)\">✓ saved · {summary} · takes effect on next session</span>"),
);
}
Err(err) => {
dom::swap_inner(
"tool-allowlist-msg",
&format!("<span style=\"color:var(--error)\">{err}</span>"),
);
}
}
});
}
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",
&format!("<span style=\"color:var(--error)\">{err}</span>"),
);
}
}
});
}
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,
&format!("<div class=\"admin-msg-slot\"><span style=\"color:var(--error)\">{err}</span></div>"),
);
}
});
} 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,
&format!(
"<span style=\"color:var(--accent)\">✓ sent (tx {short})</span>"
),
);
let _ = paint_agent_act_panel(token_id).await;
}
Err(err) => {
dom::swap_inner(
&msg_id,
&format!("<span style=\"color:var(--error)\">{err}</span>"),
);
}
}
});
}
fn claim_credits_pressed() {
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(
"claim-credits-msg",
"<span style=\"color:var(--muted)\">signing + submitting…</span>",
);
if let Some(btn) = dom::by_id("claim-credits-btn") {
let _ = btn.set_attribute("disabled", "");
}
wasm_bindgen_futures::spawn_local(async move {
let fee_payer = match super::sponsor::signer() {
Ok(k) => k,
Err(err) => {
dom::swap_inner(
"claim-credits-msg",
&format!("<span style=\"color:var(--error)\">sponsor: {err}</span>"),
);
return;
}
};
match super::registry::claim_daily_sponsored(
&signer,
&fee_payer,
super::registry::ALPHA_USD_ADDRESS,
)
.await
{
Ok(tx_hash) => {
let short = tx_short_hash(&tx_hash);
dom::swap_inner(
"claim-credits-msg",
&format!(
"<span style=\"color:var(--accent)\">✓ claimed (tx {short})</span>"
),
);
refresh_credits_pill().await;
}
Err(err) => {
let pretty = if err.contains("AlreadyClaimedToday")
|| err.to_lowercase().contains("already claimed")
{
"already claimed today".to_string()
} else {
err
};
dom::swap_inner(
"claim-credits-msg",
&format!("<span style=\"color:var(--error)\">{pretty}</span>"),
);
if let Some(btn) = dom::by_id("claim-credits-btn") {
let _ = btn.remove_attribute("disabled");
}
}
}
});
}
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"));
}
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",
&format!(
"<span style=\"color:var(--accent)\">✓ added {} (tx {short})</span>",
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",
&format!("<span style=\"color:var(--error)\">{err}</span>"),
);
}
}
});
}
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..])
}
fn lh_transfer_pressed() {
let to_raw = dom::input_by_id("lh-transfer-to")
.map(|i| i.value().trim().to_string())
.unwrap_or_default();
let amount_raw = dom::input_by_id("lh-transfer-amount")
.map(|i| i.value().trim().to_string())
.unwrap_or_default();
if !is_address_hex(&to_raw) {
dom::swap_inner(
"lh-transfer-msg",
"<span style=\"color:var(--error)\">recipient must be a 0x… address</span>",
);
return;
}
let Some(amount_wei) = parse_token_amount(&amount_raw) else {
dom::swap_inner(
"lh-transfer-msg",
"<span style=\"color:var(--error)\">amount: positive number, up to 18 decimals</span>",
);
return;
};
if amount_wei == 0 {
dom::swap_inner(
"lh-transfer-msg",
"<span style=\"color:var(--error)\">amount must be greater than zero</span>",
);
return;
}
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()),
_ => None,
}
});
let Some(from_hex) = from_hex else {
dom::swap_inner(
"lh-transfer-msg",
"<span style=\"color:var(--error)\">no apex identity yet — open admin to create one</span>",
);
return;
};
dom::swap_inner(
"lh-transfer-msg",
"<span style=\"color:var(--muted)\">signing + submitting…</span>",
);
wasm_bindgen_futures::spawn_local(async move {
if let Err(err) = run_lh_transfer(from_hex, to_raw, amount_wei).await {
dom::swap_inner(
"lh-transfer-msg",
&format!("<span style=\"color:var(--error)\">{err}</span>"),
);
}
});
}
async fn run_lh_transfer(
from_hex: String,
to_hex: String,
amount_wei: u128,
) -> Result<(), String> {
let calldata = encode_transfer_calldata(&to_hex, amount_wei)?;
let token_addr = parse_address(super::registry::LOCALHARNESS_TOKEN_ADDRESS)?;
let call = crate::tempo_tx::TempoCall {
to: token_addr,
value_wei: 0,
input: calldata,
};
let tx_hash = run_sponsored_tempo_call(&from_hex, vec![call], 500_000, "send $localharness")
.await?;
let short = tx_short_hash(&tx_hash);
dom::swap_inner(
"lh-transfer-msg",
&format!("<span style=\"color:var(--accent)\">✓ sent (tx {short})</span>"),
);
if let Some(input) = dom::input_by_id("lh-transfer-amount") {
input.set_value("");
}
if let super::tenant::Host::Tenant(name) = super::tenant::current() {
super::paint_tenant(super::tenant::Host::Tenant(name.clone()), name).await;
}
Ok(())
}
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_digest_via_iframe(&sender_hash, 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 encode_transfer_calldata(to_hex: &str, amount_wei: u128) -> Result<Vec<u8>, String> {
use sha3::{Digest, Keccak256};
let mut hasher = Keccak256::new();
hasher.update(b"transfer(address,uint256)");
let digest = hasher.finalize();
let mut selector = [0u8; 4];
selector.copy_from_slice(&digest[..4]);
let to_bytes = parse_address(to_hex)?;
let mut to_padded = [0u8; 32];
to_padded[12..].copy_from_slice(&to_bytes);
let mut amount_padded = [0u8; 32];
amount_padded[16..].copy_from_slice(&amount_wei.to_be_bytes());
let mut out = Vec::with_capacity(4 + 32 + 32);
out.extend_from_slice(&selector);
out.extend_from_slice(&to_padded);
out.extend_from_slice(&amount_padded);
Ok(out)
}
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 matches!(super::tenant::current(), super::tenant::Host::Apex) {
wasm_bindgen_futures::spawn_local(async move {
refresh_credits_pill().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_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);
for tab in ["files", "edit", "chat", "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",
"<span style=\"color:var(--error)\">too long</span>",
);
return;
}
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",
&format!(
"<span style=\"color:var(--accent)\">✓ on-chain (tx {short})</span>"
),
);
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>",
);
}
}
});
}
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.iter().any(|c| *c == 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);
}
async fn run_initial_credit_claim(signer: k256::ecdsa::SigningKey) {
dom::swap_inner(
"identity-msg",
"<span style=\"color:var(--muted)\">claiming starter credits…</span>",
);
let fee_payer = match super::sponsor::signer() {
Ok(k) => k,
Err(err) => {
web_sys::console::warn_1(&JsValue::from_str(&format!("sponsor: {err}")));
return;
}
};
match super::registry::claim_daily_sponsored(
&signer,
&fee_payer,
super::registry::ALPHA_USD_ADDRESS,
)
.await
{
Ok(tx) => {
web_sys::console::log_1(&JsValue::from_str(&format!(
"initial claimDaily tx: {tx}"
)));
}
Err(err) => {
web_sys::console::warn_1(&JsValue::from_str(&format!(
"initial claimDaily: {err}"
)));
}
}
}
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",
&format!(
"<span style=\"color:var(--error)\">{err}</span>"
),
);
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",
&format!(
"<span style=\"color:var(--error)\">save failed: {err}</span>"
),
);
}
}
});
}
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 reset_pressed() {
super::APP.with(|cell| {
let mut app = cell.borrow_mut();
app.agent = None;
app.session_key = None;
app.turn_count = 0;
app.pending_history = None;
});
dom::swap_inner("transcript", "");
wasm_bindgen_futures::spawn_local(async move {
super::history::clear().await;
});
if let Some(prompt) = dom::textarea_by_id("prompt") {
prompt.focus().ok();
}
}
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);
}