diff --git a/.github/workflows/linux-build-and-upload-release-assets.yml b/.github/workflows/linux-build-and-upload-release-assets.yml new file mode 100644 index 0000000..88baf7f --- /dev/null +++ b/.github/workflows/linux-build-and-upload-release-assets.yml @@ -0,0 +1,44 @@ +name: Linux Build and Upload Release Assets + +on: + release: + types: [created] + +env: + CARGO_TERM_COLOR: always + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Install Rust + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + override: true + + - name: Build Linux Client + run: | + cd linux + cargo build --release + + - name: Create release archive + run: | + cd linux + mkdir -p stickdeck-linux + cp target/release/stickdeck-linux stickdeck-linux/ + cp -r scripts stickdeck-linux/ + tar -czf stickdeck-linux-x86_64.tar.gz stickdeck-linux + + - name: Upload Release Asset + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ github.event.release.upload_url }} + asset_path: linux/stickdeck-linux-x86_64.tar.gz + asset_name: stickdeck-linux-x86_64.tar.gz + asset_content_type: application/gzip \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 847dda3..5ead821 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # CHANGELOG +## v0.3.2 (Unreleased) + +- **New**: Linux client support + - Feat: add native Linux client using uinput for virtual controller emulation + - Feat: support for all Xbox 360 controller features (buttons, analog sticks, triggers, D-pad) + - Feat: mouse input support on Linux + - Feat: setup, launch, and debug scripts for Linux + - Feat: GitHub Actions CI/CD for Linux builds + - Note: requires `/dev/uinput` access (run with sudo or add user to `input` group) + ## v0.3.1 - Client (PC) diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d60f037 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,137 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Stickdeck-RS transforms a Steam Deck into a virtual game controller for PC using a client-server architecture over TCP. + +## Key Architecture + +### Components +- **common/**: Shared networking protocol and data structures (Packet, Mouse, MouseButton, XGamepad, XButtons) +- **deck/**: Server running on Steam Deck - captures inputs via Steam Input API +- **win/**: Windows client - creates virtual Xbox 360 controller via ViGEm +- **linux/**: Linux client - creates virtual Xbox 360 controller via uinput + +### Network Protocol +- TCP on port 7777 +- 16-byte binary packets +- Packet types: Timestamp, Gamepad, Mouse +- Only sends on input changes + +### Threading Model +- Server: Separate threads for GUI (Iced), input polling, and network +- Client: Main thread for controller, network thread for receiving + +## Essential Commands + +### Building +```bash +# Server (Steam Deck) +cd deck && cargo build --release + +# Windows Client +cd win && cargo build --release + +# Linux Client +cd linux && cargo build --release + +# Debug builds +cd deck && cargo build +cd win && cargo build +cd linux && cargo build +``` + +### Testing +```bash +# Run tests for a specific component +cd deck && cargo test +cd win && cargo test +cd linux && cargo test +cd common && cargo test + +# Run specific test +cd deck && cargo test test_name +``` + +### Running +```bash +# Server (use provided scripts) +cd deck && ./launch.sh # Release mode +cd deck && ./debug.sh # Debug mode with logging + +# Windows Client +cd win && cargo run --release -- + +# Linux Client +cd linux && cargo run --release -- +# Or use provided scripts +cd linux && ./scripts/launch.sh +cd linux && ./scripts/debug.sh +``` + +### Development +```bash +# Format code +cargo fmt + +# Check for issues +cargo clippy + +# Check types +cargo check +``` + +## Code Patterns + +### Error Handling +- Use `Result>` for main functions +- Use `anyhow::Result` or specific error types in libraries +- Always handle disconnections gracefully + +### Performance Considerations +- Use bounded channels (capacity ~10) to prevent buffer growth +- Avoid allocations in hot paths (input polling) +- Use `perf!` macro for performance monitoring in debug builds + +### Logging +- Default log level: info +- Debug level shows update rates and performance metrics +- Use `RUST_LOG=debug` environment variable + +## Key Implementation Details + +### Steam Deck Server +- Requires Steam client running +- Uses Steamworks SDK 154 (AppID 480 - Spacewar) +- Input polling at configurable rate (default 3ms) +- GUI built with Iced framework + +### Windows Client +- Requires ViGEm Bus Driver installed +- Emulates Xbox 360 controller +- Mouse support via SendInput API +- Auto-reconnect on disconnection + +### Linux Client +- Requires /dev/uinput access (sudo or input group membership) +- Uses input-linux crate for uinput integration +- Emulates Xbox 360 controller compatible with evdev +- Mouse support via uinput mouse device +- Auto-reconnect on disconnection + +### Packet Structure +- Fixed 16-byte frames +- Binary serialization (not JSON) +- See common/src/lib.rs for Packet enum + +## Testing Virtual Controller +- Windows: Use `joy.cpl` (Game Controllers panel) +- Linux: Use `evtest` or `jstest-gtk` to test the virtual controller +- Steam Deck: Configure inputs in Steam's controller settings + +## Release Process +- Version synchronized across deck/Cargo.toml, win/Cargo.toml, and linux/Cargo.toml +- GitHub Actions builds release binaries for all platforms +- Update CHANGELOG.md with changes \ No newline at end of file diff --git a/README.md b/README.md index 581f8b9..0d63cda 100644 --- a/README.md +++ b/README.md @@ -38,9 +38,17 @@ Now you can proceed to the [client side setup](#client-side-pc). --> ### Client Side (PC) +#### Windows + 1. Install [ViGEm Bus Driver](https://github.com/nefarius/ViGEmBus) and **_restart_** your PC. 2. Download `stickdeck-win-vX.X.X.zip` from the [latest release](https://github.com/DiscreteTom/stickdeck-rs/releases/latest) and extract it. +#### Linux + +1. Download `stickdeck-linux-x86_64.tar.gz` from the [latest release](https://github.com/DiscreteTom/stickdeck-rs/releases/latest) and extract it. +2. Run `./scripts/setup.sh` in the extracted folder to install dependencies and configure permissions. +3. Logout and login again if you were added to the `input` group during setup. + ## Usage ### General @@ -48,12 +56,17 @@ Now you can proceed to the [client side setup](#client-side-pc). --> 1. Start the server on Steam Deck. Make sure the server is running and the input is captured. 2. Make sure your PC and your Steam Deck are in the same network. 3. Make sure the client on your PC is under the same minor version as the server on Steam Deck. -4. Run `launch.bat` on your PC. Once you see `Virtual controller is ready` in the console, StickDeck is ready. -5. (Optional) If you want to test the controller, run `joy.cpl` (which is a built-in Windows joystick test tool). +4. Run the client on your PC: + - **Windows**: Run `launch.bat`. Once you see `Virtual controller is ready` in the console, StickDeck is ready. + - **Linux**: Run `./scripts/launch.sh `. Once you see `Ready! Waiting for inputs from Steam Deck...`, StickDeck is ready. +5. (Optional) Test the controller: + - **Windows**: Run `joy.cpl` (built-in Windows joystick test tool). + - **Linux**: Run `evtest` or `jstest-gtk` to test the virtual controller. > [!NOTE] -> By default the client will try to connect `steamdeck:7777`. If you want to connect to a different server, you can edit `launch.bat`, replace the `steamdeck` with your server IP. -> You can find the server IP on the first line of the StickDeck UI window while the server is started. +> - **Windows**: By default the client will try to connect `steamdeck:7777`. If you want to connect to a different server, you can edit `launch.bat`, replace the `steamdeck` with your server IP. +> - **Linux**: You need to provide the Steam Deck IP address as an argument to the launch script. +> - You can find the server IP on the first line of the StickDeck UI window while the server is started. ### Mouse Actions @@ -65,7 +78,14 @@ and map any action to mouse buttons. - Poll/update rate? - Depends on the configurable input update interval. In my case, set the input update interval to 3ms to reach the max update rate of 250+Hz. - Besides, the server side will only send the input when there is a change, so the actual update rate will be lower than the configured rate. - - You can checkout the actual update rate on the PC side by running `debug.bat`. + - You can checkout the actual update rate on the PC side by running: + - **Windows**: `debug.bat` + - **Linux**: `./scripts/debug.sh ` + +- Linux client requirements? + - Requires access to `/dev/uinput` for creating virtual input devices. + - Either run with `sudo` or add your user to the `input` group: `sudo usermod -aG input $USER` + - The `setup.sh` script will help configure this automatically. ## Credit diff --git a/common/src/lib.rs b/common/src/lib.rs index e712a8c..e3d7b59 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -1,11 +1,14 @@ mod perf; +pub const PORT: u16 = 7777; + #[derive(Default, Debug, Clone, Copy, PartialEq, Eq)] pub struct MouseButton(pub u8); impl MouseButton { pub const MOUSE_LEFT_BUTTON: u8 = 1; pub const MOUSE_RIGHT_BUTTON: u8 = 2; + pub const MOUSE_MIDDLE_BUTTON: u8 = 4; pub fn left_button_down(&mut self) { self.0 |= Self::MOUSE_LEFT_BUTTON; @@ -19,6 +22,18 @@ impl MouseButton { pub fn is_right_button_down(&self) -> bool { self.0 & Self::MOUSE_RIGHT_BUTTON != 0 } + + pub const LEFT: Self = Self(Self::MOUSE_LEFT_BUTTON); + pub const RIGHT: Self = Self(Self::MOUSE_RIGHT_BUTTON); + pub const MIDDLE: Self = Self(Self::MOUSE_MIDDLE_BUTTON); + + pub const fn empty() -> Self { + Self(0) + } + + pub fn contains(&self, other: Self) -> bool { + self.0 & other.0 != 0 + } } /// The mouse movement data in pixels in one update. @@ -60,3 +75,45 @@ pub enum Packet { } pub const PACKET_FRAME_SIZE: usize = 16; + +#[derive(Default, Clone, Debug, PartialEq, Eq)] +pub struct XButtons { + pub raw: u16, +} + +impl XButtons { + pub const DPAD_UP: u16 = 0x0001; + pub const DPAD_DOWN: u16 = 0x0002; + pub const DPAD_LEFT: u16 = 0x0004; + pub const DPAD_RIGHT: u16 = 0x0008; + pub const START: u16 = 0x0010; + pub const BACK: u16 = 0x0020; + pub const LEFT_THUMB: u16 = 0x0040; + pub const RIGHT_THUMB: u16 = 0x0080; + pub const LEFT_SHOULDER: u16 = 0x0100; + pub const RIGHT_SHOULDER: u16 = 0x0200; + pub const GUIDE: u16 = 0x0400; + pub const A: u16 = 0x1000; + pub const B: u16 = 0x2000; + pub const X: u16 = 0x4000; + pub const Y: u16 = 0x8000; + + pub const fn empty() -> Self { + Self { raw: 0 } + } + + pub fn contains(&self, button: u16) -> bool { + self.raw & button != 0 + } +} + +#[derive(Default, Clone, Debug, PartialEq, Eq)] +pub struct XGamepad { + pub buttons: XButtons, + pub left_trigger: u8, + pub right_trigger: u8, + pub thumb_lx: i16, + pub thumb_ly: i16, + pub thumb_rx: i16, + pub thumb_ry: i16, +} diff --git a/deck/scripts/debug.sh b/deck/scripts/debug.sh old mode 100644 new mode 100755 diff --git a/deck/scripts/launch.sh b/deck/scripts/launch.sh old mode 100644 new mode 100755 diff --git a/deck/scripts/setup.sh b/deck/scripts/setup.sh old mode 100644 new mode 100755 diff --git a/linux/Cargo.lock b/linux/Cargo.lock new file mode 100644 index 0000000..47bcdb3 --- /dev/null +++ b/linux/Cargo.lock @@ -0,0 +1,394 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "0.6.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "301af1932e46185686725e0fad2f8f2aa7da69dd70bf6ecc44d6b703844a3933" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8bdeb6047d8983be085bab0ba1472e6dc604e7041dbf6fcd5e71523014fae9" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "403f75924867bb1033c59fbf0797484329750cfbe3c4325cd33127941fabc882" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" + +[[package]] +name = "bitflags" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" + +[[package]] +name = "cfg-if" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "env_filter" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "input-linux" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7e8c4821c88b95582ca69234a1d233f87e44182c42e121f740efb0bec1142e0" +dependencies = [ + "input-linux-sys", + "nix", +] + +[[package]] +name = "input-linux-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b91b2248b0eaf0a576ef5e60b7f2107a749e705a876bc0b9fe952ac8d83a724" +dependencies = [ + "libc", + "nix", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + +[[package]] +name = "jiff" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1f93b8b1eb69c77f24bbb0afdf66f54b632ee39af40ca21c4365a1d7347e49" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde", +] + +[[package]] +name = "jiff-static" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03343451ff899767262ec32146f6d559dd759fdadf42ff0e227c7c48f72594b4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "libc" +version = "0.2.174" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" + +[[package]] +name = "log" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + +[[package]] +name = "memchr" +version = "2.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "once_cell_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" + +[[package]] +name = "portable-atomic" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" + +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "proc-macro2" +version = "1.0.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "stickdeck-common" +version = "0.1.0" + +[[package]] +name = "stickdeck-linux" +version = "0.3.1" +dependencies = [ + "anyhow", + "env_logger", + "input-linux", + "log", + "stickdeck-common", +] + +[[package]] +name = "syn" +version = "2.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" diff --git a/linux/Cargo.toml b/linux/Cargo.toml new file mode 100644 index 0000000..5a79708 --- /dev/null +++ b/linux/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "stickdeck-linux" +version = "0.3.1" +edition = "2021" + +[lib] +name = "stickdeck_linux" +path = "src/lib.rs" + +[[bin]] +name = "stickdeck-linux" +path = "src/main.rs" + +[dependencies] +stickdeck-common = { path = "../common" } +input-linux = "0.7" +anyhow = "1" +env_logger = "0.11" +log = "0.4" + +[profile.dev] +opt-level = 2 + +[profile.release] +lto = true +codegen-units = 1 +opt-level = 3 \ No newline at end of file diff --git a/linux/PLAN.md b/linux/PLAN.md new file mode 100644 index 0000000..62d6ae9 --- /dev/null +++ b/linux/PLAN.md @@ -0,0 +1,102 @@ +To support the StickDeck project on Linux, where the client side emulates the Steam Deck as a virtual game controller (similar to an Xbox 360 controller on Windows via ViGEm Bus Driver), the primary adaptation involves replacing the Windows-specific ViGEm implementation with Linux's native uinput kernel module. Uinput enables the creation of virtual input devices from user space, allowing emulation of gamepads, keyboards, or other input hardware without requiring a separate bus driver like ViGEm. This approach is standard for Linux and integrates directly with the kernel's input subsystem (evdev). + +Below, I outline the key steps to implement Linux support in the StickDeck client, assuming the project remains Rust-based. These steps build on the existing architecture: the server on the Steam Deck captures and streams inputs, while the client on the Linux PC receives them and emulates a virtual controller. + +### Key Considerations for Linux Support +- **Permissions**: Accessing `/dev/uinput` typically requires root privileges or membership in the `input` group (e.g., via `sudo usermod -aG input $USER`). For production use, consider running the client with elevated permissions or using a setuid wrapper. +- **Event Types**: To emulate a gamepad, the virtual device must support event types such as `EV_KEY` (for buttons), `EV_ABS` (for analog sticks, triggers, and gyro/trackpad mappings), and optionally `EV_REL` (for relative mouse-like movements if needed). +- **Compatibility**: The emulated device will appear as a standard evdev device, compatible with most games and tools (e.g., SDL, Godot, or Steam) that read from `/dev/input/event*`. +- **Limitations**: Unlike ViGEm, uinput does not emulate specific proprietary protocols (e.g., exact Xbox 360 HID reports). However, it can mimic generic gamepads effectively. Test with tools like `jstest` or `evtest` to verify. + +### Steps to Implement Linux Client Support +1. **Add Platform-Specific Code**: Modify the StickDeck client to use conditional compilation (e.g., `#[cfg(target_os = "linux")]` in Rust) for Linux-specific logic. Retain the Windows ViGEm code under `#[cfg(target_os = "windows")]`. + +2. **Select a Rust Crate for Uinput Integration**: Use a Rust library to interface with uinput. Based on available options, the following are suitable alternatives: + - **input-linux**: A maintained crate providing both evdev (for reading inputs) and uinput (for writing/virtualizing). It is designed for creating virtual input devices on Linux. + - Latest version: 0.7.1 (as of recent updates). + - Dependencies: Minimal; relies on `libc` for system calls. + - Features: Supports creating devices with custom names, enabling specific event types (e.g., buttons, axes), and writing events synchronously. + - Documentation: Available at https://docs.rs/input-linux. + - **uinput**: An older, simpler wrapper for uinput syscalls, suitable for basic virtual devices. + - Latest version: 0.1.3. + - Dependencies: Primarily `libc`. + - Features: Focuses on device creation and event emission; less comprehensive than input-linux but lightweight. + - Repository: https://github.com/meh/rust-uinput. + + Recommendation: Start with `input-linux` for its active maintenance and comprehensive API. Add it to your `Cargo.toml`: + ``` + [dependencies] + input-linux = "0.7" + ``` + +3. **Create the Virtual Gamepad**: + - Open the uinput device (e.g., `/dev/uinput`). + - Define the virtual device's properties: Set a name (e.g., "StickDeck Virtual Controller"), enable relevant event types, and configure axes (e.g., for left/right sticks with ranges -32768 to 32767) and buttons (e.g., A/B/X/Y, triggers). + - Register the device to make it visible in the system. + + Example using `input-linux` (adapted from documentation; ensure error handling in production): + ```rust + use input_linux::{UinputHandle, EventKind, Key, AbsoluteAxis, UinputSetup}; + use std::fs::File; + use std::io; + + fn create_virtual_gamepad() -> io::Result> { + let file = File::create("/dev/uinput")?; + let mut handle = UinputHandle::new(file); + + // Setup device properties + let setup = UinputSetup::new("StickDeck Virtual Controller", 0x1234, 0x5678, 0x01); // Vendor, product, version + handle.setup(&setup)?; + + // Enable button events (e.g., gamepad buttons) + handle.enable(EventKind::Key)?; + handle.enable_key(Key::BtnA)?; + handle.enable_key(Key::BtnB)?; + // Add more buttons as needed... + + // Enable absolute axes (e.g., for joysticks and triggers) + handle.enable(EventKind::Absolute)?; + handle.enable_absolute(AbsoluteAxis::X, -32768..=32767, 0, 0, 0, 0)?; + handle.enable_absolute(AbsoluteAxis::Y, -32768..=32767, 0, 0, 0, 0)?; + // Add RX/RY for right stick, Hat0X/Hat0Y for D-pad, etc. + + // Create the device + handle.create()?; + + Ok(handle) + } + ``` + +4. **Handle Incoming Inputs**: + - In the client's main loop, receive streamed data from the Steam Deck server (as in the current implementation). + - Map received inputs (e.g., button presses, axis movements, gyro data) to uinput events. + - Write events to the virtual device handle. For example: + ```rust + use input_linux::{AbsoluteEvent, KeyEvent, InputEvent}; + + // Assuming 'handle' is the UinputHandle from above + // For a button press: + let btn_event = KeyEvent::new(0, Key::BtnA.into(), 1); // 1 for press, 0 for release + handle.write(&[InputEvent::from(btn_event).into()])?; + + // For axis movement (e.g., left stick X): + let axis_event = AbsoluteEvent::new(0, AbsoluteAxis::X.into(), 10000); // Value in range + handle.write(&[InputEvent::from(axis_event).into()])?; + + // Synchronize events + handle.synchronize()?; + ``` + - For trackpad/gyro support: Map to additional axes (e.g., custom ABS_MISC) or relative events if simulating mouse input. + +5. **Testing and Integration**: + - Run the client with the server active, ensuring the PC and Steam Deck are networked. + - Verify the virtual device appears (e.g., via `ls /dev/input/` or `evtest`). + - Test inputs using Linux tools like `jstest /dev/input/js0` or in games. + - Handle cleanup: Destroy the virtual device on exit to avoid lingering entries. + +6. **Potential Enhancements**: + - Add configurable options for axis sensitivity, dead zones, or mouse emulation (e.g., mapping trackpad to `EV_REL` for relative movements). + - For cross-platform consistency, abstract the emulation logic behind a trait (e.g., `trait ControllerEmulator` with implementations for ViGEm and uinput). + - If advanced features are needed (e.g., force feedback), explore extensions, though uinput primarily focuses on input emission. + +This implementation should achieve functional parity with the Windows version. If the project requires more complex HID emulation, consider combining uinput with custom userspace drivers, but for standard gamepad use, uinput suffices. For further details, consult the crate documentation or Linux kernel uinput references. \ No newline at end of file diff --git a/linux/scripts/debug.sh b/linux/scripts/debug.sh new file mode 100755 index 0000000..2287458 --- /dev/null +++ b/linux/scripts/debug.sh @@ -0,0 +1,37 @@ +#!/bin/bash + +# Check if server IP is provided +if [ -z "$1" ]; then + echo "Usage: $0 " + echo "Example: $0 192.168.1.100" + exit 1 +fi + +SERVER_IP=$1 + +# Build in debug mode +echo "Building StickDeck Linux client in debug mode..." +cd .. && cargo build +if [ $? -ne 0 ]; then + echo "Build failed!" + exit 1 +fi + +# Check if user can access /dev/uinput +if [ ! -w "/dev/uinput" ]; then + echo "Warning: Cannot write to /dev/uinput. You may need to:" + echo " 1. Run with sudo: sudo $0 $SERVER_IP" + echo " 2. Or add your user to the 'input' group: sudo usermod -aG input $USER" + echo " (logout and login again for this to take effect)" + echo "" + read -p "Continue anyway? (y/N) " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + exit 1 + fi +fi + +echo "Starting StickDeck Linux client in debug mode..." +echo "Connecting to server at $SERVER_IP" +echo "" +RUST_LOG=debug ./target/debug/stickdeck-linux "$SERVER_IP" \ No newline at end of file diff --git a/linux/scripts/launch.sh b/linux/scripts/launch.sh new file mode 100755 index 0000000..8b00e52 --- /dev/null +++ b/linux/scripts/launch.sh @@ -0,0 +1,39 @@ +#!/bin/bash + +# Check if server IP is provided +if [ -z "$1" ]; then + echo "Usage: $0 " + echo "Example: $0 192.168.1.100" + exit 1 +fi + +SERVER_IP=$1 + +# Build in release mode if binary doesn't exist +if [ ! -f "../target/release/stickdeck-linux" ]; then + echo "Building StickDeck Linux client in release mode..." + cd .. && cargo build --release + if [ $? -ne 0 ]; then + echo "Build failed!" + exit 1 + fi + cd scripts +fi + +# Check if user can access /dev/uinput +if [ ! -w "/dev/uinput" ]; then + echo "Warning: Cannot write to /dev/uinput. You may need to:" + echo " 1. Run with sudo: sudo $0 $SERVER_IP" + echo " 2. Or add your user to the 'input' group: sudo usermod -aG input $USER" + echo " (logout and login again for this to take effect)" + echo "" + read -p "Continue anyway? (y/N) " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + exit 1 + fi +fi + +echo "Starting StickDeck Linux client..." +echo "Connecting to server at $SERVER_IP" +./target/release/stickdeck-linux "$SERVER_IP" \ No newline at end of file diff --git a/linux/scripts/setup.sh b/linux/scripts/setup.sh new file mode 100755 index 0000000..8180593 --- /dev/null +++ b/linux/scripts/setup.sh @@ -0,0 +1,119 @@ +#!/bin/bash + +echo "StickDeck Linux Client Setup" +echo "============================" +echo "" + +# Check if running as root +if [ "$EUID" -eq 0 ]; then + echo "Please do not run this script as root/sudo" + echo "The script will ask for sudo when needed" + exit 1 +fi + +# Function to check if a command exists +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +# Check for Rust +echo "Checking for Rust installation..." +if ! command_exists rustc; then + echo "Rust is not installed. Please install Rust from https://rustup.rs/" + exit 1 +else + echo "✓ Rust is installed ($(rustc --version))" +fi + +# Check for cargo +if ! command_exists cargo; then + echo "Cargo is not installed. This should come with Rust." + exit 1 +else + echo "✓ Cargo is installed" +fi + +# Check if /dev/uinput exists +echo "" +echo "Checking for uinput support..." +if [ ! -e "/dev/uinput" ]; then + echo "✗ /dev/uinput not found. Loading uinput module..." + sudo modprobe uinput + if [ $? -ne 0 ]; then + echo "Failed to load uinput module. Your kernel may not support it." + exit 1 + fi + echo "✓ uinput module loaded" +else + echo "✓ /dev/uinput exists" +fi + +# Check permissions +echo "" +echo "Checking permissions..." +if [ -w "/dev/uinput" ]; then + echo "✓ You have write access to /dev/uinput" +else + echo "✗ You don't have write access to /dev/uinput" + echo "" + echo "To fix this, you can either:" + echo "1. Add your user to the 'input' group (recommended):" + echo " sudo usermod -aG input $USER" + echo " Then logout and login again" + echo "" + echo "2. Run the client with sudo (not recommended for regular use)" + echo "" + read -p "Add $USER to input group now? (y/N) " -n 1 -r + echo + if [[ $REPLY =~ ^[Yy]$ ]]; then + sudo usermod -aG input $USER + echo "✓ Added $USER to input group" + echo "⚠ You need to logout and login again for this to take effect!" + fi +fi + +# Build the client +echo "" +echo "Building StickDeck Linux client..." +cargo build --release +if [ $? -eq 0 ]; then + echo "✓ Build successful!" +else + echo "✗ Build failed. Please check the error messages above." + exit 1 +fi + +# Test tools +echo "" +echo "Optional: Installing useful testing tools..." +echo "These tools help test gamepad functionality:" +echo "- evtest: Test input events" +echo "- jstest-gtk: Graphical gamepad tester" +echo "" +read -p "Install testing tools? (y/N) " -n 1 -r +echo +if [[ $REPLY =~ ^[Yy]$ ]]; then + if command_exists apt-get; then + sudo apt-get update && sudo apt-get install -y evtest jstest-gtk + elif command_exists dnf; then + sudo dnf install -y evtest jstest-gtk + elif command_exists pacman; then + sudo pacman -S evtest jstest-gtk + else + echo "Package manager not recognized. Please install evtest and jstest-gtk manually." + fi +fi + +echo "" +echo "Setup complete!" +echo "" +echo "To run StickDeck Linux client:" +echo " ./launch.sh " +echo "" +echo "To test your virtual gamepad:" +echo " evtest # List and test input devices" +echo " jstest-gtk # Graphical gamepad tester" +echo "" +if [ ! -w "/dev/uinput" ]; then + echo "⚠ Remember to logout and login again if you added yourself to the input group!" +fi \ No newline at end of file diff --git a/linux/src/client.rs b/linux/src/client.rs new file mode 100644 index 0000000..9e228d7 --- /dev/null +++ b/linux/src/client.rs @@ -0,0 +1,100 @@ +use log::{info, warn}; +use std::{io::Read, net::TcpStream, sync::mpsc, thread, time::Duration}; +use stickdeck_common::{Mouse, Packet, XButtons, XGamepad, PACKET_FRAME_SIZE}; + +pub struct Client { + server_addr: String, + tx: mpsc::SyncSender>, + retry_duration: Duration, +} + +impl Client { + pub fn new( + server_addr: String, + tx: mpsc::SyncSender>, + retry_duration: Duration, + ) -> Self { + Self { + server_addr, + tx, + retry_duration, + } + } + + pub fn start(self) { + thread::spawn(move || { + loop { + info!("Connecting to {} ...", self.server_addr); + match self.connect_and_receive() { + Ok(_) => { + info!("Connection closed"); + } + Err(e) => { + warn!("Connection error: {}", e); + } + } + info!("Retrying in {} seconds...", self.retry_duration.as_secs()); + thread::sleep(self.retry_duration); + } + }); + } + + fn connect_and_receive(&self) -> std::io::Result<()> { + info!("Attempting to connect to {}...", self.server_addr); + let mut stream = TcpStream::connect(&self.server_addr)?; + info!("Successfully connected to {}", self.server_addr); + + let mut buf = [0; PACKET_FRAME_SIZE]; + while stream.read_exact(&mut buf).is_ok() { + match Packet::deserialize(&buf) { + Ok(packet) => { + if self.tx.send(packet).is_err() { + warn!("Main thread has disconnected"); + break; + } + } + Err(_) => { + warn!("Invalid packet: {:?}", buf); + } + } + } + + info!("Disconnected from server"); + Ok(()) + } +} + +trait DeserializablePacket { + type Target; + fn deserialize(buf: &[u8; PACKET_FRAME_SIZE]) -> Result; +} + +impl> DeserializablePacket for Packet { + type Target = Self; + + fn deserialize(buf: &[u8; PACKET_FRAME_SIZE]) -> Result { + match buf[0] { + 0 => { + let timestamp = u64::from_le_bytes(buf[1..9].try_into().unwrap()); + Ok(Packet::Timestamp(timestamp)) + } + 1 => Ok(Packet::Gamepad(Gamepad::deserialize(&buf[1..]))), + 2 => Ok(Packet::Mouse(Mouse::deserialize(&buf[1..]))), + _ => Err(buf[0]), + } + } +} + +// rust-analyzer might throw errors below, but it's fine +// see https://github.com/rust-lang/rust-analyzer/issues/17040 +include!("../../snippet/deserialize.rs"); + +#[cfg(test)] +mod tests { + use super::*; + + // rust-analyzer might throw errors below, but it's fine + // see https://github.com/rust-lang/rust-analyzer/issues/17040 + include!("../../snippet/serialize.rs"); + include!("../../snippet/test_serialize_deserialize.rs"); +} \ No newline at end of file diff --git a/linux/src/input/gamepad.rs b/linux/src/input/gamepad.rs new file mode 100644 index 0000000..387059d --- /dev/null +++ b/linux/src/input/gamepad.rs @@ -0,0 +1,298 @@ +use anyhow::{Context, Result}; +use input_linux::{ + AbsoluteAxis, AbsoluteEvent, AbsoluteInfo, AbsoluteInfoSetup, EventKind, EventTime, + InputEvent, Key, KeyEvent, KeyState, SynchronizeEvent, UInputHandle, +}; +use log::{debug, info}; +use std::fs::OpenOptions; +use stickdeck_common::{XButtons, XGamepad}; + +const STICK_MAX: i32 = 32767; +const STICK_MIN: i32 = -32768; +const TRIGGER_MAX: i32 = 255; +const TRIGGER_MIN: i32 = 0; + +pub fn init_gamepad() -> Result Result<()>> { + let file = OpenOptions::new() + .read(true) + .write(true) + .open("/dev/uinput") + .context("Failed to open /dev/uinput. Make sure you have permissions (try running with sudo or add user to 'input' group)")?; + + let handle = UInputHandle::new(file); + + handle + .set_evbit(EventKind::Key) + .context("Failed to enable key events")?; + handle + .set_evbit(EventKind::Absolute) + .context("Failed to enable absolute events")?; + + // Enable gamepad buttons + handle.set_keybit(Key::ButtonSouth)?; // A + handle.set_keybit(Key::ButtonEast)?; // B + handle.set_keybit(Key::ButtonNorth)?; // X + handle.set_keybit(Key::ButtonWest)?; // Y + handle.set_keybit(Key::ButtonTL)?; // Left Bumper + handle.set_keybit(Key::ButtonTR)?; // Right Bumper + handle.set_keybit(Key::ButtonSelect)?; // Back/View + handle.set_keybit(Key::ButtonStart)?; + handle.set_keybit(Key::ButtonMode)?; // Guide/Xbox button + handle.set_keybit(Key::ButtonThumbl)?; // Left Stick Click + handle.set_keybit(Key::ButtonThumbr)?; // Right Stick Click + + // D-Pad as hat axes + let dpad_info = AbsoluteInfo { + value: 0, + minimum: -1, + maximum: 1, + fuzz: 0, + flat: 0, + resolution: 0, + }; + + // Setup analog sticks + let stick_info = AbsoluteInfo { + value: 0, + minimum: STICK_MIN, + maximum: STICK_MAX, + fuzz: 16, + flat: 128, + resolution: 0, + }; + + // Setup triggers + let trigger_info = AbsoluteInfo { + value: 0, + minimum: TRIGGER_MIN, + maximum: TRIGGER_MAX, + fuzz: 0, + flat: 0, + resolution: 0, + }; + + // Left stick + handle.set_absbit(AbsoluteAxis::X)?; + handle.set_absbit(AbsoluteAxis::Y)?; + + // Right stick + handle.set_absbit(AbsoluteAxis::RX)?; + handle.set_absbit(AbsoluteAxis::RY)?; + + // Triggers + handle.set_absbit(AbsoluteAxis::Z)?; // Left trigger + handle.set_absbit(AbsoluteAxis::RZ)?; // Right trigger + + // D-Pad + handle.set_absbit(AbsoluteAxis::Hat0X)?; + handle.set_absbit(AbsoluteAxis::Hat0Y)?; + + // Set absolute axis info + let abs_info = vec![ + AbsoluteInfoSetup { + axis: AbsoluteAxis::X, + info: stick_info.clone(), + }, + AbsoluteInfoSetup { + axis: AbsoluteAxis::Y, + info: stick_info.clone(), + }, + AbsoluteInfoSetup { + axis: AbsoluteAxis::RX, + info: stick_info.clone(), + }, + AbsoluteInfoSetup { + axis: AbsoluteAxis::RY, + info: stick_info.clone(), + }, + AbsoluteInfoSetup { + axis: AbsoluteAxis::Z, + info: trigger_info.clone(), + }, + AbsoluteInfoSetup { + axis: AbsoluteAxis::RZ, + info: trigger_info.clone(), + }, + AbsoluteInfoSetup { + axis: AbsoluteAxis::Hat0X, + info: dpad_info.clone(), + }, + AbsoluteInfoSetup { + axis: AbsoluteAxis::Hat0Y, + info: dpad_info.clone(), + }, + ]; + + // Create the virtual device + handle + .create( + &input_linux::InputId { + bustype: input_linux::sys::BUS_USB, + vendor: 0x045e, // Microsoft + product: 0x028e, // Xbox 360 Controller + version: 0x0110, + }, + b"StickDeck Virtual Controller", + 0, + &abs_info, + ) + .context("Failed to create virtual gamepad")?; + + info!("Virtual gamepad created successfully"); + + let mut prev_state = XGamepad::default(); + + Ok(move |gamepad: &XGamepad| -> Result<()> { + let time = EventTime::new(0, 0); + let mut events = Vec::new(); + + // Map buttons + let buttons = [ + (XButtons::A, Key::ButtonSouth), + (XButtons::B, Key::ButtonEast), + (XButtons::X, Key::ButtonNorth), + (XButtons::Y, Key::ButtonWest), + (XButtons::LEFT_SHOULDER, Key::ButtonTL), + (XButtons::RIGHT_SHOULDER, Key::ButtonTR), + (XButtons::BACK, Key::ButtonSelect), + (XButtons::START, Key::ButtonStart), + (XButtons::GUIDE, Key::ButtonMode), + (XButtons::LEFT_THUMB, Key::ButtonThumbl), + (XButtons::RIGHT_THUMB, Key::ButtonThumbr), + ]; + + for (xbox_button, linux_key) in &buttons { + let current = gamepad.buttons.contains(*xbox_button); + let previous = prev_state.buttons.contains(*xbox_button); + if current != previous { + events.push(InputEvent::from(KeyEvent::new( + time, + *linux_key, + KeyState::pressed(current), + ))); + } + } + + // Map D-Pad + let dpad_x = if gamepad.buttons.contains(XButtons::DPAD_RIGHT) { + 1 + } else if gamepad.buttons.contains(XButtons::DPAD_LEFT) { + -1 + } else { + 0 + }; + + let dpad_y = if gamepad.buttons.contains(XButtons::DPAD_DOWN) { + 1 + } else if gamepad.buttons.contains(XButtons::DPAD_UP) { + -1 + } else { + 0 + }; + + let prev_dpad_x = if prev_state.buttons.contains(XButtons::DPAD_RIGHT) { + 1 + } else if prev_state.buttons.contains(XButtons::DPAD_LEFT) { + -1 + } else { + 0 + }; + + let prev_dpad_y = if prev_state.buttons.contains(XButtons::DPAD_DOWN) { + 1 + } else if prev_state.buttons.contains(XButtons::DPAD_UP) { + -1 + } else { + 0 + }; + + if dpad_x != prev_dpad_x { + events.push(InputEvent::from(AbsoluteEvent::new( + time, + AbsoluteAxis::Hat0X, + dpad_x, + ))); + } + + if dpad_y != prev_dpad_y { + events.push(InputEvent::from(AbsoluteEvent::new( + time, + AbsoluteAxis::Hat0Y, + dpad_y, + ))); + } + + // Map analog sticks + if gamepad.thumb_lx != prev_state.thumb_lx { + events.push(InputEvent::from(AbsoluteEvent::new( + time, + AbsoluteAxis::X, + gamepad.thumb_lx as i32, + ))); + } + + if gamepad.thumb_ly != prev_state.thumb_ly { + // Invert Y axis for Linux + events.push(InputEvent::from(AbsoluteEvent::new( + time, + AbsoluteAxis::Y, + -(gamepad.thumb_ly as i32), + ))); + } + + if gamepad.thumb_rx != prev_state.thumb_rx { + events.push(InputEvent::from(AbsoluteEvent::new( + time, + AbsoluteAxis::RX, + gamepad.thumb_rx as i32, + ))); + } + + if gamepad.thumb_ry != prev_state.thumb_ry { + // Invert Y axis for Linux + events.push(InputEvent::from(AbsoluteEvent::new( + time, + AbsoluteAxis::RY, + -(gamepad.thumb_ry as i32), + ))); + } + + // Map triggers + if gamepad.left_trigger != prev_state.left_trigger { + events.push(InputEvent::from(AbsoluteEvent::new( + time, + AbsoluteAxis::Z, + gamepad.left_trigger as i32, + ))); + } + + if gamepad.right_trigger != prev_state.right_trigger { + events.push(InputEvent::from(AbsoluteEvent::new( + time, + AbsoluteAxis::RZ, + gamepad.right_trigger as i32, + ))); + } + + // Write events if any changes occurred + if !events.is_empty() { + events.push(InputEvent::from(SynchronizeEvent::report(time))); + + let event_count = events.len() - 1; + + // Convert InputEvent to raw input_event + let raw_events: Vec = events + .into_iter() + .map(|e| *e.as_raw()) + .collect(); + + handle + .write(&raw_events) + .context("Failed to write gamepad events")?; + debug!("Updated gamepad state with {} events", event_count); + } + + prev_state = gamepad.clone(); + Ok(()) + }) +} \ No newline at end of file diff --git a/linux/src/input/mod.rs b/linux/src/input/mod.rs new file mode 100644 index 0000000..70332e9 --- /dev/null +++ b/linux/src/input/mod.rs @@ -0,0 +1,5 @@ +mod gamepad; +mod mouse; + +pub use gamepad::init_gamepad; +pub use mouse::init_mouse; \ No newline at end of file diff --git a/linux/src/input/mouse.rs b/linux/src/input/mouse.rs new file mode 100644 index 0000000..3d5d359 --- /dev/null +++ b/linux/src/input/mouse.rs @@ -0,0 +1,126 @@ +use anyhow::{Context, Result}; +use input_linux::{ + EventKind, EventTime, InputEvent, Key, KeyEvent, KeyState, RelativeAxis, RelativeEvent, + SynchronizeEvent, UInputHandle, +}; +use log::debug; +use std::fs::OpenOptions; +use stickdeck_common::{Mouse, MouseButton}; + +pub fn init_mouse() -> Result Result<()>> { + let file = OpenOptions::new() + .read(true) + .write(true) + .open("/dev/uinput") + .context("Failed to open /dev/uinput. Make sure you have permissions (try running with sudo or add user to 'input' group)")?; + + let handle = UInputHandle::new(file); + + // Enable key events for mouse buttons + handle + .set_evbit(EventKind::Key) + .context("Failed to enable key events")?; + handle + .set_evbit(EventKind::Relative) + .context("Failed to enable relative events")?; + + // Enable mouse buttons + handle.set_keybit(Key::ButtonLeft)?; + handle.set_keybit(Key::ButtonRight)?; + handle.set_keybit(Key::ButtonMiddle)?; + + // Enable relative axes for movement and scroll + handle.set_relbit(RelativeAxis::X)?; + handle.set_relbit(RelativeAxis::Y)?; + handle.set_relbit(RelativeAxis::Wheel)?; + + // Create the virtual mouse device + handle + .create( + &input_linux::InputId { + bustype: input_linux::sys::BUS_USB, + vendor: 0x0000, + product: 0x0000, + version: 0x0001, + }, + b"StickDeck Virtual Mouse", + 0, + &[], + ) + .context("Failed to create virtual mouse")?; + + debug!("Virtual mouse created successfully"); + + let mut prev_buttons = MouseButton::empty(); + + Ok(move |mouse: &Mouse| -> Result<()> { + let time = EventTime::new(0, 0); + let mut events = Vec::new(); + + // Handle mouse movement + if mouse.x != 0 { + events.push(InputEvent::from(RelativeEvent::new( + time, + RelativeAxis::X, + mouse.x as i32, + ))); + } + + if mouse.y != 0 { + events.push(InputEvent::from(RelativeEvent::new( + time, + RelativeAxis::Y, + mouse.y as i32, + ))); + } + + // Handle scroll wheel + if mouse.scroll != 0 { + events.push(InputEvent::from(RelativeEvent::new( + time, + RelativeAxis::Wheel, + -(mouse.scroll as i32), // Invert scroll direction for Linux + ))); + } + + // Handle button state changes + let button_mappings = [ + (MouseButton::LEFT, Key::ButtonLeft), + (MouseButton::RIGHT, Key::ButtonRight), + (MouseButton::MIDDLE, Key::ButtonMiddle), + ]; + + for (mouse_button, linux_key) in &button_mappings { + let current = mouse.buttons.contains(*mouse_button); + let previous = prev_buttons.contains(*mouse_button); + if current != previous { + events.push(InputEvent::from(KeyEvent::new( + time, + *linux_key, + KeyState::pressed(current), + ))); + } + } + + // Write events if any + if !events.is_empty() { + events.push(InputEvent::from(SynchronizeEvent::report(time))); + + let event_count = events.len() - 1; + + // Convert InputEvent to raw input_event + let raw_events: Vec = events + .into_iter() + .map(|e| *e.as_raw()) + .collect(); + + handle + .write(&raw_events) + .context("Failed to write mouse events")?; + debug!("Updated mouse state with {} events", event_count); + } + + prev_buttons = mouse.buttons; + Ok(()) + }) +} \ No newline at end of file diff --git a/linux/src/lib.rs b/linux/src/lib.rs new file mode 100644 index 0000000..7e24c0c --- /dev/null +++ b/linux/src/lib.rs @@ -0,0 +1,92 @@ +use anyhow::Result; +use log::{debug, info, warn}; +use std::env; +use std::sync::mpsc; +use std::thread::sleep; +use std::time::{Duration, Instant}; +use stickdeck_common::*; + +mod client; +mod input; + +use client::Client; +use input::{init_gamepad, init_mouse}; + +const RETRY_SECONDS: u64 = 3; +const CHANNEL_CAPACITY: usize = 10; + +pub fn main() -> Result<()> { + env_logger::init(); + + let args: Vec = env::args().collect(); + if args.len() < 2 { + eprintln!("Usage: {} ", args[0]); + std::process::exit(1); + } + + let server_ip = &args[1]; + + // Check if user accidentally included port in IP + if server_ip.contains(':') { + eprintln!("Error: Please provide only the IP address, not IP:port"); + eprintln!("The port {} will be added automatically", PORT); + eprintln!("Usage: {} ", args[0]); + eprintln!("Example: {} 192.168.1.100", args[0]); + std::process::exit(1); + } + + let server_addr = format!("{}:{}", server_ip, PORT); + + info!("StickDeck Linux Client starting..."); + info!("Connecting to server at {}", server_addr); + + let (tx, rx) = mpsc::sync_channel::>(CHANNEL_CAPACITY); + let client = Client::new(server_addr, tx, Duration::from_secs(RETRY_SECONDS)); + client.start(); + + info!("Initializing virtual gamepad..."); + let mut update_gamepad = init_gamepad()?; + + info!("Initializing virtual mouse..."); + let mut update_mouse = init_mouse()?; + + info!("Ready! Waiting for inputs from Steam Deck..."); + + let mut updates_per_second = 0; + let mut last_print = Instant::now(); + + loop { + match rx.recv() { + Ok(packet) => { + match packet { + Packet::Timestamp(timestamp) => { + debug!("Received timestamp: {}", timestamp); + } + Packet::Gamepad(gamepad) => { + perf!("update_gamepad", { + update_gamepad(&gamepad) + }, 10)?; + updates_per_second += 1; + } + Packet::Mouse(mouse) => { + perf!("update_mouse", { + update_mouse(&mouse) + }, 10)?; + } + } + + if last_print.elapsed() >= Duration::from_secs(1) { + if updates_per_second > 0 { + debug!("Updates per second: {}", updates_per_second); + } + updates_per_second = 0; + last_print = Instant::now(); + } + } + Err(e) => { + warn!("Failed to receive packet: {}", e); + sleep(Duration::from_secs(RETRY_SECONDS)); + } + } + } +} \ No newline at end of file diff --git a/linux/src/main.rs b/linux/src/main.rs new file mode 100644 index 0000000..efd6f91 --- /dev/null +++ b/linux/src/main.rs @@ -0,0 +1,5 @@ +use stickdeck_linux; + +fn main() -> anyhow::Result<()> { + stickdeck_linux::main() +} \ No newline at end of file