Documentation

Your laptop, from across the room.

Edgepad turns an Android phone into a trackpad, a media remote and a control panel for a Windows laptop, over a direct Bluetooth link with nothing in between. No account, no Wi-Fi, no server.

Transport
Bluetooth Classic RFCOMM
Protocol
Version 3
Phone
Kotlin, Android 12+
Laptop
C# on .NET 10, Windows 10 2004+
No accountNo networkNo telemetryMIT

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 and gamepad

A full on-screen keyboard whose modifiers work held or tapped, and a gamepad with editable layouts and presets. Both open sideways.

Macro buttons

Fifteen buttons in a 5x3 grid. The laptop's tray menu names an app, a document, a folder or a URL for each; the phone sends the slot number and never what it opens.

Live state

The dials show the laptop's real volume, mute, brightness and playback position, and follow changes made on the laptop itself.

Start here

Download and install

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.exe
  1. Download Edgepad.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 the version, whether a phone is connected, Macros, Start with Windows, Forget trusted phone, Open log, Documentation and Quit.

Running a newer Edgepad.exe asks the running copy to quit and takes its place, so an update takes over cleanly rather than failing on the single-instance lock.

Pairing and first connection

  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 phone to connect becomes the laptop's trusted phone; any other paired phone is refused until you choose Forget trusted phone in the tray menu.
  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.
Top centretap the gear, the keyboard, the gamepadOpens Settings, the keyboard, the gamepad.
BackLeaves 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.

Macro buttons

Fifteen buttons in a 5x3 grid, named from the laptop's tray menu. Fifteen is what one frame can name, so a full grid always arrives labelled rather than trailing off into blank buttons. A macro added while the phone is connected appears at once.

Start here

Settings

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.

Connection

The remembered laptop with its round trip, Forget, and whether to reconnect automatically.

Surface

Corners (which dial each corner holds, or none), Gestures (the gesture map and on-screen hints), Dial feel (slide sensitivity — shared or per dial kind — dial length and height, haptic ticks, snapping to round numbers, with a live preview), and natural scrolling.

Trackpad

Pointer speed and scroll speed. Settings since 2.0.0, constants before it.

Layouts

The media layout and the gamepad layout, each a full-screen canvas where pieces are dragged anywhere. The options button holds the size, the presets and reset.

Appearance

Dark or light, upright or sideways, and Background & pattern: a control colour, a colour, gradient or image behind the surface, and a grid, dots or checker over it.

Help

The five-page guide, shown one page at a time with a drawing or all on one scrolling page. It opens on the first run and again from here. Below it, Documentation opens this page in a browser.

The theme is the system's own per-app night mode, set through UiModeManager.setApplicationNightMode, so Edgepad stores no preference of its own for it. Orientation is a toggle — upright or sideways — not free rotation. Media-piece positions are stored as fractions of the surface's width and height, so a layout made in portrait keeps its shape in landscape.

Where the laptop keeps things

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 laptop reports back. After the handshake it sends a snapshot of volume, microphone and brightness, then what is playing and where, then the display's available refresh rates and the macro names, and thereafter every change as it happens. The phone keeps the last report in LaptopState, which outlives the control surface, so a surface rebuilt after a rotation or a theme change starts from real values and never shows 0 for a level it has not heard. A level never reported shows an ellipsis, not a zero.

Why Windows gestures are emulated by their results

Windows only runs its native multi-finger gestures for certified Precision Touchpad hardware. Two ways round that were rejected: a virtual PTP driver, which needs a kernel driver and test-signing mode and so weakens the laptop's security, and making the phone a Bluetooth HID touchpad, which needs a PTP certification blob and depends on Android OEM support for BluetoothHidDevice. So Edgepad produces the result instead: Win+Ctrl+Left rather than a finger-following desktop slide. The cost is the animation. Alt+Tab stays interactive because Alt is genuinely held while the fingers are down.

How it works

Transport

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

    SendInput, Core Audio, WMI, system media transport controls

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 and Dial are pure Kotlin with no Android types. That is what lets the whole gesture table run as plain JVM unit tests with no emulator.

Why the laptop half is a tray app, not a service

Windows services run in session 0 and can neither inject input into the desktop nor reach the user's audio session. So Edgepad is a per-user tray app that starts at login through the HKCU Run key. It is single-instance, held by a named mutex, and a newer copy signals the running one to quit and waits for the lock rather than killing the process.

How it works

Threads and latency

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, and closed when the app leaves the foreground.

On the laptop

RfcommServer accepts on WinRT's thread and hands each socket to a Session on a dedicated above-normal-priority thread, where the read loop turns frames straight into input. There is no queue between the socket and SendInput.

