Skip to content

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

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.

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

Both halves come from the same GitHub release. Always install both from the same one: the two refuse each other at the handshake when their protocol versions differ, and say so.
Windows 10 version 2004 or later64-bitEdgepad-x.y.z.exe
  1. Download Edgepad-x.y.z.exe. It is a single self-contained file; nothing else needs installing, and the laptop does not need .NET.
  2. Run it. It is not code-signed, so SmartScreen asks first: choose More info, then Run anyway.
  3. 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

  1. Pair the phone with the laptop once, in Windows Settings > Bluetooth & devices. Edgepad has no pairing step of its own.
  2. 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.
  3. 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.
  4. 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 whole phone screen is the control surface. A touch is classified where it starts: inside a corner's zone it belongs to that dial for its whole life, on a media piece or a top-centre button it is a button press, anywhere else it is the trackpad.

The control surface

WhereTouchThe laptop does
A corner rulerslideTurns that dial. Clockwise raises.
A corner rulertapThe dial's action: mute, play/pause, mic mute, task view, reset zoom.
Anywhere else, one fingermove / tap / tap then hold-and-movePointer / left click / drag.
Two fingersdragScroll, both axes.
Two fingerspinchZoom (Ctrl+wheel).
Two fingerstapRight click.
Three or four fingerstap, swipe up, down, left, rightWhatever Settings assigns. Ten slots in all.
Anywhere else, one fingerpress and hold until it ticks, then drawThe action or macro bound to the shape drawn. An unrecognised stroke does nothing.
Top centretap one of the five buttonsSettings, 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.

FingersGestureThe laptop does
Threeleft / rightPrevious / next virtual desktop
ThreeupTask view
ThreedownShow the desktop
ThreetapSearch
Fourleft / rightWalk the app switcher. Alt stays held while the fingers are down
FourtapNotifications

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

One SharedPreferences file holds all of it. Dial positions and the gesture map are stored by enum name, so renumbering an action can never silently remap a corner.

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

Laptop files
%APPDATA%\Edgepad\trusted-phone.txt   the trusted phone's Bluetooth address
%LOCALAPPDATA%\Edgepad\edgepad.log    connections, refusals, dropped frames,
                                      and input batches Windows refused

The log is also in the tray menu, under Open log.

How it works

The split

The phone recognises; the laptop executes. That single sentence decides most of the rest of the design.

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

Bluetooth Classic RFCOMM: an ordered, encrypted byte stream between two already-paired devices, with no server, no discovery and no network anywhere in the product.
RFCOMMBluetoothEncryptionWithAuthenticationProtocol v4
Service UUID, from protocol/actions.txt
758bb618-7b72-4cd3-9aa2-c9b88e54d555

Both 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

One product in two programs, in one repository. This is the map of both and of the single seam between them.

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

RFCOMM

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 recognises; the laptop executes. Frames go right: pointer, button, scroll, zoom, action id, control value, key, text. State comes back left: volume, mute, microphone, brightness, refresh rate, what is playing, macro names, and the PONG the round-trip readout is measured from.

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

Latency is the design priority after correctness. Every choice below exists to keep the path from a finger to SendInput short, and to stop a slow link from turning into a growing backlog.

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

  1. Unbuffered touch. View.requestUnbufferedDispatch, with every historical sample read from each MotionEvent, so events arrive as they happen rather than batched to vsync.
  2. One write per batch. The writer thread sends everything already queued in a single write.
  3. Coalescing. A backlog of MOVE, SCROLL and ZOOM frames still waiting in the outbox is summed into one before it goes out; only the last SET per control survives, and only the newest PAD_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. KEY frames 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.
  4. 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

A frame is one type byte followed by a payload whose length is fixed by the type, except 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

