Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: build

on:
push:
branches: [main]
pull_request:

jobs:
build:
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
- uses: android-actions/setup-android@v3
with:
packages: platforms;android-36 build-tools;36.0.0
- name: Server
run: server/build.sh
- name: Mac tests
working-directory: mac
run: swift test
- name: Mac app
working-directory: mac
run: scripts/build-app.sh && ditto -c -k --keepParent dist/DroidBridge.app dist/DroidBridge.zip
- uses: actions/upload-artifact@v4
with:
name: DroidBridge
path: |
mac/dist/DroidBridge.zip
server/build/droidbridge-server.jar
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
build/
.build/
*.app
dist/
.DS_Store
.swiftpm/
7 changes: 7 additions & 0 deletions NOTICE
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
DroidBridge
Copyright 2026 Levi Nóbrega and contributors

This product includes software from scrcpy (https://github.com/Genymobile/scrcpy),
Copyright (C) 2018 Genymobile, Copyright (C) 2018-2026 Romain Vimont,
licensed under the Apache License, Version 2.0. The unmodified scrcpy 4.1 server
sources are in server/third_party/ (license: server/third_party/SCRCPY-LICENSE).
61 changes: 61 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<p align="center"><img src="assets/logo.png" alt="DroidBridge" width="480"></p>

# DroidBridge

Use your Mac's mouse and keyboard on an Android phone or tablet. Push the pointer past the edge
of your Mac's screen and it shows up on the Android device; push it back and you're on the Mac.
The text clipboard is shared both ways. An open-source take on DeskDock, for macOS.

- Real Android pointer and keyboard: the device sees a USB mouse and keyboard (UHID).
- Nothing to install on the device: the Mac starts a small server over adb.
- Text clipboard sync in both directions.
- Works with several displays: only edges that don't lead to another display count.
- English and Brazilian Portuguese.

Status: early (0.1). USB only for now; Wi-Fi and file drag-and-drop are planned.

## Requirements

- macOS 13 or later.
- Android 8.1 or later with **USB debugging** on (Settings > About phone > tap Build number 7 times,
then Settings > Developer options > USB debugging).
- `adb` (Android platform-tools): `brew install android-platform-tools`.

## Use

1. Connect the device by USB and accept the debugging prompt on it.
2. Open DroidBridge. It lives in the menu bar.
3. Allow it in System Settings > Privacy & Security > Accessibility.
4. In the menu, open **Arrange Screens…** and drag the Android device against the edge of a display.
The pointer passes only through the highlighted stretch; the size slider makes it longer or shorter.
5. Push the pointer through that stretch.

Back to the Mac: push the pointer against the device edge facing the Mac, or press **⌃⌥⌘B**.

By default Command acts as Ctrl on Android, so ⌘C / ⌘V / ⌘A work as expected (Control becomes Meta).
Turn it off in the menu to keep Command as Meta.

The Android keyboard layout follows the Mac's (for example US International - PC becomes
"English (US), International style"), so dead keys and accents type the same. This needs Android 15 or later.

## Build

```sh
# Android server (needs ANDROID_HOME with platforms/android-36 and build-tools/36.0.0, JDK 17+)
server/build.sh
# Mac app (Xcode 15+), tests, and the .app bundle in mac/dist/
cd mac && swift test && scripts/build-app.sh
```

Ad-hoc signed builds lose the Accessibility permission each time they are rebuilt.
`mac/scripts/create-signing-identity.sh` creates a local signing identity that keeps it.

`tools/devtest.py` drives the server on a connected device without the Mac app.

## How it works

See [docs/architecture.md](docs/architecture.md) and the [wire protocol](protocol/README.md).

## License

Apache License 2.0. Includes the scrcpy server sources (Apache 2.0), see [NOTICE](NOTICE).
Binary file added assets/logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
52 changes: 52 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# How DroidBridge works

## The device side

There is no app to install on the phone. The Mac pushes `droidbridge-server.jar` to
`/data/local/tmp` and runs it with `app_process` as the adb shell user, like scrcpy does.
The shell user may create input devices through `/dev/uhid` (Android 8.1 and later), so the
server registers a real USB-HID mouse and keyboard. Android then draws its own pointer,
accelerates it with its own settings, and treats keys exactly like a physical keyboard
(all characters, the IME, the hardware keyboard layout).

The shell user can also read and write the clipboard in the background, which apps can't do
since Android 10.

## Coming back to the Mac

The mouse is relative and Android accelerates it, so the Mac can't compute where the pointer
is. The shell user can't monitor input events, and the system's cursor-position API is internal
to `system_server`. What the shell user can do is ask the input service for a dump: it lists each
device's hovering pointer in display coordinates, e.g.
`DeviceId(15):[... hoveringPointers=[Pointer(id=0, MOUSE) at (0, 1835.57)]]`. A dump takes
about 20 ms on a recent phone, so the server only takes one (at most every 30 ms) while the pointer
moves toward the edge that leads back to the Mac. Once the pointer touches that edge and keeps
being pushed against it, the server tells the Mac, with the position along the edge.

Measured on a Galaxy Z Fold (Android 17), 2026-10-07:

