feat(examples): add native-shell workout host path
Extend the workout exemplar with a native-shell haptic request and acknowledgment path that validates the host manifest and returns through app code without owning workout state. req: host/001 req: host/002 req: host/004 req: host/005 req: examples/001
This commit is contained in:
@@ -49,6 +49,7 @@ Keep it stable. Prefer pointers to canonical sources over copied structure, file
|
|||||||
- Prefer links or pointers to canonical sources over copied lists.
|
- Prefer links or pointers to canonical sources over copied lists.
|
||||||
- Avoid project trees, architecture maps, generated inventories, current file sizes, issue lists, TODO inventories, and other snapshots that will rot.
|
- Avoid project trees, architecture maps, generated inventories, current file sizes, issue lists, TODO inventories, and other snapshots that will rot.
|
||||||
- Stable commands: `cargo run -p hemx-xtask -- test`, `cargo check --workspace`, `redgate health --strict`. Use the xtask runner for full verification so jobs are capped from local CPU and memory. req: test/004
|
- Stable commands: `cargo run -p hemx-xtask -- test`, `cargo check --workspace`, `redgate health --strict`. Use the xtask runner for full verification so jobs are capped from local CPU and memory. req: test/004
|
||||||
|
- Run the workout product exemplar with `cargo run --bin hemx-workout-example` and open `http://127.0.0.1:3028`; set `HEMX_WORKOUT_ADDR=127.0.0.1:3030` if the default port is busy. req: examples/001
|
||||||
- hemx core stays small: effects, typed ids, registries, and wire schema only.
|
- hemx core stays small: effects, typed ids, registries, and wire schema only.
|
||||||
- Routing, auth, sessions, transport, transitions, sync, and storage belong in integration/user crates.
|
- Routing, auth, sessions, transport, transitions, sync, and storage belong in integration/user crates.
|
||||||
- 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.
|
||||||
|
|||||||
+1
-1
@@ -795,7 +795,7 @@ what a valid business email is.
|
|||||||
## examples
|
## examples
|
||||||
|
|
||||||
### req: examples/001
|
### req: examples/001
|
||||||
001 The repository must contain canonical examples that act as API tests. v0 examples are counter, todo CRUD, form wizard, docs-site page swap, auth action, SSE notifications, and keyed todo list; the workout example is the phone-first local-first product exemplar for commands/events/projections plus host export; local-first kanban is a north-star milestone example. The full techdemo may include an opaque leaf-widget island that communicates through `Effect::event`, without moving island mechanics into hemx core.
|
001 The repository must contain canonical examples that act as API tests. v0 examples are counter, todo CRUD, form wizard, docs-site page swap, auth action, SSE notifications, and keyed todo list; the workout example is the phone-first local-first product exemplar for commands/events/projections plus host export and must be runnable with the documented cargo command; local-first kanban is a north-star milestone example. The full techdemo may include an opaque leaf-widget island that communicates through `Effect::event`, without moving island mechanics into hemx core.
|
||||||
|
|
||||||
### req: examples/002
|
### req: examples/002
|
||||||
002 Each example must have a maximum ceremony budget. The counter example must fit in under 50 lines of user-authored Rust plus one template. Todo CRUD must fit in under 150 lines excluding model definitions.
|
002 Each example must have a maximum ceremony budget. The counter example must fit in under 50 lines of user-authored Rust plus one template. Todo CRUD must fit in under 150 lines excluding model definitions.
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
use hemplate::Hemplate;
|
use hemplate::Hemplate;
|
||||||
use hemx::{Html, IntoEffect};
|
use hemx::{Html, IntoEffect};
|
||||||
use hemx_host::{
|
use hemx_host::{
|
||||||
browser_pwa_host_profile, Capability, CapabilityManifest, CapabilityShape, CapabilityUse,
|
browser_pwa_host_profile, native_shell_host_profile, Capability, CapabilityManifest,
|
||||||
HostCall, HostCallId, HostEvent, SharePayload,
|
CapabilityShape, CapabilityUse, HapticPattern, HostCall, HostCallId, HostEvent, SharePayload,
|
||||||
};
|
};
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
@@ -335,7 +335,44 @@ pub fn record_share_result(app: &AppState) -> impl IntoEffect {
|
|||||||
completed: true,
|
completed: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
effects(state, "Host result accepted by app code")
|
effects(state, "Browser/PWA host result accepted by app code")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn request_native_haptic(app: &AppState) -> impl IntoEffect {
|
||||||
|
app.update(|state| {
|
||||||
|
// req: host/001 req: host/004
|
||||||
|
let manifest = CapabilityManifest::new([CapabilityUse::new(
|
||||||
|
Capability::Haptics,
|
||||||
|
CapabilityShape::Fire,
|
||||||
|
)]);
|
||||||
|
let call = HostCall::Haptic {
|
||||||
|
id: HostCallId::new("workout-set-haptic"),
|
||||||
|
pattern: HapticPattern::Success,
|
||||||
|
};
|
||||||
|
state.host_status = match manifest.validate_call(
|
||||||
|
&native_shell_host_profile("ios-android-webview-workout"),
|
||||||
|
&call,
|
||||||
|
) {
|
||||||
|
Ok(()) => {
|
||||||
|
"Native-shell haptic request accepted; waiting for host acknowledgment.".into()
|
||||||
|
}
|
||||||
|
Err(error) => format!("Native haptic unavailable: {error}"),
|
||||||
|
};
|
||||||
|
effects(state, "Native-shell host call checked against manifest")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_native_haptic_ack(app: &AppState) -> impl IntoEffect {
|
||||||
|
app.update(|state| {
|
||||||
|
// req: host/002 req: host/005
|
||||||
|
apply_host_event(
|
||||||
|
state,
|
||||||
|
HostEvent::Acknowledged {
|
||||||
|
id: HostCallId::new("workout-set-haptic"),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
effects(state, "Native-shell host result accepted by app code")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,6 +390,10 @@ fn apply_host_event(state: &mut WorkoutState, event: HostEvent) {
|
|||||||
} => {
|
} => {
|
||||||
state.host_status = "Host share cancelled; local event log unchanged.".into();
|
state.host_status = "Host share cancelled; local event log unchanged.".into();
|
||||||
}
|
}
|
||||||
|
HostEvent::Acknowledged { id } if id.0 == "workout-set-haptic" => {
|
||||||
|
state.host_status =
|
||||||
|
"Native-shell haptic acknowledged; workout state stayed app-owned.".into();
|
||||||
|
}
|
||||||
_ => {
|
_ => {
|
||||||
state.host_status = "Host event ignored by app policy.".into();
|
state.host_status = "Host event ignored by app policy.".into();
|
||||||
}
|
}
|
||||||
@@ -416,7 +457,7 @@ mod tests {
|
|||||||
assert!(app.with_workout(|state| state.projection.exported));
|
assert!(app.with_workout(|state| state.projection.exported));
|
||||||
assert!(contains_payload_text(
|
assert!(contains_payload_text(
|
||||||
&shared,
|
&shared,
|
||||||
"Host result accepted by app code"
|
"Browser/PWA host result accepted by app code"
|
||||||
));
|
));
|
||||||
assert!(contains_payload_text(
|
assert!(contains_payload_text(
|
||||||
&shared,
|
&shared,
|
||||||
@@ -424,13 +465,40 @@ mod tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn native_shell_haptic_result_returns_through_app_code() {
|
||||||
|
// req: host/001 req: host/002 req: host/005
|
||||||
|
let app = AppState::demo();
|
||||||
|
|
||||||
|
let request = run(|()| request_native_haptic(&app), ());
|
||||||
|
assert!(contains_payload_text(
|
||||||
|
&request,
|
||||||
|
"Native-shell host call checked against manifest"
|
||||||
|
));
|
||||||
|
assert!(contains_payload_text(
|
||||||
|
&request,
|
||||||
|
"waiting for host acknowledgment"
|
||||||
|
));
|
||||||
|
|
||||||
|
let ack = run(|()| record_native_haptic_ack(&app), ());
|
||||||
|
assert!(contains_payload_text(
|
||||||
|
&ack,
|
||||||
|
"Native-shell host result accepted by app code"
|
||||||
|
));
|
||||||
|
assert!(contains_payload_text(
|
||||||
|
&ack,
|
||||||
|
"workout state stayed app-owned"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rendered_page_has_phone_first_controls_without_user_js() {
|
fn rendered_page_has_phone_first_controls_without_user_js() {
|
||||||
// req: examples/001 req: examples/005
|
// req: examples/001 req: examples/005
|
||||||
let html = render(&WorkoutState::demo()).to_string();
|
let html = render(&WorkoutState::demo()).to_string();
|
||||||
assert!(html.contains("Now-first Workout Copilot"));
|
assert!(html.contains("Now-first Workout Copilot"));
|
||||||
assert!(html.contains("Complete set"));
|
assert!(html.contains("Complete set"));
|
||||||
assert!(html.contains("Export via host share"));
|
assert!(html.contains("Export via browser/PWA share"));
|
||||||
|
assert!(html.contains("Request native haptic"));
|
||||||
assert!(!html.contains("<script"));
|
assert!(!html.contains("<script"));
|
||||||
assert!(!html.contains("querySelector"));
|
assert!(!html.contains("querySelector"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,8 +70,16 @@ fn registry(state: AppState) -> HandlerRegistry {
|
|||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
move |_| workout_app::export_log(&state)
|
move |_| workout_app::export_log(&state)
|
||||||
})
|
})
|
||||||
.on(workout::record_share_result, move |_| {
|
.on(workout::record_share_result, {
|
||||||
workout_app::record_share_result(&state)
|
let state = state.clone();
|
||||||
|
move |_| workout_app::record_share_result(&state)
|
||||||
|
})
|
||||||
|
.on(workout::request_native_haptic, {
|
||||||
|
let state = state.clone();
|
||||||
|
move |_| workout_app::request_native_haptic(&state)
|
||||||
|
})
|
||||||
|
.on(workout::record_native_haptic_ack, move |_| {
|
||||||
|
workout_app::record_native_haptic_ack(&state)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,9 @@
|
|||||||
<section class="host">
|
<section class="host">
|
||||||
<h2>Export host boundary</h2>
|
<h2>Export host boundary</h2>
|
||||||
<p data-hemx-slot="host_status">{+ self.host_status +}</p>
|
<p data-hemx-slot="host_status">{+ self.host_status +}</p>
|
||||||
<button type="button" data-hemx-handle="export_log">Export via host share</button>
|
<button type="button" data-hemx-handle="export_log">Export via browser/PWA share</button>
|
||||||
<button type="button" data-hemx-handle="record_share_result">Record share completed</button>
|
<button type="button" data-hemx-handle="record_share_result">Record share completed</button>
|
||||||
|
<button type="button" data-hemx-handle="request_native_haptic">Request native haptic</button>
|
||||||
|
<button type="button" data-hemx-handle="record_native_haptic_ack">Record haptic acknowledged</button>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
Reference in New Issue
Block a user