diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a55203..bb2af35 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,8 @@ jobs: ccache \ cmake \ ninja-build \ + libayatana-appindicator3-dev \ + libgtk-3-dev \ libx11-dev \ libssl-dev \ pkg-config \ @@ -61,6 +63,12 @@ jobs: - name: Test run: ctest --test-dir build --output-on-failure + - name: Package and smoke-test portable archive + run: | + cmake --build build --target package + archive="$(find build -maxdepth 1 -name 'inputflow-*.tar.gz' -print -quit)" + tests/test_portable_archive.sh "$archive" + - name: Compiler cache stats run: ccache --show-stats @@ -174,7 +182,14 @@ jobs: run: | bash -n mwb-desktop-ui.sh bash -n scripts/inputflow-diagnostics-bundle.sh + bash -n scripts/release-gate.sh bash -n scripts/validate-rpm-packaging.sh + bash -n tests/test_diagnostics_privacy.sh + bash -n tests/test_portable_archive.sh + python3 -m py_compile scripts/generate-release-metadata.py + + - name: Verify diagnostics privacy + run: tests/test_diagnostics_privacy.sh scripts/inputflow-diagnostics-bundle.sh - name: Validate RPM packaging skeleton run: scripts/validate-rpm-packaging.sh @@ -196,7 +211,7 @@ jobs: - name: Set up Android SDK uses: android-actions/setup-android@v3 - - name: Build debug APK and run unit tests + - name: Build release APK, run unit tests, and enforce lint working-directory: android run: | - ./gradlew :app:assembleDebug :app:testDebugUnitTest --no-daemon --stacktrace + ./gradlew :app:assembleRelease :app:testDebugUnitTest :app:lintRelease --no-daemon --stacktrace diff --git a/.gitignore b/.gitignore index 6e5416e..3ef83b5 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,20 @@ mwb-socket-trace-*.txt inputflow-windows-pair-*.ps1 AGENTS.md artifacts/ +graphify-out/ +inputflow-diagnostics-*.tar.gz +inputflow-diagnostics-*.zip + +# Local configuration and credentials +.env +.env.* +!.env.example +config.ini +*.pem +*.key +*.p12 +*.pfx +*.mobileprovision # Android build / local SDK + signing material (never commit) .gradle/ diff --git a/CMakeLists.txt b/CMakeLists.txt index 7416169..afd2e81 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,7 @@ cmake_minimum_required(VERSION 3.10) -project(mwb_client CXX) +project(mwb_client VERSION 0.2.0 LANGUAGES CXX) + +include(GNUInstallDirs) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) @@ -50,6 +52,7 @@ add_executable(mwb_client src/main.cpp src/PeerRecovery.cpp src/SecretStore.cpp + src/SecureFile.cpp src/TopologyModel.cpp src/ClipboardManager.cpp src/CryptoHelper.cpp @@ -120,8 +123,10 @@ if (BUILD_TESTING) src/AppState.cpp src/Discovery.cpp src/PeerRecovery.cpp + src/SecureFile.cpp ) target_include_directories(mwb_client_unit_tests PRIVATE src) + target_compile_definitions(mwb_client_unit_tests PRIVATE MWB_TESTING=1) target_compile_options(mwb_client_unit_tests PRIVATE -Wall -Wextra -Wpedantic) target_link_libraries(mwb_client_unit_tests PRIVATE OpenSSL::Crypto pthread) mwb_apply_sanitizers(mwb_client_unit_tests) @@ -243,6 +248,13 @@ if (BUILD_TESTING) ) add_test(NAME mwb_client_doctor_invalid_config COMMAND mwb_client doctor --config "${CMAKE_CURRENT_SOURCE_DIR}/tests/invalid_config.ini") set_tests_properties(mwb_client_doctor_invalid_config PROPERTIES WILL_FAIL TRUE) + add_test( + NAME inputflow_diagnostics_privacy + COMMAND bash + "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_diagnostics_privacy.sh" + "${CMAKE_CURRENT_SOURCE_DIR}/scripts/inputflow-diagnostics-bundle.sh" + ) + set_tests_properties(inputflow_diagnostics_privacy PROPERTIES TIMEOUT 30) endif() if (PkgConfig_FOUND) @@ -263,3 +275,61 @@ if (PkgConfig_FOUND) mwb_apply_sanitizers(mwb_tray) endif() endif() + +install(TARGETS mwb_client RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}") +if (TARGET mwb_tray) + install(TARGETS mwb_tray RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}") +endif() +install( + PROGRAMS packaging/portable/inputflow-controller + DESTINATION "${CMAKE_INSTALL_BINDIR}" +) +install( + PROGRAMS mwb-desktop-ui.sh + DESTINATION "${CMAKE_INSTALL_LIBEXECDIR}/inputflow" +) +install( + PROGRAMS scripts/inputflow-diagnostics-bundle.sh + DESTINATION "${CMAKE_INSTALL_LIBEXECDIR}/inputflow/scripts" +) +install( + FILES packaging/usr/lib/systemd/user/mwb-client.service + DESTINATION "${CMAKE_INSTALL_LIBDIR}/systemd/user" +) +install( + FILES + packaging/usr/lib/modules-load.d/mwb-client-uinput.conf + packaging/usr/lib/udev/rules.d/70-mwb-client-uinput.rules + packaging/usr/lib/sysusers.d/mwb-client.conf + DESTINATION "${CMAKE_INSTALL_DATADIR}/inputflow/system-integration" +) +install( + FILES packaging/usr/share/applications/inputflow.desktop + DESTINATION "${CMAKE_INSTALL_DATADIR}/applications" +) +install(DIRECTORY assets/hicolor/ DESTINATION "${CMAKE_INSTALL_DATADIR}/icons/hicolor") +install( + FILES + README.md + SECURITY.md + LICENSE + docs/compatibility.md + docs/security-privacy.md + docs/public-repository-audit.md + docs/release-checklist.md + docs/release-readiness-report.md + packaging/portable/README.md + DESTINATION "${CMAKE_INSTALL_DOCDIR}" +) + +set(CPACK_GENERATOR "TGZ") +set(CPACK_PACKAGE_NAME "inputflow") +set(CPACK_PACKAGE_VENDOR "InputFlow") +set(CPACK_PACKAGE_CONTACT "InputFlow maintainers") +set(CPACK_PACKAGE_DESCRIPTION_SUMMARY "Trusted-LAN Linux client for PowerToys Mouse Without Borders") +set(CPACK_PACKAGE_FILE_NAME + "inputflow-${PROJECT_VERSION}-${CMAKE_SYSTEM_NAME}-${CMAKE_SYSTEM_PROCESSOR}" +) +set(CPACK_INCLUDE_TOPLEVEL_DIRECTORY ON) +set(CPACK_PACKAGE_CHECKSUM "SHA256") +include(CPack) diff --git a/README.md b/README.md index 48f1880..a8e4f20 100644 --- a/README.md +++ b/README.md @@ -1,158 +1,329 @@ # InputFlow -![Status: Public Beta](https://img.shields.io/badge/status-public%20beta-e0a100) -![License: GPLv3](https://img.shields.io/badge/license-GPLv3-blue) +[![CI](https://github.com/daredoole/inputflow-linux/actions/workflows/ci.yml/badge.svg)](https://github.com/daredoole/inputflow-linux/actions/workflows/ci.yml) +[![Status: public beta](https://img.shields.io/badge/status-public%20beta-e0a100)](docs/release-readiness-report.md) +[![Version: 0.2.0](https://img.shields.io/badge/version-0.2.0-0b7f7a)](CHANGELOG.md) +[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](LICENSE) + +InputFlow is a native C++17 Linux companion for +[Microsoft PowerToys Mouse Without Borders](https://learn.microsoft.com/windows/powertoys/mouse-without-borders). +It lets a Windows keyboard and pointer control Linux, synchronizes clipboard +content, provides Linux tray and service integration, and can expose an +encrypted relay to an Android peer. + +> **Public beta:** InputFlow is usable, but desktop-compositor and reconnect +> edge cases are still being validated. Review the +> [release-readiness report](docs/release-readiness-report.md) and +> [compatibility guide](docs/compatibility.md) before deployment. + +![InputFlow tray controller](docs/screenshots/tray-controller.svg) + +## Contents + +- [Why InputFlow](#why-inputflow) +- [Features](#features) +- [Compatibility](#compatibility) +- [Security and privacy](#security-and-privacy) +- [Requirements](#requirements) +- [Build from source](#build-from-source) +- [First run](#first-run) +- [Connection modes](#connection-modes) +- [Command line](#command-line) +- [Testing and release builds](#testing-and-release-builds) +- [Documentation](#documentation) +- [Support and contributing](#support-and-contributing) +- [License and attribution](#license-and-attribution) + +## Why InputFlow + +PowerToys Mouse Without Borders does not provide a native Linux peer. +InputFlow fills that gap while keeping the default workflow compatible with +PowerToys machine placement. Advanced topology, Android relay, diagnostics, +and recovery tools remain opt-in. + +InputFlow is not a Barrier, Synergy, Input Leap, Deskflow, or Cursr protocol +implementation. + +## Features + +- Keyboard, pointer, media-key, and absolute-coordinate input on Linux. +- Text, HTML, and image clipboard synchronization. +- PowerToys-compatible Windows pairing helper. +- Automatic reconnection and approved-peer rediscovery after DHCP, VPN, + resume, or network changes. +- GTK controller and system tray for status, peers, settings, diagnostics, + service control, and topology. +- User-level systemd service integration. +- Optional multi-monitor topology and edge handoff. +- Optional Android relay with Accessibility, Shizuku, or root injection + backends. +- Privacy-redacted, local-only diagnostics bundles. +- Portable Linux archive, Android APK, SBOM, checksums, and provenance + generated by the release gate. + +## Compatibility + +| Component | Status | Notes | +| --- | --- | --- | +| Linux on X11 | Supported beta path | Uses `/dev/uinput`; clipboard requires `xclip` or `xsel`. | +| Linux on Wayland | Supported with caveats | Uses `/dev/uinput`; clipboard requires `wl-clipboard`; the compositor may display one input-capture permission request. | +| PowerToys MWB on Windows | Target peer | Use the exported pairing helper whenever possible. | +| Android | Optional beta peer | Requires the InputFlow Android app and an enabled relay. | +| Secret Service/keyring | Supported when available | Recommended for interactive desktop sessions. | +| Protected key file | Supported | Suitable for services when owner-only permissions are maintained. | + +The default is PowerToys-compatible machine-level placement. Display-level +topology is disabled unless explicitly configured. See +[Compatibility](docs/compatibility.md) and [Topology](docs/topology.md) for +desktop-specific behavior. + +## Security and privacy + +- Configuration and state files are written atomically with owner-only + permissions and symbolic-link targets are rejected. +- Pairing keys can be stored in the desktop keyring or in a protected file; + inline configuration keys are supported but discouraged. +- The Android relay authenticates sessions with HMAC and encrypts + post-authentication frames with directional AES-256-GCM keys, ordered + sequence numbers, and replay rejection. +- PowerToys compatibility uses the protocol and cryptography required by the + PowerToys MWB peer, including AES-256-CBC on that transport. +- Diagnostics never upload automatically. Network and journal collection are + opt-in, and likely secrets, usernames, hostnames, addresses, and input + metadata are redacted. +- Android backups exclude protected application data, and pairing secrets are + protected with Android Keystore. + +Read [Security and privacy](docs/security-privacy.md) for the trust model, +stored data, network exposure, and diagnostics behavior. Report +vulnerabilities using [SECURITY.md](SECURITY.md), not a public issue. + +## Requirements + +Core build requirements: + +- A C++17 compiler +- CMake 3.16 or newer +- OpenSSL, zlib, X11, and pkg-config development files + +Optional integrations: + +- GTK 3 and Ayatana AppIndicator for the tray/controller +- libei, GLib/GIO, and libinput for Wayland input capture and gestures +- `wl-clipboard` on Wayland, or `xclip`/`xsel` on X11 +- systemd for user-service integration + +Ubuntu/Debian build dependencies: -InputFlow is a native C++17 Linux companion for [Microsoft PowerToys "Mouse Without Borders"](https://learn.microsoft.com/en-us/windows/powertoys/mouse-without-borders), enabling seamless cursor and keyboard sharing between Linux and Windows. +```bash +sudo apt-get install -y build-essential cmake pkg-config libssl-dev zlib1g-dev \ + libx11-dev libei-dev libinput-dev python3-gi gir1.2-gtk-3.0 \ + libayatana-appindicator3-dev +``` -## 🚀 Quick Start (Tray & UI Setup) +Fedora build dependencies: -Recommended first-run flow for most users. (First build the client — see -[Build & Installation](#️-build--installation) below — then:) +```bash +sudo dnf install -y gcc-c++ cmake make pkgconf-pkg-config openssl-devel \ + zlib-devel libX11-devel libei-devel libinput-devel python3-gobject gtk3 \ + libayatana-appindicator3-devel +``` -1. **Install Prerequisites:** - - **Fedora:** `sudo dnf install python3-gobject gtk3 libayatana-appindicator3` - - **Ubuntu/Debian:** `sudo apt install python3-gi gir1.2-gtk-3.0 libayatana-appindicator3-0.1` -2. **Launch Setup UI:** Run `./mwb-desktop-ui.sh menu` -3. **Configure:** - - Go to **Settings** → enter your Windows host and Security Key. Prefer the - peer's **name** over a hardcoded IP (or click **Discover…** to pick it) so - InputFlow can follow the peer automatically if its IP changes. -4. **Use PowerToys layout for normal setups:** - - If this Linux/Fedora machine has one monitor, do not configure topology. Let Windows PowerToys Mouse Without Borders own the Linux/Windows machine placement. - - If topology was enabled while testing, choose **Use PowerToys Layout Only** to set `topology_enabled=false`. -5. **Pair with Windows:** - - In the same UI, use the **Export Helper** option. - - Run the exported `.ps1` script on your Windows machine to register the Linux peer. -6. **Start:** Choose **Start Service** or launch the tray with `./build/mwb_tray`. -7. **Advanced layouts only:** Open **Advanced Topology/Layout** if you have multiple Linux monitors, stacked/asymmetric edges, wrap behavior, or wrong-edge handoff problems. +Package names can differ by distribution release. Missing optional +integrations are reported during CMake configuration and by `mwb_client +doctor`. -For the full beta setup, health-check, diagnostics, connection-quality, and packaging-verification workflow, see [docs/beta-workflow.md](docs/beta-workflow.md). +## Build from source ---- +```bash +git clone https://github.com/daredoole/inputflow-linux.git +cd inputflow-linux +cmake -S . -B build -DCMAKE_BUILD_TYPE=Release +cmake --build build --parallel +ctest --test-dir build --output-on-failure +``` -## 🛠️ Build & Installation +The primary binaries are: -### 1. Prerequisites +- `build/mwb_client` — service, CLI, diagnostics, and combined GUI entry point. +- `build/mwb_tray` — standalone system-tray controller. -**Ubuntu / Debian:** -```bash -sudo apt-get install -y build-essential cmake pkg-config libssl-dev zlib1g-dev \ - libx11-dev python3-gi gir1.2-gtk-3.0 libayatana-appindicator3-dev -``` +## First run + +### 1. Allow rootless input injection + +The repository includes a narrowly scoped udev rule and system group: -**Fedora:** ```bash -sudo dnf install -y gcc-c++ cmake make pkgconf-pkg-config openssl-devel zlib-devel \ - libX11-devel python3-gobject gtk3 libayatana-appindicator3-devel +sudo groupadd --system --force inputflow +sudo install -Dm0644 \ + packaging/usr/lib/udev/rules.d/70-mwb-client-uinput.rules \ + /etc/udev/rules.d/70-mwb-client-uinput.rules +sudo modprobe uinput +sudo udevadm control --reload-rules +sudo udevadm trigger --subsystem-match=misc --action=change +sudo usermod -aG inputflow "$USER" ``` -> Clipboard sync also needs a runtime helper: `wl-clipboard` on Wayland, or -> `xclip`/`xsel` on X11. `mwb_client doctor` reports what's missing. +Log out and back in after changing group membership. Do not grant broad access +to the system `input` group. -### 2. Compile +### 2. Create configuration ```bash -cmake -S . -B build -DMWB_BUILD_TRAY=ON -cmake --build build -j$(nproc) +./build/mwb_client init-config \ + --config ~/.config/mwb-client/config.ini ``` -### 3. Setup `/dev/uinput` (Crucial for Mouse/Keyboard) +For most users, open the guided controller: ```bash -sudo modprobe uinput -sudo groupadd -r inputflow -sudo usermod -aG inputflow $USER -echo 'KERNEL=="uinput", GROUP="inputflow", MODE="0660", OPTIONS+="static_node=uinput"' | sudo tee /etc/udev/rules.d/99-inputflow-uinput.rules -sudo udevadm control --reload-rules && sudo udevadm trigger +./mwb-desktop-ui.sh menu ``` -*Logout and back in for group changes to take effect.* ---- +Enter the Windows host and shared security key in **Settings**. Prefer a peer +name over a fixed address so rediscovery can follow network changes. Use a +keyring secret ID or protected key file for long-lived configuration. -## ✨ Features +### 3. Pair Windows -- **Absolute Cursor Movement:** Precise pointer control across screens. -- **Keyboard Sync:** Full keyboard sharing with media key support. -- **Rich Clipboard:** Text, HTML, and **Image** synchronization (plain text stays plain). -- **Systemd Integration:** Runs as a lightweight user service. -- **Tray + Dashboard:** Connection status, live peer list, LAN peer discovery, and settings that apply on save. -- **Self-Healing Reconnect:** Follows a peer across IP changes (DHCP/VPN/resume) without a restart — configure peers by **name**. -- **Lock on Disconnect:** Optionally lock the Linux session when the controlling peer drops. -- **Android Peer:** Control an Android device as a screen, with no-root (Accessibility/Shizuku) or root native-grade input injection. See [docs/android.md](docs/android.md). +Use **Export Windows Pairing Helper** in the controller, or run: ---- +```bash +./build/mwb_client export-windows-pair \ + --config ~/.config/mwb-client/config.ini \ + --output inputflow-windows-pair.ps1 +``` -## ⚠️ Public Beta Status +Review the generated script, transfer it securely, and run it on the intended +Windows peer. -InputFlow is usable today but is still stabilizing. Expect rough edges around reconnection and desktop-environment edge cases. +### 4. Install and start the user service -**What is working well:** -- Windows-to-Linux keyboard/mouse input. -- Full Clipboard sync (Text/HTML/Images). -- `systemd` service management. -- Windows pairing-helper for easy setup. -- Self-healing reconnect across peer IP changes. -- Android peer relay (no-root and, optionally, Shizuku/root native injection). +```bash +./build/mwb_client install-user-service \ + --config ~/.config/mwb-client/config.ini +systemctl --user daemon-reload +systemctl --user enable --now mwb-client.service +``` -See the [CHANGELOG](CHANGELOG.md) for what's new and [docs/](docs/) for the full -guides (compatibility, topology, Android, beta workflow, MWB-parity roadmap). +Open the tray: ---- +```bash +./build/mwb_tray +``` -## 📖 Advanced Usage & CLI +On KDE Plasma, the InputFlow icon may initially appear in the tray overflow. +On Wayland, approve the single desktop input-capture request if topology +handoff is enabled. A timeout or dismissal does not trigger repeated prompts; +restart InputFlow deliberately to request permission again. -For power users who prefer manual control: +Run a health check after setup: ```bash -./build/mwb_client run --config ~/.config/mwb-client/config.ini -./build/mwb_client discover -./build/mwb_client doctor --config ~/.config/mwb-client/config.ini +./build/mwb_client doctor \ + --config ~/.config/mwb-client/config.ini ``` -See the full [documentation section](#detailed-documentation) for environment variables and protocol details. - -User-facing beta operations: - -- [Guided Windows pairing and export helper](docs/beta-workflow.md#guided-pairing-and-export-helper) -- [Topology/layout wizard](docs/beta-workflow.md#topologylayout-wizard) -- [Health checks and diagnostics bundle](docs/beta-workflow.md#health-check) -- [Connection quality and latency reporting](docs/beta-workflow.md#connection-quality) -- [Packaging verification](docs/beta-workflow.md#packaging-verification) -- [Topology config contract and layout wizard expectations](docs/topology.md) -- [Android peer MVP](docs/android.md) -- [Migration from other keyboard/mouse sharing tools](docs/migration.md) -- [Compatibility matrix and platform caveats](docs/compatibility.md) +The complete guided procedure is in +[Beta workflow](docs/beta-workflow.md). + +## Connection modes + +| Mode | Purpose | +| --- | --- | +| `powertoys` | Default. Connect to a Windows PowerToys MWB peer. | +| `inputflow` | Run native InputFlow services, primarily the Android relay in this beta. | +| `hybrid` | Keep the PowerToys connection while enabling native InputFlow peers. | + +Linux-to-Linux native transport is not yet a supported release path. + +## Command line + +```text +mwb_client run [--config PATH] +mwb_client discover [--state PATH] +mwb_client doctor [--config PATH] [--state PATH] +mwb_client android-pair [--config PATH] [--generate] [--ip] +mwb_client topology explain [PATH] [--config PATH] +mwb_client init-config [--config PATH] [--force] +mwb_client export-windows-pair [options] +mwb_client install-user-service [--config PATH] [--unit PATH] [--force] +mwb_client secret-store --secret-id ID [--key-file PATH | --stdin] +mwb_client secret-clear [--secret-id ID] +mwb_client gui [--config PATH] [--state PATH] +``` - -## Detailed Documentation +Run `mwb_client --help` for every option. Avoid passing secrets directly on +the command line because process listings and shell history may expose them. -### Attribution -This repository started as a fork of [chrischip/mwb-client-linux](https://github.com/chrischip/mwb-client-linux) and has been substantially expanded with service management, rich clipboard support, and recovery tooling. +## Testing and release builds -### Configuration (`config.ini`) -Supports `connection_mode`, `key_file`, `key_secret_id` (keyring), `screen_width/height` overrides, `topology_enabled`, `topology_file`, experimental `android_peers_enabled`, and more. Default path: `~/.config/mwb-client/config.ini`. +Run the native regression suite: -`connection_mode=powertoys` is the default Windows PowerToys/MWB compatibility path. `connection_mode=inputflow` runs native InputFlow peer services without requiring a Windows host/key. `connection_mode=hybrid` enables both paths at once. +```bash +ctest --test-dir build --output-on-failure +``` -Display-level topology is a separate opt-in contract. The default runtime remains MWB-compatible machine placement unless topology is explicitly enabled; see [docs/topology.md](docs/topology.md) for examples, wrap policies, validation, and cross-machine handoff behavior. +Run the complete production gate: -Windows PowerToys still owns the Windows-side machine layout. InputFlow topology does not edit PowerToys per-display geometry; it only tells Linux which local display edge should hand off back to Windows. Keep the PowerToys machine position and the InputFlow topology links consistent. +```bash +scripts/release-gate.sh +``` -### Screen Sizing -The client detects screen size in this order: -1. Config/CLI overrides -2. KDE logical geometry (`kscreen-doctor`) -3. DRM connector modes (`/sys/class/drm`) -4. 1920x1080 fallback +The release gate performs repository/privacy checks, shell validation, RPM +metadata validation, a clean Release build, 18 native regression tests, +portable-archive checksum and smoke tests, Android release assembly and unit +tests, Android lint, and generation of an SBOM, provenance, and SHA-256 +checksums. -### Network & Protocol -- **Port:** 15101 (Input), 15100 (Clipboard). -- **Encryption:** AES-256-CBC (PowerToys compatible). +Generated release artifacts are placed under +`build-release-gate/release/`. The Android APK is intentionally unsigned and +must be signed and verified outside the repository before publication. ---- +Preview diagnostics collection without creating an archive: -## License -GNU GPL v3.0 — see [LICENSE](LICENSE). +```bash +scripts/inputflow-diagnostics-bundle.sh --preview +``` -InputFlow is independent and not affiliated with Microsoft. Interoperability is based on the open-source [microsoft/PowerToys](https://github.com/microsoft/PowerToys) implementation. +## Documentation + +| Document | Purpose | +| --- | --- | +| [Beta workflow](docs/beta-workflow.md) | Guided setup, pairing, diagnostics, and packaging verification. | +| [Compatibility](docs/compatibility.md) | Desktop, transport, key-storage, and network caveats. | +| [Security and privacy](docs/security-privacy.md) | Trust boundaries, encryption, data handling, and diagnostics. | +| [Public repository audit](docs/public-repository-audit.md) | Secret, personal-data, device-data, history, and image-metadata assessment. | +| [Android](docs/android.md) | Android app, relay, pairing, and injection backends. | +| [Topology](docs/topology.md) | Optional multi-display topology contract and examples. | +| [Migration](docs/migration.md) | Migration from other keyboard/mouse sharing tools. | +| [Release checklist](docs/release-checklist.md) | Required signing, device matrix, soak, and publication checks. | +| [Release readiness](docs/release-readiness-report.md) | Current automated evidence and remaining external checks. | +| [Roadmap](docs/roadmap-mwb-parity.md) | PowerToys parity and future work. | +| [Packaging](packaging/README.md) | Distribution integration and user-service packaging. | +| [Changelog](CHANGELOG.md) | Version history. | + +## Support and contributing + +- Search existing [issues](https://github.com/daredoole/inputflow-linux/issues) + before opening a bug or feature request. +- Use the repository issue templates and attach only the redacted diagnostics + bundle. Review it before sharing. +- Follow [CONTRIBUTING.md](CONTRIBUTING.md) for build, test, and pull-request + requirements. +- Participation is governed by the + [Code of Conduct](CODE_OF_CONDUCT.md). +- Security reports follow [SECURITY.md](SECURITY.md). + +## License and attribution + +InputFlow is licensed under the +[GNU General Public License v3.0](LICENSE). + +This project began as a fork of +[chrischip/mwb-client-linux](https://github.com/chrischip/mwb-client-linux) +and has been substantially expanded. InputFlow is independent and is not +affiliated with Microsoft. PowerToys interoperability is based on the +open-source [Microsoft PowerToys](https://github.com/microsoft/PowerToys) +implementation. diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index e4da20e..b3c574d 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,5 @@ - + @@ -11,9 +12,12 @@ + + + + + + + android:enabled="true" + tools:ignore="Instantiatable" /> diff --git a/android/app/src/main/aidl/com/inputflow/android/IInjectorService.aidl b/android/app/src/main/aidl/com/inputflow/android/IInjectorService.aidl index f1a5d95..5f4df61 100644 --- a/android/app/src/main/aidl/com/inputflow/android/IInjectorService.aidl +++ b/android/app/src/main/aidl/com/inputflow/android/IInjectorService.aidl @@ -7,5 +7,6 @@ import android.view.InputEvent; // be injected at system level via InputManager.injectInputEvent. interface IInjectorService { boolean ping(); - boolean inject(in InputEvent event); + // mode = InputManager.INJECT_INPUT_EVENT_MODE_* (0=ASYNC, 2=WAIT_FOR_FINISH). + boolean inject(in InputEvent event, int mode); } diff --git a/android/app/src/main/java/com/inputflow/android/InjectorManager.kt b/android/app/src/main/java/com/inputflow/android/InjectorManager.kt index b1a347e..d28b72d 100644 --- a/android/app/src/main/java/com/inputflow/android/InjectorManager.kt +++ b/android/app/src/main/java/com/inputflow/android/InjectorManager.kt @@ -154,15 +154,22 @@ object InjectorManager { /** @return true if handled natively; false to let the accessibility path handle it. */ fun handleMouse(frame: JSONObject): Boolean { val n = native ?: return false - n.handleMouse(frame) - return true + return n.handleMouse(frame) } + fun hasNativeInjector(): Boolean = native != null + fun handleKeyboard(frame: JSONObject): Boolean { val n = native ?: return false return n.handleKeyboard(frame) } + /** Native continuous scroll for 2-finger trackpad gestures. */ + fun handleScroll(frame: JSONObject): Boolean { + val n = native ?: return false + return n.scrollBy(frame.optDouble("dx", 0.0).toFloat(), frame.optDouble("dy", 0.0).toFloat()) + } + private fun sensitivity(context: Context): Float { val prefs = context.getSharedPreferences(RelayForegroundService.PREFS, Context.MODE_PRIVATE) return prefs.getFloat(SettingsActivity.KEY_SENSITIVITY, 1.0f).coerceIn(0.5f, 3.0f) diff --git a/android/app/src/main/java/com/inputflow/android/InputFlowAccessibilityService.kt b/android/app/src/main/java/com/inputflow/android/InputFlowAccessibilityService.kt index e6cb1d5..aae25d1 100644 --- a/android/app/src/main/java/com/inputflow/android/InputFlowAccessibilityService.kt +++ b/android/app/src/main/java/com/inputflow/android/InputFlowAccessibilityService.kt @@ -72,7 +72,7 @@ class InputFlowAccessibilityService : AccessibilityService() { val x = frame.optInt("x") val y = frame.optInt("y") val mouseData = frame.optInt("mouseData") - if (loggedMouseFrames < 5) { + if (wParam == WM_MOUSEMOVE && loggedMouseFrames < 5) { Log.i(TAG, "mouse frame wParam=$wParam x=$x y=$y overlay=${cursorView != null}") loggedMouseFrames += 1 } @@ -290,17 +290,25 @@ class InputFlowAccessibilityService : AccessibilityService() { } private fun handleSwipe3(dx: Double, dy: Double) { + if (isAccurateHorizontalSwipe(dx, dy)) { + dispatchPageSwipe(if (dx < 0) -1f else 1f) + return + } if (!isAccurateVerticalSwipe(dx, dy)) return mainHandler.post { if (dy < 0) { - performGlobalActionLogged(GLOBAL_ACTION_HOME, "gesture:3-up") + performGlobalActionLogged(GLOBAL_ACTION_RECENTS, "gesture:3-up") } else { - performGlobalActionLogged(GLOBAL_ACTION_RECENTS, "gesture:3-down") + performGlobalActionLogged(GLOBAL_ACTION_HOME, "gesture:3-down") } } } private fun handleSwipe4(dx: Double, dy: Double) { + if (isAccurateHorizontalSwipe(dx, dy)) { + dispatchPageSwipe(if (dx < 0) -1f else 1f) + return + } if (!isAccurateVerticalSwipe(dx, dy)) return mainHandler.post { if (dy < 0) { @@ -317,6 +325,12 @@ class InputFlowAccessibilityService : AccessibilityService() { return absY >= 32.0 && absY >= absX * 1.45 } + private fun isAccurateHorizontalSwipe(dx: Double, dy: Double): Boolean { + val absX = abs(dx) + val absY = abs(dy) + return absX >= 32.0 && absX >= absY * 1.45 + } + private fun openAppDrawerGesture() { performGlobalActionLogged(GLOBAL_ACTION_HOME, "gesture:4-up-home") mainHandler.postDelayed({ @@ -524,7 +538,10 @@ class InputFlowAccessibilityService : AccessibilityService() { lineTo(toX, toY) } val stroke = GestureDescription.StrokeDescription(path, 0, durationMs) - dispatchGesture(GestureDescription.Builder().addStroke(stroke).build(), null, null) + dispatchGesture( + GestureDescription.Builder().addStroke(stroke).build(), + null, + null) } private fun scroll(mouseData: Int) { @@ -551,6 +568,10 @@ class InputFlowAccessibilityService : AccessibilityService() { pendingScrollDx = 0.0 pendingScrollDy = 0.0 if (abs(dx) < 0.05 && abs(dy) < 0.05) return + if (isAccurateHorizontalSwipe(dx, dy)) { + dispatchPageSwipe(if (dx < 0) -1f else 1f) + return + } val metrics = resources.displayMetrics val scale = 34f * metrics.density val maxStep = 170f * metrics.density @@ -561,6 +582,14 @@ class InputFlowAccessibilityService : AccessibilityService() { gesture(pointerX, pointerY, toX, toY, 62) } + private fun dispatchPageSwipe(directionX: Float) { + val metrics = resources.displayMetrics + val y = metrics.heightPixels * 0.56f + val fromX = if (directionX < 0f) metrics.widthPixels * 0.84f else metrics.widthPixels * 0.16f + val toX = if (directionX < 0f) metrics.widthPixels * 0.16f else metrics.widthPixels * 0.84f + gesture(fromX, y, toX, y, 260) + } + private fun showCursorOverlay() { if (cursorView != null) return val prefs = applicationContext.getSharedPreferences(RelayForegroundService.PREFS, MODE_PRIVATE) diff --git a/android/app/src/main/java/com/inputflow/android/InputFlowImeService.kt b/android/app/src/main/java/com/inputflow/android/InputFlowImeService.kt index 391ffd2..ba37943 100644 --- a/android/app/src/main/java/com/inputflow/android/InputFlowImeService.kt +++ b/android/app/src/main/java/com/inputflow/android/InputFlowImeService.kt @@ -32,7 +32,10 @@ class InputFlowImeService : InputMethodService() { super.onDestroy() } - override fun onEvaluateInputViewShown(): Boolean = false + override fun onEvaluateInputViewShown(): Boolean { + super.onEvaluateInputViewShown() + return false + } override fun onCreateInputView(): View { return View(this) @@ -83,7 +86,6 @@ class InputFlowImeService : InputMethodService() { is InputAction.ModifiedKey -> sendModifiedKey(connection, action.keyCode, action.metaState) is InputAction.Menu -> connection.performContextMenuAction(action.id) } - Log.i(TAG, "keyboard action=${action.name} vk=$vkCode handled=$handled") return handled } diff --git a/android/app/src/main/java/com/inputflow/android/LayoutEditorView.kt b/android/app/src/main/java/com/inputflow/android/LayoutEditorView.kt index bfbc6b0..bec75ed 100644 --- a/android/app/src/main/java/com/inputflow/android/LayoutEditorView.kt +++ b/android/app/src/main/java/com/inputflow/android/LayoutEditorView.kt @@ -2,13 +2,13 @@ package com.inputflow.android import android.content.Context import android.graphics.Canvas -import android.graphics.Color import android.graphics.Paint import android.graphics.RectF import android.graphics.Typeface import android.util.AttributeSet import android.view.MotionEvent import android.view.View +import androidx.core.content.ContextCompat import kotlin.math.roundToInt data class DeviceRect( @@ -34,13 +34,17 @@ class LayoutEditorView @JvmOverloads constructor( } private val gridCellPx get() = (64 * resources.displayMetrics.density).toInt() + private val deviceRect = RectF() + private val cornerRadius = 12f * resources.displayMetrics.density + private val remoteDeviceColor = ContextCompat.getColor(context, R.color.colorEditorDevice) + private val localDeviceColor = ContextCompat.getColor(context, R.color.colorEditorDeviceLocal) private val bgPaint = Paint().apply { - color = Color.parseColor("#0A1628") + color = ContextCompat.getColor(context, R.color.colorEditorBg) style = Paint.Style.FILL } private val gridPaint = Paint().apply { - color = Color.parseColor("#1A2840") + color = ContextCompat.getColor(context, R.color.colorEditorGrid) style = Paint.Style.STROKE strokeWidth = 1f } @@ -54,14 +58,14 @@ class LayoutEditorView @JvmOverloads constructor( isAntiAlias = true } private val labelPaint = Paint().apply { - color = Color.WHITE + color = ContextCompat.getColor(context, R.color.colorEditorText) textSize = 14f * resources.displayMetrics.density isAntiAlias = true typeface = Typeface.DEFAULT_BOLD textAlign = Paint.Align.CENTER } private val badgePaint = Paint().apply { - color = Color.parseColor("#1A2840") + color = ContextCompat.getColor(context, R.color.colorEditorText) textSize = 10f * resources.displayMetrics.density isAntiAlias = true textAlign = Paint.Align.CENTER @@ -113,25 +117,18 @@ class LayoutEditorView @JvmOverloads constructor( val py = device.gridY * gridCellPx + viewOffsetY val pw = device.gridW * gridCellPx val ph = device.gridH * gridCellPx - val rect = RectF(px.toFloat(), py.toFloat(), (px + pw).toFloat(), (py + ph).toFloat()) - val radius = 12f * resources.displayMetrics.density + deviceRect.set(px.toFloat(), py.toFloat(), (px + pw).toFloat(), (py + ph).toFloat()) - deviceFillPaint.color = if (device.isLocal) - Color.parseColor("#1B5E20") - else - Color.parseColor("#0D47A1") + deviceFillPaint.color = if (device.isLocal) localDeviceColor else remoteDeviceColor deviceFillPaint.alpha = 200 - canvas.drawRoundRect(rect, radius, radius, deviceFillPaint) + canvas.drawRoundRect(deviceRect, cornerRadius, cornerRadius, deviceFillPaint) - deviceStrokePaint.color = if (device.isLocal) - Color.parseColor("#66BB6A") - else - Color.parseColor("#42A5F5") - canvas.drawRoundRect(rect, radius, radius, deviceStrokePaint) + deviceStrokePaint.color = if (device.isLocal) localDeviceColor else remoteDeviceColor + canvas.drawRoundRect(deviceRect, cornerRadius, cornerRadius, deviceStrokePaint) // Label - val cx = rect.centerX() - val cy = rect.centerY() + val cx = deviceRect.centerX() + val cy = deviceRect.centerY() val lineH = labelPaint.textSize if (device.isLocal) { canvas.drawText(device.name, cx, cy - lineH * 0.2f, labelPaint) @@ -171,14 +168,26 @@ class LayoutEditorView @JvmOverloads constructor( invalidate() return true } - MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + MotionEvent.ACTION_UP -> { draggingId = null invalidate() + performClick() + return true + } + MotionEvent.ACTION_CANCEL -> { + draggingId = null + invalidate() + return true } } return super.onTouchEvent(event) } + override fun performClick(): Boolean { + super.performClick() + return true + } + fun getLayoutJson(): org.json.JSONArray { val arr = org.json.JSONArray() for (device in devices) { diff --git a/android/app/src/main/java/com/inputflow/android/MainActivity.kt b/android/app/src/main/java/com/inputflow/android/MainActivity.kt index 512582c..86e3f51 100644 --- a/android/app/src/main/java/com/inputflow/android/MainActivity.kt +++ b/android/app/src/main/java/com/inputflow/android/MainActivity.kt @@ -5,6 +5,7 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter +import android.content.pm.PackageManager import android.net.Uri import android.os.Build import android.os.Bundle @@ -14,6 +15,7 @@ import android.view.MenuItem import android.view.inputmethod.InputMethodManager import android.widget.LinearLayout import android.widget.TextView +import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import com.google.android.material.appbar.MaterialToolbar import com.google.android.material.button.MaterialButton @@ -65,17 +67,33 @@ class MainActivity : AppCompatActivity() { val prefs = getSharedPreferences(RelayForegroundService.PREFS, Context.MODE_PRIVATE) applyPairingUri(intent) hostField.setText(prefs.getString(RelayForegroundService.KEY_HOST, "")) - portField.setText(prefs.getInt(RelayForegroundService.KEY_PORT, 15102).toString()) - secretField.setText(prefs.getString(RelayForegroundService.KEY_SECRET, "")) + portField.setText(getString(R.string.port_number, prefs.getInt(RelayForegroundService.KEY_PORT, 15102))) + secretField.setText(PairingSecretStore.read(this)) + + // Auto-connect on launch when already configured and not already running, + // mirroring the desktop client's auto_connect. Saves the user a tap after + // every app start (and after a reinstall) and keeps the relay self-healing. + val savedHost = prefs.getString(RelayForegroundService.KEY_HOST, "").orEmpty() + val savedSecret = PairingSecretStore.read(this) + if (savedHost.isNotBlank() && savedSecret.isNotBlank() && + RelayForegroundService.instance == null + ) { + startRelayService(Intent(this, RelayForegroundService::class.java)) + } findViewById(R.id.btnConnect).setOnClickListener { + val secret = secretField.text.toString().trim() + if (!PairingSecretStore.save(this, secret)) { + secretField.error = getString(R.string.secret_storage_error) + return@setOnClickListener + } prefs.edit() .putString(RelayForegroundService.KEY_HOST, hostField.text.toString().trim()) .putInt(RelayForegroundService.KEY_PORT, portField.text.toString().toIntOrNull() ?: 15102) - .putString(RelayForegroundService.KEY_SECRET, secretField.text.toString().trim()) .apply() requestNotificationPermission() startRelayService(Intent(this, RelayForegroundService::class.java)) + promptMissingPermissions() } findViewById(R.id.btnRelease).setOnClickListener { @@ -114,6 +132,11 @@ class MainActivity : AppCompatActivity() { registerReceiver(statusReceiver, filter) } refreshStatus() + // Returning from a settings screen: re-check and surface the next missing + // permission, but only once the user has actually started a connection. + if (RelayForegroundService.currentState != RelayForegroundService.STATE_DISCONNECTED) { + promptMissingPermissions() + } } override fun onPause() { @@ -140,8 +163,8 @@ class MainActivity : AppCompatActivity() { applyPairingUri(intent) val prefs = getSharedPreferences(RelayForegroundService.PREFS, Context.MODE_PRIVATE) hostField.setText(prefs.getString(RelayForegroundService.KEY_HOST, "")) - portField.setText(prefs.getInt(RelayForegroundService.KEY_PORT, 15102).toString()) - secretField.setText(prefs.getString(RelayForegroundService.KEY_SECRET, "")) + portField.setText(getString(R.string.port_number, prefs.getInt(RelayForegroundService.KEY_PORT, 15102))) + secretField.setText(PairingSecretStore.read(this)) } private fun refreshStatus() { @@ -212,18 +235,108 @@ class MainActivity : AppCompatActivity() { } } + private data class MissingPermission( + val label: String, + val rationale: String, + val open: () -> Unit, + ) + + private fun isAccessibilityEnabled(): Boolean { + val flat = Settings.Secure.getString( + contentResolver, Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES + ) ?: return false + return flat.split(':').any { + it.startsWith(packageName) && it.contains("InputFlowAccessibilityService") + } + } + + private fun isNotificationListenerEnabled(): Boolean { + val flat = Settings.Secure.getString( + contentResolver, "enabled_notification_listeners" + ) ?: return false + return flat.split(':').any { + it.startsWith(packageName) && it.contains("InputFlowNotificationListenerService") + } + } + + /** Required grants that Android cannot prompt for inline (deep-link only). */ + private fun missingRequiredPermissions(): List { + val prefs = getSharedPreferences(RelayForegroundService.PREFS, Context.MODE_PRIVATE) + val missing = mutableListOf() + + if (!isAccessibilityEnabled()) { + missing += MissingPermission( + "Accessibility access", + "Lets InputFlow show the remote cursor and deliver taps from your computer.", + ) { startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)) } + } + + if (prefs.getBoolean(RelayForegroundService.KEY_NOTIFICATION_SYNC_ENABLED, false) && + !isNotificationListenerEnabled() + ) { + missing += MissingPermission( + "Notification access", + "Lets InputFlow mirror this device's notifications to your computer.", + ) { startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)) } + } + + if (Build.VERSION.SDK_INT >= 33 && + checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != + PackageManager.PERMISSION_GRANTED + ) { + missing += MissingPermission( + "Show notifications", + "Lets InputFlow display your computer's notifications on this device.", + ) { requestNotificationPermission() } + } + + return missing + } + + private var permissionDialogShowing = false + + /** + * Surfaces the first still-missing required permission as a dialog that + * deep-links to the relevant settings page. Re-runs on [onResume], so the + * user is walked through each missing grant one screen at a time. + */ + private fun promptMissingPermissions() { + if (permissionDialogShowing) return + val first = missingRequiredPermissions().firstOrNull() ?: return + val remaining = missingRequiredPermissions().size + permissionDialogShowing = true + AlertDialog.Builder(this) + .setTitle("Permission needed") + .setMessage( + "${first.label}\n\n${first.rationale}" + + if (remaining > 1) "\n\n${remaining - 1} more after this." else "" + ) + .setPositiveButton("Open settings") { d, _ -> + permissionDialogShowing = false + d.dismiss() + first.open() + } + .setNegativeButton("Not now") { d, _ -> + permissionDialogShowing = false + d.dismiss() + } + .setOnCancelListener { permissionDialogShowing = false } + .show() + } + private fun applyPairingUri(intent: Intent?) { val data = intent?.data ?: return + intent.data = null if (data.scheme != "inputflow" || data.host != "android-peer") return val host = data.getQueryParameter("host").orEmpty() val port = data.getQueryParameter("port")?.toIntOrNull() ?: 15102 val secret = data.getQueryParameter("secret").orEmpty().trim() if (host.isBlank() || secret.isBlank()) return + if (!PairingSecretStore.save(this, secret)) return getSharedPreferences(RelayForegroundService.PREFS, Context.MODE_PRIVATE) .edit() .putString(RelayForegroundService.KEY_HOST, host) .putInt(RelayForegroundService.KEY_PORT, port) - .putString(RelayForegroundService.KEY_SECRET, secret) .apply() } diff --git a/android/app/src/main/java/com/inputflow/android/NativeInjector.kt b/android/app/src/main/java/com/inputflow/android/NativeInjector.kt index c24a83e..f96dcec 100644 --- a/android/app/src/main/java/com/inputflow/android/NativeInjector.kt +++ b/android/app/src/main/java/com/inputflow/android/NativeInjector.kt @@ -23,20 +23,32 @@ class NativeInjector( private var py = 0f private var primed = false private var metaState = 0 + private var primaryDownTime: Long = 0L + private var secondaryDownTime: Long = 0L + private var tertiaryDownTime: Long = 0L + private var leftDown = false + private var touchDownTime: Long = 0L fun ping(): Boolean = try { service.ping() } catch (_: Throwable) { false } - private fun inject(event: android.view.InputEvent): Boolean = - try { service.inject(event) } catch (_: Throwable) { false } + private fun inject( + event: android.view.InputEvent, + mode: Int = SystemInject.MODE_ASYNC, + ): Boolean = + try { + service.inject(event, mode) + } catch (_: Throwable) { + false + } - fun handleMouse(frame: JSONObject) { + fun handleMouse(frame: JSONObject): Boolean { val m = metricsProvider() if (!primed) { px = m.widthPixels / 2f py = m.heightPixels / 2f primed = true } - when (frame.optInt("wParam")) { + return when (frame.optInt("wParam")) { WM_MOUSEMOVE -> { val sens = sensitivityProvider() val baseX = frame.optInt("x") / 65535f * m.widthPixels @@ -45,24 +57,104 @@ class NativeInjector( val cy = m.heightPixels / 2f px = (cx + (baseX - cx) * sens).coerceIn(0f, m.widthPixels - 1f) py = (cy + (baseY - cy) * sens).coerceIn(0f, m.heightPixels - 1f) - inject(pointerEvent(MotionEvent.ACTION_HOVER_MOVE, 0)) + // While the left button is held, a move is a drag (touch move). + if (leftDown) inject(touchEvent(MotionEvent.ACTION_MOVE)) else true + } + // Left click = a real touchscreen tap (down/up) — the reliable path that + // registers in every app, exactly like `input tap`. Down→move→up = drag. + // Synchronous injection (WAIT_FOR_FINISH) guarantees the DOWN is fully + // dispatched before the UP, and a minimum down→up interval is enforced so + // the framework's tap detector never sees a zero-duration (ignored) tap. + WM_LBUTTONDOWN -> { + leftDown = true + touchDownTime = SystemClock.uptimeMillis() + inject(touchEvent(MotionEvent.ACTION_DOWN), SystemInject.MODE_WAIT_FOR_FINISH) + } + WM_LBUTTONUP -> { + val held = SystemClock.uptimeMillis() - touchDownTime + if (held < MIN_TAP_DURATION_MS) { + try { Thread.sleep(MIN_TAP_DURATION_MS - held) } catch (_: InterruptedException) {} + } + val ok = inject(touchEvent(MotionEvent.ACTION_UP), SystemInject.MODE_WAIT_FOR_FINISH) + leftDown = false + ok } - WM_LBUTTONUP -> click(MotionEvent.BUTTON_PRIMARY) - WM_RBUTTONUP -> click(MotionEvent.BUTTON_SECONDARY) - WM_MBUTTONUP -> click(MotionEvent.BUTTON_TERTIARY) + WM_RBUTTONDOWN -> button(MotionEvent.BUTTON_SECONDARY, down = true) + WM_RBUTTONUP -> button(MotionEvent.BUTTON_SECONDARY, down = false) + WM_MBUTTONDOWN -> button(MotionEvent.BUTTON_TERTIARY, down = true) + WM_MBUTTONUP -> button(MotionEvent.BUTTON_TERTIARY, down = false) WM_MOUSEWHEEL -> scroll(frame.optInt("mouseData")) + else -> false + } + } + + private fun touchEvent(action: Int): MotionEvent { + val now = SystemClock.uptimeMillis() + val props = MotionEvent.PointerProperties().apply { + id = 0; toolType = MotionEvent.TOOL_TYPE_FINGER + } + val coords = MotionEvent.PointerCoords().apply { + x = px; y = py; pressure = 1f; size = 1f + } + return MotionEvent.obtain( + touchDownTime, now, action, 1, + arrayOf(props), arrayOf(coords), 0, 0, 1f, 1f, 0, 0, + InputDevice.SOURCE_TOUCHSCREEN, 0) + } + + private fun button(button: Int, down: Boolean): Boolean { + val now = SystemClock.uptimeMillis() + return if (down) { + setButtonDownTime(button, now) + val downSent = inject(pointerEvent(MotionEvent.ACTION_DOWN, button, now, now)) + val pressSent = inject(pointerEvent(MotionEvent.ACTION_BUTTON_PRESS, button, now, now)) + downSent && pressSent + } else { + val downTime = getButtonDownTime(button).takeIf { it != 0L } ?: now + setButtonDownTime(button, 0L) + val releaseSent = inject(pointerEvent(MotionEvent.ACTION_BUTTON_RELEASE, 0, downTime, now)) + val upSent = inject(pointerEvent(MotionEvent.ACTION_UP, 0, downTime, now)) + releaseSent && upSent } } - private fun click(button: Int) { - val down = SystemClock.uptimeMillis() - inject(pointerEvent(MotionEvent.ACTION_DOWN, button, down, down)) - inject(pointerEvent(MotionEvent.ACTION_BUTTON_PRESS, button, down, down)) - inject(pointerEvent(MotionEvent.ACTION_BUTTON_RELEASE, 0, down, SystemClock.uptimeMillis())) - inject(pointerEvent(MotionEvent.ACTION_UP, 0, down, SystemClock.uptimeMillis())) + private fun getButtonDownTime(button: Int): Long = when (button) { + MotionEvent.BUTTON_PRIMARY -> primaryDownTime + MotionEvent.BUTTON_SECONDARY -> secondaryDownTime + MotionEvent.BUTTON_TERTIARY -> tertiaryDownTime + else -> 0L + } + + private fun setButtonDownTime(button: Int, value: Long) { + when (button) { + MotionEvent.BUTTON_PRIMARY -> primaryDownTime = value + MotionEvent.BUTTON_SECONDARY -> secondaryDownTime = value + MotionEvent.BUTTON_TERTIARY -> tertiaryDownTime = value + } + } + + /** Continuous trackpad-style scroll at the cursor (native ACTION_SCROLL). */ + fun scrollBy(dx: Float, dy: Float): Boolean { + val now = SystemClock.uptimeMillis() + val props = MotionEvent.PointerProperties().apply { + id = 0; toolType = MotionEvent.TOOL_TYPE_MOUSE + } + val coords = MotionEvent.PointerCoords().apply { + x = px; y = py; pressure = 1f; size = 1f + // Android scroll axes: positive vscroll = away/up. Trackpad dy>0 = down. + setAxisValue(MotionEvent.AXIS_VSCROLL, -dy / SCROLL_DIVISOR) + setAxisValue(MotionEvent.AXIS_HSCROLL, dx / SCROLL_DIVISOR) + } + val ev = MotionEvent.obtain( + now, now, MotionEvent.ACTION_SCROLL, 1, + arrayOf(props), arrayOf(coords), 0, 0, 1f, 1f, 0, 0, + InputDevice.SOURCE_MOUSE, 0) + val sent = inject(ev) + ev.recycle() + return sent } - private fun scroll(mouseData: Int) { + private fun scroll(mouseData: Int): Boolean { val now = SystemClock.uptimeMillis() val props = MotionEvent.PointerProperties().apply { id = 0; toolType = MotionEvent.TOOL_TYPE_MOUSE @@ -75,8 +167,9 @@ class NativeInjector( now, now, MotionEvent.ACTION_SCROLL, 1, arrayOf(props), arrayOf(coords), 0, 0, 1f, 1f, 0, 0, InputDevice.SOURCE_MOUSE, 0) - inject(ev) + val sent = inject(ev) ev.recycle() + return sent } private fun pointerEvent( @@ -125,10 +218,18 @@ class NativeInjector( companion object { private const val WM_MOUSEMOVE = 0x0200 + private const val WM_LBUTTONDOWN = 0x0201 private const val WM_LBUTTONUP = 0x0202 + private const val WM_RBUTTONDOWN = 0x0204 private const val WM_RBUTTONUP = 0x0205 + private const val WM_MBUTTONDOWN = 0x0207 private const val WM_MBUTTONUP = 0x0208 private const val WM_MOUSEWHEEL = 0x020A private const val LLKHF_UP = 0x80 + // Trackpad pixel-delta → scroll-unit scale; smaller = faster scroll. + private const val SCROLL_DIVISOR = 40f + // Minimum DOWN→UP interval (ms) so a tap is never zero-duration. AOSP's + // monkey tool uses ~5ms; we use a slightly safer floor. + private const val MIN_TAP_DURATION_MS = 12L } } diff --git a/android/app/src/main/java/com/inputflow/android/NotificationSyncBridge.kt b/android/app/src/main/java/com/inputflow/android/NotificationSyncBridge.kt new file mode 100644 index 0000000..1a26c8f --- /dev/null +++ b/android/app/src/main/java/com/inputflow/android/NotificationSyncBridge.kt @@ -0,0 +1,236 @@ +package com.inputflow.android + +import android.Manifest +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.RemoteInput +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.drawable.Drawable +import android.os.Build +import android.os.Bundle +import android.service.notification.NotificationListenerService +import android.service.notification.StatusBarNotification +import android.util.Base64 +import android.util.Log +import java.io.ByteArrayOutputStream +import java.util.concurrent.ConcurrentHashMap +import org.json.JSONObject +import kotlin.math.absoluteValue + +class InputFlowNotificationListenerService : NotificationListenerService() { + override fun onNotificationPosted(sbn: StatusBarNotification) { + NotificationSyncBridge.sendAndroidNotification(this, sbn) + } + + override fun onNotificationRemoved(sbn: StatusBarNotification) { + NotificationSyncBridge.sendAndroidNotificationDismiss(this, sbn) + } +} + +object NotificationSyncBridge { + private const val MIRROR_CHANNEL_ID = "inputflow-mirrored" + private const val MIRROR_CHANNEL_NAME = "Mirrored notifications" + private val sensitivePattern = Regex( + "\\b(otp|one[- ]?time|verification|2fa|two[- ]?factor|password|passcode|bank|credit|debit)\\b", + RegexOption.IGNORE_CASE + ) + + // Live reply actions keyed by the synced notification's stable_id, so a reply + // typed on the desktop can be fired back into the originating app. Bounded so a + // long-running listener can't grow unbounded; oldest entries are evicted. + private const val MAX_REPLY_ENTRIES = 200 + private val replyActions = ConcurrentHashMap() + private val replyOrder = ArrayDeque() + + fun sendAndroidNotification(context: Context, sbn: StatusBarNotification) { + if (!isEnabled(context) || sbn.packageName == context.packageName) return + + val notification = sbn.notification ?: return + if ((notification.flags and Notification.FLAG_GROUP_SUMMARY) != 0) return + if ((notification.flags and Notification.FLAG_ONGOING_EVENT) != 0) return + if (notification.visibility == Notification.VISIBILITY_SECRET) return + + val title = notification.extras.getCharSequence(Notification.EXTRA_TITLE) + ?.toString() + ?.trim() + .orEmpty() + val body = ( + notification.extras.getCharSequence(Notification.EXTRA_BIG_TEXT) + ?: notification.extras.getCharSequence(Notification.EXTRA_TEXT) + ) + ?.toString() + ?.trim() + .orEmpty() + + if (title.isBlank() && body.isBlank()) return + if (isSensitive(title, body)) return + + val sid = stableId(sbn) + val replyAction = findReplyAction(notification) + if (replyAction != null) { + rememberReplyAction(sid, replyAction) + } + + RelayForegroundService.sendNotificationUpsert( + stableId = sid, + app = appLabel(context, sbn.packageName), + packageName = sbn.packageName, + title = title, + body = body, + postedAtMs = sbn.postTime, + iconPng = appIconBase64(context, sbn.packageName), + canReply = replyAction != null + ) + } + + /** First notification action carrying a free-text RemoteInput (quick reply). */ + private fun findReplyAction(notification: Notification): Notification.Action? { + val actions = notification.actions ?: return null + return actions.firstOrNull { action -> + action.remoteInputs?.any { it.allowFreeFormInput } == true + } + } + + private fun rememberReplyAction(stableId: String, action: Notification.Action) { + synchronized(replyOrder) { + if (replyActions.put(stableId, action) == null) { + replyOrder.addLast(stableId) + while (replyOrder.size > MAX_REPLY_ENTRIES) { + replyActions.remove(replyOrder.removeFirst()) + } + } + } + } + + /** + * Fires a desktop-typed reply back into the originating app via its + * RemoteInput. Returns true if the reply was dispatched. + */ + fun replyToNotification(context: Context, stableId: String, text: String): Boolean { + val action = replyActions[stableId] ?: run { + Log.w("NotificationSync", "No live reply action is available") + return false + } + val remoteInputs = action.remoteInputs?.takeIf { it.isNotEmpty() } ?: return false + return try { + val intent = Intent() + val results = Bundle() + for (ri in remoteInputs) { + results.putCharSequence(ri.resultKey, text) + } + RemoteInput.addResultsToIntent(remoteInputs, intent, results) + action.actionIntent.send(context, 0, intent) + // A reply consumes the action; drop it so a stale id can't re-fire. + synchronized(replyOrder) { + if (replyActions.remove(stableId) != null) replyOrder.remove(stableId) + } + true + } catch (_: Exception) { + Log.w("NotificationSync", "Failed to send notification reply") + false + } + } + + /** App launcher icon as a base64 PNG, so the desktop can show the real icon. */ + private fun appIconBase64(context: Context, packageName: String): String? { + return try { + val drawable: Drawable = context.packageManager.getApplicationIcon(packageName) + val size = 96 + val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + drawable.setBounds(0, 0, size, size) + drawable.draw(canvas) + val out = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.PNG, 100, out) + bitmap.recycle() + Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP) + } catch (_: Exception) { + null + } + } + + fun sendAndroidNotificationDismiss(context: Context, sbn: StatusBarNotification) { + if (!isEnabled(context) || sbn.packageName == context.packageName) return + val sid = stableId(sbn) + synchronized(replyOrder) { + if (replyActions.remove(sid) != null) replyOrder.remove(sid) + } + RelayForegroundService.sendNotificationDismiss(sid, sbn.packageName) + } + + fun showMirroredNotification(context: Context, frame: JSONObject) { + if (!isEnabled(context)) return + if (Build.VERSION.SDK_INT >= 33 && + context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != + PackageManager.PERMISSION_GRANTED + ) { + return + } + + val title = frame.optString("title", "InputFlow notification") + val body = frame.optString("body", "") + val app = frame.optString("app", "") + val stableId = frame.optString("stable_id", "$app:$title:$body") + val manager = context.getSystemService(NotificationManager::class.java) + ensureMirrorChannel(manager) + + val notification = Notification.Builder(context, MIRROR_CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(if (app.isBlank()) title else "$app: $title") + .setContentText(body) + .setStyle(Notification.BigTextStyle().bigText(body)) + .setAutoCancel(true) + .build() + + manager.notify(notificationId(stableId), notification) + } + + fun cancelMirroredNotification(context: Context, frame: JSONObject) { + val stableId = frame.optString("stable_id", "") + if (stableId.isBlank()) return + context.getSystemService(NotificationManager::class.java) + .cancel(notificationId(stableId)) + } + + private fun isEnabled(context: Context): Boolean = + context.getSharedPreferences(RelayForegroundService.PREFS, Context.MODE_PRIVATE) + .getBoolean(RelayForegroundService.KEY_NOTIFICATION_SYNC_ENABLED, false) + + private fun isSensitive(title: String, body: String): Boolean = + sensitivePattern.containsMatchIn(title) || sensitivePattern.containsMatchIn(body) + + private fun stableId(sbn: StatusBarNotification): String { + val tag = sbn.tag ?: "" + return "${sbn.packageName}:${sbn.id}:$tag:${sbn.postTime}" + } + + private fun appLabel(context: Context, packageName: String): String { + return try { + val pm = context.packageManager + val info = pm.getApplicationInfo(packageName, 0) + pm.getApplicationLabel(info).toString() + } catch (_: Exception) { + packageName + } + } + + private fun ensureMirrorChannel(manager: NotificationManager) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + if (manager.getNotificationChannel(MIRROR_CHANNEL_ID) != null) return + manager.createNotificationChannel( + NotificationChannel( + MIRROR_CHANNEL_ID, + MIRROR_CHANNEL_NAME, + NotificationManager.IMPORTANCE_DEFAULT + ) + ) + } + + private fun notificationId(stableId: String): Int = + (stableId.hashCode().toLong().absoluteValue % 900_000L).toInt() + 100_000 +} diff --git a/android/app/src/main/java/com/inputflow/android/PairingSecretStore.kt b/android/app/src/main/java/com/inputflow/android/PairingSecretStore.kt new file mode 100644 index 0000000..34ef1b0 --- /dev/null +++ b/android/app/src/main/java/com/inputflow/android/PairingSecretStore.kt @@ -0,0 +1,114 @@ +package com.inputflow.android + +import android.annotation.SuppressLint +import android.content.Context +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import android.util.Log +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +/** + * Stores the Android relay pairing secret with an app-owned Android Keystore + * key. Existing plaintext preferences are migrated once and then removed. + */ +object PairingSecretStore { + private const val TAG = "PairingSecretStore" + private const val KEY_ALIAS = "inputflow_pairing_secret_v1" + private const val LEGACY_SECRET_KEY = "secret" + private const val CIPHERTEXT_KEY = "pairing_secret_ciphertext_v1" + private const val IV_KEY = "pairing_secret_iv_v1" + private const val TRANSFORMATION = "AES/GCM/NoPadding" + + @Synchronized + fun read(context: Context): String { + val prefs = context.getSharedPreferences( + RelayForegroundService.PREFS, + Context.MODE_PRIVATE, + ) + val ciphertext = prefs.getString(CIPHERTEXT_KEY, null) + val iv = prefs.getString(IV_KEY, null) + if (!ciphertext.isNullOrBlank() && !iv.isNullOrBlank()) { + return try { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init( + Cipher.DECRYPT_MODE, + encryptionKey(), + GCMParameterSpec(128, Base64.decode(iv, Base64.NO_WRAP)), + ) + cipher.updateAAD(context.packageName.toByteArray(Charsets.UTF_8)) + String( + cipher.doFinal(Base64.decode(ciphertext, Base64.NO_WRAP)), + Charsets.UTF_8, + ) + } catch (_: Exception) { + Log.e(TAG, "Stored pairing secret could not be decrypted") + "" + } + } + + val legacy = prefs.getString(LEGACY_SECRET_KEY, "").orEmpty() + if (legacy.isBlank()) return "" + if (!save(context, legacy)) { + Log.e(TAG, "Plaintext pairing secret migration failed") + return "" + } + return legacy + } + + @Synchronized + @SuppressLint("ApplySharedPref") + fun save(context: Context, secret: String): Boolean { + val prefs = context.getSharedPreferences( + RelayForegroundService.PREFS, + Context.MODE_PRIVATE, + ) + if (secret.isBlank()) { + return prefs.edit() + .remove(CIPHERTEXT_KEY) + .remove(IV_KEY) + .remove(LEGACY_SECRET_KEY) + .commit() + } + + return try { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, encryptionKey()) + cipher.updateAAD(context.packageName.toByteArray(Charsets.UTF_8)) + val ciphertext = cipher.doFinal(secret.toByteArray(Charsets.UTF_8)) + prefs.edit() + .putString(CIPHERTEXT_KEY, Base64.encodeToString(ciphertext, Base64.NO_WRAP)) + .putString(IV_KEY, Base64.encodeToString(cipher.iv, Base64.NO_WRAP)) + .remove(LEGACY_SECRET_KEY) + .commit() + } catch (_: Exception) { + Log.e(TAG, "Pairing secret could not be protected") + false + } + } + + private fun encryptionKey(): SecretKey { + val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + (keyStore.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it } + + val generator = KeyGenerator.getInstance( + KeyProperties.KEY_ALGORITHM_AES, + "AndroidKeyStore", + ) + generator.init( + KeyGenParameterSpec.Builder( + KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .build(), + ) + return generator.generateKey() + } +} diff --git a/android/app/src/main/java/com/inputflow/android/RelayForegroundService.kt b/android/app/src/main/java/com/inputflow/android/RelayForegroundService.kt index e1aef36..68e029e 100644 --- a/android/app/src/main/java/com/inputflow/android/RelayForegroundService.kt +++ b/android/app/src/main/java/com/inputflow/android/RelayForegroundService.kt @@ -11,7 +11,6 @@ import android.os.IBinder import android.util.Log import org.json.JSONArray import org.json.JSONObject -import java.io.DataOutputStream import java.net.Socket import java.util.concurrent.atomic.AtomicBoolean @@ -19,8 +18,6 @@ class RelayForegroundService : Service() { private val running = AtomicBoolean(false) private var worker: Thread? = null - @Volatile - private var output: DataOutputStream? = null @Volatile private var remoteControlActive = false private var deliveredMouseFrames = 0 @@ -37,7 +34,9 @@ class RelayForegroundService : Service() { if (instance === this) instance = null running.set(false) worker?.interrupt() - output = null + // Do NOT null activeSession here: under START_STICKY a stale instance can be + // destroyed while a newer instance owns the live stream. The owning + // relayLoop clears it on its own disconnect. deactivateRemoteControl() broadcastStatus(STATE_DISCONNECTED, "Stopped") super.onDestroy() @@ -84,11 +83,7 @@ class RelayForegroundService : Service() { override fun onBind(intent: Intent?): IBinder? = null fun sendTopologyUpdate(layout: JSONArray) { - try { - output?.let { - RelayProtocol.writeFrame(it, JSONObject().put("type", "topology_update").put("layout", layout)) - } - } catch (_: Exception) {} + writeFrame(JSONObject().put("type", "topology_update").put("layout", layout)) } private fun startRelay() { @@ -99,11 +94,75 @@ class RelayForegroundService : Service() { worker = Thread({ relayLoop() }, "inputflow-relay").also { it.start() } } + private fun dispatchFrame(frame: JSONObject, prefs: android.content.SharedPreferences) { + when (frame.optString("type")) { + "control" -> setRemoteControlActive(frame.optBoolean("active", false)) + "mouse" -> if (remoteControlActive) { + // Native (Shizuku/root) injects ALL mouse input — moves, clicks, wheel — + // as real events. The accessibility overlay still draws the visible + // cursor on moves (native pointer injection isn't rendered by Android). + val handledNative = InjectorManager.handleMouse(frame) + val accessibility = InputFlowAccessibilityService.instance + val isMove = frame.optInt("wParam") == WM_MOUSEMOVE + if (isMove) { + accessibility?.handleMouse(frame) + if (!handledNative && accessibility == null) { + if (droppedMouseFrames < 5) { + Log.w(TAG, "mouse frame dropped: no injector active") + } + droppedMouseFrames += 1 + } else { + deliveredMouseFrames += 1 + } + } else if (!handledNative) { + // No native injector → accessibility handles the click. + accessibility?.handleMouse(frame) + } + } + "keyboard" -> { + val cb = keyCaptureCallback + if (cb != null) { + cb(frame.optInt("vkCode"), frame.optInt("flags")) + } else if (remoteControlActive) { + val accessibility = InputFlowAccessibilityService.instance + if (accessibility?.handleMappedKeyboard(frame) == true) { + // handled by a user-defined key mapping + } else if (InjectorManager.handleKeyboard(frame)) { + // injected natively (Shizuku/root) + } else { + val laptopTypingEnabled = prefs.getBoolean(KEY_LAPTOP_TYPING_ENABLED, false) + if (!laptopTypingEnabled || InputFlowImeService.instance?.handleKeyboard(frame) != true) { + accessibility?.handleKeyboard(frame) + } + } + } + } + "gesture" -> if (remoteControlActive) { + // Native continuous scroll first (smooth, no gesture cancellation); + // multi-finger swipes/pinch still go through accessibility. + val kind = frame.optString("kind") + if (kind == "scroll" && InjectorManager.handleScroll(frame)) { + // native scroll + } else { + InputFlowAccessibilityService.instance?.handleGesture(frame) + } + } + "devices_info" -> handleDevicesInfo(frame) + "notification_upsert" -> NotificationSyncBridge.showMirroredNotification(this, frame) + "notification_dismiss" -> NotificationSyncBridge.cancelMirroredNotification(this, frame) + "notification_reply" -> NotificationSyncBridge.replyToNotification( + this, + frame.optString("stable_id"), + frame.optString("text") + ) + } + } + private fun relayLoop() { val prefs = getSharedPreferences(PREFS, Context.MODE_PRIVATE) while (running.get()) { val host = prefs.getString(KEY_HOST, "").orEmpty() - val secret = prefs.getString(KEY_SECRET, "").orEmpty() + val secret = PairingSecretStore.read(this) val port = prefs.getInt(KEY_PORT, 15102) if (host.isBlank() || secret.isBlank()) { startRelayForeground("Missing pairing settings") @@ -117,67 +176,52 @@ class RelayForegroundService : Service() { // the Linux box across IP changes (DHCP/VPN), same self-healing // model as the desktop client. An IP literal resolves to itself. val address = java.net.InetAddress.getByName(host) - Log.i(TAG, "relay connecting: $host -> ${address.hostAddress}:$port") + Log.i(TAG, "relay connection attempt started") java.net.Socket().use { socket -> socket.tcpNoDelay = true socket.connect(java.net.InetSocketAddress(address, port), 8000) - val device = "${Build.MANUFACTURER} ${Build.MODEL}".trim() - val streams = RelayProtocol.authenticate(socket, secret, device) - output = streams.second - deliveredMouseFrames = 0 - droppedMouseFrames = 0 - Log.i(TAG, "relay connected to $host:$port as $device") - deactivateRemoteControl() - startRelayForeground("Connected to $host:$port") - broadcastStatus(STATE_CONNECTED, "$host:$port") - while (running.get()) { - val frame = RelayProtocol.readFrame(streams.first) - when (frame.optString("type")) { - "control" -> setRemoteControlActive(frame.optBoolean("active", false)) - "mouse" -> if (remoteControlActive) { - // Prefer native injection (Shizuku/root); fall back to accessibility. - if (!InjectorManager.handleMouse(frame)) { - val accessibility = InputFlowAccessibilityService.instance - if (accessibility != null) { - deliveredMouseFrames += 1 - accessibility.handleMouse(frame) - } else { - if (droppedMouseFrames < 5) { - Log.w(TAG, "mouse frame dropped: no injector active") - } - droppedMouseFrames += 1 - } + val device = getString(R.string.default_device_name) + val session = RelayProtocol.authenticate(socket, secret, device) + synchronized(activeSessionLock) { activeSession = session } + try { + deliveredMouseFrames = 0 + droppedMouseFrames = 0 + Log.i(TAG, "encrypted relay connection authenticated") + deactivateRemoteControl() + startRelayForeground("Connected securely") + broadcastStatus(STATE_CONNECTED, "Connected securely") + while (running.get()) { + var frame = session.readFrame() + // Collapse a backlog of mouse-move frames to the newest so the + // cursor tracks live with no lag under load. Non-move frames are + // dispatched in order; only intermediate moves are dropped. + while (frame.optString("type") == "mouse" && + frame.optInt("wParam") == WM_MOUSEMOVE && + session.hasBufferedFrame() + ) { + val next = session.readFrame() + if (next.optString("type") == "mouse" && + next.optInt("wParam") == WM_MOUSEMOVE + ) { + frame = next + } else { + dispatchFrame(frame, prefs) + frame = next + break } } - "keyboard" -> { - val cb = keyCaptureCallback - if (cb != null) { - cb(frame.optInt("vkCode"), frame.optInt("flags")) - } else if (remoteControlActive) { - val accessibility = InputFlowAccessibilityService.instance - if (accessibility?.handleMappedKeyboard(frame) == true) { - // handled by a user-defined key mapping - } else if (InjectorManager.handleKeyboard(frame)) { - // injected natively (Shizuku/root) - } else { - val laptopTypingEnabled = prefs.getBoolean(KEY_LAPTOP_TYPING_ENABLED, false) - if (!laptopTypingEnabled || InputFlowImeService.instance?.handleKeyboard(frame) != true) { - accessibility?.handleKeyboard(frame) - } - } - } - } - "gesture" -> if (remoteControlActive) { - InputFlowAccessibilityService.instance?.handleGesture(frame) - } - "devices_info" -> handleDevicesInfo(frame) + dispatchFrame(frame, prefs) } + } finally { + deactivateRemoteControl() + synchronized(activeSessionLock) { + if (activeSession === session) activeSession = null + } + session.destroy() } - deactivateRemoteControl() } - } catch (e: Exception) { - Log.w(TAG, "relay disconnected; retrying", e) - output = null + } catch (_: Exception) { + Log.w(TAG, "relay disconnected; retrying") deactivateRemoteControl() startRelayForeground("Disconnected; retrying") broadcastStatus(STATE_DISCONNECTED, "Retrying…") @@ -189,17 +233,18 @@ class RelayForegroundService : Service() { private fun handleDevicesInfo(frame: JSONObject) { val json = frame.toString() cachedDevicesJson = json - sendBroadcast(Intent(ACTION_DEVICES_BROADCAST).putExtra(EXTRA_DEVICES_JSON, json)) + sendBroadcast( + Intent(ACTION_DEVICES_BROADCAST) + .setPackage(packageName) + .putExtra(EXTRA_DEVICES_JSON, json), + ) } private fun sendRelease() { - try { - output?.let { - RelayProtocol.writeFrame(it, JSONObject().put("type", "release")) - } - } catch (_: Exception) {} + writeFrame(JSONObject().put("type", "release")) } + private fun setRemoteControlActive(active: Boolean) { val changed = remoteControlActive != active remoteControlActive = active @@ -210,6 +255,9 @@ class RelayForegroundService : Service() { if (active && accessibility == null) { Log.w(TAG, "remote control requested but accessibility service is not active") } + // Always show the accessibility overlay cursor while controlled — native + // pointer injection moves the logical pointer but Android doesn't render it, + // so the overlay is what the user actually sees. accessibility?.setRemoteControlActive(active) if (!active) { InputFlowImeService.restorePreviousKeyboard() @@ -257,11 +305,12 @@ class RelayForegroundService : Service() { } companion object { + private const val WM_MOUSEMOVE = 0x0200 const val PREFS = "inputflow" const val KEY_HOST = "host" const val KEY_PORT = "port" - const val KEY_SECRET = "secret" const val KEY_LAPTOP_TYPING_ENABLED = "laptop_typing_enabled" + const val KEY_NOTIFICATION_SYNC_ENABLED = "notification_sync_enabled" const val KEY_STATUS_STATE = "status_state" const val KEY_STATUS_DETAIL = "status_detail" const val KEY_STATUS_HAS_DETAIL = "status_has_detail" @@ -281,6 +330,83 @@ class RelayForegroundService : Service() { private const val TAG = "InputFlowRelay" @Volatile var instance: RelayForegroundService? = null + // The live encrypted relay session, shared statically so the notification + // listener (a separate service) can always reach the connected socket + // regardless of which RelayForegroundService instance it resolves via + // [instance] — START_STICKY can leave more than one instance around. + @Volatile var activeSession: RelayProtocol.Session? = null + val activeSessionLock = Any() + + /** True when the live relay stream is available for outbound writes. */ + fun isConnectedForWrites(): Boolean = + currentState == STATE_CONNECTED && activeSession != null + + // Serializes all outbound relay writes onto a single background thread. + // Notification-listener callbacks fire on the MAIN thread, where a socket + // write throws NetworkOnMainThreadException; the read loop runs on its own + // worker. A single-thread executor both moves writes off the main thread and + // guarantees frames never interleave on the socket. + private val relayWriteExecutor: java.util.concurrent.ExecutorService = + java.util.concurrent.Executors.newSingleThreadExecutor() + + /** + * Queues a frame for the live relay stream. Static so callers (the separate + * notification-listener service, the settings test button) never depend on a + * resolvable RelayForegroundService [instance] — under START_STICKY that can + * be null even while the connection is up. Returns true if a connection was + * available to enqueue onto; the write itself happens asynchronously. + */ + fun writeFrame(frame: JSONObject): Boolean { + val haveStream = synchronized(activeSessionLock) { activeSession != null } + if (!haveStream) { + Log.w(TAG, "no relay output stream for ${frame.optString("type")}") + return false + } + relayWriteExecutor.execute { + try { + synchronized(activeSessionLock) { + val session = activeSession ?: return@execute + session.writeFrame(frame) + } + } catch (e: Exception) { + // Don't tear down activeSession: the owning relayLoop detects a dead + // socket via readFrame and reconnects, re-seating the stream. + Log.w(TAG, "failed to write relay frame ${frame.optString("type")}", e) + } + } + return true + } + + fun sendNotificationUpsert( + stableId: String, + app: String, + packageName: String, + title: String, + body: String, + postedAtMs: Long, + iconPng: String? = null, + canReply: Boolean = false, + ): Boolean = writeFrame( + JSONObject() + .put("type", "notification_upsert") + .put("stable_id", stableId) + .put("origin", "android") + .put("app", app) + .put("package", packageName) + .put("title", title) + .put("body", body) + .put("posted_at_ms", postedAtMs) + .put("can_reply", canReply) + .apply { if (!iconPng.isNullOrEmpty()) put("icon_png", iconPng) } + ) + + fun sendNotificationDismiss(stableId: String, packageName: String): Boolean = writeFrame( + JSONObject() + .put("type", "notification_dismiss") + .put("stable_id", stableId) + .put("origin", "android") + .put("package", packageName) + ) @Volatile var currentState: String = STATE_DISCONNECTED @Volatile var currentDetail: String? = null @Volatile var cachedDevicesJson: String? = null diff --git a/android/app/src/main/java/com/inputflow/android/RelayProtocol.kt b/android/app/src/main/java/com/inputflow/android/RelayProtocol.kt index 4fd59a4..10a3857 100644 --- a/android/app/src/main/java/com/inputflow/android/RelayProtocol.kt +++ b/android/app/src/main/java/com/inputflow/android/RelayProtocol.kt @@ -4,47 +4,182 @@ import org.json.JSONObject import java.io.DataInputStream import java.io.DataOutputStream import java.net.Socket +import java.nio.ByteBuffer +import java.util.Arrays +import javax.crypto.Cipher import javax.crypto.Mac +import javax.crypto.spec.GCMParameterSpec import javax.crypto.spec.SecretKeySpec object RelayProtocol { private const val MAX_FRAME_BYTES = 64 * 1024 + private const val ENCRYPTED_FRAME_OVERHEAD = 4 + 8 + 16 + private const val MAX_WIRE_FRAME_BYTES = MAX_FRAME_BYTES + ENCRYPTED_FRAME_OVERHEAD + private val FRAME_MAGIC = byteArrayOf('I'.code.toByte(), 'F'.code.toByte(), 'P'.code.toByte(), '1'.code.toByte()) + internal val SERVER_TO_CLIENT_IV = byteArrayOf('I'.code.toByte(), 'F'.code.toByte(), 'S'.code.toByte(), 'O'.code.toByte()) + internal val CLIENT_TO_SERVER_IV = byteArrayOf('I'.code.toByte(), 'F'.code.toByte(), 'C'.code.toByte(), 'O'.code.toByte()) + private val NONCE_PATTERN = Regex("[0-9a-f]{32}") - fun readFrame(input: DataInputStream): JSONObject { - val length = input.readInt() - require(length in 1..MAX_FRAME_BYTES) { "Invalid frame length $length" } - val bytes = ByteArray(length) - input.readFully(bytes) - return JSONObject(bytes.toString(Charsets.UTF_8)) - } + class Session internal constructor( + private val input: DataInputStream, + private val output: DataOutputStream, + private val readKey: ByteArray, + private val writeKey: ByteArray, + ) { + private var readSequence = 0L + private var writeSequence = 0L - fun writeFrame(output: DataOutputStream, json: JSONObject) { - val bytes = json.toString().toByteArray(Charsets.UTF_8) - require(bytes.size in 1..MAX_FRAME_BYTES) { "Invalid frame length ${bytes.size}" } - output.writeInt(bytes.size) - output.write(bytes) - output.flush() + fun readFrame(): JSONObject { + require(readSequence != Long.MAX_VALUE) { "Relay receive sequence exhausted" } + val envelope = readWireFrame(input, MAX_WIRE_FRAME_BYTES) + val plaintext = decryptPayload( + envelope, + readKey, + readSequence, + SERVER_TO_CLIENT_IV, + ) + readSequence += 1 + return JSONObject(plaintext.toString(Charsets.UTF_8)) + } + + @Synchronized + fun writeFrame(json: JSONObject) { + require(writeSequence != Long.MAX_VALUE) { "Relay send sequence exhausted" } + val plaintext = json.toString().toByteArray(Charsets.UTF_8) + require(plaintext.size in 1..MAX_FRAME_BYTES) { + "Invalid frame length ${plaintext.size}" + } + val envelope = encryptPayload( + plaintext, + writeKey, + writeSequence, + CLIENT_TO_SERVER_IV, + ) + writeSequence += 1 + writeWireFrame(output, envelope) + } + + fun hasBufferedFrame(): Boolean = input.available() > Int.SIZE_BYTES + + fun destroy() { + Arrays.fill(readKey, 0) + Arrays.fill(writeKey, 0) + } } - fun authenticate(socket: Socket, secret: String, deviceName: String): Pair { + fun authenticate(socket: Socket, secret: String, deviceName: String): Session { val input = DataInputStream(socket.getInputStream().buffered()) val output = DataOutputStream(socket.getOutputStream().buffered()) - val hello = readFrame(input) + val hello = readPlainFrame(input) require(hello.optString("type") == "hello") { "Expected hello frame" } + require(hello.optInt("version") == 2) { "Unsupported relay protocol" } + require(hello.optString("cipher") == "AES-256-GCM") { "Unsupported relay cipher" } val nonce = hello.getString("nonce") + require(NONCE_PATTERN.matches(nonce)) { "Invalid relay nonce" } val auth = JSONObject() .put("type", "auth") .put("device", deviceName) .put("hmac", hmacSha256Hex(secret, nonce)) - writeFrame(output, auth) - val ready = readFrame(input) + writePlainFrame(output, auth) + + val session = Session( + input, + output, + deriveSessionKey(secret, nonce, "server-to-client"), + deriveSessionKey(secret, nonce, "client-to-server"), + ) + val ready = session.readFrame() require(ready.optString("type") == "ready") { "Expected ready frame" } - return input to output + return session } - private fun hmacSha256Hex(secret: String, message: String): String { + private fun readPlainFrame(input: DataInputStream): JSONObject = + JSONObject(readWireFrame(input, MAX_FRAME_BYTES).toString(Charsets.UTF_8)) + + private fun writePlainFrame(output: DataOutputStream, json: JSONObject) { + val bytes = json.toString().toByteArray(Charsets.UTF_8) + require(bytes.size in 1..MAX_FRAME_BYTES) { "Invalid frame length ${bytes.size}" } + writeWireFrame(output, bytes) + } + + private fun readWireFrame(input: DataInputStream, maximumLength: Int): ByteArray { + val length = input.readInt() + require(length in 1..maximumLength) { "Invalid relay frame length" } + return ByteArray(length).also(input::readFully) + } + + private fun writeWireFrame(output: DataOutputStream, bytes: ByteArray) { + output.writeInt(bytes.size) + output.write(bytes) + output.flush() + } + + internal fun deriveSessionKey(secret: String, nonce: String, direction: String): ByteArray = + hmacSha256(secret, "inputflow-relay-v1/$direction/$nonce") + + internal fun encryptPayload( + plaintext: ByteArray, + key: ByteArray, + sequence: Long, + ivPrefix: ByteArray, + ): ByteArray { + val header = ByteBuffer.allocate(12) + .put(FRAME_MAGIC) + .putLong(sequence) + .array() + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init( + Cipher.ENCRYPT_MODE, + SecretKeySpec(key, "AES"), + GCMParameterSpec(128, buildIv(ivPrefix, sequence)), + ) + cipher.updateAAD(header) + return header + cipher.doFinal(plaintext) + } + + internal fun decryptPayload( + envelope: ByteArray, + key: ByteArray, + expectedSequence: Long, + ivPrefix: ByteArray, + ): ByteArray { + require(envelope.size in (ENCRYPTED_FRAME_OVERHEAD + 1)..MAX_WIRE_FRAME_BYTES) { + "Invalid encrypted relay frame" + } + require(envelope.copyOfRange(0, FRAME_MAGIC.size).contentEquals(FRAME_MAGIC)) { + "Invalid encrypted relay frame" + } + val header = envelope.copyOfRange(0, 12) + val sequence = ByteBuffer.wrap(header, FRAME_MAGIC.size, Long.SIZE_BYTES).long + require(sequence == expectedSequence) { "Invalid relay sequence" } + + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init( + Cipher.DECRYPT_MODE, + SecretKeySpec(key, "AES"), + GCMParameterSpec(128, buildIv(ivPrefix, sequence)), + ) + cipher.updateAAD(header) + return cipher.doFinal(envelope, header.size, envelope.size - header.size) + } + + private fun buildIv(prefix: ByteArray, sequence: Long): ByteArray = + ByteBuffer.allocate(12).put(prefix).putLong(sequence).array() + + private fun hmacSha256(secret: String, message: String): ByteArray { val mac = Mac.getInstance("HmacSHA256") mac.init(SecretKeySpec(secret.toByteArray(Charsets.UTF_8), "HmacSHA256")) - return mac.doFinal(message.toByteArray(Charsets.UTF_8)).joinToString("") { "%02x".format(it) } + return mac.doFinal(message.toByteArray(Charsets.UTF_8)) + } + + private fun hmacSha256Hex(secret: String, message: String): String { + val digits = "0123456789abcdef" + return buildString(64) { + for (byte in hmacSha256(secret, message)) { + val value = byte.toInt() and 0xff + append(digits[value ushr 4]) + append(digits[value and 0x0f]) + } + } } } diff --git a/android/app/src/main/java/com/inputflow/android/RootInjectorService.kt b/android/app/src/main/java/com/inputflow/android/RootInjectorService.kt index 3da1833..b16ea26 100644 --- a/android/app/src/main/java/com/inputflow/android/RootInjectorService.kt +++ b/android/app/src/main/java/com/inputflow/android/RootInjectorService.kt @@ -15,9 +15,9 @@ class RootInjectorService : RootService() { SystemInject.exempt() return object : IInjectorService.Stub() { override fun ping(): Boolean = true - override fun inject(event: InputEvent?): Boolean { + override fun inject(event: InputEvent?, mode: Int): Boolean { val e = event ?: return false - return SystemInject.inject(this@RootInjectorService, e) + return SystemInject.inject(this@RootInjectorService, e, mode) } } } diff --git a/android/app/src/main/java/com/inputflow/android/SettingsActivity.kt b/android/app/src/main/java/com/inputflow/android/SettingsActivity.kt index 707b18a..93558b6 100644 --- a/android/app/src/main/java/com/inputflow/android/SettingsActivity.kt +++ b/android/app/src/main/java/com/inputflow/android/SettingsActivity.kt @@ -4,9 +4,11 @@ import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.os.Bundle +import android.provider.Settings import android.view.inputmethod.InputMethodManager import android.widget.RadioGroup import android.widget.TextView +import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import com.google.android.material.appbar.MaterialToolbar import com.google.android.material.button.MaterialButton @@ -29,6 +31,7 @@ class SettingsActivity : AppCompatActivity() { private lateinit var injectBackendGroup: RadioGroup private lateinit var injectStatusText: TextView private lateinit var injectConsentSwitch: MaterialSwitch + private lateinit var notificationSyncSwitch: MaterialSwitch override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -68,6 +71,18 @@ class SettingsActivity : AppCompatActivity() { sensitivitySlider.value = prefs.getFloat(KEY_SENSITIVITY, 1.0f).coerceIn(0.5f, 3.0f) laptopTypingSwitch.isChecked = prefs.getBoolean(RelayForegroundService.KEY_LAPTOP_TYPING_ENABLED, false) + notificationSyncSwitch = findViewById(R.id.notificationSyncSwitch) + notificationSyncSwitch.isChecked = + prefs.getBoolean(RelayForegroundService.KEY_NOTIFICATION_SYNC_ENABLED, false) + notificationSyncSwitch.setOnCheckedChangeListener { _, checked -> + saveSettings() + if (checked) { + startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)) + } + } + findViewById(R.id.btnSendTestNotification).setOnClickListener { + sendTestNotification() + } // Restore keyboard mode when (prefs.getString(KEY_KEYBOARD_MODE, "accessibility")) { @@ -146,25 +161,25 @@ class SettingsActivity : AppCompatActivity() { private fun requestShizuku() { try { if (!Shizuku.pingBinder()) { - injectStatusText.text = "Shizuku not running — install and start the Shizuku app first." + injectStatusText.setText(R.string.shizuku_not_running) return } if (Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED) { updateInjectStatus() } else { Shizuku.requestPermission(SHIZUKU_REQ) - injectStatusText.text = "Shizuku permission requested — approve, then reopen Settings." + injectStatusText.setText(R.string.shizuku_permission_requested) } - } catch (t: Throwable) { - injectStatusText.text = "Shizuku error: ${t.message}" + } catch (_: Throwable) { + injectStatusText.setText(R.string.shizuku_error) } } private fun requestRoot() { - injectStatusText.text = "Requesting root…" + injectStatusText.setText(R.string.root_requesting) Shell.getShell { shell -> runOnUiThread { - injectStatusText.text = if (shell.isRoot) "Root granted." else "Root denied." + injectStatusText.setText(if (shell.isRoot) R.string.root_granted else R.string.root_denied) } } } @@ -175,8 +190,39 @@ class SettingsActivity : AppCompatActivity() { Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED } catch (_: Throwable) { false } val root = try { Shell.isAppGrantedRoot() == true } catch (_: Throwable) { false } - injectStatusText.text = - "Shizuku: ${if (shizuku) "ready" else "unavailable"} Root: ${if (root) "ready" else "unavailable"}" + injectStatusText.text = getString( + R.string.injection_status, + getString(if (shizuku) R.string.injection_ready else R.string.injection_unavailable), + getString(if (root) R.string.injection_ready else R.string.injection_unavailable), + ) + } + + private fun sendTestNotification() { + saveSettings() + if (!notificationSyncSwitch.isChecked) { + Toast.makeText(this, R.string.test_notification_enable_sync, Toast.LENGTH_SHORT).show() + return + } + + if (!RelayForegroundService.isConnectedForWrites()) { + Toast.makeText(this, R.string.test_notification_not_connected, Toast.LENGTH_SHORT).show() + return + } + + val now = System.currentTimeMillis() + val sent = RelayForegroundService.sendNotificationUpsert( + stableId = "inputflow-test:$now", + app = getString(R.string.app_name), + packageName = packageName, + title = getString(R.string.test_notification_title), + body = getString(R.string.test_notification_body), + postedAtMs = now + ) + Toast.makeText( + this, + if (sent) R.string.test_notification_sent else R.string.test_notification_failed, + Toast.LENGTH_SHORT + ).show() } private fun saveSettings() { @@ -207,6 +253,7 @@ class SettingsActivity : AppCompatActivity() { .putString(KEY_CURSOR_COLOR, cursorColor) .putFloat(KEY_SENSITIVITY, sensitivitySlider.value) .putBoolean(RelayForegroundService.KEY_LAPTOP_TYPING_ENABLED, laptopTypingSwitch.isChecked) + .putBoolean(RelayForegroundService.KEY_NOTIFICATION_SYNC_ENABLED, notificationSyncSwitch.isChecked) .putString(KEY_KEYBOARD_MODE, keyboardMode) .putString(KEY_CONNECTION_MODE, connectionMode) .putString(InjectorManager.KEY_INJECT_BACKEND, injectBackend) diff --git a/android/app/src/main/java/com/inputflow/android/ShizukuInjectorService.kt b/android/app/src/main/java/com/inputflow/android/ShizukuInjectorService.kt index 3f78fe0..2436595 100644 --- a/android/app/src/main/java/com/inputflow/android/ShizukuInjectorService.kt +++ b/android/app/src/main/java/com/inputflow/android/ShizukuInjectorService.kt @@ -15,9 +15,9 @@ class ShizukuInjectorService(private val context: Context) : IInjectorService.St override fun ping(): Boolean = true - override fun inject(event: InputEvent?): Boolean { + override fun inject(event: InputEvent?, mode: Int): Boolean { val e = event ?: return false - return SystemInject.inject(context, e) + return SystemInject.inject(context, e, mode) } // Called by Shizuku when the user service is torn down. diff --git a/android/app/src/main/java/com/inputflow/android/SystemInject.kt b/android/app/src/main/java/com/inputflow/android/SystemInject.kt index 0bc8bcf..8906942 100644 --- a/android/app/src/main/java/com/inputflow/android/SystemInject.kt +++ b/android/app/src/main/java/com/inputflow/android/SystemInject.kt @@ -1,6 +1,7 @@ package com.inputflow.android import android.content.Context +import android.os.Build import android.view.InputEvent import org.lsposed.hiddenapibypass.HiddenApiBypass @@ -11,20 +12,32 @@ import org.lsposed.hiddenapibypass.HiddenApiBypass * AccessibilityService gesture path. */ object SystemInject { - // INJECT_INPUT_EVENT_MODE_ASYNC - private const val MODE_ASYNC = 0 + // InputManager.INJECT_INPUT_EVENT_MODE_* + const val MODE_ASYNC = 0 + const val MODE_WAIT_FOR_FINISH = 2 fun exempt() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return try { HiddenApiBypass.addHiddenApiExemptions("") } catch (_: Throwable) { } } - fun inject(context: Context, event: InputEvent): Boolean { + fun inject(context: Context, event: InputEvent, mode: Int = MODE_ASYNC): Boolean { return try { val im = context.getSystemService(Context.INPUT_SERVICE) ?: return false - HiddenApiBypass.invoke(im.javaClass, im, "injectInputEvent", event, MODE_ASYNC) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + HiddenApiBypass.invoke(im.javaClass, im, "injectInputEvent", event, mode) + } else { + val method = im.javaClass.getDeclaredMethod( + "injectInputEvent", + InputEvent::class.java, + Int::class.javaPrimitiveType, + ) + method.isAccessible = true + method.invoke(im, event, mode) + } true } catch (_: Throwable) { false diff --git a/android/app/src/main/java/com/inputflow/android/VkMap.kt b/android/app/src/main/java/com/inputflow/android/VkMap.kt index b5576dc..e378ff0 100644 --- a/android/app/src/main/java/com/inputflow/android/VkMap.kt +++ b/android/app/src/main/java/com/inputflow/android/VkMap.kt @@ -27,6 +27,10 @@ object VkMap { put(0x27, KeyEvent.KEYCODE_DPAD_RIGHT) put(0x28, KeyEvent.KEYCODE_DPAD_DOWN) put(0x2E, KeyEvent.KEYCODE_FORWARD_DEL) + put(0x2D, KeyEvent.KEYCODE_INSERT) + put(0x14, KeyEvent.KEYCODE_CAPS_LOCK) + // Function keys F1-F12 (VK 0x70-0x7B → KEYCODE_F1..F12) + for (i in 0..11) put(0x70 + i, KeyEvent.KEYCODE_F1 + i) put(0x5B, KeyEvent.KEYCODE_META_LEFT) put(0x5C, KeyEvent.KEYCODE_META_RIGHT) // OEM punctuation diff --git a/android/app/src/main/res/layout/activity_key_mapper.xml b/android/app/src/main/res/layout/activity_key_mapper.xml index 07c2305..826148b 100644 --- a/android/app/src/main/res/layout/activity_key_mapper.xml +++ b/android/app/src/main/res/layout/activity_key_mapper.xml @@ -4,8 +4,7 @@ xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" - android:orientation="vertical" - android:background="?attr/colorSurface"> + android:orientation="vertical"> + android:layout_height="match_parent"> + android:layout_height="match_parent"> @@ -246,6 +247,8 @@ @@ -270,6 +273,8 @@ android:layout_width="20dp" android:layout_height="20dp" android:layout_marginEnd="14dp" + android:contentDescription="@null" + android:importantForAccessibility="no" android:src="@drawable/ic_keyboard" app:tint="@color/colorPrimary" /> @@ -283,6 +288,8 @@ @@ -307,6 +314,8 @@ android:layout_width="20dp" android:layout_height="20dp" android:layout_marginEnd="14dp" + android:contentDescription="@null" + android:importantForAccessibility="no" android:src="@drawable/ic_notification" app:tint="@color/colorPrimary" /> @@ -320,6 +329,8 @@ diff --git a/android/app/src/main/res/layout/activity_settings.xml b/android/app/src/main/res/layout/activity_settings.xml index 45dd989..d54cd6e 100644 --- a/android/app/src/main/res/layout/activity_settings.xml +++ b/android/app/src/main/res/layout/activity_settings.xml @@ -3,8 +3,7 @@ xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" - android:layout_height="match_parent" - android:background="@color/colorSurface"> + android:layout_height="match_parent"> @@ -186,7 +185,7 @@ @@ -375,13 +374,67 @@ + + + + + + + + + + + + + + + + @@ -447,14 +500,14 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="8dp" - android:text="Grant Shizuku" /> + android:text="@string/grant_shizuku" /> + android:text="@string/grant_root" /> @@ -469,7 +522,7 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" - android:text="Allow native input injection (system-level control)" + android:text="@string/native_input_consent" android:textAppearance="@style/TextAppearance.Material3.BodyMedium" /> diff --git a/android/app/src/main/res/layout/row_shortcut.xml b/android/app/src/main/res/layout/row_shortcut.xml index 2ffe4e0..ef5c6ee 100644 --- a/android/app/src/main/res/layout/row_shortcut.xml +++ b/android/app/src/main/res/layout/row_shortcut.xml @@ -31,7 +31,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" style="@style/Widget.Material3.Chip.Assist" - android:text="None" /> + android:text="@string/shortcut_none" /> diff --git a/android/app/src/main/res/menu/main_menu.xml b/android/app/src/main/res/menu/main_menu.xml index 631b51e..54c147f 100644 --- a/android/app/src/main/res/menu/main_menu.xml +++ b/android/app/src/main/res/menu/main_menu.xml @@ -5,7 +5,7 @@ diff --git a/android/app/src/main/res/values-night/colors.xml b/android/app/src/main/res/values-night/colors.xml index 08ebed4..e23c65f 100644 --- a/android/app/src/main/res/values-night/colors.xml +++ b/android/app/src/main/res/values-night/colors.xml @@ -1,19 +1,22 @@ - #4C8FD6 - #FFFFFF - #101418 - #202832 - #0B1014 - #6FCF89 - #173322 - #EF9A9A - #3B1515 - #FFCC80 - #3D2600 - #0A1628 - #1A2840 - #4A90D9 - #4CAF50 - #FFFFFF - #34404D + #72D4C6 + #092F35 + #F2BE7A + #2D1D09 + #0D1D22 + #163039 + #F4FBF9 + #07171C + #72D4A4 + #12372B + #FF9EA5 + #431E24 + #F2BE7A + #412D15 + #07171C + #16444D + #72D4C6 + #F2BE7A + #F4FBF9 + #355A61 diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..aad222e --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,16 @@ + + + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml index 1d55475..35edb30 100644 --- a/android/app/src/main/res/values/colors.xml +++ b/android/app/src/main/res/values/colors.xml @@ -1,19 +1,22 @@ - #1664C0 + #0B7F7A #FFFFFF - #F8FAFF - #E8F0FE - #0A1628 - #2E7D32 - #E8F5E9 - #C62828 - #FFEBEE - #E65100 - #FFF3E0 - #0A1628 - #1A2840 - #1664C0 - #2E7D32 - #FFFFFF - #D0DEF5 + #A86316 + #FFFFFF + #F3F8F7 + #E4F0ED + #092F35 + #092F35 + #167C5A + #DFF3EA + #B64750 + #FCE8EA + #A86316 + #FCEFD9 + #092F35 + #16444D + #0B7F7A + #E6A15A + #F4FBF9 + #BCD7D2 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 044aabe..7a7ad44 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,7 +1,10 @@ + Android device + Could not protect the pairing secret on this device. InputFlow InputFlow controlled peer InputFlow keyboard + InputFlow notification sync Connected @@ -44,6 +47,16 @@ Direct InputFlow (Linux host) Windows runs PowerToys Mouse Without Borders. InputFlow on Linux captures that stream and relays input to this device. InputFlow on Linux is the primary source. No Windows or PowerToys needed. The Linux machine relays keyboard and mouse directly to this device. + Notifications + Sync Android notifications + Mirrors allowed Android notifications to connected InputFlow desktops. Sensitive-looking messages are skipped. + Send test notification + InputFlow test notification + If this appears on your desktop, notification sync is working. + Test notification sent to desktop. + Could not send test notification. + Connect to InputFlow before sending a test. + Enable notification sync first. Device layout Edit Device Layout Drag devices to set their position relative to each other. The green device is this tablet. @@ -62,4 +75,26 @@ Apply Layout This Device Connect to InputFlow first to load device list + 0.5× + 3× + Input method + Auto (best available) + Accessibility (no root, basic) + Shizuku (no root, native) + Root (native) + Grant Shizuku + Grant Root + Allow native input injection (system-level control) + Shizuku is not running. Install and start Shizuku first. + Shizuku permission requested. Approve it, then reopen Settings. + Shizuku could not be reached. + Requesting root… + Root granted. + Root denied. + ready + unavailable + Shizuku: %1$s Root: %2$s + %1$d + Settings + None diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml index 23f9639..1d1c765 100644 --- a/android/app/src/main/res/values/styles.xml +++ b/android/app/src/main/res/values/styles.xml @@ -2,13 +2,22 @@ diff --git a/android/app/src/main/res/xml/backup_rules.xml b/android/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..ce0b324 --- /dev/null +++ b/android/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/android/app/src/main/res/xml/data_extraction_rules.xml b/android/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..491c90f --- /dev/null +++ b/android/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/android/app/src/test/java/com/inputflow/android/RelayProtocolTest.kt b/android/app/src/test/java/com/inputflow/android/RelayProtocolTest.kt new file mode 100644 index 0000000..bff519e --- /dev/null +++ b/android/app/src/test/java/com/inputflow/android/RelayProtocolTest.kt @@ -0,0 +1,72 @@ +package com.inputflow.android + +import javax.crypto.AEADBadTagException +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class RelayProtocolTest { + private val secret = "inputflow-test-only-not-a-real-credential" + private val nonce = "00112233445566778899aabbccddeeff" + + @Test + fun encryptedFrameRoundTripsWithoutPlaintextLeakage() { + val key = RelayProtocol.deriveSessionKey(secret, nonce, "server-to-client") + val plaintext = """{"type":"keyboard","vkCode":65,"flags":0}""".toByteArray() + val encrypted = RelayProtocol.encryptPayload( + plaintext, + key, + 0, + RelayProtocol.SERVER_TO_CLIENT_IV, + ) + + assertFalse(encrypted.toString(Charsets.UTF_8).contains("keyboard")) + assertArrayEquals( + plaintext, + RelayProtocol.decryptPayload( + encrypted, + key, + 0, + RelayProtocol.SERVER_TO_CLIENT_IV, + ), + ) + } + + @Test + fun directionalKeysAreSeparated() { + val serverKey = RelayProtocol.deriveSessionKey(secret, nonce, "server-to-client") + val clientKey = RelayProtocol.deriveSessionKey(secret, nonce, "client-to-server") + assertNotEquals(serverKey.toList(), clientKey.toList()) + } + + @Test + fun tamperingAndReplaySequenceAreRejected() { + val key = RelayProtocol.deriveSessionKey(secret, nonce, "server-to-client") + val encrypted = RelayProtocol.encryptPayload( + """{"type":"ready"}""".toByteArray(), + key, + 7, + RelayProtocol.SERVER_TO_CLIENT_IV, + ) + encrypted[encrypted.lastIndex] = (encrypted.last().toInt() xor 1).toByte() + + assertThrows(AEADBadTagException::class.java) { + RelayProtocol.decryptPayload( + encrypted, + key, + 7, + RelayProtocol.SERVER_TO_CLIENT_IV, + ) + } + assertThrows(IllegalArgumentException::class.java) { + RelayProtocol.decryptPayload( + encrypted, + key, + 8, + RelayProtocol.SERVER_TO_CLIENT_IV, + ) + } + } +} diff --git a/assets/hicolor/16x16/status/inputflow-tray-attention.png b/assets/hicolor/16x16/status/inputflow-tray-attention.png index 3d4e252..b32dcbd 100644 Binary files a/assets/hicolor/16x16/status/inputflow-tray-attention.png and b/assets/hicolor/16x16/status/inputflow-tray-attention.png differ diff --git a/assets/hicolor/16x16/status/inputflow-tray-busy.png b/assets/hicolor/16x16/status/inputflow-tray-busy.png index f4ae259..51d7d86 100644 Binary files a/assets/hicolor/16x16/status/inputflow-tray-busy.png and b/assets/hicolor/16x16/status/inputflow-tray-busy.png differ diff --git a/assets/hicolor/16x16/status/inputflow-tray-offline.png b/assets/hicolor/16x16/status/inputflow-tray-offline.png index 765f5bb..2c4aaa2 100644 Binary files a/assets/hicolor/16x16/status/inputflow-tray-offline.png and b/assets/hicolor/16x16/status/inputflow-tray-offline.png differ diff --git a/assets/hicolor/16x16/status/inputflow-tray.png b/assets/hicolor/16x16/status/inputflow-tray.png index 3cb1c87..a1d7fec 100644 Binary files a/assets/hicolor/16x16/status/inputflow-tray.png and b/assets/hicolor/16x16/status/inputflow-tray.png differ diff --git a/assets/hicolor/22x22/status/inputflow-tray-attention.png b/assets/hicolor/22x22/status/inputflow-tray-attention.png index a69ecca..2362dfc 100644 Binary files a/assets/hicolor/22x22/status/inputflow-tray-attention.png and b/assets/hicolor/22x22/status/inputflow-tray-attention.png differ diff --git a/assets/hicolor/22x22/status/inputflow-tray-busy.png b/assets/hicolor/22x22/status/inputflow-tray-busy.png index 243601d..55ca06e 100644 Binary files a/assets/hicolor/22x22/status/inputflow-tray-busy.png and b/assets/hicolor/22x22/status/inputflow-tray-busy.png differ diff --git a/assets/hicolor/22x22/status/inputflow-tray-offline.png b/assets/hicolor/22x22/status/inputflow-tray-offline.png index 2497e97..77adb13 100644 Binary files a/assets/hicolor/22x22/status/inputflow-tray-offline.png and b/assets/hicolor/22x22/status/inputflow-tray-offline.png differ diff --git a/assets/hicolor/22x22/status/inputflow-tray.png b/assets/hicolor/22x22/status/inputflow-tray.png index 670f900..890cda2 100644 Binary files a/assets/hicolor/22x22/status/inputflow-tray.png and b/assets/hicolor/22x22/status/inputflow-tray.png differ diff --git a/assets/hicolor/24x24/status/inputflow-tray-attention.png b/assets/hicolor/24x24/status/inputflow-tray-attention.png index 3d57946..d5a3d25 100644 Binary files a/assets/hicolor/24x24/status/inputflow-tray-attention.png and b/assets/hicolor/24x24/status/inputflow-tray-attention.png differ diff --git a/assets/hicolor/24x24/status/inputflow-tray-busy.png b/assets/hicolor/24x24/status/inputflow-tray-busy.png index 3436096..f93e043 100644 Binary files a/assets/hicolor/24x24/status/inputflow-tray-busy.png and b/assets/hicolor/24x24/status/inputflow-tray-busy.png differ diff --git a/assets/hicolor/24x24/status/inputflow-tray-offline.png b/assets/hicolor/24x24/status/inputflow-tray-offline.png index 6f27009..74677bd 100644 Binary files a/assets/hicolor/24x24/status/inputflow-tray-offline.png and b/assets/hicolor/24x24/status/inputflow-tray-offline.png differ diff --git a/assets/hicolor/24x24/status/inputflow-tray.png b/assets/hicolor/24x24/status/inputflow-tray.png index 7b7f44c..11f8547 100644 Binary files a/assets/hicolor/24x24/status/inputflow-tray.png and b/assets/hicolor/24x24/status/inputflow-tray.png differ diff --git a/assets/hicolor/32x32/status/inputflow-tray-attention.png b/assets/hicolor/32x32/status/inputflow-tray-attention.png index 1c021b9..245bf17 100644 Binary files a/assets/hicolor/32x32/status/inputflow-tray-attention.png and b/assets/hicolor/32x32/status/inputflow-tray-attention.png differ diff --git a/assets/hicolor/32x32/status/inputflow-tray-busy.png b/assets/hicolor/32x32/status/inputflow-tray-busy.png index eea9340..2083819 100644 Binary files a/assets/hicolor/32x32/status/inputflow-tray-busy.png and b/assets/hicolor/32x32/status/inputflow-tray-busy.png differ diff --git a/assets/hicolor/32x32/status/inputflow-tray-offline.png b/assets/hicolor/32x32/status/inputflow-tray-offline.png index 2ec44c8..ca2738b 100644 Binary files a/assets/hicolor/32x32/status/inputflow-tray-offline.png and b/assets/hicolor/32x32/status/inputflow-tray-offline.png differ diff --git a/assets/hicolor/32x32/status/inputflow-tray.png b/assets/hicolor/32x32/status/inputflow-tray.png index f67e68b..482f25f 100644 Binary files a/assets/hicolor/32x32/status/inputflow-tray.png and b/assets/hicolor/32x32/status/inputflow-tray.png differ diff --git a/assets/hicolor/48x48/status/inputflow-tray-attention.png b/assets/hicolor/48x48/status/inputflow-tray-attention.png index 44e1312..76ee897 100644 Binary files a/assets/hicolor/48x48/status/inputflow-tray-attention.png and b/assets/hicolor/48x48/status/inputflow-tray-attention.png differ diff --git a/assets/hicolor/48x48/status/inputflow-tray-busy.png b/assets/hicolor/48x48/status/inputflow-tray-busy.png index bbe638f..00f329c 100644 Binary files a/assets/hicolor/48x48/status/inputflow-tray-busy.png and b/assets/hicolor/48x48/status/inputflow-tray-busy.png differ diff --git a/assets/hicolor/48x48/status/inputflow-tray-offline.png b/assets/hicolor/48x48/status/inputflow-tray-offline.png index 23e247d..7749d52 100644 Binary files a/assets/hicolor/48x48/status/inputflow-tray-offline.png and b/assets/hicolor/48x48/status/inputflow-tray-offline.png differ diff --git a/assets/hicolor/48x48/status/inputflow-tray.png b/assets/hicolor/48x48/status/inputflow-tray.png index 5ad3398..8f23907 100644 Binary files a/assets/hicolor/48x48/status/inputflow-tray.png and b/assets/hicolor/48x48/status/inputflow-tray.png differ diff --git a/assets/hicolor/scalable/apps/inputflow.svg b/assets/hicolor/scalable/apps/inputflow.svg index 23cb403..62d726a 100644 --- a/assets/hicolor/scalable/apps/inputflow.svg +++ b/assets/hicolor/scalable/apps/inputflow.svg @@ -6,7 +6,7 @@ - + diff --git a/assets/hicolor/scalable/status/inputflow-tray-attention.svg b/assets/hicolor/scalable/status/inputflow-tray-attention.svg index c7c7495..94b697d 100644 --- a/assets/hicolor/scalable/status/inputflow-tray-attention.svg +++ b/assets/hicolor/scalable/status/inputflow-tray-attention.svg @@ -1,29 +1,28 @@ - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/hicolor/scalable/status/inputflow-tray-busy.svg b/assets/hicolor/scalable/status/inputflow-tray-busy.svg index e45873d..7fd9f3e 100644 --- a/assets/hicolor/scalable/status/inputflow-tray-busy.svg +++ b/assets/hicolor/scalable/status/inputflow-tray-busy.svg @@ -1,29 +1,28 @@ - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/hicolor/scalable/status/inputflow-tray-offline.svg b/assets/hicolor/scalable/status/inputflow-tray-offline.svg index 96913be..1a3e23a 100644 --- a/assets/hicolor/scalable/status/inputflow-tray-offline.svg +++ b/assets/hicolor/scalable/status/inputflow-tray-offline.svg @@ -1,28 +1,27 @@ - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/hicolor/scalable/status/inputflow-tray.svg b/assets/hicolor/scalable/status/inputflow-tray.svg index c6301cb..8b9d71a 100644 --- a/assets/hicolor/scalable/status/inputflow-tray.svg +++ b/assets/hicolor/scalable/status/inputflow-tray.svg @@ -1,24 +1,23 @@ - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/icons/inputflow-desktop.svg b/assets/icons/inputflow-desktop.svg index 23cb403..62d726a 100644 --- a/assets/icons/inputflow-desktop.svg +++ b/assets/icons/inputflow-desktop.svg @@ -6,7 +6,7 @@ - + diff --git a/assets/icons/inputflow-tray-attention.svg b/assets/icons/inputflow-tray-attention.svg index c7c7495..94b697d 100644 --- a/assets/icons/inputflow-tray-attention.svg +++ b/assets/icons/inputflow-tray-attention.svg @@ -1,29 +1,28 @@ - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/icons/inputflow-tray-busy.svg b/assets/icons/inputflow-tray-busy.svg index e45873d..7fd9f3e 100644 --- a/assets/icons/inputflow-tray-busy.svg +++ b/assets/icons/inputflow-tray-busy.svg @@ -1,29 +1,28 @@ - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/icons/inputflow-tray-offline.svg b/assets/icons/inputflow-tray-offline.svg index 96913be..1a3e23a 100644 --- a/assets/icons/inputflow-tray-offline.svg +++ b/assets/icons/inputflow-tray-offline.svg @@ -1,28 +1,27 @@ - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/icons/inputflow-tray.svg b/assets/icons/inputflow-tray.svg index c6301cb..8b9d71a 100644 --- a/assets/icons/inputflow-tray.svg +++ b/assets/icons/inputflow-tray.svg @@ -1,24 +1,23 @@ - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/android.md b/docs/android.md index 78fe34b..a71a1cc 100644 --- a/docs/android.md +++ b/docs/android.md @@ -13,6 +13,7 @@ android_relay_port=15102 android_relay_secret=replace-with-a-long-random-secret android_peer_name=pixel-8 android_capture_backend=none +notification_sync_enabled=false ``` Then enable topology and add a machine/display whose machine id matches `android_peer_name`. When a cross-machine topology edge targets that machine, InputFlow forwards mouse events to Android. Keyboard events follow while the Android relay is active. @@ -38,6 +39,24 @@ JAVA_HOME=/path/to/jdk-21 ./gradlew :app:assembleDebug The relay foreground service uses the `connectedDevice` type so Android 15's `dataSync` runtime cap does not kill long sessions. +### Notification sync + +Android-to-Linux notification mirroring is available behind two opt-ins: + +1. Set `notification_sync_enabled=true` in the Linux config. +2. In the Android app, open **Settings → Notifications**, enable sync, and grant + Android notification listener access when Settings opens. + +When enabled, the Android app sends `notification_upsert` and +`notification_dismiss` frames over the existing authenticated relay. The Linux +client displays mirrored Android notifications through `notify-send` when it is +available. InputFlow skips its own notifications, ongoing/group-summary +notifications, hidden-content notifications, and messages that look like OTP, +password, banking, or card content. + +This first slice is Android → Linux. Linux and Windows notification capture can +reuse the same relay frames, but require platform-specific listener work. + ### Input injection backends (Settings → Input method) Choose how input is delivered to the phone: @@ -82,9 +101,14 @@ or root backend is selected — that includes secure fields. The shared - The relay **refuses to start** and `android-pair` **refuses to emit a URI** when the secret is weak (`< 16` characters or too little variety). +- Relay protocol v2 derives separate send/receive keys from each random + challenge and protects every post-authentication frame with AES-256-GCM. + Sequence numbers reject replayed, reordered, or tampered input frames. - Generate a strong (256-bit) secret with `android-pair --generate`. - Keep the relay on a trusted LAN/VPN; never expose its port to the internet. - Treat the pairing URI/QR like a password — it contains the secret. +- Android builds from before protocol v2 cannot connect to this release; update + both peers together. Existing strong pairing secrets remain valid. ## Current limitations diff --git a/docs/public-repository-audit.md b/docs/public-repository-audit.md new file mode 100644 index 0000000..df3e72c --- /dev/null +++ b/docs/public-repository-audit.md @@ -0,0 +1,65 @@ +# Public Repository Privacy Audit + +Assessment date: 2026-07-28 + +## Scope + +The assessment covered: + +- every tracked file; +- every untracked file not excluded by `.gitignore`; +- all 58 commits reachable from local Git references; +- source, documentation, tests, workflow, packaging, and Android files; +- image metadata under the public asset and Android resource trees; and +- Git author metadata. + +Build directories and other intentionally ignored local artifacts are not +publishable repository content and were excluded from content-pattern checks. + +## Checks + +- Gitleaks scan of Git history with findings redacted. +- Gitleaks scan of the current working tree with findings redacted. +- Repository-specific checks for credential/key filenames, private-key + material, personal Linux and Windows profile paths, hard-coded runtime user + IDs, non-placeholder email addresses, private/link-local addresses, and MAC + addresses outside test fixtures. +- Search for known local usernames and hostnames without printing matched + values. +- EXIF and image-metadata inspection for GPS, author, owner, camera/device, + serial-number, location, and user-comment fields. +- Validation of README local links and table-of-contents anchors. + +## Result + +- No credentials or private keys were found in the working tree or Git + history. +- No known local username or hostname was found in publishable file content. +- No personal home-directory or Windows profile path was found. +- No private/link-local network address or production MAC address was found. +- No risky personal, location, or device metadata was found in public images. +- Documentation addresses, example users, placeholder email domains, protocol + constants, and test-only fixtures were reviewed as non-personal data. + +Git commit objects retain contributor names and author email metadata as normal +project attribution. Some historical authors used direct addresses rather than +GitHub noreply addresses. Those fields are not application secrets or device +data and were not rewritten because changing them would alter published +history and attribution. Contributors who prefer pseudonymous metadata should +configure a verified GitHub noreply address before committing. + +## Preventive controls + +- `scripts/audit-public-repo.py` blocks common personal/device data and + sensitive artifacts. +- `scripts/release-gate.sh` runs the audit before building a release. +- `.gitignore` excludes local configuration, credentials, diagnostics, + signing material, build output, and analysis caches. +- The pull-request template requires confirmation that secrets, real + addresses, and personal information were not added. +- Diagnostics remain local by default and redact likely personal, device, + address, secret, and input-event data. + +History rewriting is intentionally not performed automatically. If a real +credential is ever discovered, revoke or rotate it first, then coordinate any +history rewrite with maintainers, forks, and existing clones. diff --git a/docs/release-checklist.md b/docs/release-checklist.md new file mode 100644 index 0000000..409e63d --- /dev/null +++ b/docs/release-checklist.md @@ -0,0 +1,23 @@ +# Stable release checklist + +Run `scripts/release-gate.sh` from a clean release candidate. +The gate stages the portable archive, unsigned Android release APK, changelog, +CycloneDX SBOM, provenance statement, and `SHA256SUMS` under +`build-release-gate/release/`. + +- All required CI jobs and the local release gate pass. +- No unresolved P0/P1 bugs or known critical/high vulnerabilities remain. +- Medium security findings have an owner, disposition, and target release. +- Fedora RPM and portable archive pass clean install, upgrade, launch, and + removal smoke tests. +- GNOME/KDE on X11/Wayland and Android API 26/current are smoke-tested. +- Pairing, reconnect, suspend/resume, tray lifecycle, settings persistence, + clipboard, topology, notification sync, and permission revocation pass. +- Diagnostics are previewed and confirmed free of secrets and personal/device + identifiers. +- Release archives, APK, checksums, SBOM/provenance, changelog, compatibility, + privacy, and security documentation are published together. +- The staged Android APK is unsigned by design. Sign it with the protected + release key, verify the signature, and replace the unsigned artifact before + publication. +- The release candidate completes a seven-day reconnect and resource-usage soak. diff --git a/docs/release-readiness-report.md b/docs/release-readiness-report.md new file mode 100644 index 0000000..ee2b8e2 --- /dev/null +++ b/docs/release-readiness-report.md @@ -0,0 +1,63 @@ +# InputFlow 0.2.0 Release Readiness Report + +Assessment date: 2026-07-28 + +## Outcome + +The current source is a release candidate. The automated release gate passes, +release artifacts are reproducible from the working tree, and generated +checksums verify. Publication still requires the external checks listed below. + +## Implemented controls + +- Android relay protocol v2 authenticates sessions with HMAC and protects + post-authentication traffic with directional AES-256-GCM keys, ordered + sequence numbers, replay rejection, frame limits, and authentication + timeouts. +- Android pairing secrets use Android Keystore-backed AES-GCM storage with + migration away from legacy plaintext preferences. +- Configuration and state use atomic, owner-only file writes and reject + symbolic-link targets. +- Diagnostics are local-only by default, redact secrets and device/input + metadata, and require explicit options for journal or network data. +- GUI-launched helpers use fixed argument vectors rather than shell command + strings. +- Android backups exclude protected application data and lock-screen + notifications avoid endpoint details. +- Linux and Android interfaces share an accessible InputFlow visual system, + masked secret fields, descriptive controls, day/night palettes, and + responsive settings surfaces. + +## Verification evidence + +- Nine-stage `scripts/release-gate.sh`: passed. +- Linux CTest suite: 18/18 passed. +- Diagnostics privacy regression: passed. +- Portable archive checksum and extracted-binary smoke test: passed. +- Android release assembly and JVM unit tests: passed. +- Android release lint: 0 errors, 23 non-blocking maintenance warnings. +- Release archive, unsigned APK, SBOM, and provenance checksums: passed. +- Source whitespace check: passed. +- Unsafe shell/process API scan: no matches. +- GTK accessibility inspection: four tabs exposed, status named, secrets + masked, and settings actions reachable. + +## Publication requirements + +- Sign the Android APK with the protected production signing key, run + `apksigner verify`, and archive signing evidence. The generated APK is + intentionally unsigned. +- Run the checklist on representative GNOME and KDE systems across X11 and + Wayland, plus Android API 26, a current Android release, and at least one + OEM device. No Android emulator or physical device was available for this + assessment. +- Perform a multi-device seven-day soak covering reconnects, sleep/wake, + clipboard, topology transitions, notifications, and service restarts. +- Run the gate from a reviewed clean commit in CI. Provenance generated from + the current working tree correctly records that the tree is dirty. +- Review and intentionally accept or fix the remaining Android lint + maintenance warnings before the final store submission. + +No finite test program can prove an application is perfect or vulnerability +free. Release approval should be based on the passing evidence above plus the +external device matrix, soak, signing, and clean-commit checks. diff --git a/docs/security-privacy.md b/docs/security-privacy.md new file mode 100644 index 0000000..b6cdadb --- /dev/null +++ b/docs/security-privacy.md @@ -0,0 +1,45 @@ +# Security and privacy model + +InputFlow is a remote-input utility for trusted LANs and private VPNs. The +PowerToys Mouse Without Borders compatibility protocol is encrypted with +AES-256-CBC but does not provide modern authenticated integrity. Do not expose +its ports directly to the public internet. + +The native Linux-to-Android relay uses a separate protocol. A random challenge +and the pairing secret derive directional session keys; all post-authentication +frames use AES-256-GCM with ordered sequence numbers. This protects keyboard, +pointer, notification, and topology data from passive capture, tampering, and +replay on the local network. + +## Data handled + +| Data | Purpose | Storage and sharing | +| --- | --- | --- | +| Pairing secrets | Authenticate Linux, Windows, and Android peers | Linux Secret Service or owner-only files; Android Keystore encryption; never diagnostic output | +| Hostnames and IP addresses | Connect to and display peers | Local config/state only; redacted from support bundles | +| Keyboard, pointer, and clipboard data | Deliver the requested control stream | Processed in memory; never telemetry or diagnostics | +| Android notification content | Optional notification synchronization | In memory during the active relay; excluded from diagnostics | +| Monitor geometry and topology | Route pointer movement | Local config/state; diagnostics report only non-identifying capability summaries | +| OS/session/package details | Troubleshooting | Local, user-reviewed diagnostic bundle only | + +InputFlow has no telemetry or automatic crash/diagnostic upload. Diagnostic +bundles remain on the device, omit journal and network details by default, and +include a machine-readable consent manifest. Users must review and attach a +bundle manually. + +## Privileged boundaries + +- `/dev/uinput`, Android Accessibility, Shizuku, root injection, notification + access, and IME access are separate user/admin grants. +- Native Android injection requires an additional in-app consent toggle. +- Revoking a platform permission must disable the corresponding feature without + weakening another boundary. +- Config and state writes are atomic, owner-only, and reject symlink targets. + +Security-sensitive logs must describe state transitions without pairing +secrets, clipboard/notification contents, device models, usernames, hostnames, +or network addresses. + +Input-event metadata is logged only when an explicit debug-input option is +enabled. Do not enable that option during normal use, and review journal output +before sharing it. diff --git a/packaging/portable/README.md b/packaging/portable/README.md new file mode 100644 index 0000000..96211c7 --- /dev/null +++ b/packaging/portable/README.md @@ -0,0 +1,17 @@ +# Portable Linux archive + +The `.tar.gz` release is a relocatable user-space bundle. Extract it, then run: + +```bash +./inputflow-*/bin/inputflow-controller +``` + +The wrapper locates the bundled client and controller without modifying the +system. Input injection still requires administrator-approved `/dev/uinput` +access. Files under `share/inputflow/system-integration/` are reference copies; +review and install them through the distribution package or an administrator +rather than copying them automatically. + +The RPM remains the supported system-integrated installation for Fedora. The +portable archive is supported for tested Ubuntu/Fedora desktop sessions but +does not register autostart, udev, sysusers, or desktop menu entries itself. diff --git a/packaging/portable/inputflow-controller b/packaging/portable/inputflow-controller new file mode 100755 index 0000000..a08059d --- /dev/null +++ b/packaging/portable/inputflow-controller @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +PACKAGE_ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" +CONTROLLER="$PACKAGE_ROOT/libexec/inputflow/mwb-desktop-ui.sh" + +if [[ ! -x "$CONTROLLER" ]]; then + echo "InputFlow controller is missing: $CONTROLLER" >&2 + exit 1 +fi + +PATH="$PACKAGE_ROOT/bin:$PATH" exec "$CONTROLLER" "${@:-menu}" diff --git a/scripts/audit-public-repo.py b/scripts/audit-public-repo.py new file mode 100644 index 0000000..3dded02 --- /dev/null +++ b/scripts/audit-public-repo.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""Reject files and content that should not be published in the repository.""" + +from __future__ import annotations + +import argparse +import ipaddress +import pathlib +import re +import subprocess +import sys + + +ROOT = pathlib.Path(__file__).resolve().parents[1] + +SENSITIVE_FILE = re.compile( + r"(^|/)(?:" + r"\.env(?:\.|$)|" + r"id_(?:rsa|ed25519)|" + r"config\.ini$|" + r"inputflow-diagnostics-.*\.(?:tar\.gz|zip)$|" + r".*\.(?:pem|key|p12|pfx|jks|keystore|mobileprovision)$" + r")", + re.IGNORECASE, +) +PRIVATE_KEY = re.compile(r"BEGIN (?:[A-Z ]+ )?PRIVATE KEY") +HOME_PATH = re.compile( + r"(? list[str]: + result = subprocess.run( + ["git", *arguments, "-z"], + cwd=ROOT, + check=True, + stdout=subprocess.PIPE, + ) + return [value for value in result.stdout.decode().split("\0") if value] + + +def publishable_paths(include_untracked: bool) -> list[str]: + paths = set(git_paths(["ls-files"])) + if include_untracked: + paths.update(git_paths(["ls-files", "--others", "--exclude-standard"])) + return sorted(paths) + + +def is_disallowed_ip(value: str) -> bool: + try: + address = ipaddress.ip_address(value) + except ValueError: + return False + if any(address in network for network in DOCUMENTATION_NETWORKS): + return False + return address.is_private or address.is_link_local + + +def audit_file(relative: str) -> list[str]: + problems: list[str] = [] + if SENSITIVE_FILE.search(relative): + problems.append("sensitive filename") + + path = ROOT / relative + if not path.is_file() or path.is_symlink(): + return problems + + data = path.read_bytes() + if b"\0" in data: + return problems + text = data.decode("utf-8", errors="replace") + + if PRIVATE_KEY.search(text): + problems.append("private-key material") + + for match in HOME_PATH.finditer(text): + if match.group(1).lower() not in PLACEHOLDER_USERS: + problems.append("personal home-directory path") + break + + for match in WINDOWS_PROFILE.finditer(text): + if match.group(1).lower() not in PLACEHOLDER_USERS: + problems.append("personal Windows profile path") + break + + if RUNTIME_UID.search(text): + problems.append("hard-coded desktop runtime UID") + + for match in EMAIL.finditer(text): + domain = match.group(1).rsplit("@", 1)[1].lower() + if domain not in PLACEHOLDER_EMAIL_DOMAINS: + problems.append("personal email address") + break + + if any(is_disallowed_ip(value) for value in IPV4.findall(text)): + problems.append("private or link-local IP address") + + if not relative.startswith("tests/") and MAC.search(text): + problems.append("MAC address") + + return problems + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Audit publishable repository files for secrets and personal/device data." + ) + parser.add_argument( + "--include-untracked", + action="store_true", + help="also inspect untracked files not excluded by .gitignore", + ) + args = parser.parse_args() + + findings: list[tuple[str, str]] = [] + paths = publishable_paths(args.include_untracked) + for relative in paths: + for problem in audit_file(relative): + findings.append((relative, problem)) + + if findings: + for relative, problem in findings: + print(f"{relative}: {problem}", file=sys.stderr) + print( + f"public repository audit failed with {len(findings)} finding(s)", + file=sys.stderr, + ) + return 1 + + scope = "tracked and untracked" if args.include_untracked else "tracked" + print(f"public repository audit passed: {len(paths)} {scope} files") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/generate-release-metadata.py b/scripts/generate-release-metadata.py new file mode 100644 index 0000000..530781b --- /dev/null +++ b/scripts/generate-release-metadata.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +"""Create a CycloneDX SBOM, provenance statement, and publishable artifact set.""" + +from __future__ import annotations + +import argparse +import datetime as dt +import hashlib +import json +import pathlib +import platform +import re +import shutil +import subprocess + + +def sha256(path: pathlib.Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def command_output(arguments: list[str], cwd: pathlib.Path) -> str: + result = subprocess.run( + arguments, + cwd=cwd, + check=False, + capture_output=True, + text=True, + timeout=20, + ) + return result.stdout.strip() if result.returncode == 0 else "" + + +def project_version(repo: pathlib.Path) -> str: + cmake = (repo / "CMakeLists.txt").read_text(encoding="utf-8") + match = re.search(r"project\([^)]*\bVERSION\s+([0-9.]+)", cmake, re.DOTALL) + if not match: + raise SystemExit("Unable to determine project version") + return match.group(1) + + +def gradle_components(repo: pathlib.Path) -> list[dict[str, object]]: + gradle = (repo / "android/app/build.gradle").read_text(encoding="utf-8") + dependencies: list[dict[str, object]] = [] + pattern = re.compile( + r'^\s*(?:implementation|testImplementation)\s+["\']' + r"([^:'\"]+):([^:'\"]+):([^'\"]+)[\"']", + re.MULTILINE, + ) + for group, name, version in pattern.findall(gradle): + dependencies.append( + { + "type": "library", + "group": group, + "name": name, + "version": version, + "purl": f"pkg:maven/{group}/{name}@{version}", + } + ) + return dependencies + + +def native_components(binary: pathlib.Path, repo: pathlib.Path) -> list[dict[str, object]]: + output = command_output(["ldd", str(binary)], repo) + components: list[dict[str, object]] = [] + seen: set[str] = set() + for line in output.splitlines(): + match = re.match(r"\s*([^\s]+)\s+=>\s+(/[^\s]+)", line) + if not match: + continue + name, resolved = match.groups() + library = pathlib.Path(resolved) + if name in seen or not library.is_file(): + continue + seen.add(name) + components.append( + { + "type": "library", + "name": name, + "hashes": [{"alg": "SHA-256", "content": sha256(library)}], + "properties": [ + {"name": "inputflow:discovery", "value": "runtime-linker"} + ], + } + ) + return components + + +def copy_artifact(source: pathlib.Path, destination: pathlib.Path) -> pathlib.Path: + target = destination / source.name + shutil.copy2(source, target) + return target + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--build-dir", default="build") + parser.add_argument("--android-apk") + args = parser.parse_args() + + repo = pathlib.Path(__file__).resolve().parent.parent + build_dir = (repo / args.build_dir).resolve() + release_dir = build_dir / "release" + release_dir.mkdir(parents=True, exist_ok=True) + version = project_version(repo) + + archives = sorted(build_dir.glob("inputflow-*.tar.gz")) + if len(archives) != 1: + raise SystemExit("Expected exactly one portable archive") + apk_source = ( + pathlib.Path(args.android_apk).resolve() + if args.android_apk + else repo / "android/app/build/outputs/apk/release/app-release-unsigned.apk" + ) + if not apk_source.is_file(): + raise SystemExit(f"Android release APK is missing: {apk_source}") + + copied = [ + copy_artifact(archives[0], release_dir), + copy_artifact(apk_source, release_dir), + copy_artifact(repo / "CHANGELOG.md", release_dir), + ] + copied[1].rename(release_dir / f"inputflow-android-{version}-unsigned.apk") + copied[1] = release_dir / f"inputflow-android-{version}-unsigned.apk" + + timestamp = dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat() + native_binary = build_dir / "mwb_client" + components = gradle_components(repo) + if native_binary.is_file(): + components.extend(native_components(native_binary, repo)) + + sbom_path = release_dir / "inputflow-sbom.cdx.json" + sbom = { + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "version": 1, + "metadata": { + "timestamp": timestamp, + "component": { + "type": "application", + "name": "InputFlow", + "version": version, + }, + }, + "components": components, + } + sbom_path.write_text(json.dumps(sbom, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + commit = command_output(["git", "rev-parse", "HEAD"], repo) + dirty = bool(command_output(["git", "status", "--porcelain"], repo)) + subject_paths = copied[:2] + [sbom_path] + provenance_path = release_dir / "inputflow-provenance.json" + provenance = { + "_type": "https://in-toto.io/Statement/v1", + "subject": [ + { + "name": path.name, + "digest": {"sha256": sha256(path)}, + } + for path in subject_paths + ], + "predicateType": "https://slsa.dev/provenance/v1", + "predicate": { + "buildDefinition": { + "buildType": "inputflow-local-release-gate/v1", + "externalParameters": { + "version": version, + "linuxBuildType": "Release", + "androidArtifactSigned": False, + }, + "internalParameters": {"workingTreeDirty": dirty}, + "resolvedDependencies": ( + [{"uri": "git+local", "digest": {"gitCommit": commit}}] + if commit + else [] + ), + }, + "runDetails": { + "builder": {"id": "inputflow-local-release-gate"}, + "metadata": { + "invocationId": f"{platform.system().lower()}-{commit[:12] or 'unknown'}", + "startedOn": timestamp, + "finishedOn": timestamp, + }, + }, + }, + } + provenance_path.write_text( + json.dumps(provenance, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + checksum_paths = copied[:2] + [sbom_path, provenance_path] + checksum_text = "".join(f"{sha256(path)} {path.name}\n" for path in checksum_paths) + (release_dir / "SHA256SUMS").write_text(checksum_text, encoding="utf-8") + print(f"release metadata generated in {release_dir}") + print("Android APK is unsigned; sign and verify it before publication.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/inputflow-diagnostics-bundle.sh b/scripts/inputflow-diagnostics-bundle.sh index 0206407..8249ce1 100755 --- a/scripts/inputflow-diagnostics-bundle.sh +++ b/scripts/inputflow-diagnostics-bundle.sh @@ -6,7 +6,7 @@ SCRIPT_NAME="$(basename "$0")" usage() { cat <|SECURITY_KEY|security key)[^[:space:]]*/\1[REDACTED]/Ig' \ + -e 's#/(home|Users)/[^/[:space:]]+#/\1/[REDACTED_USER]#g' \ + -e 's/^([[:space:]]*[^#;[:space:]]*(host|peer|machine|device|address|username|user_name)[^=]*[[:space:]]*=[[:space:]]*).*/\1[REDACTED]/I' \ + -e 's/^([[:space:]]*(User|Host|Hostname|Repository|config_path|state_path|Static hostname)[=:][[:space:]]*).*/\1[REDACTED]/I' \ + -e 's/^((USER|LOGNAME|HOSTNAME)=).*/\1[REDACTED]/' \ + -e 's/([[:xdigit:]]{2}:){5}[[:xdigit:]]{2}/[REDACTED_MAC]/g' \ + -e 's/([0-9]{1,3}\.){3}[0-9]{1,3}/[REDACTED_IP]/g' \ + -e 's/(^|[[:space:]])(vk|key|flags|wParam|mouseData|x|y)=(-?0x[[:xdigit:]]+|-?[0-9]+)/\1\2=[REDACTED_INPUT]/Ig' \ -e 's/[A-Fa-f0-9]{32,}/[REDACTED_HEX]/g' } @@ -118,6 +145,16 @@ safe_run() { } 2>&1 | redact_stream >"$output_file" } +safe_run_bounded() { + local output_file="$1" + shift + if have timeout; then + safe_run "$output_file" timeout --signal=TERM --kill-after=2s 10s "$@" + else + safe_run "$output_file" "$@" + fi +} + safe_shell() { local output_file="$1" local description="$2" @@ -143,7 +180,7 @@ redacted_copy_or_note() { else printf 'present=no\n' fi - } >"$output_file" + } | redact_stream >"$output_file" } json_escape() { @@ -232,7 +269,7 @@ write_json_summary() { printf ' "input": {"uinput_present": '; json_bool "$uinput_present"; printf ', "uinput_writable": '; json_bool "$uinput_writable"; printf ', "uinput_module_loaded": '; json_bool "$uinput_module"; printf '},\n' printf ' "tools": {"wl_copy": '; have wl-copy && printf true || printf false; printf ', "wl_paste": '; have wl-paste && printf true || printf false; printf ', "xclip": '; have xclip && printf true || printf false; printf ', "xsel": '; have xsel && printf true || printf false; printf ', "secret_tool": '; have secret-tool && printf true || printf false; printf ', "systemctl": '; have systemctl && printf true || printf false; printf ', "journalctl": '; have journalctl && printf true || printf false; printf ', "ip": '; have ip && printf true || printf false; printf ', "ss": '; have ss && printf true || printf false; printf '}\n' printf '}\n' - } >"$output_file" + } | redact_stream >"$output_file" } write_config_summary() { @@ -300,24 +337,47 @@ modified=%y' "$STATE_PATH" 2>/dev/null || true printf 'peer_lines=%s\n' "${peer_lines:-0}" printf '\n[redacted state]\n' redact_stream <"$STATE_PATH" - } >"$output_file" + } | redact_stream >"$output_file" } write_manifest() { - cat >"$BUNDLE_DIR/README.txt" </dev/null || date) -Host: $(hostname 2>/dev/null || printf 'unknown') -User: $(id -un 2>/dev/null || printf 'unknown') -Repository: $REPO_ROOT +Host: [REDACTED] +User: [REDACTED] +Repository: [REDACTED] + +This bundle remains on this device until you choose to share it. Security keys, +tokens, passwords, secret IDs, home-directory usernames, network addresses, and +hardware addresses are redacted by best effort. Review every file before sharing. + +Recent service journal included: $INCLUDE_JOURNAL +Network details included: $INCLUDE_NETWORK +EOF + } | redact_stream >"$BUNDLE_DIR/README.txt" +} -This bundle is redacted by best effort before files are written. Security keys, -tokens, passwords, key file values, secret IDs, and long hex strings are replaced. -Review before sharing outside trusted support channels. +write_consent_manifest() { + cat >"$BUNDLE_DIR/consent.json" </dev/null || date -uname -a +uname -srmo printf "\n[/etc/os-release]\n" test -r /etc/os-release && cat /etc/os-release printf "\n[session]\n" -id +printf "uid=%s\ngid=%s\ngroups=%s\n" "$(id -u)" "$(id -g)" "$(id -G)" printf "SHELL=%s\nUSER=%s\nLOGNAME=%s\nXDG_SESSION_TYPE=%s\nXDG_CURRENT_DESKTOP=%s\nDESKTOP_SESSION=%s\nDISPLAY=%s\nWAYLAND_DISPLAY=%s\n" "${SHELL:-}" "${USER:-}" "${LOGNAME:-}" "${XDG_SESSION_TYPE:-}" "${XDG_CURRENT_DESKTOP:-}" "${DESKTOP_SESSION:-}" "${DISPLAY:-}" "${WAYLAND_DISPLAY:-}" if command -v loginctl >/dev/null 2>&1 && test -n "${XDG_SESSION_ID:-}"; then loginctl show-session "$XDG_SESSION_ID" --no-pager 2>/dev/null @@ -342,21 +402,23 @@ if ! command -v systemctl >/dev/null 2>&1; then echo "systemctl not found" exit 0 fi -systemctl --user --no-pager status mwb-client.service inputflow.service 2>&1 +systemctl --user --no-pager --lines=0 status mwb-client.service inputflow.service 2>&1 printf "\n[known matching units]\n" systemctl --user --no-pager list-units "mwb*" "inputflow*" 2>&1 printf "\n[unit files]\n" systemctl --user --no-pager list-unit-files "mwb*" "inputflow*" 2>&1 ' -safe_shell "$BUNDLE_DIR/journal-user-recent.txt" "collect recent user journal logs" ' +if [[ "$INCLUDE_JOURNAL" -eq 1 ]]; then + safe_shell "$BUNDLE_DIR/journal-user-recent.txt" "collect recent user journal logs" ' set +e if ! command -v journalctl >/dev/null 2>&1; then echo "journalctl not found" exit 0 fi -journalctl --user --no-pager --since "2 hours ago" -u mwb-client.service -u inputflow.service -n 300 2>&1 +journalctl --user --no-pager --output=cat --since "2 hours ago" -u mwb-client.service -u inputflow.service -n 300 2>&1 ' +fi safe_shell "$BUNDLE_DIR/uinput-state.txt" "collect uinput state" ' set +e @@ -364,7 +426,7 @@ printf "[device]\n" ls -l /dev/uinput 2>&1 stat /dev/uinput 2>&1 printf "\n[user groups]\n" -id +printf "uid=%s\ngid=%s\ngroups=%s\n" "$(id -u)" "$(id -g)" "$(id -G)" printf "\n[modules]\n" grep "^uinput " /proc/modules 2>/dev/null || true lsmod 2>/dev/null | grep -E "^uinput|uinput" || true @@ -372,7 +434,8 @@ printf "\n[udev rules]\n" find /etc/udev/rules.d /usr/lib/udev/rules.d /lib/udev/rules.d -maxdepth 1 \( -iname "*uinput*" -o -iname "*inputflow*" -o -iname "*mwb*" \) -print -exec sh -c '"'"'for f; do echo "--- $f"; sed -n "1,120p" "$f"; done'"'"' sh {} + 2>/dev/null ' -safe_shell "$BUNDLE_DIR/network-hints.txt" "collect network hints" ' +if [[ "$INCLUDE_NETWORK" -eq 1 ]]; then + safe_shell "$BUNDLE_DIR/network-hints.txt" "collect network hints" ' set +e hostnamectl 2>/dev/null || hostname 2>/dev/null printf "\n[addresses]\n" @@ -387,9 +450,8 @@ if command -v ss >/dev/null 2>&1; then else echo "ss not found" fi -printf "\n[host resolution]\n" -getent hosts "$(hostname)" 2>/dev/null || true ' +fi safe_shell "$BUNDLE_DIR/package-build-info.txt" "collect package and build info" " set +e @@ -404,7 +466,6 @@ for f in '$REPO_ROOT/build/mwb_client' '$REPO_ROOT/build/mwb_tray'; do if test -e \"\$f\"; then ls -l \"\$f\" file \"\$f\" 2>/dev/null || true - \"\$f\" --version 2>&1 || true else echo \"missing: \$f\" fi @@ -423,15 +484,16 @@ fi " if [[ -x "$REPO_ROOT/build/mwb_client" ]]; then - safe_run "$BUNDLE_DIR/mwb-client-doctor.txt" "$REPO_ROOT/build/mwb_client" doctor --config "$CONFIG_PATH" --state "$STATE_PATH" + safe_run_bounded "$BUNDLE_DIR/mwb-client-doctor.txt" "$REPO_ROOT/build/mwb_client" doctor --config "$CONFIG_PATH" --state "$STATE_PATH" elif command -v mwb_client >/dev/null 2>&1; then - safe_run "$BUNDLE_DIR/mwb-client-doctor.txt" mwb_client doctor --config "$CONFIG_PATH" --state "$STATE_PATH" + safe_run_bounded "$BUNDLE_DIR/mwb-client-doctor.txt" mwb_client doctor --config "$CONFIG_PATH" --state "$STATE_PATH" else printf 'mwb_client executable not found at %s or in PATH\n' "$REPO_ROOT/build/mwb_client" >"$BUNDLE_DIR/mwb-client-doctor.txt" fi FINAL_PATH="$BUNDLE_DIR" -if have tar; then +find "$BUNDLE_DIR" -type f -exec chmod 600 {} + 2>/dev/null || true +if [[ "$PREVIEW" -eq 0 ]] && have tar; then ARCHIVE_PATH="$OUTPUT_DIR/$BUNDLE_NAME.tar.gz" if tar -czf "$ARCHIVE_PATH" -C "$OUTPUT_DIR" "$BUNDLE_NAME" >/dev/null 2>&1; then rm -rf "$BUNDLE_DIR" @@ -439,7 +501,7 @@ if have tar; then else log "warning: tar failed; leaving bundle directory unarchived" fi -else +elif [[ "$PREVIEW" -eq 0 ]]; then log "warning: tar not found; leaving bundle directory unarchived" fi diff --git a/scripts/release-gate.sh b/scripts/release-gate.sh new file mode 100755 index 0000000..9f1d3d3 --- /dev/null +++ b/scripts/release-gate.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +BUILD_DIR="${INPUTFLOW_RELEASE_BUILD_DIR:-$REPO_ROOT/build-release-gate}" + +cd "$REPO_ROOT" + +echo "[1/9] Repository hygiene" +git diff --check +python3 scripts/audit-public-repo.py --include-untracked +if git ls-files | grep -E '(^|/)(\.env($|\.)|.*\.(pem|p12|pfx|key)|inputflow-diagnostics-.*\.tar\.gz)$'; then + echo "release gate: tracked secret or diagnostics artifact detected" >&2 + exit 1 +fi +if git grep -nE -- 'BEGIN ([A-Z ]+ )?PRIVATE KEY|AKIA[0-9A-Z]{16}' -- \ + ':!tests/test_diagnostics_privacy.sh'; then + echo "release gate: possible embedded credential detected" >&2 + exit 1 +fi + +echo "[2/9] Script and privacy checks" +bash -n mwb-desktop-ui.sh scripts/*.sh tests/*.sh +PYTHONPYCACHEPREFIX="$BUILD_DIR/pycache" python3 -m py_compile scripts/generate-release-metadata.py +tests/test_diagnostics_privacy.sh scripts/inputflow-diagnostics-bundle.sh + +echo "[3/9] Packaging metadata" +scripts/validate-rpm-packaging.sh + +echo "[4/9] Configure release build" +cmake -S . -B "$BUILD_DIR" -DCMAKE_BUILD_TYPE=Release + +echo "[5/9] Build Linux targets" +cmake --build "$BUILD_DIR" --parallel + +echo "[6/9] Linux regression suite" +ctest --test-dir "$BUILD_DIR" --output-on-failure + +echo "[7/9] Portable archive" +cmake --build "$BUILD_DIR" --target package +PORTABLE_ARCHIVE="$(find "$BUILD_DIR" -maxdepth 1 -name 'inputflow-*.tar.gz' -print -quit)" +test -n "$PORTABLE_ARCHIVE" +test -n "$(find "$BUILD_DIR" -maxdepth 1 -name 'inputflow-*.tar.gz.sha256' -print -quit)" +tests/test_portable_archive.sh "$PORTABLE_ARCHIVE" + +echo "[8/9] Android release build, tests, and lint" +if [[ "${INPUTFLOW_SKIP_ANDROID:-0}" == "1" ]]; then + echo "Android gate explicitly skipped" +elif [[ -n "${JAVA_HOME:-}" && ! -x "${JAVA_HOME}/bin/java" ]]; then + env -u JAVA_HOME ./android/gradlew -p android \ + :app:assembleRelease :app:testDebugUnitTest :app:lintRelease \ + --no-daemon --stacktrace +else + ./android/gradlew -p android \ + :app:assembleRelease :app:testDebugUnitTest :app:lintRelease \ + --no-daemon --stacktrace +fi + +echo "[9/9] SBOM, provenance, and release checksums" +if [[ "${INPUTFLOW_SKIP_ANDROID:-0}" == "1" ]]; then + echo "Release metadata skipped because the Android artifact was skipped" +else + python3 scripts/generate-release-metadata.py \ + --build-dir "$BUILD_DIR" \ + --android-apk android/app/build/outputs/apk/release/app-release-unsigned.apk +fi + +echo "InputFlow release gate passed" diff --git a/scripts/validate-rpm-packaging.sh b/scripts/validate-rpm-packaging.sh index ad83b17..cacf2d3 100755 --- a/scripts/validate-rpm-packaging.sh +++ b/scripts/validate-rpm-packaging.sh @@ -83,7 +83,7 @@ grep -Eq '^Terminal=false$' packaging/usr/share/applications/inputflow.desktop | grep -Eq '^DIAGNOSTICS_BUNDLE_SCRIPT="\$SCRIPT_DIR/scripts/inputflow-diagnostics-bundle\.sh"$' mwb-desktop-ui.sh || fail "desktop UI must resolve diagnostics bundle next to the packaged controller" bash -n scripts/inputflow-diagnostics-bundle.sh || fail "diagnostics bundle script must pass bash syntax checks" grep -Eq 'doctor --config' scripts/inputflow-diagnostics-bundle.sh || fail "diagnostics bundle must collect client doctor output" -grep -Eq 'systemctl --user --no-pager status mwb-client\.service' scripts/inputflow-diagnostics-bundle.sh || fail "diagnostics bundle must collect user service status" +grep -Eq 'systemctl --user --no-pager( --[^ ]+)* status mwb-client\.service' scripts/inputflow-diagnostics-bundle.sh || fail "diagnostics bundle must collect user service status" grep -Eq 'redacted' scripts/inputflow-diagnostics-bundle.sh || fail "diagnostics bundle must redact config/state content" grep -Eq '%\{_bindir\}/mwb_client' "$spec_file" || fail "spec must package mwb_client" grep -Eq '%\{_bindir\}/mwb_tray' "$spec_file" || fail "spec must package mwb_tray" diff --git a/src/AndroidRelay.cpp b/src/AndroidRelay.cpp index 7154784..06c73fa 100644 --- a/src/AndroidRelay.cpp +++ b/src/AndroidRelay.cpp @@ -5,24 +5,48 @@ #include #include #include +#include #include +#include +#include #include #include +#include #include #include +#include #include #include +#include #include +#include +#include +#include #include #include +#include +#include +#include #include #include +#if defined(MWB_HAVE_LIBEI_INPUT_CAPTURE) +#include +#include +#endif + namespace mwb { namespace { constexpr std::size_t kMaxFrameBytes = 64 * 1024; +constexpr std::size_t kEncryptedFrameOverhead = 4 + 8 + 16; +constexpr std::size_t kMaxWireFrameBytes = kMaxFrameBytes + kEncryptedFrameOverhead; +constexpr std::size_t kMaxRelayClients = 8; +constexpr std::string_view kEncryptedFrameMagic = "IFP1"; +constexpr std::array kServerToClientIvPrefix{'I', 'F', 'S', 'O'}; +constexpr std::array kClientToServerIvPrefix{'I', 'F', 'C', 'O'}; +using RelayKey = std::array; void CloseFd(int& fd) { if (fd >= 0) { @@ -76,7 +100,7 @@ std::optional ReadFrame(int fd) { return std::nullopt; } const uint32_t length = ntohl(networkLength); - if (length == 0 || length > kMaxFrameBytes) { + if (length == 0 || length > kMaxWireFrameBytes) { return std::nullopt; } @@ -104,18 +128,200 @@ std::string RandomNonceHex() { return HexEncode(bytes.data(), bytes.size()); } -std::string HmacSha256Hex(const std::string& secret, const std::string& message) { - std::array digest{}; +RelayKey HmacSha256(const std::string& secret, const std::string& message) { + RelayKey digest{}; unsigned int digestLength = 0; - HMAC( + if (HMAC( EVP_sha256(), secret.data(), static_cast(secret.size()), reinterpret_cast(message.data()), message.size(), digest.data(), - &digestLength); - return HexEncode(digest.data(), digestLength); + &digestLength) == nullptr || + digestLength != digest.size()) { + digest.fill(0); + } + return digest; +} + +std::string HmacSha256Hex(const std::string& secret, const std::string& message) { + const auto digest = HmacSha256(secret, message); + return HexEncode(digest.data(), digest.size()); +} + +RelayKey DeriveRelayKey( + const std::string& secret, + const std::string& nonce, + std::string_view direction) { + return HmacSha256( + secret, + "inputflow-relay-v1/" + std::string(direction) + "/" + nonce); +} + +void AppendSequence(std::string& output, uint64_t sequence) { + for (int shift = 56; shift >= 0; shift -= 8) { + output.push_back(static_cast((sequence >> shift) & 0xffu)); + } +} + +uint64_t ReadSequence(std::string_view input) { + uint64_t sequence = 0; + for (const unsigned char byte : input) { + sequence = (sequence << 8u) | byte; + } + return sequence; +} + +std::array BuildRelayIv( + const std::array& prefix, + uint64_t sequence) { + std::array iv{}; + std::copy(prefix.begin(), prefix.end(), iv.begin()); + for (int index = 0; index < 8; ++index) { + iv[4 + index] = static_cast( + (sequence >> (56 - index * 8)) & 0xffu); + } + return iv; +} + +std::optional EncryptRelayFrame( + const std::string& payload, + const RelayKey& key, + uint64_t sequence, + const std::array& ivPrefix) { + if (payload.empty() || payload.size() > kMaxFrameBytes) { + return std::nullopt; + } + + std::string header{kEncryptedFrameMagic}; + AppendSequence(header, sequence); + const auto iv = BuildRelayIv(ivPrefix, sequence); + EVP_CIPHER_CTX* context = EVP_CIPHER_CTX_new(); + if (context == nullptr) { + return std::nullopt; + } + + std::string ciphertext(payload.size() + EVP_MAX_BLOCK_LENGTH, '\0'); + int outputLength = 0; + int totalLength = 0; + bool ok = + EVP_EncryptInit_ex(context, EVP_aes_256_gcm(), nullptr, nullptr, nullptr) == 1 && + EVP_CIPHER_CTX_ctrl(context, EVP_CTRL_GCM_SET_IVLEN, iv.size(), nullptr) == 1 && + EVP_EncryptInit_ex(context, nullptr, nullptr, key.data(), iv.data()) == 1 && + EVP_EncryptUpdate( + context, + nullptr, + &outputLength, + reinterpret_cast(header.data()), + static_cast(header.size())) == 1 && + EVP_EncryptUpdate( + context, + reinterpret_cast(ciphertext.data()), + &outputLength, + reinterpret_cast(payload.data()), + static_cast(payload.size())) == 1; + if (ok) { + totalLength = outputLength; + ok = EVP_EncryptFinal_ex( + context, + reinterpret_cast(ciphertext.data()) + totalLength, + &outputLength) == 1; + totalLength += outputLength; + } + + std::array tag{}; + if (ok) { + ok = EVP_CIPHER_CTX_ctrl( + context, + EVP_CTRL_GCM_GET_TAG, + tag.size(), + tag.data()) == 1; + } + EVP_CIPHER_CTX_free(context); + if (!ok) { + return std::nullopt; + } + + ciphertext.resize(static_cast(totalLength)); + std::string envelope = header + ciphertext; + envelope.append( + reinterpret_cast(tag.data()), + tag.size()); + return envelope; +} + +std::optional DecryptRelayFrame( + const std::string& envelope, + const RelayKey& key, + uint64_t expectedSequence, + const std::array& ivPrefix) { + if (envelope.size() <= kEncryptedFrameOverhead || + envelope.size() > kMaxWireFrameBytes || + envelope.compare(0, kEncryptedFrameMagic.size(), kEncryptedFrameMagic) != 0) { + return std::nullopt; + } + + constexpr std::size_t headerSize = 12; + const uint64_t sequence = ReadSequence( + std::string_view(envelope).substr(kEncryptedFrameMagic.size(), 8)); + if (sequence != expectedSequence) { + return std::nullopt; + } + + const std::size_t ciphertextSize = envelope.size() - headerSize - 16; + if (ciphertextSize == 0 || ciphertextSize > kMaxFrameBytes) { + return std::nullopt; + } + const auto iv = BuildRelayIv(ivPrefix, sequence); + const auto* ciphertext = + reinterpret_cast(envelope.data() + headerSize); + const auto* tag = + reinterpret_cast(envelope.data() + headerSize + ciphertextSize); + + EVP_CIPHER_CTX* context = EVP_CIPHER_CTX_new(); + if (context == nullptr) { + return std::nullopt; + } + std::string plaintext(ciphertextSize + EVP_MAX_BLOCK_LENGTH, '\0'); + int outputLength = 0; + int totalLength = 0; + bool ok = + EVP_DecryptInit_ex(context, EVP_aes_256_gcm(), nullptr, nullptr, nullptr) == 1 && + EVP_CIPHER_CTX_ctrl(context, EVP_CTRL_GCM_SET_IVLEN, iv.size(), nullptr) == 1 && + EVP_DecryptInit_ex(context, nullptr, nullptr, key.data(), iv.data()) == 1 && + EVP_DecryptUpdate( + context, + nullptr, + &outputLength, + reinterpret_cast(envelope.data()), + headerSize) == 1 && + EVP_DecryptUpdate( + context, + reinterpret_cast(plaintext.data()), + &outputLength, + ciphertext, + static_cast(ciphertextSize)) == 1; + if (ok) { + totalLength = outputLength; + ok = EVP_CIPHER_CTX_ctrl( + context, + EVP_CTRL_GCM_SET_TAG, + 16, + const_cast(tag)) == 1 && + EVP_DecryptFinal_ex( + context, + reinterpret_cast(plaintext.data()) + totalLength, + &outputLength) == 1; + totalLength += outputLength; + } + EVP_CIPHER_CTX_free(context); + if (!ok || totalLength <= 0 || + static_cast(totalLength) > kMaxFrameBytes) { + return std::nullopt; + } + plaintext.resize(static_cast(totalLength)); + return plaintext; } std::optional JsonStringValue(const std::string& json, const std::string& key) { @@ -125,11 +331,43 @@ std::optional JsonStringValue(const std::string& json, const std::s return std::nullopt; } const std::size_t valueStart = start + marker.size(); - const std::size_t valueEnd = json.find('"', valueStart); - if (valueEnd == std::string::npos) { - return std::nullopt; + std::string value; + bool escaping = false; + for (std::size_t index = valueStart; index < json.size(); ++index) { + const char ch = json[index]; + if (escaping) { + switch (ch) { + case '"': + case '\\': + case '/': + value.push_back(ch); + break; + case 'n': + value.push_back('\n'); + break; + case 'r': + value.push_back('\r'); + break; + case 't': + value.push_back('\t'); + break; + default: + value.push_back(ch); + break; + } + escaping = false; + continue; + } + if (ch == '\\') { + escaping = true; + continue; + } + if (ch == '"') { + return value; + } + value.push_back(ch); } - return json.substr(valueStart, valueEnd - valueStart); + return std::nullopt; } std::string EscapeJson(std::string_view value) { @@ -160,8 +398,314 @@ std::string EscapeJson(std::string_view value) { return escaped; } +std::vector Base64Decode(const std::string& input) { + static constexpr char kAlphabet[] = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + std::array table{}; + table.fill(-1); + for (int i = 0; i < 64; ++i) { + table[static_cast(kAlphabet[i])] = i; + } + + std::vector out; + int value = 0; + int bits = -8; + for (const unsigned char c : input) { + if (c == '=') { + break; + } + if (table[c] == -1) { + continue; // skip whitespace/newlines + } + value = (value << 6) + table[c]; + bits += 6; + if (bits >= 0) { + out.push_back(static_cast((value >> bits) & 0xFF)); + bits -= 8; + } + } + return out; +} + +// Decodes a base64 PNG into a per-session temp file and returns its path, so +// notify-send can render the real Android app icon. Returns empty on failure. +std::string WriteNotificationIcon(const std::string& base64, const std::string& stableId) { + if (base64.empty()) { + return {}; + } + const std::vector bytes = Base64Decode(base64); + if (bytes.empty()) { + return {}; + } + + const char* runtimeDir = std::getenv("XDG_RUNTIME_DIR"); + const std::string dir = + (runtimeDir != nullptr ? std::string(runtimeDir) : std::string("/tmp")) + "/inputflow-icons"; + mkdir(dir.c_str(), 0700); + + const std::string path = + dir + "/icon-" + std::to_string(std::hash{}(stableId)) + ".png"; + std::ofstream file(path, std::ios::binary | std::ios::trunc); + if (!file) { + return {}; + } + file.write(reinterpret_cast(bytes.data()), + static_cast(bytes.size())); + if (!file) { + return {}; + } + return path; +} + +void ShowLinuxNotification(const std::string& appName, + const std::string& title, + const std::string& body, + const std::string& iconPath) { + if (title.empty() && body.empty()) { + return; + } + + // Show the originating Android app as the notification source (-a) with its + // real icon (-i), and keep the title clean, so it reads like a native Android + // notification on the desktop rather than a generic "InputFlow" message. + const std::string app = appName.empty() ? "Android" : appName; + const std::string displayTitle = title.empty() ? app : title; + const std::string icon = iconPath.empty() ? "inputflow" : iconPath; + + const pid_t child = fork(); + if (child == 0) { + execlp( + "notify-send", + "notify-send", + "-a", + app.c_str(), + "-i", + icon.c_str(), + displayTitle.c_str(), + body.c_str(), + static_cast(nullptr)); + _exit(127); + } + if (child > 0) { + int status = 0; + (void)waitpid(child, &status, 0); + } +} + +#if defined(MWB_HAVE_LIBEI_INPUT_CAPTURE) +// Presents notifications via the org.freedesktop.Notifications DBus service so we +// can attach an inline-reply action (KDE capability) and receive the reply the +// user types on the desktop. Runs its own GLib main loop thread to dispatch the +// NotificationReplied/Closed signals. One instance per process. +class NotificationPresenter { +public: + using ReplyCallback = std::function; + + bool Start(ReplyCallback callback) { + m_callback = std::move(callback); + GError* error = nullptr; + m_connection = g_bus_get_sync(G_BUS_TYPE_SESSION, nullptr, &error); + if (m_connection == nullptr) { + if (error != nullptr) { + std::cerr << "WARN: notification DBus connect failed: " << error->message << std::endl; + g_error_free(error); + } + return false; + } + m_context = g_main_context_new(); + m_loop = g_main_loop_new(m_context, FALSE); + g_main_context_push_thread_default(m_context); + m_repliedSub = g_dbus_connection_signal_subscribe( + m_connection, "org.freedesktop.Notifications", "org.freedesktop.Notifications", + "NotificationReplied", "/org/freedesktop/Notifications", nullptr, + G_DBUS_SIGNAL_FLAGS_NONE, &NotificationPresenter::OnReplied, this, nullptr); + m_closedSub = g_dbus_connection_signal_subscribe( + m_connection, "org.freedesktop.Notifications", "org.freedesktop.Notifications", + "NotificationClosed", "/org/freedesktop/Notifications", nullptr, + G_DBUS_SIGNAL_FLAGS_NONE, &NotificationPresenter::OnClosed, this, nullptr); + g_main_context_pop_thread_default(m_context); + m_thread = std::thread([this]() { + g_main_context_push_thread_default(m_context); + g_main_loop_run(m_loop); + g_main_context_pop_thread_default(m_context); + }); + return true; + } + + void Notify(const std::string& app, const std::string& title, const std::string& body, + const std::string& iconPath, bool replyable, const std::string& stableId) { + if (m_connection == nullptr) { + return; + } + const std::string summary = title.empty() ? app : title; + + GVariantBuilder actions; + g_variant_builder_init(&actions, G_VARIANT_TYPE("as")); + if (replyable) { + g_variant_builder_add(&actions, "s", "inline-reply"); + g_variant_builder_add(&actions, "s", "Reply"); + } + + GVariantBuilder hints; + g_variant_builder_init(&hints, G_VARIANT_TYPE("a{sv}")); + if (replyable) { + g_variant_builder_add(&hints, "{sv}", "x-kde-reply-placeholder-text", + g_variant_new_string("Reply")); + } + + GError* error = nullptr; + GVariant* reply = g_dbus_connection_call_sync( + m_connection, "org.freedesktop.Notifications", "/org/freedesktop/Notifications", + "org.freedesktop.Notifications", "Notify", + g_variant_new("(susssasa{sv}i)", app.c_str(), 0u, iconPath.c_str(), + summary.c_str(), body.c_str(), &actions, &hints, -1), + G_VARIANT_TYPE("(u)"), G_DBUS_CALL_FLAGS_NONE, -1, nullptr, &error); + if (reply == nullptr) { + if (error != nullptr) { + g_error_free(error); + } + return; + } + guint32 id = 0; + g_variant_get(reply, "(u)", &id); + g_variant_unref(reply); + if (replyable && id != 0) { + std::lock_guard lock(m_mapMutex); + m_idToStable[id] = stableId; + } + } + + ~NotificationPresenter() { + if (m_loop != nullptr) { + g_main_loop_quit(m_loop); + } + if (m_thread.joinable()) { + m_thread.join(); + } + if (m_connection != nullptr) { + if (m_repliedSub != 0) g_dbus_connection_signal_unsubscribe(m_connection, m_repliedSub); + if (m_closedSub != 0) g_dbus_connection_signal_unsubscribe(m_connection, m_closedSub); + } + if (m_loop != nullptr) g_main_loop_unref(m_loop); + if (m_context != nullptr) g_main_context_unref(m_context); + if (m_connection != nullptr) g_object_unref(m_connection); + } + +private: + static void OnReplied(GDBusConnection*, const gchar*, const gchar*, const gchar*, + const gchar*, GVariant* params, gpointer userData) { + auto* self = static_cast(userData); + guint32 id = 0; + const gchar* text = nullptr; + g_variant_get(params, "(u&s)", &id, &text); + std::string stableId; + { + std::lock_guard lock(self->m_mapMutex); + auto it = self->m_idToStable.find(id); + if (it != self->m_idToStable.end()) { + stableId = it->second; + self->m_idToStable.erase(it); + } + } + if (!stableId.empty() && self->m_callback) { + self->m_callback(stableId, text != nullptr ? std::string(text) : std::string()); + } + } + + static void OnClosed(GDBusConnection*, const gchar*, const gchar*, const gchar*, + const gchar*, GVariant* params, gpointer userData) { + auto* self = static_cast(userData); + guint32 id = 0; + guint32 reason = 0; + g_variant_get(params, "(uu)", &id, &reason); + std::lock_guard lock(self->m_mapMutex); + self->m_idToStable.erase(id); + } + + GDBusConnection* m_connection{nullptr}; + GMainContext* m_context{nullptr}; + GMainLoop* m_loop{nullptr}; + std::thread m_thread; + guint m_repliedSub{0}; + guint m_closedSub{0}; + std::mutex m_mapMutex; + std::map m_idToStable; + ReplyCallback m_callback; +}; + +// Presents a synced notification, using the DBus presenter (inline reply capable) +// when available, falling back to notify-send otherwise. +void PresentNotification(AndroidRelayServer* server, const std::string& app, + const std::string& title, const std::string& body, + const std::string& iconPath, bool canReply, + const std::string& stableId) { + if (title.empty() && body.empty()) { + return; + } + static NotificationPresenter presenter; + static bool started = presenter.Start( + [server](const std::string& sid, const std::string& text) { + server->DeliverNotificationReply(sid, text); + }); + if (started) { + presenter.Notify(app.empty() ? "Android" : app, title, body, iconPath, canReply, stableId); + } else { + ShowLinuxNotification(app, title, body, iconPath); + } +} +#else +void PresentNotification(AndroidRelayServer*, const std::string& app, const std::string& title, + const std::string& body, const std::string& iconPath, bool, + const std::string&) { + ShowLinuxNotification(app, title, body, iconPath); +} +#endif + } // namespace +bool VerifyAndroidRelayHmac( + const std::string& secret, + const std::string& nonce, + const std::string& suppliedHmac) { + const std::string expected = HmacSha256Hex(secret, nonce); + return suppliedHmac.size() == expected.size() && + CRYPTO_memcmp(suppliedHmac.data(), expected.data(), expected.size()) == 0; +} + +#if defined(MWB_TESTING) +std::optional EncryptAndroidRelayFrameForTest( + const std::string& payload, + const std::string& secret, + const std::string& nonce, + uint64_t sequence, + bool serverToClient) { + return EncryptRelayFrame( + payload, + DeriveRelayKey( + secret, + nonce, + serverToClient ? "server-to-client" : "client-to-server"), + sequence, + serverToClient ? kServerToClientIvPrefix : kClientToServerIvPrefix); +} + +std::optional DecryptAndroidRelayFrameForTest( + const std::string& envelope, + const std::string& secret, + const std::string& nonce, + uint64_t expectedSequence, + bool serverToClient) { + return DecryptRelayFrame( + envelope, + DeriveRelayKey( + secret, + nonce, + serverToClient ? "server-to-client" : "client-to-server"), + expectedSequence, + serverToClient ? kServerToClientIvPrefix : kClientToServerIvPrefix); +} +#endif + AndroidRelayServer::AndroidRelayServer(AndroidRelayOptions options) : m_options(std::move(options)) { } @@ -220,8 +764,7 @@ bool AndroidRelayServer::Start() { } m_acceptThread = std::thread([this]() { AcceptLoop(); }); - std::cout << "[ANDROID] Relay listening on port " << m_options.port - << " for peer '" << m_options.peerName << "'." << std::endl; + std::cout << "[ANDROID] Relay listening on the configured port." << std::endl; return true; } @@ -304,6 +847,39 @@ void AndroidRelayServer::AcceptLoop() { break; } + { + std::lock_guard lock(m_clientsMutex); + if (m_clients.size() >= kMaxRelayClients) { + close(clientFd); + std::cerr << "WARN: Android relay client limit reached." << std::endl; + continue; + } + } + + // Enable TCP keepalive so a half-open peer (Wi-Fi drop, app killed, device + // sleep) is detected by the kernel within ~11s. Without this the blocking + // ReadFrame never returns, the dead session lingers as authenticated, and + // BroadcastFrame keeps shipping input into the void instead of the live + // client. TCP_NODELAY keeps mouse latency low. + const int on = 1; + setsockopt(clientFd, SOL_SOCKET, SO_KEEPALIVE, &on, sizeof(on)); +#ifdef TCP_KEEPIDLE + const int idle = 5, intvl = 2, cnt = 3; + setsockopt(clientFd, IPPROTO_TCP, TCP_KEEPIDLE, &idle, sizeof(idle)); + setsockopt(clientFd, IPPROTO_TCP, TCP_KEEPINTVL, &intvl, sizeof(intvl)); + setsockopt(clientFd, IPPROTO_TCP, TCP_KEEPCNT, &cnt, sizeof(cnt)); +#endif +#ifdef TCP_NODELAY + setsockopt(clientFd, IPPROTO_TCP, TCP_NODELAY, &on, sizeof(on)); +#endif + const timeval authenticationTimeout{5, 0}; + setsockopt( + clientFd, + SOL_SOCKET, + SO_RCVTIMEO, + &authenticationTimeout, + sizeof(authenticationTimeout)); + auto session = std::make_shared(); session->fd = clientFd; { @@ -319,12 +895,32 @@ void AndroidRelayServer::HandleClient(std::shared_ptr session) { RemoveClient(session); return; } + const timeval noReadTimeout{0, 0}; + setsockopt( + session->fd, + SOL_SOCKET, + SO_RCVTIMEO, + &noReadTimeout, + sizeof(noReadTimeout)); while (m_running) { - const auto frame = ReadFrame(session->fd); + if (session->readSequence == std::numeric_limits::max()) { + break; + } + const auto wireFrame = ReadFrame(session->fd); + if (!wireFrame.has_value()) { + break; + } + const auto frame = DecryptRelayFrame( + *wireFrame, + session->readKey, + session->readSequence, + kClientToServerIvPrefix); if (!frame.has_value()) { + std::cerr << "WARN: Android relay rejected an invalid encrypted frame." << std::endl; break; } + ++session->readSequence; const auto type = JsonStringValue(*frame, "type"); if (!type.has_value()) { continue; @@ -347,6 +943,25 @@ void AndroidRelayServer::HandleClient(std::shared_ptr session) { if (callback) { callback(*frame); } + } else if (*type == "notification_upsert") { + if (m_options.notificationSyncEnabled) { + const std::string stableId = JsonStringValue(*frame, "stable_id").value_or(""); + const std::string iconPath = WriteNotificationIcon( + JsonStringValue(*frame, "icon_png").value_or(""), stableId); + const bool canReply = frame->find("\"can_reply\":true") != std::string::npos; + PresentNotification( + this, + JsonStringValue(*frame, "app").value_or("Android"), + JsonStringValue(*frame, "title").value_or(""), + JsonStringValue(*frame, "body").value_or(""), + iconPath, + canReply, + stableId); + } + } else if (*type == "notification_dismiss") { + if (m_options.notificationSyncEnabled) { + std::cout << "[ANDROID] Notification dismissed." << std::endl; + } } } @@ -358,8 +973,9 @@ bool AndroidRelayServer::AuthenticateClient(const std::shared_ptr if (nonce.empty()) { return false; } - const std::string hello = "{\"type\":\"hello\",\"version\":1,\"nonce\":\"" + nonce + - "\",\"peer\":\"" + EscapeJson(m_options.peerName) + "\"}"; + const std::string hello = + "{\"type\":\"hello\",\"version\":2,\"cipher\":\"AES-256-GCM\",\"nonce\":\"" + + nonce + "\"}"; if (!SendFrame(session, hello)) { return false; } @@ -374,22 +990,24 @@ bool AndroidRelayServer::AuthenticateClient(const std::shared_ptr return false; } - const std::string expected = HmacSha256Hex(m_options.secret, nonce); - if (*hmac != expected) { + if (!VerifyAndroidRelayHmac(m_options.secret, nonce, *hmac)) { std::cerr << "WARN: Android relay rejected client with invalid auth." << std::endl; return false; } const std::string deviceName = JsonStringValue(*auth, "device").value_or("android"); + session->deviceName = deviceName; + session->readKey = DeriveRelayKey(m_options.secret, nonce, "client-to-server"); + session->writeKey = DeriveRelayKey(m_options.secret, nonce, "server-to-client"); + session->encryptionReady = true; + if (!SendFrame(session, "{\"type\":\"ready\"}")) { + return false; + } { std::lock_guard lock(m_clientsMutex); - session->deviceName = deviceName; session->authenticated = true; } - std::cout << "[ANDROID] Relay client authenticated: " << deviceName << std::endl; - if (!SendFrame(session, "{\"type\":\"ready\"}")) { - return false; - } + std::cout << "[ANDROID] Encrypted relay client authenticated." << std::endl; // Send device layout info so the Android client can show the layout editor. if (!m_localMachineName.empty()) { @@ -416,9 +1034,25 @@ bool AndroidRelayServer::SendFrame(const std::shared_ptr& session } std::lock_guard lock(session->writeMutex); - const uint32_t length = htonl(static_cast(payload.size())); + std::string wirePayload = payload; + if (session->encryptionReady) { + if (session->writeSequence == std::numeric_limits::max()) { + return false; + } + const auto encrypted = EncryptRelayFrame( + payload, + session->writeKey, + session->writeSequence, + kServerToClientIvPrefix); + if (!encrypted.has_value()) { + return false; + } + wirePayload = *encrypted; + ++session->writeSequence; + } + const uint32_t length = htonl(static_cast(wirePayload.size())); return WriteExact(session->fd, &length, sizeof(length)) && - WriteExact(session->fd, payload.data(), payload.size()); + WriteExact(session->fd, wirePayload.data(), wirePayload.size()); } bool AndroidRelayServer::BroadcastFrame(const std::string& payload) { @@ -436,8 +1070,19 @@ bool AndroidRelayServer::BroadcastFrame(const std::string& payload) { } bool delivered = false; + std::vector> dead; for (const auto& client : clients) { - delivered = SendFrame(client, payload) || delivered; + if (SendFrame(client, payload)) { + delivered = true; + } else { + // A failed write means the peer is gone (EPIPE/ECONNRESET). Drop it now + // so input stops being shipped to a dead socket and the live client + // remains the only broadcast target. + dead.push_back(client); + } + } + for (const auto& client : dead) { + RemoveClient(client); } return delivered; } @@ -483,4 +1128,13 @@ std::string BuildAndroidControlFrame(bool active) { return std::string("{\"type\":\"control\",\"active\":") + (active ? "true" : "false") + "}"; } +std::string BuildAndroidNotificationReplyFrame(const std::string& stableId, const std::string& text) { + return std::string("{\"type\":\"notification_reply\",\"stable_id\":\"") + EscapeJson(stableId) + + "\",\"text\":\"" + EscapeJson(text) + "\"}"; +} + +void AndroidRelayServer::DeliverNotificationReply(const std::string& stableId, const std::string& text) { + BroadcastFrame(BuildAndroidNotificationReplyFrame(stableId, text)); +} + } // namespace mwb diff --git a/src/AndroidRelay.h b/src/AndroidRelay.h index e89dbc1..bd8d3f1 100644 --- a/src/AndroidRelay.h +++ b/src/AndroidRelay.h @@ -1,10 +1,12 @@ #pragma once +#include #include #include #include #include #include +#include #include #include #include @@ -21,6 +23,7 @@ struct AndroidRelayOptions { bool layoutEditorEnabled{true}; int androidDeviceWidth{1920}; int androidDeviceHeight{1200}; + bool notificationSyncEnabled{false}; }; class AndroidRelayServer { @@ -41,6 +44,10 @@ class AndroidRelayServer { // Called after ready to populate the devices_info frame sent to Android. void SetLocalDeviceInfo(const std::string& machineName, int screenWidth, int screenHeight); + // Relays a desktop-typed inline reply back to Android, which fires the + // originating notification's RemoteInput to actually send the message. + void DeliverNotificationReply(const std::string& stableId, const std::string& text); + const AndroidRelayOptions& Options() const { return m_options; } @@ -49,7 +56,12 @@ class AndroidRelayServer { struct ClientSession { int fd{-1}; bool authenticated{false}; + bool encryptionReady{false}; std::string deviceName; + std::array readKey{}; + std::array writeKey{}; + uint64_t readSequence{0}; + uint64_t writeSequence{0}; std::mutex writeMutex; }; @@ -80,5 +92,24 @@ std::string BuildAndroidMouseFrame(const MouseData& mouse); std::string BuildAndroidKeyboardFrame(const KeyboardData& keyboard); std::string BuildAndroidGestureFrame(const std::string& kind, double dx, double dy); std::string BuildAndroidControlFrame(bool active); +std::string BuildAndroidNotificationReplyFrame(const std::string& stableId, const std::string& text); +bool VerifyAndroidRelayHmac( + const std::string& secret, + const std::string& nonce, + const std::string& suppliedHmac); +#if defined(MWB_TESTING) +std::optional EncryptAndroidRelayFrameForTest( + const std::string& payload, + const std::string& secret, + const std::string& nonce, + uint64_t sequence, + bool serverToClient); +std::optional DecryptAndroidRelayFrameForTest( + const std::string& envelope, + const std::string& secret, + const std::string& nonce, + uint64_t expectedSequence, + bool serverToClient); +#endif } // namespace mwb diff --git a/src/AppConfig.cpp b/src/AppConfig.cpp index e9c544a..34f31df 100644 --- a/src/AppConfig.cpp +++ b/src/AppConfig.cpp @@ -1,4 +1,5 @@ #include "AppConfig.h" +#include "SecureFile.h" #include #include @@ -501,6 +502,16 @@ bool ParseAppConfig(std::string_view text, AppConfig& outConfig, std::string* er continue; } + if (key == "notification_sync_enabled" || key == "notifications_sync_enabled") { + const auto parsed = ParseConfigBool(value); + if (!parsed.has_value()) { + SetError(errorMessage, "Config key 'notification_sync_enabled' expects true/false."); + return false; + } + outConfig.notificationSyncEnabled = *parsed; + continue; + } + SetError(errorMessage, "Unknown config key '" + std::string(key) + "' on line " + std::to_string(lineNumber) + "."); return false; } @@ -577,6 +588,7 @@ std::string RenderAppConfig(const AppConfig& config) { out << "android_layout_editor_enabled=" << RenderBool(config.androidLayoutEditorEnabled) << '\n'; out << "android_device_width=" << config.androidDeviceWidth << '\n'; out << "android_device_height=" << config.androidDeviceHeight << '\n'; + out << "notification_sync_enabled=" << RenderBool(config.notificationSyncEnabled) << '\n'; return out.str(); } @@ -599,6 +611,7 @@ std::string RenderSampleAppConfig() { out << "# Set topology_enabled=true and topology_file=... to enable runtime topology handoff.\n"; out << "# Set android_peers_enabled=true with android_relay_secret=... to relay input to Android peers.\n"; out << "# android_capture_backend=none keeps Android relay-only; evdev is prototype-only; libei is the planned KDE/Wayland backend.\n"; + out << "# Set notification_sync_enabled=true to mirror Android notifications to this desktop over the Android relay.\n"; out << RenderAppConfig(sample); return out.str(); } @@ -606,40 +619,11 @@ std::string RenderSampleAppConfig() { bool WriteAppConfig(const std::filesystem::path& path, const AppConfig& config, std::string* errorMessage) { - try { - const std::filesystem::path parent = path.parent_path(); - if (!parent.empty()) { - std::filesystem::create_directories(parent); - } - - std::ofstream file(path, std::ios::out | std::ios::trunc); - if (!file) { - SetError(errorMessage, "Failed to open config file for writing: " + path.string()); - return false; - } - - file << RenderAppConfig(config); - if (!file) { - SetError(errorMessage, "Failed to write config file: " + path.string()); - return false; - } - file.close(); - - std::error_code permissionError; - std::filesystem::permissions( - path, - std::filesystem::perms::owner_read | std::filesystem::perms::owner_write, - std::filesystem::perm_options::replace, - permissionError); - if (permissionError) { - SetError(errorMessage, "Failed to secure config file permissions for '" + path.string() + "': " + permissionError.message()); - return false; - } - } catch (const std::exception& ex) { - SetError(errorMessage, "Failed to write config file '" + path.string() + "': " + ex.what()); + std::string writeError; + if (!WritePrivateFileAtomically(path, RenderAppConfig(config), writeError)) { + SetError(errorMessage, writeError); return false; } - return true; } diff --git a/src/AppConfig.h b/src/AppConfig.h index f4e1d85..40bedee 100644 --- a/src/AppConfig.h +++ b/src/AppConfig.h @@ -45,6 +45,7 @@ struct AppConfig { bool androidLayoutEditorEnabled{true}; int androidDeviceWidth{1920}; int androidDeviceHeight{1200}; + bool notificationSyncEnabled{false}; }; AppConfig LoadDefaultAppConfig(); diff --git a/src/AppState.cpp b/src/AppState.cpp index 6ab1bed..9212a4b 100644 --- a/src/AppState.cpp +++ b/src/AppState.cpp @@ -1,4 +1,5 @@ #include "AppState.h" +#include "SecureFile.h" #include #include @@ -195,19 +196,7 @@ bool LoadAppState(const std::filesystem::path& path, AppState& state, std::strin } bool SaveAppState(const std::filesystem::path& path, const AppState& state, std::string& errorMessage) { - std::error_code mkdirError; - std::filesystem::create_directories(path.parent_path(), mkdirError); - if (mkdirError) { - errorMessage = "Failed to create state directory: " + mkdirError.message(); - return false; - } - - std::ofstream output(path); - if (!output) { - errorMessage = "Could not write state file: " + path.string(); - return false; - } - + std::ostringstream output; output << "local_machine_id=0x" << std::hex << state.localMachineId << std::dec << "\n"; for (const auto& peer : state.peers) { output << "peer=" @@ -220,12 +209,7 @@ bool SaveAppState(const std::filesystem::path& path, const AppState& state, std: << peer.lastConnectedEpochSeconds << "\n"; } - if (!output) { - errorMessage = "Failed while writing state file: " + path.string(); - return false; - } - - return true; + return WritePrivateFileAtomically(path, output.str(), errorMessage); } uint32_t EnsureLocalMachineId(AppState& state) { diff --git a/src/ClientRuntime.cpp b/src/ClientRuntime.cpp index 7865cca..3930faa 100644 --- a/src/ClientRuntime.cpp +++ b/src/ClientRuntime.cpp @@ -699,6 +699,12 @@ void ClientRuntime::StartLocalAndroidInputBridge(const ScreenSize& screenSize) { options.sendMouse = [this](const MouseData& mouse) { return TrySendAndroidMouse(mouse); }; + options.sendGesture = [this](const std::string& kind, double dx, double dy) { + return TrySendAndroidGesture(kind, dx, dy); + }; + options.sendControl = [this](bool active) { + return TrySetAndroidControlActive(active); + }; m_localAndroidInputBridge = std::make_unique(std::move(options)); m_localAndroidInputBridge->Start(); diff --git a/src/ClipboardManager.cpp b/src/ClipboardManager.cpp index c20de3e..f73f594 100644 --- a/src/ClipboardManager.cpp +++ b/src/ClipboardManager.cpp @@ -6,11 +6,9 @@ #include #include #include -#include #include #include #include -#include #include #include #include @@ -196,12 +194,6 @@ std::optional runReadCommand(const CommandSpec& command) { return std::string(bytes->begin(), bytes->end()); } -void trimTrailingNewlines(std::string& text) { - while (!text.empty() && (text.back() == '\n' || text.back() == '\r')) { - text.pop_back(); - } -} - bool runWriteCommand(const CommandSpec& command, const std::string& input) { if (command.argv.empty()) { return false; @@ -509,13 +501,98 @@ bool runSignalWatchCommand( std::u16string utf8ToUtf16(std::string_view text) { - std::wstring_convert, char16_t> converter; - return converter.from_bytes(text.data(), text.data() + text.size()); + constexpr uint32_t replacement = 0xfffdu; + std::u16string output; + output.reserve(text.size()); + std::size_t index = 0; + while (index < text.size()) { + const auto first = static_cast(text[index]); + uint32_t codePoint = replacement; + std::size_t sequenceLength = 1; + uint32_t minimum = 0; + if (first < 0x80u) { + codePoint = first; + } else if ((first & 0xe0u) == 0xc0u) { + codePoint = first & 0x1fu; + sequenceLength = 2; + minimum = 0x80u; + } else if ((first & 0xf0u) == 0xe0u) { + codePoint = first & 0x0fu; + sequenceLength = 3; + minimum = 0x800u; + } else if ((first & 0xf8u) == 0xf0u) { + codePoint = first & 0x07u; + sequenceLength = 4; + minimum = 0x10000u; + } + + bool valid = sequenceLength == 1 ? first < 0x80u : index + sequenceLength <= text.size(); + for (std::size_t offset = 1; valid && offset < sequenceLength; ++offset) { + const auto continuation = static_cast(text[index + offset]); + valid = (continuation & 0xc0u) == 0x80u; + codePoint = (codePoint << 6u) | (continuation & 0x3fu); + } + valid = valid && + codePoint >= minimum && + codePoint <= 0x10ffffu && + !(codePoint >= 0xd800u && codePoint <= 0xdfffu); + if (!valid) { + codePoint = replacement; + sequenceLength = 1; + } + index += sequenceLength; + + if (codePoint <= 0xffffu) { + output.push_back(static_cast(codePoint)); + } else { + codePoint -= 0x10000u; + output.push_back(static_cast(0xd800u + (codePoint >> 10u))); + output.push_back(static_cast(0xdc00u + (codePoint & 0x3ffu))); + } + } + return output; } std::string utf16ToUtf8(const std::u16string& text) { - std::wstring_convert, char16_t> converter; - return converter.to_bytes(text); + constexpr uint32_t replacement = 0xfffdu; + std::string output; + output.reserve(text.size() * 2); + for (std::size_t index = 0; index < text.size(); ++index) { + uint32_t codePoint = text[index]; + if (codePoint >= 0xd800u && codePoint <= 0xdbffu) { + if (index + 1 < text.size()) { + const uint32_t low = text[index + 1]; + if (low >= 0xdc00u && low <= 0xdfffu) { + codePoint = + 0x10000u + ((codePoint - 0xd800u) << 10u) + (low - 0xdc00u); + ++index; + } else { + codePoint = replacement; + } + } else { + codePoint = replacement; + } + } else if (codePoint >= 0xdc00u && codePoint <= 0xdfffu) { + codePoint = replacement; + } + + if (codePoint <= 0x7fu) { + output.push_back(static_cast(codePoint)); + } else if (codePoint <= 0x7ffu) { + output.push_back(static_cast(0xc0u | (codePoint >> 6u))); + output.push_back(static_cast(0x80u | (codePoint & 0x3fu))); + } else if (codePoint <= 0xffffu) { + output.push_back(static_cast(0xe0u | (codePoint >> 12u))); + output.push_back(static_cast(0x80u | ((codePoint >> 6u) & 0x3fu))); + output.push_back(static_cast(0x80u | (codePoint & 0x3fu))); + } else { + output.push_back(static_cast(0xf0u | (codePoint >> 18u))); + output.push_back(static_cast(0x80u | ((codePoint >> 12u) & 0x3fu))); + output.push_back(static_cast(0x80u | ((codePoint >> 6u) & 0x3fu))); + output.push_back(static_cast(0x80u | (codePoint & 0x3fu))); + } + } + return output; } std::vector encodeUtf16Le(std::u16string_view text) { diff --git a/src/ClipboardManager.h b/src/ClipboardManager.h index 385ca24..0bbd4a5 100644 --- a/src/ClipboardManager.h +++ b/src/ClipboardManager.h @@ -42,8 +42,8 @@ class ClipboardBackend { virtual bool WritePayload(const ClipboardPayload& payload) = 0; virtual std::string Name() const = 0; virtual bool WatchPayloadChanges( - const std::atomic& running, - const std::function& onPayloadChange) { + const std::atomic&, + const std::function&) { return false; } }; diff --git a/src/Discovery.cpp b/src/Discovery.cpp index 6dde63f..cc65999 100644 --- a/src/Discovery.cpp +++ b/src/Discovery.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -24,11 +25,73 @@ #include #include +#include #include namespace mwb { namespace { +std::optional RunCommandCapture(const std::vector& argv) { + if (argv.empty()) { + return std::nullopt; + } + int outputPipe[2]{-1, -1}; + if (pipe(outputPipe) != 0) { + return std::nullopt; + } + const pid_t child = fork(); + if (child < 0) { + close(outputPipe[0]); + close(outputPipe[1]); + return std::nullopt; + } + if (child == 0) { + close(outputPipe[0]); + dup2(outputPipe[1], STDOUT_FILENO); + if (outputPipe[1] > STDERR_FILENO) { + close(outputPipe[1]); + } + const int devNull = open("/dev/null", O_WRONLY); + if (devNull >= 0) { + dup2(devNull, STDERR_FILENO); + if (devNull > STDERR_FILENO) { + close(devNull); + } + } + std::vector args; + args.reserve(argv.size() + 1); + for (const auto& argument : argv) { + args.push_back(const_cast(argument.c_str())); + } + args.push_back(nullptr); + execv(args.front(), args.data()); + _exit(127); + } + + close(outputPipe[1]); + std::string output; + std::array buffer{}; + while (output.size() < 64 * 1024) { + const ssize_t count = read(outputPipe[0], buffer.data(), buffer.size()); + if (count > 0) { + output.append(buffer.data(), static_cast(count)); + continue; + } + if (count < 0 && errno == EINTR) { + continue; + } + break; + } + close(outputPipe[0]); + int status = 0; + while (waitpid(child, &status, 0) < 0 && errno == EINTR) { + } + if (output.empty()) { + return std::nullopt; + } + return output; +} + constexpr std::size_t kAbsoluteMaxHostsPerSubnet = 4096; constexpr std::size_t kAbsoluteMaxConcurrentProbes = 128; constexpr int kMinimumConnectTimeoutMs = 25; @@ -279,18 +342,18 @@ std::string AvahiResolveAddress(uint32_t address) { } const std::string ipAddress = FormatIpv4Address(address); - const std::string command = std::string(kTimeoutPath) + " 1s " + kAvahiResolveAddressPath + " -4 -a " + ipAddress + " 2>/dev/null"; - std::unique_ptr pipe(popen(command.c_str(), "r"), pclose); - if (!pipe) { + const auto output = RunCommandCapture( + {kTimeoutPath, "1s", kAvahiResolveAddressPath, "-4", "-a", ipAddress}); + if (!output.has_value()) { return {}; } - std::array buffer{}; - if (fgets(buffer.data(), static_cast(buffer.size()), pipe.get()) == nullptr) { + std::istringstream lines(*output); + std::string line; + if (!std::getline(lines, line)) { return {}; } - std::string line(buffer.data()); const std::size_t separator = line.find_first_of(" \t"); if (separator == std::string::npos) { return {}; @@ -312,17 +375,17 @@ std::string NmblookupResolveAddress(uint32_t address, int timeoutMs) { const std::string ipAddress = FormatIpv4Address(address); const int timeoutSeconds = std::max(1, (std::min(timeoutMs, 3000) + 999) / 1000); - const std::string command = std::string(kTimeoutPath) + " " + std::to_string(timeoutSeconds) + "s " + - kNmblookupPath + " -A " + ipAddress + " 2>/dev/null"; - std::unique_ptr pipe(popen(command.c_str(), "r"), pclose); - if (!pipe) { + const auto output = RunCommandCapture( + {kTimeoutPath, std::to_string(timeoutSeconds) + "s", kNmblookupPath, "-A", ipAddress}); + if (!output.has_value()) { return {}; } - std::array buffer{}; + std::istringstream lines(*output); std::string fallbackName; - while (fgets(buffer.data(), static_cast(buffer.size()), pipe.get()) != nullptr) { - const std::string line = TrimDiscoveredName(buffer.data()); + std::string rawLine; + while (std::getline(lines, rawLine)) { + const std::string line = TrimDiscoveredName(rawLine); if (line.find("") != std::string::npos) { continue; } diff --git a/src/GuiMainWindow.cpp b/src/GuiMainWindow.cpp index e2844cf..534ea2e 100644 --- a/src/GuiMainWindow.cpp +++ b/src/GuiMainWindow.cpp @@ -16,6 +16,91 @@ namespace { constexpr const char* kServiceName = "mwb-client.service"; constexpr const char* kSystemctlPath = "/usr/bin/systemctl"; +constexpr const char* kInputFlowCss = R"CSS( +.inputflow-window { + background-color: #f3f8f7; + color: #12252c; +} +.inputflow-window notebook > header { + background-color: #e4f0ed; + border-bottom: 1px solid #bcd7d2; +} +.inputflow-window notebook > header tab { + padding: 10px 14px; + color: #355158; +} +.inputflow-window notebook > header tab:checked { + color: #0b6f6b; + border-bottom: 3px solid #0b7f7a; +} +.inputflow-hero { + background-color: #092f35; + border: 1px solid #15545a; + border-radius: 14px; + padding: 18px; +} +.inputflow-hero label { + color: #f4fbf9; +} +.inputflow-eyebrow { + color: #72d4c6; + font-size: 10px; + font-weight: 700; + letter-spacing: 1.5px; +} +.inputflow-status-title { + font-size: 22px; + font-weight: 700; +} +.inputflow-status-detail { + color: #b9d9d4; +} +.inputflow-section-title { + color: #0b6f6b; + font-size: 14px; + font-weight: 700; +} +.inputflow-panel { + background-color: #ffffff; + border: 1px solid #bcd7d2; + border-radius: 10px; +} +.inputflow-window button { + border-radius: 8px; + padding: 7px 12px; +} +.inputflow-window button.suggested-action { + background-color: #0b7f7a; + color: #ffffff; +} +.inputflow-window button.destructive-action { + color: #a73540; +} +.inputflow-window textview { + background-color: #102a31; + color: #d9eeea; + font-family: monospace; +} +)CSS"; + +void InstallDesignSystem(GtkWidget* window) { + GtkCssProvider* provider = gtk_css_provider_new(); + GError* error = nullptr; + if (!gtk_css_provider_load_from_data(provider, kInputFlowCss, -1, &error)) { + if (error != nullptr) { + std::fprintf(stderr, "InputFlow UI theme could not be loaded: %s\n", error->message); + g_error_free(error); + } + g_object_unref(provider); + return; + } + gtk_style_context_add_provider_for_screen( + gtk_widget_get_screen(window), + GTK_STYLE_PROVIDER(provider), + GTK_STYLE_PROVIDER_PRIORITY_APPLICATION); + gtk_style_context_add_class(gtk_widget_get_style_context(window), "inputflow-window"); + g_object_unref(provider); +} // ---- colour helpers -------------------------------------------------------- @@ -46,8 +131,28 @@ gboolean OnDotDraw(GtkWidget* widget, cairo_t* cr, gpointer data) { void RunServiceAction(const std::string& action) { if (access(kSystemctlPath, X_OK) != 0) return; - std::string cmd = std::string(kSystemctlPath) + " --user " + action + " " + kServiceName + " &"; - (void)std::system(cmd.c_str()); + const gchar* argv[] = { + kSystemctlPath, + "--user", + action.c_str(), + kServiceName, + nullptr, + }; + GError* error = nullptr; + if (!g_spawn_async( + nullptr, + const_cast(argv), + nullptr, + static_cast(G_SPAWN_STDOUT_TO_DEV_NULL | G_SPAWN_STDERR_TO_DEV_NULL), + nullptr, + nullptr, + nullptr, + &error)) { + if (error != nullptr) { + std::fprintf(stderr, "InputFlow service action failed: %s\n", error->message); + g_error_free(error); + } + } } void OnStartService(GtkButton*, gpointer) { RunServiceAction("start"); } @@ -58,8 +163,31 @@ bool IsServiceActive() { if (access(kSystemctlPath, X_OK) != 0) { return false; } - std::string cmd = std::string(kSystemctlPath) + " --user is-active --quiet " + kServiceName; - return std::system(cmd.c_str()) == 0; + const gchar* argv[] = { + kSystemctlPath, + "--user", + "is-active", + "--quiet", + kServiceName, + nullptr, + }; + gint waitStatus = -1; + GError* error = nullptr; + const gboolean launched = g_spawn_sync( + nullptr, + const_cast(argv), + nullptr, + static_cast(G_SPAWN_STDOUT_TO_DEV_NULL | G_SPAWN_STDERR_TO_DEV_NULL), + nullptr, + nullptr, + nullptr, + nullptr, + &waitStatus, + &error); + if (error != nullptr) { + g_error_free(error); + } + return launched && g_spawn_check_wait_status(waitStatus, nullptr); } // After saving settings, a running daemon keeps its old config until restarted. @@ -390,23 +518,33 @@ void OnRefreshPeers(GtkButton*, gpointer data) { } GtkWidget* BuildStatusTab(GuiMainWindow* win) { - GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8); - gtk_container_set_border_width(GTK_CONTAINER(box), 16); + GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 12); + gtk_container_set_border_width(GTK_CONTAINER(box), 20); + + GtkWidget* hero = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6); + gtk_style_context_add_class(gtk_widget_get_style_context(hero), "inputflow-hero"); + GtkWidget* eyebrow = gtk_label_new("TRUSTED CONNECTION"); + gtk_label_set_xalign(GTK_LABEL(eyebrow), 0.0f); + gtk_style_context_add_class(gtk_widget_get_style_context(eyebrow), "inputflow-eyebrow"); + gtk_box_pack_start(GTK_BOX(hero), eyebrow, FALSE, FALSE, 0); - // Dot + state row GtkWidget* stateRow = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); win->dotArea = gtk_drawing_area_new(); gtk_widget_set_size_request(win->dotArea, 18, 18); + atk_object_set_name(gtk_widget_get_accessible(win->dotArea), "Connection status"); g_signal_connect(win->dotArea, "draw", G_CALLBACK(OnDotDraw), win); gtk_box_pack_start(GTK_BOX(stateRow), win->dotArea, FALSE, FALSE, 0); win->stateLabel = gtk_label_new("Checking..."); + gtk_style_context_add_class(gtk_widget_get_style_context(win->stateLabel), "inputflow-status-title"); gtk_box_pack_start(GTK_BOX(stateRow), win->stateLabel, FALSE, FALSE, 0); - gtk_box_pack_start(GTK_BOX(box), stateRow, FALSE, FALSE, 0); + gtk_box_pack_start(GTK_BOX(hero), stateRow, FALSE, FALSE, 0); win->detailLabel = gtk_label_new(""); gtk_label_set_xalign(GTK_LABEL(win->detailLabel), 0.0f); - gtk_style_context_add_class(gtk_widget_get_style_context(win->detailLabel), "dim-label"); - gtk_box_pack_start(GTK_BOX(box), win->detailLabel, FALSE, FALSE, 0); + gtk_label_set_line_wrap(GTK_LABEL(win->detailLabel), TRUE); + gtk_style_context_add_class(gtk_widget_get_style_context(win->detailLabel), "inputflow-status-detail"); + gtk_box_pack_start(GTK_BOX(hero), win->detailLabel, FALSE, FALSE, 0); + gtk_box_pack_start(GTK_BOX(box), hero, FALSE, FALSE, 0); // Buttons GtkWidget* btnRow = gtk_button_box_new(GTK_ORIENTATION_HORIZONTAL); @@ -416,6 +554,11 @@ GtkWidget* BuildStatusTab(GuiMainWindow* win) { GtkWidget* btnStart = gtk_button_new_with_label("Start"); GtkWidget* btnStop = gtk_button_new_with_label("Stop"); GtkWidget* btnRestart = gtk_button_new_with_label("Restart"); + gtk_style_context_add_class(gtk_widget_get_style_context(btnStart), "suggested-action"); + gtk_style_context_add_class(gtk_widget_get_style_context(btnStop), "destructive-action"); + gtk_widget_set_tooltip_text(btnStart, "Start the InputFlow background service"); + gtk_widget_set_tooltip_text(btnStop, "Stop the InputFlow background service"); + gtk_widget_set_tooltip_text(btnRestart, "Reload settings by restarting the service"); g_signal_connect(btnStart, "clicked", G_CALLBACK(OnStartService), win); g_signal_connect(btnStop, "clicked", G_CALLBACK(OnStopService), win); g_signal_connect(btnRestart, "clicked", G_CALLBACK(OnRestartService), win); @@ -426,7 +569,8 @@ GtkWidget* BuildStatusTab(GuiMainWindow* win) { // Peers (live status from saved state: connected now / last seen) GtkWidget* peersHeaderRow = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); - GtkWidget* peersHeader = MakeLabel("Peers:"); + GtkWidget* peersHeader = MakeLabel("Connected peers"); + gtk_style_context_add_class(gtk_widget_get_style_context(peersHeader), "inputflow-section-title"); gtk_box_pack_start(GTK_BOX(peersHeaderRow), peersHeader, FALSE, FALSE, 0); GtkWidget* refreshPeersBtn = gtk_button_new_with_label("Refresh"); gtk_widget_set_halign(refreshPeersBtn, GTK_ALIGN_END); @@ -440,12 +584,15 @@ GtkWidget* BuildStatusTab(GuiMainWindow* win) { gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(peersScroll), GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC); gtk_widget_set_size_request(peersScroll, -1, 110); + gtk_style_context_add_class(gtk_widget_get_style_context(peersScroll), "inputflow-panel"); gtk_container_add(GTK_CONTAINER(peersScroll), win->peersList); gtk_box_pack_start(GTK_BOX(box), peersScroll, FALSE, FALSE, 0); RefreshPeersList(win); // Log - gtk_box_pack_start(GTK_BOX(box), MakeLabel("Recent events:"), FALSE, FALSE, 0); + GtkWidget* eventsHeader = MakeLabel("Recent events"); + gtk_style_context_add_class(gtk_widget_get_style_context(eventsHeader), "inputflow-section-title"); + gtk_box_pack_start(GTK_BOX(box), eventsHeader, FALSE, FALSE, 0); win->logBuf = gtk_text_buffer_new(nullptr); win->logView = gtk_text_view_new_with_buffer(win->logBuf); gtk_text_view_set_editable(GTK_TEXT_VIEW(win->logView), FALSE); @@ -712,8 +859,10 @@ GuiMainWindow* CreateMainWindow(const AppConfig& config, win->window = gtk_window_new(GTK_WINDOW_TOPLEVEL); gtk_window_set_title(GTK_WINDOW(win->window), "InputFlow"); - gtk_window_set_default_size(GTK_WINDOW(win->window), 560, 500); + gtk_window_set_default_size(GTK_WINDOW(win->window), 720, 620); gtk_window_set_resizable(GTK_WINDOW(win->window), TRUE); + InstallDesignSystem(win->window); + atk_object_set_name(gtk_widget_get_accessible(win->window), "InputFlow controller"); g_signal_connect(win->window, "delete-event", G_CALLBACK(+[](GtkWidget* w, GdkEvent*, gpointer) -> gboolean { @@ -725,18 +874,18 @@ GuiMainWindow* CreateMainWindow(const AppConfig& config, gtk_container_add(GTK_CONTAINER(win->window), win->notebook); gtk_notebook_append_page(GTK_NOTEBOOK(win->notebook), - BuildStatusTab(win), gtk_label_new("Status")); + BuildStatusTab(win), gtk_label_new("Connection")); gtk_notebook_append_page(GTK_NOTEBOOK(win->notebook), BuildSettingsTab(win, config), gtk_label_new("Settings")); gtk_notebook_append_page(GTK_NOTEBOOK(win->notebook), - BuildMonitorTab(win, config), gtk_label_new("Monitor Layout")); + BuildMonitorTab(win, config), gtk_label_new("Monitor layout")); gtk_notebook_append_page(GTK_NOTEBOOK(win->notebook), BuildAndroidTab(win, config), gtk_label_new("Android")); ApplyModeSensitivity(win); gtk_widget_show_all(win->window); - gtk_notebook_set_current_page(GTK_NOTEBOOK(win->notebook), 2); + gtk_notebook_set_current_page(GTK_NOTEBOOK(win->notebook), 0); return win; } @@ -752,6 +901,7 @@ void GuiMainWindow::UpdateStatus(const std::string& state, const std::string& de else if (state == "failed") text = "Error"; else text = state.empty() ? "Unknown" : state; gtk_label_set_text(GTK_LABEL(stateLabel), text.c_str()); + atk_object_set_name(gtk_widget_get_accessible(dotArea), text.c_str()); } if (detailLabel && !detail.empty()) { gtk_label_set_text(GTK_LABEL(detailLabel), detail.c_str()); diff --git a/src/LibeiInputCaptureBridge.cpp b/src/LibeiInputCaptureBridge.cpp index 62f58c4..4ae6c78 100644 --- a/src/LibeiInputCaptureBridge.cpp +++ b/src/LibeiInputCaptureBridge.cpp @@ -15,7 +15,6 @@ #if defined(MWB_HAVE_LIBEI_INPUT_CAPTURE) || defined(MWB_HAVE_LIBINPUT_GESTURES) #include -#include #endif #include @@ -23,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -31,6 +31,8 @@ #include #include #include +#include +#include #include #include @@ -211,7 +213,11 @@ std::optional FindTouchpadEventDevice() { break; } const auto eventName = entry.path().filename().string(); - if (eventName.rfind("event", 0) != 0) { + if (eventName.size() <= 5 || + eventName.rfind("event", 0) != 0 || + !std::all_of(eventName.begin() + 5, eventName.end(), [](unsigned char ch) { + return std::isdigit(ch) != 0; + })) { continue; } std::ifstream nameFile(entry.path() / "device" / "name"); @@ -254,7 +260,9 @@ bool EmitClassifiedSwipe( const double absX = std::abs(dx); const double absY = std::abs(dy); - if (absY < 36.0 || absY < absX * 1.35) { + const bool vertical = absY >= 36.0 && absY >= absX * 1.35; + const bool horizontal = absX >= 36.0 && absX >= absY * 1.35; + if (!vertical && !horizontal) { return false; } @@ -289,12 +297,13 @@ bool RunNativeLibinputGestureMonitor( libinput_device* inputDevice = libinput_path_add_device(context, device.c_str()); if (!inputDevice) { - std::cerr << "WARN: Failed to open " << device << " through native libinput." << std::endl; + std::cerr << "WARN: Failed to open the selected device through native libinput." + << std::endl; libinput_unref(context); return false; } - std::cout << "[ANDROID] native libinput gesture monitor using " << device << std::endl; + std::cout << "[ANDROID] Native libinput gesture monitor started." << std::endl; bool swipeActive = false; int swipeFingers = 0; @@ -735,6 +744,22 @@ std::optional DominantEdgeFromDelta(double dx, double dy) { return std::nullopt; } +// Normalized margin the pointer is seeded *inside* the entry edge when capture +// engages. Entering exactly on the edge (0 or 65535) sits on the release +// threshold, so the tiniest opposite jitter would instantly bounce control back +// to the local desktop. Seeding inside this margin gives the handoff hysteresis. +constexpr int kEntryMargin = 1500; + +int NudgeInward(int value) { + if (value <= 0) { + return kEntryMargin; + } + if (value >= 65535) { + return 65535 - kEntryMargin; + } + return value; +} + bool MovementReturnsToLocal(const CaptureTarget& target, int x, int y, double dx, double dy) { switch (target.entryEdge) { case EdgeDirection::Left: @@ -964,55 +989,63 @@ void LibeiInputCaptureBridge::Run() { return; } - const auto sessionHandle = CreateSession(connection); - if (!sessionHandle.has_value()) { - g_object_unref(connection); - m_running = false; - return; - } - - std::vector captureTargets = BuildTopologyCaptureTargets(m_options); - if (captureTargets.empty()) { - captureTargets = BuildFallbackAndroidCaptureTarget(m_options); - } - - const auto zone = GetZones(connection, *sessionHandle); - if (!zone.has_value() || !SetPointerBarriers(connection, *sessionHandle, *zone, captureTargets)) { - CloseSession(connection, *sessionHandle); - g_object_unref(connection); - m_running = false; - return; - } + // The xdg-desktop-portal InputCapture handshake can display an interactive + // permission dialog. Never repeat the whole handshake automatically: a + // timeout or dismissal must not flood the desktop with permission prompts. + // A deliberate service restart is the retry action. + std::optional sessionHandle; + std::vector captureTargets; + ei* context = nullptr; + std::optional eisFd; + bool captureReady = false; + + for (int attempt = 1; + attempt <= kLibeiInputCaptureAutomaticSetupAttempts && m_running; + ++attempt) { + sessionHandle = CreateSession(connection); + if (sessionHandle.has_value()) { + captureTargets = BuildTopologyCaptureTargets(m_options); + if (captureTargets.empty()) { + captureTargets = BuildFallbackAndroidCaptureTarget(m_options); + } - const auto eisFd = ConnectToEis(connection, *sessionHandle); - if (!eisFd.has_value()) { - CloseSession(connection, *sessionHandle); - g_object_unref(connection); - m_running = false; - return; - } + const auto zone = GetZones(connection, *sessionHandle); + if (zone.has_value() && + SetPointerBarriers(connection, *sessionHandle, *zone, captureTargets)) { + eisFd = ConnectToEis(connection, *sessionHandle); + if (eisFd.has_value()) { + context = ei_new_receiver(this); + if (context && ei_setup_backend_fd(context, *eisFd) >= 0 && + EnableCapture(connection, *sessionHandle)) { + captureReady = true; + break; + } + if (context) { + ei_unref(context); + context = nullptr; + } + close(*eisFd); + eisFd.reset(); + } + } + CloseSession(connection, *sessionHandle); + sessionHandle.reset(); + } - ei* context = ei_new_receiver(this); - if (!context) { - close(*eisFd); - CloseSession(connection, *sessionHandle); - g_object_unref(connection); - m_running = false; - return; - } - if (ei_setup_backend_fd(context, *eisFd) < 0) { - std::cerr << "WARN: libei input capture failed to initialize EIS fd." << std::endl; - ei_unref(context); - CloseSession(connection, *sessionHandle); - g_object_unref(connection); - m_running = false; - return; } - if (!EnableCapture(connection, *sessionHandle)) { - ei_unref(context); - CloseSession(connection, *sessionHandle); + if (!captureReady) { + if (context) { + ei_unref(context); + } + if (eisFd.has_value()) { + close(*eisFd); + } g_object_unref(connection); + std::cerr << "ERROR: libei input capture handshake failed; automatic retry " + "suppressed to avoid repeated permission prompts. Restart InputFlow " + "to try again." + << std::endl; m_running = false; return; } @@ -1021,7 +1054,6 @@ void LibeiInputCaptureBridge::Run() { << " topology barrier(s)." << std::endl; int remoteX = captureTargets.front().startX; int remoteY = captureTargets.front().startY; - int keyboardEventsLogged = 0; bool remoteControlActive = false; std::optional activeTarget; const std::string androidMachineId = m_options.androidPeerName.empty() ? "android" : m_options.androidPeerName; @@ -1062,8 +1094,8 @@ void LibeiInputCaptureBridge::Run() { return false; } activeTarget = *it; - remoteX = activeTarget->startX; - remoteY = activeTarget->startY; + remoteX = NudgeInward(activeTarget->startX); + remoteY = NudgeInward(activeTarget->startY); std::cout << "[TOPOLOGY] Captured local pointer through " << edgeDirectionName(activeTarget->exitEdge) << " edge for target " << activeTarget->machineId << "." << std::endl; @@ -1178,12 +1210,6 @@ void LibeiInputCaptureBridge::Run() { const auto vk = LinuxKeyToVirtualKey(key); if (vk.has_value() && activeTarget.has_value()) { KeyboardData keyboard{*vk, press ? 0u : LLKHF_UP}; - if (keyboardEventsLogged < 20) { - std::cout << "[TOPOLOGY] libei keyboard event key=" << key - << " vk=" << *vk - << " press=" << (press ? "true" : "false") << std::endl; - ++keyboardEventsLogged; - } sendKeyboard(keyboard); } break; @@ -1222,17 +1248,66 @@ void LibeiInputCaptureBridge::RunLibinputGestureMonitor() { std::cerr << "WARN: Falling back to libinput debug-events gesture parser." << std::endl; #endif - std::cout << "[ANDROID] libinput gesture monitor using " << *device << std::endl; + std::cout << "[ANDROID] Libinput gesture monitor started." << std::endl; while (m_running) { - const std::string command = - "sh -c 'for fd in /proc/$$/fd/[3-9]*; do " - "[ -e \"$fd\" ] && eval \"exec ${fd##*/}<&-\"; " - "done; exec timeout 1s libinput debug-events --device " + *device + " 2>/dev/null'"; - FILE* pipe = popen(command.c_str(), "r"); - if (!pipe) { + constexpr const char* timeoutPath = "/usr/bin/timeout"; + constexpr const char* libinputPath = "/usr/bin/libinput"; + if (access(timeoutPath, X_OK) != 0 || access(libinputPath, X_OK) != 0) { + std::cerr << "WARN: The libinput gesture helper is unavailable." << std::endl; + return; + } + int outputPipe[2]{-1, -1}; + if (pipe(outputPipe) != 0) { std::cerr << "WARN: Failed to start libinput gesture monitor." << std::endl; return; } + const pid_t child = fork(); + if (child < 0) { + close(outputPipe[0]); + close(outputPipe[1]); + std::cerr << "WARN: Failed to start libinput gesture monitor." << std::endl; + return; + } + if (child == 0) { + close(outputPipe[0]); + dup2(outputPipe[1], STDOUT_FILENO); + if (outputPipe[1] > STDERR_FILENO) { + close(outputPipe[1]); + } + const int devNull = open("/dev/null", O_WRONLY); + if (devNull >= 0) { + dup2(devNull, STDERR_FILENO); + if (devNull > STDERR_FILENO) { + close(devNull); + } + } + const long reportedOpenMax = sysconf(_SC_OPEN_MAX); + const int openMax = reportedOpenMax > 3 + ? static_cast(std::min(reportedOpenMax, 4096)) + : 1024; + for (int fd = 3; fd < openMax; ++fd) { + close(fd); + } + execl( + timeoutPath, + timeoutPath, + "1s", + libinputPath, + "debug-events", + "--device", + device->c_str(), + static_cast(nullptr)); + _exit(127); + } + close(outputPipe[1]); + FILE* output = fdopen(outputPipe[0], "r"); + if (output == nullptr) { + close(outputPipe[0]); + int status = 0; + (void)waitpid(child, &status, 0); + std::cerr << "WARN: Failed to read libinput gesture monitor." << std::endl; + return; + } char buffer[512]; bool swipeActive = false; @@ -1242,7 +1317,7 @@ void LibeiInputCaptureBridge::RunLibinputGestureMonitor() { double swipeDy = 0.0; double pinchScale = 1.0; - while (m_running && fgets(buffer, sizeof(buffer), pipe) != nullptr) { + while (m_running && fgets(buffer, sizeof(buffer), output) != nullptr) { const std::string line(buffer); std::istringstream in(line); std::string deviceToken; @@ -1292,7 +1367,10 @@ void LibeiInputCaptureBridge::RunLibinputGestureMonitor() { } } - pclose(pipe); + fclose(output); + int status = 0; + while (waitpid(child, &status, 0) < 0 && errno == EINTR) { + } } } diff --git a/src/LibeiInputCaptureBridge.h b/src/LibeiInputCaptureBridge.h index 6f147bf..2272eea 100644 --- a/src/LibeiInputCaptureBridge.h +++ b/src/LibeiInputCaptureBridge.h @@ -11,6 +11,11 @@ namespace mwb { +// The portal handshake can display an interactive desktop permission dialog. +// Never repeat it automatically: a timeout or dismissal must require an +// explicit service restart before another prompt is requested. +inline constexpr int kLibeiInputCaptureAutomaticSetupAttempts = 1; + struct LibeiInputCaptureBridgeOptions { int desktopWidth{0}; int desktopHeight{0}; diff --git a/src/LocalAndroidInputBridge.cpp b/src/LocalAndroidInputBridge.cpp index 94afe91..e0fbb11 100644 --- a/src/LocalAndroidInputBridge.cpp +++ b/src/LocalAndroidInputBridge.cpp @@ -48,6 +48,7 @@ struct DeviceState { int relDx{0}; int relDy{0}; int wheel{0}; + int hWheel{0}; bool hasAbsX{false}; bool hasAbsY{false}; bool hasTouchState{false}; @@ -408,6 +409,10 @@ void LocalAndroidInputBridge::Run() { activeEntryEdge = transition->entryEdge; androidX = targetPoint->x; androidY = targetPoint->y; + if (m_options.sendControl && !m_options.sendControl(true)) { + active = false; + return; + } std::cout << "[ANDROID] Local pointer entered Android from " << edgeDirectionName(transition->exitEdge) << " edge using " << devices[deviceIndex].name @@ -418,10 +423,16 @@ void LocalAndroidInputBridge::Run() { androidY = ClampNormalized(androidY + normalizedDy); if (!sendMove()) { active = false; + if (m_options.sendControl) { + m_options.sendControl(false); + } return; } if (MovementReturnsFromEdge(activeEntryEdge, androidX, androidY, normalizedDx, normalizedDy)) { active = false; + if (m_options.sendControl) { + m_options.sendControl(false); + } std::cout << "[ANDROID] Local pointer returned to Fedora." << std::endl; } }; @@ -464,6 +475,8 @@ void LocalAndroidInputBridge::Run() { device.relDy += event.value; } else if (event.code == REL_WHEEL) { device.wheel += event.value; + } else if (event.code == REL_HWHEEL) { + device.hWheel += event.value; } } else if (event.type == EV_ABS) { if (event.code == ABS_X && device.hasAbsX) { @@ -516,14 +529,21 @@ void LocalAndroidInputBridge::Run() { MouseData wheel{androidX, androidY, device.wheel * 120, WM_MOUSEWHEEL}; m_options.sendMouse(wheel); } + if (active && device.hWheel != 0 && m_options.sendGesture) { + m_options.sendGesture("scroll", device.hWheel * 8.0, 0.0); + } device.relDx = 0; device.relDy = 0; device.wheel = 0; + device.hWheel = 0; } } } } + if (active && m_options.sendControl) { + m_options.sendControl(false); + } ClosePointerDevices(devices); XCloseDisplay(display); } diff --git a/src/LocalAndroidInputBridge.h b/src/LocalAndroidInputBridge.h index 831a185..ccc2255 100644 --- a/src/LocalAndroidInputBridge.h +++ b/src/LocalAndroidInputBridge.h @@ -18,6 +18,8 @@ struct LocalAndroidInputBridgeOptions { int desktopWidth{0}; int desktopHeight{0}; std::function sendMouse; + std::function sendGesture; + std::function sendControl; }; class LocalAndroidInputBridge { diff --git a/src/NetworkManager.cpp b/src/NetworkManager.cpp index 2d9c08e..f548149 100644 --- a/src/NetworkManager.cpp +++ b/src/NetworkManager.cpp @@ -264,7 +264,14 @@ std::optional ResolveConfiguredHostAddress(const std::string& host) { hints.ai_socktype = SOCK_STREAM; struct addrinfo* results = nullptr; - if (getaddrinfo(host.c_str(), nullptr, &hints, &results) != 0 || results == nullptr) { + int status = getaddrinfo(host.c_str(), nullptr, &hints, &results); + if ((status != 0 || results == nullptr) && + host.find('.') == std::string::npos && host != "localhost" && !host.empty()) { + // Bare hostname unresolved — retry the mDNS form (Windows answers .local). + if (results != nullptr) { freeaddrinfo(results); results = nullptr; } + status = getaddrinfo((host + ".local").c_str(), nullptr, &hints, &results); + } + if (status != 0 || results == nullptr) { return std::nullopt; } @@ -589,7 +596,27 @@ bool connectToRemoteSocket(const std::string& host, int port, int& fd) { struct addrinfo* results = nullptr; const std::string service = std::to_string(port); - const int resolveStatus = getaddrinfo(host.c_str(), service.c_str(), &hints, &results); + + // A bare Windows hostname (e.g. "M0491") has no DNS/NetBIOS record on Linux, + // but the same machine answers mDNS as ".local". So if a dotless host + // fails to resolve, transparently retry the mDNS form before giving up. + std::vector candidates{host}; + if (host.find('.') == std::string::npos && + host != "localhost" && !host.empty()) { + candidates.push_back(host + ".local"); + } + + int resolveStatus = EAI_NONAME; + for (const std::string& candidate : candidates) { + resolveStatus = getaddrinfo(candidate.c_str(), service.c_str(), &hints, &results); + if (resolveStatus == 0 && results != nullptr) { + break; + } + if (results != nullptr) { + freeaddrinfo(results); + results = nullptr; + } + } if (resolveStatus != 0 || results == nullptr) { if (resolveStatus != 0) { SetLastConnectError(std::string("name resolution failed: ") + gai_strerror(resolveStatus)); @@ -905,16 +932,14 @@ bool NetworkManager::HasActiveSessionPeer(uint32_t remoteMachineId) { bool NetworkManager::TryAcquireInboundControlSession(int fd, uint32_t remoteMachineId) { std::lock_guard lock(m_inboundControlSessionMutex); if (m_activeInboundControlFd >= 0 && m_activeInboundControlFd != fd) { - std::cerr << "[SERVER] Rejecting authenticated inbound control session for peer 0x" - << std::hex << remoteMachineId - << ": another inbound control session is already active for this host." - << std::dec << std::endl; + std::cerr << "[SERVER] Rejecting authenticated inbound control session: " + "another session is already active." + << std::endl; return false; } if (DebugNetworkLoggingEnabled()) { - std::cout << "[SERVER] Acquired inbound control session for peer 0x" - << std::hex << remoteMachineId << std::dec << std::endl; + std::cout << "[SERVER] Acquired authenticated inbound control session." << std::endl; } m_activeInboundControlFd = fd; m_activeInboundControlRemoteMachineId = remoteMachineId; @@ -978,8 +1003,8 @@ bool NetworkManager::TryRefreshHostFromResolver() { if (m_host == *resolved) { return false; } - std::cout << "[RECONNECT] Peer address changed to " << *resolved - << " via rediscovery; resetting backoff." << std::endl; + std::cout << "[RECONNECT] Peer address changed via rediscovery; resetting backoff." + << std::endl; m_host = *resolved; } @@ -1147,13 +1172,11 @@ bool NetworkManager::ConnectOutbound(std::array& handshakeChallenge const std::string host = HostSnapshot(); if (!connectToRemoteSocket(host, m_port, m_socket)) { - std::cerr << "[OUTBOUND] connect to " << host << ":" << m_port - << " failed: " << GetLastConnectError() << std::endl; + std::cerr << "[OUTBOUND] Connection failed: " << GetLastConnectError() << std::endl; return false; } - printf("[OUTBOUND] Connected to %s:%d\n", host.c_str(), m_port); - fflush(stdout); + std::cout << "[OUTBOUND] Connected to the approved peer." << std::endl; if (const auto peerAddress = GetPeerIpv4Address(m_socket); peerAddress.has_value()) { m_expectedPeerAddress = *peerAddress; @@ -1698,8 +1721,8 @@ void NetworkManager::RequestRemoteClipboard(uint32_t expectedRemoteMachineId) { const uint32_t remoteDestination = le32toh(remoteHeader.des); if (remoteSource != expectedRemoteMachineId || !packetTargetsMachine(remoteDestination, m_myId)) { - std::cerr << "WARN: Rejecting clipboard pull handshake from unexpected remote peer 0x" - << std::hex << remoteSource << std::dec << std::endl; + std::cerr << "WARN: Rejecting clipboard pull handshake from an unexpected peer." + << std::endl; closeSocket(fd); return; } @@ -1790,8 +1813,8 @@ void NetworkManager::PushClipboardToRemote(uint32_t expectedRemoteMachineId) { const uint32_t remoteDestination = le32toh(remoteHeader.des); if (remoteSource != expectedRemoteMachineId || !packetTargetsMachine(remoteDestination, m_myId)) { - std::cerr << "WARN: Rejecting clipboard push handshake from unexpected remote peer 0x" - << std::hex << remoteSource << std::dec << std::endl; + std::cerr << "WARN: Rejecting clipboard push handshake from an unexpected peer." + << std::endl; closeSocket(fd); return; } @@ -2209,8 +2232,8 @@ void NetworkManager::ServerListenerLoop() { } if (!AddressMatchesConfiguredHost(clientAddress, HostSnapshot(), m_expectedPeerAddress.load())) { - std::cerr << "[SERVER] Rejected inbound connection from unexpected peer " - << FormatIpv4Address(clientAddress.sin_addr) << std::endl; + std::cerr << "[SERVER] Rejected inbound connection from an unexpected peer." + << std::endl; closeSocket(clientFd); continue; } @@ -2291,7 +2314,6 @@ void NetworkManager::HandleWindowsConnection(int fd) { return; } - int handshakePackets = 0; bool trusted = false; uint32_t remoteMachineId = 0; @@ -2311,10 +2333,9 @@ void NetworkManager::HandleWindowsConnection(int fd) { const uint32_t destination = le32toh(packet.des); if (packet.type == static_cast(PackageType::Handshake)) { - ++handshakePackets; if (!packetTargetsMachine(destination, m_myId)) { - std::cerr << "[SERVER] Ignoring handshake packet for unexpected destination 0x" - << std::hex << destination << std::dec << std::endl; + std::cerr << "[SERVER] Ignoring handshake packet for an unexpected destination." + << std::endl; continue; } if (source != 0) { @@ -2363,8 +2384,7 @@ void NetworkManager::HandleWindowsConnection(int fd) { } ownsInboundControlSession = true; if (DebugNetworkLoggingEnabled()) { - std::cout << "[SERVER] Inbound handshake trusted peer 0x" - << std::hex << remoteMachineId << std::dec << std::endl; + std::cout << "[SERVER] Inbound handshake authenticated an approved peer." << std::endl; } RegisterActiveSessionPeer(remoteMachineId); MWBPacket heartbeat; @@ -2421,9 +2441,8 @@ void NetworkManager::HandleWindowsConnection(int fd) { } else if (type == static_cast(PackageType::Handshake) && packetTargetsMachine(destination, m_myId)) { if (!controlSourceMatchesBoundPeer) { - std::cerr << "[SERVER] Ignoring control handshake that claims a different peer id 0x" - << std::hex << source << " for active peer 0x" << remoteMachineId - << std::dec << std::endl; + std::cerr << "[SERVER] Ignoring control handshake with a mismatched peer id." + << std::endl; continue; } MWBPacket ack; @@ -2439,9 +2458,8 @@ void NetworkManager::HandleWindowsConnection(int fd) { type == static_cast(PackageType::Awake)) && packetTargetsMachine(destination, m_myId)) { if (!controlSourceMatchesBoundPeer) { - std::cerr << "[SERVER] Ignoring control hello that claims a different peer id 0x" - << std::hex << source << " for active peer 0x" << remoteMachineId - << std::dec << std::endl; + std::cerr << "[SERVER] Ignoring control hello with a mismatched peer id." + << std::endl; continue; } MWBPacket heartbeat; diff --git a/src/PeerRecovery.cpp b/src/PeerRecovery.cpp index b23509c..4095a1d 100644 --- a/src/PeerRecovery.cpp +++ b/src/PeerRecovery.cpp @@ -307,9 +307,7 @@ std::optional RecoverConfiguredHostFromKnownPeers(const AppConfig& const auto knownPeerHosts = CollectRecoveryCandidateHosts(state, config.host, config.port); for (const auto& host : knownPeerHosts) { if (auto reachable = ProbeReachableIpv4Host(host, config.port, 250)) { - std::cout << "[RECOVERY] Configured peer " << config.host - << " has a verified same-name address " - << *reachable; + std::cout << "[RECOVERY] Found a verified approved peer address"; if (configuredHostIsIpv4) { std::cout << "; using name-priority recovery before trusting the configured IP"; } else { @@ -337,16 +335,12 @@ std::optional RecoverConfiguredHostFromKnownPeers(const AppConfig& !HostLabelsMatch(candidate.hostName, config.host)) { continue; } - std::cout << "[RECOVERY] Configured peer " << config.host - << " resolved by LAN discovery to " - << candidate.ipAddress << " (name=" << candidate.hostName << ")" << std::endl; + std::cout << "[RECOVERY] Resolved the approved peer through LAN discovery." << std::endl; return candidate.ipAddress; } } for (const auto& host : CollectRecoveryDiscoveredHosts(state, config.host, config.port, candidates)) { - std::cout << "[RECOVERY] Configured peer " << config.host - << " is unavailable; using discovered address " - << host << " for the approved peer name" << std::endl; + std::cout << "[RECOVERY] Using the verified address of an approved peer." << std::endl; return host; } @@ -377,9 +371,7 @@ std::optional RecoverConfiguredHostFromKnownPeers(const AppConfig& continue; } if (HostLabelsMatch(candidate.hostName, solePeer->name)) { - std::cout << "[RECOVERY] Stale configured IP " << config.host - << " is unreachable; sole approved peer '" << solePeer->name - << "' rediscovered at " << candidate.ipAddress << std::endl; + std::cout << "[RECOVERY] Rediscovered the sole approved peer." << std::endl; return candidate.ipAddress; } } diff --git a/src/SecureFile.cpp b/src/SecureFile.cpp new file mode 100644 index 0000000..9196378 --- /dev/null +++ b/src/SecureFile.cpp @@ -0,0 +1,125 @@ +#include "SecureFile.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace mwb { +namespace { + +std::string ErrorText(const std::string& action, const std::filesystem::path& path, int errorNumber) { + return action + " '" + path.string() + "': " + std::strerror(errorNumber); +} + +} // namespace + +bool WritePrivateFileAtomically(const std::filesystem::path& path, + std::string_view contents, + std::string& errorMessage) { + if (path.empty() || path.filename().empty()) { + errorMessage = "Private file path must include a file name."; + return false; + } + + const std::filesystem::path parent = + path.parent_path().empty() ? std::filesystem::path(".") : path.parent_path(); + std::error_code filesystemError; + const bool parentExisted = std::filesystem::exists(parent, filesystemError); + if (filesystemError) { + errorMessage = "Could not inspect private file directory '" + parent.string() + + "': " + filesystemError.message(); + return false; + } + + std::filesystem::create_directories(parent, filesystemError); + if (filesystemError) { + errorMessage = "Could not create private file directory '" + parent.string() + + "': " + filesystemError.message(); + return false; + } + if (!parentExisted) { + std::filesystem::permissions( + parent, + std::filesystem::perms::owner_all, + std::filesystem::perm_options::replace, + filesystemError); + if (filesystemError) { + errorMessage = "Could not secure private file directory '" + parent.string() + + "': " + filesystemError.message(); + return false; + } + } + + const auto destinationStatus = std::filesystem::symlink_status(path, filesystemError); + if (filesystemError && + filesystemError != std::make_error_code(std::errc::no_such_file_or_directory)) { + errorMessage = "Could not inspect private file destination '" + path.string() + + "': " + filesystemError.message(); + return false; + } + if (!filesystemError && std::filesystem::exists(destinationStatus) && + !std::filesystem::is_regular_file(destinationStatus)) { + errorMessage = "Refusing to replace non-regular private file destination: " + path.string(); + return false; + } + + static std::atomic sequence{0}; + const std::filesystem::path temporary = + parent / ("." + path.filename().string() + ".tmp." + std::to_string(getpid()) + "." + + std::to_string(sequence.fetch_add(1, std::memory_order_relaxed))); + const int descriptor = open( + temporary.c_str(), + O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, + S_IRUSR | S_IWUSR); + if (descriptor < 0) { + errorMessage = ErrorText("Could not create temporary private file", temporary, errno); + return false; + } + + bool success = true; + std::size_t offset = 0; + while (offset < contents.size()) { + const ssize_t written = write(descriptor, contents.data() + offset, contents.size() - offset); + if (written < 0 && errno == EINTR) { + continue; + } + if (written <= 0) { + errorMessage = ErrorText("Could not write temporary private file", temporary, errno); + success = false; + break; + } + offset += static_cast(written); + } + if (success && fsync(descriptor) != 0) { + errorMessage = ErrorText("Could not sync temporary private file", temporary, errno); + success = false; + } + if (close(descriptor) != 0 && success) { + errorMessage = ErrorText("Could not close temporary private file", temporary, errno); + success = false; + } + + if (!success) { + unlink(temporary.c_str()); + return false; + } + if (rename(temporary.c_str(), path.c_str()) != 0) { + errorMessage = ErrorText("Could not atomically replace private file", path, errno); + unlink(temporary.c_str()); + return false; + } + + const int parentDescriptor = open(parent.c_str(), O_RDONLY | O_DIRECTORY | O_CLOEXEC); + if (parentDescriptor >= 0) { + (void)fsync(parentDescriptor); + (void)close(parentDescriptor); + } + return true; +} + +} // namespace mwb diff --git a/src/SecureFile.h b/src/SecureFile.h new file mode 100644 index 0000000..3e9f673 --- /dev/null +++ b/src/SecureFile.h @@ -0,0 +1,16 @@ +#pragma once + +#include +#include +#include + +namespace mwb { + +// Atomically replaces a regular file with owner-only permissions. Symlink and +// special-file destinations are rejected so config/state writes cannot be +// redirected outside the intended application directory. +bool WritePrivateFileAtomically(const std::filesystem::path& path, + std::string_view contents, + std::string& errorMessage); + +} // namespace mwb diff --git a/src/TrayController.cpp b/src/TrayController.cpp index 3942d2d..62e4f32 100644 --- a/src/TrayController.cpp +++ b/src/TrayController.cpp @@ -69,21 +69,47 @@ struct TrayContext { #endif }; -std::optional RunCommandCapture(const std::string& command) { - std::array buffer{}; - std::string output; - - FILE* pipe = popen(command.c_str(), "r"); - if (pipe == nullptr) { +std::optional RunCommandCapture(const std::vector& argv) { + if (argv.empty()) { return std::nullopt; } - - while (fgets(buffer.data(), static_cast(buffer.size()), pipe) != nullptr) { - output.append(buffer.data()); + std::vector args; + args.reserve(argv.size() + 1); + for (const auto& arg : argv) { + args.push_back(const_cast(arg.c_str())); } + args.push_back(nullptr); - const int rc = pclose(pipe); - if (rc != 0 && output.empty()) { + gchar* stdoutData = nullptr; + gchar* stderrData = nullptr; + gint waitStatus = 0; + GError* error = nullptr; + const gboolean spawned = g_spawn_sync( + nullptr, + args.data(), + nullptr, + G_SPAWN_SEARCH_PATH, + nullptr, + nullptr, + &stdoutData, + &stderrData, + &waitStatus, + &error); + std::string output = stdoutData == nullptr ? "" : stdoutData; + g_free(stdoutData); + g_free(stderrData); + if (!spawned) { + if (error != nullptr) { + g_error_free(error); + } + return std::nullopt; + } + GError* waitError = nullptr; + const bool succeeded = g_spawn_check_wait_status(waitStatus, &waitError); + if (waitError != nullptr) { + g_error_free(waitError); + } + if (!succeeded && output.empty()) { return std::nullopt; } @@ -93,6 +119,11 @@ std::optional RunCommandCapture(const std::string& command) { return output; } +bool ServiceProcessAppearsRunning() { + const auto result = RunCommandCapture({"pgrep", "-f", "[/]mwb_client run --config"}); + return result.has_value() && !result->empty(); +} + std::int64_t CurrentEpochSeconds() { return std::chrono::duration_cast( std::chrono::system_clock::now().time_since_epoch()) @@ -109,11 +140,48 @@ void SaveStateOrLog(const std::string& statePath, const mwb::AppState& state) { // Lock the local desktop session. Tries logind first (works headless and on // most modern desktops), then falls back to common screen lockers. void LockLocalSession() { - if (std::system("loginctl lock-session >/dev/null 2>&1") == 0) { - return; + const std::vector> commands{ + {"loginctl", "lock-session"}, + {"loginctl", "lock-sessions"}, + {"xdg-screensaver", "lock"}, + {"qdbus", "org.freedesktop.ScreenSaver", "/ScreenSaver", "Lock"}, + }; + for (const auto& command : commands) { + std::vector args; + args.reserve(command.size() + 1); + for (const auto& arg : command) { + args.push_back(const_cast(arg.c_str())); + } + args.push_back(nullptr); + gint waitStatus = 0; + GError* error = nullptr; + const gboolean spawned = g_spawn_sync( + nullptr, + args.data(), + nullptr, + static_cast( + G_SPAWN_SEARCH_PATH | + G_SPAWN_STDOUT_TO_DEV_NULL | + G_SPAWN_STDERR_TO_DEV_NULL), + nullptr, + nullptr, + nullptr, + nullptr, + &waitStatus, + &error); + if (error != nullptr) { + g_error_free(error); + } + GError* waitError = nullptr; + const bool succeeded = + spawned && g_spawn_check_wait_status(waitStatus, &waitError); + if (waitError != nullptr) { + g_error_free(waitError); + } + if (succeeded) { + return; + } } - (void)std::system("(loginctl lock-sessions || xdg-screensaver lock || " - "qdbus org.freedesktop.ScreenSaver /ScreenSaver Lock) >/dev/null 2>&1"); } bool SpawnAsync(const std::vector& argv) { @@ -218,7 +286,7 @@ int AcquireSingleInstanceLock() { return -1; } -std::string ResolveControllerPath() { +[[maybe_unused]] std::string ResolveControllerPath() { std::vector candidates; if (const auto exePath = ResolveExecutablePath()) { const auto exeDir = exePath->parent_path(); @@ -281,11 +349,22 @@ std::string ResolveIconThemePath() { } std::string QueryServiceState() { + const bool daemonRunning = ServiceProcessAppearsRunning(); if (access(kSystemctlPath, X_OK) != 0) { - return "unknown"; + return daemonRunning ? "active" : "inactive"; + } + const auto result = RunCommandCapture( + {kSystemctlPath, "--user", "is-active", kServiceName}); + if (!result.has_value() || result->empty() || *result == "unknown") { + return daemonRunning ? "active" : "inactive"; } - const auto result = RunCommandCapture(std::string(kSystemctlPath) + " --user is-active " + std::string(kServiceName) + " 2>/dev/null"); - return result.value_or("unknown"); + if ((*result == "active" || *result == "deactivating") && !daemonRunning) { + return "inactive"; + } + if (*result == "inactive" && daemonRunning) { + return "active"; + } + return *result; } std::string DescribeState(const std::string& state) { @@ -357,7 +436,7 @@ bool ShouldShowStartupHint() { return isatty(STDIN_FILENO) != 0; } -void MaybeShowStartupHint(const TrayContext& context) { +[[maybe_unused]] void MaybeShowStartupHint(const TrayContext& context) { if (!ShouldShowStartupHint()) { return; } @@ -498,10 +577,15 @@ bool LaunchController(TrayContext* context, const std::vector& cont return false; } +gboolean RefreshStatusOnce(gpointer userData); + void RunServiceAction(TrayContext* context, const std::string& action, const std::string& optimisticState) { if (access(kSystemctlPath, X_OK) == 0 && SpawnAsync({kSystemctlPath, "--user", action, kServiceName})) { UpdateIndicatorVisuals(context, optimisticState); + g_timeout_add_seconds(1, RefreshStatusOnce, context); + g_timeout_add_seconds(3, RefreshStatusOnce, context); + g_timeout_add_seconds(8, RefreshStatusOnce, context); return; } @@ -518,6 +602,12 @@ gboolean RefreshStatus(gpointer userData) { return G_SOURCE_CONTINUE; } +gboolean RefreshStatusOnce(gpointer userData) { + auto* context = static_cast(userData); + UpdateIndicatorVisuals(context, QueryServiceState()); + return G_SOURCE_REMOVE; +} + void OnOpenController(GtkMenuItem*, gpointer userData) { auto* context = static_cast(userData); #ifdef MWB_HAVE_GTK_GUI @@ -697,10 +787,17 @@ void BuildTrayMenu(TrayContext& context) { AddMenuItem(menu, "Quit", G_CALLBACK(OnQuit), &context); gtk_widget_show_all(menu); +#if defined(__GNUC__) +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" +#endif context.indicator = app_indicator_new( kIndicatorId, context.iconThemePath.empty() ? "network-idle-symbolic" : "inputflow-tray", APP_INDICATOR_CATEGORY_APPLICATION_STATUS); +#if defined(__GNUC__) +#pragma GCC diagnostic pop +#endif if (!context.iconThemePath.empty()) { app_indicator_set_icon_theme_path(context.indicator, context.iconThemePath.c_str()); app_indicator_set_attention_icon_full(context.indicator, "inputflow-tray-attention", "InputFlow needs attention"); @@ -714,7 +811,7 @@ void BuildTrayMenu(TrayContext& context) { UpdateIndicatorVisuals(&context, QueryServiceState()); app_indicator_set_status(context.indicator, APP_INDICATOR_STATUS_ACTIVE); - g_timeout_add_seconds(30, RefreshStatus, &context); + g_timeout_add_seconds(5, RefreshStatus, &context); } } // namespace @@ -783,6 +880,8 @@ int RunTrayAndGui(const std::string& binary, const AppConfig& config, const std::string& configPath, const std::string& statePath) { + (void)binary; + (void)args; const int instanceLockFd = AcquireSingleInstanceLock(); if (instanceLockFd == -2) { std::cerr << "InputFlow GUI is already running." << std::endl; @@ -867,6 +966,7 @@ int RunTrayAndGui(const std::string& binary, options.androidRelay.layoutEditorEnabled = runtimeConfig.androidLayoutEditorEnabled; options.androidRelay.androidDeviceWidth = runtimeConfig.androidDeviceWidth; options.androidRelay.androidDeviceHeight = runtimeConfig.androidDeviceHeight; + options.androidRelay.notificationSyncEnabled = runtimeConfig.notificationSyncEnabled; options.onSessionEstablished = [&](const std::string& host, int port, const std::string& remoteName, uint32_t, uint32_t localMachineId) { diff --git a/src/main.cpp b/src/main.cpp index 2b3f4ae..8dd778e 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -24,6 +24,7 @@ #include #include #include +#include #include #include #include @@ -128,8 +129,41 @@ std::filesystem::path DefaultUserServicePath() { return std::filesystem::current_path() / "mwb-client.service"; } -bool CommandSucceeds(const std::string& command) { - return std::system((command + " >/dev/null 2>&1").c_str()) == 0; +bool CommandSucceeds(std::initializer_list command) { + if (command.size() == 0) { + return false; + } + std::vector argv; + argv.reserve(command.size() + 1); + for (const char* argument : command) { + argv.push_back(const_cast(argument)); + } + argv.push_back(nullptr); + + const pid_t child = fork(); + if (child < 0) { + return false; + } + if (child == 0) { + const int devNull = open("/dev/null", O_RDWR); + if (devNull >= 0) { + dup2(devNull, STDOUT_FILENO); + dup2(devNull, STDERR_FILENO); + if (devNull > STDERR_FILENO) { + close(devNull); + } + } + execvp(argv.front(), argv.data()); + _exit(127); + } + + int status = 0; + while (waitpid(child, &status, 0) < 0) { + if (errno != EINTR) { + return false; + } + } + return WIFEXITED(status) && WEXITSTATUS(status) == 0; } std::string GroupName(gid_t gid) { @@ -990,9 +1024,7 @@ std::optional TryRecoverHostFromKnownPeers(const mwb::AppConfig& co const auto knownPeerHosts = mwb::CollectRecoveryCandidateHosts(state, config.host, config.port); for (const auto& host : knownPeerHosts) { if (auto reachable = ProbeReachableIpv4Host(host, config.port, 250)) { - std::cout << "[RECOVERY] Configured peer " << config.host - << " has a verified same-name address " - << *reachable; + std::cout << "[RECOVERY] Found a verified approved peer address"; if (configuredHostIsIpv4) { std::cout << "; using name-priority recovery before trusting the configured IP"; } else { @@ -1020,16 +1052,12 @@ std::optional TryRecoverHostFromKnownPeers(const mwb::AppConfig& co !mwb::HostLabelsMatch(candidate.hostName, config.host)) { continue; } - std::cout << "[RECOVERY] Configured peer " << config.host - << " resolved by LAN discovery to " - << candidate.ipAddress << " (name=" << candidate.hostName << ")" << std::endl; + std::cout << "[RECOVERY] Resolved the approved peer through LAN discovery." << std::endl; return candidate.ipAddress; } } for (const auto& host : mwb::CollectRecoveryDiscoveredHosts(state, config.host, config.port, candidates)) { - std::cout << "[RECOVERY] Configured peer " << config.host - << " is unavailable; using discovered address " - << host << " for the approved peer name" << std::endl; + std::cout << "[RECOVERY] Using the verified address of an approved peer." << std::endl; return host; } @@ -1240,6 +1268,7 @@ int RunClient(const mwb::AppConfig& config, options.androidRelay.layoutEditorEnabled = runtimeConfig.androidLayoutEditorEnabled; options.androidRelay.androidDeviceWidth = runtimeConfig.androidDeviceWidth; options.androidRelay.androidDeviceHeight = runtimeConfig.androidDeviceHeight; + options.androidRelay.notificationSyncEnabled = runtimeConfig.notificationSyncEnabled; options.onSessionEstablished = [&](const std::string& host, int port, const std::string& remoteName, uint32_t, uint32_t localMachineId) { std::lock_guard lock(stateMutex); mwb::MarkSessionEstablished(state, host, port, remoteName, localMachineId, CurrentEpochSeconds()); @@ -1745,6 +1774,8 @@ int HandleDoctorCommand(const std::vector& args) { " port=" + std::to_string(config.androidRelayPort) + " peer=" + (config.androidPeerName.empty() ? "" : config.androidPeerName) + " secret=" + (config.androidRelaySecret.empty() ? "missing" : "configured")); + PrintDoctorLine("INFO", "notification sync", + config.notificationSyncEnabled ? "enabled" : "disabled"); PrintDoctorLine("INFO", "reconnect", "initial=" + std::to_string(config.reconnectInitialBackoffMs) + "ms max=" + std::to_string(config.reconnectMaxBackoffMs) + "ms idle=" + std::to_string(config.reconnectIdleRetryMs) + "ms"); @@ -1874,7 +1905,9 @@ int HandleDoctorCommand(const std::vector& args) { if (!FindExecutableInPath("busctl")) { PrintDoctorLine("INFO", "xdg portal", "busctl unavailable; portal service not checked"); - } else if (CommandSucceeds("busctl --user --no-pager status org.freedesktop.portal.Desktop")) { + } else if (CommandSucceeds( + {"busctl", "--user", "--no-pager", "status", + "org.freedesktop.portal.Desktop"})) { PrintDoctorLine("OK", "xdg portal", "org.freedesktop.portal.Desktop is reachable"); } else if (FileExistsAny({"/usr/libexec/xdg-desktop-portal", "/usr/lib/xdg-desktop-portal"})) { PrintDoctorLine("WARN", "xdg portal", "portal executable is installed but the user service is not reachable"); @@ -1885,11 +1918,13 @@ int HandleDoctorCommand(const std::vector& args) { const std::filesystem::path unitPath = DefaultUserServicePath(); PrintDoctorLine(std::filesystem::exists(unitPath) ? "OK" : "INFO", "user service", unitPath.string()); if (FindExecutableInPath("systemctl")) { - if (!CommandSucceeds("systemctl --user show-environment")) { + if (!CommandSucceeds({"systemctl", "--user", "show-environment"})) { PrintDoctorLine("WARN", "service state", "systemctl --user is unavailable in this environment"); - } else if (CommandSucceeds("systemctl --user is-active --quiet mwb-client.service")) { + } else if (CommandSucceeds( + {"systemctl", "--user", "is-active", "--quiet", "mwb-client.service"})) { PrintDoctorLine("OK", "service state", "mwb-client.service is active"); - } else if (CommandSucceeds("systemctl --user is-enabled --quiet mwb-client.service")) { + } else if (CommandSucceeds( + {"systemctl", "--user", "is-enabled", "--quiet", "mwb-client.service"})) { PrintDoctorLine("INFO", "service state", "mwb-client.service is enabled but not active"); } else { PrintDoctorLine("INFO", "service state", "mwb-client.service is not active/enabled for this user"); @@ -2189,8 +2224,9 @@ int HandleExportWindowsPairCommand(const std::vector& args) { } else if (const auto detected = DetectOutboundLocalIpv4(config.host, config.port); detected.has_value()) { linuxIp = *detected; } else { - std::cerr << "ERR: Failed to detect the Linux IPv4 address for host '" << config.host - << "'. Pass --linux-ip explicitly." << std::endl; + std::cerr << "ERR: Failed to detect the Linux IPv4 address for the configured host. " + "Pass --linux-ip explicitly." + << std::endl; return 1; } @@ -2544,7 +2580,7 @@ int HandleInstallUserServiceCommand(const std::vector& args) { << "[Service]\n" << "Type=simple\n" << "ExecStart=" << executablePath.string() << " run --config " << configPath.string() << "\n" - << "Restart=always\n" + << "Restart=on-failure\n" << "RestartSec=3\n\n" << "[Install]\n" << "WantedBy=default.target\n"; diff --git a/tests/test_diagnostics_privacy.sh b/tests/test_diagnostics_privacy.sh new file mode 100755 index 0000000..dd183a7 --- /dev/null +++ b/tests/test_diagnostics_privacy.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_PATH="${1:?usage: test_diagnostics_privacy.sh SCRIPT_PATH}" +TEST_ROOT="$(mktemp -d)" +trap 'rm -rf "$TEST_ROOT"' EXIT + +CONFIG_PATH="$TEST_ROOT/config.ini" +STATE_PATH="$TEST_ROOT/state.ini" +OUTPUT_PATH="$TEST_ROOT/output" +SECRET_MARKER="InputFlowSecret-MUST-NOT-LEAK-9482" +HOST_MARKER="private-laptop-must-not-leak" +USER_MARKER="private-user-must-not-leak" +IP_MARKER="203.0.113.77" +MAC_MARKER="02:11:22:33:44:55" + +mkdir -p "$OUTPUT_PATH" +cat >"$CONFIG_PATH" <"$STATE_PATH" <&2 + exit 1 +fi + +grep -F '"automatic_upload": false' "$BUNDLE_PATH/consent.json" >/dev/null +grep -F '"service_journal": false' "$BUNDLE_PATH/consent.json" >/dev/null +grep -F '"network_details": false' "$BUNDLE_PATH/consent.json" >/dev/null +grep -F 'Host: [REDACTED]' "$BUNDLE_PATH/README.txt" >/dev/null +grep -F 'User: [REDACTED]' "$BUNDLE_PATH/README.txt" >/dev/null + +while IFS= read -r file; do + [[ "$(stat -c '%a' "$file")" == "600" ]] +done < <(find "$BUNDLE_PATH" -type f -print) + +echo "diagnostics privacy checks passed" diff --git a/tests/test_main.cpp b/tests/test_main.cpp index 10e7c4d..12b6859 100644 --- a/tests/test_main.cpp +++ b/tests/test_main.cpp @@ -7,6 +7,7 @@ #include "AppConfig.h" #include "AppState.h" #include "Discovery.h" +#include "LibeiInputCaptureBridge.h" #include "PeerRecovery.h" #include "ReconnectPolicy.h" #include "ScreenGeometry.h" @@ -185,6 +186,63 @@ void TestAndroidRelayFrames() { Expect(keyboardFrame.find("\"type\":\"keyboard\"") != std::string::npos, "Android keyboard frame should include type"); Expect(keyboardFrame.find("\"vkCode\":65") != std::string::npos, "Android keyboard frame should include vkCode"); Expect(keyboardFrame.find("\"flags\":128") != std::string::npos, "Android keyboard frame should include flags"); + + constexpr const char* message = "The quick brown fox jumps over the lazy dog"; + constexpr const char* validHmac = + "f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8"; + Expect( + mwb::VerifyAndroidRelayHmac("key", message, validHmac), + "Android relay should accept a valid HMAC-SHA256 authenticator"); + Expect( + !mwb::VerifyAndroidRelayHmac("key", message, std::string(64, '0')), + "Android relay should reject an incorrect authenticator"); + Expect( + !mwb::VerifyAndroidRelayHmac("key", message, "short"), + "Android relay should reject an authenticator with the wrong length"); + + constexpr const char* nonce = "00112233445566778899aabbccddeeff"; + const auto encrypted = mwb::EncryptAndroidRelayFrameForTest( + keyboardFrame, + "0123456789abcdef0123456789abcdef", + nonce, + 0, + true); + Expect(encrypted.has_value(), "Android relay should encrypt a valid frame"); + if (encrypted.has_value()) { + Expect( + encrypted->find("\"keyboard\"") == std::string::npos, + "Encrypted Android relay frames must not expose input metadata"); + const auto decrypted = mwb::DecryptAndroidRelayFrameForTest( + *encrypted, + "0123456789abcdef0123456789abcdef", + nonce, + 0, + true); + Expect( + decrypted == std::optional(keyboardFrame), + "Android relay encrypted frames should round-trip"); + Expect( + !mwb::DecryptAndroidRelayFrameForTest( + *encrypted, + "0123456789abcdef0123456789abcdef", + nonce, + 1, + true) + .has_value(), + "Android relay should reject a replayed sequence"); + + std::string tampered = *encrypted; + tampered.back() ^= 1; + Expect( + !mwb::DecryptAndroidRelayFrameForTest( + tampered, + "0123456789abcdef0123456789abcdef", + nonce, + 0, + true) + .has_value(), + "Android relay should reject tampered ciphertext"); + } } void TestAppConfigKeyFileRoundTrip() { @@ -408,6 +466,54 @@ void TestAppStateRoundTrip() { std::filesystem::remove(path, ignore); } +void TestPrivateFileSecurity() { + const std::filesystem::path root = MakeTempPath("mwb-private-file-security"); + std::error_code ignore; + std::filesystem::remove_all(root, ignore); + std::filesystem::create_directories(root); + + mwb::AppConfig config; + config.host = "192.0.2.50"; + config.key = "private-test-key"; + const std::filesystem::path configPath = root / "config.ini"; + std::string error; + Expect(mwb::WriteAppConfig(configPath, config, &error), + "WriteAppConfig should atomically write a private file"); + + const auto configPermissions = std::filesystem::status(configPath).permissions(); + const auto nonOwnerPermissions = + std::filesystem::perms::group_all | std::filesystem::perms::others_all; + Expect((configPermissions & nonOwnerPermissions) == std::filesystem::perms::none, + "Config file must not grant group or other permissions"); + + mwb::AppState state; + state.localMachineId = 0x10203040; + const std::filesystem::path statePath = root / "state.ini"; + Expect(mwb::SaveAppState(statePath, state, error), + "SaveAppState should atomically write a private file"); + const auto statePermissions = std::filesystem::status(statePath).permissions(); + Expect((statePermissions & nonOwnerPermissions) == std::filesystem::perms::none, + "State file must not grant group or other permissions"); + + const std::filesystem::path protectedTarget = root / "protected-target.txt"; + { + std::ofstream target(protectedTarget); + target << "must remain unchanged"; + } + const std::filesystem::path symlinkPath = root / "config-link.ini"; + std::filesystem::create_symlink(protectedTarget, symlinkPath); + error.clear(); + Expect(!mwb::WriteAppConfig(symlinkPath, config, &error), + "WriteAppConfig must reject a symlink destination"); + std::ifstream protectedInput(protectedTarget); + std::string protectedContents; + std::getline(protectedInput, protectedContents); + Expect(protectedContents == "must remain unchanged", + "Rejected symlink write must not alter its target"); + + std::filesystem::remove_all(root, ignore); +} + void TestEnsureLocalMachineIdStable() { mwb::AppState state; const uint32_t first = mwb::EnsureLocalMachineId(state); @@ -660,6 +766,11 @@ void TestKScreenDoctorParserIgnoresAnsiSequences() { "KScreen parser should ignore ANSI color codes"); } +void TestLibeiPortalPermissionIsNotAutomaticallyRetried() { + Expect(mwb::kLibeiInputCaptureAutomaticSetupAttempts == 1, + "interactive libei portal setup must issue at most one automatic request"); +} + } // namespace int main() { @@ -672,6 +783,7 @@ int main() { TestParseAppConfigKeyFileOverridesInlineKey(); TestParseAppConfigKeySecretIdOverridesKeyAndKeyFile(); TestAppStateRoundTrip(); + TestPrivateFileSecurity(); TestEnsureLocalMachineIdStable(); TestUpsertPeerState(); TestSessionStateTransitions(); @@ -687,6 +799,7 @@ int main() { TestKScreenDoctorSingleOutputGeometry(); TestKScreenDoctorMultiOutputBoundingBox(); TestKScreenDoctorParserIgnoresAnsiSequences(); + TestLibeiPortalPermissionIsNotAutomaticallyRetried(); if (g_failures == 0) { std::cout << "All tests passed." << std::endl; diff --git a/tests/test_portable_archive.sh b/tests/test_portable_archive.sh new file mode 100755 index 0000000..4be4df6 --- /dev/null +++ b/tests/test_portable_archive.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +set -euo pipefail + +ARCHIVE="${1:?usage: test_portable_archive.sh ARCHIVE}" +CHECKSUM="${ARCHIVE}.sha256" + +[[ -f "$ARCHIVE" ]] || { echo "missing archive: $ARCHIVE" >&2; exit 1; } +[[ -f "$CHECKSUM" ]] || { echo "missing checksum: $CHECKSUM" >&2; exit 1; } + +ARCHIVE_DIR="$(cd "$(dirname "$ARCHIVE")" && pwd -P)" +ARCHIVE_NAME="$(basename "$ARCHIVE")" +(cd "$ARCHIVE_DIR" && sha256sum --check "$(basename "$CHECKSUM")") + +TEST_ROOT="$(mktemp -d)" +cleanup() { + if [[ -n "$TEST_ROOT" && -d "$TEST_ROOT" && "$TEST_ROOT" != "/" ]]; then + rm -rf -- "$TEST_ROOT" + fi +} +trap cleanup EXIT + +tar -xzf "$ARCHIVE" -C "$TEST_ROOT" +mapfile -t PACKAGE_ROOTS < <(find "$TEST_ROOT" -mindepth 1 -maxdepth 1 -type d) +[[ "${#PACKAGE_ROOTS[@]}" -eq 1 ]] || { + echo "archive must contain exactly one package root" >&2 + exit 1 +} +PACKAGE_ROOT="${PACKAGE_ROOTS[0]}" + +for executable in \ + bin/mwb_client \ + bin/mwb_tray \ + bin/inputflow-controller \ + libexec/inputflow/mwb-desktop-ui.sh \ + libexec/inputflow/scripts/inputflow-diagnostics-bundle.sh; do + [[ -x "$PACKAGE_ROOT/$executable" ]] || { + echo "missing executable: $executable" >&2 + exit 1 + } +done + +mkdir -p "$TEST_ROOT/home" "$TEST_ROOT/output" +HOME="$TEST_ROOT/home" \ +XDG_CONFIG_HOME="$TEST_ROOT/home/config" \ +XDG_STATE_HOME="$TEST_ROOT/home/state" \ +PATH="$PACKAGE_ROOT/bin:$PATH" \ + "$PACKAGE_ROOT/bin/mwb_client" --help >/dev/null +HOME="$TEST_ROOT/home" \ +XDG_CONFIG_HOME="$TEST_ROOT/home/config" \ +XDG_STATE_HOME="$TEST_ROOT/home/state" \ +PATH="$PACKAGE_ROOT/bin:$PATH" \ + "$PACKAGE_ROOT/libexec/inputflow/scripts/inputflow-diagnostics-bundle.sh" \ + --preview --output "$TEST_ROOT/output" >/dev/null + +echo "portable archive smoke test passed" diff --git a/tests/test_protocol_security.cpp b/tests/test_protocol_security.cpp index 00787da..f364629 100644 --- a/tests/test_protocol_security.cpp +++ b/tests/test_protocol_security.cpp @@ -277,6 +277,15 @@ void TestClipboardPayloadInvalidDeflateReturnsNullopt() { "Clipboard text decode should continue rejecting malformed compressed data"); } +void TestClipboardUnicodeRoundTrip() { + const std::string text = u8"InputFlow café — 设备 🫏"; + const auto decoded = + mwb::ClipboardManager::DecodeTextPayload(mwb::ClipboardManager::EncodeTextPayload(text)); + Expect( + decoded == std::optional(text), + "Clipboard payload should round-trip BMP and supplementary Unicode text"); +} + } // namespace int main() { @@ -292,6 +301,7 @@ int main() { TestClipboardPayloadPrefersPlainTextOverHtml(); TestClipboardDecodeNormalizesOffsetBasedCfHtml(); TestClipboardPayloadInvalidDeflateReturnsNullopt(); + TestClipboardUnicodeRoundTrip(); if (g_failures != 0) { std::cerr << g_failures << " protocol security test(s) failed." << std::endl; diff --git a/tools/icon-generator/build-masters.js b/tools/icon-generator/build-masters.js index f63819f..2115cf1 100644 --- a/tools/icon-generator/build-masters.js +++ b/tools/icon-generator/build-masters.js @@ -2,154 +2,63 @@ // so the colored logo and the monochrome tray/notification glyphs stay identical. const fs = require('fs'); const path = require('path'); +const { wrap, donkeyColor, donkeyMono, cursor, gradientDefs } = require('./donkey'); + const OUT = path.join(__dirname, 'masters'); fs.mkdirSync(OUT, { recursive: true }); -// ---- shared donkey geometry (authored in a 1024 box, centred ~ (512,500)) ---- -const D = { - earL: 't(450,432) r(-20)', - earR: 't(574,432) r(20)', -}; - -// colored, multi-part donkey (for the brand logo) -const donkeyColor = ` - - - - - - - - - - - - - - - - - - - - - - -`; - -// monochrome donkey via a mask (solid ink face with knocked-out eyes / inner-ears / -// nostrils) -> survives single-colour theme tinting on Linux tray + Android notif. -function donkeyMono(id, ink) { - return ` - - - - - - - - - - - - - - - - - - - - - - - `; -} - -// corner status badge: a solid disc with a symbol knocked out (evenodd), plus a -// transparent gap ring so it separates from the donkey under any tint. -function badge(symbolPath) { +// corner status badge sized for tiny system-tray slots. +function badge() { const cx = 768, cy = 760, r = 150; // bottom-right - return { cx, cy, r, symbolPath }; -} - -function wrap(inner, extra='') { - return `${extra}${inner}\n`; + return { cx, cy, r }; } // 1) BRAND LOGO (color, gradient squircle bg + donkey + cursor) -const logo = wrap(` - - - - - - +const logo = wrap(`${gradientDefs} ${donkeyColor} - - - `); + ${cursor}`); fs.writeFileSync(path.join(OUT,'inputflow-logo.svg'), logo); // foreground-only donkey (transparent) for Android adaptive + in-app, re-centred const fg = wrap(`${donkeyColor}`); fs.writeFileSync(path.join(OUT,'inputflow-foreground.svg'), fg); -// 2) TRAY default (mono) -fs.writeFileSync(path.join(OUT,'inputflow-tray.svg'), wrap(donkeyMono('m0','#2E3440'))); +const trayViewBox = '0 0 1024 1024'; + +function trayBase(ink) { + return ` + + ${donkeyMono('tray-dk', '#FFFFFF')}`; +} + +// 2) TRAY default (active/healthy) +fs.writeFileSync(path.join(OUT,'inputflow-tray.svg'), + wrap(trayBase('#10B981'), '', trayViewBox)); -// tray variants: donkey + corner badge. badge drawn as disc with knocked-out symbol, -// and we punch a transparent ring out of the donkey so the badge reads cleanly. -function trayVariant(name, symbolInner, ink='#2E3440') { +// tray variants: colored tile + white donkey + readable status badge. +function trayVariant(name, symbolInner, ink) { const b = badge(); const inner = ` - - - - - - - - - - - - - - - - - - - - - - - - ${symbolInner(b)}`; - fs.writeFileSync(path.join(OUT,`inputflow-tray-${name}.svg`), wrap(inner)); + ${trayBase(ink)} + + ${symbolInner(b)}`; + fs.writeFileSync(path.join(OUT,`inputflow-tray-${name}.svg`), wrap(inner, '', trayViewBox)); } -// attention: disc with "!" knocked out +// attention: "!" inside the white badge trayVariant('attention', b => ` - `); + + `, '#EF4444'); -// busy: disc with two pause-bars knocked out +// busy: two pause bars inside the white badge trayVariant('busy', b => ` - `); + + `, '#F59E0B'); -// offline: disc with a diagonal slash knocked out +// offline: diagonal slash inside the white badge trayVariant('offline', b => ` - `); + `, '#6B7280'); // 3) NOTIFICATION (white silhouette on transparent, Android tints it) fs.writeFileSync(path.join(OUT,'inputflow-notification.svg'), wrap(donkeyMono('mn','#FFFFFF'))); diff --git a/tools/icon-generator/donkey.js b/tools/icon-generator/donkey.js index d192d40..1d4226c 100644 --- a/tools/icon-generator/donkey.js +++ b/tools/icon-generator/donkey.js @@ -35,27 +35,34 @@ const donkeySilhouette = (ink) => ` `; -// masked monochrome: solid face with knocked-out eyes / inner-ears / nostrils. -// Best for raster tray + notification PNGs (survives theme tinting). -const donkeyMono = (id, ink) => ` - - - +const donkeyInkShapes = ` - - - + `; + +const donkeyKnockoutShapes = ` - + `; + +const donkeyMask = (id, extraKnockouts='') => ` + + + ${donkeyInkShapes} + + ${donkeyKnockoutShapes}${extraKnockouts} +`; + +// masked monochrome: solid face with knocked-out eyes / inner-ears / nostrils. +// Best for raster tray + notification PNGs (survives theme tinting). +const donkeyMono = (id, ink, extraKnockouts='') => `${donkeyMask(id, extraKnockouts)} `; const cursor = ` @@ -65,4 +72,4 @@ const cursor = ` const gradientDefs = ` `; -module.exports = { W, wrap, donkeyColor, donkeySilhouette, donkeyMono, cursor, gradientDefs }; +module.exports = { W, wrap, donkeyColor, donkeySilhouette, donkeyMask, donkeyMono, cursor, gradientDefs };