From 2cc6c85e37345eb10b57a11c14149ae03cb87894 Mon Sep 17 00:00:00 2001 From: slhx agent Date: Thu, 11 Jun 2026 19:35:20 +0200 Subject: [PATCH] feat(host): add typed host capability boundary Introduce hemx-host with typed capability manifests, calls, events, host-check failures, reusable browser/PWA and native-shell profiles, and a tiny optional browser adapter for haptics/share that returns host events without DOM mutation. req: host/001 req: host/002 req: host/003 req: host/004 req: host/005 --- AGENTS.md | 1 + Cargo.lock | 8 + Cargo.toml | 2 +- README.md | 4 + REQUIREMENTS.md | 19 + docs/recipes/host-capabilities.md | 67 ++++ hemx-host/Cargo.toml | 17 + hemx-host/runtime/browser-host.js | 49 +++ hemx-host/src/lib.rs | 603 ++++++++++++++++++++++++++++++ 9 files changed, 769 insertions(+), 1 deletion(-) create mode 100644 docs/recipes/host-capabilities.md create mode 100644 hemx-host/Cargo.toml create mode 100644 hemx-host/runtime/browser-host.js create mode 100644 hemx-host/src/lib.rs diff --git a/AGENTS.md b/AGENTS.md index 77f42d1..8e7a505 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,4 +54,5 @@ Keep it stable. Prefer pointers to canonical sources over copied structure, file - Public examples and beginner APIs should use generated resources and `IntoEffect`, not raw ids or runtime opcodes. - Hemlate examples must use real hemplate syntax, not Vue/Handlebars sketches: `{+ expr +}` for escaped text, `{+= expr =+}` only for trusted/rendered HTML, `+attr="expr"` for dynamic attributes, and Rust-shaped `h-if`, `h-for`, `h-match`, `h-case` directives (`h-case="_"` is the default arm). - JS runtime changes must preserve root-scoped lookup and avoid selectors, VDOM, expressions, and per-node listeners. +- Host capability adapters must stay at the `hemx-host` boundary: they may call host APIs and return host events, but they must not mutate DOM or own app/domain state. req: host/002 - Axum apps should serve and load the shared runtime through hemx-axum helpers such as `runtime_js_path()` and `runtime_js()`, not hard-coded `/hemx.js` URLs or app-owned cache-busting strings. diff --git a/Cargo.lock b/Cargo.lock index 1a37cb7..1b6a207 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -518,6 +518,14 @@ dependencies = [ "syn", ] +[[package]] +name = "hemx-host" +version = "0.1.0" +dependencies = [ + "hemx-core", + "serde", +] + [[package]] name = "hemx-js" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 8139a8d..8b49a8c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["hemx", "hemx-core", "hemx-derive", "hemx-js", "hemx-axum", "hemx-build", "hemx-test", "hemx-xtask", "examples/v0", "examples/kanban", "examples/techdemo", "examples/saas"] +members = ["hemx", "hemx-core", "hemx-host", "hemx-derive", "hemx-js", "hemx-axum", "hemx-build", "hemx-test", "hemx-xtask", "examples/v0", "examples/kanban", "examples/techdemo", "examples/saas"] [workspace.package] version = "0.1.0" diff --git a/README.md b/README.md index f4e7e7c..f23f702 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,10 @@ and integrate at explicit boundaries. req: laws/002 req: auth/001 - **PWA/offline/sync:** optional adapters may reuse generated targets/effects, but core hemx must not gain a mandatory client state graph or local app runtime. See `docs/recipes/pwa-offline.md`. req: canonical_authoring/008 +- **Host capabilities:** browser, PWA, WebView, and native-shell capabilities use + `hemx-host` manifests/calls/events. Adapters return host facts to app code; + UI still changes through normal hemx effects. See + `docs/recipes/host-capabilities.md`. req: host/001 req: host/002 req: host/005 ## Escape hatches diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index bec65bb..d56d22e 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -484,6 +484,25 @@ what a valid business email is. --- +## host + +### req: host/001 +001 Host capabilities are declared as typed capability uses with one of four shapes: fire, request, stream, or schedule. This contract covers browser, PWA, WebView, and native-shell hosts without adding a second UI runtime. + +### req: host/002 +002 Host adapters may produce host events or perform explicit host side effects, but they must not mutate DOM, own application/domain state, append domain events, bypass generated hemx effects, or introduce a client app-state framework. + +### req: host/003 +003 Permission-sensitive host capabilities require an explicit user-facing reason in the app-owned manifest before standard host checks may pass. + +### req: host/004 +004 Host checks report concrete failures for undeclared capability use, unsupported host capability shape, and missing permission reasons before a host adapter executes the capability call. + +### req: host/005 +005 Host results return to app code as facts. App/domain code decides whether they become commands, events, persistence, or UI effects; hemx UI updates still happen through normal EffectBatch output. + +--- + ## auth ### req: auth/001 diff --git a/docs/recipes/host-capabilities.md b/docs/recipes/host-capabilities.md new file mode 100644 index 0000000..1678fca --- /dev/null +++ b/docs/recipes/host-capabilities.md @@ -0,0 +1,67 @@ +# Recipe: typed host capabilities + +`hemx-host` is the boundary between a hemx app and a browser, PWA, +WebView, or native shell. It is not a mobile framework and it is not a new UI +runtime. A host adapter can perform explicit host side effects or return facts; +app code still owns domain decisions and returns normal hemx effects. req: host/001 req: host/002 + +## Contract + +Declare the capability shape the app may use: + +```rust +use hemx_host::{Capability, CapabilityManifest, CapabilityShape, CapabilityUse}; + +let manifest = CapabilityManifest::new([ + CapabilityUse::new(Capability::Haptics, CapabilityShape::Fire), + CapabilityUse::new(Capability::Share, CapabilityShape::Request), +]); +``` + +Check the manifest against the concrete host profile before executing calls: + +```rust +use hemx_host::{HostProfile, HostCheckError}; + +let host = HostProfile::new( + "web", + [CapabilityUse::new(Capability::Share, CapabilityShape::Request)], +); + +let result: Result<(), HostCheckError> = manifest.check(&host); +``` + +Permission-sensitive capabilities such as microphone, camera, notifications, +secure storage, file picker, and geolocation need a user-facing reason in the +manifest before standard host checks pass. req: host/003 req: host/004 + +## Browser/PWA adapter + +`hemx-host::BROWSER_HOST_JS` is an optional tiny browser adapter. It exposes +`window.hemxBrowserHost.perform(call)`, accepts the serde JSON shape of +`HostCall`, calls browser APIs such as `navigator.share` or `navigator.vibrate`, +and returns the serde JSON shape of `HostEvent`. It does not query, patch, or +own the DOM; the app consumes the host event and returns ordinary hemx effects. +req: host/001 req: host/002 req: host/005 + +## Event flow + +Host events are facts, not app mutations: + +```text +HostEvent +→ app/domain command +→ domain validation and optional persistence +→ projection/rendering +→ hemx EffectBatch +``` + +Adapters must not mutate DOM, append domain events, or write application state +on behalf of the app. req: host/002 req: host/005 + +## Mobile + +iOS and Android shells are thin host adapters around a WebView. They implement +manifest-backed calls such as haptics, share, microphone streams, secure +storage, notifications, and explicit custom capabilities; hemx still owns UI +effects and the app still owns state. req: host/001 req: host/002 diff --git a/hemx-host/Cargo.toml b/hemx-host/Cargo.toml new file mode 100644 index 0000000..38ed02f --- /dev/null +++ b/hemx-host/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "hemx-host" +version.workspace = true +edition.workspace = true + +[lib] +path = "src/lib.rs" + +[features] +default = ["std"] +std = ["serde/std"] + +[dependencies] +serde = { version = "1", default-features = false, features = ["alloc", "derive"] } + +[dev-dependencies] +hemx-core = { path = "../hemx-core" } diff --git a/hemx-host/runtime/browser-host.js b/hemx-host/runtime/browser-host.js new file mode 100644 index 0000000..d25c5db --- /dev/null +++ b/hemx-host/runtime/browser-host.js @@ -0,0 +1,49 @@ +(() => { + function variant(value) { + if (!value || typeof value !== "object") return null; + const keys = Object.keys(value); + return keys.length === 1 ? { kind: keys[0], data: value[keys[0]] || {} } : null; + } + + function failure(id, message) { + return { Failed: { id: id || null, message: String(message) } }; + } + + async function haptic(data) { + if (!navigator.vibrate) return failure(data.id, "haptics unsupported"); + const pattern = data.pattern === "Warning" || data.pattern === "Error" ? [30, 40, 30] : 20; + navigator.vibrate(pattern); + return { Acknowledged: { id: data.id } }; + } + + async function share(data) { + if (!navigator.share) return failure(data.id, "share unsupported"); + const payload = data.payload || {}; + const request = {}; + if (payload.title) request.title = payload.title; + if (payload.text) request.text = payload.text; + if (payload.url) request.url = payload.url; + try { + await navigator.share(request); + return { ShareCompleted: { id: data.id, completed: true } }; + } catch (error) { + if (error && error.name === "AbortError") return { ShareCompleted: { id: data.id, completed: false } }; + return failure(data.id, error && error.message ? error.message : error); + } + } + + function supports(capability, shape) { + return (capability === "haptics" && shape === "Fire" && !!navigator.vibrate) + || (capability === "share" && shape === "Request" && !!navigator.share); + } + + async function perform(call) { + const request = variant(call); + if (!request) return failure(null, "invalid host call"); + if (request.kind === "Haptic") return haptic(request.data); + if (request.kind === "Share") return share(request.data); + return failure(request.data.id || null, `unsupported host call ${request.kind}`); + } + + window.hemxBrowserHost = Object.freeze({ name: "browser-pwa", supports, perform }); +})(); diff --git a/hemx-host/src/lib.rs b/hemx-host/src/lib.rs new file mode 100644 index 0000000..ff50d26 --- /dev/null +++ b/hemx-host/src/lib.rs @@ -0,0 +1,603 @@ +#![cfg_attr(not(feature = "std"), no_std)] + +//! Typed host capability contract for hemx integrations. +//! +//! `hemx-host` describes the boundary between a hemx app and the browser, +//! PWA, WebView, or native shell that can perform device/host work. It does +//! not render UI, own application state, or define provider policy. Host +//! results are facts for app code to handle before returning normal hemx +//! effects. req: host/001 req: host/002 + +extern crate alloc; + +/// Optional browser/PWA host adapter source. +/// +/// The adapter exposes `window.hemxBrowserHost.perform(call)` for the serde JSON +/// shape of [`HostCall`] and returns the serde JSON shape of [`HostEvent`]. It +/// only uses browser host APIs such as `navigator.share` and `navigator.vibrate`; +/// it does not inspect or mutate the DOM. req: host/001 req: host/002 req: host/005 +pub const BROWSER_HOST_JS: &str = include_str!("../runtime/browser-host.js"); + +use alloc::string::{String, ToString}; +use alloc::vec::Vec; +use serde::{Deserialize, Serialize}; + +/// A stable capability name understood by an app and one or more host adapters. +#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)] +pub enum Capability { + Haptics, + Microphone, + Camera, + Share, + SecureStorage, + Notifications, + Clipboard, + FilePicker, + Geolocation, + Custom(String), +} + +impl Capability { + pub fn custom(name: impl Into) -> Self { + Self::Custom(name.into()) + } + + pub fn as_str(&self) -> &str { + match self { + Self::Haptics => "haptics", + Self::Microphone => "microphone", + Self::Camera => "camera", + Self::Share => "share", + Self::SecureStorage => "secure_storage", + Self::Notifications => "notifications", + Self::Clipboard => "clipboard", + Self::FilePicker => "file_picker", + Self::Geolocation => "geolocation", + Self::Custom(name) => name.as_str(), + } + } + + /// Capabilities that require a user-facing permission reason in the app + /// manifest before standard hosts may expose them. req: host/003 + pub fn needs_permission_reason(&self) -> bool { + matches!( + self, + Self::Microphone + | Self::Camera + | Self::SecureStorage + | Self::Notifications + | Self::FilePicker + | Self::Geolocation + ) + } +} + +/// The only shapes a host capability may take. +/// +/// Keeping the shape set small prevents host plugins from becoming a second app +/// runtime. req: host/001 req: host/002 +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)] +pub enum CapabilityShape { + Fire, + Request, + Stream, + Schedule, +} + +/// One declared capability use in an app manifest or host profile. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct CapabilityUse { + pub capability: Capability, + pub shape: CapabilityShape, + pub reason: Option, +} + +impl CapabilityUse { + pub fn new(capability: Capability, shape: CapabilityShape) -> Self { + Self { + capability, + shape, + reason: None, + } + } + + pub fn with_reason(mut self, reason: impl Into) -> Self { + self.reason = Some(reason.into()); + self + } + + fn matches(&self, capability: &Capability, shape: CapabilityShape) -> bool { + self.capability == *capability && self.shape == shape + } +} + +/// App-owned declaration of host capabilities it may request. +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct CapabilityManifest { + pub uses: Vec, +} + +impl CapabilityManifest { + pub fn new(uses: impl Into>) -> Self { + Self { uses: uses.into() } + } + + pub fn allows(&self, capability: &Capability, shape: CapabilityShape) -> bool { + self.uses.iter().any(|use_| use_.matches(capability, shape)) + } + + pub fn check(&self, host: &HostProfile) -> Result<(), HostCheckError> { + for use_ in &self.uses { + if use_.capability.needs_permission_reason() + && use_ + .reason + .as_ref() + .map(|reason| reason.trim().is_empty()) + .unwrap_or(true) + { + return Err(HostCheckError::MissingPermissionReason { + capability: use_.capability.clone(), + }); + } + + if !host.supports(&use_.capability, use_.shape) { + return Err(HostCheckError::UnsupportedCapability { + capability: use_.capability.clone(), + shape: use_.shape, + host: host.name.clone(), + }); + } + } + + Ok(()) + } + + pub fn validate_call(&self, host: &HostProfile, call: &HostCall) -> Result<(), HostCheckError> { + let capability = call.capability(); + let shape = call.shape(); + + if !self.allows(&capability, shape) { + return Err(HostCheckError::UndeclaredCapability { capability, shape }); + } + + if !host.supports(&capability, shape) { + return Err(HostCheckError::UnsupportedCapability { + capability, + shape, + host: host.name.clone(), + }); + } + + Ok(()) + } +} + +/// Capabilities exposed by one concrete host adapter. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct HostProfile { + pub name: String, + pub supports: Vec, +} + +impl HostProfile { + pub fn new(name: impl Into, supports: impl Into>) -> Self { + Self { + name: name.into(), + supports: supports.into(), + } + } + + pub fn supports(&self, capability: &Capability, shape: CapabilityShape) -> bool { + self.supports + .iter() + .any(|use_| use_.matches(capability, shape)) + } +} + +/// Browser/PWA host profile for the optional `BROWSER_HOST_JS` adapter. +/// +/// Runtime feature availability is still checked by the JavaScript adapter; +/// this profile records the contract shapes the adapter owns. req: host/001 +pub fn browser_pwa_host_profile() -> HostProfile { + HostProfile::new( + "browser-pwa", + [ + CapabilityUse::new(Capability::Haptics, CapabilityShape::Fire), + CapabilityUse::new(Capability::Share, CapabilityShape::Request), + ], + ) +} + +/// Native-shell-shaped profile used by WebView adapters that expose device APIs +/// through the same host call/event contract. req: host/001 req: host/002 +pub fn native_shell_host_profile(name: impl Into) -> HostProfile { + HostProfile::new( + name, + [ + CapabilityUse::new(Capability::Haptics, CapabilityShape::Fire), + CapabilityUse::new(Capability::Share, CapabilityShape::Request), + CapabilityUse::new(Capability::Microphone, CapabilityShape::Stream), + CapabilityUse::new(Capability::Notifications, CapabilityShape::Schedule), + ], + ) +} + +/// A host-check failure that can be reported by build tooling, tests, or a host +/// adapter before executing a capability call. req: host/004 +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub enum HostCheckError { + UndeclaredCapability { + capability: Capability, + shape: CapabilityShape, + }, + UnsupportedCapability { + capability: Capability, + shape: CapabilityShape, + host: String, + }, + MissingPermissionReason { + capability: Capability, + }, +} + +impl core::fmt::Display for HostCheckError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::UndeclaredCapability { capability, shape } => write!( + f, + "host capability `{}` with shape {:?} is used but not declared", + capability.as_str(), + shape + ), + Self::UnsupportedCapability { + capability, + shape, + host, + } => write!( + f, + "host `{host}` does not support capability `{}` with shape {:?}", + capability.as_str(), + shape + ), + Self::MissingPermissionReason { capability } => write!( + f, + "host capability `{}` requires a permission reason", + capability.as_str() + ), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for HostCheckError {} + +#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)] +pub struct HostCallId(pub String); + +impl HostCallId { + pub fn new(value: impl ToString) -> Self { + Self(value.to_string()) + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)] +pub struct HostStreamId(pub String); + +impl HostStreamId { + pub fn new(value: impl ToString) -> Self { + Self(value.to_string()) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)] +pub enum HapticPattern { + Selection, + Success, + Warning, + Error, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct SharePayload { + pub title: Option, + pub text: Option, + pub url: Option, +} + +impl SharePayload { + pub fn text(text: impl Into) -> Self { + Self { + title: None, + text: Some(text.into()), + url: None, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct MicrophoneConfig { + pub mime_type: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct NotificationRequest { + pub title: String, + pub body: Option, +} + +/// Typed calls that app code may ask a host adapter to perform. +/// +/// Adapters execute these calls and return [`HostEvent`] values. They must not +/// mutate DOM or application/domain state themselves. req: host/002 +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub enum HostCall { + Haptic { + id: HostCallId, + pattern: HapticPattern, + }, + Share { + id: HostCallId, + payload: SharePayload, + }, + StartMicrophone { + stream: HostStreamId, + config: MicrophoneConfig, + }, + StopStream { + stream: HostStreamId, + }, + ScheduleNotification { + id: HostCallId, + notification: NotificationRequest, + }, + Custom { + id: HostCallId, + capability: Capability, + shape: CapabilityShape, + op: String, + payload: Vec, + }, +} + +impl HostCall { + pub fn capability(&self) -> Capability { + match self { + Self::Haptic { .. } => Capability::Haptics, + Self::Share { .. } => Capability::Share, + Self::StartMicrophone { .. } | Self::StopStream { .. } => Capability::Microphone, + Self::ScheduleNotification { .. } => Capability::Notifications, + Self::Custom { capability, .. } => capability.clone(), + } + } + + pub fn shape(&self) -> CapabilityShape { + match self { + Self::Haptic { .. } => CapabilityShape::Fire, + Self::Share { .. } => CapabilityShape::Request, + Self::StartMicrophone { .. } | Self::StopStream { .. } => CapabilityShape::Stream, + Self::ScheduleNotification { .. } => CapabilityShape::Schedule, + Self::Custom { shape, .. } => *shape, + } + } +} + +/// Facts and results produced by a host adapter. +/// +/// App code decides what a host event means for the product/domain before any +/// hemx UI effect is returned. req: host/002 req: host/005 +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub enum HostEvent { + Acknowledged { + id: HostCallId, + }, + Failed { + id: Option, + message: String, + }, + ShareCompleted { + id: HostCallId, + completed: bool, + }, + PermissionDenied { + capability: Capability, + }, + StreamChunk { + stream: HostStreamId, + bytes: Vec, + mime_type: Option, + }, + StreamEnded { + stream: HostStreamId, + }, + NotificationFired { + id: HostCallId, + action: Option, + }, + Custom { + id: Option, + capability: Capability, + payload: Vec, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + use hemx_core::{event, Effect, IntoEffect, Payload, Slot}; + + fn web_host() -> HostProfile { + browser_pwa_host_profile() + } + + fn native_shell_host() -> HostProfile { + native_shell_host_profile("ios-android-webview-test") + } + + #[test] + fn browser_host_js_is_a_thin_host_adapter_not_a_dom_runtime() { + // req: host/001 req: host/002 req: host/005 + assert!(BROWSER_HOST_JS.contains("window.hemxBrowserHost")); + assert!(BROWSER_HOST_JS.contains("navigator.vibrate")); + assert!(BROWSER_HOST_JS.contains("navigator.share")); + assert!( + BROWSER_HOST_JS.contains("return { ShareCompleted: { id: data.id, completed: true } }") + ); + assert!(!BROWSER_HOST_JS.contains("querySelector")); + assert!(!BROWSER_HOST_JS.contains("innerHTML")); + assert!(!BROWSER_HOST_JS.contains("classList")); + assert!(!BROWSER_HOST_JS.contains("dispatchEvent")); + assert!(!BROWSER_HOST_JS.contains("localStorage")); + } + + #[test] + fn manifest_checks_declared_permissions_and_host_support() { + // req: host/003 req: host/004 + let manifest = CapabilityManifest::new([CapabilityUse::new( + Capability::Microphone, + CapabilityShape::Stream, + )]); + + assert_eq!( + manifest.check(&web_host()), + Err(HostCheckError::MissingPermissionReason { + capability: Capability::Microphone, + }) + ); + + let manifest = CapabilityManifest::new([CapabilityUse::new( + Capability::Share, + CapabilityShape::Request, + )]); + + assert_eq!(manifest.check(&web_host()), Ok(())); + } + + #[test] + fn host_calls_must_be_declared_and_supported() { + // req: host/001 req: host/004 + let manifest = CapabilityManifest::new([CapabilityUse::new( + Capability::Share, + CapabilityShape::Request, + )]); + let call = HostCall::Share { + id: HostCallId::new("share-1"), + payload: SharePayload::text("log"), + }; + assert_eq!(manifest.validate_call(&web_host(), &call), Ok(())); + + let haptic = HostCall::Haptic { + id: HostCallId::new("tap"), + pattern: HapticPattern::Success, + }; + assert_eq!( + manifest.validate_call(&web_host(), &haptic), + Err(HostCheckError::UndeclaredCapability { + capability: Capability::Haptics, + shape: CapabilityShape::Fire, + }) + ); + } + + enum AppCommand { + MarkShared, + MarkHapticAck, + } + + fn handle_host_event(event: HostEvent) -> Option { + match event { + HostEvent::ShareCompleted { + completed: true, .. + } => Some(AppCommand::MarkShared), + HostEvent::Acknowledged { id } if id.0 == "tap" => Some(AppCommand::MarkHapticAck), + _ => None, + } + } + + fn apply_app_command(command: AppCommand) -> Effect { + match command { + AppCommand::MarkShared => Slot::<()>::new(7).text("export shared"), + AppCommand::MarkHapticAck => Slot::<()>::new(8).text("set complete"), + } + } + + #[test] + fn web_pwa_host_result_routes_through_app_code_before_hemx_effect() { + // req: host/001 req: host/002 req: host/005 + let manifest = CapabilityManifest::new([CapabilityUse::new( + Capability::Share, + CapabilityShape::Request, + )]); + let call = HostCall::Share { + id: HostCallId::new("share-1"), + payload: SharePayload::text("log"), + }; + manifest + .validate_call(&web_host(), &call) + .expect("web/PWA host supports declared share request"); + + let host_event = HostEvent::ShareCompleted { + id: HostCallId::new("share-1"), + completed: true, + }; + + let command = handle_host_event(host_event).expect("host event becomes app command"); + let batch = apply_app_command(command).into_batch(hemx_core::BuildFingerprint(11)); + + assert_eq!(batch.ops.len(), 1); + assert!(matches!( + &batch.ops[0], + Effect::Put { + payload: Payload::Text(text), + .. + } if text == "export shared" + )); + } + + #[test] + fn native_shell_boundary_uses_same_manifest_call_event_path() { + // req: host/001 req: host/002 req: host/005 + let manifest = CapabilityManifest::new([ + CapabilityUse::new(Capability::Haptics, CapabilityShape::Fire), + CapabilityUse::new(Capability::Microphone, CapabilityShape::Stream) + .with_reason("Record dictated workout commands"), + ]); + manifest + .check(&native_shell_host()) + .expect("native shell profile supports declared capabilities"); + + let call = HostCall::Haptic { + id: HostCallId::new("tap"), + pattern: HapticPattern::Success, + }; + manifest + .validate_call(&native_shell_host(), &call) + .expect("native shell supports declared haptic fire call"); + + let command = handle_host_event(HostEvent::Acknowledged { + id: HostCallId::new("tap"), + }) + .expect("host ack becomes app command"); + let batch = apply_app_command(command).into_batch(hemx_core::BuildFingerprint(13)); + + assert!(matches!( + &batch.ops[0], + Effect::Put { + payload: Payload::Text(text), + .. + } if text == "set complete" + )); + } + + #[test] + fn host_events_can_emit_to_existing_app_handlers_without_owning_state() { + // req: host/002 req: host/005 + let effect = event("host:share-completed", "share-1"); + let batch = effect.into_batch(hemx_core::BuildFingerprint(12)); + + assert!(matches!( + &batch.ops[0], + Effect::Emit { name, payload } + if name == "host:share-completed" && payload == "share-1" + )); + } +}