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
This commit is contained in:
slhx agent
2026-06-11 19:35:20 +02:00
parent 736f9f3c7e
commit 2cc6c85e37
9 changed files with 769 additions and 1 deletions
+603
View File
@@ -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<String>) -> 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<String>,
}
impl CapabilityUse {
pub fn new(capability: Capability, shape: CapabilityShape) -> Self {
Self {
capability,
shape,
reason: None,
}
}
pub fn with_reason(mut self, reason: impl Into<String>) -> 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<CapabilityUse>,
}
impl CapabilityManifest {
pub fn new(uses: impl Into<Vec<CapabilityUse>>) -> 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<CapabilityUse>,
}
impl HostProfile {
pub fn new(name: impl Into<String>, supports: impl Into<Vec<CapabilityUse>>) -> 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<String>) -> 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<String>,
pub text: Option<String>,
pub url: Option<String>,
}
impl SharePayload {
pub fn text(text: impl Into<String>) -> Self {
Self {
title: None,
text: Some(text.into()),
url: None,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct MicrophoneConfig {
pub mime_type: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct NotificationRequest {
pub title: String,
pub body: Option<String>,
}
/// 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<u8>,
},
}
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<HostCallId>,
message: String,
},
ShareCompleted {
id: HostCallId,
completed: bool,
},
PermissionDenied {
capability: Capability,
},
StreamChunk {
stream: HostStreamId,
bytes: Vec<u8>,
mime_type: Option<String>,
},
StreamEnded {
stream: HostStreamId,
},
NotificationFired {
id: HostCallId,
action: Option<String>,
},
Custom {
id: Option<HostCallId>,
capability: Capability,
payload: Vec<u8>,
},
}
#[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<AppCommand> {
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"
));
}
}