cat / goq
clone

turns a linux box into a private steam streaming appliance: plug in, tap your key, play from another computer

Merge pull request #114 from FelineStateMachine/feat/gate-non-macos-pointer-capture

FelineStateMachine committed fe400dd
feat(portal): gate non-macOS pointer capture
commit
fe400ddb2d4153c31c3aa49df7ed2291602ff81b
parent
f53de5e47c6a2cadc2e3f951d89281f1ef61adef
parent
ba7108a6e6dca6b575c168ec75d74abacc50ba39
author
FelineStateMachine <dami.etoile@gmail.com>
14 files changed, +356 -43
MAGENTS.md8+
MREADME.md6+
Adocs/portal-platform-support.md65++++++++++
Mdocs/portal-release.md7+
Mportal/control-runtime.test.mjs23++++
Mscripts/package-macos-client.sh6+-
Mscripts/tests/test_portal_release.py16++-
Mscripts/verify-demo-build.sh3+
Mscripts/verify-portal-release.py13+-
Msrc-tauri/Cargo.toml3+
Msrc-tauri/src/commands/state.rs20+++-
Msrc-tauri/src/main.rs1+
Msrc-tauri/src/media/input_delivery.rs191+++++++++++++++++++++++++-----
Asrc-tauri/src/platform_capabilities.rs37++++++
M AGENTS.md +8 -0
M README.md +6 -0
A docs/portal-platform-support.md +65 -0
@@ -0,0 +1,65 @@
1+# Portal platform and relative-pointer support2+3+Portal's first supported download is macOS arm64. Its relative-pointer path4+uses CoreGraphics cursor disassociation and has live Mac-to-Gamescope hardware5+evidence. Linux, Windows, and other Portal builds are not release targets yet.6+7+## Build policy8+9+| Client build | Relative pointer | Other negotiated input |10+| --- | --- | --- |11+| macOS | Enabled through the native CoreGraphics path | Absolute pointer, keyboard/text, and gamepad remain available when granted |12+| Non-macOS, default features | Not offered; any unexpected relative-pointer or pointer-feedback response is masked locally | Absolute pointer, keyboard/text, gamepad, and input acknowledgements remain available when granted |13+| Non-macOS with `experimental-non-macos-pointer-capture` | Enabled through Tao cursor grab plus browser Pointer Lock | Same independently granted input capabilities |14+15+The experimental flag makes the non-macOS code available for testing; it does16+not make that platform supported and must not appear in a published build.17+Compile both policies with:18+19+```bash20+cargo check --locked -p portal --all-targets21+cargo check --locked -p portal --all-targets \22+ --features experimental-non-macos-pointer-capture23+```24+25+The complete repository gate runs the default test suite and separately26+compiles the experimental branch. On Linux CI this proves both non-macOS27+configurations compile. Portal's macOS release job continues to build without28+feature flags.29+30+## Non-macOS acceptance checklist31+32+Complete this checklist independently for each OS/webview combination before33+proposing default or release support. Preserve the exact Portal commit,34+operating-system version, webview version, and window-system/session type with35+the evidence.36+37+1. Build and launch Portal without the experimental feature. Connect to a host38+ offering every input capability and confirm diagnostics do not report39+ relative pointer or pointer-position feedback. Confirm absolute motion and40+ buttons, keyboard/text, and a physical gamepad still work when their grants41+ are present.42+2. Build the exact same commit with43+ `experimental-non-macos-pointer-capture`. Confirm the embedded webview44+ implements `requestPointerLock`, reports ownership changes, and supplies45+ bounded `movementX`/`movementY` deltas.46+3. Against the UMPC Gamescope session, enter and exit control at least ten47+ times. Exercise motion, left/right click, both scroll axes, keyboard, and a48+ physical controller in an actual game. Confirm the remote pointer maps to49+ the native host surface rather than the encoded size.50+4. While control is active, test window focus loss/regain, Pointer Lock loss,51+ disconnect, reconnect, and application exit. Portal must visibly leave52+ control when ownership is lost, restore the local cursor, bound release53+ retries, and leave no held host input.54+5. Repeat under the platform's materially different window systems or55+ webviews. Linux requires separate Wayland/WebKitGTK and X11/WebKitGTK56+ evidence when both are claimed; Windows requires the shipped WebView257+ runtime. Record unavailable APIs as an explicit unsupported result rather58+ than bypassing the gate.59+6. Run `./scripts/verify-demo-build.sh` and retain the exact Portal-to-UMPC60+ session evidence required by `AGENTS.md`. A config check, unit test, or61+ compile-only result is not hardware acceptance.62+63+Removing the feature gate requires reviewed evidence for every platform being64+enabled and an explicit release-policy change. Do not infer support from Tao65+or webview API availability alone.
M docs/portal-release.md +7 -0
M portal/control-runtime.test.mjs +23 -0
M scripts/package-macos-client.sh +4 -2
M scripts/tests/test_portal_release.py +15 -1
M scripts/verify-demo-build.sh +3 -0
M scripts/verify-portal-release.py +8 -5
M src-tauri/Cargo.toml +3 -0
M src-tauri/src/commands/state.rs +19 -1
M src-tauri/src/main.rs +1 -0
M src-tauri/src/media/input_delivery.rs +157 -34
@@ -12,6 +12,7 @@ use tauri::ipc::Channel;
1212 use crate::commands::state::AppState;1313 use crate::media::network_diagnostics::{NetworkSessionDiagnostics, lock_network_diagnostics};1414 use crate::media::transport::{NegotiatedV1Stream, negotiate_v1};15+use crate::platform_capabilities::relative_pointer_capture_enabled;1516 1617 const INPUT_ALPN: &[u8] = sigil_protocol::LEGACY_INPUT_ALPN_V0;1718 pub(crate) const CLIENT_INPUT_QUEUE_CAPACITY: usize = 256;
@@ -154,39 +155,48 @@ async fn open_negotiated_input_stream(
154155 Ok((connection, send, recv, negotiation))155156 }156157 157-fn input_capability_offers(grants: InvitationGrants) -> Vec<Vec<Capability>> {158- let base = [159- vec![160- Capability::RelativePointer,161- Capability::PointerPositionFeedback,162- Capability::PointerVisibilityFeedback,163- Capability::AbsolutePointer,164- Capability::Keyboard,165- Capability::Text,166- Capability::Gamepad,167- ],168- vec![169- Capability::RelativePointer,170- Capability::PointerPositionFeedback,171- Capability::AbsolutePointer,172- Capability::Keyboard,173- Capability::Text,174- Capability::Gamepad,175- ],176- vec![177- Capability::RelativePointer,178- Capability::AbsolutePointer,179- Capability::Keyboard,180- Capability::Text,181- Capability::Gamepad,182- ],183- vec![184- Capability::AbsolutePointer,185- Capability::Keyboard,186- Capability::Text,187- Capability::Gamepad,188- ],189- ];158+fn input_capability_offers_for(159+ grants: InvitationGrants,160+ relative_pointer_capture: bool,161+) -> Vec<Vec<Capability>> {162+ // Keep the relative compatibility ladder ordered from newest to oldest.163+ // When local relative capture is unavailable, deliberately collapse it to164+ // only the inherited absolute-pointer offer.165+ let mut base = Vec::with_capacity(if relative_pointer_capture { 4 } else { 1 });166+ if relative_pointer_capture {167+ base.extend([168+ vec![169+ Capability::RelativePointer,170+ Capability::PointerPositionFeedback,171+ Capability::PointerVisibilityFeedback,172+ Capability::AbsolutePointer,173+ Capability::Keyboard,174+ Capability::Text,175+ Capability::Gamepad,176+ ],177+ vec![178+ Capability::RelativePointer,179+ Capability::PointerPositionFeedback,180+ Capability::AbsolutePointer,181+ Capability::Keyboard,182+ Capability::Text,183+ Capability::Gamepad,184+ ],185+ vec![186+ Capability::RelativePointer,187+ Capability::AbsolutePointer,188+ Capability::Keyboard,189+ Capability::Text,190+ Capability::Gamepad,191+ ],192+ ]);193+ }194+ base.push(vec![195+ Capability::AbsolutePointer,196+ Capability::Keyboard,197+ Capability::Text,198+ Capability::Gamepad,199+ ]);190200 let has_input_grant = grants.contains(InvitationGrants::POINTER_KEYBOARD)191201 || grants.contains(InvitationGrants::GAMEPAD);192202 let mut offers = Vec::with_capacity(base.len() * 2);
@@ -216,6 +226,27 @@ fn input_capability_offers(grants: InvitationGrants) -> Vec<Vec<Capability>> {
216226 offers217227 }218228 229+fn input_capability_offers(grants: InvitationGrants) -> Vec<Vec<Capability>> {230+ input_capability_offers_for(grants, relative_pointer_capture_enabled())231+}232+233+fn mask_unavailable_relative_pointer_capabilities(234+ mut capabilities: Vec<Capability>,235+ relative_pointer_capture: bool,236+) -> Vec<Capability> {237+ if !relative_pointer_capture {238+ capabilities.retain(|capability| {239+ !matches!(240+ capability,241+ Capability::RelativePointer242+ | Capability::PointerPositionFeedback243+ | Capability::PointerVisibilityFeedback244+ )245+ });246+ }247+ capabilities248+}249+219250 fn input_event_allowed(capabilities: &[Capability], event: &InputEvent) -> bool {220251 match event {221252 InputEvent::Probe => capabilities.contains(&Capability::InputAck),
@@ -335,6 +366,12 @@ pub(crate) async fn open_input_session(
335366 ],336367 )337368 };369+ // A malformed or future host must not be able to re-enable a local input370+ // path that this Portal build deliberately withheld from its offers.371+ let capabilities = mask_unavailable_relative_pointer_capabilities(372+ capabilities,373+ relative_pointer_capture_enabled(),374+ );338375 let availability = InputAvailability::from_capabilities(&capabilities);339376 Ok(InputSession {340377 connection,
@@ -794,7 +831,7 @@ mod tests {
794831 795832 #[test]796833 fn input_capability_fallbacks_remove_only_one_protocol_extension_at_a_time() {797- let offers = input_capability_offers(InvitationGrants::ALL);834+ let offers = input_capability_offers_for(InvitationGrants::ALL, true);798835 assert_eq!(offers.len(), 8);799836 let visibility = &offers[0];800837 let position = &offers[1];
@@ -831,6 +868,92 @@ mod tests {
831868 );832869 }833870 871+ #[test]872+ fn disabled_relative_capture_preserves_only_non_relative_input_offers() {873+ let offers = input_capability_offers_for(InvitationGrants::ALL, false);874+ assert_eq!(875+ offers,876+ [877+ vec![878+ Capability::AbsolutePointer,879+ Capability::Keyboard,880+ Capability::Text,881+ Capability::Gamepad,882+ Capability::InputAck,883+ ],884+ vec![885+ Capability::AbsolutePointer,886+ Capability::Keyboard,887+ Capability::Text,888+ Capability::Gamepad,889+ ],890+ ]891+ );892+ assert!(offers.iter().flatten().all(|capability| !matches!(893+ capability,894+ Capability::RelativePointer895+ | Capability::PointerPositionFeedback896+ | Capability::PointerVisibilityFeedback897+ )));898+ }899+900+ #[test]901+ fn compiled_pointer_policy_controls_the_actual_negotiation_offers() {902+ let offers = input_capability_offers(InvitationGrants::ALL);903+ let offers_relative_pointer = offers904+ .iter()905+ .flatten()906+ .any(|capability| *capability == Capability::RelativePointer);907+ assert_eq!(offers_relative_pointer, relative_pointer_capture_enabled());908+ assert!(909+ offers910+ .iter()911+ .all(|offer| offer.contains(&Capability::AbsolutePointer))912+ );913+ }914+915+ #[test]916+ fn negotiated_capability_mask_fails_closed_without_losing_other_input() {917+ let accepted = vec![918+ Capability::RelativePointer,919+ Capability::PointerPositionFeedback,920+ Capability::PointerVisibilityFeedback,921+ Capability::AbsolutePointer,922+ Capability::Keyboard,923+ Capability::Text,924+ Capability::Gamepad,925+ Capability::InputAck,926+ ];927+ let masked = mask_unavailable_relative_pointer_capabilities(accepted.clone(), false);928+ assert_eq!(929+ masked,930+ [931+ Capability::AbsolutePointer,932+ Capability::Keyboard,933+ Capability::Text,934+ Capability::Gamepad,935+ Capability::InputAck,936+ ]937+ );938+ assert_eq!(939+ mask_unavailable_relative_pointer_capabilities(accepted.clone(), true),940+ accepted941+ );942+ assert_eq!(943+ InputAvailability::from_capabilities(&masked),944+ InputAvailability {945+ relative_pointer: false,946+ pointer_position_feedback: false,947+ absolute_pointer: true,948+ keyboard: true,949+ text: true,950+ gamepad: true,951+ input_ack: true,952+ control: true,953+ }954+ );955+ }956+834957 #[test]835958 fn local_invitation_grants_bound_input_offers_before_the_host_intersection() {836959 let view_only = input_capability_offers(InvitationGrants::VIEW);
A src-tauri/src/platform_capabilities.rs +37 -0