TypeNamePayloadDirectionMeaning
0x01HELLOmagic EDGP, version u8phone → laptopOpens a session.
0x02HELLO_ACKversion u8laptop → phoneAccepts it. On a version mismatch the laptop sends its own version, then closes, so the phone can say which side needs updating.
0x10MOVEdx i16, dy i16phone → laptopMove the pointer, in laptop pixels, before Windows' own pointer acceleration.
0x11BUTTONbutton u8, down u8phone → laptop0 left, 1 right, 2 middle. down is 1 or 0.
0x12SCROLLdx i16, dy i16phone → laptopWheel units. 120 is one notch; positive dy is wheel forward.
0x13ZOOMdelta i16phone → laptopCtrl+wheel units.
0x20ACTIONid u8phone → laptopRun one action from the laptop's table.
0x21SETcontrol u8, value u8phone → laptopSet a control to 0-100. The laptop drops anything above 100.
0x22KEYcode u16, down u8phone → laptopPress or release one key, by Windows virtual-key code. This is what the phone's keyboard screen sends.
0x23PAD_STATEbuttons u16, lt u8, rt u8, lx i16, ly i16, rx i16, ry i16phone → laptopThe 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.
0x30PINGt i64phone → laptopThe phone's clock, in nanoseconds.
0x31PONGt i64laptop → phoneThe same value echoed back, which is what the round-trip readout measures.
0x40STATEcontrol u8, value u8, flags u8laptop → phoneA control's current value. Flags bit 0 is muted for audio controls, playing for media position.
0x41TEXTkind u8, length u8, UTF-8 bytesboth waysThe only variable-length frame: up to 255 bytes, never split inside a character.
14 frame types, read from protocol/frames.txt.

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.

FrameFieldsBytes
HELLO101 45 44 47 50 01
HELLO201 45 44 47 50 02
HELLO301 45 44 47 50 03
HELLO401 45 44 47 50 04
HELLO_ACK102 01
MOVE5 -310 05 00 fd ff
MOVE-32768 3276710 00 80 ff 7f
BUTTON0 111 00 01
BUTTON1 011 01 00
SCROLL0 -12012 00 00 88 ff
ZOOM24013 f0 00
ACTION320 03
ACTION3520 23
ACTION3620 24
SET0 5521 00 37
SET1 10021 01 64
SET3 5021 03 32
KEY65 122 41 00 01
KEY112 022 70 00 00
KEY65535 122 ff ff 01
PAD_STATE0 0 0 0 0 0 023 00 00 00 00 00 00 00 00 00 00 00 00
PAD_STATE0 0 0 -32768 -32768 -32768 -3276823 00 00 00 00 00 80 00 80 00 80 00 80
PAD_STATE0 0 0 32767 32767 32767 3276723 00 00 00 00 ff 7f ff 7f ff 7f ff 7f
PAD_STATE0 255 255 0 0 0 023 00 00 ff ff 00 00 00 00 00 00 00 00
PAD_STATE12305 32 200 -1 1 256 -25623 11 30 20 c8 ff ff 01 00 00 01 00 ff
PAD_STATE63487 0 0 0 0 0 023 ff f7 00 00 00 00 00 00 00 00 00 00
PING123456789012330 cb 04 fb 71 1f 01 00 00
PONG-131 ff ff ff ff ff ff ff ff
STATE0 42 140 00 2a 01
STATE3 42 140 03 2a 01
TEXT3 hi41 03 02 68 69
TEXT1 Song41 01 04 53 6f 6e 67
TEXT041 00 00
TEXT2 84/22741 02 06 38 34 2f 32 32 37
TEXT0 Café41 00 05 43 61 66 c3 a9
TEXT0 🎵41 00 04 f0 9f 8e b5
TEXT0 日本語41 00 09 e6 97 a5 e6 9c ac e8 aa 9e
TEXT6 0/0/1/AAAA41 06 0a 30 2f 30 2f 31 2f 41 41 41 41
TEXT741 07 00
TEXT8 ready41 08 05 72 65 61 64 79
TEXT8 no-driver41 08 09 6e 6f 2d 64 72 69 76 65 72
TEXT8 attach-failed41 08 0d 61 74 74 61 63 68 2d 66 61 69 6c 65 64
Every line of protocol/frames.txt, verbatim.

TEXT kinds

KindNameWhat it means
0NOW_PLAYINGWhat is playing. Laptop to phone.
1APPThe app playing it. Laptop to phone.
2TIMELINEThe timeline as seconds/length, such as 84/227. Laptop to phone.
3TYPEText to type, where \b is backspace and \n is enter. Phone to laptop.
4REFRESH_RATESThe display's available refresh rates as 60/120/144, in the order CONTROL 4 indexes them. Laptop to phone.
5MACROSThe laptop's macro names as Chrome/Spotify/Notes, in the order MACRO_BASE indexes them. A blank name keeps its slot. Laptop to phone.
6MACRO_ICONOne 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.
7WANT_ICONSAsks for the macro icons, with an empty payload. Sent when the phone opens the macro grid; nothing is pushed unasked. Phone to laptop.
8PAD_STATUSWhether 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.
Read from protocol/actions.txt. TEXT carries at most 255 bytes and is never split inside a character.

