From 00a8b5d26715209236b0c4bbc1da699f431a4c2e Mon Sep 17 00:00:00 2001 From: daredoole <49536135+daredoole@users.noreply.github.com> Date: Wed, 24 Jun 2026 20:16:46 -0400 Subject: [PATCH 1/9] Share donkey art in master builder --- tools/icon-generator/build-masters.js | 105 ++------------------------ tools/icon-generator/donkey.js | 29 ++++--- 2 files changed, 25 insertions(+), 109 deletions(-) diff --git a/tools/icon-generator/build-masters.js b/tools/icon-generator/build-masters.js index f63819f..8fa3a94 100644 --- a/tools/icon-generator/build-masters.js +++ b/tools/icon-generator/build-masters.js @@ -2,70 +2,11 @@ // so the colored logo and the monochrome tray/notification glyphs stay identical. const fs = require('fs'); const path = require('path'); +const { wrap, donkeyColor, donkeyMono, cursor, gradientDefs } = require('./donkey'); + const OUT = path.join(__dirname, 'masters'); fs.mkdirSync(OUT, { recursive: true }); -// ---- shared donkey geometry (authored in a 1024 box, centred ~ (512,500)) ---- -const D = { - earL: 't(450,432) r(-20)', - earR: 't(574,432) r(20)', -}; - -// colored, multi-part donkey (for the brand logo) -const donkeyColor = ` - - - - - - - - - - - - - - - - - - - - - - -`; - -// monochrome donkey via a mask (solid ink face with knocked-out eyes / inner-ears / -// nostrils) -> survives single-colour theme tinting on Linux tray + Android notif. -function donkeyMono(id, ink) { - return ` - - - - - - - - - - - - - - - - - - - - - - - `; -} - // corner status badge: a solid disc with a symbol knocked out (evenodd), plus a // transparent gap ring so it separates from the donkey under any tint. function badge(symbolPath) { @@ -73,23 +14,11 @@ function badge(symbolPath) { return { cx, cy, r, symbolPath }; } -function wrap(inner, extra='') { - return `${extra}${inner}\n`; -} - // 1) BRAND LOGO (color, gradient squircle bg + donkey + cursor) -const logo = wrap(` - - - - - - +const logo = wrap(`${gradientDefs} ${donkeyColor} - - - `); + ${cursor}`); fs.writeFileSync(path.join(OUT,'inputflow-logo.svg'), logo); // foreground-only donkey (transparent) for Android adaptive + in-app, re-centred @@ -103,30 +32,10 @@ fs.writeFileSync(path.join(OUT,'inputflow-tray.svg'), wrap(donkeyMono('m0','#2E3 // and we punch a transparent ring out of the donkey so the badge reads cleanly. function trayVariant(name, symbolInner, ink='#2E3440') { const b = badge(); + const gapRing = ` + `; const inner = ` - - - - - - - - - - - - - - - - - - - - - - - + ${donkeyMono('dk', ink, gapRing)} ${symbolInner(b)}`; fs.writeFileSync(path.join(OUT,`inputflow-tray-${name}.svg`), wrap(inner)); } diff --git a/tools/icon-generator/donkey.js b/tools/icon-generator/donkey.js index d192d40..1d4226c 100644 --- a/tools/icon-generator/donkey.js +++ b/tools/icon-generator/donkey.js @@ -35,27 +35,34 @@ const donkeySilhouette = (ink) => ` `; -// masked monochrome: solid face with knocked-out eyes / inner-ears / nostrils. -// Best for raster tray + notification PNGs (survives theme tinting). -const donkeyMono = (id, ink) => ` - - - +const donkeyInkShapes = ` - - - + `; + +const donkeyKnockoutShapes = ` - + `; + +const donkeyMask = (id, extraKnockouts='') => ` + + + ${donkeyInkShapes} + + ${donkeyKnockoutShapes}${extraKnockouts} +`; + +// masked monochrome: solid face with knocked-out eyes / inner-ears / nostrils. +// Best for raster tray + notification PNGs (survives theme tinting). +const donkeyMono = (id, ink, extraKnockouts='') => `${donkeyMask(id, extraKnockouts)} `; const cursor = ` @@ -65,4 +72,4 @@ const cursor = ` const gradientDefs = ` `; -module.exports = { W, wrap, donkeyColor, donkeySilhouette, donkeyMono, cursor, gradientDefs }; +module.exports = { W, wrap, donkeyColor, donkeySilhouette, donkeyMask, donkeyMono, cursor, gradientDefs }; From 85a8b2b8c2144c66ec5f05358330c7406e25a350 Mon Sep 17 00:00:00 2001 From: daredoole <49536135+daredoole@users.noreply.github.com> Date: Wed, 24 Jun 2026 20:30:16 -0400 Subject: [PATCH 2/9] android: lower input latency + native trackpad scroll + more keybinds - Coalesce backlogged mouse-move frames to the newest (snappy cursor, no lag). - Native continuous 2-finger scroll via ACTION_SCROLL when a native backend is active. - Extend keymap with F1-F12, Insert, CapsLock. Co-Authored-By: Claude Opus 4.8 --- .../com/inputflow/android/InjectorManager.kt | 7 ++ .../com/inputflow/android/NativeInjector.kt | 22 ++++ .../android/RelayForegroundService.kt | 104 +++++++++++------- .../main/java/com/inputflow/android/VkMap.kt | 4 + 4 files changed, 98 insertions(+), 39 deletions(-) diff --git a/android/app/src/main/java/com/inputflow/android/InjectorManager.kt b/android/app/src/main/java/com/inputflow/android/InjectorManager.kt index b1a347e..5f4063d 100644 --- a/android/app/src/main/java/com/inputflow/android/InjectorManager.kt +++ b/android/app/src/main/java/com/inputflow/android/InjectorManager.kt @@ -163,6 +163,13 @@ object InjectorManager { return n.handleKeyboard(frame) } + /** Native continuous scroll for 2-finger trackpad gestures. */ + fun handleScroll(frame: JSONObject): Boolean { + val n = native ?: return false + n.scrollBy(frame.optDouble("dx", 0.0).toFloat(), frame.optDouble("dy", 0.0).toFloat()) + return true + } + private fun sensitivity(context: Context): Float { val prefs = context.getSharedPreferences(RelayForegroundService.PREFS, Context.MODE_PRIVATE) return prefs.getFloat(SettingsActivity.KEY_SENSITIVITY, 1.0f).coerceIn(0.5f, 3.0f) diff --git a/android/app/src/main/java/com/inputflow/android/NativeInjector.kt b/android/app/src/main/java/com/inputflow/android/NativeInjector.kt index c24a83e..9a0278b 100644 --- a/android/app/src/main/java/com/inputflow/android/NativeInjector.kt +++ b/android/app/src/main/java/com/inputflow/android/NativeInjector.kt @@ -62,6 +62,26 @@ class NativeInjector( inject(pointerEvent(MotionEvent.ACTION_UP, 0, down, SystemClock.uptimeMillis())) } + /** Continuous trackpad-style scroll at the cursor (native ACTION_SCROLL). */ + fun scrollBy(dx: Float, dy: Float) { + val now = SystemClock.uptimeMillis() + val props = MotionEvent.PointerProperties().apply { + id = 0; toolType = MotionEvent.TOOL_TYPE_MOUSE + } + val coords = MotionEvent.PointerCoords().apply { + x = px; y = py; pressure = 1f; size = 1f + // Android scroll axes: positive vscroll = away/up. Trackpad dy>0 = down. + setAxisValue(MotionEvent.AXIS_VSCROLL, -dy / SCROLL_DIVISOR) + setAxisValue(MotionEvent.AXIS_HSCROLL, dx / SCROLL_DIVISOR) + } + val ev = MotionEvent.obtain( + now, now, MotionEvent.ACTION_SCROLL, 1, + arrayOf(props), arrayOf(coords), 0, 0, 1f, 1f, 0, 0, + InputDevice.SOURCE_MOUSE, 0) + inject(ev) + ev.recycle() + } + private fun scroll(mouseData: Int) { val now = SystemClock.uptimeMillis() val props = MotionEvent.PointerProperties().apply { @@ -130,5 +150,7 @@ class NativeInjector( private const val WM_MBUTTONUP = 0x0208 private const val WM_MOUSEWHEEL = 0x020A private const val LLKHF_UP = 0x80 + // Trackpad pixel-delta → scroll-unit scale; smaller = faster scroll. + private const val SCROLL_DIVISOR = 40f } } diff --git a/android/app/src/main/java/com/inputflow/android/RelayForegroundService.kt b/android/app/src/main/java/com/inputflow/android/RelayForegroundService.kt index e1aef36..82cdbc6 100644 --- a/android/app/src/main/java/com/inputflow/android/RelayForegroundService.kt +++ b/android/app/src/main/java/com/inputflow/android/RelayForegroundService.kt @@ -99,6 +99,52 @@ class RelayForegroundService : Service() { worker = Thread({ relayLoop() }, "inputflow-relay").also { it.start() } } + private fun dispatchFrame(frame: JSONObject, prefs: android.content.SharedPreferences) { + when (frame.optString("type")) { + "control" -> setRemoteControlActive(frame.optBoolean("active", false)) + "mouse" -> if (remoteControlActive) { + // Prefer native injection (Shizuku/root); fall back to accessibility. + if (!InjectorManager.handleMouse(frame)) { + val accessibility = InputFlowAccessibilityService.instance + if (accessibility != null) { + deliveredMouseFrames += 1 + accessibility.handleMouse(frame) + } else { + if (droppedMouseFrames < 5) { + Log.w(TAG, "mouse frame dropped: no injector active") + } + droppedMouseFrames += 1 + } + } + } + "keyboard" -> { + val cb = keyCaptureCallback + if (cb != null) { + cb(frame.optInt("vkCode"), frame.optInt("flags")) + } else if (remoteControlActive) { + val accessibility = InputFlowAccessibilityService.instance + if (accessibility?.handleMappedKeyboard(frame) == true) { + // handled by a user-defined key mapping + } else if (InjectorManager.handleKeyboard(frame)) { + // injected natively (Shizuku/root) + } else { + val laptopTypingEnabled = prefs.getBoolean(KEY_LAPTOP_TYPING_ENABLED, false) + if (!laptopTypingEnabled || InputFlowImeService.instance?.handleKeyboard(frame) != true) { + accessibility?.handleKeyboard(frame) + } + } + } + } + "gesture" -> if (remoteControlActive) { + // Native continuous scroll when available; richer gestures via accessibility. + if (frame.optString("kind") != "scroll" || !InjectorManager.handleScroll(frame)) { + InputFlowAccessibilityService.instance?.handleGesture(frame) + } + } + "devices_info" -> handleDevicesInfo(frame) + } + } + private fun relayLoop() { val prefs = getSharedPreferences(PREFS, Context.MODE_PRIVATE) while (running.get()) { @@ -131,47 +177,26 @@ class RelayForegroundService : Service() { startRelayForeground("Connected to $host:$port") broadcastStatus(STATE_CONNECTED, "$host:$port") while (running.get()) { - val frame = RelayProtocol.readFrame(streams.first) - when (frame.optString("type")) { - "control" -> setRemoteControlActive(frame.optBoolean("active", false)) - "mouse" -> if (remoteControlActive) { - // Prefer native injection (Shizuku/root); fall back to accessibility. - if (!InjectorManager.handleMouse(frame)) { - val accessibility = InputFlowAccessibilityService.instance - if (accessibility != null) { - deliveredMouseFrames += 1 - accessibility.handleMouse(frame) - } else { - if (droppedMouseFrames < 5) { - Log.w(TAG, "mouse frame dropped: no injector active") - } - droppedMouseFrames += 1 - } - } - } - "keyboard" -> { - val cb = keyCaptureCallback - if (cb != null) { - cb(frame.optInt("vkCode"), frame.optInt("flags")) - } else if (remoteControlActive) { - val accessibility = InputFlowAccessibilityService.instance - if (accessibility?.handleMappedKeyboard(frame) == true) { - // handled by a user-defined key mapping - } else if (InjectorManager.handleKeyboard(frame)) { - // injected natively (Shizuku/root) - } else { - val laptopTypingEnabled = prefs.getBoolean(KEY_LAPTOP_TYPING_ENABLED, false) - if (!laptopTypingEnabled || InputFlowImeService.instance?.handleKeyboard(frame) != true) { - accessibility?.handleKeyboard(frame) - } - } - } - } - "gesture" -> if (remoteControlActive) { - InputFlowAccessibilityService.instance?.handleGesture(frame) + var frame = RelayProtocol.readFrame(streams.first) + // Collapse a backlog of mouse-move frames to the newest so the + // cursor tracks live with no lag under load. Non-move frames are + // dispatched in order; only intermediate moves are dropped. + while (frame.optString("type") == "mouse" && + frame.optInt("wParam") == WM_MOUSEMOVE && + streams.first.available() > 4 + ) { + val next = RelayProtocol.readFrame(streams.first) + if (next.optString("type") == "mouse" && + next.optInt("wParam") == WM_MOUSEMOVE + ) { + frame = next + } else { + dispatchFrame(frame, prefs) + frame = next + break } - "devices_info" -> handleDevicesInfo(frame) } + dispatchFrame(frame, prefs) } deactivateRemoteControl() } @@ -257,6 +282,7 @@ class RelayForegroundService : Service() { } companion object { + private const val WM_MOUSEMOVE = 0x0200 const val PREFS = "inputflow" const val KEY_HOST = "host" const val KEY_PORT = "port" diff --git a/android/app/src/main/java/com/inputflow/android/VkMap.kt b/android/app/src/main/java/com/inputflow/android/VkMap.kt index b5576dc..e378ff0 100644 --- a/android/app/src/main/java/com/inputflow/android/VkMap.kt +++ b/android/app/src/main/java/com/inputflow/android/VkMap.kt @@ -27,6 +27,10 @@ object VkMap { put(0x27, KeyEvent.KEYCODE_DPAD_RIGHT) put(0x28, KeyEvent.KEYCODE_DPAD_DOWN) put(0x2E, KeyEvent.KEYCODE_FORWARD_DEL) + put(0x2D, KeyEvent.KEYCODE_INSERT) + put(0x14, KeyEvent.KEYCODE_CAPS_LOCK) + // Function keys F1-F12 (VK 0x70-0x7B → KEYCODE_F1..F12) + for (i in 0..11) put(0x70 + i, KeyEvent.KEYCODE_F1 + i) put(0x5B, KeyEvent.KEYCODE_META_LEFT) put(0x5C, KeyEvent.KEYCODE_META_RIGHT) // OEM punctuation From dcaf35a933e88be9e052e8853ba7365ed17c123f Mon Sep 17 00:00:00 2001 From: daredoole <49536135+daredoole@users.noreply.github.com> Date: Wed, 24 Jun 2026 20:40:31 -0400 Subject: [PATCH 3/9] android: improve trackpad swipe gestures --- .../android/InputFlowAccessibilityService.kt | 18 ++++++++++++++++-- src/LibeiInputCaptureBridge.cpp | 4 +++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/android/app/src/main/java/com/inputflow/android/InputFlowAccessibilityService.kt b/android/app/src/main/java/com/inputflow/android/InputFlowAccessibilityService.kt index e6cb1d5..0131725 100644 --- a/android/app/src/main/java/com/inputflow/android/InputFlowAccessibilityService.kt +++ b/android/app/src/main/java/com/inputflow/android/InputFlowAccessibilityService.kt @@ -290,17 +290,25 @@ class InputFlowAccessibilityService : AccessibilityService() { } private fun handleSwipe3(dx: Double, dy: Double) { + if (isAccurateHorizontalSwipe(dx, dy)) { + dispatchDirectionalSwipe(if (dx < 0) -1f else 1f, 0f) + return + } if (!isAccurateVerticalSwipe(dx, dy)) return mainHandler.post { if (dy < 0) { - performGlobalActionLogged(GLOBAL_ACTION_HOME, "gesture:3-up") + performGlobalActionLogged(GLOBAL_ACTION_RECENTS, "gesture:3-up") } else { - performGlobalActionLogged(GLOBAL_ACTION_RECENTS, "gesture:3-down") + performGlobalActionLogged(GLOBAL_ACTION_HOME, "gesture:3-down") } } } private fun handleSwipe4(dx: Double, dy: Double) { + if (isAccurateHorizontalSwipe(dx, dy)) { + dispatchDirectionalSwipe(if (dx < 0) -1f else 1f, 0f) + return + } if (!isAccurateVerticalSwipe(dx, dy)) return mainHandler.post { if (dy < 0) { @@ -317,6 +325,12 @@ class InputFlowAccessibilityService : AccessibilityService() { return absY >= 32.0 && absY >= absX * 1.45 } + private fun isAccurateHorizontalSwipe(dx: Double, dy: Double): Boolean { + val absX = abs(dx) + val absY = abs(dy) + return absX >= 32.0 && absX >= absY * 1.45 + } + private fun openAppDrawerGesture() { performGlobalActionLogged(GLOBAL_ACTION_HOME, "gesture:4-up-home") mainHandler.postDelayed({ diff --git a/src/LibeiInputCaptureBridge.cpp b/src/LibeiInputCaptureBridge.cpp index 62f58c4..b833651 100644 --- a/src/LibeiInputCaptureBridge.cpp +++ b/src/LibeiInputCaptureBridge.cpp @@ -254,7 +254,9 @@ bool EmitClassifiedSwipe( const double absX = std::abs(dx); const double absY = std::abs(dy); - if (absY < 36.0 || absY < absX * 1.35) { + const bool vertical = absY >= 36.0 && absY >= absX * 1.35; + const bool horizontal = absX >= 36.0 && absX >= absY * 1.35; + if (!vertical && !horizontal) { return false; } From ec39859970effc8e628cace06c98ad71685b975e Mon Sep 17 00:00:00 2001 From: daredoole <49536135+daredoole@users.noreply.github.com> Date: Wed, 24 Jun 2026 20:46:23 -0400 Subject: [PATCH 4/9] android: support home page trackpad swipes --- .../android/InputFlowAccessibilityService.kt | 16 ++++++++++++++-- .../inputflow/android/RelayForegroundService.kt | 9 ++++++++- src/ClientRuntime.cpp | 3 +++ src/LocalAndroidInputBridge.cpp | 7 +++++++ src/LocalAndroidInputBridge.h | 1 + 5 files changed, 33 insertions(+), 3 deletions(-) diff --git a/android/app/src/main/java/com/inputflow/android/InputFlowAccessibilityService.kt b/android/app/src/main/java/com/inputflow/android/InputFlowAccessibilityService.kt index 0131725..53d3913 100644 --- a/android/app/src/main/java/com/inputflow/android/InputFlowAccessibilityService.kt +++ b/android/app/src/main/java/com/inputflow/android/InputFlowAccessibilityService.kt @@ -291,7 +291,7 @@ class InputFlowAccessibilityService : AccessibilityService() { private fun handleSwipe3(dx: Double, dy: Double) { if (isAccurateHorizontalSwipe(dx, dy)) { - dispatchDirectionalSwipe(if (dx < 0) -1f else 1f, 0f) + dispatchPageSwipe(if (dx < 0) -1f else 1f) return } if (!isAccurateVerticalSwipe(dx, dy)) return @@ -306,7 +306,7 @@ class InputFlowAccessibilityService : AccessibilityService() { private fun handleSwipe4(dx: Double, dy: Double) { if (isAccurateHorizontalSwipe(dx, dy)) { - dispatchDirectionalSwipe(if (dx < 0) -1f else 1f, 0f) + dispatchPageSwipe(if (dx < 0) -1f else 1f) return } if (!isAccurateVerticalSwipe(dx, dy)) return @@ -565,6 +565,10 @@ class InputFlowAccessibilityService : AccessibilityService() { pendingScrollDx = 0.0 pendingScrollDy = 0.0 if (abs(dx) < 0.05 && abs(dy) < 0.05) return + if (isAccurateHorizontalSwipe(dx, dy)) { + dispatchPageSwipe(if (dx < 0) -1f else 1f) + return + } val metrics = resources.displayMetrics val scale = 34f * metrics.density val maxStep = 170f * metrics.density @@ -575,6 +579,14 @@ class InputFlowAccessibilityService : AccessibilityService() { gesture(pointerX, pointerY, toX, toY, 62) } + private fun dispatchPageSwipe(directionX: Float) { + val metrics = resources.displayMetrics + val y = metrics.heightPixels * 0.56f + val fromX = if (directionX < 0f) metrics.widthPixels * 0.84f else metrics.widthPixels * 0.16f + val toX = if (directionX < 0f) metrics.widthPixels * 0.16f else metrics.widthPixels * 0.84f + gesture(fromX, y, toX, y, 260) + } + private fun showCursorOverlay() { if (cursorView != null) return val prefs = applicationContext.getSharedPreferences(RelayForegroundService.PREFS, MODE_PRIVATE) diff --git a/android/app/src/main/java/com/inputflow/android/RelayForegroundService.kt b/android/app/src/main/java/com/inputflow/android/RelayForegroundService.kt index 82cdbc6..17cebec 100644 --- a/android/app/src/main/java/com/inputflow/android/RelayForegroundService.kt +++ b/android/app/src/main/java/com/inputflow/android/RelayForegroundService.kt @@ -137,7 +137,7 @@ class RelayForegroundService : Service() { } "gesture" -> if (remoteControlActive) { // Native continuous scroll when available; richer gestures via accessibility. - if (frame.optString("kind") != "scroll" || !InjectorManager.handleScroll(frame)) { + if (shouldUseAccessibilityGesture(frame) || !InjectorManager.handleScroll(frame)) { InputFlowAccessibilityService.instance?.handleGesture(frame) } } @@ -145,6 +145,13 @@ class RelayForegroundService : Service() { } } + private fun shouldUseAccessibilityGesture(frame: JSONObject): Boolean { + if (frame.optString("kind") != "scroll") return true + val dx = kotlin.math.abs(frame.optDouble("dx", 0.0)) + val dy = kotlin.math.abs(frame.optDouble("dy", 0.0)) + return dx >= 2.0 && dx >= dy * 1.8 + } + private fun relayLoop() { val prefs = getSharedPreferences(PREFS, Context.MODE_PRIVATE) while (running.get()) { diff --git a/src/ClientRuntime.cpp b/src/ClientRuntime.cpp index 7865cca..db530a2 100644 --- a/src/ClientRuntime.cpp +++ b/src/ClientRuntime.cpp @@ -699,6 +699,9 @@ void ClientRuntime::StartLocalAndroidInputBridge(const ScreenSize& screenSize) { options.sendMouse = [this](const MouseData& mouse) { return TrySendAndroidMouse(mouse); }; + options.sendGesture = [this](const std::string& kind, double dx, double dy) { + return TrySendAndroidGesture(kind, dx, dy); + }; m_localAndroidInputBridge = std::make_unique(std::move(options)); m_localAndroidInputBridge->Start(); diff --git a/src/LocalAndroidInputBridge.cpp b/src/LocalAndroidInputBridge.cpp index 94afe91..a04c322 100644 --- a/src/LocalAndroidInputBridge.cpp +++ b/src/LocalAndroidInputBridge.cpp @@ -48,6 +48,7 @@ struct DeviceState { int relDx{0}; int relDy{0}; int wheel{0}; + int hWheel{0}; bool hasAbsX{false}; bool hasAbsY{false}; bool hasTouchState{false}; @@ -464,6 +465,8 @@ void LocalAndroidInputBridge::Run() { device.relDy += event.value; } else if (event.code == REL_WHEEL) { device.wheel += event.value; + } else if (event.code == REL_HWHEEL) { + device.hWheel += event.value; } } else if (event.type == EV_ABS) { if (event.code == ABS_X && device.hasAbsX) { @@ -516,9 +519,13 @@ void LocalAndroidInputBridge::Run() { MouseData wheel{androidX, androidY, device.wheel * 120, WM_MOUSEWHEEL}; m_options.sendMouse(wheel); } + if (active && device.hWheel != 0 && m_options.sendGesture) { + m_options.sendGesture("scroll", device.hWheel * 8.0, 0.0); + } device.relDx = 0; device.relDy = 0; device.wheel = 0; + device.hWheel = 0; } } } diff --git a/src/LocalAndroidInputBridge.h b/src/LocalAndroidInputBridge.h index 831a185..01905b6 100644 --- a/src/LocalAndroidInputBridge.h +++ b/src/LocalAndroidInputBridge.h @@ -18,6 +18,7 @@ struct LocalAndroidInputBridgeOptions { int desktopWidth{0}; int desktopHeight{0}; std::function sendMouse; + std::function sendGesture; }; class LocalAndroidInputBridge { From b235053ab8681375ecd7580ae2c98a3fed222661 Mon Sep 17 00:00:00 2001 From: daredoole <49536135+daredoole@users.noreply.github.com> Date: Wed, 24 Jun 2026 21:00:58 -0400 Subject: [PATCH 5/9] android: add notification sync --- android/app/src/main/AndroidManifest.xml | 10 ++ .../com/inputflow/android/InjectorManager.kt | 6 +- .../com/inputflow/android/NativeInjector.kt | 26 ++-- .../android/NotificationSyncBridge.kt | 141 ++++++++++++++++++ .../android/RelayForegroundService.kt | 101 +++++++++---- .../com/inputflow/android/SettingsActivity.kt | 45 ++++++ .../src/main/res/layout/activity_settings.xml | 54 +++++++ android/app/src/main/res/values/strings.xml | 11 ++ docs/android.md | 19 +++ src/AndroidRelay.cpp | 85 ++++++++++- src/AndroidRelay.h | 1 + src/AppConfig.cpp | 12 ++ src/AppConfig.h | 1 + src/TrayController.cpp | 1 + src/main.cpp | 3 + 15 files changed, 468 insertions(+), 48 deletions(-) create mode 100644 android/app/src/main/java/com/inputflow/android/NotificationSyncBridge.kt diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index e4da20e..d3b1aa3 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -49,6 +49,16 @@ android:exported="false" android:foregroundServiceType="connectedDevice" /> + + + + + + { val sens = sensitivityProvider() val baseX = frame.optInt("x") / 65535f * m.widthPixels @@ -51,19 +51,21 @@ class NativeInjector( WM_RBUTTONUP -> click(MotionEvent.BUTTON_SECONDARY) WM_MBUTTONUP -> click(MotionEvent.BUTTON_TERTIARY) WM_MOUSEWHEEL -> scroll(frame.optInt("mouseData")) + else -> false } } - private fun click(button: Int) { + private fun click(button: Int): Boolean { val down = SystemClock.uptimeMillis() - inject(pointerEvent(MotionEvent.ACTION_DOWN, button, down, down)) - inject(pointerEvent(MotionEvent.ACTION_BUTTON_PRESS, button, down, down)) - inject(pointerEvent(MotionEvent.ACTION_BUTTON_RELEASE, 0, down, SystemClock.uptimeMillis())) - inject(pointerEvent(MotionEvent.ACTION_UP, 0, down, SystemClock.uptimeMillis())) + val downSent = inject(pointerEvent(MotionEvent.ACTION_DOWN, button, down, down)) + val pressSent = inject(pointerEvent(MotionEvent.ACTION_BUTTON_PRESS, button, down, down)) + val releaseSent = inject(pointerEvent(MotionEvent.ACTION_BUTTON_RELEASE, 0, down, SystemClock.uptimeMillis())) + val upSent = inject(pointerEvent(MotionEvent.ACTION_UP, 0, down, SystemClock.uptimeMillis())) + return downSent && pressSent && releaseSent && upSent } /** Continuous trackpad-style scroll at the cursor (native ACTION_SCROLL). */ - fun scrollBy(dx: Float, dy: Float) { + fun scrollBy(dx: Float, dy: Float): Boolean { val now = SystemClock.uptimeMillis() val props = MotionEvent.PointerProperties().apply { id = 0; toolType = MotionEvent.TOOL_TYPE_MOUSE @@ -78,11 +80,12 @@ class NativeInjector( now, now, MotionEvent.ACTION_SCROLL, 1, arrayOf(props), arrayOf(coords), 0, 0, 1f, 1f, 0, 0, InputDevice.SOURCE_MOUSE, 0) - inject(ev) + val sent = inject(ev) ev.recycle() + return sent } - private fun scroll(mouseData: Int) { + private fun scroll(mouseData: Int): Boolean { val now = SystemClock.uptimeMillis() val props = MotionEvent.PointerProperties().apply { id = 0; toolType = MotionEvent.TOOL_TYPE_MOUSE @@ -95,8 +98,9 @@ class NativeInjector( now, now, MotionEvent.ACTION_SCROLL, 1, arrayOf(props), arrayOf(coords), 0, 0, 1f, 1f, 0, 0, InputDevice.SOURCE_MOUSE, 0) - inject(ev) + val sent = inject(ev) ev.recycle() + return sent } private fun pointerEvent( diff --git a/android/app/src/main/java/com/inputflow/android/NotificationSyncBridge.kt b/android/app/src/main/java/com/inputflow/android/NotificationSyncBridge.kt new file mode 100644 index 0000000..b22c78a --- /dev/null +++ b/android/app/src/main/java/com/inputflow/android/NotificationSyncBridge.kt @@ -0,0 +1,141 @@ +package com.inputflow.android + +import android.Manifest +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.service.notification.NotificationListenerService +import android.service.notification.StatusBarNotification +import org.json.JSONObject +import kotlin.math.absoluteValue + +class InputFlowNotificationListenerService : NotificationListenerService() { + override fun onNotificationPosted(sbn: StatusBarNotification) { + NotificationSyncBridge.sendAndroidNotification(this, sbn) + } + + override fun onNotificationRemoved(sbn: StatusBarNotification) { + NotificationSyncBridge.sendAndroidNotificationDismiss(this, sbn) + } +} + +object NotificationSyncBridge { + private const val MIRROR_CHANNEL_ID = "inputflow-mirrored" + private const val MIRROR_CHANNEL_NAME = "Mirrored notifications" + private val sensitivePattern = Regex( + "\\b(otp|one[- ]?time|verification|2fa|two[- ]?factor|password|passcode|bank|credit|debit)\\b", + RegexOption.IGNORE_CASE + ) + + fun sendAndroidNotification(context: Context, sbn: StatusBarNotification) { + if (!isEnabled(context) || sbn.packageName == context.packageName) return + + val notification = sbn.notification ?: return + if ((notification.flags and Notification.FLAG_GROUP_SUMMARY) != 0) return + if ((notification.flags and Notification.FLAG_ONGOING_EVENT) != 0) return + if (notification.visibility == Notification.VISIBILITY_SECRET) return + + val title = notification.extras.getCharSequence(Notification.EXTRA_TITLE) + ?.toString() + ?.trim() + .orEmpty() + val body = ( + notification.extras.getCharSequence(Notification.EXTRA_BIG_TEXT) + ?: notification.extras.getCharSequence(Notification.EXTRA_TEXT) + ) + ?.toString() + ?.trim() + .orEmpty() + + if (title.isBlank() && body.isBlank()) return + if (isSensitive(title, body)) return + + RelayForegroundService.instance?.sendNotificationUpsert( + stableId = stableId(sbn), + app = appLabel(context, sbn.packageName), + packageName = sbn.packageName, + title = title, + body = body, + postedAtMs = sbn.postTime + ) + } + + fun sendAndroidNotificationDismiss(context: Context, sbn: StatusBarNotification) { + if (!isEnabled(context) || sbn.packageName == context.packageName) return + RelayForegroundService.instance?.sendNotificationDismiss(stableId(sbn), sbn.packageName) + } + + fun showMirroredNotification(context: Context, frame: JSONObject) { + if (!isEnabled(context)) return + if (Build.VERSION.SDK_INT >= 33 && + context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != + PackageManager.PERMISSION_GRANTED + ) { + return + } + + val title = frame.optString("title", "InputFlow notification") + val body = frame.optString("body", "") + val app = frame.optString("app", "") + val stableId = frame.optString("stable_id", "$app:$title:$body") + val manager = context.getSystemService(NotificationManager::class.java) + ensureMirrorChannel(manager) + + val notification = Notification.Builder(context, MIRROR_CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(if (app.isBlank()) title else "$app: $title") + .setContentText(body) + .setStyle(Notification.BigTextStyle().bigText(body)) + .setAutoCancel(true) + .build() + + manager.notify(notificationId(stableId), notification) + } + + fun cancelMirroredNotification(context: Context, frame: JSONObject) { + val stableId = frame.optString("stable_id", "") + if (stableId.isBlank()) return + context.getSystemService(NotificationManager::class.java) + .cancel(notificationId(stableId)) + } + + private fun isEnabled(context: Context): Boolean = + context.getSharedPreferences(RelayForegroundService.PREFS, Context.MODE_PRIVATE) + .getBoolean(RelayForegroundService.KEY_NOTIFICATION_SYNC_ENABLED, false) + + private fun isSensitive(title: String, body: String): Boolean = + sensitivePattern.containsMatchIn(title) || sensitivePattern.containsMatchIn(body) + + private fun stableId(sbn: StatusBarNotification): String { + val tag = sbn.tag ?: "" + return "${sbn.packageName}:${sbn.id}:$tag:${sbn.postTime}" + } + + private fun appLabel(context: Context, packageName: String): String { + return try { + val pm = context.packageManager + val info = pm.getApplicationInfo(packageName, 0) + pm.getApplicationLabel(info).toString() + } catch (_: Exception) { + packageName + } + } + + private fun ensureMirrorChannel(manager: NotificationManager) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + if (manager.getNotificationChannel(MIRROR_CHANNEL_ID) != null) return + manager.createNotificationChannel( + NotificationChannel( + MIRROR_CHANNEL_ID, + MIRROR_CHANNEL_NAME, + NotificationManager.IMPORTANCE_DEFAULT + ) + ) + } + + private fun notificationId(stableId: String): Int = + (stableId.hashCode().toLong().absoluteValue % 900_000L).toInt() + 100_000 +} diff --git a/android/app/src/main/java/com/inputflow/android/RelayForegroundService.kt b/android/app/src/main/java/com/inputflow/android/RelayForegroundService.kt index 17cebec..e56c4ba 100644 --- a/android/app/src/main/java/com/inputflow/android/RelayForegroundService.kt +++ b/android/app/src/main/java/com/inputflow/android/RelayForegroundService.kt @@ -21,6 +21,7 @@ class RelayForegroundService : Service() { @Volatile private var output: DataOutputStream? = null + private val outputLock = Any() @Volatile private var remoteControlActive = false private var deliveredMouseFrames = 0 @@ -84,13 +85,41 @@ class RelayForegroundService : Service() { override fun onBind(intent: Intent?): IBinder? = null fun sendTopologyUpdate(layout: JSONArray) { - try { - output?.let { - RelayProtocol.writeFrame(it, JSONObject().put("type", "topology_update").put("layout", layout)) - } - } catch (_: Exception) {} + writeFrame(JSONObject().put("type", "topology_update").put("layout", layout)) } + fun isConnectedForWrites(): Boolean = + currentState == STATE_CONNECTED && output != null + + fun sendNotificationUpsert( + stableId: String, + app: String, + packageName: String, + title: String, + body: String, + postedAtMs: Long + ): Boolean = + writeFrame( + JSONObject() + .put("type", "notification_upsert") + .put("stable_id", stableId) + .put("origin", "android") + .put("app", app) + .put("package", packageName) + .put("title", title) + .put("body", body) + .put("posted_at_ms", postedAtMs) + ) + + fun sendNotificationDismiss(stableId: String, packageName: String): Boolean = + writeFrame( + JSONObject() + .put("type", "notification_dismiss") + .put("stable_id", stableId) + .put("origin", "android") + .put("package", packageName) + ) + private fun startRelay() { if (!running.compareAndSet(false, true)) return InjectorManager.start(this) @@ -104,17 +133,18 @@ class RelayForegroundService : Service() { "control" -> setRemoteControlActive(frame.optBoolean("active", false)) "mouse" -> if (remoteControlActive) { // Prefer native injection (Shizuku/root); fall back to accessibility. - if (!InjectorManager.handleMouse(frame)) { - val accessibility = InputFlowAccessibilityService.instance - if (accessibility != null) { - deliveredMouseFrames += 1 - accessibility.handleMouse(frame) - } else { - if (droppedMouseFrames < 5) { - Log.w(TAG, "mouse frame dropped: no injector active") - } - droppedMouseFrames += 1 + val accessibility = InputFlowAccessibilityService.instance + val isMove = frame.optInt("wParam") == WM_MOUSEMOVE + if (isMove && InjectorManager.handleMouse(frame)) { + // Native pointer motion succeeded. + } else if (accessibility != null) { + deliveredMouseFrames += 1 + accessibility.handleMouse(frame) + } else if (!InjectorManager.handleMouse(frame)) { + if (droppedMouseFrames < 5) { + Log.w(TAG, "mouse frame dropped: no injector active") } + droppedMouseFrames += 1 } } "keyboard" -> { @@ -136,22 +166,19 @@ class RelayForegroundService : Service() { } } "gesture" -> if (remoteControlActive) { - // Native continuous scroll when available; richer gestures via accessibility. - if (shouldUseAccessibilityGesture(frame) || !InjectorManager.handleScroll(frame)) { - InputFlowAccessibilityService.instance?.handleGesture(frame) + val accessibility = InputFlowAccessibilityService.instance + if (accessibility != null) { + accessibility.handleGesture(frame) + } else if (frame.optString("kind") == "scroll") { + InjectorManager.handleScroll(frame) } } "devices_info" -> handleDevicesInfo(frame) + "notification_upsert" -> NotificationSyncBridge.showMirroredNotification(this, frame) + "notification_dismiss" -> NotificationSyncBridge.cancelMirroredNotification(this, frame) } } - private fun shouldUseAccessibilityGesture(frame: JSONObject): Boolean { - if (frame.optString("kind") != "scroll") return true - val dx = kotlin.math.abs(frame.optDouble("dx", 0.0)) - val dy = kotlin.math.abs(frame.optDouble("dy", 0.0)) - return dx >= 2.0 && dx >= dy * 1.8 - } - private fun relayLoop() { val prefs = getSharedPreferences(PREFS, Context.MODE_PRIVATE) while (running.get()) { @@ -225,11 +252,26 @@ class RelayForegroundService : Service() { } private fun sendRelease() { - try { - output?.let { - RelayProtocol.writeFrame(it, JSONObject().put("type", "release")) + writeFrame(JSONObject().put("type", "release")) + } + + private fun writeFrame(frame: JSONObject): Boolean { + return try { + synchronized(outputLock) { + output?.let { + RelayProtocol.writeFrame(it, frame) + true + } ?: run { + Log.w(TAG, "failed to write relay frame type=${frame.optString("type")}: no relay output stream") + false + } } - } catch (_: Exception) {} + } catch (e: Exception) { + Log.w(TAG, "failed to write relay frame type=${frame.optString("type")}", e) + output = null + broadcastStatus(STATE_DISCONNECTED, "Retrying…") + false + } } private fun setRemoteControlActive(active: Boolean) { @@ -295,6 +337,7 @@ class RelayForegroundService : Service() { const val KEY_PORT = "port" const val KEY_SECRET = "secret" const val KEY_LAPTOP_TYPING_ENABLED = "laptop_typing_enabled" + const val KEY_NOTIFICATION_SYNC_ENABLED = "notification_sync_enabled" const val KEY_STATUS_STATE = "status_state" const val KEY_STATUS_DETAIL = "status_detail" const val KEY_STATUS_HAS_DETAIL = "status_has_detail" diff --git a/android/app/src/main/java/com/inputflow/android/SettingsActivity.kt b/android/app/src/main/java/com/inputflow/android/SettingsActivity.kt index 707b18a..27fc7ef 100644 --- a/android/app/src/main/java/com/inputflow/android/SettingsActivity.kt +++ b/android/app/src/main/java/com/inputflow/android/SettingsActivity.kt @@ -4,9 +4,11 @@ import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.os.Bundle +import android.provider.Settings import android.view.inputmethod.InputMethodManager import android.widget.RadioGroup import android.widget.TextView +import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import com.google.android.material.appbar.MaterialToolbar import com.google.android.material.button.MaterialButton @@ -29,6 +31,7 @@ class SettingsActivity : AppCompatActivity() { private lateinit var injectBackendGroup: RadioGroup private lateinit var injectStatusText: TextView private lateinit var injectConsentSwitch: MaterialSwitch + private lateinit var notificationSyncSwitch: MaterialSwitch override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -68,6 +71,18 @@ class SettingsActivity : AppCompatActivity() { sensitivitySlider.value = prefs.getFloat(KEY_SENSITIVITY, 1.0f).coerceIn(0.5f, 3.0f) laptopTypingSwitch.isChecked = prefs.getBoolean(RelayForegroundService.KEY_LAPTOP_TYPING_ENABLED, false) + notificationSyncSwitch = findViewById(R.id.notificationSyncSwitch) + notificationSyncSwitch.isChecked = + prefs.getBoolean(RelayForegroundService.KEY_NOTIFICATION_SYNC_ENABLED, false) + notificationSyncSwitch.setOnCheckedChangeListener { _, checked -> + saveSettings() + if (checked) { + startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)) + } + } + findViewById(R.id.btnSendTestNotification).setOnClickListener { + sendTestNotification() + } // Restore keyboard mode when (prefs.getString(KEY_KEYBOARD_MODE, "accessibility")) { @@ -179,6 +194,35 @@ class SettingsActivity : AppCompatActivity() { "Shizuku: ${if (shizuku) "ready" else "unavailable"} Root: ${if (root) "ready" else "unavailable"}" } + private fun sendTestNotification() { + saveSettings() + if (!notificationSyncSwitch.isChecked) { + Toast.makeText(this, R.string.test_notification_enable_sync, Toast.LENGTH_SHORT).show() + return + } + + val relay = RelayForegroundService.instance + if (relay?.isConnectedForWrites() != true) { + Toast.makeText(this, R.string.test_notification_not_connected, Toast.LENGTH_SHORT).show() + return + } + + val now = System.currentTimeMillis() + val sent = relay.sendNotificationUpsert( + stableId = "inputflow-test:$now", + app = getString(R.string.app_name), + packageName = packageName, + title = getString(R.string.test_notification_title), + body = getString(R.string.test_notification_body), + postedAtMs = now + ) + Toast.makeText( + this, + if (sent) R.string.test_notification_sent else R.string.test_notification_failed, + Toast.LENGTH_SHORT + ).show() + } + private fun saveSettings() { val prefs = getSharedPreferences(RelayForegroundService.PREFS, Context.MODE_PRIVATE) val cursorSize = when (cursorSizeGroup.checkedButtonId) { @@ -207,6 +251,7 @@ class SettingsActivity : AppCompatActivity() { .putString(KEY_CURSOR_COLOR, cursorColor) .putFloat(KEY_SENSITIVITY, sensitivitySlider.value) .putBoolean(RelayForegroundService.KEY_LAPTOP_TYPING_ENABLED, laptopTypingSwitch.isChecked) + .putBoolean(RelayForegroundService.KEY_NOTIFICATION_SYNC_ENABLED, notificationSyncSwitch.isChecked) .putString(KEY_KEYBOARD_MODE, keyboardMode) .putString(KEY_CONNECTION_MODE, connectionMode) .putString(InjectorManager.KEY_INJECT_BACKEND, injectBackend) diff --git a/android/app/src/main/res/layout/activity_settings.xml b/android/app/src/main/res/layout/activity_settings.xml index 45dd989..19c0fc2 100644 --- a/android/app/src/main/res/layout/activity_settings.xml +++ b/android/app/src/main/res/layout/activity_settings.xml @@ -375,6 +375,60 @@ + + + + + + + + + + + + + + + + InputFlow InputFlow controlled peer InputFlow keyboard + InputFlow notification sync Connected @@ -44,6 +45,16 @@ Direct InputFlow (Linux host) Windows runs PowerToys Mouse Without Borders. InputFlow on Linux captures that stream and relays input to this device. InputFlow on Linux is the primary source. No Windows or PowerToys needed. The Linux machine relays keyboard and mouse directly to this device. + Notifications + Sync Android notifications + Mirrors allowed Android notifications to connected InputFlow desktops. Sensitive-looking messages are skipped. + Send test notification + InputFlow test notification + If this appears on your desktop, notification sync is working. + Test notification sent to desktop. + Could not send test notification. + Connect to InputFlow before sending a test. + Enable notification sync first. Device layout Edit Device Layout Drag devices to set their position relative to each other. The green device is this tablet. diff --git a/docs/android.md b/docs/android.md index 78fe34b..bfbae6a 100644 --- a/docs/android.md +++ b/docs/android.md @@ -13,6 +13,7 @@ android_relay_port=15102 android_relay_secret=replace-with-a-long-random-secret android_peer_name=pixel-8 android_capture_backend=none +notification_sync_enabled=false ``` Then enable topology and add a machine/display whose machine id matches `android_peer_name`. When a cross-machine topology edge targets that machine, InputFlow forwards mouse events to Android. Keyboard events follow while the Android relay is active. @@ -38,6 +39,24 @@ JAVA_HOME=/path/to/jdk-21 ./gradlew :app:assembleDebug The relay foreground service uses the `connectedDevice` type so Android 15's `dataSync` runtime cap does not kill long sessions. +### Notification sync + +Android-to-Linux notification mirroring is available behind two opt-ins: + +1. Set `notification_sync_enabled=true` in the Linux config. +2. In the Android app, open **Settings → Notifications**, enable sync, and grant + Android notification listener access when Settings opens. + +When enabled, the Android app sends `notification_upsert` and +`notification_dismiss` frames over the existing authenticated relay. The Linux +client displays mirrored Android notifications through `notify-send` when it is +available. InputFlow skips its own notifications, ongoing/group-summary +notifications, hidden-content notifications, and messages that look like OTP, +password, banking, or card content. + +This first slice is Android → Linux. Linux and Windows notification capture can +reuse the same relay frames, but require platform-specific listener work. + ### Input injection backends (Settings → Input method) Choose how input is delivered to the phone: diff --git a/src/AndroidRelay.cpp b/src/AndroidRelay.cpp index 7154784..5d06d71 100644 --- a/src/AndroidRelay.cpp +++ b/src/AndroidRelay.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include @@ -125,11 +126,43 @@ std::optional JsonStringValue(const std::string& json, const std::s return std::nullopt; } const std::size_t valueStart = start + marker.size(); - const std::size_t valueEnd = json.find('"', valueStart); - if (valueEnd == std::string::npos) { - return std::nullopt; + std::string value; + bool escaping = false; + for (std::size_t index = valueStart; index < json.size(); ++index) { + const char ch = json[index]; + if (escaping) { + switch (ch) { + case '"': + case '\\': + case '/': + value.push_back(ch); + break; + case 'n': + value.push_back('\n'); + break; + case 'r': + value.push_back('\r'); + break; + case 't': + value.push_back('\t'); + break; + default: + value.push_back(ch); + break; + } + escaping = false; + continue; + } + if (ch == '\\') { + escaping = true; + continue; + } + if (ch == '"') { + return value; + } + value.push_back(ch); } - return json.substr(valueStart, valueEnd - valueStart); + return std::nullopt; } std::string EscapeJson(std::string_view value) { @@ -160,6 +193,37 @@ std::string EscapeJson(std::string_view value) { return escaped; } +void ShowLinuxNotification(const std::string& appName, + const std::string& title, + const std::string& body) { + if (title.empty() && body.empty()) { + return; + } + + const std::string displayTitle = appName.empty() || title.empty() + ? (title.empty() ? "Android notification" : title) + : appName + ": " + title; + + const pid_t child = fork(); + if (child == 0) { + execlp( + "notify-send", + "notify-send", + "-a", + "InputFlow", + "-i", + "inputflow", + displayTitle.c_str(), + body.c_str(), + static_cast(nullptr)); + _exit(127); + } + if (child > 0) { + int status = 0; + (void)waitpid(child, &status, 0); + } +} + } // namespace AndroidRelayServer::AndroidRelayServer(AndroidRelayOptions options) @@ -347,6 +411,19 @@ void AndroidRelayServer::HandleClient(std::shared_ptr session) { if (callback) { callback(*frame); } + } else if (*type == "notification_upsert") { + if (m_options.notificationSyncEnabled) { + ShowLinuxNotification( + JsonStringValue(*frame, "app").value_or("Android"), + JsonStringValue(*frame, "title").value_or(""), + JsonStringValue(*frame, "body").value_or("")); + } + } else if (*type == "notification_dismiss") { + if (m_options.notificationSyncEnabled) { + std::cout << "[ANDROID] Notification dismissed: " + << JsonStringValue(*frame, "stable_id").value_or("") + << std::endl; + } } } diff --git a/src/AndroidRelay.h b/src/AndroidRelay.h index e89dbc1..fb6d478 100644 --- a/src/AndroidRelay.h +++ b/src/AndroidRelay.h @@ -21,6 +21,7 @@ struct AndroidRelayOptions { bool layoutEditorEnabled{true}; int androidDeviceWidth{1920}; int androidDeviceHeight{1200}; + bool notificationSyncEnabled{false}; }; class AndroidRelayServer { diff --git a/src/AppConfig.cpp b/src/AppConfig.cpp index e9c544a..a0ebeb2 100644 --- a/src/AppConfig.cpp +++ b/src/AppConfig.cpp @@ -501,6 +501,16 @@ bool ParseAppConfig(std::string_view text, AppConfig& outConfig, std::string* er continue; } + if (key == "notification_sync_enabled" || key == "notifications_sync_enabled") { + const auto parsed = ParseConfigBool(value); + if (!parsed.has_value()) { + SetError(errorMessage, "Config key 'notification_sync_enabled' expects true/false."); + return false; + } + outConfig.notificationSyncEnabled = *parsed; + continue; + } + SetError(errorMessage, "Unknown config key '" + std::string(key) + "' on line " + std::to_string(lineNumber) + "."); return false; } @@ -577,6 +587,7 @@ std::string RenderAppConfig(const AppConfig& config) { out << "android_layout_editor_enabled=" << RenderBool(config.androidLayoutEditorEnabled) << '\n'; out << "android_device_width=" << config.androidDeviceWidth << '\n'; out << "android_device_height=" << config.androidDeviceHeight << '\n'; + out << "notification_sync_enabled=" << RenderBool(config.notificationSyncEnabled) << '\n'; return out.str(); } @@ -599,6 +610,7 @@ std::string RenderSampleAppConfig() { out << "# Set topology_enabled=true and topology_file=... to enable runtime topology handoff.\n"; out << "# Set android_peers_enabled=true with android_relay_secret=... to relay input to Android peers.\n"; out << "# android_capture_backend=none keeps Android relay-only; evdev is prototype-only; libei is the planned KDE/Wayland backend.\n"; + out << "# Set notification_sync_enabled=true to mirror Android notifications to this desktop over the Android relay.\n"; out << RenderAppConfig(sample); return out.str(); } diff --git a/src/AppConfig.h b/src/AppConfig.h index f4e1d85..40bedee 100644 --- a/src/AppConfig.h +++ b/src/AppConfig.h @@ -45,6 +45,7 @@ struct AppConfig { bool androidLayoutEditorEnabled{true}; int androidDeviceWidth{1920}; int androidDeviceHeight{1200}; + bool notificationSyncEnabled{false}; }; AppConfig LoadDefaultAppConfig(); diff --git a/src/TrayController.cpp b/src/TrayController.cpp index 3942d2d..8c38ee8 100644 --- a/src/TrayController.cpp +++ b/src/TrayController.cpp @@ -867,6 +867,7 @@ int RunTrayAndGui(const std::string& binary, options.androidRelay.layoutEditorEnabled = runtimeConfig.androidLayoutEditorEnabled; options.androidRelay.androidDeviceWidth = runtimeConfig.androidDeviceWidth; options.androidRelay.androidDeviceHeight = runtimeConfig.androidDeviceHeight; + options.androidRelay.notificationSyncEnabled = runtimeConfig.notificationSyncEnabled; options.onSessionEstablished = [&](const std::string& host, int port, const std::string& remoteName, uint32_t, uint32_t localMachineId) { diff --git a/src/main.cpp b/src/main.cpp index 2b3f4ae..3e52923 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1240,6 +1240,7 @@ int RunClient(const mwb::AppConfig& config, options.androidRelay.layoutEditorEnabled = runtimeConfig.androidLayoutEditorEnabled; options.androidRelay.androidDeviceWidth = runtimeConfig.androidDeviceWidth; options.androidRelay.androidDeviceHeight = runtimeConfig.androidDeviceHeight; + options.androidRelay.notificationSyncEnabled = runtimeConfig.notificationSyncEnabled; options.onSessionEstablished = [&](const std::string& host, int port, const std::string& remoteName, uint32_t, uint32_t localMachineId) { std::lock_guard lock(stateMutex); mwb::MarkSessionEstablished(state, host, port, remoteName, localMachineId, CurrentEpochSeconds()); @@ -1745,6 +1746,8 @@ int HandleDoctorCommand(const std::vector& args) { " port=" + std::to_string(config.androidRelayPort) + " peer=" + (config.androidPeerName.empty() ? "" : config.androidPeerName) + " secret=" + (config.androidRelaySecret.empty() ? "missing" : "configured")); + PrintDoctorLine("INFO", "notification sync", + config.notificationSyncEnabled ? "enabled" : "disabled"); PrintDoctorLine("INFO", "reconnect", "initial=" + std::to_string(config.reconnectInitialBackoffMs) + "ms max=" + std::to_string(config.reconnectMaxBackoffMs) + "ms idle=" + std::to_string(config.reconnectIdleRetryMs) + "ms"); From a932fdb1ab914453ef172988be5dee3ea3530bf3 Mon Sep 17 00:00:00 2001 From: daredoole <49536135+daredoole@users.noreply.github.com> Date: Wed, 24 Jun 2026 21:07:09 -0400 Subject: [PATCH 6/9] android: enable control for evdev cursor handoff --- src/ClientRuntime.cpp | 3 +++ src/LocalAndroidInputBridge.cpp | 13 +++++++++++++ src/LocalAndroidInputBridge.h | 1 + 3 files changed, 17 insertions(+) diff --git a/src/ClientRuntime.cpp b/src/ClientRuntime.cpp index db530a2..3930faa 100644 --- a/src/ClientRuntime.cpp +++ b/src/ClientRuntime.cpp @@ -702,6 +702,9 @@ void ClientRuntime::StartLocalAndroidInputBridge(const ScreenSize& screenSize) { options.sendGesture = [this](const std::string& kind, double dx, double dy) { return TrySendAndroidGesture(kind, dx, dy); }; + options.sendControl = [this](bool active) { + return TrySetAndroidControlActive(active); + }; m_localAndroidInputBridge = std::make_unique(std::move(options)); m_localAndroidInputBridge->Start(); diff --git a/src/LocalAndroidInputBridge.cpp b/src/LocalAndroidInputBridge.cpp index a04c322..e0fbb11 100644 --- a/src/LocalAndroidInputBridge.cpp +++ b/src/LocalAndroidInputBridge.cpp @@ -409,6 +409,10 @@ void LocalAndroidInputBridge::Run() { activeEntryEdge = transition->entryEdge; androidX = targetPoint->x; androidY = targetPoint->y; + if (m_options.sendControl && !m_options.sendControl(true)) { + active = false; + return; + } std::cout << "[ANDROID] Local pointer entered Android from " << edgeDirectionName(transition->exitEdge) << " edge using " << devices[deviceIndex].name @@ -419,10 +423,16 @@ void LocalAndroidInputBridge::Run() { androidY = ClampNormalized(androidY + normalizedDy); if (!sendMove()) { active = false; + if (m_options.sendControl) { + m_options.sendControl(false); + } return; } if (MovementReturnsFromEdge(activeEntryEdge, androidX, androidY, normalizedDx, normalizedDy)) { active = false; + if (m_options.sendControl) { + m_options.sendControl(false); + } std::cout << "[ANDROID] Local pointer returned to Fedora." << std::endl; } }; @@ -531,6 +541,9 @@ void LocalAndroidInputBridge::Run() { } } + if (active && m_options.sendControl) { + m_options.sendControl(false); + } ClosePointerDevices(devices); XCloseDisplay(display); } diff --git a/src/LocalAndroidInputBridge.h b/src/LocalAndroidInputBridge.h index 01905b6..ccc2255 100644 --- a/src/LocalAndroidInputBridge.h +++ b/src/LocalAndroidInputBridge.h @@ -19,6 +19,7 @@ struct LocalAndroidInputBridgeOptions { int desktopHeight{0}; std::function sendMouse; std::function sendGesture; + std::function sendControl; }; class LocalAndroidInputBridge { From c9b8252677ac2818b48aeee93200ded1de36bb00 Mon Sep 17 00:00:00 2001 From: daredoole <49536135+daredoole@users.noreply.github.com> Date: Wed, 24 Jun 2026 21:10:13 -0400 Subject: [PATCH 7/9] android: fix native cursor overlay and clicks --- .../com/inputflow/android/InjectorManager.kt | 2 + .../com/inputflow/android/NativeInjector.kt | 51 +++++++++++++++---- .../android/RelayForegroundService.kt | 2 +- 3 files changed, 44 insertions(+), 11 deletions(-) diff --git a/android/app/src/main/java/com/inputflow/android/InjectorManager.kt b/android/app/src/main/java/com/inputflow/android/InjectorManager.kt index cbd82e6..d28b72d 100644 --- a/android/app/src/main/java/com/inputflow/android/InjectorManager.kt +++ b/android/app/src/main/java/com/inputflow/android/InjectorManager.kt @@ -157,6 +157,8 @@ object InjectorManager { return n.handleMouse(frame) } + fun hasNativeInjector(): Boolean = native != null + fun handleKeyboard(frame: JSONObject): Boolean { val n = native ?: return false return n.handleKeyboard(frame) diff --git a/android/app/src/main/java/com/inputflow/android/NativeInjector.kt b/android/app/src/main/java/com/inputflow/android/NativeInjector.kt index ddfc2ac..2610aa8 100644 --- a/android/app/src/main/java/com/inputflow/android/NativeInjector.kt +++ b/android/app/src/main/java/com/inputflow/android/NativeInjector.kt @@ -23,6 +23,9 @@ class NativeInjector( private var py = 0f private var primed = false private var metaState = 0 + private var primaryDownTime: Long = 0L + private var secondaryDownTime: Long = 0L + private var tertiaryDownTime: Long = 0L fun ping(): Boolean = try { service.ping() } catch (_: Throwable) { false } @@ -47,21 +50,46 @@ class NativeInjector( py = (cy + (baseY - cy) * sens).coerceIn(0f, m.heightPixels - 1f) inject(pointerEvent(MotionEvent.ACTION_HOVER_MOVE, 0)) } - WM_LBUTTONUP -> click(MotionEvent.BUTTON_PRIMARY) - WM_RBUTTONUP -> click(MotionEvent.BUTTON_SECONDARY) - WM_MBUTTONUP -> click(MotionEvent.BUTTON_TERTIARY) + WM_LBUTTONDOWN -> button(MotionEvent.BUTTON_PRIMARY, down = true) + WM_LBUTTONUP -> button(MotionEvent.BUTTON_PRIMARY, down = false) + WM_RBUTTONDOWN -> button(MotionEvent.BUTTON_SECONDARY, down = true) + WM_RBUTTONUP -> button(MotionEvent.BUTTON_SECONDARY, down = false) + WM_MBUTTONDOWN -> button(MotionEvent.BUTTON_TERTIARY, down = true) + WM_MBUTTONUP -> button(MotionEvent.BUTTON_TERTIARY, down = false) WM_MOUSEWHEEL -> scroll(frame.optInt("mouseData")) else -> false } } - private fun click(button: Int): Boolean { - val down = SystemClock.uptimeMillis() - val downSent = inject(pointerEvent(MotionEvent.ACTION_DOWN, button, down, down)) - val pressSent = inject(pointerEvent(MotionEvent.ACTION_BUTTON_PRESS, button, down, down)) - val releaseSent = inject(pointerEvent(MotionEvent.ACTION_BUTTON_RELEASE, 0, down, SystemClock.uptimeMillis())) - val upSent = inject(pointerEvent(MotionEvent.ACTION_UP, 0, down, SystemClock.uptimeMillis())) - return downSent && pressSent && releaseSent && upSent + private fun button(button: Int, down: Boolean): Boolean { + val now = SystemClock.uptimeMillis() + return if (down) { + setButtonDownTime(button, now) + val downSent = inject(pointerEvent(MotionEvent.ACTION_DOWN, button, now, now)) + val pressSent = inject(pointerEvent(MotionEvent.ACTION_BUTTON_PRESS, button, now, now)) + downSent && pressSent + } else { + val downTime = getButtonDownTime(button).takeIf { it != 0L } ?: now + setButtonDownTime(button, 0L) + val releaseSent = inject(pointerEvent(MotionEvent.ACTION_BUTTON_RELEASE, 0, downTime, now)) + val upSent = inject(pointerEvent(MotionEvent.ACTION_UP, 0, downTime, now)) + releaseSent && upSent + } + } + + private fun getButtonDownTime(button: Int): Long = when (button) { + MotionEvent.BUTTON_PRIMARY -> primaryDownTime + MotionEvent.BUTTON_SECONDARY -> secondaryDownTime + MotionEvent.BUTTON_TERTIARY -> tertiaryDownTime + else -> 0L + } + + private fun setButtonDownTime(button: Int, value: Long) { + when (button) { + MotionEvent.BUTTON_PRIMARY -> primaryDownTime = value + MotionEvent.BUTTON_SECONDARY -> secondaryDownTime = value + MotionEvent.BUTTON_TERTIARY -> tertiaryDownTime = value + } } /** Continuous trackpad-style scroll at the cursor (native ACTION_SCROLL). */ @@ -149,8 +177,11 @@ class NativeInjector( companion object { private const val WM_MOUSEMOVE = 0x0200 + private const val WM_LBUTTONDOWN = 0x0201 private const val WM_LBUTTONUP = 0x0202 + private const val WM_RBUTTONDOWN = 0x0204 private const val WM_RBUTTONUP = 0x0205 + private const val WM_MBUTTONDOWN = 0x0207 private const val WM_MBUTTONUP = 0x0208 private const val WM_MOUSEWHEEL = 0x020A private const val LLKHF_UP = 0x80 diff --git a/android/app/src/main/java/com/inputflow/android/RelayForegroundService.kt b/android/app/src/main/java/com/inputflow/android/RelayForegroundService.kt index e56c4ba..81b4f7c 100644 --- a/android/app/src/main/java/com/inputflow/android/RelayForegroundService.kt +++ b/android/app/src/main/java/com/inputflow/android/RelayForegroundService.kt @@ -284,7 +284,7 @@ class RelayForegroundService : Service() { if (active && accessibility == null) { Log.w(TAG, "remote control requested but accessibility service is not active") } - accessibility?.setRemoteControlActive(active) + accessibility?.setRemoteControlActive(active && !InjectorManager.hasNativeInjector()) if (!active) { InputFlowImeService.restorePreviousKeyboard() } From 8eb2f1588ace5d2793402078a7f5adb19805f327 Mon Sep 17 00:00:00 2001 From: daredoole <49536135+daredoole@users.noreply.github.com> Date: Tue, 28 Jul 2026 22:25:31 -0400 Subject: [PATCH 8/9] Prepare InputFlow for production release --- .github/workflows/ci.yml | 17 +- .gitignore | 14 + CMakeLists.txt | 72 +- README.md | 389 ++++++++--- android/app/src/main/AndroidManifest.xml | 10 +- .../inputflow/android/IInjectorService.aidl | 3 +- .../android/InputFlowAccessibilityService.kt | 7 +- .../inputflow/android/InputFlowImeService.kt | 6 +- .../com/inputflow/android/LayoutEditorView.kt | 49 +- .../com/inputflow/android/MainActivity.kt | 125 +++- .../com/inputflow/android/NativeInjector.kt | 54 +- .../android/NotificationSyncBridge.kt | 103 ++- .../inputflow/android/PairingSecretStore.kt | 114 ++++ .../android/RelayForegroundService.kt | 262 ++++--- .../com/inputflow/android/RelayProtocol.kt | 175 ++++- .../inputflow/android/RootInjectorService.kt | 4 +- .../com/inputflow/android/SettingsActivity.kt | 24 +- .../android/ShizukuInjectorService.kt | 4 +- .../com/inputflow/android/SystemInject.kt | 21 +- .../main/res/layout/activity_key_mapper.xml | 3 +- .../res/layout/activity_layout_editor.xml | 3 +- .../app/src/main/res/layout/activity_main.xml | 15 +- .../src/main/res/layout/activity_settings.xml | 24 +- .../app/src/main/res/layout/row_shortcut.xml | 2 +- android/app/src/main/res/menu/main_menu.xml | 2 +- .../app/src/main/res/values-night/colors.xml | 37 +- .../app/src/main/res/values-night/styles.xml | 16 + android/app/src/main/res/values/colors.xml | 35 +- android/app/src/main/res/values/strings.xml | 24 + android/app/src/main/res/values/styles.xml | 13 +- android/app/src/main/res/xml/backup_rules.xml | 8 + .../main/res/xml/data_extraction_rules.xml | 17 + .../inputflow/android/RelayProtocolTest.kt | 72 ++ .../16x16/status/inputflow-tray-attention.png | Bin 371 -> 505 bytes .../16x16/status/inputflow-tray-busy.png | Bin 374 -> 533 bytes .../16x16/status/inputflow-tray-offline.png | Bin 375 -> 542 bytes .../hicolor/16x16/status/inputflow-tray.png | Bin 303 -> 463 bytes .../22x22/status/inputflow-tray-attention.png | Bin 549 -> 628 bytes .../22x22/status/inputflow-tray-busy.png | Bin 543 -> 684 bytes .../22x22/status/inputflow-tray-offline.png | Bin 550 -> 693 bytes .../hicolor/22x22/status/inputflow-tray.png | Bin 408 -> 583 bytes .../24x24/status/inputflow-tray-attention.png | Bin 543 -> 729 bytes .../24x24/status/inputflow-tray-busy.png | Bin 543 -> 783 bytes .../24x24/status/inputflow-tray-offline.png | Bin 546 -> 785 bytes .../hicolor/24x24/status/inputflow-tray.png | Bin 417 -> 677 bytes .../32x32/status/inputflow-tray-attention.png | Bin 735 -> 881 bytes .../32x32/status/inputflow-tray-busy.png | Bin 727 -> 939 bytes .../32x32/status/inputflow-tray-offline.png | Bin 733 -> 976 bytes .../hicolor/32x32/status/inputflow-tray.png | Bin 581 -> 795 bytes .../48x48/status/inputflow-tray-attention.png | Bin 1101 -> 1309 bytes .../48x48/status/inputflow-tray-busy.png | Bin 1081 -> 1408 bytes .../48x48/status/inputflow-tray-offline.png | Bin 1099 -> 1425 bytes .../hicolor/48x48/status/inputflow-tray.png | Bin 839 -> 1184 bytes .../status/inputflow-tray-attention.svg | 55 +- .../scalable/status/inputflow-tray-busy.svg | 55 +- .../status/inputflow-tray-offline.svg | 53 +- .../scalable/status/inputflow-tray.svg | 45 +- assets/icons/inputflow-tray-attention.svg | 55 +- assets/icons/inputflow-tray-busy.svg | 55 +- assets/icons/inputflow-tray-offline.svg | 53 +- assets/icons/inputflow-tray.svg | 45 +- docs/android.md | 5 + docs/public-repository-audit.md | 65 ++ docs/release-checklist.md | 23 + docs/release-readiness-report.md | 63 ++ docs/security-privacy.md | 45 ++ packaging/portable/README.md | 17 + packaging/portable/inputflow-controller | 13 + scripts/audit-public-repo.py | 163 +++++ scripts/generate-release-metadata.py | 205 ++++++ scripts/inputflow-diagnostics-bundle.sh | 112 ++- scripts/release-gate.sh | 68 ++ scripts/validate-rpm-packaging.sh | 2 +- src/AndroidRelay.cpp | 641 +++++++++++++++++- src/AndroidRelay.h | 30 + src/AppConfig.cpp | 36 +- src/AppState.cpp | 22 +- src/ClipboardManager.cpp | 101 ++- src/ClipboardManager.h | 4 +- src/Discovery.cpp | 89 ++- src/GuiMainWindow.cpp | 182 ++++- src/LibeiInputCaptureBridge.cpp | 208 ++++-- src/LibeiInputCaptureBridge.h | 5 + src/NetworkManager.cpp | 82 ++- src/PeerRecovery.cpp | 16 +- src/SecureFile.cpp | 125 ++++ src/SecureFile.h | 16 + src/TrayController.cpp | 141 +++- src/main.cpp | 69 +- tests/test_diagnostics_privacy.sh | 58 ++ tests/test_main.cpp | 113 +++ tests/test_portable_archive.sh | 55 ++ tests/test_protocol_security.cpp | 10 + tools/icon-generator/build-masters.js | 56 +- 94 files changed, 4084 insertions(+), 896 deletions(-) create mode 100644 android/app/src/main/java/com/inputflow/android/PairingSecretStore.kt create mode 100644 android/app/src/main/res/values-night/styles.xml create mode 100644 android/app/src/main/res/xml/backup_rules.xml create mode 100644 android/app/src/main/res/xml/data_extraction_rules.xml create mode 100644 android/app/src/test/java/com/inputflow/android/RelayProtocolTest.kt create mode 100644 docs/public-repository-audit.md create mode 100644 docs/release-checklist.md create mode 100644 docs/release-readiness-report.md create mode 100644 docs/security-privacy.md create mode 100644 packaging/portable/README.md create mode 100755 packaging/portable/inputflow-controller create mode 100644 scripts/audit-public-repo.py create mode 100644 scripts/generate-release-metadata.py create mode 100755 scripts/release-gate.sh create mode 100644 src/SecureFile.cpp create mode 100644 src/SecureFile.h create mode 100755 tests/test_diagnostics_privacy.sh create mode 100755 tests/test_portable_archive.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a55203..f5e3a5e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,6 +61,12 @@ jobs: - name: Test run: ctest --test-dir build --output-on-failure + - name: Package and smoke-test portable archive + run: | + cmake --build build --target package + archive="$(find build -maxdepth 1 -name 'inputflow-*.tar.gz' -print -quit)" + tests/test_portable_archive.sh "$archive" + - name: Compiler cache stats run: ccache --show-stats @@ -174,7 +180,14 @@ jobs: run: | bash -n mwb-desktop-ui.sh bash -n scripts/inputflow-diagnostics-bundle.sh + bash -n scripts/release-gate.sh bash -n scripts/validate-rpm-packaging.sh + bash -n tests/test_diagnostics_privacy.sh + bash -n tests/test_portable_archive.sh + python3 -m py_compile scripts/generate-release-metadata.py + + - name: Verify diagnostics privacy + run: tests/test_diagnostics_privacy.sh scripts/inputflow-diagnostics-bundle.sh - name: Validate RPM packaging skeleton run: scripts/validate-rpm-packaging.sh @@ -196,7 +209,7 @@ jobs: - name: Set up Android SDK uses: android-actions/setup-android@v3 - - name: Build debug APK and run unit tests + - name: Build release APK, run unit tests, and enforce lint working-directory: android run: | - ./gradlew :app:assembleDebug :app:testDebugUnitTest --no-daemon --stacktrace + ./gradlew :app:assembleRelease :app:testDebugUnitTest :app:lintRelease --no-daemon --stacktrace diff --git a/.gitignore b/.gitignore index 6e5416e..3ef83b5 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,20 @@ mwb-socket-trace-*.txt inputflow-windows-pair-*.ps1 AGENTS.md artifacts/ +graphify-out/ +inputflow-diagnostics-*.tar.gz +inputflow-diagnostics-*.zip + +# Local configuration and credentials +.env +.env.* +!.env.example +config.ini +*.pem +*.key +*.p12 +*.pfx +*.mobileprovision # Android build / local SDK + signing material (never commit) .gradle/ diff --git a/CMakeLists.txt b/CMakeLists.txt index 7416169..afd2e81 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,7 @@ cmake_minimum_required(VERSION 3.10) -project(mwb_client CXX) +project(mwb_client VERSION 0.2.0 LANGUAGES CXX) + +include(GNUInstallDirs) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) @@ -50,6 +52,7 @@ add_executable(mwb_client src/main.cpp src/PeerRecovery.cpp src/SecretStore.cpp + src/SecureFile.cpp src/TopologyModel.cpp src/ClipboardManager.cpp src/CryptoHelper.cpp @@ -120,8 +123,10 @@ if (BUILD_TESTING) src/AppState.cpp src/Discovery.cpp src/PeerRecovery.cpp + src/SecureFile.cpp ) target_include_directories(mwb_client_unit_tests PRIVATE src) + target_compile_definitions(mwb_client_unit_tests PRIVATE MWB_TESTING=1) target_compile_options(mwb_client_unit_tests PRIVATE -Wall -Wextra -Wpedantic) target_link_libraries(mwb_client_unit_tests PRIVATE OpenSSL::Crypto pthread) mwb_apply_sanitizers(mwb_client_unit_tests) @@ -243,6 +248,13 @@ if (BUILD_TESTING) ) add_test(NAME mwb_client_doctor_invalid_config COMMAND mwb_client doctor --config "${CMAKE_CURRENT_SOURCE_DIR}/tests/invalid_config.ini") set_tests_properties(mwb_client_doctor_invalid_config PROPERTIES WILL_FAIL TRUE) + add_test( + NAME inputflow_diagnostics_privacy + COMMAND bash + "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_diagnostics_privacy.sh" + "${CMAKE_CURRENT_SOURCE_DIR}/scripts/inputflow-diagnostics-bundle.sh" + ) + set_tests_properties(inputflow_diagnostics_privacy PROPERTIES TIMEOUT 30) endif() if (PkgConfig_FOUND) @@ -263,3 +275,61 @@ if (PkgConfig_FOUND) mwb_apply_sanitizers(mwb_tray) endif() endif() + +install(TARGETS mwb_client RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}") +if (TARGET mwb_tray) + install(TARGETS mwb_tray RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}") +endif() +install( + PROGRAMS packaging/portable/inputflow-controller + DESTINATION "${CMAKE_INSTALL_BINDIR}" +) +install( + PROGRAMS mwb-desktop-ui.sh + DESTINATION "${CMAKE_INSTALL_LIBEXECDIR}/inputflow" +) +install( + PROGRAMS scripts/inputflow-diagnostics-bundle.sh + DESTINATION "${CMAKE_INSTALL_LIBEXECDIR}/inputflow/scripts" +) +install( + FILES packaging/usr/lib/systemd/user/mwb-client.service + DESTINATION "${CMAKE_INSTALL_LIBDIR}/systemd/user" +) +install( + FILES + packaging/usr/lib/modules-load.d/mwb-client-uinput.conf + packaging/usr/lib/udev/rules.d/70-mwb-client-uinput.rules + packaging/usr/lib/sysusers.d/mwb-client.conf + DESTINATION "${CMAKE_INSTALL_DATADIR}/inputflow/system-integration" +) +install( + FILES packaging/usr/share/applications/inputflow.desktop + DESTINATION "${CMAKE_INSTALL_DATADIR}/applications" +) +install(DIRECTORY assets/hicolor/ DESTINATION "${CMAKE_INSTALL_DATADIR}/icons/hicolor") +install( + FILES + README.md + SECURITY.md + LICENSE + docs/compatibility.md + docs/security-privacy.md + docs/public-repository-audit.md + docs/release-checklist.md + docs/release-readiness-report.md + packaging/portable/README.md + DESTINATION "${CMAKE_INSTALL_DOCDIR}" +) + +set(CPACK_GENERATOR "TGZ") +set(CPACK_PACKAGE_NAME "inputflow") +set(CPACK_PACKAGE_VENDOR "InputFlow") +set(CPACK_PACKAGE_CONTACT "InputFlow maintainers") +set(CPACK_PACKAGE_DESCRIPTION_SUMMARY "Trusted-LAN Linux client for PowerToys Mouse Without Borders") +set(CPACK_PACKAGE_FILE_NAME + "inputflow-${PROJECT_VERSION}-${CMAKE_SYSTEM_NAME}-${CMAKE_SYSTEM_PROCESSOR}" +) +set(CPACK_INCLUDE_TOPLEVEL_DIRECTORY ON) +set(CPACK_PACKAGE_CHECKSUM "SHA256") +include(CPack) diff --git a/README.md b/README.md index 48f1880..a8e4f20 100644 --- a/README.md +++ b/README.md @@ -1,158 +1,329 @@ # InputFlow -![Status: Public Beta](https://img.shields.io/badge/status-public%20beta-e0a100) -![License: GPLv3](https://img.shields.io/badge/license-GPLv3-blue) +[![CI](https://github.com/daredoole/inputflow-linux/actions/workflows/ci.yml/badge.svg)](https://github.com/daredoole/inputflow-linux/actions/workflows/ci.yml) +[![Status: public beta](https://img.shields.io/badge/status-public%20beta-e0a100)](docs/release-readiness-report.md) +[![Version: 0.2.0](https://img.shields.io/badge/version-0.2.0-0b7f7a)](CHANGELOG.md) +[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](LICENSE) + +InputFlow is a native C++17 Linux companion for +[Microsoft PowerToys Mouse Without Borders](https://learn.microsoft.com/windows/powertoys/mouse-without-borders). +It lets a Windows keyboard and pointer control Linux, synchronizes clipboard +content, provides Linux tray and service integration, and can expose an +encrypted relay to an Android peer. + +> **Public beta:** InputFlow is usable, but desktop-compositor and reconnect +> edge cases are still being validated. Review the +> [release-readiness report](docs/release-readiness-report.md) and +> [compatibility guide](docs/compatibility.md) before deployment. + +![InputFlow tray controller](docs/screenshots/tray-controller.svg) + +## Contents + +- [Why InputFlow](#why-inputflow) +- [Features](#features) +- [Compatibility](#compatibility) +- [Security and privacy](#security-and-privacy) +- [Requirements](#requirements) +- [Build from source](#build-from-source) +- [First run](#first-run) +- [Connection modes](#connection-modes) +- [Command line](#command-line) +- [Testing and release builds](#testing-and-release-builds) +- [Documentation](#documentation) +- [Support and contributing](#support-and-contributing) +- [License and attribution](#license-and-attribution) + +## Why InputFlow + +PowerToys Mouse Without Borders does not provide a native Linux peer. +InputFlow fills that gap while keeping the default workflow compatible with +PowerToys machine placement. Advanced topology, Android relay, diagnostics, +and recovery tools remain opt-in. + +InputFlow is not a Barrier, Synergy, Input Leap, Deskflow, or Cursr protocol +implementation. + +## Features + +- Keyboard, pointer, media-key, and absolute-coordinate input on Linux. +- Text, HTML, and image clipboard synchronization. +- PowerToys-compatible Windows pairing helper. +- Automatic reconnection and approved-peer rediscovery after DHCP, VPN, + resume, or network changes. +- GTK controller and system tray for status, peers, settings, diagnostics, + service control, and topology. +- User-level systemd service integration. +- Optional multi-monitor topology and edge handoff. +- Optional Android relay with Accessibility, Shizuku, or root injection + backends. +- Privacy-redacted, local-only diagnostics bundles. +- Portable Linux archive, Android APK, SBOM, checksums, and provenance + generated by the release gate. + +## Compatibility + +| Component | Status | Notes | +| --- | --- | --- | +| Linux on X11 | Supported beta path | Uses `/dev/uinput`; clipboard requires `xclip` or `xsel`. | +| Linux on Wayland | Supported with caveats | Uses `/dev/uinput`; clipboard requires `wl-clipboard`; the compositor may display one input-capture permission request. | +| PowerToys MWB on Windows | Target peer | Use the exported pairing helper whenever possible. | +| Android | Optional beta peer | Requires the InputFlow Android app and an enabled relay. | +| Secret Service/keyring | Supported when available | Recommended for interactive desktop sessions. | +| Protected key file | Supported | Suitable for services when owner-only permissions are maintained. | + +The default is PowerToys-compatible machine-level placement. Display-level +topology is disabled unless explicitly configured. See +[Compatibility](docs/compatibility.md) and [Topology](docs/topology.md) for +desktop-specific behavior. + +## Security and privacy + +- Configuration and state files are written atomically with owner-only + permissions and symbolic-link targets are rejected. +- Pairing keys can be stored in the desktop keyring or in a protected file; + inline configuration keys are supported but discouraged. +- The Android relay authenticates sessions with HMAC and encrypts + post-authentication frames with directional AES-256-GCM keys, ordered + sequence numbers, and replay rejection. +- PowerToys compatibility uses the protocol and cryptography required by the + PowerToys MWB peer, including AES-256-CBC on that transport. +- Diagnostics never upload automatically. Network and journal collection are + opt-in, and likely secrets, usernames, hostnames, addresses, and input + metadata are redacted. +- Android backups exclude protected application data, and pairing secrets are + protected with Android Keystore. + +Read [Security and privacy](docs/security-privacy.md) for the trust model, +stored data, network exposure, and diagnostics behavior. Report +vulnerabilities using [SECURITY.md](SECURITY.md), not a public issue. + +## Requirements + +Core build requirements: + +- A C++17 compiler +- CMake 3.16 or newer +- OpenSSL, zlib, X11, and pkg-config development files + +Optional integrations: + +- GTK 3 and Ayatana AppIndicator for the tray/controller +- libei, GLib/GIO, and libinput for Wayland input capture and gestures +- `wl-clipboard` on Wayland, or `xclip`/`xsel` on X11 +- systemd for user-service integration + +Ubuntu/Debian build dependencies: -InputFlow is a native C++17 Linux companion for [Microsoft PowerToys "Mouse Without Borders"](https://learn.microsoft.com/en-us/windows/powertoys/mouse-without-borders), enabling seamless cursor and keyboard sharing between Linux and Windows. +```bash +sudo apt-get install -y build-essential cmake pkg-config libssl-dev zlib1g-dev \ + libx11-dev libei-dev libinput-dev python3-gi gir1.2-gtk-3.0 \ + libayatana-appindicator3-dev +``` -## 🚀 Quick Start (Tray & UI Setup) +Fedora build dependencies: -Recommended first-run flow for most users. (First build the client — see -[Build & Installation](#️-build--installation) below — then:) +```bash +sudo dnf install -y gcc-c++ cmake make pkgconf-pkg-config openssl-devel \ + zlib-devel libX11-devel libei-devel libinput-devel python3-gobject gtk3 \ + libayatana-appindicator3-devel +``` -1. **Install Prerequisites:** - - **Fedora:** `sudo dnf install python3-gobject gtk3 libayatana-appindicator3` - - **Ubuntu/Debian:** `sudo apt install python3-gi gir1.2-gtk-3.0 libayatana-appindicator3-0.1` -2. **Launch Setup UI:** Run `./mwb-desktop-ui.sh menu` -3. **Configure:** - - Go to **Settings** → enter your Windows host and Security Key. Prefer the - peer's **name** over a hardcoded IP (or click **Discover…** to pick it) so - InputFlow can follow the peer automatically if its IP changes. -4. **Use PowerToys layout for normal setups:** - - If this Linux/Fedora machine has one monitor, do not configure topology. Let Windows PowerToys Mouse Without Borders own the Linux/Windows machine placement. - - If topology was enabled while testing, choose **Use PowerToys Layout Only** to set `topology_enabled=false`. -5. **Pair with Windows:** - - In the same UI, use the **Export Helper** option. - - Run the exported `.ps1` script on your Windows machine to register the Linux peer. -6. **Start:** Choose **Start Service** or launch the tray with `./build/mwb_tray`. -7. **Advanced layouts only:** Open **Advanced Topology/Layout** if you have multiple Linux monitors, stacked/asymmetric edges, wrap behavior, or wrong-edge handoff problems. +Package names can differ by distribution release. Missing optional +integrations are reported during CMake configuration and by `mwb_client +doctor`. -For the full beta setup, health-check, diagnostics, connection-quality, and packaging-verification workflow, see [docs/beta-workflow.md](docs/beta-workflow.md). +## Build from source ---- +```bash +git clone https://github.com/daredoole/inputflow-linux.git +cd inputflow-linux +cmake -S . -B build -DCMAKE_BUILD_TYPE=Release +cmake --build build --parallel +ctest --test-dir build --output-on-failure +``` -## 🛠️ Build & Installation +The primary binaries are: -### 1. Prerequisites +- `build/mwb_client` — service, CLI, diagnostics, and combined GUI entry point. +- `build/mwb_tray` — standalone system-tray controller. -**Ubuntu / Debian:** -```bash -sudo apt-get install -y build-essential cmake pkg-config libssl-dev zlib1g-dev \ - libx11-dev python3-gi gir1.2-gtk-3.0 libayatana-appindicator3-dev -``` +## First run + +### 1. Allow rootless input injection + +The repository includes a narrowly scoped udev rule and system group: -**Fedora:** ```bash -sudo dnf install -y gcc-c++ cmake make pkgconf-pkg-config openssl-devel zlib-devel \ - libX11-devel python3-gobject gtk3 libayatana-appindicator3-devel +sudo groupadd --system --force inputflow +sudo install -Dm0644 \ + packaging/usr/lib/udev/rules.d/70-mwb-client-uinput.rules \ + /etc/udev/rules.d/70-mwb-client-uinput.rules +sudo modprobe uinput +sudo udevadm control --reload-rules +sudo udevadm trigger --subsystem-match=misc --action=change +sudo usermod -aG inputflow "$USER" ``` -> Clipboard sync also needs a runtime helper: `wl-clipboard` on Wayland, or -> `xclip`/`xsel` on X11. `mwb_client doctor` reports what's missing. +Log out and back in after changing group membership. Do not grant broad access +to the system `input` group. -### 2. Compile +### 2. Create configuration ```bash -cmake -S . -B build -DMWB_BUILD_TRAY=ON -cmake --build build -j$(nproc) +./build/mwb_client init-config \ + --config ~/.config/mwb-client/config.ini ``` -### 3. Setup `/dev/uinput` (Crucial for Mouse/Keyboard) +For most users, open the guided controller: ```bash -sudo modprobe uinput -sudo groupadd -r inputflow -sudo usermod -aG inputflow $USER -echo 'KERNEL=="uinput", GROUP="inputflow", MODE="0660", OPTIONS+="static_node=uinput"' | sudo tee /etc/udev/rules.d/99-inputflow-uinput.rules -sudo udevadm control --reload-rules && sudo udevadm trigger +./mwb-desktop-ui.sh menu ``` -*Logout and back in for group changes to take effect.* ---- +Enter the Windows host and shared security key in **Settings**. Prefer a peer +name over a fixed address so rediscovery can follow network changes. Use a +keyring secret ID or protected key file for long-lived configuration. -## ✨ Features +### 3. Pair Windows -- **Absolute Cursor Movement:** Precise pointer control across screens. -- **Keyboard Sync:** Full keyboard sharing with media key support. -- **Rich Clipboard:** Text, HTML, and **Image** synchronization (plain text stays plain). -- **Systemd Integration:** Runs as a lightweight user service. -- **Tray + Dashboard:** Connection status, live peer list, LAN peer discovery, and settings that apply on save. -- **Self-Healing Reconnect:** Follows a peer across IP changes (DHCP/VPN/resume) without a restart — configure peers by **name**. -- **Lock on Disconnect:** Optionally lock the Linux session when the controlling peer drops. -- **Android Peer:** Control an Android device as a screen, with no-root (Accessibility/Shizuku) or root native-grade input injection. See [docs/android.md](docs/android.md). +Use **Export Windows Pairing Helper** in the controller, or run: ---- +```bash +./build/mwb_client export-windows-pair \ + --config ~/.config/mwb-client/config.ini \ + --output inputflow-windows-pair.ps1 +``` -## ⚠️ Public Beta Status +Review the generated script, transfer it securely, and run it on the intended +Windows peer. -InputFlow is usable today but is still stabilizing. Expect rough edges around reconnection and desktop-environment edge cases. +### 4. Install and start the user service -**What is working well:** -- Windows-to-Linux keyboard/mouse input. -- Full Clipboard sync (Text/HTML/Images). -- `systemd` service management. -- Windows pairing-helper for easy setup. -- Self-healing reconnect across peer IP changes. -- Android peer relay (no-root and, optionally, Shizuku/root native injection). +```bash +./build/mwb_client install-user-service \ + --config ~/.config/mwb-client/config.ini +systemctl --user daemon-reload +systemctl --user enable --now mwb-client.service +``` -See the [CHANGELOG](CHANGELOG.md) for what's new and [docs/](docs/) for the full -guides (compatibility, topology, Android, beta workflow, MWB-parity roadmap). +Open the tray: ---- +```bash +./build/mwb_tray +``` -## 📖 Advanced Usage & CLI +On KDE Plasma, the InputFlow icon may initially appear in the tray overflow. +On Wayland, approve the single desktop input-capture request if topology +handoff is enabled. A timeout or dismissal does not trigger repeated prompts; +restart InputFlow deliberately to request permission again. -For power users who prefer manual control: +Run a health check after setup: ```bash -./build/mwb_client run --config ~/.config/mwb-client/config.ini -./build/mwb_client discover -./build/mwb_client doctor --config ~/.config/mwb-client/config.ini +./build/mwb_client doctor \ + --config ~/.config/mwb-client/config.ini ``` -See the full [documentation section](#detailed-documentation) for environment variables and protocol details. - -User-facing beta operations: - -- [Guided Windows pairing and export helper](docs/beta-workflow.md#guided-pairing-and-export-helper) -- [Topology/layout wizard](docs/beta-workflow.md#topologylayout-wizard) -- [Health checks and diagnostics bundle](docs/beta-workflow.md#health-check) -- [Connection quality and latency reporting](docs/beta-workflow.md#connection-quality) -- [Packaging verification](docs/beta-workflow.md#packaging-verification) -- [Topology config contract and layout wizard expectations](docs/topology.md) -- [Android peer MVP](docs/android.md) -- [Migration from other keyboard/mouse sharing tools](docs/migration.md) -- [Compatibility matrix and platform caveats](docs/compatibility.md) +The complete guided procedure is in +[Beta workflow](docs/beta-workflow.md). + +## Connection modes + +| Mode | Purpose | +| --- | --- | +| `powertoys` | Default. Connect to a Windows PowerToys MWB peer. | +| `inputflow` | Run native InputFlow services, primarily the Android relay in this beta. | +| `hybrid` | Keep the PowerToys connection while enabling native InputFlow peers. | + +Linux-to-Linux native transport is not yet a supported release path. + +## Command line + +```text +mwb_client run [--config PATH] +mwb_client discover [--state PATH] +mwb_client doctor [--config PATH] [--state PATH] +mwb_client android-pair [--config PATH] [--generate] [--ip] +mwb_client topology explain [PATH] [--config PATH] +mwb_client init-config [--config PATH] [--force] +mwb_client export-windows-pair [options] +mwb_client install-user-service [--config PATH] [--unit PATH] [--force] +mwb_client secret-store --secret-id ID [--key-file PATH | --stdin] +mwb_client secret-clear [--secret-id ID] +mwb_client gui [--config PATH] [--state PATH] +``` - -## Detailed Documentation +Run `mwb_client --help` for every option. Avoid passing secrets directly on +the command line because process listings and shell history may expose them. -### Attribution -This repository started as a fork of [chrischip/mwb-client-linux](https://github.com/chrischip/mwb-client-linux) and has been substantially expanded with service management, rich clipboard support, and recovery tooling. +## Testing and release builds -### Configuration (`config.ini`) -Supports `connection_mode`, `key_file`, `key_secret_id` (keyring), `screen_width/height` overrides, `topology_enabled`, `topology_file`, experimental `android_peers_enabled`, and more. Default path: `~/.config/mwb-client/config.ini`. +Run the native regression suite: -`connection_mode=powertoys` is the default Windows PowerToys/MWB compatibility path. `connection_mode=inputflow` runs native InputFlow peer services without requiring a Windows host/key. `connection_mode=hybrid` enables both paths at once. +```bash +ctest --test-dir build --output-on-failure +``` -Display-level topology is a separate opt-in contract. The default runtime remains MWB-compatible machine placement unless topology is explicitly enabled; see [docs/topology.md](docs/topology.md) for examples, wrap policies, validation, and cross-machine handoff behavior. +Run the complete production gate: -Windows PowerToys still owns the Windows-side machine layout. InputFlow topology does not edit PowerToys per-display geometry; it only tells Linux which local display edge should hand off back to Windows. Keep the PowerToys machine position and the InputFlow topology links consistent. +```bash +scripts/release-gate.sh +``` -### Screen Sizing -The client detects screen size in this order: -1. Config/CLI overrides -2. KDE logical geometry (`kscreen-doctor`) -3. DRM connector modes (`/sys/class/drm`) -4. 1920x1080 fallback +The release gate performs repository/privacy checks, shell validation, RPM +metadata validation, a clean Release build, 18 native regression tests, +portable-archive checksum and smoke tests, Android release assembly and unit +tests, Android lint, and generation of an SBOM, provenance, and SHA-256 +checksums. -### Network & Protocol -- **Port:** 15101 (Input), 15100 (Clipboard). -- **Encryption:** AES-256-CBC (PowerToys compatible). +Generated release artifacts are placed under +`build-release-gate/release/`. The Android APK is intentionally unsigned and +must be signed and verified outside the repository before publication. ---- +Preview diagnostics collection without creating an archive: -## License -GNU GPL v3.0 — see [LICENSE](LICENSE). +```bash +scripts/inputflow-diagnostics-bundle.sh --preview +``` -InputFlow is independent and not affiliated with Microsoft. Interoperability is based on the open-source [microsoft/PowerToys](https://github.com/microsoft/PowerToys) implementation. +## Documentation + +| Document | Purpose | +| --- | --- | +| [Beta workflow](docs/beta-workflow.md) | Guided setup, pairing, diagnostics, and packaging verification. | +| [Compatibility](docs/compatibility.md) | Desktop, transport, key-storage, and network caveats. | +| [Security and privacy](docs/security-privacy.md) | Trust boundaries, encryption, data handling, and diagnostics. | +| [Public repository audit](docs/public-repository-audit.md) | Secret, personal-data, device-data, history, and image-metadata assessment. | +| [Android](docs/android.md) | Android app, relay, pairing, and injection backends. | +| [Topology](docs/topology.md) | Optional multi-display topology contract and examples. | +| [Migration](docs/migration.md) | Migration from other keyboard/mouse sharing tools. | +| [Release checklist](docs/release-checklist.md) | Required signing, device matrix, soak, and publication checks. | +| [Release readiness](docs/release-readiness-report.md) | Current automated evidence and remaining external checks. | +| [Roadmap](docs/roadmap-mwb-parity.md) | PowerToys parity and future work. | +| [Packaging](packaging/README.md) | Distribution integration and user-service packaging. | +| [Changelog](CHANGELOG.md) | Version history. | + +## Support and contributing + +- Search existing [issues](https://github.com/daredoole/inputflow-linux/issues) + before opening a bug or feature request. +- Use the repository issue templates and attach only the redacted diagnostics + bundle. Review it before sharing. +- Follow [CONTRIBUTING.md](CONTRIBUTING.md) for build, test, and pull-request + requirements. +- Participation is governed by the + [Code of Conduct](CODE_OF_CONDUCT.md). +- Security reports follow [SECURITY.md](SECURITY.md). + +## License and attribution + +InputFlow is licensed under the +[GNU General Public License v3.0](LICENSE). + +This project began as a fork of +[chrischip/mwb-client-linux](https://github.com/chrischip/mwb-client-linux) +and has been substantially expanded. InputFlow is independent and is not +affiliated with Microsoft. PowerToys interoperability is based on the +open-source [Microsoft PowerToys](https://github.com/microsoft/PowerToys) +implementation. diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index d3b1aa3..b3c574d 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,5 @@ - + @@ -11,9 +12,12 @@ + android:enabled="true" + tools:ignore="Instantiatable" /> diff --git a/android/app/src/main/aidl/com/inputflow/android/IInjectorService.aidl b/android/app/src/main/aidl/com/inputflow/android/IInjectorService.aidl index f1a5d95..5f4df61 100644 --- a/android/app/src/main/aidl/com/inputflow/android/IInjectorService.aidl +++ b/android/app/src/main/aidl/com/inputflow/android/IInjectorService.aidl @@ -7,5 +7,6 @@ import android.view.InputEvent; // be injected at system level via InputManager.injectInputEvent. interface IInjectorService { boolean ping(); - boolean inject(in InputEvent event); + // mode = InputManager.INJECT_INPUT_EVENT_MODE_* (0=ASYNC, 2=WAIT_FOR_FINISH). + boolean inject(in InputEvent event, int mode); } diff --git a/android/app/src/main/java/com/inputflow/android/InputFlowAccessibilityService.kt b/android/app/src/main/java/com/inputflow/android/InputFlowAccessibilityService.kt index 53d3913..aae25d1 100644 --- a/android/app/src/main/java/com/inputflow/android/InputFlowAccessibilityService.kt +++ b/android/app/src/main/java/com/inputflow/android/InputFlowAccessibilityService.kt @@ -72,7 +72,7 @@ class InputFlowAccessibilityService : AccessibilityService() { val x = frame.optInt("x") val y = frame.optInt("y") val mouseData = frame.optInt("mouseData") - if (loggedMouseFrames < 5) { + if (wParam == WM_MOUSEMOVE && loggedMouseFrames < 5) { Log.i(TAG, "mouse frame wParam=$wParam x=$x y=$y overlay=${cursorView != null}") loggedMouseFrames += 1 } @@ -538,7 +538,10 @@ class InputFlowAccessibilityService : AccessibilityService() { lineTo(toX, toY) } val stroke = GestureDescription.StrokeDescription(path, 0, durationMs) - dispatchGesture(GestureDescription.Builder().addStroke(stroke).build(), null, null) + dispatchGesture( + GestureDescription.Builder().addStroke(stroke).build(), + null, + null) } private fun scroll(mouseData: Int) { diff --git a/android/app/src/main/java/com/inputflow/android/InputFlowImeService.kt b/android/app/src/main/java/com/inputflow/android/InputFlowImeService.kt index 391ffd2..ba37943 100644 --- a/android/app/src/main/java/com/inputflow/android/InputFlowImeService.kt +++ b/android/app/src/main/java/com/inputflow/android/InputFlowImeService.kt @@ -32,7 +32,10 @@ class InputFlowImeService : InputMethodService() { super.onDestroy() } - override fun onEvaluateInputViewShown(): Boolean = false + override fun onEvaluateInputViewShown(): Boolean { + super.onEvaluateInputViewShown() + return false + } override fun onCreateInputView(): View { return View(this) @@ -83,7 +86,6 @@ class InputFlowImeService : InputMethodService() { is InputAction.ModifiedKey -> sendModifiedKey(connection, action.keyCode, action.metaState) is InputAction.Menu -> connection.performContextMenuAction(action.id) } - Log.i(TAG, "keyboard action=${action.name} vk=$vkCode handled=$handled") return handled } diff --git a/android/app/src/main/java/com/inputflow/android/LayoutEditorView.kt b/android/app/src/main/java/com/inputflow/android/LayoutEditorView.kt index bfbc6b0..bec75ed 100644 --- a/android/app/src/main/java/com/inputflow/android/LayoutEditorView.kt +++ b/android/app/src/main/java/com/inputflow/android/LayoutEditorView.kt @@ -2,13 +2,13 @@ package com.inputflow.android import android.content.Context import android.graphics.Canvas -import android.graphics.Color import android.graphics.Paint import android.graphics.RectF import android.graphics.Typeface import android.util.AttributeSet import android.view.MotionEvent import android.view.View +import androidx.core.content.ContextCompat import kotlin.math.roundToInt data class DeviceRect( @@ -34,13 +34,17 @@ class LayoutEditorView @JvmOverloads constructor( } private val gridCellPx get() = (64 * resources.displayMetrics.density).toInt() + private val deviceRect = RectF() + private val cornerRadius = 12f * resources.displayMetrics.density + private val remoteDeviceColor = ContextCompat.getColor(context, R.color.colorEditorDevice) + private val localDeviceColor = ContextCompat.getColor(context, R.color.colorEditorDeviceLocal) private val bgPaint = Paint().apply { - color = Color.parseColor("#0A1628") + color = ContextCompat.getColor(context, R.color.colorEditorBg) style = Paint.Style.FILL } private val gridPaint = Paint().apply { - color = Color.parseColor("#1A2840") + color = ContextCompat.getColor(context, R.color.colorEditorGrid) style = Paint.Style.STROKE strokeWidth = 1f } @@ -54,14 +58,14 @@ class LayoutEditorView @JvmOverloads constructor( isAntiAlias = true } private val labelPaint = Paint().apply { - color = Color.WHITE + color = ContextCompat.getColor(context, R.color.colorEditorText) textSize = 14f * resources.displayMetrics.density isAntiAlias = true typeface = Typeface.DEFAULT_BOLD textAlign = Paint.Align.CENTER } private val badgePaint = Paint().apply { - color = Color.parseColor("#1A2840") + color = ContextCompat.getColor(context, R.color.colorEditorText) textSize = 10f * resources.displayMetrics.density isAntiAlias = true textAlign = Paint.Align.CENTER @@ -113,25 +117,18 @@ class LayoutEditorView @JvmOverloads constructor( val py = device.gridY * gridCellPx + viewOffsetY val pw = device.gridW * gridCellPx val ph = device.gridH * gridCellPx - val rect = RectF(px.toFloat(), py.toFloat(), (px + pw).toFloat(), (py + ph).toFloat()) - val radius = 12f * resources.displayMetrics.density + deviceRect.set(px.toFloat(), py.toFloat(), (px + pw).toFloat(), (py + ph).toFloat()) - deviceFillPaint.color = if (device.isLocal) - Color.parseColor("#1B5E20") - else - Color.parseColor("#0D47A1") + deviceFillPaint.color = if (device.isLocal) localDeviceColor else remoteDeviceColor deviceFillPaint.alpha = 200 - canvas.drawRoundRect(rect, radius, radius, deviceFillPaint) + canvas.drawRoundRect(deviceRect, cornerRadius, cornerRadius, deviceFillPaint) - deviceStrokePaint.color = if (device.isLocal) - Color.parseColor("#66BB6A") - else - Color.parseColor("#42A5F5") - canvas.drawRoundRect(rect, radius, radius, deviceStrokePaint) + deviceStrokePaint.color = if (device.isLocal) localDeviceColor else remoteDeviceColor + canvas.drawRoundRect(deviceRect, cornerRadius, cornerRadius, deviceStrokePaint) // Label - val cx = rect.centerX() - val cy = rect.centerY() + val cx = deviceRect.centerX() + val cy = deviceRect.centerY() val lineH = labelPaint.textSize if (device.isLocal) { canvas.drawText(device.name, cx, cy - lineH * 0.2f, labelPaint) @@ -171,14 +168,26 @@ class LayoutEditorView @JvmOverloads constructor( invalidate() return true } - MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + MotionEvent.ACTION_UP -> { draggingId = null invalidate() + performClick() + return true + } + MotionEvent.ACTION_CANCEL -> { + draggingId = null + invalidate() + return true } } return super.onTouchEvent(event) } + override fun performClick(): Boolean { + super.performClick() + return true + } + fun getLayoutJson(): org.json.JSONArray { val arr = org.json.JSONArray() for (device in devices) { diff --git a/android/app/src/main/java/com/inputflow/android/MainActivity.kt b/android/app/src/main/java/com/inputflow/android/MainActivity.kt index 512582c..86e3f51 100644 --- a/android/app/src/main/java/com/inputflow/android/MainActivity.kt +++ b/android/app/src/main/java/com/inputflow/android/MainActivity.kt @@ -5,6 +5,7 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter +import android.content.pm.PackageManager import android.net.Uri import android.os.Build import android.os.Bundle @@ -14,6 +15,7 @@ import android.view.MenuItem import android.view.inputmethod.InputMethodManager import android.widget.LinearLayout import android.widget.TextView +import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import com.google.android.material.appbar.MaterialToolbar import com.google.android.material.button.MaterialButton @@ -65,17 +67,33 @@ class MainActivity : AppCompatActivity() { val prefs = getSharedPreferences(RelayForegroundService.PREFS, Context.MODE_PRIVATE) applyPairingUri(intent) hostField.setText(prefs.getString(RelayForegroundService.KEY_HOST, "")) - portField.setText(prefs.getInt(RelayForegroundService.KEY_PORT, 15102).toString()) - secretField.setText(prefs.getString(RelayForegroundService.KEY_SECRET, "")) + portField.setText(getString(R.string.port_number, prefs.getInt(RelayForegroundService.KEY_PORT, 15102))) + secretField.setText(PairingSecretStore.read(this)) + + // Auto-connect on launch when already configured and not already running, + // mirroring the desktop client's auto_connect. Saves the user a tap after + // every app start (and after a reinstall) and keeps the relay self-healing. + val savedHost = prefs.getString(RelayForegroundService.KEY_HOST, "").orEmpty() + val savedSecret = PairingSecretStore.read(this) + if (savedHost.isNotBlank() && savedSecret.isNotBlank() && + RelayForegroundService.instance == null + ) { + startRelayService(Intent(this, RelayForegroundService::class.java)) + } findViewById(R.id.btnConnect).setOnClickListener { + val secret = secretField.text.toString().trim() + if (!PairingSecretStore.save(this, secret)) { + secretField.error = getString(R.string.secret_storage_error) + return@setOnClickListener + } prefs.edit() .putString(RelayForegroundService.KEY_HOST, hostField.text.toString().trim()) .putInt(RelayForegroundService.KEY_PORT, portField.text.toString().toIntOrNull() ?: 15102) - .putString(RelayForegroundService.KEY_SECRET, secretField.text.toString().trim()) .apply() requestNotificationPermission() startRelayService(Intent(this, RelayForegroundService::class.java)) + promptMissingPermissions() } findViewById(R.id.btnRelease).setOnClickListener { @@ -114,6 +132,11 @@ class MainActivity : AppCompatActivity() { registerReceiver(statusReceiver, filter) } refreshStatus() + // Returning from a settings screen: re-check and surface the next missing + // permission, but only once the user has actually started a connection. + if (RelayForegroundService.currentState != RelayForegroundService.STATE_DISCONNECTED) { + promptMissingPermissions() + } } override fun onPause() { @@ -140,8 +163,8 @@ class MainActivity : AppCompatActivity() { applyPairingUri(intent) val prefs = getSharedPreferences(RelayForegroundService.PREFS, Context.MODE_PRIVATE) hostField.setText(prefs.getString(RelayForegroundService.KEY_HOST, "")) - portField.setText(prefs.getInt(RelayForegroundService.KEY_PORT, 15102).toString()) - secretField.setText(prefs.getString(RelayForegroundService.KEY_SECRET, "")) + portField.setText(getString(R.string.port_number, prefs.getInt(RelayForegroundService.KEY_PORT, 15102))) + secretField.setText(PairingSecretStore.read(this)) } private fun refreshStatus() { @@ -212,18 +235,108 @@ class MainActivity : AppCompatActivity() { } } + private data class MissingPermission( + val label: String, + val rationale: String, + val open: () -> Unit, + ) + + private fun isAccessibilityEnabled(): Boolean { + val flat = Settings.Secure.getString( + contentResolver, Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES + ) ?: return false + return flat.split(':').any { + it.startsWith(packageName) && it.contains("InputFlowAccessibilityService") + } + } + + private fun isNotificationListenerEnabled(): Boolean { + val flat = Settings.Secure.getString( + contentResolver, "enabled_notification_listeners" + ) ?: return false + return flat.split(':').any { + it.startsWith(packageName) && it.contains("InputFlowNotificationListenerService") + } + } + + /** Required grants that Android cannot prompt for inline (deep-link only). */ + private fun missingRequiredPermissions(): List { + val prefs = getSharedPreferences(RelayForegroundService.PREFS, Context.MODE_PRIVATE) + val missing = mutableListOf() + + if (!isAccessibilityEnabled()) { + missing += MissingPermission( + "Accessibility access", + "Lets InputFlow show the remote cursor and deliver taps from your computer.", + ) { startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)) } + } + + if (prefs.getBoolean(RelayForegroundService.KEY_NOTIFICATION_SYNC_ENABLED, false) && + !isNotificationListenerEnabled() + ) { + missing += MissingPermission( + "Notification access", + "Lets InputFlow mirror this device's notifications to your computer.", + ) { startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)) } + } + + if (Build.VERSION.SDK_INT >= 33 && + checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != + PackageManager.PERMISSION_GRANTED + ) { + missing += MissingPermission( + "Show notifications", + "Lets InputFlow display your computer's notifications on this device.", + ) { requestNotificationPermission() } + } + + return missing + } + + private var permissionDialogShowing = false + + /** + * Surfaces the first still-missing required permission as a dialog that + * deep-links to the relevant settings page. Re-runs on [onResume], so the + * user is walked through each missing grant one screen at a time. + */ + private fun promptMissingPermissions() { + if (permissionDialogShowing) return + val first = missingRequiredPermissions().firstOrNull() ?: return + val remaining = missingRequiredPermissions().size + permissionDialogShowing = true + AlertDialog.Builder(this) + .setTitle("Permission needed") + .setMessage( + "${first.label}\n\n${first.rationale}" + + if (remaining > 1) "\n\n${remaining - 1} more after this." else "" + ) + .setPositiveButton("Open settings") { d, _ -> + permissionDialogShowing = false + d.dismiss() + first.open() + } + .setNegativeButton("Not now") { d, _ -> + permissionDialogShowing = false + d.dismiss() + } + .setOnCancelListener { permissionDialogShowing = false } + .show() + } + private fun applyPairingUri(intent: Intent?) { val data = intent?.data ?: return + intent.data = null if (data.scheme != "inputflow" || data.host != "android-peer") return val host = data.getQueryParameter("host").orEmpty() val port = data.getQueryParameter("port")?.toIntOrNull() ?: 15102 val secret = data.getQueryParameter("secret").orEmpty().trim() if (host.isBlank() || secret.isBlank()) return + if (!PairingSecretStore.save(this, secret)) return getSharedPreferences(RelayForegroundService.PREFS, Context.MODE_PRIVATE) .edit() .putString(RelayForegroundService.KEY_HOST, host) .putInt(RelayForegroundService.KEY_PORT, port) - .putString(RelayForegroundService.KEY_SECRET, secret) .apply() } diff --git a/android/app/src/main/java/com/inputflow/android/NativeInjector.kt b/android/app/src/main/java/com/inputflow/android/NativeInjector.kt index 2610aa8..f96dcec 100644 --- a/android/app/src/main/java/com/inputflow/android/NativeInjector.kt +++ b/android/app/src/main/java/com/inputflow/android/NativeInjector.kt @@ -26,11 +26,20 @@ class NativeInjector( private var primaryDownTime: Long = 0L private var secondaryDownTime: Long = 0L private var tertiaryDownTime: Long = 0L + private var leftDown = false + private var touchDownTime: Long = 0L fun ping(): Boolean = try { service.ping() } catch (_: Throwable) { false } - private fun inject(event: android.view.InputEvent): Boolean = - try { service.inject(event) } catch (_: Throwable) { false } + private fun inject( + event: android.view.InputEvent, + mode: Int = SystemInject.MODE_ASYNC, + ): Boolean = + try { + service.inject(event, mode) + } catch (_: Throwable) { + false + } fun handleMouse(frame: JSONObject): Boolean { val m = metricsProvider() @@ -48,10 +57,28 @@ class NativeInjector( val cy = m.heightPixels / 2f px = (cx + (baseX - cx) * sens).coerceIn(0f, m.widthPixels - 1f) py = (cy + (baseY - cy) * sens).coerceIn(0f, m.heightPixels - 1f) - inject(pointerEvent(MotionEvent.ACTION_HOVER_MOVE, 0)) + // While the left button is held, a move is a drag (touch move). + if (leftDown) inject(touchEvent(MotionEvent.ACTION_MOVE)) else true + } + // Left click = a real touchscreen tap (down/up) — the reliable path that + // registers in every app, exactly like `input tap`. Down→move→up = drag. + // Synchronous injection (WAIT_FOR_FINISH) guarantees the DOWN is fully + // dispatched before the UP, and a minimum down→up interval is enforced so + // the framework's tap detector never sees a zero-duration (ignored) tap. + WM_LBUTTONDOWN -> { + leftDown = true + touchDownTime = SystemClock.uptimeMillis() + inject(touchEvent(MotionEvent.ACTION_DOWN), SystemInject.MODE_WAIT_FOR_FINISH) + } + WM_LBUTTONUP -> { + val held = SystemClock.uptimeMillis() - touchDownTime + if (held < MIN_TAP_DURATION_MS) { + try { Thread.sleep(MIN_TAP_DURATION_MS - held) } catch (_: InterruptedException) {} + } + val ok = inject(touchEvent(MotionEvent.ACTION_UP), SystemInject.MODE_WAIT_FOR_FINISH) + leftDown = false + ok } - WM_LBUTTONDOWN -> button(MotionEvent.BUTTON_PRIMARY, down = true) - WM_LBUTTONUP -> button(MotionEvent.BUTTON_PRIMARY, down = false) WM_RBUTTONDOWN -> button(MotionEvent.BUTTON_SECONDARY, down = true) WM_RBUTTONUP -> button(MotionEvent.BUTTON_SECONDARY, down = false) WM_MBUTTONDOWN -> button(MotionEvent.BUTTON_TERTIARY, down = true) @@ -61,6 +88,20 @@ class NativeInjector( } } + private fun touchEvent(action: Int): MotionEvent { + val now = SystemClock.uptimeMillis() + val props = MotionEvent.PointerProperties().apply { + id = 0; toolType = MotionEvent.TOOL_TYPE_FINGER + } + val coords = MotionEvent.PointerCoords().apply { + x = px; y = py; pressure = 1f; size = 1f + } + return MotionEvent.obtain( + touchDownTime, now, action, 1, + arrayOf(props), arrayOf(coords), 0, 0, 1f, 1f, 0, 0, + InputDevice.SOURCE_TOUCHSCREEN, 0) + } + private fun button(button: Int, down: Boolean): Boolean { val now = SystemClock.uptimeMillis() return if (down) { @@ -187,5 +228,8 @@ class NativeInjector( private const val LLKHF_UP = 0x80 // Trackpad pixel-delta → scroll-unit scale; smaller = faster scroll. private const val SCROLL_DIVISOR = 40f + // Minimum DOWN→UP interval (ms) so a tap is never zero-duration. AOSP's + // monkey tool uses ~5ms; we use a slightly safer floor. + private const val MIN_TAP_DURATION_MS = 12L } } diff --git a/android/app/src/main/java/com/inputflow/android/NotificationSyncBridge.kt b/android/app/src/main/java/com/inputflow/android/NotificationSyncBridge.kt index b22c78a..1a26c8f 100644 --- a/android/app/src/main/java/com/inputflow/android/NotificationSyncBridge.kt +++ b/android/app/src/main/java/com/inputflow/android/NotificationSyncBridge.kt @@ -4,11 +4,21 @@ import android.Manifest import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager +import android.app.RemoteInput import android.content.Context +import android.content.Intent import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.drawable.Drawable import android.os.Build +import android.os.Bundle import android.service.notification.NotificationListenerService import android.service.notification.StatusBarNotification +import android.util.Base64 +import android.util.Log +import java.io.ByteArrayOutputStream +import java.util.concurrent.ConcurrentHashMap import org.json.JSONObject import kotlin.math.absoluteValue @@ -30,6 +40,13 @@ object NotificationSyncBridge { RegexOption.IGNORE_CASE ) + // Live reply actions keyed by the synced notification's stable_id, so a reply + // typed on the desktop can be fired back into the originating app. Bounded so a + // long-running listener can't grow unbounded; oldest entries are evicted. + private const val MAX_REPLY_ENTRIES = 200 + private val replyActions = ConcurrentHashMap() + private val replyOrder = ArrayDeque() + fun sendAndroidNotification(context: Context, sbn: StatusBarNotification) { if (!isEnabled(context) || sbn.packageName == context.packageName) return @@ -53,19 +70,97 @@ object NotificationSyncBridge { if (title.isBlank() && body.isBlank()) return if (isSensitive(title, body)) return - RelayForegroundService.instance?.sendNotificationUpsert( - stableId = stableId(sbn), + val sid = stableId(sbn) + val replyAction = findReplyAction(notification) + if (replyAction != null) { + rememberReplyAction(sid, replyAction) + } + + RelayForegroundService.sendNotificationUpsert( + stableId = sid, app = appLabel(context, sbn.packageName), packageName = sbn.packageName, title = title, body = body, - postedAtMs = sbn.postTime + postedAtMs = sbn.postTime, + iconPng = appIconBase64(context, sbn.packageName), + canReply = replyAction != null ) } + /** First notification action carrying a free-text RemoteInput (quick reply). */ + private fun findReplyAction(notification: Notification): Notification.Action? { + val actions = notification.actions ?: return null + return actions.firstOrNull { action -> + action.remoteInputs?.any { it.allowFreeFormInput } == true + } + } + + private fun rememberReplyAction(stableId: String, action: Notification.Action) { + synchronized(replyOrder) { + if (replyActions.put(stableId, action) == null) { + replyOrder.addLast(stableId) + while (replyOrder.size > MAX_REPLY_ENTRIES) { + replyActions.remove(replyOrder.removeFirst()) + } + } + } + } + + /** + * Fires a desktop-typed reply back into the originating app via its + * RemoteInput. Returns true if the reply was dispatched. + */ + fun replyToNotification(context: Context, stableId: String, text: String): Boolean { + val action = replyActions[stableId] ?: run { + Log.w("NotificationSync", "No live reply action is available") + return false + } + val remoteInputs = action.remoteInputs?.takeIf { it.isNotEmpty() } ?: return false + return try { + val intent = Intent() + val results = Bundle() + for (ri in remoteInputs) { + results.putCharSequence(ri.resultKey, text) + } + RemoteInput.addResultsToIntent(remoteInputs, intent, results) + action.actionIntent.send(context, 0, intent) + // A reply consumes the action; drop it so a stale id can't re-fire. + synchronized(replyOrder) { + if (replyActions.remove(stableId) != null) replyOrder.remove(stableId) + } + true + } catch (_: Exception) { + Log.w("NotificationSync", "Failed to send notification reply") + false + } + } + + /** App launcher icon as a base64 PNG, so the desktop can show the real icon. */ + private fun appIconBase64(context: Context, packageName: String): String? { + return try { + val drawable: Drawable = context.packageManager.getApplicationIcon(packageName) + val size = 96 + val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + drawable.setBounds(0, 0, size, size) + drawable.draw(canvas) + val out = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.PNG, 100, out) + bitmap.recycle() + Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP) + } catch (_: Exception) { + null + } + } + fun sendAndroidNotificationDismiss(context: Context, sbn: StatusBarNotification) { if (!isEnabled(context) || sbn.packageName == context.packageName) return - RelayForegroundService.instance?.sendNotificationDismiss(stableId(sbn), sbn.packageName) + val sid = stableId(sbn) + synchronized(replyOrder) { + if (replyActions.remove(sid) != null) replyOrder.remove(sid) + } + RelayForegroundService.sendNotificationDismiss(sid, sbn.packageName) } fun showMirroredNotification(context: Context, frame: JSONObject) { diff --git a/android/app/src/main/java/com/inputflow/android/PairingSecretStore.kt b/android/app/src/main/java/com/inputflow/android/PairingSecretStore.kt new file mode 100644 index 0000000..34ef1b0 --- /dev/null +++ b/android/app/src/main/java/com/inputflow/android/PairingSecretStore.kt @@ -0,0 +1,114 @@ +package com.inputflow.android + +import android.annotation.SuppressLint +import android.content.Context +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import android.util.Log +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +/** + * Stores the Android relay pairing secret with an app-owned Android Keystore + * key. Existing plaintext preferences are migrated once and then removed. + */ +object PairingSecretStore { + private const val TAG = "PairingSecretStore" + private const val KEY_ALIAS = "inputflow_pairing_secret_v1" + private const val LEGACY_SECRET_KEY = "secret" + private const val CIPHERTEXT_KEY = "pairing_secret_ciphertext_v1" + private const val IV_KEY = "pairing_secret_iv_v1" + private const val TRANSFORMATION = "AES/GCM/NoPadding" + + @Synchronized + fun read(context: Context): String { + val prefs = context.getSharedPreferences( + RelayForegroundService.PREFS, + Context.MODE_PRIVATE, + ) + val ciphertext = prefs.getString(CIPHERTEXT_KEY, null) + val iv = prefs.getString(IV_KEY, null) + if (!ciphertext.isNullOrBlank() && !iv.isNullOrBlank()) { + return try { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init( + Cipher.DECRYPT_MODE, + encryptionKey(), + GCMParameterSpec(128, Base64.decode(iv, Base64.NO_WRAP)), + ) + cipher.updateAAD(context.packageName.toByteArray(Charsets.UTF_8)) + String( + cipher.doFinal(Base64.decode(ciphertext, Base64.NO_WRAP)), + Charsets.UTF_8, + ) + } catch (_: Exception) { + Log.e(TAG, "Stored pairing secret could not be decrypted") + "" + } + } + + val legacy = prefs.getString(LEGACY_SECRET_KEY, "").orEmpty() + if (legacy.isBlank()) return "" + if (!save(context, legacy)) { + Log.e(TAG, "Plaintext pairing secret migration failed") + return "" + } + return legacy + } + + @Synchronized + @SuppressLint("ApplySharedPref") + fun save(context: Context, secret: String): Boolean { + val prefs = context.getSharedPreferences( + RelayForegroundService.PREFS, + Context.MODE_PRIVATE, + ) + if (secret.isBlank()) { + return prefs.edit() + .remove(CIPHERTEXT_KEY) + .remove(IV_KEY) + .remove(LEGACY_SECRET_KEY) + .commit() + } + + return try { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, encryptionKey()) + cipher.updateAAD(context.packageName.toByteArray(Charsets.UTF_8)) + val ciphertext = cipher.doFinal(secret.toByteArray(Charsets.UTF_8)) + prefs.edit() + .putString(CIPHERTEXT_KEY, Base64.encodeToString(ciphertext, Base64.NO_WRAP)) + .putString(IV_KEY, Base64.encodeToString(cipher.iv, Base64.NO_WRAP)) + .remove(LEGACY_SECRET_KEY) + .commit() + } catch (_: Exception) { + Log.e(TAG, "Pairing secret could not be protected") + false + } + } + + private fun encryptionKey(): SecretKey { + val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + (keyStore.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it } + + val generator = KeyGenerator.getInstance( + KeyProperties.KEY_ALGORITHM_AES, + "AndroidKeyStore", + ) + generator.init( + KeyGenParameterSpec.Builder( + KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .build(), + ) + return generator.generateKey() + } +} diff --git a/android/app/src/main/java/com/inputflow/android/RelayForegroundService.kt b/android/app/src/main/java/com/inputflow/android/RelayForegroundService.kt index 81b4f7c..68e029e 100644 --- a/android/app/src/main/java/com/inputflow/android/RelayForegroundService.kt +++ b/android/app/src/main/java/com/inputflow/android/RelayForegroundService.kt @@ -11,7 +11,6 @@ import android.os.IBinder import android.util.Log import org.json.JSONArray import org.json.JSONObject -import java.io.DataOutputStream import java.net.Socket import java.util.concurrent.atomic.AtomicBoolean @@ -19,9 +18,6 @@ class RelayForegroundService : Service() { private val running = AtomicBoolean(false) private var worker: Thread? = null - @Volatile - private var output: DataOutputStream? = null - private val outputLock = Any() @Volatile private var remoteControlActive = false private var deliveredMouseFrames = 0 @@ -38,7 +34,9 @@ class RelayForegroundService : Service() { if (instance === this) instance = null running.set(false) worker?.interrupt() - output = null + // Do NOT null activeSession here: under START_STICKY a stale instance can be + // destroyed while a newer instance owns the live stream. The owning + // relayLoop clears it on its own disconnect. deactivateRemoteControl() broadcastStatus(STATE_DISCONNECTED, "Stopped") super.onDestroy() @@ -88,38 +86,6 @@ class RelayForegroundService : Service() { writeFrame(JSONObject().put("type", "topology_update").put("layout", layout)) } - fun isConnectedForWrites(): Boolean = - currentState == STATE_CONNECTED && output != null - - fun sendNotificationUpsert( - stableId: String, - app: String, - packageName: String, - title: String, - body: String, - postedAtMs: Long - ): Boolean = - writeFrame( - JSONObject() - .put("type", "notification_upsert") - .put("stable_id", stableId) - .put("origin", "android") - .put("app", app) - .put("package", packageName) - .put("title", title) - .put("body", body) - .put("posted_at_ms", postedAtMs) - ) - - fun sendNotificationDismiss(stableId: String, packageName: String): Boolean = - writeFrame( - JSONObject() - .put("type", "notification_dismiss") - .put("stable_id", stableId) - .put("origin", "android") - .put("package", packageName) - ) - private fun startRelay() { if (!running.compareAndSet(false, true)) return InjectorManager.start(this) @@ -132,19 +98,25 @@ class RelayForegroundService : Service() { when (frame.optString("type")) { "control" -> setRemoteControlActive(frame.optBoolean("active", false)) "mouse" -> if (remoteControlActive) { - // Prefer native injection (Shizuku/root); fall back to accessibility. + // Native (Shizuku/root) injects ALL mouse input — moves, clicks, wheel — + // as real events. The accessibility overlay still draws the visible + // cursor on moves (native pointer injection isn't rendered by Android). + val handledNative = InjectorManager.handleMouse(frame) val accessibility = InputFlowAccessibilityService.instance val isMove = frame.optInt("wParam") == WM_MOUSEMOVE - if (isMove && InjectorManager.handleMouse(frame)) { - // Native pointer motion succeeded. - } else if (accessibility != null) { - deliveredMouseFrames += 1 - accessibility.handleMouse(frame) - } else if (!InjectorManager.handleMouse(frame)) { - if (droppedMouseFrames < 5) { - Log.w(TAG, "mouse frame dropped: no injector active") + if (isMove) { + accessibility?.handleMouse(frame) + if (!handledNative && accessibility == null) { + if (droppedMouseFrames < 5) { + Log.w(TAG, "mouse frame dropped: no injector active") + } + droppedMouseFrames += 1 + } else { + deliveredMouseFrames += 1 } - droppedMouseFrames += 1 + } else if (!handledNative) { + // No native injector → accessibility handles the click. + accessibility?.handleMouse(frame) } } "keyboard" -> { @@ -166,16 +138,23 @@ class RelayForegroundService : Service() { } } "gesture" -> if (remoteControlActive) { - val accessibility = InputFlowAccessibilityService.instance - if (accessibility != null) { - accessibility.handleGesture(frame) - } else if (frame.optString("kind") == "scroll") { - InjectorManager.handleScroll(frame) + // Native continuous scroll first (smooth, no gesture cancellation); + // multi-finger swipes/pinch still go through accessibility. + val kind = frame.optString("kind") + if (kind == "scroll" && InjectorManager.handleScroll(frame)) { + // native scroll + } else { + InputFlowAccessibilityService.instance?.handleGesture(frame) } } "devices_info" -> handleDevicesInfo(frame) "notification_upsert" -> NotificationSyncBridge.showMirroredNotification(this, frame) "notification_dismiss" -> NotificationSyncBridge.cancelMirroredNotification(this, frame) + "notification_reply" -> NotificationSyncBridge.replyToNotification( + this, + frame.optString("stable_id"), + frame.optString("text") + ) } } @@ -183,7 +162,7 @@ class RelayForegroundService : Service() { val prefs = getSharedPreferences(PREFS, Context.MODE_PRIVATE) while (running.get()) { val host = prefs.getString(KEY_HOST, "").orEmpty() - val secret = prefs.getString(KEY_SECRET, "").orEmpty() + val secret = PairingSecretStore.read(this) val port = prefs.getInt(KEY_PORT, 15102) if (host.isBlank() || secret.isBlank()) { startRelayForeground("Missing pairing settings") @@ -197,46 +176,52 @@ class RelayForegroundService : Service() { // the Linux box across IP changes (DHCP/VPN), same self-healing // model as the desktop client. An IP literal resolves to itself. val address = java.net.InetAddress.getByName(host) - Log.i(TAG, "relay connecting: $host -> ${address.hostAddress}:$port") + Log.i(TAG, "relay connection attempt started") java.net.Socket().use { socket -> socket.tcpNoDelay = true socket.connect(java.net.InetSocketAddress(address, port), 8000) - val device = "${Build.MANUFACTURER} ${Build.MODEL}".trim() - val streams = RelayProtocol.authenticate(socket, secret, device) - output = streams.second - deliveredMouseFrames = 0 - droppedMouseFrames = 0 - Log.i(TAG, "relay connected to $host:$port as $device") - deactivateRemoteControl() - startRelayForeground("Connected to $host:$port") - broadcastStatus(STATE_CONNECTED, "$host:$port") - while (running.get()) { - var frame = RelayProtocol.readFrame(streams.first) - // Collapse a backlog of mouse-move frames to the newest so the - // cursor tracks live with no lag under load. Non-move frames are - // dispatched in order; only intermediate moves are dropped. - while (frame.optString("type") == "mouse" && - frame.optInt("wParam") == WM_MOUSEMOVE && - streams.first.available() > 4 - ) { - val next = RelayProtocol.readFrame(streams.first) - if (next.optString("type") == "mouse" && - next.optInt("wParam") == WM_MOUSEMOVE + val device = getString(R.string.default_device_name) + val session = RelayProtocol.authenticate(socket, secret, device) + synchronized(activeSessionLock) { activeSession = session } + try { + deliveredMouseFrames = 0 + droppedMouseFrames = 0 + Log.i(TAG, "encrypted relay connection authenticated") + deactivateRemoteControl() + startRelayForeground("Connected securely") + broadcastStatus(STATE_CONNECTED, "Connected securely") + while (running.get()) { + var frame = session.readFrame() + // Collapse a backlog of mouse-move frames to the newest so the + // cursor tracks live with no lag under load. Non-move frames are + // dispatched in order; only intermediate moves are dropped. + while (frame.optString("type") == "mouse" && + frame.optInt("wParam") == WM_MOUSEMOVE && + session.hasBufferedFrame() ) { - frame = next - } else { - dispatchFrame(frame, prefs) - frame = next - break + val next = session.readFrame() + if (next.optString("type") == "mouse" && + next.optInt("wParam") == WM_MOUSEMOVE + ) { + frame = next + } else { + dispatchFrame(frame, prefs) + frame = next + break + } } + dispatchFrame(frame, prefs) } - dispatchFrame(frame, prefs) + } finally { + deactivateRemoteControl() + synchronized(activeSessionLock) { + if (activeSession === session) activeSession = null + } + session.destroy() } - deactivateRemoteControl() } - } catch (e: Exception) { - Log.w(TAG, "relay disconnected; retrying", e) - output = null + } catch (_: Exception) { + Log.w(TAG, "relay disconnected; retrying") deactivateRemoteControl() startRelayForeground("Disconnected; retrying") broadcastStatus(STATE_DISCONNECTED, "Retrying…") @@ -248,31 +233,17 @@ class RelayForegroundService : Service() { private fun handleDevicesInfo(frame: JSONObject) { val json = frame.toString() cachedDevicesJson = json - sendBroadcast(Intent(ACTION_DEVICES_BROADCAST).putExtra(EXTRA_DEVICES_JSON, json)) + sendBroadcast( + Intent(ACTION_DEVICES_BROADCAST) + .setPackage(packageName) + .putExtra(EXTRA_DEVICES_JSON, json), + ) } private fun sendRelease() { writeFrame(JSONObject().put("type", "release")) } - private fun writeFrame(frame: JSONObject): Boolean { - return try { - synchronized(outputLock) { - output?.let { - RelayProtocol.writeFrame(it, frame) - true - } ?: run { - Log.w(TAG, "failed to write relay frame type=${frame.optString("type")}: no relay output stream") - false - } - } - } catch (e: Exception) { - Log.w(TAG, "failed to write relay frame type=${frame.optString("type")}", e) - output = null - broadcastStatus(STATE_DISCONNECTED, "Retrying…") - false - } - } private fun setRemoteControlActive(active: Boolean) { val changed = remoteControlActive != active @@ -284,7 +255,10 @@ class RelayForegroundService : Service() { if (active && accessibility == null) { Log.w(TAG, "remote control requested but accessibility service is not active") } - accessibility?.setRemoteControlActive(active && !InjectorManager.hasNativeInjector()) + // Always show the accessibility overlay cursor while controlled — native + // pointer injection moves the logical pointer but Android doesn't render it, + // so the overlay is what the user actually sees. + accessibility?.setRemoteControlActive(active) if (!active) { InputFlowImeService.restorePreviousKeyboard() } @@ -335,7 +309,6 @@ class RelayForegroundService : Service() { const val PREFS = "inputflow" const val KEY_HOST = "host" const val KEY_PORT = "port" - const val KEY_SECRET = "secret" const val KEY_LAPTOP_TYPING_ENABLED = "laptop_typing_enabled" const val KEY_NOTIFICATION_SYNC_ENABLED = "notification_sync_enabled" const val KEY_STATUS_STATE = "status_state" @@ -357,6 +330,83 @@ class RelayForegroundService : Service() { private const val TAG = "InputFlowRelay" @Volatile var instance: RelayForegroundService? = null + // The live encrypted relay session, shared statically so the notification + // listener (a separate service) can always reach the connected socket + // regardless of which RelayForegroundService instance it resolves via + // [instance] — START_STICKY can leave more than one instance around. + @Volatile var activeSession: RelayProtocol.Session? = null + val activeSessionLock = Any() + + /** True when the live relay stream is available for outbound writes. */ + fun isConnectedForWrites(): Boolean = + currentState == STATE_CONNECTED && activeSession != null + + // Serializes all outbound relay writes onto a single background thread. + // Notification-listener callbacks fire on the MAIN thread, where a socket + // write throws NetworkOnMainThreadException; the read loop runs on its own + // worker. A single-thread executor both moves writes off the main thread and + // guarantees frames never interleave on the socket. + private val relayWriteExecutor: java.util.concurrent.ExecutorService = + java.util.concurrent.Executors.newSingleThreadExecutor() + + /** + * Queues a frame for the live relay stream. Static so callers (the separate + * notification-listener service, the settings test button) never depend on a + * resolvable RelayForegroundService [instance] — under START_STICKY that can + * be null even while the connection is up. Returns true if a connection was + * available to enqueue onto; the write itself happens asynchronously. + */ + fun writeFrame(frame: JSONObject): Boolean { + val haveStream = synchronized(activeSessionLock) { activeSession != null } + if (!haveStream) { + Log.w(TAG, "no relay output stream for ${frame.optString("type")}") + return false + } + relayWriteExecutor.execute { + try { + synchronized(activeSessionLock) { + val session = activeSession ?: return@execute + session.writeFrame(frame) + } + } catch (e: Exception) { + // Don't tear down activeSession: the owning relayLoop detects a dead + // socket via readFrame and reconnects, re-seating the stream. + Log.w(TAG, "failed to write relay frame ${frame.optString("type")}", e) + } + } + return true + } + + fun sendNotificationUpsert( + stableId: String, + app: String, + packageName: String, + title: String, + body: String, + postedAtMs: Long, + iconPng: String? = null, + canReply: Boolean = false, + ): Boolean = writeFrame( + JSONObject() + .put("type", "notification_upsert") + .put("stable_id", stableId) + .put("origin", "android") + .put("app", app) + .put("package", packageName) + .put("title", title) + .put("body", body) + .put("posted_at_ms", postedAtMs) + .put("can_reply", canReply) + .apply { if (!iconPng.isNullOrEmpty()) put("icon_png", iconPng) } + ) + + fun sendNotificationDismiss(stableId: String, packageName: String): Boolean = writeFrame( + JSONObject() + .put("type", "notification_dismiss") + .put("stable_id", stableId) + .put("origin", "android") + .put("package", packageName) + ) @Volatile var currentState: String = STATE_DISCONNECTED @Volatile var currentDetail: String? = null @Volatile var cachedDevicesJson: String? = null diff --git a/android/app/src/main/java/com/inputflow/android/RelayProtocol.kt b/android/app/src/main/java/com/inputflow/android/RelayProtocol.kt index 4fd59a4..10a3857 100644 --- a/android/app/src/main/java/com/inputflow/android/RelayProtocol.kt +++ b/android/app/src/main/java/com/inputflow/android/RelayProtocol.kt @@ -4,47 +4,182 @@ import org.json.JSONObject import java.io.DataInputStream import java.io.DataOutputStream import java.net.Socket +import java.nio.ByteBuffer +import java.util.Arrays +import javax.crypto.Cipher import javax.crypto.Mac +import javax.crypto.spec.GCMParameterSpec import javax.crypto.spec.SecretKeySpec object RelayProtocol { private const val MAX_FRAME_BYTES = 64 * 1024 + private const val ENCRYPTED_FRAME_OVERHEAD = 4 + 8 + 16 + private const val MAX_WIRE_FRAME_BYTES = MAX_FRAME_BYTES + ENCRYPTED_FRAME_OVERHEAD + private val FRAME_MAGIC = byteArrayOf('I'.code.toByte(), 'F'.code.toByte(), 'P'.code.toByte(), '1'.code.toByte()) + internal val SERVER_TO_CLIENT_IV = byteArrayOf('I'.code.toByte(), 'F'.code.toByte(), 'S'.code.toByte(), 'O'.code.toByte()) + internal val CLIENT_TO_SERVER_IV = byteArrayOf('I'.code.toByte(), 'F'.code.toByte(), 'C'.code.toByte(), 'O'.code.toByte()) + private val NONCE_PATTERN = Regex("[0-9a-f]{32}") - fun readFrame(input: DataInputStream): JSONObject { - val length = input.readInt() - require(length in 1..MAX_FRAME_BYTES) { "Invalid frame length $length" } - val bytes = ByteArray(length) - input.readFully(bytes) - return JSONObject(bytes.toString(Charsets.UTF_8)) - } + class Session internal constructor( + private val input: DataInputStream, + private val output: DataOutputStream, + private val readKey: ByteArray, + private val writeKey: ByteArray, + ) { + private var readSequence = 0L + private var writeSequence = 0L - fun writeFrame(output: DataOutputStream, json: JSONObject) { - val bytes = json.toString().toByteArray(Charsets.UTF_8) - require(bytes.size in 1..MAX_FRAME_BYTES) { "Invalid frame length ${bytes.size}" } - output.writeInt(bytes.size) - output.write(bytes) - output.flush() + fun readFrame(): JSONObject { + require(readSequence != Long.MAX_VALUE) { "Relay receive sequence exhausted" } + val envelope = readWireFrame(input, MAX_WIRE_FRAME_BYTES) + val plaintext = decryptPayload( + envelope, + readKey, + readSequence, + SERVER_TO_CLIENT_IV, + ) + readSequence += 1 + return JSONObject(plaintext.toString(Charsets.UTF_8)) + } + + @Synchronized + fun writeFrame(json: JSONObject) { + require(writeSequence != Long.MAX_VALUE) { "Relay send sequence exhausted" } + val plaintext = json.toString().toByteArray(Charsets.UTF_8) + require(plaintext.size in 1..MAX_FRAME_BYTES) { + "Invalid frame length ${plaintext.size}" + } + val envelope = encryptPayload( + plaintext, + writeKey, + writeSequence, + CLIENT_TO_SERVER_IV, + ) + writeSequence += 1 + writeWireFrame(output, envelope) + } + + fun hasBufferedFrame(): Boolean = input.available() > Int.SIZE_BYTES + + fun destroy() { + Arrays.fill(readKey, 0) + Arrays.fill(writeKey, 0) + } } - fun authenticate(socket: Socket, secret: String, deviceName: String): Pair { + fun authenticate(socket: Socket, secret: String, deviceName: String): Session { val input = DataInputStream(socket.getInputStream().buffered()) val output = DataOutputStream(socket.getOutputStream().buffered()) - val hello = readFrame(input) + val hello = readPlainFrame(input) require(hello.optString("type") == "hello") { "Expected hello frame" } + require(hello.optInt("version") == 2) { "Unsupported relay protocol" } + require(hello.optString("cipher") == "AES-256-GCM") { "Unsupported relay cipher" } val nonce = hello.getString("nonce") + require(NONCE_PATTERN.matches(nonce)) { "Invalid relay nonce" } val auth = JSONObject() .put("type", "auth") .put("device", deviceName) .put("hmac", hmacSha256Hex(secret, nonce)) - writeFrame(output, auth) - val ready = readFrame(input) + writePlainFrame(output, auth) + + val session = Session( + input, + output, + deriveSessionKey(secret, nonce, "server-to-client"), + deriveSessionKey(secret, nonce, "client-to-server"), + ) + val ready = session.readFrame() require(ready.optString("type") == "ready") { "Expected ready frame" } - return input to output + return session } - private fun hmacSha256Hex(secret: String, message: String): String { + private fun readPlainFrame(input: DataInputStream): JSONObject = + JSONObject(readWireFrame(input, MAX_FRAME_BYTES).toString(Charsets.UTF_8)) + + private fun writePlainFrame(output: DataOutputStream, json: JSONObject) { + val bytes = json.toString().toByteArray(Charsets.UTF_8) + require(bytes.size in 1..MAX_FRAME_BYTES) { "Invalid frame length ${bytes.size}" } + writeWireFrame(output, bytes) + } + + private fun readWireFrame(input: DataInputStream, maximumLength: Int): ByteArray { + val length = input.readInt() + require(length in 1..maximumLength) { "Invalid relay frame length" } + return ByteArray(length).also(input::readFully) + } + + private fun writeWireFrame(output: DataOutputStream, bytes: ByteArray) { + output.writeInt(bytes.size) + output.write(bytes) + output.flush() + } + + internal fun deriveSessionKey(secret: String, nonce: String, direction: String): ByteArray = + hmacSha256(secret, "inputflow-relay-v1/$direction/$nonce") + + internal fun encryptPayload( + plaintext: ByteArray, + key: ByteArray, + sequence: Long, + ivPrefix: ByteArray, + ): ByteArray { + val header = ByteBuffer.allocate(12) + .put(FRAME_MAGIC) + .putLong(sequence) + .array() + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init( + Cipher.ENCRYPT_MODE, + SecretKeySpec(key, "AES"), + GCMParameterSpec(128, buildIv(ivPrefix, sequence)), + ) + cipher.updateAAD(header) + return header + cipher.doFinal(plaintext) + } + + internal fun decryptPayload( + envelope: ByteArray, + key: ByteArray, + expectedSequence: Long, + ivPrefix: ByteArray, + ): ByteArray { + require(envelope.size in (ENCRYPTED_FRAME_OVERHEAD + 1)..MAX_WIRE_FRAME_BYTES) { + "Invalid encrypted relay frame" + } + require(envelope.copyOfRange(0, FRAME_MAGIC.size).contentEquals(FRAME_MAGIC)) { + "Invalid encrypted relay frame" + } + val header = envelope.copyOfRange(0, 12) + val sequence = ByteBuffer.wrap(header, FRAME_MAGIC.size, Long.SIZE_BYTES).long + require(sequence == expectedSequence) { "Invalid relay sequence" } + + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init( + Cipher.DECRYPT_MODE, + SecretKeySpec(key, "AES"), + GCMParameterSpec(128, buildIv(ivPrefix, sequence)), + ) + cipher.updateAAD(header) + return cipher.doFinal(envelope, header.size, envelope.size - header.size) + } + + private fun buildIv(prefix: ByteArray, sequence: Long): ByteArray = + ByteBuffer.allocate(12).put(prefix).putLong(sequence).array() + + private fun hmacSha256(secret: String, message: String): ByteArray { val mac = Mac.getInstance("HmacSHA256") mac.init(SecretKeySpec(secret.toByteArray(Charsets.UTF_8), "HmacSHA256")) - return mac.doFinal(message.toByteArray(Charsets.UTF_8)).joinToString("") { "%02x".format(it) } + return mac.doFinal(message.toByteArray(Charsets.UTF_8)) + } + + private fun hmacSha256Hex(secret: String, message: String): String { + val digits = "0123456789abcdef" + return buildString(64) { + for (byte in hmacSha256(secret, message)) { + val value = byte.toInt() and 0xff + append(digits[value ushr 4]) + append(digits[value and 0x0f]) + } + } } } diff --git a/android/app/src/main/java/com/inputflow/android/RootInjectorService.kt b/android/app/src/main/java/com/inputflow/android/RootInjectorService.kt index 3da1833..b16ea26 100644 --- a/android/app/src/main/java/com/inputflow/android/RootInjectorService.kt +++ b/android/app/src/main/java/com/inputflow/android/RootInjectorService.kt @@ -15,9 +15,9 @@ class RootInjectorService : RootService() { SystemInject.exempt() return object : IInjectorService.Stub() { override fun ping(): Boolean = true - override fun inject(event: InputEvent?): Boolean { + override fun inject(event: InputEvent?, mode: Int): Boolean { val e = event ?: return false - return SystemInject.inject(this@RootInjectorService, e) + return SystemInject.inject(this@RootInjectorService, e, mode) } } } diff --git a/android/app/src/main/java/com/inputflow/android/SettingsActivity.kt b/android/app/src/main/java/com/inputflow/android/SettingsActivity.kt index 27fc7ef..93558b6 100644 --- a/android/app/src/main/java/com/inputflow/android/SettingsActivity.kt +++ b/android/app/src/main/java/com/inputflow/android/SettingsActivity.kt @@ -161,25 +161,25 @@ class SettingsActivity : AppCompatActivity() { private fun requestShizuku() { try { if (!Shizuku.pingBinder()) { - injectStatusText.text = "Shizuku not running — install and start the Shizuku app first." + injectStatusText.setText(R.string.shizuku_not_running) return } if (Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED) { updateInjectStatus() } else { Shizuku.requestPermission(SHIZUKU_REQ) - injectStatusText.text = "Shizuku permission requested — approve, then reopen Settings." + injectStatusText.setText(R.string.shizuku_permission_requested) } - } catch (t: Throwable) { - injectStatusText.text = "Shizuku error: ${t.message}" + } catch (_: Throwable) { + injectStatusText.setText(R.string.shizuku_error) } } private fun requestRoot() { - injectStatusText.text = "Requesting root…" + injectStatusText.setText(R.string.root_requesting) Shell.getShell { shell -> runOnUiThread { - injectStatusText.text = if (shell.isRoot) "Root granted." else "Root denied." + injectStatusText.setText(if (shell.isRoot) R.string.root_granted else R.string.root_denied) } } } @@ -190,8 +190,11 @@ class SettingsActivity : AppCompatActivity() { Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED } catch (_: Throwable) { false } val root = try { Shell.isAppGrantedRoot() == true } catch (_: Throwable) { false } - injectStatusText.text = - "Shizuku: ${if (shizuku) "ready" else "unavailable"} Root: ${if (root) "ready" else "unavailable"}" + injectStatusText.text = getString( + R.string.injection_status, + getString(if (shizuku) R.string.injection_ready else R.string.injection_unavailable), + getString(if (root) R.string.injection_ready else R.string.injection_unavailable), + ) } private fun sendTestNotification() { @@ -201,14 +204,13 @@ class SettingsActivity : AppCompatActivity() { return } - val relay = RelayForegroundService.instance - if (relay?.isConnectedForWrites() != true) { + if (!RelayForegroundService.isConnectedForWrites()) { Toast.makeText(this, R.string.test_notification_not_connected, Toast.LENGTH_SHORT).show() return } val now = System.currentTimeMillis() - val sent = relay.sendNotificationUpsert( + val sent = RelayForegroundService.sendNotificationUpsert( stableId = "inputflow-test:$now", app = getString(R.string.app_name), packageName = packageName, diff --git a/android/app/src/main/java/com/inputflow/android/ShizukuInjectorService.kt b/android/app/src/main/java/com/inputflow/android/ShizukuInjectorService.kt index 3f78fe0..2436595 100644 --- a/android/app/src/main/java/com/inputflow/android/ShizukuInjectorService.kt +++ b/android/app/src/main/java/com/inputflow/android/ShizukuInjectorService.kt @@ -15,9 +15,9 @@ class ShizukuInjectorService(private val context: Context) : IInjectorService.St override fun ping(): Boolean = true - override fun inject(event: InputEvent?): Boolean { + override fun inject(event: InputEvent?, mode: Int): Boolean { val e = event ?: return false - return SystemInject.inject(context, e) + return SystemInject.inject(context, e, mode) } // Called by Shizuku when the user service is torn down. diff --git a/android/app/src/main/java/com/inputflow/android/SystemInject.kt b/android/app/src/main/java/com/inputflow/android/SystemInject.kt index 0bc8bcf..8906942 100644 --- a/android/app/src/main/java/com/inputflow/android/SystemInject.kt +++ b/android/app/src/main/java/com/inputflow/android/SystemInject.kt @@ -1,6 +1,7 @@ package com.inputflow.android import android.content.Context +import android.os.Build import android.view.InputEvent import org.lsposed.hiddenapibypass.HiddenApiBypass @@ -11,20 +12,32 @@ import org.lsposed.hiddenapibypass.HiddenApiBypass * AccessibilityService gesture path. */ object SystemInject { - // INJECT_INPUT_EVENT_MODE_ASYNC - private const val MODE_ASYNC = 0 + // InputManager.INJECT_INPUT_EVENT_MODE_* + const val MODE_ASYNC = 0 + const val MODE_WAIT_FOR_FINISH = 2 fun exempt() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return try { HiddenApiBypass.addHiddenApiExemptions("") } catch (_: Throwable) { } } - fun inject(context: Context, event: InputEvent): Boolean { + fun inject(context: Context, event: InputEvent, mode: Int = MODE_ASYNC): Boolean { return try { val im = context.getSystemService(Context.INPUT_SERVICE) ?: return false - HiddenApiBypass.invoke(im.javaClass, im, "injectInputEvent", event, MODE_ASYNC) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + HiddenApiBypass.invoke(im.javaClass, im, "injectInputEvent", event, mode) + } else { + val method = im.javaClass.getDeclaredMethod( + "injectInputEvent", + InputEvent::class.java, + Int::class.javaPrimitiveType, + ) + method.isAccessible = true + method.invoke(im, event, mode) + } true } catch (_: Throwable) { false diff --git a/android/app/src/main/res/layout/activity_key_mapper.xml b/android/app/src/main/res/layout/activity_key_mapper.xml index 07c2305..826148b 100644 --- a/android/app/src/main/res/layout/activity_key_mapper.xml +++ b/android/app/src/main/res/layout/activity_key_mapper.xml @@ -4,8 +4,7 @@ xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" - android:orientation="vertical" - android:background="?attr/colorSurface"> + android:orientation="vertical"> + android:layout_height="match_parent"> + android:layout_height="match_parent"> @@ -246,6 +247,8 @@ @@ -270,6 +273,8 @@ android:layout_width="20dp" android:layout_height="20dp" android:layout_marginEnd="14dp" + android:contentDescription="@null" + android:importantForAccessibility="no" android:src="@drawable/ic_keyboard" app:tint="@color/colorPrimary" /> @@ -283,6 +288,8 @@ @@ -307,6 +314,8 @@ android:layout_width="20dp" android:layout_height="20dp" android:layout_marginEnd="14dp" + android:contentDescription="@null" + android:importantForAccessibility="no" android:src="@drawable/ic_notification" app:tint="@color/colorPrimary" /> @@ -320,6 +329,8 @@ diff --git a/android/app/src/main/res/layout/activity_settings.xml b/android/app/src/main/res/layout/activity_settings.xml index 19c0fc2..d54cd6e 100644 --- a/android/app/src/main/res/layout/activity_settings.xml +++ b/android/app/src/main/res/layout/activity_settings.xml @@ -3,8 +3,7 @@ xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" - android:layout_height="match_parent" - android:background="@color/colorSurface"> + android:layout_height="match_parent"> @@ -186,7 +185,7 @@ @@ -435,7 +434,7 @@ android:layout_height="wrap_content" android:layout_marginBottom="8dp" android:layout_marginStart="4dp" - android:text="Input method" + android:text="@string/section_input_method" style="@style/SectionHeader" /> @@ -501,14 +500,14 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="8dp" - android:text="Grant Shizuku" /> + android:text="@string/grant_shizuku" /> + android:text="@string/grant_root" /> @@ -523,7 +522,7 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" - android:text="Allow native input injection (system-level control)" + android:text="@string/native_input_consent" android:textAppearance="@style/TextAppearance.Material3.BodyMedium" /> diff --git a/android/app/src/main/res/layout/row_shortcut.xml b/android/app/src/main/res/layout/row_shortcut.xml index 2ffe4e0..ef5c6ee 100644 --- a/android/app/src/main/res/layout/row_shortcut.xml +++ b/android/app/src/main/res/layout/row_shortcut.xml @@ -31,7 +31,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" style="@style/Widget.Material3.Chip.Assist" - android:text="None" /> + android:text="@string/shortcut_none" /> diff --git a/android/app/src/main/res/menu/main_menu.xml b/android/app/src/main/res/menu/main_menu.xml index 631b51e..54c147f 100644 --- a/android/app/src/main/res/menu/main_menu.xml +++ b/android/app/src/main/res/menu/main_menu.xml @@ -5,7 +5,7 @@ diff --git a/android/app/src/main/res/values-night/colors.xml b/android/app/src/main/res/values-night/colors.xml index 08ebed4..e23c65f 100644 --- a/android/app/src/main/res/values-night/colors.xml +++ b/android/app/src/main/res/values-night/colors.xml @@ -1,19 +1,22 @@ - #4C8FD6 - #FFFFFF - #101418 - #202832 - #0B1014 - #6FCF89 - #173322 - #EF9A9A - #3B1515 - #FFCC80 - #3D2600 - #0A1628 - #1A2840 - #4A90D9 - #4CAF50 - #FFFFFF - #34404D + #72D4C6 + #092F35 + #F2BE7A + #2D1D09 + #0D1D22 + #163039 + #F4FBF9 + #07171C + #72D4A4 + #12372B + #FF9EA5 + #431E24 + #F2BE7A + #412D15 + #07171C + #16444D + #72D4C6 + #F2BE7A + #F4FBF9 + #355A61 diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..aad222e --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,16 @@ + + + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml index 1d55475..35edb30 100644 --- a/android/app/src/main/res/values/colors.xml +++ b/android/app/src/main/res/values/colors.xml @@ -1,19 +1,22 @@ - #1664C0 + #0B7F7A #FFFFFF - #F8FAFF - #E8F0FE - #0A1628 - #2E7D32 - #E8F5E9 - #C62828 - #FFEBEE - #E65100 - #FFF3E0 - #0A1628 - #1A2840 - #1664C0 - #2E7D32 - #FFFFFF - #D0DEF5 + #A86316 + #FFFFFF + #F3F8F7 + #E4F0ED + #092F35 + #092F35 + #167C5A + #DFF3EA + #B64750 + #FCE8EA + #A86316 + #FCEFD9 + #092F35 + #16444D + #0B7F7A + #E6A15A + #F4FBF9 + #BCD7D2 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 2686304..7a7ad44 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,4 +1,6 @@ + Android device + Could not protect the pairing secret on this device. InputFlow InputFlow controlled peer InputFlow keyboard @@ -73,4 +75,26 @@ Apply Layout This Device Connect to InputFlow first to load device list + 0.5× + + Input method + Auto (best available) + Accessibility (no root, basic) + Shizuku (no root, native) + Root (native) + Grant Shizuku + Grant Root + Allow native input injection (system-level control) + Shizuku is not running. Install and start Shizuku first. + Shizuku permission requested. Approve it, then reopen Settings. + Shizuku could not be reached. + Requesting root… + Root granted. + Root denied. + ready + unavailable + Shizuku: %1$s Root: %2$s + %1$d + Settings + None diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml index 23f9639..1d1c765 100644 --- a/android/app/src/main/res/values/styles.xml +++ b/android/app/src/main/res/values/styles.xml @@ -2,13 +2,22 @@ diff --git a/android/app/src/main/res/xml/backup_rules.xml b/android/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..ce0b324 --- /dev/null +++ b/android/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/android/app/src/main/res/xml/data_extraction_rules.xml b/android/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..491c90f --- /dev/null +++ b/android/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/android/app/src/test/java/com/inputflow/android/RelayProtocolTest.kt b/android/app/src/test/java/com/inputflow/android/RelayProtocolTest.kt new file mode 100644 index 0000000..bff519e --- /dev/null +++ b/android/app/src/test/java/com/inputflow/android/RelayProtocolTest.kt @@ -0,0 +1,72 @@ +package com.inputflow.android + +import javax.crypto.AEADBadTagException +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class RelayProtocolTest { + private val secret = "inputflow-test-only-not-a-real-credential" + private val nonce = "00112233445566778899aabbccddeeff" + + @Test + fun encryptedFrameRoundTripsWithoutPlaintextLeakage() { + val key = RelayProtocol.deriveSessionKey(secret, nonce, "server-to-client") + val plaintext = """{"type":"keyboard","vkCode":65,"flags":0}""".toByteArray() + val encrypted = RelayProtocol.encryptPayload( + plaintext, + key, + 0, + RelayProtocol.SERVER_TO_CLIENT_IV, + ) + + assertFalse(encrypted.toString(Charsets.UTF_8).contains("keyboard")) + assertArrayEquals( + plaintext, + RelayProtocol.decryptPayload( + encrypted, + key, + 0, + RelayProtocol.SERVER_TO_CLIENT_IV, + ), + ) + } + + @Test + fun directionalKeysAreSeparated() { + val serverKey = RelayProtocol.deriveSessionKey(secret, nonce, "server-to-client") + val clientKey = RelayProtocol.deriveSessionKey(secret, nonce, "client-to-server") + assertNotEquals(serverKey.toList(), clientKey.toList()) + } + + @Test + fun tamperingAndReplaySequenceAreRejected() { + val key = RelayProtocol.deriveSessionKey(secret, nonce, "server-to-client") + val encrypted = RelayProtocol.encryptPayload( + """{"type":"ready"}""".toByteArray(), + key, + 7, + RelayProtocol.SERVER_TO_CLIENT_IV, + ) + encrypted[encrypted.lastIndex] = (encrypted.last().toInt() xor 1).toByte() + + assertThrows(AEADBadTagException::class.java) { + RelayProtocol.decryptPayload( + encrypted, + key, + 7, + RelayProtocol.SERVER_TO_CLIENT_IV, + ) + } + assertThrows(IllegalArgumentException::class.java) { + RelayProtocol.decryptPayload( + encrypted, + key, + 8, + RelayProtocol.SERVER_TO_CLIENT_IV, + ) + } + } +} diff --git a/assets/hicolor/16x16/status/inputflow-tray-attention.png b/assets/hicolor/16x16/status/inputflow-tray-attention.png index 3d4e2525a5c045258bfca7a91e3246b7ecd3c190..b32dcbdef9020befc68e70165bddd6ae9713d664 100644 GIT binary patch delta 479 zcmV<50U-YK0{H`wB!9q3L_t(|oTZXaD1%`f$G^}17&d05aF97UX>oCI;DB}zWZxd1)`!r;#^sQaJ!l4ipQaCY%rBm zQ_y;F083{l6MvA6M)5H_%T!KPfqaFg`FXW%iTt3+ot^q$pg2AY3bF-@;IO1)ZQ-)(^hF1!>uw{1_`rREH1z7$4(3Y2vU0YjIIf38rg|3Gg z-JFUvFDq*$002ovPDHLkV1j>MoqB!4fu~E9fWyl$F{@O6C=3`+@QFvt2|*RL(i5miA38yoH-Co+YU0pK z6LUnDV_JZ=K%a6#wNeSK8#O3_1^$OIZ&bwro-op{JpG{b9J6+vgJS1`kY=$NhfNJo zExlze&M6TDvQcfVyP_PhJnaDJ@JU8!{zWyl;wtYc2P{vUD{iE*Nrk2q^``FeQ}-zS q+-JksbU{i7c>H=ney&qkGw}t%_hk|EW9_H_0000OH@%D$G>Opo$E|a%}|?+$XO%>1~DxPqR>{= zB5lzkD0xDi~agCMR1L2#iCDk}J3bfXI&<73pBd*>d%=O9-G z9Tok+x%c}WIG_0)&J{Q(o?xdj5_Mt!l2Sx*3_{415B}f5kALYDh!&{LdFhM@$O!9+ zNQkr{%$SmBK_*_>1mHcFMp$9Mia>77v$2QDfm|9gx6bC#T>2^VK`~$5BeyNIna9eFnbEl`Md}GK$@< zhs6a7t11rT`ZFeOSRR6L?FsCWzihnL*biiTUoHinc663$K_(vR0Awv=0cPb_J~_Z* zKZ3ObaHdrSD2XfW xIsnxG%DE2V3s+1EBQPP?roO4oAfDi?>;Y;0z7b$K>Fodj002ovPDHLkV1j8f>^cAd delta 347 zcmV-h0i^zw1oi@uB!4|gL_t(|oXwH3O2j}EMek&{vhf2fbW5AfEF_EN#MVMf5y9ve z_y^)2SXgUBuotv7Q;G?gY-6bwT5Bn|Gu~NYF`C7|HhbXl?q%T4n|T6LGNS>qAFZ*? z7kB9eyYpLY@ATK~6>0)n# txYYv6h(njp;1u@MG{fEQX-nj z7C}sjn^`C->OyegaUr@<6q$uCL{Z$RsQ7~}L>J;l(P8ixT@2!ggFg%^Ix{NcdyYCX z=!ob8-#zbl;Xdb{9}U+eAjl~YF?&7~@l6aa!VkwyJ&G&Kgnt7>W1W0F!DwV%1~(<~ zQlbPId7_@?|BQ;TFayF_>lkBqT9MuUxlcqPcp6+?dDE2HAg>lb|b;aqlc1fD;O=ImbBCuiB6 zVyzI`xdKVL`F}SeBEp$~?d=`ZHMTMZbD^NPFk7u%BKM-Gq>3q+kOH}cR8ulM9xpq< zV7P_0&i>0UA>qS2r2b4{I;E>;0A8Pu39wJiaZfy<1@ah6MLu-88|;An{R6mG*KpSu zsg8Haxq`Ban$QAC+TUMz^)i+TSX^`>F7_!}JNne$J$-rn5Z2nJl}wBIkrMD8bZ{`S zf=K$3z$Y-j;6Op1g$d~GAHuifL{!%{c4b-q{ZNAAt88P8WSxuoj|m?+yJU3S?rUyo zj|*I7iIVI=z>h=#(X2B(xF0p6*XyQTtFC(k00005LPMeP(AW>ffBHI9j1Dj>JOy>;|ad-L^;80qE&l>AXNKS(C3$bLIf?b z7-XqFrYletpbQXGPG~k7zOkbYB{0YTq4hged4R(Y)f-grQ?Wo$P;(6L}x-%i*{EH uxWW#loBMp2=srm80Eas-$n-gdH51?K_-GNf=f-IO0000QGKCEe%1Br75B%C>MpMhUoSflp3@^s0gk$_)Pnb=2c5Y>Ua5{^L-c2 z$9eo!_$Nw0qsyU3G;T!|^e9IXK4@y~vccj5QL!?MW3g)?S$~xvL#-YZA(?=4`#ykK zRvK+h9kjJ|cnZsOKu3Keg7FA~i3pv-fo!tQWdkHm{j4tHs(4PcCwW}>wRDSr1KFLc02lMbOo6sNBqx*QBp6%7#V_D!Q%T~7tP z$HG{+J)$b4Y=3|>x(pr9F7Wd%1}?U!3Mm;NjINu(ycy~;dM+$z~ zY}IE2PB=}n3Ruz&a|E{immGhQ*GQRGmMFEXN(i!*2QQRX7JP@;xV3SppyRJ%h+CAn g*d`W(C;@-v3jpDo5pO_=LjV8(07*qoM6N<$f~!KtRR910 delta 277 zcmV+w0qXwG1Fr&*BYyz)Nkl;YShG9S$c900000NkvXXu0mjfvjur7 diff --git a/assets/hicolor/22x22/status/inputflow-tray-attention.png b/assets/hicolor/22x22/status/inputflow-tray-attention.png index a69ecca6a55a42b7197c746a008eb7ac7d3febd8..2362dfc760e69a75413c291484e6083793efc403 100644 GIT binary patch delta 604 zcmV-i0;Bz<1oQ-uBYy%rNklEZ&FlN0IuhNgW<9UXmdh|_5U9x=qGrto-phJA-e=)Od+uBl@GH=3M!(CmtXV#aO z(sAK0=1#d|=F2G{oD$}2EiS4H^uS+i@7RmN$_k>x!+&b7IpgKMJ^KXyV$!y;fpAZc zR86Ya*9f(@E2$0T_p`I8-QEThNBaAbTwYe$5@uaFJ40k(KvsaYumF8+O)B8w;DGb~ zO#H%=wn;24sTW810=d)3;GnEzEt%W-S58k+$YwvKOf%OI2lxn4MtJ1+lINLS#}@3=kYK`BOQ+ew1?>#T qnRg-xns$R^GRNb~%E2Z6SNsAezY!+VKm`u~0000L!BYy$xNklBl!{jD*J>;G#^cEbWt}nN1Gije9rHtYPcYE2X!<1zM}mL1m4oM1L;FWsL^mVmdUyFH=|v zN~MB{!XP7q;$tY@38kR)0K=7|)nM>kAq8TD<@i6eJ;U&?X;;2AVv7+jq*tF+x|(L# zfE76cYvTdfuP;HDzca&~_&ea+c>&JXaX8tB%rv5vClN~I?;7EyZ1DA5MCIcUto2d8 z?=b^-xA(#Ml7Hd*H0-yRnQ3C)j%9^aXfv#J`{3#9M)_5e8Nk2$DyrY!bH~r23)9Rr z;YL{S_uc@M1^e9!fmlJ+7V?` zM~YT@a*|OC+z5-ef%psKsrW63ub$2>%#Q43ev+9c+QrZ@pxQS;wS~EwLzi-x zeb5V?&$<(~HpS4k|Gx275U(rQNAN}t(H34BYZa^clUDV?p1KKpB3Tz+>Pj!*kKTfJ z+q}o0hJTpopzvYiy~N;k7vC${QIwyaIvz~qhmEkCAtWQ_(+r?-O>5$UngT@Asny~Pv;xhe2T>9LZ9<7NA4Z?!h vQiO9%RbBp?Jp47m8kXa8S!0p#zv36APcP8x5SIBR>9V-ZF!vwtZQXXK5^j#-3{3d=rSexc!k5 zQN8h1p_0V*17$7~jc4m-RTNEy?2F(8s!vp~x*OXCzMacN<7&O}MO8mD9U&qsN!(s! znoFUv-wHkCjDLa0lq1-5AP+g?X}7{}Knk>jP@0-*izP@POX*P81E7&@$~c6POJ>a3 zc2!XU$#MztBH6ZEER97NxfG0It5lkK)9()!Da(u=AJ}rSb)n1B5uw24Erp!7mJ?_RsaA107*qo IM6N<$f?e100ssI2 diff --git a/assets/hicolor/22x22/status/inputflow-tray-offline.png b/assets/hicolor/22x22/status/inputflow-tray-offline.png index 2497e97ed33eb0619c8af4bd87b261f8d11d7ba8..77adb1378e6691733c9206685e2c912331cb034f 100644 GIT binary patch delta 670 zcmV;P0%8581hoZ_BYy&XNklO=3AtEoLKL|=* zs1-yJQ4wT>(OMAR?d+~D8mop-K~ACzqhgaVCP@}vEV?l>FiKe9N`p)cGKlLoSbKY|dED~vAr&5+5UyUZigdI6x8i?kvFn>cnrKUJdVDL-O>hicv zmOumF*Ea>D?mZPGVH0_iFc##F*H2Fl~nS8gN1}?7YhWuY#r;s2E5N>{dc;1A>m7fd zoZ?C<6lNC-hYvKu$TRS$_bF%4F_^O^?%eI>EFB|Pab4|pG&a<-O48lahu*#cR_Rcf zoA~VMqdMb1kwd%D(Zwnq3bTuC)>5>&TA1ksikY`C$$wm}WhJ=iy^gWiI9F1kFuPcG zDm#|bmo5;bOQc(_w0G+6m~T5VF?I`u*~P+zb0@Ld zwu)7f*vC&OvJ|p{77HT3#CRB0Zequ#bvSk6P@-q0w})Q8K~?2?#u3mF$)ypVJd5B# zD4Y~$x_{DfjvQ>JU^6Sp_$9CPQhnWJmm=T2qejc6TW$ur_WCcYpcXrGc!cDQ5ai7e z(SF`QLFE11sT`=>&Vj*5@(~jL7RP+Foo~@sJ&tt9)%H#u14u$F{Ht8zuvtsk&l7kx zJd%(KDPqCNSbk2U*#zv!0RxCGDMZl6-{&*|*B2LuU;H&CCN}r#TmS$707*qoM6N<$ Ef=HW5$^ZZW delta 526 zcmV+p0`dK|1*Qa$BYy$yNkl1+;mB<^@oB z0tn&+f|61RPKZAbfQop5_P{w?sRzJ0ta}D2k6wJ|*kiQ~0gz6m?Z0^Oj$LUh$3OC!Gx2pP@Zh?qwB=KO4 zZYh<*zAN-UYkv&fW>~Pko&;!DITX zobr>O^ETvr^YP-@gaYJ7?b?_9@zGbhE3`BaiUYk<@ooO*T|Unzd=`Gx9>qS=^`x{< zv_4PpjGf~B#e_Yqy<_d0baQsNTZ2;;7WVi{%HMMS20VKqT}Zf!OA|f@B2~E;%_f$Y Q00000Ne4wvM6N<$f>sg#(*OVf diff --git a/assets/hicolor/22x22/status/inputflow-tray.png b/assets/hicolor/22x22/status/inputflow-tray.png index 670f9007e54b15644e6cbf3dcc1c6468252affbf..890cda2beb0247784aa684422624fc354c85bc2f 100644 GIT binary patch delta 559 zcmV+~0?_@K1IGlABYy%8Nkl$^p+$>Y6fH_xDbivhl45!+f{YAO5l0t3!MQpM#n<@#k2BX9 z=U(UP=npREyJzv^obR4{6xN9>v9GnkhpF)@MnBvUMWMIZEPt;tnJp9_3tu=?$HBnr zFw9_Gh@(dF(NJmt5OF?)C^tZ~IJx~8WBwF+i=~zY@yGfbNF-h{W^i%G5nR}I07mC0 zjLl4P1}NIN8TU%c@IBUt55xaCvjnAXniaLL6{BMf4@%DAZT}wxiudVJxVq;!BC%dQ zKI#6#nJjc2ihsdTymyNaXDL1A*@cH4Va@=L4_BZ)K7?g$=pI zsTJFi*NTPK)3 z@w~JOSCSpSW5;*eo0*&_xNHU=H7O=nmrxP!2qAT6IFfz|3Cq>70(1=^mRQJBZb^j< xv7l?2jwoMZ=YF_BsVFvinq>QYv&a&!74rh2;}^UtMmPWf002ovPDHLkV1m{N2`&Ht delta 382 zcmV-^0fGL<1egPmBYy#3NklLB&Viu3B1@St@z<)tAP2o+(wvPfz#q6vL z#Z*-elW};;E4ZEDK}4VUvc*3c@oFih;tba}_X5#43eOqg`N5q+C5h}AV z-HL1jQXq6Tq<5>SUh@j#$W{8IBL@f;o3aeyDP$q$Y^SPdfh_9(iR&6g2G{L^9S+!O z!U)zb>mJwbma&jZvA1tpI+ujIyUoZ4@*xd%;}<(c36x)-=8`O80*~iCfNyL`IC^_=8ww#ZKW~Txnb_XYfQ*t71iQN# zvE&1@`2Kf6SAQ2GOG`#3*PEM+09jR4#*=3^HX=Md&4?vx0+~$8W3R16cxs9fz}erA z$nrAM^Yd}Dv%`obY68JIG6I|1jmxLIVRFr9@re=<7pPWhHwi*W23& zkB+jJ5;cJ==&Qv=q&b}&naINbZfd$;cnj17_WF9{bboX}KRJP|pnwtF_b+E>akaY2 zh$U)*oX$?z>*^319E78%hY=t=HU{VLFs|0ta5*!>h$U)*?3NZ}H#f7FOk`of*kPIF z+mT3f&7uB?1y2&N$R z!3h9(&8CFBvbD)cLn6rJL7(qJKRtaTPtTAk5IgQ9pYeoD1Sy}uH|!UF2R_9o7aj=W o+^vZlPeIduBu*U1Qt+Jo0m&CIC?%NTH2?qr07*qoM6N<$f;`DmJ^%m! delta 518 zcmV+h0{Q*f1)l_vBYy$rNkl)$LQIa>K%!-1 zE3iXbF)^{RAqxKhR4i+LIjNWY1>M*Xk=mtd@AczU>5o)*j?z|L`jO6l{>FY^ z`}rL+g%-nZ6>2~}xx&wl)YOeS4wZ{xAi5fIA_kYn^+5d5_6A9sY3qZXHlKC7d|Kpw1*wv_!F@MV?#Kc& zAKI((1W9yDzNt!XugGfg+iyq>QG-AuZT%wcBg*oRA)(%)ZTE6NPhZP;2AS)KN=kT$ zk(~NxM72Z>l7GxSFIBz}p1>M;QFQ!QsUd2>?pN6TBFSc{yB9D<9H#56{=_MkkSZD5 zfTUdvnn=N5KRyeCsuHoVZ>OdnsP5jyYz`?Hw};=#5az{cr0y@>9VYkbIgs2i#=cZ3 zn+z6_4jDNWYvMGr)>~f9_e>rb1lXh7qs)hP9Y+4-LX`~APmIo}BUXHpuBhy1xP^&FDU5z^&f+84+P!uO=z=MyPdY^#lr*Phw5RnWaElZdr4Ms!0NAsTfY-lAJ!I3?r zCJlJc4O8nQX~VrF08{+|<9jPt@i*PRuK<~aq89-Wj5V5ae zc-EeR`sM^>>^L^y!HmInNO_es*m24LO3_Vd?Ux{YIf#9EOc^_l4H#wn1GuvCpuRm# z5v^*&Sr|iIh(6m(QEKL7AaAXMw7i0ph2C2a_0?fgsef^7zz6Cr@1pktiY!cZ7!&vN z=skJxvvF)7tv&{M<9RC64l2!j${?xL)@taTS14o0v4Onl0;E+(5q-W7a^M0}L8JY3MLWrRb=LYWVT~Lbe zkh0LbZ&MQA7cJcFbs>?wt$0mCL^rhbD|8RmeCwvRCeUN@!-=}=;DW`;om qJ`>t$dYt2h)HZ$K1St*vCBFaz>slxoNo$7y00003wBYy$rNkl%t7{{L(42>F%v9P4Fyjzl8W2|Olr-@=` zhuSL;D=RxYBl-oD*w`tB^-XEKP1w6nKxb#XL>nUe@Egc-1hPAq146?unR#Ar=K0Ut zo{>}LTp-HX3v@I01tWvLe~j5D zDs{uYUm?{uhFj$3%`JEWio-x^+hGc9b=^6 z{4=~(q5?_go_|y-A90UhjXW*;{*$y26=3zNtbU1N^PIaEFh(4r8_a&sE|y6(GO++f z2MBaY1%W;PBm|m{h>0URGjm6E`zB%!Nd@8c`dg`CT%1noeqnAfc!Xs`a>W??Or`1) zERh;>XjjaM)5-EzJk2)@-W3S2TF1^da0z6MJ}58JhkwhT=kiNjewvW_E^x!7=PGg? z_ccwdNRE+8q=yOdO8tc!+Eg688w%!_vA}`nYiLuEICoY5%BFH3g!@o%WG=~E>HQzV z5Kj-gNO(cM7NOWE+zqQQ33nlPeE7$80-U>-L!5{}8>cR?0}3;<86QsMuK)l507*qo IM6N<$g2}4#G5`Po diff --git a/assets/hicolor/24x24/status/inputflow-tray-offline.png b/assets/hicolor/24x24/status/inputflow-tray-offline.png index 6f27009d7fbc1a65ba524ca871f60ac7de13edbf..74677bd6ce75b3f6711c0ed9a7f1a8e01590ff5f 100644 GIT binary patch delta 762 zcmVp+5 z&Am(AE&RdZ&J5i7pEGmjUO&t+UIlbVq}Zt`|7B#VCLmz?lYhQSGQopc0y;_3*e$4a z0Quzl3?(xJhM-I@NfYD>DCR^*$tNxNNT!C?|1dEx1abNq5w}2jrPSp3$DMmZNe$H( zq%CB!O}nR>PyQ6USFk)j7FL@L!y{($WTK-Y5F8YQ!66g8L8l1iQwV|bLz31pjdn5> zl1epuOc5oj1vuI8s2Le)DX72eOtz=E0Ih9Z2n*HV{fB;vC{ZoI?BBZ+ zOTrh!_~;pVKwjLjvcUVp#sL*1Q+WO+xm05|mHF&*8Q z`Q*u1Z9j1QwBFr#PgDzJW~5?UUN%NX#}O47P7%262gavpf7wkDC8`Aq@;5=7z82SO z8&O;&W(T(#ny{~|5FMSbaNqERB1%*X7hoacr>3&#-&p&z_0)daqStd|K~5Gh*=tmcYXcTaGRJH7ggu4VZ>}! zFOZy=KsDb%mJCldC;d*h=+f0XaT(ZW_yiqY-Lk-+K6Wnn1e}E7GAs(yV0U32d2oy5 zWNEQ7DFL?|jd(NAD}U^mgO!qj9?myZAm-y}g>!f^EW&WYQ)jDN&YU=$OQ!GzI_D3^ z1YjX8RYXTdi2k@lc<3(I-266X8H>@=?kOY-FgmA59eg+m!{uWJhfGHL50vU>k~6ae sTyRh9cErL50vDk7_rxyzXw50V0Qq`RDAetijsO4v07*qoM6N<$f=-}oBLDyZ delta 522 zcmV+l0`>io2BHLzBYy$uNklAqmY|8?mTw(hA{pJCA@iwmYxvvUwYJ^Ltt9Hwl$eFL1-Hu#3V*Z`_aF~?hxs4Y=wUYS?^Qvq0K4C0_v<9PM%}%@7&%0@S^b7n^azzQ zw*X0p2s8;rfj#>u1jWLVsQCDY0KPKf~nJoD{vljEf!z z=`!YXo-qkKxzyu*7hhJ|D86v!F2+o6_C$hSNLt0-zrKqLICnAp(oUcsxREhWOlZtG ztz_O8{^D?o7B|QjA|#u{-mv=&cM7q?!#}PQPUd}s|x=aS%Kj1HX)TVQm7n_CMG&MqJNk~fk5zI5Rgn<1^%>B zS_LXEw7R{bS3%7X{R(}cxtbfr5S!!w=;=1FHRTP#L^eowk^N|4T4MyDj^qMNb&IL;m_!+}t zvmFQD-J^;ON(J03nD~l)h81wX9i$3yrEx2IBF|u$Dd7IMFjZtwDq#AT?SQ3jHabTh zQU-J`T8r?n?`VH@i?R|_BDieZf^GAy6iM9v9K!zK9e;{Spj5yEebeHGUOj^{2_Ago z(~J4R7ok+(GB)GPVkbg02di!_RgjO5z4yTr8={H~N(J2(Ck_~zv1_;=9&7d-x8KY9$dbgqGDQ}_tHc&kp07ng3cDLsSu~#n|SvWO9JMfqz~1<$pAIqlWWct2y$d* ol1`gY3F^AxiH&o}3Z|34v`-o+NFq7m00000Ne4wvM6N<$f;b~L0ssI2 delta 392 zcmV;30eAkT1)&3wBYy#CNklspY!EJ7WgUca_{~~t|(xixo9NZ7u@5@J$U-PwPG-SpC zFaIxS3VDM-d8#%L29$Mc*;JvERSYZf3IZWdIcJKnTlS>xP=BIuQMQONYmAjes`gN- z#489R*=7xXYkGDk*U3Ig)QsX2-eBjpXVx>VIw)1*7c9v+^Ff=Kxy&bN4JESgbZsqZ zK0#O6qw10aub?D|;s|?yaGIZ2T#9?%@)viMSwAqkUnGe8BWngsgl|PY??#RM5z>f<$oa< zdW%YeNWLJfXh&HkFn6dHLDb`pah%pJ)LuQGYC9yD}U$vvvIsdQyXs0jpX~+DMd!2hUJ^y>} zx%b=}@XY+b0xlaHU%}()6tfynV;4rFJ$GZ{2O*rDZvVSsFfWc&fYjF|YlN0eq<%%l* zfN=jg<4ADk+=fS>UqEJC8!|gP#R|t|x5KrxBvxGX3E)Y{i4)*T1jac5M4y0I%dg>H zS;5Wpv{*6GC%`;00b^+?+{??j-QQQo#L?S}w8~0kw6?-?bfk{s@H7G5(^GhlkMCE5 z$(LGOjDOU^!sH6zZI@P2Ay#y54-V87%4lg(uV=zz%dO$x0Qv-Q#>|6QxYyT_(bObX zI6R_UEkU0Ek|TvCrJz78xe=u>5f&hFcX5G~ygae|+?}0CFQ+H)d2%HMP5k-=q}SFW zdtg8;|I!j|sd*dnhSXqDA}nGWtd!!{FM!uElYfy37Yq~A=XAnYQX(Ti5=i<`0n8Dp zq!9&J5$8do!3&9B%A-ekBpHl8=81-TZ4HL3ETq@e$jA>9R7$!{1Xv*$q^Tt0Nygy8 zNvd5)Sv@_{`%x2dXD9qgMiTKTeK9+$zMdk%SJJh`G9{1F_cBxo6~Je?qU?$Ye^%*$ zIaI=uttF<0y7!Zl-{AGWjruGn0TG`>#FJKntH4G$b4k)E`C(!W4o<=ZXasVi3yJw@ lHm~jZpF|b#uXuI=zX1Z8p)lOknR@^L002ovPDHLkV1gEVpdSDL delta 712 zcmV;(0yq8f2HypcBYy&>Nkl616vn?@o-6Eu#NHt`CQ$6rS%Lq6fe4|29e)F^Ic~+wCU-F=_e4_ z%z@6_6w9+pIDd$80{lNc(wZ}}JZ^4x$IcVhOfA`nBCWej4Kt~QJToDb65#ONPn7h6d`P$?iTwXQ6S6{4(p$? z{#DkxOS?{}sSBFK7Kl~xhbJJ<0QNVAC%_jX(*6$jk1*L`G%+#lPEPK*izP_}#4T2M ze|U(8;|o!CY`#(nZ_zCf$vlv8)0xv6-2#!(VqQISN(t~(>vON2b1hsy*b1DKc%LJ< zn4M~m(0@6^zR~#mw`tCa#x>P@Obu2Fp9RYCzU!+6QDmNEnY)X-g5|U@&!rf9q^VyR ztTeoQV$!^dJBxX?Zcgga_alA8&tskz=2^jVE|qJ84GaoZM^4XhFQ6!Kt@Ouw3)Mgc zkeLRVS;IXil3viL_vG|%#DNO%t!>V;BYy*ONkl9h26vzK}=FNX)y}+`cm2<6-*noU zH*Mau>X*FRbI)|%{l9b1J@1UdIP?Ds=(!o5Z6RK&HP#{rlz&2v4@Py~zK-w6K$-x3 zGaUW{^CC3LFq$Y--)N3GJ{6eby{^=#~Q-BIhhFM<$tNjDLnbA?_~Q>B>QC> zh9f5dl8Axg)3nkt>(AKdaA2T#gOT7T}yDX}80-US$Qce38!pw$;zAsOnH zN34)rX)zMai&P|ff?0nav4#ro0;C*=BpI1-sYqgKGXnp#zVa*!r4lGU#gW38BheST z(*=yAA#sp`BscW)=m`lPNm)>QPMwf>D)O0NHeH2TcOtzZ8I6|AKW{HWd@T#s?-sy$ zDSt6)zKKON`Vvupy_epS38T@J%A0ZM_BhBk^*mg8nc+Xmsm&k({cU*-fTg^=5e!2b z7AG+sWbb2djx^>+yGmT!a-mRp s_|r`DrFa`t>Et^)oQ<1XMYj%dR$;tx_n!2kdN07*qoM6N<$f@>1ItpET3 delta 704 zcmV;x0zdt$2iFCVBYy&(Nkl616vn?SVMOWy9Aw>vZ6 zy_tD?M*o;A0apV4F9B)YT6*kfTP~wnB5l}IUX$6jbBZKdKz|tAOFbsorBio!k|2)f zON=P;T*9+Vd#^??E;3aM}l>~&7;7^os+-}k=!C(jGn$p-45TH*y z9JLkFy@Y!)MqQ5RGF}wb1k|mQG)t#-teZ=pXe*KCJby(9o2R+SKCp^}apGb9GuFSx zTKBoH5o+s@A#@_D}6r# z#N!EtC_ge^sf4#_mPlkC$oT2pYn5h+#Avaoo&}Wz1Zs`BSKj*$Zs_a;Ub4aG2rlL) z+9h-$$$vFX4IWeaRU>49a=hnJ{wogsR~Wf~0+u5U mH!P5m_KbUpOA`>wm4F|NsQ)1mcq;<{0000#4Uza%q)eWi*d=WssoH&Ofz5;@gdYeLoA0gkAlX~Mc8L|GcnD;g>h9yLcPLs* z0~Qd*lLRubY-u?5Cnn%oMHNn_9KyYVQqKP7%&8O{e{VoI zclsFeA3UMhQHoW-$!rK=yz7%G2T@#7j?@!J^zZnCqJQQL?23zp#+L{$3=2bDLnCJ= z=ZBq6-H5`MG4BhCnlm5*a`zpdIuR4K9oMt-ISX7#KZmB4Hf-Ip0r?M0bc6n&s5t|I ztiKeSH>5bF4gx_@^9H044nnlm6s7f3e<&VP|nUb#Unn9nQlb6^ll!WT1w-U-I{ z+>tKn?;kL4AZ}+AQjZ^I#MDluRGx%FV{;o~qqj4fEX-v~mw05XvYLBERjqjgvK`yb zTlIo-M+$RfbQBxcujL4QHjL;dq&VG8NcY??DD!GS)tDiM0g==uCt464wT&bGXiK|( zXMb{Dh-aM`crzgd`n;kliw0B>mjPQw@JBLlMJgs76irUDK#)Rw-_+=NBok)hP@0i` z7OPjSVB|?xPakgP+|A9)R&5;WOfz7UWBDK)0Alzyh17^VvW>ztkhOnY_M&wxWO$F= z*Rfo%iHRZDgUy6wLC%qaoDaKu`*K4TK{U0yi3=VE45TI|#$W|tL2&S|848Ux{}Z1E d{uOg>;1|t^yf8hs&Eo(7002ovPDHLkV1foLyAJ>W delta 710 zcmV;%0y+KA2i*mbBYy&616vn?>Xlb0>wgS1^xr(h!7gs@i)OmxCJwt95E*Rl6Ujw?au6X zZ)V=E(Ld%|z_oz?OF%khEhBo_hRf)dNb5G0*J!rsoFa)95Pv53QkMtpFsRf2kRXoh z^UOHT_2y+F`{CX=(uZ~|Kh}IQqz-Lsd&4z#hRM3^a;@c$wP=R4lznx(iXd7*pf+W{ zY;C&7wYvRiZt7*`T#v9)Ye)ToK5T8c=fypQqo^c+G%sk#?8y63c^T5&6|g)_W+GyG zI`-o%Pn&QQ)qezpf4Z+_Z{&DfV|&xh5Z2_BT}=|L_*_jhqS+!dF;o%|2oIxqiiOcX z`4wVr@f%nxM8#5B0PFwOd%eOfVT|6f4_@O&Q%OKL3I0eK$L%)V5=^#dZYYgC0RhIu z!%=IK?s?qHG3szc7x1E}CSb}sNz)8k#X7n4nZ6RK;eRPY*bLou_JLI-%o7jmpRxWm z)_RL}g-}Z;w1~|TtKnarfFc9f-?%yfp%9Vw_qZ3rWP8c*@Pt1+eBe)(#0rQztn~f} z5RWGmqWr*oqY~bwTOyHpB;zM?uVuO=5~I1IdKOd?5UAA~Z@l+QxM8ppc*zEzBRH2I zX@`*Le}DJVJ=}97H#C0sm~vk=LKY~;yS}d$B#C*J=l(wKBBn%YF#FgA*;rzawDg@~ diff --git a/assets/hicolor/32x32/status/inputflow-tray.png b/assets/hicolor/32x32/status/inputflow-tray.png index f67e68b43f16f8fe1b0ade8a0e04bd4568b87785..482f25f3dd11135f763a2113a3667c4fe8cbd8bd 100644 GIT binary patch delta 771 zcmV+e1N{8O1e*qsB!A*bL_t(|oXwU!OjA)9hTkSybO5U+_%jHEK?W*yFvciS7D<$7 zG*Ltcm^4aaWKnUUT|`|_6Z~D;L`C9YG-%SPMKFe#Xlpy*K%@-*FleQN(wNluyY}bS z-uBj8YJHM(d+uq|^PF?O@0?>onfZSOEZ%`y#A01And~Trcz@gnv)LVK?VEuHSppXC zpal!jL73v*SWMz37b+@yBdxs=zNN?zP&$vzds(%ylmb7@Rjq1?OaYJ61)z(il(-{p z{oP2A5=gB1dsa)2*<6>5iL`*n=>X8rl9SH$O?(2w9|C-4qxZcECuhL{q89K0XkpRB zTDcZy>v!VY&wmA_gNZLQP_tuIts<~uNd+!VKSwb7iLE&qqktWxV1W(GEfi?M`6&;s zH}1nx{|H;t+_QDyWau$2G_+A*471-;ai+!J3kV~dJJIoG7xMMw_{6+_30!KK-aehk27Iu1{OCd;mgl!@se}9;4&x0`m=|Dz5@imP0>IO8w zyv0^veA{u1&P~C-x(g~{CVA_Yv%zy$g zCLoy*Xg1BXmOd#L%C%HL0i9V*P4#osVw{&6q${O4VPvR+qPTqy~S8nWK zP@bsSihmappnTV%IDt^~Gv0Eilk^iL?KW|zY^+>US^?SZc2{p?EjQ2R-ev8Q8W%n1JAC7Su2sgn1rJLKP1nA4*ijrluDL6@=6{k?;9pU8fnSLsTQI|G$w&YI002ovPDHLkV1mBv BXD|Q& delta 556 zcmV+{0@MAQ2E_!BB!3J^L_t(|ob8n{Zxb;T$N#>t71T`3?ZnpHkVq`3&L^PV8S(*; z5+;~v$^;XM#7+kGmQPSuEJ(?4>BiK~OsSw7IrFnn5NZ@;BciI({UqN#KcAof_k4b5 zbi-5vDgn1kK;E>L8M9*S3c5?=Et|`$UQC=*B+&vwa4(Hm;D4M+!|RI#ag29~xXpNH zmB{PxY#jMRyH(EB&1xEQ@1KobM}M%>vLnX&ey#&k(_!)1T_}QR0f9!-ewt3)qejbq zFdKSWI5)$s+}gUo(!1%{Jy|@1JBlO$q`9Y>IhXf!c^T3?60kiTBH=MJ&HXmpGr%21 znt<@8-}T-bIe(UGpH18zZtXPfahB=Gm+F|i+FxWQh9m)juo$<;v@rV{S0Uye*TBbx zs92H(u>Y^U*K^DzjL{pugBO_5Bnb#7!T(UfaoeQ31i>!N9p$knAi$h>IBEmZ-Njsu z(U2qB!-^tJK+`%&yG%N!4NK{SzA~xpDMGY8x~F^xR)3KYCm!~{VE-HJ^_2S&uD*WJ z$G4BKfxSKfiww~I#`*+=LPXl%Vy+~Uo~^YIl~?8qmGC~@WsLcnZTJVpItx*w)qd%{ zKg0}^oxn?0V2nAKj!#};MnjA=LKY~;7c9C-_csK(qzrGRynTRq>G1Q(ORoXuE-i4H u8TI%%6(!h7{F8wHNhP2XPzktk0Y3pT0mLDA*sh`g0000dL_t(|ob8!8a1%)shTmAWNH zSdO>?Zxu&sDdnizO*kTpD+PsM6D$E&kr-lNHzkrWvcZ;R@Bcrm&1j?9Qjo$ zl?hp0&B#V#S$T=Ya>>V!{}m5B1r$@M3=Jpvr?s2Kq~boGBA#?WjLUPD_3Os-Mh8;>Qx~s$g>s7jV zQSH-r`G2yC4nX(<;>V6@CTJZFvg_bMon1T3Qu+CF6{c3g%r8ECh&#RsUx2aB-0|aD zs2uEP)Wd42guD2VL_)T}_Y}nfA9(O!-AVxnUjRv5zph!(_6RuJRy>XfHNLp0Ss|U}I^26msN>1oY~=}7E6obm2d zYP}l~d#m+9xB}>V`rp9zXjWw2BgXSwf!S^ie2w>f60Lx5=tNv$X)9)ju;b7!T(Nu5 zfqx8bDZmeQ0bB&_g1Oka5Uv0+2+s#$7B#_dXUq(@8+KS5noVp$9E2}`d!McY2N1PR z`SokDdU`a2UNA%R*{lkx621Tq!mo|WZ{A4Po;`?~v-ISNEPQ|e@CA_G@o|YCJ&I@z z`q=Ji6O*NvFQt3`enho!wh1T1iLG9u5r04rbIWX?e@(}+$AtFj+PfD~Enm)@!6O#N zTet97OZmkM-HwB31Q0H0_L*)3v%v+9f}L;;A@;Sxz#8DfhwssU;c)8@yrhIGfT?d* z3$%jU-%tRBu_-iyT?^!pfvi-qaII4u9!_Js``rft;8uYr4$e$)=DD4qkKNjC`G3_b zJmNj}?Zaa|D!}t#E5xHk3gO~JxF~UtWMBrEEhlObV6Pq|iz%YSXze z+gh}=4*LEW2A>xfJOvP~iAp64A)|P@ap2b&{LGqldfrJ>0S0Zc3k!#^P7LAA2O($( zFNcR^&EP#}(dTERukUYrtMsB}0qx390h`T#)YqX;juYbn00000NkvXXt^-0~f(V;t AlK=n! delta 1080 zcmV-81jqZG3e5S#$KR|JLh!-qAdn)(Iix?W!AjBuuOPjp zzrb>nl(GK;%YVT+Wh%>mfuzc%#$6ICc;!CCmsEkkN#H92(oR0J_TE`PR+hZWtq=5p z$GqLq&CGY_&3kiJ@`1AwkToj-S+f$5H7fyGvl8Hbyx8=#lT$DKUpwvMrq^Uq?YLdAUmvmCy{@jhUzfLok=eiOYEKf7Sb)3f z?HMISzT__9w0{!>Qe{yUx~4#E%We?A%x zRTv_9as6NPmE0lvoei;EK5vHo;j3A{Tb9HEEQ@^EWp-*AQV}p2+a@rFEYzjzEeP|K zqYf51E6b3MfGE4<)D8#JF1$`k-C%(}z7^<_IxleR9DjJzQk@&a&mdDYmx$~2_UX3#B~$*UN|pR&Pxk7G3f~)OpHjEvykI4 z7A71L|D`CrlEQe&iOv85p7MAvOgeZ}XMi+X7m)*O84gLKbxBAn0!WeeKC~`NKp0(- zYQMlugnz;kVPpR`Mc$xf&smcb^F}TrQHsT?HQ5N>kfn?q@haqQs(mO5O9U0x<7U~x z4aq6xU7UB$C{`UK;t4Q`rpT2yO69GQF_p@;C%chR_l`iV(68E`76L3VnzA>wmJ_d5#leKLm_8VG37Y-2NFvK9iWalf-!iH0*g zGpn`PxeDzVjF}$yUF!IX`+UVC1ia?4fQ>kK$bcG<~>OrWq#pbq%d zfg2!IQaBYy=)NklDU#K=ECX#52m%ae2 z-R)jyj{k4AL-*oNXV#tVn(#}u{mz`Gng97N=R0Qw{m;1}V1KExEkH)Y;W??@M?!XD z=pIsxHw(`>5sAexT9icvB%|Tmbjt20N&Qq$1Pv+VI}nJ(ekBVm1;nGv`i0b^FqaG| zlt?HtJ46+zC?F9HM(e)nm4G*bkwgSm83La&8e!#2spN?=0Y=Qr>@P4I4n_EGFU^TE z0f{5QAtHJbpMOT=XmEF;p9&}vkS~?lYcLy@X5m{|SYnF=R1^Cm)JkgC8?cD77hWg* z>W{E0qpZ4ve0RMHizxHmD7)fTr_7&sks2AFpT^?qoiFLrrgiRFtdPn z{VwvX>!b8&j}bop?)M?O4KvPgMS!>cIZ{@3Q0Dv|^5P{ut=q7=#mULZu1$?l4&u~J zgQQ=ZpxorUm~n__TqpO#*+nPI!4)X_U2)X z7H~xX_dcGMhhf=AyZkxoZ?kcVvw}}==`VMp?#3{3NkBDWr2X~|wL7rE(ADT8+0H31Bt%($tu!f4`r%Zpyli1RZ&tLBDc;&gB z;JRO0b{z?@y4b4a)glW~R(HU{#(#8W0*N~UD-U~sy=9`~QY>|<-FRt+1vKrLBxPAE zMtN8`d(ZM}k*UY!u4mmY+)50>vQL^X%q|_XzT9Q%b-m8LlHC^EY`tw=uQSzL@;Zh0 zg{JM_80FUrZnsmX?;$;L+1?i%CiMsM%$KsL028CclD3(2*(n8u@u(A@ wS~4yq#gM7v=uy8aER|N+7SO2N5U|wz1LvOOPd-(*&Hw-a07*qoM6N<$f=+0xMgRZ+ delta 1061 zcmV+=1ls$63%Ll8BYy+{Nklw$eNXatXTE6&+GM;XF+R3SB{$K6W;-+_kQ>XJ*!OlKnx4Ru(b$=*t2Sc-e)zO|LAh7^< z)7vvjip=CL;eWIf1X9JR3QbcWw&gf-Lf9O$a!1NY*YD^CP4_0bfFHeeGY~&t$Uhkl z25J-{d2#(e^|g!l$9*u0g?P?W-1wI&c@0%lwz z7D9-1^K0_+KU%*c1s_ngM=4Oo>ET_SE64BHb4f)&c-J>ER)GQg`Ooq{4L={e;P5-) zZ#-E5-)j!N>bygb_H=Md^NdW*CYn4Sd%kj7jp_l zNq-H(0Y7h`;}1@UHqSokv=Gf)ad2dI#-h*kKtPMfHf1IZ)+!Q f1Z2%hK-Rnmz)tKyb=A1`00000NkvXXu0mjfL%06W diff --git a/assets/hicolor/48x48/status/inputflow-tray-offline.png b/assets/hicolor/48x48/status/inputflow-tray-offline.png index 23e247daa3ae06d08dd83a2198dcb60f459b0eef..7749d52fccef4c0ea01f9a09d9c19f7e4900819f 100644 GIT binary patch delta 1407 zcmV-_1%Ud?2$2hrBYy>0Nkl zLY!{V7V~gXHl=ZbS(K&^rbTnmVWCU9;5gcZttQ^M>?WuioLxYo(y&EK%q{|~76k2_ zzTXF4csYmnJ@0$=9yo4(HgkuT4lmp_JQd~t-&C)=CqRR>JX903(oN<~EXe8;?2UMg8x zOSB{TtMcT~(MG&$P3`_l8Te^X?#{}@l~0;m+VJWeIDcH*)S#41gX>u;s#P!&kxH13 zIeZ`>t*6hxsb92npSPWbJt*@*fv=Qq(<1X%JuEv*UXTy#KgEi3;INH=1R}7r5cMG?t{|WHWT4Fl$DmquS35{=XV!$+WC+VUvzwh73aX=E(j==X>dJDA_ADx zulirocYl@jP*eT7?Au?5l~|4)t&uw1ai$xi^;xJrZp}mkhrJa*%xP4)XO~>K*el%^ zd$2OgrVT~1aYLaro;an`r|jkJa_~qHEAD{9-V1p4nN5@|Uu=fH;99m5CMKnD-5OXTW8?qoyl*@al0<;R z$q~RDXKFZssTN%$PJzS85sq8+E&lAaK}Q0c@e1ss=yw_2bU=Vo;UL zbR>43*3%t`RH3XdnCeEv+-jWyhrJcRyl4I!uss?RnQO$zc}`%qTLZqwH8;TFr3ESH>|rLaVXhi}RC8#iIa0*Ad7K$_}Z ze?(JJU8|qYiV>Nq2b@{{L2WRa+qU{Sw?!H37_MjWdI@Rr6?0ZwXxsW9LFI1$0F1&3=9 z0VUJmdKMh3l;D8j49tY#R)HrK(SMQPo#%Ffd2H2o<+d99v&@H2%pdh9b*m~ zFanI$eDHq_5!_=L#ZTdkp6$^hS%|}3-dWyZD<~qmE3u7sf8S$&xJwr5c)g~2x86QD zfnw_FPdYnZCy5;Lx`nAxQ?*;SUpqK~>l#kV$ndDMA2KIrGAPW%*!c^j($mm?`Slk0KbFJ`Pb3U55 z0t^CZvH$%g#Sl`2Pi5;@FY!qazH^kXcik(UK~t-=!?A!=<&J>G=6~29&QI+k+)n@i N002ovPDHLkV1l98uIm5* delta 1079 zcmV-71jzf53(E+QBYy-ENklOJ7{{MkDTLsIQy`Ec#py_YT7#9O3Acju zmOg>yCMjco0?VJ^oHCW=Phh!nsd1OY3SPMn=S!+UAQIda0cj_{S!wU|{^DJ3eV`va z?6W(onfcE=^UP?KeBf*Zq|HV^+H3@*%|<}lYy`Mpd1Vtw-G7KYc9iV<*SanXaEo3w zFmg)6UFAsC|4lb|d~c{H9)+U!(io{bB3FoLs7I3bweCb>$S4t}F zYwc2WZ>TFC&vrcRWYi1)*XBiG$2+G}7xQMpjy_^{+bvyjzb@?teRFu*(w@X1wg7j> zJ1|NLjO0Gyw0{=_QlV32il#tp!*S%CusLJqPL+|i-_j=(-JAFVzVY&=Cw?}UebVps zR2U*zas6NPos1B@XG1KL&6-}P_iEOA>k?alWtEX#XQtL65do92Z31&lM_r2Ef-qk> z>S&d2rHq{LC}cF%-WP?%f*sc5WZA?G z$tdOBTsN=SteQr|6QB{zCRf@nmY(;G(K5slDY0W~i2-%TV^RR4-Ns&!QtL8=M(o-D zM`Pp=EAS}fVF;KEp*46AA!H$3I%dTb`VRHyZGWrz1SiA}1d0atfqAv6utWsRI7Tdl z5Nl_*fhTG}z64l>N5puuGqn+c6I5Kx(X&&kSD``2>G zTDoB7%3Q~w&g58jsACil8O0L>{L5_)3t6ui7>Nq7SP>i^)aaIw50Dr0)XX-(cgt;9ibNVa+mSrN|yMTEhKnq{y zuqj4Gkf~mhsq!-53IU!>ps@E2GgIaGaf&+=D}swp=c_V=W5+%-S(gb?5ny3-Z3hdu zOF>YZeZa+?A~gZ4?%s5;s}_90qdYyrjP+XTGO{O3aN#`S6FlnyeOe%!+SdR8002ovPDHLkV1nb@0qXz& diff --git a/assets/hicolor/48x48/status/inputflow-tray.png b/assets/hicolor/48x48/status/inputflow-tray.png index 5ad3398d03a1bd18428c753526e22e9fd1475e8b..8f23907c6565a816a946fd52994210d2ea676dbb 100644 GIT binary patch delta 1165 zcmV;81akYw2A~O$BYy;ENklfD7+0$`0V`C1s3& zU6+reXh5Umj$jFxW<*eL?_g*lhDl@LlB0 zn1!CZeJIJz!_wBhyqlVHrCZR}+l@K=jKSCYk>7aN5r1LYD1;cJ1vH0uBZ{pIZPQ-5 z6;+)F@gE;b9&YpbKhbup3u93-Hy`_+e^ZHkjbe8etU>AjKeERW76M$%KwUvOT_<*E z!pDo2p*UwgHgz6iPcq*ZzJ>Pw>-eGPByCsD$_VVZ*u)-3v{1lG#Nbyv#*F~+^v}U} z)Tl~6w12+(5%P}iVox&NtGBBW7vIZleGvIQZO)8=w*rKC8~ydthd6NU9DZes=e-3> zv4QQ4(?2Qu>@Qr8;?_Ovag6r@s`8ehkTbEF){%!?H?JTmIVIT--@K^A?W2`CIr96v zPqN1`-V4yyDLTGeC8|7Z{?TvHH*}Z1V8wX~U4N@TQ=%lnJC<)7v62hC7a$ydd85h) zDXD^tcJ}m{jDUd$_tmTj{X_SZ@7JAb_SU@5m6xhH)N$*h2#ms_~Po=~=u*%4&H61!C8y@Jmdui%|wd?~;Qz7QbvmR0lJ zIl0ZXe0PlZ0%Y&=`n)BK?DO}{OKNZA0)OuXxUrE-`v~3(5WanGEh-nj$Y>Zz-1sEc zu~2{Y3_iVZgpn&UBS3GNHByaq#fgsvm-Szh83BU)GLuc66|#wQ`AcjV1AEM}nr(~} zo(d?Ln?4uD;eAb*GY~?I1OZ>I2Oj>x&^5AEsN;!4;!McMZ#sWyybn*T4z@fzT7MKF zMX?QD2RClANAd)#qeX!>YQR)I+!dz$@w(vVkT>Jl{ewf}Gb<%oN0U;3;ES0^G4dLN z&5~G7gfQ{5(MZpS5tGdaWGG#xdXTW0000~n3 delta 817 zcmV-11J3-Q3C9MIBYy)9Nkl2t;B!7Yf2JR~l8}WA?@%Gfv zj~$h2@KXd$4>+cXNWH*ftn~$_?%@Z)g8arAgN6yPsub$Ij`leq&w-7Zq~q z9uymS9uAo{Eq}hb110FiUA@n#sj(OEK3J3i;_^cI)o?ISqZBE_kH6|O+#>y-NwHG7 zEQ5=|`9uH9inRd4Bu_TWo63R{0e5p-64~WKeNNp#VX^Ym&Ln#!3vL9Y)up7?xiQVc z8!V~ITwqMw2=rMxPiQu{;bR#}%cN5LNVUp>69I;$YJbp#V4Y^l4~~}{hF=_SIId7= zop$sF9NGw z`Ex7`+MlUCz%tqZu)~psU6#=XEO8=$CDQami^u@e=!~WIHBBoNhZQFGuS?_=3R~VB zT5^e#RDYD>V$~d$lS@`9!9H(|xLs{jY6}p6Fo@_5Hh4;Xj63ZwG_9d;cko2!IKY4LpH)%MH#P87Ls)WsT!0jwm4H z)d@}ojBDx`?3_)U@1luIp-;CM5o{Uu`CS~QLKvhk3K3h3EtiWo(D(CU4mn<6dssci vvnmSs7Zd{WrVx-fg@C*%1msO2AaDKvnj&35VToW)00000NkvXXu0mjf=?Hyv diff --git a/assets/hicolor/scalable/status/inputflow-tray-attention.svg b/assets/hicolor/scalable/status/inputflow-tray-attention.svg index c7c7495..94b697d 100644 --- a/assets/hicolor/scalable/status/inputflow-tray-attention.svg +++ b/assets/hicolor/scalable/status/inputflow-tray-attention.svg @@ -1,29 +1,28 @@ - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/hicolor/scalable/status/inputflow-tray-busy.svg b/assets/hicolor/scalable/status/inputflow-tray-busy.svg index e45873d..7fd9f3e 100644 --- a/assets/hicolor/scalable/status/inputflow-tray-busy.svg +++ b/assets/hicolor/scalable/status/inputflow-tray-busy.svg @@ -1,29 +1,28 @@ - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/hicolor/scalable/status/inputflow-tray-offline.svg b/assets/hicolor/scalable/status/inputflow-tray-offline.svg index 96913be..1a3e23a 100644 --- a/assets/hicolor/scalable/status/inputflow-tray-offline.svg +++ b/assets/hicolor/scalable/status/inputflow-tray-offline.svg @@ -1,28 +1,27 @@ - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/hicolor/scalable/status/inputflow-tray.svg b/assets/hicolor/scalable/status/inputflow-tray.svg index c6301cb..8b9d71a 100644 --- a/assets/hicolor/scalable/status/inputflow-tray.svg +++ b/assets/hicolor/scalable/status/inputflow-tray.svg @@ -1,24 +1,23 @@ - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/icons/inputflow-tray-attention.svg b/assets/icons/inputflow-tray-attention.svg index c7c7495..94b697d 100644 --- a/assets/icons/inputflow-tray-attention.svg +++ b/assets/icons/inputflow-tray-attention.svg @@ -1,29 +1,28 @@ - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/icons/inputflow-tray-busy.svg b/assets/icons/inputflow-tray-busy.svg index e45873d..7fd9f3e 100644 --- a/assets/icons/inputflow-tray-busy.svg +++ b/assets/icons/inputflow-tray-busy.svg @@ -1,29 +1,28 @@ - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/icons/inputflow-tray-offline.svg b/assets/icons/inputflow-tray-offline.svg index 96913be..1a3e23a 100644 --- a/assets/icons/inputflow-tray-offline.svg +++ b/assets/icons/inputflow-tray-offline.svg @@ -1,28 +1,27 @@ - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/icons/inputflow-tray.svg b/assets/icons/inputflow-tray.svg index c6301cb..8b9d71a 100644 --- a/assets/icons/inputflow-tray.svg +++ b/assets/icons/inputflow-tray.svg @@ -1,24 +1,23 @@ - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/android.md b/docs/android.md index bfbae6a..a71a1cc 100644 --- a/docs/android.md +++ b/docs/android.md @@ -101,9 +101,14 @@ or root backend is selected — that includes secure fields. The shared - The relay **refuses to start** and `android-pair` **refuses to emit a URI** when the secret is weak (`< 16` characters or too little variety). +- Relay protocol v2 derives separate send/receive keys from each random + challenge and protects every post-authentication frame with AES-256-GCM. + Sequence numbers reject replayed, reordered, or tampered input frames. - Generate a strong (256-bit) secret with `android-pair --generate`. - Keep the relay on a trusted LAN/VPN; never expose its port to the internet. - Treat the pairing URI/QR like a password — it contains the secret. +- Android builds from before protocol v2 cannot connect to this release; update + both peers together. Existing strong pairing secrets remain valid. ## Current limitations diff --git a/docs/public-repository-audit.md b/docs/public-repository-audit.md new file mode 100644 index 0000000..df3e72c --- /dev/null +++ b/docs/public-repository-audit.md @@ -0,0 +1,65 @@ +# Public Repository Privacy Audit + +Assessment date: 2026-07-28 + +## Scope + +The assessment covered: + +- every tracked file; +- every untracked file not excluded by `.gitignore`; +- all 58 commits reachable from local Git references; +- source, documentation, tests, workflow, packaging, and Android files; +- image metadata under the public asset and Android resource trees; and +- Git author metadata. + +Build directories and other intentionally ignored local artifacts are not +publishable repository content and were excluded from content-pattern checks. + +## Checks + +- Gitleaks scan of Git history with findings redacted. +- Gitleaks scan of the current working tree with findings redacted. +- Repository-specific checks for credential/key filenames, private-key + material, personal Linux and Windows profile paths, hard-coded runtime user + IDs, non-placeholder email addresses, private/link-local addresses, and MAC + addresses outside test fixtures. +- Search for known local usernames and hostnames without printing matched + values. +- EXIF and image-metadata inspection for GPS, author, owner, camera/device, + serial-number, location, and user-comment fields. +- Validation of README local links and table-of-contents anchors. + +## Result + +- No credentials or private keys were found in the working tree or Git + history. +- No known local username or hostname was found in publishable file content. +- No personal home-directory or Windows profile path was found. +- No private/link-local network address or production MAC address was found. +- No risky personal, location, or device metadata was found in public images. +- Documentation addresses, example users, placeholder email domains, protocol + constants, and test-only fixtures were reviewed as non-personal data. + +Git commit objects retain contributor names and author email metadata as normal +project attribution. Some historical authors used direct addresses rather than +GitHub noreply addresses. Those fields are not application secrets or device +data and were not rewritten because changing them would alter published +history and attribution. Contributors who prefer pseudonymous metadata should +configure a verified GitHub noreply address before committing. + +## Preventive controls + +- `scripts/audit-public-repo.py` blocks common personal/device data and + sensitive artifacts. +- `scripts/release-gate.sh` runs the audit before building a release. +- `.gitignore` excludes local configuration, credentials, diagnostics, + signing material, build output, and analysis caches. +- The pull-request template requires confirmation that secrets, real + addresses, and personal information were not added. +- Diagnostics remain local by default and redact likely personal, device, + address, secret, and input-event data. + +History rewriting is intentionally not performed automatically. If a real +credential is ever discovered, revoke or rotate it first, then coordinate any +history rewrite with maintainers, forks, and existing clones. diff --git a/docs/release-checklist.md b/docs/release-checklist.md new file mode 100644 index 0000000..409e63d --- /dev/null +++ b/docs/release-checklist.md @@ -0,0 +1,23 @@ +# Stable release checklist + +Run `scripts/release-gate.sh` from a clean release candidate. +The gate stages the portable archive, unsigned Android release APK, changelog, +CycloneDX SBOM, provenance statement, and `SHA256SUMS` under +`build-release-gate/release/`. + +- All required CI jobs and the local release gate pass. +- No unresolved P0/P1 bugs or known critical/high vulnerabilities remain. +- Medium security findings have an owner, disposition, and target release. +- Fedora RPM and portable archive pass clean install, upgrade, launch, and + removal smoke tests. +- GNOME/KDE on X11/Wayland and Android API 26/current are smoke-tested. +- Pairing, reconnect, suspend/resume, tray lifecycle, settings persistence, + clipboard, topology, notification sync, and permission revocation pass. +- Diagnostics are previewed and confirmed free of secrets and personal/device + identifiers. +- Release archives, APK, checksums, SBOM/provenance, changelog, compatibility, + privacy, and security documentation are published together. +- The staged Android APK is unsigned by design. Sign it with the protected + release key, verify the signature, and replace the unsigned artifact before + publication. +- The release candidate completes a seven-day reconnect and resource-usage soak. diff --git a/docs/release-readiness-report.md b/docs/release-readiness-report.md new file mode 100644 index 0000000..ee2b8e2 --- /dev/null +++ b/docs/release-readiness-report.md @@ -0,0 +1,63 @@ +# InputFlow 0.2.0 Release Readiness Report + +Assessment date: 2026-07-28 + +## Outcome + +The current source is a release candidate. The automated release gate passes, +release artifacts are reproducible from the working tree, and generated +checksums verify. Publication still requires the external checks listed below. + +## Implemented controls + +- Android relay protocol v2 authenticates sessions with HMAC and protects + post-authentication traffic with directional AES-256-GCM keys, ordered + sequence numbers, replay rejection, frame limits, and authentication + timeouts. +- Android pairing secrets use Android Keystore-backed AES-GCM storage with + migration away from legacy plaintext preferences. +- Configuration and state use atomic, owner-only file writes and reject + symbolic-link targets. +- Diagnostics are local-only by default, redact secrets and device/input + metadata, and require explicit options for journal or network data. +- GUI-launched helpers use fixed argument vectors rather than shell command + strings. +- Android backups exclude protected application data and lock-screen + notifications avoid endpoint details. +- Linux and Android interfaces share an accessible InputFlow visual system, + masked secret fields, descriptive controls, day/night palettes, and + responsive settings surfaces. + +## Verification evidence + +- Nine-stage `scripts/release-gate.sh`: passed. +- Linux CTest suite: 18/18 passed. +- Diagnostics privacy regression: passed. +- Portable archive checksum and extracted-binary smoke test: passed. +- Android release assembly and JVM unit tests: passed. +- Android release lint: 0 errors, 23 non-blocking maintenance warnings. +- Release archive, unsigned APK, SBOM, and provenance checksums: passed. +- Source whitespace check: passed. +- Unsafe shell/process API scan: no matches. +- GTK accessibility inspection: four tabs exposed, status named, secrets + masked, and settings actions reachable. + +## Publication requirements + +- Sign the Android APK with the protected production signing key, run + `apksigner verify`, and archive signing evidence. The generated APK is + intentionally unsigned. +- Run the checklist on representative GNOME and KDE systems across X11 and + Wayland, plus Android API 26, a current Android release, and at least one + OEM device. No Android emulator or physical device was available for this + assessment. +- Perform a multi-device seven-day soak covering reconnects, sleep/wake, + clipboard, topology transitions, notifications, and service restarts. +- Run the gate from a reviewed clean commit in CI. Provenance generated from + the current working tree correctly records that the tree is dirty. +- Review and intentionally accept or fix the remaining Android lint + maintenance warnings before the final store submission. + +No finite test program can prove an application is perfect or vulnerability +free. Release approval should be based on the passing evidence above plus the +external device matrix, soak, signing, and clean-commit checks. diff --git a/docs/security-privacy.md b/docs/security-privacy.md new file mode 100644 index 0000000..b6cdadb --- /dev/null +++ b/docs/security-privacy.md @@ -0,0 +1,45 @@ +# Security and privacy model + +InputFlow is a remote-input utility for trusted LANs and private VPNs. The +PowerToys Mouse Without Borders compatibility protocol is encrypted with +AES-256-CBC but does not provide modern authenticated integrity. Do not expose +its ports directly to the public internet. + +The native Linux-to-Android relay uses a separate protocol. A random challenge +and the pairing secret derive directional session keys; all post-authentication +frames use AES-256-GCM with ordered sequence numbers. This protects keyboard, +pointer, notification, and topology data from passive capture, tampering, and +replay on the local network. + +## Data handled + +| Data | Purpose | Storage and sharing | +| --- | --- | --- | +| Pairing secrets | Authenticate Linux, Windows, and Android peers | Linux Secret Service or owner-only files; Android Keystore encryption; never diagnostic output | +| Hostnames and IP addresses | Connect to and display peers | Local config/state only; redacted from support bundles | +| Keyboard, pointer, and clipboard data | Deliver the requested control stream | Processed in memory; never telemetry or diagnostics | +| Android notification content | Optional notification synchronization | In memory during the active relay; excluded from diagnostics | +| Monitor geometry and topology | Route pointer movement | Local config/state; diagnostics report only non-identifying capability summaries | +| OS/session/package details | Troubleshooting | Local, user-reviewed diagnostic bundle only | + +InputFlow has no telemetry or automatic crash/diagnostic upload. Diagnostic +bundles remain on the device, omit journal and network details by default, and +include a machine-readable consent manifest. Users must review and attach a +bundle manually. + +## Privileged boundaries + +- `/dev/uinput`, Android Accessibility, Shizuku, root injection, notification + access, and IME access are separate user/admin grants. +- Native Android injection requires an additional in-app consent toggle. +- Revoking a platform permission must disable the corresponding feature without + weakening another boundary. +- Config and state writes are atomic, owner-only, and reject symlink targets. + +Security-sensitive logs must describe state transitions without pairing +secrets, clipboard/notification contents, device models, usernames, hostnames, +or network addresses. + +Input-event metadata is logged only when an explicit debug-input option is +enabled. Do not enable that option during normal use, and review journal output +before sharing it. diff --git a/packaging/portable/README.md b/packaging/portable/README.md new file mode 100644 index 0000000..96211c7 --- /dev/null +++ b/packaging/portable/README.md @@ -0,0 +1,17 @@ +# Portable Linux archive + +The `.tar.gz` release is a relocatable user-space bundle. Extract it, then run: + +```bash +./inputflow-*/bin/inputflow-controller +``` + +The wrapper locates the bundled client and controller without modifying the +system. Input injection still requires administrator-approved `/dev/uinput` +access. Files under `share/inputflow/system-integration/` are reference copies; +review and install them through the distribution package or an administrator +rather than copying them automatically. + +The RPM remains the supported system-integrated installation for Fedora. The +portable archive is supported for tested Ubuntu/Fedora desktop sessions but +does not register autostart, udev, sysusers, or desktop menu entries itself. diff --git a/packaging/portable/inputflow-controller b/packaging/portable/inputflow-controller new file mode 100755 index 0000000..a08059d --- /dev/null +++ b/packaging/portable/inputflow-controller @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +PACKAGE_ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" +CONTROLLER="$PACKAGE_ROOT/libexec/inputflow/mwb-desktop-ui.sh" + +if [[ ! -x "$CONTROLLER" ]]; then + echo "InputFlow controller is missing: $CONTROLLER" >&2 + exit 1 +fi + +PATH="$PACKAGE_ROOT/bin:$PATH" exec "$CONTROLLER" "${@:-menu}" diff --git a/scripts/audit-public-repo.py b/scripts/audit-public-repo.py new file mode 100644 index 0000000..3dded02 --- /dev/null +++ b/scripts/audit-public-repo.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""Reject files and content that should not be published in the repository.""" + +from __future__ import annotations + +import argparse +import ipaddress +import pathlib +import re +import subprocess +import sys + + +ROOT = pathlib.Path(__file__).resolve().parents[1] + +SENSITIVE_FILE = re.compile( + r"(^|/)(?:" + r"\.env(?:\.|$)|" + r"id_(?:rsa|ed25519)|" + r"config\.ini$|" + r"inputflow-diagnostics-.*\.(?:tar\.gz|zip)$|" + r".*\.(?:pem|key|p12|pfx|jks|keystore|mobileprovision)$" + r")", + re.IGNORECASE, +) +PRIVATE_KEY = re.compile(r"BEGIN (?:[A-Z ]+ )?PRIVATE KEY") +HOME_PATH = re.compile( + r"(? list[str]: + result = subprocess.run( + ["git", *arguments, "-z"], + cwd=ROOT, + check=True, + stdout=subprocess.PIPE, + ) + return [value for value in result.stdout.decode().split("\0") if value] + + +def publishable_paths(include_untracked: bool) -> list[str]: + paths = set(git_paths(["ls-files"])) + if include_untracked: + paths.update(git_paths(["ls-files", "--others", "--exclude-standard"])) + return sorted(paths) + + +def is_disallowed_ip(value: str) -> bool: + try: + address = ipaddress.ip_address(value) + except ValueError: + return False + if any(address in network for network in DOCUMENTATION_NETWORKS): + return False + return address.is_private or address.is_link_local + + +def audit_file(relative: str) -> list[str]: + problems: list[str] = [] + if SENSITIVE_FILE.search(relative): + problems.append("sensitive filename") + + path = ROOT / relative + if not path.is_file() or path.is_symlink(): + return problems + + data = path.read_bytes() + if b"\0" in data: + return problems + text = data.decode("utf-8", errors="replace") + + if PRIVATE_KEY.search(text): + problems.append("private-key material") + + for match in HOME_PATH.finditer(text): + if match.group(1).lower() not in PLACEHOLDER_USERS: + problems.append("personal home-directory path") + break + + for match in WINDOWS_PROFILE.finditer(text): + if match.group(1).lower() not in PLACEHOLDER_USERS: + problems.append("personal Windows profile path") + break + + if RUNTIME_UID.search(text): + problems.append("hard-coded desktop runtime UID") + + for match in EMAIL.finditer(text): + domain = match.group(1).rsplit("@", 1)[1].lower() + if domain not in PLACEHOLDER_EMAIL_DOMAINS: + problems.append("personal email address") + break + + if any(is_disallowed_ip(value) for value in IPV4.findall(text)): + problems.append("private or link-local IP address") + + if not relative.startswith("tests/") and MAC.search(text): + problems.append("MAC address") + + return problems + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Audit publishable repository files for secrets and personal/device data." + ) + parser.add_argument( + "--include-untracked", + action="store_true", + help="also inspect untracked files not excluded by .gitignore", + ) + args = parser.parse_args() + + findings: list[tuple[str, str]] = [] + paths = publishable_paths(args.include_untracked) + for relative in paths: + for problem in audit_file(relative): + findings.append((relative, problem)) + + if findings: + for relative, problem in findings: + print(f"{relative}: {problem}", file=sys.stderr) + print( + f"public repository audit failed with {len(findings)} finding(s)", + file=sys.stderr, + ) + return 1 + + scope = "tracked and untracked" if args.include_untracked else "tracked" + print(f"public repository audit passed: {len(paths)} {scope} files") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/generate-release-metadata.py b/scripts/generate-release-metadata.py new file mode 100644 index 0000000..530781b --- /dev/null +++ b/scripts/generate-release-metadata.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +"""Create a CycloneDX SBOM, provenance statement, and publishable artifact set.""" + +from __future__ import annotations + +import argparse +import datetime as dt +import hashlib +import json +import pathlib +import platform +import re +import shutil +import subprocess + + +def sha256(path: pathlib.Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def command_output(arguments: list[str], cwd: pathlib.Path) -> str: + result = subprocess.run( + arguments, + cwd=cwd, + check=False, + capture_output=True, + text=True, + timeout=20, + ) + return result.stdout.strip() if result.returncode == 0 else "" + + +def project_version(repo: pathlib.Path) -> str: + cmake = (repo / "CMakeLists.txt").read_text(encoding="utf-8") + match = re.search(r"project\([^)]*\bVERSION\s+([0-9.]+)", cmake, re.DOTALL) + if not match: + raise SystemExit("Unable to determine project version") + return match.group(1) + + +def gradle_components(repo: pathlib.Path) -> list[dict[str, object]]: + gradle = (repo / "android/app/build.gradle").read_text(encoding="utf-8") + dependencies: list[dict[str, object]] = [] + pattern = re.compile( + r'^\s*(?:implementation|testImplementation)\s+["\']' + r"([^:'\"]+):([^:'\"]+):([^'\"]+)[\"']", + re.MULTILINE, + ) + for group, name, version in pattern.findall(gradle): + dependencies.append( + { + "type": "library", + "group": group, + "name": name, + "version": version, + "purl": f"pkg:maven/{group}/{name}@{version}", + } + ) + return dependencies + + +def native_components(binary: pathlib.Path, repo: pathlib.Path) -> list[dict[str, object]]: + output = command_output(["ldd", str(binary)], repo) + components: list[dict[str, object]] = [] + seen: set[str] = set() + for line in output.splitlines(): + match = re.match(r"\s*([^\s]+)\s+=>\s+(/[^\s]+)", line) + if not match: + continue + name, resolved = match.groups() + library = pathlib.Path(resolved) + if name in seen or not library.is_file(): + continue + seen.add(name) + components.append( + { + "type": "library", + "name": name, + "hashes": [{"alg": "SHA-256", "content": sha256(library)}], + "properties": [ + {"name": "inputflow:discovery", "value": "runtime-linker"} + ], + } + ) + return components + + +def copy_artifact(source: pathlib.Path, destination: pathlib.Path) -> pathlib.Path: + target = destination / source.name + shutil.copy2(source, target) + return target + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--build-dir", default="build") + parser.add_argument("--android-apk") + args = parser.parse_args() + + repo = pathlib.Path(__file__).resolve().parent.parent + build_dir = (repo / args.build_dir).resolve() + release_dir = build_dir / "release" + release_dir.mkdir(parents=True, exist_ok=True) + version = project_version(repo) + + archives = sorted(build_dir.glob("inputflow-*.tar.gz")) + if len(archives) != 1: + raise SystemExit("Expected exactly one portable archive") + apk_source = ( + pathlib.Path(args.android_apk).resolve() + if args.android_apk + else repo / "android/app/build/outputs/apk/release/app-release-unsigned.apk" + ) + if not apk_source.is_file(): + raise SystemExit(f"Android release APK is missing: {apk_source}") + + copied = [ + copy_artifact(archives[0], release_dir), + copy_artifact(apk_source, release_dir), + copy_artifact(repo / "CHANGELOG.md", release_dir), + ] + copied[1].rename(release_dir / f"inputflow-android-{version}-unsigned.apk") + copied[1] = release_dir / f"inputflow-android-{version}-unsigned.apk" + + timestamp = dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat() + native_binary = build_dir / "mwb_client" + components = gradle_components(repo) + if native_binary.is_file(): + components.extend(native_components(native_binary, repo)) + + sbom_path = release_dir / "inputflow-sbom.cdx.json" + sbom = { + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "version": 1, + "metadata": { + "timestamp": timestamp, + "component": { + "type": "application", + "name": "InputFlow", + "version": version, + }, + }, + "components": components, + } + sbom_path.write_text(json.dumps(sbom, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + commit = command_output(["git", "rev-parse", "HEAD"], repo) + dirty = bool(command_output(["git", "status", "--porcelain"], repo)) + subject_paths = copied[:2] + [sbom_path] + provenance_path = release_dir / "inputflow-provenance.json" + provenance = { + "_type": "https://in-toto.io/Statement/v1", + "subject": [ + { + "name": path.name, + "digest": {"sha256": sha256(path)}, + } + for path in subject_paths + ], + "predicateType": "https://slsa.dev/provenance/v1", + "predicate": { + "buildDefinition": { + "buildType": "inputflow-local-release-gate/v1", + "externalParameters": { + "version": version, + "linuxBuildType": "Release", + "androidArtifactSigned": False, + }, + "internalParameters": {"workingTreeDirty": dirty}, + "resolvedDependencies": ( + [{"uri": "git+local", "digest": {"gitCommit": commit}}] + if commit + else [] + ), + }, + "runDetails": { + "builder": {"id": "inputflow-local-release-gate"}, + "metadata": { + "invocationId": f"{platform.system().lower()}-{commit[:12] or 'unknown'}", + "startedOn": timestamp, + "finishedOn": timestamp, + }, + }, + }, + } + provenance_path.write_text( + json.dumps(provenance, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + checksum_paths = copied[:2] + [sbom_path, provenance_path] + checksum_text = "".join(f"{sha256(path)} {path.name}\n" for path in checksum_paths) + (release_dir / "SHA256SUMS").write_text(checksum_text, encoding="utf-8") + print(f"release metadata generated in {release_dir}") + print("Android APK is unsigned; sign and verify it before publication.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/inputflow-diagnostics-bundle.sh b/scripts/inputflow-diagnostics-bundle.sh index 0206407..8249ce1 100755 --- a/scripts/inputflow-diagnostics-bundle.sh +++ b/scripts/inputflow-diagnostics-bundle.sh @@ -6,7 +6,7 @@ SCRIPT_NAME="$(basename "$0")" usage() { cat <|SECURITY_KEY|security key)[^[:space:]]*/\1[REDACTED]/Ig' \ + -e 's#/(home|Users)/[^/[:space:]]+#/\1/[REDACTED_USER]#g' \ + -e 's/^([[:space:]]*[^#;[:space:]]*(host|peer|machine|device|address|username|user_name)[^=]*[[:space:]]*=[[:space:]]*).*/\1[REDACTED]/I' \ + -e 's/^([[:space:]]*(User|Host|Hostname|Repository|config_path|state_path|Static hostname)[=:][[:space:]]*).*/\1[REDACTED]/I' \ + -e 's/^((USER|LOGNAME|HOSTNAME)=).*/\1[REDACTED]/' \ + -e 's/([[:xdigit:]]{2}:){5}[[:xdigit:]]{2}/[REDACTED_MAC]/g' \ + -e 's/([0-9]{1,3}\.){3}[0-9]{1,3}/[REDACTED_IP]/g' \ + -e 's/(^|[[:space:]])(vk|key|flags|wParam|mouseData|x|y)=(-?0x[[:xdigit:]]+|-?[0-9]+)/\1\2=[REDACTED_INPUT]/Ig' \ -e 's/[A-Fa-f0-9]{32,}/[REDACTED_HEX]/g' } @@ -118,6 +145,16 @@ safe_run() { } 2>&1 | redact_stream >"$output_file" } +safe_run_bounded() { + local output_file="$1" + shift + if have timeout; then + safe_run "$output_file" timeout --signal=TERM --kill-after=2s 10s "$@" + else + safe_run "$output_file" "$@" + fi +} + safe_shell() { local output_file="$1" local description="$2" @@ -143,7 +180,7 @@ redacted_copy_or_note() { else printf 'present=no\n' fi - } >"$output_file" + } | redact_stream >"$output_file" } json_escape() { @@ -232,7 +269,7 @@ write_json_summary() { printf ' "input": {"uinput_present": '; json_bool "$uinput_present"; printf ', "uinput_writable": '; json_bool "$uinput_writable"; printf ', "uinput_module_loaded": '; json_bool "$uinput_module"; printf '},\n' printf ' "tools": {"wl_copy": '; have wl-copy && printf true || printf false; printf ', "wl_paste": '; have wl-paste && printf true || printf false; printf ', "xclip": '; have xclip && printf true || printf false; printf ', "xsel": '; have xsel && printf true || printf false; printf ', "secret_tool": '; have secret-tool && printf true || printf false; printf ', "systemctl": '; have systemctl && printf true || printf false; printf ', "journalctl": '; have journalctl && printf true || printf false; printf ', "ip": '; have ip && printf true || printf false; printf ', "ss": '; have ss && printf true || printf false; printf '}\n' printf '}\n' - } >"$output_file" + } | redact_stream >"$output_file" } write_config_summary() { @@ -300,24 +337,47 @@ modified=%y' "$STATE_PATH" 2>/dev/null || true printf 'peer_lines=%s\n' "${peer_lines:-0}" printf '\n[redacted state]\n' redact_stream <"$STATE_PATH" - } >"$output_file" + } | redact_stream >"$output_file" } write_manifest() { - cat >"$BUNDLE_DIR/README.txt" </dev/null || date) -Host: $(hostname 2>/dev/null || printf 'unknown') -User: $(id -un 2>/dev/null || printf 'unknown') -Repository: $REPO_ROOT +Host: [REDACTED] +User: [REDACTED] +Repository: [REDACTED] + +This bundle remains on this device until you choose to share it. Security keys, +tokens, passwords, secret IDs, home-directory usernames, network addresses, and +hardware addresses are redacted by best effort. Review every file before sharing. + +Recent service journal included: $INCLUDE_JOURNAL +Network details included: $INCLUDE_NETWORK +EOF + } | redact_stream >"$BUNDLE_DIR/README.txt" +} -This bundle is redacted by best effort before files are written. Security keys, -tokens, passwords, key file values, secret IDs, and long hex strings are replaced. -Review before sharing outside trusted support channels. +write_consent_manifest() { + cat >"$BUNDLE_DIR/consent.json" </dev/null || date -uname -a +uname -srmo printf "\n[/etc/os-release]\n" test -r /etc/os-release && cat /etc/os-release printf "\n[session]\n" -id +printf "uid=%s\ngid=%s\ngroups=%s\n" "$(id -u)" "$(id -g)" "$(id -G)" printf "SHELL=%s\nUSER=%s\nLOGNAME=%s\nXDG_SESSION_TYPE=%s\nXDG_CURRENT_DESKTOP=%s\nDESKTOP_SESSION=%s\nDISPLAY=%s\nWAYLAND_DISPLAY=%s\n" "${SHELL:-}" "${USER:-}" "${LOGNAME:-}" "${XDG_SESSION_TYPE:-}" "${XDG_CURRENT_DESKTOP:-}" "${DESKTOP_SESSION:-}" "${DISPLAY:-}" "${WAYLAND_DISPLAY:-}" if command -v loginctl >/dev/null 2>&1 && test -n "${XDG_SESSION_ID:-}"; then loginctl show-session "$XDG_SESSION_ID" --no-pager 2>/dev/null @@ -342,21 +402,23 @@ if ! command -v systemctl >/dev/null 2>&1; then echo "systemctl not found" exit 0 fi -systemctl --user --no-pager status mwb-client.service inputflow.service 2>&1 +systemctl --user --no-pager --lines=0 status mwb-client.service inputflow.service 2>&1 printf "\n[known matching units]\n" systemctl --user --no-pager list-units "mwb*" "inputflow*" 2>&1 printf "\n[unit files]\n" systemctl --user --no-pager list-unit-files "mwb*" "inputflow*" 2>&1 ' -safe_shell "$BUNDLE_DIR/journal-user-recent.txt" "collect recent user journal logs" ' +if [[ "$INCLUDE_JOURNAL" -eq 1 ]]; then + safe_shell "$BUNDLE_DIR/journal-user-recent.txt" "collect recent user journal logs" ' set +e if ! command -v journalctl >/dev/null 2>&1; then echo "journalctl not found" exit 0 fi -journalctl --user --no-pager --since "2 hours ago" -u mwb-client.service -u inputflow.service -n 300 2>&1 +journalctl --user --no-pager --output=cat --since "2 hours ago" -u mwb-client.service -u inputflow.service -n 300 2>&1 ' +fi safe_shell "$BUNDLE_DIR/uinput-state.txt" "collect uinput state" ' set +e @@ -364,7 +426,7 @@ printf "[device]\n" ls -l /dev/uinput 2>&1 stat /dev/uinput 2>&1 printf "\n[user groups]\n" -id +printf "uid=%s\ngid=%s\ngroups=%s\n" "$(id -u)" "$(id -g)" "$(id -G)" printf "\n[modules]\n" grep "^uinput " /proc/modules 2>/dev/null || true lsmod 2>/dev/null | grep -E "^uinput|uinput" || true @@ -372,7 +434,8 @@ printf "\n[udev rules]\n" find /etc/udev/rules.d /usr/lib/udev/rules.d /lib/udev/rules.d -maxdepth 1 \( -iname "*uinput*" -o -iname "*inputflow*" -o -iname "*mwb*" \) -print -exec sh -c '"'"'for f; do echo "--- $f"; sed -n "1,120p" "$f"; done'"'"' sh {} + 2>/dev/null ' -safe_shell "$BUNDLE_DIR/network-hints.txt" "collect network hints" ' +if [[ "$INCLUDE_NETWORK" -eq 1 ]]; then + safe_shell "$BUNDLE_DIR/network-hints.txt" "collect network hints" ' set +e hostnamectl 2>/dev/null || hostname 2>/dev/null printf "\n[addresses]\n" @@ -387,9 +450,8 @@ if command -v ss >/dev/null 2>&1; then else echo "ss not found" fi -printf "\n[host resolution]\n" -getent hosts "$(hostname)" 2>/dev/null || true ' +fi safe_shell "$BUNDLE_DIR/package-build-info.txt" "collect package and build info" " set +e @@ -404,7 +466,6 @@ for f in '$REPO_ROOT/build/mwb_client' '$REPO_ROOT/build/mwb_tray'; do if test -e \"\$f\"; then ls -l \"\$f\" file \"\$f\" 2>/dev/null || true - \"\$f\" --version 2>&1 || true else echo \"missing: \$f\" fi @@ -423,15 +484,16 @@ fi " if [[ -x "$REPO_ROOT/build/mwb_client" ]]; then - safe_run "$BUNDLE_DIR/mwb-client-doctor.txt" "$REPO_ROOT/build/mwb_client" doctor --config "$CONFIG_PATH" --state "$STATE_PATH" + safe_run_bounded "$BUNDLE_DIR/mwb-client-doctor.txt" "$REPO_ROOT/build/mwb_client" doctor --config "$CONFIG_PATH" --state "$STATE_PATH" elif command -v mwb_client >/dev/null 2>&1; then - safe_run "$BUNDLE_DIR/mwb-client-doctor.txt" mwb_client doctor --config "$CONFIG_PATH" --state "$STATE_PATH" + safe_run_bounded "$BUNDLE_DIR/mwb-client-doctor.txt" mwb_client doctor --config "$CONFIG_PATH" --state "$STATE_PATH" else printf 'mwb_client executable not found at %s or in PATH\n' "$REPO_ROOT/build/mwb_client" >"$BUNDLE_DIR/mwb-client-doctor.txt" fi FINAL_PATH="$BUNDLE_DIR" -if have tar; then +find "$BUNDLE_DIR" -type f -exec chmod 600 {} + 2>/dev/null || true +if [[ "$PREVIEW" -eq 0 ]] && have tar; then ARCHIVE_PATH="$OUTPUT_DIR/$BUNDLE_NAME.tar.gz" if tar -czf "$ARCHIVE_PATH" -C "$OUTPUT_DIR" "$BUNDLE_NAME" >/dev/null 2>&1; then rm -rf "$BUNDLE_DIR" @@ -439,7 +501,7 @@ if have tar; then else log "warning: tar failed; leaving bundle directory unarchived" fi -else +elif [[ "$PREVIEW" -eq 0 ]]; then log "warning: tar not found; leaving bundle directory unarchived" fi diff --git a/scripts/release-gate.sh b/scripts/release-gate.sh new file mode 100755 index 0000000..9f1d3d3 --- /dev/null +++ b/scripts/release-gate.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +BUILD_DIR="${INPUTFLOW_RELEASE_BUILD_DIR:-$REPO_ROOT/build-release-gate}" + +cd "$REPO_ROOT" + +echo "[1/9] Repository hygiene" +git diff --check +python3 scripts/audit-public-repo.py --include-untracked +if git ls-files | grep -E '(^|/)(\.env($|\.)|.*\.(pem|p12|pfx|key)|inputflow-diagnostics-.*\.tar\.gz)$'; then + echo "release gate: tracked secret or diagnostics artifact detected" >&2 + exit 1 +fi +if git grep -nE -- 'BEGIN ([A-Z ]+ )?PRIVATE KEY|AKIA[0-9A-Z]{16}' -- \ + ':!tests/test_diagnostics_privacy.sh'; then + echo "release gate: possible embedded credential detected" >&2 + exit 1 +fi + +echo "[2/9] Script and privacy checks" +bash -n mwb-desktop-ui.sh scripts/*.sh tests/*.sh +PYTHONPYCACHEPREFIX="$BUILD_DIR/pycache" python3 -m py_compile scripts/generate-release-metadata.py +tests/test_diagnostics_privacy.sh scripts/inputflow-diagnostics-bundle.sh + +echo "[3/9] Packaging metadata" +scripts/validate-rpm-packaging.sh + +echo "[4/9] Configure release build" +cmake -S . -B "$BUILD_DIR" -DCMAKE_BUILD_TYPE=Release + +echo "[5/9] Build Linux targets" +cmake --build "$BUILD_DIR" --parallel + +echo "[6/9] Linux regression suite" +ctest --test-dir "$BUILD_DIR" --output-on-failure + +echo "[7/9] Portable archive" +cmake --build "$BUILD_DIR" --target package +PORTABLE_ARCHIVE="$(find "$BUILD_DIR" -maxdepth 1 -name 'inputflow-*.tar.gz' -print -quit)" +test -n "$PORTABLE_ARCHIVE" +test -n "$(find "$BUILD_DIR" -maxdepth 1 -name 'inputflow-*.tar.gz.sha256' -print -quit)" +tests/test_portable_archive.sh "$PORTABLE_ARCHIVE" + +echo "[8/9] Android release build, tests, and lint" +if [[ "${INPUTFLOW_SKIP_ANDROID:-0}" == "1" ]]; then + echo "Android gate explicitly skipped" +elif [[ -n "${JAVA_HOME:-}" && ! -x "${JAVA_HOME}/bin/java" ]]; then + env -u JAVA_HOME ./android/gradlew -p android \ + :app:assembleRelease :app:testDebugUnitTest :app:lintRelease \ + --no-daemon --stacktrace +else + ./android/gradlew -p android \ + :app:assembleRelease :app:testDebugUnitTest :app:lintRelease \ + --no-daemon --stacktrace +fi + +echo "[9/9] SBOM, provenance, and release checksums" +if [[ "${INPUTFLOW_SKIP_ANDROID:-0}" == "1" ]]; then + echo "Release metadata skipped because the Android artifact was skipped" +else + python3 scripts/generate-release-metadata.py \ + --build-dir "$BUILD_DIR" \ + --android-apk android/app/build/outputs/apk/release/app-release-unsigned.apk +fi + +echo "InputFlow release gate passed" diff --git a/scripts/validate-rpm-packaging.sh b/scripts/validate-rpm-packaging.sh index ad83b17..cacf2d3 100755 --- a/scripts/validate-rpm-packaging.sh +++ b/scripts/validate-rpm-packaging.sh @@ -83,7 +83,7 @@ grep -Eq '^Terminal=false$' packaging/usr/share/applications/inputflow.desktop | grep -Eq '^DIAGNOSTICS_BUNDLE_SCRIPT="\$SCRIPT_DIR/scripts/inputflow-diagnostics-bundle\.sh"$' mwb-desktop-ui.sh || fail "desktop UI must resolve diagnostics bundle next to the packaged controller" bash -n scripts/inputflow-diagnostics-bundle.sh || fail "diagnostics bundle script must pass bash syntax checks" grep -Eq 'doctor --config' scripts/inputflow-diagnostics-bundle.sh || fail "diagnostics bundle must collect client doctor output" -grep -Eq 'systemctl --user --no-pager status mwb-client\.service' scripts/inputflow-diagnostics-bundle.sh || fail "diagnostics bundle must collect user service status" +grep -Eq 'systemctl --user --no-pager( --[^ ]+)* status mwb-client\.service' scripts/inputflow-diagnostics-bundle.sh || fail "diagnostics bundle must collect user service status" grep -Eq 'redacted' scripts/inputflow-diagnostics-bundle.sh || fail "diagnostics bundle must redact config/state content" grep -Eq '%\{_bindir\}/mwb_client' "$spec_file" || fail "spec must package mwb_client" grep -Eq '%\{_bindir\}/mwb_tray' "$spec_file" || fail "spec must package mwb_tray" diff --git a/src/AndroidRelay.cpp b/src/AndroidRelay.cpp index 5d06d71..06c73fa 100644 --- a/src/AndroidRelay.cpp +++ b/src/AndroidRelay.cpp @@ -5,25 +5,48 @@ #include #include #include +#include #include +#include +#include #include #include +#include #include #include +#include #include #include +#include #include +#include +#include #include #include #include +#include +#include +#include #include #include +#if defined(MWB_HAVE_LIBEI_INPUT_CAPTURE) +#include +#include +#endif + namespace mwb { namespace { constexpr std::size_t kMaxFrameBytes = 64 * 1024; +constexpr std::size_t kEncryptedFrameOverhead = 4 + 8 + 16; +constexpr std::size_t kMaxWireFrameBytes = kMaxFrameBytes + kEncryptedFrameOverhead; +constexpr std::size_t kMaxRelayClients = 8; +constexpr std::string_view kEncryptedFrameMagic = "IFP1"; +constexpr std::array kServerToClientIvPrefix{'I', 'F', 'S', 'O'}; +constexpr std::array kClientToServerIvPrefix{'I', 'F', 'C', 'O'}; +using RelayKey = std::array; void CloseFd(int& fd) { if (fd >= 0) { @@ -77,7 +100,7 @@ std::optional ReadFrame(int fd) { return std::nullopt; } const uint32_t length = ntohl(networkLength); - if (length == 0 || length > kMaxFrameBytes) { + if (length == 0 || length > kMaxWireFrameBytes) { return std::nullopt; } @@ -105,18 +128,200 @@ std::string RandomNonceHex() { return HexEncode(bytes.data(), bytes.size()); } -std::string HmacSha256Hex(const std::string& secret, const std::string& message) { - std::array digest{}; +RelayKey HmacSha256(const std::string& secret, const std::string& message) { + RelayKey digest{}; unsigned int digestLength = 0; - HMAC( + if (HMAC( EVP_sha256(), secret.data(), static_cast(secret.size()), reinterpret_cast(message.data()), message.size(), digest.data(), - &digestLength); - return HexEncode(digest.data(), digestLength); + &digestLength) == nullptr || + digestLength != digest.size()) { + digest.fill(0); + } + return digest; +} + +std::string HmacSha256Hex(const std::string& secret, const std::string& message) { + const auto digest = HmacSha256(secret, message); + return HexEncode(digest.data(), digest.size()); +} + +RelayKey DeriveRelayKey( + const std::string& secret, + const std::string& nonce, + std::string_view direction) { + return HmacSha256( + secret, + "inputflow-relay-v1/" + std::string(direction) + "/" + nonce); +} + +void AppendSequence(std::string& output, uint64_t sequence) { + for (int shift = 56; shift >= 0; shift -= 8) { + output.push_back(static_cast((sequence >> shift) & 0xffu)); + } +} + +uint64_t ReadSequence(std::string_view input) { + uint64_t sequence = 0; + for (const unsigned char byte : input) { + sequence = (sequence << 8u) | byte; + } + return sequence; +} + +std::array BuildRelayIv( + const std::array& prefix, + uint64_t sequence) { + std::array iv{}; + std::copy(prefix.begin(), prefix.end(), iv.begin()); + for (int index = 0; index < 8; ++index) { + iv[4 + index] = static_cast( + (sequence >> (56 - index * 8)) & 0xffu); + } + return iv; +} + +std::optional EncryptRelayFrame( + const std::string& payload, + const RelayKey& key, + uint64_t sequence, + const std::array& ivPrefix) { + if (payload.empty() || payload.size() > kMaxFrameBytes) { + return std::nullopt; + } + + std::string header{kEncryptedFrameMagic}; + AppendSequence(header, sequence); + const auto iv = BuildRelayIv(ivPrefix, sequence); + EVP_CIPHER_CTX* context = EVP_CIPHER_CTX_new(); + if (context == nullptr) { + return std::nullopt; + } + + std::string ciphertext(payload.size() + EVP_MAX_BLOCK_LENGTH, '\0'); + int outputLength = 0; + int totalLength = 0; + bool ok = + EVP_EncryptInit_ex(context, EVP_aes_256_gcm(), nullptr, nullptr, nullptr) == 1 && + EVP_CIPHER_CTX_ctrl(context, EVP_CTRL_GCM_SET_IVLEN, iv.size(), nullptr) == 1 && + EVP_EncryptInit_ex(context, nullptr, nullptr, key.data(), iv.data()) == 1 && + EVP_EncryptUpdate( + context, + nullptr, + &outputLength, + reinterpret_cast(header.data()), + static_cast(header.size())) == 1 && + EVP_EncryptUpdate( + context, + reinterpret_cast(ciphertext.data()), + &outputLength, + reinterpret_cast(payload.data()), + static_cast(payload.size())) == 1; + if (ok) { + totalLength = outputLength; + ok = EVP_EncryptFinal_ex( + context, + reinterpret_cast(ciphertext.data()) + totalLength, + &outputLength) == 1; + totalLength += outputLength; + } + + std::array tag{}; + if (ok) { + ok = EVP_CIPHER_CTX_ctrl( + context, + EVP_CTRL_GCM_GET_TAG, + tag.size(), + tag.data()) == 1; + } + EVP_CIPHER_CTX_free(context); + if (!ok) { + return std::nullopt; + } + + ciphertext.resize(static_cast(totalLength)); + std::string envelope = header + ciphertext; + envelope.append( + reinterpret_cast(tag.data()), + tag.size()); + return envelope; +} + +std::optional DecryptRelayFrame( + const std::string& envelope, + const RelayKey& key, + uint64_t expectedSequence, + const std::array& ivPrefix) { + if (envelope.size() <= kEncryptedFrameOverhead || + envelope.size() > kMaxWireFrameBytes || + envelope.compare(0, kEncryptedFrameMagic.size(), kEncryptedFrameMagic) != 0) { + return std::nullopt; + } + + constexpr std::size_t headerSize = 12; + const uint64_t sequence = ReadSequence( + std::string_view(envelope).substr(kEncryptedFrameMagic.size(), 8)); + if (sequence != expectedSequence) { + return std::nullopt; + } + + const std::size_t ciphertextSize = envelope.size() - headerSize - 16; + if (ciphertextSize == 0 || ciphertextSize > kMaxFrameBytes) { + return std::nullopt; + } + const auto iv = BuildRelayIv(ivPrefix, sequence); + const auto* ciphertext = + reinterpret_cast(envelope.data() + headerSize); + const auto* tag = + reinterpret_cast(envelope.data() + headerSize + ciphertextSize); + + EVP_CIPHER_CTX* context = EVP_CIPHER_CTX_new(); + if (context == nullptr) { + return std::nullopt; + } + std::string plaintext(ciphertextSize + EVP_MAX_BLOCK_LENGTH, '\0'); + int outputLength = 0; + int totalLength = 0; + bool ok = + EVP_DecryptInit_ex(context, EVP_aes_256_gcm(), nullptr, nullptr, nullptr) == 1 && + EVP_CIPHER_CTX_ctrl(context, EVP_CTRL_GCM_SET_IVLEN, iv.size(), nullptr) == 1 && + EVP_DecryptInit_ex(context, nullptr, nullptr, key.data(), iv.data()) == 1 && + EVP_DecryptUpdate( + context, + nullptr, + &outputLength, + reinterpret_cast(envelope.data()), + headerSize) == 1 && + EVP_DecryptUpdate( + context, + reinterpret_cast(plaintext.data()), + &outputLength, + ciphertext, + static_cast(ciphertextSize)) == 1; + if (ok) { + totalLength = outputLength; + ok = EVP_CIPHER_CTX_ctrl( + context, + EVP_CTRL_GCM_SET_TAG, + 16, + const_cast(tag)) == 1 && + EVP_DecryptFinal_ex( + context, + reinterpret_cast(plaintext.data()) + totalLength, + &outputLength) == 1; + totalLength += outputLength; + } + EVP_CIPHER_CTX_free(context); + if (!ok || totalLength <= 0 || + static_cast(totalLength) > kMaxFrameBytes) { + return std::nullopt; + } + plaintext.resize(static_cast(totalLength)); + return plaintext; } std::optional JsonStringValue(const std::string& json, const std::string& key) { @@ -193,16 +398,79 @@ std::string EscapeJson(std::string_view value) { return escaped; } +std::vector Base64Decode(const std::string& input) { + static constexpr char kAlphabet[] = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + std::array table{}; + table.fill(-1); + for (int i = 0; i < 64; ++i) { + table[static_cast(kAlphabet[i])] = i; + } + + std::vector out; + int value = 0; + int bits = -8; + for (const unsigned char c : input) { + if (c == '=') { + break; + } + if (table[c] == -1) { + continue; // skip whitespace/newlines + } + value = (value << 6) + table[c]; + bits += 6; + if (bits >= 0) { + out.push_back(static_cast((value >> bits) & 0xFF)); + bits -= 8; + } + } + return out; +} + +// Decodes a base64 PNG into a per-session temp file and returns its path, so +// notify-send can render the real Android app icon. Returns empty on failure. +std::string WriteNotificationIcon(const std::string& base64, const std::string& stableId) { + if (base64.empty()) { + return {}; + } + const std::vector bytes = Base64Decode(base64); + if (bytes.empty()) { + return {}; + } + + const char* runtimeDir = std::getenv("XDG_RUNTIME_DIR"); + const std::string dir = + (runtimeDir != nullptr ? std::string(runtimeDir) : std::string("/tmp")) + "/inputflow-icons"; + mkdir(dir.c_str(), 0700); + + const std::string path = + dir + "/icon-" + std::to_string(std::hash{}(stableId)) + ".png"; + std::ofstream file(path, std::ios::binary | std::ios::trunc); + if (!file) { + return {}; + } + file.write(reinterpret_cast(bytes.data()), + static_cast(bytes.size())); + if (!file) { + return {}; + } + return path; +} + void ShowLinuxNotification(const std::string& appName, const std::string& title, - const std::string& body) { + const std::string& body, + const std::string& iconPath) { if (title.empty() && body.empty()) { return; } - const std::string displayTitle = appName.empty() || title.empty() - ? (title.empty() ? "Android notification" : title) - : appName + ": " + title; + // Show the originating Android app as the notification source (-a) with its + // real icon (-i), and keep the title clean, so it reads like a native Android + // notification on the desktop rather than a generic "InputFlow" message. + const std::string app = appName.empty() ? "Android" : appName; + const std::string displayTitle = title.empty() ? app : title; + const std::string icon = iconPath.empty() ? "inputflow" : iconPath; const pid_t child = fork(); if (child == 0) { @@ -210,9 +478,9 @@ void ShowLinuxNotification(const std::string& appName, "notify-send", "notify-send", "-a", - "InputFlow", + app.c_str(), "-i", - "inputflow", + icon.c_str(), displayTitle.c_str(), body.c_str(), static_cast(nullptr)); @@ -224,8 +492,220 @@ void ShowLinuxNotification(const std::string& appName, } } +#if defined(MWB_HAVE_LIBEI_INPUT_CAPTURE) +// Presents notifications via the org.freedesktop.Notifications DBus service so we +// can attach an inline-reply action (KDE capability) and receive the reply the +// user types on the desktop. Runs its own GLib main loop thread to dispatch the +// NotificationReplied/Closed signals. One instance per process. +class NotificationPresenter { +public: + using ReplyCallback = std::function; + + bool Start(ReplyCallback callback) { + m_callback = std::move(callback); + GError* error = nullptr; + m_connection = g_bus_get_sync(G_BUS_TYPE_SESSION, nullptr, &error); + if (m_connection == nullptr) { + if (error != nullptr) { + std::cerr << "WARN: notification DBus connect failed: " << error->message << std::endl; + g_error_free(error); + } + return false; + } + m_context = g_main_context_new(); + m_loop = g_main_loop_new(m_context, FALSE); + g_main_context_push_thread_default(m_context); + m_repliedSub = g_dbus_connection_signal_subscribe( + m_connection, "org.freedesktop.Notifications", "org.freedesktop.Notifications", + "NotificationReplied", "/org/freedesktop/Notifications", nullptr, + G_DBUS_SIGNAL_FLAGS_NONE, &NotificationPresenter::OnReplied, this, nullptr); + m_closedSub = g_dbus_connection_signal_subscribe( + m_connection, "org.freedesktop.Notifications", "org.freedesktop.Notifications", + "NotificationClosed", "/org/freedesktop/Notifications", nullptr, + G_DBUS_SIGNAL_FLAGS_NONE, &NotificationPresenter::OnClosed, this, nullptr); + g_main_context_pop_thread_default(m_context); + m_thread = std::thread([this]() { + g_main_context_push_thread_default(m_context); + g_main_loop_run(m_loop); + g_main_context_pop_thread_default(m_context); + }); + return true; + } + + void Notify(const std::string& app, const std::string& title, const std::string& body, + const std::string& iconPath, bool replyable, const std::string& stableId) { + if (m_connection == nullptr) { + return; + } + const std::string summary = title.empty() ? app : title; + + GVariantBuilder actions; + g_variant_builder_init(&actions, G_VARIANT_TYPE("as")); + if (replyable) { + g_variant_builder_add(&actions, "s", "inline-reply"); + g_variant_builder_add(&actions, "s", "Reply"); + } + + GVariantBuilder hints; + g_variant_builder_init(&hints, G_VARIANT_TYPE("a{sv}")); + if (replyable) { + g_variant_builder_add(&hints, "{sv}", "x-kde-reply-placeholder-text", + g_variant_new_string("Reply")); + } + + GError* error = nullptr; + GVariant* reply = g_dbus_connection_call_sync( + m_connection, "org.freedesktop.Notifications", "/org/freedesktop/Notifications", + "org.freedesktop.Notifications", "Notify", + g_variant_new("(susssasa{sv}i)", app.c_str(), 0u, iconPath.c_str(), + summary.c_str(), body.c_str(), &actions, &hints, -1), + G_VARIANT_TYPE("(u)"), G_DBUS_CALL_FLAGS_NONE, -1, nullptr, &error); + if (reply == nullptr) { + if (error != nullptr) { + g_error_free(error); + } + return; + } + guint32 id = 0; + g_variant_get(reply, "(u)", &id); + g_variant_unref(reply); + if (replyable && id != 0) { + std::lock_guard lock(m_mapMutex); + m_idToStable[id] = stableId; + } + } + + ~NotificationPresenter() { + if (m_loop != nullptr) { + g_main_loop_quit(m_loop); + } + if (m_thread.joinable()) { + m_thread.join(); + } + if (m_connection != nullptr) { + if (m_repliedSub != 0) g_dbus_connection_signal_unsubscribe(m_connection, m_repliedSub); + if (m_closedSub != 0) g_dbus_connection_signal_unsubscribe(m_connection, m_closedSub); + } + if (m_loop != nullptr) g_main_loop_unref(m_loop); + if (m_context != nullptr) g_main_context_unref(m_context); + if (m_connection != nullptr) g_object_unref(m_connection); + } + +private: + static void OnReplied(GDBusConnection*, const gchar*, const gchar*, const gchar*, + const gchar*, GVariant* params, gpointer userData) { + auto* self = static_cast(userData); + guint32 id = 0; + const gchar* text = nullptr; + g_variant_get(params, "(u&s)", &id, &text); + std::string stableId; + { + std::lock_guard lock(self->m_mapMutex); + auto it = self->m_idToStable.find(id); + if (it != self->m_idToStable.end()) { + stableId = it->second; + self->m_idToStable.erase(it); + } + } + if (!stableId.empty() && self->m_callback) { + self->m_callback(stableId, text != nullptr ? std::string(text) : std::string()); + } + } + + static void OnClosed(GDBusConnection*, const gchar*, const gchar*, const gchar*, + const gchar*, GVariant* params, gpointer userData) { + auto* self = static_cast(userData); + guint32 id = 0; + guint32 reason = 0; + g_variant_get(params, "(uu)", &id, &reason); + std::lock_guard lock(self->m_mapMutex); + self->m_idToStable.erase(id); + } + + GDBusConnection* m_connection{nullptr}; + GMainContext* m_context{nullptr}; + GMainLoop* m_loop{nullptr}; + std::thread m_thread; + guint m_repliedSub{0}; + guint m_closedSub{0}; + std::mutex m_mapMutex; + std::map m_idToStable; + ReplyCallback m_callback; +}; + +// Presents a synced notification, using the DBus presenter (inline reply capable) +// when available, falling back to notify-send otherwise. +void PresentNotification(AndroidRelayServer* server, const std::string& app, + const std::string& title, const std::string& body, + const std::string& iconPath, bool canReply, + const std::string& stableId) { + if (title.empty() && body.empty()) { + return; + } + static NotificationPresenter presenter; + static bool started = presenter.Start( + [server](const std::string& sid, const std::string& text) { + server->DeliverNotificationReply(sid, text); + }); + if (started) { + presenter.Notify(app.empty() ? "Android" : app, title, body, iconPath, canReply, stableId); + } else { + ShowLinuxNotification(app, title, body, iconPath); + } +} +#else +void PresentNotification(AndroidRelayServer*, const std::string& app, const std::string& title, + const std::string& body, const std::string& iconPath, bool, + const std::string&) { + ShowLinuxNotification(app, title, body, iconPath); +} +#endif + } // namespace +bool VerifyAndroidRelayHmac( + const std::string& secret, + const std::string& nonce, + const std::string& suppliedHmac) { + const std::string expected = HmacSha256Hex(secret, nonce); + return suppliedHmac.size() == expected.size() && + CRYPTO_memcmp(suppliedHmac.data(), expected.data(), expected.size()) == 0; +} + +#if defined(MWB_TESTING) +std::optional EncryptAndroidRelayFrameForTest( + const std::string& payload, + const std::string& secret, + const std::string& nonce, + uint64_t sequence, + bool serverToClient) { + return EncryptRelayFrame( + payload, + DeriveRelayKey( + secret, + nonce, + serverToClient ? "server-to-client" : "client-to-server"), + sequence, + serverToClient ? kServerToClientIvPrefix : kClientToServerIvPrefix); +} + +std::optional DecryptAndroidRelayFrameForTest( + const std::string& envelope, + const std::string& secret, + const std::string& nonce, + uint64_t expectedSequence, + bool serverToClient) { + return DecryptRelayFrame( + envelope, + DeriveRelayKey( + secret, + nonce, + serverToClient ? "server-to-client" : "client-to-server"), + expectedSequence, + serverToClient ? kServerToClientIvPrefix : kClientToServerIvPrefix); +} +#endif + AndroidRelayServer::AndroidRelayServer(AndroidRelayOptions options) : m_options(std::move(options)) { } @@ -284,8 +764,7 @@ bool AndroidRelayServer::Start() { } m_acceptThread = std::thread([this]() { AcceptLoop(); }); - std::cout << "[ANDROID] Relay listening on port " << m_options.port - << " for peer '" << m_options.peerName << "'." << std::endl; + std::cout << "[ANDROID] Relay listening on the configured port." << std::endl; return true; } @@ -368,6 +847,39 @@ void AndroidRelayServer::AcceptLoop() { break; } + { + std::lock_guard lock(m_clientsMutex); + if (m_clients.size() >= kMaxRelayClients) { + close(clientFd); + std::cerr << "WARN: Android relay client limit reached." << std::endl; + continue; + } + } + + // Enable TCP keepalive so a half-open peer (Wi-Fi drop, app killed, device + // sleep) is detected by the kernel within ~11s. Without this the blocking + // ReadFrame never returns, the dead session lingers as authenticated, and + // BroadcastFrame keeps shipping input into the void instead of the live + // client. TCP_NODELAY keeps mouse latency low. + const int on = 1; + setsockopt(clientFd, SOL_SOCKET, SO_KEEPALIVE, &on, sizeof(on)); +#ifdef TCP_KEEPIDLE + const int idle = 5, intvl = 2, cnt = 3; + setsockopt(clientFd, IPPROTO_TCP, TCP_KEEPIDLE, &idle, sizeof(idle)); + setsockopt(clientFd, IPPROTO_TCP, TCP_KEEPINTVL, &intvl, sizeof(intvl)); + setsockopt(clientFd, IPPROTO_TCP, TCP_KEEPCNT, &cnt, sizeof(cnt)); +#endif +#ifdef TCP_NODELAY + setsockopt(clientFd, IPPROTO_TCP, TCP_NODELAY, &on, sizeof(on)); +#endif + const timeval authenticationTimeout{5, 0}; + setsockopt( + clientFd, + SOL_SOCKET, + SO_RCVTIMEO, + &authenticationTimeout, + sizeof(authenticationTimeout)); + auto session = std::make_shared(); session->fd = clientFd; { @@ -383,12 +895,32 @@ void AndroidRelayServer::HandleClient(std::shared_ptr session) { RemoveClient(session); return; } + const timeval noReadTimeout{0, 0}; + setsockopt( + session->fd, + SOL_SOCKET, + SO_RCVTIMEO, + &noReadTimeout, + sizeof(noReadTimeout)); while (m_running) { - const auto frame = ReadFrame(session->fd); + if (session->readSequence == std::numeric_limits::max()) { + break; + } + const auto wireFrame = ReadFrame(session->fd); + if (!wireFrame.has_value()) { + break; + } + const auto frame = DecryptRelayFrame( + *wireFrame, + session->readKey, + session->readSequence, + kClientToServerIvPrefix); if (!frame.has_value()) { + std::cerr << "WARN: Android relay rejected an invalid encrypted frame." << std::endl; break; } + ++session->readSequence; const auto type = JsonStringValue(*frame, "type"); if (!type.has_value()) { continue; @@ -413,16 +945,22 @@ void AndroidRelayServer::HandleClient(std::shared_ptr session) { } } else if (*type == "notification_upsert") { if (m_options.notificationSyncEnabled) { - ShowLinuxNotification( + const std::string stableId = JsonStringValue(*frame, "stable_id").value_or(""); + const std::string iconPath = WriteNotificationIcon( + JsonStringValue(*frame, "icon_png").value_or(""), stableId); + const bool canReply = frame->find("\"can_reply\":true") != std::string::npos; + PresentNotification( + this, JsonStringValue(*frame, "app").value_or("Android"), JsonStringValue(*frame, "title").value_or(""), - JsonStringValue(*frame, "body").value_or("")); + JsonStringValue(*frame, "body").value_or(""), + iconPath, + canReply, + stableId); } } else if (*type == "notification_dismiss") { if (m_options.notificationSyncEnabled) { - std::cout << "[ANDROID] Notification dismissed: " - << JsonStringValue(*frame, "stable_id").value_or("") - << std::endl; + std::cout << "[ANDROID] Notification dismissed." << std::endl; } } } @@ -435,8 +973,9 @@ bool AndroidRelayServer::AuthenticateClient(const std::shared_ptr if (nonce.empty()) { return false; } - const std::string hello = "{\"type\":\"hello\",\"version\":1,\"nonce\":\"" + nonce + - "\",\"peer\":\"" + EscapeJson(m_options.peerName) + "\"}"; + const std::string hello = + "{\"type\":\"hello\",\"version\":2,\"cipher\":\"AES-256-GCM\",\"nonce\":\"" + + nonce + "\"}"; if (!SendFrame(session, hello)) { return false; } @@ -451,22 +990,24 @@ bool AndroidRelayServer::AuthenticateClient(const std::shared_ptr return false; } - const std::string expected = HmacSha256Hex(m_options.secret, nonce); - if (*hmac != expected) { + if (!VerifyAndroidRelayHmac(m_options.secret, nonce, *hmac)) { std::cerr << "WARN: Android relay rejected client with invalid auth." << std::endl; return false; } const std::string deviceName = JsonStringValue(*auth, "device").value_or("android"); + session->deviceName = deviceName; + session->readKey = DeriveRelayKey(m_options.secret, nonce, "client-to-server"); + session->writeKey = DeriveRelayKey(m_options.secret, nonce, "server-to-client"); + session->encryptionReady = true; + if (!SendFrame(session, "{\"type\":\"ready\"}")) { + return false; + } { std::lock_guard lock(m_clientsMutex); - session->deviceName = deviceName; session->authenticated = true; } - std::cout << "[ANDROID] Relay client authenticated: " << deviceName << std::endl; - if (!SendFrame(session, "{\"type\":\"ready\"}")) { - return false; - } + std::cout << "[ANDROID] Encrypted relay client authenticated." << std::endl; // Send device layout info so the Android client can show the layout editor. if (!m_localMachineName.empty()) { @@ -493,9 +1034,25 @@ bool AndroidRelayServer::SendFrame(const std::shared_ptr& session } std::lock_guard lock(session->writeMutex); - const uint32_t length = htonl(static_cast(payload.size())); + std::string wirePayload = payload; + if (session->encryptionReady) { + if (session->writeSequence == std::numeric_limits::max()) { + return false; + } + const auto encrypted = EncryptRelayFrame( + payload, + session->writeKey, + session->writeSequence, + kServerToClientIvPrefix); + if (!encrypted.has_value()) { + return false; + } + wirePayload = *encrypted; + ++session->writeSequence; + } + const uint32_t length = htonl(static_cast(wirePayload.size())); return WriteExact(session->fd, &length, sizeof(length)) && - WriteExact(session->fd, payload.data(), payload.size()); + WriteExact(session->fd, wirePayload.data(), wirePayload.size()); } bool AndroidRelayServer::BroadcastFrame(const std::string& payload) { @@ -513,8 +1070,19 @@ bool AndroidRelayServer::BroadcastFrame(const std::string& payload) { } bool delivered = false; + std::vector> dead; for (const auto& client : clients) { - delivered = SendFrame(client, payload) || delivered; + if (SendFrame(client, payload)) { + delivered = true; + } else { + // A failed write means the peer is gone (EPIPE/ECONNRESET). Drop it now + // so input stops being shipped to a dead socket and the live client + // remains the only broadcast target. + dead.push_back(client); + } + } + for (const auto& client : dead) { + RemoveClient(client); } return delivered; } @@ -560,4 +1128,13 @@ std::string BuildAndroidControlFrame(bool active) { return std::string("{\"type\":\"control\",\"active\":") + (active ? "true" : "false") + "}"; } +std::string BuildAndroidNotificationReplyFrame(const std::string& stableId, const std::string& text) { + return std::string("{\"type\":\"notification_reply\",\"stable_id\":\"") + EscapeJson(stableId) + + "\",\"text\":\"" + EscapeJson(text) + "\"}"; +} + +void AndroidRelayServer::DeliverNotificationReply(const std::string& stableId, const std::string& text) { + BroadcastFrame(BuildAndroidNotificationReplyFrame(stableId, text)); +} + } // namespace mwb diff --git a/src/AndroidRelay.h b/src/AndroidRelay.h index fb6d478..bd8d3f1 100644 --- a/src/AndroidRelay.h +++ b/src/AndroidRelay.h @@ -1,10 +1,12 @@ #pragma once +#include #include #include #include #include #include +#include #include #include #include @@ -42,6 +44,10 @@ class AndroidRelayServer { // Called after ready to populate the devices_info frame sent to Android. void SetLocalDeviceInfo(const std::string& machineName, int screenWidth, int screenHeight); + // Relays a desktop-typed inline reply back to Android, which fires the + // originating notification's RemoteInput to actually send the message. + void DeliverNotificationReply(const std::string& stableId, const std::string& text); + const AndroidRelayOptions& Options() const { return m_options; } @@ -50,7 +56,12 @@ class AndroidRelayServer { struct ClientSession { int fd{-1}; bool authenticated{false}; + bool encryptionReady{false}; std::string deviceName; + std::array readKey{}; + std::array writeKey{}; + uint64_t readSequence{0}; + uint64_t writeSequence{0}; std::mutex writeMutex; }; @@ -81,5 +92,24 @@ std::string BuildAndroidMouseFrame(const MouseData& mouse); std::string BuildAndroidKeyboardFrame(const KeyboardData& keyboard); std::string BuildAndroidGestureFrame(const std::string& kind, double dx, double dy); std::string BuildAndroidControlFrame(bool active); +std::string BuildAndroidNotificationReplyFrame(const std::string& stableId, const std::string& text); +bool VerifyAndroidRelayHmac( + const std::string& secret, + const std::string& nonce, + const std::string& suppliedHmac); +#if defined(MWB_TESTING) +std::optional EncryptAndroidRelayFrameForTest( + const std::string& payload, + const std::string& secret, + const std::string& nonce, + uint64_t sequence, + bool serverToClient); +std::optional DecryptAndroidRelayFrameForTest( + const std::string& envelope, + const std::string& secret, + const std::string& nonce, + uint64_t expectedSequence, + bool serverToClient); +#endif } // namespace mwb diff --git a/src/AppConfig.cpp b/src/AppConfig.cpp index a0ebeb2..34f31df 100644 --- a/src/AppConfig.cpp +++ b/src/AppConfig.cpp @@ -1,4 +1,5 @@ #include "AppConfig.h" +#include "SecureFile.h" #include #include @@ -618,40 +619,11 @@ std::string RenderSampleAppConfig() { bool WriteAppConfig(const std::filesystem::path& path, const AppConfig& config, std::string* errorMessage) { - try { - const std::filesystem::path parent = path.parent_path(); - if (!parent.empty()) { - std::filesystem::create_directories(parent); - } - - std::ofstream file(path, std::ios::out | std::ios::trunc); - if (!file) { - SetError(errorMessage, "Failed to open config file for writing: " + path.string()); - return false; - } - - file << RenderAppConfig(config); - if (!file) { - SetError(errorMessage, "Failed to write config file: " + path.string()); - return false; - } - file.close(); - - std::error_code permissionError; - std::filesystem::permissions( - path, - std::filesystem::perms::owner_read | std::filesystem::perms::owner_write, - std::filesystem::perm_options::replace, - permissionError); - if (permissionError) { - SetError(errorMessage, "Failed to secure config file permissions for '" + path.string() + "': " + permissionError.message()); - return false; - } - } catch (const std::exception& ex) { - SetError(errorMessage, "Failed to write config file '" + path.string() + "': " + ex.what()); + std::string writeError; + if (!WritePrivateFileAtomically(path, RenderAppConfig(config), writeError)) { + SetError(errorMessage, writeError); return false; } - return true; } diff --git a/src/AppState.cpp b/src/AppState.cpp index 6ab1bed..9212a4b 100644 --- a/src/AppState.cpp +++ b/src/AppState.cpp @@ -1,4 +1,5 @@ #include "AppState.h" +#include "SecureFile.h" #include #include @@ -195,19 +196,7 @@ bool LoadAppState(const std::filesystem::path& path, AppState& state, std::strin } bool SaveAppState(const std::filesystem::path& path, const AppState& state, std::string& errorMessage) { - std::error_code mkdirError; - std::filesystem::create_directories(path.parent_path(), mkdirError); - if (mkdirError) { - errorMessage = "Failed to create state directory: " + mkdirError.message(); - return false; - } - - std::ofstream output(path); - if (!output) { - errorMessage = "Could not write state file: " + path.string(); - return false; - } - + std::ostringstream output; output << "local_machine_id=0x" << std::hex << state.localMachineId << std::dec << "\n"; for (const auto& peer : state.peers) { output << "peer=" @@ -220,12 +209,7 @@ bool SaveAppState(const std::filesystem::path& path, const AppState& state, std: << peer.lastConnectedEpochSeconds << "\n"; } - if (!output) { - errorMessage = "Failed while writing state file: " + path.string(); - return false; - } - - return true; + return WritePrivateFileAtomically(path, output.str(), errorMessage); } uint32_t EnsureLocalMachineId(AppState& state) { diff --git a/src/ClipboardManager.cpp b/src/ClipboardManager.cpp index c20de3e..f73f594 100644 --- a/src/ClipboardManager.cpp +++ b/src/ClipboardManager.cpp @@ -6,11 +6,9 @@ #include #include #include -#include #include #include #include -#include #include #include #include @@ -196,12 +194,6 @@ std::optional runReadCommand(const CommandSpec& command) { return std::string(bytes->begin(), bytes->end()); } -void trimTrailingNewlines(std::string& text) { - while (!text.empty() && (text.back() == '\n' || text.back() == '\r')) { - text.pop_back(); - } -} - bool runWriteCommand(const CommandSpec& command, const std::string& input) { if (command.argv.empty()) { return false; @@ -509,13 +501,98 @@ bool runSignalWatchCommand( std::u16string utf8ToUtf16(std::string_view text) { - std::wstring_convert, char16_t> converter; - return converter.from_bytes(text.data(), text.data() + text.size()); + constexpr uint32_t replacement = 0xfffdu; + std::u16string output; + output.reserve(text.size()); + std::size_t index = 0; + while (index < text.size()) { + const auto first = static_cast(text[index]); + uint32_t codePoint = replacement; + std::size_t sequenceLength = 1; + uint32_t minimum = 0; + if (first < 0x80u) { + codePoint = first; + } else if ((first & 0xe0u) == 0xc0u) { + codePoint = first & 0x1fu; + sequenceLength = 2; + minimum = 0x80u; + } else if ((first & 0xf0u) == 0xe0u) { + codePoint = first & 0x0fu; + sequenceLength = 3; + minimum = 0x800u; + } else if ((first & 0xf8u) == 0xf0u) { + codePoint = first & 0x07u; + sequenceLength = 4; + minimum = 0x10000u; + } + + bool valid = sequenceLength == 1 ? first < 0x80u : index + sequenceLength <= text.size(); + for (std::size_t offset = 1; valid && offset < sequenceLength; ++offset) { + const auto continuation = static_cast(text[index + offset]); + valid = (continuation & 0xc0u) == 0x80u; + codePoint = (codePoint << 6u) | (continuation & 0x3fu); + } + valid = valid && + codePoint >= minimum && + codePoint <= 0x10ffffu && + !(codePoint >= 0xd800u && codePoint <= 0xdfffu); + if (!valid) { + codePoint = replacement; + sequenceLength = 1; + } + index += sequenceLength; + + if (codePoint <= 0xffffu) { + output.push_back(static_cast(codePoint)); + } else { + codePoint -= 0x10000u; + output.push_back(static_cast(0xd800u + (codePoint >> 10u))); + output.push_back(static_cast(0xdc00u + (codePoint & 0x3ffu))); + } + } + return output; } std::string utf16ToUtf8(const std::u16string& text) { - std::wstring_convert, char16_t> converter; - return converter.to_bytes(text); + constexpr uint32_t replacement = 0xfffdu; + std::string output; + output.reserve(text.size() * 2); + for (std::size_t index = 0; index < text.size(); ++index) { + uint32_t codePoint = text[index]; + if (codePoint >= 0xd800u && codePoint <= 0xdbffu) { + if (index + 1 < text.size()) { + const uint32_t low = text[index + 1]; + if (low >= 0xdc00u && low <= 0xdfffu) { + codePoint = + 0x10000u + ((codePoint - 0xd800u) << 10u) + (low - 0xdc00u); + ++index; + } else { + codePoint = replacement; + } + } else { + codePoint = replacement; + } + } else if (codePoint >= 0xdc00u && codePoint <= 0xdfffu) { + codePoint = replacement; + } + + if (codePoint <= 0x7fu) { + output.push_back(static_cast(codePoint)); + } else if (codePoint <= 0x7ffu) { + output.push_back(static_cast(0xc0u | (codePoint >> 6u))); + output.push_back(static_cast(0x80u | (codePoint & 0x3fu))); + } else if (codePoint <= 0xffffu) { + output.push_back(static_cast(0xe0u | (codePoint >> 12u))); + output.push_back(static_cast(0x80u | ((codePoint >> 6u) & 0x3fu))); + output.push_back(static_cast(0x80u | (codePoint & 0x3fu))); + } else { + output.push_back(static_cast(0xf0u | (codePoint >> 18u))); + output.push_back(static_cast(0x80u | ((codePoint >> 12u) & 0x3fu))); + output.push_back(static_cast(0x80u | ((codePoint >> 6u) & 0x3fu))); + output.push_back(static_cast(0x80u | (codePoint & 0x3fu))); + } + } + return output; } std::vector encodeUtf16Le(std::u16string_view text) { diff --git a/src/ClipboardManager.h b/src/ClipboardManager.h index 385ca24..0bbd4a5 100644 --- a/src/ClipboardManager.h +++ b/src/ClipboardManager.h @@ -42,8 +42,8 @@ class ClipboardBackend { virtual bool WritePayload(const ClipboardPayload& payload) = 0; virtual std::string Name() const = 0; virtual bool WatchPayloadChanges( - const std::atomic& running, - const std::function& onPayloadChange) { + const std::atomic&, + const std::function&) { return false; } }; diff --git a/src/Discovery.cpp b/src/Discovery.cpp index 6dde63f..cc65999 100644 --- a/src/Discovery.cpp +++ b/src/Discovery.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -24,11 +25,73 @@ #include #include +#include #include namespace mwb { namespace { +std::optional RunCommandCapture(const std::vector& argv) { + if (argv.empty()) { + return std::nullopt; + } + int outputPipe[2]{-1, -1}; + if (pipe(outputPipe) != 0) { + return std::nullopt; + } + const pid_t child = fork(); + if (child < 0) { + close(outputPipe[0]); + close(outputPipe[1]); + return std::nullopt; + } + if (child == 0) { + close(outputPipe[0]); + dup2(outputPipe[1], STDOUT_FILENO); + if (outputPipe[1] > STDERR_FILENO) { + close(outputPipe[1]); + } + const int devNull = open("/dev/null", O_WRONLY); + if (devNull >= 0) { + dup2(devNull, STDERR_FILENO); + if (devNull > STDERR_FILENO) { + close(devNull); + } + } + std::vector args; + args.reserve(argv.size() + 1); + for (const auto& argument : argv) { + args.push_back(const_cast(argument.c_str())); + } + args.push_back(nullptr); + execv(args.front(), args.data()); + _exit(127); + } + + close(outputPipe[1]); + std::string output; + std::array buffer{}; + while (output.size() < 64 * 1024) { + const ssize_t count = read(outputPipe[0], buffer.data(), buffer.size()); + if (count > 0) { + output.append(buffer.data(), static_cast(count)); + continue; + } + if (count < 0 && errno == EINTR) { + continue; + } + break; + } + close(outputPipe[0]); + int status = 0; + while (waitpid(child, &status, 0) < 0 && errno == EINTR) { + } + if (output.empty()) { + return std::nullopt; + } + return output; +} + constexpr std::size_t kAbsoluteMaxHostsPerSubnet = 4096; constexpr std::size_t kAbsoluteMaxConcurrentProbes = 128; constexpr int kMinimumConnectTimeoutMs = 25; @@ -279,18 +342,18 @@ std::string AvahiResolveAddress(uint32_t address) { } const std::string ipAddress = FormatIpv4Address(address); - const std::string command = std::string(kTimeoutPath) + " 1s " + kAvahiResolveAddressPath + " -4 -a " + ipAddress + " 2>/dev/null"; - std::unique_ptr pipe(popen(command.c_str(), "r"), pclose); - if (!pipe) { + const auto output = RunCommandCapture( + {kTimeoutPath, "1s", kAvahiResolveAddressPath, "-4", "-a", ipAddress}); + if (!output.has_value()) { return {}; } - std::array buffer{}; - if (fgets(buffer.data(), static_cast(buffer.size()), pipe.get()) == nullptr) { + std::istringstream lines(*output); + std::string line; + if (!std::getline(lines, line)) { return {}; } - std::string line(buffer.data()); const std::size_t separator = line.find_first_of(" \t"); if (separator == std::string::npos) { return {}; @@ -312,17 +375,17 @@ std::string NmblookupResolveAddress(uint32_t address, int timeoutMs) { const std::string ipAddress = FormatIpv4Address(address); const int timeoutSeconds = std::max(1, (std::min(timeoutMs, 3000) + 999) / 1000); - const std::string command = std::string(kTimeoutPath) + " " + std::to_string(timeoutSeconds) + "s " + - kNmblookupPath + " -A " + ipAddress + " 2>/dev/null"; - std::unique_ptr pipe(popen(command.c_str(), "r"), pclose); - if (!pipe) { + const auto output = RunCommandCapture( + {kTimeoutPath, std::to_string(timeoutSeconds) + "s", kNmblookupPath, "-A", ipAddress}); + if (!output.has_value()) { return {}; } - std::array buffer{}; + std::istringstream lines(*output); std::string fallbackName; - while (fgets(buffer.data(), static_cast(buffer.size()), pipe.get()) != nullptr) { - const std::string line = TrimDiscoveredName(buffer.data()); + std::string rawLine; + while (std::getline(lines, rawLine)) { + const std::string line = TrimDiscoveredName(rawLine); if (line.find("") != std::string::npos) { continue; } diff --git a/src/GuiMainWindow.cpp b/src/GuiMainWindow.cpp index e2844cf..534ea2e 100644 --- a/src/GuiMainWindow.cpp +++ b/src/GuiMainWindow.cpp @@ -16,6 +16,91 @@ namespace { constexpr const char* kServiceName = "mwb-client.service"; constexpr const char* kSystemctlPath = "/usr/bin/systemctl"; +constexpr const char* kInputFlowCss = R"CSS( +.inputflow-window { + background-color: #f3f8f7; + color: #12252c; +} +.inputflow-window notebook > header { + background-color: #e4f0ed; + border-bottom: 1px solid #bcd7d2; +} +.inputflow-window notebook > header tab { + padding: 10px 14px; + color: #355158; +} +.inputflow-window notebook > header tab:checked { + color: #0b6f6b; + border-bottom: 3px solid #0b7f7a; +} +.inputflow-hero { + background-color: #092f35; + border: 1px solid #15545a; + border-radius: 14px; + padding: 18px; +} +.inputflow-hero label { + color: #f4fbf9; +} +.inputflow-eyebrow { + color: #72d4c6; + font-size: 10px; + font-weight: 700; + letter-spacing: 1.5px; +} +.inputflow-status-title { + font-size: 22px; + font-weight: 700; +} +.inputflow-status-detail { + color: #b9d9d4; +} +.inputflow-section-title { + color: #0b6f6b; + font-size: 14px; + font-weight: 700; +} +.inputflow-panel { + background-color: #ffffff; + border: 1px solid #bcd7d2; + border-radius: 10px; +} +.inputflow-window button { + border-radius: 8px; + padding: 7px 12px; +} +.inputflow-window button.suggested-action { + background-color: #0b7f7a; + color: #ffffff; +} +.inputflow-window button.destructive-action { + color: #a73540; +} +.inputflow-window textview { + background-color: #102a31; + color: #d9eeea; + font-family: monospace; +} +)CSS"; + +void InstallDesignSystem(GtkWidget* window) { + GtkCssProvider* provider = gtk_css_provider_new(); + GError* error = nullptr; + if (!gtk_css_provider_load_from_data(provider, kInputFlowCss, -1, &error)) { + if (error != nullptr) { + std::fprintf(stderr, "InputFlow UI theme could not be loaded: %s\n", error->message); + g_error_free(error); + } + g_object_unref(provider); + return; + } + gtk_style_context_add_provider_for_screen( + gtk_widget_get_screen(window), + GTK_STYLE_PROVIDER(provider), + GTK_STYLE_PROVIDER_PRIORITY_APPLICATION); + gtk_style_context_add_class(gtk_widget_get_style_context(window), "inputflow-window"); + g_object_unref(provider); +} // ---- colour helpers -------------------------------------------------------- @@ -46,8 +131,28 @@ gboolean OnDotDraw(GtkWidget* widget, cairo_t* cr, gpointer data) { void RunServiceAction(const std::string& action) { if (access(kSystemctlPath, X_OK) != 0) return; - std::string cmd = std::string(kSystemctlPath) + " --user " + action + " " + kServiceName + " &"; - (void)std::system(cmd.c_str()); + const gchar* argv[] = { + kSystemctlPath, + "--user", + action.c_str(), + kServiceName, + nullptr, + }; + GError* error = nullptr; + if (!g_spawn_async( + nullptr, + const_cast(argv), + nullptr, + static_cast(G_SPAWN_STDOUT_TO_DEV_NULL | G_SPAWN_STDERR_TO_DEV_NULL), + nullptr, + nullptr, + nullptr, + &error)) { + if (error != nullptr) { + std::fprintf(stderr, "InputFlow service action failed: %s\n", error->message); + g_error_free(error); + } + } } void OnStartService(GtkButton*, gpointer) { RunServiceAction("start"); } @@ -58,8 +163,31 @@ bool IsServiceActive() { if (access(kSystemctlPath, X_OK) != 0) { return false; } - std::string cmd = std::string(kSystemctlPath) + " --user is-active --quiet " + kServiceName; - return std::system(cmd.c_str()) == 0; + const gchar* argv[] = { + kSystemctlPath, + "--user", + "is-active", + "--quiet", + kServiceName, + nullptr, + }; + gint waitStatus = -1; + GError* error = nullptr; + const gboolean launched = g_spawn_sync( + nullptr, + const_cast(argv), + nullptr, + static_cast(G_SPAWN_STDOUT_TO_DEV_NULL | G_SPAWN_STDERR_TO_DEV_NULL), + nullptr, + nullptr, + nullptr, + nullptr, + &waitStatus, + &error); + if (error != nullptr) { + g_error_free(error); + } + return launched && g_spawn_check_wait_status(waitStatus, nullptr); } // After saving settings, a running daemon keeps its old config until restarted. @@ -390,23 +518,33 @@ void OnRefreshPeers(GtkButton*, gpointer data) { } GtkWidget* BuildStatusTab(GuiMainWindow* win) { - GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8); - gtk_container_set_border_width(GTK_CONTAINER(box), 16); + GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 12); + gtk_container_set_border_width(GTK_CONTAINER(box), 20); + + GtkWidget* hero = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6); + gtk_style_context_add_class(gtk_widget_get_style_context(hero), "inputflow-hero"); + GtkWidget* eyebrow = gtk_label_new("TRUSTED CONNECTION"); + gtk_label_set_xalign(GTK_LABEL(eyebrow), 0.0f); + gtk_style_context_add_class(gtk_widget_get_style_context(eyebrow), "inputflow-eyebrow"); + gtk_box_pack_start(GTK_BOX(hero), eyebrow, FALSE, FALSE, 0); - // Dot + state row GtkWidget* stateRow = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); win->dotArea = gtk_drawing_area_new(); gtk_widget_set_size_request(win->dotArea, 18, 18); + atk_object_set_name(gtk_widget_get_accessible(win->dotArea), "Connection status"); g_signal_connect(win->dotArea, "draw", G_CALLBACK(OnDotDraw), win); gtk_box_pack_start(GTK_BOX(stateRow), win->dotArea, FALSE, FALSE, 0); win->stateLabel = gtk_label_new("Checking..."); + gtk_style_context_add_class(gtk_widget_get_style_context(win->stateLabel), "inputflow-status-title"); gtk_box_pack_start(GTK_BOX(stateRow), win->stateLabel, FALSE, FALSE, 0); - gtk_box_pack_start(GTK_BOX(box), stateRow, FALSE, FALSE, 0); + gtk_box_pack_start(GTK_BOX(hero), stateRow, FALSE, FALSE, 0); win->detailLabel = gtk_label_new(""); gtk_label_set_xalign(GTK_LABEL(win->detailLabel), 0.0f); - gtk_style_context_add_class(gtk_widget_get_style_context(win->detailLabel), "dim-label"); - gtk_box_pack_start(GTK_BOX(box), win->detailLabel, FALSE, FALSE, 0); + gtk_label_set_line_wrap(GTK_LABEL(win->detailLabel), TRUE); + gtk_style_context_add_class(gtk_widget_get_style_context(win->detailLabel), "inputflow-status-detail"); + gtk_box_pack_start(GTK_BOX(hero), win->detailLabel, FALSE, FALSE, 0); + gtk_box_pack_start(GTK_BOX(box), hero, FALSE, FALSE, 0); // Buttons GtkWidget* btnRow = gtk_button_box_new(GTK_ORIENTATION_HORIZONTAL); @@ -416,6 +554,11 @@ GtkWidget* BuildStatusTab(GuiMainWindow* win) { GtkWidget* btnStart = gtk_button_new_with_label("Start"); GtkWidget* btnStop = gtk_button_new_with_label("Stop"); GtkWidget* btnRestart = gtk_button_new_with_label("Restart"); + gtk_style_context_add_class(gtk_widget_get_style_context(btnStart), "suggested-action"); + gtk_style_context_add_class(gtk_widget_get_style_context(btnStop), "destructive-action"); + gtk_widget_set_tooltip_text(btnStart, "Start the InputFlow background service"); + gtk_widget_set_tooltip_text(btnStop, "Stop the InputFlow background service"); + gtk_widget_set_tooltip_text(btnRestart, "Reload settings by restarting the service"); g_signal_connect(btnStart, "clicked", G_CALLBACK(OnStartService), win); g_signal_connect(btnStop, "clicked", G_CALLBACK(OnStopService), win); g_signal_connect(btnRestart, "clicked", G_CALLBACK(OnRestartService), win); @@ -426,7 +569,8 @@ GtkWidget* BuildStatusTab(GuiMainWindow* win) { // Peers (live status from saved state: connected now / last seen) GtkWidget* peersHeaderRow = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); - GtkWidget* peersHeader = MakeLabel("Peers:"); + GtkWidget* peersHeader = MakeLabel("Connected peers"); + gtk_style_context_add_class(gtk_widget_get_style_context(peersHeader), "inputflow-section-title"); gtk_box_pack_start(GTK_BOX(peersHeaderRow), peersHeader, FALSE, FALSE, 0); GtkWidget* refreshPeersBtn = gtk_button_new_with_label("Refresh"); gtk_widget_set_halign(refreshPeersBtn, GTK_ALIGN_END); @@ -440,12 +584,15 @@ GtkWidget* BuildStatusTab(GuiMainWindow* win) { gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(peersScroll), GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC); gtk_widget_set_size_request(peersScroll, -1, 110); + gtk_style_context_add_class(gtk_widget_get_style_context(peersScroll), "inputflow-panel"); gtk_container_add(GTK_CONTAINER(peersScroll), win->peersList); gtk_box_pack_start(GTK_BOX(box), peersScroll, FALSE, FALSE, 0); RefreshPeersList(win); // Log - gtk_box_pack_start(GTK_BOX(box), MakeLabel("Recent events:"), FALSE, FALSE, 0); + GtkWidget* eventsHeader = MakeLabel("Recent events"); + gtk_style_context_add_class(gtk_widget_get_style_context(eventsHeader), "inputflow-section-title"); + gtk_box_pack_start(GTK_BOX(box), eventsHeader, FALSE, FALSE, 0); win->logBuf = gtk_text_buffer_new(nullptr); win->logView = gtk_text_view_new_with_buffer(win->logBuf); gtk_text_view_set_editable(GTK_TEXT_VIEW(win->logView), FALSE); @@ -712,8 +859,10 @@ GuiMainWindow* CreateMainWindow(const AppConfig& config, win->window = gtk_window_new(GTK_WINDOW_TOPLEVEL); gtk_window_set_title(GTK_WINDOW(win->window), "InputFlow"); - gtk_window_set_default_size(GTK_WINDOW(win->window), 560, 500); + gtk_window_set_default_size(GTK_WINDOW(win->window), 720, 620); gtk_window_set_resizable(GTK_WINDOW(win->window), TRUE); + InstallDesignSystem(win->window); + atk_object_set_name(gtk_widget_get_accessible(win->window), "InputFlow controller"); g_signal_connect(win->window, "delete-event", G_CALLBACK(+[](GtkWidget* w, GdkEvent*, gpointer) -> gboolean { @@ -725,18 +874,18 @@ GuiMainWindow* CreateMainWindow(const AppConfig& config, gtk_container_add(GTK_CONTAINER(win->window), win->notebook); gtk_notebook_append_page(GTK_NOTEBOOK(win->notebook), - BuildStatusTab(win), gtk_label_new("Status")); + BuildStatusTab(win), gtk_label_new("Connection")); gtk_notebook_append_page(GTK_NOTEBOOK(win->notebook), BuildSettingsTab(win, config), gtk_label_new("Settings")); gtk_notebook_append_page(GTK_NOTEBOOK(win->notebook), - BuildMonitorTab(win, config), gtk_label_new("Monitor Layout")); + BuildMonitorTab(win, config), gtk_label_new("Monitor layout")); gtk_notebook_append_page(GTK_NOTEBOOK(win->notebook), BuildAndroidTab(win, config), gtk_label_new("Android")); ApplyModeSensitivity(win); gtk_widget_show_all(win->window); - gtk_notebook_set_current_page(GTK_NOTEBOOK(win->notebook), 2); + gtk_notebook_set_current_page(GTK_NOTEBOOK(win->notebook), 0); return win; } @@ -752,6 +901,7 @@ void GuiMainWindow::UpdateStatus(const std::string& state, const std::string& de else if (state == "failed") text = "Error"; else text = state.empty() ? "Unknown" : state; gtk_label_set_text(GTK_LABEL(stateLabel), text.c_str()); + atk_object_set_name(gtk_widget_get_accessible(dotArea), text.c_str()); } if (detailLabel && !detail.empty()) { gtk_label_set_text(GTK_LABEL(detailLabel), detail.c_str()); diff --git a/src/LibeiInputCaptureBridge.cpp b/src/LibeiInputCaptureBridge.cpp index b833651..4ae6c78 100644 --- a/src/LibeiInputCaptureBridge.cpp +++ b/src/LibeiInputCaptureBridge.cpp @@ -15,7 +15,6 @@ #if defined(MWB_HAVE_LIBEI_INPUT_CAPTURE) || defined(MWB_HAVE_LIBINPUT_GESTURES) #include -#include #endif #include @@ -23,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -31,6 +31,8 @@ #include #include #include +#include +#include #include #include @@ -211,7 +213,11 @@ std::optional FindTouchpadEventDevice() { break; } const auto eventName = entry.path().filename().string(); - if (eventName.rfind("event", 0) != 0) { + if (eventName.size() <= 5 || + eventName.rfind("event", 0) != 0 || + !std::all_of(eventName.begin() + 5, eventName.end(), [](unsigned char ch) { + return std::isdigit(ch) != 0; + })) { continue; } std::ifstream nameFile(entry.path() / "device" / "name"); @@ -291,12 +297,13 @@ bool RunNativeLibinputGestureMonitor( libinput_device* inputDevice = libinput_path_add_device(context, device.c_str()); if (!inputDevice) { - std::cerr << "WARN: Failed to open " << device << " through native libinput." << std::endl; + std::cerr << "WARN: Failed to open the selected device through native libinput." + << std::endl; libinput_unref(context); return false; } - std::cout << "[ANDROID] native libinput gesture monitor using " << device << std::endl; + std::cout << "[ANDROID] Native libinput gesture monitor started." << std::endl; bool swipeActive = false; int swipeFingers = 0; @@ -737,6 +744,22 @@ std::optional DominantEdgeFromDelta(double dx, double dy) { return std::nullopt; } +// Normalized margin the pointer is seeded *inside* the entry edge when capture +// engages. Entering exactly on the edge (0 or 65535) sits on the release +// threshold, so the tiniest opposite jitter would instantly bounce control back +// to the local desktop. Seeding inside this margin gives the handoff hysteresis. +constexpr int kEntryMargin = 1500; + +int NudgeInward(int value) { + if (value <= 0) { + return kEntryMargin; + } + if (value >= 65535) { + return 65535 - kEntryMargin; + } + return value; +} + bool MovementReturnsToLocal(const CaptureTarget& target, int x, int y, double dx, double dy) { switch (target.entryEdge) { case EdgeDirection::Left: @@ -966,55 +989,63 @@ void LibeiInputCaptureBridge::Run() { return; } - const auto sessionHandle = CreateSession(connection); - if (!sessionHandle.has_value()) { - g_object_unref(connection); - m_running = false; - return; - } - - std::vector captureTargets = BuildTopologyCaptureTargets(m_options); - if (captureTargets.empty()) { - captureTargets = BuildFallbackAndroidCaptureTarget(m_options); - } - - const auto zone = GetZones(connection, *sessionHandle); - if (!zone.has_value() || !SetPointerBarriers(connection, *sessionHandle, *zone, captureTargets)) { - CloseSession(connection, *sessionHandle); - g_object_unref(connection); - m_running = false; - return; - } + // The xdg-desktop-portal InputCapture handshake can display an interactive + // permission dialog. Never repeat the whole handshake automatically: a + // timeout or dismissal must not flood the desktop with permission prompts. + // A deliberate service restart is the retry action. + std::optional sessionHandle; + std::vector captureTargets; + ei* context = nullptr; + std::optional eisFd; + bool captureReady = false; + + for (int attempt = 1; + attempt <= kLibeiInputCaptureAutomaticSetupAttempts && m_running; + ++attempt) { + sessionHandle = CreateSession(connection); + if (sessionHandle.has_value()) { + captureTargets = BuildTopologyCaptureTargets(m_options); + if (captureTargets.empty()) { + captureTargets = BuildFallbackAndroidCaptureTarget(m_options); + } - const auto eisFd = ConnectToEis(connection, *sessionHandle); - if (!eisFd.has_value()) { - CloseSession(connection, *sessionHandle); - g_object_unref(connection); - m_running = false; - return; - } + const auto zone = GetZones(connection, *sessionHandle); + if (zone.has_value() && + SetPointerBarriers(connection, *sessionHandle, *zone, captureTargets)) { + eisFd = ConnectToEis(connection, *sessionHandle); + if (eisFd.has_value()) { + context = ei_new_receiver(this); + if (context && ei_setup_backend_fd(context, *eisFd) >= 0 && + EnableCapture(connection, *sessionHandle)) { + captureReady = true; + break; + } + if (context) { + ei_unref(context); + context = nullptr; + } + close(*eisFd); + eisFd.reset(); + } + } + CloseSession(connection, *sessionHandle); + sessionHandle.reset(); + } - ei* context = ei_new_receiver(this); - if (!context) { - close(*eisFd); - CloseSession(connection, *sessionHandle); - g_object_unref(connection); - m_running = false; - return; - } - if (ei_setup_backend_fd(context, *eisFd) < 0) { - std::cerr << "WARN: libei input capture failed to initialize EIS fd." << std::endl; - ei_unref(context); - CloseSession(connection, *sessionHandle); - g_object_unref(connection); - m_running = false; - return; } - if (!EnableCapture(connection, *sessionHandle)) { - ei_unref(context); - CloseSession(connection, *sessionHandle); + if (!captureReady) { + if (context) { + ei_unref(context); + } + if (eisFd.has_value()) { + close(*eisFd); + } g_object_unref(connection); + std::cerr << "ERROR: libei input capture handshake failed; automatic retry " + "suppressed to avoid repeated permission prompts. Restart InputFlow " + "to try again." + << std::endl; m_running = false; return; } @@ -1023,7 +1054,6 @@ void LibeiInputCaptureBridge::Run() { << " topology barrier(s)." << std::endl; int remoteX = captureTargets.front().startX; int remoteY = captureTargets.front().startY; - int keyboardEventsLogged = 0; bool remoteControlActive = false; std::optional activeTarget; const std::string androidMachineId = m_options.androidPeerName.empty() ? "android" : m_options.androidPeerName; @@ -1064,8 +1094,8 @@ void LibeiInputCaptureBridge::Run() { return false; } activeTarget = *it; - remoteX = activeTarget->startX; - remoteY = activeTarget->startY; + remoteX = NudgeInward(activeTarget->startX); + remoteY = NudgeInward(activeTarget->startY); std::cout << "[TOPOLOGY] Captured local pointer through " << edgeDirectionName(activeTarget->exitEdge) << " edge for target " << activeTarget->machineId << "." << std::endl; @@ -1180,12 +1210,6 @@ void LibeiInputCaptureBridge::Run() { const auto vk = LinuxKeyToVirtualKey(key); if (vk.has_value() && activeTarget.has_value()) { KeyboardData keyboard{*vk, press ? 0u : LLKHF_UP}; - if (keyboardEventsLogged < 20) { - std::cout << "[TOPOLOGY] libei keyboard event key=" << key - << " vk=" << *vk - << " press=" << (press ? "true" : "false") << std::endl; - ++keyboardEventsLogged; - } sendKeyboard(keyboard); } break; @@ -1224,17 +1248,66 @@ void LibeiInputCaptureBridge::RunLibinputGestureMonitor() { std::cerr << "WARN: Falling back to libinput debug-events gesture parser." << std::endl; #endif - std::cout << "[ANDROID] libinput gesture monitor using " << *device << std::endl; + std::cout << "[ANDROID] Libinput gesture monitor started." << std::endl; while (m_running) { - const std::string command = - "sh -c 'for fd in /proc/$$/fd/[3-9]*; do " - "[ -e \"$fd\" ] && eval \"exec ${fd##*/}<&-\"; " - "done; exec timeout 1s libinput debug-events --device " + *device + " 2>/dev/null'"; - FILE* pipe = popen(command.c_str(), "r"); - if (!pipe) { + constexpr const char* timeoutPath = "/usr/bin/timeout"; + constexpr const char* libinputPath = "/usr/bin/libinput"; + if (access(timeoutPath, X_OK) != 0 || access(libinputPath, X_OK) != 0) { + std::cerr << "WARN: The libinput gesture helper is unavailable." << std::endl; + return; + } + int outputPipe[2]{-1, -1}; + if (pipe(outputPipe) != 0) { std::cerr << "WARN: Failed to start libinput gesture monitor." << std::endl; return; } + const pid_t child = fork(); + if (child < 0) { + close(outputPipe[0]); + close(outputPipe[1]); + std::cerr << "WARN: Failed to start libinput gesture monitor." << std::endl; + return; + } + if (child == 0) { + close(outputPipe[0]); + dup2(outputPipe[1], STDOUT_FILENO); + if (outputPipe[1] > STDERR_FILENO) { + close(outputPipe[1]); + } + const int devNull = open("/dev/null", O_WRONLY); + if (devNull >= 0) { + dup2(devNull, STDERR_FILENO); + if (devNull > STDERR_FILENO) { + close(devNull); + } + } + const long reportedOpenMax = sysconf(_SC_OPEN_MAX); + const int openMax = reportedOpenMax > 3 + ? static_cast(std::min(reportedOpenMax, 4096)) + : 1024; + for (int fd = 3; fd < openMax; ++fd) { + close(fd); + } + execl( + timeoutPath, + timeoutPath, + "1s", + libinputPath, + "debug-events", + "--device", + device->c_str(), + static_cast(nullptr)); + _exit(127); + } + close(outputPipe[1]); + FILE* output = fdopen(outputPipe[0], "r"); + if (output == nullptr) { + close(outputPipe[0]); + int status = 0; + (void)waitpid(child, &status, 0); + std::cerr << "WARN: Failed to read libinput gesture monitor." << std::endl; + return; + } char buffer[512]; bool swipeActive = false; @@ -1244,7 +1317,7 @@ void LibeiInputCaptureBridge::RunLibinputGestureMonitor() { double swipeDy = 0.0; double pinchScale = 1.0; - while (m_running && fgets(buffer, sizeof(buffer), pipe) != nullptr) { + while (m_running && fgets(buffer, sizeof(buffer), output) != nullptr) { const std::string line(buffer); std::istringstream in(line); std::string deviceToken; @@ -1294,7 +1367,10 @@ void LibeiInputCaptureBridge::RunLibinputGestureMonitor() { } } - pclose(pipe); + fclose(output); + int status = 0; + while (waitpid(child, &status, 0) < 0 && errno == EINTR) { + } } } diff --git a/src/LibeiInputCaptureBridge.h b/src/LibeiInputCaptureBridge.h index 6f147bf..2272eea 100644 --- a/src/LibeiInputCaptureBridge.h +++ b/src/LibeiInputCaptureBridge.h @@ -11,6 +11,11 @@ namespace mwb { +// The portal handshake can display an interactive desktop permission dialog. +// Never repeat it automatically: a timeout or dismissal must require an +// explicit service restart before another prompt is requested. +inline constexpr int kLibeiInputCaptureAutomaticSetupAttempts = 1; + struct LibeiInputCaptureBridgeOptions { int desktopWidth{0}; int desktopHeight{0}; diff --git a/src/NetworkManager.cpp b/src/NetworkManager.cpp index 2d9c08e..f548149 100644 --- a/src/NetworkManager.cpp +++ b/src/NetworkManager.cpp @@ -264,7 +264,14 @@ std::optional ResolveConfiguredHostAddress(const std::string& host) { hints.ai_socktype = SOCK_STREAM; struct addrinfo* results = nullptr; - if (getaddrinfo(host.c_str(), nullptr, &hints, &results) != 0 || results == nullptr) { + int status = getaddrinfo(host.c_str(), nullptr, &hints, &results); + if ((status != 0 || results == nullptr) && + host.find('.') == std::string::npos && host != "localhost" && !host.empty()) { + // Bare hostname unresolved — retry the mDNS form (Windows answers .local). + if (results != nullptr) { freeaddrinfo(results); results = nullptr; } + status = getaddrinfo((host + ".local").c_str(), nullptr, &hints, &results); + } + if (status != 0 || results == nullptr) { return std::nullopt; } @@ -589,7 +596,27 @@ bool connectToRemoteSocket(const std::string& host, int port, int& fd) { struct addrinfo* results = nullptr; const std::string service = std::to_string(port); - const int resolveStatus = getaddrinfo(host.c_str(), service.c_str(), &hints, &results); + + // A bare Windows hostname (e.g. "M0491") has no DNS/NetBIOS record on Linux, + // but the same machine answers mDNS as ".local". So if a dotless host + // fails to resolve, transparently retry the mDNS form before giving up. + std::vector candidates{host}; + if (host.find('.') == std::string::npos && + host != "localhost" && !host.empty()) { + candidates.push_back(host + ".local"); + } + + int resolveStatus = EAI_NONAME; + for (const std::string& candidate : candidates) { + resolveStatus = getaddrinfo(candidate.c_str(), service.c_str(), &hints, &results); + if (resolveStatus == 0 && results != nullptr) { + break; + } + if (results != nullptr) { + freeaddrinfo(results); + results = nullptr; + } + } if (resolveStatus != 0 || results == nullptr) { if (resolveStatus != 0) { SetLastConnectError(std::string("name resolution failed: ") + gai_strerror(resolveStatus)); @@ -905,16 +932,14 @@ bool NetworkManager::HasActiveSessionPeer(uint32_t remoteMachineId) { bool NetworkManager::TryAcquireInboundControlSession(int fd, uint32_t remoteMachineId) { std::lock_guard lock(m_inboundControlSessionMutex); if (m_activeInboundControlFd >= 0 && m_activeInboundControlFd != fd) { - std::cerr << "[SERVER] Rejecting authenticated inbound control session for peer 0x" - << std::hex << remoteMachineId - << ": another inbound control session is already active for this host." - << std::dec << std::endl; + std::cerr << "[SERVER] Rejecting authenticated inbound control session: " + "another session is already active." + << std::endl; return false; } if (DebugNetworkLoggingEnabled()) { - std::cout << "[SERVER] Acquired inbound control session for peer 0x" - << std::hex << remoteMachineId << std::dec << std::endl; + std::cout << "[SERVER] Acquired authenticated inbound control session." << std::endl; } m_activeInboundControlFd = fd; m_activeInboundControlRemoteMachineId = remoteMachineId; @@ -978,8 +1003,8 @@ bool NetworkManager::TryRefreshHostFromResolver() { if (m_host == *resolved) { return false; } - std::cout << "[RECONNECT] Peer address changed to " << *resolved - << " via rediscovery; resetting backoff." << std::endl; + std::cout << "[RECONNECT] Peer address changed via rediscovery; resetting backoff." + << std::endl; m_host = *resolved; } @@ -1147,13 +1172,11 @@ bool NetworkManager::ConnectOutbound(std::array& handshakeChallenge const std::string host = HostSnapshot(); if (!connectToRemoteSocket(host, m_port, m_socket)) { - std::cerr << "[OUTBOUND] connect to " << host << ":" << m_port - << " failed: " << GetLastConnectError() << std::endl; + std::cerr << "[OUTBOUND] Connection failed: " << GetLastConnectError() << std::endl; return false; } - printf("[OUTBOUND] Connected to %s:%d\n", host.c_str(), m_port); - fflush(stdout); + std::cout << "[OUTBOUND] Connected to the approved peer." << std::endl; if (const auto peerAddress = GetPeerIpv4Address(m_socket); peerAddress.has_value()) { m_expectedPeerAddress = *peerAddress; @@ -1698,8 +1721,8 @@ void NetworkManager::RequestRemoteClipboard(uint32_t expectedRemoteMachineId) { const uint32_t remoteDestination = le32toh(remoteHeader.des); if (remoteSource != expectedRemoteMachineId || !packetTargetsMachine(remoteDestination, m_myId)) { - std::cerr << "WARN: Rejecting clipboard pull handshake from unexpected remote peer 0x" - << std::hex << remoteSource << std::dec << std::endl; + std::cerr << "WARN: Rejecting clipboard pull handshake from an unexpected peer." + << std::endl; closeSocket(fd); return; } @@ -1790,8 +1813,8 @@ void NetworkManager::PushClipboardToRemote(uint32_t expectedRemoteMachineId) { const uint32_t remoteDestination = le32toh(remoteHeader.des); if (remoteSource != expectedRemoteMachineId || !packetTargetsMachine(remoteDestination, m_myId)) { - std::cerr << "WARN: Rejecting clipboard push handshake from unexpected remote peer 0x" - << std::hex << remoteSource << std::dec << std::endl; + std::cerr << "WARN: Rejecting clipboard push handshake from an unexpected peer." + << std::endl; closeSocket(fd); return; } @@ -2209,8 +2232,8 @@ void NetworkManager::ServerListenerLoop() { } if (!AddressMatchesConfiguredHost(clientAddress, HostSnapshot(), m_expectedPeerAddress.load())) { - std::cerr << "[SERVER] Rejected inbound connection from unexpected peer " - << FormatIpv4Address(clientAddress.sin_addr) << std::endl; + std::cerr << "[SERVER] Rejected inbound connection from an unexpected peer." + << std::endl; closeSocket(clientFd); continue; } @@ -2291,7 +2314,6 @@ void NetworkManager::HandleWindowsConnection(int fd) { return; } - int handshakePackets = 0; bool trusted = false; uint32_t remoteMachineId = 0; @@ -2311,10 +2333,9 @@ void NetworkManager::HandleWindowsConnection(int fd) { const uint32_t destination = le32toh(packet.des); if (packet.type == static_cast(PackageType::Handshake)) { - ++handshakePackets; if (!packetTargetsMachine(destination, m_myId)) { - std::cerr << "[SERVER] Ignoring handshake packet for unexpected destination 0x" - << std::hex << destination << std::dec << std::endl; + std::cerr << "[SERVER] Ignoring handshake packet for an unexpected destination." + << std::endl; continue; } if (source != 0) { @@ -2363,8 +2384,7 @@ void NetworkManager::HandleWindowsConnection(int fd) { } ownsInboundControlSession = true; if (DebugNetworkLoggingEnabled()) { - std::cout << "[SERVER] Inbound handshake trusted peer 0x" - << std::hex << remoteMachineId << std::dec << std::endl; + std::cout << "[SERVER] Inbound handshake authenticated an approved peer." << std::endl; } RegisterActiveSessionPeer(remoteMachineId); MWBPacket heartbeat; @@ -2421,9 +2441,8 @@ void NetworkManager::HandleWindowsConnection(int fd) { } else if (type == static_cast(PackageType::Handshake) && packetTargetsMachine(destination, m_myId)) { if (!controlSourceMatchesBoundPeer) { - std::cerr << "[SERVER] Ignoring control handshake that claims a different peer id 0x" - << std::hex << source << " for active peer 0x" << remoteMachineId - << std::dec << std::endl; + std::cerr << "[SERVER] Ignoring control handshake with a mismatched peer id." + << std::endl; continue; } MWBPacket ack; @@ -2439,9 +2458,8 @@ void NetworkManager::HandleWindowsConnection(int fd) { type == static_cast(PackageType::Awake)) && packetTargetsMachine(destination, m_myId)) { if (!controlSourceMatchesBoundPeer) { - std::cerr << "[SERVER] Ignoring control hello that claims a different peer id 0x" - << std::hex << source << " for active peer 0x" << remoteMachineId - << std::dec << std::endl; + std::cerr << "[SERVER] Ignoring control hello with a mismatched peer id." + << std::endl; continue; } MWBPacket heartbeat; diff --git a/src/PeerRecovery.cpp b/src/PeerRecovery.cpp index b23509c..4095a1d 100644 --- a/src/PeerRecovery.cpp +++ b/src/PeerRecovery.cpp @@ -307,9 +307,7 @@ std::optional RecoverConfiguredHostFromKnownPeers(const AppConfig& const auto knownPeerHosts = CollectRecoveryCandidateHosts(state, config.host, config.port); for (const auto& host : knownPeerHosts) { if (auto reachable = ProbeReachableIpv4Host(host, config.port, 250)) { - std::cout << "[RECOVERY] Configured peer " << config.host - << " has a verified same-name address " - << *reachable; + std::cout << "[RECOVERY] Found a verified approved peer address"; if (configuredHostIsIpv4) { std::cout << "; using name-priority recovery before trusting the configured IP"; } else { @@ -337,16 +335,12 @@ std::optional RecoverConfiguredHostFromKnownPeers(const AppConfig& !HostLabelsMatch(candidate.hostName, config.host)) { continue; } - std::cout << "[RECOVERY] Configured peer " << config.host - << " resolved by LAN discovery to " - << candidate.ipAddress << " (name=" << candidate.hostName << ")" << std::endl; + std::cout << "[RECOVERY] Resolved the approved peer through LAN discovery." << std::endl; return candidate.ipAddress; } } for (const auto& host : CollectRecoveryDiscoveredHosts(state, config.host, config.port, candidates)) { - std::cout << "[RECOVERY] Configured peer " << config.host - << " is unavailable; using discovered address " - << host << " for the approved peer name" << std::endl; + std::cout << "[RECOVERY] Using the verified address of an approved peer." << std::endl; return host; } @@ -377,9 +371,7 @@ std::optional RecoverConfiguredHostFromKnownPeers(const AppConfig& continue; } if (HostLabelsMatch(candidate.hostName, solePeer->name)) { - std::cout << "[RECOVERY] Stale configured IP " << config.host - << " is unreachable; sole approved peer '" << solePeer->name - << "' rediscovered at " << candidate.ipAddress << std::endl; + std::cout << "[RECOVERY] Rediscovered the sole approved peer." << std::endl; return candidate.ipAddress; } } diff --git a/src/SecureFile.cpp b/src/SecureFile.cpp new file mode 100644 index 0000000..9196378 --- /dev/null +++ b/src/SecureFile.cpp @@ -0,0 +1,125 @@ +#include "SecureFile.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace mwb { +namespace { + +std::string ErrorText(const std::string& action, const std::filesystem::path& path, int errorNumber) { + return action + " '" + path.string() + "': " + std::strerror(errorNumber); +} + +} // namespace + +bool WritePrivateFileAtomically(const std::filesystem::path& path, + std::string_view contents, + std::string& errorMessage) { + if (path.empty() || path.filename().empty()) { + errorMessage = "Private file path must include a file name."; + return false; + } + + const std::filesystem::path parent = + path.parent_path().empty() ? std::filesystem::path(".") : path.parent_path(); + std::error_code filesystemError; + const bool parentExisted = std::filesystem::exists(parent, filesystemError); + if (filesystemError) { + errorMessage = "Could not inspect private file directory '" + parent.string() + + "': " + filesystemError.message(); + return false; + } + + std::filesystem::create_directories(parent, filesystemError); + if (filesystemError) { + errorMessage = "Could not create private file directory '" + parent.string() + + "': " + filesystemError.message(); + return false; + } + if (!parentExisted) { + std::filesystem::permissions( + parent, + std::filesystem::perms::owner_all, + std::filesystem::perm_options::replace, + filesystemError); + if (filesystemError) { + errorMessage = "Could not secure private file directory '" + parent.string() + + "': " + filesystemError.message(); + return false; + } + } + + const auto destinationStatus = std::filesystem::symlink_status(path, filesystemError); + if (filesystemError && + filesystemError != std::make_error_code(std::errc::no_such_file_or_directory)) { + errorMessage = "Could not inspect private file destination '" + path.string() + + "': " + filesystemError.message(); + return false; + } + if (!filesystemError && std::filesystem::exists(destinationStatus) && + !std::filesystem::is_regular_file(destinationStatus)) { + errorMessage = "Refusing to replace non-regular private file destination: " + path.string(); + return false; + } + + static std::atomic sequence{0}; + const std::filesystem::path temporary = + parent / ("." + path.filename().string() + ".tmp." + std::to_string(getpid()) + "." + + std::to_string(sequence.fetch_add(1, std::memory_order_relaxed))); + const int descriptor = open( + temporary.c_str(), + O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, + S_IRUSR | S_IWUSR); + if (descriptor < 0) { + errorMessage = ErrorText("Could not create temporary private file", temporary, errno); + return false; + } + + bool success = true; + std::size_t offset = 0; + while (offset < contents.size()) { + const ssize_t written = write(descriptor, contents.data() + offset, contents.size() - offset); + if (written < 0 && errno == EINTR) { + continue; + } + if (written <= 0) { + errorMessage = ErrorText("Could not write temporary private file", temporary, errno); + success = false; + break; + } + offset += static_cast(written); + } + if (success && fsync(descriptor) != 0) { + errorMessage = ErrorText("Could not sync temporary private file", temporary, errno); + success = false; + } + if (close(descriptor) != 0 && success) { + errorMessage = ErrorText("Could not close temporary private file", temporary, errno); + success = false; + } + + if (!success) { + unlink(temporary.c_str()); + return false; + } + if (rename(temporary.c_str(), path.c_str()) != 0) { + errorMessage = ErrorText("Could not atomically replace private file", path, errno); + unlink(temporary.c_str()); + return false; + } + + const int parentDescriptor = open(parent.c_str(), O_RDONLY | O_DIRECTORY | O_CLOEXEC); + if (parentDescriptor >= 0) { + (void)fsync(parentDescriptor); + (void)close(parentDescriptor); + } + return true; +} + +} // namespace mwb diff --git a/src/SecureFile.h b/src/SecureFile.h new file mode 100644 index 0000000..3e9f673 --- /dev/null +++ b/src/SecureFile.h @@ -0,0 +1,16 @@ +#pragma once + +#include +#include +#include + +namespace mwb { + +// Atomically replaces a regular file with owner-only permissions. Symlink and +// special-file destinations are rejected so config/state writes cannot be +// redirected outside the intended application directory. +bool WritePrivateFileAtomically(const std::filesystem::path& path, + std::string_view contents, + std::string& errorMessage); + +} // namespace mwb diff --git a/src/TrayController.cpp b/src/TrayController.cpp index 8c38ee8..62e4f32 100644 --- a/src/TrayController.cpp +++ b/src/TrayController.cpp @@ -69,21 +69,47 @@ struct TrayContext { #endif }; -std::optional RunCommandCapture(const std::string& command) { - std::array buffer{}; - std::string output; - - FILE* pipe = popen(command.c_str(), "r"); - if (pipe == nullptr) { +std::optional RunCommandCapture(const std::vector& argv) { + if (argv.empty()) { return std::nullopt; } - - while (fgets(buffer.data(), static_cast(buffer.size()), pipe) != nullptr) { - output.append(buffer.data()); + std::vector args; + args.reserve(argv.size() + 1); + for (const auto& arg : argv) { + args.push_back(const_cast(arg.c_str())); } + args.push_back(nullptr); - const int rc = pclose(pipe); - if (rc != 0 && output.empty()) { + gchar* stdoutData = nullptr; + gchar* stderrData = nullptr; + gint waitStatus = 0; + GError* error = nullptr; + const gboolean spawned = g_spawn_sync( + nullptr, + args.data(), + nullptr, + G_SPAWN_SEARCH_PATH, + nullptr, + nullptr, + &stdoutData, + &stderrData, + &waitStatus, + &error); + std::string output = stdoutData == nullptr ? "" : stdoutData; + g_free(stdoutData); + g_free(stderrData); + if (!spawned) { + if (error != nullptr) { + g_error_free(error); + } + return std::nullopt; + } + GError* waitError = nullptr; + const bool succeeded = g_spawn_check_wait_status(waitStatus, &waitError); + if (waitError != nullptr) { + g_error_free(waitError); + } + if (!succeeded && output.empty()) { return std::nullopt; } @@ -93,6 +119,11 @@ std::optional RunCommandCapture(const std::string& command) { return output; } +bool ServiceProcessAppearsRunning() { + const auto result = RunCommandCapture({"pgrep", "-f", "[/]mwb_client run --config"}); + return result.has_value() && !result->empty(); +} + std::int64_t CurrentEpochSeconds() { return std::chrono::duration_cast( std::chrono::system_clock::now().time_since_epoch()) @@ -109,11 +140,48 @@ void SaveStateOrLog(const std::string& statePath, const mwb::AppState& state) { // Lock the local desktop session. Tries logind first (works headless and on // most modern desktops), then falls back to common screen lockers. void LockLocalSession() { - if (std::system("loginctl lock-session >/dev/null 2>&1") == 0) { - return; + const std::vector> commands{ + {"loginctl", "lock-session"}, + {"loginctl", "lock-sessions"}, + {"xdg-screensaver", "lock"}, + {"qdbus", "org.freedesktop.ScreenSaver", "/ScreenSaver", "Lock"}, + }; + for (const auto& command : commands) { + std::vector args; + args.reserve(command.size() + 1); + for (const auto& arg : command) { + args.push_back(const_cast(arg.c_str())); + } + args.push_back(nullptr); + gint waitStatus = 0; + GError* error = nullptr; + const gboolean spawned = g_spawn_sync( + nullptr, + args.data(), + nullptr, + static_cast( + G_SPAWN_SEARCH_PATH | + G_SPAWN_STDOUT_TO_DEV_NULL | + G_SPAWN_STDERR_TO_DEV_NULL), + nullptr, + nullptr, + nullptr, + nullptr, + &waitStatus, + &error); + if (error != nullptr) { + g_error_free(error); + } + GError* waitError = nullptr; + const bool succeeded = + spawned && g_spawn_check_wait_status(waitStatus, &waitError); + if (waitError != nullptr) { + g_error_free(waitError); + } + if (succeeded) { + return; + } } - (void)std::system("(loginctl lock-sessions || xdg-screensaver lock || " - "qdbus org.freedesktop.ScreenSaver /ScreenSaver Lock) >/dev/null 2>&1"); } bool SpawnAsync(const std::vector& argv) { @@ -218,7 +286,7 @@ int AcquireSingleInstanceLock() { return -1; } -std::string ResolveControllerPath() { +[[maybe_unused]] std::string ResolveControllerPath() { std::vector candidates; if (const auto exePath = ResolveExecutablePath()) { const auto exeDir = exePath->parent_path(); @@ -281,11 +349,22 @@ std::string ResolveIconThemePath() { } std::string QueryServiceState() { + const bool daemonRunning = ServiceProcessAppearsRunning(); if (access(kSystemctlPath, X_OK) != 0) { - return "unknown"; + return daemonRunning ? "active" : "inactive"; + } + const auto result = RunCommandCapture( + {kSystemctlPath, "--user", "is-active", kServiceName}); + if (!result.has_value() || result->empty() || *result == "unknown") { + return daemonRunning ? "active" : "inactive"; } - const auto result = RunCommandCapture(std::string(kSystemctlPath) + " --user is-active " + std::string(kServiceName) + " 2>/dev/null"); - return result.value_or("unknown"); + if ((*result == "active" || *result == "deactivating") && !daemonRunning) { + return "inactive"; + } + if (*result == "inactive" && daemonRunning) { + return "active"; + } + return *result; } std::string DescribeState(const std::string& state) { @@ -357,7 +436,7 @@ bool ShouldShowStartupHint() { return isatty(STDIN_FILENO) != 0; } -void MaybeShowStartupHint(const TrayContext& context) { +[[maybe_unused]] void MaybeShowStartupHint(const TrayContext& context) { if (!ShouldShowStartupHint()) { return; } @@ -498,10 +577,15 @@ bool LaunchController(TrayContext* context, const std::vector& cont return false; } +gboolean RefreshStatusOnce(gpointer userData); + void RunServiceAction(TrayContext* context, const std::string& action, const std::string& optimisticState) { if (access(kSystemctlPath, X_OK) == 0 && SpawnAsync({kSystemctlPath, "--user", action, kServiceName})) { UpdateIndicatorVisuals(context, optimisticState); + g_timeout_add_seconds(1, RefreshStatusOnce, context); + g_timeout_add_seconds(3, RefreshStatusOnce, context); + g_timeout_add_seconds(8, RefreshStatusOnce, context); return; } @@ -518,6 +602,12 @@ gboolean RefreshStatus(gpointer userData) { return G_SOURCE_CONTINUE; } +gboolean RefreshStatusOnce(gpointer userData) { + auto* context = static_cast(userData); + UpdateIndicatorVisuals(context, QueryServiceState()); + return G_SOURCE_REMOVE; +} + void OnOpenController(GtkMenuItem*, gpointer userData) { auto* context = static_cast(userData); #ifdef MWB_HAVE_GTK_GUI @@ -697,10 +787,17 @@ void BuildTrayMenu(TrayContext& context) { AddMenuItem(menu, "Quit", G_CALLBACK(OnQuit), &context); gtk_widget_show_all(menu); +#if defined(__GNUC__) +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" +#endif context.indicator = app_indicator_new( kIndicatorId, context.iconThemePath.empty() ? "network-idle-symbolic" : "inputflow-tray", APP_INDICATOR_CATEGORY_APPLICATION_STATUS); +#if defined(__GNUC__) +#pragma GCC diagnostic pop +#endif if (!context.iconThemePath.empty()) { app_indicator_set_icon_theme_path(context.indicator, context.iconThemePath.c_str()); app_indicator_set_attention_icon_full(context.indicator, "inputflow-tray-attention", "InputFlow needs attention"); @@ -714,7 +811,7 @@ void BuildTrayMenu(TrayContext& context) { UpdateIndicatorVisuals(&context, QueryServiceState()); app_indicator_set_status(context.indicator, APP_INDICATOR_STATUS_ACTIVE); - g_timeout_add_seconds(30, RefreshStatus, &context); + g_timeout_add_seconds(5, RefreshStatus, &context); } } // namespace @@ -783,6 +880,8 @@ int RunTrayAndGui(const std::string& binary, const AppConfig& config, const std::string& configPath, const std::string& statePath) { + (void)binary; + (void)args; const int instanceLockFd = AcquireSingleInstanceLock(); if (instanceLockFd == -2) { std::cerr << "InputFlow GUI is already running." << std::endl; diff --git a/src/main.cpp b/src/main.cpp index 3e52923..8dd778e 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -24,6 +24,7 @@ #include #include #include +#include #include #include #include @@ -128,8 +129,41 @@ std::filesystem::path DefaultUserServicePath() { return std::filesystem::current_path() / "mwb-client.service"; } -bool CommandSucceeds(const std::string& command) { - return std::system((command + " >/dev/null 2>&1").c_str()) == 0; +bool CommandSucceeds(std::initializer_list command) { + if (command.size() == 0) { + return false; + } + std::vector argv; + argv.reserve(command.size() + 1); + for (const char* argument : command) { + argv.push_back(const_cast(argument)); + } + argv.push_back(nullptr); + + const pid_t child = fork(); + if (child < 0) { + return false; + } + if (child == 0) { + const int devNull = open("/dev/null", O_RDWR); + if (devNull >= 0) { + dup2(devNull, STDOUT_FILENO); + dup2(devNull, STDERR_FILENO); + if (devNull > STDERR_FILENO) { + close(devNull); + } + } + execvp(argv.front(), argv.data()); + _exit(127); + } + + int status = 0; + while (waitpid(child, &status, 0) < 0) { + if (errno != EINTR) { + return false; + } + } + return WIFEXITED(status) && WEXITSTATUS(status) == 0; } std::string GroupName(gid_t gid) { @@ -990,9 +1024,7 @@ std::optional TryRecoverHostFromKnownPeers(const mwb::AppConfig& co const auto knownPeerHosts = mwb::CollectRecoveryCandidateHosts(state, config.host, config.port); for (const auto& host : knownPeerHosts) { if (auto reachable = ProbeReachableIpv4Host(host, config.port, 250)) { - std::cout << "[RECOVERY] Configured peer " << config.host - << " has a verified same-name address " - << *reachable; + std::cout << "[RECOVERY] Found a verified approved peer address"; if (configuredHostIsIpv4) { std::cout << "; using name-priority recovery before trusting the configured IP"; } else { @@ -1020,16 +1052,12 @@ std::optional TryRecoverHostFromKnownPeers(const mwb::AppConfig& co !mwb::HostLabelsMatch(candidate.hostName, config.host)) { continue; } - std::cout << "[RECOVERY] Configured peer " << config.host - << " resolved by LAN discovery to " - << candidate.ipAddress << " (name=" << candidate.hostName << ")" << std::endl; + std::cout << "[RECOVERY] Resolved the approved peer through LAN discovery." << std::endl; return candidate.ipAddress; } } for (const auto& host : mwb::CollectRecoveryDiscoveredHosts(state, config.host, config.port, candidates)) { - std::cout << "[RECOVERY] Configured peer " << config.host - << " is unavailable; using discovered address " - << host << " for the approved peer name" << std::endl; + std::cout << "[RECOVERY] Using the verified address of an approved peer." << std::endl; return host; } @@ -1877,7 +1905,9 @@ int HandleDoctorCommand(const std::vector& args) { if (!FindExecutableInPath("busctl")) { PrintDoctorLine("INFO", "xdg portal", "busctl unavailable; portal service not checked"); - } else if (CommandSucceeds("busctl --user --no-pager status org.freedesktop.portal.Desktop")) { + } else if (CommandSucceeds( + {"busctl", "--user", "--no-pager", "status", + "org.freedesktop.portal.Desktop"})) { PrintDoctorLine("OK", "xdg portal", "org.freedesktop.portal.Desktop is reachable"); } else if (FileExistsAny({"/usr/libexec/xdg-desktop-portal", "/usr/lib/xdg-desktop-portal"})) { PrintDoctorLine("WARN", "xdg portal", "portal executable is installed but the user service is not reachable"); @@ -1888,11 +1918,13 @@ int HandleDoctorCommand(const std::vector& args) { const std::filesystem::path unitPath = DefaultUserServicePath(); PrintDoctorLine(std::filesystem::exists(unitPath) ? "OK" : "INFO", "user service", unitPath.string()); if (FindExecutableInPath("systemctl")) { - if (!CommandSucceeds("systemctl --user show-environment")) { + if (!CommandSucceeds({"systemctl", "--user", "show-environment"})) { PrintDoctorLine("WARN", "service state", "systemctl --user is unavailable in this environment"); - } else if (CommandSucceeds("systemctl --user is-active --quiet mwb-client.service")) { + } else if (CommandSucceeds( + {"systemctl", "--user", "is-active", "--quiet", "mwb-client.service"})) { PrintDoctorLine("OK", "service state", "mwb-client.service is active"); - } else if (CommandSucceeds("systemctl --user is-enabled --quiet mwb-client.service")) { + } else if (CommandSucceeds( + {"systemctl", "--user", "is-enabled", "--quiet", "mwb-client.service"})) { PrintDoctorLine("INFO", "service state", "mwb-client.service is enabled but not active"); } else { PrintDoctorLine("INFO", "service state", "mwb-client.service is not active/enabled for this user"); @@ -2192,8 +2224,9 @@ int HandleExportWindowsPairCommand(const std::vector& args) { } else if (const auto detected = DetectOutboundLocalIpv4(config.host, config.port); detected.has_value()) { linuxIp = *detected; } else { - std::cerr << "ERR: Failed to detect the Linux IPv4 address for host '" << config.host - << "'. Pass --linux-ip explicitly." << std::endl; + std::cerr << "ERR: Failed to detect the Linux IPv4 address for the configured host. " + "Pass --linux-ip explicitly." + << std::endl; return 1; } @@ -2547,7 +2580,7 @@ int HandleInstallUserServiceCommand(const std::vector& args) { << "[Service]\n" << "Type=simple\n" << "ExecStart=" << executablePath.string() << " run --config " << configPath.string() << "\n" - << "Restart=always\n" + << "Restart=on-failure\n" << "RestartSec=3\n\n" << "[Install]\n" << "WantedBy=default.target\n"; diff --git a/tests/test_diagnostics_privacy.sh b/tests/test_diagnostics_privacy.sh new file mode 100755 index 0000000..dd183a7 --- /dev/null +++ b/tests/test_diagnostics_privacy.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_PATH="${1:?usage: test_diagnostics_privacy.sh SCRIPT_PATH}" +TEST_ROOT="$(mktemp -d)" +trap 'rm -rf "$TEST_ROOT"' EXIT + +CONFIG_PATH="$TEST_ROOT/config.ini" +STATE_PATH="$TEST_ROOT/state.ini" +OUTPUT_PATH="$TEST_ROOT/output" +SECRET_MARKER="InputFlowSecret-MUST-NOT-LEAK-9482" +HOST_MARKER="private-laptop-must-not-leak" +USER_MARKER="private-user-must-not-leak" +IP_MARKER="203.0.113.77" +MAC_MARKER="02:11:22:33:44:55" + +mkdir -p "$OUTPUT_PATH" +cat >"$CONFIG_PATH" <"$STATE_PATH" <&2 + exit 1 +fi + +grep -F '"automatic_upload": false' "$BUNDLE_PATH/consent.json" >/dev/null +grep -F '"service_journal": false' "$BUNDLE_PATH/consent.json" >/dev/null +grep -F '"network_details": false' "$BUNDLE_PATH/consent.json" >/dev/null +grep -F 'Host: [REDACTED]' "$BUNDLE_PATH/README.txt" >/dev/null +grep -F 'User: [REDACTED]' "$BUNDLE_PATH/README.txt" >/dev/null + +while IFS= read -r file; do + [[ "$(stat -c '%a' "$file")" == "600" ]] +done < <(find "$BUNDLE_PATH" -type f -print) + +echo "diagnostics privacy checks passed" diff --git a/tests/test_main.cpp b/tests/test_main.cpp index 10e7c4d..12b6859 100644 --- a/tests/test_main.cpp +++ b/tests/test_main.cpp @@ -7,6 +7,7 @@ #include "AppConfig.h" #include "AppState.h" #include "Discovery.h" +#include "LibeiInputCaptureBridge.h" #include "PeerRecovery.h" #include "ReconnectPolicy.h" #include "ScreenGeometry.h" @@ -185,6 +186,63 @@ void TestAndroidRelayFrames() { Expect(keyboardFrame.find("\"type\":\"keyboard\"") != std::string::npos, "Android keyboard frame should include type"); Expect(keyboardFrame.find("\"vkCode\":65") != std::string::npos, "Android keyboard frame should include vkCode"); Expect(keyboardFrame.find("\"flags\":128") != std::string::npos, "Android keyboard frame should include flags"); + + constexpr const char* message = "The quick brown fox jumps over the lazy dog"; + constexpr const char* validHmac = + "f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8"; + Expect( + mwb::VerifyAndroidRelayHmac("key", message, validHmac), + "Android relay should accept a valid HMAC-SHA256 authenticator"); + Expect( + !mwb::VerifyAndroidRelayHmac("key", message, std::string(64, '0')), + "Android relay should reject an incorrect authenticator"); + Expect( + !mwb::VerifyAndroidRelayHmac("key", message, "short"), + "Android relay should reject an authenticator with the wrong length"); + + constexpr const char* nonce = "00112233445566778899aabbccddeeff"; + const auto encrypted = mwb::EncryptAndroidRelayFrameForTest( + keyboardFrame, + "0123456789abcdef0123456789abcdef", + nonce, + 0, + true); + Expect(encrypted.has_value(), "Android relay should encrypt a valid frame"); + if (encrypted.has_value()) { + Expect( + encrypted->find("\"keyboard\"") == std::string::npos, + "Encrypted Android relay frames must not expose input metadata"); + const auto decrypted = mwb::DecryptAndroidRelayFrameForTest( + *encrypted, + "0123456789abcdef0123456789abcdef", + nonce, + 0, + true); + Expect( + decrypted == std::optional(keyboardFrame), + "Android relay encrypted frames should round-trip"); + Expect( + !mwb::DecryptAndroidRelayFrameForTest( + *encrypted, + "0123456789abcdef0123456789abcdef", + nonce, + 1, + true) + .has_value(), + "Android relay should reject a replayed sequence"); + + std::string tampered = *encrypted; + tampered.back() ^= 1; + Expect( + !mwb::DecryptAndroidRelayFrameForTest( + tampered, + "0123456789abcdef0123456789abcdef", + nonce, + 0, + true) + .has_value(), + "Android relay should reject tampered ciphertext"); + } } void TestAppConfigKeyFileRoundTrip() { @@ -408,6 +466,54 @@ void TestAppStateRoundTrip() { std::filesystem::remove(path, ignore); } +void TestPrivateFileSecurity() { + const std::filesystem::path root = MakeTempPath("mwb-private-file-security"); + std::error_code ignore; + std::filesystem::remove_all(root, ignore); + std::filesystem::create_directories(root); + + mwb::AppConfig config; + config.host = "192.0.2.50"; + config.key = "private-test-key"; + const std::filesystem::path configPath = root / "config.ini"; + std::string error; + Expect(mwb::WriteAppConfig(configPath, config, &error), + "WriteAppConfig should atomically write a private file"); + + const auto configPermissions = std::filesystem::status(configPath).permissions(); + const auto nonOwnerPermissions = + std::filesystem::perms::group_all | std::filesystem::perms::others_all; + Expect((configPermissions & nonOwnerPermissions) == std::filesystem::perms::none, + "Config file must not grant group or other permissions"); + + mwb::AppState state; + state.localMachineId = 0x10203040; + const std::filesystem::path statePath = root / "state.ini"; + Expect(mwb::SaveAppState(statePath, state, error), + "SaveAppState should atomically write a private file"); + const auto statePermissions = std::filesystem::status(statePath).permissions(); + Expect((statePermissions & nonOwnerPermissions) == std::filesystem::perms::none, + "State file must not grant group or other permissions"); + + const std::filesystem::path protectedTarget = root / "protected-target.txt"; + { + std::ofstream target(protectedTarget); + target << "must remain unchanged"; + } + const std::filesystem::path symlinkPath = root / "config-link.ini"; + std::filesystem::create_symlink(protectedTarget, symlinkPath); + error.clear(); + Expect(!mwb::WriteAppConfig(symlinkPath, config, &error), + "WriteAppConfig must reject a symlink destination"); + std::ifstream protectedInput(protectedTarget); + std::string protectedContents; + std::getline(protectedInput, protectedContents); + Expect(protectedContents == "must remain unchanged", + "Rejected symlink write must not alter its target"); + + std::filesystem::remove_all(root, ignore); +} + void TestEnsureLocalMachineIdStable() { mwb::AppState state; const uint32_t first = mwb::EnsureLocalMachineId(state); @@ -660,6 +766,11 @@ void TestKScreenDoctorParserIgnoresAnsiSequences() { "KScreen parser should ignore ANSI color codes"); } +void TestLibeiPortalPermissionIsNotAutomaticallyRetried() { + Expect(mwb::kLibeiInputCaptureAutomaticSetupAttempts == 1, + "interactive libei portal setup must issue at most one automatic request"); +} + } // namespace int main() { @@ -672,6 +783,7 @@ int main() { TestParseAppConfigKeyFileOverridesInlineKey(); TestParseAppConfigKeySecretIdOverridesKeyAndKeyFile(); TestAppStateRoundTrip(); + TestPrivateFileSecurity(); TestEnsureLocalMachineIdStable(); TestUpsertPeerState(); TestSessionStateTransitions(); @@ -687,6 +799,7 @@ int main() { TestKScreenDoctorSingleOutputGeometry(); TestKScreenDoctorMultiOutputBoundingBox(); TestKScreenDoctorParserIgnoresAnsiSequences(); + TestLibeiPortalPermissionIsNotAutomaticallyRetried(); if (g_failures == 0) { std::cout << "All tests passed." << std::endl; diff --git a/tests/test_portable_archive.sh b/tests/test_portable_archive.sh new file mode 100755 index 0000000..4be4df6 --- /dev/null +++ b/tests/test_portable_archive.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +set -euo pipefail + +ARCHIVE="${1:?usage: test_portable_archive.sh ARCHIVE}" +CHECKSUM="${ARCHIVE}.sha256" + +[[ -f "$ARCHIVE" ]] || { echo "missing archive: $ARCHIVE" >&2; exit 1; } +[[ -f "$CHECKSUM" ]] || { echo "missing checksum: $CHECKSUM" >&2; exit 1; } + +ARCHIVE_DIR="$(cd "$(dirname "$ARCHIVE")" && pwd -P)" +ARCHIVE_NAME="$(basename "$ARCHIVE")" +(cd "$ARCHIVE_DIR" && sha256sum --check "$(basename "$CHECKSUM")") + +TEST_ROOT="$(mktemp -d)" +cleanup() { + if [[ -n "$TEST_ROOT" && -d "$TEST_ROOT" && "$TEST_ROOT" != "/" ]]; then + rm -rf -- "$TEST_ROOT" + fi +} +trap cleanup EXIT + +tar -xzf "$ARCHIVE" -C "$TEST_ROOT" +mapfile -t PACKAGE_ROOTS < <(find "$TEST_ROOT" -mindepth 1 -maxdepth 1 -type d) +[[ "${#PACKAGE_ROOTS[@]}" -eq 1 ]] || { + echo "archive must contain exactly one package root" >&2 + exit 1 +} +PACKAGE_ROOT="${PACKAGE_ROOTS[0]}" + +for executable in \ + bin/mwb_client \ + bin/mwb_tray \ + bin/inputflow-controller \ + libexec/inputflow/mwb-desktop-ui.sh \ + libexec/inputflow/scripts/inputflow-diagnostics-bundle.sh; do + [[ -x "$PACKAGE_ROOT/$executable" ]] || { + echo "missing executable: $executable" >&2 + exit 1 + } +done + +mkdir -p "$TEST_ROOT/home" "$TEST_ROOT/output" +HOME="$TEST_ROOT/home" \ +XDG_CONFIG_HOME="$TEST_ROOT/home/config" \ +XDG_STATE_HOME="$TEST_ROOT/home/state" \ +PATH="$PACKAGE_ROOT/bin:$PATH" \ + "$PACKAGE_ROOT/bin/mwb_client" --help >/dev/null +HOME="$TEST_ROOT/home" \ +XDG_CONFIG_HOME="$TEST_ROOT/home/config" \ +XDG_STATE_HOME="$TEST_ROOT/home/state" \ +PATH="$PACKAGE_ROOT/bin:$PATH" \ + "$PACKAGE_ROOT/libexec/inputflow/scripts/inputflow-diagnostics-bundle.sh" \ + --preview --output "$TEST_ROOT/output" >/dev/null + +echo "portable archive smoke test passed" diff --git a/tests/test_protocol_security.cpp b/tests/test_protocol_security.cpp index 00787da..f364629 100644 --- a/tests/test_protocol_security.cpp +++ b/tests/test_protocol_security.cpp @@ -277,6 +277,15 @@ void TestClipboardPayloadInvalidDeflateReturnsNullopt() { "Clipboard text decode should continue rejecting malformed compressed data"); } +void TestClipboardUnicodeRoundTrip() { + const std::string text = u8"InputFlow café — 设备 🫏"; + const auto decoded = + mwb::ClipboardManager::DecodeTextPayload(mwb::ClipboardManager::EncodeTextPayload(text)); + Expect( + decoded == std::optional(text), + "Clipboard payload should round-trip BMP and supplementary Unicode text"); +} + } // namespace int main() { @@ -292,6 +301,7 @@ int main() { TestClipboardPayloadPrefersPlainTextOverHtml(); TestClipboardDecodeNormalizesOffsetBasedCfHtml(); TestClipboardPayloadInvalidDeflateReturnsNullopt(); + TestClipboardUnicodeRoundTrip(); if (g_failures != 0) { std::cerr << g_failures << " protocol security test(s) failed." << std::endl; diff --git a/tools/icon-generator/build-masters.js b/tools/icon-generator/build-masters.js index 8fa3a94..2115cf1 100644 --- a/tools/icon-generator/build-masters.js +++ b/tools/icon-generator/build-masters.js @@ -7,11 +7,10 @@ const { wrap, donkeyColor, donkeyMono, cursor, gradientDefs } = require('./donke const OUT = path.join(__dirname, 'masters'); fs.mkdirSync(OUT, { recursive: true }); -// corner status badge: a solid disc with a symbol knocked out (evenodd), plus a -// transparent gap ring so it separates from the donkey under any tint. -function badge(symbolPath) { +// corner status badge sized for tiny system-tray slots. +function badge() { const cx = 768, cy = 760, r = 150; // bottom-right - return { cx, cy, r, symbolPath }; + return { cx, cy, r }; } // 1) BRAND LOGO (color, gradient squircle bg + donkey + cursor) @@ -25,40 +24,41 @@ fs.writeFileSync(path.join(OUT,'inputflow-logo.svg'), logo); const fg = wrap(`${donkeyColor}`); fs.writeFileSync(path.join(OUT,'inputflow-foreground.svg'), fg); -// 2) TRAY default (mono) -fs.writeFileSync(path.join(OUT,'inputflow-tray.svg'), wrap(donkeyMono('m0','#2E3440'))); +const trayViewBox = '0 0 1024 1024'; -// tray variants: donkey + corner badge. badge drawn as disc with knocked-out symbol, -// and we punch a transparent ring out of the donkey so the badge reads cleanly. -function trayVariant(name, symbolInner, ink='#2E3440') { +function trayBase(ink) { + return ` + + ${donkeyMono('tray-dk', '#FFFFFF')}`; +} + +// 2) TRAY default (active/healthy) +fs.writeFileSync(path.join(OUT,'inputflow-tray.svg'), + wrap(trayBase('#10B981'), '', trayViewBox)); + +// tray variants: colored tile + white donkey + readable status badge. +function trayVariant(name, symbolInner, ink) { const b = badge(); - const gapRing = ` - `; const inner = ` - ${donkeyMono('dk', ink, gapRing)} - ${symbolInner(b)}`; - fs.writeFileSync(path.join(OUT,`inputflow-tray-${name}.svg`), wrap(inner)); + ${trayBase(ink)} + + ${symbolInner(b)}`; + fs.writeFileSync(path.join(OUT,`inputflow-tray-${name}.svg`), wrap(inner, '', trayViewBox)); } -// attention: disc with "!" knocked out +// attention: "!" inside the white badge trayVariant('attention', b => ` - `); + + `, '#EF4444'); -// busy: disc with two pause-bars knocked out +// busy: two pause bars inside the white badge trayVariant('busy', b => ` - `); + + `, '#F59E0B'); -// offline: disc with a diagonal slash knocked out +// offline: diagonal slash inside the white badge trayVariant('offline', b => ` - `); + `, '#6B7280'); // 3) NOTIFICATION (white silhouette on transparent, Android tints it) fs.writeFileSync(path.join(OUT,'inputflow-notification.svg'), wrap(donkeyMono('mn','#FFFFFF'))); From b6f92a9f272ff7d059b33136de9966678a4d2f8d Mon Sep 17 00:00:00 2001 From: daredoole <49536135+daredoole@users.noreply.github.com> Date: Tue, 28 Jul 2026 22:34:05 -0400 Subject: [PATCH 9/9] Fix release CI checks --- .github/workflows/ci.yml | 2 ++ assets/hicolor/scalable/apps/inputflow.svg | 2 +- assets/icons/inputflow-desktop.svg | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f5e3a5e..bb2af35 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,8 @@ jobs: ccache \ cmake \ ninja-build \ + libayatana-appindicator3-dev \ + libgtk-3-dev \ libx11-dev \ libssl-dev \ pkg-config \ diff --git a/assets/hicolor/scalable/apps/inputflow.svg b/assets/hicolor/scalable/apps/inputflow.svg index 23cb403..62d726a 100644 --- a/assets/hicolor/scalable/apps/inputflow.svg +++ b/assets/hicolor/scalable/apps/inputflow.svg @@ -6,7 +6,7 @@ - + diff --git a/assets/icons/inputflow-desktop.svg b/assets/icons/inputflow-desktop.svg index 23cb403..62d726a 100644 --- a/assets/icons/inputflow-desktop.svg +++ b/assets/icons/inputflow-desktop.svg @@ -6,7 +6,7 @@ - +