├── .envrc ├── .github ├── FUNDING.yml └── workflows │ ├── cachix.yml │ ├── pre-release.yml │ ├── rust.yml │ └── tagged-release.yml ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── DOC.md ├── LICENSE ├── README.md ├── build.rs ├── config.toml ├── de.feschber.LanMouse.desktop ├── de.feschber.LanMouse.yml ├── default.nix ├── deny.toml ├── firewall └── lan-mouse.xml ├── flake.lock ├── flake.nix ├── input-capture ├── Cargo.toml └── src │ ├── dummy.rs │ ├── error.rs │ ├── layer_shell.rs │ ├── lib.rs │ ├── libei.rs │ ├── macos.rs │ ├── windows.rs │ ├── windows │ ├── display_util.rs │ └── event_thread.rs │ └── x11.rs ├── input-emulation ├── Cargo.toml └── src │ ├── dummy.rs │ ├── error.rs │ ├── lib.rs │ ├── libei.rs │ ├── macos.rs │ ├── windows.rs │ ├── wlroots.rs │ ├── x11.rs │ └── xdg_desktop_portal.rs ├── input-event ├── Cargo.toml └── src │ ├── error.rs │ ├── lib.rs │ ├── libei.rs │ └── scancode.rs ├── lan-mouse-cli ├── Cargo.toml └── src │ └── lib.rs ├── lan-mouse-gtk ├── Cargo.toml ├── build.rs ├── resources │ ├── authorization_window.ui │ ├── client_row.ui │ ├── de.feschber.LanMouse.svg │ ├── fingerprint_window.ui │ ├── key_row.ui │ ├── resources.gresource.xml │ └── window.ui └── src │ ├── authorization_window.rs │ ├── authorization_window │ └── imp.rs │ ├── client_object.rs │ ├── client_object │ └── imp.rs │ ├── client_row.rs │ ├── client_row │ └── imp.rs │ ├── fingerprint_window.rs │ ├── fingerprint_window │ └── imp.rs │ ├── key_object.rs │ ├── key_object │ └── imp.rs │ ├── key_row.rs │ ├── key_row │ └── imp.rs │ ├── lib.rs │ ├── window.rs │ └── window │ └── imp.rs ├── lan-mouse-ipc ├── Cargo.toml └── src │ ├── connect.rs │ ├── connect_async.rs │ ├── lib.rs │ └── listen.rs ├── lan-mouse-proto ├── Cargo.toml └── src │ └── lib.rs ├── nix ├── README.md ├── default.nix └── hm-module.nix ├── screenshots ├── dark.png └── light.png ├── scripts └── makeicns.sh ├── service └── lan-mouse.service ├── shell.nix └── src ├── capture.rs ├── capture_test.rs ├── client.rs ├── config.rs ├── connect.rs ├── crypto.rs ├── dns.rs ├── emulation.rs ├── emulation_test.rs ├── lib.rs ├── listen.rs ├── main.rs └── service.rs /.envrc: -------------------------------------------------------------------------------- 1 | use flake -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [feschber] 2 | ko_fi: feschber 3 | -------------------------------------------------------------------------------- /.github/workflows/cachix.yml: -------------------------------------------------------------------------------- 1 | name: Binary Cache 2 | 3 | on: [push, pull_request, workflow_dispatch] 4 | jobs: 5 | nix: 6 | strategy: 7 | matrix: 8 | os: 9 | - ubuntu-latest 10 | - macos-13 11 | - macos-14 12 | name: "Build" 13 | runs-on: ${{ matrix.os }} 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | with: 18 | submodules: recursive 19 | 20 | - uses: DeterminateSystems/nix-installer-action@main 21 | with: 22 | logger: pretty 23 | - uses: DeterminateSystems/magic-nix-cache-action@main 24 | - uses: cachix/cachix-action@v14 25 | with: 26 | name: lan-mouse 27 | authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' 28 | 29 | - name: Build lan-mouse (x86_64-linux) 30 | if: matrix.os == 'ubuntu-latest' 31 | run: nix build --print-build-logs --show-trace .#packages.x86_64-linux.lan-mouse 32 | 33 | - name: Build lan-mouse (x86_64-darwin) 34 | if: matrix.os == 'macos-13' 35 | run: nix build --print-build-logs --show-trace .#packages.x86_64-darwin.lan-mouse 36 | 37 | - name: Build lan-mouse (aarch64-darwin) 38 | if: matrix.os == 'macos-14' 39 | run: nix build --print-build-logs --show-trace .#packages.aarch64-darwin.lan-mouse 40 | 41 | -------------------------------------------------------------------------------- /.github/workflows/pre-release.yml: -------------------------------------------------------------------------------- 1 | name: "pre-release" 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | 7 | 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | linux-release-build: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: install dependencies 18 | run: | 19 | sudo apt-get update 20 | sudo apt-get install libx11-dev libxtst-dev 21 | sudo apt-get install libadwaita-1-dev libgtk-4-dev 22 | - name: Release Build 23 | run: cargo build --release 24 | - name: Upload build artifact 25 | uses: actions/upload-artifact@v4 26 | with: 27 | name: lan-mouse-linux 28 | path: target/release/lan-mouse 29 | 30 | windows-release-build: 31 | runs-on: windows-latest 32 | steps: 33 | - uses: actions/setup-python@v5 34 | with: 35 | python-version: '3.11' 36 | # needed for cache restore 37 | - name: create gtk dir 38 | run: mkdir C:\gtk-build\gtk\x64\release 39 | - uses: actions/cache@v3 40 | id: cache 41 | with: 42 | path: c:/gtk-build/gtk/x64/release/** 43 | key: gtk-windows-build 44 | restore-keys: gtk-windows-build 45 | - name: Update path 46 | run: | 47 | echo "PKG_CONFIG=C:\gtk-build\gtk\x64\release\bin\pkgconf.exe" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append 48 | echo "C:\pkg-config-lite-0.28-1\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append 49 | echo "C:\gtk-build\gtk\x64\release\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append 50 | echo $env:GITHUB_PATH 51 | echo $env:PATH 52 | - name: Install dependencies 53 | if: steps.cache.outputs.cache-hit != 'true' 54 | run: | 55 | # choco install msys2 56 | # choco install visualstudio2022-workload-vctools 57 | # choco install pkgconfiglite 58 | py -m venv .venv 59 | .venv\Scripts\activate.ps1 60 | py -m pip install gvsbuild 61 | # see https://github.com/wingtk/gvsbuild/pull/1004 62 | Move-Item "C:\Program Files\Git\usr\bin" "C:\Program Files\Git\usr\notbin" 63 | Move-Item "C:\Program Files\Git\bin" "C:\Program Files\Git\notbin" 64 | gvsbuild build --msys-dir=C:\msys64 gtk4 libadwaita librsvg 65 | Move-Item "C:\Program Files\Git\usr\notbin" "C:\Program Files\Git\usr\bin" 66 | Move-Item "C:\Program Files\Git\notbin" "C:\Program Files\Git\bin" 67 | - uses: actions/checkout@v4 68 | - name: Release Build 69 | run: cargo build --release 70 | - name: Create Archive 71 | run: | 72 | mkdir "lan-mouse-windows" 73 | Get-Childitem -Path "C:\\gtk-build\\gtk\\x64\\release\\bin\\*.dll" -File -Recurse | Copy-Item -Destination "lan-mouse-windows" 74 | Copy-Item -Path "target\release\lan-mouse.exe" -Destination "lan-mouse-windows" 75 | Compress-Archive -Path "lan-mouse-windows\*" -DestinationPath lan-mouse-windows.zip 76 | - name: Upload build artifact 77 | uses: actions/upload-artifact@v4 78 | with: 79 | name: lan-mouse-windows 80 | path: lan-mouse-windows.zip 81 | 82 | macos-release-build: 83 | runs-on: macos-13 84 | steps: 85 | - uses: actions/checkout@v4 86 | - name: install dependencies 87 | run: brew install gtk4 libadwaita imagemagick 88 | - name: Release Build 89 | run: | 90 | cargo build --release 91 | cp target/release/lan-mouse lan-mouse-macos-intel 92 | - name: Make icns 93 | run: scripts/makeicns.sh 94 | - name: Install cargo bundle 95 | run: cargo install cargo-bundle 96 | - name: Bundle 97 | run: cargo bundle --release 98 | - name: Zip bundle 99 | run: | 100 | cd target/release/bundle/osx 101 | zip -r "lan-mouse-macos-intel.zip" "Lan Mouse.app" 102 | - name: Upload build artifact 103 | uses: actions/upload-artifact@v4 104 | with: 105 | name: lan-mouse-macos-intel 106 | path: target/release/bundle/osx/lan-mouse-macos-intel.zip 107 | 108 | macos-aarch64-release-build: 109 | runs-on: macos-14 110 | steps: 111 | - uses: actions/checkout@v4 112 | - name: install dependencies 113 | run: brew install gtk4 libadwaita imagemagick 114 | - name: Release Build 115 | run: | 116 | cargo build --release 117 | cp target/release/lan-mouse lan-mouse-macos-aarch64 118 | - name: Make icns 119 | run: scripts/makeicns.sh 120 | - name: Install cargo bundle 121 | run: cargo install cargo-bundle 122 | - name: Bundle 123 | run: cargo bundle --release 124 | - name: Zip bundle 125 | run: | 126 | cd target/release/bundle/osx 127 | zip -r "lan-mouse-macos-aarch64.zip" "Lan Mouse.app" 128 | - name: Upload build artifact 129 | uses: actions/upload-artifact@v4 130 | with: 131 | name: lan-mouse-macos-aarch64 132 | path: target/release/bundle/osx/lan-mouse-macos-aarch64.zip 133 | 134 | pre-release: 135 | name: "Pre Release" 136 | needs: [windows-release-build, linux-release-build, macos-release-build, macos-aarch64-release-build] 137 | runs-on: "ubuntu-latest" 138 | steps: 139 | - name: Download build artifacts 140 | uses: actions/download-artifact@v4 141 | - name: Create Release 142 | uses: "marvinpinto/action-automatic-releases@latest" 143 | with: 144 | repo_token: "${{ secrets.GITHUB_TOKEN }}" 145 | automatic_release_tag: "latest" 146 | prerelease: true 147 | title: "Development Build" 148 | files: | 149 | lan-mouse-linux/lan-mouse 150 | lan-mouse-macos-intel/lan-mouse-macos-intel.zip 151 | lan-mouse-macos-aarch64/lan-mouse-macos-aarch64.zip 152 | lan-mouse-windows/lan-mouse-windows.zip 153 | -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: Rust 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | build-linux: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | - name: install dependencies 20 | run: | 21 | sudo apt-get update 22 | sudo apt-get install libx11-dev libxtst-dev 23 | sudo apt-get install libadwaita-1-dev libgtk-4-dev 24 | - name: Build 25 | run: cargo build --verbose 26 | - name: Run tests 27 | run: cargo test --verbose 28 | - name: Check Formatting 29 | run: cargo fmt --check 30 | - name: Clippy 31 | run: cargo clippy --all-features --all-targets -- --deny warnings 32 | - name: Upload build artifact 33 | uses: actions/upload-artifact@v4 34 | with: 35 | name: lan-mouse 36 | path: target/debug/lan-mouse 37 | 38 | build-windows: 39 | 40 | runs-on: windows-latest 41 | 42 | steps: 43 | - uses: actions/checkout@v4 44 | - uses: actions/setup-python@v5 45 | with: 46 | python-version: '3.11' 47 | # needed for cache restore 48 | - name: create gtk dir 49 | run: mkdir C:\gtk-build\gtk\x64\release 50 | - uses: actions/cache@v3 51 | id: cache 52 | with: 53 | path: c:/gtk-build/gtk/x64/release/** 54 | key: gtk-windows-build 55 | restore-keys: gtk-windows-build 56 | - name: Update path 57 | run: | 58 | echo "PKG_CONFIG=C:\gtk-build\gtk\x64\release\bin\pkgconf.exe" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append 59 | echo "C:\pkg-config-lite-0.28-1\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append 60 | echo "C:\gtk-build\gtk\x64\release\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append 61 | echo $env:GITHUB_PATH 62 | echo $env:PATH 63 | - name: Install dependencies 64 | if: steps.cache.outputs.cache-hit != 'true' 65 | run: | 66 | # choco install msys2 67 | # choco install visualstudio2022-workload-vctools 68 | # choco install pkgconfiglite 69 | py -m venv .venv 70 | .venv\Scripts\activate.ps1 71 | py -m pip install gvsbuild 72 | # see https://github.com/wingtk/gvsbuild/pull/1004 73 | Move-Item "C:\Program Files\Git\usr\bin" "C:\Program Files\Git\usr\notbin" 74 | Move-Item "C:\Program Files\Git\bin" "C:\Program Files\Git\notbin" 75 | gvsbuild build --msys-dir=C:\msys64 gtk4 libadwaita librsvg 76 | Move-Item "C:\Program Files\Git\usr\notbin" "C:\Program Files\Git\usr\bin" 77 | Move-Item "C:\Program Files\Git\notbin" "C:\Program Files\Git\bin" 78 | - name: Build 79 | run: cargo build --verbose 80 | - name: Run tests 81 | run: cargo test --verbose 82 | - name: Check Formatting 83 | run: cargo fmt --check 84 | - name: Clippy 85 | run: cargo clippy --all-features --all-targets -- --deny warnings 86 | - name: Copy Gtk Dlls 87 | run: Get-Childitem -Path "C:\\gtk-build\\gtk\\x64\\release\\bin\\*.dll" -File -Recurse | Copy-Item -Destination "target\\debug" 88 | - name: Upload build artifact 89 | uses: actions/upload-artifact@v4 90 | with: 91 | name: lan-mouse-windows 92 | path: | 93 | target/debug/lan-mouse.exe 94 | target/debug/*.dll 95 | 96 | build-macos: 97 | runs-on: macos-13 98 | steps: 99 | - uses: actions/checkout@v4 100 | - name: install dependencies 101 | run: brew install gtk4 libadwaita imagemagick 102 | - name: Build 103 | run: cargo build --verbose 104 | - name: Run tests 105 | run: cargo test --verbose 106 | - name: Check Formatting 107 | run: cargo fmt --check 108 | - name: Clippy 109 | run: cargo clippy --all-features --all-targets -- --deny warnings 110 | - name: Make icns 111 | run: scripts/makeicns.sh 112 | - name: Install cargo bundle 113 | run: cargo install cargo-bundle 114 | - name: Bundle 115 | run: cargo bundle 116 | - name: Zip bundle 117 | run: | 118 | cd target/debug/bundle/osx 119 | zip -r "Lan Mouse macOS (Intel).zip" "Lan Mouse.app" 120 | - name: Upload build artifact 121 | uses: actions/upload-artifact@v4 122 | with: 123 | name: Lan Mouse macOS (Intel) 124 | path: target/debug/bundle/osx/Lan Mouse macOS (Intel).zip 125 | 126 | build-macos-aarch64: 127 | runs-on: macos-14 128 | steps: 129 | - uses: actions/checkout@v4 130 | - name: install dependencies 131 | run: brew install gtk4 libadwaita imagemagick 132 | - name: Build 133 | run: cargo build --verbose 134 | - name: Run tests 135 | run: cargo test --verbose 136 | - name: Check Formatting 137 | run: cargo fmt --check 138 | - name: Clippy 139 | run: cargo clippy --all-features --all-targets -- --deny warnings 140 | - name: Make icns 141 | run: scripts/makeicns.sh 142 | - name: Install cargo bundle 143 | run: cargo install cargo-bundle 144 | - name: Bundle 145 | run: cargo bundle 146 | - name: Zip bundle 147 | run: | 148 | cd target/debug/bundle/osx 149 | zip -r "Lan Mouse macOS (ARM).zip" "Lan Mouse.app" 150 | - name: Upload build artifact 151 | uses: actions/upload-artifact@v4 152 | with: 153 | name: Lan Mouse macOS (ARM) 154 | path: target/debug/bundle/osx/Lan Mouse macOS (ARM).zip 155 | -------------------------------------------------------------------------------- /.github/workflows/tagged-release.yml: -------------------------------------------------------------------------------- 1 | name: "Tagged Release" 2 | 3 | on: 4 | push: 5 | tags: 6 | - v** 7 | 8 | jobs: 9 | linux-release-build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: install dependencies 14 | run: | 15 | sudo apt-get update 16 | sudo apt-get install libx11-dev libxtst-dev 17 | sudo apt-get install libadwaita-1-dev libgtk-4-dev 18 | - name: Release Build 19 | run: cargo build --release 20 | - name: Upload build artifact 21 | uses: actions/upload-artifact@v4 22 | with: 23 | name: lan-mouse-linux 24 | path: target/release/lan-mouse 25 | 26 | windows-release-build: 27 | runs-on: windows-latest 28 | steps: 29 | - uses: actions/setup-python@v5 30 | with: 31 | python-version: '3.11' 32 | # needed for cache restore 33 | - name: create gtk dir 34 | run: mkdir C:\gtk-build\gtk\x64\release 35 | - uses: actions/cache@v3 36 | id: cache 37 | with: 38 | path: c:/gtk-build/gtk/x64/release/** 39 | key: gtk-windows-build 40 | restore-keys: gtk-windows-build 41 | - name: Update path 42 | run: | 43 | echo "PKG_CONFIG=C:\gtk-build\gtk\x64\release\bin\pkgconf.exe" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append 44 | echo "C:\pkg-config-lite-0.28-1\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append 45 | echo "C:\gtk-build\gtk\x64\release\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append 46 | echo $env:GITHUB_PATH 47 | echo $env:PATH 48 | - name: Install dependencies 49 | if: steps.cache.outputs.cache-hit != 'true' 50 | run: | 51 | # choco install msys2 52 | # choco install visualstudio2022-workload-vctools 53 | # choco install pkgconfiglite 54 | py -m venv .venv 55 | .venv\Scripts\activate.ps1 56 | py -m pip install gvsbuild 57 | # see https://github.com/wingtk/gvsbuild/pull/1004 58 | Move-Item "C:\Program Files\Git\usr\bin" "C:\Program Files\Git\usr\notbin" 59 | Move-Item "C:\Program Files\Git\bin" "C:\Program Files\Git\notbin" 60 | gvsbuild build --msys-dir=C:\msys64 gtk4 libadwaita librsvg 61 | Move-Item "C:\Program Files\Git\usr\notbin" "C:\Program Files\Git\usr\bin" 62 | Move-Item "C:\Program Files\Git\notbin" "C:\Program Files\Git\bin" 63 | - uses: actions/checkout@v4 64 | - name: Release Build 65 | run: cargo build --release 66 | - name: Create Archive 67 | run: | 68 | mkdir "lan-mouse-windows" 69 | Get-Childitem -Path "C:\\gtk-build\\gtk\\x64\\release\\bin\\*.dll" -File -Recurse | Copy-Item -Destination "lan-mouse-windows" 70 | Copy-Item -Path "target\release\lan-mouse.exe" -Destination "lan-mouse-windows" 71 | Compress-Archive -Path "lan-mouse-windows\*" -DestinationPath lan-mouse-windows.zip 72 | - name: Upload build artifact 73 | uses: actions/upload-artifact@v4 74 | with: 75 | name: lan-mouse-windows 76 | path: lan-mouse-windows.zip 77 | 78 | macos-release-build: 79 | runs-on: macos-13 80 | steps: 81 | - uses: actions/checkout@v4 82 | - name: install dependencies 83 | run: brew install gtk4 libadwaita imagemagick 84 | - name: Release Build 85 | run: | 86 | cargo build --release 87 | cp target/release/lan-mouse lan-mouse-macos-intel 88 | - name: Make icns 89 | run: scripts/makeicns.sh 90 | - name: Install cargo bundle 91 | run: cargo install cargo-bundle 92 | - name: Bundle 93 | run: cargo bundle --release 94 | - name: Zip bundle 95 | run: | 96 | cd target/release/bundle/osx 97 | zip -r "lan-mouse-macos-intel.zip" "Lan Mouse.app" 98 | - name: Upload build artifact 99 | uses: actions/upload-artifact@v4 100 | with: 101 | name: lan-mouse-macos-intel.zip 102 | path: target/release/bundle/osx/lan-mouse-macos-intel.zip 103 | 104 | macos-aarch64-release-build: 105 | runs-on: macos-14 106 | steps: 107 | - uses: actions/checkout@v4 108 | - name: install dependencies 109 | run: brew install gtk4 libadwaita imagemagick 110 | - name: Release Build 111 | run: | 112 | cargo build --release 113 | cp target/release/lan-mouse lan-mouse-macos-aarch64 114 | - name: Make icns 115 | run: scripts/makeicns.sh 116 | - name: Install cargo bundle 117 | run: cargo install cargo-bundle 118 | - name: Bundle 119 | run: cargo bundle --release 120 | - name: Zip bundle 121 | run: | 122 | cd target/release/bundle/osx 123 | zip -r "lan-mouse-macos-aarch64.zip" "Lan Mouse.app" 124 | - name: Upload build artifact 125 | uses: actions/upload-artifact@v4 126 | with: 127 | name: lan-mouse-macos-aarch64.zip 128 | path: target/release/bundle/osx/lan-mouse-macos-aarch64.zip 129 | 130 | tagged-release: 131 | name: "Tagged Release" 132 | needs: [windows-release-build, linux-release-build, macos-release-build, macos-aarch64-release-build] 133 | runs-on: "ubuntu-latest" 134 | steps: 135 | - name: Download build artifacts 136 | uses: actions/download-artifact@v4 137 | - name: Create Release 138 | uses: "marvinpinto/action-automatic-releases@latest" 139 | with: 140 | repo_token: "${{ secrets.GITHUB_TOKEN }}" 141 | prerelease: false 142 | files: | 143 | lan-mouse-linux/lan-mouse 144 | lan-mouse-macos-intel/lan-mouse-macos-intel.zip 145 | lan-mouse-macos-aarch64/lan-mouse-macos-aarch64.zip 146 | lan-mouse-windows/lan-mouse-windows.zip 147 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | .gdbinit 3 | .idea/ 4 | .vs/ 5 | .vscode/ 6 | .direnv/ 7 | result 8 | *.pem 9 | *.csr 10 | extfile.conf 11 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = [ 3 | "input-capture", 4 | "input-emulation", 5 | "input-event", 6 | "lan-mouse-ipc", 7 | "lan-mouse-cli", 8 | "lan-mouse-gtk", 9 | "lan-mouse-proto", 10 | ] 11 | 12 | [package] 13 | name = "lan-mouse" 14 | description = "Software KVM Switch / mouse & keyboard sharing software for Local Area Networks" 15 | version = "0.10.0" 16 | edition = "2021" 17 | license = "GPL-3.0-or-later" 18 | repository = "https://github.com/feschber/lan-mouse" 19 | 20 | [profile.release] 21 | codegen-units = 1 22 | lto = "fat" 23 | strip = true 24 | panic = "abort" 25 | 26 | [build-dependencies] 27 | shadow-rs = "0.38.0" 28 | 29 | [dependencies] 30 | input-event = { path = "input-event", version = "0.3.0" } 31 | input-emulation = { path = "input-emulation", version = "0.3.0", default-features = false } 32 | input-capture = { path = "input-capture", version = "0.3.0", default-features = false } 33 | lan-mouse-cli = { path = "lan-mouse-cli", version = "0.2.0" } 34 | lan-mouse-gtk = { path = "lan-mouse-gtk", version = "0.2.0", optional = true } 35 | lan-mouse-ipc = { path = "lan-mouse-ipc", version = "0.2.0" } 36 | lan-mouse-proto = { path = "lan-mouse-proto", version = "0.2.0" } 37 | shadow-rs = { version = "0.38.0", features = ["metadata"] } 38 | 39 | hickory-resolver = "0.24.1" 40 | toml = "0.8" 41 | serde = { version = "1.0", features = ["derive"] } 42 | log = "0.4.20" 43 | env_logger = "0.11.3" 44 | serde_json = "1.0.107" 45 | tokio = { version = "1.32.0", features = [ 46 | "io-util", 47 | "io-std", 48 | "macros", 49 | "net", 50 | "process", 51 | "rt", 52 | "sync", 53 | "signal", 54 | ] } 55 | futures = "0.3.28" 56 | clap = { version = "4.4.11", features = ["derive"] } 57 | slab = "0.4.9" 58 | thiserror = "2.0.0" 59 | tokio-util = "0.7.11" 60 | local-channel = "0.1.5" 61 | webrtc-dtls = { version = "0.10.0", features = ["pem"] } 62 | webrtc-util = "0.9.0" 63 | rustls = { version = "0.23.12", default-features = false, features = [ 64 | "std", 65 | "ring", 66 | ] } 67 | rcgen = "0.13.1" 68 | sha2 = "0.10.8" 69 | 70 | [target.'cfg(unix)'.dependencies] 71 | libc = "0.2.148" 72 | 73 | [features] 74 | default = [ 75 | "gtk", 76 | "layer_shell_capture", 77 | "x11_capture", 78 | "libei_capture", 79 | "wlroots_emulation", 80 | "libei_emulation", 81 | "rdp_emulation", 82 | "x11_emulation", 83 | ] 84 | gtk = ["dep:lan-mouse-gtk"] 85 | layer_shell_capture = ["input-capture/layer_shell"] 86 | x11_capture = ["input-capture/x11"] 87 | libei_capture = ["input-event/libei", "input-capture/libei"] 88 | libei_emulation = ["input-event/libei", "input-emulation/libei"] 89 | wlroots_emulation = ["input-emulation/wlroots"] 90 | x11_emulation = ["input-emulation/x11"] 91 | rdp_emulation = ["input-emulation/remote_desktop_portal"] 92 | 93 | [package.metadata.bundle] 94 | name = "Lan Mouse" 95 | icon = ["target/icon.icns"] 96 | identifier = "de.feschber.LanMouse" 97 | -------------------------------------------------------------------------------- /DOC.md: -------------------------------------------------------------------------------- 1 | # General Software Architecture 2 | 3 | ## Events 4 | 5 | Each instance of lan-mouse can emit and receive events, where 6 | an event is either a mouse or keyboard event for now. 7 | 8 | The general Architecture is shown in the following flow chart: 9 | ```mermaid 10 | graph TD 11 | A[Wayland Backend] -->|WaylandEvent| D{Input} 12 | B[X11 Backend] -->|X11Event| D{Input} 13 | C[Windows Backend] -->|WindowsEvent| D{Input} 14 | D -->|Abstract Event| E[Emitter] 15 | E -->|Udp Event| F[Receiver] 16 | F -->|Abstract Event| G{Dispatcher} 17 | G -->|Wayland Event| H[Wayland Backend] 18 | G -->|X11 Event| I[X11 Backend] 19 | G -->|Windows Event| J[Windows Backend] 20 | ``` 21 | 22 | ### Input 23 | The input component is responsible for translating inputs from a given backend 24 | to a standardized format and passing them to the event emitter. 25 | 26 | ### Emitter 27 | The event emitter serializes events and sends them over the network 28 | to the correct client. 29 | 30 | ### Receiver 31 | The receiver receives events over the network and deserializes them into 32 | the standardized event format. 33 | 34 | ### Dispatcher 35 | The dispatcher component takes events from the event receiver and passes them 36 | to the correct backend corresponding to the type of client. 37 | 38 | 39 | ## Requests 40 | 41 | // TODO this currently works differently 42 | 43 | Aside from events, requests can be sent via a simple protocol. 44 | For this, a simple tcp server is listening on the same port as the udp 45 | event receiver and accepts requests for connecting to a device or to 46 | request the keymap of a device. 47 | 48 | ```mermaid 49 | sequenceDiagram 50 | Alice->>+Bob: Request Connection (secret) 51 | Bob-->>-Alice: Ack (Keyboard Layout) 52 | ``` 53 | 54 | ## Problems 55 | The general Idea is to have a bidirectional connection by default, meaning 56 | any connected device can not only receive events but also send events back. 57 | 58 | This way when connecting e.g. a PC to a Laptop, either device can be used 59 | to control the other. 60 | 61 | It needs to be ensured, that whenever a device is controlled the controlled 62 | device does not transmit the events back to the original sender. 63 | Otherwise events are multiplied and either one of the instances crashes. 64 | 65 | To keep the implementation of input backends simple this needs to be handled 66 | on the server level. 67 | 68 | ## Device State - Active and Inactive 69 | To solve this problem, each device can be in exactly two states: 70 | 71 | Either events are sent or received. 72 | 73 | This ensures that 74 | - a) Events can never result in a feedback loop. 75 | - b) As soon as a virtual input enters another client, lan-mouse will stop receiving events, 76 | which ensures clients can only be controlled directly and not indirectly through other clients. 77 | 78 | -------------------------------------------------------------------------------- /build.rs: -------------------------------------------------------------------------------- 1 | use shadow_rs::ShadowBuilder; 2 | 3 | fn main() { 4 | ShadowBuilder::builder() 5 | .deny_const(Default::default()) 6 | .build() 7 | .expect("shadow build"); 8 | } 9 | -------------------------------------------------------------------------------- /config.toml: -------------------------------------------------------------------------------- 1 | # example configuration 2 | 3 | # configure release bind 4 | release_bind = [ "KeyA", "KeyS", "KeyD", "KeyF" ] 5 | 6 | # optional port (defaults to 4242) 7 | port = 4242 8 | 9 | # list of authorized tls certificate fingerprints that 10 | # are accepted for incoming traffic 11 | [authorized_fingerprints] 12 | "bc:05:ab:7a:a4:de:88:8c:2f:92:ac:bc:b8:49:b8:24:0d:44:b3:e6:a4:ef:d7:0b:6c:69:6d:77:53:0b:14:80" = "iridium" 13 | 14 | # define a client on the right side with host name "iridium" 15 | [[clients]] 16 | # position (left | right | top | bottom) 17 | position = "right" 18 | # hostname 19 | hostname = "iridium" 20 | # activate this client immediately when lan-mouse is started 21 | activate_on_startup = true 22 | # optional list of (known) ip addresses 23 | ips = ["192.168.178.156"] 24 | 25 | # define a client on the left side with IP address 192.168.178.189 26 | [[clients]] 27 | position = "left" 28 | # The hostname is optional: When no hostname is specified, 29 | # at least one ip address needs to be specified. 30 | hostname = "thorium" 31 | # ips for ethernet and wifi 32 | ips = ["192.168.178.189", "192.168.178.172"] 33 | # optional port 34 | port = 4242 35 | -------------------------------------------------------------------------------- /de.feschber.LanMouse.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Categories=Utility; 3 | Comment[en_US]=Mouse & Keyboard sharing via LAN 4 | Comment=Mouse & Keyboard sharing via LAN 5 | Comment[de_DE]=Maus- und Tastaturfreigabe über LAN 6 | Exec=lan-mouse 7 | Icon=de.feschber.LanMouse 8 | Name[en_US]=Lan Mouse 9 | Name=Lan Mouse 10 | StartupNotify=true 11 | Terminal=false 12 | Type=Application 13 | -------------------------------------------------------------------------------- /de.feschber.LanMouse.yml: -------------------------------------------------------------------------------- 1 | app-id: de.feschber.LanMouse 2 | runtime: org.freedesktop.Platform 3 | runtime-version: '22.08' 4 | sdk: org.freedesktop.Sdk 5 | command: target/release/lan-mouse 6 | modules: 7 | - name: hello 8 | buildsystem: simple 9 | build-commands: 10 | - cargo build --release 11 | - install -D lan-mouse /app/bin/lan-mouse 12 | sources: 13 | - type: file 14 | path: target/release/lan-mouse 15 | -------------------------------------------------------------------------------- /default.nix: -------------------------------------------------------------------------------- 1 | { pkgs ? import <nixpkgs> { } 2 | }: 3 | pkgs.callPackage nix/default.nix { } 4 | -------------------------------------------------------------------------------- /firewall/lan-mouse.xml: -------------------------------------------------------------------------------- 1 | <?xml version="1.0" encoding="utf-8"?> 2 | <!-- for packaging: /usr/lib/firewalld/services/lan-mouse.xml --> 3 | <!-- configure manually: /etc/firewalld/services/lan-mouse.xml --> 4 | <service> 5 | <short>LAN Mouse</short> 6 | <description>mouse and keyboard sharing via LAN</description> 7 | <port port="4242" protocol="udp"/> 8 | </service> 9 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "nixpkgs": { 4 | "locked": { 5 | "lastModified": 1740560979, 6 | "narHash": "sha256-Vr3Qi346M+8CjedtbyUevIGDZW8LcA1fTG0ugPY/Hic=", 7 | "owner": "nixos", 8 | "repo": "nixpkgs", 9 | "rev": "5135c59491985879812717f4c9fea69604e7f26f", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "nixos", 14 | "ref": "nixos-unstable", 15 | "repo": "nixpkgs", 16 | "type": "github" 17 | } 18 | }, 19 | "root": { 20 | "inputs": { 21 | "nixpkgs": "nixpkgs", 22 | "rust-overlay": "rust-overlay" 23 | } 24 | }, 25 | "rust-overlay": { 26 | "inputs": { 27 | "nixpkgs": [ 28 | "nixpkgs" 29 | ] 30 | }, 31 | "locked": { 32 | "lastModified": 1740623427, 33 | "narHash": "sha256-3SdPQrZoa4odlScFDUHd4CUPQ/R1gtH4Mq9u8CBiK8M=", 34 | "owner": "oxalica", 35 | "repo": "rust-overlay", 36 | "rev": "d342e8b5fd88421ff982f383c853f0fc78a847ab", 37 | "type": "github" 38 | }, 39 | "original": { 40 | "owner": "oxalica", 41 | "repo": "rust-overlay", 42 | "type": "github" 43 | } 44 | } 45 | }, 46 | "root": "root", 47 | "version": 7 48 | } 49 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Nix Flake for lan-mouse"; 3 | inputs = { 4 | nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; 5 | rust-overlay = { 6 | url = "github:oxalica/rust-overlay"; 7 | inputs.nixpkgs.follows = "nixpkgs"; 8 | }; 9 | }; 10 | outputs = { 11 | self, 12 | nixpkgs, 13 | rust-overlay, 14 | ... 15 | }: let 16 | inherit (nixpkgs) lib; 17 | genSystems = lib.genAttrs [ 18 | "aarch64-darwin" 19 | "aarch64-linux" 20 | "x86_64-darwin" 21 | "x86_64-linux" 22 | ]; 23 | pkgsFor = system: 24 | import nixpkgs { 25 | inherit system; 26 | 27 | overlays = [ 28 | rust-overlay.overlays.default 29 | ]; 30 | }; 31 | mkRustToolchain = pkgs: 32 | pkgs.rust-bin.stable.latest.default.override { 33 | extensions = ["rust-src"]; 34 | }; 35 | pkgs = genSystems (system: import nixpkgs {inherit system;}); 36 | in { 37 | packages = genSystems (system: rec { 38 | default = pkgs.${system}.callPackage ./nix {}; 39 | lan-mouse = default; 40 | }); 41 | homeManagerModules.default = import ./nix/hm-module.nix self; 42 | devShells = genSystems (system: let 43 | pkgs = pkgsFor system; 44 | rust = mkRustToolchain pkgs; 45 | in { 46 | default = pkgs.mkShell { 47 | packages = with pkgs; [ 48 | rust 49 | rust-analyzer-unwrapped 50 | pkg-config 51 | xorg.libX11 52 | gtk4 53 | libadwaita 54 | librsvg 55 | xorg.libXtst 56 | ] ++ lib.optionals stdenv.isDarwin 57 | (with darwin.apple_sdk_11_0.frameworks; [ 58 | CoreGraphics 59 | ApplicationServices 60 | ]); 61 | 62 | RUST_SRC_PATH = "${rust}/lib/rustlib/src/rust/library"; 63 | }; 64 | }); 65 | }; 66 | } 67 | -------------------------------------------------------------------------------- /input-capture/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "input-capture" 3 | description = "cross-platform input-capture library used by lan-mouse" 4 | version = "0.3.0" 5 | edition = "2021" 6 | license = "GPL-3.0-or-later" 7 | repository = "https://github.com/feschber/lan-mouse" 8 | 9 | [dependencies] 10 | futures = "0.3.28" 11 | futures-core = "0.3.30" 12 | log = "0.4.22" 13 | input-event = { path = "../input-event", version = "0.3.0" } 14 | memmap = "0.7" 15 | tempfile = "3.8" 16 | thiserror = "2.0.0" 17 | tokio = { version = "1.32.0", features = [ 18 | "io-util", 19 | "io-std", 20 | "macros", 21 | "net", 22 | "process", 23 | "rt", 24 | "sync", 25 | "signal", 26 | ] } 27 | once_cell = "1.19.0" 28 | async-trait = "0.1.81" 29 | tokio-util = "0.7.11" 30 | 31 | 32 | [target.'cfg(all(unix, not(target_os="macos")))'.dependencies] 33 | wayland-client = { version = "0.31.1", optional = true } 34 | wayland-protocols = { version = "0.32.1", features = [ 35 | "client", 36 | "staging", 37 | "unstable", 38 | ], optional = true } 39 | wayland-protocols-wlr = { version = "0.3.1", features = [ 40 | "client", 41 | ], optional = true } 42 | x11 = { version = "2.21.0", features = ["xlib", "xtest"], optional = true } 43 | ashpd = { version = "0.10", default-features = false, features = [ 44 | "tokio", 45 | ], optional = true } 46 | reis = { version = "0.4", features = ["tokio"], optional = true } 47 | 48 | [target.'cfg(target_os="macos")'.dependencies] 49 | core-graphics = { version = "0.24.0", features = ["highsierra"] } 50 | core-foundation = "0.10.0" 51 | core-foundation-sys = "0.8.6" 52 | libc = "0.2.155" 53 | keycode = "0.4.0" 54 | bitflags = "2.6.0" 55 | 56 | [target.'cfg(windows)'.dependencies] 57 | windows = { version = "0.58.0", features = [ 58 | "Win32_System_LibraryLoader", 59 | "Win32_System_Threading", 60 | "Win32_Foundation", 61 | "Win32_Graphics", 62 | "Win32_Graphics_Gdi", 63 | "Win32_UI_Input_KeyboardAndMouse", 64 | "Win32_UI_WindowsAndMessaging", 65 | ] } 66 | 67 | [features] 68 | default = ["layer_shell", "x11", "libei"] 69 | layer_shell = [ 70 | "dep:wayland-client", 71 | "dep:wayland-protocols", 72 | "dep:wayland-protocols-wlr", 73 | ] 74 | x11 = ["dep:x11"] 75 | libei = ["dep:reis", "dep:ashpd"] 76 | -------------------------------------------------------------------------------- /input-capture/src/dummy.rs: -------------------------------------------------------------------------------- 1 | use std::f64::consts::PI; 2 | use std::pin::Pin; 3 | use std::task::{ready, Context, Poll}; 4 | use std::time::Duration; 5 | 6 | use async_trait::async_trait; 7 | use futures_core::Stream; 8 | use input_event::PointerEvent; 9 | use tokio::time::{self, Instant, Interval}; 10 | 11 | use super::{Capture, CaptureError, CaptureEvent, Position}; 12 | 13 | pub struct DummyInputCapture { 14 | start: Option<Instant>, 15 | interval: Interval, 16 | offset: (i32, i32), 17 | } 18 | 19 | impl DummyInputCapture { 20 | pub fn new() -> Self { 21 | Self { 22 | start: None, 23 | interval: time::interval(Duration::from_millis(1)), 24 | offset: (0, 0), 25 | } 26 | } 27 | } 28 | 29 | impl Default for DummyInputCapture { 30 | fn default() -> Self { 31 | Self::new() 32 | } 33 | } 34 | 35 | #[async_trait] 36 | impl Capture for DummyInputCapture { 37 | async fn create(&mut self, _pos: Position) -> Result<(), CaptureError> { 38 | Ok(()) 39 | } 40 | 41 | async fn destroy(&mut self, _pos: Position) -> Result<(), CaptureError> { 42 | Ok(()) 43 | } 44 | 45 | async fn release(&mut self) -> Result<(), CaptureError> { 46 | Ok(()) 47 | } 48 | 49 | async fn terminate(&mut self) -> Result<(), CaptureError> { 50 | Ok(()) 51 | } 52 | } 53 | 54 | const FREQUENCY_HZ: f64 = 1.0; 55 | const RADIUS: f64 = 100.0; 56 | 57 | impl Stream for DummyInputCapture { 58 | type Item = Result<(Position, CaptureEvent), CaptureError>; 59 | 60 | fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> { 61 | let current = ready!(self.interval.poll_tick(cx)); 62 | let event = match self.start { 63 | None => { 64 | self.start.replace(current); 65 | CaptureEvent::Begin 66 | } 67 | Some(start) => { 68 | let elapsed = start.elapsed(); 69 | let elapsed_sec_f64 = elapsed.as_secs_f64(); 70 | let second_fraction = elapsed_sec_f64 - elapsed_sec_f64 as u64 as f64; 71 | let radians = second_fraction * 2. * PI * FREQUENCY_HZ; 72 | let offset = (radians.cos() * RADIUS * 2., (radians * 2.).sin() * RADIUS); 73 | let offset = (offset.0 as i32, offset.1 as i32); 74 | let relative_motion = (offset.0 - self.offset.0, offset.1 - self.offset.1); 75 | self.offset = offset; 76 | let (dx, dy) = (relative_motion.0 as f64, relative_motion.1 as f64); 77 | CaptureEvent::Input(input_event::Event::Pointer(PointerEvent::Motion { 78 | time: 0, 79 | dx, 80 | dy, 81 | })) 82 | } 83 | }; 84 | Poll::Ready(Some(Ok((Position::Left, event)))) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /input-capture/src/error.rs: -------------------------------------------------------------------------------- 1 | use thiserror::Error; 2 | 3 | #[derive(Debug, Error)] 4 | pub enum InputCaptureError { 5 | #[error("error creating input-capture: `{0}`")] 6 | Create(#[from] CaptureCreationError), 7 | #[error("error while capturing input: `{0}`")] 8 | Capture(#[from] CaptureError), 9 | } 10 | 11 | #[cfg(all(unix, feature = "layer_shell", not(target_os = "macos")))] 12 | use std::io; 13 | #[cfg(all(unix, feature = "layer_shell", not(target_os = "macos")))] 14 | use wayland_client::{ 15 | backend::WaylandError, 16 | globals::{BindError, GlobalError}, 17 | ConnectError, DispatchError, 18 | }; 19 | 20 | #[cfg(all(unix, feature = "libei", not(target_os = "macos")))] 21 | use ashpd::desktop::ResponseError; 22 | 23 | #[cfg(target_os = "macos")] 24 | use core_graphics::base::CGError; 25 | 26 | #[derive(Debug, Error)] 27 | pub enum CaptureError { 28 | #[error("activation stream closed unexpectedly")] 29 | ActivationClosed, 30 | #[error("libei stream was closed")] 31 | EndOfStream, 32 | #[error("io error: `{0}`")] 33 | Io(#[from] std::io::Error), 34 | #[cfg(all(unix, feature = "libei", not(target_os = "macos")))] 35 | #[error("libei error: `{0}`")] 36 | Reis(#[from] reis::Error), 37 | #[cfg(all(unix, feature = "libei", not(target_os = "macos")))] 38 | #[error(transparent)] 39 | Portal(#[from] ashpd::Error), 40 | #[cfg(all(unix, feature = "libei", not(target_os = "macos")))] 41 | #[error("libei disconnected - reason: `{0}`")] 42 | Disconnected(String), 43 | #[cfg(target_os = "macos")] 44 | #[error("failed to warp mouse cursor: `{0}`")] 45 | WarpCursor(CGError), 46 | #[cfg(target_os = "macos")] 47 | #[error("reset_mouse_position called without a connected client")] 48 | ResetMouseWithoutClient, 49 | #[cfg(target_os = "macos")] 50 | #[error("core-graphics error: {0}")] 51 | CoreGraphics(CGError), 52 | #[cfg(target_os = "macos")] 53 | #[error("unable to map key event: {0}")] 54 | KeyMapError(i64), 55 | #[cfg(target_os = "macos")] 56 | #[error("Event tap disabled")] 57 | EventTapDisabled, 58 | } 59 | 60 | #[derive(Debug, Error)] 61 | pub enum CaptureCreationError { 62 | #[error("no backend available")] 63 | NoAvailableBackend, 64 | #[cfg(all(unix, feature = "libei", not(target_os = "macos")))] 65 | #[error("error creating input-capture-portal backend: `{0}`")] 66 | Libei(#[from] LibeiCaptureCreationError), 67 | #[cfg(all(unix, feature = "layer_shell", not(target_os = "macos")))] 68 | #[error("error creating layer-shell capture backend: `{0}`")] 69 | LayerShell(#[from] LayerShellCaptureCreationError), 70 | #[cfg(all(unix, feature = "x11", not(target_os = "macos")))] 71 | #[error("error creating x11 capture backend: `{0}`")] 72 | X11(#[from] X11InputCaptureCreationError), 73 | #[cfg(windows)] 74 | #[error("error creating windows capture backend")] 75 | Windows, 76 | #[cfg(target_os = "macos")] 77 | #[error("error creating macos capture backend: `{0}`")] 78 | MacOS(#[from] MacosCaptureCreationError), 79 | } 80 | 81 | impl CaptureCreationError { 82 | /// request was intentionally denied by the user 83 | #[cfg(all(unix, feature = "libei", not(target_os = "macos")))] 84 | pub(crate) fn cancelled_by_user(&self) -> bool { 85 | matches!( 86 | self, 87 | CaptureCreationError::Libei(LibeiCaptureCreationError::Ashpd(ashpd::Error::Response( 88 | ResponseError::Cancelled 89 | ))) 90 | ) 91 | } 92 | #[cfg(not(all(unix, feature = "libei", not(target_os = "macos"))))] 93 | pub(crate) fn cancelled_by_user(&self) -> bool { 94 | false 95 | } 96 | } 97 | 98 | #[cfg(all(unix, feature = "libei", not(target_os = "macos")))] 99 | #[derive(Debug, Error)] 100 | pub enum LibeiCaptureCreationError { 101 | #[error("xdg-desktop-portal: `{0}`")] 102 | Ashpd(#[from] ashpd::Error), 103 | } 104 | 105 | #[cfg(all(unix, feature = "layer_shell", not(target_os = "macos")))] 106 | #[derive(Debug, Error)] 107 | #[error("{protocol} protocol not supported: {inner}")] 108 | pub struct WaylandBindError { 109 | inner: BindError, 110 | protocol: &'static str, 111 | } 112 | 113 | #[cfg(all(unix, feature = "layer_shell", not(target_os = "macos")))] 114 | impl WaylandBindError { 115 | pub(crate) fn new(inner: BindError, protocol: &'static str) -> Self { 116 | Self { inner, protocol } 117 | } 118 | } 119 | 120 | #[cfg(all(unix, feature = "layer_shell", not(target_os = "macos")))] 121 | #[derive(Debug, Error)] 122 | pub enum LayerShellCaptureCreationError { 123 | #[error(transparent)] 124 | Connect(#[from] ConnectError), 125 | #[error(transparent)] 126 | Global(#[from] GlobalError), 127 | #[error(transparent)] 128 | Wayland(#[from] WaylandError), 129 | #[error(transparent)] 130 | Bind(#[from] WaylandBindError), 131 | #[error(transparent)] 132 | Dispatch(#[from] DispatchError), 133 | #[error(transparent)] 134 | Io(#[from] io::Error), 135 | } 136 | 137 | #[cfg(all(unix, feature = "x11", not(target_os = "macos")))] 138 | #[derive(Debug, Error)] 139 | pub enum X11InputCaptureCreationError { 140 | #[error("X11 input capture is not yet implemented :(")] 141 | NotImplemented, 142 | } 143 | 144 | #[cfg(target_os = "macos")] 145 | #[derive(Debug, Error)] 146 | pub enum MacosCaptureCreationError { 147 | #[error("event source creation failed!")] 148 | EventSourceCreation, 149 | #[cfg(target_os = "macos")] 150 | #[error("event tap creation failed")] 151 | EventTapCreation, 152 | #[error("failed to set CG Cursor property")] 153 | CGCursorProperty, 154 | #[cfg(target_os = "macos")] 155 | #[error("failed to get display ids: {0}")] 156 | ActiveDisplays(CGError), 157 | } 158 | -------------------------------------------------------------------------------- /input-capture/src/windows.rs: -------------------------------------------------------------------------------- 1 | use async_trait::async_trait; 2 | use core::task::{Context, Poll}; 3 | use event_thread::EventThread; 4 | use futures::Stream; 5 | use std::pin::Pin; 6 | 7 | use std::task::ready; 8 | use tokio::sync::mpsc::{channel, Receiver}; 9 | 10 | use super::{Capture, CaptureError, CaptureEvent, Position}; 11 | 12 | mod display_util; 13 | mod event_thread; 14 | 15 | pub struct WindowsInputCapture { 16 | event_rx: Receiver<(Position, CaptureEvent)>, 17 | event_thread: EventThread, 18 | } 19 | 20 | #[async_trait] 21 | impl Capture for WindowsInputCapture { 22 | async fn create(&mut self, pos: Position) -> Result<(), CaptureError> { 23 | self.event_thread.create(pos); 24 | Ok(()) 25 | } 26 | 27 | async fn destroy(&mut self, pos: Position) -> Result<(), CaptureError> { 28 | self.event_thread.destroy(pos); 29 | Ok(()) 30 | } 31 | 32 | async fn release(&mut self) -> Result<(), CaptureError> { 33 | self.event_thread.release_capture(); 34 | Ok(()) 35 | } 36 | 37 | async fn terminate(&mut self) -> Result<(), CaptureError> { 38 | Ok(()) 39 | } 40 | } 41 | 42 | impl WindowsInputCapture { 43 | pub(crate) fn new() -> Self { 44 | let (event_tx, event_rx) = channel(10); 45 | let event_thread = EventThread::new(event_tx); 46 | Self { 47 | event_thread, 48 | event_rx, 49 | } 50 | } 51 | } 52 | 53 | impl Stream for WindowsInputCapture { 54 | type Item = Result<(Position, CaptureEvent), CaptureError>; 55 | fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> { 56 | match ready!(self.event_rx.poll_recv(cx)) { 57 | None => Poll::Ready(None), 58 | Some(e) => Poll::Ready(Some(Ok(e))), 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /input-capture/src/windows/display_util.rs: -------------------------------------------------------------------------------- 1 | use windows::Win32::Foundation::RECT; 2 | 3 | use crate::Position; 4 | 5 | fn is_within_dp_region(point: (i32, i32), display: &RECT) -> bool { 6 | [ 7 | Position::Left, 8 | Position::Right, 9 | Position::Top, 10 | Position::Bottom, 11 | ] 12 | .iter() 13 | .all(|&pos| is_within_dp_boundary(point, display, pos)) 14 | } 15 | 16 | fn is_within_dp_boundary(point: (i32, i32), display: &RECT, pos: Position) -> bool { 17 | let (x, y) = point; 18 | match pos { 19 | Position::Left => display.left <= x, 20 | Position::Right => display.right > x, 21 | Position::Top => display.top <= y, 22 | Position::Bottom => display.bottom > y, 23 | } 24 | } 25 | 26 | /// returns whether the given position is within the display bounds with respect to the given 27 | /// barrier position 28 | /// 29 | /// # Arguments 30 | /// 31 | /// * `x`: 32 | /// * `y`: 33 | /// * `displays`: 34 | /// * `pos`: 35 | /// 36 | /// returns: bool 37 | /// 38 | fn in_bounds(point: (i32, i32), displays: &[RECT], pos: Position) -> bool { 39 | displays 40 | .iter() 41 | .any(|d| is_within_dp_boundary(point, d, pos)) 42 | } 43 | 44 | fn in_display_region(point: (i32, i32), displays: &[RECT]) -> bool { 45 | displays.iter().any(|d| is_within_dp_region(point, d)) 46 | } 47 | 48 | fn moved_across_boundary( 49 | prev_pos: (i32, i32), 50 | curr_pos: (i32, i32), 51 | displays: &[RECT], 52 | pos: Position, 53 | ) -> bool { 54 | /* was within bounds, but is not anymore */ 55 | in_display_region(prev_pos, displays) && !in_bounds(curr_pos, displays, pos) 56 | } 57 | 58 | pub(crate) fn entered_barrier( 59 | prev_pos: (i32, i32), 60 | curr_pos: (i32, i32), 61 | displays: &[RECT], 62 | ) -> Option<Position> { 63 | [ 64 | Position::Left, 65 | Position::Right, 66 | Position::Top, 67 | Position::Bottom, 68 | ] 69 | .into_iter() 70 | .find(|&pos| moved_across_boundary(prev_pos, curr_pos, displays, pos)) 71 | } 72 | 73 | /// 74 | /// clamp point to display bounds 75 | /// 76 | /// # Arguments 77 | /// 78 | /// * `prev_point`: coordinates, the cursor was before entering, within bounds of a display 79 | /// * `entry_point`: point to clamp 80 | /// 81 | /// returns: (i32, i32), the corrected entry point 82 | /// 83 | pub(crate) fn clamp_to_display_bounds( 84 | display_regions: &[RECT], 85 | prev_point: (i32, i32), 86 | point: (i32, i32), 87 | ) -> (i32, i32) { 88 | /* find display where movement came from */ 89 | let display = display_regions 90 | .iter() 91 | .find(|&d| is_within_dp_region(prev_point, d)) 92 | .unwrap(); 93 | 94 | /* clamp to bounds (inclusive) */ 95 | let (x, y) = point; 96 | let (min_x, max_x) = (display.left, display.right - 1); 97 | let (min_y, max_y) = (display.top, display.bottom - 1); 98 | (x.clamp(min_x, max_x), y.clamp(min_y, max_y)) 99 | } 100 | -------------------------------------------------------------------------------- /input-capture/src/x11.rs: -------------------------------------------------------------------------------- 1 | use std::task::Poll; 2 | 3 | use async_trait::async_trait; 4 | use futures_core::Stream; 5 | 6 | use super::{error::X11InputCaptureCreationError, Capture, CaptureError, CaptureEvent, Position}; 7 | 8 | pub struct X11InputCapture {} 9 | 10 | impl X11InputCapture { 11 | pub fn new() -> std::result::Result<Self, X11InputCaptureCreationError> { 12 | Err(X11InputCaptureCreationError::NotImplemented) 13 | } 14 | } 15 | 16 | #[async_trait] 17 | impl Capture for X11InputCapture { 18 | async fn create(&mut self, _pos: Position) -> Result<(), CaptureError> { 19 | Ok(()) 20 | } 21 | 22 | async fn destroy(&mut self, _pos: Position) -> Result<(), CaptureError> { 23 | Ok(()) 24 | } 25 | 26 | async fn release(&mut self) -> Result<(), CaptureError> { 27 | Ok(()) 28 | } 29 | 30 | async fn terminate(&mut self) -> Result<(), CaptureError> { 31 | Ok(()) 32 | } 33 | } 34 | 35 | impl Stream for X11InputCapture { 36 | type Item = Result<(Position, CaptureEvent), CaptureError>; 37 | 38 | fn poll_next( 39 | self: std::pin::Pin<&mut Self>, 40 | _cx: &mut std::task::Context<'_>, 41 | ) -> std::task::Poll<Option<Self::Item>> { 42 | Poll::Pending 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /input-emulation/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "input-emulation" 3 | description = "cross-platform input emulation library used by lan-mouse" 4 | version = "0.3.0" 5 | edition = "2021" 6 | license = "GPL-3.0-or-later" 7 | repository = "https://github.com/feschber/lan-mouse" 8 | 9 | [dependencies] 10 | async-trait = "0.1.80" 11 | futures = "0.3.28" 12 | log = "0.4.22" 13 | input-event = { path = "../input-event", version = "0.3.0" } 14 | thiserror = "2.0.0" 15 | tokio = { version = "1.32.0", features = [ 16 | "io-util", 17 | "io-std", 18 | "macros", 19 | "net", 20 | "process", 21 | "rt", 22 | "sync", 23 | "signal", 24 | ] } 25 | once_cell = "1.19.0" 26 | 27 | [target.'cfg(all(unix, not(target_os="macos")))'.dependencies] 28 | bitflags = "2.6.0" 29 | wayland-client = { version = "0.31.1", optional = true } 30 | wayland-protocols = { version = "0.32.1", features = [ 31 | "client", 32 | "staging", 33 | "unstable", 34 | ], optional = true } 35 | wayland-protocols-wlr = { version = "0.3.1", features = [ 36 | "client", 37 | ], optional = true } 38 | wayland-protocols-misc = { version = "0.3.1", features = [ 39 | "client", 40 | ], optional = true } 41 | x11 = { version = "2.21.0", features = ["xlib", "xtest"], optional = true } 42 | ashpd = { version = "0.10", default-features = false, features = [ 43 | "tokio", 44 | ], optional = true } 45 | reis = { version = "0.4", features = ["tokio"], optional = true } 46 | 47 | [target.'cfg(target_os="macos")'.dependencies] 48 | bitflags = "2.6.0" 49 | core-graphics = { version = "0.24.0", features = ["highsierra"] } 50 | keycode = "0.4.0" 51 | 52 | [target.'cfg(windows)'.dependencies] 53 | windows = { version = "0.58.0", features = [ 54 | "Win32_System_LibraryLoader", 55 | "Win32_System_Threading", 56 | "Win32_Foundation", 57 | "Win32_Graphics", 58 | "Win32_Graphics_Gdi", 59 | "Win32_UI_Input_KeyboardAndMouse", 60 | "Win32_UI_WindowsAndMessaging", 61 | ] } 62 | 63 | [features] 64 | default = ["wlroots", "x11", "remote_desktop_portal", "libei"] 65 | wlroots = [ 66 | "dep:wayland-client", 67 | "dep:wayland-protocols", 68 | "dep:wayland-protocols-wlr", 69 | "dep:wayland-protocols-misc", 70 | ] 71 | x11 = ["dep:x11"] 72 | remote_desktop_portal = ["dep:ashpd"] 73 | libei = ["dep:reis", "dep:ashpd"] 74 | -------------------------------------------------------------------------------- /input-emulation/src/dummy.rs: -------------------------------------------------------------------------------- 1 | use async_trait::async_trait; 2 | use input_event::Event; 3 | 4 | use crate::error::EmulationError; 5 | 6 | use super::{Emulation, EmulationHandle}; 7 | 8 | #[derive(Default)] 9 | pub(crate) struct DummyEmulation; 10 | 11 | impl DummyEmulation { 12 | pub(crate) fn new() -> Self { 13 | Self {} 14 | } 15 | } 16 | 17 | #[async_trait] 18 | impl Emulation for DummyEmulation { 19 | async fn consume( 20 | &mut self, 21 | event: Event, 22 | client_handle: EmulationHandle, 23 | ) -> Result<(), EmulationError> { 24 | log::info!("received event: ({client_handle}) {event}"); 25 | Ok(()) 26 | } 27 | async fn create(&mut self, _: EmulationHandle) {} 28 | async fn destroy(&mut self, _: EmulationHandle) {} 29 | async fn terminate(&mut self) { 30 | /* nothing to do */ 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /input-emulation/src/error.rs: -------------------------------------------------------------------------------- 1 | #[derive(Debug, Error)] 2 | pub enum InputEmulationError { 3 | #[error("error creating input-emulation: `{0}`")] 4 | Create(#[from] EmulationCreationError), 5 | #[error("error emulating input: `{0}`")] 6 | Emulate(#[from] EmulationError), 7 | } 8 | 9 | #[cfg(all( 10 | unix, 11 | any(feature = "remote_desktop_portal", feature = "libei"), 12 | not(target_os = "macos") 13 | ))] 14 | use ashpd::{desktop::ResponseError, Error::Response}; 15 | use std::io; 16 | use thiserror::Error; 17 | 18 | #[cfg(all(unix, feature = "wlroots", not(target_os = "macos")))] 19 | use wayland_client::{ 20 | backend::WaylandError, 21 | globals::{BindError, GlobalError}, 22 | ConnectError, DispatchError, 23 | }; 24 | 25 | #[derive(Debug, Error)] 26 | pub enum EmulationError { 27 | #[error("event stream closed")] 28 | EndOfStream, 29 | #[cfg(all(unix, feature = "libei", not(target_os = "macos")))] 30 | #[error("libei error: `{0}`")] 31 | Libei(#[from] reis::Error), 32 | #[cfg(all(unix, feature = "wlroots", not(target_os = "macos")))] 33 | #[error("wayland error: `{0}`")] 34 | Wayland(#[from] wayland_client::backend::WaylandError), 35 | #[cfg(all( 36 | unix, 37 | any(feature = "remote_desktop_portal", feature = "libei"), 38 | not(target_os = "macos") 39 | ))] 40 | #[error("xdg-desktop-portal: `{0}`")] 41 | Ashpd(#[from] ashpd::Error), 42 | #[error("io error: `{0}`")] 43 | Io(#[from] io::Error), 44 | } 45 | 46 | #[derive(Debug, Error)] 47 | pub enum EmulationCreationError { 48 | #[cfg(all(unix, feature = "wlroots", not(target_os = "macos")))] 49 | #[error("wlroots backend: `{0}`")] 50 | Wlroots(#[from] WlrootsEmulationCreationError), 51 | #[cfg(all(unix, feature = "libei", not(target_os = "macos")))] 52 | #[error("libei backend: `{0}`")] 53 | Libei(#[from] LibeiEmulationCreationError), 54 | #[cfg(all(unix, feature = "remote_desktop_portal", not(target_os = "macos")))] 55 | #[error("xdg-desktop-portal: `{0}`")] 56 | Xdp(#[from] XdpEmulationCreationError), 57 | #[cfg(all(unix, feature = "x11", not(target_os = "macos")))] 58 | #[error("x11: `{0}`")] 59 | X11(#[from] X11EmulationCreationError), 60 | #[cfg(target_os = "macos")] 61 | #[error("macos: `{0}`")] 62 | MacOs(#[from] MacOSEmulationCreationError), 63 | #[cfg(windows)] 64 | #[error("windows: `{0}`")] 65 | Windows(#[from] WindowsEmulationCreationError), 66 | #[error("capture error")] 67 | NoAvailableBackend, 68 | } 69 | 70 | impl EmulationCreationError { 71 | /// request was intentionally denied by the user 72 | pub(crate) fn cancelled_by_user(&self) -> bool { 73 | #[cfg(all(unix, feature = "libei", not(target_os = "macos")))] 74 | if matches!( 75 | self, 76 | EmulationCreationError::Libei(LibeiEmulationCreationError::Ashpd(Response( 77 | ResponseError::Cancelled, 78 | ))) 79 | ) { 80 | return true; 81 | } 82 | #[cfg(all(unix, feature = "remote_desktop_portal", not(target_os = "macos")))] 83 | if matches!( 84 | self, 85 | EmulationCreationError::Xdp(XdpEmulationCreationError::Ashpd(Response( 86 | ResponseError::Cancelled, 87 | ))) 88 | ) { 89 | return true; 90 | } 91 | false 92 | } 93 | } 94 | 95 | #[cfg(all(unix, feature = "wlroots", not(target_os = "macos")))] 96 | #[derive(Debug, Error)] 97 | pub enum WlrootsEmulationCreationError { 98 | #[error(transparent)] 99 | Connect(#[from] ConnectError), 100 | #[error(transparent)] 101 | Global(#[from] GlobalError), 102 | #[error(transparent)] 103 | Wayland(#[from] WaylandError), 104 | #[error(transparent)] 105 | Bind(#[from] WaylandBindError), 106 | #[error(transparent)] 107 | Dispatch(#[from] DispatchError), 108 | #[error(transparent)] 109 | Io(#[from] std::io::Error), 110 | } 111 | 112 | #[cfg(all(unix, feature = "wlroots", not(target_os = "macos")))] 113 | #[derive(Debug, Error)] 114 | #[error("wayland protocol \"{protocol}\" not supported: {inner}")] 115 | pub struct WaylandBindError { 116 | inner: BindError, 117 | protocol: &'static str, 118 | } 119 | 120 | #[cfg(all(unix, feature = "wlroots", not(target_os = "macos")))] 121 | impl WaylandBindError { 122 | pub(crate) fn new(inner: BindError, protocol: &'static str) -> Self { 123 | Self { inner, protocol } 124 | } 125 | } 126 | 127 | #[cfg(all(unix, feature = "libei", not(target_os = "macos")))] 128 | #[derive(Debug, Error)] 129 | pub enum LibeiEmulationCreationError { 130 | #[error(transparent)] 131 | Ashpd(#[from] ashpd::Error), 132 | #[error(transparent)] 133 | Io(#[from] std::io::Error), 134 | #[error(transparent)] 135 | Reis(#[from] reis::Error), 136 | } 137 | 138 | #[cfg(all(unix, feature = "remote_desktop_portal", not(target_os = "macos")))] 139 | #[derive(Debug, Error)] 140 | pub enum XdpEmulationCreationError { 141 | #[error(transparent)] 142 | Ashpd(#[from] ashpd::Error), 143 | } 144 | 145 | #[cfg(all(unix, feature = "x11", not(target_os = "macos")))] 146 | #[derive(Debug, Error)] 147 | pub enum X11EmulationCreationError { 148 | #[error("could not open display")] 149 | OpenDisplay, 150 | } 151 | 152 | #[cfg(target_os = "macos")] 153 | #[derive(Debug, Error)] 154 | pub enum MacOSEmulationCreationError { 155 | #[error("could not create event source")] 156 | EventSourceCreation, 157 | } 158 | 159 | #[cfg(windows)] 160 | #[derive(Debug, Error)] 161 | pub enum WindowsEmulationCreationError {} 162 | -------------------------------------------------------------------------------- /input-emulation/src/lib.rs: -------------------------------------------------------------------------------- 1 | use async_trait::async_trait; 2 | use std::{ 3 | collections::{HashMap, HashSet}, 4 | fmt::Display, 5 | }; 6 | 7 | use input_event::{Event, KeyboardEvent}; 8 | 9 | pub use self::error::{EmulationCreationError, EmulationError, InputEmulationError}; 10 | 11 | #[cfg(windows)] 12 | mod windows; 13 | 14 | #[cfg(all(unix, feature = "x11", not(target_os = "macos")))] 15 | mod x11; 16 | 17 | #[cfg(all(unix, feature = "wlroots", not(target_os = "macos")))] 18 | mod wlroots; 19 | 20 | #[cfg(all(unix, feature = "remote_desktop_portal", not(target_os = "macos")))] 21 | mod xdg_desktop_portal; 22 | 23 | #[cfg(all(unix, feature = "libei", not(target_os = "macos")))] 24 | mod libei; 25 | 26 | #[cfg(target_os = "macos")] 27 | mod macos; 28 | 29 | /// fallback input emulation (logs events) 30 | mod dummy; 31 | mod error; 32 | 33 | pub type EmulationHandle = u64; 34 | 35 | #[derive(Clone, Copy, Debug, Eq, PartialEq)] 36 | pub enum Backend { 37 | #[cfg(all(unix, feature = "wlroots", not(target_os = "macos")))] 38 | Wlroots, 39 | #[cfg(all(unix, feature = "libei", not(target_os = "macos")))] 40 | Libei, 41 | #[cfg(all(unix, feature = "remote_desktop_portal", not(target_os = "macos")))] 42 | Xdp, 43 | #[cfg(all(unix, feature = "x11", not(target_os = "macos")))] 44 | X11, 45 | #[cfg(windows)] 46 | Windows, 47 | #[cfg(target_os = "macos")] 48 | MacOs, 49 | Dummy, 50 | } 51 | 52 | impl Display for Backend { 53 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 54 | match self { 55 | #[cfg(all(unix, feature = "wlroots", not(target_os = "macos")))] 56 | Backend::Wlroots => write!(f, "wlroots"), 57 | #[cfg(all(unix, feature = "libei", not(target_os = "macos")))] 58 | Backend::Libei => write!(f, "libei"), 59 | #[cfg(all(unix, feature = "remote_desktop_portal", not(target_os = "macos")))] 60 | Backend::Xdp => write!(f, "xdg-desktop-portal"), 61 | #[cfg(all(unix, feature = "x11", not(target_os = "macos")))] 62 | Backend::X11 => write!(f, "X11"), 63 | #[cfg(windows)] 64 | Backend::Windows => write!(f, "windows"), 65 | #[cfg(target_os = "macos")] 66 | Backend::MacOs => write!(f, "macos"), 67 | Backend::Dummy => write!(f, "dummy"), 68 | } 69 | } 70 | } 71 | 72 | pub struct InputEmulation { 73 | emulation: Box<dyn Emulation>, 74 | handles: HashSet<EmulationHandle>, 75 | pressed_keys: HashMap<EmulationHandle, HashSet<u32>>, 76 | } 77 | 78 | impl InputEmulation { 79 | async fn with_backend(backend: Backend) -> Result<InputEmulation, EmulationCreationError> { 80 | let emulation: Box<dyn Emulation> = match backend { 81 | #[cfg(all(unix, feature = "wlroots", not(target_os = "macos")))] 82 | Backend::Wlroots => Box::new(wlroots::WlrootsEmulation::new()?), 83 | #[cfg(all(unix, feature = "libei", not(target_os = "macos")))] 84 | Backend::Libei => Box::new(libei::LibeiEmulation::new().await?), 85 | #[cfg(all(unix, feature = "x11", not(target_os = "macos")))] 86 | Backend::X11 => Box::new(x11::X11Emulation::new()?), 87 | #[cfg(all(unix, feature = "remote_desktop_portal", not(target_os = "macos")))] 88 | Backend::Xdp => Box::new(xdg_desktop_portal::DesktopPortalEmulation::new().await?), 89 | #[cfg(windows)] 90 | Backend::Windows => Box::new(windows::WindowsEmulation::new()?), 91 | #[cfg(target_os = "macos")] 92 | Backend::MacOs => Box::new(macos::MacOSEmulation::new()?), 93 | Backend::Dummy => Box::new(dummy::DummyEmulation::new()), 94 | }; 95 | Ok(Self { 96 | emulation, 97 | handles: HashSet::new(), 98 | pressed_keys: HashMap::new(), 99 | }) 100 | } 101 | 102 | pub async fn new(backend: Option<Backend>) -> Result<InputEmulation, EmulationCreationError> { 103 | if let Some(backend) = backend { 104 | let b = Self::with_backend(backend).await; 105 | if b.is_ok() { 106 | log::info!("using emulation backend: {backend}"); 107 | } 108 | return b; 109 | } 110 | 111 | for backend in [ 112 | #[cfg(all(unix, feature = "wlroots", not(target_os = "macos")))] 113 | Backend::Wlroots, 114 | #[cfg(all(unix, feature = "libei", not(target_os = "macos")))] 115 | Backend::Libei, 116 | #[cfg(all(unix, feature = "remote_desktop_portal", not(target_os = "macos")))] 117 | Backend::Xdp, 118 | #[cfg(all(unix, feature = "x11", not(target_os = "macos")))] 119 | Backend::X11, 120 | #[cfg(windows)] 121 | Backend::Windows, 122 | #[cfg(target_os = "macos")] 123 | Backend::MacOs, 124 | Backend::Dummy, 125 | ] { 126 | match Self::with_backend(backend).await { 127 | Ok(b) => { 128 | log::info!("using emulation backend: {backend}"); 129 | return Ok(b); 130 | } 131 | Err(e) if e.cancelled_by_user() => return Err(e), 132 | Err(e) => log::warn!("{e}"), 133 | } 134 | } 135 | 136 | Err(EmulationCreationError::NoAvailableBackend) 137 | } 138 | 139 | pub async fn consume( 140 | &mut self, 141 | event: Event, 142 | handle: EmulationHandle, 143 | ) -> Result<(), EmulationError> { 144 | match event { 145 | Event::Keyboard(KeyboardEvent::Key { key, state, .. }) => { 146 | // prevent double pressed / released keys 147 | if self.update_pressed_keys(handle, key, state) { 148 | self.emulation.consume(event, handle).await?; 149 | } 150 | Ok(()) 151 | } 152 | _ => self.emulation.consume(event, handle).await, 153 | } 154 | } 155 | 156 | pub async fn create(&mut self, handle: EmulationHandle) -> bool { 157 | if self.handles.insert(handle) { 158 | self.pressed_keys.insert(handle, HashSet::new()); 159 | self.emulation.create(handle).await; 160 | true 161 | } else { 162 | false 163 | } 164 | } 165 | 166 | pub async fn destroy(&mut self, handle: EmulationHandle) { 167 | let _ = self.release_keys(handle).await; 168 | if self.handles.remove(&handle) { 169 | self.pressed_keys.remove(&handle); 170 | self.emulation.destroy(handle).await 171 | } 172 | } 173 | 174 | pub async fn terminate(&mut self) { 175 | for handle in self.handles.iter().cloned().collect::<Vec<_>>() { 176 | self.destroy(handle).await 177 | } 178 | self.emulation.terminate().await 179 | } 180 | 181 | pub async fn release_keys(&mut self, handle: EmulationHandle) -> Result<(), EmulationError> { 182 | if let Some(keys) = self.pressed_keys.get_mut(&handle) { 183 | let keys = keys.drain().collect::<Vec<_>>(); 184 | for key in keys { 185 | let event = Event::Keyboard(KeyboardEvent::Key { 186 | time: 0, 187 | key, 188 | state: 0, 189 | }); 190 | self.emulation.consume(event, handle).await?; 191 | if let Ok(key) = input_event::scancode::Linux::try_from(key) { 192 | log::warn!("releasing stuck key: {key:?}"); 193 | } 194 | } 195 | } 196 | 197 | let event = Event::Keyboard(KeyboardEvent::Modifiers { 198 | depressed: 0, 199 | latched: 0, 200 | locked: 0, 201 | group: 0, 202 | }); 203 | self.emulation.consume(event, handle).await?; 204 | Ok(()) 205 | } 206 | 207 | pub fn has_pressed_keys(&self, handle: EmulationHandle) -> bool { 208 | self.pressed_keys 209 | .get(&handle) 210 | .is_some_and(|p| !p.is_empty()) 211 | } 212 | 213 | /// update the pressed_keys for the given handle 214 | /// returns whether the event should be processed 215 | fn update_pressed_keys(&mut self, handle: EmulationHandle, key: u32, state: u8) -> bool { 216 | let Some(pressed_keys) = self.pressed_keys.get_mut(&handle) else { 217 | return false; 218 | }; 219 | 220 | if state == 0 { 221 | // currently pressed => can release 222 | pressed_keys.remove(&key) 223 | } else { 224 | // currently not pressed => can press 225 | pressed_keys.insert(key) 226 | } 227 | } 228 | } 229 | 230 | #[async_trait] 231 | trait Emulation: Send { 232 | async fn consume( 233 | &mut self, 234 | event: Event, 235 | handle: EmulationHandle, 236 | ) -> Result<(), EmulationError>; 237 | async fn create(&mut self, handle: EmulationHandle); 238 | async fn destroy(&mut self, handle: EmulationHandle); 239 | async fn terminate(&mut self); 240 | } 241 | -------------------------------------------------------------------------------- /input-emulation/src/windows.rs: -------------------------------------------------------------------------------- 1 | use super::error::{EmulationError, WindowsEmulationCreationError}; 2 | use input_event::{ 3 | scancode, Event, KeyboardEvent, PointerEvent, BTN_BACK, BTN_FORWARD, BTN_LEFT, BTN_MIDDLE, 4 | BTN_RIGHT, 5 | }; 6 | 7 | use async_trait::async_trait; 8 | use std::ops::BitOrAssign; 9 | use std::time::Duration; 10 | use tokio::task::AbortHandle; 11 | use windows::Win32::UI::Input::KeyboardAndMouse::{ 12 | SendInput, INPUT_0, KEYEVENTF_EXTENDEDKEY, MOUSEEVENTF_XDOWN, MOUSEEVENTF_XUP, 13 | }; 14 | use windows::Win32::UI::Input::KeyboardAndMouse::{ 15 | INPUT, INPUT_KEYBOARD, INPUT_MOUSE, KEYBDINPUT, KEYEVENTF_KEYUP, KEYEVENTF_SCANCODE, 16 | MOUSEEVENTF_HWHEEL, MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP, MOUSEEVENTF_MIDDLEDOWN, 17 | MOUSEEVENTF_MIDDLEUP, MOUSEEVENTF_MOVE, MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP, 18 | MOUSEEVENTF_WHEEL, MOUSEINPUT, 19 | }; 20 | use windows::Win32::UI::WindowsAndMessaging::{XBUTTON1, XBUTTON2}; 21 | 22 | use super::{Emulation, EmulationHandle}; 23 | 24 | const DEFAULT_REPEAT_DELAY: Duration = Duration::from_millis(500); 25 | const DEFAULT_REPEAT_INTERVAL: Duration = Duration::from_millis(32); 26 | 27 | pub(crate) struct WindowsEmulation { 28 | repeat_task: Option<AbortHandle>, 29 | } 30 | 31 | impl WindowsEmulation { 32 | pub(crate) fn new() -> Result<Self, WindowsEmulationCreationError> { 33 | Ok(Self { repeat_task: None }) 34 | } 35 | } 36 | 37 | #[async_trait] 38 | impl Emulation for WindowsEmulation { 39 | async fn consume(&mut self, event: Event, _: EmulationHandle) -> Result<(), EmulationError> { 40 | match event { 41 | Event::Pointer(pointer_event) => match pointer_event { 42 | PointerEvent::Motion { time: _, dx, dy } => { 43 | rel_mouse(dx as i32, dy as i32); 44 | } 45 | PointerEvent::Button { 46 | time: _, 47 | button, 48 | state, 49 | } => mouse_button(button, state), 50 | PointerEvent::Axis { 51 | time: _, 52 | axis, 53 | value, 54 | } => scroll(axis, value as i32), 55 | PointerEvent::AxisDiscrete120 { axis, value } => scroll(axis, value), 56 | }, 57 | Event::Keyboard(keyboard_event) => match keyboard_event { 58 | KeyboardEvent::Key { 59 | time: _, 60 | key, 61 | state, 62 | } => { 63 | match state { 64 | // pressed 65 | 0 => self.kill_repeat_task(), 66 | 1 => self.spawn_repeat_task(key).await, 67 | _ => {} 68 | } 69 | key_event(key, state) 70 | } 71 | KeyboardEvent::Modifiers { .. } => {} 72 | }, 73 | } 74 | // FIXME 75 | Ok(()) 76 | } 77 | 78 | async fn create(&mut self, _handle: EmulationHandle) {} 79 | 80 | async fn destroy(&mut self, _handle: EmulationHandle) {} 81 | 82 | async fn terminate(&mut self) {} 83 | } 84 | 85 | impl WindowsEmulation { 86 | async fn spawn_repeat_task(&mut self, key: u32) { 87 | // there can only be one repeating key and it's 88 | // always the last to be pressed 89 | self.kill_repeat_task(); 90 | let repeat_task = tokio::task::spawn_local(async move { 91 | tokio::time::sleep(DEFAULT_REPEAT_DELAY).await; 92 | loop { 93 | key_event(key, 1); 94 | tokio::time::sleep(DEFAULT_REPEAT_INTERVAL).await; 95 | } 96 | }); 97 | self.repeat_task = Some(repeat_task.abort_handle()); 98 | } 99 | fn kill_repeat_task(&mut self) { 100 | if let Some(task) = self.repeat_task.take() { 101 | task.abort(); 102 | } 103 | } 104 | } 105 | 106 | fn send_input_safe(input: INPUT) { 107 | unsafe { 108 | loop { 109 | /* retval = number of successfully submitted events */ 110 | if SendInput(&[input], std::mem::size_of::<INPUT>() as i32) > 0 { 111 | break; 112 | } 113 | } 114 | } 115 | } 116 | 117 | fn send_mouse_input(mi: MOUSEINPUT) { 118 | send_input_safe(INPUT { 119 | r#type: INPUT_MOUSE, 120 | Anonymous: INPUT_0 { mi }, 121 | }); 122 | } 123 | 124 | fn send_keyboard_input(ki: KEYBDINPUT) { 125 | send_input_safe(INPUT { 126 | r#type: INPUT_KEYBOARD, 127 | Anonymous: INPUT_0 { ki }, 128 | }); 129 | } 130 | fn rel_mouse(dx: i32, dy: i32) { 131 | let mi = MOUSEINPUT { 132 | dx, 133 | dy, 134 | mouseData: 0, 135 | dwFlags: MOUSEEVENTF_MOVE, 136 | time: 0, 137 | dwExtraInfo: 0, 138 | }; 139 | send_mouse_input(mi); 140 | } 141 | 142 | fn mouse_button(button: u32, state: u32) { 143 | let dw_flags = match state { 144 | 0 => match button { 145 | BTN_LEFT => MOUSEEVENTF_LEFTUP, 146 | BTN_RIGHT => MOUSEEVENTF_RIGHTUP, 147 | BTN_MIDDLE => MOUSEEVENTF_MIDDLEUP, 148 | BTN_BACK => MOUSEEVENTF_XUP, 149 | BTN_FORWARD => MOUSEEVENTF_XUP, 150 | _ => return, 151 | }, 152 | 1 => match button { 153 | BTN_LEFT => MOUSEEVENTF_LEFTDOWN, 154 | BTN_RIGHT => MOUSEEVENTF_RIGHTDOWN, 155 | BTN_MIDDLE => MOUSEEVENTF_MIDDLEDOWN, 156 | BTN_BACK => MOUSEEVENTF_XDOWN, 157 | BTN_FORWARD => MOUSEEVENTF_XDOWN, 158 | _ => return, 159 | }, 160 | _ => return, 161 | }; 162 | let mouse_data = match button { 163 | BTN_BACK => XBUTTON1 as u32, 164 | BTN_FORWARD => XBUTTON2 as u32, 165 | _ => 0, 166 | }; 167 | let mi = MOUSEINPUT { 168 | dx: 0, 169 | dy: 0, // no movement 170 | mouseData: mouse_data, 171 | dwFlags: dw_flags, 172 | time: 0, 173 | dwExtraInfo: 0, 174 | }; 175 | send_mouse_input(mi); 176 | } 177 | 178 | fn scroll(axis: u8, value: i32) { 179 | let event_type = match axis { 180 | 0 => MOUSEEVENTF_WHEEL, 181 | 1 => MOUSEEVENTF_HWHEEL, 182 | _ => return, 183 | }; 184 | let mi = MOUSEINPUT { 185 | dx: 0, 186 | dy: 0, 187 | mouseData: -value as u32, 188 | dwFlags: event_type, 189 | time: 0, 190 | dwExtraInfo: 0, 191 | }; 192 | send_mouse_input(mi); 193 | } 194 | 195 | fn key_event(key: u32, state: u8) { 196 | let scancode = match linux_keycode_to_windows_scancode(key) { 197 | Some(code) => code, 198 | None => return, 199 | }; 200 | let extended = scancode > 0xff; 201 | let scancode = scancode & 0xff; 202 | let mut flags = KEYEVENTF_SCANCODE; 203 | if extended { 204 | flags.bitor_assign(KEYEVENTF_EXTENDEDKEY); 205 | } 206 | if state == 0 { 207 | flags.bitor_assign(KEYEVENTF_KEYUP); 208 | } 209 | let ki = KEYBDINPUT { 210 | wVk: Default::default(), 211 | wScan: scancode, 212 | dwFlags: flags, 213 | time: 0, 214 | dwExtraInfo: 0, 215 | }; 216 | send_keyboard_input(ki); 217 | } 218 | 219 | fn linux_keycode_to_windows_scancode(linux_keycode: u32) -> Option<u16> { 220 | let linux_scancode = match scancode::Linux::try_from(linux_keycode) { 221 | Ok(s) => s, 222 | Err(_) => { 223 | log::warn!("unknown keycode: {linux_keycode}"); 224 | return None; 225 | } 226 | }; 227 | log::trace!("linux code: {linux_scancode:?}"); 228 | let windows_scancode = match scancode::Windows::try_from(linux_scancode) { 229 | Ok(s) => s, 230 | Err(_) => { 231 | log::warn!("failed to translate linux code into windows scancode: {linux_scancode:?}"); 232 | return None; 233 | } 234 | }; 235 | log::trace!("windows code: {windows_scancode:?}"); 236 | Some(windows_scancode as u16) 237 | } 238 | -------------------------------------------------------------------------------- /input-emulation/src/x11.rs: -------------------------------------------------------------------------------- 1 | use async_trait::async_trait; 2 | use std::ptr; 3 | use x11::{ 4 | xlib::{self, XCloseDisplay}, 5 | xtest, 6 | }; 7 | 8 | use input_event::{ 9 | Event, KeyboardEvent, PointerEvent, BTN_BACK, BTN_FORWARD, BTN_LEFT, BTN_MIDDLE, BTN_RIGHT, 10 | }; 11 | 12 | use crate::error::EmulationError; 13 | 14 | use super::{error::X11EmulationCreationError, Emulation, EmulationHandle}; 15 | 16 | pub(crate) struct X11Emulation { 17 | display: *mut xlib::Display, 18 | } 19 | 20 | unsafe impl Send for X11Emulation {} 21 | 22 | impl X11Emulation { 23 | pub(crate) fn new() -> Result<Self, X11EmulationCreationError> { 24 | let display = unsafe { 25 | match xlib::XOpenDisplay(ptr::null()) { 26 | d if std::ptr::eq(d, ptr::null_mut::<xlib::Display>()) => { 27 | Err(X11EmulationCreationError::OpenDisplay) 28 | } 29 | display => Ok(display), 30 | } 31 | }?; 32 | Ok(Self { display }) 33 | } 34 | 35 | fn relative_motion(&self, dx: i32, dy: i32) { 36 | unsafe { 37 | xtest::XTestFakeRelativeMotionEvent(self.display, dx, dy, 0, 0); 38 | } 39 | } 40 | 41 | fn emulate_mouse_button(&self, button: u32, state: u32) { 42 | unsafe { 43 | let x11_button = match button { 44 | BTN_RIGHT => 3, 45 | BTN_MIDDLE => 2, 46 | BTN_BACK => 8, 47 | BTN_FORWARD => 9, 48 | BTN_LEFT => 1, 49 | _ => 1, 50 | }; 51 | xtest::XTestFakeButtonEvent(self.display, x11_button, state as i32, 0); 52 | }; 53 | } 54 | 55 | const SCROLL_UP: u32 = 4; 56 | const SCROLL_DOWN: u32 = 5; 57 | const SCROLL_LEFT: u32 = 6; 58 | const SCROLL_RIGHT: u32 = 7; 59 | 60 | fn emulate_scroll(&self, axis: u8, value: f64) { 61 | let direction = match axis { 62 | 1 => { 63 | if value < 0.0 { 64 | Self::SCROLL_LEFT 65 | } else { 66 | Self::SCROLL_RIGHT 67 | } 68 | } 69 | _ => { 70 | if value < 0.0 { 71 | Self::SCROLL_UP 72 | } else { 73 | Self::SCROLL_DOWN 74 | } 75 | } 76 | }; 77 | 78 | unsafe { 79 | xtest::XTestFakeButtonEvent(self.display, direction, 1, 0); 80 | xtest::XTestFakeButtonEvent(self.display, direction, 0, 0); 81 | } 82 | } 83 | 84 | #[allow(dead_code)] 85 | fn emulate_key(&self, key: u32, state: u8) { 86 | let key = key + 8; // xorg keycodes are shifted by 8 87 | unsafe { 88 | xtest::XTestFakeKeyEvent(self.display, key, state as i32, 0); 89 | } 90 | } 91 | } 92 | 93 | impl Drop for X11Emulation { 94 | fn drop(&mut self) { 95 | unsafe { 96 | XCloseDisplay(self.display); 97 | } 98 | } 99 | } 100 | 101 | #[async_trait] 102 | impl Emulation for X11Emulation { 103 | async fn consume(&mut self, event: Event, _: EmulationHandle) -> Result<(), EmulationError> { 104 | match event { 105 | Event::Pointer(pointer_event) => match pointer_event { 106 | PointerEvent::Motion { time: _, dx, dy } => { 107 | self.relative_motion(dx as i32, dy as i32); 108 | } 109 | PointerEvent::Button { 110 | time: _, 111 | button, 112 | state, 113 | } => { 114 | self.emulate_mouse_button(button, state); 115 | } 116 | PointerEvent::Axis { 117 | time: _, 118 | axis, 119 | value, 120 | } => { 121 | self.emulate_scroll(axis, value); 122 | } 123 | PointerEvent::AxisDiscrete120 { axis, value } => { 124 | self.emulate_scroll(axis, value as f64); 125 | } 126 | }, 127 | Event::Keyboard(KeyboardEvent::Key { 128 | time: _, 129 | key, 130 | state, 131 | }) => { 132 | self.emulate_key(key, state); 133 | } 134 | _ => {} 135 | } 136 | unsafe { 137 | xlib::XFlush(self.display); 138 | } 139 | // FIXME 140 | Ok(()) 141 | } 142 | 143 | async fn create(&mut self, _: EmulationHandle) { 144 | // for our purposes it does not matter what client sent the event 145 | } 146 | 147 | async fn destroy(&mut self, _: EmulationHandle) { 148 | // for our purposes it does not matter what client sent the event 149 | } 150 | 151 | async fn terminate(&mut self) { 152 | /* nothing to do */ 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /input-emulation/src/xdg_desktop_portal.rs: -------------------------------------------------------------------------------- 1 | use ashpd::{ 2 | desktop::{ 3 | remote_desktop::{Axis, DeviceType, KeyState, RemoteDesktop}, 4 | PersistMode, Session, 5 | }, 6 | zbus::AsyncDrop, 7 | }; 8 | use async_trait::async_trait; 9 | 10 | use futures::FutureExt; 11 | use input_event::{ 12 | Event::{Keyboard, Pointer}, 13 | KeyboardEvent, PointerEvent, 14 | }; 15 | 16 | use crate::error::EmulationError; 17 | 18 | use super::{error::XdpEmulationCreationError, Emulation, EmulationHandle}; 19 | 20 | pub(crate) struct DesktopPortalEmulation<'a> { 21 | proxy: RemoteDesktop<'a>, 22 | session: Session<'a, RemoteDesktop<'a>>, 23 | } 24 | 25 | impl<'a> DesktopPortalEmulation<'a> { 26 | pub(crate) async fn new() -> Result<DesktopPortalEmulation<'a>, XdpEmulationCreationError> { 27 | log::debug!("connecting to org.freedesktop.portal.RemoteDesktop portal ..."); 28 | let proxy = RemoteDesktop::new().await?; 29 | 30 | // retry when user presses the cancel button 31 | log::debug!("creating session ..."); 32 | let session = proxy.create_session().await?; 33 | 34 | log::debug!("selecting devices ..."); 35 | proxy 36 | .select_devices( 37 | &session, 38 | DeviceType::Keyboard | DeviceType::Pointer, 39 | None, 40 | PersistMode::ExplicitlyRevoked, 41 | ) 42 | .await?; 43 | 44 | log::info!("requesting permission for input emulation"); 45 | let _devices = proxy.start(&session, None).await?.response()?; 46 | 47 | log::debug!("started session"); 48 | let session = session; 49 | 50 | Ok(Self { proxy, session }) 51 | } 52 | } 53 | 54 | #[async_trait] 55 | impl Emulation for DesktopPortalEmulation<'_> { 56 | async fn consume( 57 | &mut self, 58 | event: input_event::Event, 59 | _client: EmulationHandle, 60 | ) -> Result<(), EmulationError> { 61 | match event { 62 | Pointer(p) => match p { 63 | PointerEvent::Motion { time: _, dx, dy } => { 64 | self.proxy 65 | .notify_pointer_motion(&self.session, dx, dy) 66 | .await?; 67 | } 68 | PointerEvent::Button { 69 | time: _, 70 | button, 71 | state, 72 | } => { 73 | let state = match state { 74 | 0 => KeyState::Released, 75 | _ => KeyState::Pressed, 76 | }; 77 | self.proxy 78 | .notify_pointer_button(&self.session, button as i32, state) 79 | .await?; 80 | } 81 | PointerEvent::AxisDiscrete120 { axis, value } => { 82 | let axis = match axis { 83 | 0 => Axis::Vertical, 84 | _ => Axis::Horizontal, 85 | }; 86 | self.proxy 87 | .notify_pointer_axis_discrete(&self.session, axis, value / 120) 88 | .await?; 89 | } 90 | PointerEvent::Axis { 91 | time: _, 92 | axis, 93 | value, 94 | } => { 95 | let axis = match axis { 96 | 0 => Axis::Vertical, 97 | _ => Axis::Horizontal, 98 | }; 99 | let (dx, dy) = match axis { 100 | Axis::Vertical => (0., value), 101 | Axis::Horizontal => (value, 0.), 102 | }; 103 | self.proxy 104 | .notify_pointer_axis(&self.session, dx, dy, true) 105 | .await?; 106 | } 107 | }, 108 | Keyboard(k) => { 109 | match k { 110 | KeyboardEvent::Key { 111 | time: _, 112 | key, 113 | state, 114 | } => { 115 | let state = match state { 116 | 0 => KeyState::Released, 117 | _ => KeyState::Pressed, 118 | }; 119 | self.proxy 120 | .notify_keyboard_keycode(&self.session, key as i32, state) 121 | .await?; 122 | } 123 | KeyboardEvent::Modifiers { .. } => { 124 | // ignore 125 | } 126 | } 127 | } 128 | } 129 | Ok(()) 130 | } 131 | 132 | async fn create(&mut self, _client: EmulationHandle) {} 133 | async fn destroy(&mut self, _client: EmulationHandle) {} 134 | async fn terminate(&mut self) { 135 | if let Err(e) = self.session.close().await { 136 | log::warn!("session.close(): {e}"); 137 | }; 138 | if let Err(e) = self.session.receive_closed().await { 139 | log::warn!("session.receive_closed(): {e}"); 140 | }; 141 | } 142 | } 143 | 144 | impl AsyncDrop for DesktopPortalEmulation<'_> { 145 | #[doc = r" Perform the async cleanup."] 146 | #[allow(clippy::type_complexity, clippy::type_repetition_in_bounds)] 147 | fn async_drop<'async_trait>( 148 | self, 149 | ) -> ::core::pin::Pin< 150 | Box<dyn ::core::future::Future<Output = ()> + ::core::marker::Send + 'async_trait>, 151 | > 152 | where 153 | Self: 'async_trait, 154 | { 155 | async move { 156 | let _ = self.session.close().await; 157 | } 158 | .boxed() 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /input-event/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "input-event" 3 | description = "cross-platform input-event types for input-capture / input-emulation" 4 | version = "0.3.0" 5 | edition = "2021" 6 | license = "GPL-3.0-or-later" 7 | repository = "https://github.com/feschber/lan-mouse" 8 | 9 | [dependencies] 10 | futures-core = "0.3.30" 11 | log = "0.4.22" 12 | num_enum = "0.7.2" 13 | serde = { version = "1.0", features = ["derive"] } 14 | thiserror = "2.0.0" 15 | 16 | [target.'cfg(all(unix, not(target_os="macos")))'.dependencies] 17 | reis = { version = "0.4", optional = true } 18 | 19 | [features] 20 | default = ["libei"] 21 | libei = ["dep:reis"] 22 | -------------------------------------------------------------------------------- /input-event/src/error.rs: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /input-event/src/lib.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::{self, Display}; 2 | 3 | pub mod error; 4 | pub mod scancode; 5 | 6 | #[cfg(all(unix, feature = "libei", not(target_os = "macos")))] 7 | mod libei; 8 | 9 | // FIXME 10 | pub const BTN_LEFT: u32 = 0x110; 11 | pub const BTN_RIGHT: u32 = 0x111; 12 | pub const BTN_MIDDLE: u32 = 0x112; 13 | pub const BTN_BACK: u32 = 0x113; 14 | pub const BTN_FORWARD: u32 = 0x114; 15 | 16 | #[derive(Debug, PartialEq, Clone, Copy)] 17 | pub enum PointerEvent { 18 | /// relative motion event 19 | Motion { time: u32, dx: f64, dy: f64 }, 20 | /// mouse button event 21 | Button { time: u32, button: u32, state: u32 }, 22 | /// axis event, scroll event for touchpads 23 | Axis { time: u32, axis: u8, value: f64 }, 24 | /// discrete axis event, scroll event for mice - 120 = one scroll tick 25 | AxisDiscrete120 { axis: u8, value: i32 }, 26 | } 27 | 28 | #[derive(Debug, PartialEq, Clone, Copy)] 29 | pub enum KeyboardEvent { 30 | /// a key press / release event 31 | Key { time: u32, key: u32, state: u8 }, 32 | /// modifiers changed state 33 | Modifiers { 34 | depressed: u32, 35 | latched: u32, 36 | locked: u32, 37 | group: u32, 38 | }, 39 | } 40 | 41 | #[derive(PartialEq, Debug, Clone, Copy)] 42 | pub enum Event { 43 | /// pointer event (motion / button / axis) 44 | Pointer(PointerEvent), 45 | /// keyboard events (key / modifiers) 46 | Keyboard(KeyboardEvent), 47 | } 48 | 49 | impl Display for PointerEvent { 50 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 51 | match self { 52 | PointerEvent::Motion { time: _, dx, dy } => write!(f, "motion({dx},{dy})"), 53 | PointerEvent::Button { 54 | time: _, 55 | button, 56 | state, 57 | } => { 58 | let str = match *button { 59 | BTN_LEFT => Some("left"), 60 | BTN_RIGHT => Some("right"), 61 | BTN_MIDDLE => Some("middle"), 62 | BTN_FORWARD => Some("forward"), 63 | BTN_BACK => Some("back"), 64 | _ => None, 65 | }; 66 | if let Some(button) = str { 67 | write!(f, "button({button}, {state})") 68 | } else { 69 | write!(f, "button({button}, {state}") 70 | } 71 | } 72 | PointerEvent::Axis { 73 | time: _, 74 | axis, 75 | value, 76 | } => write!(f, "scroll({axis}, {value})"), 77 | PointerEvent::AxisDiscrete120 { axis, value } => { 78 | write!(f, "scroll-120 ({axis}, {value})") 79 | } 80 | } 81 | } 82 | } 83 | 84 | impl Display for KeyboardEvent { 85 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 86 | match self { 87 | KeyboardEvent::Key { 88 | time: _, 89 | key, 90 | state, 91 | } => { 92 | let scan = scancode::Linux::try_from(*key); 93 | if let Ok(scan) = scan { 94 | write!(f, "key({scan:?}, {state})") 95 | } else { 96 | write!(f, "key({key}, {state})") 97 | } 98 | } 99 | KeyboardEvent::Modifiers { 100 | depressed: mods_depressed, 101 | latched: mods_latched, 102 | locked: mods_locked, 103 | group, 104 | } => write!( 105 | f, 106 | "modifiers({mods_depressed},{mods_latched},{mods_locked},{group})" 107 | ), 108 | } 109 | } 110 | } 111 | 112 | impl Display for Event { 113 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 114 | match self { 115 | Event::Pointer(p) => write!(f, "{}", p), 116 | Event::Keyboard(k) => write!(f, "{}", k), 117 | } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /input-event/src/libei.rs: -------------------------------------------------------------------------------- 1 | use reis::{ 2 | ei::{button::ButtonState, keyboard::KeyState}, 3 | event::EiEvent, 4 | }; 5 | 6 | use crate::{Event, KeyboardEvent, PointerEvent}; 7 | 8 | impl Event { 9 | pub fn from_ei_event(ei_event: EiEvent) -> impl Iterator<Item = Self> { 10 | to_input_events(ei_event).into_iter() 11 | } 12 | } 13 | 14 | enum Events { 15 | None, 16 | One(Event), 17 | Two(Event, Event), 18 | } 19 | 20 | impl Events { 21 | fn into_iter(self) -> impl Iterator<Item = Event> { 22 | EventIterator::new(self) 23 | } 24 | } 25 | 26 | struct EventIterator { 27 | events: [Option<Event>; 2], 28 | pos: usize, 29 | } 30 | 31 | impl EventIterator { 32 | fn new(events: Events) -> Self { 33 | let events = match events { 34 | Events::None => [None, None], 35 | Events::One(e) => [Some(e), None], 36 | Events::Two(e, f) => [Some(e), Some(f)], 37 | }; 38 | Self { events, pos: 0 } 39 | } 40 | } 41 | 42 | impl Iterator for EventIterator { 43 | type Item = Event; 44 | 45 | fn next(&mut self) -> Option<Self::Item> { 46 | let res = if self.pos >= self.events.len() { 47 | None 48 | } else { 49 | self.events[self.pos] 50 | }; 51 | self.pos += 1; 52 | res 53 | } 54 | } 55 | 56 | fn to_input_events(ei_event: EiEvent) -> Events { 57 | match ei_event { 58 | EiEvent::KeyboardModifiers(mods) => { 59 | let modifier_event = KeyboardEvent::Modifiers { 60 | depressed: mods.depressed, 61 | latched: mods.latched, 62 | locked: mods.locked, 63 | group: mods.group, 64 | }; 65 | Events::One(Event::Keyboard(modifier_event)) 66 | } 67 | EiEvent::Frame(_) => Events::None, /* FIXME */ 68 | EiEvent::PointerMotion(motion) => { 69 | let motion_event = PointerEvent::Motion { 70 | time: motion.time as u32, 71 | dx: motion.dx as f64, 72 | dy: motion.dy as f64, 73 | }; 74 | Events::One(Event::Pointer(motion_event)) 75 | } 76 | EiEvent::PointerMotionAbsolute(_) => Events::None, 77 | EiEvent::Button(button) => { 78 | let button_event = PointerEvent::Button { 79 | time: button.time as u32, 80 | button: button.button, 81 | state: match button.state { 82 | ButtonState::Released => 0, 83 | ButtonState::Press => 1, 84 | }, 85 | }; 86 | Events::One(Event::Pointer(button_event)) 87 | } 88 | EiEvent::ScrollDelta(delta) => { 89 | let dy = Event::Pointer(PointerEvent::Axis { 90 | time: 0, 91 | axis: 0, 92 | value: delta.dy as f64, 93 | }); 94 | let dx = Event::Pointer(PointerEvent::Axis { 95 | time: 0, 96 | axis: 1, 97 | value: delta.dx as f64, 98 | }); 99 | if delta.dy != 0. && delta.dx != 0. { 100 | Events::Two(dy, dx) 101 | } else if delta.dy != 0. { 102 | Events::One(dy) 103 | } else if delta.dx != 0. { 104 | Events::One(dx) 105 | } else { 106 | Events::None 107 | } 108 | } 109 | EiEvent::ScrollStop(_) => Events::None, /* TODO */ 110 | EiEvent::ScrollCancel(_) => Events::None, /* TODO */ 111 | EiEvent::ScrollDiscrete(scroll) => { 112 | let dy = Event::Pointer(PointerEvent::AxisDiscrete120 { 113 | axis: 0, 114 | value: scroll.discrete_dy, 115 | }); 116 | let dx = Event::Pointer(PointerEvent::AxisDiscrete120 { 117 | axis: 1, 118 | value: scroll.discrete_dx, 119 | }); 120 | if scroll.discrete_dy != 0 && scroll.discrete_dx != 0 { 121 | Events::Two(dy, dx) 122 | } else if scroll.discrete_dy != 0 { 123 | Events::One(dy) 124 | } else if scroll.discrete_dx != 0 { 125 | Events::One(dx) 126 | } else { 127 | Events::None 128 | } 129 | } 130 | EiEvent::KeyboardKey(key) => { 131 | let key_event = KeyboardEvent::Key { 132 | key: key.key, 133 | state: match key.state { 134 | KeyState::Press => 1, 135 | KeyState::Released => 0, 136 | }, 137 | time: key.time as u32, 138 | }; 139 | Events::One(Event::Keyboard(key_event)) 140 | } 141 | EiEvent::TouchDown(_) => Events::None, /* TODO */ 142 | EiEvent::TouchUp(_) => Events::None, /* TODO */ 143 | EiEvent::TouchMotion(_) => Events::None, /* TODO */ 144 | _ => Events::None, 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /lan-mouse-cli/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "lan-mouse-cli" 3 | description = "CLI Frontend for lan-mouse" 4 | version = "0.2.0" 5 | edition = "2021" 6 | license = "GPL-3.0-or-later" 7 | repository = "https://github.com/feschber/lan-mouse" 8 | 9 | [dependencies] 10 | futures = "0.3.30" 11 | lan-mouse-ipc = { path = "../lan-mouse-ipc", version = "0.2.0" } 12 | clap = { version = "4.4.11", features = ["derive"] } 13 | thiserror = "2.0.0" 14 | tokio = { version = "1.32.0", features = [ 15 | "io-util", 16 | "io-std", 17 | "macros", 18 | "net", 19 | "rt", 20 | ] } 21 | -------------------------------------------------------------------------------- /lan-mouse-cli/src/lib.rs: -------------------------------------------------------------------------------- 1 | use clap::{Args, Parser, Subcommand}; 2 | use futures::StreamExt; 3 | 4 | use std::{net::IpAddr, time::Duration}; 5 | use thiserror::Error; 6 | 7 | use lan_mouse_ipc::{ 8 | connect_async, ClientHandle, ConnectionError, FrontendEvent, FrontendRequest, IpcError, 9 | Position, 10 | }; 11 | 12 | #[derive(Debug, Error)] 13 | pub enum CliError { 14 | /// is the service running? 15 | #[error("could not connect: `{0}` - is the service running?")] 16 | ServiceNotRunning(#[from] ConnectionError), 17 | #[error("error communicating with service: {0}")] 18 | Ipc(#[from] IpcError), 19 | } 20 | 21 | #[derive(Parser, Clone, Debug, PartialEq, Eq)] 22 | #[command(name = "lan-mouse-cli", about = "LanMouse CLI interface")] 23 | pub struct CliArgs { 24 | #[command(subcommand)] 25 | command: CliSubcommand, 26 | } 27 | 28 | #[derive(Args, Clone, Debug, PartialEq, Eq)] 29 | struct Client { 30 | #[arg(long)] 31 | hostname: Option<String>, 32 | #[arg(long)] 33 | port: Option<u16>, 34 | #[arg(long)] 35 | ips: Option<Vec<IpAddr>>, 36 | #[arg(long)] 37 | enter_hook: Option<String>, 38 | } 39 | 40 | #[derive(Clone, Subcommand, Debug, PartialEq, Eq)] 41 | enum CliSubcommand { 42 | /// add a new client 43 | AddClient(Client), 44 | /// remove an existing client 45 | RemoveClient { id: ClientHandle }, 46 | /// activate a client 47 | Activate { id: ClientHandle }, 48 | /// deactivate a client 49 | Deactivate { id: ClientHandle }, 50 | /// list configured clients 51 | List, 52 | /// change hostname 53 | SetHost { 54 | id: ClientHandle, 55 | host: Option<String>, 56 | }, 57 | /// change port 58 | SetPort { id: ClientHandle, port: u16 }, 59 | /// set position 60 | SetPosition { id: ClientHandle, pos: Position }, 61 | /// set ips 62 | SetIps { id: ClientHandle, ips: Vec<IpAddr> }, 63 | /// re-enable capture 64 | EnableCapture, 65 | /// re-enable emulation 66 | EnableEmulation, 67 | /// authorize a public key 68 | AuthorizeKey { 69 | description: String, 70 | sha256_fingerprint: String, 71 | }, 72 | /// deauthorize a public key 73 | RemoveAuthorizedKey { sha256_fingerprint: String }, 74 | } 75 | 76 | pub async fn run(args: CliArgs) -> Result<(), CliError> { 77 | execute(args.command).await?; 78 | Ok(()) 79 | } 80 | 81 | async fn execute(cmd: CliSubcommand) -> Result<(), CliError> { 82 | let (mut rx, mut tx) = connect_async(Some(Duration::from_millis(500))).await?; 83 | match cmd { 84 | CliSubcommand::AddClient(Client { 85 | hostname, 86 | port, 87 | ips, 88 | enter_hook, 89 | }) => { 90 | tx.request(FrontendRequest::Create).await?; 91 | while let Some(e) = rx.next().await { 92 | if let FrontendEvent::Created(handle, _, _) = e? { 93 | if let Some(hostname) = hostname { 94 | tx.request(FrontendRequest::UpdateHostname(handle, Some(hostname))) 95 | .await?; 96 | } 97 | if let Some(port) = port { 98 | tx.request(FrontendRequest::UpdatePort(handle, port)) 99 | .await?; 100 | } 101 | if let Some(ips) = ips { 102 | tx.request(FrontendRequest::UpdateFixIps(handle, ips)) 103 | .await?; 104 | } 105 | if let Some(enter_hook) = enter_hook { 106 | tx.request(FrontendRequest::UpdateEnterHook(handle, Some(enter_hook))) 107 | .await?; 108 | } 109 | break; 110 | } 111 | } 112 | } 113 | CliSubcommand::RemoveClient { id } => tx.request(FrontendRequest::Delete(id)).await?, 114 | CliSubcommand::Activate { id } => tx.request(FrontendRequest::Activate(id, true)).await?, 115 | CliSubcommand::Deactivate { id } => { 116 | tx.request(FrontendRequest::Activate(id, false)).await? 117 | } 118 | CliSubcommand::List => { 119 | tx.request(FrontendRequest::Enumerate()).await?; 120 | while let Some(e) = rx.next().await { 121 | if let FrontendEvent::Enumerate(clients) = e? { 122 | for (handle, config, state) in clients { 123 | let host = config.hostname.unwrap_or("unknown".to_owned()); 124 | let port = config.port; 125 | let pos = config.pos; 126 | let active = state.active; 127 | let ips = state.ips; 128 | println!( 129 | "id {handle}: {host}:{port} ({pos}) active: {active}, ips: {ips:?}" 130 | ); 131 | } 132 | break; 133 | } 134 | } 135 | } 136 | CliSubcommand::SetHost { id, host } => { 137 | tx.request(FrontendRequest::UpdateHostname(id, host)) 138 | .await? 139 | } 140 | CliSubcommand::SetPort { id, port } => { 141 | tx.request(FrontendRequest::UpdatePort(id, port)).await? 142 | } 143 | CliSubcommand::SetPosition { id, pos } => { 144 | tx.request(FrontendRequest::UpdatePosition(id, pos)).await? 145 | } 146 | CliSubcommand::SetIps { id, ips } => { 147 | tx.request(FrontendRequest::UpdateFixIps(id, ips)).await? 148 | } 149 | CliSubcommand::EnableCapture => tx.request(FrontendRequest::EnableCapture).await?, 150 | CliSubcommand::EnableEmulation => tx.request(FrontendRequest::EnableEmulation).await?, 151 | CliSubcommand::AuthorizeKey { 152 | description, 153 | sha256_fingerprint, 154 | } => { 155 | tx.request(FrontendRequest::AuthorizeKey( 156 | description, 157 | sha256_fingerprint, 158 | )) 159 | .await? 160 | } 161 | CliSubcommand::RemoveAuthorizedKey { sha256_fingerprint } => { 162 | tx.request(FrontendRequest::RemoveAuthorizedKey(sha256_fingerprint)) 163 | .await? 164 | } 165 | } 166 | Ok(()) 167 | } 168 | -------------------------------------------------------------------------------- /lan-mouse-gtk/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "lan-mouse-gtk" 3 | description = "GTK4 / Libadwaita Frontend for lan-mouse" 4 | version = "0.2.0" 5 | edition = "2021" 6 | license = "GPL-3.0-or-later" 7 | repository = "https://github.com/feschber/lan-mouse" 8 | 9 | [dependencies] 10 | gtk = { package = "gtk4", version = "0.9.0", features = ["v4_2"] } 11 | adw = { package = "libadwaita", version = "0.7.0", features = ["v1_1"] } 12 | async-channel = { version = "2.1.1" } 13 | hostname = "0.4.0" 14 | log = "0.4.20" 15 | lan-mouse-ipc = { path = "../lan-mouse-ipc", version = "0.2.0" } 16 | thiserror = "2.0.0" 17 | 18 | [build-dependencies] 19 | glib-build-tools = { version = "0.20.0" } 20 | -------------------------------------------------------------------------------- /lan-mouse-gtk/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | // composite_templates 3 | glib_build_tools::compile_resources( 4 | &["resources"], 5 | "resources/resources.gresource.xml", 6 | "lan-mouse.gresource", 7 | ); 8 | } 9 | -------------------------------------------------------------------------------- /lan-mouse-gtk/resources/authorization_window.ui: -------------------------------------------------------------------------------- 1 | <?xml version="1.0" encoding="UTF-8"?> 2 | <interface> 3 | <requires lib="gtk" version="4.0"/> 4 | <requires lib="libadwaita" version="1.0"/> 5 | <template class="AuthorizationWindow" parent="AdwWindow"> 6 | <property name="modal">True</property> 7 | <property name="width-request">180</property> 8 | <property name="default-width">180</property> 9 | <property name="height-request">180</property> 10 | <property name="default-height">180</property> 11 | <property name="title" translatable="yes">Unauthorized Device</property> 12 | <property name="content"> 13 | <object class="GtkBox"> 14 | <property name="orientation">vertical</property> 15 | <property name="vexpand">True</property> 16 | <child type="top"> 17 | <object class="AdwHeaderBar"> 18 | <style> 19 | <class name="flat"/> 20 | </style> 21 | </object> 22 | </child> 23 | <child> 24 | <object class="GtkBox"> 25 | <property name="orientation">vertical</property> 26 | <property name="spacing">30</property> 27 | <property name="margin-start">30</property> 28 | <property name="margin-end">30</property> 29 | <property name="margin-top">30</property> 30 | <property name="margin-bottom">30</property> 31 | <child> 32 | <object class="GtkLabel"> 33 | <property name="label">An unauthorized Device is trying to connect. Do you want to authorize this Device?</property> 34 | <property name="width-request">100</property> 35 | <property name="wrap">word-wrap</property> 36 | </object> 37 | </child> 38 | <child> 39 | <object class="AdwPreferencesGroup"> 40 | <property name="title">sha256 fingerprint</property> 41 | <child> 42 | <object class="AdwActionRow"> 43 | <property name="child"> 44 | <object class="GtkLabel" id="fingerprint"> 45 | <property name="ellipsize">PANGO_ELLIPSIZE_NONE</property> 46 | <property name="vexpand">True</property> 47 | <property name="hexpand">False</property> 48 | <property name="wrap">True</property> 49 | <property name="wrap-mode">word-char</property> 50 | <property name="justify">center</property> 51 | <property name="xalign">0.5</property> 52 | <property name="margin-top">10</property> 53 | <property name="margin-bottom">10</property> 54 | <property name="margin-start">10</property> 55 | <property name="margin-end">10</property> 56 | <property name="width-chars">64</property> 57 | </object> 58 | </property> 59 | </object> 60 | </child> 61 | </object> 62 | </child> 63 | </object> 64 | </child> 65 | <child> 66 | <object class="GtkBox"> 67 | <property name="margin-start">30</property> 68 | <property name="margin-end">30</property> 69 | <property name="margin-top">30</property> 70 | <property name="margin-bottom">30</property> 71 | <property name="orientation">horizontal</property> 72 | <property name="spacing">30</property> 73 | <property name="hexpand">True</property> 74 | <property name="vexpand">True</property> 75 | <property name="valign">end</property> 76 | <child> 77 | <object class="GtkButton" id="cancel_button"> 78 | <signal name="clicked" handler="handle_cancel" swapped="true"/> 79 | <property name="label" translatable="yes">Cancel</property> 80 | <property name="can-shrink">True</property> 81 | <property name="height-request">50</property> 82 | <property name="hexpand">True</property> 83 | </object> 84 | </child> 85 | <child> 86 | <object class="GtkButton" id="confirm_button"> 87 | <signal name="clicked" handler="handle_confirm" swapped="true"/> 88 | <property name="label" translatable="yes">Authorize</property> 89 | <property name="can-shrink">True</property> 90 | <property name="height-request">50</property> 91 | <property name="hexpand">True</property> 92 | <style> 93 | <class name="destructive-action"/> 94 | </style> 95 | </object> 96 | </child> 97 | </object> 98 | </child> 99 | </object> 100 | </property> 101 | </template> 102 | </interface> 103 | -------------------------------------------------------------------------------- /lan-mouse-gtk/resources/client_row.ui: -------------------------------------------------------------------------------- 1 | <?xml version="1.0" encoding="UTF-8"?> 2 | <interface> 3 | <template class="ClientRow" parent="AdwExpanderRow"> 4 | <property name="title">hostname</property> 5 | <!-- enabled --> 6 | <child type="prefix"> 7 | <object class="GtkSwitch" id="enable_switch"> 8 | <property name="valign">center</property> 9 | <property name="halign">end</property> 10 | <property name="tooltip-text" translatable="yes">enable</property> 11 | </object> 12 | </child> 13 | <child type="suffix"> 14 | <object class="GtkButton" id="dns_button"> 15 | <signal name="clicked" handler="handle_request_dns" swapped="true"/> 16 | <!--<property name="icon-name">network-wired-disconnected-symbolic</property>--> 17 | <property name="icon-name">network-wired-symbolic</property> 18 | <property name="valign">center</property> 19 | <property name="halign">end</property> 20 | <property name="tooltip-text" translatable="yes">resolve host</property> 21 | </object> 22 | </child> 23 | <child type="suffix"> 24 | <object class="GtkSpinner" id="dns_loading_indicator"> 25 | </object> 26 | </child> 27 | <!-- host --> 28 | <child> 29 | <object class="AdwActionRow"> 30 | <property name="title">hostname</property> 31 | <property name="subtitle">port</property> 32 | <!-- hostname --> 33 | <child> 34 | <object class="GtkEntry" id="hostname"> 35 | <!-- <property name="title" translatable="yes">hostname</property> --> 36 | <property name="xalign">0.5</property> 37 | <property name="valign">center</property> 38 | <property name="placeholder-text">hostname</property> 39 | <property name="width-chars">-1</property> 40 | </object> 41 | </child> 42 | <!-- port --> 43 | <child> 44 | <object class="GtkEntry" id="port"> 45 | <!-- <property name="title" translatable="yes">port</property> --> 46 | <property name="max-width-chars">5</property> 47 | <property name="input_purpose">GTK_INPUT_PURPOSE_NUMBER</property> 48 | <property name="xalign">0.5</property> 49 | <property name="valign">center</property> 50 | <property name="placeholder-text">4242</property> 51 | <property name="width-chars">5</property> 52 | </object> 53 | </child> 54 | </object> 55 | </child> 56 | <!-- position --> 57 | <child> 58 | <object class="AdwComboRow" id="position"> 59 | <property name="title" translatable="yes">position</property> 60 | <property name="model"> 61 | <object class="GtkStringList"> 62 | <items> 63 | <item>Left</item> 64 | <item>Right</item> 65 | <item>Top</item> 66 | <item>Bottom</item> 67 | </items> 68 | </object> 69 | </property> 70 | </object> 71 | </child> 72 | <!-- delete button --> 73 | <child> 74 | <object class="AdwActionRow" id="delete_row"> 75 | <property name="title">delete this client</property> 76 | <child> 77 | <object class="GtkButton" id="delete_button"> 78 | <signal name="activate" handler="handle_client_delete" object="delete_row" swapped="true"/> 79 | <property name="icon-name">user-trash-symbolic</property> 80 | <property name="valign">center</property> 81 | <property name="halign">center</property> 82 | <property name="name">delete-button</property> 83 | <style><class name="error"/></style> 84 | </object> 85 | </child> 86 | </object> 87 | </child> 88 | </template> 89 | </interface> 90 | -------------------------------------------------------------------------------- /lan-mouse-gtk/resources/de.feschber.LanMouse.svg: -------------------------------------------------------------------------------- 1 | <?xml version="1.0" encoding="UTF-8" standalone="no"?> 2 | <!-- Created with Inkscape (http://www.inkscape.org/) --> 3 | 4 | <svg 5 | width="48" 6 | height="48" 7 | viewBox="0 0 12.7 12.7" 8 | version="1.1" 9 | id="svg1" 10 | inkscape:version="1.3 (0e150ed6c4, 2023-07-21)" 11 | sodipodi:docname="mouse-icon.svg" 12 | xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" 13 | xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" 14 | xmlns="http://www.w3.org/2000/svg" 15 | xmlns:svg="http://www.w3.org/2000/svg"> 16 | <sodipodi:namedview 17 | id="namedview1" 18 | pagecolor="#ffffff" 19 | bordercolor="#000000" 20 | borderopacity="0.25" 21 | inkscape:showpageshadow="2" 22 | inkscape:pageopacity="0.0" 23 | inkscape:pagecheckerboard="0" 24 | inkscape:deskcolor="#d1d1d1" 25 | inkscape:document-units="mm" 26 | inkscape:zoom="22.737887" 27 | inkscape:cx="19.54887" 28 | inkscape:cy="26.167778" 29 | inkscape:window-width="2560" 30 | inkscape:window-height="1374" 31 | inkscape:window-x="0" 32 | inkscape:window-y="0" 33 | inkscape:window-maximized="1" 34 | inkscape:current-layer="layer1" /> 35 | <defs 36 | id="defs1" /> 37 | <g 38 | inkscape:label="Layer 1" 39 | inkscape:groupmode="layer" 40 | id="layer1"> 41 | <g 42 | id="g20" 43 | transform="translate(1.1586889,0.39019296)"> 44 | <g 45 | id="g8" 46 | transform="translate(-0.11519282,-3.9659242)"> 47 | <g 48 | id="g6" 49 | transform="translate(0.67275315,0.39959697)"> 50 | <g 51 | id="g5"> 52 | <rect 53 | style="fill:none;stroke:#000000;stroke-width:0.264999;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;paint-order:normal" 54 | id="rect4" 55 | width="1.3032579" 56 | height="1.3032579" 57 | x="1.7199994" 58 | y="7.5408325" 59 | ry="0.3373504" /> 60 | <rect 61 | style="fill:none;stroke:#000000;stroke-width:0.264999;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;paint-order:normal" 62 | id="rect4-2" 63 | width="1.3032579" 64 | height="1.3032579" 65 | x="3.8428385" 66 | y="7.5408325" 67 | ry="0.3373504" /> 68 | </g> 69 | <rect 70 | style="fill:none;stroke:#000000;stroke-width:0.264999;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;paint-order:normal" 71 | id="rect4-3" 72 | width="1.3032579" 73 | height="1.3032579" 74 | x="2.781419" 75 | y="5.1382394" 76 | ry="0.3373504" /> 77 | </g> 78 | <path 79 | style="fill:none;stroke:#000000;stroke-width:0.264999;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;paint-order:normal" 80 | d="M 1.1519282,7.3907619 H 7.059674" 81 | id="path5" /> 82 | <path 83 | style="fill:none;stroke:#000000;stroke-width:0.264999;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;paint-order:normal" 84 | d="M 4.1058009,6.8410941 V 7.3907617" 85 | id="path6" /> 86 | <path 87 | style="fill:none;stroke:#000000;stroke-width:0.264999;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;paint-order:normal" 88 | d="m 5.1672204,7.9404294 2e-7,-0.5496677" 89 | id="path7" /> 90 | <path 91 | style="fill:none;stroke:#000000;stroke-width:0.264999;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;paint-order:normal" 92 | d="M 3.0443815,7.9404294 V 7.3907617" 93 | id="path8" /> 94 | </g> 95 | <path 96 | style="fill:none;stroke:#000000;stroke-width:0.264999;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;paint-order:normal" 97 | d="M 6.9444811,3.4248375 Z" 98 | id="path9" /> 99 | <path 100 | style="color:#000000;fill:#000000;stroke-linecap:round;stroke-linejoin:round;-inkscape-stroke:none" 101 | d="m 6.9840449,3.4464199 c -0.072714,-0.0035 -0.1209639,-0.2113583 -0.125,-0.1386718 -0.0035,0.072714 0.052314,0.1346357 0.125,0.1386718 0,0 0.6614057,0.034643 1.3535156,0.4765625 0.6921097,0.4419191 1.4111567,1.2803292 1.5136717,2.9433594 0.05132,0.832563 -0.07521,1.3855916 -0.279297,1.75 -0.20409,0.3644084 -0.482943,0.5482749 -0.777343,0.640625 -0.5888014,0.1847002 -1.2265629,-0.021484 -1.2265629,-0.021484 -0.069024,-0.023541 -0.144095,0.013122 -0.1679688,0.082031 -0.023366,0.069587 0.014295,0.1449093 0.083984,0.1679687 0,0 0.6961634,0.2406696 1.3886717,0.023437 C 9.2189712,9.4003039 9.5672292,9.1706004 9.8043572,8.7472012 10.041486,8.323802 10.170261,7.7150888 10.116858,6.8487637 10.009921,5.1140179 9.2320232,4.3532014 8.4801387,3.8731154 7.7282538,3.3930294 6.9840449,3.4464198 6.9840449,3.4464199 Z" 102 | id="path18" 103 | sodipodi:nodetypes="cccsssscccssssc" /> 104 | <g 105 | id="g19" 106 | transform="matrix(1.8148709,0,0,1.8148709,-4.1533763,-7.8818885)"> 107 | <g 108 | id="g17" 109 | transform="translate(0.01163623,0.23038484)"> 110 | <ellipse 111 | style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.264999;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;paint-order:normal" 112 | id="path10" 113 | cx="3.9823804" 114 | cy="8.17869" 115 | rx="0.49368349" 116 | ry="0.62533247" /> 117 | <ellipse 118 | style="fill:#3d3d3d;fill-opacity:1;stroke:none;stroke-width:0.168876;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;paint-order:normal" 119 | id="ellipse17" 120 | cx="3.9823804" 121 | cy="8.17869" 122 | rx="0.31096464" 123 | ry="0.40317491" /> 124 | </g> 125 | <path 126 | id="path11" 127 | style="stroke-width:0.264999;stroke-linecap:round;stroke-linejoin:round" 128 | d="M 7.479305,9.4704944 C 7.4964603,9.9336885 6.9306558,9.9678313 5.3811502,10.087599 3.2109768,10.255341 2.4751992,9.6707727 2.4355055,9.5280908 2.3112754,9.0815374 3.8270232,8.4090748 5.3811502,8.4090748 c 1.5633309,0 2.0816988,0.6171052 2.0981548,1.0614196 z" 129 | sodipodi:nodetypes="sssss" /> 130 | <circle 131 | style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.264999;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;paint-order:normal" 132 | id="path12" 133 | cx="3.5281858" 134 | cy="9.0632057" 135 | r="0.18513133" /> 136 | <g 137 | id="g18" 138 | transform="translate(0.01163623,0.23038484)"> 139 | <ellipse 140 | style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.264999;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;paint-order:normal" 141 | id="path10-2" 142 | cx="4.6085634" 143 | cy="8.17869" 144 | rx="0.49368349" 145 | ry="0.62533247" /> 146 | <ellipse 147 | style="fill:#3d3d3d;fill-opacity:1;stroke:none;stroke-width:0.168876;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;paint-order:normal" 148 | id="ellipse16" 149 | cx="4.6085634" 150 | cy="8.17869" 151 | rx="0.31096464" 152 | ry="0.40317491" /> 153 | </g> 154 | <ellipse 155 | style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.112226;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;paint-order:normal" 156 | id="circle18" 157 | cx="3.5003331" 158 | cy="9.0344076" 159 | rx="0.078639306" 160 | ry="0.07816644" /> 161 | <ellipse 162 | style="fill:#4f4f4f;fill-opacity:1;stroke:none;stroke-width:0.264999;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1;paint-order:normal" 163 | id="path19" 164 | cx="2.4818404" 165 | cy="9.4499254" 166 | rx="0.05348238" 167 | ry="0.11930636" /> 168 | </g> 169 | </g> 170 | </g> 171 | </svg> 172 | -------------------------------------------------------------------------------- /lan-mouse-gtk/resources/fingerprint_window.ui: -------------------------------------------------------------------------------- 1 | <?xml version="1.0" encoding="UTF-8"?> 2 | <interface> 3 | <requires lib="gtk" version="4.0"/> 4 | <requires lib="libadwaita" version="1.0"/> 5 | <template class="FingerprintWindow" parent="AdwWindow"> 6 | <property name="modal">True</property> 7 | <property name="width-request">880</property> 8 | <property name="default-width">880</property> 9 | <property name="height-request">380</property> 10 | <property name="default-height">380</property> 11 | <property name="title" translatable="yes">Add Certificate Fingerprint</property> 12 | <property name="content"> 13 | <object class="AdwToolbarView"> 14 | <child type="top"> 15 | <object class="AdwHeaderBar"/> 16 | </child> 17 | <property name="content"> 18 | <object class="AdwClamp"> 19 | <property name="maximum-size">770</property> 20 | <property name="tightening-threshold">0</property> 21 | <property name="child"> 22 | <object class="GtkBox"> 23 | <property name="orientation">vertical</property> 24 | <property name="spacing">18</property> 25 | <child> 26 | <object class="GtkLabel"> 27 | <property name="label">The certificate fingerprint serves as a unique identifier for your device.</property> 28 | </object> 29 | </child> 30 | <child> 31 | <object class="GtkLabel"> 32 | <property name="label">You can find it under the `General` section of the device you want to connect</property> 33 | </object> 34 | </child> 35 | <child> 36 | <object class="AdwPreferencesGroup"> 37 | <property name="title">description</property> 38 | <child> 39 | <object class="AdwActionRow"> 40 | <property name="child"> 41 | <object class="GtkText" id="description"> 42 | <property name="margin-top">10</property> 43 | <property name="margin-bottom">10</property> 44 | <property name="margin-start">10</property> 45 | <property name="margin-end">10</property> 46 | <property name="enable-undo">True</property> 47 | <property name="hexpand">True</property> 48 | <property name="vexpand">True</property> 49 | <property name="max-length">0</property> 50 | </object> 51 | </property> 52 | </object> 53 | </child> 54 | </object> 55 | </child> 56 | <child> 57 | <object class="AdwPreferencesGroup"> 58 | <property name="title">sha256 fingerprint</property> 59 | <child> 60 | <object class="AdwActionRow"> 61 | <property name="child"> 62 | <object class="GtkText" id="fingerprint"> 63 | <property name="margin-top">10</property> 64 | <property name="margin-bottom">10</property> 65 | <property name="margin-start">10</property> 66 | <property name="margin-end">10</property> 67 | <property name="enable-undo">True</property> 68 | <property name="hexpand">True</property> 69 | <property name="vexpand">True</property> 70 | <property name="max-length">0</property> 71 | </object> 72 | </property> 73 | </object> 74 | </child> 75 | </object> 76 | </child> 77 | <child> 78 | <object class="GtkBox"> 79 | <property name="orientation">vertical</property> 80 | <property name="halign">center</property> 81 | <child> 82 | <object class="GtkButton" id="confirm_button"> 83 | <signal name="clicked" handler="handle_confirm" swapped="true"/> 84 | <property name="label" translatable="yes">Confirm</property> 85 | <property name="can-shrink">True</property> 86 | <style> 87 | <class name="pill"/> 88 | <class name="suggested-action"/> 89 | </style> 90 | </object> 91 | </child> 92 | </object> 93 | </child> 94 | </object> 95 | </property> 96 | </object> 97 | </property> 98 | </object> 99 | </property> 100 | </template> 101 | </interface> 102 | -------------------------------------------------------------------------------- /lan-mouse-gtk/resources/key_row.ui: -------------------------------------------------------------------------------- 1 | <?xml version="1.0" encoding="UTF-8"?> 2 | <interface> 3 | <template class="KeyRow" parent="AdwActionRow"> 4 | <child type="prefix"> 5 | <object class="GtkButton" id="delete_button"> 6 | <property name="valign">center</property> 7 | <property name="halign">end</property> 8 | <property name="tooltip-text" translatable="yes">revoke authorization</property> 9 | <property name="icon-name">edit-delete-symbolic</property> 10 | <style> 11 | <class name="flat"/> 12 | </style> 13 | </object> 14 | </child> 15 | </template> 16 | </interface> 17 | -------------------------------------------------------------------------------- /lan-mouse-gtk/resources/resources.gresource.xml: -------------------------------------------------------------------------------- 1 | <?xml version="1.0" encoding="UTF-8"?> 2 | <gresources> 3 | <gresource prefix="/de/feschber/LanMouse"> 4 | <file compressed="true" preprocess="xml-stripblanks">window.ui</file> 5 | <file compressed="true" preprocess="xml-stripblanks">authorization_window.ui</file> 6 | <file compressed="true" preprocess="xml-stripblanks">fingerprint_window.ui</file> 7 | <file compressed="true" preprocess="xml-stripblanks">client_row.ui</file> 8 | <file compressed="true" preprocess="xml-stripblanks">key_row.ui</file> 9 | </gresource> 10 | <gresource prefix="/de/feschber/LanMouse/icons"> 11 | <file compressed="true" preprocess="xml-stripblanks">de.feschber.LanMouse.svg</file> 12 | </gresource> 13 | </gresources> 14 | -------------------------------------------------------------------------------- /lan-mouse-gtk/src/authorization_window.rs: -------------------------------------------------------------------------------- 1 | mod imp; 2 | 3 | use glib::Object; 4 | use gtk::{gio, glib, subclass::prelude::ObjectSubclassIsExt}; 5 | 6 | glib::wrapper! { 7 | pub struct AuthorizationWindow(ObjectSubclass<imp::AuthorizationWindow>) 8 | @extends adw::Window, gtk::Window, gtk::Widget, 9 | @implements gio::ActionGroup, gio::ActionMap, gtk::Accessible, gtk::Buildable, 10 | gtk::ConstraintTarget, gtk::Native, gtk::Root, gtk::ShortcutManager; 11 | } 12 | 13 | impl AuthorizationWindow { 14 | pub(crate) fn new(fingerprint: &str) -> Self { 15 | let window: Self = Object::builder().build(); 16 | window.imp().set_fingerprint(fingerprint); 17 | window 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /lan-mouse-gtk/src/authorization_window/imp.rs: -------------------------------------------------------------------------------- 1 | use std::sync::OnceLock; 2 | 3 | use adw::prelude::*; 4 | use adw::subclass::prelude::*; 5 | use glib::subclass::InitializingObject; 6 | use gtk::{ 7 | glib::{self, subclass::Signal}, 8 | template_callbacks, Button, CompositeTemplate, Label, 9 | }; 10 | 11 | #[derive(CompositeTemplate, Default)] 12 | #[template(resource = "/de/feschber/LanMouse/authorization_window.ui")] 13 | pub struct AuthorizationWindow { 14 | #[template_child] 15 | pub fingerprint: TemplateChild<Label>, 16 | #[template_child] 17 | pub cancel_button: TemplateChild<Button>, 18 | #[template_child] 19 | pub confirm_button: TemplateChild<Button>, 20 | } 21 | 22 | #[glib::object_subclass] 23 | impl ObjectSubclass for AuthorizationWindow { 24 | const NAME: &'static str = "AuthorizationWindow"; 25 | const ABSTRACT: bool = false; 26 | 27 | type Type = super::AuthorizationWindow; 28 | type ParentType = adw::Window; 29 | 30 | fn class_init(klass: &mut Self::Class) { 31 | klass.bind_template(); 32 | klass.bind_template_callbacks(); 33 | } 34 | 35 | fn instance_init(obj: &InitializingObject<Self>) { 36 | obj.init_template(); 37 | } 38 | } 39 | 40 | #[template_callbacks] 41 | impl AuthorizationWindow { 42 | #[template_callback] 43 | fn handle_confirm(&self, _button: Button) { 44 | let fp = self.fingerprint.text().as_str().trim().to_owned(); 45 | self.obj().emit_by_name("confirm-clicked", &[&fp]) 46 | } 47 | 48 | #[template_callback] 49 | fn handle_cancel(&self, _: Button) { 50 | self.obj().emit_by_name("cancel-clicked", &[]) 51 | } 52 | 53 | pub(super) fn set_fingerprint(&self, fingerprint: &str) { 54 | self.fingerprint.set_text(fingerprint); 55 | } 56 | } 57 | 58 | impl ObjectImpl for AuthorizationWindow { 59 | fn signals() -> &'static [Signal] { 60 | static SIGNALS: OnceLock<Vec<Signal>> = OnceLock::new(); 61 | SIGNALS.get_or_init(|| { 62 | vec![ 63 | Signal::builder("confirm-clicked") 64 | .param_types([String::static_type()]) 65 | .build(), 66 | Signal::builder("cancel-clicked").build(), 67 | ] 68 | }) 69 | } 70 | } 71 | 72 | impl WidgetImpl for AuthorizationWindow {} 73 | impl WindowImpl for AuthorizationWindow {} 74 | impl ApplicationWindowImpl for AuthorizationWindow {} 75 | impl AdwWindowImpl for AuthorizationWindow {} 76 | -------------------------------------------------------------------------------- /lan-mouse-gtk/src/client_object.rs: -------------------------------------------------------------------------------- 1 | mod imp; 2 | 3 | use adw::subclass::prelude::*; 4 | use gtk::glib::{self, Object}; 5 | 6 | use lan_mouse_ipc::{ClientConfig, ClientHandle, ClientState}; 7 | 8 | glib::wrapper! { 9 | pub struct ClientObject(ObjectSubclass<imp::ClientObject>); 10 | } 11 | 12 | impl ClientObject { 13 | pub fn new(handle: ClientHandle, client: ClientConfig, state: ClientState) -> Self { 14 | Object::builder() 15 | .property("handle", handle) 16 | .property("hostname", client.hostname) 17 | .property("port", client.port as u32) 18 | .property("position", client.pos.to_string()) 19 | .property("active", state.active) 20 | .property( 21 | "ips", 22 | state 23 | .ips 24 | .iter() 25 | .map(|ip| ip.to_string()) 26 | .collect::<Vec<_>>(), 27 | ) 28 | .property("resolving", state.resolving) 29 | .build() 30 | } 31 | 32 | pub fn get_data(&self) -> ClientData { 33 | self.imp().data.borrow().clone() 34 | } 35 | } 36 | 37 | #[derive(Default, Clone)] 38 | pub struct ClientData { 39 | pub handle: ClientHandle, 40 | pub hostname: Option<String>, 41 | pub port: u32, 42 | pub active: bool, 43 | pub position: String, 44 | pub resolving: bool, 45 | pub ips: Vec<String>, 46 | } 47 | -------------------------------------------------------------------------------- /lan-mouse-gtk/src/client_object/imp.rs: -------------------------------------------------------------------------------- 1 | use std::cell::RefCell; 2 | 3 | use glib::Properties; 4 | use gtk::glib; 5 | use gtk::prelude::*; 6 | use gtk::subclass::prelude::*; 7 | 8 | use lan_mouse_ipc::ClientHandle; 9 | 10 | use super::ClientData; 11 | 12 | #[derive(Properties, Default)] 13 | #[properties(wrapper_type = super::ClientObject)] 14 | pub struct ClientObject { 15 | #[property(name = "handle", get, set, type = ClientHandle, member = handle)] 16 | #[property(name = "hostname", get, set, type = Option<String>, member = hostname)] 17 | #[property(name = "port", get, set, type = u32, member = port, maximum = u16::MAX as u32)] 18 | #[property(name = "active", get, set, type = bool, member = active)] 19 | #[property(name = "position", get, set, type = String, member = position)] 20 | #[property(name = "resolving", get, set, type = bool, member = resolving)] 21 | #[property(name = "ips", get, set, type = Vec<String>, member = ips)] 22 | pub data: RefCell<ClientData>, 23 | } 24 | 25 | #[glib::object_subclass] 26 | impl ObjectSubclass for ClientObject { 27 | const NAME: &'static str = "ClientObject"; 28 | type Type = super::ClientObject; 29 | } 30 | 31 | #[glib::derived_properties] 32 | impl ObjectImpl for ClientObject {} 33 | -------------------------------------------------------------------------------- /lan-mouse-gtk/src/client_row.rs: -------------------------------------------------------------------------------- 1 | mod imp; 2 | 3 | use adw::prelude::*; 4 | use adw::subclass::prelude::*; 5 | use gtk::glib::{self, Object}; 6 | 7 | use lan_mouse_ipc::{Position, DEFAULT_PORT}; 8 | 9 | use super::ClientObject; 10 | 11 | glib::wrapper! { 12 | pub struct ClientRow(ObjectSubclass<imp::ClientRow>) 13 | @extends gtk::ListBoxRow, gtk::Widget, adw::PreferencesRow, adw::ExpanderRow, 14 | @implements gtk::Accessible, gtk::Actionable, gtk::Buildable, gtk::ConstraintTarget; 15 | } 16 | 17 | impl ClientRow { 18 | pub fn new(client_object: &ClientObject) -> Self { 19 | let client_row: Self = Object::builder().build(); 20 | client_row 21 | .imp() 22 | .client_object 23 | .borrow_mut() 24 | .replace(client_object.clone()); 25 | client_row 26 | } 27 | 28 | pub fn bind(&self, client_object: &ClientObject) { 29 | let mut bindings = self.imp().bindings.borrow_mut(); 30 | 31 | // bind client active to switch state 32 | let active_binding = client_object 33 | .bind_property("active", &self.imp().enable_switch.get(), "state") 34 | .sync_create() 35 | .build(); 36 | 37 | // bind client active to switch position 38 | let switch_position_binding = client_object 39 | .bind_property("active", &self.imp().enable_switch.get(), "active") 40 | .sync_create() 41 | .build(); 42 | 43 | // bind hostname to hostname edit field 44 | let hostname_binding = client_object 45 | .bind_property("hostname", &self.imp().hostname.get(), "text") 46 | .transform_to(|_, v: Option<String>| { 47 | if let Some(hostname) = v { 48 | Some(hostname) 49 | } else { 50 | Some("".to_string()) 51 | } 52 | }) 53 | .sync_create() 54 | .build(); 55 | 56 | // bind hostname to title 57 | let title_binding = client_object 58 | .bind_property("hostname", self, "title") 59 | .transform_to(|_, v: Option<String>| v.or(Some("<span font_style=\"italic\" font_weight=\"light\" foreground=\"darkgrey\">no hostname!</span>".to_string()))) 60 | .sync_create() 61 | .build(); 62 | 63 | // bind port to port edit field 64 | let port_binding = client_object 65 | .bind_property("port", &self.imp().port.get(), "text") 66 | .transform_to(|_, v: u32| { 67 | if v == DEFAULT_PORT as u32 { 68 | Some("".to_string()) 69 | } else { 70 | Some(v.to_string()) 71 | } 72 | }) 73 | .sync_create() 74 | .build(); 75 | 76 | // bind port to subtitle 77 | let subtitle_binding = client_object 78 | .bind_property("port", self, "subtitle") 79 | .sync_create() 80 | .build(); 81 | 82 | // bind position to selected position 83 | let position_binding = client_object 84 | .bind_property("position", &self.imp().position.get(), "selected") 85 | .transform_to(|_, v: String| match v.as_str() { 86 | "right" => Some(1u32), 87 | "top" => Some(2u32), 88 | "bottom" => Some(3u32), 89 | _ => Some(0u32), 90 | }) 91 | .sync_create() 92 | .build(); 93 | 94 | // bind resolving status to spinner visibility 95 | let resolve_binding = client_object 96 | .bind_property( 97 | "resolving", 98 | &self.imp().dns_loading_indicator.get(), 99 | "spinning", 100 | ) 101 | .sync_create() 102 | .build(); 103 | 104 | // bind ips to tooltip-text 105 | let ip_binding = client_object 106 | .bind_property("ips", &self.imp().dns_button.get(), "tooltip-text") 107 | .transform_to(|_, ips: Vec<String>| { 108 | if ips.is_empty() { 109 | Some("no ip addresses associated with this client".into()) 110 | } else { 111 | Some(ips.join("\n")) 112 | } 113 | }) 114 | .sync_create() 115 | .build(); 116 | 117 | bindings.push(active_binding); 118 | bindings.push(switch_position_binding); 119 | bindings.push(hostname_binding); 120 | bindings.push(title_binding); 121 | bindings.push(port_binding); 122 | bindings.push(subtitle_binding); 123 | bindings.push(position_binding); 124 | bindings.push(resolve_binding); 125 | bindings.push(ip_binding); 126 | } 127 | 128 | pub fn unbind(&self) { 129 | for binding in self.imp().bindings.borrow_mut().drain(..) { 130 | binding.unbind(); 131 | } 132 | } 133 | 134 | pub fn set_active(&self, active: bool) { 135 | self.imp().set_active(active); 136 | } 137 | 138 | pub fn set_hostname(&self, hostname: Option<String>) { 139 | self.imp().set_hostname(hostname); 140 | } 141 | 142 | pub fn set_port(&self, port: u16) { 143 | self.imp().set_port(port); 144 | } 145 | 146 | pub fn set_position(&self, pos: Position) { 147 | self.imp().set_pos(pos); 148 | } 149 | 150 | pub fn set_dns_state(&self, resolved: bool) { 151 | self.imp().set_dns_state(resolved); 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /lan-mouse-gtk/src/client_row/imp.rs: -------------------------------------------------------------------------------- 1 | use std::cell::RefCell; 2 | 3 | use adw::subclass::prelude::*; 4 | use adw::{prelude::*, ActionRow, ComboRow}; 5 | use glib::{subclass::InitializingObject, Binding}; 6 | use gtk::glib::subclass::Signal; 7 | use gtk::glib::{clone, SignalHandlerId}; 8 | use gtk::{glib, Button, CompositeTemplate, Entry, Switch}; 9 | use lan_mouse_ipc::Position; 10 | use std::sync::OnceLock; 11 | 12 | use crate::client_object::ClientObject; 13 | 14 | #[derive(CompositeTemplate, Default)] 15 | #[template(resource = "/de/feschber/LanMouse/client_row.ui")] 16 | pub struct ClientRow { 17 | #[template_child] 18 | pub enable_switch: TemplateChild<gtk::Switch>, 19 | #[template_child] 20 | pub dns_button: TemplateChild<gtk::Button>, 21 | #[template_child] 22 | pub hostname: TemplateChild<gtk::Entry>, 23 | #[template_child] 24 | pub port: TemplateChild<gtk::Entry>, 25 | #[template_child] 26 | pub position: TemplateChild<ComboRow>, 27 | #[template_child] 28 | pub delete_row: TemplateChild<ActionRow>, 29 | #[template_child] 30 | pub delete_button: TemplateChild<gtk::Button>, 31 | #[template_child] 32 | pub dns_loading_indicator: TemplateChild<gtk::Spinner>, 33 | pub bindings: RefCell<Vec<Binding>>, 34 | hostname_change_handler: RefCell<Option<SignalHandlerId>>, 35 | port_change_handler: RefCell<Option<SignalHandlerId>>, 36 | position_change_handler: RefCell<Option<SignalHandlerId>>, 37 | set_state_handler: RefCell<Option<SignalHandlerId>>, 38 | pub client_object: RefCell<Option<ClientObject>>, 39 | } 40 | 41 | #[glib::object_subclass] 42 | impl ObjectSubclass for ClientRow { 43 | // `NAME` needs to match `class` attribute of template 44 | const NAME: &'static str = "ClientRow"; 45 | const ABSTRACT: bool = false; 46 | 47 | type Type = super::ClientRow; 48 | type ParentType = adw::ExpanderRow; 49 | 50 | fn class_init(klass: &mut Self::Class) { 51 | klass.bind_template(); 52 | klass.bind_template_callbacks(); 53 | } 54 | 55 | fn instance_init(obj: &InitializingObject<Self>) { 56 | obj.init_template(); 57 | } 58 | } 59 | 60 | impl ObjectImpl for ClientRow { 61 | fn constructed(&self) { 62 | self.parent_constructed(); 63 | self.delete_button.connect_clicked(clone!( 64 | #[weak(rename_to = row)] 65 | self, 66 | move |button| { 67 | row.handle_client_delete(button); 68 | } 69 | )); 70 | let handler = self.hostname.connect_changed(clone!( 71 | #[weak(rename_to = row)] 72 | self, 73 | move |entry| { 74 | row.handle_hostname_changed(entry); 75 | } 76 | )); 77 | self.hostname_change_handler.replace(Some(handler)); 78 | let handler = self.port.connect_changed(clone!( 79 | #[weak(rename_to = row)] 80 | self, 81 | move |entry| { 82 | row.handle_port_changed(entry); 83 | } 84 | )); 85 | self.port_change_handler.replace(Some(handler)); 86 | let handler = self.position.connect_selected_notify(clone!( 87 | #[weak(rename_to = row)] 88 | self, 89 | move |position| { 90 | row.handle_position_changed(position); 91 | } 92 | )); 93 | self.position_change_handler.replace(Some(handler)); 94 | let handler = self.enable_switch.connect_state_set(clone!( 95 | #[weak(rename_to = row)] 96 | self, 97 | #[upgrade_or] 98 | glib::Propagation::Proceed, 99 | move |switch, state| { 100 | row.handle_activate_switch(state, switch); 101 | glib::Propagation::Proceed 102 | } 103 | )); 104 | self.set_state_handler.replace(Some(handler)); 105 | } 106 | 107 | fn signals() -> &'static [glib::subclass::Signal] { 108 | static SIGNALS: OnceLock<Vec<Signal>> = OnceLock::new(); 109 | SIGNALS.get_or_init(|| { 110 | vec![ 111 | Signal::builder("request-activate") 112 | .param_types([bool::static_type()]) 113 | .build(), 114 | Signal::builder("request-delete").build(), 115 | Signal::builder("request-dns").build(), 116 | Signal::builder("request-hostname-change") 117 | .param_types([String::static_type()]) 118 | .build(), 119 | Signal::builder("request-port-change") 120 | .param_types([u32::static_type()]) 121 | .build(), 122 | Signal::builder("request-position-change") 123 | .param_types([u32::static_type()]) 124 | .build(), 125 | ] 126 | }) 127 | } 128 | } 129 | 130 | #[gtk::template_callbacks] 131 | impl ClientRow { 132 | #[template_callback] 133 | fn handle_activate_switch(&self, state: bool, _switch: &Switch) -> bool { 134 | self.obj().emit_by_name::<()>("request-activate", &[&state]); 135 | true // dont run default handler 136 | } 137 | 138 | #[template_callback] 139 | fn handle_request_dns(&self, _: &Button) { 140 | self.obj().emit_by_name::<()>("request-dns", &[]); 141 | } 142 | 143 | #[template_callback] 144 | fn handle_client_delete(&self, _button: &Button) { 145 | self.obj().emit_by_name::<()>("request-delete", &[]); 146 | } 147 | 148 | fn handle_port_changed(&self, port_entry: &Entry) { 149 | if let Ok(port) = port_entry.text().parse::<u16>() { 150 | self.obj() 151 | .emit_by_name::<()>("request-port-change", &[&(port as u32)]); 152 | } 153 | } 154 | 155 | fn handle_hostname_changed(&self, hostname_entry: &Entry) { 156 | self.obj() 157 | .emit_by_name::<()>("request-hostname-change", &[&hostname_entry.text()]); 158 | } 159 | 160 | fn handle_position_changed(&self, position: &ComboRow) { 161 | self.obj() 162 | .emit_by_name("request-position-change", &[&position.selected()]) 163 | } 164 | 165 | pub(super) fn set_hostname(&self, hostname: Option<String>) { 166 | let position = self.hostname.position(); 167 | let handler = self.hostname_change_handler.borrow(); 168 | let handler = handler.as_ref().expect("signal handler"); 169 | self.hostname.block_signal(handler); 170 | self.client_object 171 | .borrow_mut() 172 | .as_mut() 173 | .expect("client object") 174 | .set_property("hostname", hostname); 175 | self.hostname.unblock_signal(handler); 176 | self.hostname.set_position(position); 177 | } 178 | 179 | pub(super) fn set_port(&self, port: u16) { 180 | let position = self.port.position(); 181 | let handler = self.port_change_handler.borrow(); 182 | let handler = handler.as_ref().expect("signal handler"); 183 | self.port.block_signal(handler); 184 | self.client_object 185 | .borrow_mut() 186 | .as_mut() 187 | .expect("client object") 188 | .set_port(port as u32); 189 | self.port.unblock_signal(handler); 190 | self.port.set_position(position); 191 | } 192 | 193 | pub(super) fn set_pos(&self, pos: Position) { 194 | let handler = self.position_change_handler.borrow(); 195 | let handler = handler.as_ref().expect("signal handler"); 196 | self.position.block_signal(handler); 197 | self.client_object 198 | .borrow_mut() 199 | .as_mut() 200 | .expect("client object") 201 | .set_position(pos.to_string()); 202 | self.position.unblock_signal(handler); 203 | } 204 | 205 | pub(super) fn set_active(&self, active: bool) { 206 | let handler = self.set_state_handler.borrow(); 207 | let handler = handler.as_ref().expect("signal handler"); 208 | self.enable_switch.block_signal(handler); 209 | self.client_object 210 | .borrow_mut() 211 | .as_mut() 212 | .expect("client object") 213 | .set_active(active); 214 | self.enable_switch.unblock_signal(handler); 215 | } 216 | 217 | pub(super) fn set_dns_state(&self, resolved: bool) { 218 | if resolved { 219 | self.dns_button.set_css_classes(&["success"]) 220 | } else { 221 | self.dns_button.set_css_classes(&["warning"]) 222 | } 223 | } 224 | } 225 | 226 | impl WidgetImpl for ClientRow {} 227 | impl BoxImpl for ClientRow {} 228 | impl ListBoxRowImpl for ClientRow {} 229 | impl PreferencesRowImpl for ClientRow {} 230 | impl ExpanderRowImpl for ClientRow {} 231 | -------------------------------------------------------------------------------- /lan-mouse-gtk/src/fingerprint_window.rs: -------------------------------------------------------------------------------- 1 | mod imp; 2 | 3 | use glib::Object; 4 | use gtk::{gio, glib, prelude::ObjectExt, subclass::prelude::ObjectSubclassIsExt}; 5 | 6 | glib::wrapper! { 7 | pub struct FingerprintWindow(ObjectSubclass<imp::FingerprintWindow>) 8 | @extends adw::Window, gtk::Window, gtk::Widget, 9 | @implements gio::ActionGroup, gio::ActionMap, gtk::Accessible, gtk::Buildable, 10 | gtk::ConstraintTarget, gtk::Native, gtk::Root, gtk::ShortcutManager; 11 | } 12 | 13 | impl FingerprintWindow { 14 | pub(crate) fn new(fingerprint: Option<String>) -> Self { 15 | let window: Self = Object::builder().build(); 16 | if let Some(fp) = fingerprint { 17 | window.imp().fingerprint.set_property("text", fp); 18 | window.imp().fingerprint.set_property("editable", false); 19 | } 20 | window 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /lan-mouse-gtk/src/fingerprint_window/imp.rs: -------------------------------------------------------------------------------- 1 | use std::sync::OnceLock; 2 | 3 | use adw::prelude::*; 4 | use adw::subclass::prelude::*; 5 | use glib::subclass::InitializingObject; 6 | use gtk::{ 7 | glib::{self, subclass::Signal}, 8 | template_callbacks, Button, CompositeTemplate, Text, 9 | }; 10 | 11 | #[derive(CompositeTemplate, Default)] 12 | #[template(resource = "/de/feschber/LanMouse/fingerprint_window.ui")] 13 | pub struct FingerprintWindow { 14 | #[template_child] 15 | pub description: TemplateChild<Text>, 16 | #[template_child] 17 | pub fingerprint: TemplateChild<Text>, 18 | #[template_child] 19 | pub confirm_button: TemplateChild<Button>, 20 | } 21 | 22 | #[glib::object_subclass] 23 | impl ObjectSubclass for FingerprintWindow { 24 | const NAME: &'static str = "FingerprintWindow"; 25 | const ABSTRACT: bool = false; 26 | 27 | type Type = super::FingerprintWindow; 28 | type ParentType = adw::Window; 29 | 30 | fn class_init(klass: &mut Self::Class) { 31 | klass.bind_template(); 32 | klass.bind_template_callbacks(); 33 | } 34 | 35 | fn instance_init(obj: &InitializingObject<Self>) { 36 | obj.init_template(); 37 | } 38 | } 39 | 40 | #[template_callbacks] 41 | impl FingerprintWindow { 42 | #[template_callback] 43 | fn handle_confirm(&self, _button: Button) { 44 | let desc = self.description.text().as_str().trim().to_owned(); 45 | let fp = self.fingerprint.text().as_str().trim().to_owned(); 46 | self.obj().emit_by_name("confirm-clicked", &[&desc, &fp]) 47 | } 48 | } 49 | 50 | impl ObjectImpl for FingerprintWindow { 51 | fn signals() -> &'static [Signal] { 52 | static SIGNALS: OnceLock<Vec<Signal>> = OnceLock::new(); 53 | SIGNALS.get_or_init(|| { 54 | vec![Signal::builder("confirm-clicked") 55 | .param_types([String::static_type(), String::static_type()]) 56 | .build()] 57 | }) 58 | } 59 | } 60 | 61 | impl WidgetImpl for FingerprintWindow {} 62 | impl WindowImpl for FingerprintWindow {} 63 | impl ApplicationWindowImpl for FingerprintWindow {} 64 | impl AdwWindowImpl for FingerprintWindow {} 65 | -------------------------------------------------------------------------------- /lan-mouse-gtk/src/key_object.rs: -------------------------------------------------------------------------------- 1 | mod imp; 2 | 3 | use adw::subclass::prelude::*; 4 | use gtk::glib::{self, Object}; 5 | 6 | glib::wrapper! { 7 | pub struct KeyObject(ObjectSubclass<imp::KeyObject>); 8 | } 9 | 10 | impl KeyObject { 11 | pub fn new(desc: String, fp: String) -> Self { 12 | Object::builder() 13 | .property("description", desc) 14 | .property("fingerprint", fp) 15 | .build() 16 | } 17 | 18 | pub fn get_description(&self) -> String { 19 | self.imp().description.borrow().clone() 20 | } 21 | 22 | pub fn get_fingerprint(&self) -> String { 23 | self.imp().fingerprint.borrow().clone() 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /lan-mouse-gtk/src/key_object/imp.rs: -------------------------------------------------------------------------------- 1 | use std::cell::RefCell; 2 | 3 | use glib::Properties; 4 | use gtk::glib; 5 | use gtk::prelude::*; 6 | use gtk::subclass::prelude::*; 7 | 8 | #[derive(Properties, Default)] 9 | #[properties(wrapper_type = super::KeyObject)] 10 | pub struct KeyObject { 11 | #[property(name = "description", get, set, type = String)] 12 | pub description: RefCell<String>, 13 | #[property(name = "fingerprint", get, set, type = String)] 14 | pub fingerprint: RefCell<String>, 15 | } 16 | 17 | #[glib::object_subclass] 18 | impl ObjectSubclass for KeyObject { 19 | const NAME: &'static str = "KeyObject"; 20 | type Type = super::KeyObject; 21 | } 22 | 23 | #[glib::derived_properties] 24 | impl ObjectImpl for KeyObject {} 25 | -------------------------------------------------------------------------------- /lan-mouse-gtk/src/key_row.rs: -------------------------------------------------------------------------------- 1 | mod imp; 2 | 3 | use adw::prelude::*; 4 | use adw::subclass::prelude::*; 5 | use gtk::glib::{self, Object}; 6 | 7 | use super::KeyObject; 8 | 9 | glib::wrapper! { 10 | pub struct KeyRow(ObjectSubclass<imp::KeyRow>) 11 | @extends gtk::ListBoxRow, gtk::Widget, adw::PreferencesRow, adw::ActionRow, 12 | @implements gtk::Accessible, gtk::Actionable, gtk::Buildable, gtk::ConstraintTarget; 13 | } 14 | 15 | impl Default for KeyRow { 16 | fn default() -> Self { 17 | Self::new() 18 | } 19 | } 20 | 21 | impl KeyRow { 22 | pub fn new() -> Self { 23 | Object::builder().build() 24 | } 25 | 26 | pub fn bind(&self, key_object: &KeyObject) { 27 | let mut bindings = self.imp().bindings.borrow_mut(); 28 | 29 | let title_binding = key_object 30 | .bind_property("description", self, "title") 31 | .sync_create() 32 | .build(); 33 | 34 | let subtitle_binding = key_object 35 | .bind_property("fingerprint", self, "subtitle") 36 | .sync_create() 37 | .build(); 38 | 39 | bindings.push(title_binding); 40 | bindings.push(subtitle_binding); 41 | } 42 | 43 | pub fn unbind(&self) { 44 | for binding in self.imp().bindings.borrow_mut().drain(..) { 45 | binding.unbind(); 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /lan-mouse-gtk/src/key_row/imp.rs: -------------------------------------------------------------------------------- 1 | use std::cell::RefCell; 2 | 3 | use adw::subclass::prelude::*; 4 | use adw::{prelude::*, ActionRow}; 5 | use glib::{subclass::InitializingObject, Binding}; 6 | use gtk::glib::clone; 7 | use gtk::glib::subclass::Signal; 8 | use gtk::{glib, Button, CompositeTemplate}; 9 | use std::sync::OnceLock; 10 | 11 | #[derive(CompositeTemplate, Default)] 12 | #[template(resource = "/de/feschber/LanMouse/key_row.ui")] 13 | pub struct KeyRow { 14 | #[template_child] 15 | pub delete_button: TemplateChild<gtk::Button>, 16 | pub bindings: RefCell<Vec<Binding>>, 17 | } 18 | 19 | #[glib::object_subclass] 20 | impl ObjectSubclass for KeyRow { 21 | // `NAME` needs to match `class` attribute of template 22 | const NAME: &'static str = "KeyRow"; 23 | const ABSTRACT: bool = false; 24 | 25 | type Type = super::KeyRow; 26 | type ParentType = ActionRow; 27 | 28 | fn class_init(klass: &mut Self::Class) { 29 | klass.bind_template(); 30 | klass.bind_template_callbacks(); 31 | } 32 | 33 | fn instance_init(obj: &InitializingObject<Self>) { 34 | obj.init_template(); 35 | } 36 | } 37 | 38 | impl ObjectImpl for KeyRow { 39 | fn constructed(&self) { 40 | self.parent_constructed(); 41 | self.delete_button.connect_clicked(clone!( 42 | #[weak(rename_to = row)] 43 | self, 44 | move |button| { 45 | row.handle_delete(button); 46 | } 47 | )); 48 | } 49 | 50 | fn signals() -> &'static [glib::subclass::Signal] { 51 | static SIGNALS: OnceLock<Vec<Signal>> = OnceLock::new(); 52 | SIGNALS.get_or_init(|| vec![Signal::builder("request-delete").build()]) 53 | } 54 | } 55 | 56 | #[gtk::template_callbacks] 57 | impl KeyRow { 58 | #[template_callback] 59 | fn handle_delete(&self, _button: &Button) { 60 | self.obj().emit_by_name::<()>("request-delete", &[]); 61 | } 62 | } 63 | 64 | impl WidgetImpl for KeyRow {} 65 | impl BoxImpl for KeyRow {} 66 | impl ListBoxRowImpl for KeyRow {} 67 | impl PreferencesRowImpl for KeyRow {} 68 | impl ActionRowImpl for KeyRow {} 69 | -------------------------------------------------------------------------------- /lan-mouse-gtk/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod authorization_window; 2 | mod client_object; 3 | mod client_row; 4 | mod fingerprint_window; 5 | mod key_object; 6 | mod key_row; 7 | mod window; 8 | 9 | use std::{env, process, str}; 10 | 11 | use window::Window; 12 | 13 | use lan_mouse_ipc::FrontendEvent; 14 | 15 | use adw::Application; 16 | use gtk::{gdk::Display, glib::clone, prelude::*, IconTheme}; 17 | use gtk::{gio, glib, prelude::ApplicationExt}; 18 | 19 | use self::client_object::ClientObject; 20 | use self::key_object::KeyObject; 21 | 22 | use thiserror::Error; 23 | 24 | #[derive(Error, Debug)] 25 | pub enum GtkError { 26 | #[error("gtk frontend exited with non zero exit code: {0}")] 27 | NonZeroExitCode(i32), 28 | } 29 | 30 | pub fn run() -> Result<(), GtkError> { 31 | log::debug!("running gtk frontend"); 32 | #[cfg(windows)] 33 | let ret = std::thread::Builder::new() 34 | .stack_size(8 * 1024 * 1024) // https://gitlab.gnome.org/GNOME/gtk/-/commit/52dbb3f372b2c3ea339e879689c1de535ba2c2c3 -> caused crash on windows 35 | .name("gtk".into()) 36 | .spawn(gtk_main) 37 | .unwrap() 38 | .join() 39 | .unwrap(); 40 | #[cfg(not(windows))] 41 | let ret = gtk_main(); 42 | 43 | match ret { 44 | glib::ExitCode::SUCCESS => Ok(()), 45 | e => Err(GtkError::NonZeroExitCode(e.value())), 46 | } 47 | } 48 | 49 | fn gtk_main() -> glib::ExitCode { 50 | gio::resources_register_include!("lan-mouse.gresource").expect("Failed to register resources."); 51 | 52 | let app = Application::builder() 53 | .application_id("de.feschber.LanMouse") 54 | .build(); 55 | 56 | app.connect_startup(|app| { 57 | load_icons(); 58 | setup_actions(app); 59 | setup_menu(app); 60 | }); 61 | app.connect_activate(build_ui); 62 | 63 | let args: Vec<&'static str> = vec![]; 64 | app.run_with_args(&args) 65 | } 66 | 67 | fn load_icons() { 68 | let display = &Display::default().expect("Could not connect to a display."); 69 | let icon_theme = IconTheme::for_display(display); 70 | icon_theme.add_resource_path("/de/feschber/LanMouse/icons"); 71 | } 72 | 73 | // Add application actions 74 | fn setup_actions(app: &adw::Application) { 75 | // Quit action 76 | // This is important on macOS, where users expect a File->Quit action with a Cmd+Q shortcut. 77 | let quit_action = gio::SimpleAction::new("quit", None); 78 | quit_action.connect_activate({ 79 | let app = app.clone(); 80 | move |_, _| { 81 | app.quit(); 82 | } 83 | }); 84 | app.add_action(&quit_action); 85 | } 86 | 87 | // Set up a global menu 88 | // 89 | // Currently this is used only on macOS 90 | fn setup_menu(app: &adw::Application) { 91 | let menu = gio::Menu::new(); 92 | 93 | let file_menu = gio::Menu::new(); 94 | file_menu.append(Some("Quit"), Some("app.quit")); 95 | menu.append_submenu(Some("_File"), &file_menu); 96 | 97 | app.set_menubar(Some(&menu)) 98 | } 99 | 100 | fn build_ui(app: &Application) { 101 | log::debug!("connecting to lan-mouse-socket"); 102 | let (mut frontend_rx, frontend_tx) = match lan_mouse_ipc::connect() { 103 | Ok(conn) => conn, 104 | Err(e) => { 105 | log::error!("{e}"); 106 | process::exit(1); 107 | } 108 | }; 109 | log::debug!("connected to lan-mouse-socket"); 110 | 111 | let (sender, receiver) = async_channel::bounded(10); 112 | 113 | gio::spawn_blocking(move || { 114 | while let Some(e) = frontend_rx.next_event() { 115 | match e { 116 | Ok(e) => sender.send_blocking(e).unwrap(), 117 | Err(e) => { 118 | log::error!("{e}"); 119 | break; 120 | } 121 | } 122 | } 123 | }); 124 | 125 | let window = Window::new(app, frontend_tx); 126 | 127 | glib::spawn_future_local(clone!( 128 | #[weak] 129 | window, 130 | async move { 131 | loop { 132 | let notify = receiver.recv().await.unwrap_or_else(|_| process::exit(1)); 133 | match notify { 134 | FrontendEvent::Created(handle, client, state) => { 135 | window.new_client(handle, client, state) 136 | } 137 | FrontendEvent::Deleted(client) => window.delete_client(client), 138 | FrontendEvent::State(handle, config, state) => { 139 | window.update_client_config(handle, config); 140 | window.update_client_state(handle, state); 141 | } 142 | FrontendEvent::NoSuchClient(_) => {} 143 | FrontendEvent::Error(e) => window.show_toast(e.as_str()), 144 | FrontendEvent::Enumerate(clients) => window.update_client_list(clients), 145 | FrontendEvent::PortChanged(port, msg) => window.update_port(port, msg), 146 | FrontendEvent::CaptureStatus(s) => window.set_capture(s.into()), 147 | FrontendEvent::EmulationStatus(s) => window.set_emulation(s.into()), 148 | FrontendEvent::AuthorizedUpdated(keys) => window.set_authorized_keys(keys), 149 | FrontendEvent::PublicKeyFingerprint(fp) => window.set_pk_fp(&fp), 150 | FrontendEvent::ConnectionAttempt { fingerprint } => { 151 | window.request_authorization(&fingerprint); 152 | } 153 | FrontendEvent::DeviceConnected { 154 | fingerprint: _, 155 | addr, 156 | } => { 157 | window.show_toast(format!("device connected: {addr}").as_str()); 158 | } 159 | FrontendEvent::DeviceEntered { 160 | fingerprint: _, 161 | addr, 162 | pos, 163 | } => { 164 | window.show_toast(format!("device entered: {addr} ({pos})").as_str()); 165 | } 166 | FrontendEvent::IncomingDisconnected(addr) => { 167 | window.show_toast(format!("{addr} disconnected").as_str()); 168 | } 169 | } 170 | } 171 | } 172 | )); 173 | 174 | window.present(); 175 | } 176 | -------------------------------------------------------------------------------- /lan-mouse-gtk/src/window/imp.rs: -------------------------------------------------------------------------------- 1 | use std::cell::{Cell, RefCell}; 2 | 3 | use adw::subclass::prelude::*; 4 | use adw::{prelude::*, ActionRow, PreferencesGroup, ToastOverlay}; 5 | use glib::subclass::InitializingObject; 6 | use gtk::glib::clone; 7 | use gtk::{gdk, gio, glib, Button, CompositeTemplate, Entry, Image, Label, ListBox}; 8 | 9 | use lan_mouse_ipc::{FrontendRequestWriter, DEFAULT_PORT}; 10 | 11 | #[derive(CompositeTemplate, Default)] 12 | #[template(resource = "/de/feschber/LanMouse/window.ui")] 13 | pub struct Window { 14 | #[template_child] 15 | pub authorized_placeholder: TemplateChild<ActionRow>, 16 | #[template_child] 17 | pub fingerprint_row: TemplateChild<ActionRow>, 18 | #[template_child] 19 | pub port_edit_apply: TemplateChild<Button>, 20 | #[template_child] 21 | pub port_edit_cancel: TemplateChild<Button>, 22 | #[template_child] 23 | pub client_list: TemplateChild<ListBox>, 24 | #[template_child] 25 | pub client_placeholder: TemplateChild<ActionRow>, 26 | #[template_child] 27 | pub port_entry: TemplateChild<Entry>, 28 | #[template_child] 29 | pub hostname_copy_icon: TemplateChild<Image>, 30 | #[template_child] 31 | pub hostname_label: TemplateChild<Label>, 32 | #[template_child] 33 | pub toast_overlay: TemplateChild<ToastOverlay>, 34 | #[template_child] 35 | pub capture_emulation_group: TemplateChild<PreferencesGroup>, 36 | #[template_child] 37 | pub capture_status_row: TemplateChild<ActionRow>, 38 | #[template_child] 39 | pub emulation_status_row: TemplateChild<ActionRow>, 40 | #[template_child] 41 | pub input_emulation_button: TemplateChild<Button>, 42 | #[template_child] 43 | pub input_capture_button: TemplateChild<Button>, 44 | #[template_child] 45 | pub authorized_list: TemplateChild<ListBox>, 46 | pub clients: RefCell<Option<gio::ListStore>>, 47 | pub authorized: RefCell<Option<gio::ListStore>>, 48 | pub frontend_request_writer: RefCell<Option<FrontendRequestWriter>>, 49 | pub port: Cell<u16>, 50 | pub capture_active: Cell<bool>, 51 | pub emulation_active: Cell<bool>, 52 | } 53 | 54 | #[glib::object_subclass] 55 | impl ObjectSubclass for Window { 56 | // `NAME` needs to match `class` attribute of template 57 | const NAME: &'static str = "LanMouseWindow"; 58 | const ABSTRACT: bool = false; 59 | 60 | type Type = super::Window; 61 | type ParentType = adw::ApplicationWindow; 62 | 63 | fn class_init(klass: &mut Self::Class) { 64 | klass.bind_template(); 65 | klass.bind_template_callbacks(); 66 | } 67 | 68 | fn instance_init(obj: &InitializingObject<Self>) { 69 | obj.init_template(); 70 | } 71 | } 72 | 73 | #[gtk::template_callbacks] 74 | impl Window { 75 | #[template_callback] 76 | fn handle_add_client_pressed(&self, _button: &Button) { 77 | self.obj().request_client_create(); 78 | } 79 | 80 | #[template_callback] 81 | fn handle_copy_hostname(&self, _: &Button) { 82 | if let Ok(hostname) = hostname::get() { 83 | let display = gdk::Display::default().unwrap(); 84 | let clipboard = display.clipboard(); 85 | clipboard.set_text(hostname.to_str().expect("hostname: invalid utf8")); 86 | let icon = self.hostname_copy_icon.clone(); 87 | icon.set_icon_name(Some("emblem-ok-symbolic")); 88 | icon.set_css_classes(&["success"]); 89 | glib::spawn_future_local(clone!( 90 | #[weak] 91 | icon, 92 | async move { 93 | glib::timeout_future_seconds(1).await; 94 | icon.set_icon_name(Some("edit-copy-symbolic")); 95 | icon.set_css_classes(&[]); 96 | } 97 | )); 98 | } 99 | } 100 | 101 | #[template_callback] 102 | fn handle_copy_fingerprint(&self, button: &Button) { 103 | let fingerprint: String = self.fingerprint_row.property("subtitle"); 104 | let display = gdk::Display::default().unwrap(); 105 | let clipboard = display.clipboard(); 106 | clipboard.set_text(&fingerprint); 107 | button.set_icon_name("emblem-ok-symbolic"); 108 | button.set_css_classes(&["success"]); 109 | glib::spawn_future_local(clone!( 110 | #[weak] 111 | button, 112 | async move { 113 | glib::timeout_future_seconds(1).await; 114 | button.set_icon_name("edit-copy-symbolic"); 115 | button.set_css_classes(&[]); 116 | } 117 | )); 118 | } 119 | 120 | #[template_callback] 121 | fn handle_port_changed(&self, _entry: &Entry) { 122 | self.port_edit_apply.set_visible(true); 123 | self.port_edit_cancel.set_visible(true); 124 | } 125 | 126 | #[template_callback] 127 | fn handle_port_edit_apply(&self) { 128 | self.obj().request_port_change(); 129 | } 130 | 131 | #[template_callback] 132 | fn handle_port_edit_cancel(&self) { 133 | log::debug!("cancel port edit"); 134 | self.port_entry 135 | .set_text(self.port.get().to_string().as_str()); 136 | self.port_edit_apply.set_visible(false); 137 | self.port_edit_cancel.set_visible(false); 138 | } 139 | 140 | #[template_callback] 141 | fn handle_emulation(&self) { 142 | self.obj().request_emulation(); 143 | } 144 | 145 | #[template_callback] 146 | fn handle_capture(&self) { 147 | self.obj().request_capture(); 148 | } 149 | 150 | #[template_callback] 151 | fn handle_add_cert_fingerprint(&self, _button: &Button) { 152 | self.obj().open_fingerprint_dialog(None); 153 | } 154 | 155 | pub fn set_port(&self, port: u16) { 156 | self.port.set(port); 157 | if port == DEFAULT_PORT { 158 | self.port_entry.set_text(""); 159 | } else { 160 | self.port_entry.set_text(format!("{port}").as_str()); 161 | } 162 | self.port_edit_apply.set_visible(false); 163 | self.port_edit_cancel.set_visible(false); 164 | } 165 | } 166 | 167 | impl ObjectImpl for Window { 168 | fn constructed(&self) { 169 | if let Ok(hostname) = hostname::get() { 170 | self.hostname_label 171 | .set_text(hostname.to_str().expect("hostname: invalid utf8")); 172 | } 173 | self.parent_constructed(); 174 | self.set_port(DEFAULT_PORT); 175 | let obj = self.obj(); 176 | obj.setup_icon(); 177 | obj.setup_clients(); 178 | obj.setup_authorized(); 179 | } 180 | } 181 | 182 | impl WidgetImpl for Window {} 183 | impl WindowImpl for Window {} 184 | impl ApplicationWindowImpl for Window {} 185 | impl AdwApplicationWindowImpl for Window {} 186 | -------------------------------------------------------------------------------- /lan-mouse-ipc/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "lan-mouse-ipc" 3 | description = "library for communication between lan-mouse service and frontends" 4 | version = "0.2.0" 5 | edition = "2021" 6 | license = "GPL-3.0-or-later" 7 | repository = "https://github.com/feschber/lan-mouse" 8 | 9 | [dependencies] 10 | futures = "0.3.30" 11 | log = "0.4.22" 12 | serde = { version = "1.0", features = ["derive"] } 13 | serde_json = "1.0.107" 14 | thiserror = "2.0.0" 15 | tokio = { version = "1.32.0", features = ["net", "io-util", "time"] } 16 | tokio-stream = { version = "0.1.15", features = ["io-util"] } 17 | -------------------------------------------------------------------------------- /lan-mouse-ipc/src/connect.rs: -------------------------------------------------------------------------------- 1 | use crate::{ConnectionError, FrontendEvent, FrontendRequest, IpcError}; 2 | use std::{ 3 | cmp::min, 4 | io::{self, prelude::*, BufReader, LineWriter, Lines}, 5 | thread, 6 | time::Duration, 7 | }; 8 | 9 | #[cfg(unix)] 10 | use std::os::unix::net::UnixStream; 11 | 12 | #[cfg(windows)] 13 | use std::net::TcpStream; 14 | 15 | pub struct FrontendEventReader { 16 | #[cfg(unix)] 17 | lines: Lines<BufReader<UnixStream>>, 18 | #[cfg(windows)] 19 | lines: Lines<BufReader<TcpStream>>, 20 | } 21 | 22 | pub struct FrontendRequestWriter { 23 | #[cfg(unix)] 24 | line_writer: LineWriter<UnixStream>, 25 | #[cfg(windows)] 26 | line_writer: LineWriter<TcpStream>, 27 | } 28 | 29 | impl FrontendEventReader { 30 | pub fn next_event(&mut self) -> Option<Result<FrontendEvent, IpcError>> { 31 | match self.lines.next()? { 32 | Err(e) => Some(Err(e.into())), 33 | Ok(l) => Some(serde_json::from_str(l.as_str()).map_err(|e| e.into())), 34 | } 35 | } 36 | } 37 | 38 | impl FrontendRequestWriter { 39 | pub fn request(&mut self, request: FrontendRequest) -> Result<(), io::Error> { 40 | let mut json = serde_json::to_string(&request).unwrap(); 41 | log::debug!("requesting: {json}"); 42 | json.push('\n'); 43 | self.line_writer.write_all(json.as_bytes())?; 44 | Ok(()) 45 | } 46 | } 47 | 48 | pub fn connect() -> Result<(FrontendEventReader, FrontendRequestWriter), ConnectionError> { 49 | let rx = wait_for_service()?; 50 | let tx = rx.try_clone()?; 51 | let buf_reader = BufReader::new(rx); 52 | let lines = buf_reader.lines(); 53 | let line_writer = LineWriter::new(tx); 54 | let reader = FrontendEventReader { lines }; 55 | let writer = FrontendRequestWriter { line_writer }; 56 | Ok((reader, writer)) 57 | } 58 | 59 | /// wait for the lan-mouse socket to come online 60 | #[cfg(unix)] 61 | fn wait_for_service() -> Result<UnixStream, ConnectionError> { 62 | let socket_path = crate::default_socket_path()?; 63 | let mut duration = Duration::from_millis(10); 64 | loop { 65 | if let Ok(stream) = UnixStream::connect(&socket_path) { 66 | break Ok(stream); 67 | } 68 | // a signaling mechanism or inotify could be used to 69 | // improve this 70 | thread::sleep(exponential_back_off(&mut duration)); 71 | } 72 | } 73 | 74 | #[cfg(windows)] 75 | fn wait_for_service() -> Result<TcpStream, ConnectionError> { 76 | let mut duration = Duration::from_millis(10); 77 | loop { 78 | if let Ok(stream) = TcpStream::connect("127.0.0.1:5252") { 79 | break Ok(stream); 80 | } 81 | thread::sleep(exponential_back_off(&mut duration)); 82 | } 83 | } 84 | 85 | fn exponential_back_off(duration: &mut Duration) -> Duration { 86 | let new = duration.saturating_mul(2); 87 | *duration = min(new, Duration::from_secs(1)); 88 | *duration 89 | } 90 | -------------------------------------------------------------------------------- /lan-mouse-ipc/src/connect_async.rs: -------------------------------------------------------------------------------- 1 | use crate::{ConnectionError, FrontendEvent, FrontendRequest, IpcError}; 2 | use std::{ 3 | cmp::min, 4 | task::{ready, Poll}, 5 | time::Duration, 6 | }; 7 | 8 | use futures::{Stream, StreamExt}; 9 | use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, ReadHalf, WriteHalf}; 10 | use tokio_stream::wrappers::LinesStream; 11 | 12 | #[cfg(unix)] 13 | use tokio::net::UnixStream; 14 | 15 | #[cfg(windows)] 16 | use tokio::net::TcpStream; 17 | 18 | pub struct AsyncFrontendEventReader { 19 | #[cfg(unix)] 20 | lines_stream: LinesStream<BufReader<ReadHalf<UnixStream>>>, 21 | #[cfg(windows)] 22 | lines_stream: LinesStream<BufReader<ReadHalf<TcpStream>>>, 23 | } 24 | 25 | pub struct AsyncFrontendRequestWriter { 26 | #[cfg(unix)] 27 | tx: WriteHalf<UnixStream>, 28 | #[cfg(windows)] 29 | tx: WriteHalf<TcpStream>, 30 | } 31 | 32 | impl Stream for AsyncFrontendEventReader { 33 | type Item = Result<FrontendEvent, IpcError>; 34 | 35 | fn poll_next( 36 | mut self: std::pin::Pin<&mut Self>, 37 | cx: &mut std::task::Context<'_>, 38 | ) -> std::task::Poll<Option<Self::Item>> { 39 | let line = ready!(self.lines_stream.poll_next_unpin(cx)); 40 | let event = line.map(|l| { 41 | l.map_err(Into::<IpcError>::into) 42 | .and_then(|l| serde_json::from_str(l.as_str()).map_err(|e| e.into())) 43 | }); 44 | Poll::Ready(event) 45 | } 46 | } 47 | 48 | impl AsyncFrontendRequestWriter { 49 | pub async fn request(&mut self, request: FrontendRequest) -> Result<(), IpcError> { 50 | let mut json = serde_json::to_string(&request).unwrap(); 51 | log::debug!("requesting: {json}"); 52 | json.push('\n'); 53 | self.tx.write_all(json.as_bytes()).await?; 54 | Ok(()) 55 | } 56 | } 57 | 58 | pub async fn connect_async( 59 | timeout: Option<Duration>, 60 | ) -> Result<(AsyncFrontendEventReader, AsyncFrontendRequestWriter), ConnectionError> { 61 | let stream = if let Some(duration) = timeout { 62 | tokio::select! { 63 | s = wait_for_service() => s?, 64 | _ = tokio::time::sleep(duration) => return Err(ConnectionError::Timeout), 65 | } 66 | } else { 67 | wait_for_service().await? 68 | }; 69 | #[cfg(unix)] 70 | let (rx, tx): (ReadHalf<UnixStream>, WriteHalf<UnixStream>) = tokio::io::split(stream); 71 | #[cfg(windows)] 72 | let (rx, tx): (ReadHalf<TcpStream>, WriteHalf<TcpStream>) = tokio::io::split(stream); 73 | let buf_reader = BufReader::new(rx); 74 | let lines = buf_reader.lines(); 75 | let lines_stream = LinesStream::new(lines); 76 | let reader = AsyncFrontendEventReader { lines_stream }; 77 | let writer = AsyncFrontendRequestWriter { tx }; 78 | Ok((reader, writer)) 79 | } 80 | 81 | /// wait for the lan-mouse socket to come online 82 | #[cfg(unix)] 83 | async fn wait_for_service() -> Result<UnixStream, ConnectionError> { 84 | let socket_path = crate::default_socket_path()?; 85 | let mut duration = Duration::from_millis(10); 86 | loop { 87 | if let Ok(stream) = UnixStream::connect(&socket_path).await { 88 | break Ok(stream); 89 | } 90 | // a signaling mechanism or inotify could be used to 91 | // improve this 92 | tokio::time::sleep(exponential_back_off(&mut duration)).await; 93 | } 94 | } 95 | 96 | #[cfg(windows)] 97 | async fn wait_for_service() -> Result<TcpStream, ConnectionError> { 98 | let mut duration = Duration::from_millis(10); 99 | loop { 100 | if let Ok(stream) = TcpStream::connect("127.0.0.1:5252").await { 101 | break Ok(stream); 102 | } 103 | tokio::time::sleep(exponential_back_off(&mut duration)).await; 104 | } 105 | } 106 | 107 | fn exponential_back_off(duration: &mut Duration) -> Duration { 108 | let new = duration.saturating_mul(2); 109 | *duration = min(new, Duration::from_secs(1)); 110 | *duration 111 | } 112 | -------------------------------------------------------------------------------- /lan-mouse-ipc/src/lib.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::{HashMap, HashSet}, 3 | env::VarError, 4 | fmt::Display, 5 | io, 6 | net::{IpAddr, SocketAddr}, 7 | str::FromStr, 8 | }; 9 | use thiserror::Error; 10 | 11 | #[cfg(unix)] 12 | use std::{ 13 | env, 14 | path::{Path, PathBuf}, 15 | }; 16 | 17 | use serde::{Deserialize, Serialize}; 18 | 19 | mod connect; 20 | mod connect_async; 21 | mod listen; 22 | 23 | pub use connect::{connect, FrontendEventReader, FrontendRequestWriter}; 24 | pub use connect_async::{connect_async, AsyncFrontendEventReader, AsyncFrontendRequestWriter}; 25 | pub use listen::AsyncFrontendListener; 26 | 27 | #[derive(Debug, Error)] 28 | pub enum ConnectionError { 29 | #[error(transparent)] 30 | SocketPath(#[from] SocketPathError), 31 | #[error(transparent)] 32 | Io(#[from] io::Error), 33 | #[error("connection timed out")] 34 | Timeout, 35 | } 36 | 37 | #[derive(Debug, Error)] 38 | pub enum IpcListenerCreationError { 39 | #[error("could not determine socket-path: `{0}`")] 40 | SocketPath(#[from] SocketPathError), 41 | #[error("service already running!")] 42 | AlreadyRunning, 43 | #[error("failed to bind lan-mouse socket: `{0}`")] 44 | Bind(io::Error), 45 | } 46 | 47 | #[derive(Debug, Error)] 48 | pub enum IpcError { 49 | #[error("io error occured: `{0}`")] 50 | Io(#[from] io::Error), 51 | #[error("invalid json: `{0}`")] 52 | Json(#[from] serde_json::Error), 53 | #[error(transparent)] 54 | Connection(#[from] ConnectionError), 55 | #[error(transparent)] 56 | Listen(#[from] IpcListenerCreationError), 57 | } 58 | 59 | pub const DEFAULT_PORT: u16 = 4242; 60 | 61 | #[derive(Debug, Default, Eq, Hash, PartialEq, Clone, Copy, Serialize, Deserialize)] 62 | #[serde(rename_all = "lowercase")] 63 | pub enum Position { 64 | #[default] 65 | Left, 66 | Right, 67 | Top, 68 | Bottom, 69 | } 70 | 71 | impl Position { 72 | pub fn opposite(&self) -> Self { 73 | match self { 74 | Position::Left => Position::Right, 75 | Position::Right => Position::Left, 76 | Position::Top => Position::Bottom, 77 | Position::Bottom => Position::Top, 78 | } 79 | } 80 | } 81 | 82 | #[derive(Debug, Error)] 83 | #[error("not a valid position: {pos}")] 84 | pub struct PositionParseError { 85 | pos: String, 86 | } 87 | 88 | impl FromStr for Position { 89 | type Err = PositionParseError; 90 | 91 | fn from_str(s: &str) -> Result<Self, Self::Err> { 92 | match s { 93 | "left" => Ok(Self::Left), 94 | "right" => Ok(Self::Right), 95 | "top" => Ok(Self::Top), 96 | "bottom" => Ok(Self::Bottom), 97 | _ => Err(PositionParseError { pos: s.into() }), 98 | } 99 | } 100 | } 101 | 102 | impl Display for Position { 103 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 104 | write!( 105 | f, 106 | "{}", 107 | match self { 108 | Position::Left => "left", 109 | Position::Right => "right", 110 | Position::Top => "top", 111 | Position::Bottom => "bottom", 112 | } 113 | ) 114 | } 115 | } 116 | 117 | impl TryFrom<&str> for Position { 118 | type Error = (); 119 | 120 | fn try_from(s: &str) -> Result<Self, Self::Error> { 121 | match s { 122 | "left" => Ok(Position::Left), 123 | "right" => Ok(Position::Right), 124 | "top" => Ok(Position::Top), 125 | "bottom" => Ok(Position::Bottom), 126 | _ => Err(()), 127 | } 128 | } 129 | } 130 | 131 | #[derive(Debug, Eq, PartialEq, Clone, Serialize, Deserialize)] 132 | pub struct ClientConfig { 133 | /// hostname of this client 134 | pub hostname: Option<String>, 135 | /// fix ips, determined by the user 136 | pub fix_ips: Vec<IpAddr>, 137 | /// both active_addr and addrs can be None / empty so port needs to be stored seperately 138 | pub port: u16, 139 | /// position of a client on screen 140 | pub pos: Position, 141 | /// enter hook 142 | pub cmd: Option<String>, 143 | } 144 | 145 | impl Default for ClientConfig { 146 | fn default() -> Self { 147 | Self { 148 | port: DEFAULT_PORT, 149 | hostname: Default::default(), 150 | fix_ips: Default::default(), 151 | pos: Default::default(), 152 | cmd: None, 153 | } 154 | } 155 | } 156 | 157 | pub type ClientHandle = u64; 158 | 159 | #[derive(Debug, Default, Clone, Serialize, Deserialize)] 160 | pub struct ClientState { 161 | /// events should be sent to and received from the client 162 | pub active: bool, 163 | /// `active` address of the client, used to send data to. 164 | /// This should generally be the socket address where data 165 | /// was last received from. 166 | pub active_addr: Option<SocketAddr>, 167 | /// tracks whether or not the client is available for emulation 168 | pub alive: bool, 169 | /// ips from dns 170 | pub dns_ips: Vec<IpAddr>, 171 | /// all ip addresses associated with a particular client 172 | /// e.g. Laptops usually have at least an ethernet and a wifi port 173 | /// which have different ip addresses 174 | pub ips: HashSet<IpAddr>, 175 | /// client has pressed keys 176 | pub has_pressed_keys: bool, 177 | /// dns resolving in progress 178 | pub resolving: bool, 179 | } 180 | 181 | #[derive(Debug, Clone, Serialize, Deserialize)] 182 | pub enum FrontendEvent { 183 | /// a client was created 184 | Created(ClientHandle, ClientConfig, ClientState), 185 | /// no such client 186 | NoSuchClient(ClientHandle), 187 | /// state changed 188 | State(ClientHandle, ClientConfig, ClientState), 189 | /// the client was deleted 190 | Deleted(ClientHandle), 191 | /// new port, reason of failure (if failed) 192 | PortChanged(u16, Option<String>), 193 | /// list of all clients, used for initial state synchronization 194 | Enumerate(Vec<(ClientHandle, ClientConfig, ClientState)>), 195 | /// an error occured 196 | Error(String), 197 | /// capture status 198 | CaptureStatus(Status), 199 | /// emulation status 200 | EmulationStatus(Status), 201 | /// authorized public key fingerprints have been updated 202 | AuthorizedUpdated(HashMap<String, String>), 203 | /// public key fingerprint of this device 204 | PublicKeyFingerprint(String), 205 | /// new device connected 206 | DeviceConnected { 207 | addr: SocketAddr, 208 | fingerprint: String, 209 | }, 210 | /// incoming device entered the screen 211 | DeviceEntered { 212 | fingerprint: String, 213 | addr: SocketAddr, 214 | pos: Position, 215 | }, 216 | /// incoming disconnected 217 | IncomingDisconnected(SocketAddr), 218 | /// failed connection attempt (approval for fingerprint required) 219 | ConnectionAttempt { fingerprint: String }, 220 | } 221 | 222 | #[derive(Debug, Eq, PartialEq, Clone, Serialize, Deserialize)] 223 | pub enum FrontendRequest { 224 | /// activate/deactivate client 225 | Activate(ClientHandle, bool), 226 | /// add a new client 227 | Create, 228 | /// change the listen port (recreate udp listener) 229 | ChangePort(u16), 230 | /// remove a client 231 | Delete(ClientHandle), 232 | /// request an enumeration of all clients 233 | Enumerate(), 234 | /// resolve dns 235 | ResolveDns(ClientHandle), 236 | /// update hostname 237 | UpdateHostname(ClientHandle, Option<String>), 238 | /// update port 239 | UpdatePort(ClientHandle, u16), 240 | /// update position 241 | UpdatePosition(ClientHandle, Position), 242 | /// update fix-ips 243 | UpdateFixIps(ClientHandle, Vec<IpAddr>), 244 | /// request reenabling input capture 245 | EnableCapture, 246 | /// request reenabling input emulation 247 | EnableEmulation, 248 | /// synchronize all state 249 | Sync, 250 | /// authorize fingerprint (description, fingerprint) 251 | AuthorizeKey(String, String), 252 | /// remove fingerprint (fingerprint) 253 | RemoveAuthorizedKey(String), 254 | /// change the hook command 255 | UpdateEnterHook(u64, Option<String>), 256 | } 257 | 258 | #[derive(Clone, Copy, PartialEq, Eq, Debug, Default, Serialize, Deserialize)] 259 | pub enum Status { 260 | #[default] 261 | Disabled, 262 | Enabled, 263 | } 264 | 265 | impl From<Status> for bool { 266 | fn from(status: Status) -> Self { 267 | match status { 268 | Status::Enabled => true, 269 | Status::Disabled => false, 270 | } 271 | } 272 | } 273 | 274 | #[cfg(unix)] 275 | const LAN_MOUSE_SOCKET_NAME: &str = "lan-mouse-socket.sock"; 276 | 277 | #[derive(Debug, Error)] 278 | pub enum SocketPathError { 279 | #[error("could not determine $XDG_RUNTIME_DIR: `{0}`")] 280 | XdgRuntimeDirNotFound(VarError), 281 | #[error("could not determine $HOME: `{0}`")] 282 | HomeDirNotFound(VarError), 283 | } 284 | 285 | #[cfg(all(unix, not(target_os = "macos")))] 286 | pub fn default_socket_path() -> Result<PathBuf, SocketPathError> { 287 | let xdg_runtime_dir = 288 | env::var("XDG_RUNTIME_DIR").map_err(SocketPathError::XdgRuntimeDirNotFound)?; 289 | Ok(Path::new(xdg_runtime_dir.as_str()).join(LAN_MOUSE_SOCKET_NAME)) 290 | } 291 | 292 | #[cfg(all(unix, target_os = "macos"))] 293 | pub fn default_socket_path() -> Result<PathBuf, SocketPathError> { 294 | let home = env::var("HOME").map_err(SocketPathError::HomeDirNotFound)?; 295 | Ok(Path::new(home.as_str()) 296 | .join("Library") 297 | .join("Caches") 298 | .join(LAN_MOUSE_SOCKET_NAME)) 299 | } 300 | -------------------------------------------------------------------------------- /lan-mouse-ipc/src/listen.rs: -------------------------------------------------------------------------------- 1 | use futures::{stream::SelectAll, Stream, StreamExt}; 2 | #[cfg(unix)] 3 | use std::path::PathBuf; 4 | use std::{ 5 | io::ErrorKind, 6 | pin::Pin, 7 | task::{Context, Poll}, 8 | }; 9 | 10 | use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, ReadHalf, WriteHalf}; 11 | use tokio_stream::wrappers::LinesStream; 12 | 13 | #[cfg(unix)] 14 | use tokio::net::UnixListener; 15 | #[cfg(unix)] 16 | use tokio::net::UnixStream; 17 | 18 | #[cfg(windows)] 19 | use tokio::net::TcpListener; 20 | #[cfg(windows)] 21 | use tokio::net::TcpStream; 22 | 23 | use crate::{FrontendEvent, FrontendRequest, IpcError, IpcListenerCreationError}; 24 | 25 | pub struct AsyncFrontendListener { 26 | #[cfg(windows)] 27 | listener: TcpListener, 28 | #[cfg(unix)] 29 | listener: UnixListener, 30 | #[cfg(unix)] 31 | socket_path: PathBuf, 32 | #[cfg(unix)] 33 | line_streams: SelectAll<LinesStream<BufReader<ReadHalf<UnixStream>>>>, 34 | #[cfg(windows)] 35 | line_streams: SelectAll<LinesStream<BufReader<ReadHalf<TcpStream>>>>, 36 | #[cfg(unix)] 37 | tx_streams: Vec<WriteHalf<UnixStream>>, 38 | #[cfg(windows)] 39 | tx_streams: Vec<WriteHalf<TcpStream>>, 40 | } 41 | 42 | impl AsyncFrontendListener { 43 | pub async fn new() -> Result<Self, IpcListenerCreationError> { 44 | #[cfg(unix)] 45 | let (socket_path, listener) = { 46 | let socket_path = crate::default_socket_path()?; 47 | 48 | log::debug!("remove socket: {:?}", socket_path); 49 | if socket_path.exists() { 50 | // try to connect to see if some other instance 51 | // of lan-mouse is already running 52 | match UnixStream::connect(&socket_path).await { 53 | // connected -> lan-mouse is already running 54 | Ok(_) => return Err(IpcListenerCreationError::AlreadyRunning), 55 | // lan-mouse is not running but a socket was left behind 56 | Err(e) => { 57 | log::debug!("{socket_path:?}: {e} - removing left behind socket"); 58 | let _ = std::fs::remove_file(&socket_path); 59 | } 60 | } 61 | } 62 | let listener = match UnixListener::bind(&socket_path) { 63 | Ok(ls) => ls, 64 | // some other lan-mouse instance has bound the socket in the meantime 65 | Err(e) if e.kind() == ErrorKind::AddrInUse => { 66 | return Err(IpcListenerCreationError::AlreadyRunning) 67 | } 68 | Err(e) => return Err(IpcListenerCreationError::Bind(e)), 69 | }; 70 | (socket_path, listener) 71 | }; 72 | 73 | #[cfg(windows)] 74 | let listener = match TcpListener::bind("127.0.0.1:5252").await { 75 | Ok(ls) => ls, 76 | // some other lan-mouse instance has bound the socket in the meantime 77 | Err(e) if e.kind() == ErrorKind::AddrInUse => { 78 | return Err(IpcListenerCreationError::AlreadyRunning) 79 | } 80 | Err(e) => return Err(IpcListenerCreationError::Bind(e)), 81 | }; 82 | 83 | let adapter = Self { 84 | listener, 85 | #[cfg(unix)] 86 | socket_path, 87 | line_streams: SelectAll::new(), 88 | tx_streams: vec![], 89 | }; 90 | 91 | Ok(adapter) 92 | } 93 | 94 | pub async fn broadcast(&mut self, notify: FrontendEvent) { 95 | // encode event 96 | let mut json = serde_json::to_string(¬ify).unwrap(); 97 | json.push('\n'); 98 | 99 | let mut keep = vec![]; 100 | // TODO do simultaneously 101 | for tx in self.tx_streams.iter_mut() { 102 | // write len + payload 103 | if tx.write(json.as_bytes()).await.is_err() { 104 | keep.push(false); 105 | continue; 106 | } 107 | keep.push(true); 108 | } 109 | 110 | // could not find a better solution because async 111 | let mut keep = keep.into_iter(); 112 | self.tx_streams.retain(|_| keep.next().unwrap()); 113 | } 114 | } 115 | 116 | #[cfg(unix)] 117 | impl Drop for AsyncFrontendListener { 118 | fn drop(&mut self) { 119 | log::debug!("remove socket: {:?}", self.socket_path); 120 | let _ = std::fs::remove_file(&self.socket_path); 121 | } 122 | } 123 | 124 | impl Stream for AsyncFrontendListener { 125 | type Item = Result<FrontendRequest, IpcError>; 126 | fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> { 127 | if let Poll::Ready(Some(Ok(l))) = self.line_streams.poll_next_unpin(cx) { 128 | let request = serde_json::from_str(l.as_str()).map_err(|e| e.into()); 129 | return Poll::Ready(Some(request)); 130 | } 131 | let mut sync = false; 132 | while let Poll::Ready(Ok((stream, _))) = self.listener.poll_accept(cx) { 133 | let (rx, tx) = tokio::io::split(stream); 134 | let buf_reader = BufReader::new(rx); 135 | let lines = buf_reader.lines(); 136 | let lines = LinesStream::new(lines); 137 | self.line_streams.push(lines); 138 | self.tx_streams.push(tx); 139 | sync = true; 140 | } 141 | if sync { 142 | Poll::Ready(Some(Ok(FrontendRequest::Sync))) 143 | } else { 144 | Poll::Pending 145 | } 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /lan-mouse-proto/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "lan-mouse-proto" 3 | description = "network protocol for lan-mouse" 4 | version = "0.2.0" 5 | edition = "2021" 6 | license = "GPL-3.0-or-later" 7 | repository = "https://github.com/feschber/lan-mouse" 8 | 9 | [dependencies] 10 | num_enum = "0.7.2" 11 | thiserror = "2.0.0" 12 | input-event = { path = "../input-event", version = "0.3.0" } 13 | paste = "1.0" 14 | -------------------------------------------------------------------------------- /nix/README.md: -------------------------------------------------------------------------------- 1 | # Nix Flake Usage 2 | 3 | ## run 4 | 5 | ```bash 6 | nix run github:feschber/lan-mouse 7 | 8 | # with params 9 | nix run github:feschber/lan-mouse -- --help 10 | 11 | ``` 12 | 13 | ## home-manager module 14 | 15 | add input 16 | 17 | ```nix 18 | inputs = { 19 | lan-mouse.url = "github:feschber/lan-mouse"; 20 | } 21 | ``` 22 | 23 | enable lan-mouse 24 | 25 | ``` nix 26 | { 27 | inputs, 28 | ... 29 | }: { 30 | # add the home manager module 31 | imports = [inputs.lan-mouse.homeManagerModules.default]; 32 | 33 | programs.lan-mouse = { 34 | enable = true; 35 | # systemd = false; 36 | # package = inputs.lan-mouse.packages.${pkgs.stdenv.hostPlatform.system}.default 37 | # Optional configuration in nix syntax, see config.toml for available options 38 | # settings = { }; 39 | }; 40 | }; 41 | } 42 | 43 | ``` 44 | -------------------------------------------------------------------------------- /nix/default.nix: -------------------------------------------------------------------------------- 1 | { 2 | rustPlatform, 3 | lib, 4 | pkgs, 5 | }: let 6 | cargoToml = builtins.fromTOML (builtins.readFile ../Cargo.toml); 7 | pname = cargoToml.package.name; 8 | version = cargoToml.package.version; 9 | in 10 | rustPlatform.buildRustPackage { 11 | pname = pname; 12 | version = version; 13 | 14 | nativeBuildInputs = with pkgs; [ 15 | git 16 | pkg-config 17 | cmake 18 | makeWrapper 19 | buildPackages.gtk4 20 | ]; 21 | 22 | buildInputs = with pkgs; [ 23 | xorg.libX11 24 | gtk4 25 | libadwaita 26 | xorg.libXtst 27 | ] ++ lib.optionals stdenv.isDarwin 28 | (with darwin.apple_sdk_11_0.frameworks; [ 29 | CoreGraphics 30 | ApplicationServices 31 | ]); 32 | 33 | src = builtins.path { 34 | name = pname; 35 | path = lib.cleanSource ../.; 36 | }; 37 | 38 | cargoLock.lockFile = ../Cargo.lock; 39 | 40 | # Set Environment Variables 41 | RUST_BACKTRACE = "full"; 42 | 43 | # Needed to enable support for SVG icons in GTK 44 | postInstall = '' 45 | wrapProgram "$out/bin/lan-mouse" \ 46 | --set GDK_PIXBUF_MODULE_FILE ${pkgs.librsvg.out}/lib/gdk-pixbuf-2.0/2.10.0/loaders.cache 47 | 48 | install -Dm444 *.desktop -t $out/share/applications 49 | install -Dm444 lan-mouse-gtk/resources/*.svg -t $out/share/icons/hicolor/scalable/apps 50 | ''; 51 | 52 | meta = with lib; { 53 | description = "Lan Mouse is a mouse and keyboard sharing software"; 54 | longDescription = '' 55 | Lan Mouse is a mouse and keyboard sharing software similar to universal-control on Apple devices. It allows for using multiple pcs with a single set of mouse and keyboard. This is also known as a Software KVM switch. 56 | The primary target is Wayland on Linux but Windows and MacOS and Linux on Xorg have partial support as well (see below for more details). 57 | ''; 58 | mainProgram = pname; 59 | platforms = platforms.all; 60 | }; 61 | } 62 | -------------------------------------------------------------------------------- /nix/hm-module.nix: -------------------------------------------------------------------------------- 1 | self: { 2 | config, 3 | pkgs, 4 | lib, 5 | ... 6 | }: 7 | with lib; let 8 | cfg = config.programs.lan-mouse; 9 | defaultPackage = self.packages.${pkgs.stdenv.hostPlatform.system}.default; 10 | tomlFormat = pkgs.formats.toml {}; 11 | in { 12 | options.programs.lan-mouse = with types; { 13 | enable = mkEnableOption "Whether or not to enable lan-mouse."; 14 | package = mkOption { 15 | type = with types; nullOr package; 16 | default = defaultPackage; 17 | defaultText = literalExpression "inputs.lan-mouse.packages.${pkgs.stdenv.hostPlatform.system}.default"; 18 | description = '' 19 | The lan-mouse package to use. 20 | 21 | By default, this option will use the `packages.default` as exposed by this flake. 22 | ''; 23 | }; 24 | systemd = mkOption { 25 | type = types.bool; 26 | default = pkgs.stdenv.isLinux; 27 | description = "Whether to enable to systemd service for lan-mouse on linux."; 28 | }; 29 | launchd = mkOption { 30 | type = types.bool; 31 | default = pkgs.stdenv.isDarwin; 32 | description = "Whether to enable to launchd service for lan-mouse on macOS."; 33 | }; 34 | settings = lib.mkOption { 35 | inherit (tomlFormat) type; 36 | default = {}; 37 | example = builtins.fromTOML (builtins.readFile (self + /config.toml)); 38 | description = '' 39 | Optional configuration written to {file}`$XDG_CONFIG_HOME/lan-mouse/config.toml`. 40 | 41 | See <https://github.com/feschber/lan-mouse/> for 42 | available options and documentation. 43 | ''; 44 | }; 45 | }; 46 | 47 | config = mkIf cfg.enable { 48 | systemd.user.services.lan-mouse = lib.mkIf cfg.systemd { 49 | Unit = { 50 | Description = "Systemd service for Lan Mouse"; 51 | Requires = ["graphical-session.target"]; 52 | }; 53 | Service = { 54 | Type = "simple"; 55 | ExecStart = "${cfg.package}/bin/lan-mouse daemon"; 56 | }; 57 | Install.WantedBy = [ 58 | (lib.mkIf config.wayland.windowManager.hyprland.systemd.enable "hyprland-session.target") 59 | (lib.mkIf config.wayland.windowManager.sway.systemd.enable "sway-session.target") 60 | ]; 61 | }; 62 | 63 | launchd.agents.lan-mouse = lib.mkIf cfg.launchd { 64 | enable = true; 65 | config = { 66 | ProgramArguments = [ 67 | "${cfg.package}/bin/lan-mouse" 68 | "daemon" 69 | ]; 70 | KeepAlive = true; 71 | }; 72 | }; 73 | 74 | home.packages = [ 75 | cfg.package 76 | ]; 77 | 78 | xdg.configFile."lan-mouse/config.toml" = lib.mkIf (cfg.settings != {}) { 79 | source = tomlFormat.generate "config.toml" cfg.settings; 80 | }; 81 | }; 82 | } 83 | -------------------------------------------------------------------------------- /screenshots/dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feschber/lan-mouse/e46fe60b3e24be83de38701faa99a4fbd9186f08/screenshots/dark.png -------------------------------------------------------------------------------- /screenshots/light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feschber/lan-mouse/e46fe60b3e24be83de38701faa99a4fbd9186f08/screenshots/light.png -------------------------------------------------------------------------------- /scripts/makeicns.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | usage() { 5 | cat <<EOF 6 | $0: Make a macOS icns file from an SVG with ImageMagick and iconutil. 7 | usage: $0 [SVG [ICNS [ICONSET]] 8 | 9 | ARGUMENTS 10 | SVG The SVG file to convert 11 | Defaults to ./lan-mouse-gtk/resources/de.feschber.LanMouse.svg 12 | ICNS The icns file to create 13 | Defaults to ./target/icon.icns 14 | ICONSET The iconset directory to create 15 | Defaults to ./target/icon.iconset 16 | This is just a temporary directory 17 | EOF 18 | } 19 | 20 | if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then 21 | usage 22 | exit 0 23 | fi 24 | 25 | svg="${1:-./lan-mouse-gtk/resources/de.feschber.LanMouse.svg}" 26 | icns="${2:-./target/icon.icns}" 27 | iconset="${3:-./target/icon.iconset}" 28 | 29 | set -u 30 | 31 | mkdir -p "$iconset" 32 | magick convert -background none -resize 1024x1024 "$svg" "$iconset"/icon_512x512@2x.png 33 | magick convert -background none -resize 512x512 "$svg" "$iconset"/icon_512x512.png 34 | magick convert -background none -resize 256x256 "$svg" "$iconset"/icon_256x256.png 35 | magick convert -background none -resize 128x128 "$svg" "$iconset"/icon_128x128.png 36 | magick convert -background none -resize 64x64 "$svg" "$iconset"/icon_32x32@2x.png 37 | magick convert -background none -resize 32x32 "$svg" "$iconset"/icon_32x32.png 38 | magick convert -background none -resize 16x16 "$svg" "$iconset"/icon_16x16.png 39 | cp "$iconset"/icon_512x512.png "$iconset"/icon_256x256@2x.png 40 | cp "$iconset"/icon_256x256.png "$iconset"/icon_128x128@2x.png 41 | cp "$iconset"/icon_32x32.png "$iconset"/icon_16x16@2x.png 42 | iconutil -c icns "$iconset" -o "$icns" 43 | -------------------------------------------------------------------------------- /service/lan-mouse.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Lan Mouse 3 | # lan mouse needs an active graphical session 4 | After=graphical-session.target 5 | # make sure the service terminates with the graphical session 6 | BindsTo=graphical-session.target 7 | 8 | [Service] 9 | ExecStart=/usr/bin/lan-mouse daemon 10 | Restart=on-failure 11 | 12 | [Install] 13 | WantedBy=graphical-session.target 14 | -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | (builtins.getFlake ("git+file://" + toString ./.)).devShells.${builtins.currentSystem}.default 2 | -------------------------------------------------------------------------------- /src/capture_test.rs: -------------------------------------------------------------------------------- 1 | use crate::config::Config; 2 | use clap::Args; 3 | use futures::StreamExt; 4 | use input_capture::{self, CaptureError, CaptureEvent, InputCapture, InputCaptureError, Position}; 5 | use input_event::{Event, KeyboardEvent}; 6 | 7 | #[derive(Args, Clone, Debug, Eq, PartialEq)] 8 | pub struct TestCaptureArgs {} 9 | 10 | pub async fn run(config: Config, _args: TestCaptureArgs) -> Result<(), InputCaptureError> { 11 | log::info!("running input capture test"); 12 | log::info!("creating input capture"); 13 | let backend = config.capture_backend().map(|b| b.into()); 14 | loop { 15 | let mut input_capture = InputCapture::new(backend).await?; 16 | log::info!("creating clients"); 17 | input_capture.create(0, Position::Left).await?; 18 | input_capture.create(4, Position::Left).await?; 19 | input_capture.create(1, Position::Right).await?; 20 | input_capture.create(2, Position::Top).await?; 21 | input_capture.create(3, Position::Bottom).await?; 22 | if let Err(e) = do_capture(&mut input_capture).await { 23 | log::warn!("{e} - recreating capture"); 24 | } 25 | let _ = input_capture.terminate().await; 26 | } 27 | } 28 | 29 | async fn do_capture(input_capture: &mut InputCapture) -> Result<(), CaptureError> { 30 | loop { 31 | let (client, event) = input_capture 32 | .next() 33 | .await 34 | .ok_or(CaptureError::EndOfStream)??; 35 | let pos = match client { 36 | 0 | 4 => Position::Left, 37 | 1 => Position::Right, 38 | 2 => Position::Top, 39 | 3 => Position::Bottom, 40 | _ => panic!(), 41 | }; 42 | log::info!("position: {client} ({pos}), event: {event}"); 43 | if let CaptureEvent::Input(Event::Keyboard(KeyboardEvent::Key { key: 1, .. })) = event { 44 | input_capture.release().await?; 45 | break Ok(()); 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/client.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | cell::RefCell, 3 | collections::HashSet, 4 | net::{IpAddr, SocketAddr}, 5 | rc::Rc, 6 | }; 7 | 8 | use slab::Slab; 9 | 10 | use lan_mouse_ipc::{ClientConfig, ClientHandle, ClientState, Position}; 11 | 12 | #[derive(Clone, Default)] 13 | pub struct ClientManager { 14 | clients: Rc<RefCell<Slab<(ClientConfig, ClientState)>>>, 15 | } 16 | 17 | impl ClientManager { 18 | /// add a new client to this manager 19 | pub fn add_client(&self) -> ClientHandle { 20 | self.clients.borrow_mut().insert(Default::default()) as ClientHandle 21 | } 22 | 23 | /// set the config of the given client 24 | pub fn set_config(&self, handle: ClientHandle, config: ClientConfig) { 25 | if let Some((c, _)) = self.clients.borrow_mut().get_mut(handle as usize) { 26 | *c = config; 27 | } 28 | } 29 | 30 | /// set the state of the given client 31 | pub fn set_state(&self, handle: ClientHandle, state: ClientState) { 32 | if let Some((_, s)) = self.clients.borrow_mut().get_mut(handle as usize) { 33 | *s = state; 34 | } 35 | } 36 | 37 | /// activate the given client 38 | /// returns, whether the client was activated 39 | pub fn activate_client(&self, handle: ClientHandle) -> bool { 40 | let mut clients = self.clients.borrow_mut(); 41 | match clients.get_mut(handle as usize) { 42 | Some((_, s)) if !s.active => { 43 | s.active = true; 44 | true 45 | } 46 | _ => false, 47 | } 48 | } 49 | 50 | /// deactivate the given client 51 | /// returns, whether the client was deactivated 52 | pub fn deactivate_client(&self, handle: ClientHandle) -> bool { 53 | let mut clients = self.clients.borrow_mut(); 54 | match clients.get_mut(handle as usize) { 55 | Some((_, s)) if s.active => { 56 | s.active = false; 57 | true 58 | } 59 | _ => false, 60 | } 61 | } 62 | 63 | /// find a client by its address 64 | pub fn get_client(&self, addr: SocketAddr) -> Option<ClientHandle> { 65 | // since there shouldn't be more than a handful of clients at any given 66 | // time this is likely faster than using a HashMap 67 | self.clients 68 | .borrow() 69 | .iter() 70 | .find_map(|(k, (_, s))| { 71 | if s.active && s.ips.contains(&addr.ip()) { 72 | Some(k) 73 | } else { 74 | None 75 | } 76 | }) 77 | .map(|p| p as ClientHandle) 78 | } 79 | 80 | /// get the client at the given position 81 | pub fn client_at(&self, pos: Position) -> Option<ClientHandle> { 82 | self.clients 83 | .borrow() 84 | .iter() 85 | .find_map(|(k, (c, s))| { 86 | if s.active && c.pos == pos { 87 | Some(k) 88 | } else { 89 | None 90 | } 91 | }) 92 | .map(|p| p as ClientHandle) 93 | } 94 | 95 | pub(crate) fn get_hostname(&self, handle: ClientHandle) -> Option<String> { 96 | self.clients 97 | .borrow_mut() 98 | .get_mut(handle as usize) 99 | .and_then(|(c, _)| c.hostname.clone()) 100 | } 101 | 102 | /// get the position of the corresponding client 103 | pub(crate) fn get_pos(&self, handle: ClientHandle) -> Option<Position> { 104 | self.clients 105 | .borrow() 106 | .get(handle as usize) 107 | .map(|(c, _)| c.pos) 108 | } 109 | 110 | /// remove a client from the list 111 | pub fn remove_client(&self, client: ClientHandle) -> Option<(ClientConfig, ClientState)> { 112 | // remove id from occupied ids 113 | self.clients.borrow_mut().try_remove(client as usize) 114 | } 115 | 116 | /// get the config & state of the given client 117 | pub fn get_state(&self, handle: ClientHandle) -> Option<(ClientConfig, ClientState)> { 118 | self.clients.borrow().get(handle as usize).cloned() 119 | } 120 | 121 | /// get the current config & state of all clients 122 | pub fn get_client_states(&self) -> Vec<(ClientHandle, ClientConfig, ClientState)> { 123 | self.clients 124 | .borrow() 125 | .iter() 126 | .map(|(k, v)| (k as ClientHandle, v.0.clone(), v.1.clone())) 127 | .collect() 128 | } 129 | 130 | /// update the fix ips of the client 131 | pub fn set_fix_ips(&self, handle: ClientHandle, fix_ips: Vec<IpAddr>) { 132 | if let Some((c, _)) = self.clients.borrow_mut().get_mut(handle as usize) { 133 | c.fix_ips = fix_ips 134 | } 135 | self.update_ips(handle); 136 | } 137 | 138 | /// update the dns-ips of the client 139 | pub fn set_dns_ips(&self, handle: ClientHandle, dns_ips: Vec<IpAddr>) { 140 | if let Some((_, s)) = self.clients.borrow_mut().get_mut(handle as usize) { 141 | s.dns_ips = dns_ips 142 | } 143 | self.update_ips(handle); 144 | } 145 | 146 | fn update_ips(&self, handle: ClientHandle) { 147 | if let Some((c, s)) = self.clients.borrow_mut().get_mut(handle as usize) { 148 | s.ips = c 149 | .fix_ips 150 | .iter() 151 | .cloned() 152 | .chain(s.dns_ips.iter().cloned()) 153 | .collect::<HashSet<_>>(); 154 | } 155 | } 156 | 157 | /// update the hostname of the given client 158 | /// this automatically clears the active ip address and ips from dns 159 | pub fn set_hostname(&self, handle: ClientHandle, hostname: Option<String>) -> bool { 160 | let mut clients = self.clients.borrow_mut(); 161 | let Some((c, s)) = clients.get_mut(handle as usize) else { 162 | return false; 163 | }; 164 | 165 | // hostname changed 166 | if c.hostname != hostname { 167 | c.hostname = hostname; 168 | s.active_addr = None; 169 | s.dns_ips.clear(); 170 | drop(clients); 171 | self.update_ips(handle); 172 | true 173 | } else { 174 | false 175 | } 176 | } 177 | 178 | /// update the port of the client 179 | pub(crate) fn set_port(&self, handle: ClientHandle, port: u16) { 180 | match self.clients.borrow_mut().get_mut(handle as usize) { 181 | Some((c, s)) if c.port != port => { 182 | c.port = port; 183 | s.active_addr = s.active_addr.map(|a| SocketAddr::new(a.ip(), port)); 184 | } 185 | _ => {} 186 | }; 187 | } 188 | 189 | /// update the position of the client 190 | /// returns true, if a change in capture position is required (pos changed & client is active) 191 | pub(crate) fn set_pos(&self, handle: ClientHandle, pos: Position) -> bool { 192 | match self.clients.borrow_mut().get_mut(handle as usize) { 193 | Some((c, s)) if c.pos != pos => { 194 | log::info!("update pos {handle} {} -> {}", c.pos, pos); 195 | c.pos = pos; 196 | s.active 197 | } 198 | _ => false, 199 | } 200 | } 201 | 202 | /// update the enter hook command of the client 203 | pub(crate) fn set_enter_hook(&self, handle: ClientHandle, enter_hook: Option<String>) { 204 | if let Some((c, _s)) = self.clients.borrow_mut().get_mut(handle as usize) { 205 | c.cmd = enter_hook; 206 | } 207 | } 208 | 209 | /// set resolving status of the client 210 | pub(crate) fn set_resolving(&self, handle: ClientHandle, status: bool) { 211 | if let Some((_, s)) = self.clients.borrow_mut().get_mut(handle as usize) { 212 | s.resolving = status; 213 | } 214 | } 215 | 216 | /// get the enter hook command 217 | pub(crate) fn get_enter_cmd(&self, handle: ClientHandle) -> Option<String> { 218 | self.clients 219 | .borrow() 220 | .get(handle as usize) 221 | .and_then(|(c, _)| c.cmd.clone()) 222 | } 223 | 224 | /// returns all clients that are currently active 225 | pub(crate) fn active_clients(&self) -> Vec<ClientHandle> { 226 | self.clients 227 | .borrow() 228 | .iter() 229 | .filter(|(_, (_, s))| s.active) 230 | .map(|(h, _)| h as ClientHandle) 231 | .collect() 232 | } 233 | 234 | pub(crate) fn set_active_addr(&self, handle: ClientHandle, addr: Option<SocketAddr>) { 235 | if let Some((_, s)) = self.clients.borrow_mut().get_mut(handle as usize) { 236 | s.active_addr = addr; 237 | } 238 | } 239 | 240 | pub(crate) fn set_alive(&self, handle: ClientHandle, alive: bool) { 241 | if let Some((_, s)) = self.clients.borrow_mut().get_mut(handle as usize) { 242 | s.alive = alive; 243 | } 244 | } 245 | 246 | pub(crate) fn active_addr(&self, handle: ClientHandle) -> Option<SocketAddr> { 247 | self.clients 248 | .borrow() 249 | .get(handle as usize) 250 | .and_then(|(_, s)| s.active_addr) 251 | } 252 | 253 | pub(crate) fn alive(&self, handle: ClientHandle) -> bool { 254 | self.clients 255 | .borrow() 256 | .get(handle as usize) 257 | .map(|(_, s)| s.alive) 258 | .unwrap_or(false) 259 | } 260 | 261 | pub(crate) fn get_port(&self, handle: ClientHandle) -> Option<u16> { 262 | self.clients 263 | .borrow() 264 | .get(handle as usize) 265 | .map(|(c, _)| c.port) 266 | } 267 | 268 | pub(crate) fn get_ips(&self, handle: ClientHandle) -> Option<HashSet<IpAddr>> { 269 | self.clients 270 | .borrow() 271 | .get(handle as usize) 272 | .map(|(_, s)| s.ips.clone()) 273 | } 274 | } 275 | -------------------------------------------------------------------------------- /src/crypto.rs: -------------------------------------------------------------------------------- 1 | use std::fs; 2 | use std::io::{self, BufWriter, Read, Write}; 3 | use std::path::Path; 4 | use std::{fs::File, io::BufReader}; 5 | 6 | #[cfg(unix)] 7 | use std::os::unix::fs::PermissionsExt; 8 | 9 | use sha2::{Digest, Sha256}; 10 | use thiserror::Error; 11 | use webrtc_dtls::crypto::Certificate; 12 | 13 | #[derive(Debug, Error)] 14 | pub enum Error { 15 | #[error(transparent)] 16 | Io(#[from] io::Error), 17 | #[error(transparent)] 18 | Dtls(#[from] webrtc_dtls::Error), 19 | } 20 | 21 | pub fn generate_fingerprint(cert: &[u8]) -> String { 22 | let mut hash = Sha256::new(); 23 | hash.update(cert); 24 | let bytes = hash 25 | .finalize() 26 | .iter() 27 | .map(|x| format!("{x:02x}")) 28 | .collect::<Vec<_>>(); 29 | bytes.join(":").to_lowercase() 30 | } 31 | 32 | pub fn certificate_fingerprint(cert: &Certificate) -> String { 33 | let certificate = cert.certificate.first().expect("certificate missing"); 34 | generate_fingerprint(certificate) 35 | } 36 | 37 | /// load certificate from file 38 | pub fn load_certificate(path: &Path) -> Result<Certificate, Error> { 39 | let f = File::open(path)?; 40 | 41 | let mut reader = BufReader::new(f); 42 | let mut pem = String::new(); 43 | reader.read_to_string(&mut pem)?; 44 | Ok(Certificate::from_pem(pem.as_str())?) 45 | } 46 | 47 | pub(crate) fn load_or_generate_key_and_cert(path: &Path) -> Result<Certificate, Error> { 48 | if path.exists() && path.is_file() { 49 | Ok(load_certificate(path)?) 50 | } else { 51 | generate_key_and_cert(path) 52 | } 53 | } 54 | 55 | pub(crate) fn generate_key_and_cert(path: &Path) -> Result<Certificate, Error> { 56 | let cert = Certificate::generate_self_signed(["ignored".to_owned()])?; 57 | let serialized = cert.serialize_pem(); 58 | let parent = path.parent().expect("is a path"); 59 | fs::create_dir_all(parent)?; 60 | let f = File::create(path)?; 61 | #[cfg(unix)] 62 | { 63 | let mut perm = f.metadata()?.permissions(); 64 | perm.set_mode(0o400); /* r-- --- --- */ 65 | f.set_permissions(perm)?; 66 | } 67 | /* FIXME windows permissions */ 68 | let mut writer = BufWriter::new(f); 69 | writer.write_all(serialized.as_bytes())?; 70 | Ok(cert) 71 | } 72 | -------------------------------------------------------------------------------- /src/dns.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, net::IpAddr}; 2 | 3 | use local_channel::mpsc::{channel, Receiver, Sender}; 4 | use tokio::task::{spawn_local, JoinHandle}; 5 | 6 | use hickory_resolver::{error::ResolveError, TokioAsyncResolver}; 7 | use tokio_util::sync::CancellationToken; 8 | 9 | use lan_mouse_ipc::ClientHandle; 10 | 11 | pub(crate) struct DnsResolver { 12 | cancellation_token: CancellationToken, 13 | task: Option<JoinHandle<()>>, 14 | request_tx: Sender<DnsRequest>, 15 | event_rx: Receiver<DnsEvent>, 16 | } 17 | 18 | struct DnsRequest { 19 | handle: ClientHandle, 20 | hostname: String, 21 | } 22 | 23 | pub(crate) enum DnsEvent { 24 | Resolving(ClientHandle), 25 | Resolved(ClientHandle, String, Result<Vec<IpAddr>, ResolveError>), 26 | } 27 | 28 | struct DnsTask { 29 | resolver: TokioAsyncResolver, 30 | request_rx: Receiver<DnsRequest>, 31 | event_tx: Sender<DnsEvent>, 32 | cancellation_token: CancellationToken, 33 | active_tasks: HashMap<ClientHandle, JoinHandle<()>>, 34 | } 35 | 36 | impl DnsResolver { 37 | pub(crate) fn new() -> Result<Self, ResolveError> { 38 | let resolver = TokioAsyncResolver::tokio_from_system_conf()?; 39 | let (request_tx, request_rx) = channel(); 40 | let (event_tx, event_rx) = channel(); 41 | let cancellation_token = CancellationToken::new(); 42 | let dns_task = DnsTask { 43 | active_tasks: Default::default(), 44 | resolver, 45 | request_rx, 46 | event_tx, 47 | cancellation_token: cancellation_token.clone(), 48 | }; 49 | let task = Some(spawn_local(dns_task.run())); 50 | Ok(Self { 51 | cancellation_token, 52 | task, 53 | event_rx, 54 | request_tx, 55 | }) 56 | } 57 | 58 | pub(crate) fn resolve(&self, handle: ClientHandle, hostname: String) { 59 | let request = DnsRequest { handle, hostname }; 60 | self.request_tx.send(request).expect("channel closed"); 61 | } 62 | 63 | pub(crate) async fn event(&mut self) -> DnsEvent { 64 | self.event_rx.recv().await.expect("channel closed") 65 | } 66 | 67 | pub(crate) async fn terminate(&mut self) { 68 | self.cancellation_token.cancel(); 69 | self.task.take().expect("task").await.expect("join error"); 70 | } 71 | } 72 | 73 | impl DnsTask { 74 | async fn run(mut self) { 75 | let cancellation_token = self.cancellation_token.clone(); 76 | tokio::select! { 77 | _ = self.do_dns() => {}, 78 | _ = cancellation_token.cancelled() => {}, 79 | } 80 | } 81 | 82 | async fn do_dns(&mut self) { 83 | while let Some(dns_request) = self.request_rx.recv().await { 84 | let DnsRequest { handle, hostname } = dns_request; 85 | 86 | /* abort previous dns task */ 87 | let previous_task = self.active_tasks.remove(&handle); 88 | if let Some(task) = previous_task { 89 | if !task.is_finished() { 90 | task.abort(); 91 | } 92 | } 93 | 94 | self.event_tx 95 | .send(DnsEvent::Resolving(handle)) 96 | .expect("channel closed"); 97 | 98 | /* spawn task for dns request */ 99 | let event_tx = self.event_tx.clone(); 100 | let resolver = self.resolver.clone(); 101 | let cancellation_token = self.cancellation_token.clone(); 102 | 103 | let task = tokio::task::spawn_local(async move { 104 | tokio::select! { 105 | ips = resolver.lookup_ip(&hostname) => { 106 | let ips = ips.map(|ips| ips.iter().collect::<Vec<_>>()); 107 | event_tx 108 | .send(DnsEvent::Resolved(handle, hostname, ips)) 109 | .expect("channel closed"); 110 | } 111 | _ = cancellation_token.cancelled() => {}, 112 | } 113 | }); 114 | self.active_tasks.insert(handle, task); 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/emulation_test.rs: -------------------------------------------------------------------------------- 1 | use crate::config::Config; 2 | use clap::Args; 3 | use input_emulation::{InputEmulation, InputEmulationError}; 4 | use input_event::{Event, PointerEvent}; 5 | use std::f64::consts::PI; 6 | use std::time::{Duration, Instant}; 7 | 8 | const FREQUENCY_HZ: f64 = 1.0; 9 | const RADIUS: f64 = 100.0; 10 | 11 | #[derive(Args, Clone, Debug, Eq, PartialEq)] 12 | pub struct TestEmulationArgs { 13 | #[arg(long)] 14 | mouse: bool, 15 | #[arg(long)] 16 | keyboard: bool, 17 | #[arg(long)] 18 | scroll: bool, 19 | } 20 | 21 | pub async fn run(config: Config, _args: TestEmulationArgs) -> Result<(), InputEmulationError> { 22 | log::info!("running input emulation test"); 23 | 24 | let backend = config.emulation_backend().map(|b| b.into()); 25 | let mut emulation = InputEmulation::new(backend).await?; 26 | emulation.create(0).await; 27 | 28 | let start = Instant::now(); 29 | let mut offset = (0, 0); 30 | loop { 31 | tokio::time::sleep(Duration::from_millis(1)).await; 32 | let elapsed = start.elapsed(); 33 | let elapsed_sec_f64 = elapsed.as_secs_f64(); 34 | let second_fraction = elapsed_sec_f64 - elapsed_sec_f64 as u64 as f64; 35 | let radians = second_fraction * 2. * PI * FREQUENCY_HZ; 36 | let new_offset_f = (radians.cos() * RADIUS * 2., (radians * 2.).sin() * RADIUS); 37 | let new_offset = (new_offset_f.0 as i32, new_offset_f.1 as i32); 38 | if new_offset != offset { 39 | let relative_motion = (new_offset.0 - offset.0, new_offset.1 - offset.1); 40 | offset = new_offset; 41 | let (dx, dy) = (relative_motion.0 as f64, relative_motion.1 as f64); 42 | let event = Event::Pointer(PointerEvent::Motion { time: 0, dx, dy }); 43 | emulation.consume(event, 0).await?; 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | mod capture; 2 | pub mod capture_test; 3 | pub mod client; 4 | pub mod config; 5 | mod connect; 6 | mod crypto; 7 | mod dns; 8 | mod emulation; 9 | pub mod emulation_test; 10 | mod listen; 11 | pub mod service; 12 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use env_logger::Env; 2 | use input_capture::InputCaptureError; 3 | use input_emulation::InputEmulationError; 4 | use lan_mouse::{ 5 | capture_test, 6 | config::{self, Command, Config, ConfigError}, 7 | emulation_test, 8 | service::{Service, ServiceError}, 9 | }; 10 | use lan_mouse_cli::CliError; 11 | #[cfg(feature = "gtk")] 12 | use lan_mouse_gtk::GtkError; 13 | use lan_mouse_ipc::{IpcError, IpcListenerCreationError}; 14 | use std::{ 15 | future::Future, 16 | io, 17 | process::{self, Child}, 18 | }; 19 | use thiserror::Error; 20 | use tokio::task::LocalSet; 21 | 22 | #[derive(Debug, Error)] 23 | enum LanMouseError { 24 | #[error(transparent)] 25 | Service(#[from] ServiceError), 26 | #[error(transparent)] 27 | IpcError(#[from] IpcError), 28 | #[error(transparent)] 29 | Config(#[from] ConfigError), 30 | #[error(transparent)] 31 | Io(#[from] io::Error), 32 | #[error(transparent)] 33 | Capture(#[from] InputCaptureError), 34 | #[error(transparent)] 35 | Emulation(#[from] InputEmulationError), 36 | #[cfg(feature = "gtk")] 37 | #[error(transparent)] 38 | Gtk(#[from] GtkError), 39 | #[error(transparent)] 40 | Cli(#[from] CliError), 41 | } 42 | 43 | fn main() { 44 | // init logging 45 | let env = Env::default().filter_or("LAN_MOUSE_LOG_LEVEL", "info"); 46 | env_logger::init_from_env(env); 47 | 48 | if let Err(e) = run() { 49 | log::error!("{e}"); 50 | process::exit(1); 51 | } 52 | } 53 | 54 | fn run() -> Result<(), LanMouseError> { 55 | let config = config::Config::new()?; 56 | match config.command() { 57 | Some(command) => match command { 58 | Command::TestEmulation(args) => run_async(emulation_test::run(config, args))?, 59 | Command::TestCapture(args) => run_async(capture_test::run(config, args))?, 60 | Command::Cli(cli_args) => run_async(lan_mouse_cli::run(cli_args))?, 61 | Command::Daemon => { 62 | // if daemon is specified we run the service 63 | match run_async(run_service(config)) { 64 | Err(LanMouseError::Service(ServiceError::IpcListen( 65 | IpcListenerCreationError::AlreadyRunning, 66 | ))) => log::info!("service already running!"), 67 | r => r?, 68 | } 69 | } 70 | }, 71 | None => { 72 | // otherwise start the service as a child process and 73 | // run a frontend 74 | #[cfg(feature = "gtk")] 75 | { 76 | let mut service = start_service()?; 77 | let res = lan_mouse_gtk::run(); 78 | #[cfg(unix)] 79 | { 80 | // on unix we give the service a chance to terminate gracefully 81 | let pid = service.id() as libc::pid_t; 82 | unsafe { 83 | libc::kill(pid, libc::SIGINT); 84 | } 85 | service.wait()?; 86 | } 87 | service.kill()?; 88 | res?; 89 | } 90 | #[cfg(not(feature = "gtk"))] 91 | { 92 | // run daemon if gtk is diabled 93 | match run_async(run_service(config)) { 94 | Err(LanMouseError::Service(ServiceError::IpcListen( 95 | IpcListenerCreationError::AlreadyRunning, 96 | ))) => log::info!("service already running!"), 97 | r => r?, 98 | } 99 | } 100 | } 101 | } 102 | 103 | Ok(()) 104 | } 105 | 106 | fn run_async<F, E>(f: F) -> Result<(), LanMouseError> 107 | where 108 | F: Future<Output = Result<(), E>>, 109 | LanMouseError: From<E>, 110 | { 111 | // create single threaded tokio runtime 112 | let runtime = tokio::runtime::Builder::new_current_thread() 113 | .enable_io() 114 | .enable_time() 115 | .build()?; 116 | 117 | // run async event loop 118 | Ok(runtime.block_on(LocalSet::new().run_until(f))?) 119 | } 120 | 121 | fn start_service() -> Result<Child, io::Error> { 122 | let child = process::Command::new(std::env::current_exe()?) 123 | .args(std::env::args().skip(1)) 124 | .arg("daemon") 125 | .spawn()?; 126 | Ok(child) 127 | } 128 | 129 | async fn run_service(config: Config) -> Result<(), ServiceError> { 130 | let release_bind = config.release_bind(); 131 | let config_path = config.config_path().to_owned(); 132 | let mut service = Service::new(config).await?; 133 | log::info!("using config: {config_path:?}"); 134 | log::info!("Press {release_bind:?} to release the mouse"); 135 | service.run().await?; 136 | log::info!("service exited!"); 137 | Ok(()) 138 | } 139 | --------------------------------------------------------------------------------