- UHID relative mouse: real pointer, class `CURSOR`.
- UHID absolute (X/Y) mouse: Android ignores the device. Not usable.
- Injected events (`InputManager.injectInputEvent`, as scrcpy's "SDK" mouse): exact position but
no pointer is drawn.

## Entering the device

When the Mac cursor is pushed out of a free screen edge, the Mac freezes its cursor
(`CGAssociateMouseAndMouseCursorPosition(false)`), hides it and sends `ENTER` with the position
along the edge. The server pushes the pointer into the corner of the entry edge, then walks it along
the edge, measuring with the dump, until it is within about 12 px of the matching position
(0.3-0.5 s). From then on the Mac forwards raw mouse deltas (times the speed setting), buttons,
scrolling and keys, and swallows them locally through a `CGEventTap`.

## The Mac side

A menu-bar app (Swift, no Xcode project: Swift Package Manager plus `mac/scripts/build-app.sh`).

- `DroidBridgeCore`: wire protocol, screen-edge geometry (multiple displays: only edges that don't
lead to another display count), macOS key code to HID usage table. Unit-tested.
- `DroidBridge`: event tap, cursor hiding, adb link with reconnection, clipboard polling, menu.

Permissions: Accessibility (to swallow events). Hiding the cursor from a background app is not
possible on recent macOS, so the app comes to the front while the pointer is on the device and gives
the focus back to the previous app on return.
12 changes: 12 additions & 0 deletions mac/Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// swift-tools-version:5.9
import PackageDescription

let package = Package(
name: "DroidBridge",
platforms: [.macOS(.v13)],
targets: [
.target(name: "DroidBridgeCore"),
.executableTarget(name: "DroidBridge", dependencies: ["DroidBridgeCore"]),
.testTarget(name: "DroidBridgeCoreTests", dependencies: ["DroidBridgeCore"]),
]
)
Binary file added mac/Resources/AppIcon-1024.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added mac/Resources/MenuBarIcon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added mac/Resources/MenuBarIcon@2x.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
33 changes: 33 additions & 0 deletions mac/Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"status.noAdb" = "adb not found: install Android platform-tools";
"status.waiting" = "Waiting for an Android device (USB debugging)";
"status.connecting" = "Connecting…";
"status.connected" = "Connected: %@";
"status.onDevice" = "Controlling %@";
"status.failed" = "Could not connect: %@";
"menu.grantAccessibility" = "Allow Accessibility access…";
"menu.share" = "Share mouse and keyboard";
"menu.speed" = "Pointer speed on Android";
"speed.multiplier" = "%d×";
"menu.commandAsControl" = "Command acts as Ctrl";
"menu.invertScroll" = "Invert scrolling on Android";
"menu.hotkey" = "Back to the Mac: ⌃⌥⌘B";
"menu.quit" = "Quit DroidBridge";
"menu.arrange" = "Arrange Screens…";
"arrangement.title" = "Arrange Screens";
"arrangement.hint" = "Drag the Android device against one or two displays, as in System Settings > Displays. The pointer only passes through the highlighted stretches; along the rest of the edges it stops.";
"arrangement.passage" = "Passage";
"arrangement.size" = "Size of the Android device in the arrangement";
"arrangement.sizeHint" = "Larger means a longer passage.";
"arrangement.reset" = "Restore Default";
"arrangement.done" = "Done";
"arrangement.device" = "Android";
"edge.right" = "right";
"edge.left" = "left";
"edge.top" = "top";
"edge.bottom" = "bottom";
"axis.height" = "height";
"axis.width" = "width";
"passage.text" = "%1$@: %2$@ edge, from %3$d%% to %4$d%% of its %5$@.";
"passage.drop" = "Drop to attach to %1$@: %2$@ edge, from %3$d%% to %4$d%% of its %5$@.";
"passage.enters" = "The pointer enters the Android device through the side that touches the display, at the matching position.";
"passage.dropNone" = "Not touching any display: dropping it here puts it back where it was.";
33 changes: 33 additions & 0 deletions mac/Resources/pt-BR.lproj/Localizable.strings
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"status.noAdb" = "adb não encontrado: instale o Android platform-tools";
"status.waiting" = "Aguardando um Android (depuração USB)";
"status.connecting" = "Conectando…";
"status.connected" = "Conectado: %@";
"status.onDevice" = "Controlando %@";
"status.failed" = "Não foi possível conectar: %@";
"menu.grantAccessibility" = "Permitir acesso de Acessibilidade…";
"menu.share" = "Compartilhar mouse e teclado";
"menu.speed" = "Velocidade do ponteiro no Android";
"speed.multiplier" = "%d×";
"menu.commandAsControl" = "Command funciona como Ctrl";
"menu.invertScroll" = "Inverter rolagem no Android";
"menu.hotkey" = "Voltar ao Mac: ⌃⌥⌘B";
"menu.quit" = "Sair do DroidBridge";
"menu.arrange" = "Organizar telas…";
"arrangement.title" = "Organizar telas";
"arrangement.hint" = "Arraste o Android até encostar em um ou dois monitores, como em Ajustes do Sistema > Monitores. O ponteiro passa só pelos trechos destacados; no resto das bordas ele para.";
"arrangement.passage" = "Passagem";
"arrangement.size" = "Tamanho do Android na organização";
"arrangement.sizeHint" = "Maior = trecho de passagem mais longo.";
"arrangement.reset" = "Restaurar padrão";
"arrangement.done" = "Concluir";
"arrangement.device" = "Android";
"edge.right" = "direita";
"edge.left" = "esquerda";
"edge.top" = "de cima";
"edge.bottom" = "de baixo";
"axis.height" = "altura";
"axis.width" = "largura";
"passage.text" = "%1$@: borda %2$@, de %3$d%% a %4$d%% da %5$@.";
"passage.drop" = "Solte para encostar em %1$@: borda %2$@, de %3$d%% a %4$d%% da %5$@.";
"passage.enters" = "O ponteiro entra no Android pelo lado que encosta no monitor, na posição correspondente.";
"passage.dropNone" = "Longe dos monitores: soltando aqui, ele volta para onde estava.";
Loading
Loading