feat(host): unify typed failure results
Represent denied, timeout, unavailable, and error host outcomes with one HostFailure result shape, update the browser adapter, and keep Workout host recovery flowing through app code before UI effects. req: host/002 req: host/005 req: local/003
This commit is contained in:
@@ -46,7 +46,9 @@ req: host/001 req: host/002 req: host/005
|
|||||||
|
|
||||||
## Event flow
|
## 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
|
```text
|
||||||
HostEvent
|
HostEvent
|
||||||
|
|||||||
+28
-12
@@ -3,8 +3,8 @@ use hemx::{Html, IntoEffect};
|
|||||||
use hemx_axum::Form;
|
use hemx_axum::Form;
|
||||||
use hemx_host::{
|
use hemx_host::{
|
||||||
browser_pwa_host_profile, native_shell_host_profile, Capability, CapabilityManifest,
|
browser_pwa_host_profile, native_shell_host_profile, Capability, CapabilityManifest,
|
||||||
CapabilityShape, CapabilityUse, HapticPattern, HostCall, HostCallId, HostEvent,
|
CapabilityShape, CapabilityUse, HapticPattern, HostCall, HostCallId, HostEvent, HostFailure,
|
||||||
SharePayload as HostShareData,
|
HostFailureKind, SharePayload as HostShareData,
|
||||||
};
|
};
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::sync::{Arc, Mutex};
|
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
|
// req: host/002 req: host/005 req: local/003
|
||||||
apply_host_event(
|
apply_host_event(
|
||||||
state,
|
state,
|
||||||
HostEvent::PermissionDenied {
|
HostEvent::Failed(
|
||||||
capability: Capability::Share,
|
HostFailure::new(HostFailureKind::PermissionDenied, "share permission denied")
|
||||||
},
|
.with_capability(Capability::Share),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
effects(state, "Share permission denial returned through app code")
|
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
|
// req: host/002 req: host/005 req: local/003
|
||||||
apply_host_event(
|
apply_host_event(
|
||||||
state,
|
state,
|
||||||
HostEvent::Failed {
|
HostEvent::Failed(
|
||||||
id: Some(HostCallId::new("workout-export")),
|
HostFailure::new(HostFailureKind::Timeout, "share timed out")
|
||||||
message: "share timed out".into(),
|
.with_id(HostCallId::new("workout-export"))
|
||||||
},
|
.with_capability(Capability::Share),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
effects(state, "Host timeout returned through app code")
|
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) {
|
fn apply_host_event(state: &mut WorkoutState, event: HostEvent) {
|
||||||
match event {
|
match event {
|
||||||
HostEvent::ShareCompleted {
|
HostEvent::ShareCompleted {
|
||||||
@@ -1013,14 +1024,19 @@ fn apply_host_event(state: &mut WorkoutState, event: HostEvent) {
|
|||||||
state.host_status =
|
state.host_status =
|
||||||
"Share cancelled; local event log unchanged. Try Share export when ready.".into();
|
"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 =
|
state.host_status =
|
||||||
"Share permission denied. Local export text is still ready; try again or copy it."
|
"Share permission denied. Local export text is still ready; try again or copy it."
|
||||||
.into();
|
.into();
|
||||||
}
|
}
|
||||||
HostEvent::Failed { message, .. } => {
|
HostEvent::Failed(failure) => {
|
||||||
state.host_status = format!(
|
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" => {
|
HostEvent::Acknowledged { id } if id.0 == "workout-set-haptic" => {
|
||||||
|
|||||||
@@ -5,19 +5,19 @@
|
|||||||
return keys.length === 1 ? { kind: keys[0], data: value[keys[0]] || {} } : null;
|
return keys.length === 1 ? { kind: keys[0], data: value[keys[0]] || {} } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function failure(id, message) {
|
function failure(id, capability, kind, message) {
|
||||||
return { Failed: { id: id || null, message: String(message) } };
|
return { Failed: { id: id || null, capability: capability || null, kind, message: String(message) } };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function haptic(data) {
|
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;
|
const pattern = data.pattern === "Warning" || data.pattern === "Error" ? [30, 40, 30] : 20;
|
||||||
navigator.vibrate(pattern);
|
navigator.vibrate(pattern);
|
||||||
return { Acknowledged: { id: data.id } };
|
return { Acknowledged: { id: data.id } };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function share(data) {
|
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 payload = data.payload || {};
|
||||||
const request = {};
|
const request = {};
|
||||||
if (payload.title) request.title = payload.title;
|
if (payload.title) request.title = payload.title;
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
return { ShareCompleted: { id: data.id, completed: true } };
|
return { ShareCompleted: { id: data.id, completed: true } };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error && error.name === "AbortError") return { ShareCompleted: { id: data.id, completed: false } };
|
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) {
|
async function perform(call) {
|
||||||
const request = variant(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 === "Haptic") return haptic(request.data);
|
||||||
if (request.kind === "Share") return share(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 });
|
window.hemxBrowserHost = Object.freeze({ name: "browser-pwa", supports, perform });
|
||||||
|
|||||||
+71
-7
@@ -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<HostCallId>,
|
||||||
|
pub capability: Option<Capability>,
|
||||||
|
pub kind: HostFailureKind,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HostFailure {
|
||||||
|
pub fn new(kind: HostFailureKind, message: impl Into<String>) -> 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.
|
/// Facts and results produced by a host adapter.
|
||||||
///
|
///
|
||||||
/// App code decides what a host event means for the product/domain before any
|
/// App code decides what a host event means for the product/domain before any
|
||||||
@@ -390,17 +431,11 @@ pub enum HostEvent {
|
|||||||
Acknowledged {
|
Acknowledged {
|
||||||
id: HostCallId,
|
id: HostCallId,
|
||||||
},
|
},
|
||||||
Failed {
|
Failed(HostFailure),
|
||||||
id: Option<HostCallId>,
|
|
||||||
message: String,
|
|
||||||
},
|
|
||||||
ShareCompleted {
|
ShareCompleted {
|
||||||
id: HostCallId,
|
id: HostCallId,
|
||||||
completed: bool,
|
completed: bool,
|
||||||
},
|
},
|
||||||
PermissionDenied {
|
|
||||||
capability: Capability,
|
|
||||||
},
|
|
||||||
StreamChunk {
|
StreamChunk {
|
||||||
stream: HostStreamId,
|
stream: HostStreamId,
|
||||||
bytes: Vec<u8>,
|
bytes: Vec<u8>,
|
||||||
@@ -442,6 +477,8 @@ mod tests {
|
|||||||
assert!(
|
assert!(
|
||||||
BROWSER_HOST_JS.contains("return { ShareCompleted: { id: data.id, completed: true } }")
|
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("querySelector"));
|
||||||
assert!(!BROWSER_HOST_JS.contains("innerHTML"));
|
assert!(!BROWSER_HOST_JS.contains("innerHTML"));
|
||||||
assert!(!BROWSER_HOST_JS.contains("classList"));
|
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]
|
#[test]
|
||||||
fn web_pwa_host_result_routes_through_app_code_before_hemx_effect() {
|
fn web_pwa_host_result_routes_through_app_code_before_hemx_effect() {
|
||||||
// req: host/001 req: host/002 req: host/005
|
// req: host/001 req: host/002 req: host/005
|
||||||
|
|||||||
Reference in New Issue
Block a user