From 380730615804fc237fd8e72a0ea19dedec9b365f Mon Sep 17 00:00:00 2001 From: slhx agent Date: Thu, 11 Jun 2026 19:43:50 +0200 Subject: [PATCH] feat(host): wire techdemo host capability flow Route browser/PWA share and native-shell haptic host results through techdemo app code before returning normal hemx effects; serve the tiny browser host adapter as an app asset. req: host/001 req: host/002 req: host/004 req: host/005 req: examples/001 --- Cargo.lock | 1 + examples/techdemo/Cargo.toml | 1 + examples/techdemo/src/main.rs | 181 +++++++++++++++++- examples/techdemo/templates/app_shell.heml | 1 + .../techdemo/templates/control_center.heml | 1 + .../templates/partials/host_panel.heml | 10 + examples/techdemo/tests/e2e.rs | 50 +++++ 7 files changed, 243 insertions(+), 2 deletions(-) create mode 100644 examples/techdemo/templates/partials/host_panel.heml diff --git a/Cargo.lock b/Cargo.lock index 1b6a207..fa5d975 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -573,6 +573,7 @@ dependencies = [ "hemx", "hemx-axum", "hemx-build", + "hemx-host", "hemx-test", "scraper", "thirtyfour", diff --git a/examples/techdemo/Cargo.toml b/examples/techdemo/Cargo.toml index a0dd3f5..5fd3f0f 100644 --- a/examples/techdemo/Cargo.toml +++ b/examples/techdemo/Cargo.toml @@ -13,6 +13,7 @@ futures-util = "0.3" hemplate = { path = "../../../hemplate/hemplate" } hemx = { path = "../../hemx" } hemx-axum = { path = "../../hemx-axum" } +hemx-host = { path = "../../hemx-host" } tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "time"] } [dev-dependencies] diff --git a/examples/techdemo/src/main.rs b/examples/techdemo/src/main.rs index cf84c24..145930a 100644 --- a/examples/techdemo/src/main.rs +++ b/examples/techdemo/src/main.rs @@ -10,9 +10,16 @@ use hemx_axum::{ interactions, runtime_js, runtime_js_path, sse, DispatchRegistry, DispatchRejection, EffectResponse, InteractionRequest, PageRequest, }; +use hemx_host::{ + browser_pwa_host_profile, native_shell_host_profile, Capability, CapabilityManifest, + CapabilityShape, CapabilityUse, HapticPattern, HostCall, HostCallId, HostEvent, SharePayload, + BROWSER_HOST_JS, +}; use hemx_techdemo::ui; use hemx_techdemo::ui::control_center::{self as control, classes}; -use hemx_techdemo::ui::{issue_card as card_control, issue_lane as lane_control}; +use hemx_techdemo::ui::{ + host_panel as host_control, issue_card as card_control, issue_lane as lane_control, +}; use std::collections::{BTreeMap, VecDeque}; use std::convert::Infallible; use std::net::SocketAddr; @@ -25,6 +32,7 @@ const LANES: [(&str, &str, &str); 3] = [ ("product", "Product", "Native UX, zero app JS"), ]; const ISLAND_ORBIT: EventName = EventName::new("hemx:island-orbit"); +const HOST_CALL: EventName = EventName::new("hemx:host-call"); #[derive(Clone)] struct WorkItem { @@ -67,6 +75,7 @@ struct DemoState { activity: VecDeque, spotlight: String, selected_id: Option, + host_status: String, } impl Default for DemoState { @@ -99,6 +108,7 @@ impl Default for DemoState { activity: VecDeque::new(), spotlight: "No selectors. Generated resources address every target.".into(), selected_id: Some(2), + host_status: "Host calls are typed facts until app code accepts a result.".into(), }; state.log("Demo booted from server-rendered HTML"); state.log("Runtime attached one delegated listener per root"); @@ -151,6 +161,7 @@ struct ControlCenter { board: Html, inspector: Html, activity: Html, + host: Html, island_snapshot: String, } @@ -192,6 +203,13 @@ struct ActivityFeed { items: Vec, } +#[derive(Hemplate)] +#[hemplate = "partials"] +struct HostPanel { + status: String, + boundary: &'static str, +} + #[derive(Hemplate)] #[hemplate = "partials"] struct ArchitectureActivity; @@ -230,6 +248,7 @@ async fn main() { .route(runtime_js_path(), get(runtime)) .route("/app.css", get(app_css)) .route("/control_center.css", get(control_center_css)) + .route("/hemx-browser-host.js", get(browser_host_js)) .route("/island.js", get(island_js)) .with_state(state); @@ -258,6 +277,7 @@ async fn architecture(request: PageRequest) -> impl IntoResponse { board: architecture_board(), inspector: architecture_inspector(), activity: architecture_activity(), + host: render_host_panel(&DemoState::default()), island_snapshot: "2|3|21|architecture route ยท same opaque island bridge".to_owned(), }); request @@ -288,6 +308,14 @@ async fn control_center_css() -> impl IntoResponse { ) } +async fn browser_host_js() -> impl IntoResponse { + // req: host/001 req: host/002 req: host/005 + ( + [("content-type", "application/javascript; charset=utf-8")], + BROWSER_HOST_JS, + ) +} + async fn island_js() -> impl IntoResponse { ( [("content-type", "text/javascript; charset=utf-8")], @@ -324,6 +352,72 @@ async fn events(Query(params): Query>) -> impl IntoResp sse(batches) } +enum HostAppCommand { + BrowserShareCompleted, + NativeHapticAcknowledged, +} + +fn browser_manifest() -> CapabilityManifest { + CapabilityManifest::new([CapabilityUse::new( + Capability::Share, + CapabilityShape::Request, + )]) +} + +fn native_manifest() -> CapabilityManifest { + CapabilityManifest::new([CapabilityUse::new( + Capability::Haptics, + CapabilityShape::Fire, + )]) +} + +fn browser_share_call() -> HostCall { + HostCall::Share { + id: HostCallId::new("browser-share-1"), + payload: SharePayload::text("hemx host capability demo"), + } +} + +fn native_haptic_call() -> HostCall { + HostCall::Haptic { + id: HostCallId::new("native-haptic-tap"), + pattern: HapticPattern::Success, + } +} + +fn host_event_to_command(event: HostEvent) -> Option { + match event { + HostEvent::ShareCompleted { + completed: true, .. + } => Some(HostAppCommand::BrowserShareCompleted), + HostEvent::Acknowledged { id } if id.0 == "native-haptic-tap" => { + Some(HostAppCommand::NativeHapticAcknowledged) + } + _ => None, + } +} + +fn apply_host_event(demo: &mut DemoState, event: HostEvent) { + // req: host/002 req: host/005 + match host_event_to_command(event) { + Some(HostAppCommand::BrowserShareCompleted) => { + demo.host_status = + "Browser/PWA HostEvent became an app command before UI effects.".into(); + demo.log("Browser share completed through app host pipeline"); + } + Some(HostAppCommand::NativeHapticAcknowledged) => { + demo.host_status = + "Native-shell HostEvent became an app command before UI effects.".into(); + demo.log("Native haptic acknowledgment accepted by app code"); + } + None => { + demo.host_status = + "HostEvent ignored by app policy; no domain change was appended.".into(); + demo.log("Ignored host event by app policy"); + } + } +} + fn registry(shared: Arc) -> impl DispatchRegistry { interactions(ui::BUILD_FINGERPRINT) .on(control::launch_work, { @@ -419,6 +513,74 @@ fn registry(shared: Arc) -> impl DispatchRegistry { demo_effects(&demo, "Inspector focused") } }) + .on(host_control::request_browser_share, { + let shared = shared.clone(); + move |_| { + // req: host/001 req: host/004 req: examples/001 + let mut demo = shared.demo.lock().unwrap(); + let call = browser_share_call(); + match browser_manifest().validate_call(&browser_pwa_host_profile(), &call) { + Ok(()) => { + demo.host_status = "Browser/PWA share request accepted; waiting for HostEvent from the host adapter.".into(); + demo.log("Requested browser/PWA share through hemx-host"); + (demo_effects(&demo, "Browser host call requested"), HOST_CALL.emit("browser-share-1")) + } + Err(error) => { + demo.host_status = format!("Browser/PWA host check failed: {error}"); + (demo_effects(&demo, "Browser host check failed"), HOST_CALL.emit("browser-share-failed")) + } + } + } + }) + .on(host_control::record_browser_share, { + let shared = shared.clone(); + move |form| { + // req: host/002 req: host/005 req: examples/001 + let completed = form.parse::("completed").unwrap_or(true); + let mut demo = shared.demo.lock().unwrap(); + apply_host_event( + &mut demo, + HostEvent::ShareCompleted { + id: HostCallId::new("browser-share-1"), + completed, + }, + ); + demo_effects(&demo, "Browser host result accepted by app code") + } + }) + .on(host_control::request_native_haptic, { + let shared = shared.clone(); + move |_| { + // req: host/001 req: host/004 req: examples/001 + let mut demo = shared.demo.lock().unwrap(); + let call = native_haptic_call(); + match native_manifest().validate_call(&native_shell_host_profile("ios-android-webview"), &call) { + Ok(()) => { + demo.host_status = "Native-shell haptic request accepted; waiting for host acknowledgment.".into(); + demo.log("Requested native-shell haptic through hemx-host"); + (demo_effects(&demo, "Native host call requested"), HOST_CALL.emit("native-haptic-tap")) + } + Err(error) => { + demo.host_status = format!("Native host check failed: {error}"); + (demo_effects(&demo, "Native host check failed"), HOST_CALL.emit("native-haptic-failed")) + } + } + } + }) + .on(host_control::record_native_haptic_ack, { + let shared = shared.clone(); + move |_| { + // req: host/002 req: host/005 req: examples/001 + let mut demo = shared.demo.lock().unwrap(); + apply_host_event( + &mut demo, + HostEvent::Acknowledged { + id: HostCallId::new("native-haptic-tap"), + }, + ); + demo_effects(&demo, "Native host result accepted by app code") + } + }) .on(control::simulate_push, { let shared = shared.clone(); move |_| { @@ -464,6 +626,7 @@ fn demo_effects(demo: &DemoState, notice: &'static str) -> impl IntoEffect { control::board.put(&board_view(demo)), control::activity.put(&activity_view(demo)), control::inspector.put(&inspector_view(demo)), + control::host_panel.put(&host_panel(demo)), control::notice.text(notice), control::launch_work_form.clear(), ISLAND_ORBIT.emit(island_snapshot(demo)), @@ -510,6 +673,7 @@ fn page_html(demo: &DemoState) -> Html { board: ui::render(&board_view(demo)), inspector: render_inspector(demo), activity: render_activity(demo), + host: render_host_panel(demo), island_snapshot: island_snapshot(demo), }) } @@ -536,7 +700,7 @@ fn hero_view(demo: &DemoState) -> HeroMetrics { .count(); let impact: u64 = demo.work.iter().map(|item| item.impact as u64).sum(); HeroMetrics { - resource_count: 13, + resource_count: 18, active_count: active, shipped_count: shipped, impact_score: impact, @@ -592,6 +756,19 @@ fn render_activity(demo: &DemoState) -> Html { ui::render(&activity_view(demo)) } +fn host_panel(demo: &DemoState) -> HostPanel { + // req: host/001 req: host/002 req: host/005 + HostPanel { + status: demo.host_status.clone(), + boundary: "HostCall โ†’ HostEvent โ†’ app command โ†’ EffectBatch", + } +} + +fn render_host_panel(demo: &DemoState) -> Html { + // req: host/001 req: host/002 req: host/005 + ui::render(&host_panel(demo)) +} + fn inspector_view(demo: &DemoState) -> InspectorPanel { // req: html_safety/002 req: view/001 let selected = demo diff --git a/examples/techdemo/templates/app_shell.heml b/examples/techdemo/templates/app_shell.heml index 416a2a2..3f8d14b 100644 --- a/examples/techdemo/templates/app_shell.heml +++ b/examples/techdemo/templates/app_shell.heml @@ -5,6 +5,7 @@ hemx Techdemo + diff --git a/examples/techdemo/templates/control_center.heml b/examples/techdemo/templates/control_center.heml index c030b37..cd2b8ea 100644 --- a/examples/techdemo/templates/control_center.heml +++ b/examples/techdemo/templates/control_center.heml @@ -32,6 +32,7 @@ +
{+= self.host =+}

