feat(workout): include host result contract in mobile kit

Record share/haptics and the denied/timeout/unavailable/error host result kinds in Android and iOS release metadata, and make mobile verification require them.

req: examples/006

req: host/002
This commit is contained in:
slhx agent
2026-06-12 11:26:34 +02:00
parent 860a4edf61
commit 32fa1a4952
2 changed files with 29 additions and 4 deletions
+5 -1
View File
@@ -55,6 +55,9 @@ The generated manifest records:
- cache policy: release-scoped HTML/CSS/runtime assets only; - cache policy: release-scoped HTML/CSS/runtime assets only;
- offline truth policy: app-owned command/event/projection records, never DOM - offline truth policy: app-owned command/event/projection records, never DOM
patches or UI effect payloads; patches or UI effect payloads;
- host capability policy: Android and iOS shell metadata declare share/haptics and
the same denied, timeout, unavailable, and error result kinds handled by app
code before UI effects;
- environment/secrets boundary: public shell config in the kit, signing secrets - environment/secrets boundary: public shell config in the kit, signing secrets
outside the repo; outside the repo;
- rollback: redeploy the previous server binary and rebuild store artifacts from - rollback: redeploy the previous server binary and rebuild store artifacts from
@@ -76,4 +79,5 @@ reviewed without guessing what is still external. req: examples/006
This is not a `hemx-mobile` framework, sync layer, client database, or native UI This is not a `hemx-mobile` framework, sync layer, client database, or native UI
runtime. The mobile shells load the production Workout web app and route host runtime. The mobile shells load the production Workout web app and route host
capabilities such as share/haptics through the typed host boundary before UI capabilities such as share/haptics through the typed host boundary before UI
effects are produced. req: host/002 req: local/002 effects are produced; denied, timeout, unavailable, and error cases share the
same host result shape. req: host/002 req: local/002
+24 -3
View File
@@ -291,6 +291,10 @@ fn verify_workout_mobile_release(
"rollback", "rollback",
"android/twa-release.json", "android/twa-release.json",
"ios/webview-release.json", "ios/webview-release.json",
"denied",
"timeout",
"unavailable",
"error",
], ],
&mut failures, &mut failures,
); );
@@ -301,6 +305,12 @@ fn verify_workout_mobile_release(
&config.app_name, &config.app_name,
origin_host(&config.origin), origin_host(&config.origin),
"external Android keystore", "external Android keystore",
"share",
"haptics",
"denied",
"timeout",
"unavailable",
"error",
], ],
&mut failures, &mut failures,
); );
@@ -312,6 +322,10 @@ fn verify_workout_mobile_release(
"share", "share",
"haptics", "haptics",
"external Apple team", "external Apple team",
"denied",
"timeout",
"unavailable",
"error",
], ],
&mut failures, &mut failures,
); );
@@ -331,7 +345,7 @@ fn check_file_contains(path: &Path, needles: &[&str], failures: &mut Vec<String>
fn workout_mobile_manifest(config: &WorkoutMobileConfig, blockers: &[String]) -> String { fn workout_mobile_manifest(config: &WorkoutMobileConfig, blockers: &[String]) -> String {
format!( format!(
"{{\n \"app_id\": \"{}\",\n \"name\": \"{}\",\n \"version\": \"{}\",\n \"origin\": \"{}\",\n \"server_binary\": \"target/release/hemx-workout-example\",\n \"runtime_asset_path\": \"served by hemx_axum::runtime_js_path() from the same release\",\n \"cache_policy\": \"cache only release-scoped HTML/CSS/runtime assets; never store DOM patches or UI effects as truth\",\n \"state_policy\": \"app-owned command/event/projection records\",\n \"environment_boundary\": \"public mobile shell config lives here; secrets and signing credentials stay outside the repo\",\n \"rollback\": \"redeploy the previous server binary and matching mobile shell metadata; rebuild store artifacts with the previous version/signing inputs\",\n \"android\": \"android/twa-release.json\",\n \"ios\": \"ios/webview-release.json\",\n \"external_blockers\": [{}]\n}}\n", "{{\n \"app_id\": \"{}\",\n \"name\": \"{}\",\n \"version\": \"{}\",\n \"origin\": \"{}\",\n \"server_binary\": \"target/release/hemx-workout-example\",\n \"runtime_asset_path\": \"served by hemx_axum::runtime_js_path() from the same release\",\n \"cache_policy\": \"cache only release-scoped HTML/CSS/runtime assets; never store DOM patches or UI effects as truth\",\n \"state_policy\": \"app-owned command/event/projection records\",\n \"host_result_kinds\": [\"denied\", \"timeout\", \"unavailable\", \"error\"],\n \"environment_boundary\": \"public mobile shell config lives here; secrets and signing credentials stay outside the repo\",\n \"rollback\": \"redeploy the previous server binary and matching mobile shell metadata; rebuild store artifacts with the previous version/signing inputs\",\n \"android\": \"android/twa-release.json\",\n \"ios\": \"ios/webview-release.json\",\n \"external_blockers\": [{}]\n}}\n",
json_escape(&config.app_id), json_escape(&config.app_id),
json_escape(&config.app_name), json_escape(&config.app_name),
json_escape(&config.version), json_escape(&config.version),
@@ -342,7 +356,7 @@ fn workout_mobile_manifest(config: &WorkoutMobileConfig, blockers: &[String]) ->
fn android_twa_release_json(config: &WorkoutMobileConfig) -> String { fn android_twa_release_json(config: &WorkoutMobileConfig) -> String {
format!( format!(
"{{\n \"package\": \"{}\",\n \"name\": \"{}\",\n \"start_url\": \"{}/\",\n \"host\": \"{}\",\n \"version\": \"{}\",\n \"signing\": \"external Android keystore; never commit credentials\"\n}}\n", "{{\n \"package\": \"{}\",\n \"name\": \"{}\",\n \"start_url\": \"{}/\",\n \"host\": \"{}\",\n \"version\": \"{}\",\n \"host_capabilities\": [\"share\", \"haptics\"],\n \"host_result_kinds\": [\"denied\", \"timeout\", \"unavailable\", \"error\"],\n \"signing\": \"external Android keystore; never commit credentials\"\n}}\n",
json_escape(&config.android_package), json_escape(&config.android_package),
json_escape(&config.app_name), json_escape(&config.app_name),
json_escape(config.origin.trim_end_matches('/')), json_escape(config.origin.trim_end_matches('/')),
@@ -353,7 +367,7 @@ fn android_twa_release_json(config: &WorkoutMobileConfig) -> String {
fn ios_webview_release_json(config: &WorkoutMobileConfig) -> String { fn ios_webview_release_json(config: &WorkoutMobileConfig) -> String {
format!( format!(
"{{\n \"bundle_id\": \"{}\",\n \"name\": \"{}\",\n \"start_url\": \"{}/\",\n \"version\": \"{}\",\n \"host_capabilities\": [\"share\", \"haptics\"],\n \"signing\": \"external Apple team/provisioning profile; never commit credentials\"\n}}\n", "{{\n \"bundle_id\": \"{}\",\n \"name\": \"{}\",\n \"start_url\": \"{}/\",\n \"version\": \"{}\",\n \"host_capabilities\": [\"share\", \"haptics\"],\n \"host_result_kinds\": [\"denied\", \"timeout\", \"unavailable\", \"error\"],\n \"signing\": \"external Apple team/provisioning profile; never commit credentials\"\n}}\n",
json_escape(&config.ios_bundle_id), json_escape(&config.ios_bundle_id),
json_escape(&config.app_name), json_escape(&config.app_name),
json_escape(config.origin.trim_end_matches('/')), json_escape(config.origin.trim_end_matches('/')),
@@ -750,6 +764,9 @@ mod tests {
assert!(manifest.contains("target/release/hemx-workout-example")); assert!(manifest.contains("target/release/hemx-workout-example"));
assert!(manifest.contains("hemx_axum::runtime_js_path()")); assert!(manifest.contains("hemx_axum::runtime_js_path()"));
assert!(manifest.contains("app-owned command/event/projection records")); assert!(manifest.contains("app-owned command/event/projection records"));
assert!(manifest.contains(
"\"host_result_kinds\": [\"denied\", \"timeout\", \"unavailable\", \"error\"]"
));
assert!(manifest.contains("secrets and signing credentials stay outside the repo")); assert!(manifest.contains("secrets and signing credentials stay outside the repo"));
assert!(manifest.contains("Android signing key not configured")); assert!(manifest.contains("Android signing key not configured"));
} }
@@ -763,6 +780,10 @@ mod tests {
assert_eq!(origin_host(&config.origin), "workout.example.com"); assert_eq!(origin_host(&config.origin), "workout.example.com");
assert!(android.contains("\"start_url\": \"https://workout.example.com/app/\"")); assert!(android.contains("\"start_url\": \"https://workout.example.com/app/\""));
assert!(android.contains("\"host\": \"workout.example.com\"")); assert!(android.contains("\"host\": \"workout.example.com\""));
assert!(android.contains("\"host_capabilities\": [\"share\", \"haptics\"]"));
assert!(android.contains(
"\"host_result_kinds\": [\"denied\", \"timeout\", \"unavailable\", \"error\"]"
));
} }
#[test] #[test]