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:
@@ -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.
|
- 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).
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
Generated
+8
@@ -518,6 +518,14 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hemx-host"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"hemx-core",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hemx-js"
|
name = "hemx-js"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
resolver = "2"
|
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]
|
[workspace.package]
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@@ -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,
|
- **PWA/offline/sync:** optional adapters may reuse generated targets/effects,
|
||||||
but core hemx must not gain a mandatory client state graph or local app
|
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
|
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
|
## Escape hatches
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
## auth
|
||||||
|
|
||||||
### req: auth/001
|
### req: auth/001
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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" }
|
||||||
@@ -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 });
|
||||||
|
})();
|
||||||
@@ -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"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user