Compare commits

..

14 commits

Author SHA1 Message Date
2ac00c3b48 Updated gitignore
Some checks failed
Test / test (linux, []) (push) Has been cancelled
Test / test (windows, [null]) (push) Has been cancelled
Test / test (linux, [null portal uinput x11]) (push) Has been cancelled
Test / test (linux, [null]) (push) Has been cancelled
Test / test (linux, [portal]) (push) Has been cancelled
Test / test (linux, [uinput]) (push) Has been cancelled
Test / test (linux, [x11]) (push) Has been cancelled
Test / test (windows, []) (push) Has been cancelled
2026-08-14 22:01:02 +02:00
ed7e8bae54 Add systemd user service and CLI wrapper for remote-touchpad
Some checks are pending
Test / test (linux, []) (push) Waiting to run
Test / test (linux, [null portal uinput x11]) (push) Waiting to run
Test / test (linux, [null]) (push) Waiting to run
Test / test (linux, [portal]) (push) Waiting to run
Test / test (linux, [uinput]) (push) Waiting to run
Test / test (linux, [x11]) (push) Waiting to run
Test / test (windows, []) (push) Waiting to run
Test / test (windows, [null]) (push) Waiting to run
This commit introduces a complete systemd user service setup for
remote-touchpad, eliminating the need for users to manually manage
the binary process. Key additions:

- New rts CLI wrapper script providing a user-friendly interface
  for service management without requiring root access
- Systemd service file for proper background operation with
  restart policies and dependency management
- Service management commands: install, start, stop, status,
  restart, and uninstall
- URL extraction and QR code generation via the url command
- Service log viewing via the log command

The installation process now:
1. Verifies Go is installed
2. Builds the remote-touchpad binary with appropriate tags
3. Installs service files to ~/.config/systemd/user/
4. Installs the binary to ~/.local/bin/
5. Enables and starts the service automatically

The README has been updated with comprehensive installation
instructions for the new user service approach.
2026-08-14 21:59:58 +02:00
Unrud
a77f3aa8ec Bump version to 1.5.4 2026-08-03 00:34:58 +02:00
Unrud
9764d1975d Update dependencies 2026-08-03 00:34:58 +02:00
Unrud
5e68074781 X11: Reduce delay when mapping new keys
Reliability is improved by XSync
2026-08-03 00:32:34 +02:00
Unrud
f80b1d6ef7 X11: Minimize mod changes 2026-08-03 00:32:34 +02:00
Unrud
346d9cf375 X11: Cleanup code 2026-08-03 00:32:33 +02:00
Unrud
6ef2be8776 Format code 2026-08-02 22:12:27 +02:00
Unrud
85b6d6ea9d X11: Use XSync to improve reliability 2026-08-02 13:10:04 +02:00
Unrud
1aa8f92cc8 Use range over integers 2026-08-02 13:10:04 +02:00
Unrud
996edb0501 Snap: Specifiy supported platforms 2026-07-11 20:59:03 +02:00
Unrud
a30bed4624 Snap: Update to core24 2026-07-11 20:20:03 +02:00
Unrud
a2dfa7ac47 Bump version to 1.5.3 2025-12-24 22:59:32 +01:00
Unrud
eebde58826 Update dependencies 2025-12-24 22:57:01 +01:00
17 changed files with 397 additions and 138 deletions

11
.gitignore vendored Normal file
View file

@ -0,0 +1,11 @@
# Binaries
remote-touchpad
# Systemd service state
.remote-touchpad.service.state
# Logs and run files
.rts-log
# Environment files
.env

View file

@ -1,5 +1,14 @@
# Changelog # Changelog
## 1.5.4 (2026-08-02)
* Improve X11 keyboard input
* Update dependencies
## 1.5.3 (2025-12-24)
* Update dependencies
## 1.5.2 (2025-10-12) ## 1.5.2 (2025-10-12)
* Update dependencies * Update dependencies

View file

