diff --git a/docs/recipes/host-capabilities.md b/docs/recipes/host-capabilities.md index 1678fca..490dd42 100644 --- a/docs/recipes/host-capabilities.md +++ b/docs/recipes/host-capabilities.md @@ -46,7 +46,9 @@ req: host/001 req: host/002 req: host/005 ## Event flow -Host events are facts, not app mutations: +Host events are facts, not app mutations. Denied, timeout, unavailable, and +error cases all use `HostEvent::Failed(HostFailure { kind, ... })`, so app code +handles one typed result shape before producing UI effects: ```text HostEvent diff --git a/examples/workout/src/lib.rs b/examples/workout/src/lib.rs index 0d70000..11c06af 100644 --- a/examples/workout/src/lib.rs +++ b/examples/workout/src/lib.rs @@ -3,8 +3,8 @@ use hemx::{Html, IntoEffect}; use hemx_axum::Form; use hemx_host::{ browser_pwa_host_profile, native_shell_host_profile, Capability, CapabilityManifest, - CapabilityShape, CapabilityUse, HapticPattern, HostCall, HostCallId, HostEvent, - SharePayload as HostShareData, + CapabilityShape, CapabilityUse, HapticPattern, HostCall, HostCallId, HostEvent, HostFailure, + HostFailureKind, SharePayload as HostShareData, }; use std::collections::VecDeque; use std::sync::{Arc, Mutex}; @@ -942,9 +942,10 @@ pub fn record_share_denied(app: AppState) -> impl IntoEffect { // req: host/002 req: host/005 req: local/003 apply_host_event( state, - HostEvent::PermissionDenied { - capability: Capability::Share, - }, + HostEvent::Failed( + HostFailure::new(HostFailureKind::PermissionDenied, "share permission denied") + .with_capability(Capability::Share), + ), ); effects(state, "Share permission denial returned through app code") }) @@ -955,10 +956,11 @@ pub fn record_host_timeout(app: AppState) -> impl IntoEffect { // req: host/002 req: host/005 req: local/003 apply_host_event( state, - HostEvent::Failed { - id: Some(HostCallId::new("workout-export")), - message: "share timed out".into(), - }, + HostEvent::Failed( + HostFailure::new(HostFailureKind::Timeout, "share timed out") + .with_id(HostCallId::new("workout-export")) + .with_capability(Capability::Share), + ), ); effects(state, "Host timeout returned through app code") }) @@ -999,6 +1001,15 @@ pub fn record_native_haptic_ack(app: AppState) -> impl IntoEffect { }) } +fn host_failure_label(kind: HostFailureKind) -> &'static str { + match kind { + HostFailureKind::PermissionDenied => "permission denied", + HostFailureKind::Timeout => "timeout", + HostFailureKind::Unavailable => "unavailable", + HostFailureKind::Error => "error", + } +} + fn apply_host_event(state: &mut WorkoutState, event: HostEvent) { match event { HostEvent::ShareCompleted { @@ -1013,14 +1024,19 @@ fn apply_host_event(state: &mut WorkoutState, event: HostEvent) { state.host_status = "Share cancelled; local event log unchanged. Try Share export when ready.".into(); } - HostEvent::PermissionDenied { capability } if capability == Capability::Share => { + HostEvent::Failed(failure) + if failure.kind == HostFailureKind::PermissionDenied + && failure.capability == Some(Capability::Share) => + { state.host_status = "Share permission denied. Local export text is still ready; try again or copy it." .into(); } - HostEvent::Failed { message, .. } => { + HostEvent::Failed(failure) => { state.host_status = format!( - "Host unavailable: {message}. Keep the local export below and try Share export again." + "Host {}: {}. Keep the local export below and try Share export again.", + host_failure_label(failure.kind), + failure.message ); } HostEvent::Acknowledged { id } if id.0 == "workout-set-haptic" => { diff --git a/hemx-host/runtime/browser-host.js b/hemx-host/runtime/browser-host.js index d25c5db..ffd468b 100644 --- a/hemx-host/runtime/browser-host.js +++ b/hemx-host/runtime/browser-host.js @@ -5,19 +5,19 @@ return keys.length === 1 ? { kind: keys[0], data: value[keys[0]] || {} } : null; } - function failure(id, message) { - return { Failed: { id: id || null, message: String(message) } }; + function failure(id, capability, kind, message) { + return { Failed: { id: id || null, capability: capability || null, kind, message: String(message) } }; } async function haptic(data) { - if (!navigator.vibrate) return failure(data.id, "haptics unsupported"); + if (!navigator.vibrate) return failure(data.id, "Haptics", "Unavailable", "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"); + if (!navigator.share) return failure(data.id, "Share", "Unavailable", "share unsupported"); const payload = data.payload || {}; const request = {}; if (payload.title) request.title = payload.title; @@ -28,7 +28,7 @@ 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); + return failure(data.id, "Share", "Error", error && error.message ? error.message : error); } } @@ -39,10 +39,10 @@ async function perform(call) { const request = variant(call); - if (!request) return failure(null, "invalid host call"); + if (!request) return failure(null, null, "Error", "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}`); + return failure(request.data.id || null, null, "Unavailable", `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 index ff50d26..f1cf0fc 100644 --- a/hemx-host/src/lib.rs +++ b/hemx-host/src/lib.rs @@ -381,6 +381,47 @@ impl HostCall { } } +/// Boring, typed host failure classes that app code can handle before it emits UI. +/// req: host/002 req: host/005 +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub enum HostFailureKind { + PermissionDenied, + Timeout, + Unavailable, + Error, +} + +/// One host failure result shape for denied, timeout, unavailable, and error cases. +/// req: host/002 req: host/005 +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct HostFailure { + pub id: Option, + pub capability: Option, + pub kind: HostFailureKind, + pub message: String, +} + +impl HostFailure { + pub fn new(kind: HostFailureKind, message: impl Into) -> Self { + Self { + id: None, + capability: None, + kind, + message: message.into(), + } + } + + pub fn with_id(mut self, id: HostCallId) -> Self { + self.id = Some(id); + self + } + + pub fn with_capability(mut self, capability: Capability) -> Self { + self.capability = Some(capability); + self + } +} + /// Facts and results produced by a host adapter. /// /// App code decides what a host event means for the product/domain before any @@ -390,17 +431,11 @@ pub enum HostEvent { Acknowledged { id: HostCallId, }, - Failed { - id: Option, - message: String, - }, + Failed(HostFailure), ShareCompleted { id: HostCallId, completed: bool, }, - PermissionDenied { - capability: Capability, - }, StreamChunk { stream: HostStreamId, bytes: Vec, @@ -442,6 +477,8 @@ mod tests { assert!( BROWSER_HOST_JS.contains("return { ShareCompleted: { id: data.id, completed: true } }") ); + assert!(BROWSER_HOST_JS.contains("kind, message")); + assert!(BROWSER_HOST_JS.contains("\"Unavailable\"")); assert!(!BROWSER_HOST_JS.contains("querySelector")); assert!(!BROWSER_HOST_JS.contains("innerHTML")); assert!(!BROWSER_HOST_JS.contains("classList")); @@ -520,6 +557,33 @@ mod tests { } } + #[test] + fn host_failures_use_one_typed_result_shape() { + // req: host/002 req: host/005 + let denied = HostEvent::Failed( + HostFailure::new(HostFailureKind::PermissionDenied, "share denied") + .with_id(HostCallId::new("share-1")) + .with_capability(Capability::Share), + ); + let timeout = HostEvent::Failed( + HostFailure::new(HostFailureKind::Timeout, "share timed out") + .with_id(HostCallId::new("share-1")) + .with_capability(Capability::Share), + ); + let unavailable = HostEvent::Failed( + HostFailure::new(HostFailureKind::Unavailable, "share unsupported") + .with_capability(Capability::Share), + ); + let error = HostEvent::Failed(HostFailure::new(HostFailureKind::Error, "host crashed")); + + for event in [denied, timeout, unavailable, error] { + match event { + HostEvent::Failed(failure) => assert!(!failure.message.is_empty()), + other => panic!("unexpected host event shape: {other:?}"), + } + } + } + #[test] fn web_pwa_host_result_routes_through_app_code_before_hemx_effect() { // req: host/001 req: host/002 req: host/005