use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use web_sys::{Document, Element, KeyboardEvent, MouseEvent};
use crate::encoding::{
bytes_to_hex_str, is_address_hex, parse_address, parse_token_amount, short_addr, tx_short_hash,
};
use crate::filesystem::Filesystem;
use super::dom;
use super::templates;
#[derive(Debug, Clone)]
enum Action {
Send,
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,
PairStart,
PairCancel,
PairApprove,
PairReject,
PairJoin,
AddDevice,
AdoptDevice,
CreateNewClaim(String),
AgentActToggle(String),
AgentSendLh(String),
SavePrompt,
SaveToolAllowlist,
ResetToolAllowlist,
SaveApiKey,
ToggleDisplay,
StopTurn,
SetPublicFace(String),
SetModelAccess(String),
OpenSession,
RedeemCode,
DepositCredits,
SaveX402Price,
Consolidate,
UnlinkDevice(String),
UnlinkConfirm(String),
UnlinkCancel,
}
impl Action {
fn parse(name: &str, arg: Option<String>) -> Option<Action> {
Some(match name {
"send" => Action::Send,
"clear-key" => Action::ClearKey,
"opfs-refresh" => Action::OpfsRefresh,
"opfs-wipe" => Action::OpfsWipe,
"opfs-wipe-confirm" => Action::OpfsWipeConfirm,
"opfs-wipe-cancel" => Action::OpfsWipeCancel,
"opfs-delete" => Action::OpfsDelete(arg.unwrap_or_default()),
"opfs-close-viewer" => Action::OpfsCloseViewer,
"opfs-nav" => Action::OpfsNav(arg.unwrap_or_default()),
"opfs-open" => Action::OpfsOpen(arg.unwrap_or_default()),
"opfs-edit" => Action::OpfsEdit(arg.unwrap_or_default()),
"opfs-save" => Action::OpfsSave(arg.unwrap_or_default()),
"apex-claim" => Action::ApexClaim,
"claim-here" => Action::ClaimHere,
"claim-on-chain" => Action::ClaimOnChain,
"import-owner" => Action::ImportOwner,
"reveal-seed" => Action::RevealSeed,
"hide-seed" => Action::HideSeed,
"import-seed" => Action::ImportSeed,
"create-identity" => Action::CreateIdentity,
"show-import" => Action::ShowImport,
"cancel-import" => Action::CancelImport,
"header-admin-toggle" => Action::HeaderAdminToggle,
"header-admin-close" => Action::HeaderAdminClose,
"show-admin-tab" => Action::ShowAdminTab(arg.unwrap_or_default()),
"sync-key" => Action::SyncKey,
"restore-key" => Action::RestoreKey,
"reveal-security" => Action::RevealSecurity,
"hide-security" => Action::HideSecurity,
"reset-arm" => Action::ResetArm,
"reset-confirm" => Action::ResetConfirm,
"reset-cancel" => Action::ResetCancel,
"pricing-save" => Action::PricingSave,
"toggle-files" => Action::ToggleFiles,
"toggle-financial" => Action::ToggleFinancial,
"toggle-terminal" => Action::ToggleTerminal,
"toggle-view" => Action::ToggleView,
"show-tab" => Action::ShowTab(arg.unwrap_or_default()),
"feedback-open" => Action::FeedbackOpen,
"feedback-close" => Action::FeedbackClose,
"feedback-submit" => Action::FeedbackSubmit,
"pair-start" => Action::PairStart,
"add-device" => Action::AddDevice,
"adopt-device" => Action::AdoptDevice,
"create-new-claim" => Action::CreateNewClaim(arg.unwrap_or_default()),
"pair-cancel" => Action::PairCancel,
"pair-approve" => Action::PairApprove,
"pair-reject" => Action::PairReject,
"pair-join" => Action::PairJoin,
"agent-act-toggle" => Action::AgentActToggle(arg.unwrap_or_default()),
"agent-send-lh" => Action::AgentSendLh(arg.unwrap_or_default()),
"save-prompt" => Action::SavePrompt,
"save-tool-allowlist" => Action::SaveToolAllowlist,
"reset-tool-allowlist" => Action::ResetToolAllowlist,
"save-api-key" => Action::SaveApiKey,
"toggle-display" => Action::ToggleDisplay,
"stop-turn" => Action::StopTurn,
"set-public-face" => Action::SetPublicFace(arg.unwrap_or_default()),
"set-model-access" => Action::SetModelAccess(arg.unwrap_or_default()),
"open-session" => Action::OpenSession,
"redeem-code" => Action::RedeemCode,
"deposit-credits" => Action::DepositCredits,
"save-x402-price" => Action::SaveX402Price,
"consolidate" => Action::Consolidate,
"unlink-device" => Action::UnlinkDevice(arg.unwrap_or_default()),
"unlink-confirm" => Action::UnlinkConfirm(arg.unwrap_or_default()),
"unlink-cancel" => Action::UnlinkCancel,
_ => return None,
})
}
}
pub(crate) fn install_delegated_listeners(doc: &Document) -> Result<(), JsValue> {
let click = Closure::<dyn FnMut(_)>::new(move |event: MouseEvent| {
let Some(target) = event.target() else { return };
let Ok(mut node) = target.dyn_into::<Element>() else { return };
let action = loop {
if let Some(name) = node.get_attribute("data-action") {
let arg = node.get_attribute("data-arg");
break Action::parse(&name, arg);
}
match node.parent_element() {
Some(parent) => node = parent,
None => break None,
}
};
if let Some(action) = action {
event.prevent_default();
dispatch(action);
}
});
doc.add_event_listener_with_callback("click", click.as_ref().unchecked_ref())?;
click.forget();
let input_handler = Closure::<dyn FnMut(_)>::new(move |event: web_sys::Event| {
let Some(target) = event.target() else { return };
let Ok(el) = target.dyn_into::<Element>() else { return };
match el.id().as_str() {
"key" => on_key_input(),
"apex-input" => on_apex_input(),
_ => {}
}
});
doc.add_event_listener_with_callback("input", input_handler.as_ref().unchecked_ref())?;
input_handler.forget();
let submit_handler = Closure::<dyn FnMut(_)>::new(move |event: web_sys::Event| {
let Some(target) = event.target() else { return };
let Ok(form) = target.dyn_into::<Element>() else { return };
if let Some(name) = form.get_attribute("data-action") {
if let Some(action) = Action::parse(&name, form.get_attribute("data-arg")) {
event.prevent_default();
dispatch(action);
}
}
});
doc.add_event_listener_with_callback("submit", submit_handler.as_ref().unchecked_ref())?;
submit_handler.forget();
let keydown = Closure::<dyn FnMut(_)>::new(move |event: KeyboardEvent| {
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, create_if_missing: bool) {
set_create_button_busy(true);
let has_wallet = super::APP.with(|cell| cell.borrow().wallet.is_some());
if !has_wallet && !create_if_missing {
set_create_button_busy(false);
dom::swap_outer("agents-list", &templates::identity_choice(&name).into_string());
return;
}
let result: Result<String, String> = async {
match super::registry::check_name(&name).await {
Ok(super::registry::Status::Available) => {}
Ok(other) => return Err(format!("name not available: {other:?}")),
Err(err) => return Err(format!("check_name: {err}")),
}
let cached = super::APP.with(|cell| {
cell.borrow()
.wallet
.as_ref()
.map(|w| (w.signer.clone(), wallet_address_hex(&w.address)))
});
let (signer, addr_hex) = match cached {
Some(pair) => pair,
None => match super::wallet_store::create_and_persist().await {
Ok(wallet) => {
let pair = (wallet.signer.clone(), wallet.address_hex());
super::APP.with(|cell| cell.borrow_mut().wallet = Some(wallet));
pair
}
Err(err) => return Err(format!("wallet: {err}")),
},
};
let cost = super::registry::registration_cost().await.unwrap_or(0);
if cost > 0 {
let bal = super::registry::token_balance_of(&addr_hex).await.unwrap_or(0);
if bal < cost {
let deficit_lh = (cost - bal) / 1_000_000_000_000_000_000u128;
return Err(format!("__NEED_LH__{deficit_lh}"));
}
}
let fee_payer = super::sponsor::signer()
.map_err(|e| format!("sponsor key: {e}"))?;
super::registry::claim_and_maybe_set_main_sponsored(
&signer,
&fee_payer,
&name,
super::registry::ALPHA_USD_ADDRESS,
)
.await
.map_err(|e| format!("claim_name: {e}"))
}
.await;
match result {
Ok(tx_hash) => {
web_sys::console::log_1(&JsValue::from_str(&format!(
"claimed {name} (tx {})",
short_hash(&tx_hash)
)));
let target = format!("https://{name}.localharness.xyz/?claim=1");
if let Ok(window) = dom::window() {
let _ = window.location().assign(&target);
}
}
Err(err) => {
web_sys::console::warn_1(&JsValue::from_str(&format!("apex claim failed: {err}")));
if let Some(rest) = err.strip_prefix("__NEED_LH__") {
set_create_button_failed_with(&format!("need {rest} more LH"));
} else {
set_create_button_state(CreateBtnState::Failed);
}
}
}
}
fn set_create_button_busy(busy: bool) {
let Some(btn) = dom::by_id("create-btn") else { return };
if busy {
btn.set_inner_html("creating…");
let _ = btn.set_attribute("disabled", "");
} else {
btn.set_inner_html("create");
}
}
fn wallet_address_hex(addr: &[u8; 20]) -> String {
let mut s = String::with_capacity(42);
s.push_str("0x");
for b in addr {
s.push_str(&format!("{b:02x}"));
}
s
}
fn short_hash(tx_hash: &str) -> String {
let stripped = tx_hash.trim_start_matches("0x");
if stripped.len() < 12 {
return tx_hash.to_string();
}
format!("{}…{}", &stripped[..6], &stripped[stripped.len() - 4..])
}
pub(crate) fn refresh_keymeta() {
if let Some(input) = dom::input_by_id("key") {
let html = templates::keymeta(&input.value()).into_string();
dom::swap_inner("keymeta", &html);
}
}
fn dispatch(action: Action) {
match action {
Action::Send => {
wasm_bindgen_futures::spawn_local(async move {
super::chat::run_send().await;
});
}
Action::ClearKey => clear_key_pressed(),
Action::OpfsRefresh => {
wasm_bindgen_futures::spawn_local(async move {
super::opfs::refresh().await;
});
}
Action::OpfsCloseViewer => super::opfs::close_viewer(),
Action::ToggleDisplay => super::opfs::toggle_display(),
Action::StopTurn => super::chat::request_stop_turn(),
Action::SetPublicFace(choice) => {
wasm_bindgen_futures::spawn_local(async move {
run_set_public_face(&choice).await;
});
}
Action::OpfsNav(target) => {
wasm_bindgen_futures::spawn_local(async move {
super::opfs::navigate(&target).await;
});
}
Action::OpfsOpen(name) => {
wasm_bindgen_futures::spawn_local(async move {
super::opfs::open_file(&name).await;
});
}
Action::OpfsEdit(name) => {
wasm_bindgen_futures::spawn_local(async move {
super::opfs::edit_file(&name).await;
});
}
Action::OpfsSave(name) => {
wasm_bindgen_futures::spawn_local(async move {
super::opfs::save_file(&name).await;
});
}
Action::ApexClaim => {
let raw = dom::input_by_id("apex-input")
.map(|i| i.value())
.unwrap_or_default();
let cleaned = super::tenant::sanitize(&raw);
if cleaned.len() < 3 || cleaned.len() > 32 {
return;
}
wasm_bindgen_futures::spawn_local(async move {
run_apex_claim(cleaned, false).await;
});
}
Action::CreateNewClaim(name) => {
let cleaned = super::tenant::sanitize(&name);
if cleaned.len() < 3 || cleaned.len() > 32 {
return;
}
wasm_bindgen_futures::spawn_local(async move {
run_apex_claim(cleaned, true).await;
});
}
Action::ClaimHere => {
wasm_bindgen_futures::spawn_local(async move {
let super::tenant::Host::Tenant(name) = super::tenant::current() else {
return;
};
let addr = super::registry::owner_of_name(&name)
.await
.ok()
.flatten();
match addr {
Some(addr) => {
let _ = super::owner::remember(&addr).await;
super::paint_tenant(
super::tenant::Host::Tenant(name.clone()),
name,
)
.await;
}
None => {
dom::swap_inner(
"claim-msg",
&dom::msg_span(dom::Msg::Error, "claim failed: name has no on-chain owner"),
);
}
}
});
}
Action::ClaimOnChain => {
let Some(name) = (match super::tenant::current() {
super::tenant::Host::Tenant(n) => Some(n),
_ => None,
}) else {
return;
};
dom::swap_inner(
"claim-msg",
"<span style=\"color:var(--muted)\">ensuring identity at apex…</span>",
);
wasm_bindgen_futures::spawn_local(async move {
if let Err(err) = super::verify::create_wallet_via_iframe(false).await {
dom::swap_inner(
"claim-msg",
&dom::msg_span(dom::Msg::Error, &format!("identity setup failed: {err}")),
);
return;
}
dom::swap_inner(
"claim-msg",
"<span style=\"color:var(--muted)\">claiming on-chain…</span>",
);
match super::verify::claim_name_via_iframe(&name).await {
Ok((owner_addr, _tx)) => {
let _ = super::owner::remember(&owner_addr).await;
super::paint_tenant(
super::tenant::Host::Tenant(name.clone()),
name,
)
.await;
}
Err(err) => {
dom::swap_inner(
"claim-msg",
&dom::msg_span(dom::Msg::Error, &format!("claim failed: {err}")),
);
}
}
});
}
Action::ImportOwner => {
let raw = dom::input_by_id("import-uuid")
.map(|i| i.value().trim().to_string())
.unwrap_or_default();
if raw.len() < 32 {
dom::swap_inner(
"claim-msg",
"<span style=\"color:var(--error)\">paste a full UUID (36 chars with dashes)</span>",
);
return;
}
wasm_bindgen_futures::spawn_local(async move {
let fs = super::shared_opfs();
if let Err(err) = fs.write_atomic(".lh_owner", raw.as_bytes()).await {
dom::swap_inner(
"claim-msg",
&dom::msg_span(dom::Msg::Error, &format!("import failed: {err}")),
);
return;
}
if let super::tenant::Host::Tenant(name) = super::tenant::current() {
super::paint_tenant(super::tenant::Host::Tenant(name.clone()), name).await;
}
});
}
Action::RevealSeed => {
match super::tenant::current() {
super::tenant::Host::Apex => {
let phrase = super::APP.with(|cell| {
cell.borrow()
.wallet
.as_ref()
.map(|w| w.mnemonic.to_string())
});
if let Some(p) = phrase {
dom::swap_inner(
"seed-reveal",
&super::templates::seed_phrase(&p).into_string(),
);
}
}
_ => {
dom::swap_inner(
"seed-reveal",
"<span style=\"color:var(--muted)\">fetching…</span>",
);
wasm_bindgen_futures::spawn_local(async move {
match super::verify::reveal_seed_via_iframe().await {
Ok(phrase) => dom::swap_inner(
"seed-reveal",
&super::templates::seed_phrase(&phrase).into_string(),
),
Err(err) => dom::swap_inner(
"seed-reveal",
&maud::html! {
span style="color:var(--error)" { "reveal failed: " (err) }
button type="button" data-action="reveal-seed" class="ghost" { "retry" }
}
.into_string(),
),
}
});
}
}
}
Action::HideSeed => {
dom::swap_inner(
"seed-reveal",
r#"<button type="button" data-action="reveal-seed">I have a pen and paper — reveal</button>"#,
);
}
Action::CreateIdentity => {
dom::swap_inner(
"identity-msg",
"<span style=\"color:var(--muted)\">generating identity…</span>",
);
match super::tenant::current() {
super::tenant::Host::Apex => {
wasm_bindgen_futures::spawn_local(async move {
if let Err(err) = super::wallet_store::create_and_persist().await {
dom::swap_inner(
"identity-msg",
&dom::msg_span(dom::Msg::Error, &format!("create failed: {err}")),
);
return;
}
super::paint_apex(super::tenant::Host::Apex).await;
});
}
host => {
wasm_bindgen_futures::spawn_local(async move {
match super::verify::create_wallet_via_iframe(true).await {
Ok(_addr) => {
if let super::tenant::Host::Tenant(name) = &host {
super::paint_tenant(host.clone(), name.clone()).await;
}
}
Err(err) => {
dom::swap_inner(
"identity-msg",
&dom::msg_span(dom::Msg::Error, &format!("create failed: {err}")),
);
}
}
});
}
}
}
Action::ShowImport => {
dom::swap_outer(
"import-slot",
&templates::import_seed_inline().into_string(),
);
if let Some(textarea) = dom::textarea_by_id("import-seed") {
let _ = textarea.focus();
}
}
Action::ImportSeed => {
let phrase = dom::textarea_by_id("import-seed")
.map(|t| t.value())
.unwrap_or_default();
if phrase.split_whitespace().count() != 12 {
dom::swap_inner(
"seed-msg",
"<span style=\"color:var(--error)\">expected exactly 12 words</span>",
);
return;
}
match super::tenant::current() {
super::tenant::Host::Apex => {
wasm_bindgen_futures::spawn_local(async move {
match super::wallet_store::import(&phrase).await {
Ok(_) => {
super::paint_apex(super::tenant::Host::Apex).await;
}
Err(err) => {
dom::swap_inner(
"seed-msg",
&dom::msg_span(dom::Msg::Error, &format!("import failed: {err}")),
);
}
}
});
}
host => {
wasm_bindgen_futures::spawn_local(async move {
match super::verify::import_seed_via_iframe(&phrase).await {
Ok(_addr) => {
if let super::tenant::Host::Tenant(name) = &host {
super::paint_tenant(host.clone(), name.clone()).await;
}
}
Err(err) => {
dom::swap_inner(
"seed-msg",
&dom::msg_span(dom::Msg::Error, &format!("import failed: {err}")),
);
}
}
});
}
}
}
Action::OpfsDelete(name) => {
wasm_bindgen_futures::spawn_local(async move {
let fs = super::shared_opfs();
if let Err(err) = fs.delete(&name).await {
web_sys::console::warn_1(&JsValue::from_str(&format!(
"delete({name}): {err}"
)));
}
if name == ".lh_history.json" {
dom::swap_inner("transcript", "");
}
super::opfs::refresh().await;
});
}
Action::OpfsWipe => {
dom::swap_outer(
"opfs-wipe-slot",
&templates::opfs_wipe_confirm_inline().into_string(),
);
}
Action::OpfsWipeConfirm => {
dom::swap_outer(
"opfs-wipe-slot",
&templates::opfs_wipe_armed_inline().into_string(),
);
wasm_bindgen_futures::spawn_local(async move {
super::opfs::wipe().await;
});
}
Action::OpfsWipeCancel => {
dom::swap_outer(
"opfs-wipe-slot",
&templates::opfs_wipe_armed_inline().into_string(),
);
}
Action::CancelImport => {
dom::swap_outer("import-slot", r#"<div id="import-slot"></div>"#);
}
Action::HeaderAdminToggle => header_admin_toggle(),
Action::HeaderAdminClose => header_admin_close(),
Action::ShowAdminTab(name) => show_admin_tab(&name),
Action::SyncKey => {
wasm_bindgen_futures::spawn_local(async move { run_sync_key().await });
}
Action::RestoreKey => {
wasm_bindgen_futures::spawn_local(async move { run_restore_key().await });
}
Action::RevealSecurity => {
dom::swap_outer(
"security-slot",
&templates::admin_security_expanded().into_string(),
);
}
Action::HideSecurity => {
dom::swap_outer(
"security-slot",
&templates::admin_security_collapsed().into_string(),
);
}
Action::ResetArm => {
dom::swap_outer(
"reset-confirm-slot",
&templates::reset_confirm_inline().into_string(),
);
}
Action::ResetCancel => {
dom::swap_outer(
"reset-confirm-slot",
&templates::reset_armed_inline().into_string(),
);
}
Action::ResetConfirm => reset_confirm_pressed(),
Action::PricingSave => pricing_save_pressed(),
Action::ToggleFiles => toggle_layout_class("files-collapsed"),
Action::ToggleFinancial => toggle_layout_class("financial-collapsed"),
Action::ToggleTerminal => toggle_layout_class("terminal-collapsed"),
Action::ToggleView => toggle_layout_class("view-collapsed"),
Action::ShowTab(name) => show_mobile_tab(&name),
Action::FeedbackOpen => super::feedback::feedback_open(),
Action::FeedbackClose => super::feedback::feedback_close(),
Action::FeedbackSubmit => super::feedback::feedback_submit(),
Action::PairStart => pair_start_pressed(),
Action::AddDevice => add_device_pressed(),
Action::AdoptDevice => adopt_device_pressed(),
Action::PairCancel => pair_cancel_pressed(),
Action::PairApprove => pair_approve_pressed(),
Action::PairReject => pair_reject_pressed(),
Action::PairJoin => {
if let Some(code) = super::read_query_param("pair") {
pair_join_pressed(code);
}
}
Action::AgentActToggle(token_id) => agent_act_toggle_pressed(token_id),
Action::AgentSendLh(token_id) => agent_send_lh_pressed(token_id),
Action::SavePrompt => save_prompt_pressed(),
Action::SaveToolAllowlist => save_tool_allowlist_pressed(),
Action::ResetToolAllowlist => reset_tool_allowlist_pressed(),
Action::SaveApiKey => save_api_key_pressed(),
Action::SetModelAccess(mode) => run_set_model_access(mode),
Action::OpenSession => open_session_pressed(),
Action::RedeemCode => redeem_code_pressed(),
Action::DepositCredits => deposit_credits_pressed(),
Action::SaveX402Price => save_x402_price_pressed(),
Action::Consolidate => consolidate_pressed(),
Action::UnlinkDevice(addr) => unlink_device_prompt(addr),
Action::UnlinkConfirm(addr) => unlink_confirm_pressed(addr),
Action::UnlinkCancel => unlink_cancel_pressed(),
}
}
fn save_prompt_pressed() {
let Some(textarea) = dom::textarea_by_id("prompt-input") else { return };
let content = textarea.value();
dom::swap_inner(
"prompt-msg",
"<span style=\"color:var(--muted)\">saving…</span>",
);
wasm_bindgen_futures::spawn_local(async move {
match super::system_prompt::save(&content).await {
Ok(()) => {
let trimmed = content.trim();
let summary = if trimmed.is_empty() {
"✓ saved · using default on next session"
} else {
"✓ saved · takes effect on next session"
};
dom::swap_inner(
"prompt-msg",
&dom::msg_span(dom::Msg::Accent, &format!("{summary}")),
);
}
Err(err) => {
dom::swap_inner(
"prompt-msg",
&dom::msg_span(dom::Msg::Error, &format!("{err}")),
);
}
}
});
}
fn save_tool_allowlist_pressed() {
use crate::types::BuiltinTool;
let mut enabled = Vec::new();
if let Some(doc) = web_sys::window().and_then(|w| w.document()) {
if let Ok(checkboxes) = doc.query_selector_all(".tool-checkbox") {
for i in 0..checkboxes.length() {
if let Some(el) = checkboxes.get(i) {
let input: web_sys::HtmlInputElement = JsCast::unchecked_into(el);
if input.checked() {
if let Some(name) = input.get_attribute("data-tool") {
if let Some(tool) = BuiltinTool::ALL.iter().find(|t| t.wire_name() == name) {
enabled.push(*tool);
}
}
}
}
}
}
}
dom::swap_inner(
"tool-allowlist-msg",
"<span style=\"color:var(--muted)\">saving…</span>",
);
wasm_bindgen_futures::spawn_local(async move {
match super::tool_allowlist::save(&enabled).await {
Ok(()) => {
let summary = super::tool_allowlist::summary(&enabled);
dom::swap_inner(
"tool-allowlist-msg",
&dom::msg_span(dom::Msg::Accent, &format!("✓ saved · {summary} · takes effect on next session")),
);
}
Err(err) => {
dom::swap_inner(
"tool-allowlist-msg",
&dom::msg_span(dom::Msg::Error, &format!("{err}")),
);
}
}
});
}
fn reset_tool_allowlist_pressed() {
dom::swap_inner(
"tool-allowlist-msg",
"<span style=\"color:var(--muted)\">resetting…</span>",
);
if let Some(doc) = web_sys::window().and_then(|w| w.document()) {
if let Ok(checkboxes) = doc.query_selector_all(".tool-checkbox") {
for i in 0..checkboxes.length() {
if let Some(el) = checkboxes.get(i) {
let input: web_sys::HtmlInputElement = JsCast::unchecked_into(el);
input.set_checked(true);
}
}
}
}
wasm_bindgen_futures::spawn_local(async move {
match super::tool_allowlist::save(&[]).await {
Ok(()) => {
dom::swap_inner(
"tool-allowlist-msg",
"<span style=\"color:var(--accent)\">✓ reset · all tools enabled · takes effect on next session</span>",
);
}
Err(err) => {
dom::swap_inner(
"tool-allowlist-msg",
&dom::msg_span(dom::Msg::Error, &format!("{err}")),
);
}
}
});
}
fn save_api_key_pressed() {
let Some(input) = dom::input_by_id("api-key-input") else { return };
let value = input.value().trim().to_string();
if value.is_empty() {
return;
}
if let Ok(Some(storage)) = dom::session_storage() {
let _ = storage.set_item("gemini_api_key", &value);
}
dom::swap_inner(
"api-key-msg",
"<span style=\"color:var(--muted)\">checking…</span>",
);
wasm_bindgen_futures::spawn_local(async move {
super::key_store::save(&value).await;
super::opfs::refresh().await;
if let Some(false) = gemini_key_is_valid(&value).await {
dom::swap_inner(
"api-key-msg",
"<span style=\"color:var(--error)\">key rejected — check it</span>",
);
return;
}
if let Some(el) = dom::by_id("api-key-modal") {
if let Some(parent) = el.parent_element() {
let _ = parent.remove_child(&el);
}
}
if let super::tenant::Host::Tenant(name) = super::tenant::current() {
auto_sync_gemini_key(name, value).await;
}
});
}
async fn gemini_key_is_valid(key: &str) -> Option<bool> {
let url = format!("https://generativelanguage.googleapis.com/v1beta/models?key={key}");
match reqwest::Client::new().get(&url).send().await {
Ok(resp) => Some(resp.status().is_success()),
Err(_) => None,
}
}
fn agent_act_toggle_pressed(token_id_str: String) {
let Ok(token_id) = token_id_str.parse::<u64>() else { return };
let panel_id = format!("agent-act-{token_id}");
let Some(panel) = dom::by_id(&panel_id) else { return };
let was_hidden = panel.has_attribute("hidden");
if was_hidden {
panel.set_inner_html(
"<div class=\"admin-msg-slot\"><span style=\"color:var(--muted)\">loading…</span></div>",
);
let _ = panel.remove_attribute("hidden");
wasm_bindgen_futures::spawn_local(async move {
if let Err(err) = paint_agent_act_panel(token_id).await {
let panel_id = format!("agent-act-{token_id}");
dom::swap_inner(
&panel_id,
&maud::html! {
div class="admin-msg-slot" {
span style="color:var(--error)" { (err) }
}
}
.into_string(),
);
}
});
} else {
let _ = panel.set_attribute("hidden", "");
}
}
async fn paint_agent_act_panel(token_id: u64) -> Result<(), String> {
let tba = super::registry::tba_of_token_id(token_id)
.await
.map_err(|e| format!("tba: {e}"))?
.ok_or_else(|| "no TBA".to_string())?;
let balance = super::registry::token_balance_of(&tba).await.unwrap_or(0);
let html = templates::agent_act_panel(token_id, &tba, balance).into_string();
let panel_id = format!("agent-act-{token_id}");
dom::swap_inner(&panel_id, &html);
Ok(())
}
fn agent_send_lh_pressed(token_id_str: String) {
let Ok(token_id) = token_id_str.parse::<u64>() else { return };
let msg_id = format!("agent-act-msg-{token_id}");
let to_raw = dom::input_by_id(&format!("agent-send-to-{token_id}"))
.map(|i| i.value().trim().to_string())
.unwrap_or_default();
let amt_raw = dom::input_by_id(&format!("agent-send-amt-{token_id}"))
.map(|i| i.value().trim().to_string())
.unwrap_or_default();
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 Some(addr) = super::chat::credit_address_existing().await else { return };
if let Ok(balance_wei) = super::registry::token_balance_of(&addr).await {
let lh = balance_wei / 1_000_000_000_000_000_000u128;
dom::swap_inner("credits-balance", &format!("{lh} LH"));
}
if let Ok(expiry) = super::registry::session_expiry_of(&addr).await {
let now = (js_sys::Date::now() / 1000.0) as u64;
let msg = if expiry > now {
format!("session active · ~{} min left", (expiry - now) / 60)
} else {
"no active session".to_string()
};
dom::swap_inner("session-status", &msg);
}
if let Ok(meter) = super::registry::credit_balance_of(&addr).await {
let lh = meter / 1_000_000_000_000_000_000u128;
dom::swap_inner("meter-balance", &format!("{lh} LH metered"));
}
warn_if_sponsor_low().await;
}
const SPONSOR_RL_WINDOW_SECS: u64 = 3600;
const SPONSOR_RL_MAX: usize = 60;
fn sponsor_rate_guard() -> Result<(), String> {
let Some(storage) = web_sys::window().and_then(|w| w.local_storage().ok().flatten()) else {
return Ok(()); };
let now = (js_sys::Date::now() / 1000.0) as u64;
let prev = storage
.get_item("lh_sponsor_rl")
.ok()
.flatten()
.unwrap_or_default();
let mut stamps: Vec<u64> = prev
.split(',')
.filter_map(|s| s.trim().parse::<u64>().ok())
.filter(|t| now.saturating_sub(*t) < SPONSOR_RL_WINDOW_SECS)
.collect();
if stamps.len() >= SPONSOR_RL_MAX {
return Err("too many sponsored actions in a short window — wait a bit".into());
}
stamps.push(now);
let joined = stamps
.iter()
.map(|t| t.to_string())
.collect::<Vec<_>>()
.join(",");
let _ = storage.set_item("lh_sponsor_rl", &joined);
Ok(())
}
pub(crate) async fn warn_if_sponsor_low() {
const LOW_THRESHOLD_WEI: u128 = 5_000_000_000_000_000_000; let Ok(signer) = super::sponsor::signer() else {
return;
};
let addr = crate::wallet::address(&signer);
let addr_hex = format!(
"0x{}",
addr.iter().map(|b| format!("{b:02x}")).collect::<String>()
);
if let Ok(bal) =
super::registry::erc20_balance_of(super::registry::ALPHA_USD_ADDRESS, &addr_hex).await
{
if bal < LOW_THRESHOLD_WEI {
let whole = bal / 1_000_000_000_000_000_000u128;
web_sys::console::warn_1(&JsValue::from_str(&format!(
"sponsor fee-token LOW: ~{whole} AlphaUSD at {addr_hex} — refill soon"
)));
}
}
}
fn run_set_model_access(mode: String) {
if let Some(storage) =
web_sys::window().and_then(|w| w.local_storage().ok().flatten())
{
let _ = storage.set_item("lh_model_access", &mode);
}
if dom::by_id("credits-section").is_some() {
dom::swap_outer(
"credits-section",
&super::templates::admin_credits_section().into_string(),
);
}
if mode == "credits" {
if let Some(el) = dom::by_id("api-key-modal") {
if let Some(parent) = el.parent_element() {
let _ = parent.remove_child(&el);
}
}
}
wasm_bindgen_futures::spawn_local(async {
refresh_credits_pill().await;
});
}
fn open_session_pressed() {
dom::swap_inner(
"credits-msg",
"<span style=\"color:var(--muted)\">opening session…</span>",
);
wasm_bindgen_futures::spawn_local(async move {
let result = async {
sponsor_rate_guard()?;
let (signer, _) = super::chat::credit_signer()
.await
.ok_or_else(|| "no identity".to_string())?;
let fee_payer = super::sponsor::signer()?;
super::registry::open_session_sponsored(
&signer,
&fee_payer,
super::registry::ALPHA_USD_ADDRESS,
)
.await
}
.await;
match result {
Ok(_) => {
dom::swap_inner(
"credits-msg",
"<span style=\"color:var(--muted)\">session opened</span>",
);
refresh_credits_pill().await;
}
Err(e) => {
web_sys::console::warn_1(&JsValue::from_str(&format!("open session: {e}")));
dom::swap_inner(
"credits-msg",
"<span style=\"color:var(--error)\">couldn't open session</span>",
);
}
}
});
}
fn redeem_code_pressed() {
let Some(input) = dom::input_by_id("redeem-code") else { return };
let code = input.value().trim().to_string();
if code.is_empty() {
return;
}
dom::swap_inner(
"credits-msg",
"<span style=\"color:var(--muted)\">redeeming…</span>",
);
wasm_bindgen_futures::spawn_local(async move {
let result = async {
let (signer, _) = super::chat::credit_signer()
.await
.ok_or_else(|| "no identity".to_string())?;
let fee_payer = super::sponsor::signer()?;
super::registry::redeem_sponsored(
&signer,
&fee_payer,
&code,
super::registry::ALPHA_USD_ADDRESS,
)
.await
}
.await;
match result {
Ok(_) => {
dom::swap_inner(
"credits-msg",
"<span style=\"color:var(--muted)\">redeemed</span>",
);
refresh_credits_pill().await;
}
Err(e) => {
web_sys::console::warn_1(&JsValue::from_str(&format!("redeem: {e}")));
dom::swap_inner(
"credits-msg",
&dom::msg_span(dom::Msg::Error, &format!("redeem failed: {e}")),
);
}
}
});
}
fn local_storage() -> Option<web_sys::Storage> {
web_sys::window().and_then(|w| w.local_storage().ok().flatten())
}
fn pending_invite_code() -> Option<String> {
local_storage()?
.get_item("lh_pending_invite")
.ok()
.flatten()
.filter(|s| !s.is_empty())
}
pub(crate) async fn try_redeem_pending_invite(allow_generate: bool) {
let Some(code) = pending_invite_code() else {
return;
};
if !allow_generate && super::chat::credit_address_existing().await.is_none() {
return;
}
let Some((signer, _)) = super::chat::credit_signer().await else {
return;
};
let Ok(fee_payer) = super::sponsor::signer() else {
return;
};
if let Some(s) = local_storage() {
let _ = s.remove_item("lh_pending_invite");
}
dom::set_status("redeeming invite…", false);
match super::registry::redeem_sponsored(
&signer,
&fee_payer,
&code,
super::registry::ALPHA_USD_ADDRESS,
)
.await
{
Ok(_) => {
if let Some(s) = local_storage() {
let _ = s.set_item("lh_model_access", "credits");
}
dom::set_status("invite redeemed — platform credits added", false);
refresh_credits_pill().await;
}
Err(e) => {
web_sys::console::warn_1(&JsValue::from_str(&format!("invite redeem: {e}")));
dom::set_status("invite couldn't be redeemed (it may be used already)", true);
}
}
}
fn deposit_credits_pressed() {
let Some(input) = dom::input_by_id("deposit-amount") else {
return;
};
let Ok(whole) = input.value().trim().parse::<u128>() else {
return;
};
if whole == 0 {
return;
}
let Some(amount_wei) = whole.checked_mul(1_000_000_000_000_000_000u128) else {
return;
};
dom::swap_inner(
"credits-msg",
"<span style=\"color:var(--muted)\">depositing…</span>",
);
wasm_bindgen_futures::spawn_local(async move {
let result = async {
sponsor_rate_guard()?;
let (signer, _) = super::chat::credit_signer()
.await
.ok_or_else(|| "no identity".to_string())?;
let fee_payer = super::sponsor::signer()?;
super::registry::deposit_credits_sponsored(
&signer,
&fee_payer,
amount_wei,
super::registry::ALPHA_USD_ADDRESS,
)
.await
}
.await;
match result {
Ok(_) => {
dom::swap_inner(
"credits-msg",
"<span style=\"color:var(--muted)\">credits added</span>",
);
refresh_credits_pill().await;
}
Err(e) => {
web_sys::console::warn_1(&JsValue::from_str(&format!("deposit: {e}")));
dom::swap_inner(
"credits-msg",
"<span style=\"color:var(--error)\">deposit failed</span>",
);
}
}
});
}
fn save_x402_price_pressed() {
let Some(input) = dom::input_by_id("x402-price-input") else {
return;
};
let raw = input.value().trim().to_string();
wasm_bindgen_futures::spawn_local(async move {
use crate::filesystem::Filesystem;
let fs = super::shared_opfs();
let result: Result<(), String> = async {
if raw.is_empty() || raw == "0" {
let _ = fs.delete(".lh_x402_price").await;
return Ok(());
}
let whole: u128 = raw.parse().map_err(|_| "bad amount".to_string())?;
let wei = whole
.checked_mul(1_000_000_000_000_000_000u128)
.ok_or_else(|| "overflow".to_string())?;
fs.write_atomic(".lh_x402_price", wei.to_string().as_bytes())
.await
.map_err(|e| e.to_string())
}
.await;
match result {
Ok(()) => dom::swap_inner(
"x402-price-msg",
"<span style=\"color:var(--muted)\">saved</span>",
),
Err(e) => {
web_sys::console::warn_1(&JsValue::from_str(&format!("x402 price: {e}")));
dom::swap_inner(
"x402-price-msg",
"<span style=\"color:var(--error)\">save failed</span>",
);
}
}
});
}
async fn refresh_signer_list() {
let addr = super::APP.with(|cell| {
cell.borrow().wallet.as_ref().map(|w| w.address_hex())
});
let addr = match addr {
Some(a) => a,
None => match super::wallet_store::load_linked_owner().await {
Some(o) => o,
None => return,
},
};
let main_id = match super::registry::main_of(&addr).await {
Ok(id) if id > 0 => id,
_ => {
dom::swap_inner("signer-list", "no MAIN set");
return;
}
};
match super::registry::devices_of(main_id).await {
Ok(signers) if signers.is_empty() => {
dom::swap_inner("signer-list", "owner only (no linked devices)");
}
Ok(signers) => {
let 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=\"display:flex;justify-content:center;align-items:center;\
gap:8px;color:var(--fg);font-size:11px;margin:2px 0\">\
<code>{short}</code>\
<button type=\"button\" class=\"modal-close\" data-action=\"unlink-device\" \
data-arg=\"{s}\" title=\"unlink\">×</button></div>"
));
}
dom::swap_inner("signer-list", &html);
}
Err(_) => {
dom::swap_inner("signer-list", "");
}
}
}
struct PendingPair {
device: String,
device_pubkey: Vec<u8>,
token_id: u64,
owner_hex: String,
}
thread_local! {
static PENDING_PAIR: std::cell::RefCell<Option<PendingPair>> =
const { std::cell::RefCell::new(None) };
}
fn pair_start_pressed() {
wasm_bindgen_futures::spawn_local(async move {
let owner_hex = super::APP
.with(|cell| cell.borrow().wallet.as_ref().map(|w| w.address_hex()));
let Some(owner_hex) = owner_hex else {
dom::swap_inner("pair-msg", &dom::msg_span(dom::Msg::Error, "no identity"));
return;
};
let token_id = match super::registry::main_of(&owner_hex).await {
Ok(id) if id != 0 => id,
_ => {
dom::swap_inner(
"pair-msg",
&dom::msg_span(dom::Msg::Error, "claim a subdomain first"),
);
return;
}
};
let name = match super::registry::name_of_id(token_id).await {
Ok(n) if !n.is_empty() => n,
_ => {
dom::swap_inner("pair-msg", &dom::msg_span(dom::Msg::Error, "no MAIN name"));
return;
}
};
let code = generate_pair_code();
let pair_url = format!("https://{name}.localharness.xyz/?pair={code}");
dom::swap_outer(
"pair-slot",
&templates::pair_panel(&code, &pair_url).into_string(),
);
dom::swap_inner("pair-msg", "");
let code_hash = super::registry::pairing_code_hash(&code);
let mut found: Option<(String, Vec<u8>)> = None;
for _ in 0..100 {
if dom::by_id("pair-slot")
.map(|el| !el.class_name().contains("pair-active"))
.unwrap_or(true)
{
return;
}
match super::registry::find_pairing_device(&code_hash).await {
Ok(Some(pair)) => {
found = Some(pair);
break;
}
_ => {}
}
super::registry::sleep_ms(3000).await;
}
let Some((device, device_pubkey)) = found else {
dom::swap_inner(
"pair-msg",
&dom::msg_span(dom::Msg::Error, "timed out — try again"),
);
dom::swap_outer(
"pair-slot",
&r#"<div id="pair-slot" class="pair-slot"><button id="pair-btn" type="button" data-action="add-device" class="ghost">add a device</button></div>"#,
);
return;
};
PENDING_PAIR.with(|p| {
*p.borrow_mut() = Some(PendingPair {
device: device.clone(),
device_pubkey,
token_id,
owner_hex,
});
});
dom::swap_outer(
"pair-slot",
&templates::pair_confirm_panel(&device).into_string(),
);
dom::swap_inner("pair-msg", "");
});
}
fn pair_approve_pressed() {
let Some(pending) = PENDING_PAIR.with(|p| p.borrow_mut().take()) else {
return;
};
dom::swap_inner("pair-msg", &dom::msg_span(dom::Msg::Accent, "enrolling…"));
wasm_bindgen_futures::spawn_local(async move {
let PendingPair { device, device_pubkey, token_id, owner_hex } = pending;
match run_add_device(device.clone()).await {
Ok(tx_hash) => {
let short = tx_short_hash(&tx_hash);
dom::swap_inner(
"pair-msg",
&dom::msg_span(
dom::Msg::Accent,
&format!("✓ linked {} (tx {short})", short_addr(&device)),
),
);
dom::swap_outer(
"pair-slot",
&r#"<div id="pair-slot" class="pair-slot"><button id="pair-btn" type="button" data-action="pair-start" class="ghost">link another device</button></div>"#,
);
if !device_pubkey.is_empty() {
if wrap_and_post_key_to_device(token_id, &owner_hex, &device, &device_pubkey)
.await
.is_ok()
{
dom::swap_inner(
"pair-msg",
&dom::msg_span(
dom::Msg::Accent,
&format!(
"✓ linked {} + shared your key — it's ready to use",
short_addr(&device)
),
),
);
}
}
refresh_signer_list().await;
}
Err(err) => {
dom::swap_inner("pair-msg", &dom::msg_span(dom::Msg::Error, &format!("{err}")));
}
}
});
}
fn pair_reject_pressed() {
PENDING_PAIR.with(|p| *p.borrow_mut() = None);
dom::swap_outer(
"pair-slot",
&r#"<div id="pair-slot" class="pair-slot"><button id="pair-btn" type="button" data-action="add-device" class="ghost">add a device</button></div>"#,
);
dom::swap_inner("pair-msg", &dom::msg_span(dom::Msg::Error, "rejected that device"));
}
fn pair_cancel_pressed() {
dom::swap_outer(
"pair-slot",
&r#"<div id="pair-slot" class="pair-slot"><button id="pair-btn" type="button" data-action="add-device" class="ghost">add a device</button></div>"#,
);
dom::swap_inner("pair-msg", "");
}
fn code_key(code: &str) -> [u8; 32] {
use sha3::{Digest, Keccak256};
let mut h = Keccak256::new();
h.update(b"localharness/v0/adopt");
h.update(code.trim().to_uppercase().as_bytes());
let mut out = [0u8; 32];
out.copy_from_slice(&h.finalize());
out
}
fn hex_to_bytes(s: &str) -> Option<Vec<u8>> {
let s = s.trim().trim_start_matches("0x");
if s.is_empty() || s.len() % 2 != 0 {
return None;
}
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(s.get(i..i + 2)?, 16).ok())
.collect()
}
fn add_device_pressed() {
let phrase = super::APP
.with(|cell| cell.borrow().wallet.as_ref().map(|w| w.mnemonic.to_string()));
let Some(phrase) = phrase else {
dom::swap_inner("pair-msg", &dom::msg_span(dom::Msg::Error, "no identity on this device"));
return;
};
wasm_bindgen_futures::spawn_local(async move {
let code = generate_pair_code();
let Some(ct) = super::encryption::seal_with_raw_key(&code_key(&code), phrase.as_bytes()).await
else {
dom::swap_inner("pair-msg", &dom::msg_span(dom::Msg::Error, "encrypt failed"));
return;
};
let hex: String = ct.iter().map(|b| format!("{b:02x}")).collect();
let url = format!("https://localharness.xyz/?adopt=1#s={hex}");
dom::swap_outer("pair-slot", &templates::adopt_panel(&code, &url).into_string());
dom::swap_inner("pair-msg", "");
});
}
fn adopt_device_pressed() {
let code = dom::input_by_id("adopt-code").map(|i| i.value()).unwrap_or_default();
let ct_hex = dom::input_by_id("adopt-ct").map(|i| i.value()).unwrap_or_default();
if code.trim().is_empty() {
return;
}
wasm_bindgen_futures::spawn_local(async move {
let Some(ct) = hex_to_bytes(&ct_hex) else {
dom::swap_inner("adopt-msg", &dom::msg_span(dom::Msg::Error, "bad link — rescan the QR"));
return;
};
match super::encryption::open_with_raw_key(&code_key(&code), &ct).await {
Some(bytes) => {
let phrase = String::from_utf8_lossy(&bytes).into_owned();
match super::wallet_store::import(phrase.trim()).await {
Ok(_) => {
if let Ok(window) = dom::window() {
let _ = window.location().set_href("https://localharness.xyz/");
}
}
Err(err) => {
dom::swap_inner("adopt-msg", &dom::msg_span(dom::Msg::Error, &format!("import failed: {err}")));
}
}
}
None => {
dom::swap_inner("adopt-msg", &dom::msg_span(dom::Msg::Error, "wrong code"));
}
}
});
}
pub(crate) fn pair_join_pressed(code: String) {
dom::swap_inner(
"pair-join-msg",
"<span style=\"color:var(--muted)\">generating device key + announcing…</span>",
);
wasm_bindgen_futures::spawn_local(async move {
match run_pair_join(&code).await {
Ok(addr) => {
dom::swap_inner(
"pair-join-msg",
&dom::msg_span(
dom::Msg::Accent,
&format!(
"✓ this device is {addr} — approve it on your other \
device (check the address matches), and you'll be \
redirected automatically."
),
),
);
}
Err(err) => {
dom::swap_inner(
"pair-join-msg",
&dom::msg_span(dom::Msg::Error, &format!("{err}")),
);
}
}
});
}
async fn run_pair_join(code: &str) -> Result<String, String> {
let wallet = crate::wallet::generate();
let device_hex = wallet.address_hex();
super::wallet_store::persist_device_key(&wallet.private_key_hex)
.await
.map_err(|e| format!("save device key: {e}"))?;
let code_hash = super::registry::pairing_code_hash(code);
let pubkey = crate::wallet::pubkey_compressed(&wallet.signer);
let fee_payer = super::sponsor::signer()?;
super::registry::announce_pairing_sponsored(
&wallet.signer,
&fee_payer,
&code_hash,
&pubkey,
super::registry::ALPHA_USD_ADDRESS,
)
.await?;
let device_signer = wallet.signer.clone();
let device_addr = device_hex.clone();
wasm_bindgen_futures::spawn_local(async move {
let name = match super::tenant::current() {
super::tenant::Host::Tenant(n) => n,
_ => return,
};
let (main_id, owner_hex) = match super::registry::owner_of_name(&name).await {
Ok(Some(owner)) => (super::registry::main_of(&owner).await.unwrap_or(0), owner),
_ => (0, String::new()),
};
let slot_id = gemini_key_slot_id(&name).await.ok();
let mut enrolled = false;
let mut got_key = false;
let mut post_enroll = 0u32;
for _ in 0..60 {
if !enrolled && main_id != 0 {
if let Ok(devs) = super::registry::devices_of(main_id).await {
if devs.iter().any(|d| d.eq_ignore_ascii_case(&device_addr)) {
enrolled = true;
dom::swap_inner(
"pair-join-msg",
&dom::msg_span(dom::Msg::Accent, "✓ this device is now linked"),
);
}
}
}
if !got_key {
if let Some(slot_id) = slot_id {
if let Ok(Some(blob)) =
super::registry::wrapped_device_key_of(slot_id, &device_addr).await
{
if let Some(pt) =
super::encryption::ecies_open(&device_signer, &blob).await
{
if let Ok(key) = String::from_utf8(pt) {
super::key_store::save(&key).await;
if let Ok(Some(storage)) = dom::session_storage() {
let _ = storage.set_item("gemini_api_key", &key);
}
got_key = true;
}
}
}
}
}
if enrolled {
if got_key || post_enroll >= 2 {
dom::swap_inner(
"pair-join-msg",
&dom::msg_span(dom::Msg::Accent, "✓ linked — opening your subdomain…"),
);
if let Ok(window) = dom::window() {
let url = if owner_hex.is_empty() {
format!("https://{name}.localharness.xyz/")
} else {
format!(
"https://localharness.xyz/?link_device={owner_hex}&then={name}"
)
};
let _ = window.location().set_href(&url);
}
return;
}
post_enroll += 1;
dom::swap_inner(
"pair-join-msg",
&dom::msg_span(dom::Msg::Accent, "✓ linked — opening your subdomain…"),
);
}
super::registry::sleep_ms(3000).await;
}
});
Ok(device_hex)
}
fn apex_seed_sync_key() -> Option<[u8; 32]> {
use sha3::{Digest, Keccak256};
super::APP.with(|cell| {
let app = cell.borrow();
let wallet = app.wallet.as_ref()?;
let entropy = wallet.mnemonic.to_entropy();
let mut hasher = Keccak256::new();
hasher.update(b"localharness/v0/keysync");
hasher.update(&entropy);
let mut out = [0u8; 32];
out.copy_from_slice(&hasher.finalize());
Some(out)
})
}
async fn wrap_and_post_key_to_device(
main_id: u64,
owner_hex: &str,
device_hex: &str,
device_pubkey: &[u8],
) -> Result<(), String> {
let ct = super::registry::gemini_key_of(main_id)
.await
.map_err(|e| format!("read key: {e}"))?
.ok_or_else(|| "no synced key".to_string())?;
let seed_key = apex_seed_sync_key().ok_or_else(|| "no seed on this device".to_string())?;
let plaintext = super::encryption::open_with_raw_key(&seed_key, &ct)
.await
.ok_or_else(|| "decrypt failed".to_string())?;
let blob = super::encryption::ecies_seal(device_pubkey, &plaintext)
.await
.ok_or_else(|| "wrap failed".to_string())?;
let signer = super::APP
.with(|cell| cell.borrow().wallet.as_ref().map(|w| w.signer.clone()))
.ok_or_else(|| "no wallet".to_string())?;
let fee_payer = super::sponsor::signer()?;
super::registry::set_device_wrapped_key_sponsored(
&signer,
&fee_payer,
main_id,
device_hex,
&blob,
super::registry::ALPHA_USD_ADDRESS,
)
.await?;
let _ = owner_hex; Ok(())
}
fn generate_pair_code() -> String {
const ALPHABET: &[u8] = b"23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
let mut bytes = [0u8; 6];
let _ = getrandom::getrandom(&mut bytes);
bytes
.iter()
.map(|b| ALPHABET[(*b as usize) % ALPHABET.len()] as char)
.collect()
}
async fn run_add_device(new_signer_hex: String) -> Result<String, String> {
let (signer, owner_hex) = super::APP
.with(|cell| {
cell.borrow()
.wallet
.as_ref()
.map(|w| (w.signer.clone(), w.address_hex()))
})
.ok_or_else(|| "no apex identity".to_string())?;
let token_id = super::registry::main_of(&owner_hex)
.await
.map_err(|e| format!("mainOf: {e}"))?;
if token_id == 0 {
return Err("claim a subdomain first — it becomes your MAIN".into());
}
let tba_addr = super::registry::tba_of_token_id(token_id)
.await
.map_err(|e| format!("tba lookup: {e}"))?
.ok_or_else(|| "no TBA for MAIN".to_string())?;
let fee_payer = super::sponsor::signer()?;
super::registry::add_signer_sponsored(
&signer,
&fee_payer,
token_id,
&tba_addr,
&new_signer_hex,
super::registry::ALPHA_USD_ADDRESS,
)
.await
}
async fn owner_main_tba() -> Result<(k256::ecdsa::SigningKey, String, u64, String), String> {
let (signer, owner_hex) = super::APP
.with(|c| {
c.borrow()
.wallet
.as_ref()
.map(|w| (w.signer.clone(), w.address_hex()))
})
.ok_or_else(|| "no identity".to_string())?;
let main_id = super::registry::main_of(&owner_hex)
.await
.map_err(|e| format!("mainOf: {e}"))?;
if main_id == 0 {
return Err("set a MAIN first".into());
}
let main_name = super::registry::name_of_id(main_id)
.await
.map_err(|e| format!("name: {e}"))?;
let main_tba = super::registry::tba_of_name(&main_name)
.await
.map_err(|e| format!("tba: {e}"))?
.ok_or_else(|| "no MAIN TBA".to_string())?;
Ok((signer, owner_hex, main_id, main_tba))
}
fn consolidate_pressed() {
dom::swap_inner(
"consolidate-msg",
"<span style=\"color:var(--muted)\">consolidating…</span>",
);
wasm_bindgen_futures::spawn_local(async move {
let result = async {
let (signer, owner_hex, main_id, main_tba) = owner_main_tba().await?;
let tokens = super::registry::list_owned_tokens(&owner_hex)
.await
.map_err(|e| format!("list: {e}"))?;
let ids: Vec<u64> = tokens
.iter()
.map(|t| t.token_id)
.filter(|id| *id != main_id)
.collect();
if ids.is_empty() {
return Err("nothing to consolidate — only your MAIN".into());
}
let fee_payer = super::sponsor::signer()?;
super::registry::consolidate_into_main_sponsored(
&signer,
&fee_payer,
&main_tba,
&ids,
super::registry::ALPHA_USD_ADDRESS,
)
.await
}
.await;
match result {
Ok(_) => dom::swap_inner(
"consolidate-msg",
"<span style=\"color:var(--muted)\">✓ subdomains consolidated under your MAIN</span>",
),
Err(e) => {
web_sys::console::warn_1(&JsValue::from_str(&format!("consolidate: {e}")));
dom::swap_inner(
"consolidate-msg",
"<span style=\"color:var(--error)\">consolidate failed</span>",
);
}
}
});
}
fn unlink_device_prompt(device_hex: String) {
let short = short_addr(&device_hex);
dom::swap_inner(
"pair-msg",
&format!(
"<div class=\"unlink-confirm\">\
<div>remove <code>{short}</code>? type <b>yes</b> to confirm.</div>\
<input id=\"unlink-confirm-input\" type=\"text\" autocomplete=\"off\" \
placeholder=\"yes\">\
<div class=\"pair-confirm-actions\">\
<button type=\"button\" class=\"ghost\" data-action=\"unlink-cancel\">cancel</button>\
<button type=\"button\" class=\"button-link\" data-action=\"unlink-confirm\" \
data-arg=\"{device_hex}\">remove</button>\
</div>\
</div>"
),
);
}
fn unlink_cancel_pressed() {
dom::swap_inner("pair-msg", "");
}
fn unlink_confirm_pressed(device_hex: String) {
let typed = dom::input_by_id("unlink-confirm-input")
.map(|i| i.value().trim().to_lowercase())
.unwrap_or_default();
if typed != "yes" {
dom::swap_inner(
"pair-msg",
&dom::msg_span(dom::Msg::Error, "type yes to remove that device"),
);
return;
}
dom::swap_inner("pair-msg", &dom::msg_span(dom::Msg::Accent, "removing…"));
wasm_bindgen_futures::spawn_local(async move {
let result = async {
let (signer, _owner, main_id, main_tba) = owner_main_tba().await?;
let fee_payer = super::sponsor::signer()?;
super::registry::remove_signer_sponsored(
&signer,
&fee_payer,
main_id,
&main_tba,
&device_hex,
super::registry::ALPHA_USD_ADDRESS,
)
.await
}
.await;
match result {
Ok(_) => {
dom::swap_inner("pair-msg", "");
refresh_signer_list().await
}
Err(e) => {
dom::swap_inner(
"pair-msg",
&dom::msg_span(dom::Msg::Error, &format!("unlink failed: {e}")),
);
}
}
});
}
pub(crate) async fn run_release_subdomain(name: &str) -> Result<String, String> {
let token_id = match super::registry::check_name(name).await? {
super::registry::Status::Taken { agent_id } => agent_id,
_ => return Err(format!("'{name}' is not registered")),
};
let owner = super::registry::owner_of_name(name)
.await
.map_err(|e| format!("owner: {e}"))?
.ok_or_else(|| "no on-chain owner".to_string())?;
let diamond = parse_address(super::registry::REGISTRY_ADDRESS)?;
let call = crate::tempo_tx::TempoCall {
to: diamond,
value_wei: 0,
input: super::registry::release_name_calldata(token_id),
};
run_sponsored_tempo_call(&owner, vec![call], 1_000_000, "release subdomain").await
}
pub(crate) async fn run_sponsored_tempo_call(
from_hex: &str,
calls: Vec<crate::tempo_tx::TempoCall>,
gas_limit: u128,
purpose: &str,
) -> Result<String, String> {
sponsor_rate_guard()?;
let sender_address = parse_address(from_hex)?;
let fee_token_addr = parse_address(super::registry::ALPHA_USD_ADDRESS)?;
let nonce = super::registry::next_nonce(from_hex).await
.map_err(|e| format!("nonce: {e}"))?;
let gas_price = super::registry::current_gas_price().await
.map_err(|e| format!("gas price: {e}"))?;
let tx = crate::tempo_tx::TempoTxBuilder::new(super::registry::CHAIN_ID)
.max_priority_fee_per_gas(gas_price)
.max_fee_per_gas(gas_price)
.gas_limit(gas_limit)
.nonce(nonce)
.calls(calls)
.fee_token(fee_token_addr)
.sponsored()
.build();
let sender_hash = tx.sender_hash();
let (claimed_addr, sender_sig) =
super::verify::sign_tempo_tx_via_iframe(&tx, purpose)
.await
.map_err(|e| format!("signer: {e}"))?;
let recovered = crate::wallet::recover_address(&sender_sig, &sender_hash)
.map_err(|e| format!("recover: {e}"))?;
if recovered != sender_address {
return Err(format!(
"sender sig recovered 0x{} but expected {claimed_addr} ({from_hex})",
recovered.iter().map(|b| format!("{b:02x}")).collect::<String>(),
));
}
let fee_payer = super::sponsor::signer()?;
let fp_hash = tx.fee_payer_hash(&sender_address);
let fp_sig = crate::wallet::sign_hash(&fee_payer, &fp_hash);
let raw = tx.serialize_signed(&sender_sig, Some(&fp_sig));
let raw_hex = bytes_to_hex_str(&raw);
super::registry::submit_and_wait_receipt(&raw_hex).await
.map_err(|e| format!("submit: {e}"))
}
fn header_admin_toggle() {
let body = match super::tenant::current() {
super::tenant::Host::Apex => templates::admin_dropdown_apex().into_string(),
super::tenant::Host::Tenant(_) | super::tenant::Host::Other(_) => {
templates::admin_dropdown_tenant().into_string()
}
};
dom::swap_outer("header-admin-panel", &body);
if let Some(card) = super::APP.with(|c| c.borrow().financial_card_html.clone()) {
if dom::by_id("financial-slot").is_some() {
dom::swap_outer("financial-slot", &card);
}
}
wasm_bindgen_futures::spawn_local(async move {
refresh_usage_slot().await;
});
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);
}
}
{
use crate::filesystem::Filesystem;
if let Ok(bytes) = super::shared_opfs().read(".lh_x402_price").await {
if let Some(wei) = String::from_utf8(bytes)
.ok()
.and_then(|s| s.trim().parse::<u128>().ok())
{
if let Some(input) = dom::input_by_id("x402-price-input") {
input.set_value(&(wei / 1_000_000_000_000_000_000u128).to_string());
}
}
}
}
if let Some(allowed) = super::tool_allowlist::load().await {
if let Some(doc) = web_sys::window().and_then(|w| w.document()) {
if let Ok(checkboxes) = doc.query_selector_all(".tool-checkbox") {
for i in 0..checkboxes.length() {
if let Some(el) = checkboxes.get(i) {
let input: web_sys::HtmlInputElement = JsCast::unchecked_into(el);
if let Some(name) = input.get_attribute("data-tool") {
let is_allowed = allowed.iter().any(|t| t.wire_name() == name);
input.set_checked(is_allowed);
}
}
}
}
}
let summary = super::tool_allowlist::summary(&allowed);
dom::swap_inner("tool-allowlist-status", &summary);
} else {
dom::swap_inner("tool-allowlist-status", "all tools enabled");
}
refresh_public_face_status().await;
});
}
}
async fn refresh_public_face_status() {
let name = match super::tenant::current() {
super::tenant::Host::Tenant(n) => n,
_ => return,
};
if dom::by_id("public-face-status").is_none() {
return;
}
let face = match super::registry::id_of_name(&name).await {
Ok(id) if id != 0 => super::registry::public_face_of(id).await.ok().flatten(),
_ => None,
};
let label = match face.as_deref() {
Some("app") => "currently: app",
Some("html") => "currently: html",
_ => "currently: directory (default)",
};
dom::swap_inner("public-face-status", label);
}
fn header_admin_close() {
dom::swap_outer(
"header-admin-panel",
r#"<div id="header-admin-panel" hidden></div>"#,
);
}
fn show_admin_tab(name: &str) {
let Some(dialog) = dom::by_id("admin-dialog") else { return };
let mut cls: Vec<String> = dialog
.class_name()
.split_whitespace()
.filter(|c| !c.starts_with("tab-"))
.map(String::from)
.collect();
cls.push(format!("tab-{name}"));
dialog.set_class_name(&cls.join(" "));
for tab in ["agent", "account", "usage"] {
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 decode_hex_local(s: &str) -> Option<Vec<u8>> {
let t = s.trim().trim_start_matches("0x").trim_start_matches("0X");
if t.len() % 2 != 0 {
return None;
}
(0..t.len())
.step_by(2)
.map(|i| u8::from_str_radix(t.get(i..i + 2)?, 16).ok())
.collect()
}
async fn run_sync_key() {
let msg = "key-sync-msg";
let set_err = |m: &str| dom::swap_inner(msg, &dom::msg_span(dom::Msg::Error, &format!("{m}")));
let name = match super::tenant::current() {
super::tenant::Host::Tenant(n) => n,
_ => {
set_err("only on a subdomain");
return;
}
};
let owner_hex = super::APP.with(|cell| {
use super::VerifyState;
match &cell.borrow().verify_state {
VerifyState::Verified { address } => Some(address.clone()),
_ => None,
}
});
let Some(owner_hex) = owner_hex else {
set_err("verify as owner first");
return;
};
let key = dom::input_by_id("key").map(|i| i.value()).unwrap_or_default();
if key.trim().is_empty() {
set_err("enter your key first");
return;
}
dom::swap_inner(msg, "<span style=\"color:var(--muted)\">sealing…</span>");
let ct_hex = match super::verify::seal_key_via_iframe(&key).await {
Ok(h) => h,
Err(e) => {
set_err(&format!("seal: {e}"));
return;
}
};
let Some(ct) = decode_hex_local(&ct_hex) else {
set_err("bad ciphertext from signer");
return;
};
let id = match gemini_key_slot_id(&name).await {
Ok(id) => id,
Err(e) => {
set_err(&e);
return;
}
};
let registry_addr = match parse_address(super::registry::REGISTRY_ADDRESS) {
Ok(a) => a,
Err(e) => {
set_err(&e);
return;
}
};
let call = crate::tempo_tx::TempoCall {
to: registry_addr,
value_wei: 0,
input: super::registry::encode_set_gemini_key(id, &ct),
};
let words = (ct.len() / 32 + 1) as u128;
let gas = 1_200_000 + words * 40_000;
dom::swap_inner(msg, "<span style=\"color:var(--muted)\">syncing on-chain…</span>");
match run_sponsored_tempo_call(&owner_hex, vec![call], gas, "sync key").await {
Ok(_) => dom::swap_inner(
msg,
&dom::msg_span(dom::Msg::Accent, "synced ✓ — import your seed on another device to restore"),
),
Err(e) => set_err(&format!("sync failed: {e}")),
}
}
async fn run_restore_key() {
let msg = "key-sync-msg";
let set_err = |m: &str| dom::swap_inner(msg, &dom::msg_span(dom::Msg::Error, &format!("{m}")));
let name = match super::tenant::current() {
super::tenant::Host::Tenant(n) => n,
_ => {
set_err("only on a subdomain");
return;
}
};
let id = match gemini_key_slot_id(&name).await {
Ok(id) => id,
Err(e) => {
set_err(&e);
return;
}
};
dom::swap_inner(msg, "<span style=\"color:var(--muted)\">fetching…</span>");
let ct = match super::registry::gemini_key_of(id).await {
Ok(Some(b)) => b,
Ok(None) => {
set_err("no synced key on-chain yet");
return;
}
Err(e) => {
set_err(&format!("read: {e}"));
return;
}
};
let ct_hex = format!(
"0x{}",
ct.iter().map(|b| format!("{b:02x}")).collect::<String>()
);
let plaintext = match super::verify::open_key_via_iframe(&ct_hex).await {
Ok(p) => p,
Err(e) => {
set_err(&format!("open: {e} — import your seed on this device first"));
return;
}
};
if let Some(input) = dom::input_by_id("key") {
input.set_value(&plaintext);
}
super::key_store::save(&plaintext).await;
refresh_keymeta();
dom::swap_inner(
msg,
&dom::msg_span(dom::Msg::Accent, "restored ✓ — applies on next session"),
);
}
async fn gemini_key_slot_id(name: &str) -> Result<u64, String> {
let owner = super::registry::owner_of_name(name)
.await
.map_err(|e| format!("owner: {e}"))?
.ok_or_else(|| "name not registered on-chain".to_string())?;
let main_id = super::registry::main_of(&owner).await.unwrap_or(0);
if main_id != 0 {
return Ok(main_id);
}
match super::registry::id_of_name(name).await {
Ok(id) if id != 0 => Ok(id),
_ => Err("no token id for name".into()),
}
}
async fn auto_sync_gemini_key(name: String, key: String) {
let owner = match super::registry::owner_of_name(&name).await {
Ok(Some(o)) => o,
_ => return,
};
let slot_id = match gemini_key_slot_id(&name).await {
Ok(id) => id,
Err(_) => return,
};
let ct_hex = match super::verify::seal_key_via_iframe(&key).await {
Ok(h) => h,
Err(_) => return,
};
let Some(ct) = decode_hex_local(&ct_hex) else { return };
let Ok(registry_addr) = parse_address(super::registry::REGISTRY_ADDRESS) else { return };
let call = crate::tempo_tx::TempoCall {
to: registry_addr,
value_wei: 0,
input: super::registry::encode_set_gemini_key(slot_id, &ct),
};
let words = (ct.len() / 32 + 1) as u128;
let gas = 1_200_000 + words * 40_000;
let _ = run_sponsored_tempo_call(&owner, vec![call], gas, "auto-sync key").await;
}
pub(crate) async fn sync_local_key_to_main(name: &str) {
if let Some(key) = super::key_store::load().await {
auto_sync_gemini_key(name.to_string(), key).await;
}
}
pub(crate) async fn try_auto_restore_gemini_key(name: &str) -> bool {
if super::key_store::load().await.is_some() {
return true;
}
let slot_id = match gemini_key_slot_id(name).await {
Ok(id) => id,
Err(_) => return false,
};
let ct = match super::registry::gemini_key_of(slot_id).await {
Ok(Some(b)) => b,
_ => return false,
};
let ct_hex = format!(
"0x{}",
ct.iter().map(|b| format!("{b:02x}")).collect::<String>()
);
let plaintext = match super::verify::open_key_via_iframe(&ct_hex).await {
Ok(p) => p,
Err(_) => return false,
};
super::key_store::save(&plaintext).await;
if let Ok(Some(storage)) = dom::session_storage() {
let _ = storage.set_item("gemini_api_key", &plaintext);
}
true
}
async fn run_set_public_face(choice: &str) {
let msg = "publish-app-msg";
let set_err = |m: &str| {
dom::swap_inner(msg, &dom::msg_span(dom::Msg::Error, m));
};
let name = match super::tenant::current() {
super::tenant::Host::Tenant(n) => n,
_ => {
set_err("only on a subdomain");
return;
}
};
let verified_eoa = super::APP.with(|cell| {
use super::VerifyState;
match &cell.borrow().verify_state {
VerifyState::Verified { address } => Some(address.clone()),
_ => None,
}
});
let id = match super::registry::id_of_name(&name).await {
Ok(id) if id != 0 => id,
_ => {
set_err("name isn't registered on-chain");
return;
}
};
let registry_addr = match parse_address(super::registry::REGISTRY_ADDRESS) {
Ok(a) => a,
Err(e) => {
set_err(&e);
return;
}
};
let mk = |input: Vec<u8>| crate::tempo_tx::TempoCall {
to: registry_addr,
value_wei: 0,
input,
};
let (calls, gas): (Vec<crate::tempo_tx::TempoCall>, u128) = match choice {
"directory" => (
vec![mk(super::registry::encode_set_public_face(id, "directory"))],
500_000,
),
"app" => {
let fs = super::shared_opfs();
let src = match fs.read("app.rl").await {
Ok(b) if !b.is_empty() => String::from_utf8_lossy(&b).into_owned(),
_ => {
set_err("no app.rl on this device — build one first (run_cartridge)");
return;
}
};
let wasm = match crate::rustlite::compile(&src) {
Ok(w) => w,
Err(e) => {
set_err(&format!("compile: {e}"));
return;
}
};
if wasm.len() > 16_384 {
set_err("app wasm too large to publish (max 16 KB)");
return;
}
(
vec![
mk(super::registry::encode_set_app_wasm(id, &wasm)),
mk(super::registry::encode_set_public_face(id, "app")),
],
1_200_000 + wasm.len() as u128 * 8_500,
)
}
"html" => {
let fs = super::shared_opfs();
let html = match fs.read("index.html").await {
Ok(b) if !b.is_empty() => b,
_ => {
set_err("no index.html on this device — create one first");
return;
}
};
if html.len() > 24_576 {
set_err("index.html too large to publish (max 24 KB)");
return;
}
(
vec![
mk(super::registry::encode_set_public_html(id, &html)),
mk(super::registry::encode_set_public_face(id, "html")),
],
1_200_000 + html.len() as u128 * 8_500,
)
}
_ => {
set_err("unknown public face");
return;
}
};
dom::swap_inner(msg, "<span style=\"color:var(--muted)\">saving…</span>");
let on_chain_owner = match super::registry::owner_of_name(&name).await {
Ok(Some(o)) => o,
_ => {
set_err("name isn't registered on-chain");
return;
}
};
let local = super::chat::credit_signer().await;
let is_signer = match &local {
Some((_, addr)) => {
let addr_hex = format!(
"0x{}",
addr.iter().map(|b| format!("{b:02x}")).collect::<String>()
);
super::registry::is_authorized_signer(&on_chain_owner, &addr_hex)
.await
.unwrap_or(false)
}
None => false,
};
let result = if is_signer {
let (signer, _) = local.unwrap();
let fee_payer = match super::sponsor::signer() {
Ok(s) => s,
Err(e) => {
set_err(&e);
return;
}
};
let token_id = match super::registry::tba_token_id_of(&on_chain_owner).await {
Ok(t) => t,
Err(e) => {
set_err(&e);
return;
}
};
let targets: Vec<([u8; 20], Vec<u8>)> =
calls.iter().map(|c| (registry_addr, c.input.clone())).collect();
super::registry::tba_execute_batch_sponsored(
&signer,
&fee_payer,
token_id,
&on_chain_owner,
&targets,
super::registry::ALPHA_USD_ADDRESS,
gas + 800_000,
)
.await
} else if let Some(owner_hex) =
verified_eoa.filter(|a| a.eq_ignore_ascii_case(&on_chain_owner))
{
run_sponsored_tempo_call(&owner_hex, calls, gas, "public face").await
} else {
set_err("verify as owner first");
return;
};
match result {
Ok(_tx) => {
let head = if choice == "directory" {
"public face → directory ✓".to_string()
} else {
format!("published ✓ — {name}.localharness.xyz")
};
dom::swap_inner(
msg,
&maud::html! {
span style="color:var(--fg)" {
(head) " "
a href=(format!("https://{name}.localharness.xyz/"))
target="_blank" rel="noopener" style="color:var(--accent)" {
"open →"
}
}
}
.into_string(),
);
refresh_public_face_status().await;
}
Err(e) => set_err(&format!("failed: {e}")),
}
}
fn toggle_layout_class(class: &str) {
let Some(layout) = dom::by_id("layout") else { return };
let current = layout.class_name();
let trimmed = current.trim();
let parts: Vec<&str> = trimmed.split_whitespace().collect();
let new_cls = if parts.contains(&class) {
parts.iter().filter(|c| **c != class).copied().collect::<Vec<_>>().join(" ")
} else if parts.is_empty() {
class.to_string()
} else {
format!("{} {class}", parts.join(" "))
};
layout.set_class_name(&new_cls);
}
fn reset_confirm_pressed() {
let typed = dom::input_by_id("reset-confirm-text")
.map(|i| i.value().trim().to_string())
.unwrap_or_default();
if !typed.eq_ignore_ascii_case("RESET") {
dom::swap_inner(
"reset-confirm-msg",
"<span style=\"color:var(--error)\">type RESET to confirm</span>",
);
return;
}
wasm_bindgen_futures::spawn_local(async move {
let fs = super::shared_opfs();
if let Ok(entries) = fs.read_dir("").await {
for entry in entries {
if entry.name == ".lh_wallet" || entry.name == ".lh_owner" {
continue;
}
let _ = fs.delete(&entry.name).await;
}
}
if let Ok(window) = dom::window() {
let _ = window.location().reload();
}
});
}
fn pricing_save_pressed() {
let Some(input) = dom::input_by_id("pricing-input") else {
return;
};
let raw = input.value().trim().to_string();
let wei = match parse_eth_to_wei(&raw) {
Ok(w) => w,
Err(err) => {
dom::swap_inner(
"pricing-msg",
&dom::msg_span(dom::Msg::Error, &format!("{err}")),
);
return;
}
};
let is_owner = super::APP.with(|cell| {
matches!(cell.borrow().verify_state, super::VerifyState::Verified { .. })
});
if !is_owner {
dom::swap_inner(
"pricing-msg",
"<span style=\"color:var(--error)\">only the verified owner can change pricing</span>",
);
return;
}
dom::swap_inner(
"pricing-msg",
"<span style=\"color:var(--muted)\">saving…</span>",
);
wasm_bindgen_futures::spawn_local(async move {
match super::pricing::save(wei).await {
Ok(()) => {
super::APP
.with(|cell| cell.borrow_mut().pricing_wei = Some(wei));
let html = templates::pricing_card_body(wei, true).into_string();
dom::swap_outer("pricing-body", &html);
}
Err(err) => {
dom::swap_inner(
"pricing-msg",
&dom::msg_span(dom::Msg::Error, &format!("save failed: {err}")),
);
}
}
});
}
fn parse_eth_to_wei(s: &str) -> Result<u128, String> {
if s.is_empty() {
return Ok(0);
}
let (whole_str, frac_str) = match s.split_once('.') {
Some((w, f)) => (w, f),
None => (s, ""),
};
if !whole_str.bytes().all(|b| b.is_ascii_digit()) {
return Err("price must be a positive decimal".into());
}
if !frac_str.bytes().all(|b| b.is_ascii_digit()) {
return Err("price must be a positive decimal".into());
}
if frac_str.len() > 18 {
return Err("price has more precision than wei (18 decimals max)".into());
}
let whole: u128 = whole_str.parse().map_err(|e| format!("whole: {e}"))?;
let mut padded = String::with_capacity(18);
padded.push_str(frac_str);
while padded.len() < 18 {
padded.push('0');
}
let frac: u128 = if padded.is_empty() {
0
} else {
padded.parse().map_err(|e| format!("frac: {e}"))?
};
whole
.checked_mul(1_000_000_000_000_000_000)
.and_then(|w| w.checked_add(frac))
.ok_or_else(|| "price too large".into())
}
fn clear_key_pressed() {
if let Some(input) = dom::input_by_id("key") {
input.set_value("");
}
if let Ok(Some(storage)) = dom::session_storage() {
let _ = storage.remove_item("gemini_api_key");
}
refresh_keymeta();
if let Some(input) = dom::input_by_id("key") {
input.focus().ok();
}
wasm_bindgen_futures::spawn_local(async move {
super::key_store::clear().await;
});
dom::set_status("key cleared (sessionStorage + OPFS)", false);
}