A USB and LAN remote-control button pad for Windows 11. An Android app communicates with a small Windows companion over a local WebSocket connection.
| Feature | Status |
|---|---|
| Volume up / down (5 % steps) | ✅ implemented |
| System audio mute toggle | ✅ implemented |
| Microphone mute toggle | ✅ implemented |
| Configurable hotkeys (media keys, Fn keys) | ✅ implemented |
| App launch by logical name | ✅ implemented |
| Button grid (10 buttons, long-press to configure) | ✅ implemented |
| Two built-in button pages (Media + Productivity) | ✅ implemented |
| Persistent IP / port + button config | ✅ implemented |
| USB wired connection via ADB reverse tunnel | ✅ implemented |
| Automatic LAN discovery | ❌ not in v1 — manual IP entry |
The app ships with two built-in pages, each with 10 buttons. Swipe left or right on the control screen to switch pages; paging wraps around continuously between Media and Productivity. Long-press any button to reconfigure it, and that change is persisted for its page.
- Play/Pause
- Next Track
- Prev Track
- Vol +
- Vol -
- Mute
- Spotify
- Chrome
- Screenshot
- Mic Mute
- Screenshot
- Teams Mute
- Teams Camera
- YouTube
- GitHub
- NotebookLM
- Mute
- Gemini
- Rename
- Show Desktop
Left: Media page. Right: Productivity page.
repo-root/
├── README.md ← this file
├── start-companion.ps1 ← launches the Windows companion
├── firewall-setup.ps1 ← one-time firewall setup (requires Admin)
├── setup-usb.ps1 ← sets up ADB reverse tunnel for USB mode
│
├── windows-companion/ ← .NET 8 ASP.NET Core WebSocket server
│ ├── WindowsCompanion.csproj
│ ├── Program.cs
│ ├── appsettings.json ← port, hotkey map, app map
│ ├── WebSocketHandler.cs
│ ├── Actions/
│ │ ├── ActionMessage.cs ← JSON message model
│ │ ├── ActionRegistry.cs ← dictionary-backed action dispatch
│ │ └── CompanionOptions.cs
│ ├── Audio/
│ │ ├── AudioController.cs ← NAudio CoreAudio volume/mute
│ │ └── MicController.cs ← NAudio CoreAudio mic mute
│ └── Input/
│ ├── HotkeySimulator.cs ← Win32 SendInput P/Invoke
│ └── AppLauncher.cs ← Process.Start with config paths
│
└── android-client/ ← Kotlin + Jetpack Compose Android app
├── settings.gradle.kts
├── build.gradle.kts
├── gradle.properties
├── gradle/wrapper/gradle-wrapper.properties
└── app/
├── build.gradle.kts
└── src/main/
├── AndroidManifest.xml
├── java/com/streamdeckremote/android/
│ ├── MainActivity.kt
│ ├── model/ ActionMessage.kt, ButtonConfig.kt
│ ├── viewmodel/ MainViewModel.kt, ConnectionState.kt
│ ├── websocket/ WebSocketClient.kt
│ ├── ui/ ConnectScreen.kt, ControlScreen.kt,
│ │ ButtonConfigSheet.kt, theme/
│ └── persistence/ PreferencesRepository.kt
└── res/values/ strings.xml, themes.xml
If you have access to an AI coding assistant like GitHub Copilot, you can delegate much of the local setup, build, and iteration work in this repository. This project has also been co-developed with GitHub Copilot Coding Agents, so parts of the workflow and documentation assume an assistant can help execute commands, inspect files, and apply targeted changes.
| Tool | Install command |
| .NET 8 SDK | winget install Microsoft.DotNet.SDK.8 |
| Git | winget install Git.Git |
| JDK 17 | winget install EclipseAdoptium.Temurin.17.JDK |
| Node.js (optional tooling) | winget install OpenJS.NodeJS.LTS |
| Item | Why human action is needed |
|---|---|
| Android Studio | GUI installer; license acceptance required. Download from developer.android.com/studio. |
| Android SDK / ADB | Installed through Android Studio's SDK Manager. Requires accepting Android SDK licenses. |
| Physical Android device or emulator | Running an AVD or connecting a phone over USB requires manual device setup. |
| Windows Firewall rule | firewall-setup.ps1 needs an elevated (Administrator) PowerShell session. |
| LAN IP address | The user must read their PC's LAN IP and type it into the Android app. |
| Steam / app paths in config | appsettings.json Companion:Apps paths must match the actual install locations on the target machine. |
Android Studio itself can usually be installed with winget install Google.AndroidStudio, but the first-launch SDK Manager flow, emulator/device setup, and license acceptance still require a human in the GUI.
- .NET 8 SDK installed (see above)
- Windows 10 or 11
Open an elevated PowerShell session and run:
# From the repository root:
.\firewall-setup.ps1Equivalent manual netsh command if you prefer:
netsh advfirewall firewall add rule ^
name="CommandDeckRemoteCompanion" ^
dir=in action=allow protocol=TCP localport=8765 ^
profile=anyThe helper script and the equivalent manual rule above open the companion port on all Windows network profiles by default. That keeps the app usable on machines whose network category is forced to Public, but it also increases reachability; narrow the rule yourself if you only want Private/Domain access.
# From the repository root (no Admin needed):
.\start-companion.ps1By default, the launcher runs in Auto mode:
- it uses
windows-companion\publish\WindowsCompanion.exewhen that output is current - it switches to
dotnet run --configuration Releasewhen the companion source is newer and a .NET SDK is installed - it still falls back to the published binary on no-SDK machines, but warns if that output looks stale
Optional overrides:
.\start-companion.ps1 -Mode Published
.\start-companion.ps1 -Mode SourceOr manually:
cd windows-companion
dotnet run --configuration ReleaseThe companion prints its IP addresses and WebSocket URL on startup:
Command Deck Remote Companion listening on port 8765
WebSocket : ws://<your-ip>:8765/ws
Health : http://<your-ip>:8765/health
Edit windows-companion/appsettings.json:
{
"Companion": {
"Port": 8765,
"Hotkeys": {
"play_pause": "VK_MEDIA_PLAY_PAUSE",
"next_track": "VK_MEDIA_NEXT_TRACK",
"prev_track": "VK_MEDIA_PREV_TRACK",
"screenshot": "VK_SNAPSHOT"
},
"Apps": {
"steam": "C:\\Program Files (x86)\\Steam\\steam.exe",
"spotify": "spotify:",
"chrome": "chrome.exe",
"youtube": "chrome.exe https://www.youtube.com",
"github": "chrome.exe https://github.com",
"notebooklm": "chrome.exe https://notebooklm.google.com/",
"gemini": "chrome.exe https://gemini.google.com/"
}
}
}Spotify note: the value
"spotify:"is a URI scheme handled by Windows Shell. WithUseShellExecute = true, Windows routes it to the registered Spotify protocol handler and foregrounds the already-running Spotify window if one exists. No path configuration is needed.Chrome note:
chrome.exerelies onUseShellExecute = trueso Windows resolves it via theApp Pathsregistry key set by the Chrome installer. If Chrome is not onPATH, substitute the full path (e.g.C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe).
The key field in an ActionMessage must match one of the Hotkeys keys, or be a raw VK name (e.g. "VK_MEDIA_PLAY_PAUSE", "VK_SNAPSHOT", "F2", "WIN+D", "0xB3").
The app field must match one of the Apps keys. The shipped Productivity page expects youtube, github, notebooklm, and gemini to be present.
Use this flow to deploy the companion to a Windows gaming PC that does not have the .NET SDK installed. The output is a self-contained single-file executable — no SDK or runtime needed on the target machine.
Run from the repository root:
dotnet publish .\windows-companion\WindowsCompanion.csproj `
-p:PublishProfile=win-x64-selfcontainedOr from within the windows-companion\ directory:
dotnet publish -p:PublishProfile=win-x64-selfcontainedOutput lands in windows-companion\publish\. The folder contains:
WindowsCompanion.exe— self-contained, no runtime dependencyappsettings.json— external and editable (not bundled into the exe)
The publish profile lives at
windows-companion\Properties\PublishProfiles\win-x64-selfcontained.pubxml. It targetswin-x64, self-contained, with single-file compression enabled.
Create a small staging folder for the target machine (zip it for convenience) with this layout:
CommandDeckRemoteCompanion-win-x64\
├── publish\
│ ├── WindowsCompanion.exe
│ └── appsettings.json
├── install-companion.ps1
└── firewall-setup.ps1
Populate it from the repo like this:
| Item | Where |
|---|---|
windows-companion\publish\ (entire folder) |
As publish\ inside the staging folder |
install-companion.ps1 |
Staging folder root |
firewall-setup.ps1 |
Staging folder root |
Open a PowerShell window in the staging location and run:
.\install-companion.ps1The installer will:
- Copy all files from
publish\to%LOCALAPPDATA%\CommandDeckRemoteCompanion\(default). - Preserve an existing target-machine
appsettings.jsonby default so machine-specific app paths are not clobbered during reinstall. - Prompt to run
firewall-setup.ps1; if you accept and the shell is not already elevated, it launches an elevated sub-process automatically and uses the configuredCompanion:Portvalue fromappsettings.json.
Optional parameters:
# Custom install directory
.\install-companion.ps1 -InstallDir "D:\Tools\CommandDeck"
# Skip firewall (configure manually later)
.\install-companion.ps1 -SkipFirewall
# Replace an existing appsettings.json during reinstall
.\install-companion.ps1 -OverwriteConfigIf the companion is already running from the destination, the installer warns and exits — stop it first.
install-companion.ps1also tolerates two alternate layouts if you prefer them:windows-companion\publish\next to the script, orWindowsCompanion.exedirectly next to the script.
Open appsettings.json in the install directory and adjust Companion:Apps to match this machine:
"Apps": {
"steam": "C:\\Program Files (x86)\\Steam\\steam.exe",
"spotify": "spotify:",
"chrome": "chrome.exe"
}App paths vary per machine. The
spotify:URI andchrome.exeshell-launch shortcuts often work without changes, but Steam and other game-launcher paths typically need updating.
& "$env:LOCALAPPDATA\CommandDeckRemoteCompanion\WindowsCompanion.exe"Or use start-companion.ps1 from this repo directory — it auto-selects the freshest viable launch mode, preferring current published output when appropriate and switching to dotnet run when local source changes are newer.
- Android Studio Hedgehog or later
- Project is currently configured with
minSdk 26(Android 8.0), but versions below Android 13 are unverified - JDK 17 (bundled with Android Studio)
- A physical Android device or an AVD
The app has only been tested on Android 13+ devices, specifically:
- Samsung S20 FE
- Samsung S24 Ultra
Button sizing, spacing, and overall layout can differ on other screen sizes, aspect ratios, display-density settings, or Android vendor skins. Expect to make UI adjustments for other mobile devices.
- Install Android Studio from developer.android.com/studio.
- Accept Android SDK licenses in the SDK Manager (Android Studio → Settings → SDK Manager → SDK Licenses).
- Open the project: File → Open → navigate to
android-client/. - Let Gradle sync complete.
- Run on device or emulator via the ▶ Run button.
The Windows companion binds to 0.0.0.0:8765, so it accepts connections from the emulator out of the box — no additional companion config is needed.
- Start the companion first (see above). Confirm it is healthy:
Invoke-RestMethod 'http://localhost:8765/health'
- Open the project in Android Studio, create or start an AVD via the Device Manager, and run the app.
- In the app's connect screen, enter:
- IP address:
10.0.2.2— the standard Android Emulator alias for the host machine's loopback. - Port:
8765
- IP address:
- Tap Connect. The emulator routes
10.0.2.2:8765straight to the Windows host without any firewall rule or LAN pairing.
The firewall rule from
firewall-setup.ps1opens the configured port on all Windows network profiles by default. It is not required for emulator traffic, which arrives over the loopback interface.
- The Android app intentionally uses
ws://cleartext traffic for trusted LAN demos only. The manifest opts into cleartext traffic so emulator and device connections can succeed without TLS. - If the app cannot connect, open
http://<your-ip>:8765/healthfrom another device on the same network first. If that fails, check the firewall rule and confirm both devices are on the same LAN without client isolation. - If a hotkey button does nothing, verify the button's key matches either a raw virtual-key name such as
F13or a logical alias defined underCompanion:Hotkeysinwindows-companion/appsettings.json. - If a Productivity page launcher button does nothing, verify the corresponding
Companion:Appsentry exists foryoutube,github,notebooklm, orgemini.
- Start the Windows companion first.
- Ensure phone and PC are on the same Wi-Fi network.
- Open the app → enter the PC's LAN IP and port
8765→ tap Connect. - Use the button grid. Long-press any button to reconfigure it.
- Enable USB Debugging on the device (Settings → Developer Options → USB Debugging).
- Connect the phone to the PC with a USB cable.
- Run
.\setup-usb.ps1from the repo root to set up the ADB reverse tunnel. - Start the Windows companion.
- Open the app → tap the USB transport tab → confirm port
8765→ tap Establish Link. - Use the button grid normally. See the USB Wired Connection section for full details.
Use this mode when Wi-Fi is unavailable, unreliable, or not allowed. The app connects to localhost on the device, and ADB routes that traffic to the Windows companion over the USB cable.
- USB cable between the Android device and the Windows PC.
- USB Debugging enabled on the device: Settings → Developer Options → USB Debugging.
- ADB (Android Debug Bridge) on the PC's
PATH. Install via Android SDK Platform-Tools and add the folder toPATH.
Run the helper script once per cable session (re-run after unplugging):
# From the repository root:
.\setup-usb.ps1To use a non-default port:
.\setup-usb.ps1 -Port 9000The script checks that adb is available, verifies a device is connected, and runs:
adb reverse tcp:<port> tcp:<port>
This tells the device to forward any connection it makes to localhost:<port> through the USB cable to localhost:<port> on the Windows PC.
- On the Connect screen, tap the USB segment of the transport selector (top of the form).
- The host field automatically shows
localhost(non-editable). - Confirm the port matches your companion's configured port (default
8765). - Tap Establish Link.
The connection badge in the top bar will read USB · ONLINE when the USB tunnel is active.
adb reverse creates a socket relay on the device: any TCP connection the Android app opens to 127.0.0.1:<port> is tunnelled over the USB debug channel to 127.0.0.1:<port> on the Windows host. No Wi-Fi, no LAN IP address, and no firewall rule change is required for USB mode. The companion currently listens on all host interfaces by default, but the USB tunnel itself still targets the host's loopback endpoint.
The tunnel is session-scoped. It is reset when the USB cable is disconnected or
adb kill-serveris run. Re-runsetup-usb.ps1to restore it.
All messages are UTF-8 text WebSocket frames:
{ "action": "volume_up" }
{ "action": "volume_down" }
{ "action": "volume_mute" }
{ "action": "mic_mute" }
{ "action": "teams_mute" }
{ "action": "teams_camera" }
{ "action": "hotkey", "key": "VK_MEDIA_PLAY_PAUSE" }
{ "action": "app_launch", "app": "steam" }The server returns no response frames for action messages. Unknown actions are logged and silently ignored.
- Transport: plain TCP WebSocket (
ws://) over Wi-Fi (LAN) or USB (adb reversetunnel). TLS is not required on a trusted LAN; add it for production use. - Pairing: manual IP + port in v1. mDNS/Bonjour discovery is a natural v2 addition.
- Action dispatch:
ActionRegistryuses aDictionary<string, Func<ActionMessage, Task>>— adding a new action is a single dictionary entry, not a switch case. - Audio: NAudio
CoreAudioApi(MMDeviceEnumerator) targets the system default render/capture device. - Input: Win32
SendInputP/Invoke with an explicit VK map; raw hex codes also accepted as fallback. - Android state:
MainViewModelowns all state asStateFlows.WebSocketClientcalls back into the ViewModel; UI only reads flows — transport and UI are fully decoupled.



