Documentation
Everything Edgepad does, and how.
Install, the control surface, both apps' architecture, the wire protocol and the developer guide. The protocol tables below are generated from the same fixtures both test suites read, so this page cannot drift from the two apps.
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
A full on-screen keyboard whose modifiers work held or tapped. It opens sideways, and its text size is a setting.
Gamepad
A real virtual Xbox controller on the laptop, with analog sticks and triggers, where the ViGEmBus driver is installed; the same pad falls back to sending keys where it is not. Every control is yours to place, bind, size and label, and layouts are kept one per game.
Shapes
Press and hold on the trackpad until it ticks, then draw without lifting. The stroke is matched against the shapes drawn in Settings, and the one it matches runs a laptop action, a macro, or the pad's own focus or lock.
Macro buttons
Fifteen slots the laptop's tray menu names with an app, a document, a folder or a URL each; the phone sends the slot number and never what it opens. The grid sizes every button to the longest name and fits as many across as the screen has room for.
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-x.y.z.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 whether a phone is connected, then Start with Windows, Macros…, Open log, Documentation, Forget trusted phone and Quit Edgepad, with the version beside it.
Running a newer Edgepad-x.y.z.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 time, the laptop asks Trust this phone?; choose Trust there. That phone becomes the laptop's trusted phone, and 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. |
| Anywhere else, one finger | press and hold until it ticks, then draw | The action or macro bound to the shape drawn. An unrecognised stroke does nothing. |
| Top centre | tap one of the five buttons | Settings, the keyboard, the lock, the gamepad, the macros, left to right. The lock stays on the phone. |
| 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.
Focusing and locking the pad
The middle of the five top buttons is the lock. One tap hides the dials and the media and leaves the trackpad. A second tap inside the double-tap window locks the trackpad instead and puts the dials and the media back, so the phone can sit in a pocket or under a palm and answer only its rulers. Any later tap returns the whole surface. A locked pad keeps the lock button drawn in the full ink, because a surface that silently swallows every touch reads as a crash rather than as a mode.
The mode lives in the view: it is neither stored nor offered as a setting, since a phone that came back up silently locked would read as broken.
Shapes
Press one finger on the trackpad, hold it still until it ticks, then draw without lifting. On lift the stroke is matched against the shapes drawn under Settings > Shapes, and the one it matches runs: a laptop action, a macro slot, or the pad's own focus or lock. A stroke that matches nothing does nothing, which is the right answer — the alternative is the nearest binding firing on a scrawl.
Shape mode will not arm while dragging, and stops the moment a second finger lands, so scroll, pinch and the three- and four-finger swipes are untouched. A phone with no shapes drawn behaves exactly as it did before there were any.
Macro buttons
Fifteen slots, named from the laptop's tray menu. Fifteen is what one frame can name — fifteen names at sixteen bytes with fourteen separators is 254 of the 255 bytes a TEXT frame carries — 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.
The grid is no longer a fixed 5x3. Every cell is as wide as the widest label in it, so no button is a different size from its neighbour, and a row holds as many as the screen actually has room for. The count is capped by what fits rather than by how many slots are filled, so two macros are two ordinary buttons at the left of a full-width row and not two half-screen slabs.
Start here
Settings
The hub groups its rows by the thing each one configures, not by the kind of editor the row opens. Until 3.0.0 it did the opposite, which is how the keyboard's text size and the macro buttons' appearance both ended up on a page titled Background & pattern — a title that described neither.
Connection
The remembered laptop with its round trip, Forget, and whether to reconnect automatically.
Surface
Corners (which dial each corner holds, or none), Trackpad (pointer and scroll speed, natural scrolling, the three- and four-finger map and the on-screen hints), Shapes (draw one, bind it, delete it), and Dial feel (slide sensitivity — shared or per dial kind — dial length and height, haptic ticks, snapping to round numbers, with a live preview).
Controls
One page each for the Keyboard, the Gamepad layout, the Macro buttons and the Media layout. A page holds everything about its own control: the keyboard's text size, the gamepad's canvas and layout library, whether a macro button shows its icon alone or its icon and label, and where the three media pieces sit.
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. 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.
Orientation is a toggle — upright or sideways — not free rotation, and since 3.0.0 it speaks only for the control surface and the two canvases that stand in for it: the media layout editor, because a media layout has been stored per orientation since 2.3.0 and a phone turned mid-edit would quietly begin changing the other one, and the shape canvas, because a stroke is drawn at the pad's own proportions. The keyboard, the gamepad and the gamepad's editor are sideways and only sideways. Every other screen follows the phone, which is what the setting used to override for the whole app.
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 gamepad is the one place where the phone sends state rather than an event, and it is still not a keystroke. A PAD_STATE frame is a snapshot of a whole controller — sixteen button bits, two triggers, four axes — which the laptop copies onto the report of the virtual Xbox pad it has plugged in. The phone is describing a controller, not naming a key, which is exactly why a stick can be analog: eight compass sectors pressing WASD is what it did before, and a half push and a full push were the same key.
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.
The gamepad's virtual controller is not a counter-example to that. ViGEmBus is a signed driver the user installs deliberately and Edgepad only talks to, where a virtual touchpad would have meant shipping an unsigned driver and asking for test-signing mode. It is also optional: without it the pad sends keys.
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 · VirtualPad
SendInput, Core Audio, WMI, system media transport controls, and a virtual Xbox controller where ViGEmBus is installed
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, Dial, Shapes, PadAxis, PadMode and the gamepad's layout library are pure Kotlin with no Android types. That is what lets the gesture table, the shape matcher, the stick arithmetic, the lock button's transitions and the rules a saved layout obeys all run as plain JVM unit tests with no emulator. The stick arithmetic is there for a specific reason: it used to sit unreachable inside a View's touch handler, so nothing covered touch to output at all.
Matching a shape is a $1-style unistroke: resample to 32 points, centre, scale, compare pointwise. Two departures from $1, both deliberate. The scale is uniform, because $1's per-axis box fit makes a tall I and a round O the same blob; and there is no rotation normalisation, because a C turned around is not a C.
The laptop's virtual controller
VirtualPad wraps ViGEmBus. It copies each PAD_STATE frame onto an Xbox 360 report field for field, because the frame already carries XInput's own layout, and submits one report per frame rather than one per field — auto-submit would push seven half-written reports for every frame, and a game sampling between two of them would read a stick that had moved and a button that had not.
A missing driver is an ordinary state here, never an exception that escapes: a tray app has no dialog to show a crash in, and an escape from the session thread would take the link and everything the phone was holding down with it. Every entry point answers with a PAD_STATUS token instead. Failures are caught by namespace rather than by a list of types, because all seventeen of the library's exceptions derive straight from Exception with no common base, and the one a list missed would be the crash the class exists to prevent. The pad is unplugged in the session's finally beside the input injector, so a phone that disappears mid-game cannot leave a controller plugged in holding a stick.
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. When the app leaves the foreground, anything held down is released at once, but the link is kept for 30 seconds, so a background-image pick or a trip to Bluetooth settings does not drop it; it closes if the app is not back by then.
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; only the lastSETper control survives, and only the newestPAD_STATE, which is a snapshot of the whole controller rather than a change to it. A slow link catches up in a single packet instead of replaying every sample it missed.KEYframes are never collapsed: a press and its release are two messages that happen to name the same key, and dropping either leaves it held on the laptop. - 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 4
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. |
| 0x23 | PAD_STATE | buttons u16, lt u8, rt u8, lx i16, ly i16, rx i16, ry i16 | phone → laptop | The whole virtual controller in one frame, laid out like Windows' own XINPUT_GAMEPAD so the laptop copies the fields rather than translating them. Triggers are 0-255, sticks -32768 to 32767, positive up and right. A snapshot and not a change, so a backlog of them collapses to the newest. |
| 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 | 4 | 01 45 44 47 50 04 |
| 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 |
| ACTION | 35 | 20 23 |
| ACTION | 36 | 20 24 |
| 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 |
| PAD_STATE | 0 0 0 0 0 0 0 | 23 00 00 00 00 00 00 00 00 00 00 00 00 |
| PAD_STATE | 0 0 0 -32768 -32768 -32768 -32768 | 23 00 00 00 00 00 80 00 80 00 80 00 80 |
| PAD_STATE | 0 0 0 32767 32767 32767 32767 | 23 00 00 00 00 ff 7f ff 7f ff 7f ff 7f |
| PAD_STATE | 0 255 255 0 0 0 0 | 23 00 00 ff ff 00 00 00 00 00 00 00 00 |
| PAD_STATE | 12305 32 200 -1 1 256 -256 | 23 11 30 20 c8 ff ff 01 00 00 01 00 ff |
| PAD_STATE | 63487 0 0 0 0 0 0 | 23 ff f7 00 00 00 00 00 00 00 00 00 00 |
| 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 | 6 0/0/1/AAAA | 41 06 0a 30 2f 30 2f 31 2f 41 41 41 41 |
| TEXT | 7 | 41 07 00 |
| TEXT | 8 ready | 41 08 05 72 65 61 64 79 |
| TEXT | 8 no-driver | 41 08 09 6e 6f 2d 64 72 69 76 65 72 |
| TEXT | 8 attach-failed | 41 08 0d 61 74 74 61 63 68 2d 66 61 69 6c 65 64 |
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. |
| 6 | MACRO_ICON | One piece of one macro's icon as slot/chunk/chunks/base64, the base64 being part of a PNG. An icon does not fit the 255 bytes a payload holds, so it arrives in pieces. Laptop to phone. |
| 7 | WANT_ICONS | Asks for the macro icons, with an empty payload. Sent when the phone opens the macro grid; nothing is pushed unasked. Phone to laptop. |
| 8 | PAD_STATUS | Whether a virtual controller can be offered, as one of ready, no-driver or attach-failed. Sent after the handshake and in answer to PAD_ATTACH and PAD_DETACH. An unknown token reads as no-driver, so the phone falls back to the keyboard rather than sending input nothing receives. Laptop to phone. |
PAD_STATUS tokens
| Token | Name | What it means |
|---|---|---|
| ready | READY | The virtual controller is plugged in and PAD_STATE will be acted on. The only token that leaves the pad usable. |
| no-driver | NO_DRIVER | The virtual-controller driver, ViGEmBus, is not installed on the laptop. The pad falls back to keys. |
| attach-failed | ATTACH_FAILED | The driver is there but plugging the pad in did not work. The pad falls back to keys. |
What the laptop sends, and when
STATE volume, microphone, brightness
TEXT 4 the display's refresh rates
STATE the current refresh rate
TEXT 8 whether a virtual controller can be offered
TEXT 0 now playing, 1 the app, 2 the timeline
STATE media position
TEXT 5 the laptop's macro names
then every change as it happens.
Media position is refreshed once a second while playing.
TEXT 6, the macro icons, arrive only after the phone asks with TEXT 7.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. |
| 4 | 3.0.0 | PAD_STATE, a new frame type, which is the one thing that forces this number. Actions 35 and 36 and TEXT kind 8 came with it and would not have needed a bump on their own — nor did TEXT 6 and 7, the macro icons, in 2.3.0. |
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. Nothing in the existing frame set could express an axis — SET carries a u8 the laptop rejects above 100, and the only pair of signed i16s belongs to the mouse — so a real gamepad needed a type of its own, and a new type is the one thing that moves the version. 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. |
| 35 | PAD_ATTACH | Asks the laptop to plug in its virtual Xbox controller. The laptop answers with TEXT 8, so the phone learns whether it may send PAD_STATE or must fall back to the keyboard. |
| 36 | PAD_DETACH | Asks the laptop to unplug the virtual controller. Sent when the gamepad screen closes; the laptop also unplugs it by itself when the session ends. |
| 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. |
Gamepad buttons
| Mask | Name | What it means |
|---|---|---|
| 0 | NONE | Nothing held. What PAD_STATE carries the moment the last button comes up. |
| 1 | DPAD_UP | D-pad up. |
| 2 | DPAD_DOWN | D-pad down. |
| 4 | DPAD_LEFT | D-pad left. |
| 8 | DPAD_RIGHT | D-pad right. |
| 16 | START | Start, the menu button. |
| 32 | BACK | Back, the view button. |
| 64 | LEFT_THUMB | Left stick pressed in. |
| 128 | RIGHT_THUMB | Right stick pressed in. |
| 256 | LEFT_SHOULDER | Left bumper. |
| 512 | RIGHT_SHOULDER | Right bumper. |
| 1024 | GUIDE | The Xbox button. |
| 4096 | A | A. |
| 8192 | B | B. |
| 16384 | X | X. |
| 32768 | Y | Y. |
The laptop copies the field straight into an XINPUT_GAMEPAD rather than remapping it, so a table of Edgepad's own would be a second definition to keep in step with Microsoft's. 0x0800, between GUIDE and A, is unused by XInput and stays unused here.
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. While no phone is trusted, the first phone to complete the handshake makes the tray ask Trust this phone?, and HELLO_ACK waits for the answer. Trust writes its Bluetooth address to %APPDATA%\Edgepad\trusted-phone.txt; Don't trust, or no answer within 60 s, closes the connection. Every other paired phone is refused before HELLO_ACK. The tray menu's Forget trusted phone clears it, after asking.
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
3.0.0 gives the phone a second way to reach the laptop's input stack and does not move that boundary. PAD_STATE drives a virtual Xbox controller, which games read and the desktop largely does not; it can press A and push a stick, and it cannot type a command line. The same phone's keyboard screen already could. What is worth saying plainly instead is that the controller needs a third-party signed kernel driver, ViGEmBus, which you install and Edgepad only talks to — see Known limits for what that costs.
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.
- The virtual controller is ViGEmBus, a third-party kernel driver you install yourself. It is signed, and its author archived it in November 2023, so it receives no updates. Edgepad neither ships it nor installs it, and works without it.
- 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, ViGEmBus for the virtual controller. |
| 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, the shape matcher, the pad's focus and lock table. | 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, which bindings fit which kind of control, the stick and trigger arithmetic, the layout library and its store. | Pure |
| screens/ | Every screen: guide, devices, settings and its per-control pages, layout editors, shapes, 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 |
| Gamepad/ | VirtualPad: the ViGEmBus controller, and the PAD_STATUS answer when there is no driver. | 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. 3.0.0 adds ShapesTest for the unistroke matcher, PadAxisTest for the dead zone and the stick scaling, PadModeTest for the lock button's transitions, and GamepadStoreTest for what a name collision costs and what is left playing after a deletion. 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, the media-session title mapping, and VirtualPadTests.
Developing
Contributing
Fork the repository on GitHub and clone your fork. Your work lives on your fork, and reaches this repository as a pull request.
git clone https://github.com/<you>/edgepad.git
cd edgepad
git remote add upstream https://github.com/akshit-bansal11/edgepad.git- Bring your fork's
mainup to date withupstream/main, then branch from it on your fork. - 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. - Push the branch to your fork and open a pull request against
mainhere. CI runs the same gate on both halves, and the site's gate whensite/orprotocol/changed. The maintainer reviews and merges.
Style
- No UI libraries on the phone. No third-party packages on the laptop beyond NAudio,
System.ManagementandNefarius.ViGEm.Client. 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
Each release carries a signed APK and a compressed self-contained exe, named for the version, Edgepad-3.1.1.apk and Edgepad-3.1.1.exe, so several downloads on one phone or laptop can be told apart. The app's versionName comes from the release tag and its versionCode from a number that 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; the stick dead zone in PadAxis. They are tuned by feel rather than measured, so expect to adjust the sensitivity settings to taste.
No real stroke has been scored against the shape thresholds
unverifiedMIN_SCORE and MIN_MARGIN in Shapes decide how close is close enough and by how much the best match must beat the runner-up before it fires. Both are unmeasured guesses, like every other feel constant here, and they are the two numbers worth tuning if shapes fire too readily or not readily enough.
ViGEmBus is archived and unmaintained
acceptedThe virtual controller is a third-party signed kernel driver its author archived in November 2023; it receives no updates. It remains signed and installs and works today, and nothing about Edgepad can change that. Edgepad neither ships it nor installs it, works without it, and says on the gamepad screen which of the two modes it is in. A Windows release that stopped accepting the driver would take the controller with it and leave keyboard mode.
A ViGEmBus installed mid-session is not noticed until you reconnect
acceptedThe driver is looked for once per connection and the answer kept, because the phone sends PAD_ATTACH whenever the gamepad screen opens and retrying an absent driver would be a probe and a log line per press. The cost is one reconnect after an install you are already watching.
A gamepad layout saved by 2.x is refused, not migrated
acceptedA 2.x layout has no controller bindings in it to migrate, so migrating would be a lie; the first built-in layout — now a real controller — takes its place. Dragged positions are lost, once. The old single-layout key is still read once on update, so an arrangement already in the 3.0 encoding becomes the first entry in the library rather than being dropped with it.
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
3.0.0
2026-09-24The gamepad becomes a real controller. The laptop plugs in a virtual Xbox pad through ViGEmBus and copies each PAD_STATE frame onto its report, so sticks and triggers are analog and a game that only reads controllers can be played from the phone; a laptop without the driver falls back to sending keys and says so rather than being quietly dead. Every control on the pad is yours to add, bind, size, label and delete, and layouts are a library kept one per game. Shapes drawn on the trackpad run an action or a macro. A fifth top button focuses the pad or locks it. Settings regroups by the thing each row configures, and Orientation stops speaking for the whole app. The macro grid sizes itself and wraps. The decorative edge rulers are gone. The wire protocol moves to version 4, its first move since 0.6.0.
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 few that came from Feather are MIT. Both apps draw Lucide's own paths, and the licence text ships inside the APK as
assets/licenses/Lucide-LICENSE.txtand inside the exe beside the icons. - The typeface is Lato, by Łukasz Dziedzic, SIL Open Font License 1.1. Both apps bundle it, and its licence ships inside the APK as
assets/licenses/Lato-OFL.txtand inside the exe beside the fonts. This site also sets code in JetBrains Mono, under the same licence. - The media players' logos — Spotify, Netflix, YouTube and the rest — are trademarks of their owners. The phone shows one only to say which app is playing on the laptop; that implies no endorsement by, or affiliation with, any of them. The SVG files were supplied by the maintainer and are drawn as supplied.
- The laptop app depends on NAudio (for volume, MIT),
System.Management(for WMI brightness) and Nefarius.ViGEm.Client (the client for the virtual controller, MIT), and nothing else. The phone app depends on AndroidSVG (to draw the mark and the logos, Apache 2.0), and on JUnit for its tests only. - The virtual controller itself is ViGEmBus, BSD 3-Clause licence, by Benjamin Höglinger-Stelzer. It is a separate install and is not distributed with Edgepad.