diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index e0fa197..e3669c1 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -59,6 +59,8 @@ jobs:
dnf install -y \
cmake \
gcc-c++ \
+ gtk3-devel \
+ libayatana-appindicator3-devel \
make \
openssl-devel \
pkgconf-pkg-config \
@@ -123,6 +125,7 @@ jobs:
- name: Check shell syntax
run: |
bash -n mwb-desktop-ui.sh
+ bash -n scripts/inputflow-diagnostics-bundle.sh
bash -n scripts/validate-rpm-packaging.sh
- name: Validate RPM packaging skeleton
diff --git a/README.md b/README.md
index c5939bd..e0c024f 100644
--- a/README.md
+++ b/README.md
@@ -20,6 +20,8 @@ Recommended first-run flow for most users:
- Run the exported `.ps1` script on your Windows machine to register the Linux peer.
5. **Start:** Choose **Start Service** or launch the tray with `./build/mwb_tray`.
+For the full beta setup, health-check, diagnostics, connection-quality, and packaging-verification workflow, see [docs/beta-workflow.md](docs/beta-workflow.md).
+
---
## 🛠️ Build & Installation
@@ -93,6 +95,13 @@ For power users who prefer manual control:
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)
+- [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)
+
## Detailed Documentation
diff --git a/docs/beta-workflow.md b/docs/beta-workflow.md
new file mode 100644
index 0000000..d7cd3b9
--- /dev/null
+++ b/docs/beta-workflow.md
@@ -0,0 +1,98 @@
+# InputFlow Public Beta Workflow
+
+This guide covers the user-facing beta path for pairing Linux with PowerToys Mouse Without Borders on Windows, checking health, collecting diagnostics, tuning connection quality, and validating package output.
+
+## Guided Pairing And Export Helper
+
+Use the desktop controller for the guided path:
+
+```bash
+./mwb-desktop-ui.sh menu
+```
+
+1. Open **Settings** and enter the Windows host IP, local machine name, port, and exactly one authentication source: inline key, `key_file`, or Secret Service key ID.
+2. Use **Connection Behavior** to choose automatic reconnect behavior before starting the service.
+3. Export a Windows helper from Linux when the UI exposes the action, or use the CLI fallback:
+
+```bash
+./build/mwb_client export-windows-pair \
+ --config ~/.config/mwb-client/config.ini \
+ --output inputflow-windows-pair.ps1 \
+ --position auto
+```
+
+The helper writes the Linux peer name, IP, layout position, shared key, `MachinePool`, `MachineMatrixString`, and `Name2IP` values expected by PowerToys. On Windows, install and open PowerToys Mouse Without Borders once, then run the exported script in PowerShell:
+
+```powershell
+powershell -ExecutionPolicy Bypass -File .\inputflow-windows-pair.ps1
+```
+
+Keep the exported `.ps1` private because it contains pairing material. Delete it after confirming Windows sees the Linux peer.
+
+
+
+## Health Check
+
+Run the built-in doctor before filing a beta issue or after changing package/service setup:
+
+```bash
+./build/mwb_client doctor --config ~/.config/mwb-client/config.ini
+```
+
+The desktop controller shows the same health check together with the user service status through:
+
+```bash
+./mwb-desktop-ui.sh status
+```
+
+Review warnings for missing `/dev/uinput` access, missing `inputflow` group membership, missing packaged files, unavailable clipboard helpers, or invalid authentication configuration.
+
+## Diagnostics Bundle
+
+Until a one-click diagnostics bundle command is available, collect this minimal bundle for beta reports:
+
+```bash
+./build/mwb_client doctor --config ~/.config/mwb-client/config.ini > inputflow-doctor.txt
+systemctl --user status --no-pager mwb-client.service > inputflow-service-status.txt
+journalctl --user -u mwb-client.service --since "30 minutes ago" --no-pager > inputflow-service-log.txt
+```
+
+Also include `~/.config/mwb-client/config.ini` with `key`, `key_file`, `key_secret_id`, Windows IPs, and hostnames redacted as needed. Do not attach exported Windows helper scripts or unredacted Secret Service identifiers to public issues.
+
+## Connection Quality
+
+For most beta users, keep automatic reconnect enabled and tune only if the service reconnects too aggressively or too slowly:
+
+```ini
+auto_connect_enabled=true
+reconnect_initial_backoff_ms=500
+reconnect_max_backoff_ms=30000
+reconnect_idle_retry_ms=5000
+latency_report=false
+```
+
+Enable latency reporting only while debugging missed or delayed input:
+
+```bash
+./build/mwb_client run --config ~/.config/mwb-client/config.ini --latency-report
+```
+
+You can also set `latency_report=true` in `config.ini` or run with `MWB_LATENCY_REPORT=1`. The report prints client-side queue and injection timing when the client shuts down.
+
+## Packaging Verification
+
+Validate RPM metadata without a full RPM build:
+
+```bash
+scripts/validate-rpm-packaging.sh
+```
+
+Run full Fedora RPM validation when the build dependencies are available:
+
+```bash
+MWB_VALIDATE_RPM_BUILD=1 scripts/validate-rpm-packaging.sh
+```
+
+The validation checks that the package keeps the compatibility binary and service names, includes the user unit, sysusers integration, modules-load file, and `/dev/uinput` udev rule, and does not introduce undocumented command aliases.
+
+
diff --git a/mwb-desktop-ui.sh b/mwb-desktop-ui.sh
index 7f013c4..ac0c58d 100755
--- a/mwb-desktop-ui.sh
+++ b/mwb-desktop-ui.sh
@@ -13,6 +13,7 @@ RECONNECT_IDLE_CONFIG_KEY="${MWB_RECONNECT_IDLE_CONFIG_KEY:-reconnect_idle_retry
MPRIS_MEDIA_KEYS_CONFIG_KEY="${MWB_MPRIS_MEDIA_KEYS_CONFIG_KEY:-mpris_media_keys_enabled}"
MPRIS_PLAYER_CONFIG_KEY="${MWB_MPRIS_PLAYER_CONFIG_KEY:-mpris_player}"
LATENCY_REPORT_CONFIG_KEY="${MWB_LATENCY_REPORT_CONFIG_KEY:-latency_report}"
+DIAGNOSTICS_BUNDLE_SCRIPT="$SCRIPT_DIR/scripts/inputflow-diagnostics-bundle.sh"
DEFAULT_AUTO_CONNECT_ENABLED="${MWB_DEFAULT_AUTO_CONNECT_ENABLED:-true}"
DEFAULT_RECONNECT_INITIAL_MS="${MWB_DEFAULT_RECONNECT_INITIAL_MS:-1000}"
DEFAULT_RECONNECT_MAX_MS="${MWB_DEFAULT_RECONNECT_MAX_MS:-300000}"
@@ -680,7 +681,7 @@ menu_summary_text() {
show_status() {
local status_text doctor_text
status_text="$(systemctl --user status --no-pager "$SERVICE_NAME" 2>&1 || true)"
- doctor_text="$("$APP_BIN" doctor --config "$CONFIG_PATH" 2>&1 || true)"
+ doctor_text="$("$APP_BIN" doctor --config "$CONFIG_PATH" --state "$STATE_PATH" 2>&1 || true)"
zenity --text-info --title="$APP_NAME service status" --width=900 --height=600 <<<"$doctor_text
----
@@ -688,6 +689,110 @@ show_status() {
$status_text"
}
+append_check_line() {
+ local status="$1" name="$2" detail="$3"
+ printf '%-5s %-28s %s\n' "$status" "$name" "$detail"
+}
+
+probe_tcp_port() {
+ local host="$1" port="$2"
+ MWB_PROBE_HOST="$host" MWB_PROBE_PORT="$port" timeout 2 bash -c ':/dev/null 2>&1
+}
+
+health_check() {
+ require_client_binary || return 1
+ local host port key key_file secret_id auth_count service_status health_text doctor_text
+ host="$(read_config_value host)"
+ port="$(read_config_value port)"; [[ -n "$port" ]] || port="15101"
+ 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")"
+ service_status="$(service_state)"
+
+ health_text="$(
+ 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
+ if probe_tcp_port "$host" "$port"; then
+ append_check_line OK "input port" "$host:$port reachable"
+ else
+ append_check_line WARN "input port" "$host:$port not reachable"
+ fi
+ if probe_tcp_port "$host" "15100"; then
+ append_check_line OK "clipboard port" "$host:15100 reachable"
+ else
+ append_check_line WARN "clipboard port" "$host:15100 not reachable"
+ fi
+ else
+ append_check_line WARN "port probe" "host or timeout command unavailable"
+ fi
+ )"
+ doctor_text="$("$APP_BIN" doctor --config "$CONFIG_PATH" --state "$STATE_PATH" 2>&1 || true)"
+ zenity --text-info --title="$APP_NAME health check" --width=900 --height=620 <<<"$health_text
+
+----
+Client doctor
+----
+$doctor_text"
+}
+
+connection_quality() {
+ local 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
+ host="$(read_config_value host)"
+ port="$(read_config_value port)"; [[ -n "$port" ]] || port="15101"
+ state="$(service_state)"
+ IFS=$'\t' read -r auto_connect_enabled reconnect_initial_backoff_ms reconnect_max_backoff_ms reconnect_idle_retry_ms < <(read_connection_behavior_values)
+ clipboard_enabled="$(read_config_value clipboard_enabled)"; [[ -n "$clipboard_enabled" ]] || clipboard_enabled="true"
+ clipboard_send_enabled="$(read_config_value clipboard_send_enabled)"; [[ -n "$clipboard_send_enabled" ]] || clipboard_send_enabled="true"
+ clipboard_force_poll="$(read_config_value clipboard_force_poll)"; [[ -n "$clipboard_force_poll" ]] || clipboard_force_poll="false"
+ clipboard_poll_ms="$(read_config_value clipboard_poll_ms)"; [[ -n "$clipboard_poll_ms" ]] || clipboard_poll_ms="1000"
+ latency_report="$(read_config_value "$LATENCY_REPORT_CONFIG_KEY")"; [[ -n "$latency_report" ]] || latency_report="false"
+
+ peer_lines="No peer state has been recorded yet."
+ if [[ -f "$STATE_PATH" ]]; then
+ peer_lines="$(awk -F'\t' '
+ /^peer=/ {
+ sub(/^peer=/, "", $1)
+ name=$2; approved=$3; connected=$4; last_seen=$5; last_connected=$6
+ if (name == "") name="unknown"
+ printf "- %s (%s): paired=%s connected=%s last_seen=%s last_connected=%s\n", name, $1, approved, connected, last_seen, last_connected
+ }
+ ' "$STATE_PATH")"
+ [[ -n "$peer_lines" ]] || peer_lines="No peer entries found in $STATE_PATH."
+ fi
+
+ quality_text="Service: $(service_state_label "$state") ($state)
+Configured host: ${host:-not configured}
+Input port: $port
+Clipboard port: 15100
+Reconnect mode: $( [[ "$auto_connect_enabled" == "true" ]] && printf 'Auto' || printf 'Manual' )
+Reconnect timing: initial=${reconnect_initial_backoff_ms}ms max=${reconnect_max_backoff_ms}ms idle=${reconnect_idle_retry_ms}ms
+Clipboard: enabled=$clipboard_enabled send_local=$clipboard_send_enabled force_poll=$clipboard_force_poll poll=${clipboard_poll_ms}ms
+Latency report logging: $latency_report
+
+Known peers:
+$peer_lines"
+ zenity --text-info --title="$APP_NAME connection quality" --width=840 --height=520 <<<"$quality_text"
+}
+
+diagnostics_bundle() {
+ if [[ ! -x "$DIAGNOSTICS_BUNDLE_SCRIPT" ]]; then
+ zenity --error --text="Diagnostics bundle script is not available:
+$DIAGNOSTICS_BUNDLE_SCRIPT"
+ return 1
+ fi
+ local output_dir result
+ output_dir="$(zenity --file-selection --directory --title="$APP_NAME diagnostics output folder" || true)"
+ [[ -n "$output_dir" ]] || return 1
+ result="$("$DIAGNOSTICS_BUNDLE_SCRIPT" --config "$CONFIG_PATH" --state "$STATE_PATH" --output "$output_dir" 2>&1 || true)"
+ zenity --text-info --title="$APP_NAME diagnostics bundle" --width=760 --height=420 <<<"$result"
+}
+
show_peers() {
local rows=() configured_host selected_peer selected_host selected_port selected_name selected_action
configured_host="$(read_config_value host)"
@@ -829,6 +934,53 @@ discover_and_save_peer() {
fi
}
+export_windows_helper() {
+ require_client_binary || return 1
+ local output_dir position result
+ output_dir="$(zenity --file-selection --directory --title="$APP_NAME Windows helper output folder" || true)"
+ [[ -n "$output_dir" ]] || return 1
+ position="$(zenity --list --radiolist --title="$APP_NAME Windows helper" --width=560 --height=260 \
+ --text="Choose where the Linux desktop sits relative to the Windows host." \
+ --column="Use" --column="Position" \
+ TRUE "auto" \
+ FALSE "top-left" \
+ FALSE "top-right" \
+ FALSE "bottom-left" \
+ FALSE "bottom-right" || true)"
+ [[ -n "$position" ]] || return 1
+ result="$("$APP_BIN" export-windows-pair --config "$CONFIG_PATH" --output "$output_dir" --position "$position" --force 2>&1 || true)"
+ zenity --text-info --title="$APP_NAME Windows pairing helper" --width=820 --height=440 <<<"$result
+
+Next steps:
+1. Copy the exported .ps1 file to the Windows machine.
+2. Start PowerToys once so Mouse Without Borders creates its settings file.
+3. Run the helper in PowerShell.
+4. Return here and run Health Check."
+}
+
+guided_pairing() {
+ while true; do
+ local choice
+ choice="$(zenity --list --title="$APP_NAME guided pairing" --width=620 --height=360 \
+ --text="Use this flow to discover Windows, save Linux settings, export the Windows helper, then verify the setup." \
+ --column="Step" \
+ "1. Discover Windows peer and save settings" \
+ "2. Edit settings manually" \
+ "3. Export Windows helper" \
+ "4. Start service" \
+ "5. Run health check" \
+ "Back" || true)"
+ case "$choice" in
+ "1. Discover Windows peer and save settings") discover_and_save_peer ;;
+ "2. Edit settings manually") edit_settings ;;
+ "3. Export Windows helper") export_windows_helper ;;
+ "4. Start service") start_session ;;
+ "5. Run health check") health_check ;;
+ ""|"Back") return 0 ;;
+ esac
+ done
+}
+
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
@@ -1045,7 +1197,23 @@ Terminal=false
Categories=Utility;Network;
Keywords=mouse;keyboard;sharing;input;controller;
StartupNotify=false
-Actions=OpenSettings;OpenConnectionBehavior;ShowTrayHelp;ShowStatus;StartService;RestartService;StopService;
+Actions=GuidedPairing;HealthCheck;DiagnosticsBundle;ConnectionQuality;OpenSettings;OpenConnectionBehavior;ShowTrayHelp;ShowStatus;StartService;RestartService;StopService;
+
+[Desktop Action GuidedPairing]
+Name=Guided Pairing
+Exec=$SCRIPT_DIR/$(basename "${BASH_SOURCE[0]}") guided-pairing
+
+[Desktop Action HealthCheck]
+Name=Health Check
+Exec=$SCRIPT_DIR/$(basename "${BASH_SOURCE[0]}") health-check
+
+[Desktop Action DiagnosticsBundle]
+Name=Diagnostics Bundle
+Exec=$SCRIPT_DIR/$(basename "${BASH_SOURCE[0]}") diagnostics-bundle
+
+[Desktop Action ConnectionQuality]
+Name=Connection Quality
+Exec=$SCRIPT_DIR/$(basename "${BASH_SOURCE[0]}") connection-quality
[Desktop Action OpenSettings]
Name=Open Settings
@@ -1102,6 +1270,10 @@ main_menu() {
local choice
choice="$(zenity --list --title="$APP_NAME" --text="$(menu_summary_text)" --width=540 --height=400 \
--column="Action" \
+ "Guided Pairing" \
+ "Health Check" \
+ "Diagnostics Bundle" \
+ "Connection Quality" \
"Settings" \
"Peers (Discovery & Known)" \
"Connection Behavior" \
@@ -1114,6 +1286,10 @@ main_menu() {
"Quit" || true)"
case "$choice" in
+ "Guided Pairing") guided_pairing ;;
+ "Health Check") health_check ;;
+ "Diagnostics Bundle") diagnostics_bundle ;;
+ "Connection Quality") connection_quality ;;
"Settings") edit_settings ;;
"Peers (Discovery & Known)")
local peer_choice
@@ -1138,6 +1314,10 @@ require_ui
case "${1:-menu}" in
""|menu) main_menu ;;
+ guided-pairing|pairing|export-helper) guided_pairing ;;
+ health-check|doctor) health_check ;;
+ diagnostics-bundle|diagnostics) diagnostics_bundle ;;
+ connection-quality|quality) connection_quality ;;
settings) edit_settings ;;
connection|connection-behavior|reconnect) edit_connection_behavior ;;
discover) discover_and_save_peer ;;
@@ -1150,7 +1330,7 @@ case "${1:-menu}" in
tray) start_tray ;;
install-desktop-entry|install-desktop-entries) install_desktop_entry ;;
help|-h|--help)
- printf 'Usage: %s [menu|settings|connection|discover|peers|tray-help|status|start|restart|stop|tray|install-desktop-entry]\n' "$(basename "${BASH_SOURCE[0]}")"
+ printf 'Usage: %s [menu|guided-pairing|health-check|diagnostics-bundle|connection-quality|settings|connection|discover|peers|tray-help|status|start|restart|stop|tray|install-desktop-entry]\n' "$(basename "${BASH_SOURCE[0]}")"
;;
*)
zenity --error --text="Unknown action: $1"
diff --git a/packaging/README.md b/packaging/README.md
index 6ba13bd..2a9a4f4 100644
--- a/packaging/README.md
+++ b/packaging/README.md
@@ -17,6 +17,11 @@ packagers can copy files directly or map them to distro macros.
- `usr/lib/systemd/user/mwb-client.service`
Runs `mwb_client` from a user systemd manager with
`%h/.config/mwb-client/config.ini`.
+- `usr/share/applications/inputflow.desktop`
+ Launches the packaged desktop controller from `/usr/libexec/inputflow`.
+- `../scripts/inputflow-diagnostics-bundle.sh`
+ Canonical diagnostics bundle helper; the RPM installs it under
+ `/usr/libexec/inputflow/scripts/`.
- `rpm/inputflow.spec`
Fedora/RPM packaging skeleton for the InputFlow package. It intentionally keeps
the installed command and user service named `mwb_client` and
@@ -30,6 +35,10 @@ Install the files to the standard macro destinations:
- `%{_modulesloaddir}/mwb-client-uinput.conf`
- `%{_udevrulesdir}/70-mwb-client-uinput.rules`
- `%{_userunitdir}/mwb-client.service`
+- `%{_libexecdir}/inputflow/mwb-desktop-ui.sh`
+- `%{_libexecdir}/inputflow/scripts/inputflow-diagnostics-bundle.sh`
+- `%{_datadir}/applications/inputflow.desktop`
+- `%{_datadir}/icons/hicolor/scalable/apps/inputflow.svg`
Create the group with systemd-sysusers from package scriptlets/macros. Reload udev
rules after installing the rule, and load or trigger `uinput` if the package wants
@@ -80,6 +89,10 @@ The packaged user unit is intentionally configuration-gated with
`ConditionPathExists=%h/.config/mwb-client/config.ini`; an enabled service will be
skipped until the user creates that file.
+The desktop entry is a launcher only; it does not enable or start services during
+package installation. The diagnostics bundle script redacts likely secret, host,
+and address values before archiving config or state files.
+
Firewall rules are normally unnecessary for the Linux client when it only initiates
outbound connections to a Windows host. SELinux policy should be kept distro-owned;
do not disable SELinux or apply broad device labels for this service unless a
diff --git a/packaging/rpm/inputflow.spec b/packaging/rpm/inputflow.spec
index 9285e8d..e8c917d 100644
--- a/packaging/rpm/inputflow.spec
+++ b/packaging/rpm/inputflow.spec
@@ -14,6 +14,8 @@ BuildRequires: openssl-devel
BuildRequires: pkgconf-pkg-config
BuildRequires: systemd-rpm-macros
BuildRequires: zlib-devel
+BuildRequires: gtk3-devel
+BuildRequires: libayatana-appindicator3-devel
Requires: systemd
%{?systemd_requires}
@@ -21,6 +23,8 @@ Requires(pre): systemd
Requires(post): systemd-udev
Requires(postun): systemd-udev
Recommends: wl-clipboard
+Recommends: zenity
+Recommends: python3-gobject
%description
InputFlow is a Linux companion client for Microsoft PowerToys Mouse
@@ -31,16 +35,21 @@ retain the mwb-client naming for compatibility.
%autosetup -n %{name}-%{version}
%build
-%cmake
+%cmake -DMWB_BUILD_TRAY=ON
%cmake_build
%install
-%cmake_build --target mwb_client
+%cmake_build --target mwb_client mwb_tray
install -Dpm0755 %{_vpath_builddir}/mwb_client %{buildroot}%{_bindir}/mwb_client
+install -Dpm0755 %{_vpath_builddir}/mwb_tray %{buildroot}%{_bindir}/mwb_tray
+install -Dpm0755 mwb-desktop-ui.sh %{buildroot}%{_libexecdir}/inputflow/mwb-desktop-ui.sh
+install -Dpm0755 scripts/inputflow-diagnostics-bundle.sh %{buildroot}%{_libexecdir}/inputflow/scripts/inputflow-diagnostics-bundle.sh
install -Dpm0644 packaging/usr/lib/sysusers.d/mwb-client.conf %{buildroot}%{_sysusersdir}/mwb-client.conf
install -Dpm0644 packaging/usr/lib/modules-load.d/mwb-client-uinput.conf %{buildroot}%{_modulesloaddir}/mwb-client-uinput.conf
install -Dpm0644 packaging/usr/lib/udev/rules.d/70-mwb-client-uinput.rules %{buildroot}%{_udevrulesdir}/70-mwb-client-uinput.rules
install -Dpm0644 packaging/usr/lib/systemd/user/mwb-client.service %{buildroot}%{_userunitdir}/mwb-client.service
+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
%pre
%sysusers_create %{_sysusersdir}/mwb-client.conf
@@ -62,10 +71,15 @@ udevadm trigger --name-match=uinput || :
%license LICENSE
%doc README.md packaging/README.md
%{_bindir}/mwb_client
+%{_bindir}/mwb_tray
+%{_libexecdir}/inputflow/mwb-desktop-ui.sh
+%{_libexecdir}/inputflow/scripts/inputflow-diagnostics-bundle.sh
%{_sysusersdir}/mwb-client.conf
%{_modulesloaddir}/mwb-client-uinput.conf
%{_udevrulesdir}/70-mwb-client-uinput.rules
%{_userunitdir}/mwb-client.service
+%{_datadir}/applications/inputflow.desktop
+%{_datadir}/icons/hicolor/scalable/apps/inputflow.svg
%changelog
* Fri Apr 24 2026 InputFlow Maintainers - 0.1.0-1
diff --git a/packaging/usr/share/applications/inputflow.desktop b/packaging/usr/share/applications/inputflow.desktop
new file mode 100644
index 0000000..2ec9e80
--- /dev/null
+++ b/packaging/usr/share/applications/inputflow.desktop
@@ -0,0 +1,10 @@
+[Desktop Entry]
+Type=Application
+Version=1.0
+Name=InputFlow Controller
+Comment=Configure and monitor InputFlow
+Exec=/usr/libexec/inputflow/mwb-desktop-ui.sh menu
+Icon=inputflow
+Terminal=false
+Categories=Utility;RemoteAccess;
+StartupNotify=false
diff --git a/scripts/inputflow-diagnostics-bundle.sh b/scripts/inputflow-diagnostics-bundle.sh
new file mode 100755
index 0000000..60d64e4
--- /dev/null
+++ b/scripts/inputflow-diagnostics-bundle.sh
@@ -0,0 +1,354 @@
+#!/usr/bin/env bash
+set -u
+set -o pipefail
+
+SCRIPT_NAME="$(basename "$0")"
+
+usage() {
+ cat <&2
+}
+
+die() {
+ log "error: $*"
+ exit 1
+}
+
+have() {
+ command -v "$1" >/dev/null 2>&1
+}
+
+default_config_path() {
+ if [[ -n "${XDG_CONFIG_HOME:-}" ]]; then
+ printf '%s\n' "$XDG_CONFIG_HOME/mwb-client/config.ini"
+ elif [[ -n "${HOME:-}" ]]; then
+ printf '%s\n' "$HOME/.config/mwb-client/config.ini"
+ else
+ printf '%s\n' "mwb-client/config.ini"
+ fi
+}
+
+default_state_path() {
+ if [[ -n "${XDG_STATE_HOME:-}" ]]; then
+ printf '%s\n' "$XDG_STATE_HOME/mwb-client/state.ini"
+ elif [[ -n "${HOME:-}" ]]; then
+ printf '%s\n' "$HOME/.local/state/mwb-client/state.ini"
+ else
+ printf '%s\n' "mwb-client/state.ini"
+ fi
+}
+
+CONFIG_PATH="$(default_config_path)"
+STATE_PATH="$(default_state_path)"
+OUTPUT_DIR="."
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --config)
+ [[ $# -ge 2 ]] || die "--config requires a path"
+ CONFIG_PATH="$2"
+ shift 2
+ ;;
+ --state)
+ [[ $# -ge 2 ]] || die "--state requires a path"
+ STATE_PATH="$2"
+ shift 2
+ ;;
+ --output)
+ [[ $# -ge 2 ]] || die "--output requires a directory"
+ OUTPUT_DIR="$2"
+ shift 2
+ ;;
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ *)
+ die "unknown argument: $1"
+ ;;
+ esac
+done
+
+mkdir -p "$OUTPUT_DIR" || die "failed to create output directory: $OUTPUT_DIR"
+OUTPUT_DIR="$(cd "$OUTPUT_DIR" && pwd -P)" || die "failed to resolve output directory: $OUTPUT_DIR"
+
+TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
+BUNDLE_NAME="inputflow-diagnostics-${TIMESTAMP}-$$"
+BUNDLE_DIR="$OUTPUT_DIR/$BUNDLE_NAME"
+mkdir -p "$BUNDLE_DIR" || die "failed to create bundle directory: $BUNDLE_DIR"
+chmod 700 "$BUNDLE_DIR" 2>/dev/null || true
+
+REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P 2>/dev/null || pwd -P)"
+CONFIG_PATH_DISPLAY="$CONFIG_PATH"
+STATE_PATH_DISPLAY="$STATE_PATH"
+
+redact_stream() {
+ sed -E \
+ -e 's/^([[:space:]]*[^#;[:space:]]*(key|token|secret|password|passphrase)[^=]*[[:space:]]*=[[:space:]]*).*/\1[REDACTED]/I' \
+ -e 's/([[:space:]]--?[^[:space:]]*(key|token|secret|password|passphrase)[^[:space:]=]*[=[:space:]]+)[^[:space:]]+/\1[REDACTED]/Ig' \
+ -e 's/([A-Za-z_][A-Za-z0-9_]*(KEY|TOKEN|SECRET|PASSWORD|PASSPHRASE)[A-Za-z0-9_]*=)[^[:space:]]+/\1[REDACTED]/Ig' \
+ -e "s/((secret|key|token|password|passphrase)( service)? id[[:space:]]+'?)[^'[:space:]]+('?)/\1[REDACTED]\4/Ig" \
+ -e 's/(|SECURITY_KEY|security key)[^[:space:]]*/\1[REDACTED]/Ig' \
+ -e 's/[A-Fa-f0-9]{32,}/[REDACTED_HEX]/g'
+}
+
+safe_run() {
+ local output_file="$1"
+ shift
+ {
+ printf '$'
+ printf ' %q' "$@"
+ printf '\n\n'
+ "$@"
+ local status=$?
+ printf '\n[exit status: %s]\n' "$status"
+ } 2>&1 | redact_stream >"$output_file"
+}
+
+safe_shell() {
+ local output_file="$1"
+ local description="$2"
+ local script="$3"
+ {
+ printf '$ %s\n\n' "$description"
+ bash -c "$script"
+ local status=$?
+ printf '\n[exit status: %s]\n' "$status"
+ } 2>&1 | redact_stream >"$output_file"
+}
+
+redacted_copy_or_note() {
+ local source_file="$1"
+ local output_file="$2"
+ {
+ printf 'source=%s\n' "$source_file"
+ if [[ -f "$source_file" && -r "$source_file" ]]; then
+ printf 'present=yes\n\n'
+ redact_stream <"$source_file"
+ elif [[ -e "$source_file" ]]; then
+ printf 'present=yes\nreadable=no\n'
+ else
+ printf 'present=no\n'
+ fi
+ } >"$output_file"
+}
+
+write_config_summary() {
+ local output_file="$1"
+ {
+ printf 'config_path=%s\n' "$CONFIG_PATH_DISPLAY"
+ if [[ ! -e "$CONFIG_PATH" ]]; then
+ printf 'present=no\n'
+ return
+ fi
+ printf 'present=yes\n'
+ if [[ -f "$CONFIG_PATH" ]]; then
+ stat -c 'mode=%A
+owner=%U:%G
+size_bytes=%s
+modified=%y' "$CONFIG_PATH" 2>/dev/null || true
+ fi
+ if [[ ! -r "$CONFIG_PATH" ]]; then
+ printf 'readable=no\n'
+ return
+ fi
+ printf 'readable=yes\n\n[redacted values]\n'
+ local line trimmed key value
+ while IFS= read -r line || [[ -n "$line" ]]; do
+ trimmed="${line#"${line%%[![:space:]]*}"}"
+ trimmed="${trimmed%"${trimmed##*[![:space:]]}"}"
+ [[ -z "$trimmed" || "$trimmed" == \#* || "$trimmed" == \;* ]] && continue
+ if [[ "$trimmed" != *"="* ]]; then
+ printf '%s\n' "$trimmed" | redact_stream
+ continue
+ fi
+ key="${trimmed%%=*}"
+ value="${trimmed#*=}"
+ key="${key%"${key##*[![:space:]]}"}"
+ value="${value#"${value%%[![:space:]]*}"}"
+ if [[ "$key" =~ [Kk][Ee][Yy]|[Tt][Oo][Kk][Ee][Nn]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Pp][Aa][Ss][Ss] ]]; then
+ printf '%s=[REDACTED]\n' "$key"
+ else
+ printf '%s=%s\n' "$key" "$value" | redact_stream
+ fi
+ done <"$CONFIG_PATH"
+ } >"$output_file"
+}
+
+write_state_summary() {
+ local output_file="$1"
+ {
+ printf 'state_path=%s\n' "$STATE_PATH_DISPLAY"
+ if [[ ! -e "$STATE_PATH" ]]; then
+ printf 'present=no\n'
+ return
+ fi
+ printf 'present=yes\n'
+ stat -c 'mode=%A
+owner=%U:%G
+size_bytes=%s
+modified=%y' "$STATE_PATH" 2>/dev/null || true
+ if [[ ! -r "$STATE_PATH" ]]; then
+ printf 'readable=no\n'
+ return
+ fi
+ printf 'readable=yes\n'
+ printf 'peer_lines=%s\n' "$(grep -c '^peer=' "$STATE_PATH" 2>/dev/null || printf '0')"
+ printf '\n[redacted state]\n'
+ redact_stream <"$STATE_PATH"
+ } >"$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
+
+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.
+EOF
+}
+
+write_manifest
+write_config_summary "$BUNDLE_DIR/config-summary.txt"
+write_state_summary "$BUNDLE_DIR/app-state.txt"
+
+safe_shell "$BUNDLE_DIR/os-session.txt" "collect OS and session info" '
+set +e
+date -Is 2>/dev/null || date
+uname -a
+printf "\n[/etc/os-release]\n"
+test -r /etc/os-release && cat /etc/os-release
+printf "\n[session]\n"
+id
+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
+fi
+'
+
+safe_shell "$BUNDLE_DIR/systemd-user-status.txt" "collect systemd user service status" '
+set +e
+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
+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" '
+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
+'
+
+safe_shell "$BUNDLE_DIR/uinput-state.txt" "collect uinput state" '
+set +e
+printf "[device]\n"
+ls -l /dev/uinput 2>&1
+stat /dev/uinput 2>&1
+printf "\n[user groups]\n"
+id
+printf "\n[modules]\n"
+grep "^uinput " /proc/modules 2>/dev/null || true
+lsmod 2>/dev/null | grep -E "^uinput|uinput" || true
+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" '
+set +e
+hostnamectl 2>/dev/null || hostname 2>/dev/null
+printf "\n[addresses]\n"
+ip -brief address 2>&1
+printf "\n[links]\n"
+ip -brief link 2>&1
+printf "\n[routes]\n"
+ip route 2>&1
+printf "\n[listening/inputflow sockets]\n"
+if command -v ss >/dev/null 2>&1; then
+ ss -lntup 2>/dev/null | grep -Ei "15101|mwb|inputflow|State|Netid" || true
+else
+ echo "ss not found"
+fi
+printf "\n[host resolution]\n"
+getent hosts "$(hostname)" 2>/dev/null || true
+'
+
+safe_shell "$BUNDLE_DIR/package-build-info.txt" "collect package and build info" "
+set +e
+printf '[repository]\\n'
+if command -v git >/dev/null 2>&1 && git -C '$REPO_ROOT' rev-parse --is-inside-work-tree >/dev/null 2>&1; then
+ git -C '$REPO_ROOT' rev-parse --show-toplevel
+ git -C '$REPO_ROOT' rev-parse --short HEAD
+ git -C '$REPO_ROOT' status --short
+fi
+printf '\\n[build artifacts]\\n'
+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
+done
+printf '\\n[cmake cache summary]\\n'
+if test -r '$REPO_ROOT/build/CMakeCache.txt'; then
+ grep -E '^(CMAKE_BUILD_TYPE|CMAKE_PROJECT_VERSION|MWB_|CMAKE_CXX_COMPILER|CMAKE_CXX_FLAGS)' '$REPO_ROOT/build/CMakeCache.txt' 2>/dev/null || true
+fi
+printf '\\n[installed packages]\\n'
+if command -v dpkg-query >/dev/null 2>&1; then
+ dpkg-query -W 'mwb*' 'inputflow*' 2>/dev/null || true
+fi
+if command -v rpm >/dev/null 2>&1; then
+ rpm -qa 2>/dev/null | grep -Ei 'mwb|inputflow' || true
+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"
+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"
+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
+ 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"
+ FINAL_PATH="$ARCHIVE_PATH"
+ else
+ log "warning: tar failed; leaving bundle directory unarchived"
+ fi
+else
+ log "warning: tar not found; leaving bundle directory unarchived"
+fi
+
+printf '%s\n' "$FINAL_PATH"
diff --git a/scripts/validate-rpm-packaging.sh b/scripts/validate-rpm-packaging.sh
index 9f95739..1a0bb09 100755
--- a/scripts/validate-rpm-packaging.sh
+++ b/scripts/validate-rpm-packaging.sh
@@ -6,11 +6,15 @@ cleanup_paths=()
required_files=(
"README.md"
"LICENSE"
+ "mwb-desktop-ui.sh"
+ "assets/icons/inputflow-desktop.svg"
"packaging/README.md"
"packaging/usr/lib/sysusers.d/mwb-client.conf"
"packaging/usr/lib/modules-load.d/mwb-client-uinput.conf"
"packaging/usr/lib/udev/rules.d/70-mwb-client-uinput.rules"
"packaging/usr/lib/systemd/user/mwb-client.service"
+ "packaging/usr/share/applications/inputflow.desktop"
+ "scripts/inputflow-diagnostics-bundle.sh"
)
fail() {
@@ -67,14 +71,43 @@ grep -Eq 'KERNEL=="uinput"' packaging/usr/lib/udev/rules.d/70-mwb-client-uinput.
grep -Eq 'GROUP="inputflow"' packaging/usr/lib/udev/rules.d/70-mwb-client-uinput.rules || fail "udev rule must use inputflow group"
grep -Eq 'MODE="0660"' packaging/usr/lib/udev/rules.d/70-mwb-client-uinput.rules || fail "udev rule must set mode 0660"
grep -Eq 'static_node=uinput' packaging/usr/lib/udev/rules.d/70-mwb-client-uinput.rules || fail "udev rule must keep a stable uinput node"
+grep -Eq '^uinput$' packaging/usr/lib/modules-load.d/mwb-client-uinput.conf || fail "modules-load file must load uinput"
+grep -Eq '^ConditionPathExists=%h/\.config/mwb-client/config\.ini$' packaging/usr/lib/systemd/user/mwb-client.service || fail "user service must be gated by config presence"
+grep -Eq '^ExecStart=/usr/bin/mwb_client run --config %h/\.config/mwb-client/config\.ini$' packaging/usr/lib/systemd/user/mwb-client.service || fail "user service must run packaged mwb_client with the gated config"
+grep -Eq '^NoNewPrivileges=true$' packaging/usr/lib/systemd/user/mwb-client.service || fail "user service must keep NoNewPrivileges hardening"
+grep -Eq '^Type=Application$' packaging/usr/share/applications/inputflow.desktop || fail "desktop entry must be an application"
+grep -Eq '^Name=InputFlow Controller$' packaging/usr/share/applications/inputflow.desktop || fail "desktop entry must expose InputFlow Controller"
+grep -Eq '^Exec=/usr/libexec/inputflow/mwb-desktop-ui\.sh menu$' packaging/usr/share/applications/inputflow.desktop || fail "desktop entry must launch the packaged controller"
+grep -Eq '^Icon=inputflow$' packaging/usr/share/applications/inputflow.desktop || fail "desktop entry must use the packaged inputflow icon name"
+grep -Eq '^Terminal=false$' packaging/usr/share/applications/inputflow.desktop || fail "desktop entry must not require a terminal"
+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 '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"
+grep -Eq '%\{_libexecdir\}/inputflow/mwb-desktop-ui\.sh' "$spec_file" || fail "spec must package the desktop controller"
+grep -Eq '%\{_libexecdir\}/inputflow/scripts/inputflow-diagnostics-bundle\.sh' "$spec_file" || fail "spec must package diagnostics bundle script"
grep -Eq '%\{_userunitdir\}/mwb-client\.service' "$spec_file" || fail "spec must package mwb-client.service"
grep -Eq '%\{_sysusersdir\}/mwb-client\.conf' "$spec_file" || fail "spec must package sysusers integration"
grep -Eq '%\{_udevrulesdir\}/70-mwb-client-uinput\.rules' "$spec_file" || fail "spec must package udev rule"
+grep -Eq '%\{_datadir\}/applications/inputflow\.desktop' "$spec_file" || fail "spec must package desktop entry"
+grep -Eq '%\{_datadir\}/icons/hicolor/scalable/apps/inputflow\.svg' "$spec_file" || fail "spec must package application icon"
grep -Eq '%sysusers_create[[:space:]]+%\{_sysusersdir\}/mwb-client\.conf' "$spec_file" || fail "spec must create the inputflow group via systemd-sysusers"
grep -Eq '%systemd_user_post[[:space:]]+mwb-client\.service' "$spec_file" || fail "spec must run user-unit post macro"
+grep -Eq '%systemd_user_preun[[:space:]]+mwb-client\.service' "$spec_file" || fail "spec must run user-unit preun macro"
+grep -Eq '%systemd_user_postun_with_restart[[:space:]]+mwb-client\.service' "$spec_file" || fail "spec must run user-unit postun macro"
grep -Eq 'udevadm control --reload-rules' "$spec_file" || fail "spec must reload udev rules"
grep -Eq 'udevadm trigger --name-match=uinput' "$spec_file" || fail "spec must retrigger /dev/uinput"
+grep -Eq 'install -Dpm0755 mwb-desktop-ui\.sh .+%\{_libexecdir\}/inputflow/mwb-desktop-ui\.sh' "$spec_file" || fail "spec must install the desktop controller executable"
+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"
+
+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
+fi
if grep -Eq '/usr/bin/inputflow|inputflow-client\.service|inputflow\.service' "$spec_file"; then
fail "spec introduced an inputflow command or service alias before the documented compatibility pass"
diff --git a/src/TrayController.cpp b/src/TrayController.cpp
index 4334e23..5615158 100644
--- a/src/TrayController.cpp
+++ b/src/TrayController.cpp
@@ -31,6 +31,10 @@ struct TrayContext {
GtkWidget* openControllerItem{nullptr};
GtkWidget* editSettingsItem{nullptr};
GtkWidget* editConnectionItem{nullptr};
+ GtkWidget* healthCheckItem{nullptr};
+ GtkWidget* diagnosticsBundleItem{nullptr};
+ GtkWidget* connectionQualityItem{nullptr};
+ GtkWidget* guidedPairingItem{nullptr};
GtkWidget* discoverPeersItem{nullptr};
GtkWidget* showPeersItem{nullptr};
GtkWidget* trayHelpItem{nullptr};
@@ -392,6 +396,10 @@ void UpdateIndicatorVisuals(TrayContext* context, const std::string& state) {
const bool controllerAvailable = !context->controllerPath.empty();
gtk_widget_set_sensitive(context->editSettingsItem, controllerAvailable);
gtk_widget_set_sensitive(context->editConnectionItem, controllerAvailable);
+ gtk_widget_set_sensitive(context->healthCheckItem, controllerAvailable);
+ gtk_widget_set_sensitive(context->diagnosticsBundleItem, controllerAvailable);
+ gtk_widget_set_sensitive(context->connectionQualityItem, controllerAvailable);
+ gtk_widget_set_sensitive(context->guidedPairingItem, controllerAvailable);
gtk_widget_set_sensitive(context->discoverPeersItem, controllerAvailable);
gtk_widget_set_sensitive(context->showPeersItem, controllerAvailable);
gtk_widget_set_sensitive(context->trayHelpItem, TRUE);
@@ -477,6 +485,26 @@ void OnEditConnectionBehavior(GtkMenuItem*, gpointer userData) {
(void)LaunchController(context, {"connection"});
}
+void OnHealthCheck(GtkMenuItem*, gpointer userData) {
+ auto* context = static_cast(userData);
+ (void)LaunchController(context, {"health-check"});
+}
+
+void OnDiagnosticsBundle(GtkMenuItem*, gpointer userData) {
+ auto* context = static_cast(userData);
+ (void)LaunchController(context, {"diagnostics-bundle"});
+}
+
+void OnConnectionQuality(GtkMenuItem*, gpointer userData) {
+ auto* context = static_cast(userData);
+ (void)LaunchController(context, {"connection-quality"});
+}
+
+void OnGuidedPairing(GtkMenuItem*, gpointer userData) {
+ auto* context = static_cast(userData);
+ (void)LaunchController(context, {"guided-pairing"});
+}
+
void OnDiscoverPeers(GtkMenuItem*, gpointer userData) {
auto* context = static_cast(userData);
(void)LaunchController(context, {"discover"});
@@ -553,6 +581,10 @@ int main(int argc, char** argv) {
context.editSettingsItem = AddMenuItem(menu, "Settings", G_CALLBACK(OnEditSettings), &context);
context.editConnectionItem = AddMenuItem(menu, "Connection Behavior", G_CALLBACK(OnEditConnectionBehavior), &context);
+ context.healthCheckItem = AddMenuItem(menu, "Health Check", G_CALLBACK(OnHealthCheck), &context);
+ context.diagnosticsBundleItem = AddMenuItem(menu, "Diagnostics Bundle", G_CALLBACK(OnDiagnosticsBundle), &context);
+ context.connectionQualityItem = AddMenuItem(menu, "Connection Quality", G_CALLBACK(OnConnectionQuality), &context);
+ context.guidedPairingItem = AddMenuItem(menu, "Guided Pairing", G_CALLBACK(OnGuidedPairing), &context);
context.discoverPeersItem = AddMenuItem(menu, "Discover Peers", G_CALLBACK(OnDiscoverPeers), &context);
context.showPeersItem = AddMenuItem(menu, "Known Peers", G_CALLBACK(OnShowPeers), &context);
diff --git a/src/main.cpp b/src/main.cpp
index 8cd7e29..8566222 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -57,7 +57,7 @@ void PrintGeneralUsage(std::ostream& out, const char* argv0) {
<< " [--reconnect-initial-backoff-ms MS] [--reconnect-max-backoff-ms MS] [--reconnect-idle-retry-ms MS]"
<< " [--latency-report]\n";
out << " " << binary << " discover [--state PATH] [--port PORT] [--timeout-ms MS] [--max-hosts N]\n";
- out << " " << binary << " doctor [--config PATH]\n";
+ out << " " << binary << " doctor [--config PATH] [--state 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 << " export-windows-pair [--config PATH] [--output PATH] [--force] [--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";
@@ -949,6 +949,18 @@ std::int64_t CurrentEpochSeconds() {
.count();
}
+std::filesystem::path ResolveRelativeTo(const std::filesystem::path& path,
+ const std::filesystem::path& baseDirectory) {
+ if (path.empty() || path.is_absolute()) {
+ return path;
+ }
+ return baseDirectory / path;
+}
+
+std::string BoolToken(bool value) {
+ return value ? "yes" : "no";
+}
+
bool LoadOptionalState(const std::filesystem::path& statePath, mwb::AppState& state) {
if (!std::filesystem::exists(statePath)) {
return true;
@@ -971,6 +983,116 @@ bool SaveStateOrReport(const std::filesystem::path& statePath, const mwb::AppSta
return true;
}
+bool FileContainsNonWhitespace(const std::filesystem::path& path) {
+ std::ifstream file(path);
+ if (!file) {
+ return false;
+ }
+
+ char ch = '\0';
+ while (file.get(ch)) {
+ if (std::isspace(static_cast(ch)) == 0) {
+ return true;
+ }
+ }
+ return false;
+}
+
+void PrintDoctorAuthKeyPresence(const mwb::AppConfig& config,
+ const std::filesystem::path& configPath) {
+ if (!config.keySecretId.empty()) {
+ PrintDoctorLine("OK", "auth key", "key_secret_id configured");
+ return;
+ }
+ if (!config.keyFile.empty()) {
+ const std::filesystem::path keyPath = ResolveRelativeTo(config.keyFile, configPath.parent_path());
+ if (!std::filesystem::exists(keyPath)) {
+ PrintDoctorLine("WARN", "auth key", "key_file missing: " + keyPath.string());
+ } else if (!FileContainsNonWhitespace(keyPath)) {
+ PrintDoctorLine("WARN", "auth key", "key_file empty or unreadable: " + keyPath.string());
+ } else {
+ PrintDoctorLine("OK", "auth key", "key_file present: " + keyPath.string());
+ }
+ return;
+ }
+ if (!config.key.empty()) {
+ PrintDoctorLine("OK", "auth key", "inline key configured");
+ return;
+ }
+ PrintDoctorLine("WARN", "auth key", "not configured");
+}
+
+void PrintDoctorHostProbe(const mwb::AppConfig& config, int port) {
+ const std::string label = "host " + std::to_string(port);
+ if (config.host.empty()) {
+ PrintDoctorLine("INFO", label, "not checked; host not configured");
+ return;
+ }
+
+ const auto reachable = ProbeReachableIpv4Host(config.host, port, 500);
+ PrintDoctorLine(reachable.has_value() ? "OK" : "WARN", label,
+ reachable.has_value()
+ ? "tcp reachable ip=" + *reachable
+ : "tcp unreachable host=" + config.host);
+}
+
+const mwb::PeerState* FindPeerForQuality(const mwb::AppState& state,
+ const std::string& host,
+ int port) {
+ const mwb::PeerState* hostOnlyMatch = nullptr;
+ for (const auto& peer : state.peers) {
+ if (peer.host != host) {
+ continue;
+ }
+ if (peer.port == port) {
+ return &peer;
+ }
+ if (hostOnlyMatch == nullptr) {
+ hostOnlyMatch = &peer;
+ }
+ }
+ return hostOnlyMatch;
+}
+
+void PrintDoctorConnectionQuality(const mwb::AppConfig& config,
+ const std::filesystem::path& statePath) {
+ if (!std::filesystem::exists(statePath)) {
+ PrintDoctorLine("INFO", "state", statePath.string() + " does not exist");
+ PrintDoctorLine("INFO", "connection quality", "state=missing");
+ return;
+ }
+
+ mwb::AppState state;
+ std::string error;
+ if (!mwb::LoadAppState(statePath, state, error)) {
+ PrintDoctorLine("WARN", "state", error);
+ PrintDoctorLine("WARN", "connection quality", "state=unreadable");
+ return;
+ }
+
+ PrintDoctorLine("OK", "state", statePath.string() + " peers=" + std::to_string(state.peers.size()));
+ if (config.host.empty()) {
+ PrintDoctorLine("INFO", "connection quality", "host=missing peers=" + std::to_string(state.peers.size()));
+ return;
+ }
+
+ const mwb::PeerState* peer = FindPeerForQuality(state, config.host, config.port);
+ if (peer == nullptr) {
+ PrintDoctorLine("INFO", "connection quality",
+ "host=" + config.host + " port=" + std::to_string(config.port) + " peer_state=missing");
+ return;
+ }
+
+ const std::string detail =
+ "host=" + peer->host +
+ " port=" + std::to_string(peer->port) +
+ " approved=" + BoolToken(peer->approved) +
+ " connected_now=" + BoolToken(peer->connectedNow) +
+ " last_seen_epoch=" + std::to_string(peer->lastSeenEpochSeconds) +
+ " last_connected_epoch=" + std::to_string(peer->lastConnectedEpochSeconds);
+ PrintDoctorLine(peer->lastConnectedEpochSeconds > 0 ? "OK" : "INFO", "connection quality", detail);
+}
+
int RunClient(const mwb::AppConfig& config,
const std::filesystem::path& statePath) {
mwb::AppState state;
@@ -1436,6 +1558,7 @@ int HandleDiscoverCommand(const std::string& binary, const std::vector& args) {
std::filesystem::path configPath = mwb::DefaultConfigPath();
+ std::filesystem::path statePath = mwb::DefaultStatePath();
for (std::size_t index = 0; index < args.size(); ++index) {
const std::string& arg = args[index];
@@ -1453,6 +1576,12 @@ int HandleDoctorCommand(const std::vector& args) {
return 1;
}
configPath = *value;
+ } else if (arg == "--state") {
+ const auto value = requireValue("--state");
+ if (!value) {
+ return 1;
+ }
+ statePath = *value;
} else {
std::cerr << "ERR: Unknown doctor option: " << arg << std::endl;
return 1;
@@ -1491,16 +1620,17 @@ int HandleDoctorCommand(const std::vector& args) {
if (!config.keySecretId.empty()) {
PrintDoctorLine("OK", "key source", "Secret Service id '" + config.keySecretId + "'");
} else if (!config.keyFile.empty()) {
- std::filesystem::path keyPath = config.keyFile;
- if (keyPath.is_relative()) {
- keyPath = configPath.parent_path() / keyPath;
- }
+ std::filesystem::path keyPath = ResolveRelativeTo(config.keyFile, configPath.parent_path());
PrintDoctorLine(std::filesystem::exists(keyPath) ? "OK" : "WARN", "key file", keyPath.string());
} else if (!config.key.empty()) {
PrintDoctorLine("WARN", "key source", "inline key configured; prefer key_file or key_secret_id");
} else {
PrintDoctorLine("WARN", "key source", "not configured");
}
+ PrintDoctorAuthKeyPresence(config, configPath);
+ PrintDoctorHostProbe(config, 15100);
+ PrintDoctorHostProbe(config, 15101);
+ PrintDoctorConnectionQuality(config, statePath);
if (config.screenWidth && config.screenHeight) {
PrintDoctorLine("OK", "screen override", std::to_string(*config.screenWidth) + "x" + std::to_string(*config.screenHeight));
@@ -1508,6 +1638,12 @@ int HandleDoctorCommand(const std::vector& args) {
PrintDoctorLine("INFO", "screen override", "not configured; /sys/class/drm autodetection will be used");
}
}
+ if (!configExists || hasError) {
+ PrintDoctorLine("WARN", "auth key", "not checked; config unavailable");
+ PrintDoctorLine("INFO", "host 15100", "not checked; config unavailable");
+ PrintDoctorLine("INFO", "host 15101", "not checked; config unavailable");
+ PrintDoctorConnectionQuality(config, statePath);
+ }
PrintDoctorLine("INFO", "drm", DrmSummary());