A new connection replaces the old one rather than being refused, because after a dropped link the phone reconnects before the laptop's old socket has noticed it is dead. Audio-change notifications arrive on COM threads and brightness events on a WMI thread, so Session.Send is locked and they interleave safely with the read loop's PONGs.

The four things that make it feel immediate

  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, and only the last SET per control survives. A slow link catches up in a single packet instead of replaying every sample it missed.
  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 3

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.
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.
13 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
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
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
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
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.
Read from protocol/actions.txt. TEXT carries at most 255 bytes and is never split inside a character.

What the laptop sends, and when

After HELLO_ACK
STATE  volume, microphone, brightness
TEXT   0 now playing, 1 the app, 2 the timeline
STATE  media position
TEXT   4 the display's refresh rates
STATE  the current refresh rate
TEXT   5 the laptop's macro names

then every change as it happens.
Media position is refreshed once a second while playing.

Versioning

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.

Only a new frame type forces a version, because an unknown type closes the connection. The refresh-rate dial and the macro buttons both arrived without a bump, which is the rule working rather than being broken. From 1.0 this number moves only in a major release, and any change to what a frame means bumps it on both sides in the same commit and adds a fixture line.

How it works

Actions and controls

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.
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.
22 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).

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. The first phone to complete the handshake has its Bluetooth address written to %APPDATA%\Edgepad\trusted-phone.txt, and every other paired phone is refused before HELLO_ACK. The tray menu's Forget trusted phone clears it.

The handshake also carries the protocol version. A mismatch is refused with the laptop's version in the reply, so the phone can say which side needs updating instead of misreading frames. A refusal before HELLO_ACK names both possible causes: a laptop app older than the phone's, or a laptop that trusts a different phone.

What the action table is, and is not

The line Windows draws

SendInput is silently refused when an elevated window, a UAC prompt or the secure desktop has focus. This is Windows protecting them and the app cannot cross it. The laptop counts those refusals and logs them once per session, so a gesture that appears to do nothing has a recorded reason.

Known limits

  • The trusted phone's Bluetooth address is stored in plain text under %APPDATA%\Edgepad. Anyone who can write that file can change which phone is trusted — and that person can already run programs as you.
  • Edgepad.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.
  • 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.
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.Pure
protocol/Frame and id coding.Pure
link/The RFCOMM socket, coalescing, the laptop-state model, round-trip stats.Pure
gamepad/Gamepad layout, control geometry, the layout store.Pure
screens/Every screen: guide, devices, settings, layout editors, keyboard, gamepad, macros.Needs a device

Inside the laptop app

Under windows/src/Edgepad.

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
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. All pure Kotlin, so it runs with no emulator.

Windows

The dispatcher's drop paths, input batching, trust on first use, the macro store, display modes, the level overlay, and the media-session title mapping.

Developing

Contributing

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.
  1. Branch from main.
  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. Open a pull request.

Style

  • No UI libraries on the phone. No third-party packages on the laptop beyond NAudio and System.Management. A new dependency needs a reason in the pull request.
  • Constants are named. A number that appears twice is a constant.
  • Doc comments say why, not what the next line already says.
  • Nothing runs on the input path that could block: no logging, no allocation while drawing, no I/O on the session thread beyond the socket.

Bug reports and feature requests use the issue templates in .github/ISSUE_TEMPLATE. Open an issue for anything bigger than a fix, so the design can be talked through before code exists. Report anything exploitable through a security advisory, not a public issue.

Developing

Releases

Releases are built by CI, not by hand. Pushing a version tag is the whole process.
Cut a release
# 1. Update the version table in docs/PROTOCOL.md if the protocol changed.
# 2. Move Unreleased in CHANGELOG.md under the new version.
git tag v2.0.0
git push origin v2.0.0

.github/workflows/release.yml runs both quality gates, builds a signed release APK and a compressed self-contained exe, and attaches them to a GitHub Release under the stable names Edgepad.apk and Edgepad.exe, so releases/latest/download/<file> always resolves to the newest build.

Versioned file names were rejected because they break that permanent link, and CI artifacts were rejected as a distribution channel because they expire and need a GitHub login. versionName comes from the tag; versionCode comes from the workflow run number, which only increases, so an update always installs over the previous one.

Reference

Design decisions

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. They are tuned by feel rather than measured, so expect to adjust the sensitivity settings to taste.

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.

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 typeface is JetBrains Mono, SIL Open Font License 1.1. Its licence ships inside the APK under assets/licenses.
  • Player logos belong to their owners and are drawn as supplied.
  • The laptop app depends on NAudio (for volume) and System.Management (for WMI brightness), and nothing else. The phone app depends on JUnit, and nothing else.