PAD_STATUS tokens

TokenNameWhat it means
readyREADYThe virtual controller is plugged in and PAD_STATE will be acted on. The only token that leaves the pad usable.
no-driverNO_DRIVERThe virtual-controller driver, ViGEmBus, is not installed on the laptop. The pad falls back to keys.
attach-failedATTACH_FAILEDThe driver is there but plugging the pad in did not work. The pad falls back to keys.
The whole vocabulary of TEXT kind 8, read from protocol/actions.txt. A token the phone does not know reads as no-driver.

What the laptop sends, and when

After HELLO_ACK
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

VersionReleaseChange
10.1.0HELLO through STATE. TEXT kinds 0 and 1 arrived in 0.2.0 without a bump.
20.3.0TEXT kind 2. A mismatch is refused with the laptop's version.
30.6.0TEXT 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.
43.0.0PAD_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

The laptop owns both tables. The phone names an id; the laptop decides what the id does.

Actions

IdNameWhat it means
1MUTE_TOGGLEMute key.
2PLAY_PAUSEMedia play/pause key.
3NEXT_TRACKMedia next key.
4PREVIOUS_TRACKMedia previous key.
5MIC_MUTE_TOGGLEToggles the default microphone's mute.
6LOCKLockWorkStation. Win+L cannot be injected.
20TASK_VIEWWin+Tab.
21SHOW_DESKTOPWin+D.
22SEARCHWin+S.
23NOTIFICATIONSWin+N.
24DESKTOP_LEFTWin+Ctrl+Left.
25DESKTOP_RIGHTWin+Ctrl+Right.
26APP_SWITCH_BEGINHolds Alt and presses Tab. Alt stays down until APP_SWITCH_END or the session ends.
27APP_SWITCH_NEXTTab, only while Alt is held.
28APP_SWITCH_PREVIOUSShift+Tab, only while Alt is held.
29APP_SWITCH_ENDReleases Alt.
30ZOOM_RESETCtrl+0.
31VOLUME_UPVolume-up key.
32VOLUME_DOWNVolume-down key.
33BRIGHTNESS_UPThe panel's brightness, plus 10.
34BRIGHTNESS_DOWNThe panel's brightness, minus 10.
35PAD_ATTACHAsks 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.
36PAD_DETACHAsks the laptop to unplug the virtual controller. Sent when the gamepad screen closes; the laptop also unplugs it by itself when the session ends.
64MACRO_BASEThe 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.
24 action ids, read from protocol/actions.txt. Sent as the payload of the ACTION frame.

Controls

IdNameWhat it means
0VOLUMESET sets the default speakers' level; STATE reports the level and a muted flag.
1BRIGHTNESSSET sets the built-in panel's brightness through WMI; STATE reports the level.
2MIC_LEVELSET sets the default microphone's level; STATE reports the level and a muted flag.
3MEDIA_POSITIONSET seeks the current track to that percent, where the player allows it; STATE reports the percent and a playing flag.
4REFRESH_RATESET 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.
5 control ids, read from protocol/actions.txt. Carried by SET (phone to laptop) and STATE (laptop to phone).

Gamepad buttons

MaskNameWhat it means
0NONENothing held. What PAD_STATE carries the moment the last button comes up.
1DPAD_UPD-pad up.
2DPAD_DOWND-pad down.
4DPAD_LEFTD-pad left.
8DPAD_RIGHTD-pad right.
16STARTStart, the menu button.
32BACKBack, the view button.
64LEFT_THUMBLeft stick pressed in.
128RIGHT_THUMBRight stick pressed in.
256LEFT_SHOULDERLeft bumper.
512RIGHT_SHOULDERRight bumper.
1024GUIDEThe Xbox button.
4096AA.
8192BB.
16384XX.
32768YY.
The bits of PAD_STATE's buttons field, read from protocol/actions.txt. XInput's own wButtons values, in decimal.

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

Edgepad lets a phone drive a laptop, so its boundaries matter. There are two layers, and one common misreading of them.

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.exe is 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

A monorepo. One slug, one repository, two programs that must ship together.
PathWhat
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.

PackageWhatTestable
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.

FolderWhatTestable
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

