Compare commits

..

3 Commits

Author SHA1 Message Date
Ferdinand Schober
09f08f1798 fix drop impl for desktop-portal 2024-07-05 00:31:16 +02:00
Ferdinand Schober
f97621e987 adjust error handling 2024-07-04 23:44:32 +02:00
Ferdinand Schober
b3aa3f4281 remove dispatch workaround 2024-07-04 22:40:05 +02:00
134 changed files with 7633 additions and 15157 deletions

View File

@@ -1,24 +0,0 @@
root = true
[*]
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
charset = utf-8
[*.rs]
indent_style = space
indent_size = 4
max_line_length = 100
[*.{yml,yaml}]
indent_style = space
indent_size = 2
[*.toml]
indent_style = space
indent_size = 4
[*.nix]
indent_style = space
indent_size = 2

View File

@@ -1,34 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Ensure we're at the repo root
cd "$(git rev-parse --show-toplevel)" || exit 1
echo "Running cargo fmt (auto-format)..."
# Run formatter to apply fixes but do not stage them. If formatting changed,
# fail the commit so the user can review and stage the changes manually.
cargo fmt --all
if ! git diff --quiet --exit-code; then
echo "" >&2
echo "ERROR: cargo fmt modified files. Review changes, stage them, and commit again." >&2
git --no-pager diff --name-only
exit 1
fi
echo "Running cargo clippy..."
# Matches CI: deny warnings to keep code health strict
if ! cargo clippy --workspace --all-targets --all-features -- -D warnings; then
echo "" >&2
echo "ERROR: clippy found warnings/errors. Fix them before committing." >&2
exit 1
fi
echo "Running cargo test..."
if ! cargo test --workspace --all-features; then
echo "" >&2
echo "ERROR: Some tests failed. Fix tests before committing." >&2
exit 1
fi
echo "All pre-commit checks passed."
exit 0

View File

@@ -1,50 +1,40 @@
name: Nix Binary Cache name: Binary Cache
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
on: [push, pull_request, workflow_dispatch]
jobs: jobs:
nix: nix:
strategy: strategy:
matrix: matrix:
os: os:
- ubuntu-latest - ubuntu-latest
- macos-15-intel - macos-13
- macos-latest - macos-14
name: "Build" name: "Build"
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v4
with: with:
submodules: recursive submodules: recursive
# - uses: DeterminateSystems/nix-installer-action@main - uses: DeterminateSystems/nix-installer-action@main
# with: with:
# logger: pretty logger: pretty
# - uses: DeterminateSystems/magic-nix-cache-action@main - uses: DeterminateSystems/magic-nix-cache-action@main
- uses: cachix/install-nix-action@v31 - uses: cachix/cachix-action@v14
- uses: cachix/cachix-action@v16 with:
with: name: lan-mouse
name: lan-mouse authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
- name: Build lan-mouse (x86_64-linux) - name: Build lan-mouse (x86_64-linux)
if: matrix.os == 'ubuntu-latest' if: matrix.os == 'ubuntu-latest'
run: nix build --print-build-logs --show-trace .#packages.x86_64-linux.lan-mouse run: nix build --print-build-logs --show-trace .#packages.x86_64-linux.lan-mouse
- name: Build lan-mouse (x86_64-darwin) - name: Build lan-mouse (x86_64-darwin)
if: matrix.os == 'macos-15-intel' if: matrix.os == 'macos-13'
run: nix build --print-build-logs --show-trace .#packages.x86_64-darwin.lan-mouse run: nix build --print-build-logs --show-trace .#packages.x86_64-darwin.lan-mouse
- name: Build lan-mouse (aarch64-darwin)
if: matrix.os == 'macos-14'
run: nix build --print-build-logs --show-trace .#packages.aarch64-darwin.lan-mouse
- name: Build lan-mouse (aarch64-darwin)
if: matrix.os == 'macos-latest'
run: nix build --print-build-logs --show-trace .#packages.aarch64-darwin.lan-mouse

130
.github/workflows/pre-release.yml vendored Normal file
View File

@@ -0,0 +1,130 @@
name: "pre-release"
on:
push:
branches: [ "main" ]
env:
CARGO_TERM_COLOR: always
jobs:
linux-release-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: install dependencies
run: |
sudo apt-get update
sudo apt-get install libx11-dev libxtst-dev
sudo apt-get install libadwaita-1-dev libgtk-4-dev
- name: Release Build
run: cargo build --release
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: lan-mouse-linux
path: target/release/lan-mouse
windows-release-build:
runs-on: windows-latest
steps:
- uses: actions/setup-python@v5
with:
python-version: '3.11'
# needed for cache restore
- name: create gtk dir
run: mkdir C:\gtk-build\gtk\x64\release
- uses: actions/cache@v3
id: cache
with:
path: c:/gtk-build/gtk/x64/release/**
key: gtk-windows-build
restore-keys: gtk-windows-build
- name: Update path
run: |
echo "PKG_CONFIG=C:\gtk-build\gtk\x64\release\bin\pkgconf.exe" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
echo "C:\pkg-config-lite-0.28-1\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
echo "C:\gtk-build\gtk\x64\release\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
echo $env:GITHUB_PATH
echo $env:PATH
- name: Install dependencies
if: steps.cache.outputs.cache-hit != 'true'
run: |
# choco install msys2
# choco install visualstudio2022-workload-vctools
# choco install pkgconfiglite
pipx install gvsbuild
# see https://github.com/wingtk/gvsbuild/pull/1004
Move-Item "C:\Program Files\Git\usr\bin" "C:\Program Files\Git\usr\notbin"
Move-Item "C:\Program Files\Git\bin" "C:\Program Files\Git\notbin"
gvsbuild build --msys-dir=C:\msys64 gtk4 libadwaita librsvg
Move-Item "C:\Program Files\Git\usr\notbin" "C:\Program Files\Git\usr\bin"
Move-Item "C:\Program Files\Git\notbin" "C:\Program Files\Git\bin"
- uses: actions/checkout@v4
- name: Release Build
run: cargo build --release
- name: Create Archive
run: |
mkdir "lan-mouse-windows"
Get-Childitem -Path "C:\\gtk-build\\gtk\\x64\\release\\bin\\*.dll" -File -Recurse | Copy-Item -Destination "lan-mouse-windows"
Copy-Item -Path "target\release\lan-mouse.exe" -Destination "lan-mouse-windows"
Compress-Archive -Path "lan-mouse-windows\*" -DestinationPath lan-mouse-windows.zip
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: lan-mouse-windows
path: lan-mouse-windows.zip
macos-release-build:
runs-on: macos-13
steps:
- uses: actions/checkout@v4
- name: install dependencies
run: brew install gtk4 libadwaita
- name: Release Build
run: |
cargo build --release
cp target/release/lan-mouse lan-mouse-macos-intel
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: lan-mouse-macos-intel
path: lan-mouse-macos-intel
macos-aarch64-release-build:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- name: install dependencies
run: brew install gtk4 libadwaita
- name: Release Build
run: |
cargo build --release
cp target/release/lan-mouse lan-mouse-macos-aarch64
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: lan-mouse-macos-aarch64
path: lan-mouse-macos-aarch64
pre-release:
name: "Pre Release"
needs: [windows-release-build, linux-release-build, macos-release-build]
runs-on: "ubuntu-latest"
steps:
- name: Download build artifacts
uses: actions/download-artifact@v4
- name: Create Release
uses: "marvinpinto/action-automatic-releases@latest"
with:
repo_token: "${{ secrets.GITHUB_TOKEN }}"
automatic_release_tag: "latest"
prerelease: true
title: "Development Build"
files: |
lan-mouse-linux/lan-mouse
lan-mouse-macos-intel/lan-mouse-macos-intel
lan-mouse-macos-aarch64/lan-mouse-macos-aarch64
lan-mouse-windows/lan-mouse-windows.zip

View File

@@ -1,204 +0,0 @@
name: "Release"
run-name: "Release - ${{ startsWith(github.ref, 'refs/tags/') && github.ref_name || github.event.inputs.name || github.ref_name }}"
on:
push:
branches: [ "main" ]
tags:
- v**
workflow_dispatch:
inputs:
name:
description: 'Development release name'
required: false
default: ''
env:
CARGO_TERM_COLOR: always
jobs:
linux-release-build:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v6
- name: install dependencies
run: |
sudo apt-get update
sudo apt-get install libx11-dev libxtst-dev
sudo apt-get install libadwaita-1-dev libgtk-4-dev
- name: Release Build
run: |
cargo build --release
cp target/release/lan-mouse lan-mouse-linux-x86_64
- name: Upload build artifact
uses: actions/upload-artifact@v6
with:
name: lan-mouse-linux-x86_64
path: lan-mouse-linux-x86_64
linux-arm64-release-build:
runs-on: ubuntu-22.04-arm
steps:
- uses: actions/checkout@v6
- name: install dependencies
run: |
sudo apt-get update
sudo apt-get install libx11-dev libxtst-dev
sudo apt-get install libadwaita-1-dev libgtk-4-dev
- name: Release Build
run: |
cargo build --release
cp target/release/lan-mouse lan-mouse-linux-arm64
- name: Upload build artifact
uses: actions/upload-artifact@v6
with:
name: lan-mouse-linux-arm64
path: lan-mouse-linux-arm64
windows-release-build:
runs-on: windows-latest
steps:
- uses: actions/setup-python@v5
with:
python-version: '3.11'
# needed for cache restore
- name: create gtk dir
run: mkdir C:\gtk-build\gtk\x64\release
- uses: actions/cache@v3
id: cache
with:
path: c:/gtk-build/gtk/x64/release/**
key: gtk-windows-build
restore-keys: gtk-windows-build
- name: Update path
run: |
echo "PKG_CONFIG=C:\gtk-build\gtk\x64\release\bin\pkgconf.exe" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
echo "C:\pkg-config-lite-0.28-1\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
echo "C:\gtk-build\gtk\x64\release\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
echo $env:GITHUB_PATH
echo $env:PATH
- name: Install dependencies
if: steps.cache.outputs.cache-hit != 'true'
run: |
# choco install msys2
# choco install visualstudio2022-workload-vctools
# choco install pkgconfiglite
py -m venv .venv
.venv\Scripts\activate.ps1
py -m pip install gvsbuild
# see https://github.com/wingtk/gvsbuild/pull/1004
Move-Item "C:\Program Files\Git\usr\bin" "C:\Program Files\Git\usr\notbin"
Move-Item "C:\Program Files\Git\bin" "C:\Program Files\Git\notbin"
gvsbuild build --msys-dir=C:\msys64 gtk4 libadwaita librsvg
Move-Item "C:\Program Files\Git\usr\notbin" "C:\Program Files\Git\usr\bin"
Move-Item "C:\Program Files\Git\notbin" "C:\Program Files\Git\bin"
- uses: actions/checkout@v6
- name: Release Build
run: cargo build --release
- name: Create Archive
run: |
mkdir "lan-mouse-windows"
Get-Childitem -Path "C:\\gtk-build\\gtk\\x64\\release\\bin\\*.dll" -File -Recurse | Copy-Item -Destination "lan-mouse-windows"
Copy-Item -Path "target\release\lan-mouse.exe" -Destination "lan-mouse-windows"
Compress-Archive -Path "lan-mouse-windows\*" -DestinationPath lan-mouse-windows-x86_64.zip
- name: Upload build artifact
uses: actions/upload-artifact@v6
with:
name: lan-mouse-windows-x86_64
path: lan-mouse-windows-x86_64.zip
macos-release-build:
runs-on: macos-15-intel
steps:
- uses: actions/checkout@v6
- name: install dependencies
run: |
brew install --cask inkscape
brew install gtk4 libadwaita imagemagick librsvg
- name: Release Build
run: |
cargo build --release
cp target/release/lan-mouse lan-mouse-macos-intel
- name: Make icns
run: scripts/makeicns.sh
- name: Install cargo bundle
run: cargo install cargo-bundle
- name: Bundle
run: |
cargo bundle --release
scripts/copy-macos-dylib.sh "target/release/bundle/osx/Lan Mouse.app/Contents/MacOS/lan-mouse"
- name: Zip bundle
run: |
cd target/release/bundle/osx
zip -r "lan-mouse-macos-intel.zip" "Lan Mouse.app"
- name: Upload build artifact
uses: actions/upload-artifact@v6
with:
name: lan-mouse-macos-intel
path: target/release/bundle/osx/lan-mouse-macos-intel.zip
macos-arm64-release-build:
runs-on: macos-15
steps:
- uses: actions/checkout@v6
- name: install dependencies
run: |
brew install --cask inkscape
brew install gtk4 libadwaita imagemagick librsvg
- name: Release Build
run: |
cargo build --release
cp target/release/lan-mouse lan-mouse-macos-arm64
- name: Make icns
run: scripts/makeicns.sh
- name: Install cargo bundle
run: cargo install cargo-bundle
- name: Bundle
run: |
cargo bundle --release
scripts/copy-macos-dylib.sh "target/release/bundle/osx/Lan Mouse.app/Contents/MacOS/lan-mouse"
- name: Zip bundle
run: |
cd target/release/bundle/osx
zip -r "lan-mouse-macos-arm64.zip" "Lan Mouse.app"
- name: Upload build artifact
uses: actions/upload-artifact@v6
with:
name: lan-mouse-macos-arm64
path: target/release/bundle/osx/lan-mouse-macos-arm64.zip
release:
name: "Release"
needs: [windows-release-build, linux-release-build, linux-arm64-release-build, macos-release-build, macos-arm64-release-build]
runs-on: "ubuntu-latest"
steps:
- name: Download build artifacts
uses: actions/download-artifact@v4
- name: Create Pre-Release
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
uses: softprops/action-gh-release@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
tag_name: ${{ github.event.inputs.name || github.ref_name }}
name: ${{ github.event.inputs.name || github.ref_name }}
prerelease: true
generate_release_notes: true
files: |
lan-mouse-linux-x86_64/lan-mouse-linux-x86_64
lan-mouse-linux-arm64/lan-mouse-linux-arm64
lan-mouse-macos-intel/lan-mouse-macos-intel.zip
lan-mouse-macos-arm64/lan-mouse-macos-arm64.zip
lan-mouse-windows-x86_64/lan-mouse-windows-x86_64.zip
- name: Create Tagged Release
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
name: ${{ github.ref_name }}
generate_release_notes: true
files: |
lan-mouse-linux-x86_64/lan-mouse-linux-x86_64
lan-mouse-linux-arm64/lan-mouse-linux-arm64
lan-mouse-macos-intel/lan-mouse-macos-intel.zip
lan-mouse-macos-arm64/lan-mouse-macos-arm64.zip
lan-mouse-windows-x86_64/lan-mouse-windows-x86_64.zip

View File

@@ -9,92 +9,124 @@ on:
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs: jobs:
fmt: build-linux:
name: Formatting
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- name: cargo fmt - name: install dependencies
run: cargo fmt --check run: |
sudo apt-get update
sudo apt-get install libx11-dev libxtst-dev
sudo apt-get install libadwaita-1-dev libgtk-4-dev
- name: Build
run: cargo build --verbose
- name: Run tests
run: cargo test --verbose
- name: Check Formatting
run: cargo fmt --check
- name: Clippy
run: cargo clippy --all-features --all-targets -- --deny warnings
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: lan-mouse
path: target/debug/lan-mouse
build-windows:
runs-on: windows-latest
ci:
name: ${{ matrix.job }} ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os:
- ubuntu-latest
- windows-latest
- macos-latest
- macos-15-intel
job:
- build
- check
- clippy
- test
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- uses: Swatinem/rust-cache@v2 - uses: actions/setup-python@v5
- name: Install Linux deps with:
if: runner.os == 'Linux' python-version: '3.11'
run: | # needed for cache restore
sudo apt-get update - name: create gtk dir
sudo apt-get install libx11-dev libxtst-dev libadwaita-1-dev libgtk-4-dev run: mkdir C:\gtk-build\gtk\x64\release
- name: Install macOS dependencies - uses: actions/cache@v3
if: runner.os == 'macOS' id: cache
run: brew install gtk4 libadwaita imagemagick with:
- name: Install Windows Dependencies - create gtk dir path: c:/gtk-build/gtk/x64/release/**
if: runner.os == 'Windows' key: gtk-windows-build
run: mkdir C:\gtk-build\gtk\x64\release restore-keys: gtk-windows-build
- name: Install Windows Dependencies - install gtk from cache - name: Update path
uses: actions/cache@v3 run: |
if: runner.os == 'Windows' echo "PKG_CONFIG=C:\gtk-build\gtk\x64\release\bin\pkgconf.exe" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
id: cache echo "C:\pkg-config-lite-0.28-1\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
with: echo "C:\gtk-build\gtk\x64\release\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
path: c:/gtk-build/gtk/x64/release/** echo $env:GITHUB_PATH
key: gtk-windows-build echo $env:PATH
restore-keys: gtk-windows-build - name: Install dependencies
- name: Install Windows Dependencies - update PATH if: steps.cache.outputs.cache-hit != 'true'
if: runner.os == 'Windows' run: |
run: | # choco install msys2
echo "PKG_CONFIG=C:\gtk-build\gtk\x64\release\bin\pkgconf.exe" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append # choco install visualstudio2022-workload-vctools
echo "C:\pkg-config-lite-0.28-1\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append # choco install pkgconfiglite
echo "C:\gtk-build\gtk\x64\release\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append pipx install gvsbuild
echo $env:GITHUB_PATH # see https://github.com/wingtk/gvsbuild/pull/1004
echo $env:PATH Move-Item "C:\Program Files\Git\usr\bin" "C:\Program Files\Git\usr\notbin"
- name: Install Windows dependencies - build gtk Move-Item "C:\Program Files\Git\bin" "C:\Program Files\Git\notbin"
if: runner.os == 'Windows' && steps.cache.outputs.cache-hit != 'true' gvsbuild build --msys-dir=C:\msys64 gtk4 libadwaita librsvg
run: | Move-Item "C:\Program Files\Git\usr\notbin" "C:\Program Files\Git\usr\bin"
# choco install msys2 Move-Item "C:\Program Files\Git\notbin" "C:\Program Files\Git\bin"
# choco install visualstudio2022-workload-vctools - name: Build
# choco install pkgconfiglite run: cargo build --verbose
py -m venv .venv - name: Run tests
.venv\Scripts\activate.ps1 run: cargo test --verbose
py -m pip install gvsbuild - name: Check Formatting
gvsbuild build --msys-dir=C:\msys64 gtk4 libadwaita librsvg run: cargo fmt --check
- name: cargo build - name: Clippy
if: matrix.job == 'build' run: cargo clippy --all-features --all-targets -- --deny warnings
run: cargo build - name: Copy Gtk Dlls
run: Get-Childitem -Path "C:\\gtk-build\\gtk\\x64\\release\\bin\\*.dll" -File -Recurse | Copy-Item -Destination "target\\debug"
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: lan-mouse-windows
path: |
target/debug/lan-mouse.exe
target/debug/*.dll
- name: cargo check build-macos:
if: matrix.job == 'check' runs-on: macos-13
run: cargo check --workspace --all-targets --all-features steps:
- uses: actions/checkout@v4
- name: install dependencies
run: brew install gtk4 libadwaita
- name: Build
run: cargo build --verbose
- name: Run tests
run: cargo test --verbose
- name: Check Formatting
run: cargo fmt --check
- name: Clippy
run: cargo clippy --all-features --all-targets -- --deny warnings
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: lan-mouse-macos
path: target/debug/lan-mouse
- name: cargo test build-macos-aarch64:
if: matrix.job == 'test' runs-on: macos-14
run: cargo test --workspace --all-features steps:
- uses: actions/checkout@v4
- name: cargo clippy - name: install dependencies
if: matrix.job == 'clippy' run: brew install gtk4 libadwaita
run: cargo clippy --workspace --all-targets --all-features -- -D warnings - name: Build
run: cargo build --verbose
- uses: clechasseur/rs-clippy-check@v4 - name: Run tests
if: matrix.job == 'clippy' run: cargo test --verbose
with: - name: Check Formatting
args: --workspace --all-targets --all-features run: cargo fmt --check
- name: Clippy
run: cargo clippy --all-features --all-targets -- --deny warnings
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: lan-mouse-macos-aarch64
path: target/debug/lan-mouse

124
.github/workflows/tagged-release.yml vendored Normal file
View File

@@ -0,0 +1,124 @@
name: "Tagged Release"
on:
push:
tags:
- "v*"
jobs:
linux-release-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: install dependencies
run: |
sudo apt-get update
sudo apt-get install libx11-dev libxtst-dev
sudo apt-get install libadwaita-1-dev libgtk-4-dev
- name: Release Build
run: cargo build --release
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: lan-mouse-linux
path: target/release/lan-mouse
windows-release-build:
runs-on: windows-latest
steps:
- uses: actions/setup-python@v5
with:
python-version: '3.11'
# needed for cache restore
- name: create gtk dir
run: mkdir C:\gtk-build\gtk\x64\release
- uses: actions/cache@v3
id: cache
with:
path: c:/gtk-build/gtk/x64/release/**
key: gtk-windows-build
restore-keys: gtk-windows-build
- name: Update path
run: |
echo "PKG_CONFIG=C:\gtk-build\gtk\x64\release\bin\pkgconf.exe" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
echo "C:\pkg-config-lite-0.28-1\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
echo "C:\gtk-build\gtk\x64\release\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
echo $env:GITHUB_PATH
echo $env:PATH
- name: Install dependencies
if: steps.cache.outputs.cache-hit != 'true'
run: |
# choco install msys2
# choco install visualstudio2022-workload-vctools
# choco install pkgconfiglite
pipx install gvsbuild
# see https://github.com/wingtk/gvsbuild/pull/1004
Move-Item "C:\Program Files\Git\usr\bin" "C:\Program Files\Git\usr\notbin"
Move-Item "C:\Program Files\Git\bin" "C:\Program Files\Git\notbin"
gvsbuild build --msys-dir=C:\msys64 gtk4 libadwaita librsvg
Move-Item "C:\Program Files\Git\usr\notbin" "C:\Program Files\Git\usr\bin"
Move-Item "C:\Program Files\Git\notbin" "C:\Program Files\Git\bin"
- uses: actions/checkout@v4
- name: Release Build
run: cargo build --release
- name: Create Archive
run: |
mkdir "lan-mouse-windows"
Get-Childitem -Path "C:\\gtk-build\\gtk\\x64\\release\\bin\\*.dll" -File -Recurse | Copy-Item -Destination "lan-mouse-windows"
Copy-Item -Path "target\release\lan-mouse.exe" -Destination "lan-mouse-windows"
Compress-Archive -Path "lan-mouse-windows\*" -DestinationPath lan-mouse-windows.zip
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: lan-mouse-windows
path: lan-mouse-windows.zip
macos-release-build:
runs-on: macos-13
steps:
- uses: actions/checkout@v4
- name: install dependencies
run: brew install gtk4 libadwaita
- name: Release Build
run: |
cargo build --release
cp target/release/lan-mouse lan-mouse-macos-intel
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: lan-mouse-macos-intel
path: lan-mouse-macos-intel
macos-aarch64-release-build:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- name: install dependencies
run: brew install gtk4 libadwaita
- name: Release Build
run: |
cargo build --release
cp target/release/lan-mouse lan-mouse-macos-aarch64
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: lan-mouse-macos-aarch64
path: lan-mouse-macos-aarch64
tagged-release:
name: "Tagged Release"
needs: [windows-release-build, linux-release-build, macos-release-build]
runs-on: "ubuntu-latest"
steps:
- name: Download build artifacts
uses: actions/download-artifact@v4
- name: "Create Release"
uses: "marvinpinto/action-automatic-releases@latest"
with:
repo_token: "${{ secrets.GITHUB_TOKEN }}"
prerelease: false
files: |
lan-mouse-linux/lan-mouse
lan-mouse-macos-intel/lan-mouse-macos-intel
lan-mouse-macos-aarch64/lan-mouse-macos-aarch64
lan-mouse-windows/lan-mouse-windows.zip

7
.gitignore vendored
View File

@@ -5,10 +5,3 @@
.vscode/ .vscode/
.direnv/ .direnv/
result result
*.pem
*.csr
extfile.conf
# flatpak files
.flatpak-builder
repo

View File

@@ -1,4 +0,0 @@
style_edition = "2024"
max_width = 100
tab_spaces = 4

View File

@@ -1,65 +0,0 @@
# Lan Mouse Agent Instructions
## Overview
Lan Mouse is an open-source Software KVM sharing mouse/keyboard input across local networks. The Rust workspace combines a GTK frontend, CLI/daemon mode, and multi-OS capture/emulation backends for Linux, Windows, and macOS.
## Core principles
- **Scope discipline.** Only implement what was requested; describe follow-up work instead of absorbing it.
- **Clarify OS behavior.** Ask when requirements touch OS-specific capture/emulation (they differ significantly).
- **Docs stay current.** Update [README.md](README.md) or [DOC.md](DOC.md) when touching public APIs or platform support.
- **Rust idioms.** Use `Result`/`Option`, `thiserror` for errors, descriptive logs, and concise comments for non-obvious invariants.
## Terminology
- **Client:** A remote machine that can receive or send input events. Each client is either _active_ (receiving events) or _inactive_ (can send events back). This mutual exclusion prevents feedback loops.
- **Backend:** OS-specific implementation for capture or emulation (e.g., libei, layer-shell, wlroots, X11, Windows, macOS).
- **Handle:** A per-client identifier used to route events and track state (pressed keys, position).
## Architecture
**Pipeline:** `input-capture``lan-mouse-ipc``input-emulation`
- **input-capture:** Reads OS events into a `Stream<CaptureEvent>`. Backends tried in priority order (libei → layer-shell → X11 → fallback). Tracks `pressed_keys` to avoid stuck modifiers. `position_map` queues events when multiple clients share a screen edge.
- **input-emulation:** Replays events via the `Emulation` trait (`consume`, `create`, `destroy`, `terminate`). Maintains `pressed_keys` and releases them on disconnect.
- **lan-mouse-ipc / lan-mouse-proto:** Protocol glue and serialization. Events are UDP; connection requests are TCP on the same port. Version bumps required when serialization changes.
- **input-event:** Shared scancode enums and abstract event types—extend here, don't duplicate translations.
## Feature & cfg discipline
- Feature flags live in root `Cargo.toml`. Gate OS-specific modules with tight cfgs (e.g., `cfg(all(unix, feature = "layer_shell", not(target_os = "macos")))`).
- Prefer module-level gating over per-function cfgs to avoid empty stubs.
- New backends: add feature in `Cargo.toml`, create gated module, log backend selection.
## Async patterns
- Tokio runtime with `futures` streams and `async_trait`. Model new flows as streams or async methods.
- Avoid blocking; use `spawn_blocking` if needed. Prefer existing single-threaded stream handling.
- `InputCapture` implements `Stream` and manually pumps backends—don't short-circuit this logic.
## Commands
```sh
cargo build --workspace # full build
cargo build -p <crate> # single crate
cargo test --workspace # all tests
cargo fmt && cargo clippy --workspace --all-targets --all-features # lint
RUST_LOG=lan_mouse=debug cargo run # debug logging
```
Run from repo root—no `cd` in scripts.
## Testing
- Unit tests for utilities; integration tests for protocol behavior.
- OS-specific backends: test via GTK/CLI on target OS or document manual verification.
- Dummy backend exercises pipeline without real dependencies.
- Verify `terminate()` releases keys on unexpected disconnect.
## Workflow
1. Clarify ambiguous requirements, especially OS-specific behavior.
2. Implement minimal change; flag follow-up work.
3. Add proportional tests; run `cargo test` on affected crates.
4. Run `cargo fmt` and `cargo clippy --workspace --all-targets --all-features`.

3345
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,98 +3,53 @@ members = [
"input-capture", "input-capture",
"input-emulation", "input-emulation",
"input-event", "input-event",
"lan-mouse-ipc",
"lan-mouse-cli",
"lan-mouse-gtk",
"lan-mouse-proto",
] ]
[package] [package]
name = "lan-mouse" name = "lan-mouse"
description = "Software KVM Switch / mouse & keyboard sharing software for Local Area Networks" description = "Software KVM Switch / mouse & keyboard sharing software for Local Area Networks"
version = "0.10.0" version = "0.8.0"
edition = "2021" edition = "2021"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"
repository = "https://github.com/feschber/lan-mouse" repository = "https://github.com/ferdinandschober/lan-mouse"
[profile.release] [profile.release]
codegen-units = 1
lto = "fat"
strip = true strip = true
panic = "abort" lto = "fat"
[build-dependencies]
shadow-rs = "1.2.0"
[dependencies] [dependencies]
input-event = { path = "input-event", version = "0.3.0" } input-event = { path = "input-event", version = "0.1.0" }
input-emulation = { path = "input-emulation", version = "0.3.0", default-features = false } input-emulation = { path = "input-emulation", version = "0.1.0", default-features = false }
input-capture = { path = "input-capture", version = "0.3.0", default-features = false } input-capture = { path = "input-capture", version = "0.1.0", default-features = false }
lan-mouse-cli = { path = "lan-mouse-cli", version = "0.2.0" }
lan-mouse-gtk = { path = "lan-mouse-gtk", version = "0.2.0", optional = true }
lan-mouse-ipc = { path = "lan-mouse-ipc", version = "0.2.0" }
lan-mouse-proto = { path = "lan-mouse-proto", version = "0.2.0" }
shadow-rs = { version = "1.2.0", features = ["metadata"] }
hickory-resolver = "0.25.2" hickory-resolver = "0.24.1"
toml = "0.8" toml = "0.8"
toml_edit = { version = "0.22", features = ["serde"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
anyhow = "1.0.71"
log = "0.4.20" log = "0.4.20"
env_logger = "0.11.3" env_logger = "0.11.3"
serde_json = "1.0.107" serde_json = "1.0.107"
tokio = { version = "1.32.0", features = [ tokio = {version = "1.32.0", features = ["io-util", "io-std", "macros", "net", "process", "rt", "sync", "signal"] }
"io-util",
"io-std",
"macros",
"net",
"process",
"rt",
"sync",
"signal",
] }
futures = "0.3.28" futures = "0.3.28"
clap = { version = "4.4.11", features = ["derive"] } clap = { version="4.4.11", features = ["derive"] }
gtk = { package = "gtk4", version = "0.8.1", features = ["v4_2"], optional = true }
adw = { package = "libadwaita", version = "0.6.0", features = ["v1_1"], optional = true }
async-channel = { version = "2.1.1", optional = true }
hostname = "0.4.0"
slab = "0.4.9" slab = "0.4.9"
thiserror = "2.0.0" endi = "1.1.0"
tokio-util = "0.7.11" thiserror = "1.0.61"
local-channel = "0.1.5"
webrtc-dtls = { version = "0.12.0", features = ["pem"] }
webrtc-util = "0.11.0"
rustls = { version = "0.23.12", default-features = false, features = [
"std",
"ring",
] }
rcgen = "0.13.1"
sha2 = "0.10.8"
notify = "8.2.0"
[target.'cfg(unix)'.dependencies] [target.'cfg(unix)'.dependencies]
libc = "0.2.148" libc = "0.2.148"
[features] [build-dependencies]
default = [ glib-build-tools = { version = "0.19.0", optional = true }
"gtk",
"layer_shell_capture",
"x11_capture",
"libei_capture",
"wlroots_emulation",
"libei_emulation",
"rdp_emulation",
"x11_emulation",
]
gtk = ["dep:lan-mouse-gtk"]
layer_shell_capture = ["input-capture/layer_shell"]
x11_capture = ["input-capture/x11"]
libei_capture = ["input-event/libei", "input-capture/libei"]
libei_emulation = ["input-event/libei", "input-emulation/libei"]
wlroots_emulation = ["input-emulation/wlroots"]
x11_emulation = ["input-emulation/x11"]
rdp_emulation = ["input-emulation/remote_desktop_portal"]
[package.metadata.bundle] [features]
name = "Lan Mouse" default = [ "wayland", "x11", "xdg_desktop_portal", "libei", "gtk" ]
icon = ["target/icon.icns"] wayland = [ "input-capture/wayland", "input-emulation/wayland" ]
identifier = "de.feschber.LanMouse" x11 = [ "input-capture/x11", "input-emulation/x11" ]
osx_info_plist_exts = ["build-aux/macos-lsui-element.plist"] xdg_desktop_portal = [ "input-emulation/xdg_desktop_portal" ]
resources = ["target/menubar-template.png"] libei = [ "input-capture/libei", "input-emulation/libei" ]
gtk = ["dep:gtk", "dep:adw", "dep:async-channel", "dep:glib-build-tools"]

452
README.md
View File

@@ -1,69 +1,74 @@
# Lan Mouse # Lan Mouse
Lan Mouse is a mouse and keyboard sharing software similar to universal-control on Apple devices.
[![CI](https://github.com/feschber/lan-mouse/actions/workflows/rust.yml/badge.svg)](https://github.com/feschber/lan-mouse/actions/workflows/rust.yml) [![Cachix](https://github.com/feschber/lan-mouse/actions/workflows/cachix.yml/badge.svg)](https://github.com/feschber/lan-mouse/actions/workflows/cachix.yml) [![Release](https://github.com/feschber/lan-mouse/actions/workflows/release.yml/badge.svg)](https://github.com/feschber/lan-mouse/actions/workflows/release.yml) It allows for using multiple pcs with a single set of mouse and keyboard.
[![crates.io](https://img.shields.io/crates/v/lan-mouse.svg)](https://crates.io/crates/lan-mouse) [![license](https://img.shields.io/crates/l/lan-mouse.svg)](https://github.com/feschber/lan-mouse/blob/main/Cargo.toml)
Lan Mouse is a *cross-platform* mouse and keyboard sharing software similar to universal-control on Apple devices.
It allows for using multiple PCs via a single set of mouse and keyboard.
This is also known as a Software KVM switch. This is also known as a Software KVM switch.
Goal of this project is to be an open-source alternative to proprietary tools like [Synergy 2/3](https://symless.com/synergy), [Share Mouse](https://www.sharemouse.com/de/) 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).
and other open source tools like [Deskflow](https://github.com/deskflow/deskflow) or [Input Leap](https://github.com/input-leap) (Synergy fork).
Focus lies on performance, ease of use and a maintainable implementation that can be expanded to support additional backends for e.g. Android, iOS, ... in the future.
***blazingly fast™*** because it's written in rust.
- _Now with a gtk frontend_ - _Now with a gtk frontend_
<picture> <picture>
<source media="(prefers-color-scheme: dark)" srcset="/screenshots/dark.png?raw=true"> <source media="(prefers-color-scheme: dark)" srcset="https://github.com/feschber/lan-mouse/assets/40996949/016a06a9-76db-4951-9dcc-127d012c59df">
<source media="(prefers-color-scheme: light)" srcset="/screenshots/light.png?raw=true"> <source media="(prefers-color-scheme: light)" srcset="https://github.com/feschber/lan-mouse/assets/40996949/d6318340-f811-4e16-9d6e-d1b79883c709">
<img alt="Screenshot of Lan-Mouse" srcset="/screenshots/dark.png"> <img alt="Screenshot of Lan-Mouse" srcset="https://github.com/feschber/lan-mouse/assets/40996949/016a06a9-76db-4951-9dcc-127d012c59df">
</picture> </picture>
## Encryption Goal of this project is to be an open-source replacement for proprietary tools like [Synergy 2/3](https://symless.com/synergy), [Share Mouse](https://www.sharemouse.com/de/).
Focus lies on performance and a clean, manageable implementation that can easily be expanded to support additional backends like e.g. Android, iOS, ... .
***blazingly fast™*** because it's written in rust.
For an alternative (with slightly different goals) you may check out [Input Leap](https://github.com/input-leap).
> [!WARNING]
> Since this tool has gained a bit of popularity over the past couple of days:
>
> All network traffic is currently **unencrypted** and sent in **plaintext**.
>
> A malicious actor with access to the network could read input data or send input events with spoofed IPs to take control over a device.
>
> Therefore you should only use this tool in your local network with trusted devices for now
> and I take no responsibility for any leakage of data!
Lan Mouse encrypts all network traffic using the DTLS implementation provided by [WebRTC.rs](https://github.com/webrtc-rs/webrtc).
There are currently no mitigations in place for timing side-channel attacks.
## OS Support ## OS Support
Most current desktop environments and operating systems are fully supported, this includes The following table shows support for input emulation (to emulate events received from other clients) and
- GNOME >= 45 input capture (to send events *to* other clients) on different operating systems:
- KDE Plasma >= 6.1
- Most wlroots based compositors, including Sway (>= 1.8), Hyprland and Wayfire
- Windows
- MacOS
| OS / Desktop Environment | input emulation | input capture |
### Caveats / Known Issues |---------------------------|--------------------------|--------------------------------------|
| Wayland (wlroots) | :heavy_check_mark: | :heavy_check_mark: |
| Wayland (KDE) | :heavy_check_mark: | :heavy_check_mark: |
| Wayland (Gnome) | :heavy_check_mark: | :heavy_check_mark: (starting at GNOME 45) |
| Windows | :heavy_check_mark: | :heavy_check_mark: |
| X11 | :heavy_check_mark: | WIP |
| MacOS | :heavy_check_mark: | WIP |
> [!Important] > [!Important]
> - **X11** currently only has support for input emulation, i.e. can only be used on the receiving end. > Gnome -> Sway only partially works (modifier events are not handled correctly)
> [!Important]
> **Wayfire**
> >
> - **Sway / wlroots**: Wlroots based compositors without libei support on the receiving end currently do not handle modifier events on the client side. > If you are using [Wayfire](https://github.com/WayfireWM/wayfire), make sure to use a recent version (must be newer than October 23rd) and **add `shortcuts-inhibit` to the list of plugins in your wayfire config!**
> This results in CTRL / SHIFT / ALT / SUPER keys not working with a sending device that is NOT using the `layer-shell` backend
>
> - **Wayfire**: If you are using [Wayfire](https://github.com/WayfireWM/wayfire), make sure to use a recent version (must be newer than October 23rd) and **add `shortcuts-inhibit` to the list of plugins in your wayfire config!**
> Otherwise input capture will not work. > Otherwise input capture will not work.
>
> - **Windows**: The mouse cursor will be invisible when sending input to a Windows system if
> there is no real mouse connected to the machine.
For more detailed information about os support see [Detailed OS Support](#detailed-os-support)
### Android & IOS
A proof of concept for an Android / IOS Application by [rohitsangwan01](https://github.com/rohitsangwan01) can be found [here](https://github.com/rohitsangwan01/lan-mouse-mobile).
It can be used as a remote control for any device supported by Lan Mouse.
## Installation ## Installation
### Install via cargo
```sh
cargo install lan-mouse
```
<details> ### Download from Releases
<summary>Arch Linux</summary> Precompiled release binaries for Windows, MacOS and Linux are available in the [releases section](https://github.com/feschber/lan-mouse/releases).
For Windows, the depenedencies are included in the .zip file, for other operating systems see [Installing Dependencies](#installing-dependencies).
### Arch Linux
Lan Mouse can be installed from the [official repositories](https://archlinux.org/packages/extra/x86_64/lan-mouse/): Lan Mouse can be installed from the [official repositories](https://archlinux.org/packages/extra/x86_64/lan-mouse/):
@@ -71,64 +76,43 @@ Lan Mouse can be installed from the [official repositories](https://archlinux.or
pacman -S lan-mouse pacman -S lan-mouse
``` ```
The prerelease version (following `main`) is available on the AUR: It is also available on the AUR:
```sh ```sh
# git version (includes latest changes)
paru -S lan-mouse-git paru -S lan-mouse-git
# alternatively
paru -S lan-mouse-bin
``` ```
</details>
<details>
<summary>Nix (OS)</summary>
### Nix
- nixpkgs: [search.nixos.org](https://search.nixos.org/packages?channel=unstable&show=lan-mouse&from=0&size=50&sort=relevance&type=packages&query=lan-mouse) - nixpkgs: [search.nixos.org](https://search.nixos.org/packages?channel=unstable&show=lan-mouse&from=0&size=50&sort=relevance&type=packages&query=lan-mouse)
- flake: [README.md](./nix/README.md) - flake: [README.md](./nix/README.md)
</details>
<details>
<summary>Fedora</summary>
You can install Lan Mouse from the [Terra Repository](https://terra.fyralabs.com).
After enabling Terra: ### Manual Installation
First make sure to [install the necessary dependencies](#installing-dependencies).
Build in release mode:
```sh ```sh
dnf install lan-mouse cargo build --release
``` ```
</details>
<details> Run directly:
<summary>MacOS</summary>
- Download the package for your Mac (Intel or ARM) from the releases page
- Unzip it
- Remove the quarantine with `xattr -rd com.apple.quarantine "Lan Mouse.app"`
- Launch the app
- Use the menu bar item to open the settings window or quit Lan Mouse. Bundled macOS builds run as a menu bar app and do not keep a Dock icon visible.
- Grant accessibility permissions in System Preferences
</details>
<details>
<summary>Manual Installation</summary>
First make sure to [install the necessary dependencies](#installing-dependencies-for-development--compiling-from-source).
Precompiled release binaries for Windows, MacOS and Linux are available in the [releases section](https://github.com/feschber/lan-mouse/releases).
For Windows, the depenedencies are included in the .zip file, for other operating systems see [Installing Dependencies](#installing-dependencies-for-development--compiling-from-source).
Alternatively, the `lan-mouse` binary can be compiled from source (see below).
### Installing desktop file, app icon and firewall rules (optional)
```sh ```sh
# install lan-mouse (replace path/to/ with the correct path) cargo run --release
sudo cp path/to/lan-mouse /usr/local/bin/ ```
Install the files:
```sh
# install lan-mouse
sudo cp target/release/lan-mouse /usr/local/bin/
# install app icon # install app icon
sudo mkdir -p /usr/local/share/icons/hicolor/scalable/apps sudo mkdir -p /usr/local/share/icons/hicolor/scalable/apps
sudo cp lan-mouse-gtk/resources/de.feschber.LanMouse.svg /usr/local/share/icons/hicolor/scalable/apps sudo cp resources/de.feschber.LanMouse.svg /usr/local/share/icons/hicolor/scalable/apps
# update icon cache # update icon cache
gtk-update-icon-cache /usr/local/share/icons/hicolor/ gtk-update-icon-cache /usr/local/share/icons/hicolor/
@@ -142,81 +126,33 @@ sudo cp firewall/lan-mouse.xml /etc/firewalld/services
# -> enable the service in firewalld settings # -> enable the service in firewalld settings
``` ```
Instead of downloading from the releases, the `lan-mouse` binary ### Conditional Compilation
can be easily compiled via cargo or nix:
### Compiling and installing manually: Currently only x11, wayland, windows and MacOS are supported backends.
```sh Depending on the toolchain used, support for other platforms is omitted
# compile in release mode automatically (it does not make sense to build a Windows `.exe` with
cargo build --release support for x11 and wayland backends).
# install lan-mouse However one might still want to omit support for e.g. wayland, x11 or libei on
sudo cp target/release/lan-mouse /usr/local/bin/ a Linux system.
```
### Compiling and installing via cargo: This is possible through
```sh
# will end up in ~/.cargo/bin
cargo install lan-mouse
```
### Compiling and installing via nix:
```sh
# you can find the executable in result/bin/lan-mouse
nix-build
```
### Conditional compilation
Support for other platforms is omitted automatically based on the active
rust toolchain.
Additionally, available backends and frontends can be configured manually via
[cargo features](https://doc.rust-lang.org/cargo/reference/features.html). [cargo features](https://doc.rust-lang.org/cargo/reference/features.html).
E.g. if only support for sway is needed, the following command produces E.g. if only wayland support is needed, the following command produces
an executable with support for only the `layer-shell` capture backend an executable with just support for wayland:
and `wlroots` emulation backend:
```sh ```sh
cargo build --no-default-features --features layer_shell_capture,wlroots_emulation cargo build --no-default-features --features wayland
``` ```
For a detailed list of available features, checkout the [Cargo.toml](./Cargo.toml) For a detailed list of available features, checkout the [Cargo.toml](./Cargo.toml)
</details>
## Installing Dependencies
## Development
### Git pre-commit hook
This repository includes a local git hooks directory `.githooks/` with a `pre-commit` script that enforces formatting, lints, and tests before allowing a commit. It is optional to enable it, but it will prevent you from committing code with failing unit tests or that needs clippy/fmt fixes. To enable the hook locally:
1. Make the hook executable:
```sh
chmod +x .githooks/pre-commit
```
2. Point git to the hooks directory (one-time per clone):
```sh
git config core.hooksPath .githooks
```
The `pre-commit` script runs `cargo fmt --all` (and fails if files were modified), `cargo clippy --workspace --all-targets --all-features -- -D warnings`, and `cargo test --workspace --all-features`.
### Dependencies & Compiling from Source
<details> <details>
<summary>MacOS</summary> <summary>MacOS</summary>
```sh ```sh
# Install dependencies brew install libadwaita
brew install libadwaita pkg-config imagemagick
cargo install cargo-bundle
# Create the macOS icon file
scripts/makeicns.sh
# Create the .app bundle
cargo bundle
# Copy all dynamic libraries into the bundle, and update the bundle to find them there
scripts/copy-macos-dylib.sh
``` ```
</details> </details>
@@ -243,24 +179,12 @@ sudo pacman -S libadwaita gtk libx11 libxtst
sudo dnf install libadwaita-devel libXtst-devel libX11-devel sudo dnf install libadwaita-devel libXtst-devel libX11-devel
``` ```
</details> </details>
<details>
<summary>Nix</summary>
```sh
nix-shell .
```
</details>
<details>
<summary>Nix (flake)</summary>
```sh
nix develop
```
</details>
<details> <details>
<summary>Windows</summary> <summary>Windows</summary>
> [!NOTE]
> This is only necessary when building lan-mouse from source. The windows release comes with precompiled gtk dlls.
- First install [Rust](https://www.rust-lang.org/tools/install). - First install [Rust](https://www.rust-lang.org/tools/install).
- Then follow the instructions at [gtk-rs.org](https://gtk-rs.org/gtk4-rs/stable/latest/book/installation_windows.html) - Then follow the instructions at [gtk-rs.org](https://gtk-rs.org/gtk4-rs/stable/latest/book/installation_windows.html)
@@ -291,64 +215,48 @@ python -m pipx ensurepath
pipx install gvsbuild pipx install gvsbuild
# build gtk + libadwaita # build gtk + libadwaita
gvsbuild build gtk4 libadwaita librsvg adwaita-icon-theme gvsbuild build gtk4 libadwaita librsvg
``` ```
- **Make sure to add the directory** `C:\gtk-build\gtk\x64\release\bin` - **Make sure to add the directory** `C:\gtk-build\gtk\x64\release\bin`
[**to the `PATH` environment variable**]((https://learn.microsoft.com/en-us/previous-versions/office/developer/sharepoint-2010/ee537574(v=office.14))). Otherwise the project will fail to build. [**to the `PATH` environment variable**]((https://learn.microsoft.com/en-us/previous-versions/office/developer/sharepoint-2010/ee537574(v=office.14))). Otherwise the project will fail to build.
To avoid building GTK from source, it is possible to disable To avoid building GTK from source, it is possible to disable
the gtk frontend (see conditional compilation). the gtk frontend (see conditional compilation below).
</details> </details>
## Usage ## Usage
<details> ### Gtk Frontend
<summary>Gtk Frontend</summary>
By default the gtk frontend will open when running `lan-mouse`. By default the gtk frontend will open when running `lan-mouse`.
To connect a device you want to control, simply click the `Add` button and enter the hostname To add a new connection, simply click the `Add` button on *both* devices,
of the device. enter the corresponding hostname and activate it.
On the *remote* device, authorize your *local* device for incoming traffic using the `Authorize` button If the mouse can not be moved onto a device, make sure you have port `4242` (or the one selected)
under the "Incoming Connections" section. opened up in your firewall.
The fingerprint for authorization can be found under the general section of your *local* device.
It is of the form "aa:bb:cc:..."
Authorized devices can be persisted using the configuration file (see [Configuration](#configuration)). ### Command Line Interface
The cli interface can be enabled using `--frontend cli` as commandline arguments.
Type `help` to list the available commands.
If the device still can not be entered, make sure you have UDP port `4242` (or the one selected) opened up in your firewall. E.g.:
</details>
<details>
<summary>Command Line Interface</summary>
The cli interface can be accessed by passing `cli` as a commandline argument.
Use
```sh ```sh
lan-mouse cli help $ cargo run --release -- --frontend cli
(...)
> connect <host> left|right|top|bottom
(...)
> list
(...)
> activate 0
``` ```
to list the available commands and
```sh
lan-mouse cli <cmd> help
```
for information on how to use a specific command.
</details> ### Daemon
Lan Mouse can be launched in daemon mode to keep it running in the background.
<details> To do so, add `--daemon` to the commandline args:
<summary>Daemon Mode</summary>
Lan Mouse can be launched in daemon mode to keep it running in the background (e.g. for use in a systemd-service).
To do so, use the `daemon` subcommand:
```sh ```sh
lan-mouse daemon $ cargo run --release -- --daemon
``` ```
</details>
## Systemd Service
In order to start lan-mouse with a graphical session automatically, In order to start lan-mouse with a graphical session automatically,
the [systemd-service](service/lan-mouse.service) can be used: the [systemd-service](service/lan-mouse.service) can be used:
@@ -360,9 +268,6 @@ cp service/lan-mouse.service ~/.config/systemd/user
systemctl --user daemon-reload systemctl --user daemon-reload
systemctl --user enable --now lan-mouse.service systemctl --user enable --now lan-mouse.service
``` ```
> [!Important]
> Make sure to point `ExecStart=/usr/bin/lan-mouse daemon` to the actual `lan-mouse` binary (in case it is not under `/usr/bin`, e.g. when installed manually.
## Configuration ## Configuration
To automatically load clients on startup, the file `$XDG_CONFIG_HOME/lan-mouse/config.toml` is parsed. To automatically load clients on startup, the file `$XDG_CONFIG_HOME/lan-mouse/config.toml` is parsed.
@@ -373,7 +278,7 @@ To create this file you can copy the following example config:
### Example config ### Example config
> [!TIP] > [!TIP]
> key symbols in the release bind are named according > key symbols in the release bind are named according
> to their names in [input-event/src/scancode.rs#L172](input-event/src/scancode.rs#L176). > to their names in [src/scancode.rs#L172](src/scancode.rs#L172).
> This is bound to change > This is bound to change
```toml ```toml
@@ -384,16 +289,12 @@ release_bind = [ "KeyA", "KeyS", "KeyD", "KeyF" ]
# optional port (defaults to 4242) # optional port (defaults to 4242)
port = 4242 port = 4242
# # optional frontend -> defaults to gtk if available
# list of authorized tls certificate fingerprints that # # possible values are "cli" and "gtk"
# are accepted for incoming traffic # frontend = "gtk"
[authorized_fingerprints]
"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"
# define a client on the right side with host name "iridium" # define a client on the right side with host name "iridium"
[[clients]] [right]
# position (left | right | top | bottom)
position = "right"
# hostname # hostname
hostname = "iridium" hostname = "iridium"
# activate this client immediately when lan-mouse is started # activate this client immediately when lan-mouse is started
@@ -402,8 +303,7 @@ activate_on_startup = true
ips = ["192.168.178.156"] ips = ["192.168.178.156"]
# define a client on the left side with IP address 192.168.178.189 # define a client on the left side with IP address 192.168.178.189
[[clients]] [left]
position = "left"
# The hostname is optional: When no hostname is specified, # The hostname is optional: When no hostname is specified,
# at least one ip address needs to be specified. # at least one ip address needs to be specified.
hostname = "thorium" hostname = "thorium"
@@ -423,57 +323,109 @@ Where `left` can be either `left`, `right`, `top` or `bottom`.
- [x] Liveness tracking: Automatically release keys, when server offline - [x] Liveness tracking: Automatically release keys, when server offline
- [x] MacOS KeyCode Translation - [x] MacOS KeyCode Translation
- [x] Libei Input Capture - [x] Libei Input Capture
- [x] MacOS Input Capture
- [x] Windows Input Capture
- [x] Encryption
- [ ] X11 Input Capture - [ ] X11 Input Capture
- [ ] Windows Input Capture
- [ ] MacOS Input Capture
- [ ] Latency measurement and visualization - [ ] Latency measurement and visualization
- [ ] Bandwidth usage measurement and visualization - [ ] Bandwidth usage measurement and visualization
- [ ] Clipboard support - [ ] Clipboard support
- [ ] *Encryption*
## Protocol
Currently *all* mouse and keyboard events are sent via **UDP** for performance reasons.
Each event is sent as one single datagram, currently without any acknowledgement to guarantee 0% packet loss.
This means, any packet that is lost results in a discarded mouse / key event, which is ignored for now.
**UDP** also has the additional benefit that no reconnection logic is required.
Any client can just go offline and it will simply start working again as soon as it comes back online.
Additionally a tcp server is hosted for data that needs to be sent reliably (e.g. the keymap from the server or clipboard contents in the future) can be requested via a tcp connection.
## Bandwidth considerations
The most bandwidth is taken up by mouse events. A typical office mouse has a polling rate of 125Hz
while gaming mice typically have a much higher polling rate of 1000Hz.
A mouse Event consists of 21 Bytes:
- 1 Byte for the event type enum,
- 4 Bytes (u32) for the timestamp,
- 8 Bytes (f64) for dx,
- 8 Bytes (f64) for dy.
Additionally the IP header with 20 Bytes and the udp header with 8 Bytes take up another 28 Byte.
So in total there is 49 * 1000 Bytes/s for a 1000Hz gaming mouse.
This makes for a bandwidth requirement of 392 kbit/s in total _even_ for a high end gaming mouse.
So bandwidth is a non-issue.
Larger data chunks, like the keymap are offered by the server via tcp listening on the same port.
This way we dont need to implement any congestion control and leave this up to tcp.
In the future this can be used for e.g. clipboard contents as well.
## Packets per Second
While on LAN the performance is great,
some WIFI cards seem to struggle with the amount of packets per second,
particularly on high-end gaming mice with 1000Hz+ polling rates.
The plan is to implement a way of accumulating packets and sending them as
one single key event to reduce the packet rate (basically reducing the polling
rate artificially).
The way movement data is currently sent is also quite wasteful since even a 16bit integer
is likely enough to represent even the fastest possible mouse movement.
A different encoding that is more efficient for smaller values like
[Protocol Buffers](https://protobuf.dev/programming-guides/encoding/)
would be a better choice for the future and could also help for WIFI connections.
## Security
Sending key and mouse event data over the local network might not be the biggest security concern but in any public network or business environment it's *QUITE* a problem to basically broadcast your keystrokes.
- There should be an encryption layer below the application to enable a secure link.
- The encryption keys could be generated by the graphical frontend.
## Detailed OS Support ## Wayland support
### Input Emulation (for receiving events)
On wayland input-emulation is in an early/unstable state as of writing this.
In order to use a device for sending events, an **input-capture** backend is required, while receiving events requires For this reason a suitable backend is chosen based on the active desktop environment / compositor.
a supported **input-emulation** *and* **input-capture** backend.
A suitable backend is chosen automatically based on the active desktop environment / compositor. Different compositors have different ways of enabling input emulation:
The following sections detail the emulation and capture backends provided by lan-mouse and their support in desktop environments / operating systems. #### Wlroots
Most wlroots-based compositors like Hyprland and Sway support the following
unstable wayland protocols for keyboard and mouse emulation:
- [virtual-keyboard-unstable-v1](https://wayland.app/protocols/virtual-keyboard-unstable-v1)
- [wlr-virtual-pointer-unstable-v1](https://wayland.app/protocols/wlr-virtual-pointer-unstable-v1)
### Input Emulation Support #### KDE
KDE also has a protocol for input emulation ([kde-fake-input](https://wayland.app/protocols/kde-fake-input)),
it is however not exposed to third party applications.
| Desktop / Backend | wlroots | libei | remote-desktop portal | windows | macos | x11 | The recommended way to emulate input on KDE is the
|---------------------------|--------------------------|--------------------------|--------------------------|--------------------------|----------------------------------------|--------------------| [freedesktop remote-desktop-portal](https://flatpak.github.io/xdg-desktop-portal/#gdbus-org.freedesktop.portal.RemoteDesktop).
| Wayland (wlroots) | :heavy_check_mark: | | | | | |
| Wayland (KDE) | | :heavy_check_mark: | :heavy_check_mark: | | | |
| Wayland (Gnome) | | :heavy_check_mark: | :heavy_check_mark: | | | |
| Windows | | | | :heavy_check_mark: | | |
| MacOS | | | | | :heavy_check_mark: | |
| X11 | | | | | | :heavy_check_mark: |
- `wlroots`: This backend makes use of the [wlr-virtual-pointer-unstable-v1](https://wayland.app/protocols/wlr-virtual-pointer-unstable-v1) and [virtual-keyboard-unstable-v1](https://wayland.app/protocols/virtual-keyboard-unstable-v1) protocols and is supported by most wlroots based compositors. #### Gnome
- `libei`: This backend uses [libei](https://gitlab.freedesktop.org/libinput/libei) and is supported by GNOME >= 45 or KDE Plasma >= 6.1. Gnome uses [libei](https://gitlab.freedesktop.org/libinput/libei) for input emulation and capture,
- `xdp`: This backend uses the [freedesktop remote-desktop-portal](https://flatpak.github.io/xdg-desktop-portal/#gdbus-org.freedesktop.portal.RemoteDesktop) and is supported on GNOME and Plasma. which has the goal to become the general approach for emulating and capturing Input on Wayland.
- `x11`: Backend for X11 sessions.
- `windows`: Backend for Windows.
- `macos`: Backend for MacOS.
### Input capture
To capture mouse and keyboard input, a few things are necessary:
- Displaying an immovable surface at screen edges
- Locking the mouse in place
- (optionally but highly recommended) reading unaccelerated mouse input
### Input Capture Support | Required Protocols (Event Emitting) | Sway | Kwin | Gnome |
|----------------------------------------|--------------------|----------------------|----------------------|
| pointer-constraints-unstable-v1 | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
| relative-pointer-unstable-v1 | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
| keyboard-shortcuts-inhibit-unstable-v1 | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
| wlr-layer-shell-unstable-v1 | :heavy_check_mark: | :heavy_check_mark: | :x: |
| Desktop / Backend | layer-shell | libei | windows | macos | x11 | The [zwlr\_virtual\_pointer\_manager\_v1](wlr-virtual-pointer-unstable-v1) is required
|---------------------------|--------------------------|--------------------------|--------------------------|----------------------------------------|-----| to display surfaces on screen edges and used to display the immovable window on
| Wayland (wlroots) | :heavy_check_mark: | | | | | both wlroots based compositors and KDE.
| Wayland (KDE) | :heavy_check_mark: | :heavy_check_mark: | | | |
| Wayland (Gnome) | | :heavy_check_mark: | | | | Gnome unfortunately does not support this protocol
| Windows | | | :heavy_check_mark: | | | and [likely won't ever support it](https://gitlab.gnome.org/GNOME/gnome-shell/-/issues/1141).
| MacOS | | | | :heavy_check_mark: | |
| X11 | | | | | WIP | ~In order for layershell surfaces to be able to lock the pointer using the pointer\_constraints protocol [this patch](https://github.com/swaywm/sway/pull/7178) needs to be applied to sway.~
(this works natively on sway versions >= 1.8)
- `layer-shell`: This backend creates a single pixel wide window on the edges of Displays to capture the cursor using the [layer-shell protocol](https://wayland.app/protocols/wlr-layer-shell-unstable-v1).
- `libei`: This backend uses [libei](https://gitlab.freedesktop.org/libinput/libei) and is supported by GNOME >= 45 or KDE Plasma >= 6.1.
- `windows`: Backend for input capture on Windows.
- `macos`: Backend for input capture on MacOS.
- `x11`: TODO (not yet supported)

View File

@@ -1,50 +0,0 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/flatpak/flatpak-builder/refs/heads/main/data/flatpak-manifest.schema.json
app-id: de.feschber.LanMouse
runtime: org.gnome.Platform
runtime-version: "48"
sdk: org.gnome.Sdk
sdk-extensions:
- org.freedesktop.Sdk.Extension.rust-stable
- org.freedesktop.Sdk.Extension.llvm20
command: /app/bin/lan-mouse
build-options:
append-path: "/usr/lib/sdk/rust-stable/bin:/usr/lib/sdk/llvm20/bin"
env:
"CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER": "clang"
"CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS": "-C link-arg=-fuse-ld=/usr/lib/sdk/rust-stable/bin/mold"
"CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER": "clang"
"CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_RUSTFLAGS": "-C link-arg=-fuse-ld=/usr/lib/sdk/rust-stable/bin/mold"
build-args:
"--share=network"
prepend-ld-library-path:
"/usr/lib/sdk/llvm19/lib"
finish-args:
- "--socket=wayland"
- "--socket=fallback-x11"
- "--device=dri"
- "--socket=session-bus"
- "--share=network"
- "--filesystem=xdg-config"
- "--env=RUST_BACKTRACE=1"
- "--env=RUST_LOG=lan-mouse=debug"
- "--env=GTK_PATH=/app/lib/gtk-4.0"
modules:
- name: lan-mouse
buildsystem: simple
build-options:
build-args:
- "--share=network"
append-path: /usr/lib/sdk/rust-stable/bin
env:
CARGO_HOME: /run/build/lan-mouse/cargo
build-commands:
- cargo fetch --manifest-path Cargo.toml --verbose
- cargo build
- install -Dm0755 target/debug/lan-mouse /app/bin/lan-mouse
- install -Dm0644 lan-mouse-gtk/resources/de.feschber.LanMouse.svg ${FLATPAK_DEST}/share/icons/hicolor/scalable/apps/${FLATPAK_ID}.svg
- install -Dm0644 de.feschber.LanMouse.desktop ${FLATPAK_DEST}/share/applications/${FLATPAK_ID}.desktop
sources:
- type: dir
path: ..

View File

@@ -1,8 +0,0 @@
<key>LSUIElement</key>
<true/>
<key>NSAppSleepDisabled</key>
<true/>
<key>NSInputMonitoringUsageDescription</key>
<string>Lan Mouse needs Input Monitoring access to capture keyboard and mouse input and forward it to remote machines on your network.</string>
<key>NSAppleEventsUsageDescription</key>
<string>Lan Mouse uses Apple Events to deliver synthesized keyboard and mouse events to the system.</string>

View File

@@ -1,8 +1,9 @@
use shadow_rs::ShadowBuilder;
fn main() { fn main() {
ShadowBuilder::builder() // composite_templates
.deny_const(Default::default()) #[cfg(feature = "gtk")]
.build() glib_build_tools::compile_resources(
.expect("shadow build"); &["resources"],
"resources/resources.gresource.xml",
"lan-mouse.gresource",
);
} }

View File

@@ -1,30 +1,24 @@
# example configuration # example configuration
# configure release bind # capture_backend = "LayerShell"
# release bind
release_bind = [ "KeyA", "KeyS", "KeyD", "KeyF" ] release_bind = [ "KeyA", "KeyS", "KeyD", "KeyF" ]
# optional port (defaults to 4242) # optional port (defaults to 4242)
port = 4242 port = 4242
# optional frontend -> defaults to gtk if available
# list of authorized tls certificate fingerprints that # frontend = "gtk"
# are accepted for incoming traffic
[authorized_fingerprints]
"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"
# define a client on the right side with host name "iridium" # define a client on the right side with host name "iridium"
[[clients]] [right]
# position (left | right | top | bottom)
position = "right"
# hostname # hostname
hostname = "iridium" hostname = "iridium"
# activate this client immediately when lan-mouse is started
activate_on_startup = true
# optional list of (known) ip addresses # optional list of (known) ip addresses
ips = ["192.168.178.156"] ips = ["192.168.178.156"]
# define a client on the left side with IP address 192.168.178.189 # define a client on the left side with IP address 192.168.178.189
[[clients]] [left]
position = "left"
# The hostname is optional: When no hostname is specified, # The hostname is optional: When no hostname is specified,
# at least one ip address needs to be specified. # at least one ip address needs to be specified.
hostname = "thorium" hostname = "thorium"

14
de.feschber.LanMouse.yml Normal file
View File

@@ -0,0 +1,14 @@
app-id: de.feschber.LanMouse
runtime: org.freedesktop.Platform
runtime-version: '22.08'
sdk: org.freedesktop.Sdk
command: target/release/lan-mouse
modules:
- name: hello
buildsystem: simple
build-commands:
- cargo build --release
- install -D lan-mouse /app/bin/lan-mouse
sources:
- type: file
path: target/release/lan-mouse

View File

@@ -1,3 +0,0 @@
{ pkgs ? import <nixpkgs> { }
}:
pkgs.callPackage nix/default.nix { }

1
dylibs/.gitignore vendored
View File

@@ -1 +0,0 @@
*

46
flake.lock generated
View File

@@ -1,12 +1,30 @@
{ {
"nodes": { "nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1705309234,
"narHash": "sha256-uNRRNRKmJyCRC/8y1RqBkqWBLM034y4qN7EprSdmgyA=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "1ef2e671c3b0c19053962c07dbda38332dcebf26",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1772963539, "lastModified": 1716293225,
"narHash": "sha256-9jVDGZnvCckTGdYT53d/EfznygLskyLQXYwJLKMPsZs=", "narHash": "sha256-pU9ViBVE3XYb70xZx+jK6SEVphvt7xMTbm6yDIF4xPs=",
"owner": "nixos", "owner": "nixos",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "9dcb002ca1690658be4a04645215baea8b95f31d", "rev": "3eaeaeb6b1e08a016380c279f8846e0bd8808916",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -24,16 +42,17 @@
}, },
"rust-overlay": { "rust-overlay": {
"inputs": { "inputs": {
"flake-utils": "flake-utils",
"nixpkgs": [ "nixpkgs": [
"nixpkgs" "nixpkgs"
] ]
}, },
"locked": { "locked": {
"lastModified": 1773025773, "lastModified": 1716257780,
"narHash": "sha256-Wik8+xApNfldpUFjPmJkPdg0RrvUPSWGIZis+A/0N1w=", "narHash": "sha256-R+NjvJzKEkTVCmdrKRfPE4liX/KMGVqGUwwS5H8ET8A=",
"owner": "oxalica", "owner": "oxalica",
"repo": "rust-overlay", "repo": "rust-overlay",
"rev": "3c06fdbbd36ff60386a1e590ee0cd52dcd1892bf", "rev": "4e5e3d2c5c9b2721bd266f9e43c14e96811b89d2",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -41,6 +60,21 @@
"repo": "rust-overlay", "repo": "rust-overlay",
"type": "github" "type": "github"
} }
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
} }
}, },
"root": "root", "root": "root",

135
flake.nix
View File

@@ -7,87 +7,56 @@
inputs.nixpkgs.follows = "nixpkgs"; inputs.nixpkgs.follows = "nixpkgs";
}; };
}; };
outputs = outputs = {
{ self,
nixpkgs, nixpkgs,
rust-overlay, rust-overlay,
self, ...
... }: let
}: inherit (nixpkgs) lib;
let genSystems = lib.genAttrs [
inherit (nixpkgs) lib; "aarch64-darwin"
forEachPkgs = "x86_64-darwin"
f: "x86_64-linux"
lib.genAttrs ];
[ pkgsFor = system:
"aarch64-darwin" import nixpkgs {
"aarch64-linux" inherit system;
"x86_64-darwin"
"x86_64-linux" overlays = [
] rust-overlay.overlays.default
( ];
system: };
let mkRustToolchain = pkgs:
pkgs = import nixpkgs { pkgs.rust-bin.stable.latest.default.override {
inherit system; extensions = ["rust-src"];
overlays = [ rust-overlay.overlays.default ]; };
}; pkgs = genSystems (system: import nixpkgs {inherit system;});
# Default toolchain for devshell in {
rustToolchain = pkgs.rust-bin.stable.latest.default.override { packages = genSystems (system: rec {
extensions = [ default = pkgs.${system}.callPackage ./nix {};
# includes already: lan-mouse = default;
# rustc });
# cargo homeManagerModules.default = import ./nix/hm-module.nix self;
# rust-std devShells = genSystems (system: let
# rust-docs pkgs = pkgsFor system;
# rustfmt-preview rust = mkRustToolchain pkgs;
# clippy-preview in {
"rust-analyzer" default = pkgs.mkShell {
"rust-src" packages = with pkgs; [
]; rust
}; rust-analyzer-unwrapped
# Minimal toolchain for builds (rustc + cargo + rust-std only) pkg-config
rustToolchainForBuild = pkgs.rust-bin.stable.latest.minimal; xorg.libX11
in gtk4
f { inherit pkgs rustToolchain rustToolchainForBuild; } libadwaita
); xorg.libXtst
in ] ++ lib.optionals stdenv.isDarwin [
{ darwin.apple_sdk_11_0.frameworks.CoreGraphics
packages = forEachPkgs ( ];
{ pkgs, rustToolchainForBuild, ... }:
let RUST_SRC_PATH = "${rust}/lib/rustlib/src/rust/library";
customRustPlatform = pkgs.makeRustPlatform { };
cargo = rustToolchainForBuild; });
rustc = rustToolchainForBuild; };
};
lan-mouse = pkgs.callPackage ./nix { rustPlatform = customRustPlatform; };
in
{
default = lan-mouse;
inherit lan-mouse;
}
);
devShells = forEachPkgs (
{ pkgs, rustToolchain, ... }:
{
default = pkgs.mkShell {
packages =
with pkgs;
[
rustToolchain
pkg-config
gtk4
libadwaita
librsvg
]
++ lib.optionals pkgs.stdenv.isLinux [
libX11
libXtst
];
env.RUST_SRC_PATH = "${rustToolchain}/lib/rustlib/src/rust/library";
};
}
);
homeManagerModules.default = import ./nix/hm-module.nix self;
};
} }

View File

@@ -1,62 +1,37 @@
[package] [package]
name = "input-capture" name = "input-capture"
description = "cross-platform input-capture library used by lan-mouse" description = "cross-platform input-capture library used by lan-mouse"
version = "0.3.0" version = "0.1.0"
edition = "2021" edition = "2021"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"
repository = "https://github.com/feschber/lan-mouse" repository = "https://github.com/ferdinandschober/lan-mouse"
[dependencies] [dependencies]
anyhow = "1.0.86"
futures = "0.3.28" futures = "0.3.28"
futures-core = "0.3.30" futures-core = "0.3.30"
log = "0.4.22" log = "0.4.22"
input-event = { path = "../input-event", version = "0.3.0" } input-event = { path = "../input-event", version = "0.1.0" }
memmap = "0.7" memmap = "0.7"
tempfile = "3.25.0" tempfile = "3.8"
thiserror = "2.0.0" thiserror = "1.0.61"
tokio = { version = "1.32.0", features = [ tokio = { version = "1.32.0", features = ["io-util", "io-std", "macros", "net", "process", "rt", "sync", "signal"] }
"io-util",
"io-std",
"macros",
"net",
"process",
"rt",
"sync",
"signal",
"time",
] }
once_cell = "1.19.0" once_cell = "1.19.0"
async-trait = "0.1.81"
tokio-util = "0.7.11"
[target.'cfg(all(unix, not(target_os="macos")))'.dependencies] [target.'cfg(all(unix, not(target_os="macos")))'.dependencies]
wayland-client = { version = "0.31.1", optional = true } wayland-client = { version="0.31.1", optional = true }
wayland-protocols = { version = "0.32.1", features = [ wayland-protocols = { version="0.32.1", features=["client", "staging", "unstable"], optional = true }
"client", wayland-protocols-wlr = { version="0.3.1", features=["client"], optional = true }
"staging",
"unstable",
], optional = true }
wayland-protocols-wlr = { version = "0.3.1", features = [
"client",
], optional = true }
x11 = { version = "2.21.0", features = ["xlib", "xtest"], optional = true } x11 = { version = "2.21.0", features = ["xlib", "xtest"], optional = true }
ashpd = { version = "0.13.9", default-features = false, features = [ ashpd = { version = "0.8", default-features = false, features = ["tokio"], optional = true }
"input_capture", reis = { version = "0.2", features = [ "tokio" ], optional = true }
"tokio",
], optional = true }
reis = { version = "0.5.0", features = ["tokio"], optional = true }
[target.'cfg(target_os="macos")'.dependencies] [target.'cfg(target_os="macos")'.dependencies]
core-graphics = { version = "0.25.0", features = ["highsierra"] } core-graphics = { version = "0.23", features = ["highsierra"] }
core-foundation = "0.10.0"
core-foundation-sys = "0.8.6"
libc = "0.2.155"
keycode = "1.0.0"
bitflags = "2.6.0"
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
windows = { version = "0.61.2", features = [ windows = { version = "0.57.0", features = [
"Win32_System_LibraryLoader", "Win32_System_LibraryLoader",
"Win32_System_Threading", "Win32_System_Threading",
"Win32_Foundation", "Win32_Foundation",
@@ -67,11 +42,7 @@ windows = { version = "0.61.2", features = [
] } ] }
[features] [features]
default = ["layer_shell", "x11", "libei"] default = ["wayland", "x11", "libei"]
layer_shell = [ wayland = ["dep:wayland-client", "dep:wayland-protocols", "dep:wayland-protocols-wlr" ]
"dep:wayland-client",
"dep:wayland-protocols",
"dep:wayland-protocols-wlr",
]
x11 = ["dep:x11"] x11 = ["dep:x11"]
libei = ["dep:reis", "dep:ashpd"] libei = ["dep:reis", "dep:ashpd"]

View File

@@ -1,25 +0,0 @@
fn main() {
let unix = cfg!(unix);
let layer_shell = cfg!(feature = "layer_shell");
let libei = cfg!(feature = "libei");
let x11 = cfg!(feature = "x11");
let macos = cfg!(target_os = "macos");
let libei = unix && !macos && libei;
let layer_shell = unix && !macos && layer_shell;
let x11 = unix && !macos && x11;
println!("cargo::rustc-check-cfg=cfg(layer_shell)");
println!("cargo::rustc-check-cfg=cfg(libei)");
println!("cargo::rustc-check-cfg=cfg(x11)");
if layer_shell {
println!("cargo::rustc-cfg=layer_shell");
}
if libei {
println!("cargo::rustc-cfg=libei");
}
if x11 {
println!("cargo::rustc-cfg=x11");
}
}

View File

@@ -1,28 +1,18 @@
use std::f64::consts::PI; use std::io;
use std::pin::Pin; use std::pin::Pin;
use std::task::{Context, Poll, ready}; use std::task::{Context, Poll};
use std::time::Duration;
use async_trait::async_trait;
use futures_core::Stream; use futures_core::Stream;
use input_event::PointerEvent;
use tokio::time::{self, Instant, Interval};
use super::{Capture, CaptureError, CaptureEvent, Position}; use input_event::Event;
pub struct DummyInputCapture { use super::{CaptureHandle, InputCapture, Position};
start: Option<Instant>,
interval: Interval, pub struct DummyInputCapture {}
offset: (i32, i32),
}
impl DummyInputCapture { impl DummyInputCapture {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {}
start: None,
interval: time::interval(Duration::from_millis(1)),
offset: (0, 0),
}
} }
} }
@@ -32,55 +22,24 @@ impl Default for DummyInputCapture {
} }
} }
#[async_trait] impl InputCapture for DummyInputCapture {
impl Capture for DummyInputCapture { fn create(&mut self, _handle: CaptureHandle, _pos: Position) -> io::Result<()> {
async fn create(&mut self, _pos: Position) -> Result<(), CaptureError> {
Ok(()) Ok(())
} }
async fn destroy(&mut self, _pos: Position) -> Result<(), CaptureError> { fn destroy(&mut self, _handle: CaptureHandle) -> io::Result<()> {
Ok(()) Ok(())
} }
async fn release(&mut self) -> Result<(), CaptureError> { fn release(&mut self) -> io::Result<()> {
Ok(())
}
async fn terminate(&mut self) -> Result<(), CaptureError> {
Ok(()) Ok(())
} }
} }
const FREQUENCY_HZ: f64 = 1.0;
const RADIUS: f64 = 100.0;
impl Stream for DummyInputCapture { impl Stream for DummyInputCapture {
type Item = Result<(Position, CaptureEvent), CaptureError>; type Item = io::Result<(CaptureHandle, Event)>;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> { fn poll_next(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
let current = ready!(self.interval.poll_tick(cx)); Poll::Pending
let event = match self.start {
None => {
self.start.replace(current);
CaptureEvent::Begin
}
Some(start) => {
let elapsed = start.elapsed();
let elapsed_sec_f64 = elapsed.as_secs_f64();
let second_fraction = elapsed_sec_f64 - elapsed_sec_f64 as u64 as f64;
let radians = second_fraction * 2. * PI * FREQUENCY_HZ;
let offset = (radians.cos() * RADIUS * 2., (radians * 2.).sin() * RADIUS);
let offset = (offset.0 as i32, offset.1 as i32);
let relative_motion = (offset.0 - self.offset.0, offset.1 - self.offset.1);
self.offset = offset;
let (dx, dy) = (relative_motion.0 as f64, relative_motion.1 as f64);
CaptureEvent::Input(input_event::Event::Pointer(PointerEvent::Motion {
time: 0,
dx,
dy,
}))
}
};
Poll::Ready(Some(Ok((Position::Left, event))))
} }
} }

View File

@@ -1,161 +1,142 @@
use std::fmt::Display;
use thiserror::Error; use thiserror::Error;
#[derive(Debug, Error)] #[cfg(all(unix, feature = "wayland", not(target_os = "macos")))]
pub enum InputCaptureError {
#[error("error creating input-capture: `{0}`")]
Create(#[from] CaptureCreationError),
#[error("error while capturing input: `{0}`")]
Capture(#[from] CaptureError),
}
#[cfg(layer_shell)]
use std::io; use std::io;
#[cfg(layer_shell)] #[cfg(all(unix, feature = "wayland", not(target_os = "macos")))]
use wayland_client::{ use wayland_client::{
ConnectError, DispatchError,
backend::WaylandError, backend::WaylandError,
globals::{BindError, GlobalError}, globals::{BindError, GlobalError},
ConnectError, DispatchError,
}; };
#[cfg(libei)]
use ashpd::desktop::ResponseError;
#[cfg(target_os = "macos")]
use core_graphics::base::CGError;
#[derive(Debug, Error)]
pub enum CaptureError {
#[error("activation stream closed unexpectedly")]
ActivationClosed,
#[error("libei stream was closed")]
EndOfStream,
#[error("io error: `{0}`")]
Io(#[from] std::io::Error),
#[cfg(libei)]
#[error("libei error: `{0}`")]
Reis(#[from] reis::Error),
#[cfg(libei)]
#[error(transparent)]
Portal(#[from] ashpd::Error),
#[cfg(libei)]
#[error("libei disconnected - reason: `{0}`")]
Disconnected(String),
#[cfg(target_os = "macos")]
#[error("failed to warp mouse cursor: `{0}`")]
WarpCursor(CGError),
#[cfg(target_os = "macos")]
#[error("reset_mouse_position called without a connected client")]
ResetMouseWithoutClient,
#[cfg(target_os = "macos")]
#[error("core-graphics error: {0}")]
CoreGraphics(CGError),
#[cfg(target_os = "macos")]
#[error("unable to map key event: {0}")]
KeyMapError(i64),
#[cfg(target_os = "macos")]
#[error("Event tap disabled")]
EventTapDisabled,
}
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum CaptureCreationError { pub enum CaptureCreationError {
#[error("no backend available")]
NoAvailableBackend, NoAvailableBackend,
#[cfg(libei)] #[cfg(all(unix, feature = "libei", not(target_os = "macos")))]
#[error("error creating input-capture-portal backend: `{0}`")]
Libei(#[from] LibeiCaptureCreationError), Libei(#[from] LibeiCaptureCreationError),
#[cfg(layer_shell)] #[cfg(all(unix, feature = "wayland", not(target_os = "macos")))]
#[error("error creating layer-shell capture backend: `{0}`")]
LayerShell(#[from] LayerShellCaptureCreationError), LayerShell(#[from] LayerShellCaptureCreationError),
#[cfg(x11)] #[cfg(all(unix, feature = "x11", not(target_os = "macos")))]
#[error("error creating x11 capture backend: `{0}`")]
X11(#[from] X11InputCaptureCreationError), X11(#[from] X11InputCaptureCreationError),
#[cfg(windows)]
#[error("error creating windows capture backend")]
Windows,
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
#[error("error creating macos capture backend: `{0}`")] Macos(#[from] MacOSInputCaptureCreationError),
MacOS(#[from] MacosCaptureCreationError), #[cfg(windows)]
Windows,
} }
impl CaptureCreationError { impl Display for CaptureCreationError {
/// request was intentionally denied by the user fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
#[cfg(libei)] let reason = match self {
pub(crate) fn cancelled_by_user(&self) -> bool { #[cfg(all(unix, feature = "libei", not(target_os = "macos")))]
matches!( CaptureCreationError::Libei(reason) => {
self, format!("error creating portal backend: {reason}")
CaptureCreationError::Libei(LibeiCaptureCreationError::Ashpd(ashpd::Error::Response( }
ResponseError::Cancelled #[cfg(all(unix, feature = "wayland", not(target_os = "macos")))]
))) CaptureCreationError::LayerShell(reason) => {
) format!("error creating layer-shell backend: {reason}")
} }
#[cfg(not(libei))] #[cfg(all(unix, feature = "x11", not(target_os = "macos")))]
pub(crate) fn cancelled_by_user(&self) -> bool { CaptureCreationError::X11(e) => format!("{e}"),
false #[cfg(target_os = "macos")]
CaptureCreationError::Macos(e) => format!("{e}"),
#[cfg(windows)]
CaptureCreationError::Windows => String::new(),
CaptureCreationError::NoAvailableBackend => "no available backend".to_string(),
};
write!(f, "could not create input capture: {reason}")
} }
} }
#[cfg(libei)] #[cfg(all(unix, feature = "libei", not(target_os = "macos")))]
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum LibeiCaptureCreationError { pub enum LibeiCaptureCreationError {
#[error("xdg-desktop-portal: `{0}`")]
Ashpd(#[from] ashpd::Error), Ashpd(#[from] ashpd::Error),
} }
#[cfg(layer_shell)] #[cfg(all(unix, feature = "libei", not(target_os = "macos")))]
impl Display for LibeiCaptureCreationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
LibeiCaptureCreationError::Ashpd(portal_error) => write!(f, "{portal_error}"),
}
}
}
#[cfg(all(unix, feature = "wayland", not(target_os = "macos")))]
#[derive(Debug, Error)] #[derive(Debug, Error)]
#[error("{protocol} protocol not supported: {inner}")]
pub struct WaylandBindError { pub struct WaylandBindError {
inner: BindError, inner: BindError,
protocol: &'static str, protocol: &'static str,
} }
#[cfg(all(unix, feature = "wayland", not(target_os = "macos")))]
#[cfg(layer_shell)]
impl WaylandBindError { impl WaylandBindError {
pub(crate) fn new(inner: BindError, protocol: &'static str) -> Self { pub(crate) fn new(inner: BindError, protocol: &'static str) -> Self {
Self { inner, protocol } Self { inner, protocol }
} }
} }
#[cfg(layer_shell)] #[cfg(all(unix, feature = "wayland", not(target_os = "macos")))]
impl Display for WaylandBindError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{} protocol not supported: {}",
self.protocol, self.inner
)
}
}
#[cfg(all(unix, feature = "wayland", not(target_os = "macos")))]
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum LayerShellCaptureCreationError { pub enum LayerShellCaptureCreationError {
#[error(transparent)]
Connect(#[from] ConnectError), Connect(#[from] ConnectError),
#[error(transparent)]
Global(#[from] GlobalError), Global(#[from] GlobalError),
#[error(transparent)]
Wayland(#[from] WaylandError), Wayland(#[from] WaylandError),
#[error(transparent)]
Bind(#[from] WaylandBindError), Bind(#[from] WaylandBindError),
#[error(transparent)]
Dispatch(#[from] DispatchError), Dispatch(#[from] DispatchError),
#[error(transparent)]
Io(#[from] io::Error), Io(#[from] io::Error),
} }
#[cfg(x11)] #[cfg(all(unix, feature = "wayland", not(target_os = "macos")))]
impl Display for LayerShellCaptureCreationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
LayerShellCaptureCreationError::Bind(e) => write!(f, "{e}"),
LayerShellCaptureCreationError::Connect(e) => {
write!(f, "could not connect to wayland compositor: {e}")
}
LayerShellCaptureCreationError::Global(e) => write!(f, "wayland error: {e}"),
LayerShellCaptureCreationError::Wayland(e) => write!(f, "wayland error: {e}"),
LayerShellCaptureCreationError::Dispatch(e) => {
write!(f, "error dispatching wayland events: {e}")
}
LayerShellCaptureCreationError::Io(e) => write!(f, "io error: {e}"),
}
}
}
#[cfg(all(unix, feature = "x11", not(target_os = "macos")))]
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum X11InputCaptureCreationError { pub enum X11InputCaptureCreationError {
#[error("X11 input capture is not yet implemented :(")] NotImplemented,
}
#[cfg(all(unix, feature = "x11", not(target_os = "macos")))]
impl Display for X11InputCaptureCreationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "X11 input capture is not yet implemented :(")
}
}
#[cfg(target_os = "macos")]
#[derive(Debug, Error)]
pub enum MacOSInputCaptureCreationError {
NotImplemented, NotImplemented,
} }
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
#[derive(Debug, Error)] impl Display for MacOSInputCaptureCreationError {
pub enum MacosCaptureCreationError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
#[error("event source creation failed!")] write!(f, "macos input capture is not yet implemented :(")
EventSourceCreation, }
#[cfg(target_os = "macos")]
#[error("event tap creation failed")]
EventTapCreation,
#[error("accessibility permission is required")]
AccessibilityPermission,
#[error("input monitoring permission is required")]
InputMonitoringPermission,
#[error("failed to set CG Cursor property")]
CGCursorProperty,
#[cfg(target_os = "macos")]
#[error("failed to get display ids: {0}")]
ActiveDisplays(CGError),
} }

View File

@@ -1,57 +1,33 @@
use std::{ use std::{fmt::Display, io};
collections::{HashMap, HashSet, VecDeque},
fmt::Display,
mem::swap,
task::{Poll, ready},
};
use async_trait::async_trait;
use futures::StreamExt;
use futures_core::Stream; use futures_core::Stream;
use input_event::{Event, KeyboardEvent, scancode}; use input_event::Event;
pub use error::{CaptureCreationError, CaptureError, InputCaptureError}; use self::error::CaptureCreationError;
pub mod error; pub mod error;
#[cfg(libei)] #[cfg(all(unix, feature = "libei", not(target_os = "macos")))]
mod libei; pub mod libei;
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
mod macos; pub mod macos;
#[cfg(layer_shell)] #[cfg(all(unix, feature = "wayland", not(target_os = "macos")))]
mod layer_shell; pub mod wayland;
#[cfg(windows)] #[cfg(windows)]
mod windows; pub mod windows;
#[cfg(x11)] #[cfg(all(unix, feature = "x11", not(target_os = "macos")))]
mod x11; pub mod x11;
/// fallback input capture (does not produce events) /// fallback input capture (does not produce events)
mod dummy; pub mod dummy;
pub type CaptureHandle = u64; pub type CaptureHandle = u64;
#[derive(Copy, Clone, Debug, PartialEq)]
pub enum CaptureEvent {
/// capture on this capture handle is now active
Begin,
/// input event coming from capture handle
Input(Event),
}
impl Display for CaptureEvent {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CaptureEvent::Begin => write!(f, "begin capture"),
CaptureEvent::Input(e) => write!(f, "{e}"),
}
}
}
#[derive(Debug, Clone, Copy, Eq, Hash, PartialEq)] #[derive(Debug, Clone, Copy, Eq, Hash, PartialEq)]
pub enum Position { pub enum Position {
Left, Left,
@@ -79,17 +55,17 @@ impl Display for Position {
Position::Top => "top", Position::Top => "top",
Position::Bottom => "bottom", Position::Bottom => "bottom",
}; };
write!(f, "{pos}") write!(f, "{}", pos)
} }
} }
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Backend { pub enum Backend {
#[cfg(libei)] #[cfg(all(unix, feature = "libei", not(target_os = "macos")))]
InputCapturePortal, InputCapturePortal,
#[cfg(layer_shell)] #[cfg(all(unix, feature = "wayland", not(target_os = "macos")))]
LayerShell, LayerShell,
#[cfg(x11)] #[cfg(all(unix, feature = "x11", not(target_os = "macos")))]
X11, X11,
#[cfg(windows)] #[cfg(windows)]
Windows, Windows,
@@ -101,11 +77,11 @@ pub enum Backend {
impl Display for Backend { impl Display for Backend {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { match self {
#[cfg(libei)] #[cfg(all(unix, feature = "libei", not(target_os = "macos")))]
Backend::InputCapturePortal => write!(f, "input-capture-portal"), Backend::InputCapturePortal => write!(f, "input-capture-portal"),
#[cfg(layer_shell)] #[cfg(all(unix, feature = "wayland", not(target_os = "macos")))]
Backend::LayerShell => write!(f, "layer-shell"), Backend::LayerShell => write!(f, "layer-shell"),
#[cfg(x11)] #[cfg(all(unix, feature = "x11", not(target_os = "macos")))]
Backend::X11 => write!(f, "X11"), Backend::X11 => write!(f, "X11"),
#[cfg(windows)] #[cfg(windows)]
Backend::Windows => write!(f, "windows"), Backend::Windows => write!(f, "windows"),
@@ -116,208 +92,40 @@ impl Display for Backend {
} }
} }
pub struct InputCapture { pub trait InputCapture: Stream<Item = io::Result<(CaptureHandle, Event)>> + Unpin {
/// capture backend
capture: Box<dyn Capture>,
/// keys pressed by active capture
pressed_keys: HashSet<scancode::Linux>,
/// map from position to ids
position_map: HashMap<Position, Vec<CaptureHandle>>,
/// map from id to position
id_map: HashMap<CaptureHandle, Position>,
/// pending events
pending: VecDeque<(CaptureHandle, CaptureEvent)>,
}
impl InputCapture {
/// create a new client with the given id /// create a new client with the given id
pub async fn create(&mut self, id: CaptureHandle, pos: Position) -> Result<(), CaptureError> { fn create(&mut self, id: CaptureHandle, pos: Position) -> io::Result<()>;
assert!(!self.id_map.contains_key(&id));
self.id_map.insert(id, pos);
if let Some(v) = self.position_map.get_mut(&pos) {
v.push(id);
Ok(())
} else {
self.position_map.insert(pos, vec![id]);
self.capture.create(pos).await
}
}
/// destroy the client with the given id, if it exists /// destroy the client with the given id, if it exists
pub async fn destroy(&mut self, id: CaptureHandle) -> Result<(), CaptureError> { fn destroy(&mut self, id: CaptureHandle) -> io::Result<()>;
let pos = self
.id_map
.remove(&id)
.expect("no position for this handle");
log::debug!("destroying capture {id} @ {pos}");
let remaining = self.position_map.get_mut(&pos).expect("id vector");
remaining.retain(|&i| i != id);
log::debug!("remaining ids @ {pos}: {remaining:?}");
if remaining.is_empty() {
log::debug!("destroying capture @ {pos} - no remaining ids");
self.position_map.remove(&pos);
self.capture.destroy(pos).await?;
}
Ok(())
}
/// release mouse /// release mouse
pub async fn release(&mut self) -> Result<(), CaptureError> { fn release(&mut self) -> io::Result<()>;
self.pressed_keys.clear();
self.capture.release().await
}
/// Drain and return every key the capture has forwarded as
/// down-but-not-up. The caller is expected to synthesize key-up
/// events to the remote peer for each — otherwise the peer
/// retains phantom-held keys after capture is released. The
/// canonical case is the release-bind chord
/// (Ctrl+Shift+Alt+Meta): the down events were sent while
/// capture was active, but the matching up events arrive after
/// the local tap has flipped to passthrough and never reach
/// the peer.
pub fn take_pressed_keys(&mut self) -> HashSet<scancode::Linux> {
std::mem::take(&mut self.pressed_keys)
}
/// destroy the input capture
pub async fn terminate(&mut self) -> Result<(), CaptureError> {
self.capture.terminate().await
}
/// creates a new [`InputCapture`]
pub async fn new(backend: Option<Backend>) -> Result<Self, CaptureCreationError> {
let capture = create(backend).await?;
Ok(Self {
capture,
id_map: Default::default(),
pending: Default::default(),
position_map: Default::default(),
pressed_keys: HashSet::new(),
})
}
/// check whether the given keys are pressed
pub fn keys_pressed(&self, keys: &[scancode::Linux]) -> bool {
keys.iter().all(|k| self.pressed_keys.contains(k))
}
fn update_pressed_keys(&mut self, key: u32, state: u8) {
if let Ok(scancode) = scancode::Linux::try_from(key) {
log::debug!("key: {key}, state: {state}, scancode: {scancode:?}");
match state {
1 => self.pressed_keys.insert(scancode),
_ => self.pressed_keys.remove(&scancode),
};
}
}
} }
impl Stream for InputCapture { pub async fn create_backend(
type Item = Result<(CaptureHandle, CaptureEvent), CaptureError>;
fn poll_next(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> Poll<Option<Self::Item>> {
if let Some(e) = self.pending.pop_front() {
return Poll::Ready(Some(Ok(e)));
}
// ready
let event = ready!(self.capture.poll_next_unpin(cx));
// stream closed
let event = match event {
Some(e) => e,
None => return Poll::Ready(None),
};
// error occurred
let (pos, event) = match event {
Ok(e) => e,
Err(e) => return Poll::Ready(Some(Err(e))),
};
// handle key presses
if let CaptureEvent::Input(Event::Keyboard(KeyboardEvent::Key { key, state, .. })) = event {
self.update_pressed_keys(key, state);
}
let len = self
.position_map
.get(&pos)
.map(|ids| ids.len())
.unwrap_or(0);
match len {
0 => Poll::Pending,
1 => Poll::Ready(Some(Ok((
self.position_map.get(&pos).expect("no id")[0],
event,
)))),
_ => {
let mut position_map = HashMap::new();
swap(&mut self.position_map, &mut position_map);
{
for &id in position_map.get(&pos).expect("position") {
self.pending.push_back((id, event));
}
}
swap(&mut self.position_map, &mut position_map);
Poll::Ready(Some(Ok(self.pending.pop_front().expect("event"))))
}
}
}
}
#[async_trait]
trait Capture: Stream<Item = Result<(Position, CaptureEvent), CaptureError>> + Unpin {
/// create a new client with the given id
async fn create(&mut self, pos: Position) -> Result<(), CaptureError>;
/// destroy the client with the given id, if it exists
async fn destroy(&mut self, pos: Position) -> Result<(), CaptureError>;
/// release mouse
async fn release(&mut self) -> Result<(), CaptureError>;
/// destroy the input capture
async fn terminate(&mut self) -> Result<(), CaptureError>;
}
async fn create_backend(
backend: Backend, backend: Backend,
) -> Result< ) -> Result<Box<dyn InputCapture<Item = io::Result<(CaptureHandle, Event)>>>, CaptureCreationError>
Box<dyn Capture<Item = Result<(Position, CaptureEvent), CaptureError>>>, {
CaptureCreationError,
> {
match backend { match backend {
#[cfg(libei)] #[cfg(all(unix, feature = "libei", not(target_os = "macos")))]
Backend::InputCapturePortal => Ok(Box::new(libei::LibeiInputCapture::new().await?)), Backend::InputCapturePortal => Ok(Box::new(libei::LibeiInputCapture::new().await?)),
#[cfg(layer_shell)] #[cfg(all(unix, feature = "wayland", not(target_os = "macos")))]
Backend::LayerShell => Ok(Box::new(layer_shell::LayerShellInputCapture::new()?)), Backend::LayerShell => Ok(Box::new(wayland::WaylandInputCapture::new()?)),
#[cfg(x11)] #[cfg(all(unix, feature = "x11", not(target_os = "macos")))]
Backend::X11 => Ok(Box::new(x11::X11InputCapture::new()?)), Backend::X11 => Ok(Box::new(x11::X11InputCapture::new()?)),
#[cfg(windows)] #[cfg(windows)]
Backend::Windows => Ok(Box::new(windows::WindowsInputCapture::new())), Backend::Windows => Ok(Box::new(windows::WindowsInputCapture::new())),
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
Backend::MacOs => Ok(Box::new(macos::MacOSInputCapture::new().await?)), Backend::MacOs => Ok(Box::new(macos::MacOSInputCapture::new()?)),
Backend::Dummy => Ok(Box::new(dummy::DummyInputCapture::new())), Backend::Dummy => Ok(Box::new(dummy::DummyInputCapture::new())),
} }
} }
async fn create( pub async fn create(
backend: Option<Backend>, backend: Option<Backend>,
) -> Result< ) -> Result<Box<dyn InputCapture<Item = io::Result<(CaptureHandle, Event)>>>, CaptureCreationError>
Box<dyn Capture<Item = Result<(Position, CaptureEvent), CaptureError>>>, {
CaptureCreationError,
> {
if let Some(backend) = backend { if let Some(backend) = backend {
let b = create_backend(backend).await; let b = create_backend(backend).await;
if b.is_ok() { if b.is_ok() {
@@ -327,23 +135,23 @@ async fn create(
} }
for backend in [ for backend in [
#[cfg(libei)] #[cfg(all(unix, feature = "libei", not(target_os = "macos")))]
Backend::InputCapturePortal, Backend::InputCapturePortal,
#[cfg(layer_shell)] #[cfg(all(unix, feature = "wayland", not(target_os = "macos")))]
Backend::LayerShell, Backend::LayerShell,
#[cfg(x11)] #[cfg(all(unix, feature = "x11", not(target_os = "macos")))]
Backend::X11, Backend::X11,
#[cfg(windows)] #[cfg(windows)]
Backend::Windows, Backend::Windows,
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
Backend::MacOs, Backend::MacOs,
Backend::Dummy,
] { ] {
match create_backend(backend).await { match create_backend(backend).await {
Ok(b) => { Ok(b) => {
log::info!("using capture backend: {backend}"); log::info!("using capture backend: {backend}");
return Ok(b); return Ok(b);
} }
Err(e) if e.cancelled_by_user() => return Err(e),
Err(e) => log::warn!("{backend} input capture backend unavailable: {e}"), Err(e) => log::warn!("{backend} input capture backend unavailable: {e}"),
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,888 +1,35 @@
use super::{Capture, CaptureError, CaptureEvent, Position, error::MacosCaptureCreationError}; use crate::{error::MacOSInputCaptureCreationError, CaptureHandle, InputCapture, Position};
use async_trait::async_trait;
use bitflags::bitflags;
use core_foundation::{
base::{CFRelease, TCFType, kCFAllocatorDefault},
date::CFTimeInterval,
number::{CFBooleanRef, kCFBooleanTrue},
runloop::{CFRunLoop, CFRunLoopSource, kCFRunLoopCommonModes},
string::{CFStringCreateWithCString, CFStringRef, kCFStringEncodingUTF8},
};
use core_graphics::{
base::{CGError, kCGErrorSuccess},
display::{CGDisplay, CGPoint},
event::{
CGEvent, CGEventFlags, CGEventTap, CGEventTapLocation, CGEventTapOptions,
CGEventTapPlacement, CGEventTapProxy, CGEventType, CallbackResult, EventField,
},
event_source::{CGEventSource, CGEventSourceStateID},
};
use futures_core::Stream; use futures_core::Stream;
use input_event::{ use input_event::Event;
BTN_BACK, BTN_FORWARD, BTN_LEFT, BTN_MIDDLE, BTN_RIGHT, Event, KeyboardEvent, PointerEvent, use std::task::{Context, Poll};
}; use std::{io, pin::Pin};
use keycode::{KeyMap, KeyMapping};
use libc::c_void;
use once_cell::unsync::Lazy;
use std::{
collections::HashSet,
ffi::{CString, c_char},
pin::Pin,
sync::{Arc, OnceLock},
task::{Context, Poll, ready},
thread::{self},
};
use tokio::sync::{
Mutex,
mpsc::{self, Receiver, Sender},
oneshot,
};
#[derive(Debug, Default)] pub struct MacOSInputCapture;
struct Bounds {
xmin: f64,
xmax: f64,
ymin: f64,
ymax: f64,
}
#[derive(Debug)]
struct InputCaptureState {
/// active capture positions
active_clients: Lazy<HashSet<Position>>,
/// the currently entered capture position, if any
current_pos: Option<Position>,
/// position where the cursor was captured
enter_position: Option<CGPoint>,
/// bounds of the input capture area
bounds: Bounds,
/// current state of modifier keys
modifier_state: XMods,
}
#[derive(Debug)]
enum ProducerEvent {
Release,
Create(Position),
Destroy(Position),
Grab(Position),
EventTapDisabled,
DisplayReconfigured,
}
impl InputCaptureState {
fn new() -> Result<Self, MacosCaptureCreationError> {
let mut res = Self {
active_clients: Lazy::new(HashSet::new),
current_pos: None,
enter_position: None,
bounds: Bounds::default(),
modifier_state: Default::default(),
};
res.update_bounds()?;
Ok(res)
}
fn crossed(&mut self, event: &CGEvent) -> Option<Position> {
let location = event.location();
let relative_x = event.get_double_value_field(EventField::MOUSE_EVENT_DELTA_X);
let relative_y = event.get_double_value_field(EventField::MOUSE_EVENT_DELTA_Y);
for &position in self.active_clients.iter() {
if (position == Position::Left && (location.x + relative_x) <= self.bounds.xmin)
|| (position == Position::Right && (location.x + relative_x) >= self.bounds.xmax)
|| (position == Position::Top && (location.y + relative_y) <= self.bounds.ymin)
|| (position == Position::Bottom && (location.y + relative_y) >= self.bounds.ymax)
{
log::debug!("Crossed barrier into position: {position:?}");
return Some(position);
}
}
None
}
// Get the max bounds of all displays
fn update_bounds(&mut self) -> Result<(), MacosCaptureCreationError> {
let active_ids =
CGDisplay::active_displays().map_err(MacosCaptureCreationError::ActiveDisplays)?;
active_ids.iter().for_each(|d| {
let bounds = CGDisplay::new(*d).bounds();
self.bounds.xmin = self.bounds.xmin.min(bounds.origin.x);
self.bounds.xmax = self.bounds.xmax.max(bounds.origin.x + bounds.size.width);
self.bounds.ymin = self.bounds.ymin.min(bounds.origin.y);
self.bounds.ymax = self.bounds.ymax.max(bounds.origin.y + bounds.size.height);
});
log::debug!("Updated displays bounds: {0:?}", self.bounds);
Ok(())
}
/// start the input capture by
fn start_capture(&mut self, event: &CGEvent, position: Position) -> Result<(), CaptureError> {
let mut location = event.location();
let edge_offset = 1.0;
// move cursor location to display bounds
match position {
Position::Left => location.x = self.bounds.xmin + edge_offset,
Position::Right => location.x = self.bounds.xmax - edge_offset,
Position::Top => location.y = self.bounds.ymin + edge_offset,
Position::Bottom => location.y = self.bounds.ymax - edge_offset,
};
self.enter_position = Some(location);
self.reset_cursor()
}
/// resets the cursor to the position, where the capture started
fn reset_cursor(&mut self) -> Result<(), CaptureError> {
let pos = self.enter_position.expect("capture active");
log::trace!("Resetting cursor position to: {}, {}", pos.x, pos.y);
CGDisplay::warp_mouse_cursor_position(pos).map_err(CaptureError::WarpCursor)
}
fn hide_cursor(&self) -> Result<(), CaptureError> {
CGDisplay::hide_cursor(&CGDisplay::main()).map_err(CaptureError::CoreGraphics)
}
fn show_cursor(&self) -> Result<(), CaptureError> {
CGDisplay::show_cursor(&CGDisplay::main()).map_err(CaptureError::CoreGraphics)
}
async fn handle_producer_event(
&mut self,
producer_event: ProducerEvent,
) -> Result<(), CaptureError> {
log::debug!("handling event: {producer_event:?}");
match producer_event {
ProducerEvent::Release => {
if self.current_pos.is_some() {
self.show_cursor()?;
self.current_pos = None;
}
}
ProducerEvent::Grab(pos) => {
if self.current_pos.is_none() {
self.hide_cursor()?;
self.current_pos = Some(pos);
}
}
ProducerEvent::Create(p) => {
self.active_clients.insert(p);
}
ProducerEvent::Destroy(p) => {
if let Some(current) = self.current_pos {
if current == p {
self.show_cursor()?;
self.current_pos = None;
};
}
self.active_clients.remove(&p);
}
ProducerEvent::EventTapDisabled => {
// Tap death can happen mid-capture (TCC Accessibility
// revoked, tap-timeout, etc). Release state so we
// don't leave the cursor hidden even if the outer
// task only logs this error rather than propagating.
if self.current_pos.is_some() {
self.show_cursor()?;
self.current_pos = None;
}
return Err(CaptureError::EventTapDisabled);
}
ProducerEvent::DisplayReconfigured => {
// The macOS display configuration changed — a monitor
// was plugged in/out, the resolution changed, the
// arrangement was rearranged, etc. Re-fetch the
// active-display bounds so barrier crossings and the
// cursor-warp on capture-start use the current
// geometry instead of whatever was true at process
// start.
if let Err(e) = self.update_bounds() {
log::warn!("failed to refresh display bounds: {e}");
} else {
log::info!("display reconfigured: {:?}", self.bounds);
}
}
};
Ok(())
}
}
fn get_events(
ev_type: &CGEventType,
ev: &CGEvent,
result: &mut Vec<CaptureEvent>,
modifier_state: &mut XMods,
) -> Result<(), CaptureError> {
fn map_pointer_event(ev: &CGEvent) -> PointerEvent {
PointerEvent::Motion {
time: 0,
dx: ev.get_double_value_field(EventField::MOUSE_EVENT_DELTA_X),
dy: ev.get_double_value_field(EventField::MOUSE_EVENT_DELTA_Y),
}
}
fn map_key(ev: &CGEvent) -> Result<u32, CaptureError> {
let code = ev.get_integer_value_field(EventField::KEYBOARD_EVENT_KEYCODE);
match KeyMap::from_key_mapping(KeyMapping::Mac(code as u16)) {
Ok(k) => Ok(k.evdev as u32),
Err(()) => Err(CaptureError::KeyMapError(code)),
}
}
match ev_type {
CGEventType::KeyDown => {
let k = map_key(ev)?;
result.push(CaptureEvent::Input(Event::Keyboard(KeyboardEvent::Key {
time: 0,
key: k,
state: 1,
})));
}
CGEventType::KeyUp => {
let k = map_key(ev)?;
result.push(CaptureEvent::Input(Event::Keyboard(KeyboardEvent::Key {
time: 0,
key: k,
state: 0,
})));
}
CGEventType::FlagsChanged => {
let mut depressed = XMods::empty();
let mut mods_locked = XMods::empty();
let cg_flags = ev.get_flags();
if cg_flags.contains(CGEventFlags::CGEventFlagShift) {
depressed |= XMods::ShiftMask;
}
if cg_flags.contains(CGEventFlags::CGEventFlagControl) {
depressed |= XMods::ControlMask;
}
if cg_flags.contains(CGEventFlags::CGEventFlagAlternate) {
depressed |= XMods::Mod1Mask;
}
if cg_flags.contains(CGEventFlags::CGEventFlagCommand) {
depressed |= XMods::Mod4Mask;
}
if cg_flags.contains(CGEventFlags::CGEventFlagAlphaShift) {
depressed |= XMods::LockMask;
mods_locked |= XMods::LockMask;
}
// check if pressed or released
let state = if depressed > *modifier_state { 1 } else { 0 };
*modifier_state = depressed;
if let Ok(key) = map_key(ev) {
let key_event = CaptureEvent::Input(Event::Keyboard(KeyboardEvent::Key {
time: 0,
key,
state,
}));
result.push(key_event);
}
let modifier_event = KeyboardEvent::Modifiers {
depressed: depressed.bits(),
latched: 0,
locked: mods_locked.bits(),
group: 0,
};
result.push(CaptureEvent::Input(Event::Keyboard(modifier_event)));
}
CGEventType::MouseMoved => {
result.push(CaptureEvent::Input(Event::Pointer(map_pointer_event(ev))))
}
CGEventType::LeftMouseDragged => {
result.push(CaptureEvent::Input(Event::Pointer(map_pointer_event(ev))))
}
CGEventType::RightMouseDragged => {
result.push(CaptureEvent::Input(Event::Pointer(map_pointer_event(ev))))
}
CGEventType::OtherMouseDragged => {
result.push(CaptureEvent::Input(Event::Pointer(map_pointer_event(ev))))
}
CGEventType::LeftMouseDown => {
result.push(CaptureEvent::Input(Event::Pointer(PointerEvent::Button {
time: 0,
button: BTN_LEFT,
state: 1,
})))
}
CGEventType::LeftMouseUp => {
result.push(CaptureEvent::Input(Event::Pointer(PointerEvent::Button {
time: 0,
button: BTN_LEFT,
state: 0,
})))
}
CGEventType::RightMouseDown => {
result.push(CaptureEvent::Input(Event::Pointer(PointerEvent::Button {
time: 0,
button: BTN_RIGHT,
state: 1,
})))
}
CGEventType::RightMouseUp => {
result.push(CaptureEvent::Input(Event::Pointer(PointerEvent::Button {
time: 0,
button: BTN_RIGHT,
state: 0,
})))
}
CGEventType::OtherMouseDown => {
let btn_num = ev.get_integer_value_field(EventField::MOUSE_EVENT_BUTTON_NUMBER);
let button = match btn_num {
3 => BTN_BACK,
4 => BTN_FORWARD,
_ => BTN_MIDDLE,
};
result.push(CaptureEvent::Input(Event::Pointer(PointerEvent::Button {
time: 0,
button,
state: 1,
})))
}
CGEventType::OtherMouseUp => {
let btn_num = ev.get_integer_value_field(EventField::MOUSE_EVENT_BUTTON_NUMBER);
let button = match btn_num {
3 => BTN_BACK,
4 => BTN_FORWARD,
_ => BTN_MIDDLE,
};
result.push(CaptureEvent::Input(Event::Pointer(PointerEvent::Button {
time: 0,
button,
state: 0,
})))
}
CGEventType::ScrollWheel => {
if ev.get_integer_value_field(EventField::SCROLL_WHEEL_EVENT_IS_CONTINUOUS) != 0 {
let v =
ev.get_integer_value_field(EventField::SCROLL_WHEEL_EVENT_POINT_DELTA_AXIS_1);
let h =
ev.get_integer_value_field(EventField::SCROLL_WHEEL_EVENT_POINT_DELTA_AXIS_2);
if v != 0 {
result.push(CaptureEvent::Input(Event::Pointer(PointerEvent::Axis {
time: 0,
axis: 0, // Vertical
value: v as f64,
})));
}
if h != 0 {
result.push(CaptureEvent::Input(Event::Pointer(PointerEvent::Axis {
time: 0,
axis: 1, // Horizontal
value: h as f64,
})));
}
} else {
// line based scrolling
const LINES_PER_STEP: i32 = 3;
const V120_STEPS_PER_LINE: i32 = 120 / LINES_PER_STEP;
let v = ev.get_integer_value_field(EventField::SCROLL_WHEEL_EVENT_DELTA_AXIS_1);
let h = ev.get_integer_value_field(EventField::SCROLL_WHEEL_EVENT_DELTA_AXIS_2);
if v != 0 {
result.push(CaptureEvent::Input(Event::Pointer(
PointerEvent::AxisDiscrete120 {
axis: 0, // Vertical
value: V120_STEPS_PER_LINE * v as i32,
},
)));
}
if h != 0 {
result.push(CaptureEvent::Input(Event::Pointer(
PointerEvent::AxisDiscrete120 {
axis: 1, // Horizontal
value: V120_STEPS_PER_LINE * h as i32,
},
)));
}
}
}
_ => (),
}
Ok(())
}
fn create_event_tap<'a>(
client_state: Arc<Mutex<InputCaptureState>>,
notify_tx: Sender<ProducerEvent>,
event_tx: Sender<(Position, CaptureEvent)>,
) -> Result<CGEventTap<'a>, MacosCaptureCreationError> {
// Shared slot for the tap's mach port pointer. Stored as `usize`
// because raw pointers aren't `Send`, but the integer
// representation is — and CGEventTapEnable is documented as
// thread-safe. Set immediately after CGEventTap::new returns;
// read by the callback to recover from a TapDisabledByTimeout.
let tap_mach_port: Arc<OnceLock<usize>> = Arc::new(OnceLock::new());
let tap_mach_port_cb = Arc::clone(&tap_mach_port);
let cg_events_of_interest: Vec<CGEventType> = vec![
CGEventType::LeftMouseDown,
CGEventType::LeftMouseUp,
CGEventType::RightMouseDown,
CGEventType::RightMouseUp,
CGEventType::OtherMouseDown,
CGEventType::OtherMouseUp,
CGEventType::MouseMoved,
CGEventType::LeftMouseDragged,
CGEventType::RightMouseDragged,
CGEventType::OtherMouseDragged,
CGEventType::ScrollWheel,
CGEventType::KeyDown,
CGEventType::KeyUp,
CGEventType::FlagsChanged,
];
let event_tap_callback = move |_proxy: CGEventTapProxy,
event_type: CGEventType,
cg_ev: &CGEvent| {
log::trace!("Got event from tap: {event_type:?}");
let mut state = client_state.blocking_lock();
let mut capture_position = None;
let mut res_events = vec![];
if matches!(event_type, CGEventType::TapDisabledByTimeout) {
// The kernel disables the tap when our callback runs
// longer than ~1s on a single event — typical causes
// are heavy load, scheduler contention, or this
// process being briefly suspended (e.g. App Nap on a
// long idle). It is NOT a fatal condition: Apple's
// documented recovery is to call CGEventTapEnable
// and resume processing. Re-enable in place and KEEP
// existing capture state so the user doesn't see the
// cursor pop back to the local screen mid-session.
if let Some(&port) = tap_mach_port_cb.get() {
log::warn!("CGEventTap disabled by timeout — re-enabling");
unsafe {
CGEventTapEnable(port as *mut c_void, true);
}
} else {
log::error!(
"CGEventTap disabled by timeout, but mach port not yet stored — cannot re-enable"
);
}
return CallbackResult::Keep;
}
if matches!(event_type, CGEventType::TapDisabledByUserInput) {
// Deliberate kill — secure-input mode (e.g. password
// field), TCC Accessibility revoked mid-session, or
// the user disabling event-monitoring. We can't
// recover from this; drop captured state synchronously
// and return Keep on this event. Otherwise the
// `current_pos.is_some()` branch below would drop this
// event (and any racing callback still in flight) back
// into `CallbackResult::Drop`, silently eating the
// user's clicks and keypresses while the tap winds
// down. Clear state + show the cursor here, then
// notify the producer loop so the service can tear
// down cleanly.
log::error!("CGEventTap disabled by user input, releasing capture state");
if state.current_pos.is_some() {
let _ = CGDisplay::show_cursor(&CGDisplay::main());
state.current_pos = None;
}
notify_tx
.blocking_send(ProducerEvent::EventTapDisabled)
.unwrap_or_else(|e| {
log::error!("Failed to send notification: {e}");
});
return CallbackResult::Keep;
}
// Are we in a client?
if let Some(current_pos) = state.current_pos {
capture_position = Some(current_pos);
get_events(
&event_type,
cg_ev,
&mut res_events,
&mut state.modifier_state,
)
.unwrap_or_else(|e| {
log::error!("Failed to get events: {e}");
});
// Keep (hidden) cursor at the edge of the screen
if matches!(
event_type,
CGEventType::MouseMoved
| CGEventType::LeftMouseDragged
| CGEventType::RightMouseDragged
| CGEventType::OtherMouseDragged
) {
state.reset_cursor().unwrap_or_else(|e| log::warn!("{e}"));
}
} else if matches!(event_type, CGEventType::MouseMoved) {
// Did we cross a barrier?
if let Some(new_pos) = state.crossed(cg_ev) {
capture_position = Some(new_pos);
state
.start_capture(cg_ev, new_pos)
.unwrap_or_else(|e| log::warn!("{e}"));
res_events.push(CaptureEvent::Begin);
notify_tx
.blocking_send(ProducerEvent::Grab(new_pos))
.expect("Failed to send notification");
}
}
if let Some(pos) = capture_position {
res_events.iter().for_each(|e| {
// error must be ignored, since the event channel
// may already be closed when the InputCapture instance is dropped.
let _ = event_tx.blocking_send((pos, *e));
});
// Returning Drop should stop the event from being processed
// but core fundation still returns the event
cg_ev.set_type(CGEventType::Null);
CallbackResult::Drop
} else {
CallbackResult::Keep
}
};
let tap = CGEventTap::new(
CGEventTapLocation::Session,
CGEventTapPlacement::HeadInsertEventTap,
CGEventTapOptions::Default,
cg_events_of_interest,
event_tap_callback,
)
.map_err(|_| MacosCaptureCreationError::EventTapCreation)?;
// Hand the mach port pointer to the callback so it can re-enable
// the tap on TapDisabledByTimeout. The pointer is valid for the
// lifetime of `tap` (which lives on the event-tap thread until
// the run loop exits).
let port_ptr = tap.mach_port().as_concrete_TypeRef() as usize;
let _ = tap_mach_port.set(port_ptr);
let tap_source: CFRunLoopSource = tap
.mach_port()
.create_runloop_source(0)
.expect("Failed creating loop source");
unsafe {
CFRunLoop::get_current().add_source(&tap_source, kCFRunLoopCommonModes);
}
Ok(tap)
}
fn event_tap_thread(
client_state: Arc<Mutex<InputCaptureState>>,
event_tx: Sender<(Position, CaptureEvent)>,
notify_tx: Sender<ProducerEvent>,
ready: std::sync::mpsc::Sender<Result<CFRunLoop, MacosCaptureCreationError>>,
exit: oneshot::Sender<()>,
) {
// Clone now: create_event_tap consumes notify_tx into its closure.
let display_notify_tx = notify_tx.clone();
let _tap = match create_event_tap(client_state, notify_tx, event_tx) {
Err(e) => {
ready.send(Err(e)).expect("channel closed");
return;
}
Ok(tap) => {
let run_loop = CFRunLoop::get_current();
ready.send(Ok(run_loop)).expect("channel closed");
tap
}
};
// Register a Quartz display-reconfiguration callback so the
// capture state's bounds get refreshed when the user plugs in a
// monitor, changes resolution, or rearranges displays. The
// callback runs on this thread's CFRunLoop. Box-leak the sender
// so the C side has a stable user_info pointer; reclaim it after
// the run loop exits.
let display_user_info = Box::into_raw(Box::new(display_notify_tx)) as *mut c_void;
unsafe {
CGDisplayRegisterReconfigurationCallback(
display_reconfiguration_callback,
display_user_info,
);
}
log::debug!("running CFRunLoop...");
CFRunLoop::run_current();
log::debug!("event tap thread exiting!...");
unsafe {
CGDisplayRemoveReconfigurationCallback(display_reconfiguration_callback, display_user_info);
// Reclaim the leaked sender Box so we don't leak a tokio
// channel sender on every capture create/destroy cycle.
drop(Box::from_raw(
display_user_info as *mut Sender<ProducerEvent>,
));
}
let _ = exit.send(());
}
/// Quartz display-reconfiguration callback. Fires twice per change:
/// once with `kCGDisplayBeginConfigurationFlag` set (BEFORE the
/// change is applied — the bounds are still stale at this point),
/// then again afterwards with the actual change flags (Add, Remove,
/// Mode, DesktopShapeChanged, etc.). Skip the begin phase; on the
/// real notification, kick the producer task to refresh bounds.
extern "C" fn display_reconfiguration_callback(_display: u32, flags: u32, user_info: *mut c_void) {
const K_CG_DISPLAY_BEGIN_CONFIGURATION_FLAG: u32 = 1 << 0;
if flags & K_CG_DISPLAY_BEGIN_CONFIGURATION_FLAG != 0 {
return;
}
if user_info.is_null() {
return;
}
// SAFETY: user_info is a Box::into_raw of Sender<ProducerEvent>
// owned by `event_tap_thread`. It's valid for the lifetime of
// that thread; the registration is removed before the box is
// freed. The callback only fires while the run loop is running
// on that thread, so we know the box is live here.
let sender = unsafe { &*(user_info as *const Sender<ProducerEvent>) };
if let Err(e) = sender.blocking_send(ProducerEvent::DisplayReconfigured) {
log::warn!("failed to notify display reconfiguration: {e}");
}
}
pub struct MacOSInputCapture {
event_rx: Receiver<(Position, CaptureEvent)>,
notify_tx: Sender<ProducerEvent>,
run_loop: CFRunLoop,
}
impl MacOSInputCapture { impl MacOSInputCapture {
pub async fn new() -> Result<Self, MacosCaptureCreationError> { pub fn new() -> std::result::Result<Self, MacOSInputCaptureCreationError> {
request_macos_capture_permissions()?; Err(MacOSInputCaptureCreationError::NotImplemented)
let state = Arc::new(Mutex::new(InputCaptureState::new()?));
let (event_tx, event_rx) = mpsc::channel(32);
let (notify_tx, mut notify_rx) = mpsc::channel(32);
let (ready_tx, ready_rx) = std::sync::mpsc::channel();
let (tap_exit_tx, mut tap_exit_rx) = oneshot::channel();
unsafe {
configure_cf_settings()?;
}
log::info!("Enabling CGEvent tap");
let event_tap_thread_state = state.clone();
let event_tap_notify = notify_tx.clone();
thread::spawn(move || {
event_tap_thread(
event_tap_thread_state,
event_tx,
event_tap_notify,
ready_tx,
tap_exit_tx,
)
});
// wait for event tap creation result
let run_loop = ready_rx.recv().expect("channel closed")?;
let _tap_task: tokio::task::JoinHandle<()> = tokio::task::spawn_local(async move {
loop {
tokio::select! {
producer_event = notify_rx.recv() => {
let Some(producer_event) = producer_event else {
break;
};
let mut state = state.lock().await;
state.handle_producer_event(producer_event).await.unwrap_or_else(|e| {
log::error!("Failed to handle producer event: {e}");
})
}
_ = &mut tap_exit_rx => break,
}
}
// show cursor
let _ = CGDisplay::show_cursor(&CGDisplay::main());
});
Ok(Self {
event_rx,
notify_tx,
run_loop,
})
}
}
fn request_macos_capture_permissions() -> Result<(), MacosCaptureCreationError> {
// Call both request functions unconditionally so macOS surfaces both
// TCC prompts on the very first launch. TCC always returns `false` the
// first time a permission is requested (the grant only becomes visible
// on the next process launch), so returning early on the first failure
// would skip the second prompt and force the user through an extra
// relaunch just to see it.
let accessibility = request_accessibility_permission();
let input_monitoring = request_input_monitoring_permission();
if !accessibility {
return Err(MacosCaptureCreationError::AccessibilityPermission);
}
if !input_monitoring {
return Err(MacosCaptureCreationError::InputMonitoringPermission);
}
Ok(())
}
fn request_accessibility_permission() -> bool {
// Silent check. The GUI owns the one-time user-visible prompt at
// startup (see lan_mouse_gtk::macos_privacy) so retries triggered by
// clicking the "Reenable" button don't pop a fresh Accessibility
// alert every time.
unsafe { AXIsProcessTrusted() }
}
fn request_input_monitoring_permission() -> bool {
// Silent check, same reasoning as above.
unsafe { CGPreflightListenEventAccess() }
}
impl Drop for MacOSInputCapture {
fn drop(&mut self) {
self.run_loop.stop();
}
}
#[async_trait]
impl Capture for MacOSInputCapture {
async fn create(&mut self, pos: Position) -> Result<(), CaptureError> {
let notify_tx = self.notify_tx.clone();
tokio::task::spawn_local(async move {
log::debug!("creating capture, {pos}");
let _ = notify_tx.send(ProducerEvent::Create(pos)).await;
log::debug!("done !");
});
Ok(())
}
async fn destroy(&mut self, pos: Position) -> Result<(), CaptureError> {
let notify_tx = self.notify_tx.clone();
tokio::task::spawn_local(async move {
log::debug!("destroying capture {pos}");
let _ = notify_tx.send(ProducerEvent::Destroy(pos)).await;
log::debug!("done !");
});
Ok(())
}
async fn release(&mut self) -> Result<(), CaptureError> {
let notify_tx = self.notify_tx.clone();
tokio::task::spawn_local(async move {
log::debug!("notifying Release");
let _ = notify_tx.send(ProducerEvent::Release).await;
});
Ok(())
}
async fn terminate(&mut self) -> Result<(), CaptureError> {
Ok(())
} }
} }
impl Stream for MacOSInputCapture { impl Stream for MacOSInputCapture {
type Item = Result<(Position, CaptureEvent), CaptureError>; type Item = io::Result<(CaptureHandle, Event)>;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> { fn poll_next(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
match ready!(self.event_rx.poll_recv(cx)) { Poll::Pending
None => Poll::Ready(None),
Some(e) => Poll::Ready(Some(Ok(e))),
}
} }
} }
type CGSConnectionID = u32; impl InputCapture for MacOSInputCapture {
fn create(&mut self, _id: CaptureHandle, _pos: Position) -> io::Result<()> {
#[link(name = "ApplicationServices", kind = "framework")] Ok(())
extern "C" {
fn CGSSetConnectionProperty(
cid: CGSConnectionID,
targetCID: CGSConnectionID,
key: CFStringRef,
value: CFBooleanRef,
) -> CGError;
fn _CGSDefaultConnection() -> CGSConnectionID;
}
extern "C" {
fn CGEventSourceSetLocalEventsSuppressionInterval(
event_source: CGEventSource,
seconds: CFTimeInterval,
);
fn CGPreflightListenEventAccess() -> bool;
/// Re-enable an event tap that was disabled by a
/// `kCGEventTapDisabledByTimeout` event. The Apple-documented
/// recovery path: see Quartz Event Services Reference. The `tap`
/// argument is a `CFMachPortRef`; we pass the raw pointer so we
/// can store it as `usize` for cross-thread sharing.
fn CGEventTapEnable(tap: *mut c_void, enable: bool);
/// Register a callback invoked when the display configuration
/// changes (monitor add/remove, resolution change, mirror,
/// rearrange, etc). See Quartz Display Services Reference.
fn CGDisplayRegisterReconfigurationCallback(
callback: extern "C" fn(u32, u32, *mut c_void),
user_info: *mut c_void,
) -> CGError;
fn CGDisplayRemoveReconfigurationCallback(
callback: extern "C" fn(u32, u32, *mut c_void),
user_info: *mut c_void,
) -> CGError;
}
#[link(name = "ApplicationServices", kind = "framework")]
extern "C" {
fn AXIsProcessTrusted() -> bool;
}
unsafe fn configure_cf_settings() -> Result<(), MacosCaptureCreationError> {
// When we warp the cursor using CGWarpMouseCursorPosition local events are suppressed for a short time
// this leeds to the cursor not flowing when crossing back from a clinet, set this to to 0 stops the warp
// from working, set a low value by trial and error, 0.05s seems good. 0.25s is the default
let event_source = CGEventSource::new(CGEventSourceStateID::CombinedSessionState)
.map_err(|_| MacosCaptureCreationError::EventSourceCreation)?;
CGEventSourceSetLocalEventsSuppressionInterval(event_source, 0.05);
// FIXME Memory Leak
// This is a private settings that allows the cursor to be hidden while in the background.
// It is used by Barrier and other apps.
let key = CString::new("SetsCursorInBackground").unwrap();
let cf_key = CFStringCreateWithCString(
kCFAllocatorDefault,
key.as_ptr() as *const c_char,
kCFStringEncodingUTF8,
);
if CGSSetConnectionProperty(
_CGSDefaultConnection(),
_CGSDefaultConnection(),
cf_key,
kCFBooleanTrue,
) != kCGErrorSuccess
{
return Err(MacosCaptureCreationError::CGCursorProperty);
} }
CFRelease(cf_key as *const c_void);
Ok(())
}
// From X11/X.h fn destroy(&mut self, _id: CaptureHandle) -> io::Result<()> {
bitflags! { Ok(())
#[repr(C)] }
#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
struct XMods: u32 { fn release(&mut self) -> io::Result<()> {
const ShiftMask = (1<<0); Ok(())
const LockMask = (1<<1);
const ControlMask = (1<<2);
const Mod1Mask = (1<<3);
const Mod2Mask = (1<<4);
const Mod3Mask = (1<<5);
const Mod4Mask = (1<<6);
const Mod5Mask = (1<<7);
} }
} }

View File

@@ -1,21 +1,20 @@
use async_trait::async_trait;
use futures_core::Stream; use futures_core::Stream;
use memmap::MmapOptions;
use std::{ use std::{
collections::{HashSet, VecDeque}, collections::VecDeque,
env, env,
fmt::{self, Display},
io::{self, ErrorKind}, io::{self, ErrorKind},
os::fd::{AsFd, RawFd}, os::fd::{AsFd, OwnedFd, RawFd},
pin::Pin, pin::Pin,
task::{Context, Poll, ready}, task::{ready, Context, Poll},
}; };
use tokio::io::unix::AsyncFd; use tokio::io::unix::AsyncFd;
use std::{ use std::{
fs::File, fs::File,
io::{BufWriter, Write}, io::{BufWriter, Write},
os::unix::prelude::AsRawFd, os::unix::prelude::{AsRawFd, FromRawFd},
sync::Arc, rc::Rc,
}; };
use wayland_protocols::{ use wayland_protocols::{
@@ -45,89 +44,72 @@ use wayland_protocols_wlr::layer_shell::v1::client::{
}; };
use wayland_client::{ use wayland_client::{
Connection, Dispatch, DispatchError, EventQueue, QueueHandle, WEnum,
backend::{ReadEventsGuard, WaylandError}, backend::{ReadEventsGuard, WaylandError},
delegate_noop, delegate_noop,
globals::{Global, GlobalList, GlobalListContents, registry_queue_init}, globals::{registry_queue_init, GlobalListContents},
protocol::{ protocol::{
wl_buffer, wl_compositor, wl_buffer, wl_compositor,
wl_keyboard::{self, WlKeyboard}, wl_keyboard::{self, WlKeyboard},
wl_output::{self, WlOutput}, wl_output::{self, WlOutput},
wl_pointer::{self, WlPointer}, wl_pointer::{self, WlPointer},
wl_region, wl_region, wl_registry, wl_seat, wl_shm, wl_shm_pool,
wl_registry::{self, WlRegistry},
wl_seat, wl_shm, wl_shm_pool,
wl_surface::WlSurface, wl_surface::WlSurface,
}, },
Connection, Dispatch, DispatchError, EventQueue, QueueHandle, WEnum,
}; };
use tempfile;
use input_event::{Event, KeyboardEvent, PointerEvent}; use input_event::{Event, KeyboardEvent, PointerEvent};
use crate::{CaptureError, CaptureEvent};
use super::{ use super::{
Capture, Position,
error::{LayerShellCaptureCreationError, WaylandBindError}, error::{LayerShellCaptureCreationError, WaylandBindError},
CaptureHandle, InputCapture, Position,
}; };
struct Globals { struct Globals {
compositor: wl_compositor::WlCompositor, compositor: wl_compositor::WlCompositor,
pointer_constraints: ZwpPointerConstraintsV1, pointer_constraints: ZwpPointerConstraintsV1,
relative_pointer_manager: ZwpRelativePointerManagerV1, relative_pointer_manager: ZwpRelativePointerManagerV1,
shortcut_inhibit_manager: Option<ZwpKeyboardShortcutsInhibitManagerV1>, shortcut_inhibit_manager: ZwpKeyboardShortcutsInhibitManagerV1,
seat: wl_seat::WlSeat, seat: wl_seat::WlSeat,
shm: wl_shm::WlShm, shm: wl_shm::WlShm,
layer_shell: ZwlrLayerShellV1, layer_shell: ZwlrLayerShellV1,
outputs: Vec<WlOutput>,
xdg_output_manager: ZxdgOutputManagerV1, xdg_output_manager: ZxdgOutputManagerV1,
} }
#[derive(Clone, Debug)] #[derive(Debug, Clone)]
struct Output {
wl_output: WlOutput,
global: Global,
info: Option<OutputInfo>,
pending_info: OutputInfo,
has_xdg_info: bool,
}
impl Display for Output {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if let Some(info) = &self.info {
write!(
f,
"{} {}x{} @pos {:?} ({})",
info.name, info.size.0, info.size.1, info.position, info.description
)
} else {
write!(f, "unknown output")
}
}
}
#[derive(Clone, Debug, Default)]
struct OutputInfo { struct OutputInfo {
description: String,
name: String, name: String,
position: (i32, i32), position: (i32, i32),
size: (i32, i32), size: (i32, i32),
} }
impl OutputInfo {
fn new() -> Self {
Self {
name: "".to_string(),
position: (0, 0),
size: (0, 0),
}
}
}
struct State { struct State {
active_positions: HashSet<Position>,
pointer: Option<WlPointer>, pointer: Option<WlPointer>,
keyboard: Option<WlKeyboard>, keyboard: Option<WlKeyboard>,
pointer_lock: Option<ZwpLockedPointerV1>, pointer_lock: Option<ZwpLockedPointerV1>,
rel_pointer: Option<ZwpRelativePointerV1>, rel_pointer: Option<ZwpRelativePointerV1>,
shortcut_inhibitor: Option<ZwpKeyboardShortcutsInhibitorV1>, shortcut_inhibitor: Option<ZwpKeyboardShortcutsInhibitorV1>,
active_windows: Vec<Arc<Window>>, client_for_window: Vec<(Rc<Window>, CaptureHandle)>,
focused: Option<Arc<Window>>, focused: Option<(Rc<Window>, CaptureHandle)>,
global_list: GlobalList, g: Globals,
globals: Globals, wayland_fd: OwnedFd,
wayland_fd: RawFd,
read_guard: Option<ReadEventsGuard>, read_guard: Option<ReadEventsGuard>,
qh: QueueHandle<Self>, qh: QueueHandle<Self>,
pending_events: VecDeque<(Position, CaptureEvent)>, pending_events: VecDeque<(CaptureHandle, Event)>,
outputs: Vec<Output>, output_info: Vec<(WlOutput, OutputInfo)>,
scroll_discrete_pending: bool, scroll_discrete_pending: bool,
} }
@@ -138,11 +120,11 @@ struct Inner {
impl AsRawFd for Inner { impl AsRawFd for Inner {
fn as_raw_fd(&self) -> RawFd { fn as_raw_fd(&self) -> RawFd {
self.state.wayland_fd self.state.wayland_fd.as_raw_fd()
} }
} }
pub struct LayerShellInputCapture(AsyncFd<Inner>); pub struct WaylandInputCapture(AsyncFd<Inner>);
struct Window { struct Window {
buffer: wl_buffer::WlBuffer, buffer: wl_buffer::WlBuffer,
@@ -160,7 +142,7 @@ impl Window {
size: (i32, i32), size: (i32, i32),
) -> Window { ) -> Window {
log::debug!("creating window output: {output:?}, size: {size:?}"); log::debug!("creating window output: {output:?}, size: {size:?}");
let g = &state.globals; let g = &state.g;
let (width, height) = match pos { let (width, height) = match pos {
Position::Left | Position::Right => (1, size.1 as u32), Position::Left | Position::Right => (1, size.1 as u32),
@@ -221,36 +203,41 @@ impl Drop for Window {
} }
} }
fn get_edges(outputs: &[Output], pos: Position) -> Vec<(Output, i32)> { fn get_edges(outputs: &[(WlOutput, OutputInfo)], pos: Position) -> Vec<(WlOutput, i32)> {
outputs outputs
.iter() .iter()
.filter_map(|output| { .map(|(o, i)| {
output.info.as_ref().map(|info| { (
( o.clone(),
output.clone(), match pos {
match pos { Position::Left => i.position.0,
Position::Left => info.position.0, Position::Right => i.position.0 + i.size.0,
Position::Right => info.position.0 + info.size.0, Position::Top => i.position.1,
Position::Top => info.position.1, Position::Bottom => i.position.1 + i.size.1,
Position::Bottom => info.position.1 + info.size.1, },
}, )
)
})
}) })
.collect() .collect()
} }
fn get_output_configuration(state: &State, pos: Position) -> Vec<Output> { fn get_output_configuration(state: &State, pos: Position) -> Vec<(WlOutput, OutputInfo)> {
// get all output edges corresponding to the position // get all output edges corresponding to the position
let edges = get_edges(&state.outputs, pos); let edges = get_edges(&state.output_info, pos);
let opposite_edges = get_edges(&state.outputs, pos.opposite()); log::debug!("edges: {edges:?}");
let opposite_edges = get_edges(&state.output_info, pos.opposite());
// remove those edges that are at the same position // remove those edges that are at the same position
// as an opposite edge of a different output // as an opposite edge of a different output
edges let outputs: Vec<WlOutput> = edges
.iter() .iter()
.filter(|(_, edge)| !opposite_edges.iter().map(|(_, e)| *e).any(|e| &e == edge)) .filter(|(_, edge)| !opposite_edges.iter().map(|(_, e)| *e).any(|e| &e == edge))
.map(|(o, _)| o.clone()) .map(|(o, _)| o.clone())
.collect();
state
.output_info
.iter()
.filter(|(o, _)| outputs.contains(o))
.map(|(o, i)| (o.clone(), i.clone()))
.collect() .collect()
} }
@@ -269,83 +256,101 @@ fn draw(f: &mut File, (width, height): (u32, u32)) {
} }
} }
impl LayerShellInputCapture { impl WaylandInputCapture {
pub fn new() -> std::result::Result<Self, LayerShellCaptureCreationError> { pub fn new() -> std::result::Result<Self, LayerShellCaptureCreationError> {
let conn = Connection::connect_to_env()?; let conn = Connection::connect_to_env()?;
let (global_list, mut queue) = registry_queue_init::<State>(&conn)?; let (g, mut queue) = registry_queue_init::<State>(&conn)?;
let qh = queue.handle(); let qh = queue.handle();
let compositor: wl_compositor::WlCompositor = global_list let compositor: wl_compositor::WlCompositor = g
.bind(&qh, 4..=5, ()) .bind(&qh, 4..=5, ())
.map_err(|e| WaylandBindError::new(e, "wl_compositor 4..=5"))?; .map_err(|e| WaylandBindError::new(e, "wl_compositor 4..=5"))?;
let xdg_output_manager: ZxdgOutputManagerV1 = global_list let xdg_output_manager: ZxdgOutputManagerV1 = g
.bind(&qh, 1..=3, ()) .bind(&qh, 1..=3, ())
.map_err(|e| WaylandBindError::new(e, "xdg_output_manager 1..=3"))?; .map_err(|e| WaylandBindError::new(e, "xdg_output_manager 1..=3"))?;
let shm: wl_shm::WlShm = global_list let shm: wl_shm::WlShm = g
.bind(&qh, 1..=1, ()) .bind(&qh, 1..=1, ())
.map_err(|e| WaylandBindError::new(e, "wl_shm"))?; .map_err(|e| WaylandBindError::new(e, "wl_shm"))?;
let layer_shell: ZwlrLayerShellV1 = global_list let layer_shell: ZwlrLayerShellV1 = g
.bind(&qh, 3..=4, ()) .bind(&qh, 3..=4, ())
.map_err(|e| WaylandBindError::new(e, "wlr_layer_shell 3..=4"))?; .map_err(|e| WaylandBindError::new(e, "wlr_layer_shell 3..=4"))?;
let seat: wl_seat::WlSeat = global_list let seat: wl_seat::WlSeat = g
.bind(&qh, 7..=8, ()) .bind(&qh, 7..=8, ())
.map_err(|e| WaylandBindError::new(e, "wl_seat 7..=8"))?; .map_err(|e| WaylandBindError::new(e, "wl_seat 7..=8"))?;
let pointer_constraints: ZwpPointerConstraintsV1 = global_list let pointer_constraints: ZwpPointerConstraintsV1 = g
.bind(&qh, 1..=1, ()) .bind(&qh, 1..=1, ())
.map_err(|e| WaylandBindError::new(e, "zwp_pointer_constraints_v1"))?; .map_err(|e| WaylandBindError::new(e, "zwp_pointer_constraints_v1"))?;
let relative_pointer_manager: ZwpRelativePointerManagerV1 = global_list let relative_pointer_manager: ZwpRelativePointerManagerV1 = g
.bind(&qh, 1..=1, ()) .bind(&qh, 1..=1, ())
.map_err(|e| WaylandBindError::new(e, "zwp_relative_pointer_manager_v1"))?; .map_err(|e| WaylandBindError::new(e, "zwp_relative_pointer_manager_v1"))?;
let shortcut_inhibit_manager: Result< let shortcut_inhibit_manager: ZwpKeyboardShortcutsInhibitManagerV1 = g
ZwpKeyboardShortcutsInhibitManagerV1,
WaylandBindError,
> = global_list
.bind(&qh, 1..=1, ()) .bind(&qh, 1..=1, ())
.map_err(|e| WaylandBindError::new(e, "zwp_keyboard_shortcuts_inhibit_manager_v1")); .map_err(|e| WaylandBindError::new(e, "zwp_keyboard_shortcuts_inhibit_manager_v1"))?;
// layer-shell backend still works without this protocol so we make it an optional dependency let outputs = vec![];
if let Err(e) = &shortcut_inhibit_manager {
log::warn!("shortcut_inhibit_manager not supported: {e}\nkeybinds handled by the compositor will not be passed
to the client");
}
let shortcut_inhibit_manager = shortcut_inhibit_manager.ok();
let mut state = State { let g = Globals {
active_positions: Default::default(), compositor,
pointer: None, shm,
keyboard: None, layer_shell,
global_list, seat,
globals: Globals { pointer_constraints,
compositor, relative_pointer_manager,
shm, shortcut_inhibit_manager,
layer_shell, outputs,
seat, xdg_output_manager,
pointer_constraints,
relative_pointer_manager,
shortcut_inhibit_manager,
xdg_output_manager,
},
pointer_lock: None,
rel_pointer: None,
shortcut_inhibitor: None,
active_windows: Vec::new(),
focused: None,
qh,
wayland_fd: queue.as_fd().as_raw_fd(),
read_guard: None,
pending_events: VecDeque::new(),
outputs: vec![],
scroll_discrete_pending: false,
}; };
for global in state.global_list.contents().clone_list() {
state.register_global(global);
}
// flush outgoing events // flush outgoing events
queue.flush()?; queue.flush()?;
// prepare reading wayland events
let read_guard = queue.prepare_read().unwrap(); // there can not yet be events to dispatch
let wayland_fd = read_guard.connection_fd().try_clone_to_owned().unwrap();
std::mem::drop(read_guard);
let mut state = State {
pointer: None,
keyboard: None,
g,
pointer_lock: None,
rel_pointer: None,
shortcut_inhibitor: None,
client_for_window: Vec::new(),
focused: None,
qh,
wayland_fd,
read_guard: None,
pending_events: VecDeque::new(),
output_info: vec![],
scroll_discrete_pending: false,
};
// dispatch registry to () again, in order to read all wl_outputs
conn.display().get_registry(&state.qh, ());
log::debug!("==============> requested registry");
// roundtrip to read wl_output globals
queue.roundtrip(&mut state)?;
log::debug!("==============> roundtrip 1 done");
// read outputs
for output in state.g.outputs.iter() {
state
.g
.xdg_output_manager
.get_xdg_output(output, &state.qh, output.clone());
}
// roundtrip to read xdg_output events
queue.roundtrip(&mut state)?;
log::debug!("==============> roundtrip 2 done");
for i in &state.output_info {
log::debug!("{:#?}", i.1);
}
let read_guard = loop { let read_guard = loop {
match queue.prepare_read() { match queue.prepare_read() {
Some(r) => break r, Some(r) => break r,
@@ -359,71 +364,29 @@ impl LayerShellInputCapture {
let inner = AsyncFd::new(Inner { queue, state })?; let inner = AsyncFd::new(Inner { queue, state })?;
Ok(LayerShellInputCapture(inner)) Ok(WaylandInputCapture(inner))
} }
fn add_client(&mut self, pos: Position) { fn add_client(&mut self, handle: CaptureHandle, pos: Position) {
self.0.get_mut().state.add_client(pos); self.0.get_mut().state.add_client(handle, pos);
} }
fn delete_client(&mut self, pos: Position) { fn delete_client(&mut self, handle: CaptureHandle) {
let inner = self.0.get_mut(); let inner = self.0.get_mut();
inner.state.active_positions.remove(&pos);
// remove all windows corresponding to this client // remove all windows corresponding to this client
while let Some(i) = inner.state.active_windows.iter().position(|w| w.pos == pos) { while let Some(i) = inner
inner.state.active_windows.remove(i); .state
.client_for_window
.iter()
.position(|(_, c)| *c == handle)
{
inner.state.client_for_window.remove(i);
inner.state.focused = None; inner.state.focused = None;
} }
} }
} }
impl State { impl State {
fn update_output_info(&mut self, name: u32) {
let output = self
.outputs
.iter_mut()
.find(|o| o.global.name == name)
.expect("output not found");
if output.has_xdg_info {
output.info.replace(output.pending_info.clone());
self.update_windows();
}
}
fn register_global(&mut self, global: Global) {
if global.interface.as_str() == "wl_output" {
log::debug!("new output global: wl_output {}", global.name);
let wl_output = self.global_list.registry().bind::<WlOutput, _, _>(
global.name,
4,
&self.qh,
global.name,
);
self.globals
.xdg_output_manager
.get_xdg_output(&wl_output, &self.qh, global.name);
self.outputs.push(Output {
wl_output,
global,
info: None,
has_xdg_info: false,
pending_info: Default::default(),
})
}
}
fn deregister_global(&mut self, name: u32) {
self.outputs.retain(|o| {
if o.global.name == name {
log::debug!("{o} (global {:?}) removed", o.global);
o.wl_output.release();
false
} else {
true
}
});
}
fn grab( fn grab(
&mut self, &mut self,
surface: &WlSurface, surface: &WlSurface,
@@ -431,7 +394,7 @@ impl State {
serial: u32, serial: u32,
qh: &QueueHandle<State>, qh: &QueueHandle<State>,
) { ) {
let window = self.focused.as_ref().unwrap(); let (window, _) = self.focused.as_ref().unwrap();
// hide the cursor // hide the cursor
pointer.set_cursor(serial, None, 0, 0); pointer.set_cursor(serial, None, 0, 0);
@@ -444,7 +407,7 @@ impl State {
// lock pointer // lock pointer
if self.pointer_lock.is_none() { if self.pointer_lock.is_none() {
self.pointer_lock = Some(self.globals.pointer_constraints.lock_pointer( self.pointer_lock = Some(self.g.pointer_constraints.lock_pointer(
surface, surface,
pointer, pointer,
None, None,
@@ -456,7 +419,7 @@ impl State {
// request relative input // request relative input
if self.rel_pointer.is_none() { if self.rel_pointer.is_none() {
self.rel_pointer = Some(self.globals.relative_pointer_manager.get_relative_pointer( self.rel_pointer = Some(self.g.relative_pointer_manager.get_relative_pointer(
pointer, pointer,
qh, qh,
(), (),
@@ -464,21 +427,19 @@ impl State {
} }
// capture modifier keys // capture modifier keys
if let Some(shortcut_inhibit_manager) = &self.globals.shortcut_inhibit_manager { if self.shortcut_inhibitor.is_none() {
if self.shortcut_inhibitor.is_none() { self.shortcut_inhibitor = Some(self.g.shortcut_inhibit_manager.inhibit_shortcuts(
self.shortcut_inhibitor = Some(shortcut_inhibit_manager.inhibit_shortcuts( surface,
surface, &self.g.seat,
&self.globals.seat, qh,
qh, (),
(), ));
));
}
} }
} }
fn ungrab(&mut self) { fn ungrab(&mut self) {
// get focused client // get focused client
let window = match self.focused.as_ref() { let (window, _client) = match self.focused.as_ref() {
Some(focused) => focused, Some(focused) => focused,
None => return, None => return,
}; };
@@ -508,41 +469,27 @@ impl State {
} }
} }
fn add_client(&mut self, pos: Position) { fn add_client(&mut self, client: CaptureHandle, pos: Position) {
self.active_positions.insert(pos);
let outputs = get_output_configuration(self, pos); let outputs = get_output_configuration(self, pos);
log::info!( log::debug!("outputs: {outputs:?}");
"adding capture for position {pos} - using outputs: {:?}", outputs.iter().for_each(|(o, i)| {
outputs let window = Window::new(self, &self.qh, o, pos, i.size);
.iter() let window = Rc::new(window);
.map(|o| o self.client_for_window.push((window, client));
.info
.as_ref()
.map(|i| i.name.to_owned())
.unwrap_or("unknown output".to_owned()))
.collect::<Vec<_>>()
);
outputs.iter().for_each(|o| {
if let Some(info) = o.info.as_ref() {
let window = Window::new(self, &self.qh, &o.wl_output, pos, info.size);
let window = Arc::new(window);
self.active_windows.push(window);
}
}); });
} }
fn update_windows(&mut self) { fn update_windows(&mut self) {
log::info!("active outputs: "); log::debug!("updating windows");
for output in self.outputs.iter().filter(|o| o.info.is_some()) { log::debug!("output info: {:?}", self.output_info);
log::info!(" * {output}"); let clients: Vec<_> = self
} .client_for_window
.drain(..)
self.active_windows.clear(); .map(|(w, c)| (c, w.pos))
.collect();
let active_positions = self.active_positions.iter().cloned().collect::<Vec<_>>(); for (client, pos) in clients {
for pos in active_positions { self.add_client(client, pos);
self.add_client(pos);
} }
} }
} }
@@ -582,17 +529,17 @@ impl Inner {
match self.queue.dispatch_pending(&mut self.state) { match self.queue.dispatch_pending(&mut self.state) {
Ok(_) => {} Ok(_) => {}
Err(DispatchError::Backend(WaylandError::Io(e))) => { Err(DispatchError::Backend(WaylandError::Io(e))) => {
log::error!("Wayland Error: {e}"); log::error!("Wayland Error: {}", e);
} }
Err(DispatchError::Backend(e)) => { Err(DispatchError::Backend(e)) => {
panic!("backend error: {e}"); panic!("backend error: {}", e);
} }
Err(DispatchError::BadMessage { Err(DispatchError::BadMessage {
sender_id, sender_id,
interface, interface,
opcode, opcode,
}) => { }) => {
panic!("bad message {sender_id}, {interface} , {opcode}"); panic!("bad message {}, {} , {}", sender_id, interface, opcode);
} }
} }
} }
@@ -614,34 +561,28 @@ impl Inner {
} }
} }
#[async_trait] impl InputCapture for WaylandInputCapture {
impl Capture for LayerShellInputCapture { fn create(&mut self, handle: CaptureHandle, pos: Position) -> io::Result<()> {
async fn create(&mut self, pos: Position) -> Result<(), CaptureError> { self.add_client(handle, pos);
self.add_client(pos);
let inner = self.0.get_mut(); let inner = self.0.get_mut();
Ok(inner.flush_events()?) inner.flush_events()
}
fn destroy(&mut self, handle: CaptureHandle) -> io::Result<()> {
self.delete_client(handle);
let inner = self.0.get_mut();
inner.flush_events()
} }
async fn destroy(&mut self, pos: Position) -> Result<(), CaptureError> { fn release(&mut self) -> io::Result<()> {
self.delete_client(pos);
let inner = self.0.get_mut();
Ok(inner.flush_events()?)
}
async fn release(&mut self) -> Result<(), CaptureError> {
log::debug!("releasing pointer"); log::debug!("releasing pointer");
let inner = self.0.get_mut(); let inner = self.0.get_mut();
inner.state.ungrab(); inner.state.ungrab();
Ok(inner.flush_events()?) inner.flush_events()
}
async fn terminate(&mut self) -> Result<(), CaptureError> {
Ok(())
} }
} }
impl Stream for LayerShellInputCapture { impl Stream for WaylandInputCapture {
type Item = Result<(Position, CaptureEvent), CaptureError>; type Item = io::Result<(CaptureHandle, Event)>;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> { fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
if let Some(event) = self.0.get_mut().state.pending_events.pop_front() { if let Some(event) = self.0.get_mut().state.pending_events.pop_front() {
@@ -659,7 +600,7 @@ impl Stream for LayerShellInputCapture {
// prepare next read // prepare next read
match inner.prepare_read() { match inner.prepare_read() {
Ok(_) => {} Ok(_) => {}
Err(e) => return Poll::Ready(Some(Err(e.into()))), Err(e) => return Poll::Ready(Some(Err(e))),
} }
} }
@@ -669,14 +610,14 @@ impl Stream for LayerShellInputCapture {
// flush outgoing events // flush outgoing events
if let Err(e) = inner.flush_events() { if let Err(e) = inner.flush_events() {
if e.kind() != ErrorKind::WouldBlock { if e.kind() != ErrorKind::WouldBlock {
return Poll::Ready(Some(Err(e.into()))); return Poll::Ready(Some(Err(e)));
} }
} }
// prepare for the next read // prepare for the next read
match inner.prepare_read() { match inner.prepare_read() {
Ok(_) => {} Ok(_) => {}
Err(e) => return Poll::Ready(Some(Err(e.into()))), Err(e) => return Poll::Ready(Some(Err(e))),
} }
} }
@@ -706,16 +647,10 @@ impl Dispatch<wl_seat::WlSeat, ()> for State {
capabilities: WEnum::Value(capabilities), capabilities: WEnum::Value(capabilities),
} = event } = event
{ {
if capabilities.contains(wl_seat::Capability::Pointer) { if capabilities.contains(wl_seat::Capability::Pointer) && state.pointer.is_none() {
if let Some(p) = state.pointer.take() {
p.release();
}
state.pointer.replace(seat.get_pointer(qh, ())); state.pointer.replace(seat.get_pointer(qh, ()));
} }
if capabilities.contains(wl_seat::Capability::Keyboard) { if capabilities.contains(wl_seat::Capability::Keyboard) && state.keyboard.is_none() {
if let Some(k) = state.keyboard.take() {
k.release();
}
seat.get_keyboard(qh, ()); seat.get_keyboard(qh, ());
} }
} }
@@ -740,20 +675,23 @@ impl Dispatch<WlPointer, ()> for State {
} => { } => {
// get client corresponding to the focused surface // get client corresponding to the focused surface
{ {
if let Some(window) = app.active_windows.iter().find(|w| w.surface == surface) { if let Some((window, client)) = app
app.focused = Some(window.clone()); .client_for_window
.iter()
.find(|(w, _c)| w.surface == surface)
{
app.focused = Some((window.clone(), *client));
app.grab(&surface, pointer, serial, qh); app.grab(&surface, pointer, serial, qh);
} else { } else {
return; return;
} }
} }
let pos = app let (_, client) = app
.active_windows .client_for_window
.iter() .iter()
.find(|w| w.surface == surface) .find(|(w, _c)| w.surface == surface)
.map(|w| w.pos)
.unwrap(); .unwrap();
app.pending_events.push_back((pos, CaptureEvent::Begin)); app.pending_events.push_back((*client, Event::Enter()));
} }
wl_pointer::Event::Leave { .. } => { wl_pointer::Event::Leave { .. } => {
/* There are rare cases, where when a window is opened in /* There are rare cases, where when a window is opened in
@@ -774,18 +712,18 @@ impl Dispatch<WlPointer, ()> for State {
button, button,
state, state,
} => { } => {
let window = app.focused.as_ref().unwrap(); let (_, client) = app.focused.as_ref().unwrap();
app.pending_events.push_back(( app.pending_events.push_back((
window.pos, *client,
CaptureEvent::Input(Event::Pointer(PointerEvent::Button { Event::Pointer(PointerEvent::Button {
time, time,
button, button,
state: u32::from(state), state: u32::from(state),
})), }),
)); ));
} }
wl_pointer::Event::Axis { time, axis, value } => { wl_pointer::Event::Axis { time, axis, value } => {
let window = app.focused.as_ref().unwrap(); let (_, client) = app.focused.as_ref().unwrap();
if app.scroll_discrete_pending { if app.scroll_discrete_pending {
// each axisvalue120 event is coupled with // each axisvalue120 event is coupled with
// a corresponding axis event, which needs to // a corresponding axis event, which needs to
@@ -793,27 +731,27 @@ impl Dispatch<WlPointer, ()> for State {
app.scroll_discrete_pending = false; app.scroll_discrete_pending = false;
} else { } else {
app.pending_events.push_back(( app.pending_events.push_back((
window.pos, *client,
CaptureEvent::Input(Event::Pointer(PointerEvent::Axis { Event::Pointer(PointerEvent::Axis {
time, time,
axis: u32::from(axis) as u8, axis: u32::from(axis) as u8,
value, value,
})), }),
)); ));
} }
} }
wl_pointer::Event::AxisValue120 { axis, value120 } => { wl_pointer::Event::AxisValue120 { axis, value120 } => {
let window = app.focused.as_ref().unwrap(); let (_, client) = app.focused.as_ref().unwrap();
app.scroll_discrete_pending = true; app.scroll_discrete_pending = true;
app.pending_events.push_back(( app.pending_events.push_back((
window.pos, *client,
CaptureEvent::Input(Event::Pointer(PointerEvent::AxisDiscrete120 { Event::Pointer(PointerEvent::AxisDiscrete120 {
axis: u32::from(axis) as u8, axis: u32::from(axis) as u8,
value: value120, value: value120,
})), }),
)); ));
} }
wl_pointer::Event::Frame => { wl_pointer::Event::Frame {} => {
// TODO properly handle frame events // TODO properly handle frame events
// we simply insert a frame event on the client side // we simply insert a frame event on the client side
// after each event for now // after each event for now
@@ -832,7 +770,10 @@ impl Dispatch<WlKeyboard, ()> for State {
_: &Connection, _: &Connection,
_: &QueueHandle<Self>, _: &QueueHandle<Self>,
) { ) {
let window = &app.focused; let (_window, client) = match &app.focused {
Some(focused) => (Some(&focused.0), Some(&focused.1)),
None => (None, None),
};
match event { match event {
wl_keyboard::Event::Key { wl_keyboard::Event::Key {
serial: _, serial: _,
@@ -840,14 +781,14 @@ impl Dispatch<WlKeyboard, ()> for State {
key, key,
state, state,
} => { } => {
if let Some(window) = window { if let Some(client) = client {
app.pending_events.push_back(( app.pending_events.push_back((
window.pos, *client,
CaptureEvent::Input(Event::Keyboard(KeyboardEvent::Key { Event::Keyboard(KeyboardEvent::Key {
time, time,
key, key,
state: u32::from(state) as u8, state: u32::from(state) as u8,
})), }),
)); ));
} }
} }
@@ -858,18 +799,27 @@ impl Dispatch<WlKeyboard, ()> for State {
mods_locked, mods_locked,
group, group,
} => { } => {
if let Some(window) = window { if let Some(client) = client {
app.pending_events.push_back(( app.pending_events.push_back((
window.pos, *client,
CaptureEvent::Input(Event::Keyboard(KeyboardEvent::Modifiers { Event::Keyboard(KeyboardEvent::Modifiers {
depressed: mods_depressed, mods_depressed,
latched: mods_latched, mods_latched,
locked: mods_locked, mods_locked,
group, group,
})), }),
)); ));
} }
} }
wl_keyboard::Event::Keymap {
format: _,
fd,
size: _,
} => {
let fd = unsafe { &File::from_raw_fd(fd.as_raw_fd()) };
let _mmap = unsafe { MmapOptions::new().map_copy(fd).unwrap() };
// TODO keymap
}
_ => (), _ => (),
} }
} }
@@ -887,16 +837,21 @@ impl Dispatch<ZwpRelativePointerV1, ()> for State {
if let zwp_relative_pointer_v1::Event::RelativeMotion { if let zwp_relative_pointer_v1::Event::RelativeMotion {
utime_hi, utime_hi,
utime_lo, utime_lo,
dx_unaccel: dx, dx: _,
dy_unaccel: dy, dy: _,
.. dx_unaccel: surface_x,
dy_unaccel: surface_y,
} = event } = event
{ {
if let Some(window) = &app.focused { if let Some((_window, client)) = &app.focused {
let time = ((((utime_hi as u64) << 32) | utime_lo as u64) / 1000) as u32; let time = (((utime_hi as u64) << 32 | utime_lo as u64) / 1000) as u32;
app.pending_events.push_back(( app.pending_events.push_back((
window.pos, *client,
CaptureEvent::Input(Event::Pointer(PointerEvent::Motion { time, dx, dy })), Event::Pointer(PointerEvent::Motion {
time,
relative_x: surface_x,
relative_y: surface_y,
}),
)); ));
} }
} }
@@ -913,10 +868,10 @@ impl Dispatch<ZwlrLayerSurfaceV1, ()> for State {
_: &QueueHandle<Self>, _: &QueueHandle<Self>,
) { ) {
if let zwlr_layer_surface_v1::Event::Configure { serial, .. } = event { if let zwlr_layer_surface_v1::Event::Configure { serial, .. } = event {
if let Some(window) = app if let Some((window, _client)) = app
.active_windows .client_for_window
.iter() .iter()
.find(|w| &w.layer_surface == layer_surface) .find(|(w, _c)| &w.layer_surface == layer_surface)
{ {
// client corresponding to the layer_surface // client corresponding to the layer_surface
let surface = &window.surface; let surface = &window.surface;
@@ -930,89 +885,94 @@ impl Dispatch<ZwlrLayerSurfaceV1, ()> for State {
} }
// delegate wl_registry events to App itself // delegate wl_registry events to App itself
impl Dispatch<WlRegistry, GlobalListContents> for State { impl Dispatch<wl_registry::WlRegistry, GlobalListContents> for State {
fn event( fn event(
state: &mut Self, _state: &mut Self,
_registry: &WlRegistry, _proxy: &wl_registry::WlRegistry,
event: <WlRegistry as wayland_client::Proxy>::Event, _event: <wl_registry::WlRegistry as wayland_client::Proxy>::Event,
_data: &GlobalListContents, _data: &GlobalListContents,
_conn: &Connection, _conn: &Connection,
_qh: &QueueHandle<Self>, _qhandle: &QueueHandle<Self>,
) {
}
}
impl Dispatch<wl_registry::WlRegistry, ()> for State {
fn event(
state: &mut Self,
registry: &wl_registry::WlRegistry,
event: <wl_registry::WlRegistry as wayland_client::Proxy>::Event,
_: &(),
_: &Connection,
qh: &QueueHandle<Self>,
) { ) {
match event { match event {
wl_registry::Event::Global { wl_registry::Event::Global {
name, name,
interface, interface,
version, version: _,
} => { } => {
state.register_global(Global { if interface.as_str() == "wl_output" {
name, log::debug!("wl_output global");
interface, state
version, .g
}); .outputs
} .push(registry.bind::<WlOutput, _, _>(name, 4, qh, ()))
wl_registry::Event::GlobalRemove { name } => { }
state.deregister_global(name);
} }
wl_registry::Event::GlobalRemove { .. } => {}
_ => {} _ => {}
} }
} }
} }
impl Dispatch<ZxdgOutputV1, u32> for State { impl Dispatch<ZxdgOutputV1, WlOutput> for State {
fn event( fn event(
state: &mut Self, state: &mut Self,
_: &ZxdgOutputV1, _: &ZxdgOutputV1,
event: <ZxdgOutputV1 as wayland_client::Proxy>::Event, event: <ZxdgOutputV1 as wayland_client::Proxy>::Event,
name: &u32, wl_output: &WlOutput,
_: &Connection, _: &Connection,
_: &QueueHandle<Self>, _: &QueueHandle<Self>,
) { ) {
let output = state log::debug!("xdg-output - {event:?}");
.outputs let output_info = match state.output_info.iter_mut().find(|(o, _)| o == wl_output) {
.iter_mut() Some((_, c)) => c,
.find(|o| o.global.name == *name) None => {
.expect("output"); let output_info = OutputInfo::new();
state.output_info.push((wl_output.clone(), output_info));
&mut state.output_info.last_mut().unwrap().1
}
};
log::debug!("xdg_output {name} - {event:?}");
match event { match event {
zxdg_output_v1::Event::LogicalPosition { x, y } => { zxdg_output_v1::Event::LogicalPosition { x, y } => {
output.pending_info.position = (x, y); output_info.position = (x, y);
output.has_xdg_info = true;
} }
zxdg_output_v1::Event::LogicalSize { width, height } => { zxdg_output_v1::Event::LogicalSize { width, height } => {
output.pending_info.size = (width, height); output_info.size = (width, height);
output.has_xdg_info = true;
}
zxdg_output_v1::Event::Done => {
log::warn!("Use of deprecated xdg-output event \"done\"");
state.update_output_info(*name);
} }
zxdg_output_v1::Event::Done => {}
zxdg_output_v1::Event::Name { name } => { zxdg_output_v1::Event::Name { name } => {
output.pending_info.name = name; output_info.name = name;
output.has_xdg_info = true;
} }
zxdg_output_v1::Event::Description { description } => { zxdg_output_v1::Event::Description { .. } => {}
output.pending_info.description = description; _ => {}
output.has_xdg_info = true;
}
_ => todo!(),
} }
} }
} }
impl Dispatch<WlOutput, u32> for State { impl Dispatch<WlOutput, ()> for State {
fn event( fn event(
state: &mut Self, state: &mut Self,
_wl_output: &WlOutput, _proxy: &WlOutput,
event: <WlOutput as wayland_client::Proxy>::Event, event: <WlOutput as wayland_client::Proxy>::Event,
name: &u32, _data: &(),
_conn: &Connection, _conn: &Connection,
_qhandle: &QueueHandle<Self>, _qhandle: &QueueHandle<Self>,
) { ) {
log::debug!("wl_output {name} - {event:?}");
if let wl_output::Event::Done = event { if let wl_output::Event::Done = event {
state.update_output_info(*name); state.update_windows();
} }
} }
} }

View File

@@ -1,57 +1,619 @@
use async_trait::async_trait; use anyhow::Result;
use core::task::{Context, Poll}; use core::task::{Context, Poll};
use event_thread::EventThread;
use futures::Stream; use futures::Stream;
use std::pin::Pin; use once_cell::unsync::Lazy;
use std::collections::HashMap;
use std::ptr::{addr_of, addr_of_mut};
use futures::executor::block_on;
use std::default::Default;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{mpsc, Mutex};
use std::task::ready; use std::task::ready;
use tokio::sync::mpsc::{Receiver, channel}; use std::{io, pin::Pin, thread};
use tokio::sync::mpsc::{channel, Receiver, Sender};
use windows::core::{w, PCWSTR};
use windows::Win32::Foundation::{FALSE, HINSTANCE, HWND, LPARAM, LRESULT, RECT, WPARAM};
use windows::Win32::Graphics::Gdi::{
EnumDisplayDevicesW, EnumDisplaySettingsW, DEVMODEW, DISPLAY_DEVICEW,
DISPLAY_DEVICE_ATTACHED_TO_DESKTOP, ENUM_CURRENT_SETTINGS,
};
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
use windows::Win32::System::Threading::GetCurrentThreadId;
use super::{Capture, CaptureError, CaptureEvent, Position}; use windows::Win32::UI::WindowsAndMessaging::{
CallNextHookEx, CreateWindowExW, DispatchMessageW, GetMessageW, PostThreadMessageW,
RegisterClassW, SetWindowsHookExW, TranslateMessage, EDD_GET_DEVICE_INTERFACE_NAME, HHOOK,
HMENU, HOOKPROC, KBDLLHOOKSTRUCT, LLKHF_EXTENDED, MSG, MSLLHOOKSTRUCT, WH_KEYBOARD_LL,
WH_MOUSE_LL, WINDOW_STYLE, WM_DISPLAYCHANGE, WM_KEYDOWN, WM_KEYUP, WM_LBUTTONDOWN,
WM_LBUTTONUP, WM_MBUTTONDOWN, WM_MBUTTONUP, WM_MOUSEMOVE, WM_MOUSEWHEEL, WM_RBUTTONDOWN,
WM_RBUTTONUP, WM_SYSKEYDOWN, WM_SYSKEYUP, WM_USER, WM_XBUTTONDOWN, WM_XBUTTONUP, WNDCLASSW,
WNDPROC,
};
mod display_util; use input_event::{
mod event_thread; scancode::{self, Linux},
Event, KeyboardEvent, PointerEvent, BTN_BACK, BTN_FORWARD, BTN_LEFT, BTN_MIDDLE, BTN_RIGHT,
};
pub struct WindowsInputCapture { use super::{CaptureHandle, InputCapture, Position};
event_rx: Receiver<(Position, CaptureEvent)>,
event_thread: EventThread, enum Request {
Create(CaptureHandle, Position),
Destroy(CaptureHandle),
} }
#[async_trait] pub struct WindowsInputCapture {
impl Capture for WindowsInputCapture { event_rx: Receiver<(CaptureHandle, Event)>,
async fn create(&mut self, pos: Position) -> Result<(), CaptureError> { msg_thread: Option<std::thread::JoinHandle<()>>,
self.event_thread.create(pos); }
enum EventType {
Request = 0,
Release = 1,
Exit = 2,
}
unsafe fn signal_message_thread(event_type: EventType) {
if let Some(event_tid) = get_event_tid() {
PostThreadMessageW(event_tid, WM_USER, WPARAM(event_type as usize), LPARAM(0)).unwrap();
} else {
panic!();
}
}
impl InputCapture for WindowsInputCapture {
fn create(&mut self, handle: CaptureHandle, pos: Position) -> io::Result<()> {
unsafe {
{
let mut requests = REQUEST_BUFFER.lock().unwrap();
requests.push(Request::Create(handle, pos));
}
signal_message_thread(EventType::Request);
}
Ok(())
}
fn destroy(&mut self, handle: CaptureHandle) -> io::Result<()> {
unsafe {
{
let mut requests = REQUEST_BUFFER.lock().unwrap();
requests.push(Request::Destroy(handle));
}
signal_message_thread(EventType::Request);
}
Ok(()) Ok(())
} }
async fn destroy(&mut self, pos: Position) -> Result<(), CaptureError> { fn release(&mut self) -> io::Result<()> {
self.event_thread.destroy(pos); unsafe { signal_message_thread(EventType::Release) };
Ok(()) Ok(())
} }
}
async fn release(&mut self) -> Result<(), CaptureError> { static mut REQUEST_BUFFER: Mutex<Vec<Request>> = Mutex::new(Vec::new());
self.event_thread.release_capture(); static mut ACTIVE_CLIENT: Option<CaptureHandle> = None;
Ok(()) static mut CLIENT_FOR_POS: Lazy<HashMap<Position, CaptureHandle>> = Lazy::new(HashMap::new);
static mut EVENT_TX: Option<Sender<(CaptureHandle, Event)>> = None;
static mut EVENT_THREAD_ID: AtomicU32 = AtomicU32::new(0);
unsafe fn set_event_tid(tid: u32) {
EVENT_THREAD_ID.store(tid, Ordering::SeqCst);
}
unsafe fn get_event_tid() -> Option<u32> {
match EVENT_THREAD_ID.load(Ordering::SeqCst) {
0 => None,
id => Some(id),
}
}
static mut ENTRY_POINT: (i32, i32) = (0, 0);
fn to_mouse_event(wparam: WPARAM, lparam: LPARAM) -> Option<PointerEvent> {
let mouse_low_level: MSLLHOOKSTRUCT = unsafe { *(lparam.0 as *const MSLLHOOKSTRUCT) };
match wparam {
WPARAM(p) if p == WM_LBUTTONDOWN as usize => Some(PointerEvent::Button {
time: 0,
button: BTN_LEFT,
state: 1,
}),
WPARAM(p) if p == WM_MBUTTONDOWN as usize => Some(PointerEvent::Button {
time: 0,
button: BTN_MIDDLE,
state: 1,
}),
WPARAM(p) if p == WM_RBUTTONDOWN as usize => Some(PointerEvent::Button {
time: 0,
button: BTN_RIGHT,
state: 1,
}),
WPARAM(p) if p == WM_LBUTTONUP as usize => Some(PointerEvent::Button {
time: 0,
button: BTN_LEFT,
state: 0,
}),
WPARAM(p) if p == WM_MBUTTONUP as usize => Some(PointerEvent::Button {
time: 0,
button: BTN_MIDDLE,
state: 0,
}),
WPARAM(p) if p == WM_RBUTTONUP as usize => Some(PointerEvent::Button {
time: 0,
button: BTN_RIGHT,
state: 0,
}),
WPARAM(p) if p == WM_MOUSEMOVE as usize => unsafe {
let (x, y) = (mouse_low_level.pt.x, mouse_low_level.pt.y);
let (ex, ey) = ENTRY_POINT;
let (dx, dy) = (x - ex, y - ey);
Some(PointerEvent::Motion {
time: 0,
relative_x: dx as f64,
relative_y: dy as f64,
})
},
WPARAM(p) if p == WM_MOUSEWHEEL as usize => Some(PointerEvent::AxisDiscrete120 {
axis: 0,
value: -(mouse_low_level.mouseData as i32 >> 16),
}),
WPARAM(p) if p == WM_XBUTTONDOWN as usize || p == WM_XBUTTONUP as usize => {
let hb = mouse_low_level.mouseData >> 16;
let button = match hb {
1 => BTN_BACK,
2 => BTN_FORWARD,
_ => {
log::warn!("unknown mouse button");
return None;
}
};
Some(PointerEvent::Button {
time: 0,
button,
state: if p == WM_XBUTTONDOWN as usize { 1 } else { 0 },
})
}
w => {
log::warn!("unknown mouse event: {w:?}");
None
}
}
}
unsafe fn to_key_event(wparam: WPARAM, lparam: LPARAM) -> Option<KeyboardEvent> {
let kybrdllhookstruct: KBDLLHOOKSTRUCT = *(lparam.0 as *const KBDLLHOOKSTRUCT);
let mut scan_code = kybrdllhookstruct.scanCode;
log::trace!("scan_code: {scan_code}");
if kybrdllhookstruct.flags.contains(LLKHF_EXTENDED) {
scan_code |= 0xE000;
}
let Ok(win_scan_code) = scancode::Windows::try_from(scan_code) else {
log::warn!("failed to translate to windows scancode: {scan_code}");
return None;
};
log::trace!("windows_scan: {win_scan_code:?}");
let Ok(linux_scan_code): Result<Linux, ()> = win_scan_code.try_into() else {
log::warn!("failed to translate into linux scancode: {win_scan_code:?}");
return None;
};
log::trace!("windows_scan: {linux_scan_code:?}");
let scan_code = linux_scan_code as u32;
match wparam {
WPARAM(p) if p == WM_KEYDOWN as usize => Some(KeyboardEvent::Key {
time: 0,
key: scan_code,
state: 1,
}),
WPARAM(p) if p == WM_KEYUP as usize => Some(KeyboardEvent::Key {
time: 0,
key: scan_code,
state: 0,
}),
WPARAM(p) if p == WM_SYSKEYDOWN as usize => Some(KeyboardEvent::Key {
time: 0,
key: scan_code,
state: 1,
}),
WPARAM(p) if p == WM_SYSKEYUP as usize => Some(KeyboardEvent::Key {
time: 0,
key: scan_code,
state: 1,
}),
_ => None,
}
}
///
/// clamp point to display bounds
///
/// # Arguments
///
/// * `prev_point`: coordinates, the cursor was before entering, within bounds of a display
/// * `entry_point`: point to clamp
///
/// returns: (i32, i32), the corrected entry point
///
fn clamp_to_display_bounds(prev_point: (i32, i32), point: (i32, i32)) -> (i32, i32) {
/* find display where movement came from */
let display_regions = unsafe { get_display_regions() };
let display = display_regions
.iter()
.find(|&d| is_within_dp_region(prev_point, d))
.unwrap();
/* clamp to bounds (inclusive) */
let (x, y) = point;
let (min_x, max_x) = (display.left, display.right - 1);
let (min_y, max_y) = (display.top, display.bottom - 1);
(x.clamp(min_x, max_x), y.clamp(min_y, max_y))
}
unsafe fn send_blocking(event: Event) {
if let Some(active) = ACTIVE_CLIENT {
block_on(async move {
let _ = EVENT_TX.as_ref().unwrap().send((active, event)).await;
});
}
}
unsafe fn check_client_activation(wparam: WPARAM, lparam: LPARAM) -> bool {
if wparam.0 != WM_MOUSEMOVE as usize {
return ACTIVE_CLIENT.is_some();
}
let mouse_low_level: MSLLHOOKSTRUCT = *(lparam.0 as *const MSLLHOOKSTRUCT);
static mut PREV_POS: Option<(i32, i32)> = None;
let curr_pos = (mouse_low_level.pt.x, mouse_low_level.pt.y);
let prev_pos = PREV_POS.unwrap_or(curr_pos);
PREV_POS.replace(curr_pos);
/* next event is the first actual event */
let ret = ACTIVE_CLIENT.is_some();
/* client already active, no need to check */
if ACTIVE_CLIENT.is_some() {
return ret;
} }
async fn terminate(&mut self) -> Result<(), CaptureError> { /* check if a client was activated */
Ok(()) let Some(pos) = entered_barrier(prev_pos, curr_pos, get_display_regions()) else {
return ret;
};
/* check if a client is registered for the barrier */
let Some(client) = CLIENT_FOR_POS.get(&pos) else {
return ret;
};
/* update active client and entry point */
ACTIVE_CLIENT.replace(*client);
ENTRY_POINT = clamp_to_display_bounds(prev_pos, curr_pos);
/* notify main thread */
log::debug!("ENTERED @ {prev_pos:?} -> {curr_pos:?}");
send_blocking(Event::Enter());
ret
}
unsafe extern "system" fn mouse_proc(ncode: i32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
let active = check_client_activation(wparam, lparam);
/* no client was active */
if !active {
return CallNextHookEx(HHOOK::default(), ncode, wparam, lparam);
}
/* get active client if any */
let Some(client) = ACTIVE_CLIENT else {
return LRESULT(1);
};
/* convert to lan-mouse event */
let Some(pointer_event) = to_mouse_event(wparam, lparam) else {
return LRESULT(1);
};
let event = (client, Event::Pointer(pointer_event));
/* notify mainthread (drop events if sending too fast) */
if let Err(e) = EVENT_TX.as_ref().unwrap().try_send(event) {
log::warn!("e: {e}");
}
/* don't pass event to applications */
LRESULT(1)
}
unsafe extern "system" fn kybrd_proc(ncode: i32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
/* get active client if any */
let Some(client) = ACTIVE_CLIENT else {
return CallNextHookEx(HHOOK::default(), ncode, wparam, lparam);
};
/* convert to key event */
let Some(key_event) = to_key_event(wparam, lparam) else {
return LRESULT(1);
};
let event = (client, Event::Keyboard(key_event));
if let Err(e) = EVENT_TX.as_ref().unwrap().try_send(event) {
log::warn!("e: {e}");
}
/* don't pass event to applications */
LRESULT(1)
}
unsafe extern "system" fn window_proc(
_hwnd: HWND,
uint: u32,
_wparam: WPARAM,
_lparam: LPARAM,
) -> LRESULT {
match uint {
x if x == WM_DISPLAYCHANGE => {
log::debug!("display resolution changed");
DISPLAY_RESOLUTION_CHANGED = true;
}
_ => {}
}
LRESULT(1)
}
fn enumerate_displays() -> Vec<RECT> {
unsafe {
let mut display_rects = vec![];
let mut devices = vec![];
for i in 0.. {
let mut device: DISPLAY_DEVICEW = std::mem::zeroed();
device.cb = std::mem::size_of::<DISPLAY_DEVICEW>() as u32;
let ret = EnumDisplayDevicesW(None, i, &mut device, EDD_GET_DEVICE_INTERFACE_NAME);
if ret == FALSE {
break;
}
if device.StateFlags & DISPLAY_DEVICE_ATTACHED_TO_DESKTOP != 0 {
devices.push(device.DeviceName);
}
}
for device in devices {
let mut dev_mode: DEVMODEW = std::mem::zeroed();
dev_mode.dmSize = std::mem::size_of::<DEVMODEW>() as u16;
let ret = EnumDisplaySettingsW(
PCWSTR::from_raw(&device as *const _),
ENUM_CURRENT_SETTINGS,
&mut dev_mode,
);
if ret == FALSE {
log::warn!("no display mode");
}
let pos = dev_mode.Anonymous1.Anonymous2.dmPosition;
let (x, y) = (pos.x, pos.y);
let (width, height) = (dev_mode.dmPelsWidth, dev_mode.dmPelsHeight);
display_rects.push(RECT {
left: x,
right: x + width as i32,
top: y,
bottom: y + height as i32,
});
}
display_rects
}
}
static mut DISPLAY_RESOLUTION_CHANGED: bool = true;
unsafe fn get_display_regions() -> &'static Vec<RECT> {
static mut DISPLAYS: Vec<RECT> = vec![];
if DISPLAY_RESOLUTION_CHANGED {
DISPLAYS = enumerate_displays();
DISPLAY_RESOLUTION_CHANGED = false;
log::debug!("displays: {DISPLAYS:?}");
}
&*addr_of!(DISPLAYS)
}
fn is_within_dp_region(point: (i32, i32), display: &RECT) -> bool {
[
Position::Left,
Position::Right,
Position::Top,
Position::Bottom,
]
.iter()
.all(|&pos| is_within_dp_boundary(point, display, pos))
}
fn is_within_dp_boundary(point: (i32, i32), display: &RECT, pos: Position) -> bool {
let (x, y) = point;
match pos {
Position::Left => display.left <= x,
Position::Right => display.right > x,
Position::Top => display.top <= y,
Position::Bottom => display.bottom > y,
}
}
/// returns whether the given position is within the display bounds with respect to the given
/// barrier position
///
/// # Arguments
///
/// * `x`:
/// * `y`:
/// * `displays`:
/// * `pos`:
///
/// returns: bool
///
fn in_bounds(point: (i32, i32), displays: &[RECT], pos: Position) -> bool {
displays
.iter()
.any(|d| is_within_dp_boundary(point, d, pos))
}
fn in_display_region(point: (i32, i32), displays: &[RECT]) -> bool {
displays.iter().any(|d| is_within_dp_region(point, d))
}
fn moved_across_boundary(
prev_pos: (i32, i32),
curr_pos: (i32, i32),
displays: &[RECT],
pos: Position,
) -> bool {
/* was within bounds, but is not anymore */
in_display_region(prev_pos, displays) && !in_bounds(curr_pos, displays, pos)
}
fn entered_barrier(
prev_pos: (i32, i32),
curr_pos: (i32, i32),
displays: &[RECT],
) -> Option<Position> {
[
Position::Left,
Position::Right,
Position::Top,
Position::Bottom,
]
.into_iter()
.find(|&pos| moved_across_boundary(prev_pos, curr_pos, displays, pos))
}
fn get_msg() -> Option<MSG> {
unsafe {
let mut msg = std::mem::zeroed();
let ret = GetMessageW(addr_of_mut!(msg), HWND::default(), 0, 0);
match ret.0 {
0 => None,
x if x > 0 => Some(msg),
_ => panic!("error in GetMessageW"),
}
}
}
fn message_thread(ready_tx: mpsc::Sender<()>) {
unsafe {
set_event_tid(GetCurrentThreadId());
ready_tx.send(()).expect("channel closed");
let mouse_proc: HOOKPROC = Some(mouse_proc);
let kybrd_proc: HOOKPROC = Some(kybrd_proc);
let window_proc: WNDPROC = Some(window_proc);
/* register hooks */
let _ = SetWindowsHookExW(WH_MOUSE_LL, mouse_proc, HINSTANCE::default(), 0).unwrap();
let _ = SetWindowsHookExW(WH_KEYBOARD_LL, kybrd_proc, HINSTANCE::default(), 0).unwrap();
let instance = GetModuleHandleW(None).unwrap();
let window_class: WNDCLASSW = WNDCLASSW {
lpfnWndProc: window_proc,
hInstance: instance.into(),
lpszClassName: w!("lan-mouse-message-window-class"),
..Default::default()
};
let ret = RegisterClassW(&window_class);
if ret == 0 {
panic!("RegisterClassW");
}
/* window is used ro receive WM_DISPLAYCHANGE messages */
let ret = CreateWindowExW(
Default::default(),
w!("lan-mouse-message-window-class"),
w!("lan-mouse-msg-window"),
WINDOW_STYLE::default(),
0,
0,
0,
0,
HWND::default(),
HMENU::default(),
instance,
None,
);
if ret.0 == 0 {
panic!("CreateWindowExW");
}
/* run message loop */
loop {
// mouse / keybrd proc do not actually return a message
let Some(msg) = get_msg() else {
break;
};
if msg.hwnd.0 == 0 {
/* messages sent via PostThreadMessage */
match msg.wParam.0 {
x if x == EventType::Exit as usize => break,
x if x == EventType::Release as usize => {
ACTIVE_CLIENT.take();
}
x if x == EventType::Request as usize => {
let requests = {
let mut res = vec![];
let mut requests = REQUEST_BUFFER.lock().unwrap();
for request in requests.drain(..) {
res.push(request);
}
res
};
for request in requests {
update_clients(request)
}
}
_ => {}
}
} else {
/* other messages for window_procs */
let _ = TranslateMessage(&msg);
DispatchMessageW(&msg);
}
}
}
}
fn update_clients(request: Request) {
match request {
Request::Create(handle, pos) => {
unsafe { CLIENT_FOR_POS.insert(pos, handle) };
}
Request::Destroy(handle) => unsafe {
for pos in [
Position::Left,
Position::Right,
Position::Top,
Position::Bottom,
] {
if ACTIVE_CLIENT == Some(handle) {
ACTIVE_CLIENT.take();
}
if CLIENT_FOR_POS.get(&pos).copied() == Some(handle) {
CLIENT_FOR_POS.remove(&pos);
}
}
},
} }
} }
impl WindowsInputCapture { impl WindowsInputCapture {
pub(crate) fn new() -> Self { pub(crate) fn new() -> Self {
let (event_tx, event_rx) = channel(10); unsafe {
let event_thread = EventThread::new(event_tx); let (tx, rx) = channel(10);
Self { EVENT_TX.replace(tx);
event_thread, let (ready_tx, ready_rx) = mpsc::channel();
event_rx, let msg_thread = Some(thread::spawn(|| message_thread(ready_tx)));
/* wait for thread to set its id */
ready_rx.recv().expect("channel closed");
Self {
msg_thread,
event_rx: rx,
}
} }
} }
} }
impl Stream for WindowsInputCapture { impl Stream for WindowsInputCapture {
type Item = Result<(Position, CaptureEvent), CaptureError>; type Item = io::Result<(CaptureHandle, Event)>;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> { fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
match ready!(self.event_rx.poll_recv(cx)) { match ready!(self.event_rx.poll_recv(cx)) {
None => Poll::Ready(None), None => Poll::Ready(None),
@@ -59,3 +621,10 @@ impl Stream for WindowsInputCapture {
} }
} }
} }
impl Drop for WindowsInputCapture {
fn drop(&mut self) {
unsafe { signal_message_thread(EventType::Exit) };
let _ = self.msg_thread.take().unwrap().join();
}
}

View File

@@ -1,99 +0,0 @@
use windows::Win32::Foundation::RECT;
use crate::Position;
fn is_within_dp_region(point: (i32, i32), display: &RECT) -> bool {
[
Position::Left,
Position::Right,
Position::Top,
Position::Bottom,
]
.iter()
.all(|&pos| is_within_dp_boundary(point, display, pos))
}
fn is_within_dp_boundary(point: (i32, i32), display: &RECT, pos: Position) -> bool {
let (x, y) = point;
match pos {
Position::Left => display.left <= x,
Position::Right => display.right > x,
Position::Top => display.top <= y,
Position::Bottom => display.bottom > y,
}
}
/// returns whether the given position is within the display bounds with respect to the given
/// barrier position
///
/// # Arguments
///
/// * `x`:
/// * `y`:
/// * `displays`:
/// * `pos`:
///
/// returns: bool
///
fn in_bounds(point: (i32, i32), displays: &[RECT], pos: Position) -> bool {
displays
.iter()
.any(|d| is_within_dp_boundary(point, d, pos))
}
fn in_display_region(point: (i32, i32), displays: &[RECT]) -> bool {
displays.iter().any(|d| is_within_dp_region(point, d))
}
fn moved_across_boundary(
prev_pos: (i32, i32),
curr_pos: (i32, i32),
displays: &[RECT],
pos: Position,
) -> bool {
/* was within bounds, but is not anymore */
in_display_region(prev_pos, displays) && !in_bounds(curr_pos, displays, pos)
}
pub(crate) fn entered_barrier(
prev_pos: (i32, i32),
curr_pos: (i32, i32),
displays: &[RECT],
) -> Option<Position> {
[
Position::Left,
Position::Right,
Position::Top,
Position::Bottom,
]
.into_iter()
.find(|&pos| moved_across_boundary(prev_pos, curr_pos, displays, pos))
}
///
/// clamp point to display bounds
///
/// # Arguments
///
/// * `prev_point`: coordinates, the cursor was before entering, within bounds of a display
/// * `entry_point`: point to clamp
///
/// returns: (i32, i32), the corrected entry point
///
pub(crate) fn clamp_to_display_bounds(
display_regions: &[RECT],
prev_point: (i32, i32),
point: (i32, i32),
) -> (i32, i32) {
/* find display where movement came from */
let display = display_regions
.iter()
.find(|&d| is_within_dp_region(prev_point, d))
.unwrap();
/* clamp to bounds (inclusive) */
let (x, y) = point;
let (min_x, max_x) = (display.left, display.right - 1);
let (min_y, max_y) = (display.top, display.bottom - 1);
(x.clamp(min_x, max_x), y.clamp(min_y, max_y))
}

View File

@@ -1,549 +0,0 @@
use std::cell::{Cell, RefCell};
use std::collections::HashSet;
use std::ptr::addr_of_mut;
use std::default::Default;
use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
use std::sync::{Arc, Condvar, Mutex};
use std::thread;
use tokio::sync::mpsc::Sender;
use tokio::sync::mpsc::error::TrySendError;
use windows::Win32::Foundation::{FALSE, HWND, LPARAM, LRESULT, RECT, WPARAM};
use windows::Win32::Graphics::Gdi::{
DEVMODEW, DISPLAY_DEVICE_ATTACHED_TO_DESKTOP, DISPLAY_DEVICEW, ENUM_CURRENT_SETTINGS,
EnumDisplayDevicesW, EnumDisplaySettingsW,
};
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
use windows::Win32::System::Threading::GetCurrentThreadId;
use windows::core::{PCWSTR, w};
use windows::Win32::UI::WindowsAndMessaging::{
CallNextHookEx, CreateWindowExW, DispatchMessageW, EDD_GET_DEVICE_INTERFACE_NAME, GetMessageW,
HOOKPROC, KBDLLHOOKSTRUCT, LLKHF_EXTENDED, MSG, MSLLHOOKSTRUCT, PostThreadMessageW,
RegisterClassW, SetWindowsHookExW, TranslateMessage, WH_KEYBOARD_LL, WH_MOUSE_LL, WINDOW_STYLE,
WM_DISPLAYCHANGE, WM_KEYDOWN, WM_KEYUP, WM_LBUTTONDOWN, WM_LBUTTONUP, WM_MBUTTONDOWN,
WM_MBUTTONUP, WM_MOUSEHWHEEL, WM_MOUSEMOVE, WM_MOUSEWHEEL, WM_RBUTTONDOWN, WM_RBUTTONUP,
WM_SYSKEYDOWN, WM_SYSKEYUP, WM_USER, WM_XBUTTONDOWN, WM_XBUTTONUP, WNDCLASSW, WNDPROC,
};
use input_event::{
BTN_BACK, BTN_FORWARD, BTN_LEFT, BTN_MIDDLE, BTN_RIGHT, Event, KeyboardEvent, PointerEvent,
scancode::{self, Linux},
};
use super::{CaptureEvent, Position, display_util};
pub(crate) struct EventThread {
request_buffer: Arc<Mutex<Vec<ClientUpdate>>>,
thread: Option<thread::JoinHandle<()>>,
thread_id: u32,
}
impl EventThread {
pub(crate) fn new(event_tx: Sender<(Position, CaptureEvent)>) -> Self {
let request_buffer = Default::default();
let (thread, thread_id) = start(event_tx, Arc::clone(&request_buffer));
Self {
request_buffer,
thread: Some(thread),
thread_id,
}
}
pub(crate) fn release_capture(&self) {
self.signal(RequestType::Release);
}
pub(crate) fn create(&self, pos: Position) {
self.client_update(ClientUpdate::Create(pos));
}
pub(crate) fn destroy(&self, pos: Position) {
self.client_update(ClientUpdate::Destroy(pos));
}
fn exit(&self) {
self.signal(RequestType::Exit);
}
fn client_update(&self, request: ClientUpdate) {
{
let mut requests = self.request_buffer.lock().unwrap();
requests.push(request);
}
self.signal(RequestType::ClientUpdate);
}
fn signal(&self, event_type: RequestType) {
let id = self.thread_id;
unsafe { PostThreadMessageW(id, WM_USER, WPARAM(event_type as usize), LPARAM(0)).unwrap() };
}
}
impl Drop for EventThread {
fn drop(&mut self) {
self.exit();
let _ = self.thread.take().expect("thread").join();
}
}
enum RequestType {
ClientUpdate = 0,
Release = 1,
Exit = 2,
}
enum ClientUpdate {
Create(Position),
Destroy(Position),
}
fn blocking_send_event(pos: Position, event: CaptureEvent) {
EVENT_TX.with_borrow_mut(|tx| tx.as_mut().unwrap().blocking_send((pos, event)).unwrap())
}
fn try_send_event(
pos: Position,
event: CaptureEvent,
) -> Result<(), TrySendError<(Position, CaptureEvent)>> {
EVENT_TX.with_borrow_mut(|tx| tx.as_mut().unwrap().try_send((pos, event)))
}
thread_local! {
/// all configured clients
static CLIENTS: RefCell<HashSet<Position>> = RefCell::new(HashSet::new());
/// currently active client
static ACTIVE_CLIENT: Cell<Option<Position>> = const { Cell::new(None) };
/// input event channel
static EVENT_TX: RefCell<Option<Sender<(Position, CaptureEvent)>>> = const { RefCell::new(None) };
/// position of barrier entry
static ENTRY_POINT: Cell<(i32, i32)> = const { Cell::new((0, 0)) };
/// previous mouse position
static PREV_POS: Cell<Option<(i32, i32)>> = const { Cell::new(None) };
/// displays and generation counter
static DISPLAYS: RefCell<(Vec<RECT>, i32)> = const { RefCell::new((Vec::new(), 0)) };
}
fn get_msg() -> Option<MSG> {
unsafe {
let mut msg = std::mem::zeroed();
let ret = GetMessageW(addr_of_mut!(msg), None, 0, 0);
match ret.0 {
0 => None,
x if x > 0 => Some(msg),
_ => panic!("error in GetMessageW"),
}
}
}
fn start(
event_tx: Sender<(Position, CaptureEvent)>,
request_buffer: Arc<Mutex<Vec<ClientUpdate>>>,
) -> (thread::JoinHandle<()>, u32) {
/* condition variable to wait for thead id */
let thread_id = Arc::new((Condvar::new(), Mutex::new(None)));
let thread_id_ = Arc::clone(&thread_id);
let msg_thread = thread::spawn(|| start_routine(thread_id_, event_tx, request_buffer));
/* wait for thread to set its id */
let (cond, thread_id) = &*thread_id;
let mut thread_id = thread_id.lock().unwrap();
while (*thread_id).is_none() {
thread_id = cond.wait(thread_id).expect("channel closed");
}
(msg_thread, thread_id.expect("thread id"))
}
fn start_routine(
ready: Arc<(Condvar, Mutex<Option<u32>>)>,
event_tx: Sender<(Position, CaptureEvent)>,
request_buffer: Arc<Mutex<Vec<ClientUpdate>>>,
) {
EVENT_TX.replace(Some(event_tx));
/* communicate thread id */
{
let (cnd, mtx) = &*ready;
let mut ready = mtx.lock().unwrap();
*ready = Some(unsafe { GetCurrentThreadId() });
cnd.notify_one();
}
let mouse_proc: HOOKPROC = Some(mouse_proc);
let kybrd_proc: HOOKPROC = Some(kybrd_proc);
let window_proc: WNDPROC = Some(window_proc);
/* register hooks */
unsafe {
let _ = SetWindowsHookExW(WH_MOUSE_LL, mouse_proc, None, 0).unwrap();
let _ = SetWindowsHookExW(WH_KEYBOARD_LL, kybrd_proc, None, 0).unwrap();
}
let instance = unsafe { GetModuleHandleW(None).unwrap() };
let instance = instance.into();
let window_class: WNDCLASSW = WNDCLASSW {
lpfnWndProc: window_proc,
hInstance: instance,
lpszClassName: w!("lan-mouse-message-window-class"),
..Default::default()
};
static WINDOW_CLASS_REGISTERED: AtomicBool = AtomicBool::new(false);
if WINDOW_CLASS_REGISTERED
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_ok()
{
/* register window class if not yet done so */
unsafe {
let ret = RegisterClassW(&window_class);
if ret == 0 {
panic!("RegisterClassW");
}
}
}
/* window is used ro receive WM_DISPLAYCHANGE messages */
unsafe {
CreateWindowExW(
Default::default(),
w!("lan-mouse-message-window-class"),
w!("lan-mouse-msg-window"),
WINDOW_STYLE::default(),
0,
0,
0,
0,
None,
None,
Some(instance),
None,
)
.expect("CreateWindowExW");
}
/* run message loop */
while let Some(msg) = get_msg() {
// mouse / keybrd proc do not actually return a message
if msg.hwnd.0.is_null() {
/* messages sent via PostThreadMessage */
match msg.wParam.0 {
x if x == RequestType::Exit as usize => break,
x if x == RequestType::Release as usize => {
ACTIVE_CLIENT.take();
}
x if x == RequestType::ClientUpdate as usize => {
let requests = {
let mut res = vec![];
let mut requests = request_buffer.lock().unwrap();
for request in requests.drain(..) {
res.push(request);
}
res
};
for request in requests {
update_clients(request)
}
}
_ => {}
}
} else {
/* other messages for window_procs */
unsafe {
let _ = TranslateMessage(&msg);
DispatchMessageW(&msg);
}
}
}
}
fn check_client_activation(wparam: WPARAM, lparam: LPARAM) -> bool {
if wparam.0 != WM_MOUSEMOVE as usize {
return ACTIVE_CLIENT.get().is_some();
}
let mouse_low_level: MSLLHOOKSTRUCT = unsafe { *(lparam.0 as *const MSLLHOOKSTRUCT) };
let curr_pos = (mouse_low_level.pt.x, mouse_low_level.pt.y);
let prev_pos = PREV_POS.get().unwrap_or(curr_pos);
PREV_POS.replace(Some(curr_pos));
/* next event is the first actual event */
let ret = ACTIVE_CLIENT.get().is_some();
/* client already active, no need to check */
if ACTIVE_CLIENT.get().is_some() {
return ret;
}
/* check if a client was activated */
let entered = DISPLAYS.with_borrow_mut(|(displays, generation)| {
update_display_regions(displays, generation);
display_util::entered_barrier(prev_pos, curr_pos, displays)
});
let Some(pos) = entered else {
return ret;
};
/* check if a client is registered for the barrier */
if !CLIENTS.with_borrow(|clients| clients.contains(&pos)) {
return ret;
}
/* update active client and entry point */
ACTIVE_CLIENT.replace(Some(pos));
let entry_point = DISPLAYS.with_borrow(|(displays, _)| {
display_util::clamp_to_display_bounds(displays, prev_pos, curr_pos)
});
ENTRY_POINT.replace(entry_point);
/* notify main thread */
log::debug!("ENTERED @ {prev_pos:?} -> {curr_pos:?}");
let active = ACTIVE_CLIENT.get().expect("active client");
blocking_send_event(active, CaptureEvent::Begin);
ret
}
unsafe extern "system" fn mouse_proc(ncode: i32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
let active = check_client_activation(wparam, lparam);
/* no client was active */
if !active {
return CallNextHookEx(None, ncode, wparam, lparam);
}
/* get active client if any */
let Some(pos) = ACTIVE_CLIENT.get() else {
return LRESULT(1);
};
/* convert to lan-mouse event */
let Some(pointer_event) = to_mouse_event(wparam, lparam) else {
return LRESULT(1);
};
/* notify mainthread (drop events if sending too fast) */
if let Err(e) = try_send_event(pos, CaptureEvent::Input(Event::Pointer(pointer_event))) {
log::warn!("e: {e}");
}
/* don't pass event to applications */
LRESULT(1)
}
unsafe extern "system" fn kybrd_proc(ncode: i32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
/* get active client if any */
let Some(client) = ACTIVE_CLIENT.get() else {
return CallNextHookEx(None, ncode, wparam, lparam);
};
/* convert to key event */
let Some(key_event) = to_key_event(wparam, lparam) else {
return LRESULT(1);
};
if let Err(e) = try_send_event(client, CaptureEvent::Input(Event::Keyboard(key_event))) {
log::warn!("e: {e}");
}
/* don't pass event to applications */
LRESULT(1)
}
unsafe extern "system" fn window_proc(
_hwnd: HWND,
uint: u32,
_wparam: WPARAM,
_lparam: LPARAM,
) -> LRESULT {
if uint == WM_DISPLAYCHANGE {
log::debug!("display resolution changed");
DISPLAY_RESOLUTION_GENERATION.fetch_add(1, Ordering::Release);
}
LRESULT(1)
}
static DISPLAY_RESOLUTION_GENERATION: AtomicI32 = AtomicI32::new(1);
fn update_display_regions(displays: &mut Vec<RECT>, generation: &mut i32) {
let global_generation = DISPLAY_RESOLUTION_GENERATION.load(Ordering::Acquire);
if *generation != global_generation {
enumerate_displays(displays);
log::debug!("displays: {displays:?}");
*generation = global_generation;
}
}
fn enumerate_displays(display_rects: &mut Vec<RECT>) {
display_rects.clear();
unsafe {
let mut devices = vec![];
for i in 0.. {
let mut device: DISPLAY_DEVICEW = std::mem::zeroed();
device.cb = std::mem::size_of::<DISPLAY_DEVICEW>() as u32;
let ret = EnumDisplayDevicesW(None, i, &mut device, EDD_GET_DEVICE_INTERFACE_NAME);
if ret == FALSE {
break;
}
if device
.StateFlags
.contains(DISPLAY_DEVICE_ATTACHED_TO_DESKTOP)
{
devices.push(device.DeviceName);
}
}
for device in devices {
let mut dev_mode: DEVMODEW = std::mem::zeroed();
dev_mode.dmSize = std::mem::size_of::<DEVMODEW>() as u16;
let ret = EnumDisplaySettingsW(
PCWSTR::from_raw(&device as *const _),
ENUM_CURRENT_SETTINGS,
&mut dev_mode,
);
if ret == FALSE {
log::warn!("no display mode");
}
let pos = dev_mode.Anonymous1.Anonymous2.dmPosition;
let (x, y) = (pos.x, pos.y);
let (width, height) = (dev_mode.dmPelsWidth, dev_mode.dmPelsHeight);
display_rects.push(RECT {
left: x,
right: x + width as i32,
top: y,
bottom: y + height as i32,
});
}
}
}
fn update_clients(request: ClientUpdate) {
match request {
ClientUpdate::Create(pos) => {
CLIENTS.with_borrow_mut(|clients| clients.insert(pos));
}
ClientUpdate::Destroy(pos) => {
if let Some(active_pos) = ACTIVE_CLIENT.get() {
if pos == active_pos {
let _ = ACTIVE_CLIENT.take();
}
}
CLIENTS.with_borrow_mut(|clients| clients.remove(&pos));
}
}
}
fn to_key_event(wparam: WPARAM, lparam: LPARAM) -> Option<KeyboardEvent> {
let kybrdllhookstruct: KBDLLHOOKSTRUCT = unsafe { *(lparam.0 as *const KBDLLHOOKSTRUCT) };
let mut scan_code = kybrdllhookstruct.scanCode;
log::trace!("scan_code: {scan_code}");
if kybrdllhookstruct.flags.contains(LLKHF_EXTENDED) {
scan_code |= 0xE000;
}
let Ok(win_scan_code) = scancode::Windows::try_from(scan_code) else {
log::warn!("failed to translate to windows scancode: {scan_code}");
return None;
};
log::trace!("windows_scan: {win_scan_code:?}");
let Ok(linux_scan_code): Result<Linux, ()> = win_scan_code.try_into() else {
log::warn!("failed to translate into linux scancode: {win_scan_code:?}");
return None;
};
log::trace!("windows_scan: {linux_scan_code:?}");
let scan_code = linux_scan_code as u32;
match wparam {
WPARAM(p) if p == WM_KEYDOWN as usize => Some(KeyboardEvent::Key {
time: 0,
key: scan_code,
state: 1,
}),
WPARAM(p) if p == WM_KEYUP as usize => Some(KeyboardEvent::Key {
time: 0,
key: scan_code,
state: 0,
}),
WPARAM(p) if p == WM_SYSKEYDOWN as usize => Some(KeyboardEvent::Key {
time: 0,
key: scan_code,
state: 1,
}),
WPARAM(p) if p == WM_SYSKEYUP as usize => Some(KeyboardEvent::Key {
time: 0,
key: scan_code,
state: 0,
}),
_ => None,
}
}
fn to_mouse_event(wparam: WPARAM, lparam: LPARAM) -> Option<PointerEvent> {
let mouse_low_level: MSLLHOOKSTRUCT = unsafe { *(lparam.0 as *const MSLLHOOKSTRUCT) };
match wparam {
WPARAM(p) if p == WM_LBUTTONDOWN as usize => Some(PointerEvent::Button {
time: 0,
button: BTN_LEFT,
state: 1,
}),
WPARAM(p) if p == WM_MBUTTONDOWN as usize => Some(PointerEvent::Button {
time: 0,
button: BTN_MIDDLE,
state: 1,
}),
WPARAM(p) if p == WM_RBUTTONDOWN as usize => Some(PointerEvent::Button {
time: 0,
button: BTN_RIGHT,
state: 1,
}),
WPARAM(p) if p == WM_LBUTTONUP as usize => Some(PointerEvent::Button {
time: 0,
button: BTN_LEFT,
state: 0,
}),
WPARAM(p) if p == WM_MBUTTONUP as usize => Some(PointerEvent::Button {
time: 0,
button: BTN_MIDDLE,
state: 0,
}),
WPARAM(p) if p == WM_RBUTTONUP as usize => Some(PointerEvent::Button {
time: 0,
button: BTN_RIGHT,
state: 0,
}),
WPARAM(p) if p == WM_MOUSEMOVE as usize => {
let (x, y) = (mouse_low_level.pt.x, mouse_low_level.pt.y);
let (ex, ey) = ENTRY_POINT.get();
let (dx, dy) = (x - ex, y - ey);
let (dx, dy) = (dx as f64, dy as f64);
Some(PointerEvent::Motion { time: 0, dx, dy })
}
WPARAM(p) if p == WM_MOUSEWHEEL as usize => Some(PointerEvent::AxisDiscrete120 {
axis: 0,
value: -(mouse_low_level.mouseData as i32 >> 16),
}),
WPARAM(p) if p == WM_XBUTTONDOWN as usize || p == WM_XBUTTONUP as usize => {
let hb = mouse_low_level.mouseData >> 16;
let button = match hb {
1 => BTN_BACK,
2 => BTN_FORWARD,
_ => {
log::warn!("unknown mouse button");
return None;
}
};
Some(PointerEvent::Button {
time: 0,
button,
state: if p == WM_XBUTTONDOWN as usize { 1 } else { 0 },
})
}
WPARAM(p) if p == WM_MOUSEHWHEEL as usize => Some(PointerEvent::AxisDiscrete120 {
axis: 1, // Horizontal
value: mouse_low_level.mouseData as i32 >> 16,
}),
w => {
log::warn!("unknown mouse event: {w:?}");
None
}
}
}

View File

@@ -1,9 +1,13 @@
use std::io;
use std::task::Poll; use std::task::Poll;
use async_trait::async_trait;
use futures_core::Stream; use futures_core::Stream;
use super::{Capture, CaptureError, CaptureEvent, Position, error::X11InputCaptureCreationError}; use super::InputCapture;
use input_event::Event;
use super::error::X11InputCaptureCreationError;
use super::{CaptureHandle, Position};
pub struct X11InputCapture {} pub struct X11InputCapture {}
@@ -13,27 +17,22 @@ impl X11InputCapture {
} }
} }
#[async_trait] impl InputCapture for X11InputCapture {
impl Capture for X11InputCapture { fn create(&mut self, _id: CaptureHandle, _pos: Position) -> io::Result<()> {
async fn create(&mut self, _pos: Position) -> Result<(), CaptureError> {
Ok(()) Ok(())
} }
async fn destroy(&mut self, _pos: Position) -> Result<(), CaptureError> { fn destroy(&mut self, _id: CaptureHandle) -> io::Result<()> {
Ok(()) Ok(())
} }
async fn release(&mut self) -> Result<(), CaptureError> { fn release(&mut self) -> io::Result<()> {
Ok(())
}
async fn terminate(&mut self) -> Result<(), CaptureError> {
Ok(()) Ok(())
} }
} }
impl Stream for X11InputCapture { impl Stream for X11InputCapture {
type Item = Result<(Position, CaptureEvent), CaptureError>; type Item = io::Result<(CaptureHandle, Event)>;
fn poll_next( fn poll_next(
self: std::pin::Pin<&mut Self>, self: std::pin::Pin<&mut Self>,

View File

@@ -1,61 +1,36 @@
[package] [package]
name = "input-emulation" name = "input-emulation"
description = "cross-platform input emulation library used by lan-mouse" description = "cross-platform input emulation library used by lan-mouse"
version = "0.3.0" version = "0.1.0"
edition = "2021" edition = "2021"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"
repository = "https://github.com/feschber/lan-mouse" repository = "https://github.com/ferdinandschober/lan-mouse"
[dependencies] [dependencies]
anyhow = "1.0.86"
async-trait = "0.1.80" async-trait = "0.1.80"
futures = "0.3.28" futures = "0.3.28"
log = "0.4.22" log = "0.4.22"
input-event = { path = "../input-event", version = "0.3.0" } input-event = { path = "../input-event", version = "0.1.0" }
thiserror = "2.0.0" thiserror = "1.0.61"
tokio = { version = "1.32.0", features = [ tokio = { version = "1.32.0", features = ["io-util", "io-std", "macros", "net", "process", "rt", "sync", "signal"] }
"io-util",
"io-std",
"macros",
"net",
"process",
"rt",
"sync",
"signal",
"time"
] }
once_cell = "1.19.0" once_cell = "1.19.0"
[target.'cfg(all(unix, not(target_os="macos")))'.dependencies] [target.'cfg(all(unix, not(target_os="macos")))'.dependencies]
bitflags = "2.6.0" wayland-client = { version="0.31.1", optional = true }
wayland-client = { version = "0.31.1", optional = true } wayland-protocols = { version="0.32.1", features=["client", "staging", "unstable"], optional = true }
wayland-protocols = { version = "0.32.1", features = [ wayland-protocols-wlr = { version="0.3.1", features=["client"], optional = true }
"client", wayland-protocols-misc = { version="0.3.1", features=["client"], optional = true }
"staging",
"unstable",
], optional = true }
wayland-protocols-wlr = { version = "0.3.1", features = [
"client",
], optional = true }
wayland-protocols-misc = { version = "0.3.1", features = [
"client",
], optional = true }
x11 = { version = "2.21.0", features = ["xlib", "xtest"], optional = true } x11 = { version = "2.21.0", features = ["xlib", "xtest"], optional = true }
ashpd = { version = "0.13.9", default-features = false, features = [ ashpd = { version = "0.8", default-features = false, features = ["tokio"], optional = true }
"remote_desktop", reis = { version = "0.2", features = [ "tokio" ], optional = true }
"screencast",
"tokio",
], optional = true }
reis = { version = "0.5.0", features = ["tokio"], optional = true }
[target.'cfg(target_os="macos")'.dependencies] [target.'cfg(target_os="macos")'.dependencies]
bitflags = "2.6.0" core-graphics = { version = "0.23", features = ["highsierra"] }
core-foundation = "0.10.0" keycode = "0.4.0"
core-foundation-sys = "0.8.6"
core-graphics = { version = "0.25.0", features = ["highsierra"] }
keycode = "1.0.0"
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
windows = { version = "0.61.2", features = [ windows = { version = "0.57.0", features = [
"Win32_System_LibraryLoader", "Win32_System_LibraryLoader",
"Win32_System_Threading", "Win32_System_Threading",
"Win32_Foundation", "Win32_Foundation",
@@ -66,13 +41,8 @@ windows = { version = "0.61.2", features = [
] } ] }
[features] [features]
default = ["wlroots", "x11", "remote_desktop_portal", "libei"] default = ["wayland", "x11", "xdg_desktop_portal", "libei"]
wlroots = [ wayland = ["dep:wayland-client", "dep:wayland-protocols", "dep:wayland-protocols-wlr", "dep:wayland-protocols-misc" ]
"dep:wayland-client",
"dep:wayland-protocols",
"dep:wayland-protocols-wlr",
"dep:wayland-protocols-misc",
]
x11 = ["dep:x11"] x11 = ["dep:x11"]
remote_desktop_portal = ["dep:ashpd"] xdg_desktop_portal = ["dep:ashpd"]
libei = ["dep:reis", "dep:ashpd"] libei = ["dep:reis", "dep:ashpd"]

View File

@@ -1,31 +0,0 @@
fn main() {
let unix = cfg!(unix);
let libei = cfg!(feature = "libei");
let x11 = cfg!(feature = "x11");
let macos = cfg!(target_os = "macos");
let wlroots = cfg!(feature = "wlroots");
let rdp = cfg!(feature = "remote_desktop_portal");
let libei = unix && !macos && libei;
let wlroots = unix && !macos && wlroots;
let x11 = unix && !macos && x11;
let rdp = unix && !macos && rdp;
println!("cargo::rustc-check-cfg=cfg(wlroots)");
println!("cargo::rustc-check-cfg=cfg(libei)");
println!("cargo::rustc-check-cfg=cfg(x11)");
println!("cargo::rustc-check-cfg=cfg(rdp)");
if libei {
println!("cargo::rustc-cfg=libei");
}
if x11 {
println!("cargo::rustc-cfg=x11");
}
if wlroots {
println!("cargo::rustc-cfg=wlroots");
}
if rdp {
println!("cargo::rustc-cfg=rdp");
}
}

View File

@@ -3,19 +3,19 @@ use input_event::Event;
use crate::error::EmulationError; use crate::error::EmulationError;
use super::{Emulation, EmulationHandle}; use super::{EmulationHandle, InputEmulation};
#[derive(Default)] #[derive(Default)]
pub(crate) struct DummyEmulation; pub struct DummyEmulation;
impl DummyEmulation { impl DummyEmulation {
pub(crate) fn new() -> Self { pub fn new() -> Self {
Self {} Self {}
} }
} }
#[async_trait] #[async_trait]
impl Emulation for DummyEmulation { impl InputEmulation for DummyEmulation {
async fn consume( async fn consume(
&mut self, &mut self,
event: Event, event: Event,
@@ -26,7 +26,4 @@ impl Emulation for DummyEmulation {
} }
async fn create(&mut self, _: EmulationHandle) {} async fn create(&mut self, _: EmulationHandle) {}
async fn destroy(&mut self, _: EmulationHandle) {} async fn destroy(&mut self, _: EmulationHandle) {}
async fn terminate(&mut self) {
/* nothing to do */
}
} }

View File

@@ -1,34 +1,29 @@
#[derive(Debug, Error)] use std::{fmt::Display, io};
pub enum InputEmulationError {
#[error("error creating input-emulation: `{0}`")]
Create(#[from] EmulationCreationError),
#[error("error emulating input: `{0}`")]
Emulate(#[from] EmulationError),
}
#[cfg(any(libei, rdp))]
use ashpd::{Error::Response, desktop::ResponseError};
use std::io;
use thiserror::Error; use thiserror::Error;
#[cfg(all(unix, feature = "wayland", not(target_os = "macos")))]
#[cfg(wlroots)]
use wayland_client::{ use wayland_client::{
ConnectError, DispatchError,
backend::WaylandError, backend::WaylandError,
globals::{BindError, GlobalError}, globals::{BindError, GlobalError},
ConnectError, DispatchError,
}; };
#[cfg(all(unix, feature = "libei", not(target_os = "macos")))]
use reis::tokio::HandshakeError;
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum EmulationError { pub enum EmulationError {
#[error("event stream closed")] #[cfg(all(unix, feature = "libei", not(target_os = "macos")))]
EndOfStream, #[error("libei error flushing events: `{0}`")]
#[cfg(libei)] Libei(#[from] reis::event::Error),
#[error("libei error: `{0}`")] #[cfg(all(unix, feature = "wayland", not(target_os = "macos")))]
Libei(#[from] reis::Error),
#[cfg(wlroots)]
#[error("wayland error: `{0}`")] #[error("wayland error: `{0}`")]
Wayland(#[from] wayland_client::backend::WaylandError), Wayland(#[from] wayland_client::backend::WaylandError),
#[cfg(any(rdp, libei))] #[cfg(all(
unix,
any(feature = "xdg_desktop_portal", feature = "libei"),
not(target_os = "macos")
))]
#[error("xdg-desktop-portal: `{0}`")] #[error("xdg-desktop-portal: `{0}`")]
Ashpd(#[from] ashpd::Error), Ashpd(#[from] ashpd::Error),
#[error("io error: `{0}`")] #[error("io error: `{0}`")]
@@ -37,119 +32,159 @@ pub enum EmulationError {
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum EmulationCreationError { pub enum EmulationCreationError {
#[cfg(wlroots)] #[cfg(all(unix, feature = "wayland", not(target_os = "macos")))]
#[error("wlroots backend: `{0}`")]
Wlroots(#[from] WlrootsEmulationCreationError), Wlroots(#[from] WlrootsEmulationCreationError),
#[cfg(libei)] #[cfg(all(unix, feature = "libei", not(target_os = "macos")))]
#[error("libei backend: `{0}`")]
Libei(#[from] LibeiEmulationCreationError), Libei(#[from] LibeiEmulationCreationError),
#[cfg(rdp)] #[cfg(all(unix, feature = "xdg_desktop_portal", not(target_os = "macos")))]
#[error("xdg-desktop-portal: `{0}`")]
Xdp(#[from] XdpEmulationCreationError), Xdp(#[from] XdpEmulationCreationError),
#[cfg(x11)] #[cfg(all(unix, feature = "x11", not(target_os = "macos")))]
#[error("x11: `{0}`")]
X11(#[from] X11EmulationCreationError), X11(#[from] X11EmulationCreationError),
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
#[error("macos: `{0}`")]
MacOs(#[from] MacOSEmulationCreationError), MacOs(#[from] MacOSEmulationCreationError),
#[cfg(windows)] #[cfg(windows)]
#[error("windows: `{0}`")]
Windows(#[from] WindowsEmulationCreationError), Windows(#[from] WindowsEmulationCreationError),
#[error("capture error")]
NoAvailableBackend, NoAvailableBackend,
} }
impl EmulationCreationError { impl Display for EmulationCreationError {
/// request was intentionally denied by the user fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
pub(crate) fn cancelled_by_user(&self) -> bool { let reason = match self {
#[cfg(libei)] #[cfg(all(unix, feature = "wayland", not(target_os = "macos")))]
if matches!( EmulationCreationError::Wlroots(e) => format!("wlroots backend: {e}"),
self, #[cfg(all(unix, feature = "libei", not(target_os = "macos")))]
EmulationCreationError::Libei(LibeiEmulationCreationError::Ashpd(Response( EmulationCreationError::Libei(e) => format!("libei backend: {e}"),
ResponseError::Cancelled, #[cfg(all(unix, feature = "xdg_desktop_portal", not(target_os = "macos")))]
))) EmulationCreationError::Xdp(e) => format!("desktop portal backend: {e}"),
) { #[cfg(all(unix, feature = "x11", not(target_os = "macos")))]
return true; EmulationCreationError::X11(e) => format!("x11 backend: {e}"),
} #[cfg(target_os = "macos")]
#[cfg(rdp)] EmulationCreationError::MacOs(e) => format!("macos backend: {e}"),
if matches!( #[cfg(windows)]
self, EmulationCreationError::Windows(e) => format!("windows backend: {e}"),
EmulationCreationError::Xdp(XdpEmulationCreationError::Ashpd(Response( EmulationCreationError::NoAvailableBackend => "no backend available".to_string(),
ResponseError::Cancelled, };
))) write!(f, "could not create input emulation backend: {reason}")
) {
return true;
}
false
} }
} }
#[cfg(wlroots)] #[cfg(all(unix, feature = "wayland", not(target_os = "macos")))]
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum WlrootsEmulationCreationError { pub enum WlrootsEmulationCreationError {
#[error(transparent)]
Connect(#[from] ConnectError), Connect(#[from] ConnectError),
#[error(transparent)]
Global(#[from] GlobalError), Global(#[from] GlobalError),
#[error(transparent)]
Wayland(#[from] WaylandError), Wayland(#[from] WaylandError),
#[error(transparent)]
Bind(#[from] WaylandBindError), Bind(#[from] WaylandBindError),
#[error(transparent)]
Dispatch(#[from] DispatchError), Dispatch(#[from] DispatchError),
#[error(transparent)]
Io(#[from] std::io::Error), Io(#[from] std::io::Error),
} }
#[cfg(wlroots)] #[cfg(all(unix, feature = "wayland", not(target_os = "macos")))]
#[derive(Debug, Error)] #[derive(Debug, Error)]
#[error("wayland protocol \"{protocol}\" not supported: {inner}")]
pub struct WaylandBindError { pub struct WaylandBindError {
inner: BindError, inner: BindError,
protocol: &'static str, protocol: &'static str,
} }
#[cfg(all(unix, feature = "wayland", not(target_os = "macos")))]
#[cfg(wlroots)]
impl WaylandBindError { impl WaylandBindError {
pub(crate) fn new(inner: BindError, protocol: &'static str) -> Self { pub(crate) fn new(inner: BindError, protocol: &'static str) -> Self {
Self { inner, protocol } Self { inner, protocol }
} }
} }
#[cfg(libei)] #[cfg(all(unix, feature = "wayland", not(target_os = "macos")))]
impl Display for WaylandBindError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{} protocol not supported: {}",
self.protocol, self.inner
)
}
}
#[cfg(all(unix, feature = "wayland", not(target_os = "macos")))]
impl Display for WlrootsEmulationCreationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
WlrootsEmulationCreationError::Bind(e) => write!(f, "{e}"),
WlrootsEmulationCreationError::Connect(e) => {
write!(f, "could not connect to wayland compositor: {e}")
}
WlrootsEmulationCreationError::Global(e) => write!(f, "wayland error: {e}"),
WlrootsEmulationCreationError::Wayland(e) => write!(f, "wayland error: {e}"),
WlrootsEmulationCreationError::Dispatch(e) => {
write!(f, "error dispatching wayland events: {e}")
}
WlrootsEmulationCreationError::Io(e) => write!(f, "io error: {e}"),
}
}
}
#[cfg(all(unix, feature = "libei", not(target_os = "macos")))]
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum LibeiEmulationCreationError { pub enum LibeiEmulationCreationError {
#[error(transparent)]
Ashpd(#[from] ashpd::Error), Ashpd(#[from] ashpd::Error),
#[error(transparent)]
Io(#[from] std::io::Error), Io(#[from] std::io::Error),
#[error(transparent)] Handshake(#[from] HandshakeError),
Reis(#[from] reis::Error),
} }
#[cfg(rdp)] #[cfg(all(unix, feature = "libei", not(target_os = "macos")))]
impl Display for LibeiEmulationCreationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
LibeiEmulationCreationError::Ashpd(e) => write!(f, "xdg-desktop-portal: {e}"),
LibeiEmulationCreationError::Io(e) => write!(f, "io error: {e}"),
LibeiEmulationCreationError::Handshake(e) => write!(f, "error in libei handshake: {e}"),
}
}
}
#[cfg(all(unix, feature = "xdg_desktop_portal", not(target_os = "macos")))]
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum XdpEmulationCreationError { pub enum XdpEmulationCreationError {
#[error(transparent)]
Ashpd(#[from] ashpd::Error), Ashpd(#[from] ashpd::Error),
} }
#[cfg(x11)] #[cfg(all(unix, feature = "xdg_desktop_portal", not(target_os = "macos")))]
impl Display for XdpEmulationCreationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
XdpEmulationCreationError::Ashpd(e) => write!(f, "portal error: {e}"),
}
}
}
#[cfg(all(unix, feature = "x11", not(target_os = "macos")))]
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum X11EmulationCreationError { pub enum X11EmulationCreationError {
#[error("could not open display")]
OpenDisplay, OpenDisplay,
} }
#[cfg(all(unix, feature = "x11", not(target_os = "macos")))]
impl Display for X11EmulationCreationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
X11EmulationCreationError::OpenDisplay => write!(f, "could not open display!"),
}
}
}
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum MacOSEmulationCreationError { pub enum MacOSEmulationCreationError {
#[error("could not create event source")]
EventSourceCreation, EventSourceCreation,
#[error("accessibility permission is required")] }
AccessibilityPermission,
#[error("input control permission is required")] #[cfg(target_os = "macos")]
InputControlPermission, impl Display for MacOSEmulationCreationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
MacOSEmulationCreationError::EventSourceCreation => {
write!(f, "could not create event source")
}
}
}
} }
#[cfg(windows)] #[cfg(windows)]

View File

@@ -1,46 +1,46 @@
use async_trait::async_trait; use async_trait::async_trait;
use std::{ use error::EmulationError;
collections::{HashMap, HashSet}, use std::fmt::Display;
fmt::Display,
};
use input_event::{Event, KeyboardEvent}; use input_event::Event;
pub use self::error::{EmulationCreationError, EmulationError, InputEmulationError}; use anyhow::Result;
use self::error::EmulationCreationError;
#[cfg(windows)] #[cfg(windows)]
mod windows; pub mod windows;
#[cfg(x11)] #[cfg(all(unix, feature = "x11", not(target_os = "macos")))]
mod x11; pub mod x11;
#[cfg(wlroots)] #[cfg(all(unix, feature = "wayland", not(target_os = "macos")))]
mod wlroots; pub mod wlroots;
#[cfg(rdp)] #[cfg(all(unix, feature = "xdg_desktop_portal", not(target_os = "macos")))]
mod xdg_desktop_portal; pub mod xdg_desktop_portal;
#[cfg(libei)] #[cfg(all(unix, feature = "libei", not(target_os = "macos")))]
mod libei; pub mod libei;
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
mod macos; pub mod macos;
/// fallback input emulation (logs events) /// fallback input emulation (logs events)
mod dummy; pub mod dummy;
mod error; pub mod error;
pub type EmulationHandle = u64; pub type EmulationHandle = u64;
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Backend { pub enum Backend {
#[cfg(wlroots)] #[cfg(all(unix, feature = "wayland", not(target_os = "macos")))]
Wlroots, Wlroots,
#[cfg(libei)] #[cfg(all(unix, feature = "libei", not(target_os = "macos")))]
Libei, Libei,
#[cfg(rdp)] #[cfg(all(unix, feature = "xdg_desktop_portal", not(target_os = "macos")))]
Xdp, Xdp,
#[cfg(x11)] #[cfg(all(unix, feature = "x11", not(target_os = "macos")))]
X11, X11,
#[cfg(windows)] #[cfg(windows)]
Windows, Windows,
@@ -52,13 +52,13 @@ pub enum Backend {
impl Display for Backend { impl Display for Backend {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { match self {
#[cfg(wlroots)] #[cfg(all(unix, feature = "wayland", not(target_os = "macos")))]
Backend::Wlroots => write!(f, "wlroots"), Backend::Wlroots => write!(f, "wlroots"),
#[cfg(libei)] #[cfg(all(unix, feature = "libei", not(target_os = "macos")))]
Backend::Libei => write!(f, "libei"), Backend::Libei => write!(f, "libei"),
#[cfg(rdp)] #[cfg(all(unix, feature = "xdg_desktop_portal", not(target_os = "macos")))]
Backend::Xdp => write!(f, "xdg-desktop-portal"), Backend::Xdp => write!(f, "xdg-desktop-portal"),
#[cfg(x11)] #[cfg(all(unix, feature = "x11", not(target_os = "macos")))]
Backend::X11 => write!(f, "X11"), Backend::X11 => write!(f, "X11"),
#[cfg(windows)] #[cfg(windows)]
Backend::Windows => write!(f, "windows"), Backend::Windows => write!(f, "windows"),
@@ -69,166 +69,8 @@ impl Display for Backend {
} }
} }
pub struct InputEmulation {
emulation: Box<dyn Emulation>,
handles: HashSet<EmulationHandle>,
pressed_keys: HashMap<EmulationHandle, HashSet<u32>>,
}
impl InputEmulation {
async fn with_backend(backend: Backend) -> Result<InputEmulation, EmulationCreationError> {
let emulation: Box<dyn Emulation> = match backend {
#[cfg(wlroots)]
Backend::Wlroots => Box::new(wlroots::WlrootsEmulation::new()?),
#[cfg(libei)]
Backend::Libei => Box::new(libei::LibeiEmulation::new().await?),
#[cfg(x11)]
Backend::X11 => Box::new(x11::X11Emulation::new()?),
#[cfg(rdp)]
Backend::Xdp => Box::new(xdg_desktop_portal::DesktopPortalEmulation::new().await?),
#[cfg(windows)]
Backend::Windows => Box::new(windows::WindowsEmulation::new()?),
#[cfg(target_os = "macos")]
Backend::MacOs => Box::new(macos::MacOSEmulation::new()?),
Backend::Dummy => Box::new(dummy::DummyEmulation::new()),
};
Ok(Self {
emulation,
handles: HashSet::new(),
pressed_keys: HashMap::new(),
})
}
pub async fn new(backend: Option<Backend>) -> Result<InputEmulation, EmulationCreationError> {
if let Some(backend) = backend {
let b = Self::with_backend(backend).await;
if b.is_ok() {
log::info!("using emulation backend: {backend}");
}
return b;
}
for backend in [
#[cfg(wlroots)]
Backend::Wlroots,
#[cfg(libei)]
Backend::Libei,
#[cfg(rdp)]
Backend::Xdp,
#[cfg(x11)]
Backend::X11,
#[cfg(windows)]
Backend::Windows,
#[cfg(target_os = "macos")]
Backend::MacOs,
Backend::Dummy,
] {
match Self::with_backend(backend).await {
Ok(b) => {
log::info!("using emulation backend: {backend}");
return Ok(b);
}
Err(e) if e.cancelled_by_user() => return Err(e),
Err(e) => log::warn!("{e}"),
}
}
Err(EmulationCreationError::NoAvailableBackend)
}
pub async fn consume(
&mut self,
event: Event,
handle: EmulationHandle,
) -> Result<(), EmulationError> {
match event {
Event::Keyboard(KeyboardEvent::Key { key, state, .. }) => {
// prevent double pressed / released keys
if self.update_pressed_keys(handle, key, state) {
self.emulation.consume(event, handle).await?;
}
Ok(())
}
_ => self.emulation.consume(event, handle).await,
}
}
pub async fn create(&mut self, handle: EmulationHandle) -> bool {
if self.handles.insert(handle) {
self.pressed_keys.insert(handle, HashSet::new());
self.emulation.create(handle).await;
true
} else {
false
}
}
pub async fn destroy(&mut self, handle: EmulationHandle) {
let _ = self.release_keys(handle).await;
if self.handles.remove(&handle) {
self.pressed_keys.remove(&handle);
self.emulation.destroy(handle).await
}
}
pub async fn terminate(&mut self) {
for handle in self.handles.iter().cloned().collect::<Vec<_>>() {
self.destroy(handle).await
}
self.emulation.terminate().await
}
pub async fn release_keys(&mut self, handle: EmulationHandle) -> Result<(), EmulationError> {
if let Some(keys) = self.pressed_keys.get_mut(&handle) {
let keys = keys.drain().collect::<Vec<_>>();
for key in keys {
let event = Event::Keyboard(KeyboardEvent::Key {
time: 0,
key,
state: 0,
});
self.emulation.consume(event, handle).await?;
if let Ok(key) = input_event::scancode::Linux::try_from(key) {
log::warn!("releasing stuck key: {key:?}");
}
}
}
let event = Event::Keyboard(KeyboardEvent::Modifiers {
depressed: 0,
latched: 0,
locked: 0,
group: 0,
});
self.emulation.consume(event, handle).await?;
Ok(())
}
pub fn has_pressed_keys(&self, handle: EmulationHandle) -> bool {
self.pressed_keys
.get(&handle)
.is_some_and(|p| !p.is_empty())
}
/// update the pressed_keys for the given handle
/// returns whether the event should be processed
fn update_pressed_keys(&mut self, handle: EmulationHandle, key: u32, state: u8) -> bool {
let Some(pressed_keys) = self.pressed_keys.get_mut(&handle) else {
return false;
};
if state == 0 {
// currently pressed => can release
pressed_keys.remove(&key)
} else {
// currently not pressed => can press
pressed_keys.insert(key)
}
}
}
#[async_trait] #[async_trait]
trait Emulation: Send { pub trait InputEmulation: Send {
async fn consume( async fn consume(
&mut self, &mut self,
event: Event, event: Event,
@@ -236,5 +78,64 @@ trait Emulation: Send {
) -> Result<(), EmulationError>; ) -> Result<(), EmulationError>;
async fn create(&mut self, handle: EmulationHandle); async fn create(&mut self, handle: EmulationHandle);
async fn destroy(&mut self, handle: EmulationHandle); async fn destroy(&mut self, handle: EmulationHandle);
async fn terminate(&mut self); }
pub async fn create_backend(
backend: Backend,
) -> Result<Box<dyn InputEmulation>, EmulationCreationError> {
match backend {
#[cfg(all(unix, feature = "wayland", not(target_os = "macos")))]
Backend::Wlroots => Ok(Box::new(wlroots::WlrootsEmulation::new()?)),
#[cfg(all(unix, feature = "libei", not(target_os = "macos")))]
Backend::Libei => Ok(Box::new(libei::LibeiEmulation::new().await?)),
#[cfg(all(unix, feature = "x11", not(target_os = "macos")))]
Backend::X11 => Ok(Box::new(x11::X11Emulation::new()?)),
#[cfg(all(unix, feature = "xdg_desktop_portal", not(target_os = "macos")))]
Backend::Xdp => Ok(Box::new(
xdg_desktop_portal::DesktopPortalEmulation::new().await?,
)),
#[cfg(windows)]
Backend::Windows => Ok(Box::new(windows::WindowsEmulation::new()?)),
#[cfg(target_os = "macos")]
Backend::MacOs => Ok(Box::new(macos::MacOSEmulation::new()?)),
Backend::Dummy => Ok(Box::new(dummy::DummyEmulation::new())),
}
}
pub async fn create(
backend: Option<Backend>,
) -> Result<Box<dyn InputEmulation>, EmulationCreationError> {
if let Some(backend) = backend {
let b = create_backend(backend).await;
if b.is_ok() {
log::info!("using emulation backend: {backend}");
}
return b;
}
for backend in [
#[cfg(all(unix, feature = "wayland", not(target_os = "macos")))]
Backend::Wlroots,
#[cfg(all(unix, feature = "libei", not(target_os = "macos")))]
Backend::Libei,
#[cfg(all(unix, feature = "xdg_desktop_portal", not(target_os = "macos")))]
Backend::Xdp,
#[cfg(all(unix, feature = "x11", not(target_os = "macos")))]
Backend::X11,
#[cfg(windows)]
Backend::Windows,
#[cfg(target_os = "macos")]
Backend::MacOs,
Backend::Dummy,
] {
match create_backend(backend).await {
Ok(b) => {
log::info!("using emulation backend: {backend}");
return Ok(b);
}
Err(e) => log::warn!("{e}"),
}
}
Err(EmulationCreationError::NoAvailableBackend)
} }

View File

@@ -1,36 +1,57 @@
use futures::{StreamExt, future}; use anyhow::{anyhow, Result};
use futures::StreamExt;
use once_cell::sync::Lazy;
use std::{ use std::{
env, fs, io, collections::HashMap,
io,
os::{fd::OwnedFd, unix::net::UnixStream}, os::{fd::OwnedFd, unix::net::UnixStream},
path::PathBuf,
sync::{ sync::{
Arc, Mutex, RwLock, atomic::{AtomicU32, Ordering},
atomic::{AtomicBool, Ordering}, Arc, RwLock,
}, },
time::{SystemTime, UNIX_EPOCH}, time::{SystemTime, UNIX_EPOCH},
}; };
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
use ashpd::desktop::{ use ashpd::{
PersistMode, Session, desktop::{
remote_desktop::{DeviceType, RemoteDesktop, SelectDevicesOptions}, remote_desktop::{DeviceType, RemoteDesktop},
ResponseError,
},
WindowIdentifier,
}; };
use async_trait::async_trait; use async_trait::async_trait;
use reis::{ use reis::{
ei::{ ei::{
self, Button, Keyboard, Pointer, Scroll, button::ButtonState, handshake::ContextType, self, button::ButtonState, handshake::ContextType, keyboard::KeyState, Button, Keyboard,
keyboard::KeyState, Pointer, Scroll,
}, },
event::{self, Connection, DeviceCapability, DeviceEvent, EiEvent, SeatEvent}, event::{DeviceCapability, DeviceEvent, EiEvent, SeatEvent},
tokio::EiConvertEventStream, tokio::{ei_handshake, EiConvertEventStream, EiEventStream},
}; };
use input_event::{Event, KeyboardEvent, PointerEvent}; use input_event::{Event, KeyboardEvent, PointerEvent};
use crate::error::EmulationError; use crate::error::EmulationError;
use super::{Emulation, EmulationHandle, error::LibeiEmulationCreationError}; use super::{error::LibeiEmulationCreationError, EmulationHandle, InputEmulation};
static INTERFACES: Lazy<HashMap<&'static str, u32>> = Lazy::new(|| {
let mut m = HashMap::new();
m.insert("ei_connection", 1);
m.insert("ei_callback", 1);
m.insert("ei_pingpong", 1);
m.insert("ei_seat", 1);
m.insert("ei_device", 2);
m.insert("ei_pointer", 1);
m.insert("ei_pointer_absolute", 1);
m.insert("ei_scroll", 1);
m.insert("ei_button", 1);
m.insert("ei_keyboard", 1);
m.insert("ei_touchscreen", 1);
m
});
#[derive(Clone, Default)] #[derive(Clone, Default)]
struct Devices { struct Devices {
@@ -40,115 +61,71 @@ struct Devices {
keyboard: Arc<RwLock<Option<(ei::Device, ei::Keyboard)>>>, keyboard: Arc<RwLock<Option<(ei::Device, ei::Keyboard)>>>,
} }
pub(crate) struct LibeiEmulation { pub struct LibeiEmulation {
context: ei::Context, context: ei::Context,
conn: event::Connection,
devices: Devices, devices: Devices,
ei_task: JoinHandle<()>, serial: AtomicU32,
error: Arc<Mutex<Option<EmulationError>>>, ei_task: JoinHandle<Result<()>>,
libei_error: Arc<AtomicBool>,
_remote_desktop: RemoteDesktop,
session: Session<RemoteDesktop>,
} }
/// Get the path to the RemoteDesktop token file async fn get_ei_fd() -> Result<OwnedFd, ashpd::Error> {
fn get_token_file_path() -> PathBuf { let proxy = RemoteDesktop::new().await?;
let cache_dir = env::var("XDG_CACHE_HOME")
.ok()
.map(PathBuf::from)
.unwrap_or_else(|| {
let home = env::var("HOME").expect("HOME not set");
PathBuf::from(home).join(".cache")
});
cache_dir.join("lan-mouse").join("remote-desktop.token") // retry when user presses the cancel button
} let (session, _) = loop {
log::debug!("creating session ...");
let session = proxy.create_session().await?;
/// Read the RemoteDesktop token from file log::debug!("selecting devices ...");
fn read_token() -> Option<String> { proxy
let token_path = get_token_file_path(); .select_devices(&session, DeviceType::Keyboard | DeviceType::Pointer)
match fs::read_to_string(&token_path) { .await?;
Ok(token) => Some(token.trim().to_string()),
Err(_) => None,
}
}
/// Write the RemoteDesktop token to file log::info!("requesting permission for input emulation");
fn write_token(token: &str) -> io::Result<()> { match proxy
let token_path = get_token_file_path(); .start(&session, &WindowIdentifier::default())
if let Some(parent) = token_path.parent() { .await?
fs::create_dir_all(parent)?; .response()
} {
Ok(d) => break (session, d),
Err(ashpd::Error::Response(ResponseError::Cancelled)) => {
log::warn!("request cancelled!");
continue;
}
e => e?,
};
};
fs::write(&token_path, token)?; proxy.connect_to_eis(&session).await
Ok(())
}
async fn get_ei_fd() -> Result<(RemoteDesktop, Session<RemoteDesktop>, OwnedFd), ashpd::Error> {
let remote_desktop = RemoteDesktop::new().await?;
let restore_token = read_token();
log::debug!("creating session ...");
let session = remote_desktop.create_session(Default::default()).await?;
log::debug!("selecting devices ...");
let options = SelectDevicesOptions::default()
.set_devices(DeviceType::Keyboard | DeviceType::Pointer)
.set_persist_mode(PersistMode::ExplicitlyRevoked)
.set_restore_token(restore_token.as_deref());
remote_desktop.select_devices(&session, options).await?;
log::info!("requesting permission for input emulation");
let start_response = remote_desktop
.start(&session, None, Default::default())
.await?
.response()?;
// The restore token is only valid once, we need to re-save it each time
if let Some(token_str) = start_response.restore_token() {
if let Err(e) = write_token(token_str) {
log::warn!("failed to save RemoteDesktop token: {}", e);
}
}
let fd = remote_desktop
.connect_to_eis(&session, Default::default())
.await?;
Ok((remote_desktop, session, fd))
} }
impl LibeiEmulation { impl LibeiEmulation {
pub(crate) async fn new() -> Result<Self, LibeiEmulationCreationError> { pub async fn new() -> Result<Self, LibeiEmulationCreationError> {
let (_remote_desktop, session, eifd) = get_ei_fd().await?; let eifd = get_ei_fd().await?;
let stream = UnixStream::from(eifd); let stream = UnixStream::from(eifd);
stream.set_nonblocking(true)?; stream.set_nonblocking(true)?;
let context = ei::Context::new(stream)?; let context = ei::Context::new(stream)?;
let (conn, events) = context context.flush().map_err(|e| io::Error::new(e.kind(), e))?;
.handshake_tokio("de.feschber.LanMouse", ContextType::Sender) let mut events = EiEventStream::new(context.clone())?;
.await?; let handshake = ei_handshake(
&mut events,
"de.feschber.LanMouse",
ContextType::Sender,
&INTERFACES,
)
.await?;
let events = EiConvertEventStream::new(events, handshake.serial);
let devices = Devices::default(); let devices = Devices::default();
let libei_error = Arc::new(AtomicBool::default()); let ei_task =
let error = Arc::new(Mutex::new(None)); tokio::task::spawn_local(ei_event_handler(events, context.clone(), devices.clone()));
let ei_handler = ei_task(
events, let serial = AtomicU32::new(handshake.serial);
conn.clone(),
context.clone(),
devices.clone(),
libei_error.clone(),
error.clone(),
);
let ei_task = tokio::task::spawn_local(ei_handler);
Ok(Self { Ok(Self {
serial,
context, context,
conn,
devices,
ei_task, ei_task,
error, devices,
libei_error,
_remote_desktop,
session,
}) })
} }
} }
@@ -160,7 +137,7 @@ impl Drop for LibeiEmulation {
} }
#[async_trait] #[async_trait]
impl Emulation for LibeiEmulation { impl InputEmulation for LibeiEmulation {
async fn consume( async fn consume(
&mut self, &mut self,
event: Event, event: Event,
@@ -170,19 +147,17 @@ impl Emulation for LibeiEmulation {
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
.unwrap() .unwrap()
.as_micros() as u64; .as_micros() as u64;
if self.libei_error.load(Ordering::SeqCst) {
// don't break sending additional events but signal error
if let Some(e) = self.error.lock().unwrap().take() {
return Err(e);
}
}
match event { match event {
Event::Pointer(p) => match p { Event::Pointer(p) => match p {
PointerEvent::Motion { time: _, dx, dy } => { PointerEvent::Motion {
time: _,
relative_x,
relative_y,
} => {
let pointer_device = self.devices.pointer.read().unwrap(); let pointer_device = self.devices.pointer.read().unwrap();
if let Some((d, p)) = pointer_device.as_ref() { if let Some((d, p)) = pointer_device.as_ref() {
p.motion_relative(dx as f32, dy as f32); p.motion_relative(relative_x as f32, relative_y as f32);
d.frame(self.conn.serial(), now); d.frame(self.serial.load(Ordering::SeqCst), now);
} }
} }
PointerEvent::Button { PointerEvent::Button {
@@ -199,7 +174,7 @@ impl Emulation for LibeiEmulation {
_ => ButtonState::Press, _ => ButtonState::Press,
}, },
); );
d.frame(self.conn.serial(), now); d.frame(self.serial.load(Ordering::SeqCst), now);
} }
} }
PointerEvent::Axis { PointerEvent::Axis {
@@ -213,7 +188,7 @@ impl Emulation for LibeiEmulation {
0 => s.scroll(0., value as f32), 0 => s.scroll(0., value as f32),
_ => s.scroll(value as f32, 0.), _ => s.scroll(value as f32, 0.),
} }
d.frame(self.conn.serial(), now); d.frame(self.serial.load(Ordering::SeqCst), now);
} }
} }
PointerEvent::AxisDiscrete120 { axis, value } => { PointerEvent::AxisDiscrete120 { axis, value } => {
@@ -223,9 +198,10 @@ impl Emulation for LibeiEmulation {
0 => s.scroll_discrete(0, value), 0 => s.scroll_discrete(0, value),
_ => s.scroll_discrete(value, 0), _ => s.scroll_discrete(value, 0),
} }
d.frame(self.conn.serial(), now); d.frame(self.serial.load(Ordering::SeqCst), now);
} }
} }
PointerEvent::Frame {} => {}
}, },
Event::Keyboard(k) => match k { Event::Keyboard(k) => match k {
KeyboardEvent::Key { KeyboardEvent::Key {
@@ -242,11 +218,12 @@ impl Emulation for LibeiEmulation {
_ => KeyState::Press, _ => KeyState::Press,
}, },
); );
d.frame(self.conn.serial(), now); d.frame(self.serial.load(Ordering::SeqCst), now);
} }
} }
KeyboardEvent::Modifiers { .. } => {} KeyboardEvent::Modifiers { .. } => {}
}, },
_ => {}
} }
self.context self.context
.flush() .flush()
@@ -256,41 +233,19 @@ impl Emulation for LibeiEmulation {
async fn create(&mut self, _: EmulationHandle) {} async fn create(&mut self, _: EmulationHandle) {}
async fn destroy(&mut self, _: EmulationHandle) {} async fn destroy(&mut self, _: EmulationHandle) {}
async fn terminate(&mut self) {
let _ = self.session.close().await;
self.ei_task.abort();
}
}
async fn ei_task(
mut events: EiConvertEventStream,
_conn: Connection,
context: ei::Context,
devices: Devices,
libei_error: Arc<AtomicBool>,
error: Arc<Mutex<Option<EmulationError>>>,
) {
loop {
match ei_event_handler(&mut events, &context, &devices).await {
Ok(()) => {}
Err(e) => {
libei_error.store(true, Ordering::SeqCst);
error.lock().unwrap().replace(e);
// wait for termination -> otherwise we will loop forever
future::pending::<()>().await;
}
}
}
} }
async fn ei_event_handler( async fn ei_event_handler(
events: &mut EiConvertEventStream, mut events: EiConvertEventStream,
context: &ei::Context, context: ei::Context,
devices: &Devices, devices: Devices,
) -> Result<(), EmulationError> { ) -> Result<()> {
loop { loop {
let event = events.next().await.ok_or(EmulationError::EndOfStream)??; let event = events
.next()
.await
.ok_or(anyhow!("ei stream closed"))?
.map_err(|e| anyhow!("libei error: {e:?}"))?;
const CAPABILITIES: &[DeviceCapability] = &[ const CAPABILITIES: &[DeviceCapability] = &[
DeviceCapability::Pointer, DeviceCapability::Pointer,
DeviceCapability::PointerAbsolute, DeviceCapability::PointerAbsolute,
@@ -303,7 +258,7 @@ async fn ei_event_handler(
match event { match event {
EiEvent::Disconnected(e) => { EiEvent::Disconnected(e) => {
log::debug!("ei disconnected: {e:?}"); log::debug!("ei disconnected: {e:?}");
return Err(EmulationError::EndOfStream); break;
} }
EiEvent::SeatAdded(e) => { EiEvent::SeatAdded(e) => {
e.seat().bind_capabilities(CAPABILITIES); e.seat().bind_capabilities(CAPABILITIES);
@@ -375,6 +330,7 @@ async fn ei_event_handler(
// EiEvent::TouchMotion(_) => { }, // EiEvent::TouchMotion(_) => { },
_ => unreachable!("unexpected ei event"), _ => unreachable!("unexpected ei event"),
} }
context.flush().map_err(|e| io::Error::new(e.kind(), e))?; context.flush()?;
} }
Ok(())
} }

View File

@@ -1,79 +1,70 @@
use super::{Emulation, EmulationHandle, error::EmulationError}; use super::{error::EmulationError, EmulationHandle, InputEmulation};
use async_trait::async_trait; use async_trait::async_trait;
use bitflags::bitflags; use core_graphics::display::{CGDisplayBounds, CGMainDisplayID, CGPoint};
use core_graphics::base::CGFloat;
use core_graphics::display::{
CGDirectDisplayID, CGDisplayBounds, CGGetDisplaysWithRect, CGPoint, CGRect, CGSize,
};
use core_graphics::event::{ use core_graphics::event::{
CGEvent, CGEventFlags, CGEventTapLocation, CGEventType, CGKeyCode, CGMouseButton, EventField, CGEvent, CGEventTapLocation, CGEventType, CGKeyCode, CGMouseButton, EventField, ScrollEventUnit,
ScrollEventUnit,
}; };
use core_graphics::event_source::{CGEventSource, CGEventSourceStateID}; use core_graphics::event_source::{CGEventSource, CGEventSourceStateID};
use input_event::{ use input_event::{Event, KeyboardEvent, PointerEvent};
BTN_BACK, BTN_FORWARD, BTN_LEFT, BTN_MIDDLE, BTN_RIGHT, Event, KeyboardEvent, PointerEvent,
scancode,
};
use keycode::{KeyMap, KeyMapping}; use keycode::{KeyMap, KeyMapping};
use std::cell::Cell; use std::ops::{Index, IndexMut};
use std::collections::HashSet; use std::time::Duration;
use std::rc::Rc; use tokio::task::AbortHandle;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::{sync::Notify, task::JoinHandle};
use super::error::MacOSEmulationCreationError; use super::error::MacOSEmulationCreationError;
const DEFAULT_REPEAT_DELAY: Duration = Duration::from_millis(500); const DEFAULT_REPEAT_DELAY: Duration = Duration::from_millis(500);
const DEFAULT_REPEAT_INTERVAL: Duration = Duration::from_millis(32); const DEFAULT_REPEAT_INTERVAL: Duration = Duration::from_millis(32);
const DOUBLE_CLICK_INTERVAL: Duration = Duration::from_millis(500);
pub(crate) struct MacOSEmulation { pub struct MacOSEmulation {
/// global event source for all events pub event_source: CGEventSource,
event_source: CGEventSource, repeat_task: Option<AbortHandle>,
/// task handle for key repeats button_state: ButtonState,
repeat_task: Option<JoinHandle<()>>,
/// current state of the mouse buttons (tracked by evdev button code)
pressed_buttons: HashSet<u32>,
/// button previously pressed (evdev button code)
previous_button: Option<u32>,
/// timestamp of previous click (button down)
previous_button_click: Option<Instant>,
/// click state, i.e. number of clicks in quick succession
button_click_state: i64,
/// current modifier state
modifier_state: Rc<Cell<XMods>>,
/// notify to cancel key repeats
notify_repeat_task: Arc<Notify>,
} }
/// Maps an evdev button code to the CGEventType used for drag events. struct ButtonState {
fn drag_event_type(button: u32) -> CGEventType { left: bool,
match button { right: bool,
BTN_LEFT => CGEventType::LeftMouseDragged, center: bool,
BTN_RIGHT => CGEventType::RightMouseDragged, }
// middle, back, forward, and any other button all use OtherMouseDragged
_ => CGEventType::OtherMouseDragged, impl Index<CGMouseButton> for ButtonState {
type Output = bool;
fn index(&self, index: CGMouseButton) -> &Self::Output {
match index {
CGMouseButton::Left => &self.left,
CGMouseButton::Right => &self.right,
CGMouseButton::Center => &self.center,
}
}
}
impl IndexMut<CGMouseButton> for ButtonState {
fn index_mut(&mut self, index: CGMouseButton) -> &mut Self::Output {
match index {
CGMouseButton::Left => &mut self.left,
CGMouseButton::Right => &mut self.right,
CGMouseButton::Center => &mut self.center,
}
} }
} }
unsafe impl Send for MacOSEmulation {} unsafe impl Send for MacOSEmulation {}
impl MacOSEmulation { impl MacOSEmulation {
pub(crate) fn new() -> Result<Self, MacOSEmulationCreationError> { pub fn new() -> Result<Self, MacOSEmulationCreationError> {
request_macos_emulation_permissions()?;
let event_source = CGEventSource::new(CGEventSourceStateID::CombinedSessionState) let event_source = CGEventSource::new(CGEventSourceStateID::CombinedSessionState)
.map_err(|_| MacOSEmulationCreationError::EventSourceCreation)?; .map_err(|_| MacOSEmulationCreationError::EventSourceCreation)?;
let button_state = ButtonState {
left: false,
right: false,
center: false,
};
Ok(Self { Ok(Self {
event_source, event_source,
pressed_buttons: HashSet::new(), button_state,
previous_button: None,
previous_button_click: None,
button_click_state: 0,
repeat_task: None, repeat_task: None,
notify_repeat_task: Arc::new(Notify::new()),
modifier_state: Rc::new(Cell::new(XMods::empty())),
}) })
} }
@@ -85,79 +76,25 @@ impl MacOSEmulation {
async fn spawn_repeat_task(&mut self, key: u16) { async fn spawn_repeat_task(&mut self, key: u16) {
// there can only be one repeating key and it's // there can only be one repeating key and it's
// always the last to be pressed // always the last to be pressed
self.cancel_repeat_task().await; self.kill_repeat_task();
// initial key event
key_event(self.event_source.clone(), key, 1, self.modifier_state.get());
// repeat task
let event_source = self.event_source.clone(); let event_source = self.event_source.clone();
let notify = self.notify_repeat_task.clone();
let modifiers = self.modifier_state.clone();
let repeat_task = tokio::task::spawn_local(async move { let repeat_task = tokio::task::spawn_local(async move {
let stop = tokio::select! { tokio::time::sleep(DEFAULT_REPEAT_DELAY).await;
_ = tokio::time::sleep(DEFAULT_REPEAT_DELAY) => false, loop {
_ = notify.notified() => true, key_event(event_source.clone(), key, 1);
}; tokio::time::sleep(DEFAULT_REPEAT_INTERVAL).await;
if !stop {
loop {
key_event(event_source.clone(), key, 1, modifiers.get());
tokio::select! {
_ = tokio::time::sleep(DEFAULT_REPEAT_INTERVAL) => {},
_ = notify.notified() => break,
}
}
} }
// release key when cancelled
update_modifiers(&modifiers, key as u32, 0);
key_event(event_source.clone(), key, 0, modifiers.get());
}); });
self.repeat_task = Some(repeat_task); self.repeat_task = Some(repeat_task.abort_handle());
} }
fn kill_repeat_task(&mut self) {
async fn cancel_repeat_task(&mut self) {
if let Some(task) = self.repeat_task.take() { if let Some(task) = self.repeat_task.take() {
self.notify_repeat_task.notify_waiters(); task.abort();
let _ = task.await;
} }
} }
} }
fn request_macos_emulation_permissions() -> Result<(), MacOSEmulationCreationError> { fn key_event(event_source: CGEventSource, key: u16, state: u8) {
// Request both permissions up front so the user sees both TCC prompts
// on the first launch. See the matching comment in input-capture/src/
// macos.rs::request_macos_capture_permissions for the rationale.
let accessibility = request_accessibility_permission();
let input_control = request_input_control_permission();
if !accessibility {
return Err(MacOSEmulationCreationError::AccessibilityPermission);
}
if !input_control {
return Err(MacOSEmulationCreationError::InputControlPermission);
}
Ok(())
}
fn request_accessibility_permission() -> bool {
// Silent check. The GUI owns the one-time user-visible prompt at
// startup (see lan_mouse_gtk::macos_privacy).
unsafe { AXIsProcessTrusted() }
}
fn request_input_control_permission() -> bool {
unsafe { CGPreflightPostEventAccess() }
}
#[link(name = "CoreGraphics", kind = "framework")]
extern "C" {
fn CGPreflightPostEventAccess() -> bool;
}
#[link(name = "ApplicationServices", kind = "framework")]
extern "C" {
fn AXIsProcessTrusted() -> bool;
}
fn key_event(event_source: CGEventSource, key: u16, state: u8, modifiers: XMods) {
let event = match CGEvent::new_keyboard_event(event_source, key, state != 0) { let event = match CGEvent::new_keyboard_event(event_source, key, state != 0) {
Ok(e) => e, Ok(e) => e,
Err(_) => { Err(_) => {
@@ -165,287 +102,178 @@ fn key_event(event_source: CGEventSource, key: u16, state: u8, modifiers: XMods)
return; return;
} }
}; };
event.set_flags(to_cgevent_flags(modifiers));
event.post(CGEventTapLocation::HID); event.post(CGEventTapLocation::HID);
log::trace!("key event: {key} {state}");
}
fn modifier_event(event_source: CGEventSource, depressed: XMods) {
let Ok(event) = CGEvent::new(event_source) else {
log::warn!("could not create CGEvent");
return;
};
let flags = to_cgevent_flags(depressed);
event.set_type(CGEventType::FlagsChanged);
event.set_flags(flags);
event.post(CGEventTapLocation::HID);
log::trace!("modifiers updated: {depressed:?}");
}
fn get_display_at_point(x: CGFloat, y: CGFloat) -> Option<CGDirectDisplayID> {
let mut displays: [CGDirectDisplayID; 16] = [0; 16];
let mut display_count: u32 = 0;
let rect = CGRect::new(&CGPoint::new(x, y), &CGSize::new(0.0, 0.0));
let error = unsafe {
CGGetDisplaysWithRect(
rect,
1,
displays.as_mut_ptr(),
&mut display_count as *mut u32,
)
};
if error != 0 {
log::warn!("error getting displays at point ({x}, {y}): {error}");
return Option::None;
}
if display_count == 0 {
log::debug!("no displays found at point ({x}, {y})");
return Option::None;
}
displays.first().copied()
}
fn get_display_bounds(display: CGDirectDisplayID) -> (CGFloat, CGFloat, CGFloat, CGFloat) {
unsafe {
let bounds = CGDisplayBounds(display);
let min_x = bounds.origin.x;
let max_x = bounds.origin.x + bounds.size.width;
let min_y = bounds.origin.y;
let max_y = bounds.origin.y + bounds.size.height;
(min_x as f64, min_y as f64, max_x as f64, max_y as f64)
}
}
fn clamp_to_screen_space(
current_x: CGFloat,
current_y: CGFloat,
dx: CGFloat,
dy: CGFloat,
) -> (CGFloat, CGFloat) {
// Check which display the mouse is currently on
// Determine what the location of the mouse would be after applying the move
// Get the display at the new location
// If the point is not on a display
// Clamp the mouse to the current display
// Else If the point is on a display
// Clamp the mouse to the new display
let current_display = match get_display_at_point(current_x, current_y) {
Some(display) => display,
None => {
log::warn!("could not get current display!");
return (current_x, current_y);
}
};
let new_x = current_x + dx;
let new_y = current_y + dy;
let final_display = get_display_at_point(new_x, new_y).unwrap_or(current_display);
let (min_x, min_y, max_x, max_y) = get_display_bounds(final_display);
(
new_x.clamp(min_x, max_x - 1.),
new_y.clamp(min_y, max_y - 1.),
)
} }
#[async_trait] #[async_trait]
impl Emulation for MacOSEmulation { impl InputEmulation for MacOSEmulation {
async fn consume( async fn consume(
&mut self, &mut self,
event: Event, event: Event,
_handle: EmulationHandle, _handle: EmulationHandle,
) -> Result<(), EmulationError> { ) -> Result<(), EmulationError> {
log::trace!("{event:?}");
match event { match event {
Event::Pointer(pointer_event) => { Event::Pointer(pointer_event) => match pointer_event {
match pointer_event { PointerEvent::Motion {
PointerEvent::Motion { time: _, dx, dy } => { time: _,
let mut mouse_location = match self.get_mouse_location() { relative_x,
Some(l) => l, relative_y,
None => { } => {
log::warn!("could not get mouse location!"); // FIXME secondary displays?
return Ok(()); let (min_x, min_y, max_x, max_y) = unsafe {
} let display = CGMainDisplayID();
}; let bounds = CGDisplayBounds(display);
let min_x = bounds.origin.x;
let (new_mouse_x, new_mouse_y) = let max_x = bounds.origin.x + bounds.size.width;
clamp_to_screen_space(mouse_location.x, mouse_location.y, dx, dy); let min_y = bounds.origin.y;
let max_y = bounds.origin.y + bounds.size.height;
mouse_location.x = new_mouse_x; (min_x as f64, min_y as f64, max_x as f64, max_y as f64)
mouse_location.y = new_mouse_y; };
let mut mouse_location = match self.get_mouse_location() {
// If any button is held, emit a drag event for it; Some(l) => l,
// otherwise emit a normal mouse-moved event. None => {
let event_type = self log::warn!("could not get mouse location!");
.pressed_buttons return Ok(());
.iter()
.next()
.map(|&btn| drag_event_type(btn))
.unwrap_or(CGEventType::MouseMoved);
let event = match CGEvent::new_mouse_event(
self.event_source.clone(),
event_type,
mouse_location,
CGMouseButton::Left,
) {
Ok(e) => e,
Err(_) => {
log::warn!("mouse event creation failed!");
return Ok(());
}
};
event.set_integer_value_field(EventField::MOUSE_EVENT_DELTA_X, dx as i64);
event.set_integer_value_field(EventField::MOUSE_EVENT_DELTA_Y, dy as i64);
event.post(CGEventTapLocation::HID);
}
PointerEvent::Button {
time: _,
button,
state,
} => {
// button number for OtherMouse events (3 = back, 4 = forward, etc.)
let cg_button_number: Option<i64> = match button {
BTN_BACK => Some(3),
BTN_FORWARD => Some(4),
_ => None,
};
let (event_type, mouse_button) = match (button, state) {
(BTN_LEFT, 1) => (CGEventType::LeftMouseDown, CGMouseButton::Left),
(BTN_LEFT, 0) => (CGEventType::LeftMouseUp, CGMouseButton::Left),
(BTN_RIGHT, 1) => (CGEventType::RightMouseDown, CGMouseButton::Right),
(BTN_RIGHT, 0) => (CGEventType::RightMouseUp, CGMouseButton::Right),
(BTN_MIDDLE, 1) => (CGEventType::OtherMouseDown, CGMouseButton::Center),
(BTN_MIDDLE, 0) => (CGEventType::OtherMouseUp, CGMouseButton::Center),
(BTN_BACK, 1) | (BTN_FORWARD, 1) => {
(CGEventType::OtherMouseDown, CGMouseButton::Center)
}
(BTN_BACK, 0) | (BTN_FORWARD, 0) => {
(CGEventType::OtherMouseUp, CGMouseButton::Center)
}
_ => {
log::warn!("invalid button event: {button},{state}");
return Ok(());
}
};
// store button state using the evdev button code so
// back, forward, and middle are tracked independently
if state == 1 {
self.pressed_buttons.insert(button);
} else {
self.pressed_buttons.remove(&button);
} }
};
// update double-click tracking using the evdev button mouse_location.x = (mouse_location.x + relative_x).clamp(min_x, max_x - 1.);
// code so that back/forward don't alias with middle mouse_location.y = (mouse_location.y + relative_y).clamp(min_y, max_y - 1.);
if state == 1 {
if self.previous_button == Some(button)
&& self
.previous_button_click
.is_some_and(|i| i.elapsed() < DOUBLE_CLICK_INTERVAL)
{
self.button_click_state += 1;
} else {
self.button_click_state = 1;
}
self.previous_button = Some(button);
self.previous_button_click = Some(Instant::now());
}
log::debug!("click_state: {}", self.button_click_state); let mut event_type = CGEventType::MouseMoved;
let location = self.get_mouse_location().unwrap(); if self.button_state.left {
let event = match CGEvent::new_mouse_event( event_type = CGEventType::LeftMouseDragged
self.event_source.clone(), } else if self.button_state.right {
event_type, event_type = CGEventType::RightMouseDragged
location, } else if self.button_state.center {
mouse_button, event_type = CGEventType::OtherMouseDragged
) { };
Ok(e) => e, let event = match CGEvent::new_mouse_event(
Err(()) => { self.event_source.clone(),
log::warn!("mouse event creation failed!"); event_type,
return Ok(()); mouse_location,
} CGMouseButton::Left,
}; ) {
event.set_integer_value_field( Ok(e) => e,
EventField::MOUSE_EVENT_CLICK_STATE, Err(_) => {
self.button_click_state, log::warn!("mouse event creation failed!");
); return Ok(());
// Set the button number for extra buttons (back=3, forward=4)
if let Some(btn_num) = cg_button_number {
event.set_integer_value_field(
EventField::MOUSE_EVENT_BUTTON_NUMBER,
btn_num,
);
} }
event.post(CGEventTapLocation::HID); };
} event.set_integer_value_field(
PointerEvent::Axis { EventField::MOUSE_EVENT_DELTA_X,
time: _, relative_x as i64,
axis, );
value, event.set_integer_value_field(
} => { EventField::MOUSE_EVENT_DELTA_Y,
let value = value as i32; relative_y as i64,
let (count, wheel1, wheel2, wheel3) = match axis { );
0 => (1, value, 0, 0), // 0 = vertical => 1 scroll wheel device (y axis) event.post(CGEventTapLocation::HID);
1 => (2, 0, value, 0), // 1 = horizontal => 2 scroll wheel devices (y, x) -> (0, x)
_ => {
log::warn!("invalid scroll event: {axis}, {value}");
return Ok(());
}
};
let event = match CGEvent::new_scroll_event(
self.event_source.clone(),
ScrollEventUnit::PIXEL,
count,
wheel1,
wheel2,
wheel3,
) {
Ok(e) => e,
Err(()) => {
log::warn!("scroll event creation failed!");
return Ok(());
}
};
event.post(CGEventTapLocation::HID);
}
PointerEvent::AxisDiscrete120 { axis, value } => {
const LINES_PER_STEP: i32 = 3;
let (count, wheel1, wheel2, wheel3) = match axis {
0 => (1, value / (120 / LINES_PER_STEP), 0, 0), // 0 = vertical => 1 scroll wheel device (y axis)
1 => (2, 0, value / (120 / LINES_PER_STEP), 0), // 1 = horizontal => 2 scroll wheel devices (y, x) -> (0, x)
_ => {
log::warn!("invalid scroll event: {axis}, {value}");
return Ok(());
}
};
let event = match CGEvent::new_scroll_event(
self.event_source.clone(),
ScrollEventUnit::LINE,
count,
wheel1,
wheel2,
wheel3,
) {
Ok(e) => e,
Err(()) => {
log::warn!("scroll event creation failed!");
return Ok(());
}
};
event.post(CGEventTapLocation::HID);
}
} }
PointerEvent::Button {
time: _,
button,
state,
} => {
let (event_type, mouse_button) = match (button, state) {
(b, 1) if b == input_event::BTN_LEFT => {
(CGEventType::LeftMouseDown, CGMouseButton::Left)
}
(b, 0) if b == input_event::BTN_LEFT => {
(CGEventType::LeftMouseUp, CGMouseButton::Left)
}
(b, 1) if b == input_event::BTN_RIGHT => {
(CGEventType::RightMouseDown, CGMouseButton::Right)
}
(b, 0) if b == input_event::BTN_RIGHT => {
(CGEventType::RightMouseUp, CGMouseButton::Right)
}
(b, 1) if b == input_event::BTN_MIDDLE => {
(CGEventType::OtherMouseDown, CGMouseButton::Center)
}
(b, 0) if b == input_event::BTN_MIDDLE => {
(CGEventType::OtherMouseUp, CGMouseButton::Center)
}
_ => {
log::warn!("invalid button event: {button},{state}");
return Ok(());
}
};
// store button state
self.button_state[mouse_button] = state == 1;
// reset button click state in case it's not a button event let location = self.get_mouse_location().unwrap();
if !matches!(pointer_event, PointerEvent::Button { .. }) { let event = match CGEvent::new_mouse_event(
self.button_click_state = 0; self.event_source.clone(),
event_type,
location,
mouse_button,
) {
Ok(e) => e,
Err(()) => {
log::warn!("mouse event creation failed!");
return Ok(());
}
};
event.post(CGEventTapLocation::HID);
} }
} PointerEvent::Axis {
time: _,
axis,
value,
} => {
let value = value as i32;
let (count, wheel1, wheel2, wheel3) = match axis {
0 => (1, value, 0, 0), // 0 = vertical => 1 scroll wheel device (y axis)
1 => (2, 0, value, 0), // 1 = horizontal => 2 scroll wheel devices (y, x) -> (0, x)
_ => {
log::warn!("invalid scroll event: {axis}, {value}");
return Ok(());
}
};
let event = match CGEvent::new_scroll_event(
self.event_source.clone(),
ScrollEventUnit::PIXEL,
count,
wheel1,
wheel2,
wheel3,
) {
Ok(e) => e,
Err(()) => {
log::warn!("scroll event creation failed!");
return Ok(());
}
};
event.post(CGEventTapLocation::HID);
}
PointerEvent::AxisDiscrete120 { axis, value } => {
let (count, wheel1, wheel2, wheel3) = match axis {
0 => (1, value, 0, 0), // 0 = vertical => 1 scroll wheel device (y axis)
1 => (2, 0, value, 0), // 1 = horizontal => 2 scroll wheel devices (y, x) -> (0, x)
_ => {
log::warn!("invalid scroll event: {axis}, {value}");
return Ok(());
}
};
let event = match CGEvent::new_scroll_event(
self.event_source.clone(),
ScrollEventUnit::PIXEL,
count,
wheel1,
wheel2,
wheel3,
) {
Ok(e) => e,
Err(()) => {
log::warn!("scroll event creation failed!");
return Ok(());
}
};
event.post(CGEventTapLocation::HID);
}
PointerEvent::Frame { .. } => {}
},
Event::Keyboard(keyboard_event) => match keyboard_event { Event::Keyboard(keyboard_event) => match keyboard_event {
KeyboardEvent::Key { KeyboardEvent::Key {
time: _, time: _,
@@ -459,26 +287,16 @@ impl Emulation for MacOSEmulation {
return Ok(()); return Ok(());
} }
}; };
let is_modifier = update_modifiers(&self.modifier_state, key, state);
if is_modifier {
modifier_event(self.event_source.clone(), self.modifier_state.get());
}
match state { match state {
// pressed // pressed
1 => self.spawn_repeat_task(code).await, 1 => self.spawn_repeat_task(code).await,
_ => self.cancel_repeat_task().await, _ => self.kill_repeat_task(),
} }
key_event(self.event_source.clone(), code, state)
} }
KeyboardEvent::Modifiers { KeyboardEvent::Modifiers { .. } => {}
depressed,
latched,
locked,
group,
} => {
set_modifiers(&self.modifier_state, depressed, latched, locked, group);
modifier_event(self.event_source.clone(), self.modifier_state.get());
}
}, },
_ => (),
} }
// FIXME // FIXME
Ok(()) Ok(())
@@ -487,84 +305,4 @@ impl Emulation for MacOSEmulation {
async fn create(&mut self, _handle: EmulationHandle) {} async fn create(&mut self, _handle: EmulationHandle) {}
async fn destroy(&mut self, _handle: EmulationHandle) {} async fn destroy(&mut self, _handle: EmulationHandle) {}
async fn terminate(&mut self) {}
}
fn update_modifiers(modifiers: &Cell<XMods>, key: u32, state: u8) -> bool {
if let Ok(key) = scancode::Linux::try_from(key) {
let mask = match key {
scancode::Linux::KeyLeftShift | scancode::Linux::KeyRightShift => XMods::ShiftMask,
scancode::Linux::KeyCapsLock => XMods::LockMask,
scancode::Linux::KeyLeftCtrl | scancode::Linux::KeyRightCtrl => XMods::ControlMask,
scancode::Linux::KeyLeftAlt | scancode::Linux::KeyRightalt => XMods::Mod1Mask,
scancode::Linux::KeyLeftMeta | scancode::Linux::KeyRightmeta => XMods::Mod4Mask,
_ => XMods::empty(),
};
// unchanged
if mask.is_empty() {
return false;
}
let mut mods = modifiers.get();
match state {
1 => mods.insert(mask),
_ => mods.remove(mask),
}
modifiers.set(mods);
true
} else {
false
}
}
fn set_modifiers(
active_modifiers: &Cell<XMods>,
depressed: u32,
latched: u32,
locked: u32,
group: u32,
) {
let depressed = XMods::from_bits(depressed).unwrap_or_default();
let _latched = XMods::from_bits(latched).unwrap_or_default();
let _locked = XMods::from_bits(locked).unwrap_or_default();
let _group = XMods::from_bits(group).unwrap_or_default();
// we only care about the depressed modifiers for now
active_modifiers.replace(depressed);
}
fn to_cgevent_flags(depressed: XMods) -> CGEventFlags {
let mut flags = CGEventFlags::empty();
if depressed.contains(XMods::ShiftMask) {
flags |= CGEventFlags::CGEventFlagShift;
}
if depressed.contains(XMods::LockMask) {
flags |= CGEventFlags::CGEventFlagAlphaShift;
}
if depressed.contains(XMods::ControlMask) {
flags |= CGEventFlags::CGEventFlagControl;
}
if depressed.contains(XMods::Mod1Mask) {
flags |= CGEventFlags::CGEventFlagAlternate;
}
if depressed.contains(XMods::Mod4Mask) {
flags |= CGEventFlags::CGEventFlagCommand;
}
flags
}
// From X11/X.h
bitflags! {
#[repr(C)]
#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
struct XMods: u32 {
const ShiftMask = (1<<0);
const LockMask = (1<<1);
const ControlMask = (1<<2);
const Mod1Mask = (1<<3);
const Mod2Mask = (1<<4);
const Mod3Mask = (1<<5);
const Mod4Mask = (1<<6);
const Mod5Mask = (1<<7);
}
} }

View File

@@ -1,46 +1,50 @@
use super::error::{EmulationError, WindowsEmulationCreationError}; use super::error::{EmulationError, WindowsEmulationCreationError};
use input_event::{ use input_event::{
BTN_BACK, BTN_FORWARD, BTN_LEFT, BTN_MIDDLE, BTN_RIGHT, Event, KeyboardEvent, PointerEvent, scancode, Event, KeyboardEvent, PointerEvent, BTN_BACK, BTN_FORWARD, BTN_LEFT, BTN_MIDDLE,
scancode, BTN_RIGHT,
}; };
use async_trait::async_trait; use async_trait::async_trait;
use std::ops::BitOrAssign; use std::ops::BitOrAssign;
use std::time::Duration; use std::time::Duration;
use tokio::task::AbortHandle; use tokio::task::AbortHandle;
use windows::Win32::UI::Input::KeyboardAndMouse::{
SendInput, INPUT_0, KEYEVENTF_EXTENDEDKEY, MOUSEEVENTF_XDOWN, MOUSEEVENTF_XUP,
};
use windows::Win32::UI::Input::KeyboardAndMouse::{ use windows::Win32::UI::Input::KeyboardAndMouse::{
INPUT, INPUT_KEYBOARD, INPUT_MOUSE, KEYBDINPUT, KEYEVENTF_KEYUP, KEYEVENTF_SCANCODE, INPUT, INPUT_KEYBOARD, INPUT_MOUSE, KEYBDINPUT, KEYEVENTF_KEYUP, KEYEVENTF_SCANCODE,
MOUSEEVENTF_HWHEEL, MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP, MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_HWHEEL, MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP, MOUSEEVENTF_MIDDLEDOWN,
MOUSEEVENTF_MIDDLEUP, MOUSEEVENTF_MOVE, MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP, MOUSEEVENTF_MIDDLEUP, MOUSEEVENTF_MOVE, MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP,
MOUSEEVENTF_WHEEL, MOUSEINPUT, MOUSEEVENTF_WHEEL, MOUSEINPUT,
}; };
use windows::Win32::UI::Input::KeyboardAndMouse::{
INPUT_0, KEYEVENTF_EXTENDEDKEY, MOUSEEVENTF_XDOWN, MOUSEEVENTF_XUP, SendInput,
};
use windows::Win32::UI::WindowsAndMessaging::{XBUTTON1, XBUTTON2}; use windows::Win32::UI::WindowsAndMessaging::{XBUTTON1, XBUTTON2};
use super::{Emulation, EmulationHandle}; use super::{EmulationHandle, InputEmulation};
const DEFAULT_REPEAT_DELAY: Duration = Duration::from_millis(500); const DEFAULT_REPEAT_DELAY: Duration = Duration::from_millis(500);
const DEFAULT_REPEAT_INTERVAL: Duration = Duration::from_millis(32); const DEFAULT_REPEAT_INTERVAL: Duration = Duration::from_millis(32);
pub(crate) struct WindowsEmulation { pub struct WindowsEmulation {
repeat_task: Option<AbortHandle>, repeat_task: Option<AbortHandle>,
} }
impl WindowsEmulation { impl WindowsEmulation {
pub(crate) fn new() -> Result<Self, WindowsEmulationCreationError> { pub fn new() -> Result<Self, WindowsEmulationCreationError> {
Ok(Self { repeat_task: None }) Ok(Self { repeat_task: None })
} }
} }
#[async_trait] #[async_trait]
impl Emulation for WindowsEmulation { impl InputEmulation for WindowsEmulation {
async fn consume(&mut self, event: Event, _: EmulationHandle) -> Result<(), EmulationError> { async fn consume(&mut self, event: Event, _: EmulationHandle) -> Result<(), EmulationError> {
match event { match event {
Event::Pointer(pointer_event) => match pointer_event { Event::Pointer(pointer_event) => match pointer_event {
PointerEvent::Motion { time: _, dx, dy } => { PointerEvent::Motion {
rel_mouse(dx as i32, dy as i32); time: _,
relative_x,
relative_y,
} => {
rel_mouse(relative_x as i32, relative_y as i32);
} }
PointerEvent::Button { PointerEvent::Button {
time: _, time: _,
@@ -53,6 +57,7 @@ impl Emulation for WindowsEmulation {
value, value,
} => scroll(axis, value as i32), } => scroll(axis, value as i32),
PointerEvent::AxisDiscrete120 { axis, value } => scroll(axis, value), PointerEvent::AxisDiscrete120 { axis, value } => scroll(axis, value),
PointerEvent::Frame {} => {}
}, },
Event::Keyboard(keyboard_event) => match keyboard_event { Event::Keyboard(keyboard_event) => match keyboard_event {
KeyboardEvent::Key { KeyboardEvent::Key {
@@ -70,6 +75,7 @@ impl Emulation for WindowsEmulation {
} }
KeyboardEvent::Modifiers { .. } => {} KeyboardEvent::Modifiers { .. } => {}
}, },
_ => {}
} }
// FIXME // FIXME
Ok(()) Ok(())
@@ -78,8 +84,6 @@ impl Emulation for WindowsEmulation {
async fn create(&mut self, _handle: EmulationHandle) {} async fn create(&mut self, _handle: EmulationHandle) {}
async fn destroy(&mut self, _handle: EmulationHandle) {} async fn destroy(&mut self, _handle: EmulationHandle) {}
async fn terminate(&mut self) {}
} }
impl WindowsEmulation { impl WindowsEmulation {

View File

@@ -1,18 +1,15 @@
use crate::error::EmulationError; use crate::error::EmulationError;
use super::{Emulation, error::WlrootsEmulationCreationError}; use super::{error::WlrootsEmulationCreationError, InputEmulation};
use async_trait::async_trait; use async_trait::async_trait;
use bitflags::bitflags;
use std::collections::HashMap; use std::collections::HashMap;
use std::io; use std::io;
use std::os::fd::{AsFd, OwnedFd}; use std::os::fd::{AsFd, OwnedFd};
use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
use wayland_client::WEnum;
use wayland_client::backend::WaylandError; use wayland_client::backend::WaylandError;
use wayland_client::WEnum;
use wayland_client::protocol::wl_keyboard::{self, WlKeyboard}; use wayland_client::protocol::wl_keyboard::{self, WlKeyboard};
use wayland_client::protocol::wl_pointer::{Axis, AxisSource, ButtonState}; use wayland_client::protocol::wl_pointer::{Axis, ButtonState};
use wayland_client::protocol::wl_seat::WlSeat; use wayland_client::protocol::wl_seat::WlSeat;
use wayland_protocols_wlr::virtual_pointer::v1::client::{ use wayland_protocols_wlr::virtual_pointer::v1::client::{
zwlr_virtual_pointer_manager_v1::ZwlrVirtualPointerManagerV1 as VpManager, zwlr_virtual_pointer_manager_v1::ZwlrVirtualPointerManagerV1 as VpManager,
@@ -25,15 +22,16 @@ use wayland_protocols_misc::zwp_virtual_keyboard_v1::client::{
}; };
use wayland_client::{ use wayland_client::{
Connection, Dispatch, EventQueue, QueueHandle, delegate_noop, delegate_noop,
globals::{GlobalListContents, registry_queue_init}, globals::{registry_queue_init, GlobalListContents},
protocol::{wl_registry, wl_seat}, protocol::{wl_registry, wl_seat},
Connection, Dispatch, EventQueue, QueueHandle,
}; };
use input_event::{Event, KeyboardEvent, PointerEvent, scancode}; use input_event::{Event, KeyboardEvent, PointerEvent};
use super::EmulationHandle;
use super::error::WaylandBindError; use super::error::WaylandBindError;
use super::EmulationHandle;
struct State { struct State {
keymap: Option<(u32, OwnedFd, u32)>, keymap: Option<(u32, OwnedFd, u32)>,
@@ -52,7 +50,7 @@ pub(crate) struct WlrootsEmulation {
} }
impl WlrootsEmulation { impl WlrootsEmulation {
pub(crate) fn new() -> Result<Self, WlrootsEmulationCreationError> { pub fn new() -> Result<Self, WlrootsEmulationCreationError> {
let conn = Connection::connect_to_env()?; let conn = Connection::connect_to_env()?;
let (globals, queue) = registry_queue_init::<State>(&conn)?; let (globals, queue) = registry_queue_init::<State>(&conn)?;
let qh = queue.handle(); let qh = queue.handle();
@@ -104,11 +102,7 @@ impl State {
panic!("no keymap"); panic!("no keymap");
} }
let vinput = VirtualInput { let vinput = VirtualInput { pointer, keyboard };
pointer,
keyboard,
modifiers: Arc::new(Mutex::new(XMods::empty())),
};
self.input_for_client.insert(client, vinput); self.input_for_client.insert(client, vinput);
} }
@@ -122,7 +116,7 @@ impl State {
} }
#[async_trait] #[async_trait]
impl Emulation for WlrootsEmulation { impl InputEmulation for WlrootsEmulation {
async fn consume( async fn consume(
&mut self, &mut self,
event: Event, event: Event,
@@ -162,37 +156,32 @@ impl Emulation for WlrootsEmulation {
async fn create(&mut self, handle: EmulationHandle) { async fn create(&mut self, handle: EmulationHandle) {
self.state.add_client(handle); self.state.add_client(handle);
if let Err(e) = self.queue.flush() { if let Err(e) = self.queue.flush() {
log::error!("{e}"); log::error!("{}", e);
} }
} }
async fn destroy(&mut self, handle: EmulationHandle) { async fn destroy(&mut self, handle: EmulationHandle) {
self.state.destroy_client(handle); self.state.destroy_client(handle);
if let Err(e) = self.queue.flush() { if let Err(e) = self.queue.flush() {
log::error!("{e}"); log::error!("{}", e);
} }
} }
async fn terminate(&mut self) {
/* nothing to do */
}
} }
struct VirtualInput { struct VirtualInput {
pointer: Vp, pointer: Vp,
keyboard: Vk, keyboard: Vk,
modifiers: Arc<Mutex<XMods>>,
} }
impl VirtualInput { impl VirtualInput {
fn consume_event(&self, event: Event) -> Result<(), ()> { fn consume_event(&self, event: Event) -> Result<(), ()> {
let now: u32 = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u32;
match event { match event {
Event::Pointer(e) => { Event::Pointer(e) => {
match e { match e {
PointerEvent::Motion { time, dx, dy } => self.pointer.motion(time, dx, dy), PointerEvent::Motion {
time,
relative_x,
relative_y,
} => self.pointer.motion(time, relative_x, relative_y),
PointerEvent::Button { PointerEvent::Button {
time, time,
button, button,
@@ -209,42 +198,28 @@ impl VirtualInput {
PointerEvent::AxisDiscrete120 { axis, value } => { PointerEvent::AxisDiscrete120 { axis, value } => {
let axis: Axis = (axis as u32).try_into()?; let axis: Axis = (axis as u32).try_into()?;
self.pointer self.pointer
.axis_discrete(now, axis, value as f64 / 8., value / 120); .axis_discrete(0, axis, value as f64 / 6., value / 120);
self.pointer.axis_source(AxisSource::Wheel);
self.pointer.frame(); self.pointer.frame();
} }
PointerEvent::Frame {} => self.pointer.frame(),
} }
self.pointer.frame(); self.pointer.frame();
} }
Event::Keyboard(e) => match e { Event::Keyboard(e) => match e {
KeyboardEvent::Key { time, key, state } => { KeyboardEvent::Key { time, key, state } => {
self.keyboard.key(time, key, state as u32); self.keyboard.key(time, key, state as u32);
if let Ok(mut mods) = self.modifiers.lock() {
if mods.update_by_key_event(key, state) {
log::trace!("Key triggers modifier change: {mods:?}");
self.keyboard.modifiers(
mods.mask_pressed().bits(),
0,
mods.mask_locks().bits(),
0,
);
}
}
} }
KeyboardEvent::Modifiers { KeyboardEvent::Modifiers {
depressed: mods_depressed, mods_depressed,
latched: mods_latched, mods_latched,
locked: mods_locked, mods_locked,
group, group,
} => { } => {
// Synchronize internal modifier state, assuming server is authoritative
if let Ok(mut mods) = self.modifiers.lock() {
mods.update_by_mods_event(e);
}
self.keyboard self.keyboard
.modifiers(mods_depressed, mods_latched, mods_locked, group); .modifiers(mods_depressed, mods_latched, mods_locked, group);
} }
}, },
_ => {}
} }
Ok(()) Ok(())
} }
@@ -301,74 +276,3 @@ impl Dispatch<WlSeat, ()> for State {
} }
} }
} }
// From X11/X.h
bitflags! {
#[repr(C)]
#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
struct XMods: u32 {
const ShiftMask = (1<<0);
const LockMask = (1<<1);
const ControlMask = (1<<2);
const Mod1Mask = (1<<3);
const Mod2Mask = (1<<4);
const Mod3Mask = (1<<5);
const Mod4Mask = (1<<6);
const Mod5Mask = (1<<7);
}
}
impl XMods {
fn update_by_mods_event(&mut self, evt: KeyboardEvent) {
if let KeyboardEvent::Modifiers {
depressed, locked, ..
} = evt
{
*self = XMods::from_bits_truncate(depressed) | XMods::from_bits_truncate(locked);
}
}
fn update_by_key_event(&mut self, key: u32, state: u8) -> bool {
if let Ok(key) = scancode::Linux::try_from(key) {
log::trace!("Attempting to process modifier from: {key:#?}");
let pressed_mask = match key {
scancode::Linux::KeyLeftShift | scancode::Linux::KeyRightShift => XMods::ShiftMask,
scancode::Linux::KeyLeftCtrl | scancode::Linux::KeyRightCtrl => XMods::ControlMask,
scancode::Linux::KeyLeftAlt | scancode::Linux::KeyRightalt => XMods::Mod1Mask,
scancode::Linux::KeyLeftMeta | scancode::Linux::KeyRightmeta => XMods::Mod4Mask,
_ => XMods::empty(),
};
let locked_mask = match key {
scancode::Linux::KeyCapsLock => XMods::LockMask,
scancode::Linux::KeyNumlock => XMods::Mod2Mask,
scancode::Linux::KeyScrollLock => XMods::Mod3Mask,
_ => XMods::empty(),
};
// unchanged
if pressed_mask.is_empty() && locked_mask.is_empty() {
log::trace!("{key:#?} is not a modifier key");
return false;
}
match state {
1 => self.insert(pressed_mask),
_ => {
self.remove(pressed_mask);
self.toggle(locked_mask);
}
}
true
} else {
false
}
}
fn mask_locks(&self) -> XMods {
*self & (XMods::LockMask | XMods::Mod2Mask | XMods::Mod3Mask)
}
fn mask_pressed(&self) -> XMods {
*self & (XMods::ShiftMask | XMods::ControlMask | XMods::Mod1Mask | XMods::Mod4Mask)
}
}

View File

@@ -6,24 +6,24 @@ use x11::{
}; };
use input_event::{ use input_event::{
BTN_BACK, BTN_FORWARD, BTN_LEFT, BTN_MIDDLE, BTN_RIGHT, Event, KeyboardEvent, PointerEvent, Event, KeyboardEvent, PointerEvent, BTN_BACK, BTN_FORWARD, BTN_LEFT, BTN_MIDDLE, BTN_RIGHT,
}; };
use crate::error::EmulationError; use crate::error::EmulationError;
use super::{Emulation, EmulationHandle, error::X11EmulationCreationError}; use super::{error::X11EmulationCreationError, EmulationHandle, InputEmulation};
pub(crate) struct X11Emulation { pub struct X11Emulation {
display: *mut xlib::Display, display: *mut xlib::Display,
} }
unsafe impl Send for X11Emulation {} unsafe impl Send for X11Emulation {}
impl X11Emulation { impl X11Emulation {
pub(crate) fn new() -> Result<Self, X11EmulationCreationError> { pub fn new() -> Result<Self, X11EmulationCreationError> {
let display = unsafe { let display = unsafe {
match xlib::XOpenDisplay(ptr::null()) { match xlib::XOpenDisplay(ptr::null()) {
d if std::ptr::eq(d, ptr::null_mut::<xlib::Display>()) => { d if d == ptr::null::<xlib::Display>() as *mut xlib::Display => {
Err(X11EmulationCreationError::OpenDisplay) Err(X11EmulationCreationError::OpenDisplay)
} }
display => Ok(display), display => Ok(display),
@@ -99,12 +99,16 @@ impl Drop for X11Emulation {
} }
#[async_trait] #[async_trait]
impl Emulation for X11Emulation { impl InputEmulation for X11Emulation {
async fn consume(&mut self, event: Event, _: EmulationHandle) -> Result<(), EmulationError> { async fn consume(&mut self, event: Event, _: EmulationHandle) -> Result<(), EmulationError> {
match event { match event {
Event::Pointer(pointer_event) => match pointer_event { Event::Pointer(pointer_event) => match pointer_event {
PointerEvent::Motion { time: _, dx, dy } => { PointerEvent::Motion {
self.relative_motion(dx as i32, dy as i32); time: _,
relative_x,
relative_y,
} => {
self.relative_motion(relative_x as i32, relative_y as i32);
} }
PointerEvent::Button { PointerEvent::Button {
time: _, time: _,
@@ -123,6 +127,7 @@ impl Emulation for X11Emulation {
PointerEvent::AxisDiscrete120 { axis, value } => { PointerEvent::AxisDiscrete120 { axis, value } => {
self.emulate_scroll(axis, value as f64); self.emulate_scroll(axis, value as f64);
} }
PointerEvent::Frame {} => {}
}, },
Event::Keyboard(KeyboardEvent::Key { Event::Keyboard(KeyboardEvent::Key {
time: _, time: _,
@@ -147,8 +152,4 @@ impl Emulation for X11Emulation {
async fn destroy(&mut self, _: EmulationHandle) { async fn destroy(&mut self, _: EmulationHandle) {
// for our purposes it does not matter what client sent the event // for our purposes it does not matter what client sent the event
} }
async fn terminate(&mut self) {
/* nothing to do */
}
} }

View File

@@ -1,12 +1,11 @@
use anyhow::Result;
use ashpd::{ use ashpd::{
desktop::{ desktop::{
PersistMode, Session, remote_desktop::{Axis, DeviceType, KeyState, RemoteDesktop},
remote_desktop::{ ResponseError, Session,
Axis, DeviceType, KeyState, NotifyPointerAxisOptions, RemoteDesktop,
SelectDevicesOptions,
},
}, },
zbus::AsyncDrop, zbus::AsyncDrop,
WindowIdentifier,
}; };
use async_trait::async_trait; use async_trait::async_trait;
@@ -18,33 +17,42 @@ use input_event::{
use crate::error::EmulationError; use crate::error::EmulationError;
use super::{Emulation, EmulationHandle, error::XdpEmulationCreationError}; use super::{error::XdpEmulationCreationError, EmulationHandle, InputEmulation};
pub(crate) struct DesktopPortalEmulation { pub struct DesktopPortalEmulation<'a> {
proxy: RemoteDesktop, proxy: RemoteDesktop<'a>,
session: Session<RemoteDesktop>, session: Session<'a>,
} }
impl DesktopPortalEmulation { impl<'a> DesktopPortalEmulation<'a> {
pub(crate) async fn new() -> Result<DesktopPortalEmulation, XdpEmulationCreationError> { pub async fn new() -> Result<DesktopPortalEmulation<'a>, XdpEmulationCreationError> {
log::debug!("connecting to org.freedesktop.portal.RemoteDesktop portal ..."); log::debug!("connecting to org.freedesktop.portal.RemoteDesktop portal ...");
let proxy = RemoteDesktop::new().await?; let proxy = RemoteDesktop::new().await?;
// retry when user presses the cancel button // retry when user presses the cancel button
log::debug!("creating session ..."); let (session, _) = loop {
let session = proxy.create_session(Default::default()).await?; log::debug!("creating session ...");
let session = proxy.create_session().await?;
log::debug!("selecting devices ..."); log::debug!("selecting devices ...");
let options = SelectDevicesOptions::default() proxy
.set_devices(DeviceType::Keyboard | DeviceType::Pointer) .select_devices(&session, DeviceType::Keyboard | DeviceType::Pointer)
.set_persist_mode(PersistMode::ExplicitlyRevoked); .await?;
proxy.select_devices(&session, options).await?;
log::info!("requesting permission for input emulation"); log::info!("requesting permission for input emulation");
let _devices = proxy match proxy
.start(&session, None, Default::default()) .start(&session, &WindowIdentifier::default())
.await? .await?
.response()?; .response()
{
Ok(d) => break (session, d),
Err(ashpd::Error::Response(ResponseError::Cancelled)) => {
log::warn!("request cancelled!");
continue;
}
e => e?,
};
};
log::debug!("started session"); log::debug!("started session");
let session = session; let session = session;
@@ -54,7 +62,7 @@ impl DesktopPortalEmulation {
} }
#[async_trait] #[async_trait]
impl Emulation for DesktopPortalEmulation { impl<'a> InputEmulation for DesktopPortalEmulation<'a> {
async fn consume( async fn consume(
&mut self, &mut self,
event: input_event::Event, event: input_event::Event,
@@ -62,9 +70,13 @@ impl Emulation for DesktopPortalEmulation {
) -> Result<(), EmulationError> { ) -> Result<(), EmulationError> {
match event { match event {
Pointer(p) => match p { Pointer(p) => match p {
PointerEvent::Motion { time: _, dx, dy } => { PointerEvent::Motion {
time: _,
relative_x,
relative_y,
} => {
self.proxy self.proxy
.notify_pointer_motion(&self.session, dx, dy, Default::default()) .notify_pointer_motion(&self.session, relative_x, relative_y)
.await?; .await?;
} }
PointerEvent::Button { PointerEvent::Button {
@@ -77,12 +89,7 @@ impl Emulation for DesktopPortalEmulation {
_ => KeyState::Pressed, _ => KeyState::Pressed,
}; };
self.proxy self.proxy
.notify_pointer_button( .notify_pointer_button(&self.session, button as i32, state)
&self.session,
button as i32,
state,
Default::default(),
)
.await?; .await?;
} }
PointerEvent::AxisDiscrete120 { axis, value } => { PointerEvent::AxisDiscrete120 { axis, value } => {
@@ -91,12 +98,7 @@ impl Emulation for DesktopPortalEmulation {
_ => Axis::Horizontal, _ => Axis::Horizontal,
}; };
self.proxy self.proxy
.notify_pointer_axis_discrete( .notify_pointer_axis_discrete(&self.session, axis, value)
&self.session,
axis,
value / 120,
Default::default(),
)
.await?; .await?;
} }
PointerEvent::Axis { PointerEvent::Axis {
@@ -113,14 +115,10 @@ impl Emulation for DesktopPortalEmulation {
Axis::Horizontal => (value, 0.), Axis::Horizontal => (value, 0.),
}; };
self.proxy self.proxy
.notify_pointer_axis( .notify_pointer_axis(&self.session, dx, dy, true)
&self.session,
dx,
dy,
NotifyPointerAxisOptions::default().set_finish(true),
)
.await?; .await?;
} }
PointerEvent::Frame {} => {}
}, },
Keyboard(k) => { Keyboard(k) => {
match k { match k {
@@ -134,12 +132,7 @@ impl Emulation for DesktopPortalEmulation {
_ => KeyState::Pressed, _ => KeyState::Pressed,
}; };
self.proxy self.proxy
.notify_keyboard_keycode( .notify_keyboard_keycode(&self.session, key as i32, state)
&self.session,
key as i32,
state,
Default::default(),
)
.await?; .await?;
} }
KeyboardEvent::Modifiers { .. } => { KeyboardEvent::Modifiers { .. } => {
@@ -147,24 +140,18 @@ impl Emulation for DesktopPortalEmulation {
} }
} }
} }
_ => {}
} }
Ok(()) Ok(())
} }
async fn create(&mut self, _client: EmulationHandle) {} async fn create(&mut self, _client: EmulationHandle) {}
async fn destroy(&mut self, _client: EmulationHandle) {} async fn destroy(&mut self, _client: EmulationHandle) {}
async fn terminate(&mut self) {
if let Err(e) = self.session.close().await {
log::warn!("session.close(): {e}");
};
if let Err(e) = self.session.receive_closed().await {
log::warn!("session.receive_closed(): {e}");
};
}
} }
impl AsyncDrop for DesktopPortalEmulation { impl<'a> AsyncDrop for DesktopPortalEmulation<'a> {
#[doc = r" Perform the async cleanup."] #[doc = r" Perform the async cleanup."]
#[must_use]
#[allow(clippy::type_complexity, clippy::type_repetition_in_bounds)] #[allow(clippy::type_complexity, clippy::type_repetition_in_bounds)]
fn async_drop<'async_trait>( fn async_drop<'async_trait>(
self, self,

View File

@@ -1,21 +1,14 @@
[package] [package]
name = "input-event" name = "input-event"
description = "cross-platform input-event types for input-capture / input-emulation" description = "cross-platform input-event types for input-capture / input-emulation"
version = "0.3.0" version = "0.1.0"
edition = "2021" edition = "2021"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"
repository = "https://github.com/feschber/lan-mouse" repository = "https://github.com/ferdinandschober/lan-mouse"
[dependencies] [dependencies]
anyhow = "1.0.86"
futures-core = "0.3.30" futures-core = "0.3.30"
log = "0.4.22" log = "0.4.22"
num_enum = "0.7.2" num_enum = "0.7.2"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
thiserror = "2.0.0"
[target.'cfg(all(unix, not(target_os="macos")))'.dependencies]
reis = { version = "0.5.0", optional = true }
[features]
default = ["libei"]
libei = ["dep:reis"]

View File

@@ -1 +0,0 @@

View File

@@ -1,11 +1,11 @@
use std::fmt::{self, Display}; use anyhow::{anyhow, Result};
use std::{
error::Error,
fmt::{self, Display},
};
pub mod error;
pub mod scancode; pub mod scancode;
#[cfg(all(unix, feature = "libei", not(target_os = "macos")))]
mod libei;
// FIXME // FIXME
pub const BTN_LEFT: u32 = 0x110; pub const BTN_LEFT: u32 = 0x110;
pub const BTN_RIGHT: u32 = 0x111; pub const BTN_RIGHT: u32 = 0x111;
@@ -15,25 +15,39 @@ pub const BTN_FORWARD: u32 = 0x114;
#[derive(Debug, PartialEq, Clone, Copy)] #[derive(Debug, PartialEq, Clone, Copy)]
pub enum PointerEvent { pub enum PointerEvent {
/// relative motion event Motion {
Motion { time: u32, dx: f64, dy: f64 }, time: u32,
/// mouse button event relative_x: f64,
Button { time: u32, button: u32, state: u32 }, relative_y: f64,
/// axis event, scroll event for touchpads },
Axis { time: u32, axis: u8, value: f64 }, Button {
/// discrete axis event, scroll event for mice - 120 = one scroll tick time: u32,
AxisDiscrete120 { axis: u8, value: i32 }, button: u32,
state: u32,
},
Axis {
time: u32,
axis: u8,
value: f64,
},
AxisDiscrete120 {
axis: u8,
value: i32,
},
Frame {},
} }
#[derive(Debug, PartialEq, Clone, Copy)] #[derive(Debug, PartialEq, Clone, Copy)]
pub enum KeyboardEvent { pub enum KeyboardEvent {
/// a key press / release event Key {
Key { time: u32, key: u32, state: u8 }, time: u32,
/// modifiers changed state key: u32,
state: u8,
},
Modifiers { Modifiers {
depressed: u32, mods_depressed: u32,
latched: u32, mods_latched: u32,
locked: u32, mods_locked: u32,
group: u32, group: u32,
}, },
} }
@@ -44,12 +58,33 @@ pub enum Event {
Pointer(PointerEvent), Pointer(PointerEvent),
/// keyboard events (key / modifiers) /// keyboard events (key / modifiers)
Keyboard(KeyboardEvent), Keyboard(KeyboardEvent),
/// enter event: request to enter a client.
/// The client must release the pointer if it is grabbed
/// and reply with a leave event, as soon as its ready to
/// receive events
Enter(),
/// leave event: this client is now ready to receive events and will
/// not send any events after until it sends an enter event
Leave(),
/// ping a client, to see if it is still alive. A client that does
/// not respond with a pong event will be assumed to be offline.
Ping(),
/// response to a ping event: this event signals that a client
/// is still alive but must otherwise be ignored
Pong(),
/// explicit disconnect request. The client will no longer
/// send events until the next Enter event. All of its keys should be released.
Disconnect(),
} }
impl Display for PointerEvent { impl Display for PointerEvent {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self { match self {
PointerEvent::Motion { time: _, dx, dy } => write!(f, "motion({dx},{dy})"), PointerEvent::Motion {
time: _,
relative_x,
relative_y,
} => write!(f, "motion({relative_x},{relative_y})"),
PointerEvent::Button { PointerEvent::Button {
time: _, time: _,
button, button,
@@ -77,6 +112,7 @@ impl Display for PointerEvent {
PointerEvent::AxisDiscrete120 { axis, value } => { PointerEvent::AxisDiscrete120 { axis, value } => {
write!(f, "scroll-120 ({axis}, {value})") write!(f, "scroll-120 ({axis}, {value})")
} }
PointerEvent::Frame {} => write!(f, "frame()"),
} }
} }
} }
@@ -97,9 +133,9 @@ impl Display for KeyboardEvent {
} }
} }
KeyboardEvent::Modifiers { KeyboardEvent::Modifiers {
depressed: mods_depressed, mods_depressed,
latched: mods_latched, mods_latched,
locked: mods_locked, mods_locked,
group, group,
} => write!( } => write!(
f, f,
@@ -112,8 +148,440 @@ impl Display for KeyboardEvent {
impl Display for Event { impl Display for Event {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self { match self {
Event::Pointer(p) => write!(f, "{p}"), Event::Pointer(p) => write!(f, "{}", p),
Event::Keyboard(k) => write!(f, "{k}"), Event::Keyboard(k) => write!(f, "{}", k),
Event::Enter() => write!(f, "enter"),
Event::Leave() => write!(f, "leave"),
Event::Ping() => write!(f, "ping"),
Event::Pong() => write!(f, "pong"),
Event::Disconnect() => write!(f, "disconnect"),
}
}
}
impl Event {
fn event_type(&self) -> EventType {
match self {
Self::Pointer(_) => EventType::Pointer,
Self::Keyboard(_) => EventType::Keyboard,
Self::Enter() => EventType::Enter,
Self::Leave() => EventType::Leave,
Self::Ping() => EventType::Ping,
Self::Pong() => EventType::Pong,
Self::Disconnect() => EventType::Disconnect,
}
}
}
impl PointerEvent {
fn event_type(&self) -> PointerEventType {
match self {
Self::Motion { .. } => PointerEventType::Motion,
Self::Button { .. } => PointerEventType::Button,
Self::Axis { .. } => PointerEventType::Axis,
Self::AxisDiscrete120 { .. } => PointerEventType::AxisDiscrete120,
Self::Frame { .. } => PointerEventType::Frame,
}
}
}
impl KeyboardEvent {
fn event_type(&self) -> KeyboardEventType {
match self {
KeyboardEvent::Key { .. } => KeyboardEventType::Key,
KeyboardEvent::Modifiers { .. } => KeyboardEventType::Modifiers,
}
}
}
enum PointerEventType {
Motion,
Button,
Axis,
AxisDiscrete120,
Frame,
}
enum KeyboardEventType {
Key,
Modifiers,
}
enum EventType {
Pointer,
Keyboard,
Enter,
Leave,
Ping,
Pong,
Disconnect,
}
impl TryFrom<u8> for PointerEventType {
type Error = anyhow::Error;
fn try_from(value: u8) -> Result<Self> {
match value {
x if x == Self::Motion as u8 => Ok(Self::Motion),
x if x == Self::Button as u8 => Ok(Self::Button),
x if x == Self::Axis as u8 => Ok(Self::Axis),
x if x == Self::AxisDiscrete120 as u8 => Ok(Self::AxisDiscrete120),
x if x == Self::Frame as u8 => Ok(Self::Frame),
_ => Err(anyhow!(ProtocolError {
msg: format!("invalid pointer event type {}", value),
})),
}
}
}
impl TryFrom<u8> for KeyboardEventType {
type Error = anyhow::Error;
fn try_from(value: u8) -> Result<Self> {
match value {
x if x == Self::Key as u8 => Ok(Self::Key),
x if x == Self::Modifiers as u8 => Ok(Self::Modifiers),
_ => Err(anyhow!(ProtocolError {
msg: format!("invalid keyboard event type {}", value),
})),
}
}
}
impl From<&Event> for Vec<u8> {
fn from(event: &Event) -> Self {
let event_id = vec![event.event_type() as u8];
let event_data = match event {
Event::Pointer(p) => p.into(),
Event::Keyboard(k) => k.into(),
Event::Enter() => vec![],
Event::Leave() => vec![],
Event::Ping() => vec![],
Event::Pong() => vec![],
Event::Disconnect() => vec![],
};
[event_id, event_data].concat()
}
}
#[derive(Debug)]
struct ProtocolError {
msg: String,
}
impl fmt::Display for ProtocolError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Protocol violation: {}", self.msg)
}
}
impl Error for ProtocolError {}
impl TryFrom<Vec<u8>> for Event {
type Error = anyhow::Error;
fn try_from(value: Vec<u8>) -> Result<Self> {
let event_id = u8::from_be_bytes(value[..1].try_into()?);
match event_id {
i if i == (EventType::Pointer as u8) => Ok(Event::Pointer(value.try_into()?)),
i if i == (EventType::Keyboard as u8) => Ok(Event::Keyboard(value.try_into()?)),
i if i == (EventType::Enter as u8) => Ok(Event::Enter()),
i if i == (EventType::Leave as u8) => Ok(Event::Leave()),
i if i == (EventType::Ping as u8) => Ok(Event::Ping()),
i if i == (EventType::Pong as u8) => Ok(Event::Pong()),
i if i == (EventType::Disconnect as u8) => Ok(Event::Disconnect()),
_ => Err(anyhow!(ProtocolError {
msg: format!("invalid event_id {}", event_id),
})),
}
}
}
impl From<&PointerEvent> for Vec<u8> {
fn from(event: &PointerEvent) -> Self {
let id = vec![event.event_type() as u8];
let data = match event {
PointerEvent::Motion {
time,
relative_x,
relative_y,
} => {
let time = time.to_be_bytes();
let relative_x = relative_x.to_be_bytes();
let relative_y = relative_y.to_be_bytes();
[&time[..], &relative_x[..], &relative_y[..]].concat()
}
PointerEvent::Button {
time,
button,
state,
} => {
let time = time.to_be_bytes();
let button = button.to_be_bytes();
let state = state.to_be_bytes();
[&time[..], &button[..], &state[..]].concat()
}
PointerEvent::Axis { time, axis, value } => {
let time = time.to_be_bytes();
let axis = axis.to_be_bytes();
let value = value.to_be_bytes();
[&time[..], &axis[..], &value[..]].concat()
}
PointerEvent::AxisDiscrete120 { axis, value } => {
let axis = axis.to_be_bytes();
let value = value.to_be_bytes();
[&axis[..], &value[..]].concat()
}
PointerEvent::Frame {} => {
vec![]
}
};
[id, data].concat()
}
}
impl TryFrom<Vec<u8>> for PointerEvent {
type Error = anyhow::Error;
fn try_from(data: Vec<u8>) -> Result<Self> {
match data.get(1) {
Some(id) => {
let event_type = match id.to_owned().try_into() {
Ok(event_type) => event_type,
Err(e) => return Err(e),
};
match event_type {
PointerEventType::Motion => {
let time = match data.get(2..6) {
Some(d) => u32::from_be_bytes(d.try_into()?),
None => {
return Err(anyhow!(ProtocolError {
msg: "Expected 4 Bytes at index 2".into(),
}))
}
};
let relative_x = match data.get(6..14) {
Some(d) => f64::from_be_bytes(d.try_into()?),
None => {
return Err(anyhow!(ProtocolError {
msg: "Expected 8 Bytes at index 6".into(),
}))
}
};
let relative_y = match data.get(14..22) {
Some(d) => f64::from_be_bytes(d.try_into()?),
None => {
return Err(anyhow!(ProtocolError {
msg: "Expected 8 Bytes at index 14".into(),
}))
}
};
Ok(Self::Motion {
time,
relative_x,
relative_y,
})
}
PointerEventType::Button => {
let time = match data.get(2..6) {
Some(d) => u32::from_be_bytes(d.try_into()?),
None => {
return Err(anyhow!(ProtocolError {
msg: "Expected 4 Bytes at index 2".into(),
}))
}
};
let button = match data.get(6..10) {
Some(d) => u32::from_be_bytes(d.try_into()?),
None => {
return Err(anyhow!(ProtocolError {
msg: "Expected 4 Bytes at index 10".into(),
}))
}
};
let state = match data.get(10..14) {
Some(d) => u32::from_be_bytes(d.try_into()?),
None => {
return Err(anyhow!(ProtocolError {
msg: "Expected 4 Bytes at index 14".into(),
}))
}
};
Ok(Self::Button {
time,
button,
state,
})
}
PointerEventType::Axis => {
let time = match data.get(2..6) {
Some(d) => u32::from_be_bytes(d.try_into()?),
None => {
return Err(anyhow!(ProtocolError {
msg: "Expected 4 Bytes at index 2".into(),
}))
}
};
let axis = match data.get(6) {
Some(d) => *d,
None => {
return Err(anyhow!(ProtocolError {
msg: "Expected 1 Byte at index 6".into(),
}));
}
};
let value = match data.get(7..15) {
Some(d) => f64::from_be_bytes(d.try_into()?),
None => {
return Err(anyhow!(ProtocolError {
msg: "Expected 8 Bytes at index 7".into(),
}));
}
};
Ok(Self::Axis { time, axis, value })
}
PointerEventType::AxisDiscrete120 => {
let axis = match data.get(2) {
Some(d) => *d,
None => {
return Err(anyhow!(ProtocolError {
msg: "Expected 1 Byte at index 2".into(),
}));
}
};
let value = match data.get(3..7) {
Some(d) => i32::from_be_bytes(d.try_into()?),
None => {
return Err(anyhow!(ProtocolError {
msg: "Expected 4 Bytes at index 3".into(),
}));
}
};
Ok(Self::AxisDiscrete120 { axis, value })
}
PointerEventType::Frame => Ok(Self::Frame {}),
}
}
None => Err(anyhow!(ProtocolError {
msg: "Expected an element at index 0".into(),
})),
}
}
}
impl From<&KeyboardEvent> for Vec<u8> {
fn from(event: &KeyboardEvent) -> Self {
let id = vec![event.event_type() as u8];
let data = match event {
KeyboardEvent::Key { time, key, state } => {
let time = time.to_be_bytes();
let key = key.to_be_bytes();
let state = state.to_be_bytes();
[&time[..], &key[..], &state[..]].concat()
}
KeyboardEvent::Modifiers {
mods_depressed,
mods_latched,
mods_locked,
group,
} => {
let mods_depressed = mods_depressed.to_be_bytes();
let mods_latched = mods_latched.to_be_bytes();
let mods_locked = mods_locked.to_be_bytes();
let group = group.to_be_bytes();
[
&mods_depressed[..],
&mods_latched[..],
&mods_locked[..],
&group[..],
]
.concat()
}
};
[id, data].concat()
}
}
impl TryFrom<Vec<u8>> for KeyboardEvent {
type Error = anyhow::Error;
fn try_from(data: Vec<u8>) -> Result<Self> {
match data.get(1) {
Some(id) => {
let event_type = match id.to_owned().try_into() {
Ok(event_type) => event_type,
Err(e) => return Err(e),
};
match event_type {
KeyboardEventType::Key => {
let time = match data.get(2..6) {
Some(d) => u32::from_be_bytes(d.try_into()?),
None => {
return Err(anyhow!(ProtocolError {
msg: "Expected 4 Bytes at index 6".into(),
}))
}
};
let key = match data.get(6..10) {
Some(d) => u32::from_be_bytes(d.try_into()?),
None => {
return Err(anyhow!(ProtocolError {
msg: "Expected 4 Bytes at index 10".into(),
}))
}
};
let state = match data.get(10) {
Some(d) => *d,
None => {
return Err(anyhow!(ProtocolError {
msg: "Expected 1 Bytes at index 14".into(),
}))
}
};
Ok(KeyboardEvent::Key { time, key, state })
}
KeyboardEventType::Modifiers => {
let mods_depressed = match data.get(2..6) {
Some(d) => u32::from_be_bytes(d.try_into()?),
None => {
return Err(anyhow!(ProtocolError {
msg: "Expected 4 Bytes at index 6".into(),
}))
}
};
let mods_latched = match data.get(6..10) {
Some(d) => u32::from_be_bytes(d.try_into()?),
None => {
return Err(anyhow!(ProtocolError {
msg: "Expected 4 Bytes at index 10".into(),
}))
}
};
let mods_locked = match data.get(10..14) {
Some(d) => u32::from_be_bytes(d.try_into()?),
None => {
return Err(anyhow!(ProtocolError {
msg: "Expected 4 Bytes at index 14".into(),
}))
}
};
let group = match data.get(14..18) {
Some(d) => u32::from_be_bytes(d.try_into()?),
None => {
return Err(anyhow!(ProtocolError {
msg: "Expected 4 Bytes at index 18".into(),
}))
}
};
Ok(KeyboardEvent::Modifiers {
mods_depressed,
mods_latched,
mods_locked,
group,
})
}
}
}
None => Err(anyhow!(ProtocolError {
msg: "Expected an element at index 0".into(),
})),
} }
} }
} }

View File

@@ -1,146 +0,0 @@
use reis::{
ei::{button::ButtonState, keyboard::KeyState},
event::EiEvent,
};
use crate::{Event, KeyboardEvent, PointerEvent};
impl Event {
pub fn from_ei_event(ei_event: EiEvent) -> impl Iterator<Item = Self> {
to_input_events(ei_event).into_iter()
}
}
enum Events {
None,
One(Event),
Two(Event, Event),
}
impl Events {
fn into_iter(self) -> impl Iterator<Item = Event> {
EventIterator::new(self)
}
}
struct EventIterator {
events: [Option<Event>; 2],
pos: usize,
}
impl EventIterator {
fn new(events: Events) -> Self {
let events = match events {
Events::None => [None, None],
Events::One(e) => [Some(e), None],
Events::Two(e, f) => [Some(e), Some(f)],
};
Self { events, pos: 0 }
}
}
impl Iterator for EventIterator {
type Item = Event;
fn next(&mut self) -> Option<Self::Item> {
let res = if self.pos >= self.events.len() {
None
} else {
self.events[self.pos]
};
self.pos += 1;
res
}
}
fn to_input_events(ei_event: EiEvent) -> Events {
match ei_event {
EiEvent::KeyboardModifiers(mods) => {
let modifier_event = KeyboardEvent::Modifiers {
depressed: mods.depressed,
latched: mods.latched,
locked: mods.locked,
group: mods.group,
};
Events::One(Event::Keyboard(modifier_event))
}
EiEvent::Frame(_) => Events::None, /* FIXME */
EiEvent::PointerMotion(motion) => {
let motion_event = PointerEvent::Motion {
time: motion.time as u32,
dx: motion.dx as f64,
dy: motion.dy as f64,
};
Events::One(Event::Pointer(motion_event))
}
EiEvent::PointerMotionAbsolute(_) => Events::None,
EiEvent::Button(button) => {
let button_event = PointerEvent::Button {
time: button.time as u32,
button: button.button,
state: match button.state {
ButtonState::Released => 0,
ButtonState::Press => 1,
},
};
Events::One(Event::Pointer(button_event))
}
EiEvent::ScrollDelta(delta) => {
let dy = Event::Pointer(PointerEvent::Axis {
time: 0,
axis: 0,
value: delta.dy as f64,
});
let dx = Event::Pointer(PointerEvent::Axis {
time: 0,
axis: 1,
value: delta.dx as f64,
});
if delta.dy != 0. && delta.dx != 0. {
Events::Two(dy, dx)
} else if delta.dy != 0. {
Events::One(dy)
} else if delta.dx != 0. {
Events::One(dx)
} else {
Events::None
}
}
EiEvent::ScrollStop(_) => Events::None, /* TODO */
EiEvent::ScrollCancel(_) => Events::None, /* TODO */
EiEvent::ScrollDiscrete(scroll) => {
let dy = Event::Pointer(PointerEvent::AxisDiscrete120 {
axis: 0,
value: scroll.discrete_dy,
});
let dx = Event::Pointer(PointerEvent::AxisDiscrete120 {
axis: 1,
value: scroll.discrete_dx,
});
if scroll.discrete_dy != 0 && scroll.discrete_dx != 0 {
Events::Two(dy, dx)
} else if scroll.discrete_dy != 0 {
Events::One(dy)
} else if scroll.discrete_dx != 0 {
Events::One(dx)
} else {
Events::None
}
}
EiEvent::KeyboardKey(key) => {
let key_event = KeyboardEvent::Key {
key: key.key,
state: match key.state {
KeyState::Press => 1,
KeyState::Released => 0,
},
time: key.time as u32,
};
Events::One(Event::Keyboard(key_event))
}
EiEvent::TouchDown(_) => Events::None, /* TODO */
EiEvent::TouchUp(_) => Events::None, /* TODO */
EiEvent::TouchMotion(_) => Events::None, /* TODO */
_ => Events::None,
}
}

View File

@@ -1,20 +0,0 @@
[package]
name = "lan-mouse-cli"
description = "CLI Frontend for lan-mouse"
version = "0.2.0"
edition = "2021"
license = "GPL-3.0-or-later"
repository = "https://github.com/feschber/lan-mouse"
[dependencies]
futures = "0.3.30"
lan-mouse-ipc = { path = "../lan-mouse-ipc", version = "0.2.0" }
clap = { version = "4.4.11", features = ["derive"] }
thiserror = "2.0.0"
tokio = { version = "1.32.0", features = [
"io-util",
"io-std",
"macros",
"net",
"rt",
] }

View File

@@ -1,170 +0,0 @@
use clap::{Args, Parser, Subcommand};
use futures::StreamExt;
use std::{net::IpAddr, time::Duration};
use thiserror::Error;
use lan_mouse_ipc::{
ClientHandle, ConnectionError, FrontendEvent, FrontendRequest, IpcError, Position,
connect_async,
};
#[derive(Debug, Error)]
pub enum CliError {
/// is the service running?
#[error("could not connect: `{0}` - is the service running?")]
ServiceNotRunning(#[from] ConnectionError),
#[error("error communicating with service: {0}")]
Ipc(#[from] IpcError),
}
#[derive(Parser, Clone, Debug, PartialEq, Eq)]
#[command(name = "lan-mouse-cli", about = "LanMouse CLI interface")]
pub struct CliArgs {
#[command(subcommand)]
command: CliSubcommand,
}
#[derive(Args, Clone, Debug, PartialEq, Eq)]
struct Client {
#[arg(long)]
hostname: Option<String>,
#[arg(long)]
port: Option<u16>,
#[arg(long)]
ips: Option<Vec<IpAddr>>,
#[arg(long)]
enter_hook: Option<String>,
}
#[derive(Clone, Subcommand, Debug, PartialEq, Eq)]
enum CliSubcommand {
/// add a new client
AddClient(Client),
/// remove an existing client
RemoveClient { id: ClientHandle },
/// activate a client
Activate { id: ClientHandle },
/// deactivate a client
Deactivate { id: ClientHandle },
/// list configured clients
List,
/// change hostname
SetHost {
id: ClientHandle,
host: Option<String>,
},
/// change port
SetPort { id: ClientHandle, port: u16 },
/// set position
SetPosition { id: ClientHandle, pos: Position },
/// set ips
SetIps { id: ClientHandle, ips: Vec<IpAddr> },
/// re-enable capture
EnableCapture,
/// re-enable emulation
EnableEmulation,
/// authorize a public key
AuthorizeKey {
description: String,
sha256_fingerprint: String,
},
/// deauthorize a public key
RemoveAuthorizedKey { sha256_fingerprint: String },
/// save configuration to file
SaveConfig,
}
pub async fn run(args: CliArgs) -> Result<(), CliError> {
execute(args.command).await?;
Ok(())
}
async fn execute(cmd: CliSubcommand) -> Result<(), CliError> {
let (mut rx, mut tx) = connect_async(Some(Duration::from_millis(500))).await?;
match cmd {
CliSubcommand::AddClient(Client {
hostname,
port,
ips,
enter_hook,
}) => {
tx.request(FrontendRequest::Create).await?;
while let Some(e) = rx.next().await {
if let FrontendEvent::Created(handle, _, _) = e? {
if let Some(hostname) = hostname {
tx.request(FrontendRequest::UpdateHostname(handle, Some(hostname)))
.await?;
}
if let Some(port) = port {
tx.request(FrontendRequest::UpdatePort(handle, port))
.await?;
}
if let Some(ips) = ips {
tx.request(FrontendRequest::UpdateFixIps(handle, ips))
.await?;
}
if let Some(enter_hook) = enter_hook {
tx.request(FrontendRequest::UpdateEnterHook(handle, Some(enter_hook)))
.await?;
}
break;
}
}
}
CliSubcommand::RemoveClient { id } => tx.request(FrontendRequest::Delete(id)).await?,
CliSubcommand::Activate { id } => tx.request(FrontendRequest::Activate(id, true)).await?,
CliSubcommand::Deactivate { id } => {
tx.request(FrontendRequest::Activate(id, false)).await?
}
CliSubcommand::List => {
tx.request(FrontendRequest::Enumerate()).await?;
while let Some(e) = rx.next().await {
if let FrontendEvent::Enumerate(clients) = e? {
for (handle, config, state) in clients {
let host = config.hostname.unwrap_or("unknown".to_owned());
let port = config.port;
let pos = config.pos;
let active = state.active;
let ips = state.ips;
println!(
"id {handle}: {host}:{port} ({pos}) active: {active}, ips: {ips:?}"
);
}
break;
}
}
}
CliSubcommand::SetHost { id, host } => {
tx.request(FrontendRequest::UpdateHostname(id, host))
.await?
}
CliSubcommand::SetPort { id, port } => {
tx.request(FrontendRequest::UpdatePort(id, port)).await?
}
CliSubcommand::SetPosition { id, pos } => {
tx.request(FrontendRequest::UpdatePosition(id, pos)).await?
}
CliSubcommand::SetIps { id, ips } => {
tx.request(FrontendRequest::UpdateFixIps(id, ips)).await?
}
CliSubcommand::EnableCapture => tx.request(FrontendRequest::EnableCapture).await?,
CliSubcommand::EnableEmulation => tx.request(FrontendRequest::EnableEmulation).await?,
CliSubcommand::AuthorizeKey {
description,
sha256_fingerprint,
} => {
tx.request(FrontendRequest::AuthorizeKey(
description,
sha256_fingerprint,
))
.await?
}
CliSubcommand::RemoveAuthorizedKey { sha256_fingerprint } => {
tx.request(FrontendRequest::RemoveAuthorizedKey(sha256_fingerprint))
.await?
}
CliSubcommand::SaveConfig => tx.request(FrontendRequest::SaveConfiguration).await?,
}
Ok(())
}

View File

@@ -1,19 +0,0 @@
[package]
name = "lan-mouse-gtk"
description = "GTK4 / Libadwaita Frontend for lan-mouse"
version = "0.2.0"
edition = "2021"
license = "GPL-3.0-or-later"
repository = "https://github.com/feschber/lan-mouse"
[dependencies]
gtk = { package = "gtk4", version = "0.9.0", features = ["v4_2"] }
adw = { package = "libadwaita", version = "0.7.0", features = ["v1_1"] }
async-channel = { version = "2.1.1" }
hostname = "0.4.0"
log = "0.4.20"
lan-mouse-ipc = { path = "../lan-mouse-ipc", version = "0.2.0" }
thiserror = "2.0.0"
[build-dependencies]
glib-build-tools = { version = "0.20.0" }

View File

@@ -1,8 +0,0 @@
fn main() {
// composite_templates
glib_build_tools::compile_resources(
&["resources"],
"resources/resources.gresource.xml",
"lan-mouse.gresource",
);
}

View File

@@ -1,102 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<requires lib="gtk" version="4.0"/>
<requires lib="libadwaita" version="1.0"/>
<template class="AuthorizationWindow" parent="AdwWindow">
<property name="modal">True</property>
<property name="width-request">180</property>
<property name="default-width">180</property>
<property name="height-request">180</property>
<property name="default-height">180</property>
<property name="title" translatable="yes">Unauthorized Device</property>
<property name="content">
<object class="GtkBox">
<property name="orientation">vertical</property>
<property name="vexpand">True</property>
<child type="top">
<object class="AdwHeaderBar">
<style>
<class name="flat"/>
</style>
</object>
</child>
<child>
<object class="GtkBox">
<property name="orientation">vertical</property>
<property name="spacing">30</property>
<property name="margin-start">30</property>
<property name="margin-end">30</property>
<property name="margin-top">30</property>
<property name="margin-bottom">30</property>
<child>
<object class="GtkLabel">
<property name="label">An unauthorized Device is trying to connect. Do you want to authorize this Device?</property>
<property name="width-request">100</property>
<property name="wrap">word-wrap</property>
</object>
</child>
<child>
<object class="AdwPreferencesGroup">
<property name="title">sha256 fingerprint</property>
<child>
<object class="AdwActionRow">
<property name="child">
<object class="GtkLabel" id="fingerprint">
<property name="ellipsize">PANGO_ELLIPSIZE_NONE</property>
<property name="vexpand">True</property>
<property name="hexpand">False</property>
<property name="wrap">True</property>
<property name="wrap-mode">word-char</property>
<property name="justify">center</property>
<property name="xalign">0.5</property>
<property name="margin-top">10</property>
<property name="margin-bottom">10</property>
<property name="margin-start">10</property>
<property name="margin-end">10</property>
<property name="width-chars">64</property>
</object>
</property>
</object>
</child>
</object>
</child>
</object>
</child>
<child>
<object class="GtkBox">
<property name="margin-start">30</property>
<property name="margin-end">30</property>
<property name="margin-top">30</property>
<property name="margin-bottom">30</property>
<property name="orientation">horizontal</property>
<property name="spacing">30</property>
<property name="hexpand">True</property>
<property name="vexpand">True</property>
<property name="valign">end</property>
<child>
<object class="GtkButton" id="cancel_button">
<signal name="clicked" handler="handle_cancel" swapped="true"/>
<property name="label" translatable="yes">Cancel</property>
<property name="can-shrink">True</property>
<property name="height-request">50</property>
<property name="hexpand">True</property>
</object>
</child>
<child>
<object class="GtkButton" id="confirm_button">
<signal name="clicked" handler="handle_confirm" swapped="true"/>
<property name="label" translatable="yes">Authorize</property>
<property name="can-shrink">True</property>
<property name="height-request">50</property>
<property name="hexpand">True</property>
<style>
<class name="destructive-action"/>
</style>
</object>
</child>
</object>
</child>
</object>
</property>
</template>
</interface>

View File

@@ -1,101 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<requires lib="gtk" version="4.0"/>
<requires lib="libadwaita" version="1.0"/>
<template class="FingerprintWindow" parent="AdwWindow">
<property name="modal">True</property>
<property name="width-request">880</property>
<property name="default-width">880</property>
<property name="height-request">380</property>
<property name="default-height">380</property>
<property name="title" translatable="yes">Add Certificate Fingerprint</property>
<property name="content">
<object class="AdwToolbarView">
<child type="top">
<object class="AdwHeaderBar"/>
</child>
<property name="content">
<object class="AdwClamp">
<property name="maximum-size">770</property>
<property name="tightening-threshold">0</property>
<property name="child">
<object class="GtkBox">
<property name="orientation">vertical</property>
<property name="spacing">18</property>
<child>
<object class="GtkLabel">
<property name="label">The certificate fingerprint serves as a unique identifier for your device.</property>
</object>
</child>
<child>
<object class="GtkLabel">
<property name="label">You can find it under the `General` section of the device you want to connect</property>
</object>
</child>
<child>
<object class="AdwPreferencesGroup">
<property name="title">description</property>
<child>
<object class="AdwActionRow">
<property name="child">
<object class="GtkText" id="description">
<property name="margin-top">10</property>
<property name="margin-bottom">10</property>
<property name="margin-start">10</property>
<property name="margin-end">10</property>
<property name="enable-undo">True</property>
<property name="hexpand">True</property>
<property name="vexpand">True</property>
<property name="max-length">0</property>
</object>
</property>
</object>
</child>
</object>
</child>
<child>
<object class="AdwPreferencesGroup">
<property name="title">sha256 fingerprint</property>
<child>
<object class="AdwActionRow">
<property name="child">
<object class="GtkText" id="fingerprint">
<property name="margin-top">10</property>
<property name="margin-bottom">10</property>
<property name="margin-start">10</property>
<property name="margin-end">10</property>
<property name="enable-undo">True</property>
<property name="hexpand">True</property>
<property name="vexpand">True</property>
<property name="max-length">0</property>
</object>
</property>
</object>
</child>
</object>
</child>
<child>
<object class="GtkBox">
<property name="orientation">vertical</property>
<property name="halign">center</property>
<child>
<object class="GtkButton" id="confirm_button">
<signal name="clicked" handler="handle_confirm" swapped="true"/>
<property name="label" translatable="yes">Confirm</property>
<property name="can-shrink">True</property>
<style>
<class name="pill"/>
<class name="suggested-action"/>
</style>
</object>
</child>
</object>
</child>
</object>
</property>
</object>
</property>
</object>
</property>
</template>
</interface>

View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<path d="m 0 3 c 0 -1.644531 1.355469 -3 3 -3 h 5 c 1.644531 0 3 1.355469 3 3 c 0 0.550781 -0.449219 1 -1 1 s -1 -0.449219 -1 -1 c 0 -0.570312 -0.429688 -1 -1 -1 h -5 c -0.570312 0 -1 0.429688 -1 1 v 5 c 0 0.570312 0.429688 1 1 1 c 0.550781 0 1 0.449219 1 1 s -0.449219 1 -1 1 c -1.644531 0 -3 -1.355469 -3 -3 z m 5 5 c 0 -1.644531 1.355469 -3 3 -3 h 5 c 1.644531 0 3 1.355469 3 3 v 5 c 0 1.644531 -1.355469 3 -3 3 h -5 c -1.644531 0 -3 -1.355469 -3 -3 z m 2 0 v 5 c 0 0.570312 0.429688 1 1 1 h 5 c 0.570312 0 1 -0.429688 1 -1 v -5 c 0 -0.570312 -0.429688 -1 -1 -1 h -5 c -0.570312 0 -1 0.429688 -1 1 z m 0 0" fill="#2e3436"/>
</svg>

Before

Width:  |  Height:  |  Size: 765 B

View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<path d="m 8 0 c -4.410156 0 -8 3.589844 -8 8 s 3.589844 8 8 8 s 8 -3.589844 8 -8 s -3.589844 -8 -8 -8 z m 0 2 c 3.332031 0 6 2.667969 6 6 s -2.667969 6 -6 6 s -6 -2.667969 -6 -6 s 2.667969 -6 6 -6 z m -2.03125 2.96875 c -0.265625 0 -0.519531 0.105469 -0.707031 0.292969 c -0.390625 0.390625 -0.390625 1.023437 0 1.414062 l 1.292969 1.292969 l -1.292969 1.292969 c -0.390625 0.390625 -0.390625 1.023437 0 1.414062 s 1.023437 0.390625 1.414062 0 l 1.292969 -1.292969 l 1.292969 1.292969 c 0.390625 0.390625 1.023437 0.390625 1.414062 0 s 0.390625 -1.023437 0 -1.414062 l -1.292969 -1.292969 l 1.292969 -1.292969 c 0.390625 -0.390625 0.390625 -1.023437 0 -1.414062 c -0.1875 -0.1875 -0.441406 -0.292969 -0.707031 -0.292969 s -0.519531 0.105469 -0.707031 0.292969 l -1.292969 1.292969 l -1.292969 -1.292969 c -0.1875 -0.1875 -0.441406 -0.292969 -0.707031 -0.292969 z m 0 0" fill="#2e3436"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<path d="m 13.753906 4.660156 c 0.175782 -0.199218 0.261719 -0.460937 0.246094 -0.726562 c -0.019531 -0.265625 -0.140625 -0.511719 -0.339844 -0.6875 c -0.199218 -0.175782 -0.460937 -0.261719 -0.726562 -0.246094 c -0.265625 0.019531 -0.511719 0.140625 -0.6875 0.339844 l -6.296875 7.195312 l -2.242188 -2.242187 c -0.390625 -0.390625 -1.023437 -0.390625 -1.414062 0 c -0.1875 0.1875 -0.292969 0.441406 -0.292969 0.707031 s 0.105469 0.519531 0.292969 0.707031 l 3 3 c 0.195312 0.195313 0.464843 0.304688 0.738281 0.292969 c 0.277344 -0.007812 0.539062 -0.132812 0.722656 -0.339844 z m 0 0" fill="#2e3436"/>
</svg>

Before

Width:  |  Height:  |  Size: 743 B

View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<path d="m 7 1 v 6 h -6 v 2 h 6 v 6 h 2 v -6 h 6 v -2 h -6 v -6 z m 0 0" fill="#2e3436"/>
</svg>

Before

Width:  |  Height:  |  Size: 228 B

View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<path d="m 7.085938 2 c 0.574218 0.007812 1.152343 0.085938 1.726562 0.238281 c 3.054688 0.820313 5.1875 3.597657 5.1875 6.761719 h 2 v 1 h -0.007812 c 0.003906 0.265625 -0.101563 0.519531 -0.285157 0.707031 l -2 2 c -0.390625 0.390625 -1.023437 0.390625 -1.414062 0 l -2 -2 c -0.1875 -0.1875 -0.289063 -0.441406 -0.289063 -0.707031 h -0.003906 v -1 h 2 c 0 -2.269531 -1.515625 -4.242188 -3.707031 -4.832031 c -2.1875 -0.585938 -4.488281 0.367187 -5.625 2.332031 c -1.132813 1.964844 -0.808594 4.429688 0.796875 6.035156 c 0.390625 0.390625 0.390625 1.023438 0 1.414063 s -1.023438 0.390625 -1.414063 0 c -2.238281 -2.238281 -2.695312 -5.710938 -1.113281 -8.449219 c 1.1875 -2.054688 3.304688 -3.324219 5.578125 -3.480469 c 0.1875 -0.015625 0.378906 -0.023437 0.570313 -0.019531 z m 0 0" fill="#2e3436"/>
</svg>

Before

Width:  |  Height:  |  Size: 943 B

View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<path d="m 13.753906 4.660156 c 0.175782 -0.199218 0.261719 -0.460937 0.246094 -0.726562 c -0.019531 -0.265625 -0.140625 -0.511719 -0.339844 -0.6875 c -0.199218 -0.175782 -0.460937 -0.261719 -0.726562 -0.246094 c -0.265625 0.019531 -0.511719 0.140625 -0.6875 0.339844 l -6.296875 7.195312 l -2.242188 -2.242187 c -0.390625 -0.390625 -1.023437 -0.390625 -1.414062 0 c -0.1875 0.1875 -0.292969 0.441406 -0.292969 0.707031 s 0.105469 0.519531 0.292969 0.707031 l 3 3 c 0.195312 0.195313 0.464843 0.304688 0.738281 0.292969 c 0.277344 -0.007812 0.539062 -0.132812 0.722656 -0.339844 z m 0 0" fill="#2e3436"/>
</svg>

Before

Width:  |  Height:  |  Size: 743 B

View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<g fill="#2e3436">
<path d="m 1 2 h 14 v 2 h -14 z m 0 0"/>
<path d="m 1 7 h 14 v 2 h -14 z m 0 0"/>
<path d="m 1 12 h 14 v 2 h -14 z m 0 0"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 314 B

View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<path d="m 3 2 c -0.265625 0 -0.519531 0.105469 -0.707031 0.292969 c -0.390625 0.390625 -0.390625 1.023437 0 1.414062 l 4.292969 4.292969 l -4.292969 4.292969 c -0.390625 0.390625 -0.390625 1.023437 0 1.414062 s 1.023437 0.390625 1.414062 0 l 4.292969 -4.292969 l 4.292969 4.292969 c 0.390625 0.390625 1.023437 0.390625 1.414062 0 s 0.390625 -1.023437 0 -1.414062 l -4.292969 -4.292969 l 4.292969 -4.292969 c 0.390625 -0.390625 0.390625 -1.023437 0 -1.414062 c -0.1875 -0.1875 -0.441406 -0.292969 -0.707031 -0.292969 s -0.519531 0.105469 -0.707031 0.292969 l -4.292969 4.292969 l -4.292969 -4.292969 c -0.1875 -0.1875 -0.441406 -0.292969 -0.707031 -0.292969 z m 0 0" fill="#2e3436"/>
</svg>

Before

Width:  |  Height:  |  Size: 822 B

View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<path d="m 8.074219 0 c -1.203125 -0.0117188 -2.40625 0.285156 -3.492188 0.890625 c -0.480469 0.269531 -0.652343 0.878906 -0.382812 1.359375 c 0.269531 0.484375 0.878906 0.65625 1.359375 0.386719 c 1.550781 -0.867188 3.4375 -0.847657 4.972656 0.050781 c 1.53125 0.898438 2.46875 2.535156 2.46875 4.3125 v 1 c 0 0.550781 0.449219 1 1 1 s 1 -0.449219 1 -1 v -1 c 0 -0.019531 0 -0.039062 -0.003906 -0.054688 c -0.019532 -2.460937 -1.332032 -4.738281 -3.457032 -5.984374 c -1.070312 -0.628907 -2.265624 -0.9492192 -3.46875 -0.960938 z m -5.199219 2.832031 c -0.066406 0 -0.132812 0.007813 -0.195312 0.023438 c -0.257813 0.058593 -0.484376 0.21875 -0.625 0.445312 c -0.6875 1.109375 -1.054688 2.390625 -1.054688 3.699219 v 5.0625 c 0 0.550781 0.449219 1 1 1 s 1 -0.449219 1 -1 v -5.0625 c 0 -0.933594 0.261719 -1.851562 0.753906 -2.644531 c 0.292969 -0.46875 0.148438 -1.082031 -0.320312 -1.375 c -0.167969 -0.105469 -0.363282 -0.15625 -0.558594 -0.148438 z m 5.125 0.167969 c -2.199219 0 -4 1.800781 -4 4 v 1 c 0 0.550781 0.449219 1 1 1 s 1 -0.449219 1 -1 v -1 c 0 -1.117188 0.882812 -2 2 -2 s 2 0.882812 2 2 v 5 s 0.007812 0.441406 0.175781 0.941406 s 0.5 1.148438 1.117188 1.765625 c 0.390625 0.390625 1.023437 0.390625 1.414062 0 s 0.390625 -1.023437 0 -1.414062 c -0.382812 -0.382813 -0.550781 -0.734375 -0.632812 -0.984375 s -0.074219 -0.308594 -0.074219 -0.308594 v -5 c 0 -2.199219 -1.800781 -4 -4 -4 z m 0 3 c -0.550781 0 -1 0.449219 -1 1 v 5 s 0 0.59375 0.144531 1.320312 c 0.144531 0.726563 0.414063 1.652344 1.148438 2.386719 c 0.390625 0.390625 1.023437 0.390625 1.414062 0 s 0.390625 -1.023437 0 -1.414062 c -0.265625 -0.265625 -0.496093 -0.839844 -0.601562 -1.363281 c -0.105469 -0.523438 -0.105469 -0.929688 -0.105469 -0.929688 v -5 c 0 -0.550781 -0.449219 -1 -1 -1 z m -3 4 c -0.550781 0 -1 0.449219 -1 1 v 3 c 0 0.550781 0.449219 1 1 1 s 1 -0.449219 1 -1 v -3 c 0 -0.550781 -0.449219 -1 -1 -1 z m 9 0 c -0.550781 0 -1 0.449219 -1 1 s 0.449219 1 1 1 s 1 -0.449219 1 -1 s -0.449219 -1 -1 -1 z m 0 0" fill="#2e3434"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<path d="m 6 0.015625 c -0.554688 0 -1 0.445313 -1 1 v 3 c 0 0.554687 0.445312 1 1 1 h 1 v 2 h -7 v 2 h 2 v 2 h -1 c -0.554688 0 -1 0.445313 -1 1 v 3 c 0 0.554687 0.445312 1 1 1 h 4 c 0.554688 0 1 -0.445313 1 -1 v -3 c 0 -0.554687 -0.445312 -1 -1 -1 h -1 v -2 h 8 v 2 h -1 c -0.554688 0 -1 0.445313 -1 1 v 3 c 0 0.554687 0.445312 1 1 1 h 4 c 0.554688 0 1 -0.445313 1 -1 v -3 c 0 -0.554687 -0.445312 -1 -1 -1 h -1 v -2 h 2 v -2 h -7 v -2 h 1 c 0.554688 0 1 -0.445313 1 -1 v -3 c 0 -0.554687 -0.445312 -1 -1 -1 z m 0 0" fill="#2e3436"/>
</svg>

Before

Width:  |  Height:  |  Size: 673 B

View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<g fill="#2e3436">
<path d="m 6.5 0 c -1.378906 0 -2.5 1.121094 -2.5 2.5 v 0.5 h -3 c -0.550781 0 -1 0.449219 -1 1 s 0.449219 1 1 1 h 1 v 8 c 0 1.65625 1.34375 3 3 3 h 6 c 1.65625 0 3 -1.34375 3 -3 v -8 h 1 c 0.550781 0 1 -0.449219 1 -1 s -0.449219 -1 -1 -1 h -3.023438 v -0.5 c 0 -1.378906 -1.117187 -2.5 -2.5 -2.5 z m 0 2 h 2.976562 c 0.289063 0 0.5 0.210938 0.5 0.5 v 0.5 h -3.976562 v -0.5 c 0 -0.289062 0.210938 -0.5 0.5 -0.5 z m -2.5 3 h 8 v 8 c 0 0.5625 -0.4375 1 -1 1 h -6 c -0.5625 0 -1 -0.4375 -1 -1 z m 0 0"/>
<path d="m 7 7 v 5 c 0 0.277344 -0.222656 0.5 -0.5 0.5 s -0.5 -0.222656 -0.5 -0.5 v -5 c 0 -0.277344 0.222656 -0.5 0.5 -0.5 s 0.5 0.222656 0.5 0.5 z m 0 0"/>
<path d="m 10 7 v 5 c 0 0.277344 -0.222656 0.5 -0.5 0.5 s -0.5 -0.222656 -0.5 -0.5 v -5 c 0 -0.277344 0.222656 -0.5 0.5 -0.5 s 0.5 0.222656 0.5 0.5 z m 0 0"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1009 B

View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<path d="m 7.90625 0.09375 c -0.527344 -0.0273438 -1.039062 0.28125 -1.4375 0.96875 l -6.25 11.59375 c -0.535156 0.964844 0.046875 2.34375 1.09375 2.34375 h 13.15625 c 0.980469 0 1.902344 -1.160156 1.21875 -2.34375 l -6.3125 -11.53125 c -0.398438 -0.644531 -0.941406 -1.003906 -1.46875 -1.03125 z m 1.09375 3.90625 v 5 c 0.007812 0.527344 -0.472656 1 -1 1 s -1.007812 -0.472656 -1 -1 v -5 z m -1 7 c 0.550781 0 1 0.449219 1 1 s -0.449219 1 -1 1 s -1 -0.449219 -1 -1 s 0.449219 -1 1 -1 z m 0 0" fill="#2e3436"/>
</svg>

Before

Width:  |  Height:  |  Size: 649 B

View File

@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<g fill="#2e3436">
<path d="m 15 10 c 0.265625 0 0.519531 0.105469 0.707031 0.292969 c 0.390625 0.390625 0.390625 1.023437 0 1.414062 l -1.292969 1.292969 l 1.292969 1.292969 c 0.390625 0.390625 0.390625 1.023437 0 1.414062 s -1.023437 0.390625 -1.414062 0 l -1.292969 -1.292969 l -1.292969 1.292969 c -0.390625 0.390625 -1.023437 0.390625 -1.414062 0 s -0.390625 -1.023437 0 -1.414062 l 1.292969 -1.292969 l -1.292969 -1.292969 c -0.390625 -0.390625 -0.390625 -1.023437 0 -1.414062 c 0.1875 -0.1875 0.441406 -0.292969 0.707031 -0.292969 s 0.519531 0.105469 0.707031 0.292969 l 1.292969 1.292969 l 1.292969 -1.292969 c 0.1875 -0.1875 0.441406 -0.292969 0.707031 -0.292969 z m 0 0"/>
<path d="m 6 0 c -0.554688 0 -1 0.445312 -1 1 v 3 c 0 0.554688 0.445312 1 1 1 h 1 v 2 h -7 v 2 h 2 v 2 h -1 c -0.554688 0 -1 0.445312 -1 1 v 3 c 0 0.554688 0.445312 1 1 1 h 4 c 0.554688 0 1 -0.445312 1 -1 v -3 c 0 -0.554688 -0.445312 -1 -1 -1 h -1 v -2 h 12 v -2 h -7 v -2 h 1 c 0.554688 0 1 -0.445312 1 -1 v -3 c 0 -0.554688 -0.445312 -1 -1 -1 z m 0 0" fill-opacity="0.34902"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1,16 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<template class="KeyRow" parent="AdwActionRow">
<child type="prefix">
<object class="GtkButton" id="delete_button">
<property name="valign">center</property>
<property name="halign">end</property>
<property name="tooltip-text" translatable="yes">revoke authorization</property>
<property name="icon-name">edit-delete-symbolic</property>
<style>
<class name="flat"/>
</style>
</object>
</child>
</template>
</interface>

View File

@@ -1,31 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<gresources>
<gresource prefix="/de/feschber/LanMouse">
<file compressed="true" preprocess="xml-stripblanks">window.ui</file>
<file compressed="true" preprocess="xml-stripblanks">authorization_window.ui</file>
<file compressed="true" preprocess="xml-stripblanks">fingerprint_window.ui</file>
<file compressed="true" preprocess="xml-stripblanks">client_row.ui</file>
<file compressed="true" preprocess="xml-stripblanks">key_row.ui</file>
</gresource>
<gresource prefix="/de/feschber/LanMouse/icons">
<file compressed="true" preprocess="xml-stripblanks">de.feschber.LanMouse.svg</file>
<!--
Bundled Adwaita symbolic icons so the GTK frontend has a complete icon set
on platforms (notably macOS) where the Adwaita icon theme is not installed.
Registered via IconTheme::add_resource_path("/de/feschber/LanMouse/icons").
-->
<file compressed="true" preprocess="xml-stripblanks" alias="scalable/actions/edit-copy-symbolic.svg">icons/scalable/actions/edit-copy-symbolic.svg</file>
<file compressed="true" preprocess="xml-stripblanks" alias="scalable/actions/edit-delete-symbolic.svg">icons/scalable/actions/edit-delete-symbolic.svg</file>
<file compressed="true" preprocess="xml-stripblanks" alias="scalable/actions/emblem-ok-symbolic.svg">icons/scalable/actions/emblem-ok-symbolic.svg</file>
<file compressed="true" preprocess="xml-stripblanks" alias="scalable/actions/list-add-symbolic.svg">icons/scalable/actions/list-add-symbolic.svg</file>
<file compressed="true" preprocess="xml-stripblanks" alias="scalable/actions/object-rotate-right-symbolic.svg">icons/scalable/actions/object-rotate-right-symbolic.svg</file>
<file compressed="true" preprocess="xml-stripblanks" alias="scalable/actions/object-select-symbolic.svg">icons/scalable/actions/object-select-symbolic.svg</file>
<file compressed="true" preprocess="xml-stripblanks" alias="scalable/actions/open-menu-symbolic.svg">icons/scalable/actions/open-menu-symbolic.svg</file>
<file compressed="true" preprocess="xml-stripblanks" alias="scalable/actions/process-stop-symbolic.svg">icons/scalable/actions/process-stop-symbolic.svg</file>
<file compressed="true" preprocess="xml-stripblanks" alias="scalable/devices/auth-fingerprint-symbolic.svg">icons/scalable/devices/auth-fingerprint-symbolic.svg</file>
<file compressed="true" preprocess="xml-stripblanks" alias="scalable/devices/network-wired-symbolic.svg">icons/scalable/devices/network-wired-symbolic.svg</file>
<file compressed="true" preprocess="xml-stripblanks" alias="scalable/places/user-trash-symbolic.svg">icons/scalable/places/user-trash-symbolic.svg</file>
<file compressed="true" preprocess="xml-stripblanks" alias="scalable/status/dialog-warning-symbolic.svg">icons/scalable/status/dialog-warning-symbolic.svg</file>
<file compressed="true" preprocess="xml-stripblanks" alias="scalable/status/network-wired-disconnected-symbolic.svg">icons/scalable/status/network-wired-disconnected-symbolic.svg</file>
</gresource>
</gresources>

View File

@@ -1,278 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<requires lib="gtk" version="4.0"/>
<requires lib="libadwaita" version="1.0"/>
<menu id="main-menu">
<item>
<attribute name="label" translatable="yes">_Close window</attribute>
<attribute name="action">window.close</attribute>
</item>
</menu>
<template class="LanMouseWindow" parent="AdwApplicationWindow">
<property name="width-request">600</property>
<property name="height-request">700</property>
<property name="title" translatable="yes">Lan Mouse</property>
<property name="show-menubar">True</property>
<property name="content">
<object class="GtkBox">
<property name="orientation">vertical</property>
<child type="top">
<object class="AdwHeaderBar">
<child type ="end">
<object class="GtkMenuButton">
<property name="icon-name">open-menu-symbolic</property>
<property name="menu-model">main-menu</property>
</object>
</child>
<style>
<class name="flat"/>
</style>
</object>
</child>
<child>
<object class="AdwToastOverlay" id="toast_overlay">
<child>
<object class="AdwStatusPage">
<property name="title" translatable="yes">Lan Mouse</property>
<property name="description" translatable="yes">easily use your mouse and keyboard on multiple computers</property>
<property name="icon-name">de.feschber.LanMouse</property>
<property name="child">
<object class="AdwClamp">
<property name="maximum-size">600</property>
<property name="tightening-threshold">0</property>
<property name="child">
<object class="GtkBox">
<property name="orientation">vertical</property>
<property name="spacing">12</property>
<child>
<object class="AdwPreferencesGroup" id="capture_emulation_group">
<property name="title" translatable="yes">Capture / Emulation Status</property>
<child>
<object class="AdwActionRow" id="capture_status_row">
<property name="title">input capture is disabled</property>
<property name="subtitle">required for outgoing and incoming connections</property>
<property name="icon-name">dialog-warning-symbolic</property>
<child>
<object class="GtkButton" id="input_capture_button">
<property name="child">
<object class="AdwButtonContent">
<property name="icon-name">object-rotate-right-symbolic</property>
<property name="label" translatable="yes">Reenable</property>
</object>
</property>
<signal name="clicked" handler="handle_capture" swapped="true"/>
<property name="valign">center</property>
<style>
<class name="pill"/>
<class name="flat"/>
</style>
</object>
</child>
<style>
<class name="warning"/>
</style>
</object>
</child>
<child>
<object class="AdwActionRow" id="emulation_status_row">
<property name="title">input emulation is disabled</property>
<property name="subtitle">required for incoming connections</property>
<property name="icon-name">dialog-warning-symbolic</property>
<child>
<object class="GtkButton" id="input_emulation_button">
<property name="child">
<object class="AdwButtonContent">
<property name="icon-name">object-rotate-right-symbolic</property>
<property name="label" translatable="yes">Reenable</property>
</object>
</property>
<property name="valign">center</property>
<signal name="clicked" handler="handle_emulation" swapped="true"/>
<style>
<class name="pill"/>
<class name="flat"/>
</style>
</object>
</child>
<child>
</child>
<style>
<class name="warning"/>
</style>
</object>
</child>
</object>
</child>
<child>
<object class="AdwPreferencesGroup">
<property name="title" translatable="yes">General</property>
<!--
<child>
<object class="AdwActionRow">
<property name="title" translatable="yes">enable</property>
<child type="suffix">
<object class="GtkSwitch">
<property name="valign">center</property>
<property name="tooltip-text" translatable="yes">enable</property>
</object>
</child>
</object>
</child>
-->
<child>
<object class="AdwActionRow">
<property name="title">hostname &#38;amp; port</property>
<child>
<object class="GtkButton" id="copy-hostname-button">
<!--<property name="icon-name">edit-copy-symbolic</property>-->
<property name="valign">center</property>
<signal name="clicked" handler="handle_copy_hostname" swapped="true"/>
<child>
<object class="GtkBox">
<property name="spacing">30</property>
<child>
<object class="GtkLabel" id="hostname_label">
<property name="label">&lt;span font_style=&quot;italic&quot; font_weight=&quot;light&quot; foreground=&quot;darkgrey&quot;&gt;could not determine hostname&lt;/span&gt;</property>
<property name="use-markup">true</property>
<property name="valign">center</property>
</object>
</child>
<child>
<object class="GtkImage" id="hostname_copy_icon">
<property name="icon-name">edit-copy-symbolic</property>
</object>
</child>
</object>
</child>
</object>
</child>
<child>
<object class="GtkEntry" id="port_entry">
<property name="max-width-chars">5</property>
<signal name="activate" handler="handle_port_edit_apply" swapped="true"/>
<signal name="changed" handler="handle_port_changed" swapped="true"/>
<!-- <signal name="delete-text" handler="handle_port_changed" swapped="true"/> -->
<!-- <property name="title" translatable="yes">port</property> -->
<property name="placeholder-text">4242</property>
<property name="width-chars">5</property>
<property name="xalign">0.5</property>
<property name="valign">center</property>
<!-- <property name="show-apply-button">True</property> -->
<property name="input-purpose">GTK_INPUT_PURPOSE_DIGITS</property>
</object>
</child>
<child>
<object class="GtkButton" id="port_edit_apply">
<signal name="clicked" handler="handle_port_edit_apply" swapped="true"/>
<property name="icon-name">object-select-symbolic</property>
<property name="valign">center</property>
<property name="visible">false</property>
<property name="name">port-edit-apply</property>
<style><class name="success"/></style>
</object>
</child>
<child>
<object class="GtkButton" id="port_edit_cancel">
<signal name="clicked" handler="handle_port_edit_cancel" swapped="true"/>
<property name="icon-name">process-stop-symbolic</property>
<property name="valign">center</property>
<property name="visible">false</property>
<property name="name">port-edit-cancel</property>
<style><class name="error"/></style>
</object>
</child>
</object>
</child>
<child>
<object class="AdwActionRow" id="fingerprint_row">
<property name="title">certificate fingerprint</property>
<property name="icon-name">auth-fingerprint-symbolic</property>
<child>
<object class="GtkButton" id="copy-fingerprint-button">
<property name="icon-name">edit-copy-symbolic</property>
<property name="valign">center</property>
<signal name="clicked" handler="handle_copy_fingerprint" swapped="true"/>
</object>
</child>
</object>
</child>
</object>
</child>
<child>
<object class="AdwPreferencesGroup">
<property name="title" translatable="yes">Connections</property>
<property name="header-suffix">
<object class="GtkButton">
<signal name="clicked" handler="handle_add_client_pressed" swapped="true"/>
<property name="child">
<object class="AdwButtonContent">
<property name="icon-name">list-add-symbolic</property>
<property name="label" translatable="yes">Add</property>
</object>
</property>
<style>
<class name="flat"/>
</style>
</object>
</property>
<child>
<object class="GtkListBox" id="client_list">
<property name="selection-mode">none</property>
<child type="placeholder">
<object class="AdwActionRow" id="client_placeholder">
<property name="title">No connections!</property>
<property name="subtitle">add a new client via the + button</property>
</object>
</child>
<style>
<class name="boxed-list" />
</style>
</object>
</child>
</object>
</child>
<child>
<object class="AdwPreferencesGroup">
<property name="title" translatable="yes">Incoming Connections</property>
<property name="header-suffix">
<object class="GtkButton">
<signal name="clicked" handler="handle_add_cert_fingerprint" swapped="true"/>
<property name="child">
<object class="AdwButtonContent">
<property name="icon-name">auth-fingerprint-symbolic</property>
<property name="label" translatable="yes">Authorize</property>
</object>
</property>
<style>
<class name="flat"/>
</style>
</object>
</property>
<child>
<object class="GtkListBox" id="authorized_list">
<property name="selection-mode">none</property>
<child type="placeholder">
<object class="AdwActionRow" id="authorized_placeholder">
<property name="title">no devices registered!</property>
<property name="subtitle">authorize a new device via the "Authorize" button</property>
</object>
</child>
<style>
<class name="boxed-list" />
</style>
</object>
</child>
</object>
</child>
</object>
</property>
</object>
</property>
</object>
</child>
</object>
</child>
</object>
</property>
</template>
</interface>

View File

@@ -1,19 +0,0 @@
mod imp;
use glib::Object;
use gtk::{gio, glib, subclass::prelude::ObjectSubclassIsExt};
glib::wrapper! {
pub struct AuthorizationWindow(ObjectSubclass<imp::AuthorizationWindow>)
@extends adw::Window, gtk::Window, gtk::Widget,
@implements gio::ActionGroup, gio::ActionMap, gtk::Accessible, gtk::Buildable,
gtk::ConstraintTarget, gtk::Native, gtk::Root, gtk::ShortcutManager;
}
impl AuthorizationWindow {
pub(crate) fn new(fingerprint: &str) -> Self {
let window: Self = Object::builder().build();
window.imp().set_fingerprint(fingerprint);
window
}
}

View File

@@ -1,76 +0,0 @@
use std::sync::OnceLock;
use adw::prelude::*;
use adw::subclass::prelude::*;
use glib::subclass::InitializingObject;
use gtk::{
Button, CompositeTemplate, Label,
glib::{self, subclass::Signal},
template_callbacks,
};
#[derive(CompositeTemplate, Default)]
#[template(resource = "/de/feschber/LanMouse/authorization_window.ui")]
pub struct AuthorizationWindow {
#[template_child]
pub fingerprint: TemplateChild<Label>,
#[template_child]
pub cancel_button: TemplateChild<Button>,
#[template_child]
pub confirm_button: TemplateChild<Button>,
}
#[glib::object_subclass]
impl ObjectSubclass for AuthorizationWindow {
const NAME: &'static str = "AuthorizationWindow";
const ABSTRACT: bool = false;
type Type = super::AuthorizationWindow;
type ParentType = adw::Window;
fn class_init(klass: &mut Self::Class) {
klass.bind_template();
klass.bind_template_callbacks();
}
fn instance_init(obj: &InitializingObject<Self>) {
obj.init_template();
}
}
#[template_callbacks]
impl AuthorizationWindow {
#[template_callback]
fn handle_confirm(&self, _button: Button) {
let fp = self.fingerprint.text().as_str().trim().to_owned();
self.obj().emit_by_name("confirm-clicked", &[&fp])
}
#[template_callback]
fn handle_cancel(&self, _: Button) {
self.obj().emit_by_name("cancel-clicked", &[])
}
pub(super) fn set_fingerprint(&self, fingerprint: &str) {
self.fingerprint.set_text(fingerprint);
}
}
impl ObjectImpl for AuthorizationWindow {
fn signals() -> &'static [Signal] {
static SIGNALS: OnceLock<Vec<Signal>> = OnceLock::new();
SIGNALS.get_or_init(|| {
vec![
Signal::builder("confirm-clicked")
.param_types([String::static_type()])
.build(),
Signal::builder("cancel-clicked").build(),
]
})
}
}
impl WidgetImpl for AuthorizationWindow {}
impl WindowImpl for AuthorizationWindow {}
impl ApplicationWindowImpl for AuthorizationWindow {}
impl AdwWindowImpl for AuthorizationWindow {}

View File

@@ -1,230 +0,0 @@
use std::cell::RefCell;
use adw::subclass::prelude::*;
use adw::{ActionRow, ComboRow, prelude::*};
use glib::{Binding, subclass::InitializingObject};
use gtk::glib::subclass::Signal;
use gtk::glib::{SignalHandlerId, clone};
use gtk::{Button, CompositeTemplate, Entry, Switch, glib};
use lan_mouse_ipc::Position;
use std::sync::OnceLock;
use crate::client_object::ClientObject;
#[derive(CompositeTemplate, Default)]
#[template(resource = "/de/feschber/LanMouse/client_row.ui")]
pub struct ClientRow {
#[template_child]
pub enable_switch: TemplateChild<gtk::Switch>,
#[template_child]
pub dns_button: TemplateChild<gtk::Button>,
#[template_child]
pub hostname: TemplateChild<gtk::Entry>,
#[template_child]
pub port: TemplateChild<gtk::Entry>,
#[template_child]
pub position: TemplateChild<ComboRow>,
#[template_child]
pub delete_row: TemplateChild<ActionRow>,
#[template_child]
pub delete_button: TemplateChild<gtk::Button>,
#[template_child]
pub dns_loading_indicator: TemplateChild<gtk::Spinner>,
pub bindings: RefCell<Vec<Binding>>,
hostname_change_handler: RefCell<Option<SignalHandlerId>>,
port_change_handler: RefCell<Option<SignalHandlerId>>,
position_change_handler: RefCell<Option<SignalHandlerId>>,
set_state_handler: RefCell<Option<SignalHandlerId>>,
pub client_object: RefCell<Option<ClientObject>>,
}
#[glib::object_subclass]
impl ObjectSubclass for ClientRow {
// `NAME` needs to match `class` attribute of template
const NAME: &'static str = "ClientRow";
const ABSTRACT: bool = false;
type Type = super::ClientRow;
type ParentType = adw::ExpanderRow;
fn class_init(klass: &mut Self::Class) {
klass.bind_template();
klass.bind_template_callbacks();
}
fn instance_init(obj: &InitializingObject<Self>) {
obj.init_template();
}
}
impl ObjectImpl for ClientRow {
fn constructed(&self) {
self.parent_constructed();
self.delete_button.connect_clicked(clone!(
#[weak(rename_to = row)]
self,
move |button| {
row.handle_client_delete(button);
}
));
let handler = self.hostname.connect_changed(clone!(
#[weak(rename_to = row)]
self,
move |entry| {
row.handle_hostname_changed(entry);
}
));
self.hostname_change_handler.replace(Some(handler));
let handler = self.port.connect_changed(clone!(
#[weak(rename_to = row)]
self,
move |entry| {
row.handle_port_changed(entry);
}
));
self.port_change_handler.replace(Some(handler));
let handler = self.position.connect_selected_notify(clone!(
#[weak(rename_to = row)]
self,
move |position| {
row.handle_position_changed(position);
}
));
self.position_change_handler.replace(Some(handler));
let handler = self.enable_switch.connect_state_set(clone!(
#[weak(rename_to = row)]
self,
#[upgrade_or]
glib::Propagation::Proceed,
move |switch, state| {
row.handle_activate_switch(state, switch);
glib::Propagation::Proceed
}
));
self.set_state_handler.replace(Some(handler));
}
fn signals() -> &'static [glib::subclass::Signal] {
static SIGNALS: OnceLock<Vec<Signal>> = OnceLock::new();
SIGNALS.get_or_init(|| {
vec![
Signal::builder("request-activate")
.param_types([bool::static_type()])
.build(),
Signal::builder("request-delete").build(),
Signal::builder("request-dns").build(),
Signal::builder("request-hostname-change")
.param_types([String::static_type()])
.build(),
Signal::builder("request-port-change")
.param_types([u32::static_type()])
.build(),
Signal::builder("request-position-change")
.param_types([u32::static_type()])
.build(),
]
})
}
}
#[gtk::template_callbacks]
impl ClientRow {
#[template_callback]
fn handle_activate_switch(&self, state: bool, _switch: &Switch) -> bool {
self.obj().emit_by_name::<()>("request-activate", &[&state]);
true // dont run default handler
}
#[template_callback]
fn handle_request_dns(&self, _: &Button) {
self.obj().emit_by_name::<()>("request-dns", &[]);
}
#[template_callback]
fn handle_client_delete(&self, _button: &Button) {
self.obj().emit_by_name::<()>("request-delete", &[]);
}
fn handle_port_changed(&self, port_entry: &Entry) {
if let Ok(port) = port_entry.text().parse::<u16>() {
self.obj()
.emit_by_name::<()>("request-port-change", &[&(port as u32)]);
}
}
fn handle_hostname_changed(&self, hostname_entry: &Entry) {
self.obj()
.emit_by_name::<()>("request-hostname-change", &[&hostname_entry.text()]);
}
fn handle_position_changed(&self, position: &ComboRow) {
self.obj()
.emit_by_name("request-position-change", &[&position.selected()])
}
pub(super) fn set_hostname(&self, hostname: Option<String>) {
let position = self.hostname.position();
let handler = self.hostname_change_handler.borrow();
let handler = handler.as_ref().expect("signal handler");
self.hostname.block_signal(handler);
self.client_object
.borrow_mut()
.as_mut()
.expect("client object")
.set_property("hostname", hostname);
self.hostname.unblock_signal(handler);
self.hostname.set_position(position);
}
pub(super) fn set_port(&self, port: u16) {
let position = self.port.position();
let handler = self.port_change_handler.borrow();
let handler = handler.as_ref().expect("signal handler");
self.port.block_signal(handler);
self.client_object
.borrow_mut()
.as_mut()
.expect("client object")
.set_port(port as u32);
self.port.unblock_signal(handler);
self.port.set_position(position);
}
pub(super) fn set_pos(&self, pos: Position) {
let handler = self.position_change_handler.borrow();
let handler = handler.as_ref().expect("signal handler");
self.position.block_signal(handler);
self.client_object
.borrow_mut()
.as_mut()
.expect("client object")
.set_position(pos.to_string());
self.position.unblock_signal(handler);
}
pub(super) fn set_active(&self, active: bool) {
let handler = self.set_state_handler.borrow();
let handler = handler.as_ref().expect("signal handler");
self.enable_switch.block_signal(handler);
self.client_object
.borrow_mut()
.as_mut()
.expect("client object")
.set_active(active);
self.enable_switch.unblock_signal(handler);
}
pub(super) fn set_dns_state(&self, resolved: bool) {
if resolved {
self.dns_button.set_css_classes(&["success"])
} else {
self.dns_button.set_css_classes(&["warning"])
}
}
}
impl WidgetImpl for ClientRow {}
impl BoxImpl for ClientRow {}
impl ListBoxRowImpl for ClientRow {}
impl PreferencesRowImpl for ClientRow {}
impl ExpanderRowImpl for ClientRow {}

View File

@@ -1,22 +0,0 @@
mod imp;
use glib::Object;
use gtk::{gio, glib, prelude::ObjectExt, subclass::prelude::ObjectSubclassIsExt};
glib::wrapper! {
pub struct FingerprintWindow(ObjectSubclass<imp::FingerprintWindow>)
@extends adw::Window, gtk::Window, gtk::Widget,
@implements gio::ActionGroup, gio::ActionMap, gtk::Accessible, gtk::Buildable,
gtk::ConstraintTarget, gtk::Native, gtk::Root, gtk::ShortcutManager;
}
impl FingerprintWindow {
pub(crate) fn new(fingerprint: Option<String>) -> Self {
let window: Self = Object::builder().build();
if let Some(fp) = fingerprint {
window.imp().fingerprint.set_property("text", fp);
window.imp().fingerprint.set_property("editable", false);
}
window
}
}

View File

@@ -1,67 +0,0 @@
use std::sync::OnceLock;
use adw::prelude::*;
use adw::subclass::prelude::*;
use glib::subclass::InitializingObject;
use gtk::{
Button, CompositeTemplate, Text,
glib::{self, subclass::Signal},
template_callbacks,
};
#[derive(CompositeTemplate, Default)]
#[template(resource = "/de/feschber/LanMouse/fingerprint_window.ui")]
pub struct FingerprintWindow {
#[template_child]
pub description: TemplateChild<Text>,
#[template_child]
pub fingerprint: TemplateChild<Text>,
#[template_child]
pub confirm_button: TemplateChild<Button>,
}
#[glib::object_subclass]
impl ObjectSubclass for FingerprintWindow {
const NAME: &'static str = "FingerprintWindow";
const ABSTRACT: bool = false;
type Type = super::FingerprintWindow;
type ParentType = adw::Window;
fn class_init(klass: &mut Self::Class) {
klass.bind_template();
klass.bind_template_callbacks();
}
fn instance_init(obj: &InitializingObject<Self>) {
obj.init_template();
}
}
#[template_callbacks]
impl FingerprintWindow {
#[template_callback]
fn handle_confirm(&self, _button: Button) {
let desc = self.description.text().as_str().trim().to_owned();
let fp = self.fingerprint.text().as_str().trim().to_owned();
self.obj().emit_by_name("confirm-clicked", &[&desc, &fp])
}
}
impl ObjectImpl for FingerprintWindow {
fn signals() -> &'static [Signal] {
static SIGNALS: OnceLock<Vec<Signal>> = OnceLock::new();
SIGNALS.get_or_init(|| {
vec![
Signal::builder("confirm-clicked")
.param_types([String::static_type(), String::static_type()])
.build(),
]
})
}
}
impl WidgetImpl for FingerprintWindow {}
impl WindowImpl for FingerprintWindow {}
impl ApplicationWindowImpl for FingerprintWindow {}
impl AdwWindowImpl for FingerprintWindow {}

View File

@@ -1,25 +0,0 @@
mod imp;
use adw::subclass::prelude::*;
use gtk::glib::{self, Object};
glib::wrapper! {
pub struct KeyObject(ObjectSubclass<imp::KeyObject>);
}
impl KeyObject {
pub fn new(desc: String, fp: String) -> Self {
Object::builder()
.property("description", desc)
.property("fingerprint", fp)
.build()
}
pub fn get_description(&self) -> String {
self.imp().description.borrow().clone()
}
pub fn get_fingerprint(&self) -> String {
self.imp().fingerprint.borrow().clone()
}
}

View File

@@ -1,24 +0,0 @@
use std::cell::RefCell;
use glib::Properties;
use gtk::glib;
use gtk::prelude::*;
use gtk::subclass::prelude::*;
#[derive(Properties, Default)]
#[properties(wrapper_type = super::KeyObject)]
pub struct KeyObject {
#[property(name = "description", get, set, type = String)]
pub description: RefCell<String>,
#[property(name = "fingerprint", get, set, type = String)]
pub fingerprint: RefCell<String>,
}
#[glib::object_subclass]
impl ObjectSubclass for KeyObject {
const NAME: &'static str = "KeyObject";
type Type = super::KeyObject;
}
#[glib::derived_properties]
impl ObjectImpl for KeyObject {}

View File

@@ -1,48 +0,0 @@
mod imp;
use adw::prelude::*;
use adw::subclass::prelude::*;
use gtk::glib::{self, Object};
use super::KeyObject;
glib::wrapper! {
pub struct KeyRow(ObjectSubclass<imp::KeyRow>)
@extends gtk::ListBoxRow, gtk::Widget, adw::PreferencesRow, adw::ActionRow,
@implements gtk::Accessible, gtk::Actionable, gtk::Buildable, gtk::ConstraintTarget;
}
impl Default for KeyRow {
fn default() -> Self {
Self::new()
}
}
impl KeyRow {
pub fn new() -> Self {
Object::builder().build()
}
pub fn bind(&self, key_object: &KeyObject) {
let mut bindings = self.imp().bindings.borrow_mut();
let title_binding = key_object
.bind_property("description", self, "title")
.sync_create()
.build();
let subtitle_binding = key_object
.bind_property("fingerprint", self, "subtitle")
.sync_create()
.build();
bindings.push(title_binding);
bindings.push(subtitle_binding);
}
pub fn unbind(&self) {
for binding in self.imp().bindings.borrow_mut().drain(..) {
binding.unbind();
}
}
}

View File

@@ -1,68 +0,0 @@
use std::cell::RefCell;
use adw::subclass::prelude::*;
use adw::{ActionRow, prelude::*};
use glib::{Binding, subclass::InitializingObject};
use gtk::glib::clone;
use gtk::glib::subclass::Signal;
use gtk::{Button, CompositeTemplate, glib};
use std::sync::OnceLock;
#[derive(CompositeTemplate, Default)]
#[template(resource = "/de/feschber/LanMouse/key_row.ui")]
pub struct KeyRow {
#[template_child]
pub delete_button: TemplateChild<gtk::Button>,
pub bindings: RefCell<Vec<Binding>>,
}
#[glib::object_subclass]
impl ObjectSubclass for KeyRow {
// `NAME` needs to match `class` attribute of template
const NAME: &'static str = "KeyRow";
const ABSTRACT: bool = false;
type Type = super::KeyRow;
type ParentType = ActionRow;
fn class_init(klass: &mut Self::Class) {
klass.bind_template();
klass.bind_template_callbacks();
}
fn instance_init(obj: &InitializingObject<Self>) {
obj.init_template();
}
}
impl ObjectImpl for KeyRow {
fn constructed(&self) {
self.parent_constructed();
self.delete_button.connect_clicked(clone!(
#[weak(rename_to = row)]
self,
move |button| {
row.handle_delete(button);
}
));
}
fn signals() -> &'static [glib::subclass::Signal] {
static SIGNALS: OnceLock<Vec<Signal>> = OnceLock::new();
SIGNALS.get_or_init(|| vec![Signal::builder("request-delete").build()])
}
}
#[gtk::template_callbacks]
impl KeyRow {
#[template_callback]
fn handle_delete(&self, _button: &Button) {
self.obj().emit_by_name::<()>("request-delete", &[]);
}
}
impl WidgetImpl for KeyRow {}
impl BoxImpl for KeyRow {}
impl ListBoxRowImpl for KeyRow {}
impl PreferencesRowImpl for KeyRow {}
impl ActionRowImpl for KeyRow {}

View File

@@ -1,291 +0,0 @@
mod authorization_window;
mod client_object;
mod client_row;
mod fingerprint_window;
mod key_object;
mod key_row;
#[cfg(target_os = "macos")]
mod macos_privacy;
#[cfg(target_os = "macos")]
mod macos_status_item;
mod window;
use std::{env, process, str};
use window::Window;
use lan_mouse_ipc::FrontendEvent;
use adw::Application;
use gtk::{IconTheme, gdk::Display, glib::clone, prelude::*};
use gtk::{gio, glib, prelude::ApplicationExt};
use self::client_object::ClientObject;
use self::key_object::KeyObject;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum GtkError {
#[error("gtk frontend exited with non zero exit code: {0}")]
NonZeroExitCode(i32),
}
pub fn run() -> Result<(), GtkError> {
log::debug!("running gtk frontend");
#[cfg(windows)]
let ret = std::thread::Builder::new()
.stack_size(8 * 1024 * 1024) // https://gitlab.gnome.org/GNOME/gtk/-/commit/52dbb3f372b2c3ea339e879689c1de535ba2c2c3 -> caused crash on windows
.name("gtk".into())
.spawn(gtk_main)
.unwrap()
.join()
.unwrap();
#[cfg(not(windows))]
let ret = gtk_main();
match ret {
glib::ExitCode::SUCCESS => Ok(()),
e => Err(GtkError::NonZeroExitCode(e.value())),
}
}
fn gtk_main() -> glib::ExitCode {
#[cfg(target_os = "macos")]
{
configure_macos_bundle_environment();
install_macos_gtk_log_filter();
}
gio::resources_register_include!("lan-mouse.gresource").expect("Failed to register resources.");
let app = Application::builder()
.application_id("de.feschber.LanMouse")
.build();
app.connect_startup(|app| {
load_icons();
setup_actions(app);
setup_menu(app);
});
app.connect_activate(build_ui);
let args: Vec<&'static str> = vec![];
app.run_with_args(&args)
}
#[cfg(target_os = "macos")]
fn install_macos_gtk_log_filter() {
glib::log_set_writer_func(|level, fields| {
if level == glib::LogLevel::Warning && is_gtk_theme_parser_warning(fields) {
return glib::LogWriterOutput::Handled;
}
glib::log_writer_default(level, fields)
});
}
#[cfg(target_os = "macos")]
fn is_gtk_theme_parser_warning(fields: &[glib::LogField<'_>]) -> bool {
let mut domain = None;
let mut message = None;
for field in fields {
match field.key() {
"GLIB_DOMAIN" => domain = field.value_str(),
"MESSAGE" => message = field.value_str(),
_ => {}
}
}
domain == Some("Gtk")
&& message.is_some_and(|message| message.starts_with("Theme parser warning: gtk.css:"))
}
#[cfg(target_os = "macos")]
fn configure_macos_bundle_environment() {
let Ok(exe) = env::current_exe() else {
return;
};
let Some(contents) = exe
.parent()
.and_then(|dir| dir.parent())
.map(std::path::Path::to_owned)
else {
return;
};
let share = contents.join("Resources").join("share");
if !share.exists() {
return;
}
let schemas = share.join("glib-2.0").join("schemas");
if schemas.exists() {
env::set_var("GSETTINGS_SCHEMA_DIR", schemas);
}
env::set_var("XDG_DATA_DIRS", &share);
env::set_var(
"GTK_DATA_PREFIX",
contents.join("Resources").to_string_lossy().as_ref(),
);
}
fn load_icons() {
let display = &Display::default().expect("Could not connect to a display.");
let icon_theme = IconTheme::for_display(display);
icon_theme.add_resource_path("/de/feschber/LanMouse/icons");
}
// Add application actions
fn setup_actions(app: &adw::Application) {
// Quit action
// This is important on macOS, where users expect a File->Quit action with a Cmd+Q shortcut.
let quit_action = gio::SimpleAction::new("quit", None);
quit_action.connect_activate({
let app = app.clone();
move |_, _| {
app.quit();
}
});
app.add_action(&quit_action);
}
// Set up a global menu
//
// Currently this is used only on macOS
fn setup_menu(app: &adw::Application) {
let menu = gio::Menu::new();
let file_menu = gio::Menu::new();
file_menu.append(Some("Quit"), Some("app.quit"));
menu.append_submenu(Some("_File"), &file_menu);
app.set_menubar(Some(&menu))
}
fn build_ui(app: &Application) {
log::debug!("connecting to lan-mouse-socket");
let (mut frontend_rx, frontend_tx) = match lan_mouse_ipc::connect() {
Ok(conn) => conn,
Err(e) => {
log::error!("{e}");
process::exit(1);
}
};
log::debug!("connected to lan-mouse-socket");
let (sender, receiver) = async_channel::bounded(10);
gio::spawn_blocking(move || {
while let Some(e) = frontend_rx.next_event() {
match e {
Ok(e) => sender.send_blocking(e).unwrap(),
Err(e) => {
log::error!("{e}");
break;
}
}
}
});
let window = Window::new(app, frontend_tx);
#[cfg(target_os = "macos")]
{
window.connect_close_request(|window| {
window.set_visible(false);
glib::Propagation::Stop
});
macos_status_item::setup(app, &window);
// First-launch TCC prompts. No-op when already granted.
macos_privacy::fire_initial_prompts();
// Watch the Accessibility grant continuously for the lifetime
// of the process. On a grant, swap the warning row into its
// "relaunch required" state (the daemon subprocess already
// bailed and can't recover without a restart). On a REVOKE,
// quit immediately — an active CGEventTap at
// HeadInsertEventTap can wedge system input if the process
// lingers after losing AX, and forcing the process to exit is
// the only bulletproof way to guarantee the kernel tears the
// tap down.
let window_weak = window.downgrade();
let app_weak = app.downgrade();
macos_privacy::watch_accessibility_state(move |change| match change {
macos_privacy::AccessibilityChange::Granted => {
if let Some(window) = window_weak.upgrade() {
window.present();
window.refresh_capture_emulation_status();
}
}
macos_privacy::AccessibilityChange::Revoked => {
log::warn!("Accessibility revoked — quitting to avoid wedging system input");
if let Some(app) = app_weak.upgrade() {
app.quit();
}
}
});
}
glib::spawn_future_local(clone!(
#[weak]
window,
async move {
loop {
let notify = receiver.recv().await.unwrap_or_else(|_| process::exit(1));
match notify {
FrontendEvent::Created(handle, client, state) => {
window.new_client(handle, client, state)
}
FrontendEvent::Deleted(client) => window.delete_client(client),
FrontendEvent::State(handle, config, state) => {
window.update_client_config(handle, config);
window.update_client_state(handle, state);
}
FrontendEvent::NoSuchClient(_) => {}
FrontendEvent::Error(e) => window.show_toast(e.as_str()),
FrontendEvent::Enumerate(clients) => window.update_client_list(clients),
FrontendEvent::PortChanged(port, msg) => window.update_port(port, msg),
FrontendEvent::CaptureStatus(s) => window.set_capture(s.into()),
FrontendEvent::EmulationStatus(s) => window.set_emulation(s.into()),
FrontendEvent::AuthorizedUpdated(keys) => window.set_authorized_keys(keys),
FrontendEvent::PublicKeyFingerprint(fp) => window.set_pk_fp(&fp),
FrontendEvent::ConnectionAttempt { fingerprint } => {
window.request_authorization(&fingerprint);
}
FrontendEvent::DeviceConnected {
fingerprint: _,
addr,
} => {
window.show_toast(format!("device connected: {addr}").as_str());
}
FrontendEvent::DeviceEntered {
fingerprint: _,
addr,
pos,
} => {
window.show_toast(format!("device entered: {addr} ({pos})").as_str());
}
FrontendEvent::IncomingDisconnected(addr) => {
window.show_toast(format!("{addr} disconnected").as_str());
}
}
}
}
));
#[cfg(not(target_os = "macos"))]
window.present();
// On macOS, default to presenting the main window on every launch
// so the user gets a visible confirmation that the app is running
// — including the post-grant relaunch and normal Dock/Finder/`open`
// launches. Opt out by setting `LAN_MOUSE_HIDDEN=1` in the
// environment (useful for a LaunchAgent / login-item configuration
// where the user wants the app to come up quietly into the menu
// bar only, with no window on boot).
#[cfg(target_os = "macos")]
if env::var_os("LAN_MOUSE_HIDDEN").is_none() {
window.present();
}
}

View File

@@ -1,256 +0,0 @@
//! Tiny macOS Privacy-pane helpers used by the GUI.
//!
//! On macOS 13+, the Accessibility grant transitively confers the
//! listen-only event-tap privilege that Input Monitoring gates and the
//! synthesize-event privilege that Post Event gates, and the bundle
//! typically isn't even listed in those separate panes. So the single
//! user-facing action for any missing-capture or missing-emulation
//! scenario is "re-toggle Accessibility" — we don't route elsewhere.
use std::ffi::{c_uchar, c_void};
use std::process::Command;
use std::sync::Once;
use gtk::glib;
// Apple declares `AXIsProcessTrusted` as returning `Boolean` (`unsigned char`),
// NOT C's `bool`. Rust's `bool` has a strict bit pattern (0 or 1) so binding
// a `Boolean`-returning function as `-> bool` is technically UB if Apple ever
// returns a non-canonical true value. Keep these as `c_uchar` and normalize.
#[link(name = "ApplicationServices", kind = "framework")]
extern "C" {
fn AXIsProcessTrusted() -> c_uchar;
fn AXIsProcessTrustedWithOptions(options: *const c_void) -> c_uchar;
}
#[link(name = "CoreFoundation", kind = "framework")]
extern "C" {
static kCFAllocatorDefault: *const c_void;
static kCFTypeDictionaryKeyCallBacks: *const c_void;
static kCFTypeDictionaryValueCallBacks: *const c_void;
static kCFBooleanTrue: *const c_void;
fn CFDictionaryCreate(
allocator: *const c_void,
keys: *const *const c_void,
values: *const *const c_void,
num: isize,
key_callbacks: *const c_void,
value_callbacks: *const c_void,
) -> *const c_void;
fn CFRelease(cf: *const c_void);
}
// kAXTrustedCheckOptionPrompt is a CFStringRef exported from ApplicationServices.
#[link(name = "ApplicationServices", kind = "framework")]
extern "C" {
static kAXTrustedCheckOptionPrompt: *const c_void;
}
#[link(name = "CoreGraphics", kind = "framework")]
extern "C" {
fn CGRequestListenEventAccess() -> c_uchar;
fn CGRequestPostEventAccess() -> c_uchar;
// CFMachPortRef CGEventTapCreate(
// CGEventTapLocation tap, CGEventTapPlacement place,
// CGEventTapOptions options, CGEventMask eventsOfInterest,
// CGEventTapCallBack callback, void *userInfo);
fn CGEventTapCreate(
tap: u32,
place: u32,
options: u32,
events_of_interest: u64,
callback: *const c_void,
user_info: *const c_void,
) -> *const c_void;
}
pub fn accessibility_granted() -> bool {
let raw = unsafe { AXIsProcessTrusted() };
log::debug!("AXIsProcessTrusted() = {raw}");
raw != 0
}
pub enum AccessibilityChange {
/// AX was missing at startup and the user has now granted it.
/// Capture/emulation still need a relaunch to take effect, since
/// the daemon subprocess already bailed.
Granted,
/// AX was granted and the user has now revoked it. Quit immediately
/// — leaving the process alive with an active CGEventTap at
/// HeadInsertEventTap can wedge system input (clicks/keys silently
/// consumed) until the process dies. See
/// macos-cgeventtap-drop-fallthrough-tcc-revoke skill for the
/// underlying event-tap-disable footgun.
Revoked,
}
/// Poll for Accessibility grant/revoke transitions. Starts a 1-second
/// GLib timer that fires `on_change` every time `AXIsProcessTrusted()`
/// flips, and keeps running for the lifetime of the process.
///
/// We rely on polling rather than AXObserver because the AX notification
/// API requires a trusted process to subscribe — the precondition we
/// can't assume. This runs on the GTK main thread (via
/// `timeout_add_seconds_local`).
pub fn watch_accessibility_state<F>(mut on_change: F)
where
F: FnMut(AccessibilityChange) + 'static,
{
let mut last = accessibility_granted();
log::info!("watching Accessibility state (initial = {last})");
glib::timeout_add_seconds_local(1, move || {
let current = accessibility_granted();
if current != last {
log::info!("Accessibility state flip: {last} -> {current}");
on_change(if current {
AccessibilityChange::Granted
} else {
AccessibilityChange::Revoked
});
last = current;
}
glib::ControlFlow::Continue
});
}
pub fn open_accessibility_settings() {
open_url("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility");
}
/// Spawn a fresh instance of the current `.app` bundle via Launch Services
/// after a 1-second delay, so the new instance starts *after* the current
/// process has exited — otherwise Launch Services reactivates the existing
/// process instead of launching a fresh one, and the stale IPC socket
/// would block the new daemon subprocess. The caller is responsible for
/// quitting the current process (e.g. `Application::quit()`) after this.
pub fn relaunch_bundle() {
// Resolve the .app bundle path from the current executable: it lives
// at <bundle>/Contents/MacOS/lan-mouse, so three parents up is the
// bundle root we hand to `open`.
let Ok(exe) = std::env::current_exe() else {
return;
};
let Some(bundle) = exe
.parent()
.and_then(std::path::Path::parent)
.and_then(std::path::Path::parent)
else {
return;
};
// Trailing `&` backgrounds the sleep+open so our shell call returns
// immediately; the spawned shell is adopted by launchd once we exit.
let cmd = format!("(sleep 1 && open {bundle:?}) &");
let _ = Command::new("sh").arg("-c").arg(cmd).spawn();
}
/// Make sure the app appears in System Settings → Privacy → Input Monitoring.
///
/// `CGRequestListenEventAccess()` is *supposed* to register the app in the
/// list (and prompt) on first call, but in practice — particularly after a
/// `tccutil reset ListenEvent <bundle>` — it often silently no-ops and the
/// app never gets added. The reliable way to force registration is to
/// attempt a protected action: create a `CGEventTap`. If permission is
/// missing the call returns null, but the attempt itself causes TCC to add
/// the bundle to the Input Monitoring pane so the user can toggle it on.
/// If permission already exists the tap is created successfully, and we
/// tear it down immediately so it doesn't intercept events.
unsafe fn ensure_listed_in_input_monitoring() {
let req = CGRequestListenEventAccess();
log::debug!("CGRequestListenEventAccess() = {req}");
let cb = input_monitoring_noop_tap_callback as *const c_void;
// Use kCGSessionEventTap (1), NOT kCGHIDEventTap (0). The HID tap sits
// below window-server input and requires Accessibility in addition to
// Input Monitoring, so attempting it when Accessibility isn't granted
// surfaces an Accessibility prompt as a side effect — which is confusing
// on top of the real Accessibility prompt we already fire explicitly.
// The session tap requires only Input Monitoring, so its failure is a
// clean "Input Monitoring missing" signal that TCC uses to list the
// bundle under the Input Monitoring pane.
// kCGHeadInsertEventTap = 0, kCGEventTapOptionListenOnly = 1,
// mask kCGEventKeyDown = 1 << 10.
let tap = CGEventTapCreate(1, 0, 1, 1 << 10, cb, std::ptr::null());
log::debug!("CGEventTapCreate(kCGSessionEventTap) -> {tap:?}");
if !tap.is_null() {
CFRelease(tap);
}
}
extern "C" fn input_monitoring_noop_tap_callback(
_proxy: *const c_void,
_ty: u32,
event: *const c_void,
_refcon: *const c_void,
) -> *const c_void {
// Pass through unchanged. This tap is never added to a run loop, so
// in practice the callback never fires — it exists only so the tap
// can be created (and the attempt is what forces TCC registration).
event
}
fn open_url(url: &str) {
if let Err(e) = Command::new("open").arg(url).spawn() {
log::warn!("failed to open {url}: {e}");
}
}
/// One-shot, at GUI startup: if a permission is missing, fire the system
/// prompt. This is where the familiar first-launch "Lan Mouse.app would
/// like to control this computer" alert comes from. Subsequent clicks on
/// the Reenable button use URL-scheme navigation instead, so we never
/// double up alerts on retries.
///
/// Guarded with a `Once` because GApplication::activate can fire more
/// than once in a process (reactivation, window presentation) and we
/// must not re-pop the TCC alert on each activation — that looks like a
/// bug to the user.
pub fn fire_initial_prompts() {
static FIRED: Once = Once::new();
FIRED.call_once(fire_initial_prompts_inner);
}
fn fire_initial_prompts_inner() {
if !accessibility_granted() {
// When Accessibility isn't granted yet, ONLY fire the Accessibility
// prompt. Do NOT also try to register Input Monitoring or Post Event
// — those paths have been observed to surface a second Accessibility
// dialog on top of the one we fire explicitly (Post Event is part of
// the Accessibility category on modern macOS, and CGEventTap attempts
// can bail on Accessibility before they reach the Input Monitoring
// check). Once the user grants Accessibility and relaunches, this
// branch is skipped and we register the other grants cleanly below.
log::info!("firing first-launch Accessibility prompt");
unsafe {
let key = kAXTrustedCheckOptionPrompt;
let value = kCFBooleanTrue;
let options = CFDictionaryCreate(
kCFAllocatorDefault,
&key as *const _,
&value as *const _,
1,
kCFTypeDictionaryKeyCallBacks,
kCFTypeDictionaryValueCallBacks,
);
AXIsProcessTrustedWithOptions(options);
CFRelease(options);
}
return;
}
// Accessibility is granted. Attempt Input Monitoring registration
// unconditionally — even if preflight returns true — so the bundle gets
// listed in System Settings under its own identity (otherwise launches
// from a parent process that already has Input Monitoring, e.g. Terminal,
// inherit the grant but the bundle is never listed for the user to
// toggle persistently).
log::info!("ensuring Lan Mouse is listed under Input Monitoring");
unsafe {
ensure_listed_in_input_monitoring();
}
// Same for Post Event: now that Accessibility is present, this call is
// safe — it won't surface the generic Accessibility prompt.
log::info!("ensuring Lan Mouse is listed under Accessibility > Post Event");
unsafe {
CGRequestPostEventAccess();
}
}

View File

@@ -1,346 +0,0 @@
#![allow(clashing_extern_declarations)]
use std::{
cell::RefCell,
ffi::{CStr, CString, c_char, c_double, c_uint, c_void},
sync::OnceLock,
};
use adw::prelude::*;
use gtk::{gio, glib};
use crate::window::Window;
type Id = *mut c_void;
type Class = *mut c_void;
type Sel = *mut c_void;
type Bool = i8;
struct StatusItem {
app: glib::WeakRef<adw::Application>,
window: glib::WeakRef<Window>,
_hold: gio::ApplicationHoldGuard,
_delegate: Id,
_status_item: Id,
}
thread_local! {
static STATUS_ITEM: RefCell<Option<StatusItem>> = const { RefCell::new(None) };
}
pub fn setup(app: &adw::Application, window: &Window) {
log::debug!("macos_status_item::setup entered");
STATUS_ITEM.with(|item| {
let already_initialized = item.borrow().is_some();
if already_initialized {
let mut cell = item.borrow_mut();
if let Some(existing) = cell.as_mut() {
existing.app.set(Some(app));
existing.window.set(Some(window));
}
return;
}
unsafe {
let hold = app.hold();
let ns_app = msg_send_id(class(c"NSApplication"), sel(c"sharedApplication"));
assert!(
!ns_app.is_null(),
"NSApplication sharedApplication returned null"
);
msg_send_bool_usize(ns_app, sel(c"setActivationPolicy:"), 1);
let delegate = new_delegate();
let menu = menu(&[
menu_item(c"Open Lan Mouse", c"showLanMouse:"),
separator_item(),
menu_item(c"Quit Lan Mouse", c"quitLanMouse:"),
]);
let status_bar = msg_send_id(class(c"NSStatusBar"), sel(c"systemStatusBar"));
assert!(
!status_bar.is_null(),
"NSStatusBar systemStatusBar returned null"
);
let status_item = msg_send_id_f64(status_bar, sel(c"statusItemWithLength:"), -1.0);
assert!(!status_item.is_null(), "statusItemWithLength returned null");
// Retain so the status item survives autorelease pool drain.
let status_item = msg_send_id(status_item, sel(c"retain"));
let button = msg_send_id(status_item, sel(c"button"));
assert!(!button.is_null(), "NSStatusItem.button was null");
set_button_image(button);
msg_send_void_id(button, sel(c"setToolTip:"), nsstring(c"Lan Mouse"));
msg_send_void_id(status_item, sel(c"setMenu:"), menu);
for item in menu_items(menu) {
msg_send_void_id(item, sel(c"setTarget:"), delegate);
}
install_reopen_handler(delegate);
log::debug!("macos_status_item ready at {status_item:p}");
item.replace(Some(StatusItem {
app: app.downgrade(),
window: window.downgrade(),
_hold: hold,
_delegate: delegate,
_status_item: status_item,
}));
}
});
}
// Prefer a pre-rendered template PNG (black silhouette with alpha) so macOS
// auto-tints the glyph to match the menu bar in light and dark modes.
// Falls back to the full-color icns, then to "LM" text.
unsafe fn set_button_image(button: Id) {
if let Some(image) = load_menubar_template() {
msg_send_void_bool(image, sel(c"setTemplate:"), 1);
msg_send_void_id(button, sel(c"setImage:"), image);
return;
}
if let Some(image) = load_app_icon() {
msg_send_void_id(button, sel(c"setImage:"), image);
return;
}
log::warn!("no menu bar image available; falling back to text title");
msg_send_void_id(button, sel(c"setTitle:"), nsstring(c"LM"));
}
unsafe fn load_menubar_template() -> Option<Id> {
load_resource_image(c"menubar-template", c"png", MENUBAR_ICON_SIZE)
}
unsafe fn load_app_icon() -> Option<Id> {
load_resource_image(c"icon", c"icns", MENUBAR_ICON_SIZE)
}
unsafe fn load_resource_image(name: &CStr, ext: &CStr, size_pt: c_double) -> Option<Id> {
let bundle = msg_send_id(class(c"NSBundle"), sel(c"mainBundle"));
if bundle.is_null() {
return None;
}
let path = msg_send_id_id_id(
bundle,
sel(c"pathForResource:ofType:"),
nsstring(name),
nsstring(ext),
);
if path.is_null() {
return None;
}
let image = msg_send_id_id(
msg_send_id(class(c"NSImage"), sel(c"alloc")),
sel(c"initWithContentsOfFile:"),
path,
);
if image.is_null() {
return None;
}
// Render at menu bar height; 22pt is the full status bar icon height.
msg_send_void_size(image, sel(c"setSize:"), size_pt, size_pt);
Some(image)
}
const MENUBAR_ICON_SIZE: c_double = 22.0;
unsafe fn menu(items: &[Id]) -> Id {
let menu = msg_send_id(msg_send_id(class(c"NSMenu"), sel(c"alloc")), sel(c"init"));
for item in items {
msg_send_void_id(menu, sel(c"addItem:"), *item);
}
menu
}
unsafe fn menu_item(title: &CStr, action: &CStr) -> Id {
msg_send_id_id_sel_id(
msg_send_id(class(c"NSMenuItem"), sel(c"alloc")),
sel(c"initWithTitle:action:keyEquivalent:"),
nsstring(title),
sel(action),
nsstring(c""),
)
}
unsafe fn separator_item() -> Id {
msg_send_id(class(c"NSMenuItem"), sel(c"separatorItem"))
}
unsafe fn menu_items(menu: Id) -> Vec<Id> {
let count = msg_send_usize(menu, sel(c"numberOfItems"));
(0..count)
.map(|idx| msg_send_id_usize(menu, sel(c"itemAtIndex:"), idx))
.collect()
}
unsafe fn new_delegate() -> Id {
let class = delegate_class();
msg_send_id(msg_send_id(class, sel(c"alloc")), sel(c"init"))
}
fn delegate_class() -> Class {
static CLASS: OnceLock<usize> = OnceLock::new();
*CLASS.get_or_init(|| unsafe {
let superclass = class(c"NSObject");
let class_name = CString::new("LanMouseStatusItemDelegate").unwrap();
let class = objc_allocateClassPair(superclass, class_name.as_ptr(), 0);
assert!(!class.is_null(), "failed to allocate status item delegate");
class_addMethod(
class,
sel(c"showLanMouse:"),
show_lan_mouse as *const c_void,
c"v@:@".as_ptr(),
);
class_addMethod(
class,
sel(c"quitLanMouse:"),
quit_lan_mouse as *const c_void,
c"v@:@".as_ptr(),
);
// kAEReopenApplication handler — fires when the user re-launches
// the .app while it's already running (Finder, `open`, Dock).
class_addMethod(
class,
sel(c"handleReopenEvent:withReplyEvent:"),
handle_reopen_event as *const c_void,
c"v@:@@".as_ptr(),
);
objc_registerClassPair(class);
class as usize
}) as Class
}
extern "C" fn show_lan_mouse(_this: Id, _cmd: Sel, _sender: Id) {
present_window();
}
extern "C" fn handle_reopen_event(_this: Id, _cmd: Sel, _event: Id, _reply: Id) {
log::debug!("kAEReopenApplication received — presenting main window");
present_window();
}
fn present_window() {
STATUS_ITEM.with(|item| {
let item = item.borrow();
let Some(item) = item.as_ref() else {
return;
};
if let Some(window) = item.window.upgrade() {
window.present();
}
unsafe {
let ns_app = msg_send_id(class(c"NSApplication"), sel(c"sharedApplication"));
msg_send_void_bool(ns_app, sel(c"activateIgnoringOtherApps:"), 1);
}
});
}
// Register the status-item delegate as the handler for the
// kAEReopenApplication Apple Event ('aevt'/'rapp'). NSApplication
// installs a default handler at -finishLaunching that just delegates to
// applicationShouldHandleReopen:hasVisibleWindows: — which is a no-op
// here because GApplication owns NSApp's delegate. Replacing it lets us
// re-present the window when the user double-clicks the .app while
// we're already running.
unsafe fn install_reopen_handler(delegate: Id) {
const K_CORE_EVENT_CLASS: c_uint = 0x6165_7674; // 'aevt'
const K_AE_REOPEN_APPLICATION: c_uint = 0x7261_7070; // 'rapp'
let manager = msg_send_id(
class(c"NSAppleEventManager"),
sel(c"sharedAppleEventManager"),
);
if manager.is_null() {
log::warn!("NSAppleEventManager unavailable; re-launch will not re-open window");
return;
}
msg_send_void_id_sel_u32_u32(
manager,
sel(c"setEventHandler:andSelector:forEventClass:andEventID:"),
delegate,
sel(c"handleReopenEvent:withReplyEvent:"),
K_CORE_EVENT_CLASS,
K_AE_REOPEN_APPLICATION,
);
}
extern "C" fn quit_lan_mouse(_this: Id, _cmd: Sel, _sender: Id) {
STATUS_ITEM.with(|item| {
if let Some(app) = item.borrow().as_ref().and_then(|item| item.app.upgrade()) {
app.quit();
}
});
}
unsafe fn class(name: &CStr) -> Class {
let class = objc_getClass(name.as_ptr());
assert!(!class.is_null(), "missing Objective-C class {name:?}");
class
}
unsafe fn sel(name: &CStr) -> Sel {
sel_registerName(name.as_ptr())
}
unsafe fn nsstring(value: &CStr) -> Id {
msg_send_id_ptr(
class(c"NSString"),
sel(c"stringWithUTF8String:"),
value.as_ptr(),
)
}
#[link(name = "objc")]
extern "C" {
fn objc_allocateClassPair(superclass: Class, name: *const c_char, extra_bytes: usize) -> Class;
fn objc_getClass(name: *const c_char) -> Class;
fn objc_registerClassPair(class: Class);
fn sel_registerName(name: *const c_char) -> Sel;
fn class_addMethod(class: Class, name: Sel, imp: *const c_void, types: *const c_char) -> Bool;
}
#[link(name = "AppKit", kind = "framework")]
extern "C" {}
#[link(name = "objc")]
extern "C" {
#[link_name = "objc_msgSend"]
fn msg_send_id(receiver: Id, selector: Sel) -> Id;
#[link_name = "objc_msgSend"]
fn msg_send_id_f64(receiver: Id, selector: Sel, value: c_double) -> Id;
#[link_name = "objc_msgSend"]
fn msg_send_id_id_sel_id(receiver: Id, selector: Sel, a: Id, b: Sel, c: Id) -> Id;
#[link_name = "objc_msgSend"]
fn msg_send_id_id_id(receiver: Id, selector: Sel, a: Id, b: Id) -> Id;
#[link_name = "objc_msgSend"]
fn msg_send_id_id(receiver: Id, selector: Sel, a: Id) -> Id;
#[link_name = "objc_msgSend"]
fn msg_send_void_size(receiver: Id, selector: Sel, width: c_double, height: c_double);
#[link_name = "objc_msgSend"]
fn msg_send_id_ptr(receiver: Id, selector: Sel, value: *const c_char) -> Id;
#[link_name = "objc_msgSend"]
fn msg_send_id_usize(receiver: Id, selector: Sel, value: usize) -> Id;
#[link_name = "objc_msgSend"]
fn msg_send_usize(receiver: Id, selector: Sel) -> usize;
#[link_name = "objc_msgSend"]
fn msg_send_void_bool(receiver: Id, selector: Sel, value: Bool);
#[link_name = "objc_msgSend"]
fn msg_send_void_id(receiver: Id, selector: Sel, value: Id);
#[link_name = "objc_msgSend"]
fn msg_send_bool_usize(receiver: Id, selector: Sel, value: usize) -> Bool;
#[link_name = "objc_msgSend"]
fn msg_send_void_id_sel_u32_u32(
receiver: Id,
selector: Sel,
a: Id,
b: Sel,
c: c_uint,
d: c_uint,
);
}

View File

@@ -1,565 +0,0 @@
mod imp;
use std::collections::HashMap;
use adw::prelude::*;
use adw::subclass::prelude::*;
use glib::{Object, clone};
use gtk::{
NoSelection, gio,
glib::{self, closure_local},
};
use lan_mouse_ipc::{
ClientConfig, ClientHandle, ClientState, DEFAULT_PORT, FrontendRequest, FrontendRequestWriter,
Position,
};
use crate::{
authorization_window::AuthorizationWindow, fingerprint_window::FingerprintWindow,
key_object::KeyObject, key_row::KeyRow,
};
use super::{client_object::ClientObject, client_row::ClientRow};
#[cfg(target_os = "macos")]
fn set_button_content_label(button: &gtk::Button, label: &str) {
// The Reenable/Grant/Relaunch button wraps its icon+label in an
// AdwButtonContent (see window.ui). Walk into it and swap the label
// rather than GtkButton::set_label, which would replace the content
// widget and drop the icon.
if let Some(content) = button.child().and_downcast::<adw::ButtonContent>() {
content.set_label(label);
}
}
glib::wrapper! {
pub struct Window(ObjectSubclass<imp::Window>)
@extends adw::ApplicationWindow, gtk::Window, gtk::Widget,
@implements gio::ActionGroup, gio::ActionMap, gtk::Accessible, gtk::Buildable,
gtk::ConstraintTarget, gtk::Native, gtk::Root, gtk::ShortcutManager;
}
impl Window {
pub(super) fn new(app: &adw::Application, conn: FrontendRequestWriter) -> Self {
let window: Self = Object::builder().property("application", app).build();
window
.imp()
.frontend_request_writer
.borrow_mut()
.replace(conn);
window
}
fn clients(&self) -> gio::ListStore {
self.imp()
.clients
.borrow()
.clone()
.expect("Could not get clients")
}
fn authorized(&self) -> gio::ListStore {
self.imp()
.authorized
.borrow()
.clone()
.expect("Could not get authorized")
}
fn client_by_idx(&self, idx: u32) -> Option<ClientObject> {
self.clients().item(idx).map(|o| o.downcast().unwrap())
}
fn authorized_by_idx(&self, idx: u32) -> Option<KeyObject> {
self.authorized().item(idx).map(|o| o.downcast().unwrap())
}
fn row_by_idx(&self, idx: i32) -> Option<ClientRow> {
self.imp()
.client_list
.get()
.row_at_index(idx)
.map(|o| o.downcast().expect("expected ClientRow"))
}
fn setup_authorized(&self) {
let store = gio::ListStore::new::<KeyObject>();
self.imp().authorized.replace(Some(store));
let selection_model = NoSelection::new(Some(self.authorized()));
self.imp().authorized_list.bind_model(
Some(&selection_model),
clone!(
#[weak(rename_to = window)]
self,
#[upgrade_or_panic]
move |obj| {
let key_obj = obj.downcast_ref().expect("object of type `KeyObject`");
let row = window.create_key_row(key_obj);
row.connect_closure(
"request-delete",
false,
closure_local!(
#[strong]
window,
move |row: KeyRow| {
if let Some(key_obj) = window.authorized_by_idx(row.index() as u32)
{
window.request_fingerprint_remove(key_obj.get_fingerprint());
}
}
),
);
row.upcast()
}
),
)
}
fn setup_clients(&self) {
let model = gio::ListStore::new::<ClientObject>();
self.imp().clients.replace(Some(model));
let selection_model = NoSelection::new(Some(self.clients()));
self.imp().client_list.bind_model(
Some(&selection_model),
clone!(
#[weak(rename_to = window)]
self,
#[upgrade_or_panic]
move |obj| {
let client_object = obj
.downcast_ref()
.expect("Expected object of type `ClientObject`.");
let row = window.create_client_row(client_object);
row.connect_closure(
"request-hostname-change",
false,
closure_local!(
#[strong]
window,
move |row: ClientRow, hostname: String| {
log::debug!("request-hostname-change");
if let Some(client) = window.client_by_idx(row.index() as u32) {
let hostname = Some(hostname).filter(|s| !s.is_empty());
/* changed in response to FrontendEvent
* -> do not request additional update */
window.request(FrontendRequest::UpdateHostname(
client.handle(),
hostname,
));
}
}
),
);
row.connect_closure(
"request-port-change",
false,
closure_local!(
#[strong]
window,
move |row: ClientRow, port: u32| {
if let Some(client) = window.client_by_idx(row.index() as u32) {
window.request(FrontendRequest::UpdatePort(
client.handle(),
port as u16,
));
}
}
),
);
row.connect_closure(
"request-activate",
false,
closure_local!(
#[strong]
window,
move |row: ClientRow, active: bool| {
if let Some(client) = window.client_by_idx(row.index() as u32) {
log::debug!(
"request: {} client",
if active { "activating" } else { "deactivating" }
);
window.request(FrontendRequest::Activate(
client.handle(),
active,
));
}
}
),
);
row.connect_closure(
"request-delete",
false,
closure_local!(
#[strong]
window,
move |row: ClientRow| {
if let Some(client) = window.client_by_idx(row.index() as u32) {
window.request(FrontendRequest::Delete(client.handle()));
}
}
),
);
row.connect_closure(
"request-dns",
false,
closure_local!(
#[strong]
window,
move |row: ClientRow| {
if let Some(client) = window.client_by_idx(row.index() as u32) {
window.request(FrontendRequest::ResolveDns(
client.get_data().handle,
));
}
}
),
);
row.connect_closure(
"request-position-change",
false,
closure_local!(
#[strong]
window,
move |row: ClientRow, pos_idx: u32| {
if let Some(client) = window.client_by_idx(row.index() as u32) {
let position = match pos_idx {
0 => Position::Left,
1 => Position::Right,
2 => Position::Top,
_ => Position::Bottom,
};
window.request(FrontendRequest::UpdatePosition(
client.handle(),
position,
));
}
}
),
);
row.upcast()
}
),
);
}
fn setup_icon(&self) {
self.set_icon_name(Some("de.feschber.LanMouse"));
}
/// workaround for a bug in libadwaita that shows an ugly line beneath
/// the last element if a placeholder is set.
/// https://gitlab.gnome.org/GNOME/gtk/-/merge_requests/6308
fn update_placeholder_visibility(&self) {
let visible = self.clients().n_items() == 0;
let placeholder = self.imp().client_placeholder.get();
self.imp().client_list.set_placeholder(match visible {
true => Some(&placeholder),
false => None,
});
}
fn update_auth_placeholder_visibility(&self) {
let visible = self.authorized().n_items() == 0;
let placeholder = self.imp().authorized_placeholder.get();
self.imp().authorized_list.set_placeholder(match visible {
true => Some(&placeholder),
false => None,
});
}
fn create_client_row(&self, client_object: &ClientObject) -> ClientRow {
let row = ClientRow::new(client_object);
row.bind(client_object);
row
}
fn create_key_row(&self, key_object: &KeyObject) -> KeyRow {
let row = KeyRow::new();
row.bind(key_object);
row
}
pub(super) fn new_client(
&self,
handle: ClientHandle,
client: ClientConfig,
state: ClientState,
) {
let client = ClientObject::new(handle, client, state.clone());
self.clients().append(&client);
self.update_placeholder_visibility();
self.update_dns_state(handle, !state.ips.is_empty());
}
pub(super) fn update_client_list(
&self,
clients: Vec<(ClientHandle, ClientConfig, ClientState)>,
) {
for (handle, client, state) in clients {
if self.client_idx(handle).is_some() {
self.update_client_config(handle, client);
self.update_client_state(handle, state);
} else {
self.new_client(handle, client, state);
}
}
}
pub(super) fn update_port(&self, port: u16, msg: Option<String>) {
if let Some(msg) = msg {
self.show_toast(msg.as_str());
}
self.imp().set_port(port);
}
fn client_idx(&self, handle: ClientHandle) -> Option<usize> {
self.clients()
.iter::<ClientObject>()
.position(|c| c.ok().map(|c| c.handle() == handle).unwrap_or_default())
}
pub(super) fn delete_client(&self, handle: ClientHandle) {
let Some(idx) = self.client_idx(handle) else {
log::warn!("could not find client with handle {handle}");
return;
};
self.clients().remove(idx as u32);
if self.clients().n_items() == 0 {
self.update_placeholder_visibility();
}
}
pub(super) fn update_client_config(&self, handle: ClientHandle, client: ClientConfig) {
let Some(row) = self.row_for_handle(handle) else {
log::warn!("could not find row for handle {handle}");
return;
};
row.set_hostname(client.hostname);
row.set_port(client.port);
row.set_position(client.pos);
}
pub(super) fn update_client_state(&self, handle: ClientHandle, state: ClientState) {
let Some(row) = self.row_for_handle(handle) else {
log::warn!("could not find row for handle {handle}");
return;
};
let Some(client_object) = self.client_object_for_handle(handle) else {
log::warn!("could not find row for handle {handle}");
return;
};
/* activation state */
row.set_active(state.active);
/* dns state */
client_object.set_resolving(state.resolving);
self.update_dns_state(handle, !state.ips.is_empty());
let ips = state
.ips
.into_iter()
.map(|ip| ip.to_string())
.collect::<Vec<_>>();
client_object.set_ips(ips);
}
fn client_object_for_handle(&self, handle: ClientHandle) -> Option<ClientObject> {
self.client_idx(handle)
.and_then(|i| self.client_by_idx(i as u32))
}
fn row_for_handle(&self, handle: ClientHandle) -> Option<ClientRow> {
self.client_idx(handle)
.and_then(|i| self.row_by_idx(i as i32))
}
fn update_dns_state(&self, handle: ClientHandle, resolved: bool) {
if let Some(client_row) = self.row_for_handle(handle) {
client_row.set_dns_state(resolved);
}
}
fn request_port_change(&self) {
let port = self
.imp()
.port_entry
.get()
.text()
.as_str()
.parse::<u16>()
.unwrap_or(DEFAULT_PORT);
self.request(FrontendRequest::ChangePort(port));
}
fn request_capture(&self) {
self.request(FrontendRequest::EnableCapture);
}
fn request_emulation(&self) {
self.request(FrontendRequest::EnableEmulation);
}
fn request_client_create(&self) {
self.request(FrontendRequest::Create);
}
fn open_fingerprint_dialog(&self, fp: Option<String>) {
let window = FingerprintWindow::new(fp);
window.set_transient_for(Some(self));
window.connect_closure(
"confirm-clicked",
false,
closure_local!(
#[strong(rename_to = parent)]
self,
move |w: FingerprintWindow, desc: String, fp: String| {
parent.request_fingerprint_add(desc, fp);
w.close();
}
),
);
window.present();
}
fn request_fingerprint_add(&self, desc: String, fp: String) {
self.request(FrontendRequest::AuthorizeKey(desc, fp));
}
fn request_fingerprint_remove(&self, fp: String) {
self.request(FrontendRequest::RemoveAuthorizedKey(fp));
}
fn request(&self, request: FrontendRequest) {
let mut requester = self.imp().frontend_request_writer.borrow_mut();
let requester = requester.as_mut().unwrap();
if let Err(e) = requester.request(request) {
log::error!("error sending message: {e}");
};
}
pub(super) fn show_toast(&self, msg: &str) {
let toast = adw::Toast::new(msg);
self.add_toast(toast);
}
pub(super) fn add_toast(&self, toast: adw::Toast) {
let toast_overlay = &self.imp().toast_overlay;
toast_overlay.add_toast(toast);
}
pub(super) fn set_capture(&self, active: bool) {
self.imp().capture_active.replace(active);
self.update_capture_emulation_status();
}
pub(super) fn set_emulation(&self, active: bool) {
self.imp().emulation_active.replace(active);
self.update_capture_emulation_status();
}
#[cfg(target_os = "macos")]
pub(super) fn refresh_capture_emulation_status(&self) {
self.update_capture_emulation_status();
}
fn update_capture_emulation_status(&self) {
let capture = self.imp().capture_active.get();
let emulation = self.imp().emulation_active.get();
#[cfg(target_os = "macos")]
{
// On macOS, capture and emulation share the same TCC gate
// (Accessibility). Collapse to a single warning row —
// emulation_status_row stays hidden and capture_status_row
// doubles as the shared status indicator. Its text and
// button mutate based on whether we're waiting for AX or
// waiting for the user to relaunch the app.
let anything_off = !capture || !emulation;
self.imp().emulation_status_row.set_visible(false);
self.imp().capture_status_row.set_visible(anything_off);
self.imp().capture_emulation_group.set_visible(anything_off);
if anything_off {
self.update_macos_warning_row_text();
}
}
#[cfg(not(target_os = "macos"))]
{
self.imp().capture_status_row.set_visible(!capture);
self.imp().emulation_status_row.set_visible(!emulation);
self.imp()
.capture_emulation_group
.set_visible(!capture || !emulation);
}
}
#[cfg(target_os = "macos")]
fn update_macos_warning_row_text(&self) {
let row = &self.imp().capture_status_row;
let button = &self.imp().input_capture_button;
if crate::macos_privacy::accessibility_granted() {
// AX granted but capture/emulation still off → the daemon
// subprocess bailed at startup and needs a fresh process to
// re-initialize with the new grant in place.
row.set_title("relaunch required");
row.set_subtitle("Accessibility granted — restart to activate capture and emulation");
set_button_content_label(button, "Relaunch");
} else {
// AX missing → send the user to System Settings.
row.set_title("input capture is disabled");
row.set_subtitle("grant Accessibility permission to enable");
set_button_content_label(button, "Grant");
}
}
pub(super) fn set_authorized_keys(&self, fingerprints: HashMap<String, String>) {
let authorized = self.authorized();
// clear list
authorized.remove_all();
// insert fingerprints
for (fingerprint, description) in fingerprints {
let key_obj = KeyObject::new(description, fingerprint);
authorized.append(&key_obj);
}
self.update_auth_placeholder_visibility();
}
pub(super) fn set_pk_fp(&self, fingerprint: &str) {
self.imp().fingerprint_row.set_subtitle(fingerprint);
}
pub(super) fn request_authorization(&self, fingerprint: &str) {
if let Some(w) = self.imp().authorization_window.borrow_mut().take() {
w.close();
}
let window = AuthorizationWindow::new(fingerprint);
window.set_transient_for(Some(self));
window.connect_closure(
"confirm-clicked",
false,
closure_local!(
#[strong(rename_to = parent)]
self,
move |w: AuthorizationWindow, fp: String| {
w.close();
parent.open_fingerprint_dialog(Some(fp));
}
),
);
window.connect_closure(
"cancel-clicked",
false,
closure_local!(move |w: AuthorizationWindow| {
w.close();
}),
);
window.present();
self.imp().authorization_window.replace(Some(window));
}
}

View File

@@ -1,209 +0,0 @@
use std::cell::{Cell, RefCell};
use adw::subclass::prelude::*;
use adw::{ActionRow, PreferencesGroup, ToastOverlay, prelude::*};
use glib::subclass::InitializingObject;
use gtk::glib::clone;
use gtk::{Button, CompositeTemplate, Entry, Image, Label, ListBox, gdk, gio, glib};
use lan_mouse_ipc::{DEFAULT_PORT, FrontendRequestWriter};
use crate::authorization_window::AuthorizationWindow;
#[derive(CompositeTemplate, Default)]
#[template(resource = "/de/feschber/LanMouse/window.ui")]
pub struct Window {
#[template_child]
pub authorized_placeholder: TemplateChild<ActionRow>,
#[template_child]
pub fingerprint_row: TemplateChild<ActionRow>,
#[template_child]
pub port_edit_apply: TemplateChild<Button>,
#[template_child]
pub port_edit_cancel: TemplateChild<Button>,
#[template_child]
pub client_list: TemplateChild<ListBox>,
#[template_child]
pub client_placeholder: TemplateChild<ActionRow>,
#[template_child]
pub port_entry: TemplateChild<Entry>,
#[template_child]
pub hostname_copy_icon: TemplateChild<Image>,
#[template_child]
pub hostname_label: TemplateChild<Label>,
#[template_child]
pub toast_overlay: TemplateChild<ToastOverlay>,
#[template_child]
pub capture_emulation_group: TemplateChild<PreferencesGroup>,
#[template_child]
pub capture_status_row: TemplateChild<ActionRow>,
#[template_child]
pub emulation_status_row: TemplateChild<ActionRow>,
#[template_child]
pub input_emulation_button: TemplateChild<Button>,
#[template_child]
pub input_capture_button: TemplateChild<Button>,
#[template_child]
pub authorized_list: TemplateChild<ListBox>,
pub clients: RefCell<Option<gio::ListStore>>,
pub authorized: RefCell<Option<gio::ListStore>>,
pub frontend_request_writer: RefCell<Option<FrontendRequestWriter>>,
pub port: Cell<u16>,
pub capture_active: Cell<bool>,
pub emulation_active: Cell<bool>,
pub authorization_window: RefCell<Option<AuthorizationWindow>>,
}
#[glib::object_subclass]
impl ObjectSubclass for Window {
// `NAME` needs to match `class` attribute of template
const NAME: &'static str = "LanMouseWindow";
const ABSTRACT: bool = false;
type Type = super::Window;
type ParentType = adw::ApplicationWindow;
fn class_init(klass: &mut Self::Class) {
klass.bind_template();
klass.bind_template_callbacks();
}
fn instance_init(obj: &InitializingObject<Self>) {
obj.init_template();
}
}
#[gtk::template_callbacks]
impl Window {
#[template_callback]
fn handle_add_client_pressed(&self, _button: &Button) {
self.obj().request_client_create();
}
#[template_callback]
fn handle_copy_hostname(&self, _: &Button) {
if let Ok(hostname) = hostname::get() {
let display = gdk::Display::default().unwrap();
let clipboard = display.clipboard();
clipboard.set_text(hostname.to_str().expect("hostname: invalid utf8"));
let icon = self.hostname_copy_icon.clone();
icon.set_icon_name(Some("emblem-ok-symbolic"));
icon.set_css_classes(&["success"]);
glib::spawn_future_local(clone!(
#[weak]
icon,
async move {
glib::timeout_future_seconds(1).await;
icon.set_icon_name(Some("edit-copy-symbolic"));
icon.set_css_classes(&[]);
}
));
}
}
#[template_callback]
fn handle_copy_fingerprint(&self, button: &Button) {
let fingerprint: String = self.fingerprint_row.property("subtitle");
let display = gdk::Display::default().unwrap();
let clipboard = display.clipboard();
clipboard.set_text(&fingerprint);
button.set_icon_name("emblem-ok-symbolic");
button.set_css_classes(&["success"]);
glib::spawn_future_local(clone!(
#[weak]
button,
async move {
glib::timeout_future_seconds(1).await;
button.set_icon_name("edit-copy-symbolic");
button.set_css_classes(&[]);
}
));
}
#[template_callback]
fn handle_port_changed(&self, _entry: &Entry) {
self.port_edit_apply.set_visible(true);
self.port_edit_cancel.set_visible(true);
}
#[template_callback]
fn handle_port_edit_apply(&self) {
self.obj().request_port_change();
}
#[template_callback]
fn handle_port_edit_cancel(&self) {
log::debug!("cancel port edit");
self.port_entry
.set_text(self.port.get().to_string().as_str());
self.port_edit_apply.set_visible(false);
self.port_edit_cancel.set_visible(false);
}
#[template_callback]
fn handle_emulation(&self) {
// On macOS the emulation_status_row is hidden — capture_status_row
// acts as the shared warning (see update_capture_emulation_status).
// This handler still fires for the non-macOS platforms where the
// emulation row is distinct.
self.obj().request_emulation();
}
#[template_callback]
fn handle_capture(&self) {
#[cfg(target_os = "macos")]
{
use crate::macos_privacy;
if macos_privacy::accessibility_granted() {
// AX granted but the row is still visible => the daemon
// subprocess bailed before AX was in place and needs a
// fresh process. Quit + relaunch via Launch Services.
log::info!("capture row clicked in relaunch-required state");
macos_privacy::relaunch_bundle();
if let Some(app) = self.obj().application() {
app.quit();
}
return;
}
log::info!("capture row clicked in AX-missing state, opening pane");
macos_privacy::open_accessibility_settings();
}
self.obj().request_capture();
}
#[template_callback]
fn handle_add_cert_fingerprint(&self, _button: &Button) {
self.obj().open_fingerprint_dialog(None);
}
pub fn set_port(&self, port: u16) {
self.port.set(port);
if port == DEFAULT_PORT {
self.port_entry.set_text("");
} else {
self.port_entry.set_text(format!("{port}").as_str());
}
self.port_edit_apply.set_visible(false);
self.port_edit_cancel.set_visible(false);
}
}
impl ObjectImpl for Window {
fn constructed(&self) {
if let Ok(hostname) = hostname::get() {
self.hostname_label
.set_text(hostname.to_str().expect("hostname: invalid utf8"));
}
self.parent_constructed();
self.set_port(DEFAULT_PORT);
let obj = self.obj();
obj.setup_icon();
obj.setup_clients();
obj.setup_authorized();
}
}
impl WidgetImpl for Window {}
impl WindowImpl for Window {}
impl ApplicationWindowImpl for Window {}
impl AdwApplicationWindowImpl for Window {}

View File

@@ -1,16 +0,0 @@
[package]
name = "lan-mouse-ipc"
description = "library for communication between lan-mouse service and frontends"
version = "0.2.0"
edition = "2021"
license = "GPL-3.0-or-later"
repository = "https://github.com/feschber/lan-mouse"
[dependencies]
futures = "0.3.30"
log = "0.4.22"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0.107"
thiserror = "2.0.0"
tokio = { version = "1.32.0", features = ["macros", "net", "io-util", "time"] }
tokio-stream = { version = "0.1.15", features = ["io-util"] }

View File

@@ -1,89 +0,0 @@
use crate::{ConnectionError, FrontendEvent, FrontendRequest, IpcError};
use std::{
cmp::min,
io::{self, BufReader, LineWriter, Lines, prelude::*},
thread,
time::Duration,
};
#[cfg(unix)]
use std::os::unix::net::UnixStream;
#[cfg(windows)]
use std::net::TcpStream;
pub struct FrontendEventReader {
#[cfg(unix)]
lines: Lines<BufReader<UnixStream>>,
#[cfg(windows)]
lines: Lines<BufReader<TcpStream>>,
}
pub struct FrontendRequestWriter {
#[cfg(unix)]
line_writer: LineWriter<UnixStream>,
#[cfg(windows)]
line_writer: LineWriter<TcpStream>,
}
impl FrontendEventReader {
pub fn next_event(&mut self) -> Option<Result<FrontendEvent, IpcError>> {
match self.lines.next()? {
Err(e) => Some(Err(e.into())),
Ok(l) => Some(serde_json::from_str(l.as_str()).map_err(|e| e.into())),
}
}
}
impl FrontendRequestWriter {
pub fn request(&mut self, request: FrontendRequest) -> Result<(), io::Error> {
let mut json = serde_json::to_string(&request).unwrap();
log::debug!("requesting: {json}");
json.push('\n');
self.line_writer.write_all(json.as_bytes())?;
Ok(())
}
}
pub fn connect() -> Result<(FrontendEventReader, FrontendRequestWriter), ConnectionError> {
let rx = wait_for_service()?;
let tx = rx.try_clone()?;
let buf_reader = BufReader::new(rx);
let lines = buf_reader.lines();
let line_writer = LineWriter::new(tx);
let reader = FrontendEventReader { lines };
let writer = FrontendRequestWriter { line_writer };
Ok((reader, writer))
}
/// wait for the lan-mouse socket to come online
#[cfg(unix)]
fn wait_for_service() -> Result<UnixStream, ConnectionError> {
let socket_path = crate::default_socket_path()?;
let mut duration = Duration::from_millis(10);
loop {
if let Ok(stream) = UnixStream::connect(&socket_path) {
break Ok(stream);
}
// a signaling mechanism or inotify could be used to
// improve this
thread::sleep(exponential_back_off(&mut duration));
}
}
#[cfg(windows)]
fn wait_for_service() -> Result<TcpStream, ConnectionError> {
let mut duration = Duration::from_millis(10);
loop {
if let Ok(stream) = TcpStream::connect("127.0.0.1:5252") {
break Ok(stream);
}
thread::sleep(exponential_back_off(&mut duration));
}
}
fn exponential_back_off(duration: &mut Duration) -> Duration {
let new = duration.saturating_mul(2);
*duration = min(new, Duration::from_secs(1));
*duration
}

View File

@@ -1,111 +0,0 @@
use crate::{ConnectionError, FrontendEvent, FrontendRequest, IpcError};
use std::{
cmp::min,
task::{Poll, ready},
time::Duration,
};
use futures::{Stream, StreamExt};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, ReadHalf, WriteHalf};
use tokio_stream::wrappers::LinesStream;
#[cfg(unix)]
use tokio::net::UnixStream;
#[cfg(windows)]
use tokio::net::TcpStream;
pub struct AsyncFrontendEventReader {
#[cfg(unix)]
lines_stream: LinesStream<BufReader<ReadHalf<UnixStream>>>,
#[cfg(windows)]
lines_stream: LinesStream<BufReader<ReadHalf<TcpStream>>>,
}
pub struct AsyncFrontendRequestWriter {
#[cfg(unix)]
tx: WriteHalf<UnixStream>,
#[cfg(windows)]
tx: WriteHalf<TcpStream>,
}
impl Stream for AsyncFrontendEventReader {
type Item = Result<FrontendEvent, IpcError>;
fn poll_next(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Option<Self::Item>> {
let line = ready!(self.lines_stream.poll_next_unpin(cx));
let event = line.map(|l| {
l.map_err(Into::<IpcError>::into)
.and_then(|l| serde_json::from_str(l.as_str()).map_err(|e| e.into()))
});
Poll::Ready(event)
}
}
impl AsyncFrontendRequestWriter {
pub async fn request(&mut self, request: FrontendRequest) -> Result<(), IpcError> {
let mut json = serde_json::to_string(&request).unwrap();
log::debug!("requesting: {json}");
json.push('\n');
self.tx.write_all(json.as_bytes()).await?;
Ok(())
}
}
pub async fn connect_async(
timeout: Option<Duration>,
) -> Result<(AsyncFrontendEventReader, AsyncFrontendRequestWriter), ConnectionError> {
let stream = if let Some(duration) = timeout {
tokio::select! {
s = wait_for_service() => s?,
_ = tokio::time::sleep(duration) => return Err(ConnectionError::Timeout),
}
} else {
wait_for_service().await?
};
#[cfg(unix)]
let (rx, tx): (ReadHalf<UnixStream>, WriteHalf<UnixStream>) = tokio::io::split(stream);
#[cfg(windows)]
let (rx, tx): (ReadHalf<TcpStream>, WriteHalf<TcpStream>) = tokio::io::split(stream);
let buf_reader = BufReader::new(rx);
let lines = buf_reader.lines();
let lines_stream = LinesStream::new(lines);
let reader = AsyncFrontendEventReader { lines_stream };
let writer = AsyncFrontendRequestWriter { tx };
Ok((reader, writer))
}
/// wait for the lan-mouse socket to come online
#[cfg(unix)]
async fn wait_for_service() -> Result<UnixStream, ConnectionError> {
let socket_path = crate::default_socket_path()?;
let mut duration = Duration::from_millis(10);
loop {
if let Ok(stream) = UnixStream::connect(&socket_path).await {
break Ok(stream);
}
// a signaling mechanism or inotify could be used to
// improve this
tokio::time::sleep(exponential_back_off(&mut duration)).await;
}
}
#[cfg(windows)]
async fn wait_for_service() -> Result<TcpStream, ConnectionError> {
let mut duration = Duration::from_millis(10);
loop {
if let Ok(stream) = TcpStream::connect("127.0.0.1:5252").await {
break Ok(stream);
}
tokio::time::sleep(exponential_back_off(&mut duration)).await;
}
}
fn exponential_back_off(duration: &mut Duration) -> Duration {
let new = duration.saturating_mul(2);
*duration = min(new, Duration::from_secs(1));
*duration
}

View File

@@ -1,301 +0,0 @@
use std::{
collections::{HashMap, HashSet},
env::VarError,
fmt::Display,
io,
net::{IpAddr, SocketAddr},
str::FromStr,
};
use thiserror::Error;
#[cfg(unix)]
use std::{
env,
path::{Path, PathBuf},
};
use serde::{Deserialize, Serialize};
mod connect;
mod connect_async;
mod listen;
pub use connect::{FrontendEventReader, FrontendRequestWriter, connect};
pub use connect_async::{AsyncFrontendEventReader, AsyncFrontendRequestWriter, connect_async};
pub use listen::AsyncFrontendListener;
#[derive(Debug, Error)]
pub enum ConnectionError {
#[error(transparent)]
SocketPath(#[from] SocketPathError),
#[error(transparent)]
Io(#[from] io::Error),
#[error("connection timed out")]
Timeout,
}
#[derive(Debug, Error)]
pub enum IpcListenerCreationError {
#[error("could not determine socket-path: `{0}`")]
SocketPath(#[from] SocketPathError),
#[error("service already running!")]
AlreadyRunning,
#[error("failed to bind lan-mouse socket: `{0}`")]
Bind(io::Error),
}
#[derive(Debug, Error)]
pub enum IpcError {
#[error("io error occured: `{0}`")]
Io(#[from] io::Error),
#[error("invalid json: `{0}`")]
Json(#[from] serde_json::Error),
#[error(transparent)]
Connection(#[from] ConnectionError),
#[error(transparent)]
Listen(#[from] IpcListenerCreationError),
}
pub const DEFAULT_PORT: u16 = 4242;
#[derive(Debug, Default, Eq, Hash, PartialEq, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Position {
#[default]
Left,
Right,
Top,
Bottom,
}
impl Position {
pub fn opposite(&self) -> Self {
match self {
Position::Left => Position::Right,
Position::Right => Position::Left,
Position::Top => Position::Bottom,
Position::Bottom => Position::Top,
}
}
}
#[derive(Debug, Error)]
#[error("not a valid position: {pos}")]
pub struct PositionParseError {
pos: String,
}
impl FromStr for Position {
type Err = PositionParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"left" => Ok(Self::Left),
"right" => Ok(Self::Right),
"top" => Ok(Self::Top),
"bottom" => Ok(Self::Bottom),
_ => Err(PositionParseError { pos: s.into() }),
}
}
}
impl Display for Position {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}",
match self {
Position::Left => "left",
Position::Right => "right",
Position::Top => "top",
Position::Bottom => "bottom",
}
)
}
}
impl TryFrom<&str> for Position {
type Error = ();
fn try_from(s: &str) -> Result<Self, Self::Error> {
match s {
"left" => Ok(Position::Left),
"right" => Ok(Position::Right),
"top" => Ok(Position::Top),
"bottom" => Ok(Position::Bottom),
_ => Err(()),
}
}
}
#[derive(Debug, Eq, PartialEq, Clone, Serialize, Deserialize)]
pub struct ClientConfig {
/// hostname of this client
pub hostname: Option<String>,
/// fix ips, determined by the user
pub fix_ips: Vec<IpAddr>,
/// both active_addr and addrs can be None / empty so port needs to be stored seperately
pub port: u16,
/// position of a client on screen
pub pos: Position,
/// enter hook
pub cmd: Option<String>,
}
impl Default for ClientConfig {
fn default() -> Self {
Self {
port: DEFAULT_PORT,
hostname: Default::default(),
fix_ips: Default::default(),
pos: Default::default(),
cmd: None,
}
}
}
pub type ClientHandle = u64;
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct ClientState {
/// events should be sent to and received from the client
pub active: bool,
/// `active` address of the client, used to send data to.
/// This should generally be the socket address where data
/// was last received from.
pub active_addr: Option<SocketAddr>,
/// tracks whether or not the client is available for emulation
pub alive: bool,
/// ips from dns
pub dns_ips: Vec<IpAddr>,
/// all ip addresses associated with a particular client
/// e.g. Laptops usually have at least an ethernet and a wifi port
/// which have different ip addresses
pub ips: HashSet<IpAddr>,
/// client has pressed keys
pub has_pressed_keys: bool,
/// dns resolving in progress
pub resolving: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum FrontendEvent {
/// a client was created
Created(ClientHandle, ClientConfig, ClientState),
/// no such client
NoSuchClient(ClientHandle),
/// state changed
State(ClientHandle, ClientConfig, ClientState),
/// the client was deleted
Deleted(ClientHandle),
/// new port, reason of failure (if failed)
PortChanged(u16, Option<String>),
/// list of all clients, used for initial state synchronization
Enumerate(Vec<(ClientHandle, ClientConfig, ClientState)>),
/// an error occured
Error(String),
/// capture status
CaptureStatus(Status),
/// emulation status
EmulationStatus(Status),
/// authorized public key fingerprints have been updated
AuthorizedUpdated(HashMap<String, String>),
/// public key fingerprint of this device
PublicKeyFingerprint(String),
/// new device connected
DeviceConnected {
addr: SocketAddr,
fingerprint: String,
},
/// incoming device entered the screen
DeviceEntered {
fingerprint: String,
addr: SocketAddr,
pos: Position,
},
/// incoming disconnected
IncomingDisconnected(SocketAddr),
/// failed connection attempt (approval for fingerprint required)
ConnectionAttempt { fingerprint: String },
}
#[derive(Debug, Eq, PartialEq, Clone, Serialize, Deserialize)]
pub enum FrontendRequest {
/// activate/deactivate client
Activate(ClientHandle, bool),
/// add a new client
Create,
/// change the listen port (recreate udp listener)
ChangePort(u16),
/// remove a client
Delete(ClientHandle),
/// request an enumeration of all clients
Enumerate(),
/// resolve dns
ResolveDns(ClientHandle),
/// update hostname
UpdateHostname(ClientHandle, Option<String>),
/// update port
UpdatePort(ClientHandle, u16),
/// update position
UpdatePosition(ClientHandle, Position),
/// update fix-ips
UpdateFixIps(ClientHandle, Vec<IpAddr>),
/// request reenabling input capture
EnableCapture,
/// request reenabling input emulation
EnableEmulation,
/// synchronize all state
Sync,
/// authorize fingerprint (description, fingerprint)
AuthorizeKey(String, String),
/// remove fingerprint (fingerprint)
RemoveAuthorizedKey(String),
/// change the hook command
UpdateEnterHook(u64, Option<String>),
/// save config file
SaveConfiguration,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, Serialize, Deserialize)]
pub enum Status {
#[default]
Disabled,
Enabled,
}
impl From<Status> for bool {
fn from(status: Status) -> Self {
match status {
Status::Enabled => true,
Status::Disabled => false,
}
}
}
#[cfg(unix)]
const LAN_MOUSE_SOCKET_NAME: &str = "lan-mouse-socket.sock";
#[derive(Debug, Error)]
pub enum SocketPathError {
#[error("could not determine $XDG_RUNTIME_DIR: `{0}`")]
XdgRuntimeDirNotFound(VarError),
#[error("could not determine $HOME: `{0}`")]
HomeDirNotFound(VarError),
}
#[cfg(all(unix, not(target_os = "macos")))]
pub fn default_socket_path() -> Result<PathBuf, SocketPathError> {
let xdg_runtime_dir =
env::var("XDG_RUNTIME_DIR").map_err(SocketPathError::XdgRuntimeDirNotFound)?;
Ok(Path::new(xdg_runtime_dir.as_str()).join(LAN_MOUSE_SOCKET_NAME))
}
#[cfg(all(unix, target_os = "macos"))]
pub fn default_socket_path() -> Result<PathBuf, SocketPathError> {
let home = env::var("HOME").map_err(SocketPathError::HomeDirNotFound)?;
Ok(Path::new(home.as_str())
.join("Library")
.join("Caches")
.join(LAN_MOUSE_SOCKET_NAME))
}

View File

@@ -1,147 +0,0 @@
use futures::{Stream, StreamExt, stream::SelectAll};
#[cfg(unix)]
use std::path::PathBuf;
use std::{
io::ErrorKind,
pin::Pin,
task::{Context, Poll},
};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, ReadHalf, WriteHalf};
use tokio_stream::wrappers::LinesStream;
#[cfg(unix)]
use tokio::net::UnixListener;
#[cfg(unix)]
use tokio::net::UnixStream;
#[cfg(windows)]
use tokio::net::TcpListener;
#[cfg(windows)]
use tokio::net::TcpStream;
use crate::{FrontendEvent, FrontendRequest, IpcError, IpcListenerCreationError};
pub struct AsyncFrontendListener {
#[cfg(windows)]
listener: TcpListener,
#[cfg(unix)]
listener: UnixListener,
#[cfg(unix)]
socket_path: PathBuf,
#[cfg(unix)]
line_streams: SelectAll<LinesStream<BufReader<ReadHalf<UnixStream>>>>,
#[cfg(windows)]
line_streams: SelectAll<LinesStream<BufReader<ReadHalf<TcpStream>>>>,
#[cfg(unix)]
tx_streams: Vec<WriteHalf<UnixStream>>,
#[cfg(windows)]
tx_streams: Vec<WriteHalf<TcpStream>>,
}
impl AsyncFrontendListener {
pub async fn new() -> Result<Self, IpcListenerCreationError> {
#[cfg(unix)]
let (socket_path, listener) = {
let socket_path = crate::default_socket_path()?;
log::debug!("remove socket: {socket_path:?}");
if socket_path.exists() {
// try to connect to see if some other instance
// of lan-mouse is already running
match UnixStream::connect(&socket_path).await {
// connected -> lan-mouse is already running
Ok(_) => return Err(IpcListenerCreationError::AlreadyRunning),
// lan-mouse is not running but a socket was left behind
Err(e) => {
log::debug!("{socket_path:?}: {e} - removing left behind socket");
let _ = std::fs::remove_file(&socket_path);
}
}
}
let listener = match UnixListener::bind(&socket_path) {
Ok(ls) => ls,
// some other lan-mouse instance has bound the socket in the meantime
Err(e) if e.kind() == ErrorKind::AddrInUse => {
return Err(IpcListenerCreationError::AlreadyRunning);
}
Err(e) => return Err(IpcListenerCreationError::Bind(e)),
};
(socket_path, listener)
};
#[cfg(windows)]
let listener = match TcpListener::bind("127.0.0.1:5252").await {
Ok(ls) => ls,
// some other lan-mouse instance has bound the socket in the meantime
Err(e) if e.kind() == ErrorKind::AddrInUse => {
return Err(IpcListenerCreationError::AlreadyRunning);
}
Err(e) => return Err(IpcListenerCreationError::Bind(e)),
};
let adapter = Self {
listener,
#[cfg(unix)]
socket_path,
line_streams: SelectAll::new(),
tx_streams: vec![],
};
Ok(adapter)
}
pub async fn broadcast(&mut self, notify: FrontendEvent) {
// encode event
let mut json = serde_json::to_string(&notify).unwrap();
json.push('\n');
let mut keep = vec![];
// TODO do simultaneously
for tx in self.tx_streams.iter_mut() {
// write len + payload
if tx.write(json.as_bytes()).await.is_err() {
keep.push(false);
continue;
}
keep.push(true);
}
// could not find a better solution because async
let mut keep = keep.into_iter();
self.tx_streams.retain(|_| keep.next().unwrap());
}
}
#[cfg(unix)]
impl Drop for AsyncFrontendListener {
fn drop(&mut self) {
log::debug!("remove socket: {:?}", self.socket_path);
let _ = std::fs::remove_file(&self.socket_path);
}
}
impl Stream for AsyncFrontendListener {
type Item = Result<FrontendRequest, IpcError>;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
if let Poll::Ready(Some(Ok(l))) = self.line_streams.poll_next_unpin(cx) {
let request = serde_json::from_str(l.as_str()).map_err(|e| e.into());
return Poll::Ready(Some(request));
}
let mut sync = false;
while let Poll::Ready(Ok((stream, _))) = self.listener.poll_accept(cx) {
let (rx, tx) = tokio::io::split(stream);
let buf_reader = BufReader::new(rx);
let lines = buf_reader.lines();
let lines = LinesStream::new(lines);
self.line_streams.push(lines);
self.tx_streams.push(tx);
sync = true;
}
if sync {
Poll::Ready(Some(Ok(FrontendRequest::Sync)))
} else {
Poll::Pending
}
}
}

View File

@@ -1,13 +0,0 @@
[package]
name = "lan-mouse-proto"
description = "network protocol for lan-mouse"
version = "0.2.0"
edition = "2021"
license = "GPL-3.0-or-later"
repository = "https://github.com/feschber/lan-mouse"
[dependencies]
num_enum = "0.7.2"
thiserror = "2.0.0"
input-event = { path = "../input-event", version = "0.3.0" }
paste = "1.0"

View File

@@ -1,282 +0,0 @@
use input_event::{Event as InputEvent, KeyboardEvent, PointerEvent};
use num_enum::{IntoPrimitive, TryFromPrimitive, TryFromPrimitiveError};
use paste::paste;
use std::{
fmt::{Debug, Display, Formatter},
mem::size_of,
};
use thiserror::Error;
/// defines the maximum size an encoded event can take up
/// this is currently the pointer motion event
/// type: u8, time: u32, dx: f64, dy: f64
pub const MAX_EVENT_SIZE: usize = size_of::<u8>() + size_of::<u32>() + 2 * size_of::<f64>();
/// error type for protocol violations
#[derive(Debug, Error)]
pub enum ProtocolError {
/// event type does not exist
#[error("invalid event id: `{0}`")]
InvalidEventId(#[from] TryFromPrimitiveError<EventType>),
/// position type does not exist
#[error("invalid event id: `{0}`")]
InvalidPosition(#[from] TryFromPrimitiveError<Position>),
}
/// Position of a client
#[derive(Clone, Copy, Debug, TryFromPrimitive, IntoPrimitive)]
#[repr(u8)]
pub enum Position {
Left,
Right,
Top,
Bottom,
}
impl Display for Position {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
let pos = match self {
Position::Left => "left",
Position::Right => "right",
Position::Top => "top",
Position::Bottom => "bottom",
};
write!(f, "{pos}")
}
}
/// main lan-mouse protocol event type
#[derive(Clone, Copy, Debug)]
pub enum ProtoEvent {
/// notify a client that the cursor entered its region at the given position
/// [`ProtoEvent::Ack`] with the same serial is used for synchronization between devices
Enter(Position),
/// notify a client that the cursor left its region
/// [`ProtoEvent::Ack`] with the same serial is used for synchronization between devices
Leave(u32),
/// acknowledge of an [`ProtoEvent::Enter`] or [`ProtoEvent::Leave`] event
Ack(u32),
/// Input event
Input(InputEvent),
/// Ping event for tracking unresponsive clients.
/// A client has to respond with [`ProtoEvent::Pong`].
Ping,
/// Response to [`ProtoEvent::Ping`], true if emulation is enabled / available
Pong(bool),
}
impl Display for ProtoEvent {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
ProtoEvent::Enter(s) => write!(f, "Enter({s})"),
ProtoEvent::Leave(s) => write!(f, "Leave({s})"),
ProtoEvent::Ack(s) => write!(f, "Ack({s})"),
ProtoEvent::Input(e) => write!(f, "{e}"),
ProtoEvent::Ping => write!(f, "ping"),
ProtoEvent::Pong(alive) => {
write!(
f,
"pong: {}",
if *alive { "alive" } else { "not available" }
)
}
}
}
}
#[derive(TryFromPrimitive, IntoPrimitive)]
#[repr(u8)]
pub enum EventType {
PointerMotion,
PointerButton,
PointerAxis,
PointerAxisValue120,
KeyboardKey,
KeyboardModifiers,
Ping,
Pong,
Enter,
Leave,
Ack,
}
impl ProtoEvent {
fn event_type(&self) -> EventType {
match self {
ProtoEvent::Input(e) => match e {
InputEvent::Pointer(p) => match p {
PointerEvent::Motion { .. } => EventType::PointerMotion,
PointerEvent::Button { .. } => EventType::PointerButton,
PointerEvent::Axis { .. } => EventType::PointerAxis,
PointerEvent::AxisDiscrete120 { .. } => EventType::PointerAxisValue120,
},
InputEvent::Keyboard(k) => match k {
KeyboardEvent::Key { .. } => EventType::KeyboardKey,
KeyboardEvent::Modifiers { .. } => EventType::KeyboardModifiers,
},
},
ProtoEvent::Ping => EventType::Ping,
ProtoEvent::Pong(_) => EventType::Pong,
ProtoEvent::Enter(_) => EventType::Enter,
ProtoEvent::Leave(_) => EventType::Leave,
ProtoEvent::Ack(_) => EventType::Ack,
}
}
}
impl TryFrom<[u8; MAX_EVENT_SIZE]> for ProtoEvent {
type Error = ProtocolError;
fn try_from(buf: [u8; MAX_EVENT_SIZE]) -> Result<Self, Self::Error> {
let mut buf = &buf[..];
let event_type = decode_u8(&mut buf)?;
match EventType::try_from(event_type)? {
EventType::PointerMotion => {
Ok(Self::Input(InputEvent::Pointer(PointerEvent::Motion {
time: decode_u32(&mut buf)?,
dx: decode_f64(&mut buf)?,
dy: decode_f64(&mut buf)?,
})))
}
EventType::PointerButton => {
Ok(Self::Input(InputEvent::Pointer(PointerEvent::Button {
time: decode_u32(&mut buf)?,
button: decode_u32(&mut buf)?,
state: decode_u32(&mut buf)?,
})))
}
EventType::PointerAxis => Ok(Self::Input(InputEvent::Pointer(PointerEvent::Axis {
time: decode_u32(&mut buf)?,
axis: decode_u8(&mut buf)?,
value: decode_f64(&mut buf)?,
}))),
EventType::PointerAxisValue120 => Ok(Self::Input(InputEvent::Pointer(
PointerEvent::AxisDiscrete120 {
axis: decode_u8(&mut buf)?,
value: decode_i32(&mut buf)?,
},
))),
EventType::KeyboardKey => Ok(Self::Input(InputEvent::Keyboard(KeyboardEvent::Key {
time: decode_u32(&mut buf)?,
key: decode_u32(&mut buf)?,
state: decode_u8(&mut buf)?,
}))),
EventType::KeyboardModifiers => Ok(Self::Input(InputEvent::Keyboard(
KeyboardEvent::Modifiers {
depressed: decode_u32(&mut buf)?,
latched: decode_u32(&mut buf)?,
locked: decode_u32(&mut buf)?,
group: decode_u32(&mut buf)?,
},
))),
EventType::Ping => Ok(Self::Ping),
EventType::Pong => Ok(Self::Pong(decode_u8(&mut buf)? != 0)),
EventType::Enter => Ok(Self::Enter(decode_u8(&mut buf)?.try_into()?)),
EventType::Leave => Ok(Self::Leave(decode_u32(&mut buf)?)),
EventType::Ack => Ok(Self::Ack(decode_u32(&mut buf)?)),
}
}
}
impl From<ProtoEvent> for ([u8; MAX_EVENT_SIZE], usize) {
fn from(event: ProtoEvent) -> Self {
let mut buf = [0u8; MAX_EVENT_SIZE];
let mut len = 0usize;
{
let mut buf = &mut buf[..];
let buf = &mut buf;
let len = &mut len;
encode_u8(buf, len, event.event_type() as u8);
match event {
ProtoEvent::Input(event) => match event {
InputEvent::Pointer(p) => match p {
PointerEvent::Motion { time, dx, dy } => {
encode_u32(buf, len, time);
encode_f64(buf, len, dx);
encode_f64(buf, len, dy);
}
PointerEvent::Button {
time,
button,
state,
} => {
encode_u32(buf, len, time);
encode_u32(buf, len, button);
encode_u32(buf, len, state);
}
PointerEvent::Axis { time, axis, value } => {
encode_u32(buf, len, time);
encode_u8(buf, len, axis);
encode_f64(buf, len, value);
}
PointerEvent::AxisDiscrete120 { axis, value } => {
encode_u8(buf, len, axis);
encode_i32(buf, len, value);
}
},
InputEvent::Keyboard(k) => match k {
KeyboardEvent::Key { time, key, state } => {
encode_u32(buf, len, time);
encode_u32(buf, len, key);
encode_u8(buf, len, state);
}
KeyboardEvent::Modifiers {
depressed,
latched,
locked,
group,
} => {
encode_u32(buf, len, depressed);
encode_u32(buf, len, latched);
encode_u32(buf, len, locked);
encode_u32(buf, len, group);
}
},
},
ProtoEvent::Ping => {}
ProtoEvent::Pong(alive) => encode_u8(buf, len, alive as u8),
ProtoEvent::Enter(pos) => encode_u8(buf, len, pos as u8),
ProtoEvent::Leave(serial) => encode_u32(buf, len, serial),
ProtoEvent::Ack(serial) => encode_u32(buf, len, serial),
}
}
(buf, len)
}
}
macro_rules! decode_impl {
($t:ty) => {
paste! {
fn [<decode_ $t>](data: &mut &[u8]) -> Result<$t, ProtocolError> {
let (int_bytes, rest) = data.split_at(size_of::<$t>());
*data = rest;
Ok($t::from_be_bytes(int_bytes.try_into().unwrap()))
}
}
};
}
decode_impl!(u8);
decode_impl!(u32);
decode_impl!(i32);
decode_impl!(f64);
macro_rules! encode_impl {
($t:ty) => {
paste! {
fn [<encode_ $t>](buf: &mut &mut [u8], amt: &mut usize, n: $t) {
let src = n.to_be_bytes();
let data = std::mem::take(buf);
let (int_bytes, rest) = data.split_at_mut(size_of::<$t>());
int_bytes.copy_from_slice(&src);
*amt += size_of::<$t>();
*buf = rest
}
}
};
}
encode_impl!(u8);
encode_impl!(u32);
encode_impl!(i32);
encode_impl!(f64);

View File

@@ -1,18 +1,18 @@
# Nix Flake Usage # Nix Flake Usage
## Run ## run
```bash ```bash
nix run github:feschber/lan-mouse nix run github:feschber/lan-mouse
# With params # with params
nix run github:feschber/lan-mouse -- --help nix run github:feschber/lan-mouse -- --help
``` ```
## Home-manager module ## home-manager module
Add input: add input
```nix ```nix
inputs = { inputs = {
@@ -20,27 +20,14 @@ inputs = {
} }
``` ```
Optional: add [our binary cache](https://app.cachix.org/cache/lan-mouse) to allow a faster package install. enable lan-mouse
```nix
nixConfig = {
extra-substituters = [
"https://lan-mouse.cachix.org/"
];
extra-trusted-public-keys = [
"lan-mouse.cachix.org-1:KlE2AEZUgkzNKM7BIzMQo8w9yJYqUpor1CAUNRY6OyM="
];
};
```
Enable lan-mouse:
``` nix ``` nix
{ {
inputs, inputs,
... ...
}: { }: {
# Add the Home Manager module # add the home manager module
imports = [inputs.lan-mouse.homeManagerModules.default]; imports = [inputs.lan-mouse.homeManagerModules.default];
programs.lan-mouse = { programs.lan-mouse = {

View File

@@ -1,39 +1,29 @@
{ {
stdenv,
rustPlatform, rustPlatform,
lib, lib,
pkg-config, pkgs,
libX11, }: let
gtk4, cargoToml = builtins.fromTOML (builtins.readFile ../Cargo.toml);
libadwaita,
libXtst,
wrapGAppsHook4,
librsvg,
git,
}:
let
cargoToml = fromTOML (builtins.readFile ../Cargo.toml);
pname = cargoToml.package.name; pname = cargoToml.package.name;
version = cargoToml.package.version; version = cargoToml.package.version;
in in
rustPlatform.buildRustPackage { rustPlatform.buildRustPackage {
inherit pname; pname = pname;
inherit version; version = version;
nativeBuildInputs = [ nativeBuildInputs = with pkgs; [
pkg-config pkg-config
wrapGAppsHook4 cmake
git buildPackages.gtk4
]; ];
buildInputs = [ buildInputs = with pkgs; [
xorg.libX11
gtk4 gtk4
libadwaita libadwaita
librsvg xorg.libXtst
] ] ++ lib.optionals stdenv.isDarwin [
++ lib.optionals stdenv.isLinux [ darwin.apple_sdk_11_0.frameworks.CoreGraphics
libX11
libXtst
]; ];
src = builtins.path { src = builtins.path {
@@ -46,11 +36,6 @@ rustPlatform.buildRustPackage {
# Set Environment Variables # Set Environment Variables
RUST_BACKTRACE = "full"; RUST_BACKTRACE = "full";
postInstall = ''
install -Dm444 *.desktop -t $out/share/applications
install -Dm444 lan-mouse-gtk/resources/*.svg -t $out/share/icons/hicolor/scalable/apps
'';
meta = with lib; { meta = with lib; {
description = "Lan Mouse is a mouse and keyboard sharing software"; description = "Lan Mouse is a mouse and keyboard sharing software";
longDescription = '' longDescription = ''

View File

@@ -52,7 +52,7 @@ in {
}; };
Service = { Service = {
Type = "simple"; Type = "simple";
ExecStart = "${cfg.package}/bin/lan-mouse daemon"; ExecStart = "${cfg.package}/bin/lan-mouse --daemon";
}; };
Install.WantedBy = [ Install.WantedBy = [
(lib.mkIf config.wayland.windowManager.hyprland.systemd.enable "hyprland-session.target") (lib.mkIf config.wayland.windowManager.hyprland.systemd.enable "hyprland-session.target")
@@ -65,7 +65,7 @@ in {
config = { config = {
ProgramArguments = [ ProgramArguments = [
"${cfg.package}/bin/lan-mouse" "${cfg.package}/bin/lan-mouse"
"daemon" "--daemon"
]; ];
KeepAlive = true; KeepAlive = true;
}; };

View File

@@ -5,6 +5,7 @@
<!-- enabled --> <!-- enabled -->
<child type="prefix"> <child type="prefix">
<object class="GtkSwitch" id="enable_switch"> <object class="GtkSwitch" id="enable_switch">
<signal name="state_set" handler="handle_client_set_state" swapped="true"/>
<property name="valign">center</property> <property name="valign">center</property>
<property name="halign">end</property> <property name="halign">end</property>
<property name="tooltip-text" translatable="yes">enable</property> <property name="tooltip-text" translatable="yes">enable</property>
@@ -43,7 +44,6 @@
<child> <child>
<object class="GtkEntry" id="port"> <object class="GtkEntry" id="port">
<!-- <property name="title" translatable="yes">port</property> --> <!-- <property name="title" translatable="yes">port</property> -->
<property name="max-width-chars">5</property>
<property name="input_purpose">GTK_INPUT_PURPOSE_NUMBER</property> <property name="input_purpose">GTK_INPUT_PURPOSE_NUMBER</property>
<property name="xalign">0.5</property> <property name="xalign">0.5</property>
<property name="valign">center</property> <property name="valign">center</property>

View File

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<gresources>
<gresource prefix="/de/feschber/LanMouse">
<file compressed="true" preprocess="xml-stripblanks">window.ui</file>
<file compressed="true" preprocess="xml-stripblanks">client_row.ui</file>
</gresource>
<gresource prefix="/de/feschber/LanMouse/icons">
<file compressed="true" preprocess="xml-stripblanks">de.feschber.LanMouse.svg</file>
</gresource>
</gresources>

167
resources/window.ui Normal file
View File

@@ -0,0 +1,167 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<requires lib="gtk" version="4.0"/>
<requires lib="libadwaita" version="1.0"/>
<menu id="main-menu">
<item>
<attribute name="label" translatable="yes">_Close window</attribute>
<attribute name="action">window.close</attribute>
</item>
</menu>
<template class="LanMouseWindow" parent="AdwApplicationWindow">
<property name="width-request">600</property>
<property name="height-request">700</property>
<property name="title" translatable="yes">Lan Mouse</property>
<property name="show-menubar">True</property>
<property name="content">
<object class="GtkBox">
<property name="orientation">vertical</property>
<child type="top">
<object class="AdwHeaderBar">
<child type ="end">
<object class="GtkMenuButton">
<property name="icon-name">open-menu-symbolic</property>
<property name="menu-model">main-menu</property>
</object>
</child>
<style>
<class name="flat"/>
</style>
</object>
</child>
<child>
<object class="AdwToastOverlay" id="toast_overlay">
<child>
<object class="AdwStatusPage">
<property name="title" translatable="yes">Lan Mouse</property>
<property name="description" translatable="yes">easily use your mouse and keyboard on multiple computers</property>
<property name="icon-name">de.feschber.LanMouse</property>
<property name="child">
<object class="AdwClamp">
<property name="maximum-size">600</property>
<property name="tightening-threshold">0</property>
<property name="child">
<object class="GtkBox">
<property name="orientation">vertical</property>
<property name="spacing">12</property>
<child>
<object class="AdwPreferencesGroup">
<property name="title" translatable="yes">General</property>
<!--
<child>
<object class="AdwActionRow">
<property name="title" translatable="yes">enable</property>
<child type="suffix">
<object class="GtkSwitch">
<property name="valign">center</property>
<property name="tooltip-text" translatable="yes">enable</property>
</object>
</child>
</object>
</child>
-->
<child>
<object class="AdwActionRow">
<property name="title">port</property>
<child>
<object class="GtkEntry" id="port_entry">
<signal name="activate" handler="handle_port_edit_apply" swapped="true"/>
<signal name="changed" handler="handle_port_changed" swapped="true"/>
<!-- <signal name="delete-text" handler="handle_port_changed" swapped="true"/> -->
<!-- <property name="title" translatable="yes">port</property> -->
<property name="placeholder-text">4242</property>
<property name="width-chars">5</property>
<property name="xalign">0.5</property>
<property name="valign">center</property>
<!-- <property name="show-apply-button">True</property> -->
<property name="input-purpose">GTK_INPUT_PURPOSE_DIGITS</property>
</object>
</child>
<child>
<object class="GtkButton" id="port_edit_apply">
<signal name="clicked" handler="handle_port_edit_apply" swapped="true"/>
<property name="icon-name">object-select-symbolic</property>
<property name="valign">center</property>
<property name="visible">false</property>
<property name="name">port-edit-apply</property>
<style><class name="success"/></style>
</object>
</child>
<child>
<object class="GtkButton" id="port_edit_cancel">
<signal name="clicked" handler="handle_port_edit_cancel" swapped="true"/>
<property name="icon-name">process-stop-symbolic</property>
<property name="valign">center</property>
<property name="visible">false</property>
<property name="name">port-edit-cancel</property>
<style><class name="error"/></style>
</object>
</child>
</object>
</child>
<child>
<object class="AdwActionRow">
<property name="title">hostname</property>
<child>
<object class="GtkLabel" id="hostname_label">
<property name="label">&lt;span font_style=&quot;italic&quot; font_weight=&quot;light&quot; foreground=&quot;darkgrey&quot;&gt;could not determine hostname&lt;/span&gt;</property>
<property name="use-markup">true</property>
<property name="valign">center</property>
</object>
</child>
<child>
<object class="GtkButton" id="copy-hostname-button">
<property name="icon-name">edit-copy-symbolic</property>
<property name="valign">center</property>
<signal name="clicked" handler="handle_copy_hostname" swapped="true"/>
</object>
</child>
</object>
</child>
</object>
</child>
<child>
<object class="AdwPreferencesGroup">
<property name="title" translatable="yes">Connections</property>
<property name="header-suffix">
<object class="GtkButton">
<signal name="clicked" handler="handle_add_client_pressed" swapped="true"/>
<property name="child">
<object class="AdwButtonContent">
<property name="icon-name">list-add-symbolic</property>
<property name="label" translatable="yes">Add</property>
</object>
</property>
<style>
<class name="flat"/>
</style>
</object>
</property>
<child>
<object class="GtkListBox" id="client_list">
<property name="selection-mode">none</property>
<child type="placeholder">
<object class="AdwActionRow" id="client_placeholder">
<property name="title">No connections!</property>
<property name="subtitle">add a new client via the + button</property>
</object>
</child>
<style>
<class name="boxed-list" />
</style>
</object>
</child>
</object>
</child>
</object>
</property>
</object>
</property>
</object>
</child>
</object>
</child>
</object>
</property>
</template>
</interface>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Some files were not shown because too many files have changed in this diff Show More