diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb929e1..f31a486 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,10 @@ concurrency: group: ci-${{ github.ref }} cancel-in-progress: true +env: + CCACHE_DIR: ${{ github.workspace }}/.ccache + CCACHE_MAXSIZE: 500M + jobs: ubuntu-build: name: Ubuntu build and tests @@ -22,19 +26,29 @@ jobs: - name: Check out source uses: actions/checkout@v4 + - name: Restore compiler cache + uses: actions/cache@v4 + with: + path: .ccache + key: ${{ runner.os }}-${{ github.job }}-ccache-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-${{ github.job }}-ccache- + - name: Install dependencies run: | sudo apt-get update sudo apt-get install -y --no-install-recommends \ build-essential \ + ccache \ cmake \ + ninja-build \ libx11-dev \ libssl-dev \ pkg-config \ zlib1g-dev - name: Configure - run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Release + run: cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_CXX_COMPILER_LAUNCHER=ccache - name: Build run: cmake --build build --parallel @@ -42,6 +56,9 @@ jobs: - name: Test run: ctest --test-dir build --output-on-failure + - name: Compiler cache stats + run: ccache --show-stats + fedora-build: name: Fedora build and tests runs-on: ubuntu-latest @@ -55,32 +72,39 @@ jobs: - name: Check out source uses: actions/checkout@v4 + - name: Restore compiler cache + uses: actions/cache@v4 + with: + path: .ccache + key: ${{ runner.os }}-${{ github.job }}-ccache-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-${{ github.job }}-ccache- + - name: Install dependencies run: | dnf install -y \ + ccache \ cmake \ gcc-c++ \ gtk3-devel \ libayatana-appindicator3-devel \ libX11-devel \ make \ + ninja-build \ openssl-devel \ pkgconf-pkg-config \ rpm-build \ systemd-rpm-macros \ zlib-devel - - name: Validate RPM packaging skeleton - run: MWB_VALIDATE_RPM_BUILD=1 scripts/validate-rpm-packaging.sh - - - name: Configure - run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Release - - - name: Build - run: cmake --build build --parallel + - name: Validate RPM packaging, build, and tests + run: | + MWB_VALIDATE_RPM_BUILD=1 \ + MWB_RPM_CMAKE_EXTRA_ARGS="-G Ninja -DCMAKE_CXX_COMPILER_LAUNCHER=ccache" \ + scripts/validate-rpm-packaging.sh - - name: Test - run: ctest --test-dir build --output-on-failure + - name: Compiler cache stats + run: ccache --show-stats sanitizers: name: ASan and UBSan @@ -90,19 +114,29 @@ jobs: - name: Check out source uses: actions/checkout@v4 + - name: Restore compiler cache + uses: actions/cache@v4 + with: + path: .ccache + key: ${{ runner.os }}-${{ github.job }}-ccache-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-${{ github.job }}-ccache- + - name: Install dependencies run: | sudo apt-get update sudo apt-get install -y --no-install-recommends \ build-essential \ + ccache \ cmake \ + ninja-build \ libx11-dev \ libssl-dev \ pkg-config \ zlib1g-dev - name: Configure - run: cmake -S . -B build-sanitize -DCMAKE_BUILD_TYPE=Debug -DMWB_ENABLE_SANITIZERS=ON + run: cmake -S . -B build-sanitize -G Ninja -DCMAKE_BUILD_TYPE=Debug -DMWB_ENABLE_SANITIZERS=ON -DCMAKE_CXX_COMPILER_LAUNCHER=ccache - name: Build run: cmake --build build-sanitize --parallel @@ -110,6 +144,9 @@ jobs: - name: Test run: ctest --test-dir build-sanitize --output-on-failure + - name: Compiler cache stats + run: ccache --show-stats + static-checks: name: Static checks runs-on: ubuntu-latest diff --git a/README.md b/README.md index cade956..03ba778 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,9 @@ User-facing beta operations: 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. ### Configuration (`config.ini`) -Supports `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`. +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`. + +`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. 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. diff --git a/docs/android.md b/docs/android.md index 52062a2..ed25f7c 100644 --- a/docs/android.md +++ b/docs/android.md @@ -7,6 +7,7 @@ InputFlow can expose an experimental Android controlled-peer relay. The Linux cl Add these keys to `~/.config/mwb-client/config.ini`: ```ini +connection_mode=hybrid android_peers_enabled=true android_relay_port=15102 android_relay_secret=replace-with-a-long-random-secret @@ -16,7 +17,7 @@ android_capture_backend=none 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. -The relay is disabled by default. If `android_relay_secret` is empty, the relay does not start. +The relay is disabled by default. If `android_relay_secret` is empty, the relay does not start. Use `connection_mode=inputflow` for Android-only testing without a Windows PowerToys host, or `connection_mode=hybrid` when Windows and Android should both be active. `android_capture_backend` controls Linux-local physical mouse capture: diff --git a/docs/compatibility.md b/docs/compatibility.md index 86f9805..1bf6933 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -20,6 +20,14 @@ InputFlow is a native Linux peer for Microsoft PowerToys Mouse Without Borders ( | Network trust model | Trusted LAN/subnet | Use on a trusted local network. Do not expose MWB ports to untrusted networks or the public internet. | | Display-level topology config | Opt-in | The contract is documented in [Topology Config Contract](topology.md), and the default runtime remains MWB-compatible machine placement unless topology is enabled. | +## Connection Modes + +`connection_mode=powertoys` is the default compatibility mode. It requires a Windows host plus one security-key source and runs the PowerToys/MWB socket protocol. + +`connection_mode=inputflow` disables the PowerToys transport and runs native InputFlow peer services only. In the current beta, that mainly means Android relay/local capture paths; Linux-to-Linux native peer transport is still future work. + +`connection_mode=hybrid` runs both. Use it when this Linux host should stay paired to Windows PowerToys while also exposing InputFlow-native peers such as Android. + ## Linux Session Details X11 is the simpler path because clipboard helpers and desktop automation policy are more predictable. Wayland can work, but compositor policy matters: even with `/dev/uinput` access, the compositor or desktop environment may restrict, gate, or prompt around synthetic input behavior. diff --git a/mwb-desktop-ui.sh b/mwb-desktop-ui.sh index 521d6cb..386c396 100755 --- a/mwb-desktop-ui.sh +++ b/mwb-desktop-ui.sh @@ -6,6 +6,7 @@ SERVICE_NAME="mwb-client.service" CONFIG_PATH="${XDG_CONFIG_HOME:-$HOME/.config}/mwb-client/config.ini" STATE_PATH="${XDG_STATE_HOME:-$HOME/.local/state}/mwb-client/state.ini" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CONNECTION_MODE_CONFIG_KEY="${MWB_CONNECTION_MODE_CONFIG_KEY:-connection_mode}" AUTO_CONNECT_CONFIG_KEY="${MWB_AUTO_CONNECT_CONFIG_KEY:-auto_connect_enabled}" RECONNECT_INITIAL_CONFIG_KEY="${MWB_RECONNECT_INITIAL_CONFIG_KEY:-reconnect_initial_backoff_ms}" RECONNECT_MAX_CONFIG_KEY="${MWB_RECONNECT_MAX_CONFIG_KEY:-reconnect_max_backoff_ms}" @@ -146,6 +147,38 @@ format_epoch_label() { date -d "@$epoch" '+%Y-%m-%d %H:%M:%S' 2>/dev/null || printf '%s' "$epoch" } +read_connection_mode() { + local mode + mode="$(read_config_value "$CONNECTION_MODE_CONFIG_KEY")" + case "$mode" in + inputflow|hybrid|powertoys) printf '%s\n' "$mode" ;; + power_toys|mwb|windows) printf 'powertoys\n' ;; + native|peer|inputflow_peers) printf 'inputflow\n' ;; + both) printf 'hybrid\n' ;; + *) printf 'powertoys\n' ;; + esac +} + +connection_mode_label() { + case "$1" in + inputflow) printf 'InputFlow peers' ;; + hybrid) printf 'Hybrid' ;; + *) printf 'PowerToys compatibility' ;; + esac +} + +connection_mode_from_label() { + case "$1" in + "InputFlow peers") printf 'inputflow\n' ;; + "Hybrid") printf 'hybrid\n' ;; + *) printf 'powertoys\n' ;; + esac +} + +connection_mode_requires_windows() { + [[ "$(read_connection_mode)" != "inputflow" ]] +} + strip_matching_quotes() { local value="$1" if [[ "${#value}" -ge 2 ]]; then @@ -219,7 +252,7 @@ canonical_managed_key() { local candidate case "$input_key" in - host|key|key_file|machine_name|port|screen_width|screen_height|clipboard_enabled|clipboard_send_enabled|clipboard_force_poll|clipboard_poll_ms|"$MPRIS_MEDIA_KEYS_CONFIG_KEY"|"$MPRIS_PLAYER_CONFIG_KEY"|"$LATENCY_REPORT_CONFIG_KEY"|"$AUTO_CONNECT_CONFIG_KEY"|"$RECONNECT_INITIAL_CONFIG_KEY"|"$RECONNECT_MAX_CONFIG_KEY"|"$RECONNECT_IDLE_CONFIG_KEY") + "$CONNECTION_MODE_CONFIG_KEY"|host|key|key_file|machine_name|port|screen_width|screen_height|clipboard_enabled|clipboard_send_enabled|clipboard_force_poll|clipboard_poll_ms|"$MPRIS_MEDIA_KEYS_CONFIG_KEY"|"$MPRIS_PLAYER_CONFIG_KEY"|"$LATENCY_REPORT_CONFIG_KEY"|"$AUTO_CONNECT_CONFIG_KEY"|"$RECONNECT_INITIAL_CONFIG_KEY"|"$RECONNECT_MAX_CONFIG_KEY"|"$RECONNECT_IDLE_CONFIG_KEY") printf '%s\n' "$input_key" return 0 ;; @@ -297,10 +330,12 @@ disable_topology_config() { write_config() { local host="$1" key="$2" key_file="$3" secret_id="$4" machine_name="$5" port="$6" auto_connect_enabled="$7" reconnect_initial_backoff_ms="$8" reconnect_max_backoff_ms="$9" reconnect_idle_retry_ms="${10}" clipboard_enabled="${11}" clipboard_send_enabled="${12}" clipboard_force_poll="${13}" clipboard_poll_ms="${14}" screen_width="${15}" screen_height="${16}" mpris_media_keys_enabled="${17}" mpris_player="${18}" latency_report="${19}" local secret_key_name="${20:-$(detect_secret_id_key_name)}" + local connection_mode="${21:-$(read_connection_mode)}" local tmp_path line existing_key managed_key local -a existing_lines=() - local -a ordered_keys=("host" "key" "key_file" "$secret_key_name" "machine_name" "port" "screen_width" "screen_height" "$AUTO_CONNECT_CONFIG_KEY" "$RECONNECT_INITIAL_CONFIG_KEY" "$RECONNECT_MAX_CONFIG_KEY" "$RECONNECT_IDLE_CONFIG_KEY" "clipboard_enabled" "clipboard_send_enabled" "clipboard_force_poll" "clipboard_poll_ms" "$MPRIS_MEDIA_KEYS_CONFIG_KEY" "$MPRIS_PLAYER_CONFIG_KEY" "$LATENCY_REPORT_CONFIG_KEY") + local -a ordered_keys=("$CONNECTION_MODE_CONFIG_KEY" "host" "key" "key_file" "$secret_key_name" "machine_name" "port" "screen_width" "screen_height" "$AUTO_CONNECT_CONFIG_KEY" "$RECONNECT_INITIAL_CONFIG_KEY" "$RECONNECT_MAX_CONFIG_KEY" "$RECONNECT_IDLE_CONFIG_KEY" "clipboard_enabled" "clipboard_send_enabled" "clipboard_force_poll" "clipboard_poll_ms" "$MPRIS_MEDIA_KEYS_CONFIG_KEY" "$MPRIS_PLAYER_CONFIG_KEY" "$LATENCY_REPORT_CONFIG_KEY") local -A values=( + ["$CONNECTION_MODE_CONFIG_KEY"]="$connection_mode" [host]="$host" [key]="$key" [key_file]="$key_file" @@ -770,8 +805,9 @@ service_state_label() { } menu_summary_text() { - local state host key key_file secret_id auth_label auto_connect_enabled reconnect_initial_backoff_ms reconnect_max_backoff_ms reconnect_idle_retry_ms topology_enabled topology_file topology_label + local state connection_mode host key key_file secret_id auth_label auto_connect_enabled reconnect_initial_backoff_ms reconnect_max_backoff_ms reconnect_idle_retry_ms topology_enabled topology_file topology_label state="$(service_state)" + connection_mode="$(read_connection_mode)" host="$(read_config_value host)" key="$(read_config_value key)" key_file="$(read_config_value key_file)" @@ -788,8 +824,9 @@ menu_summary_text() { topology_label="Disabled" fi - printf 'Status: %s\nHost: %s\nAuth: %s\nReconnect: %s\nTopology: %s' \ + printf 'Status: %s\nMode: %s\nHost: %s\nAuth: %s\nReconnect: %s\nTopology: %s' \ "$(service_state_label "$state")" \ + "$(connection_mode_label "$connection_mode")" \ "$host" \ "$auth_label" \ "$( [[ "$auto_connect_enabled" == "true" ]] && printf 'Auto' || printf 'Manual' )" \ @@ -819,7 +856,8 @@ probe_tcp_port() { health_check() { require_client_binary || return 1 - local host port key key_file secret_id auth_count service_status health_text doctor_text + local connection_mode host port key key_file secret_id auth_count service_status health_text doctor_text + connection_mode="$(read_connection_mode)" host="$(read_config_value host)" port="$(read_config_value port)"; [[ -n "$port" ]] || port="15101" key="$(read_config_value key)" @@ -832,9 +870,15 @@ health_check() { append_check_line "$([[ -f "$CONFIG_PATH" ]] && printf OK || printf WARN)" "config file" "$CONFIG_PATH" append_check_line "$([[ -e /dev/uinput ]] && printf OK || printf WARN)" "uinput device" "$([[ -e /dev/uinput ]] && ls -l /dev/uinput 2>/dev/null || printf 'missing; install udev rule and reload')" append_check_line "$([[ "$service_status" == "active" ]] && printf OK || printf WARN)" "user service" "$(service_state_label "$service_status")" - append_check_line "$([[ -n "$host" ]] && printf OK || printf WARN)" "Windows host" "${host:-not configured}" - append_check_line "$([[ "$auth_count" == "1" ]] && printf OK || printf WARN)" "authentication" "$(configured_auth_label "$key" "$key_file" "$secret_id")" - if [[ -n "$host" ]] && command -v timeout >/dev/null 2>&1; then + append_check_line OK "connection mode" "$(connection_mode_label "$connection_mode")" + if [[ "$connection_mode" != "inputflow" ]]; then + append_check_line "$([[ -n "$host" ]] && printf OK || printf WARN)" "Windows host" "${host:-not configured}" + append_check_line "$([[ "$auth_count" == "1" ]] && printf OK || printf WARN)" "authentication" "$(configured_auth_label "$key" "$key_file" "$secret_id")" + else + append_check_line OK "Windows host" "not required" + append_check_line OK "authentication" "not required" + fi + if [[ "$connection_mode" != "inputflow" && -n "$host" ]] && command -v timeout >/dev/null 2>&1; then if probe_tcp_port "$host" "$port"; then append_check_line OK "input port" "$host:$port reachable" else @@ -845,8 +889,10 @@ health_check() { else append_check_line WARN "clipboard port" "$host:15100 not reachable" fi - else + elif [[ "$connection_mode" != "inputflow" ]]; then append_check_line WARN "port probe" "host or timeout command unavailable" + else + append_check_line OK "port probe" "skipped for inputflow mode" fi )" doctor_text="$("$APP_BIN" doctor --config "$CONFIG_PATH" --state "$STATE_PATH" 2>&1 || true)" @@ -859,8 +905,9 @@ $doctor_text" } connection_quality() { - local host port state auto_connect_enabled reconnect_initial_backoff_ms reconnect_max_backoff_ms reconnect_idle_retry_ms + local connection_mode host port state auto_connect_enabled reconnect_initial_backoff_ms reconnect_max_backoff_ms reconnect_idle_retry_ms local clipboard_enabled clipboard_send_enabled clipboard_force_poll clipboard_poll_ms latency_report quality_text peer_lines + connection_mode="$(read_connection_mode)" host="$(read_config_value host)" port="$(read_config_value port)"; [[ -n "$port" ]] || port="15101" state="$(service_state)" @@ -884,7 +931,8 @@ connection_quality() { [[ -n "$peer_lines" ]] || peer_lines="No peer entries found in $STATE_PATH." fi - quality_text="Service: $(service_state_label "$state") ($state) +quality_text="Service: $(service_state_label "$state") ($state) +Connection mode: $(connection_mode_label "$connection_mode") Configured host: ${host:-not configured} Input port: $port Clipboard port: 15100 @@ -1310,10 +1358,11 @@ guided_pairing() { edit_settings() { local preset_host="${1:-}" - local host key key_file secret_id secret_key_name machine_name port screen_width screen_height auto_connect_enabled reconnect_initial_backoff_ms reconnect_max_backoff_ms reconnect_idle_retry_ms clipboard_enabled clipboard_force_poll clipboard_poll_ms + local connection_mode connection_mode_label_value host key key_file secret_id secret_key_name machine_name port screen_width screen_height auto_connect_enabled reconnect_initial_backoff_ms reconnect_max_backoff_ms reconnect_idle_retry_ms clipboard_enabled clipboard_force_poll clipboard_poll_ms local clipboard_send_enabled current_auth_mode auth_action key_mode cleanup_secret_id saved_message local mpris_media_keys_enabled mpris_player latency_report gui_output + connection_mode="$(read_connection_mode)" host="$(read_config_value host)" key="$(read_config_value key)" key_file="$(read_config_value key_file)" @@ -1334,19 +1383,21 @@ edit_settings() { current_auth_mode="$(configured_auth_mode "$key" "$key_file" "$secret_id")" - local fields="host:Windows Host:entry||machine_name:Local Machine Name:entry||port:Network Port:entry||screen_width:Screen Width:entry||screen_height:Screen Height:entry||clipboard_poll_ms:Clipboard Poll (ms):entry||mpris_player:MPRIS Player:entry||clipboard_enabled:Sync Clipboard:switch||clipboard_send_enabled:Send Local Clipboard:switch||clipboard_force_poll:Force Wayland Polling:switch||mpris_media_keys_enabled:Enable Media Keys:switch||latency_report:Print Latency Report:switch" - local values="${preset_host:-$host}|$machine_name|$port|$screen_width|$screen_height|$clipboard_poll_ms|$mpris_player|$clipboard_enabled|$clipboard_send_enabled|$clipboard_force_poll|$mpris_media_keys_enabled|$latency_report" + connection_mode_label_value="$(connection_mode_label "$connection_mode")" + local fields="connection_mode:Connection Mode|PowerToys compatibility|InputFlow peers|Hybrid:combo||host:Windows Host:entry||machine_name:Local Machine Name:entry||port:Network Port:entry||screen_width:Screen Width:entry||screen_height:Screen Height:entry||clipboard_poll_ms:Clipboard Poll (ms):entry||mpris_player:MPRIS Player:entry||clipboard_enabled:Sync Clipboard:switch||clipboard_send_enabled:Send Local Clipboard:switch||clipboard_force_poll:Force Wayland Polling:switch||mpris_media_keys_enabled:Enable Media Keys:switch||latency_report:Print Latency Report:switch" + local values="$connection_mode_label_value|${preset_host:-$host}|$machine_name|$port|$screen_width|$screen_height|$clipboard_poll_ms|$mpris_player|$clipboard_enabled|$clipboard_send_enabled|$clipboard_force_poll|$mpris_media_keys_enabled|$latency_report" gui_output="$(python3 "$SCRIPT_DIR/src/ConfigDialog.py" "$APP_NAME Settings" "$fields" "$values" || true)" [[ -n "$gui_output" ]] || return 1 - IFS='|' read -r host machine_name port screen_width screen_height clipboard_poll_ms mpris_player clipboard_enabled clipboard_send_enabled clipboard_force_poll mpris_media_keys_enabled latency_report <<< "$gui_output" + IFS='|' read -r connection_mode_label_value host machine_name port screen_width screen_height clipboard_poll_ms mpris_player clipboard_enabled clipboard_send_enabled clipboard_force_poll mpris_media_keys_enabled latency_report <<< "$gui_output" + connection_mode="$(connection_mode_from_label "$connection_mode_label_value")" # Validation if ! is_integer_in_range "$port" 1 65535; then zenity --error --text="Port must be 1-65535."; return 1; fi - # Authentication (Keep Zenity for secret-tool branching) - while true; do + # Authentication is only required for PowerToys compatibility. + while [[ "$connection_mode" != "inputflow" ]]; do auth_action="$(zenity --list --radiolist --title="$APP_NAME Auth" --width=500 --height=220 \ --text="Method: $current_auth_mode" \ --column="Use" --column="Action" \ @@ -1389,7 +1440,7 @@ edit_settings() { break done - write_config "$host" "$key" "$key_file" "$secret_id" "$machine_name" "$port" "$auto_connect_enabled" "$reconnect_initial_backoff_ms" "$reconnect_max_backoff_ms" "$reconnect_idle_retry_ms" "$clipboard_enabled" "$clipboard_send_enabled" "$clipboard_force_poll" "$clipboard_poll_ms" "$screen_width" "$screen_height" "$mpris_media_keys_enabled" "$mpris_player" "$latency_report" "$secret_key_name" + write_config "$host" "$key" "$key_file" "$secret_id" "$machine_name" "$port" "$auto_connect_enabled" "$reconnect_initial_backoff_ms" "$reconnect_max_backoff_ms" "$reconnect_idle_retry_ms" "$clipboard_enabled" "$clipboard_send_enabled" "$clipboard_force_poll" "$clipboard_poll_ms" "$screen_width" "$screen_height" "$mpris_media_keys_enabled" "$mpris_player" "$latency_report" "$secret_key_name" "$connection_mode" zenity --info --text="Settings saved." offer_service_restart_if_active "Settings updated." } @@ -1451,29 +1502,30 @@ start_session() { "$APP_BIN" init-config --config "$CONFIG_PATH" --force >/dev/null fi - local host key key_file secret_id auth_count resolved_key_file + local connection_mode host key key_file secret_id auth_count resolved_key_file + connection_mode="$(read_connection_mode)" host="$(read_config_value host)" key="$(read_config_value key)" key_file="$(read_config_value key_file)" secret_id="$(read_secret_id_value)" auth_count="$(configured_auth_source_count "$key" "$key_file" "$secret_id")" - if [[ -z "$host" ]]; then + if [[ "$connection_mode" != "inputflow" && -z "$host" ]]; then zenity --error --text="Set a Windows host before starting." return 1 fi - if (( auth_count == 0 )); then + if [[ "$connection_mode" != "inputflow" ]] && (( auth_count == 0 )); then zenity --error --text="Set exactly one authentication method before starting: inline key, key file, or Secret Service entry." return 1 fi - if (( auth_count > 1 )); then + if [[ "$connection_mode" != "inputflow" ]] && (( auth_count > 1 )); then zenity --error --text="Multiple authentication methods are configured. Keep only one of: inline key, key file, or Secret Service entry." return 1 fi - if [[ -n "$key_file" ]]; then + if [[ "$connection_mode" != "inputflow" && -n "$key_file" ]]; then resolved_key_file="$(resolve_config_relative_path "$key_file")" if [[ ! -r "$resolved_key_file" ]]; then zenity --error --text="Key file is not readable: $resolved_key_file" @@ -1481,7 +1533,7 @@ start_session() { fi fi - if [[ -n "$secret_id" && -z "$(trim_whitespace "$secret_id")" ]]; then + if [[ "$connection_mode" != "inputflow" && -n "$secret_id" && -z "$(trim_whitespace "$secret_id")" ]]; then zenity --error --text="Secret Service authentication requires a non-empty identifier." return 1 fi diff --git a/packaging/rpm/inputflow.spec b/packaging/rpm/inputflow.spec index df4c264..55db056 100644 --- a/packaging/rpm/inputflow.spec +++ b/packaging/rpm/inputflow.spec @@ -36,7 +36,7 @@ retain the mwb-client naming for compatibility. %autosetup -n %{name}-%{version} %build -%cmake -DMWB_BUILD_TRAY=ON +%cmake -DMWB_BUILD_TRAY=ON %{?mwb_cmake_extra_args} %cmake_build %install @@ -52,6 +52,9 @@ install -Dpm0644 packaging/usr/lib/systemd/user/mwb-client.service %{buildroot}% install -Dpm0644 packaging/usr/share/applications/inputflow.desktop %{buildroot}%{_datadir}/applications/inputflow.desktop install -Dpm0644 assets/icons/inputflow-desktop.svg %{buildroot}%{_datadir}/icons/hicolor/scalable/apps/inputflow.svg +%check +ctest --test-dir %{_vpath_builddir} --output-on-failure + %pre %sysusers_create %{_sysusersdir}/mwb-client.conf diff --git a/scripts/validate-rpm-packaging.sh b/scripts/validate-rpm-packaging.sh index 1a0bb09..ad83b17 100755 --- a/scripts/validate-rpm-packaging.sh +++ b/scripts/validate-rpm-packaging.sh @@ -104,6 +104,8 @@ grep -Eq 'install -Dpm0755 mwb-desktop-ui\.sh .+%\{_libexecdir\}/inputflow/mwb-d grep -Eq 'install -Dpm0755 scripts/inputflow-diagnostics-bundle\.sh .+%\{_libexecdir\}/inputflow/scripts/inputflow-diagnostics-bundle\.sh' "$spec_file" || fail "spec must install the diagnostics bundle executable" grep -Eq 'install -Dpm0644 packaging/usr/share/applications/inputflow\.desktop .+%\{_datadir\}/applications/inputflow\.desktop' "$spec_file" || fail "spec must install the desktop entry" grep -Eq 'install -Dpm0644 assets/icons/inputflow-desktop\.svg .+%\{_datadir\}/icons/hicolor/scalable/apps/inputflow\.svg' "$spec_file" || fail "spec must install the application icon" +grep -Eq '^%check$' "$spec_file" || fail "spec must run package build tests in %check" +grep -Eq 'ctest --test-dir .*\{_vpath_builddir\} --output-on-failure' "$spec_file" || fail "spec %check must run ctest" if command -v desktop-file-validate >/dev/null 2>&1; then run_quiet "desktop-file-validate" desktop-file-validate packaging/usr/share/applications/inputflow.desktop @@ -142,7 +144,11 @@ if command -v rpmbuild >/dev/null 2>&1; then --transform "s#^\\./#${package_name}-${package_version}/#" \ -czf "$topdir/SOURCES/${package_name}-${package_version}.tar.gz" - run_quiet "rpmbuild -bb" rpmbuild -bb --define "_topdir $topdir" "$spec_file" + rpm_args=(--define "_topdir $topdir") + if [[ -n "${MWB_RPM_CMAKE_EXTRA_ARGS:-}" ]]; then + rpm_args+=(--define "mwb_cmake_extra_args ${MWB_RPM_CMAKE_EXTRA_ARGS}") + fi + run_quiet "rpmbuild -bb" rpmbuild -bb "${rpm_args[@]}" "$spec_file" fi fi diff --git a/src/AppConfig.cpp b/src/AppConfig.cpp index 8e23f7e..0e1f58b 100644 --- a/src/AppConfig.cpp +++ b/src/AppConfig.cpp @@ -117,6 +117,40 @@ AppConfig LoadDefaultAppConfig() { return AppConfig{}; } +std::optional ParseConnectionMode(std::string_view value) { + const std::string lowered = ToLower(Trim(value)); + if (lowered == "powertoys" || lowered == "power_toys" || lowered == "mwb" || lowered == "windows") { + return ConnectionMode::PowerToys; + } + if (lowered == "inputflow" || lowered == "inputflow_peers" || lowered == "native" || lowered == "peer") { + return ConnectionMode::InputFlow; + } + if (lowered == "hybrid" || lowered == "both") { + return ConnectionMode::Hybrid; + } + return std::nullopt; +} + +std::string_view ConnectionModeName(ConnectionMode mode) { + switch (mode) { + case ConnectionMode::PowerToys: + return "powertoys"; + case ConnectionMode::InputFlow: + return "inputflow"; + case ConnectionMode::Hybrid: + return "hybrid"; + } + return "powertoys"; +} + +bool PowerToysCompatibilityEnabled(ConnectionMode mode) { + return mode == ConnectionMode::PowerToys || mode == ConnectionMode::Hybrid; +} + +bool InputFlowPeersEnabled(ConnectionMode mode) { + return mode == ConnectionMode::InputFlow || mode == ConnectionMode::Hybrid; +} + std::optional ParseConfigBool(std::string_view value) { const std::string lowered = ToLower(Trim(value)); if (lowered == "1" || lowered == "true" || lowered == "yes" || lowered == "on") { @@ -202,6 +236,16 @@ bool ParseAppConfig(std::string_view text, AppConfig& outConfig, std::string* er return false; } + if (key == "connection_mode" || key == "mode") { + const auto parsed = ParseConnectionMode(value); + if (!parsed.has_value()) { + SetError(errorMessage, "Config key 'connection_mode' expects powertoys, inputflow, or hybrid."); + return false; + } + outConfig.connectionMode = *parsed; + continue; + } + if (key == "host") { outConfig.host = std::string(value); continue; @@ -468,6 +512,7 @@ bool LoadAppConfigFromDefaultPath(AppConfig& outConfig, std::string* errorMessag std::string RenderAppConfig(const AppConfig& config) { std::ostringstream out; + out << "connection_mode=" << ConnectionModeName(config.connectionMode) << '\n'; out << "host=" << config.host << '\n'; out << "key=" << config.key << '\n'; out << "key_file=" << config.keyFile << '\n'; @@ -521,6 +566,7 @@ std::string RenderSampleAppConfig() { out << "# Prefer key_secret_id=... or key_file=... to keep the security key out of this config file.\n"; out << "# key_secret_id uses the desktop keyring via Secret Service when secret-tool is available.\n"; out << "# Relative key_file paths resolve against the directory containing config.ini.\n"; + out << "# connection_mode=powertoys uses PowerToys/MWB compatibility, inputflow uses native InputFlow peers, hybrid enables both.\n"; out << "# Set auto_connect_enabled=false to keep the service idle until you re-enable it.\n"; out << "# Set screen_width and screen_height to your local desktop size when needed.\n"; out << "# Set topology_enabled=true and topology_file=... to enable runtime topology handoff.\n"; diff --git a/src/AppConfig.h b/src/AppConfig.h index 1e4cb9f..e3a7fcb 100644 --- a/src/AppConfig.h +++ b/src/AppConfig.h @@ -7,7 +7,14 @@ namespace mwb { +enum class ConnectionMode { + PowerToys, + InputFlow, + Hybrid, +}; + struct AppConfig { + ConnectionMode connectionMode{ConnectionMode::PowerToys}; std::string host; std::string key; std::string keyFile; @@ -41,6 +48,11 @@ struct AppConfig { AppConfig LoadDefaultAppConfig(); +std::optional ParseConnectionMode(std::string_view value); +std::string_view ConnectionModeName(ConnectionMode mode); +bool PowerToysCompatibilityEnabled(ConnectionMode mode); +bool InputFlowPeersEnabled(ConnectionMode mode); + std::optional ParseConfigBool(std::string_view value); std::optional ParseConfigInt(std::string_view value); std::optional ParseConfigInt(std::string_view value, int minValue, int maxValue); diff --git a/src/ClientRuntime.cpp b/src/ClientRuntime.cpp index 850aa08..618b325 100644 --- a/src/ClientRuntime.cpp +++ b/src/ClientRuntime.cpp @@ -337,7 +337,7 @@ void ClientRuntime::ConfigureTopologyPreview(const ScreenSize& screenSize) { TrySendAndroidMouse(mouse)) { return true; } - return m_network && m_network->SendMouse(mouse); + return m_options.powerToysCompatibilityEnabled && m_network && m_network->SendMouse(mouse); }); std::cout << "[TOPOLOGY] Loaded topology from " << m_options.topologyFilePath.string() @@ -377,27 +377,31 @@ int ClientRuntime::Run() { ConfigureTopologyPreview(screenSize); m_dispatcher.Start(); - m_network = std::make_unique(m_options.host, m_options.port, m_options.key); - m_network->SetScreenSize(screenSize.width, screenSize.height); - m_network->SetAutoConnectEnabled(m_options.autoConnectEnabled); - m_network->SetReconnectBackoff( - m_options.reconnectInitialBackoffMs, - m_options.reconnectMaxBackoffMs, - m_options.reconnectIdleRetryMs); - if (m_options.localMachineId.has_value() || !m_options.localMachineName.empty()) { - m_network->SetLocalIdentity(m_options.localMachineId.value_or(0), m_options.localMachineName); - } - if (m_options.onSessionEstablished) { - m_network->SetOnSessionEstablished(m_options.onSessionEstablished); - } - m_network->SetOnSessionDisconnected([this]() { - m_dispatcher.ResetInputState(); - if (m_options.onSessionDisconnected) { - m_options.onSessionDisconnected(); + if (m_options.powerToysCompatibilityEnabled) { + m_network = std::make_unique(m_options.host, m_options.port, m_options.key); + m_network->SetScreenSize(screenSize.width, screenSize.height); + m_network->SetAutoConnectEnabled(m_options.autoConnectEnabled); + m_network->SetReconnectBackoff( + m_options.reconnectInitialBackoffMs, + m_options.reconnectMaxBackoffMs, + m_options.reconnectIdleRetryMs); + if (m_options.localMachineId.has_value() || !m_options.localMachineName.empty()) { + m_network->SetLocalIdentity(m_options.localMachineId.value_or(0), m_options.localMachineName); } - }); + if (m_options.onSessionEstablished) { + m_network->SetOnSessionEstablished(m_options.onSessionEstablished); + } + m_network->SetOnSessionDisconnected([this]() { + m_dispatcher.ResetInputState(); + if (m_options.onSessionDisconnected) { + m_options.onSessionDisconnected(); + } + }); + } else { + std::cout << "[MODE] PowerToys compatibility disabled; running InputFlow peer services only." << std::endl; + } - if (m_options.clipboardEnabled) { + if (m_network && m_options.clipboardEnabled) { m_clipboard = ClipboardManager::CreateDefault(); } @@ -454,6 +458,8 @@ int ClientRuntime::Run() { }); } else if (!m_options.clipboardEnabled) { std::cerr << "WARN: Clipboard sync disabled by configuration." << std::endl; + } else if (!m_network) { + std::cerr << "WARN: Clipboard sync is disabled because PowerToys compatibility transport is not active." << std::endl; } else { std::cerr << "WARN: No supported clipboard backend detected. Install wl-clipboard for Wayland or xclip/xsel for X11 to enable clipboard sync." << std::endl; } @@ -462,35 +468,37 @@ int ClientRuntime::Run() { std::cerr << "WARN: Local clipboard watch disabled; clipboard is running in receive-only mode." << std::endl; } - m_network->SetOnMouseCallback([this](const MouseData& md) { - if (m_options.debugInputLogging) { - std::cout << "[INPUT] Mouse: x=" << md.x << " y=" << md.y << " wParam=0x" << std::hex << md.wParam << std::dec << std::endl; - } else if (m_options.debugShortcutLogging && md.wParam != 0x0200) { - std::cout << "[INPUT] Mouse event: wParam=0x" << std::hex << md.wParam - << " x=" << std::dec << md.x - << " y=" << md.y - << " mouseData=" << md.mouseData - << std::endl; - } - if (m_androidRelayActive && TrySendAndroidMouse(md)) { - return; - } - m_androidRelayActive = false; - m_dispatcher.SubmitMouse(md); - }); + if (m_network) { + m_network->SetOnMouseCallback([this](const MouseData& md) { + if (m_options.debugInputLogging) { + std::cout << "[INPUT] Mouse: x=" << md.x << " y=" << md.y << " wParam=0x" << std::hex << md.wParam << std::dec << std::endl; + } else if (m_options.debugShortcutLogging && md.wParam != 0x0200) { + std::cout << "[INPUT] Mouse event: wParam=0x" << std::hex << md.wParam + << " x=" << std::dec << md.x + << " y=" << md.y + << " mouseData=" << md.mouseData + << std::endl; + } + if (m_androidRelayActive && TrySendAndroidMouse(md)) { + return; + } + m_androidRelayActive = false; + m_dispatcher.SubmitMouse(md); + }); - m_network->SetOnKeyboardCallback([this](const KeyboardData& kd) { - if (m_options.debugInputLogging || m_options.debugKeyLogging || m_options.debugShortcutLogging) { - std::cout << "[INPUT] Keyboard: vk=0x" << std::hex << kd.vkCode - << " flags=0x" << kd.flags << std::dec << std::endl; - } - if (m_androidRelayActive && TrySendAndroidKeyboard(kd)) { - return; - } - m_dispatcher.SubmitKeyboard(kd); - }); + m_network->SetOnKeyboardCallback([this](const KeyboardData& kd) { + if (m_options.debugInputLogging || m_options.debugKeyLogging || m_options.debugShortcutLogging) { + std::cout << "[INPUT] Keyboard: vk=0x" << std::hex << kd.vkCode + << " flags=0x" << kd.flags << std::dec << std::endl; + } + if (m_androidRelayActive && TrySendAndroidKeyboard(kd)) { + return; + } + m_dispatcher.SubmitKeyboard(kd); + }); + } - if (!m_network->Connect()) { + if (m_network && !m_network->Connect()) { std::cerr << "Terminating: Network failure." << std::endl; m_dispatcher.Stop(); if (m_androidRelay) { @@ -501,12 +509,18 @@ int ClientRuntime::Run() { return 1; } - if (!m_options.autoConnectEnabled) { + if (m_network && !m_options.autoConnectEnabled) { std::cout << "[CONNECT] Auto-connect disabled by configuration; service is idle until you re-enable it." << std::endl; } StartClipboardWatcher(); - m_network->RunLoop(); + if (m_network) { + m_network->RunLoop(); + } else { + while (!m_stopRequested.load()) { + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } + } Stop(); return 0; } @@ -804,7 +818,7 @@ void ClientRuntime::ApplyAndroidTopologyUpdate(const std::string& frameJson) { TrySendAndroidMouse(mouse)) { return true; } - return m_network && m_network->SendMouse(mouse); + return m_options.powerToysCompatibilityEnabled && m_network && m_network->SendMouse(mouse); }); std::cout << "[ANDROID] Applied topology update: linux " << exitEdge diff --git a/src/ClientRuntime.h b/src/ClientRuntime.h index 304f1cc..969abb1 100644 --- a/src/ClientRuntime.h +++ b/src/ClientRuntime.h @@ -43,6 +43,8 @@ struct RuntimeOptions { bool debugKeyLogging{false}; bool debugShortcutLogging{false}; bool latencyReport{false}; + bool powerToysCompatibilityEnabled{true}; + bool inputFlowPeersEnabled{false}; bool topologyRuntimeEnabled{false}; std::filesystem::path topologyFilePath; std::string androidCaptureBackend{"none"}; diff --git a/src/GuiMainWindow.cpp b/src/GuiMainWindow.cpp index f3aa6ee..4449ec5 100644 --- a/src/GuiMainWindow.cpp +++ b/src/GuiMainWindow.cpp @@ -50,12 +50,64 @@ void OnStartService(GtkButton*, gpointer) { RunServiceAction("start"); } void OnStopService(GtkButton*, gpointer) { RunServiceAction("stop"); } void OnRestartService(GtkButton*, gpointer) { RunServiceAction("restart"); } +ConnectionMode ActiveConnectionMode(GuiMainWindow* win) { + if (!win->connectionModeCombo) { + return ConnectionMode::PowerToys; + } + const gchar* activeId = gtk_combo_box_get_active_id(GTK_COMBO_BOX(win->connectionModeCombo)); + if (activeId == nullptr) { + return ConnectionMode::PowerToys; + } + return ParseConnectionMode(activeId).value_or(ConnectionMode::PowerToys); +} + +void ApplyModeSensitivity(GuiMainWindow* win) { + const ConnectionMode mode = ActiveConnectionMode(win); + const gboolean powerToysEnabled = PowerToysCompatibilityEnabled(mode); + const gboolean inputFlowEnabled = InputFlowPeersEnabled(mode); + + GtkWidget* powerToysWidgets[] = { + win->hostEntry, + win->portSpin, + win->keyEntry, + win->autoConnectSwitch, + win->clipboardSwitch, + win->mprisSwitch, + win->mprisPlayerEntry, + }; + for (GtkWidget* widget : powerToysWidgets) { + if (widget) { + gtk_widget_set_sensitive(widget, powerToysEnabled); + } + } + + GtkWidget* inputFlowWidgets[] = { + win->androidSwitch, + win->androidPortSpin, + win->androidSecretEntry, + win->androidNameEntry, + win->androidBackendCombo, + win->androidWidthSpin, + win->androidHeightSpin, + }; + for (GtkWidget* widget : inputFlowWidgets) { + if (widget) { + gtk_widget_set_sensitive(widget, inputFlowEnabled); + } + } +} + +void OnConnectionModeChanged(GtkComboBox*, gpointer data) { + ApplyModeSensitivity(static_cast(data)); +} + // ---- settings save --------------------------------------------------------- void OnSaveSettings(GtkButton*, gpointer data) { auto* win = static_cast(data); AppConfig cfg; + cfg.connectionMode = ActiveConnectionMode(win); cfg.host = gtk_entry_get_text(GTK_ENTRY(win->hostEntry)); cfg.port = static_cast(gtk_spin_button_get_value(GTK_SPIN_BUTTON(win->portSpin))); cfg.machineName = gtk_entry_get_text(GTK_ENTRY(win->nameEntry)); @@ -74,8 +126,8 @@ void OnSaveSettings(GtkButton*, gpointer data) { cfg.androidPeerName = gtk_entry_get_text(GTK_ENTRY(win->androidNameEntry)); cfg.androidDeviceWidth = static_cast(gtk_spin_button_get_value(GTK_SPIN_BUTTON(win->androidWidthSpin))); cfg.androidDeviceHeight = static_cast(gtk_spin_button_get_value(GTK_SPIN_BUTTON(win->androidHeightSpin))); - const gchar* backendText = gtk_combo_box_text_get_active_text(GTK_COMBO_BOX_TEXT(win->androidBackendCombo)); - if (backendText) cfg.androidCaptureBackend = backendText; + const gchar* backendId = gtk_combo_box_get_active_id(GTK_COMBO_BOX(win->androidBackendCombo)); + if (backendId) cfg.androidCaptureBackend = backendId; std::string err; WriteAppConfig(win->configPath, cfg, &err); @@ -196,6 +248,15 @@ GtkWidget* BuildSettingsTab(GuiMainWindow* win, const AppConfig& cfg) { // Section: Connection gtk_box_pack_start(GTK_BOX(box), MakeSectionHeader("Connection"), FALSE, FALSE, 0); + win->connectionModeCombo = gtk_combo_box_text_new(); + gtk_combo_box_text_append(GTK_COMBO_BOX_TEXT(win->connectionModeCombo), "powertoys", "PowerToys compatibility"); + gtk_combo_box_text_append(GTK_COMBO_BOX_TEXT(win->connectionModeCombo), "inputflow", "InputFlow peers"); + gtk_combo_box_text_append(GTK_COMBO_BOX_TEXT(win->connectionModeCombo), "hybrid", "Hybrid"); + const std::string activeMode{ConnectionModeName(cfg.connectionMode)}; + gtk_combo_box_set_active_id(GTK_COMBO_BOX(win->connectionModeCombo), activeMode.c_str()); + g_signal_connect(win->connectionModeCombo, "changed", G_CALLBACK(OnConnectionModeChanged), win); + GridAttach(grid, MakeLabel("Mode"), win->connectionModeCombo, row++); + win->hostEntry = gtk_entry_new(); gtk_entry_set_text(GTK_ENTRY(win->hostEntry), cfg.host.c_str()); gtk_entry_set_placeholder_text(GTK_ENTRY(win->hostEntry), "hostname or IP"); @@ -371,7 +432,7 @@ GtkWidget* BuildAndroidTab(GuiMainWindow* win, const AppConfig& cfg) { win->androidBackendCombo = gtk_combo_box_text_new(); gtk_combo_box_text_append(GTK_COMBO_BOX_TEXT(win->androidBackendCombo), "none", "none"); gtk_combo_box_text_append(GTK_COMBO_BOX_TEXT(win->androidBackendCombo), "libei", "libei"); - gtk_combo_box_text_append(GTK_COMBO_BOX_TEXT(win->androidBackendCombo), "local", "local"); + gtk_combo_box_text_append(GTK_COMBO_BOX_TEXT(win->androidBackendCombo), "evdev", "evdev"); gtk_combo_box_set_active_id(GTK_COMBO_BOX(win->androidBackendCombo), cfg.androidCaptureBackend.empty() ? "none" : cfg.androidCaptureBackend.c_str()); GridAttach(grid, MakeLabel("Capture backend"), win->androidBackendCombo, row++); @@ -431,6 +492,8 @@ GuiMainWindow* CreateMainWindow(const AppConfig& config, gtk_notebook_append_page(GTK_NOTEBOOK(win->notebook), BuildAndroidTab(win, config), gtk_label_new("Android")); + ApplyModeSensitivity(win); + gtk_widget_show_all(win->window); gtk_widget_hide(win->window); // start hidden; shown from tray return win; diff --git a/src/GuiMainWindow.h b/src/GuiMainWindow.h index 833406a..ffa1dba 100644 --- a/src/GuiMainWindow.h +++ b/src/GuiMainWindow.h @@ -24,6 +24,7 @@ struct GuiMainWindow { GtkTextBuffer* logBuf{nullptr}; // Settings tab — connection + GtkWidget* connectionModeCombo{nullptr}; GtkWidget* hostEntry{nullptr}; GtkWidget* portSpin{nullptr}; GtkWidget* nameEntry{nullptr}; diff --git a/src/TrayController.cpp b/src/TrayController.cpp index d40415f..0613a92 100644 --- a/src/TrayController.cpp +++ b/src/TrayController.cpp @@ -804,10 +804,12 @@ int RunTrayAndGui(const std::string& binary, options.localMachineId = state.localMachineId; options.localMachineName = runtimeConfig.machineName; options.latencyReport = runtimeConfig.latencyReport; + options.powerToysCompatibilityEnabled = PowerToysCompatibilityEnabled(runtimeConfig.connectionMode); + options.inputFlowPeersEnabled = InputFlowPeersEnabled(runtimeConfig.connectionMode); options.topologyRuntimeEnabled = runtimeConfig.topologyRuntimeEnabled; options.topologyFilePath = runtimeConfig.topologyFile; options.androidCaptureBackend = runtimeConfig.androidCaptureBackend; - options.androidRelay.enabled = runtimeConfig.androidPeersEnabled; + options.androidRelay.enabled = options.inputFlowPeersEnabled && runtimeConfig.androidPeersEnabled; options.androidRelay.port = runtimeConfig.androidRelayPort; options.androidRelay.secret = runtimeConfig.androidRelaySecret; options.androidRelay.peerName = runtimeConfig.androidPeerName; diff --git a/src/main.cpp b/src/main.cpp index 21c4331..11f8b17 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -57,6 +57,7 @@ void PrintGeneralUsage(std::ostream& out, const char* argv0) { out << " " << binary << " run [--config PATH] [--state PATH] [--host IP] [--key KEY | --key-file PATH | --key-secret-id ID]" << " [--name NAME] [--port PORT]" + << " [--connection-mode powertoys|inputflow|hybrid]" << " [--enable-clipboard | --disable-clipboard | --clipboard-receive-only | --clipboard-full]" << " [--clipboard-force-poll] [--clipboard-poll-ms MS]" << " [--screen-width PX --screen-height PX]" @@ -68,7 +69,7 @@ void PrintGeneralUsage(std::ostream& out, const char* argv0) { out << " " << binary << " doctor [--config PATH] [--state PATH]\n"; out << " " << binary << " android-pair [--config PATH]\n"; out << " " << binary << " topology explain [PATH] [--config PATH]\n"; - out << " " << binary << " init-config [--config PATH] [--force] [--host IP] [--key KEY | --key-file PATH | --key-secret-id ID] [--name NAME] [--port PORT]\n"; + out << " " << binary << " init-config [--config PATH] [--force] [--host IP] [--key KEY | --key-file PATH | --key-secret-id ID] [--name NAME] [--port PORT] [--connection-mode powertoys|inputflow|hybrid]\n"; out << " " << binary << " export-windows-pair [--config PATH] [--output PATH] [--force] [--dry-run] [--check] [--linux-ip IP] [--position auto|top-left|top-right|bottom-left|bottom-right] [--key KEY | --key-file PATH | --key-secret-id ID] [--name NAME]\n"; out << " " << binary << " install-user-service [--config PATH] [--unit PATH] [--force]\n"; out << " " << binary << " secret-store [--config PATH] --secret-id ID [--key KEY | --key-file PATH | --stdin]\n"; @@ -1184,8 +1185,10 @@ int RunClient(const mwb::AppConfig& config, } mwb::AppConfig runtimeConfig = config; - if (const auto recoveredHost = TryRecoverHostFromKnownPeers(config, state); recoveredHost.has_value()) { - runtimeConfig.host = *recoveredHost; + if (mwb::PowerToysCompatibilityEnabled(runtimeConfig.connectionMode)) { + if (const auto recoveredHost = TryRecoverHostFromKnownPeers(config, state); recoveredHost.has_value()) { + runtimeConfig.host = *recoveredHost; + } } std::mutex stateMutex; @@ -1211,10 +1214,12 @@ int RunClient(const mwb::AppConfig& config, options.debugKeyLogging = IsTruthyEnv("MWB_DEBUG_KEYS"); options.debugShortcutLogging = IsTruthyEnv("MWB_DEBUG_SHORTCUTS"); options.latencyReport = runtimeConfig.latencyReport; + options.powerToysCompatibilityEnabled = mwb::PowerToysCompatibilityEnabled(runtimeConfig.connectionMode); + options.inputFlowPeersEnabled = mwb::InputFlowPeersEnabled(runtimeConfig.connectionMode); options.topologyRuntimeEnabled = runtimeConfig.topologyRuntimeEnabled; options.topologyFilePath = runtimeConfig.topologyFile; options.androidCaptureBackend = runtimeConfig.androidCaptureBackend; - options.androidRelay.enabled = runtimeConfig.androidPeersEnabled; + options.androidRelay.enabled = options.inputFlowPeersEnabled && runtimeConfig.androidPeersEnabled; options.androidRelay.port = runtimeConfig.androidRelayPort; options.androidRelay.secret = runtimeConfig.androidRelaySecret; options.androidRelay.peerName = runtimeConfig.androidPeerName; @@ -1385,6 +1390,17 @@ int HandleRunCommand(const std::string& binary, const std::vector& return 1; } config.port = *parsed; + } else if (arg == "--connection-mode" || arg == "--mode") { + const auto value = requireValue(arg.c_str()); + if (!value) { + return 1; + } + const auto parsed = mwb::ParseConnectionMode(*value); + if (!parsed.has_value()) { + std::cerr << "ERR: Invalid connection mode. Use powertoys, inputflow, or hybrid." << std::endl; + return 1; + } + config.connectionMode = *parsed; } else if (arg == "--enable-clipboard") { config.clipboardEnabled = true; config.clipboardSendEnabled = true; @@ -1497,13 +1513,15 @@ int HandleRunCommand(const std::string& binary, const std::vector& config.reconnectIdleRetryMs = config.reconnectMaxBackoffMs; } - std::string keyError; - if (!ResolveConfiguredKey(config, keyFileBaseDir, &keyError)) { - std::cerr << "ERR: " << keyError << std::endl; - return 1; + if (mwb::PowerToysCompatibilityEnabled(config.connectionMode)) { + std::string keyError; + if (!ResolveConfiguredKey(config, keyFileBaseDir, &keyError)) { + std::cerr << "ERR: " << keyError << std::endl; + return 1; + } } - if (config.host.empty() || config.key.empty()) { + if (mwb::PowerToysCompatibilityEnabled(config.connectionMode) && (config.host.empty() || config.key.empty())) { std::cerr << "ERR: Both host and a security key are required. Use --key, --key-file, --key-secret-id, or a config file." << std::endl; return 1; } @@ -1697,7 +1715,13 @@ int HandleDoctorCommand(const std::vector& args) { hasError = true; } else { PrintDoctorLine("OK", "config", configPath.string()); - PrintDoctorLine(config.host.empty() ? "WARN" : "OK", "host", config.host.empty() ? "not configured" : config.host); + const bool powerToysEnabled = mwb::PowerToysCompatibilityEnabled(config.connectionMode); + PrintDoctorLine("INFO", "connection mode", std::string(mwb::ConnectionModeName(config.connectionMode))); + PrintDoctorLine(powerToysEnabled && config.host.empty() ? "WARN" : "OK", + "host", + powerToysEnabled + ? (config.host.empty() ? "not configured" : config.host) + : "not required for inputflow mode"); PrintDoctorLine("INFO", "machine name", config.machineName.empty() ? "" : config.machineName); PrintDoctorLine("INFO", "port", std::to_string(config.port)); PrintDoctorLine("INFO", "clipboard", std::string(config.clipboardEnabled ? "enabled" : "disabled") + @@ -1711,7 +1735,9 @@ int HandleDoctorCommand(const std::vector& args) { "ms max=" + std::to_string(config.reconnectMaxBackoffMs) + "ms idle=" + std::to_string(config.reconnectIdleRetryMs) + "ms"); - if (!config.keySecretId.empty()) { + if (!powerToysEnabled) { + PrintDoctorLine("INFO", "key source", "not required for inputflow mode"); + } else if (!config.keySecretId.empty()) { PrintDoctorLine("OK", "key source", "Secret Service id '" + config.keySecretId + "'"); } else if (!config.keyFile.empty()) { std::filesystem::path keyPath = ResolveRelativeTo(config.keyFile, configPath.parent_path()); @@ -1721,10 +1747,17 @@ int HandleDoctorCommand(const std::vector& args) { } else { PrintDoctorLine("WARN", "key source", "not configured"); } - PrintDoctorAuthKeyPresence(config, configPath); - PrintDoctorHostProbe(config, 15100); - PrintDoctorHostProbe(config, 15101); - PrintDoctorConnectionQuality(config, statePath); + if (powerToysEnabled) { + PrintDoctorAuthKeyPresence(config, configPath); + PrintDoctorHostProbe(config, 15100); + PrintDoctorHostProbe(config, 15101); + PrintDoctorConnectionQuality(config, statePath); + } else { + PrintDoctorLine("INFO", "auth key", "not checked; PowerToys compatibility disabled"); + PrintDoctorLine("INFO", "host 15100", "not checked; PowerToys compatibility disabled"); + PrintDoctorLine("INFO", "host 15101", "not checked; PowerToys compatibility disabled"); + PrintDoctorLine("INFO", "connection quality", "not checked; PowerToys compatibility disabled"); + } if (config.screenWidth && config.screenHeight) { PrintDoctorLine("OK", "screen override", std::to_string(*config.screenWidth) + "x" + std::to_string(*config.screenHeight)); @@ -1919,6 +1952,17 @@ int HandleInitConfigCommand(const std::vector& args) { return 1; } config.port = *parsed; + } else if (arg == "--connection-mode" || arg == "--mode") { + const auto value = requireValue(arg.c_str()); + if (!value) { + return 1; + } + const auto parsed = mwb::ParseConnectionMode(*value); + if (!parsed.has_value()) { + std::cerr << "ERR: Invalid connection mode. Use powertoys, inputflow, or hybrid." << std::endl; + return 1; + } + config.connectionMode = *parsed; } else if (arg == "--auto-connect") { config.autoConnectEnabled = true; } else if (arg == "--manual-only") { diff --git a/tests/test_main.cpp b/tests/test_main.cpp index 8352ecf..29a49ac 100644 --- a/tests/test_main.cpp +++ b/tests/test_main.cpp @@ -35,6 +35,7 @@ std::filesystem::path MakeTempPath(const std::string& name) { void TestAppConfigRoundTrip() { mwb::AppConfig config; + config.connectionMode = mwb::ConnectionMode::Hybrid; config.host = "192.0.2.107"; config.key = "secret"; config.machineName = "fedora"; @@ -64,6 +65,8 @@ void TestAppConfigRoundTrip() { std::string error; Expect(mwb::SaveConfigFile(path, config, error), "SaveConfigFile should succeed"); const std::string rendered = mwb::RenderAppConfig(config); + ExpectRenderedLine(rendered, "connection_mode", "hybrid", + "Rendered config should keep connection_mode"); ExpectRenderedLine(rendered, "key", "secret", "Rendered config should keep the inline key"); ExpectRenderedLine(rendered, "key_file", "", "Rendered config should keep an empty key_file entry for inline keys"); ExpectRenderedLine(rendered, "key_secret_id", "", @@ -99,10 +102,13 @@ void TestAppConfigRoundTrip() { mwb::AppConfig loaded; Expect(mwb::LoadConfigFile(path, loaded, error), "LoadConfigFile should succeed"); + Expect(loaded.connectionMode == config.connectionMode, "Config connectionMode round-trip"); Expect(loaded.host == config.host, "Config host round-trip"); Expect(loaded.key == config.key, "Config key round-trip"); Expect(loaded.keyFile.empty(), "Config key_file should stay empty for inline keys"); const std::string loadedRendered = mwb::RenderAppConfig(loaded); + ExpectRenderedLine(loadedRendered, "connection_mode", "hybrid", + "Loaded config should keep connection_mode"); ExpectRenderedLine(loadedRendered, "key", "secret", "Loaded config should keep the inline key"); ExpectRenderedLine(loadedRendered, "key_file", "", "Loaded config should keep an empty key_file entry"); ExpectRenderedLine(loadedRendered, "key_secret_id", "", @@ -311,6 +317,24 @@ void TestAppConfigConnectionPolicyRoundTrip() { std::filesystem::remove(path, ignore); } +void TestAppConfigConnectionModeAliases() { + mwb::AppConfig config; + std::string error; + + Expect(mwb::ParseAppConfig("connection_mode=inputflow\n", config, &error), + "ParseAppConfig should accept inputflow connection mode"); + Expect(config.connectionMode == mwb::ConnectionMode::InputFlow, + "inputflow connection mode should parse"); + + Expect(mwb::ParseAppConfig("connection_mode=both\n", config, &error), + "ParseAppConfig should accept both as hybrid alias"); + Expect(config.connectionMode == mwb::ConnectionMode::Hybrid, + "both connection mode alias should parse as hybrid"); + + Expect(!mwb::ParseAppConfig("connection_mode=bluetooth\n", config, &error), + "ParseAppConfig should reject unknown connection mode"); +} + void TestParseAppConfigKeyFileOverridesInlineKey() { mwb::AppConfig config; std::string error; @@ -644,6 +668,7 @@ int main() { TestAppConfigKeyFileRoundTrip(); TestAppConfigKeySecretIdRoundTrip(); TestAppConfigConnectionPolicyRoundTrip(); + TestAppConfigConnectionModeAliases(); TestParseAppConfigKeyFileOverridesInlineKey(); TestParseAppConfigKeySecretIdOverridesKeyAndKeyFile(); TestAppStateRoundTrip();