Either half can be worked on alone. You do not need both toolchains to contribute to one app.

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, with rollForward: latestFeature.
  • Windows 10 version 2004 or later, because the app builds against the Windows SDK projection for Bluetooth.

Developing

The quality gate

One script checks both halves: format, lint, build, test. CI runs the same script in its non-mutating mode, so the local gate and CI cannot disagree.
The 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 code

It 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

HalfStepLocal / CI
androidKotlin formattingktlintFormat / ktlintCheck
androidAndroid lint, then the unit testssame
windowsC# formattingdotnet format / --verify-no-changes
windowsBuild, then testdotnet 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

Build each half directly, without the gate, while iterating.

Laptop

Run the tray app
dotnet run --project windows/src/Edgepad

It must already be running before the phone tries to connect: there is no discovery or retry on the laptop side, only the phone reconnects.

Build a release-shaped exe
dotnet publish windows/src/Edgepad -c Release -r win-x64 --self-contained \
  -p:PublishSingleFile=true \
  -p:IncludeNativeLibrariesForSelfExtract=true \
  -p:EnableCompressionInSingleFile=true

A self-contained single file, the same shape a release ships. Compression takes it from roughly 143 MB to 58.5 MB.

Build only
cd windows && dotnet build Edgepad.slnx -c Release

Phone

Install a debug build on a connected device
cd android && ./gradlew installDebug

Uninstall 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.

Build only
cd android && ./gradlew assembleDebug

Together

  1. Pair the phone and the laptop in Windows Settings.
  2. Start the tray app with dotnet run.
  3. Install and open the phone app, then tap the laptop.
  4. Read %LOCALAPPDATA%\Edgepad\edgepad.log for dropped frames or refused input batches.

This site

Documentation 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-mutating

Biome 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

Both suites read the same fixtures. Neither sends real input, opens a real Bluetooth socket, or touches a device.
Run them
cd android && ./gradlew testDebugUnitTest
cd windows && dotnet test --solution Edgepad.slnx

The 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

Edgepad is small on purpose. The bar for a change is that it makes the product better for someone using it, and that it passes the same gate CI runs.

Fork the repository on GitHub and clone your fork. Your work lives on your fork, and reaches this repository as a pull request.

Get the code
git clone https://github.com/<you>/edgepad.git
cd edgepad
git remote add upstream https://github.com/akshit-bansal11/edgepad.git
  1. Bring your fork's main up to date with upstream/main, then branch from it on your fork.
  2. Keep the two apps in step. A protocol change edits protocol/frames.txt or protocol/actions.txt, both codecs or both enums, and bumps the protocol version on both sides — all in one commit.
  3. 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.
  4. Run pwsh scripts/check.ps1 until it is clean.
  5. Commit with a Conventional Commits subject and a body saying what changed and why, including what you rejected.
  6. Add a line under Unreleased in CHANGELOG.md.
  7. Push the branch to your fork and open a pull request against main here. CI runs the same gate on both halves, and the site's gate when site/ or protocol/ changed. The maintainer reviews and merges.

Style

  • No UI libraries on the phone. No third-party packages on the laptop beyond NAudio, System.Management and Nefarius.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

Releases are published by the maintainer, and built by CI rather than by hand. A pull request never cuts one: its line under Unreleased ships with the next release.

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

Each of these had a real alternative that was considered and rejected. The reason is kept with the rule, because a rule stripped of its reasoning gets re-litigated by the next person who reads it.

Reference

Known limits

What is not built, what is not verified, and what is a deliberate trade. Nothing here is hidden because it is inconvenient.

No latency figure has ever been measured

unverified

The 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

unverified

Pointer 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

unverified

MIN_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

accepted

The 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

accepted

The 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

accepted

A 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 built

A 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 built

It 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

accepted

Back-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

accepted

Media 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

accepted

Only 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

accepted

An 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

accepted

SmartScreen 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

accepted

Edgepad.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

The full record is CHANGELOG.md, which follows Keep a Changelog. Versions follow Semantic Versioning.

3.0.0

2026-09-24

The 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-17

Macro 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-16

The 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-15

The 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

earlier

Protocol version 3: TEXT kind 3 carries text to type, which is what the phone's keyboard screen sends.

0.3.0

earlier

Protocol 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

earlier

Protocol version 1: HELLO through STATE. The first release.

Reference

Licence and credits

MIT.
  • 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.txt and 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.txt and 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.