Documentation
Your laptop, from across the room.
Edgepad turns an Android phone into a trackpad, a media remote and a control panel for a Windows laptop, over a direct Bluetooth link with nothing in between. No account, no Wi-Fi, no server.
- Transport
- Bluetooth Classic RFCOMM
- Protocol
- Version 3
- Phone
- Kotlin, Android 12+
- Laptop
- C# on .NET 10, Windows 10 2004+
Start here
What Edgepad is
It is for the times the laptop is across the room rather than under your hands: plugged into a television, docked on a desk you are not sitting at, parked somewhere a mouse dongle will not reach.
The alternatives are a remote app that only sends media keys, or a remote-desktop app that streams the whole screen and wants an account and a network round trip to change the volume. Edgepad is neither. The phone and the laptop pair once, the way a headset does, and after that they talk directly: no account, no Wi-Fi, no service in between that can be slow or down.
The cost is Bluetooth's own. Both ends need it, and it does not reach another room or the internet.
What it does
Corner rulers
Each corner holds a dial drawn as a ruler that wraps the bend. Slide along it, clockwise to raise. Volume, brightness, media scrub, zoom, app switcher, microphone level or refresh rate; Settings picks what each corner does, how long the rulers are and how tall.
Trackpad
Everything between the corners moves the laptop's pointer. One finger moves and clicks, two scroll and pinch, three and four fingers do whatever you assign them.
Media
The track, the app playing it and where it is, with previous, play/pause and next. Drag the three pieces anywhere on the surface.
Keyboard and gamepad
A full on-screen keyboard whose modifiers work held or tapped, and a gamepad with editable layouts and presets. Both open sideways.
Macro buttons
Fifteen buttons in a 5x3 grid. The laptop's tray menu names an app, a document, a folder or a URL for each; the phone sends the slot number and never what it opens.
Live state
The dials show the laptop's real volume, mute, brightness and playback position, and follow changes made on the laptop itself.
Start here
Download and install
- Download
Edgepad.exe. It is a single self-contained file; nothing else needs installing, and the laptop does not need .NET. - Run it. It is not code-signed, so SmartScreen asks first: choose More info, then Run anyway.
- It lives in the system tray. The menu shows the version, whether a phone is connected, Macros, Start with Windows, Forget trusted phone, Open log, Documentation and Quit.
Running a newer Edgepad.exe asks the running copy to quit and takes its place, so an update takes over cleanly rather than failing on the single-instance lock.
Pairing and first connection
- Pair the phone with the laptop once, in Windows Settings > Bluetooth & devices. Edgepad has no pairing step of its own.
- Keep Edgepad running in the laptop's tray. It must already be running before the phone tries to connect: there is no discovery or retry on the laptop side.
- Open Edgepad on the phone and tap the laptop. The first phone to connect becomes the laptop's trusted phone; any other paired phone is refused until you choose Forget trusted phone in the tray menu.
- The phone remembers the laptop and reconnects when the app opens. If the link drops after a completed handshake, the phone retries ten times, two seconds apart, and says so. Disconnecting on purpose does not retry.
Start here
Using it
The control surface
| Where | Touch | The laptop does |
|---|---|---|
| A corner ruler | slide | Turns that dial. Clockwise raises. |
| A corner ruler | tap | The dial's action: mute, play/pause, mic mute, task view, reset zoom. |
| Anywhere else, one finger | move / tap / tap then hold-and-move | Pointer / left click / drag. |
| Two fingers | drag | Scroll, both axes. |
| Two fingers | pinch | Zoom (Ctrl+wheel). |
| Two fingers | tap | Right click. |
| Three or four fingers | tap, swipe up, down, left, right | Whatever Settings assigns. Ten slots in all. |
| Top centre | tap the gear, the keyboard, the gamepad | Opens Settings, the keyboard, the gamepad. |
| Back | — | Leaves the surface. The link stays up. |
Default three- and four-finger gestures
Swapped from Windows' own defaults on purpose. Every one of these is changed under Settings > Gestures.
| Fingers | Gesture | The laptop does |
|---|---|---|
| Three | left / right | Previous / next virtual desktop |
| Three | up | Task view |
| Three | down | Show the desktop |
| Three | tap | Search |
| Four | left / right | Walk the app switcher. Alt stays held while the fingers are down |
| Four | tap | Notifications |
The corner dials
A dial is a ruler lying along the screen's edge, bending round the corner along the display's own rounding. The finger slides the ruler under a fixed indicator; clockwise raises the value everywhere, like turning a knob. Sliding past a small slop arms it — there is no hold — the ticks lengthen while armed, and one haptic tick fires per notch that passes. A tap without a slide runs the dial's action instead.
Seven kinds compete for four places: volume, brightness, media scrub, zoom, app switcher, microphone level and refresh rate. A corner may also hold nothing. A dial shows the laptop's real value from STATE frames, and dragging sends absolute SET frames.
Macro buttons
Fifteen buttons in a 5x3 grid, named from the laptop's tray menu. Fifteen is what one frame can name, so a full grid always arrives labelled rather than trailing off into blank buttons. A macro added while the phone is connected appears at once.
Start here
Settings
Connection
The remembered laptop with its round trip, Forget, and whether to reconnect automatically.
Surface
Corners (which dial each corner holds, or none), Gestures (the gesture map and on-screen hints), Dial feel (slide sensitivity — shared or per dial kind — dial length and height, haptic ticks, snapping to round numbers, with a live preview), and natural scrolling.
Trackpad
Pointer speed and scroll speed. Settings since 2.0.0, constants before it.
Layouts
The media layout and the gamepad layout, each a full-screen canvas where pieces are dragged anywhere. The options button holds the size, the presets and reset.
Appearance
Dark or light, upright or sideways, and Background & pattern: a control colour, a colour, gradient or image behind the surface, and a grid, dots or checker over it.
Help
The five-page guide, shown one page at a time with a drawing or all on one scrolling page. It opens on the first run and again from here. Below it, Documentation opens this page in a browser.
The theme is the system's own per-app night mode, set through UiModeManager.setApplicationNightMode, so Edgepad stores no preference of its own for it. Orientation is a toggle — upright or sideways — not free rotation. Media-piece positions are stored as fractions of the surface's width and height, so a layout made in portrait keeps its shape in landscape.
Where the laptop keeps things
%APPDATA%\Edgepad\trusted-phone.txt the trusted phone's Bluetooth address
%LOCALAPPDATA%\Edgepad\edgepad.log connections, refusals, dropped frames,
and input batches Windows refusedThe log is also in the tray menu, under Open log.
How it works
The split
Every touch is turned into a semantic frame on the phone — pointer moved, button pressed, scroll, zoom, run action 3, set control 0 to 55, type “hi” — and the laptop carries it out with the Windows input, audio, display and media APIs. The laptop owns the table of what each action id does. The phone can name an action; it cannot invent one.
The laptop reports back. After the handshake it sends a snapshot of volume, microphone and brightness, then what is playing and where, then the display's available refresh rates and the macro names, and thereafter every change as it happens. The phone keeps the last report in LaptopState, which outlives the control surface, so a surface rebuilt after a rotation or a theme change starts from real values and never shows 0 for a level it has not heard. A level never reported shows an ellipsis, not a zero.
Why Windows gestures are emulated by their results
Windows only runs its native multi-finger gestures for certified Precision Touchpad hardware. Two ways round that were rejected: a virtual PTP driver, which needs a kernel driver and test-signing mode and so weakens the laptop's security, and making the phone a Bluetooth HID touchpad, which needs a PTP certification blob and depends on Android OEM support for BluetoothHidDevice. So Edgepad produces the result instead: Win+Ctrl+Left rather than a finger-following desktop slide. The cost is the animation. Alt+Tab stays interactive because Alt is genuinely held while the fingers are down.
How it works
Transport
758bb618-7b72-4cd3-9aa2-c9b88e54d555Both sides know this one id. There is no discovery step and no service record to look up.
The socket is bound at BluetoothEncryptionWithAuthentication. That means only a device already paired with the laptop can connect at all, and the stream is encrypted by the link layer rather than by anything Edgepad wrote.
What was rejected
BLE GATT
Windows as a GATT server is unreliable across adapters, and GATT is datagram-shaped where an ordered stream is wanted.
Wi-Fi / LAN
Needs a shared network and a discovery step. Bluetooth was also the requirement.
Phone as a Bluetooth HID touchpad
Windows only runs its multi-finger gestures for certified Precision Touchpad hardware, so the gestures would have to be emulated anyway.
How it works
Architecture
Android phone
Kotlin, platform views, no UI libraries
MainActivity
Guide, devices, settings, layout editors, keyboard, gamepad, macros
ControlSurface
One custom View that draws everything and receives every touch
TrackpadRecognizer · Dial x4
Pure Kotlin, no Android types. Turns touch into semantic frames
LaptopLink
RFCOMM socket: a reader thread, a writer thread, a coalescing outbox
LaptopState
The laptop's last report, kept across rotation and theme changes
Windows laptop
C# on .NET 10, WinForms tray, single instance
TrayContext
Menu, status, run at login, macros editor, Forget
RfcommServer
Advertises the service, accepts on WinRT's thread
Session
One per phone, on its own above-normal-priority thread
FrameCodec -> Dispatcher
Decodes, then executes from the laptop-owned action table
InputInjector · AudioEndpoint · BrightnessControl · MediaSessions
SendInput, Core Audio, WMI, system media transport controls
The phone's surface
ControlSurface is a single custom View that draws everything and receives every touch. There is no view hierarchy between the finger and the recogniser. Geometry is computed once in onSizeChanged; nothing is allocated while drawing.
Perimeter models the screen's edge as one clockwise path — a rounded rectangle whose corner radius is the display's own, from WindowInsets.getRoundedCorner — so a ruler bends round a corner instead of being clipped by it. A dial's finger position is projected onto that path and its movement measured along it. Perimeter is the single place that says a screen has four corners; three files each used to carry their own copy.
TrackpadRecognizer and Dial are pure Kotlin with no Android types. That is what lets the whole gesture table run as plain JVM unit tests with no emulator.
Why the laptop half is a tray app, not a service
Windows services run in session 0 and can neither inject input into the desktop nor reach the user's audio session. So Edgepad is a per-user tray app that starts at login through the HKCU Run key. It is single-instance, held by a named mutex, and a newer copy signals the running one to quit and waits for the lock rather than killing the process.
How it works
Threads and latency
On the phone
LaptopLink.open blocks for the life of the connection on its own thread and reads there. A second thread drains the outbox and writes. send never blocks, so touch handling never waits on the link. Every callback into the activity is posted to the main thread. The link is kept across configuration changes through onRetainNonConfigurationInstance with its listener swapped to the new activity, and closed when the app leaves the foreground.
On the laptop
RfcommServer accepts on WinRT's thread and hands each socket to a Session on a dedicated above-normal-priority thread, where the read loop turns frames straight into input. There is no queue between the socket and SendInput.
A new connection replaces the old one rather than being refused, because after a dropped link the phone reconnects before the laptop's old socket has noticed it is dead. Audio-change notifications arrive on COM threads and brightness events on a WMI thread, so Session.Send is locked and they interleave safely with the read loop's PONGs.
The four things that make it feel immediate
- Unbuffered touch.
View.requestUnbufferedDispatch, with every historical sample read from eachMotionEvent, so events arrive as they happen rather than batched to vsync. - One write per batch. The writer thread sends everything already queued in a single write.
- Coalescing. A backlog of
MOVE,SCROLLandZOOMframes still waiting in the outbox is summed into one before it goes out, and only the lastSETper control survives. A slow link catches up in a single packet instead of replaying every sample it missed. - Brightness off the hot path. A WMI call is far slower than a frame, so brightness writes go to their own thread with latest-value-wins. Queueing them would make a dial drag lag further behind the finger the longer it moved.
How it works
Wire protocol, version 3
TEXT, whose second header byte gives the length of the text that follows. All multi-byte integers are little-endian. Frames are 2 to 258 bytes.Errors, and what is merely ignored
An unknown type byte, a wrong length, a bad magic or a flag byte other than 0 or 1 is a protocol error and closes the connection. A well-formed frame naming an action, a control or a text kind this side does not know is dropped and counted, never treated as an error. That is what lets a newer app talk to an older one until the version check says otherwise — and why new ids do not need a version bump.
Frames
| Type | Name | Payload | Direction | Meaning |
|---|---|---|---|---|
| 0x01 | HELLO | magic EDGP, version u8 | phone → laptop | Opens a session. |
| 0x02 | HELLO_ACK | version u8 | laptop → phone | Accepts it. On a version mismatch the laptop sends its own version, then closes, so the phone can say which side needs updating. |
| 0x10 | MOVE | dx i16, dy i16 | phone → laptop | Move the pointer, in laptop pixels, before Windows' own pointer acceleration. |
| 0x11 | BUTTON | button u8, down u8 | phone → laptop | 0 left, 1 right, 2 middle. down is 1 or 0. |
| 0x12 | SCROLL | dx i16, dy i16 | phone → laptop | Wheel units. 120 is one notch; positive dy is wheel forward. |
| 0x13 | ZOOM | delta i16 | phone → laptop | Ctrl+wheel units. |
| 0x20 | ACTION | id u8 | phone → laptop | Run one action from the laptop's table. |
| 0x21 | SET | control u8, value u8 | phone → laptop | Set a control to 0-100. The laptop drops anything above 100. |
| 0x22 | KEY | code u16, down u8 | phone → laptop | Press or release one key, by Windows virtual-key code. This is what the phone's keyboard screen sends. |
| 0x30 | PING | t i64 | phone → laptop | The phone's clock, in nanoseconds. |
| 0x31 | PONG | t i64 | laptop → phone | The same value echoed back, which is what the round-trip readout measures. |
| 0x40 | STATE | control u8, value u8, flags u8 | laptop → phone | A control's current value. Flags bit 0 is muted for audio controls, playing for media position. |
| 0x41 | TEXT | kind u8, length u8, UTF-8 bytes | both ways | The only variable-length frame: up to 255 bytes, never split inside a character. |
Golden bytes
Each line of the fixture is a frame's fields and its exact bytes. Both test suites encode the fields and must get exactly those bytes, then decode the bytes and must get exactly those fields. Because one file drives assertions in two codebases, changing one side's codec without the other fails that side's test immediately. The fixture is what stops the two apps from drifting apart — not code review.
| Frame | Fields | Bytes |
|---|---|---|
| HELLO | 1 | 01 45 44 47 50 01 |
| HELLO | 2 | 01 45 44 47 50 02 |
| HELLO | 3 | 01 45 44 47 50 03 |
| HELLO_ACK | 1 | 02 01 |
| MOVE | 5 -3 | 10 05 00 fd ff |
| MOVE | -32768 32767 | 10 00 80 ff 7f |
| BUTTON | 0 1 | 11 00 01 |
| BUTTON | 1 0 | 11 01 00 |
| SCROLL | 0 -120 | 12 00 00 88 ff |
| ZOOM | 240 | 13 f0 00 |
| ACTION | 3 | 20 03 |
| SET | 0 55 | 21 00 37 |
| SET | 1 100 | 21 01 64 |
| SET | 3 50 | 21 03 32 |
| KEY | 65 1 | 22 41 00 01 |
| KEY | 112 0 | 22 70 00 00 |
| KEY | 65535 1 | 22 ff ff 01 |
| PING | 1234567890123 | 30 cb 04 fb 71 1f 01 00 00 |
| PONG | -1 | 31 ff ff ff ff ff ff ff ff |
| STATE | 0 42 1 | 40 00 2a 01 |
| STATE | 3 42 1 | 40 03 2a 01 |
| TEXT | 3 hi | 41 03 02 68 69 |
| TEXT | 1 Song | 41 01 04 53 6f 6e 67 |
| TEXT | 0 | 41 00 00 |
| TEXT | 2 84/227 | 41 02 06 38 34 2f 32 32 37 |
| TEXT | 0 Café | 41 00 05 43 61 66 c3 a9 |
| TEXT | 0 🎵 | 41 00 04 f0 9f 8e b5 |
| TEXT | 0 日本語 | 41 00 09 e6 97 a5 e6 9c ac e8 aa 9e |
TEXT kinds
| Kind | Name | What it means |
|---|---|---|
| 0 | NOW_PLAYING | What is playing. Laptop to phone. |
| 1 | APP | The app playing it. Laptop to phone. |
| 2 | TIMELINE | The timeline as seconds/length, such as 84/227. Laptop to phone. |
| 3 | TYPE | Text to type, where \b is backspace and \n is enter. Phone to laptop. |
| 4 | REFRESH_RATES | The display's available refresh rates as 60/120/144, in the order CONTROL 4 indexes them. Laptop to phone. |
| 5 | MACROS | The laptop's macro names as Chrome/Spotify/Notes, in the order MACRO_BASE indexes them. A blank name keeps its slot. Laptop to phone. |
What the laptop sends, and when
STATE volume, microphone, brightness
TEXT 0 now playing, 1 the app, 2 the timeline
STATE media position
TEXT 4 the display's refresh rates
STATE the current refresh rate
TEXT 5 the laptop's macro names
then every change as it happens.
Media position is refreshed once a second while playing.Versioning
| Version | Release | Change |
|---|---|---|
| 1 | 0.1.0 | HELLO through STATE. TEXT kinds 0 and 1 arrived in 0.2.0 without a bump. |
| 2 | 0.3.0 | TEXT kind 2. A mismatch is refused with the laptop's version. |
| 3 | 0.6.0 | TEXT kind 3. Actions 31 to 34 arrived in 0.5.0, and CONTROL 4, TEXT 4 and 5 and the macro block in 1.1.0 — all without a bump. |
Only a new frame type forces a version, because an unknown type closes the connection. The refresh-rate dial and the macro buttons both arrived without a bump, which is the rule working rather than being broken. From 1.0 this number moves only in a major release, and any change to what a frame means bumps it on both sides in the same commit and adds a fixture line.
How it works
Actions and controls
Actions
| Id | Name | What it means |
|---|---|---|
| 1 | MUTE_TOGGLE | Mute key. |
| 2 | PLAY_PAUSE | Media play/pause key. |
| 3 | NEXT_TRACK | Media next key. |
| 4 | PREVIOUS_TRACK | Media previous key. |
| 5 | MIC_MUTE_TOGGLE | Toggles the default microphone's mute. |
| 6 | LOCK | LockWorkStation. Win+L cannot be injected. |
| 20 | TASK_VIEW | Win+Tab. |
| 21 | SHOW_DESKTOP | Win+D. |
| 22 | SEARCH | Win+S. |
| 23 | NOTIFICATIONS | Win+N. |
| 24 | DESKTOP_LEFT | Win+Ctrl+Left. |
| 25 | DESKTOP_RIGHT | Win+Ctrl+Right. |
| 26 | APP_SWITCH_BEGIN | Holds Alt and presses Tab. Alt stays down until APP_SWITCH_END or the session ends. |
| 27 | APP_SWITCH_NEXT | Tab, only while Alt is held. |
| 28 | APP_SWITCH_PREVIOUS | Shift+Tab, only while Alt is held. |
| 29 | APP_SWITCH_END | Releases Alt. |
| 30 | ZOOM_RESET | Ctrl+0. |
| 31 | VOLUME_UP | Volume-up key. |
| 32 | VOLUME_DOWN | Volume-down key. |
| 33 | BRIGHTNESS_UP | The panel's brightness, plus 10. |
| 34 | BRIGHTNESS_DOWN | The panel's brightness, minus 10. |
| 64 | MACRO_BASE | The first of 32 reserved macro slots (ids 64-95). Slot n runs as MACRO_BASE + n. The phone sends the index; the laptop's own list decides what it opens. |
Controls
| Id | Name | What it means |
|---|---|---|
| 0 | VOLUME | SET sets the default speakers' level; STATE reports the level and a muted flag. |
| 1 | BRIGHTNESS | SET sets the built-in panel's brightness through WMI; STATE reports the level. |
| 2 | MIC_LEVEL | SET sets the default microphone's level; STATE reports the level and a muted flag. |
| 3 | MEDIA_POSITION | SET seeks the current track to that percent, where the player allows it; STATE reports the percent and a playing flag. |
| 4 | REFRESH_RATE | SET switches the display to the rate at that index of the TEXT 4 list; STATE reports the current rate's index. Never a rate in hertz. |
How it works
Security and trust
The two layers
Pairing
Enforced by the socket's protection level. The link is bound at BluetoothEncryptionWithAuthentication, so a device not already bonded with the laptop through Windows cannot connect at all, and the stream is encrypted by the link layer.
Trust on first use
Enforced by TrustStore. The first phone to complete the handshake has its Bluetooth address written to %APPDATA%\Edgepad\trusted-phone.txt, and every other paired phone is refused before HELLO_ACK. The tray menu's Forget trusted phone clears it.
The handshake also carries the protocol version. A mismatch is refused with the laptop's version in the reply, so the phone can say which side needs updating instead of misreading frames. A refusal before HELLO_ACK names both possible causes: a laptop app older than the phone's, or a laptop that trusts a different phone.
What the action table is, and is not
The line Windows draws
SendInput is silently refused when an elevated window, a UAC prompt or the secure desktop has focus. This is Windows protecting them and the app cannot cross it. The laptop counts those refusals and logs them once per session, so a gesture that appears to do nothing has a recorded reason.
Known limits
- The trusted phone's Bluetooth address is stored in plain text under
%APPDATA%\Edgepad. Anyone who can write that file can change which phone is trusted — and that person can already run programs as you. Edgepad.exeis not code-signed, so SmartScreen warns on first run. Verify the download came from the project's own Releases page.- Text typed from the phone goes wherever the laptop's focus is, exactly as a keyboard would.
- Nothing leaves the two devices: no network, no server, no account, no telemetry. The laptop writes a local log of connections and errors and nothing else.
- Release builds are signed, and every release is signed with the same key, so a phone will refuse an update that was not built by this project.
Developing
Repository layout
| Path | What |
|---|---|
| android/ | The phone app. Kotlin, Android platform views, no UI libraries. JUnit 4 is the only dependency. |
| windows/ | The laptop tray app. C# on .NET 10, WinForms for the tray, WinRT for Bluetooth and media, NAudio for volume, WMI for brightness. |
| protocol/ | The wire format and the id tables as plain-text fixtures. Both test suites read them, so the two apps cannot drift apart. |
| scripts/ | The quality gate and the release-key script. |
| docs/ | Architecture and protocol. |
| site/ | This documentation site. Next.js; not part of either gate. |
Inside the phone app
Under android/app/src/main/java/me/akshitbansal/edgepad. The packages marked pure have no Android platform types and run as plain JVM unit tests.
| Package | What | Testable |
|---|---|---|
| surface/ | Gesture recognition, the dials, the corner geometry, the ruler painter. | Pure |
| protocol/ | Frame and id coding. | Pure |
| link/ | The RFCOMM socket, coalescing, the laptop-state model, round-trip stats. | Pure |
| gamepad/ | Gamepad layout, control geometry, the layout store. | Pure |
| screens/ | Every screen: guide, devices, settings, layout editors, keyboard, gamepad, macros. | Needs a device |
Inside the laptop app
Under windows/src/Edgepad.
| Folder | What | Testable |
|---|---|---|
| Protocol/ | Frame and id coding. | Pure |
| Dispatch/ | The dispatcher: what each action id does, and every drop path. | Pure |
| Bluetooth/ | RfcommServer and Session. Needs a live socket to verify. | Needs hardware |
| Injection/ | InputBuilder, InputInjector and the SendInput interop. | Needs hardware |
| Controls/ | Audio, brightness, display modes, media sessions, the on-screen level overlay. | Needs hardware |
| Trust/ | TrustStore: trust on first use. | Needs hardware |
| Macros/ | The macro list and its tray-menu editor. | Needs hardware |
Developing
Prerequisites
Phone app
- JDK 17.
- The Android SDK with platform 37 and build tools 37.0.0.
- The Gradle wrapper fetches Gradle itself — nothing to install for that.
Laptop app
- The .NET SDK version pinned in
windows/global.json: 10.0.401, withrollForward: latestFeature. - Windows 10 version 2004 or later, because the app builds against the Windows SDK projection for Bluetooth.
Developing
The quality gate
pwsh scripts/check.ps1 # formats in place, then checks everything
pwsh scripts/check.ps1 -Only android # ktlint, Android lint, unit tests
pwsh scripts/check.ps1 -Only windows # dotnet format, build, tests
pwsh scripts/check.ps1 -Ci # what CI runs: fails on unformatted codeIt writes locally because fixing your formatting is useful. CI must not write to your branch, so it fails instead of quietly reformatting the pull request.
What it actually runs
| Half | Step | Local / CI |
|---|---|---|
| android | Kotlin formatting | ktlintFormat / ktlintCheck |
| android | Android lint, then the unit tests | same |
| windows | C# formatting | dotnet format / --verify-no-changes |
| windows | Build, then test | dotnet build -warnaserror; dotnet test |
What CI does on top
.github/workflows/ci.yml runs on every push to main and every pull request, in two jobs: Android on ubuntu-latest with Temurin 17 and Gradle wrapper validation, Windows on windows-latest with the SDK from global.json. Each runs the gate in -Ci mode and then uploads an artifact — a debug APK and the self-contained exe.
Developing
Running it locally
Laptop
dotnet run --project windows/src/EdgepadIt must already be running before the phone tries to connect: there is no discovery or retry on the laptop side, only the phone reconnects.
dotnet publish windows/src/Edgepad -c Release -r win-x64 --self-contained \
-p:PublishSingleFile=true \
-p:IncludeNativeLibrariesForSelfExtract=true \
-p:EnableCompressionInSingleFile=trueA self-contained single file, the same shape a release ships. Compression takes it from roughly 143 MB to 58.5 MB.
cd windows && dotnet build Edgepad.slnx -c ReleasePhone
cd android && ./gradlew installDebugUninstall any release build first: debug builds are versioned 0.0.0-dev and signed with the debug key, so they will not install over a release.
cd android && ./gradlew assembleDebugTogether
- Pair the phone and the laptop in Windows Settings.
- Start the tray app with
dotnet run. - Install and open the phone app, then tap the laptop.
- Read
%LOCALAPPDATA%\Edgepad\edgepad.logfor dropped frames or refused input batches.
This site
cd site
npm install
npm run dev # http://localhost:3000
npm run check # Biome -> ESLint -> tsc, the site's own gate
npm run check:ci # the same, non-mutatingBiome owns formatting and import order, ESLint owns React hooks and the Next.js rules, tsc owns types. It reads ../protocol/*.txt at build time, so it must be built from inside the repository. It is not part of either app's quality gate and CI does not run it.
Developing
Tests
cd android && ./gradlew testDebugUnitTest
cd windows && dotnet test --solution Edgepad.slnxThe fixture layer
protocol/frames.txt holds every frame type as golden bytes, and protocol/actions.txt the action, control and text-kind ids. FrameFixtureTest and IdsFixtureTest on the phone, and FrameFixtureTests and IdsFixtureTests on the laptop, parse those same files at run time rather than copying them into either codebase.
A protocol change therefore always touches a fixture file and both codecs in the same commit. Change one side's codec without the other and that side's test fails immediately.
Above the fixtures
Android
The gesture recogniser and the whole finger table, assignable actions, natural scrolling, the dials (arming, slop, snapping, steppers, haptic notches), the corner and perimeter geometry, coalescing, round-trip stats, the gamepad layout, the modifier latch and the laptop-state model. All pure Kotlin, so it runs with no emulator.
Windows
The dispatcher's drop paths, input batching, trust on first use, the macro store, display modes, the level overlay, and the media-session title mapping.
Developing
Contributing
- Branch from
main. - Keep the two apps in step. A protocol change edits
protocol/frames.txtorprotocol/actions.txt, both codecs or both enums, and bumps the protocol version on both sides — all in one commit. - Add or change a test when you add logic or a branch, or fix a bug. Prefer the pure layers over anything that needs a device.
- Run
pwsh scripts/check.ps1until it is clean. - Commit with a Conventional Commits subject and a body saying what changed and why, including what you rejected.
- Add a line under Unreleased in
CHANGELOG.md. - Open a pull request.
Style
- No UI libraries on the phone. No third-party packages on the laptop beyond NAudio and
System.Management. A new dependency needs a reason in the pull request. - Constants are named. A number that appears twice is a constant.
- Doc comments say why, not what the next line already says.
- Nothing runs on the input path that could block: no logging, no allocation while drawing, no I/O on the session thread beyond the socket.
Bug reports and feature requests use the issue templates in .github/ISSUE_TEMPLATE. Open an issue for anything bigger than a fix, so the design can be talked through before code exists. Report anything exploitable through a security advisory, not a public issue.
Developing
Releases
# 1. Update the version table in docs/PROTOCOL.md if the protocol changed.
# 2. Move Unreleased in CHANGELOG.md under the new version.
git tag v2.0.0
git push origin v2.0.0.github/workflows/release.yml runs both quality gates, builds a signed release APK and a compressed self-contained exe, and attaches them to a GitHub Release under the stable names Edgepad.apk and Edgepad.exe, so releases/latest/download/<file> always resolves to the newest build.
Versioned file names were rejected because they break that permanent link, and CI artifacts were rejected as a distribution channel because they expire and need a GitHub login. versionName comes from the tag; versionCode comes from the workflow run number, which only increases, so an update always installs over the previous one.
Reference
Design decisions
Reference
Known limits
No latency figure has ever been measured
unverifiedThe app carries a live round-trip readout built from PING/PONG, and no number from real hardware has been reported. Nothing in this project writes a latency figure down until one has been.
The feel constants are first guesses
unverifiedPointer gain, scroll units per dp, zoom and switch step sizes in TrackpadRecognizer; base units per dp, units per step and the haptic notch size in Dial; the ruler sizes and hit depths in ControlSurface. They are tuned by feel rather than measured, so expect to adjust the sensitivity settings to taste.
Overlay mode is not built
not builtA mode that draws only the dials over whatever is on screen, kept alive by a foreground service, is planned and does not exist. In overlay mode nothing can be claimed back from Android's own gestures, so corner drags may be stolen by the assistant gesture or the notification shade.
No keyboard-backlight dial
not builtIt was asked for. Windows has no general API for a laptop keyboard backlight: it goes through the maker's own driver and utility. Windows Dynamic Lighting covers only compliant RGB devices, and whether any given laptop's keyboard is one has not been checked. Left open rather than guessed at.
Android's back gesture wins some of the edge
acceptedBack-gesture exclusion is requested for every dial, but Android honours at most 200dp per vertical edge and the bottom cannot be claimed at all. With some layouts parts of a ruler will lose to the back gesture under gesture navigation. Immersive mode may soften it; untested.
Seeking depends on the player
acceptedMedia position is read from Windows' system media transport controls and seeking uses TryChangePlaybackPositionAsync, which is dropped where the player does not allow it. Browsers vary by site.
A background browser tab keeps the browser's name
acceptedOnly the front tab titles the window, so a stream playing in a background tab is reported as the browser. A packaged app is matched by its own id instead.
SendInput cannot reach elevated windows
acceptedAn elevated window, a UAC prompt or the secure desktop silently refuses injected input. That is Windows protecting them. The refusals are counted and logged once per session.
Edgepad.exe is not code-signed
acceptedSmartScreen warns on every download. Acceptable for a small audience; it is the largest drop-off point if traffic is ever driven at it.
Artifact sizes
acceptedEdgepad.exe is about 58.5 MB as a compressed single file, down from roughly 143 MB uncompressed. Edgepad.apk is about 2.3 MB with R8 off, mostly the Kotlin standard library. Follow-ups, not defects.
Not goals
No account, no server, no telemetry, and no web deployment: Edgepad ships as two native binaries from GitHub Releases and there is nothing to host. The link does not reach another room or the internet, and it is not meant to.
Reference
Version history
2.0.0
2026-09-17Macro buttons — the phone can launch things on the laptop, by slot number. A refresh-rate dial. An on-screen readout on the laptop when a level is set from the phone. Pointer and scroll speed become settings. Per-dial-kind sensitivity. Two-finger gestures become fixed rather than assignable. Three security claims corrected. The wire protocol stayed at version 3.
1.0.0
2026-09-16The first stable release. Behaviour unchanged from 0.10.0; what changed is the promise around it. The wire protocol settles at version 3 and from here only moves in a major release, and the repository is arranged for someone other than its author to work in. Several duplicated pieces — both layout editors' canvas, the gamepad's shape rules, the surface's three buttons, Perimeter's four corners, the extended-key table — were reduced to one each.
0.10.0
2026-09-15The phone app is redesigned. A five-page guide, shown one page at a time or all on one scrolling page. JetBrains Mono bundled and used for every piece of text. Settings pages of their own for Corners, Dial feel and Background & pattern. Layout editors that fill the screen so what is laid out has the surface's proportions.
0.6.0
earlierProtocol version 3: TEXT kind 3 carries text to type, which is what the phone's keyboard screen sends.
0.3.0
earlierProtocol version 2: TEXT kind 2 carries the timeline, and a version mismatch is refused with the laptop's own version so the phone can name which side is old. The rulers become slide-along rather than turned round a corner point.
0.1.0
earlierProtocol version 1: HELLO through STATE. The first release.
Reference
Licence and credits
- Icons are Lucide, ISC licence.
- The typeface is JetBrains Mono, SIL Open Font License 1.1. Its licence ships inside the APK under
assets/licenses. - Player logos belong to their owners and are drawn as supplied.
- The laptop app depends on NAudio (for volume) and
System.Management(for WMI brightness), and nothing else. The phone app depends on JUnit, and nothing else.