diff --git a/docs/api/methods.md b/docs/api/methods.md
index 9c711c0bc..78fe36b30 100644
--- a/docs/api/methods.md
+++ b/docs/api/methods.md
@@ -4813,7 +4813,9 @@ Returns an empty object `{}` on success.
Direct platform input control for remote control use cases. These methods bypass the token pipeline entirely: no hooks, history, or sound effects are triggered.
-The input macro format is identical to what goes after the `:` in a ZapScript `input.keyboard` or `input.gamepad` command on a token. Each character is a separate keypress, `{...}` groups are special keys/combos, and `\` is the escape character.
+The input macro format is identical to what goes after the `:` in a ZapScript `input.keyboard` or `input.gamepad` command on a token. Each character is a separate keypress, `{...}` groups are special keys/combos, and `\` is the escape character. Macros also support `{delay:duration}`, `{hold:key:duration}`, `{press:key}`, and `{release:key}`. Press and release have short forms `{_key}` and `{^key}`. Delay and explicit hold durations are limited to 30 seconds.
+
+Persistent `{press:key}` and `{release:key}` input is available only over supported WebSocket input sessions. A press remains held across requests from that WebSocket until its matching release. Each WebSocket owns its held keys and buttons; one connection cannot release another connection's input. Core releases all owned input when the WebSocket disconnects, input execution fails, or Core shuts down. HTTP JSON-RPC requests reject persistent press and release tokens because HTTP has no durable session lifecycle.
### input.keyboard
@@ -4827,7 +4829,7 @@ An object:
| Key | Type | Required | Description |
| :--- | :----- | :------- | :--------------------------------------------------------------------------------------------------------------------------------------------------- |
-| keys | string | Yes | Input macro string. Each character is a keypress, `{...}` for special keys (e.g. `{enter}`, `{f9}`, `{ctrl+q}`). Same format as ZapScript on a token. |
+| keys | string | Yes | Input macro string. Each character is a keypress, `{...}` for special keys (e.g. `{enter}`, `{f9}`, `{ctrl+q}`). WebSocket requests may use `{press:key}` and `{release:key}` to hold a key across requests. |
#### Result
@@ -4870,7 +4872,7 @@ An object:
| Key | Type | Required | Description |
| :------ | :----- | :------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| buttons | string | Yes | Input macro string. Each character is a button press, `{...}` for named buttons (e.g. `{up}`, `{start}`, `{l1}`). Same format as ZapScript on a token. |
+| buttons | string | Yes | Input macro string. Each character is a button press, `{...}` for named buttons (e.g. `{up}`, `{start}`, `{l1}`). WebSocket requests may use `{press:button}` and `{release:button}` to hold a button across requests. |
#### Result
diff --git a/pkg/api/input_session_integration_test.go b/pkg/api/input_session_integration_test.go
new file mode 100644
index 000000000..50ea8bc82
--- /dev/null
+++ b/pkg/api/input_session_integration_test.go
@@ -0,0 +1,319 @@
+// Zaparoo Core
+// Copyright (c) 2026 The Zaparoo Project Contributors.
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This file is part of Zaparoo Core.
+//
+// Zaparoo Core 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.
+//
+// Zaparoo Core 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 Zaparoo Core. If not, see .
+
+package api
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers/syncutil"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/mocks"
+ "github.com/gorilla/websocket"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+type trackedAPIInputSession struct {
+ blockPress <-chan struct{}
+ started chan []string
+ released chan struct{}
+ unblock chan struct{}
+ keyboard map[string]struct{}
+ gamepad map[string]struct{}
+ calls [][]string
+ mu syncutil.Mutex
+ waitForRelease bool
+}
+
+func newTrackedAPIInputSession() *trackedAPIInputSession {
+ return &trackedAPIInputSession{
+ started: make(chan []string, 4),
+ released: make(chan struct{}, 2),
+ unblock: make(chan struct{}, 1),
+ keyboard: make(map[string]struct{}),
+ gamepad: make(map[string]struct{}),
+ }
+}
+
+func (s *trackedAPIInputSession) KeyboardPressSequence(
+ ctx context.Context,
+ args []string,
+ _ time.Duration,
+) error {
+ copiedArgs := append([]string(nil), args...)
+ s.started <- copiedArgs
+ if s.waitForRelease {
+ <-s.unblock
+ }
+ if len(args) > 0 && strings.HasPrefix(args[0], "{press:") && s.blockPress != nil {
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-s.blockPress:
+ }
+ }
+
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.calls = append(s.calls, copiedArgs)
+ applyTrackedInputTokens(s.keyboard, args)
+ return nil
+}
+
+func (s *trackedAPIInputSession) GamepadPressSequence(
+ _ context.Context,
+ args []string,
+ _ time.Duration,
+) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.calls = append(s.calls, append([]string(nil), args...))
+ applyTrackedInputTokens(s.gamepad, args)
+ return nil
+}
+
+func (s *trackedAPIInputSession) ReleaseAll() error {
+ s.mu.Lock()
+ clear(s.keyboard)
+ clear(s.gamepad)
+ s.mu.Unlock()
+ select {
+ case s.unblock <- struct{}{}:
+ default:
+ }
+ select {
+ case s.released <- struct{}{}:
+ default:
+ }
+ return nil
+}
+
+func applyTrackedInputTokens(held map[string]struct{}, args []string) {
+ for _, token := range args {
+ switch {
+ case strings.HasPrefix(token, "{press:") && strings.HasSuffix(token, "}"):
+ held[strings.TrimSuffix(strings.TrimPrefix(token, "{press:"), "}")] = struct{}{}
+ case strings.HasPrefix(token, "{release:") && strings.HasSuffix(token, "}"):
+ delete(held, strings.TrimSuffix(strings.TrimPrefix(token, "{release:"), "}"))
+ }
+ }
+}
+
+func (s *trackedAPIInputSession) keyboardSnapshot() map[string]struct{} {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ result := make(map[string]struct{}, len(s.keyboard))
+ for key := range s.keyboard {
+ result[key] = struct{}{}
+ }
+ return result
+}
+
+func (s *trackedAPIInputSession) gamepadSnapshot() map[string]struct{} {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ result := make(map[string]struct{}, len(s.gamepad))
+ for button := range s.gamepad {
+ result[button] = struct{}{}
+ }
+ return result
+}
+
+func (s *trackedAPIInputSession) callSnapshot() [][]string {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ result := make([][]string, len(s.calls))
+ for i := range s.calls {
+ result[i] = append([]string(nil), s.calls[i]...)
+ }
+ return result
+}
+
+type inputSessionTestPlatform struct {
+ *mocks.MockPlatform
+ newSession func() *trackedAPIInputSession
+ created chan *trackedAPIInputSession
+}
+
+func newInputSessionTestPlatform(
+ factory func() *trackedAPIInputSession,
+) *inputSessionTestPlatform {
+ return &inputSessionTestPlatform{
+ MockPlatform: mocks.NewMockPlatform(),
+ newSession: factory,
+ created: make(chan *trackedAPIInputSession, 4),
+ }
+}
+
+func (p *inputSessionTestPlatform) NewInputSession() platforms.InputSession {
+ session := p.newSession()
+ p.created <- session
+ return session
+}
+
+func sendInputRPC(
+ t *testing.T,
+ conn *websocket.Conn,
+ id int,
+ method, paramName, macro string,
+) {
+ t.Helper()
+ payload := fmt.Sprintf(
+ `{"jsonrpc":"2.0","id":%d,"method":%q,"params":{%q:%q}}`,
+ id,
+ method,
+ paramName,
+ macro,
+ )
+ require.NoError(t, conn.WriteMessage(websocket.TextMessage, []byte(payload)))
+ require.NoError(t, conn.SetReadDeadline(time.Now().Add(2*time.Second)))
+ _, message, err := conn.ReadMessage()
+ require.NoError(t, err)
+ var response models.ResponseObject
+ require.NoError(t, json.Unmarshal(message, &response))
+ require.Nil(t, response.Error)
+ assert.Equal(t, models.NewNumberID(int64(id)), response.ID)
+}
+
+func TestWebSocketInputSessionPersistsIsolatesAndReleasesOnDisconnect(t *testing.T) {
+ platform := newInputSessionTestPlatform(newTrackedAPIInputSession)
+ wsURL, cleanup := startPriorityWSServerWithPlatform(t, NewMethodMap(), platform)
+ defer cleanup()
+
+ firstConn := dialWS(t, wsURL)
+ sendInputRPC(t, firstConn, 1, models.MethodInputKeyboard, "keys", "{press:up}")
+ firstSession := <-platform.created
+ assert.Equal(t, map[string]struct{}{"up": {}}, firstSession.keyboardSnapshot())
+
+ sendInputRPC(t, firstConn, 2, models.MethodInputKeyboard, "keys", "{press:left}")
+ assert.Equal(t, map[string]struct{}{"up": {}, "left": {}}, firstSession.keyboardSnapshot())
+ sendInputRPC(t, firstConn, 5, models.MethodInputGamepad, "buttons", "{press:start}")
+ assert.Equal(t, map[string]struct{}{"start": {}}, firstSession.gamepadSnapshot())
+
+ secondConn := dialWS(t, wsURL)
+ defer func() { _ = secondConn.Close() }()
+ sendInputRPC(t, secondConn, 3, models.MethodInputKeyboard, "keys", "{release:up}")
+ secondSession := <-platform.created
+ assert.Empty(t, secondSession.keyboardSnapshot())
+ assert.Equal(t, map[string]struct{}{"up": {}, "left": {}}, firstSession.keyboardSnapshot(),
+ "second WebSocket must not release first WebSocket input")
+
+ sendInputRPC(t, firstConn, 4, models.MethodInputKeyboard, "keys", "{release:up}")
+ assert.Equal(t, map[string]struct{}{"left": {}}, firstSession.keyboardSnapshot())
+
+ require.NoError(t, firstConn.Close())
+ select {
+ case <-firstSession.released:
+ case <-time.After(2 * time.Second):
+ t.Fatal("input session was not released after WebSocket disconnect")
+ }
+ assert.Empty(t, firstSession.keyboardSnapshot())
+ assert.Empty(t, firstSession.gamepadSnapshot())
+}
+
+func TestWebSocketDisconnectCancelsActiveInputBeforeRelease(t *testing.T) {
+ blockedPress := make(chan struct{})
+ platform := newInputSessionTestPlatform(func() *trackedAPIInputSession {
+ session := newTrackedAPIInputSession()
+ session.blockPress = blockedPress
+ return session
+ })
+ wsURL, cleanup := startPriorityWSServerWithPlatform(t, NewMethodMap(), platform)
+ defer cleanup()
+
+ conn := dialWS(t, wsURL)
+ press := []byte(`{"jsonrpc":"2.0","id":1,"method":"input.keyboard","params":{"keys":"{press:up}"}}`)
+ require.NoError(t, conn.WriteMessage(websocket.TextMessage, press))
+ session := <-platform.created
+ assert.Equal(t, []string{"{press:up}"}, <-session.started)
+
+ require.NoError(t, conn.Close())
+ select {
+ case <-session.released:
+ case <-time.After(2 * time.Second):
+ t.Fatal("disconnect did not cancel active input and release its session")
+ }
+ assert.Empty(t, session.keyboardSnapshot())
+}
+
+func TestWebSocketDisconnectReleasesBeforeWaitingForInputWorker(t *testing.T) {
+ platform := newInputSessionTestPlatform(func() *trackedAPIInputSession {
+ session := newTrackedAPIInputSession()
+ session.waitForRelease = true
+ return session
+ })
+ wsURL, cleanup := startPriorityWSServerWithPlatform(t, NewMethodMap(), platform)
+ defer cleanup()
+
+ conn := dialWS(t, wsURL)
+ press := []byte(`{"jsonrpc":"2.0","id":1,"method":"input.keyboard","params":{"keys":"{press:up}"}}`)
+ require.NoError(t, conn.WriteMessage(websocket.TextMessage, press))
+ session := <-platform.created
+ assert.Equal(t, []string{"{press:up}"}, <-session.started)
+
+ require.NoError(t, conn.Close())
+ select {
+ case <-session.released:
+ case <-time.After(2 * time.Second):
+ t.Fatal("disconnect waited for input worker before releasing session")
+ }
+}
+
+func TestWebSocketInputRequestsExecuteInArrivalOrder(t *testing.T) {
+ unblockPress := make(chan struct{})
+ platform := newInputSessionTestPlatform(func() *trackedAPIInputSession {
+ session := newTrackedAPIInputSession()
+ session.blockPress = unblockPress
+ return session
+ })
+ wsURL, cleanup := startPriorityWSServerWithPlatform(t, NewMethodMap(), platform)
+ defer cleanup()
+
+ conn := dialWS(t, wsURL)
+ defer func() { _ = conn.Close() }()
+ press := []byte(`{"jsonrpc":"2.0","id":1,"method":"input.keyboard","params":{"keys":"{press:up}"}}`)
+ release := []byte(`{"jsonrpc":"2.0","id":2,"method":"input.keyboard","params":{"keys":"{release:up}"}}`)
+ require.NoError(t, conn.WriteMessage(websocket.TextMessage, press))
+ session := <-platform.created
+ assert.Equal(t, []string{"{press:up}"}, <-session.started)
+ require.NoError(t, conn.WriteMessage(websocket.TextMessage, release))
+
+ select {
+ case started := <-session.started:
+ t.Fatalf("second input request started before first completed: %v", started)
+ case <-time.After(100 * time.Millisecond):
+ }
+ close(unblockPress)
+
+ for range 2 {
+ require.NoError(t, conn.SetReadDeadline(time.Now().Add(2*time.Second)))
+ _, _, err := conn.ReadMessage()
+ require.NoError(t, err)
+ }
+ assert.Equal(t, []string{"{release:up}"}, <-session.started)
+ assert.Equal(t, [][]string{{"{press:up}"}, {"{release:up}"}}, session.callSnapshot())
+ assert.Empty(t, session.keyboardSnapshot())
+}
diff --git a/pkg/api/methods/input.go b/pkg/api/methods/input.go
index 21e6a74df..9582592e1 100644
--- a/pkg/api/methods/input.go
+++ b/pkg/api/methods/input.go
@@ -26,10 +26,21 @@ import (
"github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models/requests"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/api/validation"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers/inputmacro"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/zapscript"
"github.com/rs/zerolog/log"
)
+func hasPersistentInputTokens(args []string) bool {
+ for _, token := range args {
+ action := inputmacro.ClassifyControl(token).Action
+ if action == inputmacro.ActionPress || action == inputmacro.ActionRelease {
+ return true
+ }
+ }
+ return false
+}
+
func parseInputMacro(cmdName, macro string) ([]string, error) {
script, err := zapscriptlib.NewParser("**" + cmdName + ":" + macro).ParseScript()
if err != nil {
@@ -55,8 +66,17 @@ func HandleInputKeyboard(env requests.RequestEnv) (any, error) {
log.Info().Int("key_count", len(args)).Msg("keyboard input via API")
- if err := zapscript.PressKeyboardSequence(env.Platform, args, 0); err != nil {
- return nil, fmt.Errorf("keyboard press failed: %w", err)
+ if env.InputSession != nil {
+ if err := env.InputSession.KeyboardPressSequence(env.Context, args, 0); err != nil {
+ return nil, fmt.Errorf("keyboard press failed: %w", err)
+ }
+ } else {
+ if hasPersistentInputTokens(args) {
+ return nil, models.ClientErrf("persistent keyboard input requires a supported WebSocket session")
+ }
+ if err := zapscript.PressKeyboardSequence(env.Platform, args, 0); err != nil {
+ return nil, fmt.Errorf("keyboard press failed: %w", err)
+ }
}
return NoContent{}, nil
@@ -76,8 +96,17 @@ func HandleInputGamepad(env requests.RequestEnv) (any, error) {
log.Info().Int("button_count", len(args)).Msg("gamepad input via API")
- if err := zapscript.PressGamepadSequence(env.Platform, args, 0); err != nil {
- return nil, fmt.Errorf("gamepad press failed: %w", err)
+ if env.InputSession != nil {
+ if err := env.InputSession.GamepadPressSequence(env.Context, args, 0); err != nil {
+ return nil, fmt.Errorf("gamepad press failed: %w", err)
+ }
+ } else {
+ if hasPersistentInputTokens(args) {
+ return nil, models.ClientErrf("persistent gamepad input requires a supported WebSocket session")
+ }
+ if err := zapscript.PressGamepadSequence(env.Platform, args, 0); err != nil {
+ return nil, fmt.Errorf("gamepad press failed: %w", err)
+ }
}
return NoContent{}, nil
diff --git a/pkg/api/methods/input_session_test.go b/pkg/api/methods/input_session_test.go
new file mode 100644
index 000000000..14da90f6d
--- /dev/null
+++ b/pkg/api/methods/input_session_test.go
@@ -0,0 +1,132 @@
+// Zaparoo Core
+// Copyright (c) 2026 The Zaparoo Project Contributors.
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This file is part of Zaparoo Core.
+//
+// Zaparoo Core 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.
+//
+// Zaparoo Core 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 Zaparoo Core. If not, see .
+
+package methods
+
+import (
+ "context"
+ "encoding/json"
+ "testing"
+ "time"
+
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models/requests"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+type recordingInputSession struct {
+ keyboardErr error
+ gamepadErr error
+ keyboardArgs []string
+ gamepadArgs []string
+ keyboardDelay time.Duration
+ gamepadDelay time.Duration
+ released bool
+}
+
+func (s *recordingInputSession) KeyboardPressSequence(
+ _ context.Context,
+ args []string,
+ delay time.Duration,
+) error {
+ s.keyboardArgs = append([]string(nil), args...)
+ s.keyboardDelay = delay
+ return s.keyboardErr
+}
+
+func (s *recordingInputSession) GamepadPressSequence(
+ _ context.Context,
+ args []string,
+ delay time.Duration,
+) error {
+ s.gamepadArgs = append([]string(nil), args...)
+ s.gamepadDelay = delay
+ return s.gamepadErr
+}
+
+func (s *recordingInputSession) ReleaseAll() error {
+ s.released = true
+ return nil
+}
+
+func TestHandleInputKeyboard_UsesDurableInputSession(t *testing.T) {
+ t.Parallel()
+
+ session := &recordingInputSession{}
+ env := requests.RequestEnv{
+ Context: t.Context(),
+ InputSession: session,
+ Params: json.RawMessage(`{"keys":"{press:up}"}`),
+ }
+
+ result, err := HandleInputKeyboard(env)
+ require.NoError(t, err)
+ assert.Equal(t, NoContent{}, result)
+ assert.Equal(t, []string{"{press:up}"}, session.keyboardArgs)
+ assert.Zero(t, session.keyboardDelay)
+ assert.False(t, session.released)
+}
+
+func TestHandleInputGamepad_UsesDurableInputSession(t *testing.T) {
+ t.Parallel()
+
+ session := &recordingInputSession{}
+ env := requests.RequestEnv{
+ Context: t.Context(),
+ InputSession: session,
+ Params: json.RawMessage(`{"buttons":"{press:start}"}`),
+ }
+
+ result, err := HandleInputGamepad(env)
+ require.NoError(t, err)
+ assert.Equal(t, NoContent{}, result)
+ assert.Equal(t, []string{"{press:start}"}, session.gamepadArgs)
+ assert.Zero(t, session.gamepadDelay)
+}
+
+func TestHandleInputKeyboard_RejectsPersistentInputWithoutSession(t *testing.T) {
+ t.Parallel()
+
+ env := requests.RequestEnv{
+ Context: t.Context(),
+ Params: json.RawMessage(`{"keys":"{press:up}"}`),
+ }
+
+ _, err := HandleInputKeyboard(env)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "requires a supported WebSocket session")
+ var clientErr *models.ClientError
+ require.ErrorAs(t, err, &clientErr)
+}
+
+func TestHandleInputGamepad_RejectsPersistentInputWithoutSession(t *testing.T) {
+ t.Parallel()
+
+ env := requests.RequestEnv{
+ Context: t.Context(),
+ Params: json.RawMessage(`{"buttons":"{release:start}"}`),
+ }
+
+ _, err := HandleInputGamepad(env)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "requires a supported WebSocket session")
+ var clientErr *models.ClientError
+ require.ErrorAs(t, err, &clientErr)
+}
diff --git a/pkg/api/models/requests/requests.go b/pkg/api/models/requests/requests.go
index 355c8e0b4..d5b79e5e4 100644
--- a/pkg/api/models/requests/requests.go
+++ b/pkg/api/models/requests/requests.go
@@ -57,7 +57,10 @@ type RequestEnv struct {
IndexPauser *syncutil.Pauser
ScrapePauser *syncutil.Pauser
BackupPauser *syncutil.Pauser
- ClientID string
+ // InputSession is non-nil only for durable transports such as WebSocket.
+ // It owns keyboard and gamepad inputs held across requests.
+ InputSession platforms.InputSession
+ ClientID string
// ClientRole is the paired client's permission role ("admin" or
// "member"), or "" when the request carries no paired identity (local
// connections, plaintext WebSocket, HTTP). See pkg/api/permissions.
diff --git a/pkg/api/request_priority.go b/pkg/api/request_priority.go
index 4dedbc4de..9af7e606b 100644
--- a/pkg/api/request_priority.go
+++ b/pkg/api/request_priority.go
@@ -34,6 +34,7 @@ type apiRequestPriority int
const (
apiPriorityHigh apiRequestPriority = iota
+ apiPriorityInput
apiPriorityNormal
apiPriorityLow
)
@@ -42,6 +43,8 @@ func (p apiRequestPriority) String() string {
switch p {
case apiPriorityHigh:
return "high"
+ case apiPriorityInput:
+ return "input"
case apiPriorityLow:
return "low"
default:
@@ -72,6 +75,8 @@ func classifyAPIMethod(method string) apiRequestPriority {
method = strings.ToLower(method)
switch method {
+ case models.MethodInputKeyboard, models.MethodInputGamepad:
+ return apiPriorityInput
case models.MethodMediaHistoryLatest,
models.MethodRun,
models.MethodRunScript,
diff --git a/pkg/api/request_priority_test.go b/pkg/api/request_priority_test.go
index f88bcd33e..8eb85ad03 100644
--- a/pkg/api/request_priority_test.go
+++ b/pkg/api/request_priority_test.go
@@ -80,6 +80,8 @@ func TestClassifyAPIMethod(t *testing.T) {
{"high media history latest", models.MethodMediaHistoryLatest, apiPriorityHigh},
{"high run case insensitive", "RUN", apiPriorityHigh},
{"high media control", models.MethodMediaControl, apiPriorityHigh},
+ {"input keyboard", models.MethodInputKeyboard, apiPriorityInput},
+ {"input gamepad", models.MethodInputGamepad, apiPriorityInput},
{"low media generate", models.MethodMediaGenerate, apiPriorityLow},
{"low media image", models.MethodMediaImage, apiPriorityLow},
{"low scrape prefix", "media.scrape.queue", apiPriorityLow},
diff --git a/pkg/api/server.go b/pkg/api/server.go
index acf21685c..2d23ac4ef 100644
--- a/pkg/api/server.go
+++ b/pkg/api/server.go
@@ -1161,7 +1161,7 @@ func handleWSMessage(
// Heartbeat ping/pong runs on the decrypted plaintext so encrypted
// sessions get an encrypted pong, and remote plaintext probes are
// rejected by decryptIncomingFrame before reaching this point.
- dispatcher := getOrCreateWSDispatcher(st.GetContext(), session)
+ dispatcher := getOrCreateWSDispatcher(st.GetContext(), session, platform)
if bytes.Equal(plaintext, []byte("ping")) {
if err := dispatcher.enqueuePong(cs, tracker); err != nil {
@@ -1191,6 +1191,7 @@ func handleWSMessage(
IndexPauser: indexPauser,
ScrapePauser: scrapePauser,
BackupPauser: backupPauser,
+ InputSession: dispatcher.inputSession,
IsLocal: isLocal,
ClientID: session.Request.RemoteAddr,
}
diff --git a/pkg/api/ws_dispatcher.go b/pkg/api/ws_dispatcher.go
index c27c002ed..2afae5660 100644
--- a/pkg/api/ws_dispatcher.go
+++ b/pkg/api/ws_dispatcher.go
@@ -23,12 +23,14 @@ import (
"context"
"errors"
"fmt"
+ "sync"
"time"
apimiddleware "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/middleware"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models/requests"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers/syncutil"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms"
"github.com/olahol/melody"
"github.com/rs/zerolog/log"
)
@@ -36,13 +38,14 @@ import (
type mediaDBLockMode uint8
const (
- wsHighConcurrency = 1
- wsNormalConcurrency = 4
- wsLowConcurrency = 2
- wsQueueSize = 256
- wsLowQueueSize = 16
- wsResponseQueueSize = 256
- wsGlobalImageConcurrent = 2
+ wsHighConcurrency = 1
+ wsNormalConcurrency = 4
+ wsLowConcurrency = 2
+ wsQueueSize = 256
+ wsLowQueueSize = 16
+ wsResponseQueueSize = 256
+ wsGlobalImageConcurrent = 2
+ wsInputWorkerDrainTimeout = 2 * time.Second
)
const (
@@ -106,16 +109,24 @@ type wsResponseJob struct {
}
type wsSessionDispatcher struct {
- ctx context.Context
- cancel context.CancelFunc
- session *melody.Session
- high chan *wsRequestJob
- normal chan *wsRequestJob
- low chan *wsRequestJob
- responses chan *wsResponseJob
+ ctx context.Context
+ cancel context.CancelFunc
+ session *melody.Session
+ inputSession platforms.InputSession
+ high chan *wsRequestJob
+ normal chan *wsRequestJob
+ input chan *wsRequestJob
+ low chan *wsRequestJob
+ responses chan *wsResponseJob
+ inputDone chan struct{}
+ closeOnce sync.Once
}
-func getOrCreateWSDispatcher(parent context.Context, session *melody.Session) *wsSessionDispatcher {
+func getOrCreateWSDispatcher(
+ parent context.Context,
+ session *melody.Session,
+ platform platforms.Platform,
+) *wsSessionDispatcher {
if existing, ok := session.Get(wsDispatcherSessionKey); ok {
if d, ok := existing.(*wsSessionDispatcher); ok {
return d
@@ -123,14 +134,21 @@ func getOrCreateWSDispatcher(parent context.Context, session *melody.Session) *w
}
ctx, cancel := context.WithCancel(parent)
+ var inputSession platforms.InputSession
+ if provider, ok := platform.(platforms.InputSessionProvider); ok {
+ inputSession = provider.NewInputSession()
+ }
d := &wsSessionDispatcher{
- ctx: ctx,
- cancel: cancel,
- session: session,
- high: make(chan *wsRequestJob, wsQueueSize),
- normal: make(chan *wsRequestJob, wsQueueSize),
- low: make(chan *wsRequestJob, wsLowQueueSize),
- responses: make(chan *wsResponseJob, wsResponseQueueSize),
+ ctx: ctx,
+ cancel: cancel,
+ session: session,
+ inputSession: inputSession,
+ high: make(chan *wsRequestJob, wsQueueSize),
+ normal: make(chan *wsRequestJob, wsQueueSize),
+ input: make(chan *wsRequestJob, wsQueueSize),
+ low: make(chan *wsRequestJob, wsLowQueueSize),
+ responses: make(chan *wsResponseJob, wsResponseQueueSize),
+ inputDone: make(chan struct{}),
}
session.Set(wsDispatcherSessionKey, d)
d.start()
@@ -150,11 +168,43 @@ func closeWSDispatcher(session *melody.Session) {
}
func (d *wsSessionDispatcher) close() {
- d.cancel()
- d.drainQueuedJobs(d.high)
- d.drainQueuedJobs(d.normal)
- d.drainQueuedJobs(d.low)
- d.drainQueuedResponses()
+ d.closeOnce.Do(func() {
+ d.cancel()
+ d.releaseInputSession()
+ d.drainQueuedJobs(d.high)
+ d.drainQueuedJobs(d.normal)
+ d.drainQueuedJobs(d.input)
+ d.drainQueuedJobs(d.low)
+ d.waitForInputWorker()
+ // Retry after the drain wait in case worker cleanup raced the first
+ // release or a device release initially failed.
+ d.releaseInputSession()
+ d.drainQueuedResponses()
+ })
+}
+
+func (d *wsSessionDispatcher) waitForInputWorker() {
+ if d.inputDone == nil {
+ return
+ }
+
+ timer := time.NewTimer(wsInputWorkerDrainTimeout)
+ defer timer.Stop()
+ select {
+ case <-d.inputDone:
+ case <-timer.C:
+ log.Warn().Dur("timeout", wsInputWorkerDrainTimeout).
+ Msg("timed out waiting for WebSocket input worker to stop")
+ }
+}
+
+func (d *wsSessionDispatcher) releaseInputSession() {
+ if d.inputSession == nil {
+ return
+ }
+ if err := d.inputSession.ReleaseAll(); err != nil {
+ log.Warn().Err(err).Msg("error releasing WebSocket input session")
+ }
}
func (*wsSessionDispatcher) drainQueuedJobs(queue <-chan *wsRequestJob) {
@@ -190,12 +240,19 @@ func (d *wsSessionDispatcher) drainQueuedResponses() {
}
func (d *wsSessionDispatcher) start() {
+ if d.inputDone == nil {
+ d.inputDone = make(chan struct{})
+ }
for range wsHighConcurrency {
go d.worker(d.high)
}
for range wsNormalConcurrency {
go d.worker(d.normal)
}
+ go func() {
+ defer close(d.inputDone)
+ d.worker(d.input)
+ }()
for range wsLowConcurrency {
go d.worker(d.low)
}
@@ -206,6 +263,8 @@ func (d *wsSessionDispatcher) queue(priority apiRequestPriority) chan *wsRequest
switch priority {
case apiPriorityHigh:
return d.high
+ case apiPriorityInput:
+ return d.input
case apiPriorityLow:
return d.low
default:
diff --git a/pkg/api/ws_dispatcher_test.go b/pkg/api/ws_dispatcher_test.go
index 27afa3958..60d16ad8c 100644
--- a/pkg/api/ws_dispatcher_test.go
+++ b/pkg/api/ws_dispatcher_test.go
@@ -35,6 +35,7 @@ import (
"github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models/requests"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/config"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/service/state"
"github.com/gorilla/websocket"
"github.com/olahol/melody"
@@ -53,17 +54,26 @@ func indexRPCID(ids []models.RPCID, target models.RPCID) int {
func startPriorityWSServer(t *testing.T, methodMap *MethodMap) (wsURL string, cleanup func()) {
t.Helper()
+ return startPriorityWSServerWithPlatform(t, methodMap, nil)
+}
+
+func startPriorityWSServerWithPlatform(
+ t *testing.T,
+ methodMap *MethodMap,
+ platform platforms.Platform,
+) (wsURL string, cleanup func()) {
+ t.Helper()
cfg, err := config.NewConfig(t.TempDir(), config.BaseDefaults)
require.NoError(t, err)
- st, _ := state.NewState(nil, "test-boot")
+ st, _ := state.NewState(platform, "test-boot")
m := newWebSocketSession()
m.HandleDisconnect(func(s *melody.Session) {
closeWSDispatcher(s)
})
m.HandleMessage(handleWSMessage(
- methodMap, nil, cfg, st, nil, nil,
+ methodMap, platform, cfg, st, nil, nil,
nil, nil, nil, nil, nil, nil,
nil, nil, nil, nil, nil,
))
@@ -264,7 +274,7 @@ func TestWebSocketBusyResponseUsesEncryptedSession(t *testing.T) {
m := newWebSocketSession()
m.HandleConnect(func(session *melody.Session) {
- dispatcher := getOrCreateWSDispatcher(ctx, session)
+ dispatcher := getOrCreateWSDispatcher(ctx, session, nil)
dispatcher.enqueueResponse(&wsResponseJob{
result: requestResult{
ID: models.NewStringID("busy-request"),
@@ -634,6 +644,35 @@ func TestCloseWSDispatcherCancelsQueuedRequests(t *testing.T) {
assert.Error(t, jobCtx.Err())
}
+func TestCloseWSDispatcherBoundsInputWorkerDrain(t *testing.T) {
+ t.Parallel()
+
+ ctx, cancel := context.WithCancel(t.Context())
+ d := &wsSessionDispatcher{
+ ctx: ctx,
+ cancel: cancel,
+ inputDone: make(chan struct{}),
+ }
+
+ started := time.Now()
+ d.close()
+ assert.Less(t, time.Since(started), wsInputWorkerDrainTimeout+time.Second)
+}
+
+func TestWSDispatcherStartInitializesInputDone(t *testing.T) {
+ t.Parallel()
+
+ ctx, cancel := context.WithCancel(t.Context())
+ d := &wsSessionDispatcher{
+ ctx: ctx,
+ cancel: cancel,
+ }
+
+ d.start()
+ require.NotNil(t, d.inputDone)
+ d.close()
+}
+
type countingRequestTracker struct {
count int
}
diff --git a/pkg/helpers/inputmacro/control.go b/pkg/helpers/inputmacro/control.go
new file mode 100644
index 000000000..282608939
--- /dev/null
+++ b/pkg/helpers/inputmacro/control.go
@@ -0,0 +1,68 @@
+// Zaparoo Core
+// Copyright (c) 2026 The Zaparoo Project Contributors.
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This file is part of Zaparoo Core.
+//
+// Zaparoo Core 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.
+//
+// Zaparoo Core 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 Zaparoo Core. If not, see .
+
+// Package inputmacro classifies control tokens shared by input transports and emitters.
+package inputmacro
+
+import "strings"
+
+// Action identifies an inline input macro control operation.
+type Action uint8
+
+const (
+ ActionNone Action = iota
+ ActionDelay
+ ActionPress
+ ActionRelease
+ ActionHold
+)
+
+// Control describes a recognized inline input macro token.
+type Control struct {
+ Value string
+ Action Action
+}
+
+// ClassifyControl recognizes braced delay, press, release, and hold tokens,
+// including their sigil forms. Non-control tokens return ActionNone.
+func ClassifyControl(token string) Control {
+ if len(token) <= 2 || token[0] != '{' || token[len(token)-1] != '}' {
+ return Control{}
+ }
+
+ inner := token[1 : len(token)-1]
+ switch {
+ case strings.HasPrefix(inner, "delay:"):
+ return Control{Action: ActionDelay, Value: inner[len("delay:"):]}
+ case strings.HasPrefix(inner, "press:"):
+ return Control{Action: ActionPress, Value: inner[len("press:"):]}
+ case strings.HasPrefix(inner, "release:"):
+ return Control{Action: ActionRelease, Value: inner[len("release:"):]}
+ case strings.HasPrefix(inner, "hold:"):
+ return Control{Action: ActionHold, Value: inner[len("hold:"):]}
+ case len(inner) > 1 && inner[0] == '_':
+ return Control{Action: ActionPress, Value: inner[1:]}
+ case len(inner) > 1 && inner[0] == '^':
+ return Control{Action: ActionRelease, Value: inner[1:]}
+ case len(inner) > 1 && inner[0] == '~':
+ return Control{Action: ActionHold, Value: inner[1:]}
+ default:
+ return Control{}
+ }
+}
diff --git a/pkg/helpers/inputmacro/control_test.go b/pkg/helpers/inputmacro/control_test.go
new file mode 100644
index 000000000..f7b311665
--- /dev/null
+++ b/pkg/helpers/inputmacro/control_test.go
@@ -0,0 +1,54 @@
+// Zaparoo Core
+// Copyright (c) 2026 The Zaparoo Project Contributors.
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This file is part of Zaparoo Core.
+//
+// Zaparoo Core 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.
+//
+// Zaparoo Core 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 Zaparoo Core. If not, see .
+
+package inputmacro
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestClassifyControl(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ input string
+ want Control
+ }{
+ {name: "delay", input: "{delay:500ms}", want: Control{Action: ActionDelay, Value: "500ms"}},
+ {name: "press", input: "{press:up}", want: Control{Action: ActionPress, Value: "up"}},
+ {name: "release", input: "{release:up}", want: Control{Action: ActionRelease, Value: "up"}},
+ {name: "hold", input: "{hold:up:1s}", want: Control{Action: ActionHold, Value: "up:1s"}},
+ {name: "press sigil", input: "{_up}", want: Control{Action: ActionPress, Value: "up"}},
+ {name: "release sigil", input: "{^up}", want: Control{Action: ActionRelease, Value: "up"}},
+ {name: "hold sigil", input: "{~up:1s}", want: Control{Action: ActionHold, Value: "up:1s"}},
+ {name: "standard key", input: "{up}"},
+ {name: "unbraced", input: "press:up"},
+ {name: "empty sigil", input: "{_}"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ assert.Equal(t, tt.want, ClassifyControl(tt.input))
+ })
+ }
+}
diff --git a/pkg/platforms/platforms.go b/pkg/platforms/platforms.go
index d1600cb47..1617a7f9b 100644
--- a/pkg/platforms/platforms.go
+++ b/pkg/platforms/platforms.go
@@ -23,6 +23,7 @@ import (
"context"
"errors"
"os"
+ "time"
"github.com/ZaparooProject/go-zapscript"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models"
@@ -70,6 +71,21 @@ type MediaReadyPlatform interface {
WaitForMediaReady(context.Context, *config.Instance, *models.ActiveMedia) error
}
+// InputSession owns keyboard and gamepad inputs held by one durable client
+// connection. Implementations must isolate held input between sessions and
+// release all owned input when ReleaseAll is called.
+type InputSession interface {
+ KeyboardPressSequence(context.Context, []string, time.Duration) error
+ GamepadPressSequence(context.Context, []string, time.Duration) error
+ ReleaseAll() error
+}
+
+// InputSessionProvider is optionally implemented by platforms that support
+// input held across multiple API requests.
+type InputSessionProvider interface {
+ NewInputSession() InputSession
+}
+
// LauncherLifecycle determines how a launcher process is managed
type LauncherLifecycle int
diff --git a/pkg/platforms/replayos/platform.go b/pkg/platforms/replayos/platform.go
index 858469521..5f510476a 100644
--- a/pkg/platforms/replayos/platform.go
+++ b/pkg/platforms/replayos/platform.go
@@ -77,16 +77,16 @@ type Platform struct {
cmd command.Executor
ctx context.Context
clock clockwork.Clock
- activeMedia func() *models.ActiveMedia
+ cancel context.CancelFunc
setActiveMedia func(*models.ActiveMedia)
stopTracker func() error
- cancel context.CancelFunc
+ activeMedia func() *models.ActiveMedia
+ activeStorage string
+ pendingROMPath string
+ lastKnownCore string
+ procPath string
+ storagePaths []string
shared.LinuxInput
- activeStorage string
- pendingROMPath string
- lastKnownCore string
- procPath string
- storagePaths []string
trackerMu syncutil.RWMutex
keyboardRealMode bool
}
diff --git a/pkg/platforms/shared/linuxinput.go b/pkg/platforms/shared/linuxinput.go
index 601cbf254..330f6db4d 100644
--- a/pkg/platforms/shared/linuxinput.go
+++ b/pkg/platforms/shared/linuxinput.go
@@ -22,25 +22,29 @@
package shared
import (
- "errors"
"fmt"
"strconv"
- "strings"
"time"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/config"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers/linuxinput"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers/linuxinput/keyboardmap"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers/syncutil"
"github.com/rs/zerolog/log"
)
// LinuxInput manages virtual keyboard and gamepad devices for Linux platforms.
// Embed this struct in platform implementations that need input device support.
type LinuxInput struct {
- NewKeyboard func(time.Duration) (linuxinput.Keyboard, error)
- NewGamepad func(time.Duration) (linuxinput.Gamepad, error)
- kbd linuxinput.Keyboard
- gpd linuxinput.Gamepad
+ NewKeyboard func(time.Duration) (linuxinput.Keyboard, error)
+ NewGamepad func(time.Duration) (linuxinput.Gamepad, error)
+ inputSessions map[*linuxInputSession]*heldInputState
+ keyboardRefs map[int]int
+ gamepadRefs map[int]int
+ kbd linuxinput.Keyboard
+ gpd linuxinput.Gamepad
+ sequenceMu syncutil.Mutex
+ inputMu syncutil.Mutex
}
// InitDevices initializes keyboard and optionally gamepad based on config.
@@ -75,36 +79,14 @@ func (l *LinuxInput) InitDevices(cfg *config.Instance, gamepadEnabledByDefault b
return nil
}
-// CloseDevices closes keyboard and gamepad devices.
+// CloseDevices releases all held input before closing keyboard and gamepad devices.
func (l *LinuxInput) CloseDevices() {
- if l.kbd.Device != nil {
- if err := l.kbd.Close(); err != nil {
- log.Warn().Err(err).Msg("error closing keyboard")
- }
- }
- if l.gpd.Device != nil {
- if err := l.gpd.Close(); err != nil {
- log.Warn().Err(err).Msg("error closing gamepad")
- }
- }
+ l.closeInputDevices()
}
// KeyboardPress sends a keyboard key press.
func (l *LinuxInput) KeyboardPress(arg string) error {
- codes, isCombo, err := linuxinput.ParseKeyCombo(arg)
- if err != nil {
- return fmt.Errorf("failed to parse key combo: %w", err)
- }
- if isCombo {
- if err := l.kbd.Combo(codes...); err != nil {
- return fmt.Errorf("failed to press keyboard combo: %w", err)
- }
- return nil
- }
- if err := l.kbd.Press(codes[0]); err != nil {
- return fmt.Errorf("failed to press keyboard key: %w", err)
- }
- return nil
+ return l.pressKeyboardToken(arg)
}
// DefaultInterKeyDelay is the default pause between consecutive key presses in a
@@ -148,231 +130,19 @@ func parseMacroDuration(s string) (time.Duration, error) {
return d, nil
}
-// KeyboardPressSequence sends a key sequence with shift-batching and inline
-// macro token support. interKeyDelay is the gap between consecutive keys; if
-// zero, DefaultInterKeyDelay is used.
-//
-// Shift-batching: maximal runs of shift-modified single characters are grouped
-// under one held LeftShift, matching how a person types *MENU.
-//
-// Inline tokens (passed through from the parser):
-//
-// {delay:N} — sleep N (integer ms or Go duration)
-// {press:k}/{_k} — key down, no release
-// {release:k}/{^k} — key up
-// {hold:k:dur}/{~k:dur} — key down, sleep dur, key up
-//
-// A sequence-scoped defer ensures any key left held is released on return.
+// KeyboardPressSequence sends a request-scoped key sequence. Explicit press
+// tokens are always released before this method returns.
func (l *LinuxInput) KeyboardPressSequence(args []string, interKeyDelay time.Duration) error {
- if l.kbd.Device == nil {
- return errors.New("virtual keyboard is disabled")
- }
- if interKeyDelay == 0 {
- interKeyDelay = DefaultInterKeyDelay
- }
-
- const shiftCode = 42 // KEY_LEFTSHIFT
-
- // held tracks keys currently pressed for sequence-scoped release-all.
- held := make(map[int]bool)
-
- keyDown := func(code int) error {
- if err := l.kbd.Device.KeyDown(code); err != nil {
- return fmt.Errorf("key down %d: %w", code, err)
- }
- held[code] = true
- return nil
- }
-
- keyUp := func(code int) error {
- if err := l.kbd.Device.KeyUp(code); err != nil {
- return fmt.Errorf("key up %d: %w", code, err)
- }
- delete(held, code)
- return nil
- }
-
- // Release any key left held on error or normal completion.
- defer func() {
- for code := range held {
- _ = l.kbd.Device.KeyUp(code)
- }
- }()
-
- i := 0
- for i < len(args) {
- token := args[i]
-
- // Dispatch inline control tokens: {delay:…}, {press:…}, {release:…},
- // {hold:…:…}, {_…}, {^…}, {~…:…}. These must be checked before the
- // shifted-run detection because their content starts with '{'.
- if len(token) > 2 && token[0] == '{' && token[len(token)-1] == '}' {
- inner := token[1 : len(token)-1]
- switch {
- case strings.HasPrefix(inner, "delay:"):
- d, err := parseMacroDuration(inner[len("delay:"):])
- if err != nil {
- return fmt.Errorf("invalid delay token %q: %w", token, err)
- }
- time.Sleep(d)
- i++
- continue
-
- case strings.HasPrefix(inner, "press:") || (len(inner) > 1 && inner[0] == '_'):
- var name string
- if inner[0] == '_' {
- name = inner[1:]
- } else {
- name = inner[len("press:"):]
- }
- code, err := resolveHoldKeyCode(name)
- if err != nil {
- return fmt.Errorf("token %q: %w", token, err)
- }
- if err := keyDown(code); err != nil {
- return fmt.Errorf("token %q: %w", token, err)
- }
- i++
- continue
-
- case strings.HasPrefix(inner, "release:") || (len(inner) > 1 && inner[0] == '^'):
- var name string
- if inner[0] == '^' {
- name = inner[1:]
- } else {
- name = inner[len("release:"):]
- }
- code, err := resolveHoldKeyCode(name)
- if err != nil {
- return fmt.Errorf("token %q: %w", token, err)
- }
- if err := keyUp(code); err != nil {
- return fmt.Errorf("token %q: %w", token, err)
- }
- i++
- continue
-
- case strings.HasPrefix(inner, "hold:") || (len(inner) > 1 && inner[0] == '~'):
- var rest string
- if inner[0] == '~' {
- rest = inner[1:]
- } else {
- rest = inner[len("hold:"):]
- }
- // Split "key:dur" or "key" at the LAST ':'.
- var keyName, durStr string
- if idx := strings.LastIndex(rest, ":"); idx != -1 {
- keyName = rest[:idx]
- durStr = rest[idx+1:]
- } else {
- keyName = rest
- }
- code, err := resolveHoldKeyCode(keyName)
- if err != nil {
- return fmt.Errorf("token %q: %w", token, err)
- }
- holdDur := l.kbd.Delay
- if durStr != "" {
- holdDur, err = parseMacroDuration(durStr)
- if err != nil {
- return fmt.Errorf("invalid hold duration in %q: %w", token, err)
- }
- }
- if err := keyDown(code); err != nil {
- return fmt.Errorf("token %q key down: %w", token, err)
- }
- time.Sleep(holdDur)
- if err := keyUp(code); err != nil {
- return fmt.Errorf("token %q key up: %w", token, err)
- }
- i++
- continue
- }
- // Fall through for standard braced keys like {enter}, {ctrl+c}.
- }
-
- // Check if this starts a shifted run (token maps to a negative keymap code).
- if baseCode, ok := keyboardmap.IsShiftedKey(token); ok {
- // Extend to the maximal contiguous run of shifted single chars.
- end := i + 1
- for end < len(args) {
- if _, ok2 := keyboardmap.IsShiftedKey(args[end]); !ok2 {
- break
- }
- end++
- }
-
- // Hold LeftShift across the whole run.
- if err := keyDown(shiftCode); err != nil {
- return fmt.Errorf("failed to press shift down: %w", err)
- }
-
- // Emit the first key (baseCode already resolved above).
- if err := keyDown(baseCode); err != nil {
- return fmt.Errorf("failed to press shifted key %q down: %w", token, err)
- }
- time.Sleep(l.kbd.Delay)
- if err := keyUp(baseCode); err != nil {
- return fmt.Errorf("failed to release shifted key %q: %w", token, err)
- }
- time.Sleep(interKeyDelay)
-
- // Emit remaining keys in the run.
- for j := i + 1; j < end; j++ {
- bc, _ := keyboardmap.IsShiftedKey(args[j])
- if err := keyDown(bc); err != nil {
- return fmt.Errorf("failed to press shifted key %q down: %w", args[j], err)
- }
- time.Sleep(l.kbd.Delay)
- if err := keyUp(bc); err != nil {
- return fmt.Errorf("failed to release shifted key %q: %w", args[j], err)
- }
- time.Sleep(interKeyDelay)
- }
-
- // Release Shift after the run.
- if err := keyUp(shiftCode); err != nil {
- return fmt.Errorf("failed to release shift: %w", err)
- }
-
- i = end
- continue
- }
-
- // Non-shifted, non-control token: parse and emit via combo/press.
- codes, isCombo, err := linuxinput.ParseKeyCombo(token)
- if err != nil {
- return fmt.Errorf("failed to parse key %q: %w", token, err)
- }
-
- if isCombo {
- if err := l.kbd.Combo(codes...); err != nil {
- return fmt.Errorf("failed to press combo %q: %w", token, err)
- }
- } else {
- if err := l.kbd.Press(codes[0]); err != nil {
- return fmt.Errorf("failed to press key %q: %w", token, err)
- }
- }
- time.Sleep(interKeyDelay)
-
- i++
- }
-
- return nil
+ return l.pressKeyboardSequence(args, interKeyDelay)
}
// GamepadPress sends a gamepad button press.
func (l *LinuxInput) GamepadPress(name string) error {
- if l.gpd.Device == nil {
- return errors.New("virtual gamepad is disabled")
- }
- code, ok := linuxinput.ToGamepadCode(name)
- if !ok {
- return fmt.Errorf("unknown button: %s", name)
- }
- if err := l.gpd.Press(code); err != nil {
- return fmt.Errorf("failed to press gamepad button %s: %w", name, err)
- }
- return nil
+ return l.pressGamepadToken(name)
+}
+
+// GamepadPressSequence sends a request-scoped gamepad sequence. Explicit press
+// tokens are always released before this method returns.
+func (l *LinuxInput) GamepadPressSequence(args []string, interKeyDelay time.Duration) error {
+ return l.pressGamepadSequence(args, interKeyDelay)
}
diff --git a/pkg/platforms/shared/linuxinput_session.go b/pkg/platforms/shared/linuxinput_session.go
new file mode 100644
index 000000000..f91a6d220
--- /dev/null
+++ b/pkg/platforms/shared/linuxinput_session.go
@@ -0,0 +1,915 @@
+//go:build linux
+
+// Zaparoo Core
+// Copyright (c) 2026 The Zaparoo Project Contributors.
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package shared
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers/inputmacro"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers/linuxinput"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers/linuxinput/keyboardmap"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms"
+ "github.com/rs/zerolog/log"
+)
+
+type inputMacroAction = inputmacro.Action
+
+const (
+ inputMacroNone = inputmacro.ActionNone
+ inputMacroDelay = inputmacro.ActionDelay
+ inputMacroPress = inputmacro.ActionPress
+ inputMacroRelease = inputmacro.ActionRelease
+ inputMacroHold = inputmacro.ActionHold
+ maxInputMacroDuration = 30 * time.Second
+)
+
+type parsedInputMacro struct {
+ name string
+ duration string
+ action inputMacroAction
+}
+
+type heldInputState struct {
+ keyboard map[int]struct{}
+ gamepad map[int]struct{}
+}
+
+type linuxInputSession struct {
+ input *LinuxInput
+ closed bool
+}
+
+// NewInputSession creates an isolated owner for input held across API requests.
+func (l *LinuxInput) NewInputSession() platforms.InputSession {
+ return &linuxInputSession{input: l}
+}
+
+func (s *linuxInputSession) KeyboardPressSequence(
+ ctx context.Context,
+ args []string,
+ interKeyDelay time.Duration,
+) error {
+ s.input.sequenceMu.Lock()
+ defer s.input.sequenceMu.Unlock()
+
+ if s.inputSessionClosed() {
+ return errors.New("input session is closed")
+ }
+
+ err := s.input.keyboardPressSequenceLocked(ctx, args, interKeyDelay, s)
+ if err == nil {
+ return nil
+ }
+ return errors.Join(err, s.input.releaseInputSession(s))
+}
+
+func (s *linuxInputSession) GamepadPressSequence(
+ ctx context.Context,
+ args []string,
+ interKeyDelay time.Duration,
+) error {
+ s.input.sequenceMu.Lock()
+ defer s.input.sequenceMu.Unlock()
+
+ if s.inputSessionClosed() {
+ return errors.New("input session is closed")
+ }
+
+ err := s.input.gamepadPressSequenceLocked(ctx, args, interKeyDelay, s)
+ if err == nil {
+ return nil
+ }
+ return errors.Join(err, s.input.releaseInputSession(s))
+}
+
+func (s *linuxInputSession) inputSessionClosed() bool {
+ s.input.inputMu.Lock()
+ defer s.input.inputMu.Unlock()
+ return s.closed
+}
+
+func (s *linuxInputSession) ReleaseAll() error {
+ s.input.inputMu.Lock()
+ defer s.input.inputMu.Unlock()
+
+ s.closed = true
+ return s.input.releaseInputSessionLocked(s)
+}
+
+func parseInputMacroToken(token string) (parsedInputMacro, bool) {
+ control := inputmacro.ClassifyControl(token)
+ switch control.Action {
+ case inputMacroNone:
+ return parsedInputMacro{}, false
+ case inputMacroDelay:
+ return parsedInputMacro{action: control.Action, duration: control.Value}, true
+ case inputMacroPress, inputMacroRelease:
+ return parsedInputMacro{action: control.Action, name: control.Value}, true
+ case inputMacroHold:
+ name, duration := splitInputHold(control.Value)
+ return parsedInputMacro{action: control.Action, name: name, duration: duration}, true
+ default:
+ return parsedInputMacro{}, false
+ }
+}
+
+func splitInputHold(value string) (name, duration string) {
+ if idx := strings.LastIndex(value, ":"); idx != -1 {
+ return value[:idx], value[idx+1:]
+ }
+ return value, ""
+}
+
+func parseBoundedInputMacroDuration(value string) (time.Duration, error) {
+ duration, err := parseMacroDuration(value)
+ if err != nil {
+ return 0, err
+ }
+ if duration < 0 || duration > maxInputMacroDuration {
+ return 0, fmt.Errorf("duration %q must be between 0 and %s", value, maxInputMacroDuration)
+ }
+ return duration, nil
+}
+
+func sleepInputContext(ctx context.Context, duration time.Duration) error {
+ if duration <= 0 {
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ default:
+ return nil
+ }
+ }
+
+ timer := time.NewTimer(duration)
+ defer timer.Stop()
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-timer.C:
+ return nil
+ }
+}
+
+func (l *LinuxInput) ensureInputStateLocked() {
+ if l.inputSessions == nil {
+ l.inputSessions = make(map[*linuxInputSession]*heldInputState)
+ }
+ if l.keyboardRefs == nil {
+ l.keyboardRefs = make(map[int]int)
+ }
+ if l.gamepadRefs == nil {
+ l.gamepadRefs = make(map[int]int)
+ }
+}
+
+func (l *LinuxInput) sessionStateLocked(session *linuxInputSession) *heldInputState {
+ l.ensureInputStateLocked()
+ state, ok := l.inputSessions[session]
+ if !ok {
+ state = &heldInputState{
+ keyboard: make(map[int]struct{}),
+ gamepad: make(map[int]struct{}),
+ }
+ l.inputSessions[session] = state
+ }
+ return state
+}
+
+func (l *LinuxInput) keyboardDownLocked(code int) error {
+ l.ensureInputStateLocked()
+ if l.keyboardRefs[code] > 0 {
+ l.keyboardRefs[code]++
+ return nil
+ }
+ if l.kbd.Device == nil {
+ return errors.New("virtual keyboard is disabled")
+ }
+ if err := l.kbd.Device.KeyDown(code); err != nil {
+ return fmt.Errorf("key down %d: %w", code, err)
+ }
+ l.keyboardRefs[code] = 1
+ return nil
+}
+
+func (l *LinuxInput) keyboardUpLocked(code int) error {
+ count := l.keyboardRefs[code]
+ if count == 0 {
+ return nil
+ }
+ if count > 1 {
+ l.keyboardRefs[code] = count - 1
+ return nil
+ }
+ if l.kbd.Device == nil {
+ return errors.New("virtual keyboard is disabled")
+ }
+ if err := l.kbd.Device.KeyUp(code); err != nil {
+ return fmt.Errorf("key up %d: %w", code, err)
+ }
+ delete(l.keyboardRefs, code)
+ return nil
+}
+
+func (l *LinuxInput) gamepadDownLocked(code int) error {
+ l.ensureInputStateLocked()
+ if l.gamepadRefs[code] > 0 {
+ l.gamepadRefs[code]++
+ return nil
+ }
+ if l.gpd.Device == nil {
+ return errors.New("virtual gamepad is disabled")
+ }
+ if err := l.gpd.Device.ButtonDown(code); err != nil {
+ return fmt.Errorf("button down %d: %w", code, err)
+ }
+ l.gamepadRefs[code] = 1
+ return nil
+}
+
+func (l *LinuxInput) gamepadUpLocked(code int) error {
+ count := l.gamepadRefs[code]
+ if count == 0 {
+ return nil
+ }
+ if count > 1 {
+ l.gamepadRefs[code] = count - 1
+ return nil
+ }
+ if l.gpd.Device == nil {
+ return errors.New("virtual gamepad is disabled")
+ }
+ if err := l.gpd.Device.ButtonUp(code); err != nil {
+ return fmt.Errorf("button up %d: %w", code, err)
+ }
+ delete(l.gamepadRefs, code)
+ return nil
+}
+
+func (l *LinuxInput) sessionKeyboardDownLocked(session *linuxInputSession, code int) error {
+ if session.closed {
+ return errors.New("input session is closed")
+ }
+ state := l.sessionStateLocked(session)
+ if _, ok := state.keyboard[code]; ok {
+ return nil
+ }
+ if err := l.keyboardDownLocked(code); err != nil {
+ return err
+ }
+ state.keyboard[code] = struct{}{}
+ return nil
+}
+
+func (l *LinuxInput) sessionKeyboardUpLocked(session *linuxInputSession, code int) error {
+ if session.closed {
+ return errors.New("input session is closed")
+ }
+ state, ok := l.inputSessions[session]
+ if !ok {
+ return nil
+ }
+ if _, ok := state.keyboard[code]; !ok {
+ return nil
+ }
+ if err := l.keyboardUpLocked(code); err != nil {
+ return err
+ }
+ delete(state.keyboard, code)
+ l.removeEmptySessionStateLocked(session, state)
+ return nil
+}
+
+func (l *LinuxInput) sessionGamepadDownLocked(session *linuxInputSession, code int) error {
+ if session.closed {
+ return errors.New("input session is closed")
+ }
+ state := l.sessionStateLocked(session)
+ if _, ok := state.gamepad[code]; ok {
+ return nil
+ }
+ if err := l.gamepadDownLocked(code); err != nil {
+ return err
+ }
+ state.gamepad[code] = struct{}{}
+ return nil
+}
+
+func (l *LinuxInput) sessionGamepadUpLocked(session *linuxInputSession, code int) error {
+ if session.closed {
+ return errors.New("input session is closed")
+ }
+ state, ok := l.inputSessions[session]
+ if !ok {
+ return nil
+ }
+ if _, ok := state.gamepad[code]; !ok {
+ return nil
+ }
+ if err := l.gamepadUpLocked(code); err != nil {
+ return err
+ }
+ delete(state.gamepad, code)
+ l.removeEmptySessionStateLocked(session, state)
+ return nil
+}
+
+func (l *LinuxInput) removeEmptySessionStateLocked(session *linuxInputSession, state *heldInputState) {
+ if len(state.keyboard) == 0 && len(state.gamepad) == 0 {
+ delete(l.inputSessions, session)
+ }
+}
+
+func (l *LinuxInput) releaseInputSessionLocked(session *linuxInputSession) error {
+ state, ok := l.inputSessions[session]
+ if !ok {
+ return nil
+ }
+
+ var cleanupErr error
+ for code := range state.keyboard {
+ if err := l.keyboardUpLocked(code); err != nil {
+ cleanupErr = errors.Join(cleanupErr, err)
+ continue
+ }
+ delete(state.keyboard, code)
+ }
+ for code := range state.gamepad {
+ if err := l.gamepadUpLocked(code); err != nil {
+ cleanupErr = errors.Join(cleanupErr, err)
+ continue
+ }
+ delete(state.gamepad, code)
+ }
+ l.removeEmptySessionStateLocked(session, state)
+ return cleanupErr
+}
+
+func (l *LinuxInput) releaseInputSession(session *linuxInputSession) error {
+ l.inputMu.Lock()
+ defer l.inputMu.Unlock()
+ return l.releaseInputSessionLocked(session)
+}
+
+func (l *LinuxInput) keyboardLocalDown(held map[int]int, code int) error {
+ l.inputMu.Lock()
+ defer l.inputMu.Unlock()
+ return l.keyboardLocalDownLocked(held, code)
+}
+
+func (l *LinuxInput) keyboardLocalUp(held map[int]int, code int) error {
+ l.inputMu.Lock()
+ defer l.inputMu.Unlock()
+ return l.keyboardLocalUpLocked(held, code)
+}
+
+func (l *LinuxInput) gamepadLocalDown(held map[int]int, code int) error {
+ l.inputMu.Lock()
+ defer l.inputMu.Unlock()
+ return l.gamepadLocalDownLocked(held, code)
+}
+
+func (l *LinuxInput) gamepadLocalUp(held map[int]int, code int) error {
+ l.inputMu.Lock()
+ defer l.inputMu.Unlock()
+ return l.gamepadLocalUpLocked(held, code)
+}
+
+func (l *LinuxInput) releaseKeyboardLocals(held map[int]int) error {
+ l.inputMu.Lock()
+ defer l.inputMu.Unlock()
+ return l.releaseKeyboardLocalsLocked(held)
+}
+
+func (l *LinuxInput) releaseGamepadLocals(held map[int]int) error {
+ l.inputMu.Lock()
+ defer l.inputMu.Unlock()
+ return l.releaseGamepadLocalsLocked(held)
+}
+
+func (l *LinuxInput) sessionKeyboardDown(session *linuxInputSession, code int) error {
+ l.inputMu.Lock()
+ defer l.inputMu.Unlock()
+ return l.sessionKeyboardDownLocked(session, code)
+}
+
+func (l *LinuxInput) sessionKeyboardUp(session *linuxInputSession, code int) error {
+ l.inputMu.Lock()
+ defer l.inputMu.Unlock()
+ return l.sessionKeyboardUpLocked(session, code)
+}
+
+func (l *LinuxInput) sessionGamepadDown(session *linuxInputSession, code int) error {
+ l.inputMu.Lock()
+ defer l.inputMu.Unlock()
+ return l.sessionGamepadDownLocked(session, code)
+}
+
+func (l *LinuxInput) sessionGamepadUp(session *linuxInputSession, code int) error {
+ l.inputMu.Lock()
+ defer l.inputMu.Unlock()
+ return l.sessionGamepadUpLocked(session, code)
+}
+
+func (l *LinuxInput) keyboardDeviceEnabled() bool {
+ l.inputMu.Lock()
+ defer l.inputMu.Unlock()
+ return l.kbd.Device != nil
+}
+
+func (l *LinuxInput) gamepadDeviceEnabled() bool {
+ l.inputMu.Lock()
+ defer l.inputMu.Unlock()
+ return l.gpd.Device != nil
+}
+
+func (l *LinuxInput) keyboardLocalDownLocked(held map[int]int, code int) error {
+ if err := l.keyboardDownLocked(code); err != nil {
+ return err
+ }
+ held[code]++
+ return nil
+}
+
+func (l *LinuxInput) keyboardLocalUpLocked(held map[int]int, code int) error {
+ if held[code] == 0 {
+ return nil
+ }
+ if err := l.keyboardUpLocked(code); err != nil {
+ return err
+ }
+ if held[code] == 1 {
+ delete(held, code)
+ } else {
+ held[code]--
+ }
+ return nil
+}
+
+func (l *LinuxInput) gamepadLocalDownLocked(held map[int]int, code int) error {
+ if err := l.gamepadDownLocked(code); err != nil {
+ return err
+ }
+ held[code]++
+ return nil
+}
+
+func (l *LinuxInput) gamepadLocalUpLocked(held map[int]int, code int) error {
+ if held[code] == 0 {
+ return nil
+ }
+ if err := l.gamepadUpLocked(code); err != nil {
+ return err
+ }
+ if held[code] == 1 {
+ delete(held, code)
+ } else {
+ held[code]--
+ }
+ return nil
+}
+
+func (l *LinuxInput) releaseKeyboardLocalsLocked(held map[int]int) error {
+ var cleanupErr error
+ for code, count := range held {
+ for range count {
+ if err := l.keyboardUpLocked(code); err != nil {
+ cleanupErr = errors.Join(cleanupErr, err)
+ break
+ }
+ }
+ }
+ return cleanupErr
+}
+
+func (l *LinuxInput) releaseGamepadLocalsLocked(held map[int]int) error {
+ var cleanupErr error
+ for code, count := range held {
+ for range count {
+ if err := l.gamepadUpLocked(code); err != nil {
+ cleanupErr = errors.Join(cleanupErr, err)
+ break
+ }
+ }
+ }
+ return cleanupErr
+}
+
+func (l *LinuxInput) pressKeyboardToken(arg string) (retErr error) {
+ codes, isCombo, err := linuxinput.ParseKeyCombo(arg)
+ if err != nil {
+ return fmt.Errorf("failed to parse key combo: %w", err)
+ }
+ if !isCombo && codes[0] < 0 {
+ codes = []int{42, -codes[0]}
+ isCombo = true
+ }
+
+ l.sequenceMu.Lock()
+ defer l.sequenceMu.Unlock()
+ if !l.keyboardDeviceEnabled() {
+ return errors.New("virtual keyboard is disabled")
+ }
+
+ held := make(map[int]int)
+ var pressErr error
+ defer func() {
+ retErr = errors.Join(retErr, l.releaseKeyboardLocals(held))
+ }()
+ for _, code := range codes {
+ if err := l.keyboardLocalDown(held, code); err != nil {
+ pressErr = err
+ break
+ }
+ }
+ if pressErr == nil {
+ pressErr = sleepInputContext(context.Background(), l.kbd.Delay)
+ }
+ if pressErr == nil {
+ for i := len(codes) - 1; i >= 0; i-- {
+ if err := l.keyboardLocalUp(held, codes[i]); err != nil {
+ pressErr = err
+ break
+ }
+ }
+ }
+ if pressErr != nil {
+ if isCombo {
+ return fmt.Errorf("failed to press keyboard combo: %w", pressErr)
+ }
+ return fmt.Errorf("failed to press keyboard key: %w", pressErr)
+ }
+ return nil
+}
+
+func (l *LinuxInput) pressGamepadToken(name string) (retErr error) {
+ l.sequenceMu.Lock()
+ defer l.sequenceMu.Unlock()
+ if !l.gamepadDeviceEnabled() {
+ return errors.New("virtual gamepad is disabled")
+ }
+ code, ok := linuxinput.ToGamepadCode(name)
+ if !ok {
+ return fmt.Errorf("unknown button: %s", name)
+ }
+
+ held := make(map[int]int)
+ defer func() {
+ retErr = errors.Join(retErr, l.releaseGamepadLocals(held))
+ }()
+ if err := l.gamepadLocalDown(held, code); err != nil {
+ return fmt.Errorf("failed to press gamepad button %s: %w", name, err)
+ }
+ if err := sleepInputContext(context.Background(), l.gpd.Delay); err != nil {
+ return fmt.Errorf("failed to press gamepad button %s: %w", name, err)
+ }
+ if err := l.gamepadLocalUp(held, code); err != nil {
+ return fmt.Errorf("failed to press gamepad button %s: %w", name, err)
+ }
+ return nil
+}
+
+func (l *LinuxInput) pressKeyboardSequence(args []string, interKeyDelay time.Duration) error {
+ l.sequenceMu.Lock()
+ defer l.sequenceMu.Unlock()
+ return l.keyboardPressSequenceLocked(context.Background(), args, interKeyDelay, nil)
+}
+
+func (l *LinuxInput) pressGamepadSequence(args []string, interKeyDelay time.Duration) error {
+ l.sequenceMu.Lock()
+ defer l.sequenceMu.Unlock()
+ return l.gamepadPressSequenceLocked(context.Background(), args, interKeyDelay, nil)
+}
+
+func (l *LinuxInput) keyboardPressSequenceLocked(
+ ctx context.Context,
+ args []string,
+ interKeyDelay time.Duration,
+ session *linuxInputSession,
+) (retErr error) {
+ if !l.keyboardDeviceEnabled() {
+ return errors.New("virtual keyboard is disabled")
+ }
+ if interKeyDelay == 0 {
+ interKeyDelay = DefaultInterKeyDelay
+ }
+
+ const shiftCode = 42
+ localHeld := make(map[int]int)
+ defer func() {
+ retErr = errors.Join(retErr, l.releaseKeyboardLocals(localHeld))
+ }()
+
+ localDown := func(code int) error {
+ return l.keyboardLocalDown(localHeld, code)
+ }
+ localUp := func(code int) error {
+ return l.keyboardLocalUp(localHeld, code)
+ }
+ persistentDown := func(code int) error {
+ if session == nil {
+ return localDown(code)
+ }
+ return l.sessionKeyboardDown(session, code)
+ }
+ persistentUp := func(code int) error {
+ if session == nil {
+ return localUp(code)
+ }
+ return l.sessionKeyboardUp(session, code)
+ }
+
+ for i := 0; i < len(args); {
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ token := args[i]
+ if macro, ok := parseInputMacroToken(token); ok {
+ switch macro.action {
+ case inputMacroNone:
+ return fmt.Errorf("unsupported input macro token %q", token)
+ case inputMacroDelay:
+ duration, err := parseBoundedInputMacroDuration(macro.duration)
+ if err != nil {
+ return fmt.Errorf("invalid delay token %q: %w", token, err)
+ }
+ if err := sleepInputContext(ctx, duration); err != nil {
+ return fmt.Errorf("delay token %q: %w", token, err)
+ }
+ case inputMacroPress, inputMacroRelease, inputMacroHold:
+ code, err := resolveHoldKeyCode(macro.name)
+ if err != nil {
+ return fmt.Errorf("token %q: %w", token, err)
+ }
+ switch macro.action {
+ case inputMacroNone, inputMacroDelay:
+ return fmt.Errorf("unsupported input macro token %q", token)
+ case inputMacroPress:
+ if downErr := persistentDown(code); downErr != nil {
+ return fmt.Errorf("token %q: %w", token, downErr)
+ }
+ case inputMacroRelease:
+ if upErr := persistentUp(code); upErr != nil {
+ return fmt.Errorf("token %q: %w", token, upErr)
+ }
+ case inputMacroHold:
+ holdDuration := l.kbd.Delay
+ if macro.duration != "" {
+ holdDuration, err = parseBoundedInputMacroDuration(macro.duration)
+ if err != nil {
+ return fmt.Errorf("invalid hold duration in %q: %w", token, err)
+ }
+ }
+ if err := localDown(code); err != nil {
+ return fmt.Errorf("token %q key down: %w", token, err)
+ }
+ if err := sleepInputContext(ctx, holdDuration); err != nil {
+ return fmt.Errorf("token %q hold: %w", token, err)
+ }
+ if err := localUp(code); err != nil {
+ return fmt.Errorf("token %q key up: %w", token, err)
+ }
+ }
+ }
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ i++
+ continue
+ }
+
+ if baseCode, ok := keyboardmap.IsShiftedKey(token); ok {
+ end := i + 1
+ for end < len(args) {
+ if _, shifted := keyboardmap.IsShiftedKey(args[end]); !shifted {
+ break
+ }
+ end++
+ }
+ if err := localDown(shiftCode); err != nil {
+ return fmt.Errorf("failed to press shift down: %w", err)
+ }
+ for j := i; j < end; j++ {
+ if j > i {
+ baseCode, _ = keyboardmap.IsShiftedKey(args[j])
+ }
+ if err := localDown(baseCode); err != nil {
+ return fmt.Errorf("failed to press shifted key %q down: %w", args[j], err)
+ }
+ if err := sleepInputContext(ctx, l.kbd.Delay); err != nil {
+ return fmt.Errorf("press shifted key %q: %w", args[j], err)
+ }
+ if err := localUp(baseCode); err != nil {
+ return fmt.Errorf("failed to release shifted key %q: %w", args[j], err)
+ }
+ if err := sleepInputContext(ctx, interKeyDelay); err != nil {
+ return fmt.Errorf("inter-key delay: %w", err)
+ }
+ }
+ if err := localUp(shiftCode); err != nil {
+ return fmt.Errorf("failed to release shift: %w", err)
+ }
+ i = end
+ continue
+ }
+
+ codes, _, err := linuxinput.ParseKeyCombo(token)
+ if err != nil {
+ return fmt.Errorf("failed to parse key %q: %w", token, err)
+ }
+ for _, code := range codes {
+ if err := localDown(code); err != nil {
+ return fmt.Errorf("failed to press key %q down: %w", token, err)
+ }
+ }
+ if err := sleepInputContext(ctx, l.kbd.Delay); err != nil {
+ return fmt.Errorf("press key %q: %w", token, err)
+ }
+ for j := len(codes) - 1; j >= 0; j-- {
+ if err := localUp(codes[j]); err != nil {
+ return fmt.Errorf("failed to release key %q: %w", token, err)
+ }
+ }
+ if err := sleepInputContext(ctx, interKeyDelay); err != nil {
+ return fmt.Errorf("inter-key delay: %w", err)
+ }
+ i++
+ }
+ return nil
+}
+
+func resolveGamepadHoldCode(name string) (int, error) {
+ arg := name
+ if len([]rune(name)) > 1 {
+ arg = "{" + name + "}"
+ }
+ code, ok := linuxinput.ToGamepadCode(arg)
+ if !ok {
+ return 0, fmt.Errorf("unknown button: %s", name)
+ }
+ return code, nil
+}
+
+func (l *LinuxInput) gamepadPressSequenceLocked(
+ ctx context.Context,
+ args []string,
+ interKeyDelay time.Duration,
+ session *linuxInputSession,
+) (retErr error) {
+ if !l.gamepadDeviceEnabled() {
+ return errors.New("virtual gamepad is disabled")
+ }
+ if interKeyDelay == 0 {
+ interKeyDelay = DefaultInterKeyDelay
+ }
+
+ localHeld := make(map[int]int)
+ defer func() {
+ retErr = errors.Join(retErr, l.releaseGamepadLocals(localHeld))
+ }()
+ localDown := func(code int) error {
+ return l.gamepadLocalDown(localHeld, code)
+ }
+ localUp := func(code int) error {
+ return l.gamepadLocalUp(localHeld, code)
+ }
+ persistentDown := func(code int) error {
+ if session == nil {
+ return localDown(code)
+ }
+ return l.sessionGamepadDown(session, code)
+ }
+ persistentUp := func(code int) error {
+ if session == nil {
+ return localUp(code)
+ }
+ return l.sessionGamepadUp(session, code)
+ }
+
+ for _, token := range args {
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ if macro, ok := parseInputMacroToken(token); ok {
+ switch macro.action {
+ case inputMacroNone:
+ return fmt.Errorf("unsupported input macro token %q", token)
+ case inputMacroDelay:
+ duration, err := parseBoundedInputMacroDuration(macro.duration)
+ if err != nil {
+ return fmt.Errorf("invalid delay token %q: %w", token, err)
+ }
+ if err := sleepInputContext(ctx, duration); err != nil {
+ return fmt.Errorf("delay token %q: %w", token, err)
+ }
+ case inputMacroPress, inputMacroRelease, inputMacroHold:
+ code, err := resolveGamepadHoldCode(macro.name)
+ if err != nil {
+ return fmt.Errorf("token %q: %w", token, err)
+ }
+ switch macro.action {
+ case inputMacroNone, inputMacroDelay:
+ return fmt.Errorf("unsupported input macro token %q", token)
+ case inputMacroPress:
+ if downErr := persistentDown(code); downErr != nil {
+ return fmt.Errorf("token %q: %w", token, downErr)
+ }
+ case inputMacroRelease:
+ if upErr := persistentUp(code); upErr != nil {
+ return fmt.Errorf("token %q: %w", token, upErr)
+ }
+ case inputMacroHold:
+ holdDuration := l.gpd.Delay
+ if macro.duration != "" {
+ holdDuration, err = parseBoundedInputMacroDuration(macro.duration)
+ if err != nil {
+ return fmt.Errorf("invalid hold duration in %q: %w", token, err)
+ }
+ }
+ if err := localDown(code); err != nil {
+ return fmt.Errorf("token %q button down: %w", token, err)
+ }
+ if err := sleepInputContext(ctx, holdDuration); err != nil {
+ return fmt.Errorf("token %q hold: %w", token, err)
+ }
+ if err := localUp(code); err != nil {
+ return fmt.Errorf("token %q button up: %w", token, err)
+ }
+ }
+ }
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ continue
+ }
+
+ code, ok := linuxinput.ToGamepadCode(token)
+ if !ok {
+ return fmt.Errorf("unknown button: %s", token)
+ }
+ if err := localDown(code); err != nil {
+ return fmt.Errorf("failed to press gamepad button %s: %w", token, err)
+ }
+ if err := sleepInputContext(ctx, l.gpd.Delay); err != nil {
+ return fmt.Errorf("press gamepad button %s: %w", token, err)
+ }
+ if err := localUp(code); err != nil {
+ return fmt.Errorf("failed to release gamepad button %s: %w", token, err)
+ }
+ if err := sleepInputContext(ctx, interKeyDelay); err != nil {
+ return fmt.Errorf("inter-button delay: %w", err)
+ }
+ }
+ return nil
+}
+
+func (l *LinuxInput) closeInputDevices() {
+ l.inputMu.Lock()
+ defer l.inputMu.Unlock()
+
+ for session := range l.inputSessions {
+ session.closed = true
+ if err := l.releaseInputSessionLocked(session); err != nil {
+ log.Warn().Err(err).Msg("error releasing input session during device shutdown")
+ }
+ }
+ for code := range l.keyboardRefs {
+ if l.kbd.Device != nil {
+ if err := l.kbd.Device.KeyUp(code); err != nil {
+ log.Warn().Err(err).Int("key_code", code).Msg("error releasing keyboard key during device shutdown")
+ }
+ }
+ delete(l.keyboardRefs, code)
+ }
+ for code := range l.gamepadRefs {
+ if l.gpd.Device != nil {
+ if err := l.gpd.Device.ButtonUp(code); err != nil {
+ log.Warn().Err(err).Int("button_code", code).
+ Msg("error releasing gamepad button during device shutdown")
+ }
+ }
+ delete(l.gamepadRefs, code)
+ }
+ clear(l.inputSessions)
+
+ if l.kbd.Device != nil {
+ if err := l.kbd.Close(); err != nil {
+ log.Warn().Err(err).Msg("error closing keyboard")
+ }
+ l.kbd.Device = nil
+ }
+ if l.gpd.Device != nil {
+ if err := l.gpd.Close(); err != nil {
+ log.Warn().Err(err).Msg("error closing gamepad")
+ }
+ l.gpd.Device = nil
+ }
+}
diff --git a/pkg/platforms/shared/linuxinput_session_test.go b/pkg/platforms/shared/linuxinput_session_test.go
new file mode 100644
index 000000000..3dbefcd61
--- /dev/null
+++ b/pkg/platforms/shared/linuxinput_session_test.go
@@ -0,0 +1,345 @@
+//go:build linux
+
+// Zaparoo Core
+// Copyright (c) 2026 The Zaparoo Project Contributors.
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package shared
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers/linuxinput"
+ "github.com/bendahl/uinput"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+type recordingGamepad struct {
+ events []keyEvent
+ closed bool
+}
+
+func (*recordingGamepad) ButtonPress(_ int) error { return nil }
+func (r *recordingGamepad) ButtonDown(code int) error {
+ r.events = append(r.events, keyEvent{kind: "down", code: code})
+ return nil
+}
+
+func (r *recordingGamepad) ButtonUp(code int) error {
+ r.events = append(r.events, keyEvent{kind: "up", code: code})
+ return nil
+}
+func (*recordingGamepad) LeftStickMoveX(_ float32) error { return nil }
+func (*recordingGamepad) LeftStickMoveY(_ float32) error { return nil }
+func (*recordingGamepad) RightStickMoveX(_ float32) error { return nil }
+func (*recordingGamepad) RightStickMoveY(_ float32) error { return nil }
+func (*recordingGamepad) LeftStickMove(_, _ float32) error { return nil }
+func (*recordingGamepad) RightStickMove(_, _ float32) error { return nil }
+func (*recordingGamepad) HatPress(_ uinput.HatDirection) error { return nil }
+func (*recordingGamepad) HatRelease(_ uinput.HatDirection) error { return nil }
+func (r *recordingGamepad) Close() error {
+ r.closed = true
+ return nil
+}
+
+func newRecordingInputDevices() (*LinuxInput, *recordingKeyboard, *recordingGamepad) {
+ keyboard := &recordingKeyboard{}
+ gamepad := &recordingGamepad{}
+ input := &LinuxInput{
+ kbd: linuxinput.Keyboard{Device: keyboard},
+ gpd: linuxinput.Gamepad{Device: gamepad},
+ }
+ return input, keyboard, gamepad
+}
+
+func TestInputSession_KeyboardPersistsAcrossRequests(t *testing.T) {
+ t.Parallel()
+
+ input, keyboard, _ := newRecordingInputDevices()
+ session := input.NewInputSession()
+
+ require.NoError(t, session.KeyboardPressSequence(t.Context(), []string{"{press:up}"}, 0))
+ assert.Equal(t, []keyEvent{{kind: "down", code: 103}}, keyboard.events)
+
+ require.NoError(t, session.KeyboardPressSequence(t.Context(), []string{"{release:up}"}, 0))
+ assert.Equal(t, []keyEvent{
+ {kind: "down", code: 103},
+ {kind: "up", code: 103},
+ }, keyboard.events)
+}
+
+func TestInputSession_KeyboardTracksMultipleHeldKeys(t *testing.T) {
+ t.Parallel()
+
+ input, keyboard, _ := newRecordingInputDevices()
+ session := input.NewInputSession()
+
+ require.NoError(t, session.KeyboardPressSequence(t.Context(), []string{
+ "{press:up}",
+ "{press:left}",
+ }, 0))
+ assert.ElementsMatch(t, []keyEvent{
+ {kind: "down", code: 103},
+ {kind: "down", code: 105},
+ }, keyboard.events)
+
+ require.NoError(t, session.ReleaseAll())
+ assert.Len(t, keyboard.events, 4)
+ assert.ElementsMatch(t, []keyEvent{
+ {kind: "up", code: 103},
+ {kind: "up", code: 105},
+ }, keyboard.events[2:])
+}
+
+func TestInputSession_KeyboardIsolationUsesReferenceCounts(t *testing.T) {
+ t.Parallel()
+
+ input, keyboard, _ := newRecordingInputDevices()
+ first := input.NewInputSession()
+ second := input.NewInputSession()
+
+ require.NoError(t, first.KeyboardPressSequence(t.Context(), []string{"{press:up}"}, 0))
+ require.NoError(t, second.KeyboardPressSequence(t.Context(), []string{"{release:up}"}, 0))
+ assert.Equal(t, []keyEvent{{kind: "down", code: 103}}, keyboard.events,
+ "one session must not release another session's key")
+
+ require.NoError(t, second.KeyboardPressSequence(t.Context(), []string{"{press:up}"}, 0))
+ require.NoError(t, second.KeyboardPressSequence(t.Context(), []string{"{release:up}"}, 0))
+ assert.Equal(t, []keyEvent{{kind: "down", code: 103}}, keyboard.events,
+ "physical key remains down while first session owns it")
+
+ require.NoError(t, first.KeyboardPressSequence(t.Context(), []string{"{release:up}"}, 0))
+ assert.Equal(t, []keyEvent{
+ {kind: "down", code: 103},
+ {kind: "up", code: 103},
+ }, keyboard.events)
+}
+
+func TestInputSession_RequestScopedPressDoesNotReleasePersistentKey(t *testing.T) {
+ t.Parallel()
+
+ input, keyboard, _ := newRecordingInputDevices()
+ session := input.NewInputSession()
+
+ require.NoError(t, session.KeyboardPressSequence(t.Context(), []string{"{press:up}"}, 0))
+ require.NoError(t, input.KeyboardPress("{up}"))
+ assert.Equal(t, []keyEvent{{kind: "down", code: 103}}, keyboard.events)
+
+ require.NoError(t, session.KeyboardPressSequence(t.Context(), []string{"{release:up}"}, 0))
+ assert.Equal(t, keyEvent{kind: "up", code: 103}, keyboard.events[1])
+}
+
+func TestInputSession_KeyboardErrorReleasesHeldInput(t *testing.T) {
+ t.Parallel()
+
+ input, keyboard, _ := newRecordingInputDevices()
+ session := input.NewInputSession()
+
+ err := session.KeyboardPressSequence(t.Context(), []string{
+ "{press:a}",
+ "{not-a-key}",
+ }, 0)
+ require.Error(t, err)
+ assert.Equal(t, []keyEvent{
+ {kind: "down", code: 30},
+ {kind: "up", code: 30},
+ }, keyboard.events)
+}
+
+func TestInputSession_CancellationReleasesHeldInput(t *testing.T) {
+ t.Parallel()
+
+ input, keyboard, _ := newRecordingInputDevices()
+ keyboard.keyDownSignal = make(chan int, 1)
+ session := input.NewInputSession()
+ ctx, cancel := context.WithCancel(t.Context())
+ errCh := make(chan error, 1)
+ go func() {
+ errCh <- session.KeyboardPressSequence(ctx, []string{
+ "{press:a}",
+ "{delay:30s}",
+ }, 0)
+ }()
+
+ assert.Equal(t, 30, <-keyboard.keyDownSignal)
+ cancel()
+ err := <-errCh
+ require.ErrorIs(t, err, context.Canceled)
+ assert.Equal(t, []keyEvent{
+ {kind: "down", code: 30},
+ {kind: "up", code: 30},
+ }, keyboard.events)
+}
+
+func TestInputSession_PreCanceledRequestDoesNotPressInput(t *testing.T) {
+ t.Parallel()
+
+ input, keyboard, _ := newRecordingInputDevices()
+ session := input.NewInputSession()
+ ctx, cancel := context.WithCancel(t.Context())
+ cancel()
+
+ err := session.KeyboardPressSequence(ctx, []string{"{press:a}"}, 0)
+ require.ErrorIs(t, err, context.Canceled)
+ assert.Empty(t, keyboard.events)
+}
+
+func TestInputSession_ReleaseNotBlockedByAnotherSessionDelay(t *testing.T) {
+ t.Parallel()
+
+ input, keyboard, _ := newRecordingInputDevices()
+ keyboard.keyDownSignal = make(chan int, 2)
+ first := input.NewInputSession()
+ second := input.NewInputSession()
+ require.NoError(t, first.KeyboardPressSequence(t.Context(), []string{"{press:up}"}, 0))
+ assert.Equal(t, 103, <-keyboard.keyDownSignal)
+
+ ctx, cancel := context.WithCancel(t.Context())
+ sequenceErr := make(chan error, 1)
+ go func() {
+ sequenceErr <- second.KeyboardPressSequence(ctx, []string{
+ "{press:b}",
+ "{delay:30s}",
+ }, 0)
+ }()
+ assert.Equal(t, 48, <-keyboard.keyDownSignal)
+
+ releaseDone := make(chan error, 1)
+ go func() {
+ releaseDone <- first.ReleaseAll()
+ }()
+ select {
+ case err := <-releaseDone:
+ require.NoError(t, err)
+ case <-time.After(250 * time.Millisecond):
+ t.Fatal("input release blocked behind another session's delay")
+ }
+
+ cancel()
+ require.ErrorIs(t, <-sequenceErr, context.Canceled)
+ assert.Contains(t, keyboard.events, keyEvent{kind: "up", code: 103})
+ assert.Contains(t, keyboard.events, keyEvent{kind: "up", code: 48})
+}
+
+func TestInputSession_ClosedSessionRetriesFailedRelease(t *testing.T) {
+ t.Parallel()
+
+ input, keyboard, _ := newRecordingInputDevices()
+ session := input.NewInputSession()
+ require.NoError(t, session.KeyboardPressSequence(t.Context(), []string{"{press:a}"}, 0))
+ keyboard.failUpOnCode = 30
+ keyboard.failUpOnce = true
+
+ require.Error(t, session.ReleaseAll())
+ require.NoError(t, session.ReleaseAll())
+ assert.Equal(t, []keyEvent{
+ {kind: "down", code: 30},
+ {kind: "up", code: 30},
+ }, keyboard.events)
+}
+
+func TestCloseDevicesNotBlockedByRequestScopedDelay(t *testing.T) {
+ t.Parallel()
+
+ input, keyboard, _ := newRecordingInputDevices()
+ keyboard.keyDownSignal = make(chan int, 1)
+ sequenceDone := make(chan error, 1)
+ go func() {
+ sequenceDone <- input.KeyboardPressSequence([]string{
+ "{press:a}",
+ "{delay:300ms}",
+ }, 0)
+ }()
+ assert.Equal(t, 30, <-keyboard.keyDownSignal)
+
+ closeDone := make(chan struct{})
+ go func() {
+ input.CloseDevices()
+ close(closeDone)
+ }()
+ select {
+ case <-closeDone:
+ case <-time.After(150 * time.Millisecond):
+ t.Fatal("device shutdown blocked behind request-scoped delay")
+ }
+
+ require.NoError(t, <-sequenceDone)
+ assert.True(t, keyboard.closed)
+ assert.Contains(t, keyboard.events, keyEvent{kind: "up", code: 30})
+}
+
+func TestParseBoundedInputMacroDuration(t *testing.T) {
+ t.Parallel()
+
+ duration, err := parseBoundedInputMacroDuration(maxInputMacroDuration.String())
+ require.NoError(t, err)
+ assert.Equal(t, maxInputMacroDuration, duration)
+
+ _, err = parseBoundedInputMacroDuration((maxInputMacroDuration + time.Millisecond).String())
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "must be between")
+
+ _, err = parseBoundedInputMacroDuration("-1ms")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "must be between")
+}
+
+func TestInputSequencesRejectExcessiveDurations(t *testing.T) {
+ t.Parallel()
+
+ input, keyboard, gamepad := newRecordingInputDevices()
+
+ err := input.KeyboardPressSequence([]string{"{delay:31s}"}, 0)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "invalid delay token")
+
+ err = input.KeyboardPressSequence([]string{"{hold:a:31s}"}, 0)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "invalid hold duration")
+ assert.Empty(t, keyboard.events)
+
+ err = input.GamepadPressSequence([]string{"{delay:31s}"}, 0)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "invalid delay token")
+
+ err = input.GamepadPressSequence([]string{"{hold:start:31s}"}, 0)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "invalid hold duration")
+ assert.Empty(t, gamepad.events)
+}
+
+func TestInputSession_GamepadPersistsAcrossRequests(t *testing.T) {
+ t.Parallel()
+
+ input, _, gamepad := newRecordingInputDevices()
+ session := input.NewInputSession()
+
+ require.NoError(t, session.GamepadPressSequence(t.Context(), []string{"{press:up}"}, 0))
+ require.Len(t, gamepad.events, 1)
+ assert.Equal(t, "down", gamepad.events[0].kind)
+
+ require.NoError(t, session.GamepadPressSequence(t.Context(), []string{"{release:up}"}, 0))
+ require.Len(t, gamepad.events, 2)
+ assert.Equal(t, gamepad.events[0].code, gamepad.events[1].code)
+ assert.Equal(t, "up", gamepad.events[1].kind)
+}
+
+func TestInputSession_CloseDevicesReleasesBeforeClosing(t *testing.T) {
+ t.Parallel()
+
+ input, keyboard, gamepad := newRecordingInputDevices()
+ session := input.NewInputSession()
+ require.NoError(t, session.KeyboardPressSequence(t.Context(), []string{"{press:up}"}, 0))
+ require.NoError(t, session.GamepadPressSequence(t.Context(), []string{"{press:start}"}, 0))
+
+ input.CloseDevices()
+
+ assert.Equal(t, "up", keyboard.events[len(keyboard.events)-1].kind)
+ assert.Equal(t, "up", gamepad.events[len(gamepad.events)-1].kind)
+ assert.True(t, keyboard.closed)
+ assert.True(t, gamepad.closed)
+ assert.Error(t, session.KeyboardPressSequence(t.Context(), []string{"{press:a}"}, 0))
+}
diff --git a/pkg/platforms/shared/linuxinput_test.go b/pkg/platforms/shared/linuxinput_test.go
index d926142ee..8f86669e1 100644
--- a/pkg/platforms/shared/linuxinput_test.go
+++ b/pkg/platforms/shared/linuxinput_test.go
@@ -292,10 +292,15 @@ type keyEvent struct {
// recordingKeyboard records all key events in order for assertion.
// If failOnCode is set, KeyDown returns an error when that code is pressed.
type recordingKeyboard struct {
- events []keyEvent
- failOnCode int // if non-zero, KeyDown returns error for this code
- failOnce bool // trigger failOnCode only once
- failed bool
+ keyDownSignal chan int
+ events []keyEvent
+ failOnCode int
+ failUpOnCode int
+ closed bool
+ failOnce bool
+ failed bool
+ failUpOnce bool
+ failedUp bool
}
func (r *recordingKeyboard) KeyPress(code int) error {
@@ -309,16 +314,29 @@ func (r *recordingKeyboard) KeyDown(code int) error {
return fmt.Errorf("injected KeyDown error for code %d", code)
}
r.events = append(r.events, keyEvent{"down", code})
+ if r.keyDownSignal != nil {
+ select {
+ case r.keyDownSignal <- code:
+ default:
+ }
+ }
return nil
}
func (r *recordingKeyboard) KeyUp(code int) error {
+ if r.failUpOnCode != 0 && code == r.failUpOnCode && (!r.failUpOnce || !r.failedUp) {
+ r.failedUp = true
+ return fmt.Errorf("injected KeyUp error for code %d", code)
+ }
r.events = append(r.events, keyEvent{"up", code})
return nil
}
func (*recordingKeyboard) FetchSyspath() (string, error) { return "", nil }
-func (*recordingKeyboard) Close() error { return nil }
+func (r *recordingKeyboard) Close() error {
+ r.closed = true
+ return nil
+}
// TestKeyboardPress_SingleKey tests successful single key press
func TestKeyboardPress_SingleKey(t *testing.T) {
diff --git a/pkg/zapscript/input.go b/pkg/zapscript/input.go
index 39735025d..cd1b56ea7 100644
--- a/pkg/zapscript/input.go
+++ b/pkg/zapscript/input.go
@@ -168,6 +168,10 @@ type keyboardSequencer interface {
KeyboardPressSequence(args []string, interKeyDelay time.Duration) error
}
+type gamepadSequencer interface {
+ GamepadPressSequence(args []string, interKeyDelay time.Duration) error
+}
+
// PressKeyboardSequence is shared between ZapScript commands and API handlers.
// interKeyDelay sets the gap between consecutive key presses; pass 0 to use
// the default (100 ms). If the platform implements keyboardSequencer, the full
@@ -198,6 +202,12 @@ func PressGamepadSequence(pl platforms.Platform, args []string, interKeyDelay ti
if interKeyDelay == 0 {
interKeyDelay = defaultInterKeyDelay
}
+ if gs, ok := pl.(gamepadSequencer); ok {
+ if err := gs.GamepadPressSequence(args, interKeyDelay); err != nil {
+ return fmt.Errorf("gamepad sequence: %w", err)
+ }
+ return nil
+ }
for _, name := range args {
if err := pl.GamepadPress(name); err != nil {
return fmt.Errorf("failed to press gamepad button '%s': %w", name, err)
diff --git a/pkg/zapscript/input_test.go b/pkg/zapscript/input_test.go
index 4c56fc0c5..69a9c00c8 100644
--- a/pkg/zapscript/input_test.go
+++ b/pkg/zapscript/input_test.go
@@ -64,6 +64,24 @@ func (p *sequenceMockPlatform) KeyboardPressSequence(args []string, interKeyDela
return p.recorder.sequenceErr
}
+type gamepadSequenceMockPlatform struct {
+ *mocks.MockPlatform
+ recorder *sequenceRecorder
+}
+
+func newGamepadSequenceMockPlatform() *gamepadSequenceMockPlatform {
+ return &gamepadSequenceMockPlatform{
+ MockPlatform: mocks.NewMockPlatform(),
+ recorder: &sequenceRecorder{},
+ }
+}
+
+func (p *gamepadSequenceMockPlatform) GamepadPressSequence(args []string, interKeyDelay time.Duration) error {
+ p.recorder.sequenceArgs = append([]string(nil), args...)
+ p.recorder.sequenceDelay = interKeyDelay
+ return p.recorder.sequenceErr
+}
+
func TestIsSpecialKey(t *testing.T) {
t.Parallel()
@@ -455,6 +473,32 @@ func TestPressKeyboardSequence_SequencerErrorIsWrapped(t *testing.T) {
assert.Contains(t, err.Error(), "device failed")
}
+func TestPressGamepadSequence_UsesPlatformSequencer(t *testing.T) {
+ t.Parallel()
+
+ mockPlatform := newGamepadSequenceMockPlatform()
+ args := []string{"{press:up}", "{release:up}"}
+
+ err := PressGamepadSequence(mockPlatform, args, 7*time.Millisecond)
+
+ require.NoError(t, err)
+ assert.Equal(t, args, mockPlatform.recorder.sequenceArgs)
+ assert.Equal(t, 7*time.Millisecond, mockPlatform.recorder.sequenceDelay)
+}
+
+func TestPressGamepadSequence_SequencerErrorIsWrapped(t *testing.T) {
+ t.Parallel()
+
+ mockPlatform := newGamepadSequenceMockPlatform()
+ mockPlatform.recorder.sequenceErr = errors.New("device failed")
+
+ err := PressGamepadSequence(mockPlatform, []string{"a"}, time.Nanosecond)
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "gamepad sequence")
+ assert.Contains(t, err.Error(), "device failed")
+}
+
func TestPressGamepadSequence_PressesButtons(t *testing.T) {
t.Parallel()