diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..9f3768a --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,43 @@ +name: Test +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + os: [linux] + tags: [[], ['null'], [portal], [uinput], [x11], + ['null', portal, uinput, x11]] + include: + - os: windows + tags: [] + - os: windows + tags: ['null'] + env: + GOOS: ${{ matrix.os }} + GOFLAGS: -tags=${{ join(matrix.tags, ',') }} + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-go@v4 + with: + go-version: stable + check-latest: true + - run: sudo apt install libxrandr-dev libxtst-dev libxt-dev + if: ${{ contains(matrix.tags, 'x11') }} + + - name: Go Format + run: gofmt -s -w . && git diff --exit-code + + - name: Go Mod Tidy + run: go mod tidy && git diff --exit-code + + - name: Go Vet + run: go vet ./... + + - name: Go Build + run: go build ./... + + - name: Go Test (with data race detection) + run: go test -race ./... + if: ${{ matrix.os == 'linux' }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..07c477a --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# Binaries +remote-touchpad + +# Systemd service state +.remote-touchpad.service.state + +# Logs and run files +.rts-log + +# Environment files +.env \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index d8545b9..e59454c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,61 @@ # 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) + +* Update dependencies + +## 1.5.0 (2025-03-18) + +* Add restore token support for the remote desktop portal +* Update dependencies + +## 1.4.8 (2024-09-01) + +* Update dependencies + +## 1.4.7 (2024-08-30) + +* Update dependencies + +## 1.4.6 (2024-02-06) + +* Prevent incorrect MIME types + +## 1.4.5 (2024-01-31) + +* Keep text input open after sending +* Workaround for virtual keyboard covering buttons (Chromium & iOS) +* Enable hardware keyboard input in mouse mode + +## 1.4.4 (2024-01-23) + +* Fix Xwayland detection on newer servers +* Fix support for the KDE remote desktop portal +* Set correct minimum version for Go +* Update dependencies + +## 1.4.3 (2023-10-03) + +* Update dependencies + +## 1.4.2 (2023-05-20) + +* Fix Windows support + +## 1.4.1 (2023-05-11) + +* uinput: Fix vertical scroll direction +* Update dependencies + ## 1.4.0 (2023-04-25) * Add feedback to buttons diff --git a/README.md b/README.md index 2740e73..e039950 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,45 @@ Supports Flatpak's RemoteDesktop portal (for Wayland), Windows and X11. * [Snap](https://snapcraft.io/remote-touchpad) * [Windows](https://github.com/Unrud/remote-touchpad/releases/latest) * Golang: - * Portal & uinput & X11: `go install -tags portal,uinput,x11 github.com/unrud/remote-touchpad@latest` - * Windows: `go install github.com/unrud/remote-touchpad@latest` + * Portal & uinput & X11: + + ```sh + go install -tags portal,uinput,x11 github.com/unrud/remote-touchpad@latest + ``` + * Windows: + + ```sh + 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 diff --git a/desktop/com.github.unrud.RemoteTouchpad.metainfo.xml b/desktop/com.github.unrud.RemoteTouchpad.metainfo.xml index f2ecdfa..113ef4d 100644 --- a/desktop/com.github.unrud.RemoteTouchpad.metainfo.xml +++ b/desktop/com.github.unrud.RemoteTouchpad.metainfo.xml @@ -17,8 +17,104 @@ com.github.unrud.RemoteTouchpad.desktop https://github.com/Unrud/remote-touchpad/issues https://github.com/Unrud/remote-touchpad - Unrud + https://github.com/Unrud/remote-touchpad + Unrud + + Unrud + + + +
    +
  • Improve X11 keyboard input
  • +
  • Update dependencies
  • +
+
+
+ + +
    +
  • Update dependencies
  • +
+
+
+ + +
    +
  • Update dependencies
  • +
+
+
+ + +
    +
  • Add restore token support for the remote desktop portal
  • +
  • Update dependencies
  • +
+
+
+ + +
    +
  • Update dependencies
  • +
+
+
+ + +
    +
  • Update dependencies
  • +
+
+
+ + +
    +
  • Prevent incorrect MIME types
  • +
+
+
+ + +
    +
  • Keep text input open after sending
  • +
  • Workaround for virtual keyboard covering buttons (Chromium & iOS)
  • +
  • Enable hardware keyboard input in mouse mode
  • +
+
+
+ + +
    +
  • Fix Xwayland detection on newer servers
  • +
  • Fix support for the KDE remote desktop portal
  • +
  • Set correct minimum version for Go
  • +
  • Update dependencies
  • +
+
+
+ + +
    +
  • Update dependencies
  • +
+
+
+ + +
    +
  • Fix Windows support
  • +
+
+
+ + +
    +
  • uinput: Fix vertical scroll direction
  • +
  • Update dependencies
  • +
+
+
    @@ -277,16 +373,16 @@ - https://raw.githubusercontent.com/Unrud/remote-touchpad/v1.4.0/screenshots/1.png + https://raw.githubusercontent.com/Unrud/remote-touchpad/v1.5.4/screenshots/1.png - https://raw.githubusercontent.com/Unrud/remote-touchpad/v1.4.0/screenshots/2.png + https://raw.githubusercontent.com/Unrud/remote-touchpad/v1.5.4/screenshots/2.png - https://raw.githubusercontent.com/Unrud/remote-touchpad/v1.4.0/screenshots/3.png + https://raw.githubusercontent.com/Unrud/remote-touchpad/v1.5.4/screenshots/3.png - https://raw.githubusercontent.com/Unrud/remote-touchpad/v1.4.0/screenshots/4.png + https://raw.githubusercontent.com/Unrud/remote-touchpad/v1.5.4/screenshots/4.png diff --git a/flatpak/com.github.unrud.RemoteTouchpad.yaml b/flatpak/com.github.unrud.RemoteTouchpad.yaml index de5d5be..239160e 100644 --- a/flatpak/com.github.unrud.RemoteTouchpad.yaml +++ b/flatpak/com.github.unrud.RemoteTouchpad.yaml @@ -1,6 +1,6 @@ app-id: com.github.unrud.RemoteTouchpad runtime: org.freedesktop.Platform -runtime-version: '22.08' +runtime-version: '25.08' sdk: org.freedesktop.Sdk command: remote-touchpad sdk-extensions: @@ -8,19 +8,17 @@ sdk-extensions: finish-args: - --socket=fallback-x11 - --socket=wayland + - --share=ipc # Required by Flathub linter - --share=network modules: - name: remote-touchpad sources: - type: git path: .. - - type: shell - commands: - - . /usr/lib/sdk/golang/enable.sh; go mod vendor -e - type: git url: https://github.com/godbus/dbus - tag: v5.1.0 - commit: e523abc905595cf17fb0001a7d77eaaddfaa216d + tag: v5.2.2 + commit: a8ac15ba63645f02ffd57f4b443203279ab40b30 dest: vendor/github.com/godbus/dbus/v5 - type: git url: https://github.com/skip2/go-qrcode @@ -28,9 +26,17 @@ modules: dest: vendor/github.com/skip2/go-qrcode - type: git url: https://github.com/golang/net - tag: v0.9.0 - commit: 694cff8668bac64e0864b552bffc280cd27f21b1 + tag: v0.57.0 + commit: b8f09f6f062ceb4531b7af4bd17a5c8fe9c4b2b5 dest: vendor/golang.org/x/net + - type: git + url: https://github.com/golang/sys + tag: v0.47.0 + commit: 9e7e939dcafac07e8ab4cffa6e5fc74908413f00 + dest: vendor/golang.org/x/sys + - type: file + path: modules.txt + dest: vendor buildsystem: simple build-commands: - . /usr/lib/sdk/golang/enable.sh; go build -tags portal,x11 diff --git a/flatpak/modules.txt b/flatpak/modules.txt new file mode 100644 index 0000000..6092904 --- /dev/null +++ b/flatpak/modules.txt @@ -0,0 +1,17 @@ +# github.com/bendahl/uinput v1.7.0 +## explicit; go 1.13 +github.com/bendahl/uinput +# github.com/godbus/dbus/v5 v5.2.2 +## explicit; go 1.20 +github.com/godbus/dbus/v5 +# github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e +## explicit; go 1.13 +github.com/skip2/go-qrcode +github.com/skip2/go-qrcode/bitset +github.com/skip2/go-qrcode/reedsolomon +# golang.org/x/net v0.57.0 +## explicit; go 1.25.0 +golang.org/x/net/websocket +# golang.org/x/sys v0.47.0 +## explicit; go 1.25.0 +golang.org/x/sys/unix diff --git a/go.mod b/go.mod index fc4d234..5afd19d 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,12 @@ module github.com/unrud/remote-touchpad -go 1.18 +go 1.25.5 require ( - github.com/bendahl/uinput v1.6.0 - github.com/godbus/dbus/v5 v5.1.0 + github.com/bendahl/uinput v1.7.0 + github.com/godbus/dbus/v5 v5.2.2 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e - golang.org/x/net v0.9.0 + golang.org/x/net v0.57.0 ) + +require golang.org/x/sys v0.47.0 // indirect diff --git a/go.sum b/go.sum index ef28f60..783b4df 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,10 @@ -github.com/bendahl/uinput v1.6.0 h1:fM6r3OSC17rHh758mizKjSBuqi+XinhiGd4N3pWvZiI= -github.com/bendahl/uinput v1.6.0/go.mod h1:Np7w3DINc9wB83p12fTAM3DPPhFnAKP0WTXRqCQJ6Z8= -github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= -github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/bendahl/uinput v1.7.0 h1:nA4fm8Wu8UYNOPykIZm66nkWEyvxzfmJ8YC02PM40jg= +github.com/bendahl/uinput v1.7.0/go.mod h1:Np7w3DINc9wB83p12fTAM3DPPhFnAKP0WTXRqCQJ6Z8= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +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/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= -golang.org/x/net v0.9.0 h1:aWJ/m6xSmxWBx+V0XRHTlrYrPG56jKsLdTFmsSsCzOM= -golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/host.go b/host.go index 54874e5..622b3bf 100644 --- a/host.go +++ b/host.go @@ -74,7 +74,8 @@ func findDefaultHost() string { continue } for _, linkLocalPrefix := range []string{ - "169.254.", "fe8", "fe9", "fea", "feb"} { + "169.254.", "fe8", "fe9", "fea", "feb", + } { if strings.HasPrefix(ip.String(), linkLocalPrefix) { addIP(20, ip) continue addrs diff --git a/inputcontrol/controller.go b/inputcontrol/controller.go index 06ed8e4..111e937 100644 --- a/inputcontrol/controller.go +++ b/inputcontrol/controller.go @@ -21,8 +21,10 @@ package inputcontrol import "sort" -type PointerButton int -type Key int +type ( + PointerButton int + Key int +) const ( PointerButtonLeft PointerButton = iota diff --git a/inputcontrol/controller_portal.go b/inputcontrol/controller_portal.go index d8e2700..99ecf8b 100644 --- a/inputcontrol/controller_portal.go +++ b/inputcontrol/controller_portal.go @@ -22,10 +22,20 @@ package inputcontrol import ( + "crypto/aes" + "crypto/cipher" + "crypto/hkdf" + "crypto/rand" + "crypto/sha256" "errors" "fmt" + "io" + "log" + "os" + "path/filepath" + "slices" + "github.com/godbus/dbus/v5" - "sync" ) const ( @@ -35,6 +45,8 @@ const ( btnReleased uint32 = 0 btnPressed uint32 = 1 + untilRevoked uint32 = 2 + // linux/input-event-codes.h btnLeft int32 = 0x110 btnRight int32 = 0x111 @@ -43,9 +55,8 @@ const ( type portalController struct { bus *dbus.Conn - remoteDesktop dbus.BusObject + portalDesktop dbus.BusObject sessionHandle dbus.ObjectPath - lock sync.RWMutex } func init() { @@ -71,85 +82,209 @@ func InitPortalController() (Controller, error) { if err != nil { return nil, &UnsupportedPlatformError{err} } - remoteDesktop := bus.Object("org.freedesktop.portal.Desktop", + portalDesktop := bus.Object("org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop") - availableDeviceTypesV, err := remoteDesktop.GetProperty( + remoteDesktopVersionV, err := portalDesktop.GetProperty( + "org.freedesktop.portal.RemoteDesktop.version") + if err != nil { + return nil, &UnsupportedPlatformError{ + fmt.Errorf("getting 'version' failed: %w", err), + } + } + remoteDesktopVersion, ok := remoteDesktopVersionV.Value().(uint32) + if !ok { + return nil, &UnsupportedPlatformError{ + errors.New("unexpected 'version' type"), + } + } + restoreTokenStore, err := func() (*secretStore, error) { + if remoteDesktopVersion < 2 { + return nil, nil + } + cacheDirectory, err := os.UserCacheDir() + if err != nil { + return nil, err + } + if err := os.MkdirAll(cacheDirectory, 0o700); err != nil { + return nil, err + } + secret, err := retrieveSecret(bus) + if err != nil { + return nil, err + } + return newSecretStore(secret, + filepath.Join(cacheDirectory, "remote-touchpad.portal-restore-token.bin")) + }() + if err != nil { + log.Printf("Skipping restore token: %v", err) + } + availableDeviceTypesV, err := portalDesktop.GetProperty( "org.freedesktop.portal.RemoteDesktop.AvailableDeviceTypes") if err != nil { - return nil, &UnsupportedPlatformError{err} + return nil, &UnsupportedPlatformError{ + fmt.Errorf("getting 'AvailableDeviceTypes' failed: %w", err), + } } availableDeviceTypes, ok := availableDeviceTypesV.Value().(uint32) if !ok { return nil, &UnsupportedPlatformError{ - errors.New("unexpected 'AvailableDeviceTypes' return type")} + errors.New("unexpected 'AvailableDeviceTypes' return type"), + } } if availableDeviceTypes&deviceKeyboard == 0 || availableDeviceTypes&devicePointer == 0 { return nil, &UnsupportedPlatformError{ - errors.New("keyboard or pointer source type not supported")} + errors.New("keyboard or pointer source type not supported"), + } } - inVardict := make(map[string]dbus.Variant) - inVardict["session_handle_token"] = dbus.MakeVariant("t") - result, outVardict, err := getResponse(bus, remoteDesktop, - "org.freedesktop.portal.RemoteDesktop.CreateSession", 0, inVardict) + createSessionResults, err := checkResponse(getResponse(bus, portalDesktop, + "org.freedesktop.portal.RemoteDesktop.CreateSession", 0, + map[string]dbus.Variant{"session_handle_token": dbus.MakeVariant("t")}, + )) if err != nil { - return nil, &UnsupportedPlatformError{err} - } - if result != 0 { return nil, &UnsupportedPlatformError{ - fmt.Errorf("Calling 'CreateSession' failed (%v)", result)} + fmt.Errorf("calling 'CreateSession' failed: %w", err), + } } - sessionHandleV, ok := outVardict["session_handle"] + sessionHandleString, ok := createSessionResults["session_handle"].Value().(string) if !ok { return nil, &UnsupportedPlatformError{ - errors.New("'session_handle' missing from 'CreateSession' return value")} + errors.New("unexpected 'session_handle' type in 'CreateSession' return value"), + } } - sessionHandleS, ok := sessionHandleV.Value().(string) - if !ok { - return nil, &UnsupportedPlatformError{ - errors.New("unexpected 'session_handle' type in 'CreateSession' return value")} + sessionHandle := dbus.ObjectPath(sessionHandleString) + selectDevicesOptions := map[string]dbus.Variant{ + "types": dbus.MakeVariant(deviceKeyboard | devicePointer), } - sessionHandle := dbus.ObjectPath(sessionHandleS) - inVardict = make(map[string]dbus.Variant) - inVardict["type"] = dbus.MakeVariant(deviceKeyboard | devicePointer) - result, outVardict, err = getResponse(bus, remoteDesktop, - "org.freedesktop.portal.RemoteDesktop.SelectDevices", 0, sessionHandle, inVardict) + if restoreTokenStore != nil { + if restoreToken, err := restoreTokenStore.Load(); err != nil { + if !errors.Is(err, os.ErrNotExist) { + log.Printf("Failed to load restore token: %v", err) + } + } else if len(restoreToken) > 0 { + selectDevicesOptions["restore_token"] = dbus.MakeVariant(string(restoreToken)) + } + selectDevicesOptions["persist_mode"] = dbus.MakeVariant(untilRevoked) + } + _, err = checkResponse(getResponse(bus, portalDesktop, + "org.freedesktop.portal.RemoteDesktop.SelectDevices", 0, + sessionHandle, selectDevicesOptions, + )) if err != nil { - return nil, &UnsupportedPlatformError{err} - } - if result != 0 { return nil, &UnsupportedPlatformError{ - fmt.Errorf("Calling 'SelectDevices' failed (%v)", result)} + fmt.Errorf("calling 'SelectDevices' failed: %w", err), + } } - inVardict = make(map[string]dbus.Variant) - result, outVardict, err = getResponse(bus, remoteDesktop, - "org.freedesktop.portal.RemoteDesktop.Start", 0, sessionHandle, "", inVardict) + startResponseStatus, startResults, err := getResponse(bus, portalDesktop, + "org.freedesktop.portal.RemoteDesktop.Start", 0, + sessionHandle, "", map[string]dbus.Variant{}, + ) if err != nil { - return nil, &UnsupportedPlatformError{err} + return nil, &UnsupportedPlatformError{ + fmt.Errorf("calling 'Start' failed: %w", err), + } } - if result != 0 { + if startResponseStatus != 0 { return nil, errors.New("keyboard or pointer access denied") } - devicesV, ok := outVardict["devices"] - if !ok { - return nil, &UnsupportedPlatformError{ - errors.New("'devices' missing from 'Start' return value")} + if restoreToken, _ := startResults["restore_token"].Value().(string); restoreTokenStore != nil { + if err := restoreTokenStore.Store([]byte(restoreToken)); err != nil { + log.Printf("Failed to store restore token: %v", err) + } } - devices, ok := devicesV.Value().(uint32) + devices, ok := startResults["devices"].Value().(uint32) if !ok { 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 { return nil, errors.New("keyboard or pointer access denied") } cleanupBus = false - return &portalController{bus: bus, remoteDesktop: remoteDesktop, - sessionHandle: sessionHandle}, nil + return &portalController{ + bus: bus, portalDesktop: portalDesktop, + sessionHandle: sessionHandle, + }, nil +} + +func retrieveSecret(bus *dbus.Conn) ([]byte, error) { + portalDesktop := bus.Object("org.freedesktop.portal.Desktop", + "/org/freedesktop/portal/desktop") + secretReader, secretWriter, err := os.Pipe() + if err != nil { + return nil, err + } + defer secretReader.Close() + defer secretWriter.Close() + if _, err := checkResponse(getResponse(bus, portalDesktop, + "org.freedesktop.portal.Secret.RetrieveSecret", 0, + dbus.UnixFD(secretWriter.Fd()), map[string]dbus.Variant{}, + )); err != nil { + return nil, fmt.Errorf("calling 'RetrieveSecret' failed: %w", err) + } + if err := secretWriter.Close(); err != nil { + return nil, err + } + secret, err := io.ReadAll(secretReader) + if err != nil { + return nil, err + } + if len(secret) < 16 { + return nil, fmt.Errorf("'RetrieveSecret' returned too few bytes (%d)", len(secret)) + } + return secret, err +} + +type secretStore struct { + aesgcm cipher.AEAD + filename string +} + +func newSecretStore(key []byte, filename string) (*secretStore, error) { + derivedKey, err := hkdf.Key(sha256.New, key, nil, "", 32) + if err != nil { + return nil, err + } + block, err := aes.NewCipher(derivedKey) + if err != nil { + return nil, err + } + aesgcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + return &secretStore{ + aesgcm: aesgcm, + filename: filename, + }, nil +} + +func (s *secretStore) Load() ([]byte, error) { + data, err := os.ReadFile(s.filename) + if err != nil { + return nil, err + } + if len(data) < s.aesgcm.NonceSize() { + return nil, errors.New("invalid ciphertext") + } + nonce := data[:s.aesgcm.NonceSize()] + ciphertext := data[len(nonce):] + return s.aesgcm.Open(nil, nonce, ciphertext, nil) +} + +func (s *secretStore) Store(data []byte) error { + nonce := make([]byte, s.aesgcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return err + } + ciphertext := s.aesgcm.Seal(nil, nonce, data, nil) + return os.WriteFile(s.filename, slices.Concat(nonce, ciphertext), 0o600) } 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) bus.Signal(ch) defer bus.RemoveSignal(ch) @@ -161,47 +296,39 @@ func getResponse(bus *dbus.Conn, object dbus.BusObject, method string, s := <-ch if s.Path == requestPath && s.Name == "org.freedesktop.portal.Request.Response" { if len(s.Body) != 2 { - return 0, nil, errors.New("unexpected 'Response' return length") + return 0, nil, fmt.Errorf("unexpected 'Response' return length (%d)", len(s.Body)) } - result, ok := s.Body[0].(uint32) + responseStatus, ok := s.Body[0].(uint32) if !ok { return 0, nil, errors.New("unexpected 'Response' return type") } - outVardict, ok := s.Body[1].(map[string]dbus.Variant) + results, ok := s.Body[1].(map[string]dbus.Variant) if !ok { return 0, nil, errors.New("unexpected 'Response' return type") } - return result, outVardict, nil + return responseStatus, results, nil } } } +func checkResponse(responseStatus uint32, results map[string]dbus.Variant, err error) (map[string]dbus.Variant, error) { + if err == nil && responseStatus != 0 { + err = fmt.Errorf("unexpected 'Response' status (%d)", responseStatus) + } + return results, err +} + func (p *portalController) Close() error { - p.lock.Lock() - defer p.lock.Unlock() - if p.bus == nil { - return errors.New("dbus connection closed") - } - if err := p.bus.Close(); err != nil { - return err - } - p.bus = nil - p.remoteDesktop = nil - return nil + return p.bus.Close() } func (p *portalController) keyboardKeys(keys []Keysym) error { - p.lock.RLock() - defer p.lock.RUnlock() - if p.bus == nil { - return errors.New("dbus connection closed") - } - inVardict := make(map[string]dbus.Variant) for _, keysym := range keys { for _, state := range [...]uint32{btnPressed, btnReleased} { - if err := p.remoteDesktop.Call( - "org.freedesktop.portal.RemoteDesktop.NotifyKeyboardKeysym", - 0, p.sessionHandle, inVardict, keysym, state).Store(); err != nil { + if err := p.portalDesktop.Call( + "org.freedesktop.portal.RemoteDesktop.NotifyKeyboardKeysym", 0, + p.sessionHandle, map[string]dbus.Variant{}, keysym, state, + ).Store(); err != nil { return err } } @@ -231,11 +358,6 @@ func (p *portalController) KeyboardKey(key Key) error { } func (p *portalController) PointerButton(button PointerButton, press bool) error { - p.lock.RLock() - defer p.lock.RUnlock() - if p.bus == nil { - return errors.New("dbus connection closed") - } var btn int32 switch button { case PointerButtonLeft: @@ -251,38 +373,27 @@ func (p *portalController) PointerButton(button PointerButton, press bool) error if press { state = btnPressed } - inVardict := make(map[string]dbus.Variant) - if err := p.remoteDesktop.Call("org.freedesktop.portal.RemoteDesktop.NotifyPointerButton", - 0, p.sessionHandle, inVardict, btn, state).Store(); err != nil { + if err := p.portalDesktop.Call("org.freedesktop.portal.RemoteDesktop.NotifyPointerButton", 0, + p.sessionHandle, map[string]dbus.Variant{}, btn, state, + ).Store(); err != nil { return err } return nil } func (p *portalController) PointerMove(deltaX, deltaY int) error { - p.lock.RLock() - defer p.lock.RUnlock() - if p.bus == nil { - return errors.New("dbus connection closed") - } - inVardict := make(map[string]dbus.Variant) - if err := p.remoteDesktop.Call("org.freedesktop.portal.RemoteDesktop.NotifyPointerMotion", - 0, p.sessionHandle, inVardict, float64(deltaX), float64(deltaY)).Store(); err != nil { + if err := p.portalDesktop.Call("org.freedesktop.portal.RemoteDesktop.NotifyPointerMotion", 0, + p.sessionHandle, map[string]dbus.Variant{}, float64(deltaX), float64(deltaY), + ).Store(); err != nil { return err } return nil } func (p *portalController) PointerScroll(deltaHorizontal, deltaVertical int, finish bool) error { - p.lock.RLock() - defer p.lock.RUnlock() - if p.bus == nil { - return errors.New("dbus connection closed") - } - inVardict := make(map[string]dbus.Variant) - inVardict["finish"] = dbus.MakeVariant(finish) - if err := p.remoteDesktop.Call("org.freedesktop.portal.RemoteDesktop.NotifyPointerAxis", - 0, p.sessionHandle, inVardict, float64(deltaHorizontal), float64(deltaVertical)).Store(); err != nil { + if err := p.portalDesktop.Call("org.freedesktop.portal.RemoteDesktop.NotifyPointerAxis", 0, + p.sessionHandle, map[string]dbus.Variant{"finish": dbus.MakeVariant(finish)}, float64(deltaHorizontal), float64(deltaVertical), + ).Store(); err != nil { return err } return nil diff --git a/inputcontrol/controller_uinput.go b/inputcontrol/controller_uinput.go index 8e6023b..2dd9bab 100644 --- a/inputcontrol/controller_uinput.go +++ b/inputcontrol/controller_uinput.go @@ -25,10 +25,11 @@ package inputcontrol import ( "errors" "fmt" - "github.com/bendahl/uinput" "log" "os" "time" + + "github.com/bendahl/uinput" ) const shiftKeysDelay time.Duration = 50 * time.Millisecond @@ -74,11 +75,7 @@ func InitUinputController() (Controller, error) { } func (p *uinputController) Close() error { - if err := p.keyboard.Close(); err != nil { - p.mouse.Close() - return err - } - return p.mouse.Close() + return errors.Join(p.keyboard.Close(), p.mouse.Close()) } func (p *uinputController) KeyboardText(text string) error { @@ -199,8 +196,5 @@ func (p *uinputController) PointerMove(deltaX, deltaY int) error { } func (p *uinputController) PointerScroll(deltaHorizontal, deltaVertical int, finish bool) error { - if err := p.mouse.Wheel(false, int32(deltaVertical)); err != nil { - return err - } - return p.mouse.Wheel(true, int32(deltaHorizontal)) + return errors.Join(p.mouse.Wheel(false, int32(-deltaVertical)), p.mouse.Wheel(true, int32(deltaHorizontal))) } diff --git a/inputcontrol/controller_windows.go b/inputcontrol/controller_windows.go index 78c36a8..58b5e9a 100644 --- a/inputcontrol/controller_windows.go +++ b/inputcontrol/controller_windows.go @@ -111,9 +111,8 @@ func (p *windowsController) sendInput(inputs []keybdInput) error { if len(inputs) == 0 { return nil } - if r, _, err := sendInputProc.Call(uintptr(len(inputs)), - uintptr(unsafe.Pointer(&inputs[0])), - unsafe.Sizeof(inputs[0])); int(r) != len(inputs) { + if sent, _, err := sendInputProc.Call(uintptr(len(inputs)), + uintptr(unsafe.Pointer(&inputs[0])), unsafe.Sizeof(inputs[0])); int(sent) != len(inputs) { return err } return nil @@ -197,8 +196,8 @@ func (p *windowsController) PointerButton(button PointerButton, press bool) erro } else { return fmt.Errorf("unsupported pointer button: %#v", button) } - if r, _, err := sendInputProc.Call(1, uintptr(unsafe.Pointer(&input)), - unsafe.Sizeof(input)); int(r) != 1 { + if sent, _, err := sendInputProc.Call(1, uintptr(unsafe.Pointer(&input)), + unsafe.Sizeof(input)); int(sent) != 1 { return err } return nil @@ -211,8 +210,8 @@ func (p *windowsController) PointerMove(deltaX, deltaY int) error { dy: int32(deltaY), dwFlags: mouseeventfMove, } - if r, _, err := sendInputProc.Call(1, uintptr(unsafe.Pointer(&input)), - unsafe.Sizeof(input)); int(r) != 1 { + if sent, _, err := sendInputProc.Call(1, uintptr(unsafe.Pointer(&input)), + unsafe.Sizeof(input)); int(sent) != 1 { return err } return nil @@ -237,9 +236,8 @@ func (p *windowsController) PointerScroll(deltaHorizontal, deltaVertical int, fi if len(inputs) == 0 { return nil } - if r, _, err := sendInputProc.Call(uintptr(len(inputs)), - uintptr(unsafe.Pointer(&inputs[0])), - unsafe.Sizeof(inputs[0])); int(r) != len(inputs) { + if sent, _, err := sendInputProc.Call(uintptr(len(inputs)), + uintptr(unsafe.Pointer(&inputs[0])), unsafe.Sizeof(inputs[0])); int(sent) != len(inputs) { return err } return nil diff --git a/inputcontrol/controller_x11.go b/inputcontrol/controller_x11.go index c5d19d9..f45b75b 100644 --- a/inputcontrol/controller_x11.go +++ b/inputcontrol/controller_x11.go @@ -22,6 +22,7 @@ package inputcontrol // #cgo LDFLAGS: -lX11 -lXrandr -lXtst +// #include // #include // #include // #include @@ -31,9 +32,12 @@ package inputcontrol // return DefaultRootWindow(dpy); // } import "C" + import ( "errors" "fmt" + "maps" + "slices" "strings" "sync" "time" @@ -41,12 +45,18 @@ import ( ) const ( - keyboardMappingDelay time.Duration = 500 * time.Millisecond + keyboardMappingDelay time.Duration = 200 * time.Millisecond scrollDiv int = 20 ) -var modifierIndices [6]uint = [...]uint{C.ShiftMapIndex, C.Mod1MapIndex, - C.Mod2MapIndex, C.Mod3MapIndex, C.Mod4MapIndex, C.Mod5MapIndex} +var modifierIndexes = [...]int{ + C.ShiftMapIndex, + C.Mod1MapIndex, + C.Mod2MapIndex, + C.Mod3MapIndex, + C.Mod4MapIndex, + C.Mod5MapIndex, +} type x11Controller struct { display *C.Display @@ -62,18 +72,31 @@ func InitX11Controller() (Controller, error) { display := C.XOpenDisplay(nil) if display == nil { return nil, &UnsupportedPlatformError{ - errors.New("failed to connect to X server")} + errors.New("failed to connect to X server"), + } } p := &x11Controller{display: display} if p.xIsXwayland() { p.Close() return nil, &UnsupportedPlatformError{ - errors.New("X server is Xwayland")} + errors.New("X server is Xwayland"), + } } return p, nil } func (p *x11Controller) xIsXwayland() bool { + // Detection method from https://gitlab.freedesktop.org/xorg/app/xisxwayland/-/blob/xisxwayland-2/xisxwayland.c + var opcode, event, error, major, minor C.int + xwaylandExtensionName := C.CString("XWAYLAND") + defer C.free(unsafe.Pointer(xwaylandExtensionName)) + if C.XQueryExtension(p.display, xwaylandExtensionName, &opcode, &event, &error) != 0 { + return true + } + if C.XRRQueryExtension(p.display, &event, &error) == 0 || + C.XRRQueryVersion(p.display, &major, &minor) == 0 { + return false + } resources := C.XRRGetScreenResourcesCurrent(p.display, C.MacroDefaultRootWindow(p.display)) if resources == nil { return false @@ -101,53 +124,68 @@ func (p *x11Controller) Close() error { 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 C.XDisplayKeycodes(p.display, &minKeycodes, &maxKeycodes) - var keysymsPerKeycode C.int - keysyms := C.XGetKeyboardMapping(p.display, C.KeyCode(minKeycodes), - maxKeycodes-minKeycodes+1, &keysymsPerKeycode) + var keysymsPerKeycodeC C.int + keysyms := C.XGetKeyboardMapping( + p.display, + C.KeyCode(minKeycodes), + maxKeycodes-minKeycodes+1, + &keysymsPerKeycodeC, + ) if keysyms == nil { return 0, 0, errors.New("failed to get keyboard mapping") } defer C.XFree(unsafe.Pointer(keysyms)) + keysymsPerKeycode = int(keysymsPerKeycodeC) keycodes: - for keycode := C.KeyCode(minKeycodes); keycode <= C.KeyCode(maxKeycodes); keycode++ { - for i := 0; i < int(keysymsPerKeycode); i++ { - keysymsIndex := int(keycode- - C.KeyCode(minKeycodes))*int(keysymsPerKeycode) + i - keysym := *(*C.KeySym)(unsafe.Pointer(uintptr(unsafe.Pointer(keysyms)) + - uintptr(keysymsIndex)*unsafe.Sizeof(*keysyms))) + for keycode := int(minKeycodes); keycode <= int(maxKeycodes); keycode++ { + for i := range keysymsPerKeycode { + keysymsIndex := (keycode-int(minKeycodes))*keysymsPerKeycode + i + keysym := *(*C.KeySym)(unsafe.Pointer( + uintptr(unsafe.Pointer(keysyms)) + + uintptr(keysymsIndex)*unsafe.Sizeof(*keysyms), + )) if keysym != 0 { continue keycodes } } - return keycode, keysymsPerKeycode, nil + return C.KeyCode(keycode), keysymsPerKeycode, nil } return 0, 0, errors.New("no empty keycode found") } -func (p *x11Controller) changeKeyMappingLocked(keysymsPerKeycode C.int, - keycode C.KeyCode, keysym Keysym) { +func (p *x11Controller) changeKeyMappingLocked( + keycode C.KeyCode, keysymsPerKeycode int, keysym Keysym, +) { keycodeMapping := make([]C.KeySym, keysymsPerKeycode) for i := range keycodeMapping { keycodeMapping[i] = C.KeySym(keysym) } - C.XChangeKeyboardMapping(p.display, C.int(keycode), keysymsPerKeycode, - (*C.KeySym)(unsafe.Pointer(&keycodeMapping[0])), 1) - C.XFlush(p.display) + C.XChangeKeyboardMapping( + 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 { modKeymap := C.XGetModifierMapping(p.display) defer C.XFreeModifiermap(modKeymap) modKeycodes := make(map[uint]C.KeyCode) - for _, modIndex := range modifierIndices { - for i := 0; i < int(modKeymap.max_keypermod); i++ { - keycode := *(*C.KeyCode)(unsafe.Pointer(uintptr(unsafe.Pointer(modKeymap.modifiermap)) + - uintptr(uint(modIndex)*uint(modKeymap.max_keypermod)+uint(i)))) + for _, modIndex := range modifierIndexes { + for i := range int(modKeymap.max_keypermod) { + keycodeIndex := modIndex*int(modKeymap.max_keypermod) + i + keycode := *(*C.KeyCode)(unsafe.Pointer( + uintptr(unsafe.Pointer(modKeymap.modifiermap)) + + uintptr(keycodeIndex)*unsafe.Sizeof(*modKeymap.modifiermap), + )) if keycode != 0 { - modKeycodes[1< /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 diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 26fa53a..6d23326 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -1,17 +1,21 @@ -# Build by running "snapcraft". +# Build by running `snapcraft`. # WARNING: # 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 license: GPL-3.0+ grade: stable adopt-info: remote-touchpad -base: core20 +base: core24 confinement: strict +platforms: + amd64: + arm64: + apps: remote-touchpad: command: bin/remote-touchpad @@ -26,7 +30,7 @@ apps: - desktop - x11 common-id: com.github.unrud.RemoteTouchpad - desktop: share/applications/com.github.unrud.RemoteTouchpad.desktop + desktop: usr/share/applications/com.github.unrud.RemoteTouchpad.desktop parts: remote-touchpad: @@ -36,6 +40,8 @@ parts: - x11 source: . source-type: git + build-snaps: + - go build-packages: - gcc - libc6-dev @@ -46,14 +52,10 @@ parts: - libxt6 - libxrandr2 - libxtst6 - override-pull: | - snapcraftctl pull - # 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: | - snapcraftctl build - install -Dm0755 -t "${SNAPCRAFT_PART_INSTALL}/bin" desktop/remote-touchpad-wait-on-error - install -Dm0644 -t "${SNAPCRAFT_PART_INSTALL}/usr/share/metainfo" desktop/com.github.unrud.RemoteTouchpad.metainfo.xml - install -Dm0644 -t "${SNAPCRAFT_PART_INSTALL}/usr/share/applications" desktop/com.github.unrud.RemoteTouchpad.desktop - install -Dm0644 -t "${SNAPCRAFT_PART_INSTALL}/usr/share/icons/hicolor/scalable/apps" desktop/com.github.unrud.RemoteTouchpad.svg + craftctl default + install -Dm0755 -t "${CRAFT_PART_INSTALL}/bin" desktop/remote-touchpad-wait-on-error + install -Dm0644 -t "${CRAFT_PART_INSTALL}/usr/share/metainfo" desktop/com.github.unrud.RemoteTouchpad.metainfo.xml + install -Dm0644 -t "${CRAFT_PART_INSTALL}/usr/share/applications" desktop/com.github.unrud.RemoteTouchpad.desktop + install -Dm0644 -t "${CRAFT_PART_INSTALL}/usr/share/icons/hicolor/scalable/apps" desktop/com.github.unrud.RemoteTouchpad.svg parse-info: [usr/share/metainfo/com.github.unrud.RemoteTouchpad.metainfo.xml] diff --git a/webdata.go b/webdata.go index 4d07131..1b816b6 100644 --- a/webdata.go +++ b/webdata.go @@ -1,19 +1,52 @@ +/* + * Copyright (c) 2024 Unrud + * + * This file is part of Remote-Touchpad. + * + * Remote-Touchpad is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Remote-Touchpad is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Remote-Touchpad. If not, see . + */ + package main import ( "embed" "io/fs" "log" + "mime" ) //go:embed webdata/* var webdataFSWithPrefix embed.FS var webdataFS fs.FS +var webdataTypes = map[string]string{ + ".css": "text/css; charset=utf-8", + ".html": "text/html; charset=utf-8", + ".mjs": "text/javascript; charset=utf-8", + ".png": "image/png", + ".woff": "font/woff", +} + func init() { var err error webdataFS, err = fs.Sub(webdataFSWithPrefix, "webdata") if err != nil { log.Fatal(err) } + for ext, typ := range webdataTypes { + if err := mime.AddExtensionType(ext, typ); err != nil { + log.Fatal(err) + } + } } diff --git a/webdata/app/ui.mjs b/webdata/app/ui.mjs index 5441823..a21ddf1 100644 --- a/webdata/app/ui.mjs +++ b/webdata/app/ui.mjs @@ -57,12 +57,13 @@ export default class UI { (target) => target.classList.contains("touch-input")); document.addEventListener("mousedown", this.#handleMousedown.bind(this)); document.addEventListener("touchend", this.#handleTouchend.bind(this)); - textInput.addEventListener("input", this.#handleTextInput.bind(this)); + textInput.addEventListener("input", () => { this.#updateTextInput(); }); sendText.addEventListener("click", this.#handleSendText.bind(this)); window.addEventListener("popstate", () => { this.#update(); }); compat.addFullscreenchangeEventListener(() => { this.#update(); }); compat.addPointerlockchangeEventListener(() => { this.#update(); }); for (const button of buttons) { + button.setAttribute("tabindex", "-1"); button.addEventListener("click", this.#handleButtonClick.bind(this)); } this.#update(); @@ -97,8 +98,13 @@ export default class UI { } } - #handleTextInput() { + #updateTextInput(newValue = null) { + if (newValue != null) { + textInput.value = newValue; + } sessionStorage.setItem("text-input", textInput.value); + sendText.textContent = sendText.getAttribute(`data-${textInput.value ? "send" : "back"}-content`); + textInput.focus(); } #handleButtonClick(event) { @@ -112,10 +118,10 @@ export default class UI { // normalize line endings const text = textInput.value.replace(/\r\n?/g, "\n"); this.#inputController.keyboardText(text); - textInput.value = ""; - this.#handleTextInput(); + this.#updateTextInput(""); + } else { + history.back(); } - history.back(); } #showScene(scene) { @@ -159,8 +165,7 @@ export default class UI { showTextInput() { this.#showScene(textInputScene); - textInput.value = sessionStorage.getItem("text-input") || ""; - textInput.focus(); + this.#updateTextInput(sessionStorage.getItem("text-input") || ""); if (history.state != "text-input") { history.pushState("text-input", ""); } diff --git a/webdata/index.html b/webdata/index.html index a011d0b..52fb46f 100644 --- a/webdata/index.html +++ b/webdata/index.html @@ -1,7 +1,7 @@ - + Remote Touchpad @@ -28,7 +28,7 @@
    - +
    @@ -95,6 +95,6 @@ -
    +

    Mouse

    diff --git a/webdata/main.css b/webdata/main.css index bf66d6a..6b9907f 100644 --- a/webdata/main.css +++ b/webdata/main.css @@ -153,6 +153,10 @@ textarea { padding: 0 0.5rem; } +textarea:focus { + outline: none; +} + #keys { padding: 0; justify-content: end; @@ -177,33 +181,48 @@ textarea { align-items: stretch; padding: 0; gap: 0; + flex-direction: row; +} + +#text-input .buttons { + flex-direction: column; } #text-input .buttons > button { - border-bottom: none; + border-top: none; border-right: none; } -#text-input .buttons > button:first-child { - border-left: none; +#text-input .buttons > button:last-child { + border-bottom: none; } -@media (min-aspect-ratio: 4/3) { - #text-input { - flex-direction: row; +/* Only Safari for iOS */ +@supports (-webkit-touch-callout: none) { + /* Virtual keyboard can overlay content */ + #text-input .buttons > button:last-child { + display: flex; + padding-top: 0.2em; + justify-content: center; } +} - #text-input .buttons { +/* Portrait orientation (not for Safari on iOS) */ +@supports not (-webkit-touch-callout: none) { @media (max-aspect-ratio: 4/3) { + #text-input { flex-direction: column; } - #text-input .buttons > button, - #text-input .buttons > button:first-child { + #text-input .buttons { + flex-direction: row; + } + + #text-input .buttons > button { border-top: 1px solid black; - border-left: 1px solid black; + border-bottom: none; } #text-input .buttons > button:first-child { - border-top: none; + border-left: none; } -} +} } diff --git a/webdata_test.go b/webdata_test.go new file mode 100644 index 0000000..7ce8e8b --- /dev/null +++ b/webdata_test.go @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2024 Unrud + * + * This file is part of Remote-Touchpad. + * + * Remote-Touchpad is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Remote-Touchpad is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Remote-Touchpad. If not, see . + */ + +package main + +import ( + "fmt" + "io/fs" + "path/filepath" + "testing" +) + +func TestWebdataTypesCompleteness(t *testing.T) { + if err := fs.WalkDir(webdataFS, ".", func(_ string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + ext := filepath.Ext(d.Name()) + if _, haveType := webdataTypes[ext]; !haveType { + return fmt.Errorf("missing mime type for extension %#v", ext) + } + return nil + }); err != nil { + t.Fatal(err) + } +}