Every control posts through a generated handle and receives typed updates.

diff --git a/examples/techdemo/templates/partials/host_panel.heml b/examples/techdemo/templates/partials/host_panel.heml new file mode 100644 index 0000000..11cd24d --- /dev/null +++ b/examples/techdemo/templates/partials/host_panel.heml @@ -0,0 +1,10 @@ +
+

Typed host boundary
{+ self.status +}

+
+ + + + +
+ {+ self.boundary +} +
diff --git a/examples/techdemo/tests/e2e.rs b/examples/techdemo/tests/e2e.rs index a2310be..5fa5ecc 100644 --- a/examples/techdemo/tests/e2e.rs +++ b/examples/techdemo/tests/e2e.rs @@ -1,5 +1,8 @@ use hemx_axum::runtime_js_path; use hemx_techdemo::ui::control_center::{self as control, launch_work, reset_demo, simulate_push}; +use hemx_techdemo::ui::host_panel::{ + record_browser_share, record_native_haptic_ack, request_browser_share, request_native_haptic, +}; use hemx_techdemo::ui::issue_card::{advance_work, delete_work, spotlight_work}; use hemx_techdemo::ui::issue_lane::move_to_lane as move_to_lane_handle; use hemx_techdemo::ui::BUILD_FINGERPRINT; @@ -256,6 +259,53 @@ fn product_is_e2e_working_over_http() { ); assert_emit(&push_batch, &island_event_name("orbit"), "activity rows"); + let browser_host_request = post("/", &handle_form_body(request_browser_share, &[])); + assert_effect_response(&browser_host_request); + let browser_request_batch = browser_host_request.effects(); + assert_payload_contains(&browser_request_batch, "Browser host call requested"); + assert_payload_contains( + &browser_request_batch, + "Browser/PWA share request accepted; waiting for HostEvent", + ); + assert_emit(&browser_request_batch, "hemx:host-call", "browser-share-1"); + + let browser_host_result = post( + "/", + &handle_form_body(record_browser_share, &[("completed", "true")]), + ); + assert_effect_response(&browser_host_result); + let browser_result_batch = browser_host_result.effects(); + assert_payload_contains( + &browser_result_batch, + "Browser/PWA HostEvent became an app command before UI effects.", + ); + assert_payload_contains( + &browser_result_batch, + "Browser share completed through app host pipeline", + ); + + let native_host_request = post("/", &handle_form_body(request_native_haptic, &[])); + assert_effect_response(&native_host_request); + let native_request_batch = native_host_request.effects(); + assert_payload_contains(&native_request_batch, "Native host call requested"); + assert_payload_contains( + &native_request_batch, + "Native-shell haptic request accepted; waiting for host acknowledgment.", + ); + assert_emit(&native_request_batch, "hemx:host-call", "native-haptic-tap"); + + let native_host_result = post("/", &handle_form_body(record_native_haptic_ack, &[])); + assert_effect_response(&native_host_result); + let native_result_batch = native_host_result.effects(); + assert_payload_contains( + &native_result_batch, + "Native-shell HostEvent became an app command before UI effects.", + ); + assert_payload_contains( + &native_result_batch, + "Native haptic acknowledgment accepted by app code", + ); + let delete_missing = post("/", &handle_form_body(delete_work, &[("work_id", "999")])); assert_effect_response(&delete_missing); let delete_missing_batch = delete_missing.effects();