@ -23,6 +23,35 @@ Supports Flatpak's RemoteDesktop portal (for Wayland), Windows and X11.
go install github.com/unrud/remote-touchpad@latest go install github.com/unrud/remote-touchpad@latest
``` ```
## Linux User Service Installation
To install remote-touchpad as a user-specific systemd service (no root access required):
1. Clone the repository and navigate to the project directory:
```sh
git clone https://github.com/Unrud/remote-touchpad.git
cd remote-touchpad
```
2. Install the service using the provided wrapper script (no sudo required):
```sh
./rts install
```
3. Access the service:
* View service status: `./rts status`
* Get URL: `./rts url`
* View logs: `./rts log`
* Stop service: `./rts stop`
* Start service: `./rts start`
* Restart service: `./rts restart`
* Uninstall: `./rts uninstall`
The service will run in the background and maintain a persistent connection with URL and QR code accessible via the `url` command.
## Screenshots ## Screenshots
![screenshot 1](https://raw.githubusercontent.com/Unrud/remote-touchpad/master/screenshots/1.png) ![screenshot 1](https://raw.githubusercontent.com/Unrud/remote-touchpad/master/screenshots/1.png)

View file

@ -23,6 +23,21 @@
<name translate="no">Unrud</name> <name translate="no">Unrud</name>
</developer> </developer>
<releases> <releases>
<release version="1.5.4" date="2026-08-02">
<description>
<ul>
<li>Improve X11 keyboard input</li>
<li>Update dependencies</li>
</ul>
</description>
</release>
<release version="1.5.3" date="2025-12-24">
<description>
<ul>
<li>Update dependencies</li>
</ul>
</description>
</release>
<release version="1.5.2" date="2025-10-12"> <release version="1.5.2" date="2025-10-12">
<description> <description>
<ul> <ul>
@ -358,16 +373,16 @@
</releases> </releases>
<screenshots> <screenshots>
<screenshot type="default"> <screenshot type="default">
<image>https://raw.githubusercontent.com/Unrud/remote-touchpad/v1.5.2/screenshots/1.png</image> <image>https://raw.githubusercontent.com/Unrud/remote-touchpad/v1.5.4/screenshots/1.png</image>
</screenshot> </screenshot>
<screenshot> <screenshot>
<image>https://raw.githubusercontent.com/Unrud/remote-touchpad/v1.5.2/screenshots/2.png</image> <image>https://raw.githubusercontent.com/Unrud/remote-touchpad/v1.5.4/screenshots/2.png</image>
</screenshot> </screenshot>
<screenshot> <screenshot>
<image>https://raw.githubusercontent.com/Unrud/remote-touchpad/v1.5.2/screenshots/3.png</image> <image>https://raw.githubusercontent.com/Unrud/remote-touchpad/v1.5.4/screenshots/3.png</image>
</screenshot> </screenshot>
<screenshot> <screenshot>
<image>https://raw.githubusercontent.com/Unrud/remote-touchpad/v1.5.2/screenshots/4.png</image> <image>https://raw.githubusercontent.com/Unrud/remote-touchpad/v1.5.4/screenshots/4.png</image>
</screenshot> </screenshot>
</screenshots> </screenshots>
<content_rating type="oars-1.1"/> <content_rating type="oars-1.1"/>

View file

@ -17,8 +17,8 @@ modules:
path: .. path: ..
- type: git - type: git
url: https://github.com/godbus/dbus url: https://github.com/godbus/dbus
tag: v5.1.0 tag: v5.2.2
commit: e523abc905595cf17fb0001a7d77eaaddfaa216d commit: a8ac15ba63645f02ffd57f4b443203279ab40b30
dest: vendor/github.com/godbus/dbus/v5 dest: vendor/github.com/godbus/dbus/v5
- type: git - type: git
url: https://github.com/skip2/go-qrcode url: https://github.com/skip2/go-qrcode
@ -26,14 +26,14 @@ modules:
dest: vendor/github.com/skip2/go-qrcode dest: vendor/github.com/skip2/go-qrcode
- type: git - type: git
url: https://github.com/golang/net url: https://github.com/golang/net
tag: v0.46.0 tag: v0.57.0
commit: 2002a064a49010d8b881673c73f04e0b741e7c32 commit: b8f09f6f062ceb4531b7af4bd17a5c8fe9c4b2b5
dest: vendor/golang.org/x/net dest: vendor/golang.org/x/net
- type: git - type: git
url: https://github.com/golang/crypto url: https://github.com/golang/sys
tag: v0.43.0 tag: v0.47.0
commit: 627cb894b6b2021e34c4ad4af4c0a963127491e4 commit: 9e7e939dcafac07e8ab4cffa6e5fc74908413f00
dest: vendor/golang.org/x/crypto dest: vendor/golang.org/x/sys
- type: file - type: file
path: modules.txt path: modules.txt
dest: vendor dest: vendor

View file

@ -1,17 +1,17 @@
# github.com/bendahl/uinput v1.7.0 # github.com/bendahl/uinput v1.7.0
## explicit; go 1.13 ## explicit; go 1.13
github.com/bendahl/uinput github.com/bendahl/uinput
# github.com/godbus/dbus/v5 v5.1.0 # github.com/godbus/dbus/v5 v5.2.2
## explicit; go 1.12 ## explicit; go 1.20
github.com/godbus/dbus/v5 github.com/godbus/dbus/v5
# github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e # github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
## explicit; go 1.13 ## explicit; go 1.13
github.com/skip2/go-qrcode github.com/skip2/go-qrcode
github.com/skip2/go-qrcode/bitset github.com/skip2/go-qrcode/bitset
github.com/skip2/go-qrcode/reedsolomon github.com/skip2/go-qrcode/reedsolomon
# golang.org/x/crypto v0.43.0 # golang.org/x/net v0.57.0
## explicit; go 1.24.0 ## explicit; go 1.25.0
golang.org/x/crypto/hkdf
# golang.org/x/net v0.46.0
## explicit; go 1.24.0
golang.org/x/net/websocket golang.org/x/net/websocket
# golang.org/x/sys v0.47.0
## explicit; go 1.25.0
golang.org/x/sys/unix

11
go.mod
View file

@ -1,13 +1,12 @@
module github.com/unrud/remote-touchpad module github.com/unrud/remote-touchpad
go 1.24.0 go 1.25.5
toolchain go1.24.8
require ( require (
github.com/bendahl/uinput v1.7.0 github.com/bendahl/uinput v1.7.0
github.com/godbus/dbus/v5 v5.1.0 github.com/godbus/dbus/v5 v5.2.2
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
golang.org/x/crypto v0.43.0 golang.org/x/net v0.57.0
golang.org/x/net v0.46.0
) )
require golang.org/x/sys v0.47.0 // indirect

12
go.sum
View file

@ -1,10 +1,10 @@
github.com/bendahl/uinput v1.7.0 h1:nA4fm8Wu8UYNOPykIZm66nkWEyvxzfmJ8YC02PM40jg= github.com/bendahl/uinput v1.7.0 h1:nA4fm8Wu8UYNOPykIZm66nkWEyvxzfmJ8YC02PM40jg=
github.com/bendahl/uinput v1.7.0/go.mod h1:Np7w3DINc9wB83p12fTAM3DPPhFnAKP0WTXRqCQJ6Z8= github.com/bendahl/uinput v1.7.0/go.mod h1:Np7w3DINc9wB83p12fTAM3DPPhFnAKP0WTXRqCQJ6Z8=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=

View file

@ -74,7 +74,8 @@ func findDefaultHost() string {
continue continue
} }
for _, linkLocalPrefix := range []string{ for _, linkLocalPrefix := range []string{
"169.254.", "fe8", "fe9", "fea", "feb"} { "169.254.", "fe8", "fe9", "fea", "feb",
} {
if strings.HasPrefix(ip.String(), linkLocalPrefix) { if strings.HasPrefix(ip.String(), linkLocalPrefix) {
addIP(20, ip) addIP(20, ip)
continue addrs continue addrs

View file

@ -21,8 +21,10 @@ package inputcontrol
import "sort" import "sort"
type PointerButton int type (
type Key int PointerButton int
Key int
)
const ( const (
PointerButtonLeft PointerButton = iota PointerButtonLeft PointerButton = iota

View file

@ -24,6 +24,7 @@ package inputcontrol
import ( import (
"crypto/aes" "crypto/aes"
"crypto/cipher" "crypto/cipher"
"crypto/hkdf"
"crypto/rand" "crypto/rand"
"crypto/sha256" "crypto/sha256"
"errors" "errors"
@ -35,7 +36,6 @@ import (
"slices" "slices"
"github.com/godbus/dbus/v5" "github.com/godbus/dbus/v5"
"golang.org/x/crypto/hkdf"
) )
const ( const (
@ -88,12 +88,14 @@ func InitPortalController() (Controller, error) {
"org.freedesktop.portal.RemoteDesktop.version") "org.freedesktop.portal.RemoteDesktop.version")
if err != nil { if err != nil {
return nil, &UnsupportedPlatformError{ return nil, &UnsupportedPlatformError{
fmt.Errorf("getting 'version' failed: %w", err)} fmt.Errorf("getting 'version' failed: %w", err),
}
} }
remoteDesktopVersion, ok := remoteDesktopVersionV.Value().(uint32) remoteDesktopVersion, ok := remoteDesktopVersionV.Value().(uint32)
if !ok { if !ok {
return nil, &UnsupportedPlatformError{ return nil, &UnsupportedPlatformError{
errors.New("unexpected 'version' type")} errors.New("unexpected 'version' type"),
}
} }
restoreTokenStore, err := func() (*secretStore, error) { restoreTokenStore, err := func() (*secretStore, error) {
if remoteDesktopVersion < 2 { if remoteDesktopVersion < 2 {
@ -103,7 +105,7 @@ func InitPortalController() (Controller, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err := os.MkdirAll(cacheDirectory, 0700); err != nil { if err := os.MkdirAll(cacheDirectory, 0o700); err != nil {
return nil, err return nil, err
} }
secret, err := retrieveSecret(bus) secret, err := retrieveSecret(bus)
@ -120,17 +122,20 @@ func InitPortalController() (Controller, error) {
"org.freedesktop.portal.RemoteDesktop.AvailableDeviceTypes") "org.freedesktop.portal.RemoteDesktop.AvailableDeviceTypes")
if err != nil { if err != nil {
return nil, &UnsupportedPlatformError{ return nil, &UnsupportedPlatformError{
fmt.Errorf("getting 'AvailableDeviceTypes' failed: %w", err)} fmt.Errorf("getting 'AvailableDeviceTypes' failed: %w", err),
}
} }
availableDeviceTypes, ok := availableDeviceTypesV.Value().(uint32) availableDeviceTypes, ok := availableDeviceTypesV.Value().(uint32)
if !ok { if !ok {
return nil, &UnsupportedPlatformError{ return nil, &UnsupportedPlatformError{
errors.New("unexpected 'AvailableDeviceTypes' return type")} errors.New("unexpected 'AvailableDeviceTypes' return type"),
}
} }
if availableDeviceTypes&deviceKeyboard == 0 || if availableDeviceTypes&deviceKeyboard == 0 ||
availableDeviceTypes&devicePointer == 0 { availableDeviceTypes&devicePointer == 0 {
return nil, &UnsupportedPlatformError{ return nil, &UnsupportedPlatformError{
errors.New("keyboard or pointer source type not supported")} errors.New("keyboard or pointer source type not supported"),
}
} }
createSessionResults, err := checkResponse(getResponse(bus, portalDesktop, createSessionResults, err := checkResponse(getResponse(bus, portalDesktop,
"org.freedesktop.portal.RemoteDesktop.CreateSession", 0, "org.freedesktop.portal.RemoteDesktop.CreateSession", 0,
@ -138,12 +143,14 @@ func InitPortalController() (Controller, error) {
)) ))
if err != nil { if err != nil {
return nil, &UnsupportedPlatformError{ return nil, &UnsupportedPlatformError{
fmt.Errorf("calling 'CreateSession' failed: %w", err)} fmt.Errorf("calling 'CreateSession' failed: %w", err),
}
} }
sessionHandleString, ok := createSessionResults["session_handle"].Value().(string) sessionHandleString, ok := createSessionResults["session_handle"].Value().(string)
if !ok { if !ok {
return nil, &UnsupportedPlatformError{ return nil, &UnsupportedPlatformError{
errors.New("unexpected 'session_handle' type in 'CreateSession' return value")} errors.New("unexpected 'session_handle' type in 'CreateSession' return value"),
}
} }
sessionHandle := dbus.ObjectPath(sessionHandleString) sessionHandle := dbus.ObjectPath(sessionHandleString)
selectDevicesOptions := map[string]dbus.Variant{ selectDevicesOptions := map[string]dbus.Variant{
@ -165,7 +172,8 @@ func InitPortalController() (Controller, error) {
)) ))
if err != nil { if err != nil {
return nil, &UnsupportedPlatformError{ return nil, &UnsupportedPlatformError{
fmt.Errorf("calling 'SelectDevices' failed: %w", err)} fmt.Errorf("calling 'SelectDevices' failed: %w", err),
}
} }
startResponseStatus, startResults, err := getResponse(bus, portalDesktop, startResponseStatus, startResults, err := getResponse(bus, portalDesktop,
"org.freedesktop.portal.RemoteDesktop.Start", 0, "org.freedesktop.portal.RemoteDesktop.Start", 0,
@ -173,7 +181,8 @@ func InitPortalController() (Controller, error) {
) )
if err != nil { if err != nil {
return nil, &UnsupportedPlatformError{ return nil, &UnsupportedPlatformError{
fmt.Errorf("calling 'Start' failed: %w", err)} fmt.Errorf("calling 'Start' failed: %w", err),
}
} }
if startResponseStatus != 0 { if startResponseStatus != 0 {
return nil, errors.New("keyboard or pointer access denied") return nil, errors.New("keyboard or pointer access denied")
@ -186,14 +195,17 @@ func InitPortalController() (Controller, error) {
devices, ok := startResults["devices"].Value().(uint32) devices, ok := startResults["devices"].Value().(uint32)
if !ok { if !ok {
return nil, &UnsupportedPlatformError{ return nil, &UnsupportedPlatformError{
errors.New("unexpected 'devices' type in 'Start' return value")} errors.New("unexpected 'devices' type in 'Start' return value"),
}
} }
if devices&deviceKeyboard == 0 || devices&devicePointer == 0 { if devices&deviceKeyboard == 0 || devices&devicePointer == 0 {
return nil, errors.New("keyboard or pointer access denied") return nil, errors.New("keyboard or pointer access denied")
} }
cleanupBus = false cleanupBus = false
return &portalController{bus: bus, portalDesktop: portalDesktop, return &portalController{
sessionHandle: sessionHandle}, nil bus: bus, portalDesktop: portalDesktop,
sessionHandle: sessionHandle,
}, nil
} }
func retrieveSecret(bus *dbus.Conn) ([]byte, error) { func retrieveSecret(bus *dbus.Conn) ([]byte, error) {
@ -230,9 +242,8 @@ type secretStore struct {
} }
func newSecretStore(key []byte, filename string) (*secretStore, error) { func newSecretStore(key []byte, filename string) (*secretStore, error) {
hkdf := hkdf.New(sha256.New, key, nil, nil) derivedKey, err := hkdf.Key(sha256.New, key, nil, "", 32)
derivedKey := make([]byte, 32) if err != nil {
if _, err := io.ReadFull(hkdf, derivedKey); err != nil {
return nil, err return nil, err
} }
block, err := aes.NewCipher(derivedKey) block, err := aes.NewCipher(derivedKey)
@ -268,11 +279,12 @@ func (s *secretStore) Store(data []byte) error {
return err return err
} }
ciphertext := s.aesgcm.Seal(nil, nonce, data, nil) ciphertext := s.aesgcm.Seal(nil, nonce, data, nil)
return os.WriteFile(s.filename, slices.Concat(nonce, ciphertext), 0600) return os.WriteFile(s.filename, slices.Concat(nonce, ciphertext), 0o600)
} }
func getResponse(bus *dbus.Conn, object dbus.BusObject, method string, func getResponse(bus *dbus.Conn, object dbus.BusObject, method string,
flags dbus.Flags, args ...interface{}) (uint32, map[string]dbus.Variant, error) { flags dbus.Flags, args ...interface{},
) (uint32, map[string]dbus.Variant, error) {
ch := make(chan *dbus.Signal, 512) ch := make(chan *dbus.Signal, 512)
bus.Signal(ch) bus.Signal(ch)
defer bus.RemoveSignal(ch) defer bus.RemoveSignal(ch)

View file

@ -25,10 +25,11 @@ package inputcontrol
import ( import (
"errors" "errors"
"fmt" "fmt"
"github.com/bendahl/uinput"
"log" "log"
"os" "os"
"time" "time"
"github.com/bendahl/uinput"
) )
const shiftKeysDelay time.Duration = 50 * time.Millisecond const shiftKeysDelay time.Duration = 50 * time.Millisecond

View file

@ -32,9 +32,12 @@ package inputcontrol
// return DefaultRootWindow(dpy); // return DefaultRootWindow(dpy);
// } // }
import "C" import "C"
import ( import (
"errors" "errors"
"fmt" "fmt"
"maps"
"slices"
"strings" "strings"
"sync" "sync"
"time" "time"
@ -42,12 +45,18 @@ import (
) )
const ( const (
keyboardMappingDelay time.Duration = 500 * time.Millisecond keyboardMappingDelay time.Duration = 200 * time.Millisecond
scrollDiv int = 20 scrollDiv int = 20
) )
var modifierIndices [6]uint = [...]uint{C.ShiftMapIndex, C.Mod1MapIndex, var modifierIndexes = [...]int{
C.Mod2MapIndex, C.Mod3MapIndex, C.Mod4MapIndex, C.Mod5MapIndex} C.ShiftMapIndex,
C.Mod1MapIndex,
C.Mod2MapIndex,
C.Mod3MapIndex,
C.Mod4MapIndex,
C.Mod5MapIndex,
}
type x11Controller struct { type x11Controller struct {
display *C.Display display *C.Display
@ -63,13 +72,15 @@ func InitX11Controller() (Controller, error) {
display := C.XOpenDisplay(nil) display := C.XOpenDisplay(nil)
if display == nil { if display == nil {
return nil, &UnsupportedPlatformError{ return nil, &UnsupportedPlatformError{
errors.New("failed to connect to X server")} errors.New("failed to connect to X server"),
}
} }
p := &x11Controller{display: display} p := &x11Controller{display: display}
if p.xIsXwayland() { if p.xIsXwayland() {
p.Close() p.Close()
return nil, &UnsupportedPlatformError{ return nil, &UnsupportedPlatformError{
errors.New("X server is Xwayland")} errors.New("X server is Xwayland"),
}
} }
return p, nil return p, nil
} }
@ -113,53 +124,68 @@ func (p *x11Controller) Close() error {
return nil return nil
} }
func (p *x11Controller) findEmptyKeycodeLocked() (C.KeyCode, C.int, error) { func (p *x11Controller) findEmptyKeycodeLocked() (keycode C.KeyCode, keysymsPerKeycode int, err error) {
var minKeycodes, maxKeycodes C.int var minKeycodes, maxKeycodes C.int
C.XDisplayKeycodes(p.display, &minKeycodes, &maxKeycodes) C.XDisplayKeycodes(p.display, &minKeycodes, &maxKeycodes)
var keysymsPerKeycode C.int var keysymsPerKeycodeC C.int
keysyms := C.XGetKeyboardMapping(p.display, C.KeyCode(minKeycodes), keysyms := C.XGetKeyboardMapping(
maxKeycodes-minKeycodes+1, &keysymsPerKeycode) p.display,
C.KeyCode(minKeycodes),
maxKeycodes-minKeycodes+1,
&keysymsPerKeycodeC,
)
if keysyms == nil { if keysyms == nil {
return 0, 0, errors.New("failed to get keyboard mapping") return 0, 0, errors.New("failed to get keyboard mapping")
} }
defer C.XFree(unsafe.Pointer(keysyms)) defer C.XFree(unsafe.Pointer(keysyms))
keysymsPerKeycode = int(keysymsPerKeycodeC)
keycodes: keycodes:
for keycode := C.KeyCode(minKeycodes); keycode <= C.KeyCode(maxKeycodes); keycode++ { for keycode := int(minKeycodes); keycode <= int(maxKeycodes); keycode++ {
for i := 0; i < int(keysymsPerKeycode); i++ { for i := range keysymsPerKeycode {
keysymsIndex := int(keycode- keysymsIndex := (keycode-int(minKeycodes))*keysymsPerKeycode + i
C.KeyCode(minKeycodes))*int(keysymsPerKeycode) + i keysym := *(*C.KeySym)(unsafe.Pointer(
keysym := *(*C.KeySym)(unsafe.Pointer(uintptr(unsafe.Pointer(keysyms)) + uintptr(unsafe.Pointer(keysyms)) +
uintptr(keysymsIndex)*unsafe.Sizeof(*keysyms))) uintptr(keysymsIndex)*unsafe.Sizeof(*keysyms),
))
if keysym != 0 { if keysym != 0 {
continue keycodes continue keycodes
} }
} }
return keycode, keysymsPerKeycode, nil return C.KeyCode(keycode), keysymsPerKeycode, nil
} }
return 0, 0, errors.New("no empty keycode found") return 0, 0, errors.New("no empty keycode found")
} }
func (p *x11Controller) changeKeyMappingLocked(keysymsPerKeycode C.int, func (p *x11Controller) changeKeyMappingLocked(
keycode C.KeyCode, keysym Keysym) { keycode C.KeyCode, keysymsPerKeycode int, keysym Keysym,
) {
keycodeMapping := make([]C.KeySym, keysymsPerKeycode) keycodeMapping := make([]C.KeySym, keysymsPerKeycode)
for i := range keycodeMapping { for i := range keycodeMapping {
keycodeMapping[i] = C.KeySym(keysym) keycodeMapping[i] = C.KeySym(keysym)
} }
C.XChangeKeyboardMapping(p.display, C.int(keycode), keysymsPerKeycode, C.XChangeKeyboardMapping(
(*C.KeySym)(unsafe.Pointer(&keycodeMapping[0])), 1) p.display,
C.XFlush(p.display) C.int(keycode),
C.int(keysymsPerKeycode),
(*C.KeySym)(unsafe.Pointer(&keycodeMapping[0])),
1,
)
C.XSync(p.display, C.False)
} }
func (p *x11Controller) getModKeycodesLocked() map[uint]C.KeyCode { func (p *x11Controller) getModKeycodesLocked() map[uint]C.KeyCode {
modKeymap := C.XGetModifierMapping(p.display) modKeymap := C.XGetModifierMapping(p.display)
defer C.XFreeModifiermap(modKeymap) defer C.XFreeModifiermap(modKeymap)
modKeycodes := make(map[uint]C.KeyCode) modKeycodes := make(map[uint]C.KeyCode)
for _, modIndex := range modifierIndices { for _, modIndex := range modifierIndexes {
for i := 0; i < int(modKeymap.max_keypermod); i++ { for i := range int(modKeymap.max_keypermod) {
keycode := *(*C.KeyCode)(unsafe.Pointer(uintptr(unsafe.Pointer(modKeymap.modifiermap)) + keycodeIndex := modIndex*int(modKeymap.max_keypermod) + i
uintptr(uint(modIndex)*uint(modKeymap.max_keypermod)+uint(i)))) keycode := *(*C.KeyCode)(unsafe.Pointer(
uintptr(unsafe.Pointer(modKeymap.modifiermap)) +
uintptr(keycodeIndex)*unsafe.Sizeof(*modKeymap.modifiermap),
))
if keycode != 0 { if keycode != 0 {
modKeycodes[1<<uint(modIndex)] = keycode modKeycodes[1<<modIndex] = keycode
break break
} }
} }
@ -167,50 +193,39 @@ func (p *x11Controller) getModKeycodesLocked() map[uint]C.KeyCode {
return modKeycodes return modKeycodes
} }
func (p *x11Controller) findKeycodeLocked(keyboard C.XkbDescPtr, func (p *x11Controller) findKeycodeLocked(
modKeycodes map[uint]C.KeyCode, activeMods C.uint, keyboard C.XkbDescPtr, modKeycodes map[uint]C.KeyCode, activeMods uint, keysym Keysym,
keysym Keysym) (C.KeyCode, C.uint) { ) (keycode C.KeyCode, mods uint) {
keycode := C.XKeysymToKeycode(p.display, C.KeySym(keysym)) keycode = C.XKeysymToKeycode(p.display, C.KeySym(keysym))
if keycode == 0 { if keycode == 0 {
return 0, 0 return 0, 0
} }
var alwaysActiveMods C.uint for activeModBitmap := range uint(1) << len(modKeycodes) {
for modIndex := uint(0); modIndex < 8; modIndex++ { mods := activeMods
mod := uint(1) << modIndex for i, mod := range slices.Sorted(maps.Keys(modKeycodes)) {
if _, modAvailable := modKeycodes[mod]; !modAvailable { if activeModBitmap&(1<<i) != 0 {
alwaysActiveMods |= activeMods & C.uint(mod) mods ^= mod
}
}
_, shiftModAvailable := modKeycodes[C.ShiftMask]
for _, modIndex := range modifierIndices {
var mod C.uint
if modIndex != C.ShiftMapIndex {
mod = 1 << modIndex
}
for _, shiftMod := range [...]C.uint{0, C.ShiftMask} {
if shiftMod != 0 && !shiftModAvailable {
continue
}
mods := alwaysActiveMods | shiftMod | mod
var retMods C.uint
var retKeysym C.KeySym
C.XkbTranslateKeyCode(keyboard, keycode, mods, &retMods, &retKeysym)
if retKeysym == C.KeySym(keysym) {
return keycode, mods
} }
} }
var retMods C.uint
var retKeysym C.KeySym
C.XkbTranslateKeyCode(keyboard, keycode, C.uint(mods), &retMods, &retKeysym)
if retKeysym == C.KeySym(keysym) {
return keycode, mods
}
} }
return 0, 0 return 0, 0
} }
func (p *x11Controller) sendModsLocked(modKeycodes map[uint]C.KeyCode, mods C.uint, func (p *x11Controller) sendModsLocked(
press bool) { modKeycodes map[uint]C.KeyCode, mods uint, press bool,
) {
var pressC C.int = C.False var pressC C.int = C.False
if press { if press {
pressC = C.True pressC = C.True
} }
for mod, keycode := range modKeycodes { for mod, keycode := range modKeycodes {
if mods&C.uint(mod) != 0 { if mods&mod != 0 {
C.XTestFakeKeyEvent(p.display, C.uint(keycode), pressC, 0) C.XTestFakeKeyEvent(p.display, C.uint(keycode), pressC, 0)
} }
} }
@ -231,17 +246,22 @@ func (p *x11Controller) keyboardKeys(keys []Keysym) error {
C.XkbCompatMapMask|C.XkbGeometryMask, C.XkbUseCoreKbd) C.XkbCompatMapMask|C.XkbGeometryMask, C.XkbUseCoreKbd)
defer C.XkbFreeKeyboard(keyboard, C.XkbAllComponentsMask, C.True) defer C.XkbFreeKeyboard(keyboard, C.XkbAllComponentsMask, C.True)
var emptyKeycode C.KeyCode var emptyKeycode C.KeyCode
var keysymsPerKeycode C.int var keysymsPerKeycode int
for _, keysym := range keys { for _, keysym := range keys {
var root, child C.Window var root, child C.Window
var rootX, rootY, x, y C.int var rootX, rootY, x, y C.int
var activeMods C.uint var activeModsC C.uint
C.XSync(p.display, C.False) C.XQueryPointer(
C.XQueryPointer(p.display, rootWindow, &root, &child, &rootX, &rootY, p.display,
&x, &y, &activeMods) rootWindow,
keycode, mods := p.findKeycodeLocked(keyboard, modKeycodes, activeMods, &root, &child,
keysym) &rootX, &rootY,
var pressMods, releaseMods C.uint &x, &y,
&activeModsC,
)
activeMods := uint(activeModsC)
keycode, mods := p.findKeycodeLocked(keyboard, modKeycodes, activeMods, keysym)
var pressMods, releaseMods uint
if keycode == 0 { if keycode == 0 {
if emptyKeycode == 0 { if emptyKeycode == 0 {
var err error var err error
@ -249,12 +269,10 @@ func (p *x11Controller) keyboardKeys(keys []Keysym) error {
if err != nil { if err != nil {
return err return err
} }
defer p.changeKeyMappingLocked(keysymsPerKeycode, emptyKeycode, 0) defer p.changeKeyMappingLocked(emptyKeycode, keysymsPerKeycode, 0)
} }
keycode = emptyKeycode keycode = emptyKeycode
p.changeKeyMappingLocked(keysymsPerKeycode, keycode, keysym) p.changeKeyMappingLocked(keycode, keysymsPerKeycode, keysym)
// race condition!
time.Sleep(keyboardMappingDelay)
} else { } else {
pressMods = mods & ^activeMods pressMods = mods & ^activeMods
releaseMods = activeMods & ^mods releaseMods = activeMods & ^mods
@ -265,7 +283,7 @@ func (p *x11Controller) keyboardKeys(keys []Keysym) error {
C.XTestFakeKeyEvent(p.display, C.uint(keycode), C.False, 0) C.XTestFakeKeyEvent(p.display, C.uint(keycode), C.False, 0)
p.sendModsLocked(modKeycodes, pressMods, false) p.sendModsLocked(modKeycodes, pressMods, false)
p.sendModsLocked(modKeycodes, releaseMods, true) p.sendModsLocked(modKeycodes, releaseMods, true)
C.XFlush(p.display) C.XSync(p.display, C.False)
if keycode == emptyKeycode { if keycode == emptyKeycode {
// race condition! // race condition!
time.Sleep(keyboardMappingDelay) time.Sleep(keyboardMappingDelay)
@ -309,7 +327,7 @@ func (p *x11Controller) sendButton(button uint, press bool) error {
pressC = C.True pressC = C.True
} }
C.XTestFakeButtonEvent(p.display, C.uint(button), pressC, 0) C.XTestFakeButtonEvent(p.display, C.uint(button), pressC, 0)
C.XFlush(p.display) C.XSync(p.display, C.False)
return nil return nil
} }
@ -333,7 +351,7 @@ func (p *x11Controller) PointerMove(deltaX, deltaY int) error {
return errors.New("X server connection closed") return errors.New("X server connection closed")
} }
C.XTestFakeRelativeMotionEvent(p.display, C.int(deltaX), C.int(deltaY), 0) C.XTestFakeRelativeMotionEvent(p.display, C.int(deltaX), C.int(deltaY), 0)
C.XFlush(p.display) C.XSync(p.display, C.False)
return nil return nil
} }
@ -354,7 +372,7 @@ func (p *x11Controller) PointerScroll(deltaHorizontal, deltaVertical int, finish
buttonHorizontal = 6 buttonHorizontal = 6
stepsHorizontal = -stepsHorizontal stepsHorizontal = -stepsHorizontal
} }
for i := 0; i < stepsHorizontal; i++ { for range stepsHorizontal {
if err := p.sendButton(buttonHorizontal, true); err != nil { if err := p.sendButton(buttonHorizontal, true); err != nil {
return err return err
} }
@ -367,7 +385,7 @@ func (p *x11Controller) PointerScroll(deltaHorizontal, deltaVertical int, finish
buttonVertical = 4 buttonVertical = 4
stepsVertical = -stepsVertical stepsVertical = -stepsVertical
} }
for i := 0; i < stepsVertical; i++ { for range stepsVertical {
if err := p.sendButton(buttonVertical, true); err != nil { if err := p.sendButton(buttonVertical, true); err != nil {
return err return err
} }

View file

@ -27,9 +27,6 @@ import (
"errors" "errors"
"flag" "flag"
"fmt" "fmt"
"github.com/unrud/remote-touchpad/inputcontrol"
"github.com/unrud/remote-touchpad/terminal"
"golang.org/x/net/websocket"
"log" "log"
mathrand "math/rand" mathrand "math/rand"
"net" "net"
@ -39,6 +36,10 @@ import (
"strings" "strings"
"time" "time"
"unicode/utf8" "unicode/utf8"
"github.com/unrud/remote-touchpad/inputcontrol"
"github.com/unrud/remote-touchpad/terminal"
"golang.org/x/net/websocket"
) )
const ( const (
@ -47,7 +48,7 @@ const (
authenticationRateBurst int = 10 authenticationRateBurst int = 10
challengeLength int = 8 challengeLength int = 8
defaultBind string = ":0" defaultBind string = ":0"
version string = "1.5.2" version string = "1.5.4"
prettyAppName string = "Remote Touchpad" prettyAppName string = "Remote Touchpad"
) )

12
remote-touchpad.service Normal file
View file

@ -0,0 +1,12 @@
[Unit]
Description=Remote Touchpad Service
After=network.target
[Service]
Type=simple
ExecStart=%h/.local/bin/remote-touchpad
Restart=always
RestartSec=5s
[Install]
WantedBy=default.target

149
rts Executable file
View file

@ -0,0 +1,149 @@
#!/bin/bash
# Remote Touchpad CLI Wrapper
# Provides easy access to remote-touchpad service
SERVICE_NAME="remote-touchpad"
SERVICE_USER="$USER"
install() {
echo "Installing remote-touchpad as a user systemd service..."
# Check if Go is installed
if ! command -v go &> /dev/null; then
echo "Error: Go is not installed. Please install Go first."
return 1
fi
echo "Building remote-touchpad binary..."
go build -tags portal,uinput,x11 -o remote-touchpad
if [ ! -f "remote-touchpad" ]; then
echo "Error: Failed to build remote-touchpad binary"
return 1
fi
# Setup directories
mkdir -p ~/.local/bin ~/.config/systemd/user
echo "Installing service files..."
cp remote-touchpad ~/.local/bin/ || echo "Error: cp to ~/.local/bin failed"
cp remote-touchpad.service ~/.config/systemd/user/ || echo "Error: cp to ~/.config/systemd/user failed"
echo "Enabling and starting service..."
systemctl --user daemon-reload
systemctl --user enable --now ${SERVICE_NAME}.service
echo "✓ remote-touchpad user service installed and started"
echo ""
echo "Use './rts status' to check the service"
echo "Use './rts url' to get the connection URL"
}
start() {
systemctl --user start ${SERVICE_NAME}.service
echo "Started remote-touchpad service"
}
stop() {
systemctl --user stop ${SERVICE_NAME}.service
echo "Stopped remote-touchpad service"
}
status() {
systemctl --user status ${SERVICE_NAME}.service --no-pager
}
restart() {
systemctl --user restart ${SERVICE_NAME}.service
echo "Restarted remote-touchpad service"
}
url() {
# Extract URL from service logs
echo "Fetching remote-touchpad URL..."
local url=$(journalctl --user -u ${SERVICE_NAME}.service -n 100 --no-pager 2>/dev/null | grep -oE "http://[^ ]+|https://[^ ]+" | tail -1)
if [ -n "$url" ]; then
echo "Remote Touchpad URL: $url"
# Generate QR code if qrencode is available
if command -v qrencode &> /dev/null; then
echo "Generating QR code..."
echo "$url" | qrencode -t ansiutf8
else
echo "QR code generation requires qrencode. Install it with:"
echo " sudo apt-get install qrencode # Debian/Ubuntu"
echo " sudo dnf install qrencode # Fedora"
echo " brew install qrencode # macOS"
fi
else
echo "Could not find URL in service logs. Is the service running?"
fi
}
log() {
echo "Viewing remote-touchpad service logs..."
journalctl --user -u ${SERVICE_NAME}.service --no-pager
}
uninstall() {
echo "Uninstalling remote-touchpad..."
systemctl --user stop ${SERVICE_NAME}.service
systemctl --user disable ${SERVICE_NAME}.service
rm -f ~/.config/systemd/user/${SERVICE_NAME}.service
rm -f ~/.local/bin/remote-touchpad
systemctl --user daemon-reload
echo "✓ remote-touchpad uninstalled"
}
help() {
echo "Remote Touchpad CLI Wrapper"
echo ""
echo "Usage: rts [command]"
echo ""
echo "Commands:"
echo " install - Install remote-touchpad as a systemd user service"
echo " start - Start the remote-touchpad service"
echo " stop - Stop the remote-touchpad service"
echo " status - Check service status"
echo " restart - Restart the service"
echo " url - Show the remote-touchpad URL"
echo " log - View service logs"
echo " uninstall - Uninstall the service"
echo " help - Show this help message"
}
# Main command handler
case "$1" in
install)
install
;;
start)
start
;;
stop)
stop
;;
status)
status
;;
restart)
restart
;;
url)
url
;;
log)
log
;;
uninstall)
uninstall
;;
help|--help|-h|"")
help
;;
*)
echo "Unknown command: $1"
help
exit 1
;;
esac

View file

@ -1,17 +1,21 @@
# Build by running "snapcraft". # Build by running `snapcraft`.
# WARNING: # WARNING:
# Snapcraft uses caching for already build steps but it's buggy and can cause strange problems. # Snapcraft uses caching for already build steps but it's buggy and can cause strange problems.
# Clean the cache by running "snapcraft clean". # Clean the cache by running `snapcraft clean`.
name: remote-touchpad name: remote-touchpad
license: GPL-3.0+ license: GPL-3.0+
grade: stable grade: stable
adopt-info: remote-touchpad adopt-info: remote-touchpad
base: core22 base: core24
confinement: strict confinement: strict
platforms:
amd64:
arm64:
apps: apps:
remote-touchpad: remote-touchpad:
command: bin/remote-touchpad command: bin/remote-touchpad
@ -48,10 +52,6 @@ parts:
- libxt6 - libxt6
- libxrandr2 - libxrandr2
- libxtst6 - libxtst6
override-pull: |
craftctl default
# WORKAROUND: Point icon directly to SVG otherwise snapcraft can't find it
sed -e 's|Icon=com.github.unrud.RemoteTouchpad|Icon=/usr/share/icons/hicolor/scalable/apps/com.github.unrud.RemoteTouchpad.svg|' -i desktop/com.github.unrud.RemoteTouchpad.desktop
override-build: | override-build: |
craftctl default craftctl default
install -Dm0755 -t "${CRAFT_PART_INSTALL}/bin" desktop/remote-touchpad-wait-on-error install -Dm0755 -t "${CRAFT_PART_INSTALL}/bin" desktop/remote-touchpad-wait-on-error