From db4d502fb528b7412789f918f8d14a7c4f8347bf Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Sat, 15 Aug 2026 09:14:48 +0800 Subject: [PATCH 1/2] fix(api): preserve held input across WebSocket requests --- docs/api/methods.md | 8 +- pkg/api/input_session_integration_test.go | 319 ++++++ pkg/api/methods/input.go | 41 +- pkg/api/methods/input_session_test.go | 132 +++ pkg/api/models/requests/requests.go | 5 +- pkg/api/request_priority.go | 5 + pkg/api/request_priority_test.go | 2 + pkg/api/server.go | 3 +- pkg/api/ws_dispatcher.go | 82 +- pkg/api/ws_dispatcher_test.go | 16 +- pkg/platforms/platforms.go | 16 + pkg/platforms/replayos/platform.go | 14 +- pkg/platforms/shared/linuxinput.go | 276 +----- pkg/platforms/shared/linuxinput_session.go | 913 ++++++++++++++++++ .../shared/linuxinput_session_test.go | 305 ++++++ pkg/platforms/shared/linuxinput_test.go | 28 +- pkg/zapscript/input.go | 10 + pkg/zapscript/input_test.go | 44 + 18 files changed, 1922 insertions(+), 297 deletions(-) create mode 100644 pkg/api/input_session_integration_test.go create mode 100644 pkg/api/methods/input_session_test.go create mode 100644 pkg/platforms/shared/linuxinput_session.go create mode 100644 pkg/platforms/shared/linuxinput_session_test.go diff --git a/docs/api/methods.md b/docs/api/methods.md index 9c711c0bc..9b9e920f1 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}`. + +Persistent `{press:key}` and `{release:key}` input is available only over WebSocket. 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..aa5eaf0b9 100644 --- a/pkg/api/methods/input.go +++ b/pkg/api/methods/input.go @@ -21,6 +21,7 @@ package methods import ( "fmt" + "strings" zapscriptlib "github.com/ZaparooProject/go-zapscript" "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models" @@ -30,6 +31,20 @@ import ( "github.com/rs/zerolog/log" ) +func hasPersistentInputTokens(args []string) bool { + for _, token := range args { + if len(token) <= 2 || token[0] != '{' || token[len(token)-1] != '}' { + continue + } + inner := token[1 : len(token)-1] + if strings.HasPrefix(inner, "press:") || strings.HasPrefix(inner, "release:") || + (len(inner) > 1 && (inner[0] == '_' || inner[0] == '^')) { + return true + } + } + return false +} + func parseInputMacro(cmdName, macro string) ([]string, error) { script, err := zapscriptlib.NewParser("**" + cmdName + ":" + macro).ParseScript() if err != nil { @@ -55,8 +70,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 +100,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..dcd48da0d 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" ) @@ -106,16 +108,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 +133,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 +167,30 @@ 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) + if d.inputDone != nil { + <-d.inputDone + } + // Retry after the input worker exits in case its final cleanup raced + // the first release or a device release initially failed. + d.releaseInputSession() + d.drainQueuedResponses() + }) +} + +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) { @@ -196,6 +232,10 @@ func (d *wsSessionDispatcher) start() { 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 +246,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..6c2eeed72 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"), 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..077236ceb --- /dev/null +++ b/pkg/platforms/shared/linuxinput_session.go @@ -0,0 +1,913 @@ +//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/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 uint8 + +const ( + inputMacroNone inputMacroAction = iota + inputMacroDelay + inputMacroPress + inputMacroRelease + inputMacroHold +) + +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) { + if len(token) <= 2 || token[0] != '{' || token[len(token)-1] != '}' { + return parsedInputMacro{}, false + } + + inner := token[1 : len(token)-1] + switch { + case strings.HasPrefix(inner, "delay:"): + return parsedInputMacro{action: inputMacroDelay, duration: inner[len("delay:"):]}, true + case strings.HasPrefix(inner, "press:"): + return parsedInputMacro{action: inputMacroPress, name: inner[len("press:"):]}, true + case strings.HasPrefix(inner, "release:"): + return parsedInputMacro{action: inputMacroRelease, name: inner[len("release:"):]}, true + case strings.HasPrefix(inner, "hold:"): + name, duration := splitInputHold(inner[len("hold:"):]) + return parsedInputMacro{action: inputMacroHold, name: name, duration: duration}, true + case len(inner) > 1 && inner[0] == '_': + return parsedInputMacro{action: inputMacroPress, name: inner[1:]}, true + case len(inner) > 1 && inner[0] == '^': + return parsedInputMacro{action: inputMacroRelease, name: inner[1:]}, true + case len(inner) > 1 && inner[0] == '~': + name, duration := splitInputHold(inner[1:]) + return parsedInputMacro{action: inputMacroHold, 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 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 := parseMacroDuration(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 = parseMacroDuration(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 := parseMacroDuration(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 = parseMacroDuration(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..6c3a6f9b1 --- /dev/null +++ b/pkg/platforms/shared/linuxinput_session_test.go @@ -0,0 +1,305 @@ +//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:1h}", + }, 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:1h}", + }, 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 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() From 98bcf3eb0baab991a9cc17fa7fd4e099a6da220d Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Sat, 15 Aug 2026 09:46:23 +0800 Subject: [PATCH 2/2] fix(api): harden persistent input lifecycle --- docs/api/methods.md | 4 +- pkg/api/methods/input.go | 10 +-- pkg/api/ws_dispatcher.go | 41 +++++++---- pkg/api/ws_dispatcher_test.go | 29 ++++++++ pkg/helpers/inputmacro/control.go | 68 +++++++++++++++++++ pkg/helpers/inputmacro/control_test.go | 54 +++++++++++++++ pkg/platforms/shared/linuxinput_session.go | 64 ++++++++--------- .../shared/linuxinput_session_test.go | 44 +++++++++++- 8 files changed, 260 insertions(+), 54 deletions(-) create mode 100644 pkg/helpers/inputmacro/control.go create mode 100644 pkg/helpers/inputmacro/control_test.go diff --git a/docs/api/methods.md b/docs/api/methods.md index 9b9e920f1..78fe36b30 100644 --- a/docs/api/methods.md +++ b/docs/api/methods.md @@ -4813,9 +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. Macros also support `{delay:duration}`, `{hold:key:duration}`, `{press:key}`, and `{release:key}`. Press and release have short forms `{_key}` and `{^key}`. +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 WebSocket. 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. +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 diff --git a/pkg/api/methods/input.go b/pkg/api/methods/input.go index aa5eaf0b9..9582592e1 100644 --- a/pkg/api/methods/input.go +++ b/pkg/api/methods/input.go @@ -21,24 +21,20 @@ package methods import ( "fmt" - "strings" zapscriptlib "github.com/ZaparooProject/go-zapscript" "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 { - if len(token) <= 2 || token[0] != '{' || token[len(token)-1] != '}' { - continue - } - inner := token[1 : len(token)-1] - if strings.HasPrefix(inner, "press:") || strings.HasPrefix(inner, "release:") || - (len(inner) > 1 && (inner[0] == '_' || inner[0] == '^')) { + action := inputmacro.ClassifyControl(token).Action + if action == inputmacro.ActionPress || action == inputmacro.ActionRelease { return true } } diff --git a/pkg/api/ws_dispatcher.go b/pkg/api/ws_dispatcher.go index dcd48da0d..2afae5660 100644 --- a/pkg/api/ws_dispatcher.go +++ b/pkg/api/ws_dispatcher.go @@ -38,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 ( @@ -174,16 +175,29 @@ func (d *wsSessionDispatcher) close() { d.drainQueuedJobs(d.normal) d.drainQueuedJobs(d.input) d.drainQueuedJobs(d.low) - if d.inputDone != nil { - <-d.inputDone - } - // Retry after the input worker exits in case its final cleanup raced - // the first release or a device release initially failed. + 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 @@ -226,6 +240,9 @@ 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) } diff --git a/pkg/api/ws_dispatcher_test.go b/pkg/api/ws_dispatcher_test.go index 6c2eeed72..60d16ad8c 100644 --- a/pkg/api/ws_dispatcher_test.go +++ b/pkg/api/ws_dispatcher_test.go @@ -644,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/shared/linuxinput_session.go b/pkg/platforms/shared/linuxinput_session.go index 077236ceb..f91a6d220 100644 --- a/pkg/platforms/shared/linuxinput_session.go +++ b/pkg/platforms/shared/linuxinput_session.go @@ -13,20 +13,22 @@ import ( "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 uint8 +type inputMacroAction = inputmacro.Action const ( - inputMacroNone inputMacroAction = iota - inputMacroDelay - inputMacroPress - inputMacroRelease - inputMacroHold + inputMacroNone = inputmacro.ActionNone + inputMacroDelay = inputmacro.ActionDelay + inputMacroPress = inputmacro.ActionPress + inputMacroRelease = inputmacro.ActionRelease + inputMacroHold = inputmacro.ActionHold + maxInputMacroDuration = 30 * time.Second ) type parsedInputMacro struct { @@ -103,28 +105,17 @@ func (s *linuxInputSession) ReleaseAll() error { } func parseInputMacroToken(token string) (parsedInputMacro, bool) { - if len(token) <= 2 || token[0] != '{' || token[len(token)-1] != '}' { + control := inputmacro.ClassifyControl(token) + switch control.Action { + case inputMacroNone: return parsedInputMacro{}, false - } - - inner := token[1 : len(token)-1] - switch { - case strings.HasPrefix(inner, "delay:"): - return parsedInputMacro{action: inputMacroDelay, duration: inner[len("delay:"):]}, true - case strings.HasPrefix(inner, "press:"): - return parsedInputMacro{action: inputMacroPress, name: inner[len("press:"):]}, true - case strings.HasPrefix(inner, "release:"): - return parsedInputMacro{action: inputMacroRelease, name: inner[len("release:"):]}, true - case strings.HasPrefix(inner, "hold:"): - name, duration := splitInputHold(inner[len("hold:"):]) - return parsedInputMacro{action: inputMacroHold, name: name, duration: duration}, true - case len(inner) > 1 && inner[0] == '_': - return parsedInputMacro{action: inputMacroPress, name: inner[1:]}, true - case len(inner) > 1 && inner[0] == '^': - return parsedInputMacro{action: inputMacroRelease, name: inner[1:]}, true - case len(inner) > 1 && inner[0] == '~': - name, duration := splitInputHold(inner[1:]) - return parsedInputMacro{action: inputMacroHold, name: name, duration: duration}, true + 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 } @@ -137,6 +128,17 @@ func splitInputHold(value string) (name, duration string) { 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 { @@ -635,7 +637,7 @@ func (l *LinuxInput) keyboardPressSequenceLocked( case inputMacroNone: return fmt.Errorf("unsupported input macro token %q", token) case inputMacroDelay: - duration, err := parseMacroDuration(macro.duration) + duration, err := parseBoundedInputMacroDuration(macro.duration) if err != nil { return fmt.Errorf("invalid delay token %q: %w", token, err) } @@ -661,7 +663,7 @@ func (l *LinuxInput) keyboardPressSequenceLocked( case inputMacroHold: holdDuration := l.kbd.Delay if macro.duration != "" { - holdDuration, err = parseMacroDuration(macro.duration) + holdDuration, err = parseBoundedInputMacroDuration(macro.duration) if err != nil { return fmt.Errorf("invalid hold duration in %q: %w", token, err) } @@ -801,7 +803,7 @@ func (l *LinuxInput) gamepadPressSequenceLocked( case inputMacroNone: return fmt.Errorf("unsupported input macro token %q", token) case inputMacroDelay: - duration, err := parseMacroDuration(macro.duration) + duration, err := parseBoundedInputMacroDuration(macro.duration) if err != nil { return fmt.Errorf("invalid delay token %q: %w", token, err) } @@ -827,7 +829,7 @@ func (l *LinuxInput) gamepadPressSequenceLocked( case inputMacroHold: holdDuration := l.gpd.Delay if macro.duration != "" { - holdDuration, err = parseMacroDuration(macro.duration) + holdDuration, err = parseBoundedInputMacroDuration(macro.duration) if err != nil { return fmt.Errorf("invalid hold duration in %q: %w", token, err) } diff --git a/pkg/platforms/shared/linuxinput_session_test.go b/pkg/platforms/shared/linuxinput_session_test.go index 6c3a6f9b1..3dbefcd61 100644 --- a/pkg/platforms/shared/linuxinput_session_test.go +++ b/pkg/platforms/shared/linuxinput_session_test.go @@ -160,7 +160,7 @@ func TestInputSession_CancellationReleasesHeldInput(t *testing.T) { go func() { errCh <- session.KeyboardPressSequence(ctx, []string{ "{press:a}", - "{delay:1h}", + "{delay:30s}", }, 0) }() @@ -202,7 +202,7 @@ func TestInputSession_ReleaseNotBlockedByAnotherSessionDelay(t *testing.T) { go func() { sequenceErr <- second.KeyboardPressSequence(ctx, []string{ "{press:b}", - "{delay:1h}", + "{delay:30s}", }, 0) }() assert.Equal(t, 48, <-keyboard.keyDownSignal) @@ -271,6 +271,46 @@ func TestCloseDevicesNotBlockedByRequestScopedDelay(t *testing.T) { 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()