diff --git a/go.mod b/go.mod
index fe4389ac..b1712f51 100755
--- a/go.mod
+++ b/go.mod
@@ -1,13 +1,13 @@
module github.com/ZaparooProject/zaparoo-core/v2
-go 1.25.7
+go 1.26.1
require (
fyne.io/systray v1.11.0
github.com/DATA-DOG/go-sqlmock v1.5.2
github.com/Microsoft/go-winio v0.6.2
github.com/ZaparooProject/go-pn532 v0.20.3
- github.com/ZaparooProject/go-zapscript v0.1.0
+ github.com/ZaparooProject/go-zapscript v0.2.0
github.com/adrg/xdg v0.5.3
github.com/andygrunwald/vdf v1.1.0
github.com/bendahl/uinput v1.7.0
@@ -76,7 +76,7 @@ require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/davidmz/go-pageant v1.0.2 // indirect
github.com/ebitengine/purego v0.9.1 // indirect
- github.com/expr-lang/expr v1.17.7 // indirect
+ github.com/expr-lang/expr v1.17.8 // indirect
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
github.com/gdamore/encoding v1.0.1 // indirect
github.com/geoffgarside/ber v1.1.0 // indirect
diff --git a/go.sum b/go.sum
index a1814924..519778f3 100644
--- a/go.sum
+++ b/go.sum
@@ -18,6 +18,8 @@ github.com/ZaparooProject/go-pn532 v0.20.3 h1:okjV/BVttJZvjXDZ0wuNlI165iT3N9g1B+
github.com/ZaparooProject/go-pn532 v0.20.3/go.mod h1:38r+NvAyNsUa/McJC4FEk2+uxOzrN1AUgrh/fpc0oLk=
github.com/ZaparooProject/go-zapscript v0.1.0 h1:IlDzx4WsYOy17cADbWyegIjB7wprAy6dNr6WsXyx/Ts=
github.com/ZaparooProject/go-zapscript v0.1.0/go.mod h1:mXkIhNUoWCdsoUkjFPmbVba9PTkzD5H4ntmIxnNNSD0=
+github.com/ZaparooProject/go-zapscript v0.2.0 h1:MrZ1923LgLCcybX7kO5uJEHM8tZn995tXzJsSkK0Ep4=
+github.com/ZaparooProject/go-zapscript v0.2.0/go.mod h1:P5qov6iCMzYiSF3tqr8BAanvNNh/YjPuUENfmPo6Yj4=
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
github.com/andygrunwald/vdf v1.1.0 h1:gmstp0R7DOepIZvWoSJY97ix7QOrsxpGPU6KusKXqvw=
@@ -57,6 +59,8 @@ github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2I
github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU=
github.com/expr-lang/expr v1.17.7 h1:Q0xY/e/2aCIp8g9s/LGvMDCC5PxYlvHgDZRQ4y16JX8=
github.com/expr-lang/expr v1.17.7/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
+github.com/expr-lang/expr v1.17.8 h1:W1loDTT+0PQf5YteHSTpju2qfUfNoBt4yw9+wOEU9VM=
+github.com/expr-lang/expr v1.17.8/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
diff --git a/pkg/api/client/client_test.go b/pkg/api/client/client_test.go
index 717e06ab..112ba24b 100644
--- a/pkg/api/client/client_test.go
+++ b/pkg/api/client/client_test.go
@@ -197,6 +197,7 @@ func TestLocalClient_ContextCancellation(t *testing.T) {
cfg := testConfigWithPort(t, port)
ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
// Cancel context after a short delay
go func() {
@@ -456,6 +457,7 @@ func TestWaitNotification_ContextCancellation(t *testing.T) {
cfg := testConfigWithPort(t, port)
ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
go func() {
time.Sleep(50 * time.Millisecond)
diff --git a/pkg/api/methods/media_control.go b/pkg/api/methods/media_control.go
index 6fe8e18a..c3a14971 100644
--- a/pkg/api/methods/media_control.go
+++ b/pkg/api/methods/media_control.go
@@ -65,9 +65,10 @@ func HandleMediaControl(env requests.RequestEnv) (any, error) { //nolint:gocriti
var err error
switch {
case control.Func != nil:
- err = control.Func(env.Config, platforms.ControlParams{Args: params.Args})
+ err = control.Func(env.Context, env.Config, platforms.ControlParams{Args: params.Args})
case control.Script != "":
- err = zapscript.RunControlScript(env.Platform, env.Config, env.Database, env.State, control.Script)
+ exprEnv := zapscript.GetExprEnv(env.Platform, env.Config, env.State, nil, nil)
+ err = zapscript.RunControlScript(env.Platform, env.Config, env.Database, control.Script, &exprEnv)
default:
err = fmt.Errorf("control %q has no implementation", params.Action)
}
diff --git a/pkg/api/methods/media_control_test.go b/pkg/api/methods/media_control_test.go
index dbd3bd93..e8331c28 100644
--- a/pkg/api/methods/media_control_test.go
+++ b/pkg/api/methods/media_control_test.go
@@ -72,7 +72,7 @@ func TestHandleMediaControl_UnknownAction(t *testing.T) {
ID: "test-launcher",
SystemID: "NES",
Controls: map[string]platforms.Control{
- "save_state": {Func: func(_ *config.Instance, _ platforms.ControlParams) error {
+ "save_state": {Func: func(_ context.Context, _ *config.Instance, _ platforms.ControlParams) error {
return nil
}},
},
@@ -140,7 +140,7 @@ func TestHandleMediaControl_Success(t *testing.T) {
ID: "test-launcher",
SystemID: "NES",
Controls: map[string]platforms.Control{
- "save_state": {Func: func(_ *config.Instance, _ platforms.ControlParams) error {
+ "save_state": {Func: func(_ context.Context, _ *config.Instance, _ platforms.ControlParams) error {
called = true
return nil
}},
@@ -191,7 +191,7 @@ func TestHandleMediaControl_ArgsPassThrough(t *testing.T) {
ID: "test-launcher",
SystemID: "NES",
Controls: map[string]platforms.Control{
- "save_state": {Func: func(_ *config.Instance, cp platforms.ControlParams) error {
+ "save_state": {Func: func(_ context.Context, _ *config.Instance, cp platforms.ControlParams) error {
receivedArgs = cp.Args
return nil
}},
@@ -230,7 +230,7 @@ func TestHandleMediaControl_ArgsNilWhenOmitted(t *testing.T) {
ID: "test-launcher",
SystemID: "NES",
Controls: map[string]platforms.Control{
- "save_state": {Func: func(_ *config.Instance, cp platforms.ControlParams) error {
+ "save_state": {Func: func(_ context.Context, _ *config.Instance, cp platforms.ControlParams) error {
receivedArgs = cp.Args
return nil
}},
diff --git a/pkg/api/middleware/auth_test.go b/pkg/api/middleware/auth_test.go
index 71a30d35..66e6b17b 100644
--- a/pkg/api/middleware/auth_test.go
+++ b/pkg/api/middleware/auth_test.go
@@ -262,6 +262,7 @@ func TestHTTPAuthMiddleware(t *testing.T) {
url += "?key=" + tt.queryParam
}
+ //nolint:noctx // test helper, no context needed
req := httptest.NewRequest(http.MethodGet, url, http.NoBody)
if tt.authHeader != "" {
req.Header.Set("Authorization", tt.authHeader)
@@ -336,6 +337,7 @@ func TestWebSocketAuthHandler(t *testing.T) {
url += "?key=" + tt.queryParam
}
+ //nolint:noctx // test helper, no context needed
req := httptest.NewRequest(http.MethodGet, url, http.NoBody)
if tt.authHeader != "" {
req.Header.Set("Authorization", tt.authHeader)
@@ -373,6 +375,7 @@ func TestHTTPAuthMiddleware_Integration(t *testing.T) {
// Test valid key - should reach all middlewares and handler
callCount = 0
+ //nolint:noctx // test helper, no context needed
req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody)
req.Header.Set("Authorization", "Bearer valid-key")
recorder := httptest.NewRecorder()
@@ -383,6 +386,7 @@ func TestHTTPAuthMiddleware_Integration(t *testing.T) {
// Test invalid key - should not reach subsequent middlewares or handler
callCount = 0
+ //nolint:noctx // test helper, no context needed
req = httptest.NewRequest(http.MethodGet, "/test", http.NoBody)
req.Header.Set("Authorization", "Bearer invalid-key")
recorder = httptest.NewRecorder()
@@ -393,6 +397,7 @@ func TestHTTPAuthMiddleware_Integration(t *testing.T) {
// Test no key - should not reach subsequent middlewares or handler
callCount = 0
+ //nolint:noctx // test helper, no context needed
req = httptest.NewRequest(http.MethodGet, "/test", http.NoBody)
recorder = httptest.NewRecorder()
wrapped.ServeHTTP(recorder, req)
@@ -445,6 +450,7 @@ func TestHTTPAuthMiddleware_LocalhostExempt(t *testing.T) {
wrapped := middleware(handler)
+ //nolint:noctx // test helper, no context needed
req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody)
req.RemoteAddr = tt.remoteAddr
@@ -487,6 +493,7 @@ func TestWebSocketAuthHandler_LocalhostExempt(t *testing.T) {
cfg := NewAuthConfig(keysProvider([]string{"secret-key"}))
+ //nolint:noctx // test helper, no context needed
req := httptest.NewRequest(http.MethodGet, "/ws", http.NoBody)
req.RemoteAddr = tt.remoteAddr
@@ -546,6 +553,7 @@ func TestHTTPAuthMiddleware_HotReload(t *testing.T) {
wrapped := middleware(handler)
// Request with valid key should succeed
+ //nolint:noctx // test helper, no context needed
req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody)
req.RemoteAddr = "192.168.1.100:12345" // Non-localhost to require auth
req.Header.Set("Authorization", "Bearer secret")
@@ -557,6 +565,7 @@ func TestHTTPAuthMiddleware_HotReload(t *testing.T) {
keys = []string{"new-secret"}
// Old key should now fail
+ //nolint:noctx // test helper, no context needed
req = httptest.NewRequest(http.MethodGet, "/test", http.NoBody)
req.RemoteAddr = "192.168.1.100:12345"
req.Header.Set("Authorization", "Bearer secret")
@@ -565,6 +574,7 @@ func TestHTTPAuthMiddleware_HotReload(t *testing.T) {
assert.Equal(t, http.StatusUnauthorized, recorder.Code)
// New key should succeed
+ //nolint:noctx // test helper, no context needed
req = httptest.NewRequest(http.MethodGet, "/test", http.NoBody)
req.RemoteAddr = "192.168.1.100:12345"
req.Header.Set("Authorization", "Bearer new-secret")
diff --git a/pkg/api/middleware/ipfilter_test.go b/pkg/api/middleware/ipfilter_test.go
index 023fa406..5d18da6c 100644
--- a/pkg/api/middleware/ipfilter_test.go
+++ b/pkg/api/middleware/ipfilter_test.go
@@ -312,6 +312,7 @@ func TestHTTPIPFilterMiddleware(t *testing.T) {
wrapped := middleware(handler)
+ //nolint:noctx // test helper, no context needed
req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody)
req.RemoteAddr = tt.remoteAddr
@@ -351,6 +352,7 @@ func TestHTTPIPFilterMiddleware_Integration(t *testing.T) {
// Test allowed IP - should reach all middlewares and handler
callCount = 0
+ //nolint:noctx // test helper, no context needed
req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody)
req.RemoteAddr = "192.168.1.100:12345"
recorder := httptest.NewRecorder()
@@ -361,6 +363,7 @@ func TestHTTPIPFilterMiddleware_Integration(t *testing.T) {
// Test blocked IP - should not reach subsequent middlewares or handler
callCount = 0
+ //nolint:noctx // test helper, no context needed
req = httptest.NewRequest(http.MethodGet, "/test", http.NoBody)
req.RemoteAddr = "10.0.0.1:12345"
recorder = httptest.NewRecorder()
@@ -515,6 +518,7 @@ func TestHTTPIPFilterMiddleware_HotReload(t *testing.T) {
wrapped := middleware(handler)
// Request from allowed IP should succeed
+ //nolint:noctx // test helper, no context needed
req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody)
req.RemoteAddr = "192.168.1.100:12345"
recorder := httptest.NewRecorder()
@@ -522,6 +526,7 @@ func TestHTTPIPFilterMiddleware_HotReload(t *testing.T) {
assert.Equal(t, http.StatusOK, recorder.Code)
// Request from blocked IP should fail
+ //nolint:noctx // test helper, no context needed
req = httptest.NewRequest(http.MethodGet, "/test", http.NoBody)
req.RemoteAddr = "192.168.1.200:12345"
recorder = httptest.NewRecorder()
@@ -532,6 +537,7 @@ func TestHTTPIPFilterMiddleware_HotReload(t *testing.T) {
allowedIPs = []string{"192.168.1.200"}
// Old IP should now be blocked
+ //nolint:noctx // test helper, no context needed
req = httptest.NewRequest(http.MethodGet, "/test", http.NoBody)
req.RemoteAddr = "192.168.1.100:12345"
recorder = httptest.NewRecorder()
@@ -539,6 +545,7 @@ func TestHTTPIPFilterMiddleware_HotReload(t *testing.T) {
assert.Equal(t, http.StatusForbidden, recorder.Code)
// New IP should now be allowed
+ //nolint:noctx // test helper, no context needed
req = httptest.NewRequest(http.MethodGet, "/test", http.NoBody)
req.RemoteAddr = "192.168.1.200:12345"
recorder = httptest.NewRecorder()
diff --git a/pkg/api/server_fileserver_test.go b/pkg/api/server_fileserver_test.go
index 5dcd9e44..0b643f64 100644
--- a/pkg/api/server_fileserver_test.go
+++ b/pkg/api/server_fileserver_test.go
@@ -136,6 +136,7 @@ func TestFsCustom404(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
+ //nolint:noctx // test helper, no context needed
req := httptest.NewRequest(http.MethodGet, tt.path, http.NoBody)
rec := httptest.NewRecorder()
@@ -176,6 +177,7 @@ func TestFsCustom404_MissingIndex(t *testing.T) {
handler := fsCustom404(http.FS(mockFS))
+ //nolint:noctx // test helper, no context needed
req := httptest.NewRequest(http.MethodGet, "/unknown", http.NoBody)
rec := httptest.NewRecorder()
diff --git a/pkg/api/server_pna_test.go b/pkg/api/server_pna_test.go
index 173e087f..9556e805 100644
--- a/pkg/api/server_pna_test.go
+++ b/pkg/api/server_pna_test.go
@@ -79,6 +79,7 @@ func TestPrivateNetworkAccessMiddleware(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
+ //nolint:noctx // test helper, no context needed
req := httptest.NewRequest(tt.method, "/api", http.NoBody)
if tt.requestPNAHeader != "" {
req.Header.Set("Access-Control-Request-Private-Network", tt.requestPNAHeader)
diff --git a/pkg/api/server_post_test.go b/pkg/api/server_post_test.go
index 7a8be55f..8318b771 100644
--- a/pkg/api/server_post_test.go
+++ b/pkg/api/server_post_test.go
@@ -90,6 +90,7 @@ func TestHandlePostRequest_ValidRequest(t *testing.T) {
handler, _ := createTestPostHandler(t)
reqBody := `{"jsonrpc":"2.0","id":"` + uuid.New().String() + `","method":"test.echo"}`
+ //nolint:noctx // test helper, no context needed
req := httptest.NewRequest(http.MethodPost, "/api", strings.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
@@ -112,6 +113,7 @@ func TestHandlePostRequest_InvalidJSON(t *testing.T) {
handler, _ := createTestPostHandler(t)
+ //nolint:noctx // test helper, no context needed
req := httptest.NewRequest(http.MethodPost, "/api", strings.NewReader(`{invalid json`))
req.Header.Set("Content-Type", "application/json")
@@ -136,6 +138,7 @@ func TestHandlePostRequest_UnknownMethod(t *testing.T) {
handler, _ := createTestPostHandler(t)
reqBody := `{"jsonrpc":"2.0","id":"` + uuid.New().String() + `","method":"nonexistent.method"}`
+ //nolint:noctx // test helper, no context needed
req := httptest.NewRequest(http.MethodPost, "/api", strings.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
@@ -157,6 +160,7 @@ func TestHandlePostRequest_WrongContentType(t *testing.T) {
handler, _ := createTestPostHandler(t)
+ //nolint:noctx // test helper, no context needed
req := httptest.NewRequest(http.MethodPost, "/api", strings.NewReader(`{"test":"data"}`))
req.Header.Set("Content-Type", "text/plain")
@@ -173,6 +177,7 @@ func TestHandlePostRequest_ContentTypeWithCharset(t *testing.T) {
handler, _ := createTestPostHandler(t)
reqBody := `{"jsonrpc":"2.0","id":"` + uuid.New().String() + `","method":"test.echo"}`
+ //nolint:noctx // test helper, no context needed
req := httptest.NewRequest(http.MethodPost, "/api", strings.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json; charset=utf-8")
@@ -191,6 +196,7 @@ func TestHandlePostRequest_Notification(t *testing.T) {
// JSON-RPC notification (no ID field)
reqBody := `{"jsonrpc":"2.0","method":"test.echo"}`
+ //nolint:noctx // test helper, no context needed
req := httptest.NewRequest(http.MethodPost, "/api", strings.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
@@ -209,6 +215,7 @@ func TestHandlePostRequest_MethodError(t *testing.T) {
handler, _ := createTestPostHandler(t)
reqBody := `{"jsonrpc":"2.0","id":"` + uuid.New().String() + `","method":"test.error"}`
+ //nolint:noctx // test helper, no context needed
req := httptest.NewRequest(http.MethodPost, "/api", strings.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
@@ -232,6 +239,7 @@ func TestHandlePostRequest_OversizedBody(t *testing.T) {
// Create a body larger than 1MB
largeBody := strings.Repeat("x", 2<<20) // 2MB
+ //nolint:noctx // test helper, no context needed
req := httptest.NewRequest(http.MethodPost, "/api", strings.NewReader(largeBody))
req.Header.Set("Content-Type", "application/json")
@@ -249,6 +257,7 @@ func TestHandlePostRequest_EmptyBody(t *testing.T) {
handler, _ := createTestPostHandler(t)
+ //nolint:noctx // test helper, no context needed
req := httptest.NewRequest(http.MethodPost, "/api", strings.NewReader(""))
req.Header.Set("Content-Type", "application/json")
@@ -271,6 +280,7 @@ func TestHandlePostRequest_InvalidJSONRPCVersion(t *testing.T) {
handler, _ := createTestPostHandler(t)
reqBody := `{"jsonrpc":"1.0","id":"` + uuid.New().String() + `","method":"test.echo"}`
+ //nolint:noctx // test helper, no context needed
req := httptest.NewRequest(http.MethodPost, "/api", strings.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
@@ -293,6 +303,7 @@ func TestHandlePostRequest_StringID(t *testing.T) {
handler, _ := createTestPostHandler(t)
reqBody := `{"jsonrpc":"2.0","id":"my-custom-string-id","method":"test.echo"}`
+ //nolint:noctx // test helper, no context needed
req := httptest.NewRequest(http.MethodPost, "/api", strings.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
@@ -314,6 +325,7 @@ func TestHandlePostRequest_NumberID(t *testing.T) {
handler, _ := createTestPostHandler(t)
reqBody := `{"jsonrpc":"2.0","id":12345,"method":"test.echo"}`
+ //nolint:noctx // test helper, no context needed
req := httptest.NewRequest(http.MethodPost, "/api", strings.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
@@ -337,6 +349,7 @@ func TestHandlePostRequest_MissingID(t *testing.T) {
// Request without ID field = notification
reqBody := `{"jsonrpc":"2.0","method":"test.echo"}`
+ //nolint:noctx // test helper, no context needed
req := httptest.NewRequest(http.MethodPost, "/api", strings.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
@@ -356,6 +369,7 @@ func TestHandlePostRequest_NullID(t *testing.T) {
// Request with explicit null ID
reqBody := `{"jsonrpc":"2.0","id":null,"method":"test.echo"}`
+ //nolint:noctx // test helper, no context needed
req := httptest.NewRequest(http.MethodPost, "/api", strings.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
@@ -381,6 +395,7 @@ func TestHandlePostRequest_UUIDStringID(t *testing.T) {
testUUID := uuid.New().String()
reqBody := `{"jsonrpc":"2.0","id":"` + testUUID + `","method":"test.echo"}`
+ //nolint:noctx // test helper, no context needed
req := httptest.NewRequest(http.MethodPost, "/api", strings.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
@@ -403,6 +418,7 @@ func TestHandlePostRequest_InvalidObjectID(t *testing.T) {
// Object ID is invalid per JSON-RPC spec
reqBody := `{"jsonrpc":"2.0","id":{"nested":"object"},"method":"test.echo"}`
+ //nolint:noctx // test helper, no context needed
req := httptest.NewRequest(http.MethodPost, "/api", strings.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
@@ -427,6 +443,7 @@ func TestHandlePostRequest_InvalidArrayID(t *testing.T) {
// Array ID is invalid per JSON-RPC spec
reqBody := `{"jsonrpc":"2.0","id":[1,2,3],"method":"test.echo"}`
+ //nolint:noctx // test helper, no context needed
req := httptest.NewRequest(http.MethodPost, "/api", strings.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
@@ -460,6 +477,7 @@ func TestHandlePostRequest_ResponseWithCallback(t *testing.T) {
require.NoError(t, err)
reqBody := `{"jsonrpc":"2.0","id":"` + uuid.New().String() + `","method":"test.callback"}`
+ //nolint:noctx // test helper, no context needed
req := httptest.NewRequest(http.MethodPost, "/api", strings.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
diff --git a/pkg/audio/audio.go b/pkg/audio/audio.go
index 83312433..8634153c 100644
--- a/pkg/audio/audio.go
+++ b/pkg/audio/audio.go
@@ -84,7 +84,7 @@ func (p *MalgoPlayer) playWAV(r io.ReadCloser) error {
if p.currentCancel != nil {
p.currentCancel()
}
- ctx, cancel := context.WithCancel(context.Background())
+ ctx, cancel := context.WithCancel(context.Background()) //nolint:gosec // G118: stored in p.currentCancel
p.currentCancel = cancel
p.playbackGen++
thisGen := p.playbackGen
@@ -161,7 +161,7 @@ func (p *MalgoPlayer) PlayFile(path string) error {
if p.currentCancel != nil {
p.currentCancel()
}
- ctx, cancel := context.WithCancel(context.Background())
+ ctx, cancel := context.WithCancel(context.Background()) //nolint:gosec // G118: stored in p.currentCancel
p.currentCancel = cancel
p.playbackGen++
thisGen := p.playbackGen
diff --git a/pkg/config/auth.go b/pkg/config/auth.go
index e3cdb3f3..0b1cc7c9 100644
--- a/pkg/config/auth.go
+++ b/pkg/config/auth.go
@@ -234,7 +234,7 @@ func marshalAuthFile(
Creds: creds,
}
- data, err := toml.Marshal(file)
+ data, err := toml.Marshal(file) //nolint:gosec // G117: field name matches existing TOML key, not a secret
if err != nil {
return nil, fmt.Errorf("failed to marshal auth file: %w", err)
}
diff --git a/pkg/platforms/batocera/platform.go b/pkg/platforms/batocera/platform.go
index d41fda1e..3ae75eaa 100644
--- a/pkg/platforms/batocera/platform.go
+++ b/pkg/platforms/batocera/platform.go
@@ -461,7 +461,7 @@ func (p *Platform) stopKodi(cfg *config.Instance, reason platforms.StopIntent) e
// just stop playback but keep Kodi running ("Kodi mode" stays active)
if reason == platforms.StopForMenu {
log.Info().Msg("stopping Kodi playback (Kodi mode stays active)")
- if err := client.Stop(); err != nil {
+ if err := client.Stop(context.Background()); err != nil {
return fmt.Errorf("failed to stop Kodi playback: %w", err)
}
// Don't clear activeMedia - Kodi is still running and we're still in "Kodi mode"
diff --git a/pkg/platforms/batocera/tracker.go b/pkg/platforms/batocera/tracker.go
index f6120039..953cf208 100644
--- a/pkg/platforms/batocera/tracker.go
+++ b/pkg/platforms/batocera/tracker.go
@@ -47,8 +47,7 @@ func (p *Platform) startGameTracker(
p.clock = clockwork.NewRealClock()
}
- ctx, cancel := context.WithCancel(context.Background())
-
+ ctx, cancel := context.WithCancel(context.Background()) //nolint:gosec // G118: cancel returned in closure
// Poll every 2 seconds for responsive tracking
ticker := p.clock.NewTicker(2 * time.Second)
diff --git a/pkg/platforms/libreelec/platform.go b/pkg/platforms/libreelec/platform.go
index 2113915c..d09f2942 100644
--- a/pkg/platforms/libreelec/platform.go
+++ b/pkg/platforms/libreelec/platform.go
@@ -152,7 +152,7 @@ func (p *Platform) StopActiveLauncher(_ platforms.StopIntent) error {
p.setActiveMedia(nil)
client := kodi.NewClient(p.cfg)
- if err := client.Stop(); err != nil {
+ if err := client.Stop(context.Background()); err != nil {
return fmt.Errorf("failed to stop Kodi client: %w", err)
}
return nil
diff --git a/pkg/platforms/platforms.go b/pkg/platforms/platforms.go
index 48af6a07..f7188920 100644
--- a/pkg/platforms/platforms.go
+++ b/pkg/platforms/platforms.go
@@ -88,6 +88,11 @@ const (
ControlLoad = "load"
ControlReset = "reset"
ControlTogglePause = "toggle_pause"
+ ControlStop = "stop"
+ ControlFastForward = "fast_forward"
+ ControlRewind = "rewind"
+ ControlNext = "next"
+ ControlPrevious = "previous"
)
// ControlParams contains parameters for a control action.
@@ -96,7 +101,7 @@ type ControlParams struct {
}
// ControlFunc is a function that executes a control action on active media.
-type ControlFunc func(*config.Instance, ControlParams) error
+type ControlFunc func(context.Context, *config.Instance, ControlParams) error
// Control represents a single control action. Either Func (native Go for
// built-in launchers) or Script (zapscript string for custom launchers)
@@ -351,7 +356,7 @@ func KeyboardControls(pl Platform, actions map[string]string) map[string]Control
controls := make(map[string]Control, len(actions))
for action, key := range actions {
controls[action] = Control{
- Func: func(_ *config.Instance, _ ControlParams) error {
+ Func: func(_ context.Context, _ *config.Instance, _ ControlParams) error {
return pl.KeyboardPress(key)
},
}
diff --git a/pkg/platforms/shared/installer/http_test.go b/pkg/platforms/shared/installer/http_test.go
index f489f794..cd399860 100644
--- a/pkg/platforms/shared/installer/http_test.go
+++ b/pkg/platforms/shared/installer/http_test.go
@@ -103,6 +103,7 @@ func TestDownloadHTTPFile_ContextCancellation(t *testing.T) {
finalPath := filepath.Join(tempDir, "game.rom")
ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
// Cancel after a short delay
go func() {
diff --git a/pkg/platforms/shared/installer/installer_test.go b/pkg/platforms/shared/installer/installer_test.go
index d2d9ccd8..f69fbf3a 100644
--- a/pkg/platforms/shared/installer/installer_test.go
+++ b/pkg/platforms/shared/installer/installer_test.go
@@ -222,6 +222,7 @@ func TestInstallRemoteFile_ContextCancellation(t *testing.T) {
setupShowLoader(mockPlatform)
ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
downloader := func(args DownloaderArgs) error {
// Simulate that the downloader checks context and returns error when cancelled
diff --git a/pkg/platforms/shared/kodi/client.go b/pkg/platforms/shared/kodi/client.go
index 54a50819..a1e1681c 100644
--- a/pkg/platforms/shared/kodi/client.go
+++ b/pkg/platforms/shared/kodi/client.go
@@ -24,6 +24,7 @@ import (
"context"
"encoding/base64"
"encoding/json"
+ "errors"
"fmt"
"io"
"net/http"
@@ -140,15 +141,82 @@ func (c *Client) LaunchTVEpisode(path string) error {
return err
}
+// getFirstActivePlayer returns the first active player, or an error if none are active.
+func (c *Client) getFirstActivePlayer(ctx context.Context) (*Player, error) {
+ players, err := c.GetActivePlayers(ctx)
+ if err != nil {
+ return nil, err
+ }
+ if len(players) == 0 {
+ return nil, errors.New("no active players")
+ }
+ return &players[0], nil
+}
+
+// PlayPause toggles play/pause on the first active player
+func (c *Client) PlayPause(ctx context.Context) error {
+ player, err := c.getFirstActivePlayer(ctx)
+ if err != nil {
+ return err
+ }
+ _, err = c.APIRequest(ctx, APIMethodPlayerPlayPause, PlayerPlayPauseParams{
+ PlayerID: player.ID,
+ })
+ return err
+}
+
+// FastForward increases playback speed of the first active player
+func (c *Client) FastForward(ctx context.Context) error {
+ player, err := c.getFirstActivePlayer(ctx)
+ if err != nil {
+ return err
+ }
+ _, err = c.APIRequest(ctx, APIMethodPlayerSetSpeed, PlayerSetSpeedParams{
+ PlayerID: player.ID,
+ Speed: "increment",
+ })
+ return err
+}
+
+// Rewind decreases playback speed of the first active player
+func (c *Client) Rewind(ctx context.Context) error {
+ player, err := c.getFirstActivePlayer(ctx)
+ if err != nil {
+ return err
+ }
+ _, err = c.APIRequest(ctx, APIMethodPlayerSetSpeed, PlayerSetSpeedParams{
+ PlayerID: player.ID,
+ Speed: "decrement",
+ })
+ return err
+}
+
+// GoTo navigates to next or previous item in playlist.
+// Direction must be "next" or "previous".
+func (c *Client) GoTo(ctx context.Context, direction string) error {
+ if direction != "next" && direction != "previous" {
+ return fmt.Errorf("invalid GoTo direction: %q (must be \"next\" or \"previous\")", direction)
+ }
+ player, err := c.getFirstActivePlayer(ctx)
+ if err != nil {
+ return err
+ }
+ _, err = c.APIRequest(ctx, APIMethodPlayerGoTo, PlayerGoToParams{
+ PlayerID: player.ID,
+ To: direction,
+ })
+ return err
+}
+
// Stop stops all active players in Kodi
-func (c *Client) Stop() error {
- players, err := c.GetActivePlayers(context.Background())
+func (c *Client) Stop(ctx context.Context) error {
+ players, err := c.GetActivePlayers(ctx)
if err != nil {
return err
}
for _, player := range players {
- _, err := c.APIRequest(context.Background(), APIMethodPlayerStop, PlayerStopParams{
+ _, err := c.APIRequest(ctx, APIMethodPlayerStop, PlayerStopParams{
PlayerID: player.ID,
})
if err != nil {
diff --git a/pkg/platforms/shared/kodi/client_test.go b/pkg/platforms/shared/kodi/client_test.go
index cca94bd8..f03a3dcb 100644
--- a/pkg/platforms/shared/kodi/client_test.go
+++ b/pkg/platforms/shared/kodi/client_test.go
@@ -267,7 +267,7 @@ func TestClient_Stop_NoActivePlayers(t *testing.T) {
client.SetURL(server.URL)
// Execute
- err := client.Stop()
+ err := client.Stop(context.Background())
// Verify
require.NoError(t, err)
@@ -329,7 +329,7 @@ func TestClient_Stop_SingleActivePlayer(t *testing.T) {
client.SetURL(server.URL)
// Execute
- err := client.Stop()
+ err := client.Stop(context.Background())
// Verify
require.NoError(t, err)
@@ -390,7 +390,7 @@ func TestClient_Stop_MultipleActivePlayers(t *testing.T) {
client.SetURL(server.URL)
// Execute
- err := client.Stop()
+ err := client.Stop(context.Background())
// Verify
require.NoError(t, err)
@@ -1460,3 +1460,323 @@ func TestClient_APIRequest_UsesAuthenticationWhenConfigured(t *testing.T) {
assert.Equal(t, "Bearer test-bearer-token-12345", authHeader)
})
}
+
+func TestClient_PlayPause_MakesCorrectAPICall(t *testing.T) {
+ t.Parallel()
+
+ var receivedPayloads []map[string]any
+ server := createMockKodiServer(t, func(payload map[string]any) map[string]any {
+ receivedPayloads = append(receivedPayloads, payload)
+ method, ok := payload["method"].(string)
+ if !ok {
+ t.Fatalf("expected method to be string, got %T", payload["method"])
+ }
+
+ switch method {
+ case "Player.GetActivePlayers":
+ return map[string]any{
+ "jsonrpc": "2.0",
+ "id": payload["id"],
+ "result": []any{
+ map[string]any{"playerid": 1, "type": "video"},
+ },
+ }
+ case "Player.PlayPause":
+ return map[string]any{
+ "jsonrpc": "2.0",
+ "id": payload["id"],
+ "result": map[string]any{"speed": 0},
+ }
+ default:
+ t.Errorf("Unexpected API method called: %s", method)
+ return map[string]any{}
+ }
+ })
+ defer server.Close()
+
+ client := kodi.NewClient(nil)
+ client.SetURL(server.URL)
+
+ err := client.PlayPause(context.Background())
+ require.NoError(t, err)
+
+ require.Len(t, receivedPayloads, 2)
+ assert.Equal(t, "Player.GetActivePlayers", receivedPayloads[0]["method"])
+ assert.Equal(t, "Player.PlayPause", receivedPayloads[1]["method"])
+
+ params, ok := receivedPayloads[1]["params"].(map[string]any)
+ require.True(t, ok)
+ assert.Equal(t, 1, int(params["playerid"].(float64)))
+}
+
+func TestClient_PlayPause_NoActivePlayers(t *testing.T) {
+ t.Parallel()
+
+ server := createMockKodiServer(t, func(payload map[string]any) map[string]any {
+ return map[string]any{
+ "jsonrpc": "2.0",
+ "id": payload["id"],
+ "result": []any{},
+ }
+ })
+ defer server.Close()
+
+ client := kodi.NewClient(nil)
+ client.SetURL(server.URL)
+
+ err := client.PlayPause(context.Background())
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "no active players")
+}
+
+func TestClient_FastForward_NoActivePlayers(t *testing.T) {
+ t.Parallel()
+
+ server := createMockKodiServer(t, func(payload map[string]any) map[string]any {
+ return map[string]any{
+ "jsonrpc": "2.0",
+ "id": payload["id"],
+ "result": []any{},
+ }
+ })
+ defer server.Close()
+
+ client := kodi.NewClient(nil)
+ client.SetURL(server.URL)
+
+ err := client.FastForward(context.Background())
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "no active players")
+}
+
+func TestClient_FastForward_MakesCorrectAPICall(t *testing.T) {
+ t.Parallel()
+
+ var receivedPayloads []map[string]any
+ server := createMockKodiServer(t, func(payload map[string]any) map[string]any {
+ receivedPayloads = append(receivedPayloads, payload)
+ method, ok := payload["method"].(string)
+ if !ok {
+ t.Fatalf("expected method to be string, got %T", payload["method"])
+ }
+
+ switch method {
+ case "Player.GetActivePlayers":
+ return map[string]any{
+ "jsonrpc": "2.0",
+ "id": payload["id"],
+ "result": []any{
+ map[string]any{"playerid": 1, "type": "video"},
+ },
+ }
+ case "Player.SetSpeed":
+ return map[string]any{
+ "jsonrpc": "2.0",
+ "id": payload["id"],
+ "result": map[string]any{"speed": 2},
+ }
+ default:
+ t.Errorf("Unexpected API method called: %s", method)
+ return map[string]any{}
+ }
+ })
+ defer server.Close()
+
+ client := kodi.NewClient(nil)
+ client.SetURL(server.URL)
+
+ err := client.FastForward(context.Background())
+ require.NoError(t, err)
+
+ require.Len(t, receivedPayloads, 2)
+ assert.Equal(t, "Player.SetSpeed", receivedPayloads[1]["method"])
+
+ params, ok := receivedPayloads[1]["params"].(map[string]any)
+ require.True(t, ok)
+ assert.Equal(t, 1, int(params["playerid"].(float64)))
+ assert.Equal(t, "increment", params["speed"])
+}
+
+func TestClient_Rewind_NoActivePlayers(t *testing.T) {
+ t.Parallel()
+
+ server := createMockKodiServer(t, func(payload map[string]any) map[string]any {
+ return map[string]any{
+ "jsonrpc": "2.0",
+ "id": payload["id"],
+ "result": []any{},
+ }
+ })
+ defer server.Close()
+
+ client := kodi.NewClient(nil)
+ client.SetURL(server.URL)
+
+ err := client.Rewind(context.Background())
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "no active players")
+}
+
+func TestClient_Rewind_MakesCorrectAPICall(t *testing.T) {
+ t.Parallel()
+
+ var receivedPayloads []map[string]any
+ server := createMockKodiServer(t, func(payload map[string]any) map[string]any {
+ receivedPayloads = append(receivedPayloads, payload)
+ method, ok := payload["method"].(string)
+ if !ok {
+ t.Fatalf("expected method to be string, got %T", payload["method"])
+ }
+
+ switch method {
+ case "Player.GetActivePlayers":
+ return map[string]any{
+ "jsonrpc": "2.0",
+ "id": payload["id"],
+ "result": []any{
+ map[string]any{"playerid": 1, "type": "video"},
+ },
+ }
+ case "Player.SetSpeed":
+ return map[string]any{
+ "jsonrpc": "2.0",
+ "id": payload["id"],
+ "result": map[string]any{"speed": -2},
+ }
+ default:
+ t.Errorf("Unexpected API method called: %s", method)
+ return map[string]any{}
+ }
+ })
+ defer server.Close()
+
+ client := kodi.NewClient(nil)
+ client.SetURL(server.URL)
+
+ err := client.Rewind(context.Background())
+ require.NoError(t, err)
+
+ require.Len(t, receivedPayloads, 2)
+ assert.Equal(t, "Player.SetSpeed", receivedPayloads[1]["method"])
+
+ params, ok := receivedPayloads[1]["params"].(map[string]any)
+ require.True(t, ok)
+ assert.Equal(t, 1, int(params["playerid"].(float64)))
+ assert.Equal(t, "decrement", params["speed"])
+}
+
+func TestClient_GoTo_NoActivePlayers(t *testing.T) {
+ t.Parallel()
+
+ server := createMockKodiServer(t, func(payload map[string]any) map[string]any {
+ return map[string]any{
+ "jsonrpc": "2.0",
+ "id": payload["id"],
+ "result": []any{},
+ }
+ })
+ defer server.Close()
+
+ client := kodi.NewClient(nil)
+ client.SetURL(server.URL)
+
+ err := client.GoTo(context.Background(), "next")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "no active players")
+}
+
+func TestClient_GoTo_Next_MakesCorrectAPICall(t *testing.T) {
+ t.Parallel()
+
+ var receivedPayloads []map[string]any
+ server := createMockKodiServer(t, func(payload map[string]any) map[string]any {
+ receivedPayloads = append(receivedPayloads, payload)
+ method, ok := payload["method"].(string)
+ if !ok {
+ t.Fatalf("expected method to be string, got %T", payload["method"])
+ }
+
+ switch method {
+ case "Player.GetActivePlayers":
+ return map[string]any{
+ "jsonrpc": "2.0",
+ "id": payload["id"],
+ "result": []any{
+ map[string]any{"playerid": 1, "type": "video"},
+ },
+ }
+ case "Player.GoTo":
+ return map[string]any{
+ "jsonrpc": "2.0",
+ "id": payload["id"],
+ "result": "OK",
+ }
+ default:
+ t.Errorf("Unexpected API method called: %s", method)
+ return map[string]any{}
+ }
+ })
+ defer server.Close()
+
+ client := kodi.NewClient(nil)
+ client.SetURL(server.URL)
+
+ err := client.GoTo(context.Background(), "next")
+ require.NoError(t, err)
+
+ require.Len(t, receivedPayloads, 2)
+ assert.Equal(t, "Player.GoTo", receivedPayloads[1]["method"])
+
+ params, ok := receivedPayloads[1]["params"].(map[string]any)
+ require.True(t, ok)
+ assert.Equal(t, 1, int(params["playerid"].(float64)))
+ assert.Equal(t, "next", params["to"])
+}
+
+func TestClient_GoTo_Previous_MakesCorrectAPICall(t *testing.T) {
+ t.Parallel()
+
+ var receivedPayloads []map[string]any
+ server := createMockKodiServer(t, func(payload map[string]any) map[string]any {
+ receivedPayloads = append(receivedPayloads, payload)
+ method, ok := payload["method"].(string)
+ if !ok {
+ t.Fatalf("expected method to be string, got %T", payload["method"])
+ }
+
+ switch method {
+ case "Player.GetActivePlayers":
+ return map[string]any{
+ "jsonrpc": "2.0",
+ "id": payload["id"],
+ "result": []any{
+ map[string]any{"playerid": 1, "type": "video"},
+ },
+ }
+ case "Player.GoTo":
+ return map[string]any{
+ "jsonrpc": "2.0",
+ "id": payload["id"],
+ "result": "OK",
+ }
+ default:
+ t.Errorf("Unexpected API method called: %s", method)
+ return map[string]any{}
+ }
+ })
+ defer server.Close()
+
+ client := kodi.NewClient(nil)
+ client.SetURL(server.URL)
+
+ err := client.GoTo(context.Background(), "previous")
+ require.NoError(t, err)
+
+ require.Len(t, receivedPayloads, 2)
+ assert.Equal(t, "Player.GoTo", receivedPayloads[1]["method"])
+
+ params, ok := receivedPayloads[1]["params"].(map[string]any)
+ require.True(t, ok)
+ assert.Equal(t, 1, int(params["playerid"].(float64)))
+ assert.Equal(t, "previous", params["to"])
+}
diff --git a/pkg/platforms/shared/kodi/interface.go b/pkg/platforms/shared/kodi/interface.go
index 7b8f24a8..4c25ddf9 100644
--- a/pkg/platforms/shared/kodi/interface.go
+++ b/pkg/platforms/shared/kodi/interface.go
@@ -39,7 +39,20 @@ type KodiClient interface {
LaunchTVEpisode(path string) error
// Stop stops all active players in Kodi
- Stop() error
+ Stop(ctx context.Context) error
+
+ // PlayPause toggles play/pause on the first active player
+ PlayPause(ctx context.Context) error
+
+ // FastForward increases playback speed of the first active player
+ FastForward(ctx context.Context) error
+
+ // Rewind decreases playback speed of the first active player
+ Rewind(ctx context.Context) error
+
+ // GoTo navigates to next or previous item in playlist.
+ // Direction should be "next" or "previous".
+ GoTo(ctx context.Context, direction string) error
// Quit gracefully exits Kodi application
Quit(ctx context.Context) error
diff --git a/pkg/platforms/shared/kodi/interface_test.go b/pkg/platforms/shared/kodi/interface_test.go
index bcd46de7..f98267c5 100644
--- a/pkg/platforms/shared/kodi/interface_test.go
+++ b/pkg/platforms/shared/kodi/interface_test.go
@@ -61,8 +61,8 @@ func (m *MockKodiClient) LaunchTVEpisode(path string) error {
return nil
}
-func (m *MockKodiClient) Stop() error {
- args := m.Called()
+func (m *MockKodiClient) Stop(ctx context.Context) error {
+ args := m.Called(ctx)
if err := args.Error(0); err != nil {
return fmt.Errorf("mock Stop error: %w", err)
}
@@ -171,6 +171,38 @@ func (m *MockKodiClient) APIRequest(ctx context.Context, method kodi.APIMethod,
return nil, nil
}
+func (m *MockKodiClient) PlayPause(ctx context.Context) error {
+ args := m.Called(ctx)
+ if err := args.Error(0); err != nil {
+ return fmt.Errorf("mock PlayPause error: %w", err)
+ }
+ return nil
+}
+
+func (m *MockKodiClient) FastForward(ctx context.Context) error {
+ args := m.Called(ctx)
+ if err := args.Error(0); err != nil {
+ return fmt.Errorf("mock FastForward error: %w", err)
+ }
+ return nil
+}
+
+func (m *MockKodiClient) Rewind(ctx context.Context) error {
+ args := m.Called(ctx)
+ if err := args.Error(0); err != nil {
+ return fmt.Errorf("mock Rewind error: %w", err)
+ }
+ return nil
+}
+
+func (m *MockKodiClient) GoTo(ctx context.Context, direction string) error {
+ args := m.Called(ctx, direction)
+ if err := args.Error(0); err != nil {
+ return fmt.Errorf("mock GoTo error: %w", err)
+ }
+ return nil
+}
+
func (m *MockKodiClient) LaunchSong(path string) error {
args := m.Called(path)
return args.Error(0) //nolint:wrapcheck // Mock implementation, error wrapping not needed
@@ -306,13 +338,13 @@ func TestKodiClient_Stop(t *testing.T) {
// This test drives the addition of Stop method - critical for fixing the broken implementation
mockClient := new(MockKodiClient)
- mockClient.On("Stop").Return(nil)
+ mockClient.On("Stop", mock.Anything).Return(nil)
// Use as KodiClient interface
var client kodi.KodiClient = mockClient
// Test Stop method exists and can be called
- err := client.Stop()
+ err := client.Stop(context.Background())
require.NoError(t, err)
mockClient.AssertExpectations(t)
@@ -478,6 +510,66 @@ func TestKodiClient_SetURL(t *testing.T) {
mockClient.AssertExpectations(t)
}
+func TestKodiClient_PlayPause(t *testing.T) {
+ t.Parallel()
+
+ mockClient := new(MockKodiClient)
+ mockClient.On("PlayPause", mock.Anything).Return(nil)
+
+ var client kodi.KodiClient = mockClient
+
+ err := client.PlayPause(context.Background())
+ require.NoError(t, err)
+
+ mockClient.AssertExpectations(t)
+}
+
+func TestKodiClient_FastForward(t *testing.T) {
+ t.Parallel()
+
+ mockClient := new(MockKodiClient)
+ mockClient.On("FastForward", mock.Anything).Return(nil)
+
+ var client kodi.KodiClient = mockClient
+
+ err := client.FastForward(context.Background())
+ require.NoError(t, err)
+
+ mockClient.AssertExpectations(t)
+}
+
+func TestKodiClient_Rewind(t *testing.T) {
+ t.Parallel()
+
+ mockClient := new(MockKodiClient)
+ mockClient.On("Rewind", mock.Anything).Return(nil)
+
+ var client kodi.KodiClient = mockClient
+
+ err := client.Rewind(context.Background())
+ require.NoError(t, err)
+
+ mockClient.AssertExpectations(t)
+}
+
+func TestKodiClient_GoTo(t *testing.T) {
+ t.Parallel()
+
+ mockClient := new(MockKodiClient)
+ mockClient.On("GoTo", mock.Anything, "next").Return(nil)
+ mockClient.On("GoTo", mock.Anything, "previous").Return(nil)
+
+ var client kodi.KodiClient = mockClient
+
+ err := client.GoTo(context.Background(), "next")
+ require.NoError(t, err)
+
+ err = client.GoTo(context.Background(), "previous")
+ require.NoError(t, err)
+
+ mockClient.AssertExpectations(t)
+}
+
func TestKodiClient_CollectionLaunchMethods(t *testing.T) {
t.Parallel()
diff --git a/pkg/platforms/shared/kodi/launchers.go b/pkg/platforms/shared/kodi/launchers.go
index a69a863d..3cc7f096 100644
--- a/pkg/platforms/shared/kodi/launchers.go
+++ b/pkg/platforms/shared/kodi/launchers.go
@@ -29,11 +29,62 @@ import (
"github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms/shared"
)
+// kodiControls builds a Controls map for Kodi launchers with standard
+// playback controls. The launcherID and groups are captured by closures
+// so each control creates the correct Kodi client at invocation time.
+func kodiControls(
+ id string, groups []string,
+) map[string]platforms.Control {
+ cf := func(
+ fn func(context.Context, KodiClient) error,
+ ) platforms.ControlFunc {
+ return func(
+ ctx context.Context, cfg *config.Instance,
+ _ platforms.ControlParams,
+ ) error {
+ return fn(ctx, NewClientWithLauncherID(cfg, id, groups))
+ }
+ }
+ return map[string]platforms.Control{
+ platforms.ControlTogglePause: {
+ Func: cf(func(ctx context.Context, c KodiClient) error {
+ return c.PlayPause(ctx)
+ }),
+ },
+ platforms.ControlStop: {
+ Func: cf(func(ctx context.Context, c KodiClient) error {
+ return c.Stop(ctx)
+ }),
+ },
+ platforms.ControlFastForward: {
+ Func: cf(func(ctx context.Context, c KodiClient) error {
+ return c.FastForward(ctx)
+ }),
+ },
+ platforms.ControlRewind: {
+ Func: cf(func(ctx context.Context, c KodiClient) error {
+ return c.Rewind(ctx)
+ }),
+ },
+ platforms.ControlNext: {
+ Func: cf(func(ctx context.Context, c KodiClient) error {
+ return c.GoTo(ctx, "next")
+ }),
+ },
+ platforms.ControlPrevious: {
+ Func: cf(func(ctx context.Context, c KodiClient) error {
+ return c.GoTo(ctx, "previous")
+ }),
+ },
+ }
+}
+
// NewKodiLocalLauncher creates a standard KodiLocalVideo launcher for direct video file playback
func NewKodiLocalLauncher() platforms.Launcher {
id := shared.LauncherKodiLocalVideo
groups := []string{shared.GroupKodi}
return platforms.Launcher{
+ Controls: kodiControls(id, groups),
ID: id,
Groups: groups,
SystemID: systemdefs.SystemVideo,
@@ -56,6 +107,7 @@ func NewKodiMovieLauncher() platforms.Launcher {
id := shared.LauncherKodiMovie
groups := []string{shared.GroupKodi}
return platforms.Launcher{
+ Controls: kodiControls(id, groups),
ID: id,
Groups: groups,
SystemID: systemdefs.SystemMovie,
@@ -83,6 +135,7 @@ func NewKodiTVLauncher() platforms.Launcher {
id := shared.LauncherKodiTVEpisode
groups := []string{shared.GroupKodi, shared.GroupKodiTV}
return platforms.Launcher{
+ Controls: kodiControls(id, groups),
ID: id,
Groups: groups,
SystemID: systemdefs.SystemTVEpisode,
@@ -110,6 +163,7 @@ func NewKodiMusicLauncher() platforms.Launcher {
id := shared.LauncherKodiLocalAudio
groups := []string{shared.GroupKodi, shared.GroupKodiMusic}
return platforms.Launcher{
+ Controls: kodiControls(id, groups),
ID: id,
Groups: groups,
SystemID: systemdefs.SystemMusicTrack,
@@ -130,6 +184,7 @@ func NewKodiAlbumLauncher() platforms.Launcher {
id := shared.LauncherKodiAlbum
groups := []string{shared.GroupKodi, shared.GroupKodiMusic}
return platforms.Launcher{
+ Controls: kodiControls(id, groups),
ID: id,
Groups: groups,
SystemID: systemdefs.SystemMusicAlbum,
@@ -157,6 +212,7 @@ func NewKodiArtistLauncher() platforms.Launcher {
id := shared.LauncherKodiArtist
groups := []string{shared.GroupKodi, shared.GroupKodiMusic}
return platforms.Launcher{
+ Controls: kodiControls(id, groups),
ID: id,
Groups: groups,
SystemID: systemdefs.SystemMusicArtist,
@@ -184,6 +240,7 @@ func NewKodiTVShowLauncher() platforms.Launcher {
id := shared.LauncherKodiTVShow
groups := []string{shared.GroupKodi, shared.GroupKodiTV}
return platforms.Launcher{
+ Controls: kodiControls(id, groups),
ID: id,
Groups: groups,
SystemID: systemdefs.SystemTVShow,
@@ -211,6 +268,7 @@ func NewKodiSongLauncher() platforms.Launcher {
id := shared.LauncherKodiSong
groups := []string{shared.GroupKodi, shared.GroupKodiMusic}
return platforms.Launcher{
+ Controls: kodiControls(id, groups),
ID: id,
Groups: groups,
SystemID: systemdefs.SystemMusicTrack,
diff --git a/pkg/platforms/shared/kodi/launchers_test.go b/pkg/platforms/shared/kodi/launchers_test.go
index 99d50dae..92a8372a 100644
--- a/pkg/platforms/shared/kodi/launchers_test.go
+++ b/pkg/platforms/shared/kodi/launchers_test.go
@@ -23,10 +23,20 @@ import (
"testing"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/database/systemdefs"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms/shared"
"github.com/stretchr/testify/assert"
)
+var expectedControls = []string{
+ platforms.ControlTogglePause,
+ platforms.ControlStop,
+ platforms.ControlFastForward,
+ platforms.ControlRewind,
+ platforms.ControlNext,
+ platforms.ControlPrevious,
+}
+
// TestNewKodiLocalLauncher tests the creation of standard KodiLocal launcher
func TestNewKodiLocalLauncher(t *testing.T) {
t.Parallel()
@@ -45,6 +55,10 @@ func TestNewKodiLocalLauncher(t *testing.T) {
}
assert.Equal(t, expectedExtensions, launcher.Extensions)
assert.NotNil(t, launcher.Launch, "Launch function should be set")
+ assert.Len(t, launcher.Controls, 6)
+ for _, action := range expectedControls {
+ assert.Contains(t, launcher.Controls, action)
+ }
}
// TestNewKodiMovieLauncher tests the creation of standard KodiMovie launcher
@@ -58,6 +72,10 @@ func TestNewKodiMovieLauncher(t *testing.T) {
assert.Equal(t, []string{shared.SchemeKodiMovie}, launcher.Schemes)
assert.NotNil(t, launcher.Launch, "Launch function should be set")
assert.NotNil(t, launcher.Scanner, "Scanner function should be set")
+ assert.Len(t, launcher.Controls, 6)
+ for _, action := range expectedControls {
+ assert.Contains(t, launcher.Controls, action)
+ }
}
// TestNewKodiTVLauncher tests the creation of standard KodiTV launcher
@@ -71,6 +89,10 @@ func TestNewKodiTVLauncher(t *testing.T) {
assert.Equal(t, []string{shared.SchemeKodiEpisode}, launcher.Schemes)
assert.NotNil(t, launcher.Launch, "Launch function should be set")
assert.NotNil(t, launcher.Scanner, "Scanner function should be set")
+ assert.Len(t, launcher.Controls, 6)
+ for _, action := range expectedControls {
+ assert.Contains(t, launcher.Controls, action)
+ }
}
// TestNewKodiSongLauncher tests the creation of KodiSong launcher for individual songs
@@ -85,6 +107,10 @@ func TestNewKodiSongLauncher(t *testing.T) {
assert.NotNil(t, launcher.Launch, "Launch function should be set")
// Scanner will be tested when scanners are implemented
// assert.NotNil(t, launcher.Scanner, "Scanner function should be set")
+ assert.Len(t, launcher.Controls, 6)
+ for _, action := range expectedControls {
+ assert.Contains(t, launcher.Controls, action)
+ }
}
// TestNewKodiMusicLauncher tests the creation of KodiMusic launcher for local music files
@@ -102,6 +128,10 @@ func TestNewKodiMusicLauncher(t *testing.T) {
assert.Contains(t, launcher.Extensions, ".m4a")
assert.NotNil(t, launcher.Launch, "Launch function should be set")
assert.Nil(t, launcher.Scanner, "Scanner function should not be set for local files")
+ assert.Len(t, launcher.Controls, 6)
+ for _, action := range expectedControls {
+ assert.Contains(t, launcher.Controls, action)
+ }
}
// TestNewKodiAlbumLauncher tests the creation of KodiAlbum launcher for album collection playback
@@ -115,6 +145,10 @@ func TestNewKodiAlbumLauncher(t *testing.T) {
assert.Equal(t, []string{shared.SchemeKodiAlbum}, launcher.Schemes)
assert.NotNil(t, launcher.Launch, "Launch function should be set")
assert.NotNil(t, launcher.Scanner, "Scanner function should be set for collection")
+ assert.Len(t, launcher.Controls, 6)
+ for _, action := range expectedControls {
+ assert.Contains(t, launcher.Controls, action)
+ }
}
// TestNewKodiArtistLauncher tests the creation of KodiArtist launcher for artist collection playback
@@ -128,6 +162,10 @@ func TestNewKodiArtistLauncher(t *testing.T) {
assert.Equal(t, []string{shared.SchemeKodiArtist}, launcher.Schemes)
assert.NotNil(t, launcher.Launch, "Launch function should be set")
assert.NotNil(t, launcher.Scanner, "Scanner function should be set for collection")
+ assert.Len(t, launcher.Controls, 6)
+ for _, action := range expectedControls {
+ assert.Contains(t, launcher.Controls, action)
+ }
}
// TestNewKodiTVShowLauncher tests the creation of KodiTVShow launcher for TV show collection playback
@@ -141,4 +179,8 @@ func TestNewKodiTVShowLauncher(t *testing.T) {
assert.Equal(t, []string{shared.SchemeKodiShow}, launcher.Schemes)
assert.NotNil(t, launcher.Launch, "Launch function should be set")
assert.NotNil(t, launcher.Scanner, "Scanner function should be set for collection")
+ assert.Len(t, launcher.Controls, 6)
+ for _, action := range expectedControls {
+ assert.Contains(t, launcher.Controls, action)
+ }
}
diff --git a/pkg/platforms/shared/kodi/scanner_test.go b/pkg/platforms/shared/kodi/scanner_test.go
index e83b9eab..b30529ba 100644
--- a/pkg/platforms/shared/kodi/scanner_test.go
+++ b/pkg/platforms/shared/kodi/scanner_test.go
@@ -62,8 +62,8 @@ func (m *MockKodiClient) LaunchTVEpisode(path string) error {
return nil
}
-func (m *MockKodiClient) Stop() error {
- args := m.Called()
+func (m *MockKodiClient) Stop(ctx context.Context) error {
+ args := m.Called(ctx)
if err := args.Error(0); err != nil {
return fmt.Errorf("mock Stop error: %w", err)
}
@@ -172,6 +172,38 @@ func (m *MockKodiClient) APIRequest(ctx context.Context, method APIMethod, param
return nil, nil
}
+func (m *MockKodiClient) PlayPause(ctx context.Context) error {
+ args := m.Called(ctx)
+ if err := args.Error(0); err != nil {
+ return fmt.Errorf("mock PlayPause error: %w", err)
+ }
+ return nil
+}
+
+func (m *MockKodiClient) FastForward(ctx context.Context) error {
+ args := m.Called(ctx)
+ if err := args.Error(0); err != nil {
+ return fmt.Errorf("mock FastForward error: %w", err)
+ }
+ return nil
+}
+
+func (m *MockKodiClient) Rewind(ctx context.Context) error {
+ args := m.Called(ctx)
+ if err := args.Error(0); err != nil {
+ return fmt.Errorf("mock Rewind error: %w", err)
+ }
+ return nil
+}
+
+func (m *MockKodiClient) GoTo(ctx context.Context, direction string) error {
+ args := m.Called(ctx, direction)
+ if err := args.Error(0); err != nil {
+ return fmt.Errorf("mock GoTo error: %w", err)
+ }
+ return nil
+}
+
func (m *MockKodiClient) LaunchSong(path string) error {
args := m.Called(path)
if err := args.Error(0); err != nil {
diff --git a/pkg/platforms/shared/kodi/types.go b/pkg/platforms/shared/kodi/types.go
index cad0e5e3..9d074762 100644
--- a/pkg/platforms/shared/kodi/types.go
+++ b/pkg/platforms/shared/kodi/types.go
@@ -99,6 +99,11 @@ const (
APIMethodPlaylistClear APIMethod = "Playlist.Clear"
APIMethodPlaylistAdd APIMethod = "Playlist.Add"
+ // Player Controls
+ APIMethodPlayerPlayPause APIMethod = "Player.PlayPause"
+ APIMethodPlayerSetSpeed APIMethod = "Player.SetSpeed"
+ APIMethodPlayerGoTo APIMethod = "Player.GoTo"
+
// Application Control
APIMethodApplicationQuit APIMethod = "Application.Quit"
)
@@ -154,6 +159,23 @@ type PlayerStopParams struct {
PlayerID int `json:"playerid"`
}
+// PlayerPlayPauseParams represents parameters for Player.PlayPause API method
+type PlayerPlayPauseParams struct {
+ PlayerID int `json:"playerid"`
+}
+
+// PlayerSetSpeedParams represents parameters for Player.SetSpeed API method
+type PlayerSetSpeedParams struct {
+ Speed string `json:"speed"`
+ PlayerID int `json:"playerid"`
+}
+
+// PlayerGoToParams represents parameters for Player.GoTo API method
+type PlayerGoToParams struct {
+ To string `json:"to"`
+ PlayerID int `json:"playerid"`
+}
+
// VideoLibraryGetMoviesResponse represents the response from VideoLibrary.GetMovies
type VideoLibraryGetMoviesResponse struct {
Movies []Movie `json:"movies"`
diff --git a/pkg/platforms/steamos/emudeck.go b/pkg/platforms/steamos/emudeck.go
index d1de04e7..7a355cd7 100644
--- a/pkg/platforms/steamos/emudeck.go
+++ b/pkg/platforms/steamos/emudeck.go
@@ -448,9 +448,31 @@ func LaunchViaEmuDeck(ctx context.Context, romPath, systemFolder string) (*os.Pr
return cmd.Process, nil
}
+// retroArchControls returns the control map for RetroArch-based launchers
+// using RetroArch's default keyboard hotkeys.
+func retroArchControls() map[string]platforms.Control {
+ return map[string]platforms.Control{
+ platforms.ControlSaveState: {Script: "**input.keyboard:{f2}"},
+ platforms.ControlLoadState: {Script: "**input.keyboard:{f4}"},
+ platforms.ControlToggleMenu: {Script: "**input.keyboard:{f1}"},
+ platforms.ControlTogglePause: {Script: "**input.keyboard:p"},
+ platforms.ControlReset: {Script: "**input.keyboard:{f9}"},
+ platforms.ControlFastForward: {Script: "**input.keyboard:l"},
+ platforms.ControlStop: {Script: "**stop"},
+ }
+}
+
// createEmuDeckLauncher creates a launcher for a specific EmuDeck system.
func createEmuDeckLauncher(systemFolder string, systemInfo esde.SystemInfo, paths EmuDeckPaths) platforms.Launcher {
+ emulator := emulatorMapping[systemFolder]
+
+ var controls map[string]platforms.Control
+ if emulator.Type == EmulatorRetroArch {
+ controls = retroArchControls()
+ }
+
return platforms.Launcher{
+ Controls: controls,
ID: "EmuDeck" + systemInfo.GetLauncherID(),
SystemID: systemInfo.SystemID,
Lifecycle: platforms.LifecycleTracked,
diff --git a/pkg/platforms/steamos/launchers_test.go b/pkg/platforms/steamos/launchers_test.go
index 04a1b0ef..83457c0b 100644
--- a/pkg/platforms/steamos/launchers_test.go
+++ b/pkg/platforms/steamos/launchers_test.go
@@ -26,6 +26,7 @@ import (
"path/filepath"
"testing"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms/shared/esde"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -282,6 +283,84 @@ func TestRetroDECKLauncherID(t *testing.T) {
assert.Contains(t, launcher.ID, "RetroDECK")
}
+// TestEmuDeckRetroArchLauncherHasControls tests that RetroArch-based EmuDeck
+// launchers have playback controls set.
+func TestEmuDeckRetroArchLauncherHasControls(t *testing.T) {
+ t.Parallel()
+
+ paths := EmuDeckPaths{
+ RomsPath: "/home/testuser/Emulation/roms",
+ GamelistPath: "/home/testuser/ES-DE/gamelists",
+ }
+
+ systemInfo := esde.SystemInfo{
+ SystemID: "nes",
+ }
+
+ launcher := createEmuDeckLauncher("nes", systemInfo, paths)
+
+ expectedControls := []string{
+ platforms.ControlSaveState,
+ platforms.ControlLoadState,
+ platforms.ControlToggleMenu,
+ platforms.ControlTogglePause,
+ platforms.ControlReset,
+ platforms.ControlFastForward,
+ platforms.ControlStop,
+ }
+
+ require.NotNil(t, launcher.Controls, "RetroArch launcher should have controls")
+ for _, name := range expectedControls {
+ ctrl, ok := launcher.Controls[name]
+ assert.True(t, ok, "should have control: %s", name)
+ assert.NotEmpty(t, ctrl.Script, "control %s should have a script", name)
+ }
+}
+
+// TestEmuDeckStandaloneLauncherHasNoControls tests that standalone emulator
+// launchers do not have controls set.
+func TestEmuDeckStandaloneLauncherHasNoControls(t *testing.T) {
+ t.Parallel()
+
+ paths := EmuDeckPaths{
+ RomsPath: "/home/testuser/Emulation/roms",
+ GamelistPath: "/home/testuser/ES-DE/gamelists",
+ }
+
+ systemInfo := esde.SystemInfo{
+ SystemID: "psx",
+ }
+
+ launcher := createEmuDeckLauncher("psx", systemInfo, paths)
+
+ assert.Nil(t, launcher.Controls, "standalone launcher should not have controls")
+}
+
+// TestRetroArchControls tests the retroArchControls helper returns the
+// expected control scripts.
+func TestRetroArchControls(t *testing.T) {
+ t.Parallel()
+
+ controls := retroArchControls()
+
+ expected := map[string]string{
+ platforms.ControlSaveState: "**input.keyboard:{f2}",
+ platforms.ControlLoadState: "**input.keyboard:{f4}",
+ platforms.ControlToggleMenu: "**input.keyboard:{f1}",
+ platforms.ControlTogglePause: "**input.keyboard:p",
+ platforms.ControlReset: "**input.keyboard:{f9}",
+ platforms.ControlFastForward: "**input.keyboard:l",
+ platforms.ControlStop: "**stop",
+ }
+
+ assert.Len(t, controls, len(expected))
+ for name, script := range expected {
+ ctrl, ok := controls[name]
+ assert.True(t, ok, "missing control: %s", name)
+ assert.Equal(t, script, ctrl.Script, "wrong script for control: %s", name)
+ }
+}
+
// TestGetRetroArchCoresPath tests the RetroArch cores path function.
func TestGetRetroArchCoresPath(t *testing.T) {
t.Parallel()
diff --git a/pkg/readers/externaldrive/externaldrive_test.go b/pkg/readers/externaldrive/externaldrive_test.go
index 3d8e9674..1450cc42 100644
--- a/pkg/readers/externaldrive/externaldrive_test.go
+++ b/pkg/readers/externaldrive/externaldrive_test.go
@@ -47,6 +47,7 @@ const (
// testContext returns a context with the specified timeout for test synchronization.
// Using context instead of raw time.After provides better semantics and cancellation support.
func testContext(timeout time.Duration) (context.Context, context.CancelFunc) {
+ //nolint:gosec // G118: caller is responsible for cancel
return context.WithTimeout(context.Background(), timeout)
}
diff --git a/pkg/readers/libnfc/tags/mifare.go b/pkg/readers/libnfc/tags/mifare.go
index 613fd629..87a648b9 100644
--- a/pkg/readers/libnfc/tags/mifare.go
+++ b/pkg/readers/libnfc/tags/mifare.go
@@ -42,14 +42,15 @@ const (
// buildMifareAuthCommand returns a command to authenticate against a block
func buildMifareAuthCommand(block byte, cardUID string) []byte {
- command := []byte{
+ uidBytes, _ := hex.DecodeString(cardUID)
+ command := make([]byte, 0, 8+len(uidBytes))
+ command = append(command,
// Auth using key A
0x60, block,
// Using the NDEF well known private key
0xd3, 0xf7, 0xd3, 0xf7, 0xd3, 0xf7,
- }
+ )
// And finally append the tag UID to the end
- uidBytes, _ := hex.DecodeString(cardUID)
return append(command, uidBytes...)
}
diff --git a/pkg/readers/shared/ndef/ndef.go b/pkg/readers/shared/ndef/ndef.go
index 20cd6332..11b3f337 100644
--- a/pkg/readers/shared/ndef/ndef.go
+++ b/pkg/readers/shared/ndef/ndef.go
@@ -76,11 +76,12 @@ func calculateNDEFHeader(payload []byte) ([]byte, error) {
return nil, errors.New("NDEF payload too large")
}
- header := []byte{0x03, 0xFF}
buf := new(bytes.Buffer)
if err := binary.Write(buf, binary.BigEndian, uint16(length)); err != nil {
return nil, fmt.Errorf("failed to write NDEF length header: %w", err)
}
+ header := make([]byte, 0, 2+buf.Len())
+ header = append(header, 0x03, 0xFF)
return append(header, buf.Bytes()...), nil
}
diff --git a/pkg/service/context.go b/pkg/service/context.go
new file mode 100644
index 00000000..2b522d84
--- /dev/null
+++ b/pkg/service/context.go
@@ -0,0 +1,40 @@
+// 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 service
+
+import (
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/config"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/database"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/playlists"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/state"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/tokens"
+)
+
+// ServiceContext holds the shared dependencies threaded through all
+// service-layer functions. Created once in Start() and passed by pointer.
+type ServiceContext struct {
+ Platform platforms.Platform
+ Config *config.Instance
+ State *state.State
+ DB *database.Database
+ LaunchSoftwareQueue chan *tokens.Token
+ PlaylistQueue chan *playlists.Playlist
+}
diff --git a/pkg/service/hooks.go b/pkg/service/hooks.go
index 2fea3c12..5c82e04a 100644
--- a/pkg/service/hooks.go
+++ b/pkg/service/hooks.go
@@ -22,11 +22,8 @@ package service
import (
"time"
- "github.com/ZaparooProject/zaparoo-core/v2/pkg/config"
- "github.com/ZaparooProject/zaparoo-core/v2/pkg/database"
- "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms"
+ gozapscript "github.com/ZaparooProject/go-zapscript"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/service/playlists"
- "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/state"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/service/tokens"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/zapscript"
"github.com/rs/zerolog/log"
@@ -34,22 +31,19 @@ import (
// runHook executes a hook script with the standard playlist from state.
// Returns error if the script fails (for blocking hooks) or nil on success.
+// The scanned/launching params provide optional context for the expression env.
func runHook(
- pl platforms.Platform,
- cfg *config.Instance,
- st *state.State,
- db *database.Database,
- lsq chan<- *tokens.Token,
- plq chan *playlists.Playlist,
+ svc *ServiceContext,
hookName string,
script string,
- exprOpts *zapscript.ExprEnvOptions,
+ scanned *gozapscript.ExprEnvScanned,
+ launching *gozapscript.ExprEnvLaunching,
) error {
log.Info().Msgf("running %s: %s", hookName, script)
plsc := playlists.PlaylistController{
- Active: st.GetActivePlaylist(),
- Queue: plq,
+ Active: svc.State.GetActivePlaylist(),
+ Queue: svc.PlaylistQueue,
}
t := tokens.Token{
@@ -58,12 +52,6 @@ func runHook(
Source: tokens.SourceHook,
}
- // Ensure InHookContext is set to prevent recursive hook execution
- hookOpts := &zapscript.ExprEnvOptions{InHookContext: true}
- if exprOpts != nil {
- hookOpts.Scanned = exprOpts.Scanned
- hookOpts.Launching = exprOpts.Launching
- }
-
- return runTokenZapScript(pl, cfg, st, t, db, lsq, plsc, hookOpts)
+ hookEnv := zapscript.GetExprEnv(svc.Platform, svc.Config, svc.State, scanned, launching)
+ return runTokenZapScript(svc, t, plsc, &hookEnv, true)
}
diff --git a/pkg/service/hooks_test.go b/pkg/service/hooks_test.go
index d9ef5270..f0456062 100644
--- a/pkg/service/hooks_test.go
+++ b/pkg/service/hooks_test.go
@@ -23,29 +23,18 @@ import (
"testing"
gozapscript "github.com/ZaparooProject/go-zapscript"
- "github.com/ZaparooProject/zaparoo-core/v2/pkg/config"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/database"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/service/playlists"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/service/state"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/service/tokens"
testhelpers "github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/helpers"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/mocks"
- "github.com/ZaparooProject/zaparoo-core/v2/pkg/zapscript"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
-type hookTestEnv struct {
- platform *mocks.MockPlatform
- cfg *config.Instance
- st *state.State
- db *database.Database
- lsq chan *tokens.Token
- plq chan *playlists.Playlist
-}
-
-func setupHookTest(t *testing.T) *hookTestEnv {
+func setupHookTest(t *testing.T) *ServiceContext {
t.Helper()
fs := testhelpers.NewMemoryFS()
@@ -61,135 +50,119 @@ func setupHookTest(t *testing.T) *hookTestEnv {
mockMediaDB := &testhelpers.MockMediaDBI{}
- db := &database.Database{
- UserDB: mockUserDB,
- MediaDB: mockMediaDB,
- }
-
st, _ := state.NewState(mockPlatform, "test-boot-uuid")
- return &hookTestEnv{
- platform: mockPlatform,
- cfg: cfg,
- st: st,
- db: db,
- lsq: make(chan *tokens.Token, 1),
- plq: make(chan *playlists.Playlist, 1),
+ return &ServiceContext{
+ Platform: mockPlatform,
+ Config: cfg,
+ State: st,
+ DB: &database.Database{
+ UserDB: mockUserDB,
+ MediaDB: mockMediaDB,
+ },
+ LaunchSoftwareQueue: make(chan *tokens.Token, 1),
+ PlaylistQueue: make(chan *playlists.Playlist, 1),
}
}
-func (e *hookTestEnv) runHook(hookName, script string, opts *zapscript.ExprEnvOptions) error {
- return runHook(e.platform, e.cfg, e.st, e.db, e.lsq, e.plq, hookName, script, opts)
-}
-
func TestRunHook_BasicExecution(t *testing.T) {
t.Parallel()
- env := setupHookTest(t)
+ svc := setupHookTest(t)
- err := env.runHook("test_hook", "**echo:test message", nil)
+ err := runHook(svc, "test_hook", "**echo:test message", nil, nil)
assert.NoError(t, err, "echo hook should succeed")
}
func TestRunHook_WithScannedContext(t *testing.T) {
t.Parallel()
- env := setupHookTest(t)
+ svc := setupHookTest(t)
- scannedOpts := &zapscript.ExprEnvOptions{
- Scanned: &gozapscript.ExprEnvScanned{
- ID: "test-token-id",
- Value: "**launch:/games/sonic.bin",
- Data: "raw-ndef-data",
- },
+ scanned := &gozapscript.ExprEnvScanned{
+ ID: "test-token-id",
+ Value: "**launch:/games/sonic.bin",
+ Data: "raw-ndef-data",
}
- err := env.runHook("on_scan", "**echo:scanned", scannedOpts)
+ err := runHook(svc, "on_scan", "**echo:scanned", scanned, nil)
assert.NoError(t, err, "hook with scanned context should succeed")
}
func TestRunHook_WithLaunchingContext(t *testing.T) {
t.Parallel()
- env := setupHookTest(t)
+ svc := setupHookTest(t)
- launchingOpts := &zapscript.ExprEnvOptions{
- Launching: &gozapscript.ExprEnvLaunching{
- Path: "/games/genesis/sonic.bin",
- SystemID: "genesis",
- LauncherID: "retroarch",
- },
+ launching := &gozapscript.ExprEnvLaunching{
+ Path: "/games/genesis/sonic.bin",
+ SystemID: "genesis",
+ LauncherID: "retroarch",
}
- err := env.runHook("before_media_start", "**echo:launching", launchingOpts)
+ err := runHook(svc, "before_media_start", "**echo:launching", nil, launching)
assert.NoError(t, err, "hook with launching context should succeed")
}
-func TestRunHook_SetsInHookContext(t *testing.T) {
+func TestRunHook_AlwaysInHookContext(t *testing.T) {
t.Parallel()
- env := setupHookTest(t)
+ svc := setupHookTest(t)
- // Pass opts without InHookContext set - runHook should set it internally
- opts := &zapscript.ExprEnvOptions{
- Scanned: &gozapscript.ExprEnvScanned{
- ID: "test-id",
- },
- InHookContext: false, // Should be overridden to true by runHook
+ // Hooks always run in hook context (inHookContext=true), which means
+ // before_media_start hooks inside hooks are blocked (no recursion).
+ scanned := &gozapscript.ExprEnvScanned{
+ ID: "test-id",
}
- err := env.runHook("test_hook", "**echo:test", opts)
+ err := runHook(svc, "test_hook", "**echo:test", scanned, nil)
assert.NoError(t, err)
- // The function always creates new opts with InHookContext=true, preserving other fields
}
func TestRunHook_PreservesScannedAndLaunching(t *testing.T) {
t.Parallel()
- env := setupHookTest(t)
+ svc := setupHookTest(t)
- // Provide both Scanned and Launching contexts
- opts := &zapscript.ExprEnvOptions{
- Scanned: &gozapscript.ExprEnvScanned{
- ID: "scanned-id",
- Value: "scanned-value",
- Data: "scanned-data",
- },
- Launching: &gozapscript.ExprEnvLaunching{
- Path: "/path/to/game",
- SystemID: "snes",
- LauncherID: "mister",
- },
+ scanned := &gozapscript.ExprEnvScanned{
+ ID: "scanned-id",
+ Value: "scanned-value",
+ Data: "scanned-data",
+ }
+ launching := &gozapscript.ExprEnvLaunching{
+ Path: "/path/to/game",
+ SystemID: "snes",
+ LauncherID: "mister",
}
- err := env.runHook("combined_hook", "**echo:both contexts", opts)
+ err := runHook(svc, "combined_hook", "**echo:both contexts", scanned, launching)
assert.NoError(t, err, "hook with both contexts should succeed")
}
func TestRunHook_InvalidScript(t *testing.T) {
t.Parallel()
- env := setupHookTest(t)
+ svc := setupHookTest(t)
- err := env.runHook("test_hook", "**unknown_command:arg", nil)
+ err := runHook(svc, "test_hook", "**unknown_command:arg", nil, nil)
assert.Error(t, err, "unknown command should return error")
}
func TestRunHook_EmptyScript(t *testing.T) {
t.Parallel()
- env := setupHookTest(t)
+ svc := setupHookTest(t)
- err := env.runHook("test_hook", "", nil)
+ err := runHook(svc, "test_hook", "", nil, nil)
require.Error(t, err, "empty script should return error")
assert.Contains(t, err.Error(), "script is empty")
}
-func TestRunHook_NilExprOpts(t *testing.T) {
+func TestRunHook_NilContextParams(t *testing.T) {
t.Parallel()
- env := setupHookTest(t)
+ svc := setupHookTest(t)
- err := env.runHook("test_hook", "**echo:nil opts test", nil)
- assert.NoError(t, err, "nil exprOpts should work")
+ err := runHook(svc, "test_hook", "**echo:nil opts test", nil, nil)
+ assert.NoError(t, err, "nil scanned/launching should work")
}
diff --git a/pkg/service/queues.go b/pkg/service/queues.go
index baf9b2f0..b723a303 100644
--- a/pkg/service/queues.go
+++ b/pkg/service/queues.go
@@ -29,14 +29,11 @@ import (
"github.com/ZaparooProject/zaparoo-core/v2/pkg/api/notifications"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/assets"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/audio"
- "github.com/ZaparooProject/zaparoo-core/v2/pkg/config"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/database"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers"
- "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/readers"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/service/playlists"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/service/playtime"
- "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/state"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/service/tokens"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/zapscript"
"github.com/google/uuid"
@@ -45,21 +42,18 @@ import (
)
func runTokenZapScript(
- platform platforms.Platform,
- cfg *config.Instance,
- st *state.State,
+ svc *ServiceContext,
token tokens.Token, //nolint:gocritic // single-use parameter in service function
- db *database.Database,
- lsq chan<- *tokens.Token,
plsc playlists.PlaylistController,
- exprOpts *zapscript.ExprEnvOptions,
+ exprEnv *gozapscript.ArgExprEnv,
+ inHookContext bool,
) error {
- if !st.RunZapScriptEnabled() {
+ if !svc.State.RunZapScriptEnabled() {
log.Warn().Msg("ignoring ZapScript, run ZapScript is disabled")
return nil
}
- mappedValue, hasMapping := getMapping(cfg, db, platform, token)
+ mappedValue, hasMapping := getMapping(svc.Config, svc.DB, svc.Platform, token)
if hasMapping {
log.Info().Msgf("found mapping: %s", mappedValue)
token.Text = mappedValue
@@ -80,8 +74,8 @@ func runTokenZapScript(
cmd := cmds[i]
// Run before_media_start hook; errors block the launch.
- beforeMediaStartScript := cfg.LaunchersBeforeMediaStart()
- if shouldRunBeforeMediaStartHook(exprOpts, beforeMediaStartScript, cmd.Name) {
+ beforeMediaStartScript := svc.Config.LaunchersBeforeMediaStart()
+ if shouldRunBeforeMediaStartHook(inHookContext, beforeMediaStartScript, cmd.Name) {
log.Info().Msgf("running before_media_start hook: %s", beforeMediaStartScript)
hookPlsc := playlists.PlaylistController{
Active: pls,
@@ -91,15 +85,23 @@ func runTokenZapScript(
ScanTime: time.Now(),
Text: beforeMediaStartScript,
}
- launchingOpts := buildLaunchingExprOpts(cmd)
- hookErr := runTokenZapScript(platform, cfg, st, hookToken, db, lsq, hookPlsc, launchingOpts)
+ launching := buildLaunchingContext(cmd)
+ hookEnv := zapscript.GetExprEnv(svc.Platform, svc.Config, svc.State, nil, launching)
+ hookErr := runTokenZapScript(svc, hookToken, hookPlsc, &hookEnv, true)
if hookErr != nil {
return fmt.Errorf("before_media_start hook blocked launch: %w", hookErr)
}
}
+ var cmdEnv gozapscript.ArgExprEnv
+ if exprEnv != nil {
+ cmdEnv = *exprEnv
+ } else {
+ cmdEnv = zapscript.GetExprEnv(svc.Platform, svc.Config, svc.State, nil, nil)
+ }
+
result, err := zapscript.RunCommand(
- platform, cfg,
+ svc.Platform, svc.Config,
playlists.PlaylistController{
Active: pls,
Queue: plsc.Queue,
@@ -108,9 +110,9 @@ func runTokenZapScript(
cmd,
len(script.Cmds),
i,
- db,
- st,
- exprOpts,
+ svc.DB,
+ svc.State.LauncherManager(),
+ &cmdEnv,
)
if err != nil {
return fmt.Errorf("failed to run zapscript command: %w", err)
@@ -120,17 +122,18 @@ func runTokenZapScript(
log.Debug().Any("token", token).Msg("cmd launch: clearing current playlist")
select {
case plsc.Queue <- nil:
- case <-st.GetContext().Done():
+ case <-svc.State.GetContext().Done():
return errors.New("service shutting down")
}
}
if result.MediaChanged && token.ReaderID != "" {
- if r, ok := st.GetReader(token.ReaderID); ok && readers.HasCapability(r, readers.CapabilityRemovable) {
+ r, ok := svc.State.GetReader(token.ReaderID)
+ if ok && readers.HasCapability(r, readers.CapabilityRemovable) {
log.Debug().Any("token", token).Msg("media changed, updating software token")
select {
- case lsq <- &token:
- case <-st.GetContext().Done():
+ case svc.LaunchSoftwareQueue <- &token:
+ case <-svc.State.GetContext().Done():
return errors.New("service shutting down")
}
}
@@ -157,13 +160,8 @@ func runTokenZapScript(
}
func launchPlaylistMedia(
- platform platforms.Platform,
- cfg *config.Instance,
- st *state.State,
- db *database.Database,
- lsq chan<- *tokens.Token,
+ svc *ServiceContext,
pls *playlists.Playlist,
- plq chan<- *playlists.Playlist,
activePlaylist *playlists.Playlist,
player audio.Player,
) {
@@ -174,13 +172,13 @@ func launchPlaylistMedia(
}
plsc := playlists.PlaylistController{
Active: activePlaylist,
- Queue: plq,
+ Queue: svc.PlaylistQueue,
}
- err := runTokenZapScript(platform, cfg, st, t, db, lsq, plsc, nil)
+ err := runTokenZapScript(svc, t, plsc, nil, false)
if err != nil {
log.Error().Err(err).Msgf("error launching token")
- path, enabled := cfg.FailSoundPath(helpers.DataDir(platform))
+ path, enabled := svc.Config.FailSoundPath(helpers.DataDir(svc.Platform))
helpers.PlayConfiguredSound(player, path, enabled, assets.FailSound, "fail")
}
@@ -200,28 +198,23 @@ func launchPlaylistMedia(
TokenValue: t.Text,
TokenData: t.Data,
ClockReliable: helpers.IsClockReliable(now),
- BootUUID: st.BootUUID(),
+ BootUUID: svc.State.BootUUID(),
MonotonicStart: monotonicStart,
CreatedAt: now,
}
he.Success = err == nil
- err = db.UserDB.AddHistory(&he)
+ err = svc.DB.UserDB.AddHistory(&he)
if err != nil {
log.Error().Err(err).Msgf("error adding history")
}
}
func handlePlaylist(
- cfg *config.Instance,
- pl platforms.Platform,
- db *database.Database,
- st *state.State,
+ svc *ServiceContext,
pls *playlists.Playlist,
- lsq chan<- *tokens.Token,
- plq chan<- *playlists.Playlist,
player audio.Player,
) {
- activePlaylist := st.GetActivePlaylist()
+ activePlaylist := svc.State.GetActivePlaylist()
switch {
case pls == nil:
@@ -229,14 +222,14 @@ func handlePlaylist(
if activePlaylist != nil {
log.Info().Msg("clearing playlist")
}
- st.SetActivePlaylist(nil)
+ svc.State.SetActivePlaylist(nil)
return
case activePlaylist == nil:
// new playlist loaded
- st.SetActivePlaylist(pls)
+ svc.State.SetActivePlaylist(pls)
if pls.Playing {
log.Info().Any("pls", pls).Msg("setting new playlist, launching token")
- go launchPlaylistMedia(pl, cfg, st, db, lsq, pls, plq, activePlaylist, player)
+ go launchPlaylistMedia(svc, pls, activePlaylist, player)
} else {
log.Info().Any("pls", pls).Msg("setting new playlist")
}
@@ -248,10 +241,10 @@ func handlePlaylist(
return
}
- st.SetActivePlaylist(pls)
+ svc.State.SetActivePlaylist(pls)
if pls.Playing {
log.Info().Any("pls", pls).Msg("updating playlist, launching token")
- go launchPlaylistMedia(pl, cfg, st, db, lsq, pls, plq, activePlaylist, player)
+ go launchPlaylistMedia(svc, pls, activePlaylist, player)
} else {
log.Info().Any("pls", pls).Msg("updating playlist")
}
@@ -260,20 +253,15 @@ func handlePlaylist(
}
func processTokenQueue(
- platform platforms.Platform,
- cfg *config.Instance,
- st *state.State,
+ svc *ServiceContext,
itq <-chan tokens.Token,
- db *database.Database,
- lsq chan<- *tokens.Token,
- plq chan *playlists.Playlist,
limitsManager *playtime.LimitsManager,
player audio.Player,
) {
for {
select {
- case pls := <-plq:
- handlePlaylist(cfg, platform, db, st, pls, lsq, plq, player)
+ case pls := <-svc.PlaylistQueue:
+ handlePlaylist(svc, pls, player)
continue
case t := <-itq:
// TODO: change this channel to send a token pointer or something
@@ -284,10 +272,10 @@ func processTokenQueue(
log.Info().Msgf("processing token: %v", t)
- path, enabled := cfg.SuccessSoundPath(helpers.DataDir(platform))
+ path, enabled := svc.Config.SuccessSoundPath(helpers.DataDir(svc.Platform))
helpers.PlayConfiguredSound(player, path, enabled, assets.SuccessSound, "success")
- err := platform.ScanHook(&t)
+ err := svc.Platform.ScanHook(&t)
if err != nil {
log.Error().Err(err).Msgf("error writing tmp scan result")
}
@@ -308,14 +296,14 @@ func processTokenQueue(
TokenValue: t.Text,
TokenData: t.Data,
ClockReliable: helpers.IsClockReliable(now),
- BootUUID: st.BootUUID(),
+ BootUUID: svc.State.BootUUID(),
MonotonicStart: monotonicStart,
CreatedAt: now,
}
// Parse script early to check if playtime limits apply
// Only block media-launching commands, not utility commands (execute, delay, echo, etc.)
- mappedValue, hasMapping := getMapping(cfg, db, platform, t)
+ mappedValue, hasMapping := getMapping(svc.Config, svc.DB, svc.Platform, t)
scriptText := t.Text
if hasMapping {
scriptText = mappedValue
@@ -337,15 +325,15 @@ func processTokenQueue(
log.Warn().Err(limitErr).Msg("playtime: launch blocked by daily limit")
// Send playtime limit notification
- notifications.PlaytimeLimitReached(st.Notifications, models.PlaytimeLimitReachedParams{
+ notifications.PlaytimeLimitReached(svc.State.Notifications, models.PlaytimeLimitReachedParams{
Reason: models.PlaytimeLimitReasonDaily,
})
- path, enabled := cfg.LimitSoundPath(helpers.DataDir(platform))
+ path, enabled := svc.Config.LimitSoundPath(helpers.DataDir(svc.Platform))
helpers.PlayConfiguredSound(player, path, enabled, assets.LimitSound, "limit")
he.Success = false
- if histErr := db.UserDB.AddHistory(&he); histErr != nil {
+ if histErr := svc.DB.UserDB.AddHistory(&he); histErr != nil {
log.Error().Err(histErr).Msgf("error adding history")
}
@@ -359,11 +347,11 @@ func processTokenQueue(
// launch tokens in a separate thread
go func() {
plsc := playlists.PlaylistController{
- Active: st.GetActivePlaylist(),
- Queue: plq,
+ Active: svc.State.GetActivePlaylist(),
+ Queue: svc.PlaylistQueue,
}
- err = runTokenZapScript(platform, cfg, st, t, db, lsq, plsc, nil)
+ err = runTokenZapScript(svc, t, plsc, nil, false)
if err != nil {
if errors.Is(err, zapscript.ErrFileNotFound) {
log.Warn().Err(err).Msgf("error launching token")
@@ -373,17 +361,17 @@ func processTokenQueue(
}
if err != nil {
- path, enabled := cfg.FailSoundPath(helpers.DataDir(platform))
+ path, enabled := svc.Config.FailSoundPath(helpers.DataDir(svc.Platform))
helpers.PlayConfiguredSound(player, path, enabled, assets.FailSound, "fail")
}
he.Success = err == nil
- err = db.UserDB.AddHistory(&he)
+ err = svc.DB.UserDB.AddHistory(&he)
if err != nil {
log.Error().Err(err).Msgf("error adding history")
}
}()
- case <-st.GetContext().Done():
+ case <-svc.State.GetContext().Done():
log.Debug().Msg("exiting service worker via context cancellation")
return
}
diff --git a/pkg/service/queues_helpers.go b/pkg/service/queues_helpers.go
index 290f943f..dfc2105a 100644
--- a/pkg/service/queues_helpers.go
+++ b/pkg/service/queues_helpers.go
@@ -31,35 +31,30 @@ import (
// - A hook script is configured (non-empty)
// - The command is a media-launching command
func shouldRunBeforeMediaStartHook(
- exprOpts *zscript.ExprEnvOptions,
+ inHookContext bool,
hookScript string,
cmdName string,
) bool {
- inHookContext := exprOpts != nil && exprOpts.InHookContext
return !inHookContext && hookScript != "" && zscript.IsMediaLaunchingCommand(cmdName)
}
-// buildLaunchingExprOpts creates ExprEnvOptions for the before_media_start hook.
-// Extracts path, system ID, and launcher ID from the command being launched.
-func buildLaunchingExprOpts(cmd zapscript.Command) *zscript.ExprEnvOptions {
- opts := &zscript.ExprEnvOptions{
- Launching: &zapscript.ExprEnvLaunching{},
- InHookContext: true,
- }
+// buildLaunchingContext extracts launching context from a command being launched.
+func buildLaunchingContext(cmd zapscript.Command) *zapscript.ExprEnvLaunching {
+ launching := &zapscript.ExprEnvLaunching{}
if len(cmd.Args) > 0 {
- opts.Launching.Path = cmd.Args[0]
+ launching.Path = cmd.Args[0]
}
if sysID := cmd.AdvArgs.Get(zapscript.KeySystem); sysID != "" {
- opts.Launching.SystemID = sysID
+ launching.SystemID = sysID
}
if launcherID := cmd.AdvArgs.Get(zapscript.KeyLauncher); launcherID != "" {
- opts.Launching.LauncherID = launcherID
+ launching.LauncherID = launcherID
}
- return opts
+ return launching
}
// scriptHasMediaLaunchingCommand checks if any command in the script launches media.
diff --git a/pkg/service/queues_helpers_test.go b/pkg/service/queues_helpers_test.go
index b34ba487..ef42b43d 100644
--- a/pkg/service/queues_helpers_test.go
+++ b/pkg/service/queues_helpers_test.go
@@ -24,7 +24,6 @@ import (
gozapscript "github.com/ZaparooProject/go-zapscript"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/service/playlists"
- "github.com/ZaparooProject/zaparoo-core/v2/pkg/zapscript"
"github.com/stretchr/testify/assert"
)
@@ -32,108 +31,108 @@ func TestShouldRunBeforeMediaStartHook(t *testing.T) {
t.Parallel()
tests := []struct {
- name string
- exprOpts *zapscript.ExprEnvOptions
- hookScript string
- cmdName string
- expected bool
+ name string
+ hookScript string
+ cmdName string
+ inHookContext bool
+ expected bool
}{
{
- name: "runs when all conditions met",
- exprOpts: nil,
- hookScript: "**echo:before launch",
- cmdName: gozapscript.ZapScriptCmdLaunch,
- expected: true,
+ name: "runs when all conditions met",
+ inHookContext: false,
+ hookScript: "**echo:before launch",
+ cmdName: gozapscript.ZapScriptCmdLaunch,
+ expected: true,
},
{
- name: "runs with non-hook exprOpts",
- exprOpts: &zapscript.ExprEnvOptions{InHookContext: false},
- hookScript: "**echo:test",
- cmdName: gozapscript.ZapScriptCmdLaunch,
- expected: true,
+ name: "runs when not in hook context",
+ inHookContext: false,
+ hookScript: "**echo:test",
+ cmdName: gozapscript.ZapScriptCmdLaunch,
+ expected: true,
},
{
- name: "blocked when in hook context",
- exprOpts: &zapscript.ExprEnvOptions{InHookContext: true},
- hookScript: "**echo:test",
- cmdName: gozapscript.ZapScriptCmdLaunch,
- expected: false,
+ name: "blocked when in hook context",
+ inHookContext: true,
+ hookScript: "**echo:test",
+ cmdName: gozapscript.ZapScriptCmdLaunch,
+ expected: false,
},
{
- name: "blocked when hook script empty",
- exprOpts: nil,
- hookScript: "",
- cmdName: gozapscript.ZapScriptCmdLaunch,
- expected: false,
+ name: "blocked when hook script empty",
+ inHookContext: false,
+ hookScript: "",
+ cmdName: gozapscript.ZapScriptCmdLaunch,
+ expected: false,
},
{
- name: "blocked when command is not media-launching",
- exprOpts: nil,
- hookScript: "**echo:test",
- cmdName: gozapscript.ZapScriptCmdExecute,
- expected: false,
+ name: "blocked when command is not media-launching",
+ inHookContext: false,
+ hookScript: "**echo:test",
+ cmdName: gozapscript.ZapScriptCmdExecute,
+ expected: false,
},
{
- name: "blocked when command is echo",
- exprOpts: nil,
- hookScript: "**echo:test",
- cmdName: gozapscript.ZapScriptCmdEcho,
- expected: false,
+ name: "blocked when command is echo",
+ inHookContext: false,
+ hookScript: "**echo:test",
+ cmdName: gozapscript.ZapScriptCmdEcho,
+ expected: false,
},
{
- name: "blocked when command is delay",
- exprOpts: nil,
- hookScript: "**echo:test",
- cmdName: gozapscript.ZapScriptCmdDelay,
- expected: false,
+ name: "blocked when command is delay",
+ inHookContext: false,
+ hookScript: "**echo:test",
+ cmdName: gozapscript.ZapScriptCmdDelay,
+ expected: false,
},
{
- name: "runs for launch.system command",
- exprOpts: nil,
- hookScript: "**echo:test",
- cmdName: gozapscript.ZapScriptCmdLaunchSystem,
- expected: true,
+ name: "runs for launch.system command",
+ inHookContext: false,
+ hookScript: "**echo:test",
+ cmdName: gozapscript.ZapScriptCmdLaunchSystem,
+ expected: true,
},
{
- name: "runs for launch.random command",
- exprOpts: nil,
- hookScript: "**echo:test",
- cmdName: gozapscript.ZapScriptCmdLaunchRandom,
- expected: true,
+ name: "runs for launch.random command",
+ inHookContext: false,
+ hookScript: "**echo:test",
+ cmdName: gozapscript.ZapScriptCmdLaunchRandom,
+ expected: true,
},
{
- name: "runs for launch.search command",
- exprOpts: nil,
- hookScript: "**echo:test",
- cmdName: gozapscript.ZapScriptCmdLaunchSearch,
- expected: true,
+ name: "runs for launch.search command",
+ inHookContext: false,
+ hookScript: "**echo:test",
+ cmdName: gozapscript.ZapScriptCmdLaunchSearch,
+ expected: true,
},
{
- name: "blocked for playlist.play command (queues state change)",
- exprOpts: nil,
- hookScript: "**echo:test",
- cmdName: gozapscript.ZapScriptCmdPlaylistPlay,
- expected: false,
+ name: "blocked for playlist.play command (queues state change)",
+ inHookContext: false,
+ hookScript: "**echo:test",
+ cmdName: gozapscript.ZapScriptCmdPlaylistPlay,
+ expected: false,
},
{
- name: "blocked for playlist.stop command",
- exprOpts: nil,
- hookScript: "**echo:test",
- cmdName: gozapscript.ZapScriptCmdPlaylistStop,
- expected: false,
+ name: "blocked for playlist.stop command",
+ inHookContext: false,
+ hookScript: "**echo:test",
+ cmdName: gozapscript.ZapScriptCmdPlaylistStop,
+ expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
- result := shouldRunBeforeMediaStartHook(tt.exprOpts, tt.hookScript, tt.cmdName)
+ result := shouldRunBeforeMediaStartHook(tt.inHookContext, tt.hookScript, tt.cmdName)
assert.Equal(t, tt.expected, result)
})
}
}
-func TestBuildLaunchingExprOpts(t *testing.T) {
+func TestBuildLaunchingContext(t *testing.T) {
t.Parallel()
t.Run("empty command", func(t *testing.T) {
@@ -144,14 +143,12 @@ func TestBuildLaunchingExprOpts(t *testing.T) {
Args: []string{},
}
- opts := buildLaunchingExprOpts(cmd)
+ launching := buildLaunchingContext(cmd)
- assert.NotNil(t, opts)
- assert.NotNil(t, opts.Launching)
- assert.True(t, opts.InHookContext, "InHookContext should always be true")
- assert.Empty(t, opts.Launching.Path)
- assert.Empty(t, opts.Launching.SystemID)
- assert.Empty(t, opts.Launching.LauncherID)
+ assert.NotNil(t, launching)
+ assert.Empty(t, launching.Path)
+ assert.Empty(t, launching.SystemID)
+ assert.Empty(t, launching.LauncherID)
})
t.Run("with path only", func(t *testing.T) {
@@ -162,12 +159,11 @@ func TestBuildLaunchingExprOpts(t *testing.T) {
Args: []string{"/games/snes/mario.sfc"},
}
- opts := buildLaunchingExprOpts(cmd)
+ launching := buildLaunchingContext(cmd)
- assert.Equal(t, "/games/snes/mario.sfc", opts.Launching.Path)
- assert.Empty(t, opts.Launching.SystemID)
- assert.Empty(t, opts.Launching.LauncherID)
- assert.True(t, opts.InHookContext)
+ assert.Equal(t, "/games/snes/mario.sfc", launching.Path)
+ assert.Empty(t, launching.SystemID)
+ assert.Empty(t, launching.LauncherID)
})
t.Run("with system ID", func(t *testing.T) {
@@ -179,11 +175,11 @@ func TestBuildLaunchingExprOpts(t *testing.T) {
AdvArgs: gozapscript.NewAdvArgs(map[string]string{"system": "genesis"}),
}
- opts := buildLaunchingExprOpts(cmd)
+ launching := buildLaunchingContext(cmd)
- assert.Equal(t, "/games/sonic.bin", opts.Launching.Path)
- assert.Equal(t, "genesis", opts.Launching.SystemID)
- assert.Empty(t, opts.Launching.LauncherID)
+ assert.Equal(t, "/games/sonic.bin", launching.Path)
+ assert.Equal(t, "genesis", launching.SystemID)
+ assert.Empty(t, launching.LauncherID)
})
t.Run("with launcher ID", func(t *testing.T) {
@@ -195,11 +191,11 @@ func TestBuildLaunchingExprOpts(t *testing.T) {
AdvArgs: gozapscript.NewAdvArgs(map[string]string{"launcher": "retroarch"}),
}
- opts := buildLaunchingExprOpts(cmd)
+ launching := buildLaunchingContext(cmd)
- assert.Equal(t, "/games/game.rom", opts.Launching.Path)
- assert.Empty(t, opts.Launching.SystemID)
- assert.Equal(t, "retroarch", opts.Launching.LauncherID)
+ assert.Equal(t, "/games/game.rom", launching.Path)
+ assert.Empty(t, launching.SystemID)
+ assert.Equal(t, "retroarch", launching.LauncherID)
})
t.Run("with all fields", func(t *testing.T) {
@@ -211,13 +207,11 @@ func TestBuildLaunchingExprOpts(t *testing.T) {
AdvArgs: gozapscript.NewAdvArgs(map[string]string{"system": "snes", "launcher": "mister"}),
}
- opts := buildLaunchingExprOpts(cmd)
+ launching := buildLaunchingContext(cmd)
- assert.Equal(t, "/roms/snes/zelda.sfc", opts.Launching.Path)
- assert.Equal(t, "snes", opts.Launching.SystemID)
- assert.Equal(t, "mister", opts.Launching.LauncherID)
- assert.True(t, opts.InHookContext)
- assert.Nil(t, opts.Scanned, "Scanned should not be set")
+ assert.Equal(t, "/roms/snes/zelda.sfc", launching.Path)
+ assert.Equal(t, "snes", launching.SystemID)
+ assert.Equal(t, "mister", launching.LauncherID)
})
t.Run("multiple args uses first as path", func(t *testing.T) {
@@ -228,9 +222,9 @@ func TestBuildLaunchingExprOpts(t *testing.T) {
Args: []string{"/path/to/game.rom", "extra", "args"},
}
- opts := buildLaunchingExprOpts(cmd)
+ launching := buildLaunchingContext(cmd)
- assert.Equal(t, "/path/to/game.rom", opts.Launching.Path)
+ assert.Equal(t, "/path/to/game.rom", launching.Path)
})
}
diff --git a/pkg/service/queues_playlist_test.go b/pkg/service/queues_playlist_test.go
index 619896b1..b922b21f 100644
--- a/pkg/service/queues_playlist_test.go
+++ b/pkg/service/queues_playlist_test.go
@@ -35,15 +35,7 @@ import (
"github.com/stretchr/testify/require"
)
-type playlistTestEnv struct {
- platform *mocks.MockPlatform
- cfg *config.Instance
- st *state.State
- db *database.Database
- lsq chan *tokens.Token
-}
-
-func setupPlaylistTestEnv(t *testing.T) *playlistTestEnv {
+func setupPlaylistTestEnv(t *testing.T) *ServiceContext {
t.Helper()
mockPlatform := mocks.NewMockPlatform()
@@ -69,19 +61,20 @@ func setupPlaylistTestEnv(t *testing.T) *playlistTestEnv {
mockUserDB.On("GetEnabledMappings").Return([]database.Mapping{}, nil).Maybe()
mockUserDB.On("GetSupportedZapLinkHosts").Return([]string{}, nil).Maybe()
- return &playlistTestEnv{
- platform: mockPlatform,
- cfg: cfg,
- st: st,
- db: &database.Database{UserDB: mockUserDB},
- lsq: make(chan *tokens.Token, 10),
+ return &ServiceContext{
+ Platform: mockPlatform,
+ Config: cfg,
+ State: st,
+ DB: &database.Database{UserDB: mockUserDB},
+ LaunchSoftwareQueue: make(chan *tokens.Token, 10),
+ PlaylistQueue: make(chan *playlists.Playlist, 10),
}
}
func TestRunTokenZapScript_ClearsPlaylistOnMediaChange(t *testing.T) {
t.Parallel()
- env := setupPlaylistTestEnv(t)
+ svc := setupPlaylistTestEnv(t)
plq := make(chan *playlists.Playlist, 10)
plsc := playlists.PlaylistController{Queue: plq}
@@ -91,7 +84,7 @@ func TestRunTokenZapScript_ClearsPlaylistOnMediaChange(t *testing.T) {
ScanTime: time.Now(),
}
- err := runTokenZapScript(env.platform, env.cfg, env.st, token, env.db, env.lsq, plsc, nil)
+ err := runTokenZapScript(svc, token, plsc, nil, false)
require.NoError(t, err)
select {
@@ -105,7 +98,7 @@ func TestRunTokenZapScript_ClearsPlaylistOnMediaChange(t *testing.T) {
func TestRunTokenZapScript_SkipsPlaylistClearForPlaylistSource(t *testing.T) {
t.Parallel()
- env := setupPlaylistTestEnv(t)
+ svc := setupPlaylistTestEnv(t)
plq := make(chan *playlists.Playlist, 10)
plsc := playlists.PlaylistController{Queue: plq}
@@ -116,7 +109,7 @@ func TestRunTokenZapScript_SkipsPlaylistClearForPlaylistSource(t *testing.T) {
Source: tokens.SourcePlaylist,
}
- err := runTokenZapScript(env.platform, env.cfg, env.st, token, env.db, env.lsq, plsc, nil)
+ err := runTokenZapScript(svc, token, plsc, nil, false)
require.NoError(t, err)
select {
@@ -130,8 +123,8 @@ func TestRunTokenZapScript_SkipsPlaylistClearForPlaylistSource(t *testing.T) {
func TestRunTokenZapScript_NoPlaylistClearForNonMediaCommand(t *testing.T) {
t.Parallel()
- env := setupPlaylistTestEnv(t)
- env.platform.On("KeyboardPress", "{f2}").Return(nil)
+ svc := setupPlaylistTestEnv(t)
+ svc.Platform.(*mocks.MockPlatform).On("KeyboardPress", "{f2}").Return(nil)
plq := make(chan *playlists.Playlist, 10)
plsc := playlists.PlaylistController{Queue: plq}
@@ -141,7 +134,7 @@ func TestRunTokenZapScript_NoPlaylistClearForNonMediaCommand(t *testing.T) {
ScanTime: time.Now(),
}
- err := runTokenZapScript(env.platform, env.cfg, env.st, token, env.db, env.lsq, plsc, nil)
+ err := runTokenZapScript(svc, token, plsc, nil, false)
require.NoError(t, err)
select {
diff --git a/pkg/service/reader_manager_test.go b/pkg/service/reader_manager_test.go
index 88704414..a2baf17a 100644
--- a/pkg/service/reader_manager_test.go
+++ b/pkg/service/reader_manager_test.go
@@ -71,7 +71,16 @@ func setupReaderManager(t *testing.T) *readerManagerEnv {
lsq := make(chan *tokens.Token, 10)
plq := make(chan *playlists.Playlist, 10)
- go readerManager(mockPlatform, cfg, st, db, itq, lsq, plq, scanQueue, mockPlayer, nil)
+ svc := &ServiceContext{
+ Platform: mockPlatform,
+ Config: cfg,
+ State: st,
+ DB: db,
+ LaunchSoftwareQueue: lsq,
+ PlaylistQueue: plq,
+ }
+
+ go readerManager(svc, itq, scanQueue, mockPlayer, nil)
t.Cleanup(func() {
st.StopService()
diff --git a/pkg/service/readers.go b/pkg/service/readers.go
index 83103a5d..2d28021b 100644
--- a/pkg/service/readers.go
+++ b/pkg/service/readers.go
@@ -28,15 +28,12 @@ import (
"github.com/ZaparooProject/zaparoo-core/v2/pkg/assets"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/audio"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/config"
- "github.com/ZaparooProject/zaparoo-core/v2/pkg/database"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/database/systemdefs"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/readers"
- "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/playlists"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/service/state"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/service/tokens"
- "github.com/ZaparooProject/zaparoo-core/v2/pkg/zapscript"
"github.com/jonboulle/clockwork"
"github.com/rs/zerolog/log"
)
@@ -150,16 +147,11 @@ func connectReaders(
}
func runBeforeExitHook(
- pl platforms.Platform,
- cfg *config.Instance,
- st *state.State,
- db *database.Database,
- lsq chan *tokens.Token,
- plq chan *playlists.Playlist,
+ svc *ServiceContext,
activeMedia models.ActiveMedia, //nolint:gocritic // single-use parameter in service function
) {
var systemIDs []string
- launchers := pl.Launchers(cfg)
+ launchers := svc.Platform.Launchers(svc.Config)
for i := range launchers {
l := &launchers[i]
if l.ID == activeMedia.SystemID {
@@ -174,12 +166,12 @@ func runBeforeExitHook(
if len(systemIDs) > 0 {
for _, systemID := range systemIDs {
- defaults, ok := cfg.LookupSystemDefaults(systemID)
+ defaults, ok := svc.Config.LookupSystemDefaults(systemID)
if !ok || defaults.BeforeExit == "" {
continue
}
- if err := runHook(pl, cfg, st, db, lsq, plq, "before_exit", defaults.BeforeExit, nil); err != nil {
+ if err := runHook(svc, "before_exit", defaults.BeforeExit, nil, nil); err != nil {
log.Error().Err(err).Msg("error running before_exit script")
}
@@ -189,12 +181,7 @@ func runBeforeExitHook(
}
func timedExit(
- pl platforms.Platform,
- cfg *config.Instance,
- st *state.State,
- db *database.Database,
- lsq chan *tokens.Token,
- plq chan *playlists.Playlist,
+ svc *ServiceContext,
clock clockwork.Clock,
exitTimer clockwork.Timer,
) clockwork.Timer {
@@ -205,20 +192,20 @@ func timedExit(
}
}
- if !cfg.HoldModeEnabled() {
+ if !svc.Config.HoldModeEnabled() {
log.Debug().Msg("hold mode not enabled, skipping exit timer")
return exitTimer
}
// Only hardware readers support hold mode exit
- lastToken := st.GetLastScanned()
+ lastToken := svc.State.GetLastScanned()
if lastToken.Source != tokens.SourceReader {
log.Debug().Str("source", lastToken.Source).Msg("skipping exit timer for non-reader source")
return exitTimer
}
// Check if the reader supports removal detection
- r, ok := st.GetReader(lastToken.ReaderID)
+ r, ok := svc.State.GetReader(lastToken.ReaderID)
if !ok {
log.Debug().Str("readerID", lastToken.ReaderID).Msg("reader not found in state, skipping exit timer")
return exitTimer
@@ -228,47 +215,47 @@ func timedExit(
return exitTimer
}
- timerLen := time.Duration(float64(cfg.ReadersScan().ExitDelay) * float64(time.Second))
+ timerLen := time.Duration(float64(svc.Config.ReadersScan().ExitDelay) * float64(time.Second))
log.Debug().Msgf("exit timer set to: %s seconds", timerLen)
exitTimer = clock.NewTimer(timerLen)
go func() {
select {
case <-exitTimer.Chan():
- case <-st.GetContext().Done():
+ case <-svc.State.GetContext().Done():
return
}
- if !cfg.HoldModeEnabled() {
+ if !svc.Config.HoldModeEnabled() {
log.Debug().Msg("exit timer expired, but hold mode disabled")
return
}
- activeMedia := st.ActiveMedia()
+ activeMedia := svc.State.ActiveMedia()
if activeMedia == nil {
log.Debug().Msg("no active media, cancelling exit")
return
}
- if st.GetSoftwareToken() == nil {
+ if svc.State.GetSoftwareToken() == nil {
log.Debug().Msg("no active software token, cancelling exit")
return
}
- if cfg.IsHoldModeIgnoredSystem(activeMedia.SystemID) {
+ if svc.Config.IsHoldModeIgnoredSystem(activeMedia.SystemID) {
log.Debug().Msg("active system ignored in config, cancelling exit")
return
}
- runBeforeExitHook(pl, cfg, st, db, lsq, plq, *activeMedia)
+ runBeforeExitHook(svc, *activeMedia)
log.Info().Msg("exiting media")
- err := pl.StopActiveLauncher(platforms.StopForMenu)
+ err := svc.Platform.StopActiveLauncher(platforms.StopForMenu)
if err != nil {
log.Warn().Msgf("error killing launcher: %s", err)
}
- lsq <- nil
+ svc.LaunchSoftwareQueue <- nil
}()
return exitTimer
@@ -285,13 +272,8 @@ func timedExit(
// This manager also handles the logic of what to do when a token is removed
// from the reader.
func readerManager(
- pl platforms.Platform,
- cfg *config.Instance,
- st *state.State,
- db *database.Database,
+ svc *ServiceContext,
itq chan<- tokens.Token,
- lsq chan *tokens.Token,
- plq chan *playlists.Playlist,
scanQueue chan readers.Scan,
player audio.Player,
clock clockwork.Clock,
@@ -306,44 +288,44 @@ func readerManager(
var exitTimer clockwork.Timer
var autoDetector *AutoDetector
- if cfg.AutoDetect() {
- autoDetector = NewAutoDetector(cfg)
+ if svc.Config.AutoDetect() {
+ autoDetector = NewAutoDetector(svc.Config)
}
readerTicker := time.NewTicker(1 * time.Second)
playFail := func() {
if time.Since(lastError) > 1*time.Second {
- path, enabled := cfg.FailSoundPath(helpers.DataDir(pl))
+ path, enabled := svc.Config.FailSoundPath(helpers.DataDir(svc.Platform))
helpers.PlayConfiguredSound(player, path, enabled, assets.FailSound, "fail")
}
}
// manage reader connections
go func() {
- log.Info().Msgf("reader manager started, auto-detect=%v", cfg.AutoDetect())
+ log.Info().Msgf("reader manager started, auto-detect=%v", svc.Config.AutoDetect())
sleepMonitor := helpers.NewSleepWakeMonitor(5 * time.Second)
readerConnectAttempts := 0
lastReaderCount := 0
for {
select {
- case <-st.GetContext().Done():
+ case <-svc.State.GetContext().Done():
log.Info().Msg("reader manager shutting down via context cancellation")
return
case <-readerTicker.C:
// Check for wake from sleep and reconnect all readers if detected
if sleepMonitor.Check() {
log.Info().Msg("detected wake from sleep, reconnecting all readers")
- for _, r := range st.ListReaders() {
+ for _, r := range svc.State.ListReaders() {
if r != nil {
- st.RemoveReader(r.ReaderID())
+ svc.State.RemoveReader(r.ReaderID())
}
}
lastReaderCount = 0
}
readerConnectAttempts++
- rs := st.ListReaders()
+ rs := svc.State.ListReaders()
if len(rs) != lastReaderCount {
if len(rs) == 0 {
@@ -356,7 +338,7 @@ func readerManager(
// Only log if no readers for 2 minutes
log.Debug().
Int("attempts", readerConnectAttempts).
- Bool("autoDetect", cfg.AutoDetect()).
+ Bool("autoDetect", svc.Config.AutoDetect()).
Msg("no readers connected")
}
@@ -368,7 +350,7 @@ func readerManager(
Str("path", r.Path()).
Str("info", r.Info()).
Msg("pruning disconnected reader")
- st.RemoveReader(readerID)
+ svc.State.RemoveReader(readerID)
if autoDetector != nil {
autoDetector.ClearPath(r.Path())
autoDetector.ClearFailedPath(r.Path())
@@ -376,7 +358,8 @@ func readerManager(
}
}
- if connectErr := connectReaders(pl, cfg, st, scanQueue, autoDetector); connectErr != nil {
+ connectErr := connectReaders(svc.Platform, svc.Config, svc.State, scanQueue, autoDetector)
+ if connectErr != nil {
log.Warn().Msgf("error connecting rs: %s", connectErr)
}
// Reset monitor after potentially blocking operations to avoid
@@ -394,7 +377,7 @@ preprocessing:
var scanSource string
select {
- case <-st.GetContext().Done():
+ case <-svc.State.GetContext().Done():
log.Debug().Msg("closing reader manager via context cancellation")
break preprocessing
case t := <-scanQueue:
@@ -409,15 +392,15 @@ preprocessing:
scan = t.Token
readerError = t.ReaderError
scanSource = t.Source
- case stoken := <-lsq:
+ case stoken := <-svc.LaunchSoftwareQueue:
// a token has been launched that starts software, used for managing exits
log.Debug().Msgf("new software token: %v", stoken)
- if exitTimer != nil && !helpers.TokensEqual(stoken, st.GetSoftwareToken()) {
+ if exitTimer != nil && !helpers.TokensEqual(stoken, svc.State.GetSoftwareToken()) {
if stopped := exitTimer.Stop(); stopped {
log.Info().Msg("different software token inserted, cancelling exit")
}
}
- st.SetSoftwareToken(stoken)
+ svc.State.SetSoftwareToken(stoken)
continue preprocessing
}
@@ -433,43 +416,41 @@ preprocessing:
log.Info().Msgf("new token scanned: %v", scan)
// Run on_scan hook before SetActiveCard so last_scanned refers to previous token
- if onScanScript := cfg.ReadersScan().OnScan; onScanScript != "" {
- scannedOpts := &zapscript.ExprEnvOptions{
- Scanned: &gozapscript.ExprEnvScanned{
- ID: scan.UID,
- Value: scan.Text,
- Data: scan.Data,
- },
+ if onScanScript := svc.Config.ReadersScan().OnScan; onScanScript != "" {
+ scanned := &gozapscript.ExprEnvScanned{
+ ID: scan.UID,
+ Value: scan.Text,
+ Data: scan.Data,
}
- if err := runHook(pl, cfg, st, db, lsq, plq, "on_scan", onScanScript, scannedOpts); err != nil {
+ if err := runHook(svc, "on_scan", onScanScript, scanned, nil); err != nil {
log.Warn().Err(err).Msg("on_scan hook blocked token processing")
continue preprocessing
}
}
- st.SetActiveCard(*scan)
+ svc.State.SetActiveCard(*scan)
if exitTimer != nil {
stopped := exitTimer.Stop()
- stoken := st.GetSoftwareToken()
+ stoken := svc.State.GetSoftwareToken()
if stopped && helpers.TokensEqual(scan, stoken) {
log.Info().Msg("same token reinserted, cancelling exit")
continue preprocessing
} else if stopped {
log.Info().Msg("new token inserted, restarting exit timer")
- exitTimer = timedExit(pl, cfg, st, db, lsq, plq, clock, exitTimer)
+ exitTimer = timedExit(svc, clock, exitTimer)
}
}
// avoid launching a token that was just written by a reader
// NOTE: This check requires both UID and Text to match (see helpers.TokensEqual).
- wt := st.GetWroteToken()
+ wt := svc.State.GetWroteToken()
if wt != nil && helpers.TokensEqual(scan, wt) {
log.Info().Msg("skipping launching just written token")
- st.SetWroteToken(nil)
+ svc.State.SetWroteToken(nil)
continue preprocessing
}
- st.SetWroteToken(nil)
+ svc.State.SetWroteToken(nil)
log.Info().Msgf("sending token to queue: %v", scan)
@@ -485,28 +466,29 @@ preprocessing:
log.Debug().Msg("cancelled exit timer due to reader error")
}
}
- st.SetActiveCard(tokens.Token{})
+ svc.State.SetActiveCard(tokens.Token{})
case scanNormalRemoval:
log.Info().Msg("token was removed")
// Clear ActiveCard before hook to prevent blocked removals from affecting new scans
- st.SetActiveCard(tokens.Token{})
+ svc.State.SetActiveCard(tokens.Token{})
// Run on_remove hook; errors skip exit timer but card state is already cleared
- if onRemoveScript := cfg.ReadersScan().OnRemove; cfg.HoldModeEnabled() && onRemoveScript != "" {
- if err := runHook(pl, cfg, st, db, lsq, plq, "on_remove", onRemoveScript, nil); err != nil {
+ onRemoveScript := svc.Config.ReadersScan().OnRemove
+ if svc.Config.HoldModeEnabled() && onRemoveScript != "" {
+ if err := runHook(svc, "on_remove", onRemoveScript, nil, nil); err != nil {
log.Warn().Err(err).Msg("on_remove hook blocked exit, media will keep running")
continue preprocessing
}
}
- exitTimer = timedExit(pl, cfg, st, db, lsq, plq, clock, exitTimer)
+ exitTimer = timedExit(svc, clock, exitTimer)
}
}
// daemon shutdown
- rs := st.ListReaders()
+ rs := svc.State.ListReaders()
for _, r := range rs {
if r != nil {
err := r.Close()
diff --git a/pkg/service/scan_behavior_test.go b/pkg/service/scan_behavior_test.go
index 903d74a9..e10f93ee 100644
--- a/pkg/service/scan_behavior_test.go
+++ b/pkg/service/scan_behavior_test.go
@@ -161,15 +161,24 @@ func setupScanBehavior(
limitsManager := playtime.NewLimitsManager(db, mockPlatform, cfg, nil, mockPlayer)
+ svc := &ServiceContext{
+ Platform: mockPlatform,
+ Config: cfg,
+ State: st,
+ DB: db,
+ LaunchSoftwareQueue: lsq,
+ PlaylistQueue: plq,
+ }
+
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
- readerManager(mockPlatform, cfg, st, db, itq, lsq, plq, scanQueue, mockPlayer, fakeClock)
+ readerManager(svc, itq, scanQueue, mockPlayer, fakeClock)
}()
go func() {
defer wg.Done()
- processTokenQueue(mockPlatform, cfg, st, itq, db, lsq, plq, limitsManager, mockPlayer)
+ processTokenQueue(svc, itq, limitsManager, mockPlayer)
}()
t.Cleanup(func() {
diff --git a/pkg/service/service.go b/pkg/service/service.go
index f8f37934..f3300649 100644
--- a/pkg/service/service.go
+++ b/pkg/service/service.go
@@ -264,10 +264,19 @@ func Start(
limitsManager.SetEnabled(true)
}
+ svc := &ServiceContext{
+ Platform: pl,
+ Config: cfg,
+ State: st,
+ DB: db,
+ LaunchSoftwareQueue: lsq,
+ PlaylistQueue: plq,
+ }
+
// Set up the OnMediaStart hook
st.SetOnMediaStartHook(func(_ *models.ActiveMedia) {
if script := cfg.LaunchersOnMediaStart(); script != "" {
- if hookErr := runHook(pl, cfg, st, db, lsq, plq, "on_media_start", script, nil); hookErr != nil {
+ if hookErr := runHook(svc, "on_media_start", script, nil, nil); hookErr != nil {
log.Error().Err(hookErr).Msg("error running on_media_start script")
}
}
@@ -330,10 +339,10 @@ func Start(
}
log.Info().Msg("starting reader manager")
- go readerManager(pl, cfg, st, db, itq, lsq, plq, make(chan readers.Scan), player, nil)
+ go readerManager(svc, itq, make(chan readers.Scan), player, nil)
log.Info().Msg("starting input token queue manager")
- go processTokenQueue(pl, cfg, st, itq, db, lsq, plq, limitsManager, player)
+ go processTokenQueue(svc, itq, limitsManager, player)
log.Info().Msg("running platform post start")
err = pl.StartPost(cfg, st.LauncherManager(), st.ActiveMedia, st.SetActiveMedia, db)
@@ -617,7 +626,7 @@ func startPublishers(
// CRITICAL: Always start the drain goroutine, even if there are no active publishers.
// The notifChan MUST be consumed or it will fill up and block the notification system.
// If there are no publishers, notifications are simply discarded after being consumed.
- ctx, cancel := context.WithCancel(st.GetContext())
+ ctx, cancel := context.WithCancel(st.GetContext()) //nolint:gosec // G118: cancel returned to caller
go func() {
for {
select {
diff --git a/pkg/testing/helpers/api.go b/pkg/testing/helpers/api.go
index 869b20f5..6a7a22c1 100644
--- a/pkg/testing/helpers/api.go
+++ b/pkg/testing/helpers/api.go
@@ -446,7 +446,7 @@ func (m *MockWebSocketConnection) SetCloseError(err error) {
// CreateTestContext creates a context with timeout for testing
func CreateTestContext(timeout time.Duration) (context.Context, context.CancelFunc) {
- return context.WithTimeout(context.Background(), timeout)
+ return context.WithTimeout(context.Background(), timeout) //nolint:gosec // G118: caller is responsible for cancel
}
// WaitForMessages waits for a specific number of messages with timeout
diff --git a/pkg/testing/mocks/kodi_client.go b/pkg/testing/mocks/kodi_client.go
index dbcb7c46..9033dafd 100644
--- a/pkg/testing/mocks/kodi_client.go
+++ b/pkg/testing/mocks/kodi_client.go
@@ -101,9 +101,45 @@ func (m *MockKodiClient) LaunchTVShow(path string) error {
return nil
}
+// PlayPause mocks toggling play/pause on the first active player
+func (m *MockKodiClient) PlayPause(ctx context.Context) error {
+ args := m.Called(ctx)
+ if err := args.Error(0); err != nil {
+ return fmt.Errorf("mock PlayPause error: %w", err)
+ }
+ return nil
+}
+
+// FastForward mocks increasing playback speed of the first active player
+func (m *MockKodiClient) FastForward(ctx context.Context) error {
+ args := m.Called(ctx)
+ if err := args.Error(0); err != nil {
+ return fmt.Errorf("mock FastForward error: %w", err)
+ }
+ return nil
+}
+
+// Rewind mocks decreasing playback speed of the first active player
+func (m *MockKodiClient) Rewind(ctx context.Context) error {
+ args := m.Called(ctx)
+ if err := args.Error(0); err != nil {
+ return fmt.Errorf("mock Rewind error: %w", err)
+ }
+ return nil
+}
+
+// GoTo mocks navigating to next or previous item in playlist
+func (m *MockKodiClient) GoTo(ctx context.Context, direction string) error {
+ args := m.Called(ctx, direction)
+ if err := args.Error(0); err != nil {
+ return fmt.Errorf("mock GoTo error: %w", err)
+ }
+ return nil
+}
+
// Stop mocks stopping all active players in Kodi
-func (m *MockKodiClient) Stop() error {
- args := m.Called()
+func (m *MockKodiClient) Stop(ctx context.Context) error {
+ args := m.Called(ctx)
if err := args.Error(0); err != nil {
return fmt.Errorf("mock Stop error: %w", err)
}
@@ -273,7 +309,11 @@ func (m *MockKodiClient) SetupBasicMock() {
m.On("LaunchFile", mock.AnythingOfType("string")).Return(nil).Maybe()
m.On("LaunchMovie", mock.AnythingOfType("string")).Return(nil).Maybe()
m.On("LaunchTVEpisode", mock.AnythingOfType("string")).Return(nil).Maybe()
- m.On("Stop").Return(nil).Maybe()
+ m.On("Stop", mock.Anything).Return(nil).Maybe()
+ m.On("PlayPause", mock.Anything).Return(nil).Maybe()
+ m.On("FastForward", mock.Anything).Return(nil).Maybe()
+ m.On("Rewind", mock.Anything).Return(nil).Maybe()
+ m.On("GoTo", mock.Anything, mock.AnythingOfType("string")).Return(nil).Maybe()
m.On("Quit", mock.Anything).Return(nil).Maybe()
m.On("GetActivePlayers", mock.Anything).Return([]kodi.Player{}, nil).Maybe()
m.On("GetPlayerItem", mock.Anything, mock.AnythingOfType("int")).Return((*kodi.PlayerItem)(nil), nil).Maybe()
diff --git a/pkg/ui/tui/generatedb.go b/pkg/ui/tui/generatedb.go
index c592edca..c8bdee3c 100644
--- a/pkg/ui/tui/generatedb.go
+++ b/pkg/ui/tui/generatedb.go
@@ -133,8 +133,7 @@ func BuildGenerateDBPage(
pages *tview.Pages,
app *tview.Application,
) {
- ctx, cancel := context.WithCancel(context.Background())
-
+ ctx, cancel := context.WithCancel(context.Background()) //nolint:gosec // G118: cancel called in goBack
// Create page frame
frame := NewPageFrame(app).
SetTitle("Update Media DB")
diff --git a/pkg/ui/tui/utils.go b/pkg/ui/tui/utils.go
index 60e2cd74..3f99b275 100644
--- a/pkg/ui/tui/utils.go
+++ b/pkg/ui/tui/utils.go
@@ -41,12 +41,14 @@ const TagReadTimeout = 30 * time.Second
// tuiContext creates a context with the TUI request timeout.
// Use this for API calls from the TUI to avoid long hangs.
func tuiContext() (context.Context, context.CancelFunc) {
+ //nolint:gosec // G118: caller is responsible for cancel
return context.WithTimeout(context.Background(), TUIRequestTimeout)
}
// tagReadContext creates a context with the tag read timeout.
// Use this for operations where the user needs to physically interact with a tag.
func tagReadContext() (context.Context, context.CancelFunc) {
+ //nolint:gosec // G118: caller is responsible for cancel
return context.WithTimeout(context.Background(), TagReadTimeout)
}
diff --git a/pkg/zapscript/commands.go b/pkg/zapscript/commands.go
index 3fe5c313..31681818 100644
--- a/pkg/zapscript/commands.go
+++ b/pkg/zapscript/commands.go
@@ -28,6 +28,7 @@ import (
"path/filepath"
"runtime"
"strings"
+ "sync"
"github.com/ZaparooProject/go-zapscript"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/config"
@@ -68,53 +69,65 @@ func ParseAdvArgs[T any](pl platforms.Platform, env *platforms.CmdEnv, dest *T)
return nil
}
-var cmdMap = map[string]func(
- platforms.Platform,
- platforms.CmdEnv,
-) (platforms.CmdResult, error){
- zapscript.ZapScriptCmdLaunch: cmdLaunch,
- zapscript.ZapScriptCmdLaunchSystem: cmdSystem,
- zapscript.ZapScriptCmdLaunchRandom: cmdRandom,
- zapscript.ZapScriptCmdLaunchSearch: cmdSearch,
- zapscript.ZapScriptCmdLaunchTitle: cmdTitle,
-
- zapscript.ZapScriptCmdPlaylistPlay: cmdPlaylistPlay,
- zapscript.ZapScriptCmdPlaylistStop: cmdPlaylistStop,
- zapscript.ZapScriptCmdPlaylistNext: cmdPlaylistNext,
- zapscript.ZapScriptCmdPlaylistPrevious: cmdPlaylistPrevious,
- zapscript.ZapScriptCmdPlaylistGoto: cmdPlaylistGoto,
- zapscript.ZapScriptCmdPlaylistPause: cmdPlaylistPause,
- zapscript.ZapScriptCmdPlaylistLoad: cmdPlaylistLoad,
- zapscript.ZapScriptCmdPlaylistOpen: cmdPlaylistOpen,
-
- zapscript.ZapScriptCmdExecute: cmdExecute,
- zapscript.ZapScriptCmdDelay: cmdDelay,
- zapscript.ZapScriptCmdStop: cmdStop,
- zapscript.ZapScriptCmdEcho: cmdEcho,
-
- zapscript.ZapScriptCmdMisterINI: forwardCmd,
- zapscript.ZapScriptCmdMisterCore: forwardCmd,
- zapscript.ZapScriptCmdMisterScript: forwardCmd,
- zapscript.ZapScriptCmdMisterMGL: forwardCmd,
-
- zapscript.ZapScriptCmdHTTPGet: cmdHTTPGet,
- zapscript.ZapScriptCmdHTTPPost: cmdHTTPPost,
-
- zapscript.ZapScriptCmdInputKeyboard: cmdKeyboard,
- zapscript.ZapScriptCmdInputGamepad: cmdGamepad,
- zapscript.ZapScriptCmdInputCoinP1: cmdCoinP1,
- zapscript.ZapScriptCmdInputCoinP2: cmdCoinP2,
-
- zapscript.ZapScriptCmdInputKey: cmdKey, // DEPRECATED
- zapscript.ZapScriptCmdKey: cmdKey, // DEPRECATED
- zapscript.ZapScriptCmdCoinP1: cmdCoinP1, // DEPRECATED
- zapscript.ZapScriptCmdCoinP2: cmdCoinP2, // DEPRECATED
- zapscript.ZapScriptCmdRandom: cmdRandom, // DEPRECATED
- zapscript.ZapScriptCmdShell: cmdExecute, // DEPRECATED
- zapscript.ZapScriptCmdCommand: cmdExecute, // DEPRECATED
- zapscript.ZapScriptCmdINI: forwardCmd, // DEPRECATED
- zapscript.ZapScriptCmdSystem: cmdSystem, // DEPRECATED
- zapscript.ZapScriptCmdGet: cmdHTTPGet, // DEPRECATED
+type cmdFunc = func(platforms.Platform, platforms.CmdEnv) (platforms.CmdResult, error)
+
+var (
+ cmdMapOnce sync.Once
+ cmdMapVal map[string]cmdFunc
+)
+
+func lookupCmd(name string) (cmdFunc, bool) {
+ cmdMapOnce.Do(func() {
+ cmdMapVal = map[string]cmdFunc{
+ zapscript.ZapScriptCmdLaunch: cmdLaunch,
+ zapscript.ZapScriptCmdLaunchSystem: cmdSystem,
+ zapscript.ZapScriptCmdLaunchRandom: cmdRandom,
+ zapscript.ZapScriptCmdLaunchSearch: cmdSearch,
+ zapscript.ZapScriptCmdLaunchTitle: cmdTitle,
+
+ zapscript.ZapScriptCmdPlaylistPlay: cmdPlaylistPlay,
+ zapscript.ZapScriptCmdPlaylistStop: cmdPlaylistStop,
+ zapscript.ZapScriptCmdPlaylistNext: cmdPlaylistNext,
+ zapscript.ZapScriptCmdPlaylistPrevious: cmdPlaylistPrevious,
+ zapscript.ZapScriptCmdPlaylistGoto: cmdPlaylistGoto,
+ zapscript.ZapScriptCmdPlaylistPause: cmdPlaylistPause,
+ zapscript.ZapScriptCmdPlaylistLoad: cmdPlaylistLoad,
+ zapscript.ZapScriptCmdPlaylistOpen: cmdPlaylistOpen,
+
+ zapscript.ZapScriptCmdExecute: cmdExecute,
+ zapscript.ZapScriptCmdDelay: cmdDelay,
+ zapscript.ZapScriptCmdStop: cmdStop,
+ zapscript.ZapScriptCmdEcho: cmdEcho,
+
+ zapscript.ZapScriptCmdControl: cmdControl,
+
+ zapscript.ZapScriptCmdMisterINI: forwardCmd,
+ zapscript.ZapScriptCmdMisterCore: forwardCmd,
+ zapscript.ZapScriptCmdMisterScript: forwardCmd,
+ zapscript.ZapScriptCmdMisterMGL: forwardCmd,
+
+ zapscript.ZapScriptCmdHTTPGet: cmdHTTPGet,
+ zapscript.ZapScriptCmdHTTPPost: cmdHTTPPost,
+
+ zapscript.ZapScriptCmdInputKeyboard: cmdKeyboard,
+ zapscript.ZapScriptCmdInputGamepad: cmdGamepad,
+ zapscript.ZapScriptCmdInputCoinP1: cmdCoinP1,
+ zapscript.ZapScriptCmdInputCoinP2: cmdCoinP2,
+
+ zapscript.ZapScriptCmdInputKey: cmdKey, // DEPRECATED
+ zapscript.ZapScriptCmdKey: cmdKey, // DEPRECATED
+ zapscript.ZapScriptCmdCoinP1: cmdCoinP1, // DEPRECATED
+ zapscript.ZapScriptCmdCoinP2: cmdCoinP2, // DEPRECATED
+ zapscript.ZapScriptCmdRandom: cmdRandom, // DEPRECATED
+ zapscript.ZapScriptCmdShell: cmdExecute, // DEPRECATED
+ zapscript.ZapScriptCmdCommand: cmdExecute, // DEPRECATED
+ zapscript.ZapScriptCmdINI: forwardCmd, // DEPRECATED
+ zapscript.ZapScriptCmdSystem: cmdSystem, // DEPRECATED
+ zapscript.ZapScriptCmdGet: cmdHTTPGet, // DEPRECATED
+ }
+ })
+ f, ok := cmdMapVal[name]
+ return f, ok
}
// IsMediaLaunchingCommand returns true if the command launches media and should be subject to playtime limits.
@@ -144,7 +157,7 @@ func IsMediaLaunchingCommand(cmdName string) bool {
// IsValidCommand returns true if the command name is a valid ZapScript command.
func IsValidCommand(cmdName string) bool {
- _, ok := cmdMap[cmdName]
+ _, ok := lookupCmd(cmdName)
return ok
}
@@ -190,18 +203,12 @@ func findFile(pl platforms.Platform, cfg *config.Instance, path string) (string,
return path, fmt.Errorf("%w: %s", ErrFileNotFound, path)
}
-// ExprEnvOptions provides optional context for expression environment.
-type ExprEnvOptions struct {
- Scanned *zapscript.ExprEnvScanned
- Launching *zapscript.ExprEnvLaunching
- InHookContext bool // prevents recursive hook execution
-}
-
-func getExprEnv(
+func GetExprEnv(
pl platforms.Platform,
cfg *config.Instance,
st *state.State,
- opts *ExprEnvOptions,
+ scanned *zapscript.ExprEnvScanned,
+ launching *zapscript.ExprEnvLaunching,
) zapscript.ArgExprEnv {
hostname, err := os.Hostname()
if err != nil {
@@ -237,19 +244,19 @@ func getExprEnv(
env.ActiveMedia.Name = activeMedia.Name
}
- if opts != nil {
- if opts.Scanned != nil {
- env.Scanned = *opts.Scanned
- }
- if opts.Launching != nil {
- env.Launching = *opts.Launching
- }
+ if scanned != nil {
+ env.Scanned = *scanned
+ }
+ if launching != nil {
+ env.Launching = *launching
}
return env
}
// RunCommand parses and runs a single ZapScript command.
+// The lm parameter is only needed for media-launching commands (launch guard);
+// pass nil for contexts where media launches are not allowed (e.g. control scripts).
func RunCommand(
pl platforms.Platform,
cfg *config.Instance,
@@ -259,8 +266,8 @@ func RunCommand(
totalCmds int,
currentIndex int,
db *database.Database,
- st *state.State,
- exprOpts *ExprEnvOptions,
+ lm *state.LauncherManager,
+ exprEnv *zapscript.ArgExprEnv,
) (platforms.CmdResult, error) {
unsafe := token.Unsafe
newCmds := make([]zapscript.Command, 0)
@@ -288,11 +295,9 @@ func RunCommand(
unsafe = true
}
- exprEnv := getExprEnv(pl, cfg, st, exprOpts)
-
for i, arg := range cmd.Args {
reader := zapscript.NewParser(arg)
- output, evalErr := reader.EvalExpressions(exprEnv)
+ output, evalErr := reader.EvalExpressions(*exprEnv)
if evalErr != nil {
return platforms.CmdResult{}, fmt.Errorf("error evaluating arg expression: %w", evalErr)
}
@@ -302,7 +307,7 @@ func RunCommand(
var advArgEvalErr error
cmd.AdvArgs.Range(func(k zapscript.Key, arg string) bool {
reader := zapscript.NewParser(arg)
- output, evalErr := reader.EvalExpressions(exprEnv)
+ output, evalErr := reader.EvalExpressions(*exprEnv)
if evalErr != nil {
advArgEvalErr = fmt.Errorf("error evaluating advanced arg expression: %w", evalErr)
return false
@@ -331,25 +336,32 @@ func RunCommand(
CurrentIndex: currentIndex,
Unsafe: unsafe,
Database: db,
- ExprEnv: &exprEnv,
+ ExprEnv: exprEnv,
+ }
+
+ if lm != nil {
+ env.LauncherCtx = lm.GetContext()
}
- cmdFunc, ok := cmdMap[cmd.Name]
+ cmdFn, ok := lookupCmd(cmd.Name)
if !ok {
return platforms.CmdResult{}, fmt.Errorf("unknown command: %s", cmd)
}
// Acquire launch guard for media-launching commands to prevent concurrent launches
if IsMediaLaunchingCommand(cmd.Name) {
- if guardErr := st.LauncherManager().TryStartLaunch(); guardErr != nil {
+ if lm == nil {
+ return platforms.CmdResult{}, errors.New("launcher manager required for media-launching commands")
+ }
+ if guardErr := lm.TryStartLaunch(); guardErr != nil {
return platforms.CmdResult{}, fmt.Errorf("launch guard: %w", guardErr)
}
- defer st.LauncherManager().EndLaunch()
- env.LauncherCtx = st.LauncherManager().GetContext()
+ defer lm.EndLaunch()
+ env.LauncherCtx = lm.GetContext()
}
log.Info().Msgf("running command: %s", cmd)
- res, err := cmdFunc(pl, env)
+ res, err := cmdFn(pl, env)
if err != nil {
if errors.Is(err, ErrFileNotFound) {
log.Warn().Err(err).Msgf("error running command: %s", cmd)
diff --git a/pkg/zapscript/commands_test.go b/pkg/zapscript/commands_test.go
index 6222d513..b9c874c6 100644
--- a/pkg/zapscript/commands_test.go
+++ b/pkg/zapscript/commands_test.go
@@ -150,6 +150,11 @@ func TestIsMediaLaunchingCommand(t *testing.T) {
cmdName: zapscript.ZapScriptCmdEcho,
want: false,
},
+ {
+ name: "control command",
+ cmdName: zapscript.ZapScriptCmdControl,
+ want: false,
+ },
// HTTP commands - should NOT be blocked
{
@@ -278,6 +283,7 @@ func TestIsMediaLaunchingCommand_ComprehensiveCoverage(t *testing.T) {
zapscript.ZapScriptCmdDelay,
zapscript.ZapScriptCmdStop,
zapscript.ZapScriptCmdEcho,
+ zapscript.ZapScriptCmdControl,
zapscript.ZapScriptCmdPlaylistPlay, // queues state change
zapscript.ZapScriptCmdPlaylistNext, // queues state change
zapscript.ZapScriptCmdPlaylistPrevious, // queues state change
@@ -318,7 +324,7 @@ func TestIsMediaLaunchingCommand_ComprehensiveCoverage(t *testing.T) {
}
}
-// TestGetExprEnv_ScannedContext verifies that Scanned fields are populated from ExprEnvOptions.
+// TestGetExprEnv_ScannedContext verifies that Scanned fields are populated.
func TestGetExprEnv_ScannedContext(t *testing.T) {
t.Parallel()
@@ -328,22 +334,20 @@ func TestGetExprEnv_ScannedContext(t *testing.T) {
cfg := &config.Instance{}
st, _ := state.NewState(mockPlatform, "test-boot-uuid")
- opts := &ExprEnvOptions{
- Scanned: &zapscript.ExprEnvScanned{
- ID: "scanned-token-id",
- Value: "**launch:/games/sonic.bin",
- Data: "NDEF-record-data",
- },
+ scanned := &zapscript.ExprEnvScanned{
+ ID: "scanned-token-id",
+ Value: "**launch:/games/sonic.bin",
+ Data: "NDEF-record-data",
}
- env := getExprEnv(mockPlatform, cfg, st, opts)
+ env := GetExprEnv(mockPlatform, cfg, st, scanned, nil)
assert.Equal(t, "scanned-token-id", env.Scanned.ID, "Scanned.ID should be populated")
assert.Equal(t, "**launch:/games/sonic.bin", env.Scanned.Value, "Scanned.Value should be populated")
assert.Equal(t, "NDEF-record-data", env.Scanned.Data, "Scanned.Data should be populated")
}
-// TestGetExprEnv_LaunchingContext verifies that Launching fields are populated from ExprEnvOptions.
+// TestGetExprEnv_LaunchingContext verifies that Launching fields are populated.
func TestGetExprEnv_LaunchingContext(t *testing.T) {
t.Parallel()
@@ -353,23 +357,21 @@ func TestGetExprEnv_LaunchingContext(t *testing.T) {
cfg := &config.Instance{}
st, _ := state.NewState(mockPlatform, "test-boot-uuid")
- opts := &ExprEnvOptions{
- Launching: &zapscript.ExprEnvLaunching{
- Path: "/games/genesis/sonic.bin",
- SystemID: "genesis",
- LauncherID: "retroarch",
- },
+ launching := &zapscript.ExprEnvLaunching{
+ Path: "/games/genesis/sonic.bin",
+ SystemID: "genesis",
+ LauncherID: "retroarch",
}
- env := getExprEnv(mockPlatform, cfg, st, opts)
+ env := GetExprEnv(mockPlatform, cfg, st, nil, launching)
assert.Equal(t, "/games/genesis/sonic.bin", env.Launching.Path, "Launching.Path should be populated")
assert.Equal(t, "genesis", env.Launching.SystemID, "Launching.SystemID should be populated")
assert.Equal(t, "retroarch", env.Launching.LauncherID, "Launching.LauncherID should be populated")
}
-// TestGetExprEnv_NilOpts verifies that nil ExprEnvOptions leaves Scanned/Launching empty.
-func TestGetExprEnv_NilOpts(t *testing.T) {
+// TestGetExprEnv_NilParams verifies that nil scanned/launching leaves those fields empty.
+func TestGetExprEnv_NilParams(t *testing.T) {
t.Parallel()
mockPlatform := mocks.NewMockPlatform()
@@ -378,14 +380,14 @@ func TestGetExprEnv_NilOpts(t *testing.T) {
cfg := &config.Instance{}
st, _ := state.NewState(mockPlatform, "test-boot-uuid")
- env := getExprEnv(mockPlatform, cfg, st, nil)
+ env := GetExprEnv(mockPlatform, cfg, st, nil, nil)
- assert.Empty(t, env.Scanned.ID, "Scanned.ID should be empty with nil opts")
- assert.Empty(t, env.Scanned.Value, "Scanned.Value should be empty with nil opts")
- assert.Empty(t, env.Scanned.Data, "Scanned.Data should be empty with nil opts")
- assert.Empty(t, env.Launching.Path, "Launching.Path should be empty with nil opts")
- assert.Empty(t, env.Launching.SystemID, "Launching.SystemID should be empty with nil opts")
- assert.Empty(t, env.Launching.LauncherID, "Launching.LauncherID should be empty with nil opts")
+ assert.Empty(t, env.Scanned.ID, "Scanned.ID should be empty with nil params")
+ assert.Empty(t, env.Scanned.Value, "Scanned.Value should be empty with nil params")
+ assert.Empty(t, env.Scanned.Data, "Scanned.Data should be empty with nil params")
+ assert.Empty(t, env.Launching.Path, "Launching.Path should be empty with nil params")
+ assert.Empty(t, env.Launching.SystemID, "Launching.SystemID should be empty with nil params")
+ assert.Empty(t, env.Launching.LauncherID, "Launching.LauncherID should be empty with nil params")
}
// TestGetExprEnv_BothContexts verifies both Scanned and Launching can be set simultaneously.
@@ -398,20 +400,18 @@ func TestGetExprEnv_BothContexts(t *testing.T) {
cfg := &config.Instance{}
st, _ := state.NewState(mockPlatform, "test-boot-uuid")
- opts := &ExprEnvOptions{
- Scanned: &zapscript.ExprEnvScanned{
- ID: "token-123",
- Value: "test-value",
- Data: "test-data",
- },
- Launching: &zapscript.ExprEnvLaunching{
- Path: "/path/to/game",
- SystemID: "snes",
- LauncherID: "mister",
- },
+ scanned := &zapscript.ExprEnvScanned{
+ ID: "token-123",
+ Value: "test-value",
+ Data: "test-data",
+ }
+ launching := &zapscript.ExprEnvLaunching{
+ Path: "/path/to/game",
+ SystemID: "snes",
+ LauncherID: "mister",
}
- env := getExprEnv(mockPlatform, cfg, st, opts)
+ env := GetExprEnv(mockPlatform, cfg, st, scanned, launching)
// Verify Scanned
assert.Equal(t, "token-123", env.Scanned.ID)
@@ -443,7 +443,7 @@ func TestGetExprEnv_ActiveMedia(t *testing.T) {
Name: "Super Mario World",
})
- env := getExprEnv(mockPlatform, cfg, st, nil)
+ env := GetExprEnv(mockPlatform, cfg, st, nil, nil)
assert.True(t, env.MediaPlaying, "MediaPlaying should be true when media is active")
assert.Equal(t, "retroarch", env.ActiveMedia.LauncherID)
@@ -463,7 +463,7 @@ func TestGetExprEnv_NoActiveMedia(t *testing.T) {
cfg := &config.Instance{}
st, _ := state.NewState(mockPlatform, "test-boot-uuid")
- env := getExprEnv(mockPlatform, cfg, st, nil)
+ env := GetExprEnv(mockPlatform, cfg, st, nil, nil)
assert.False(t, env.MediaPlaying, "MediaPlaying should be false when no media is active")
assert.Empty(t, env.ActiveMedia.LauncherID)
@@ -491,6 +491,7 @@ func TestIsValidCommand(t *testing.T) {
{name: "stop", cmdName: zapscript.ZapScriptCmdStop, want: true},
{name: "http.get", cmdName: zapscript.ZapScriptCmdHTTPGet, want: true},
{name: "input.keyboard", cmdName: zapscript.ZapScriptCmdInputKeyboard, want: true},
+ {name: "control", cmdName: zapscript.ZapScriptCmdControl, want: true},
// Invalid commands
{name: "unknown command", cmdName: "unknown.cmd", want: false},
{name: "empty string", cmdName: "", want: false},
diff --git a/pkg/zapscript/control.go b/pkg/zapscript/control.go
index 3ffd2cfc..3a0415cb 100644
--- a/pkg/zapscript/control.go
+++ b/pkg/zapscript/control.go
@@ -28,7 +28,6 @@ import (
"github.com/ZaparooProject/zaparoo-core/v2/pkg/database"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/service/playlists"
- "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/state"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/service/tokens"
)
@@ -52,19 +51,27 @@ func IsPlaylistCommand(cmdName string) bool {
}
}
+// IsControlCommand returns true if the command is the control command.
+// The control command is blocked in control context to prevent recursion
+// where a control's Script invokes another control command.
+func IsControlCommand(cmdName string) bool {
+ return cmdName == gozapscript.ZapScriptCmdControl
+}
+
// isControlAllowed returns true if the command is safe to run in control context.
func isControlAllowed(cmdName string) bool {
- return !IsMediaLaunchingCommand(cmdName) && !IsPlaylistCommand(cmdName)
+ return !IsMediaLaunchingCommand(cmdName) && !IsPlaylistCommand(cmdName) && !IsControlCommand(cmdName)
}
// RunControlScript parses and executes a zapscript string in control context.
// All commands are validated before any are executed to prevent partial execution.
+// The exprEnv is passed directly to each command instead of building from state.
func RunControlScript(
pl platforms.Platform,
cfg *config.Instance,
db *database.Database,
- st *state.State,
script string,
+ exprEnv *gozapscript.ArgExprEnv,
) error {
parser := gozapscript.NewParser(script)
parsed, err := parser.ParseScript()
@@ -88,6 +95,11 @@ func RunControlScript(
Source: tokens.SourceControl,
}
+ var env gozapscript.ArgExprEnv
+ if exprEnv != nil {
+ env = *exprEnv
+ }
+
for i, cmd := range parsed.Cmds {
_, err := RunCommand(
pl, cfg,
@@ -97,8 +109,8 @@ func RunControlScript(
len(parsed.Cmds),
i,
db,
- st,
- nil,
+ nil, // lm not needed — control commands cannot launch media
+ &env,
)
if err != nil {
return fmt.Errorf("control command %q failed: %w", cmd.Name, err)
diff --git a/pkg/zapscript/control_cmd.go b/pkg/zapscript/control_cmd.go
new file mode 100644
index 00000000..478b86fb
--- /dev/null
+++ b/pkg/zapscript/control_cmd.go
@@ -0,0 +1,113 @@
+// 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 zapscript
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "github.com/ZaparooProject/go-zapscript"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms"
+ "github.com/rs/zerolog/log"
+)
+
+var (
+ ErrNoActiveMedia = errors.New("no active media")
+ ErrNoLauncher = errors.New("no launcher associated with active media")
+)
+
+//nolint:gocritic // single-use parameter in command handler
+func cmdControl(pl platforms.Platform, env platforms.CmdEnv) (platforms.CmdResult, error) {
+ if len(env.Cmd.Args) == 0 {
+ return platforms.CmdResult{}, ErrArgCount
+ }
+
+ action := env.Cmd.Args[0]
+ if action == "" {
+ return platforms.CmdResult{}, ErrRequiredArgs
+ }
+
+ if env.ExprEnv == nil || !env.ExprEnv.MediaPlaying {
+ return platforms.CmdResult{}, ErrNoActiveMedia
+ }
+
+ launcherID := env.ExprEnv.ActiveMedia.LauncherID
+ if launcherID == "" {
+ return platforms.CmdResult{}, ErrNoLauncher
+ }
+
+ var launcher *platforms.Launcher
+ for _, l := range pl.Launchers(env.Cfg) {
+ if l.ID == launcherID {
+ launcher = &l
+ break
+ }
+ }
+ if launcher == nil {
+ return platforms.CmdResult{}, fmt.Errorf("launcher not found: %s", launcherID)
+ }
+
+ if len(launcher.Controls) == 0 {
+ return platforms.CmdResult{}, fmt.Errorf("launcher %s has no control capabilities", launcherID)
+ }
+
+ control, ok := launcher.Controls[action]
+ if !ok {
+ return platforms.CmdResult{}, fmt.Errorf("action %q not supported by launcher %s", action, launcherID)
+ }
+
+ // Build control params from advargs, stripping the global "when" key
+ var args map[string]string
+ raw := env.Cmd.AdvArgs.Raw()
+ if len(raw) > 0 {
+ args = make(map[string]string, len(raw))
+ for k, v := range raw {
+ if k == string(zapscript.KeyWhen) {
+ continue
+ }
+ args[k] = v
+ }
+ if len(args) == 0 {
+ args = nil
+ }
+ }
+
+ log.Info().Str("action", action).Str("launcher", launcherID).Msg("executing control command")
+
+ var err error
+ switch {
+ case control.Func != nil:
+ ctx := env.LauncherCtx
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ err = control.Func(ctx, env.Cfg, platforms.ControlParams{Args: args})
+ case control.Script != "":
+ err = RunControlScript(pl, env.Cfg, env.Database, control.Script, env.ExprEnv)
+ default:
+ err = fmt.Errorf("control %q has no implementation", action)
+ }
+ if err != nil {
+ return platforms.CmdResult{}, fmt.Errorf("control action %q failed: %w", action, err)
+ }
+
+ return platforms.CmdResult{}, nil
+}
diff --git a/pkg/zapscript/control_cmd_test.go b/pkg/zapscript/control_cmd_test.go
new file mode 100644
index 00000000..8db9311a
--- /dev/null
+++ b/pkg/zapscript/control_cmd_test.go
@@ -0,0 +1,420 @@
+// 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 zapscript
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/ZaparooProject/go-zapscript"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/config"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/mocks"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func newControlExprEnv(launcherID string) *zapscript.ArgExprEnv {
+ return &zapscript.ArgExprEnv{
+ MediaPlaying: true,
+ ActiveMedia: zapscript.ExprEnvActiveMedia{
+ LauncherID: launcherID,
+ },
+ }
+}
+
+func TestCmdControl_Success(t *testing.T) {
+ t.Parallel()
+
+ var calledWith platforms.ControlParams
+ controlFunc := func(_ context.Context, _ *config.Instance, params platforms.ControlParams) error {
+ calledWith = params
+ return nil
+ }
+
+ mockPlatform := mocks.NewMockPlatform()
+ mockPlatform.On("Launchers", (*config.Instance)(nil)).Return([]platforms.Launcher{
+ {
+ ID: "test-launcher",
+ Controls: map[string]platforms.Control{
+ "toggle_pause": {Func: controlFunc},
+ },
+ },
+ })
+
+ env := platforms.CmdEnv{
+ Cmd: zapscript.Command{
+ Name: "control",
+ Args: []string{"toggle_pause"},
+ },
+ ExprEnv: newControlExprEnv("test-launcher"),
+ }
+
+ result, err := cmdControl(mockPlatform, env)
+ require.NoError(t, err)
+ assert.Equal(t, platforms.CmdResult{}, result)
+ assert.Nil(t, calledWith.Args)
+}
+
+func TestCmdControl_SuccessWithScript(t *testing.T) {
+ t.Parallel()
+
+ mockPlatform := mocks.NewMockPlatform()
+ mockPlatform.On("ID").Return("test")
+ mockPlatform.On("KeyboardPress", "{f2}").Return(nil)
+ mockPlatform.On("Launchers", (*config.Instance)(nil)).Return([]platforms.Launcher{
+ {
+ ID: "test-launcher",
+ Controls: map[string]platforms.Control{
+ "save_state": {Script: "**input.keyboard:{f2}"},
+ },
+ },
+ })
+
+ env := platforms.CmdEnv{
+ Cmd: zapscript.Command{
+ Name: "control",
+ Args: []string{"save_state"},
+ },
+ ExprEnv: newControlExprEnv("test-launcher"),
+ }
+
+ result, err := cmdControl(mockPlatform, env)
+ require.NoError(t, err)
+ assert.Equal(t, platforms.CmdResult{}, result)
+ mockPlatform.AssertCalled(t, "KeyboardPress", "{f2}")
+}
+
+func TestCmdControl_ScriptExprEnvPropagation(t *testing.T) {
+ // Skipped: input macro commands (input.keyboard, input.gamepad) don't support
+ // expression evaluation in go-zapscript's parseInputMacroArg.
+ // See: https://github.com/ZaparooProject/go-zapscript/issues/2
+ t.Skip("blocked by go-zapscript#2: input macro commands don't support expressions")
+}
+
+func TestCmdControl_NoArgs(t *testing.T) {
+ t.Parallel()
+
+ env := platforms.CmdEnv{
+ Cmd: zapscript.Command{
+ Name: "control",
+ Args: []string{},
+ },
+ }
+
+ _, err := cmdControl(nil, env)
+ require.ErrorIs(t, err, ErrArgCount)
+}
+
+func TestCmdControl_EmptyAction(t *testing.T) {
+ t.Parallel()
+
+ env := platforms.CmdEnv{
+ Cmd: zapscript.Command{
+ Name: "control",
+ Args: []string{""},
+ },
+ }
+
+ _, err := cmdControl(nil, env)
+ require.ErrorIs(t, err, ErrRequiredArgs)
+}
+
+func TestCmdControl_NoActiveMedia(t *testing.T) {
+ t.Parallel()
+
+ env := platforms.CmdEnv{
+ Cmd: zapscript.Command{
+ Name: "control",
+ Args: []string{"toggle_pause"},
+ },
+ ExprEnv: &zapscript.ArgExprEnv{
+ MediaPlaying: false,
+ },
+ }
+
+ _, err := cmdControl(nil, env)
+ require.ErrorIs(t, err, ErrNoActiveMedia)
+}
+
+func TestCmdControl_NilExprEnv(t *testing.T) {
+ t.Parallel()
+
+ env := platforms.CmdEnv{
+ Cmd: zapscript.Command{
+ Name: "control",
+ Args: []string{"toggle_pause"},
+ },
+ }
+
+ _, err := cmdControl(nil, env)
+ require.ErrorIs(t, err, ErrNoActiveMedia)
+}
+
+func TestCmdControl_EmptyLauncherID(t *testing.T) {
+ t.Parallel()
+
+ env := platforms.CmdEnv{
+ Cmd: zapscript.Command{
+ Name: "control",
+ Args: []string{"toggle_pause"},
+ },
+ ExprEnv: newControlExprEnv(""),
+ }
+
+ _, err := cmdControl(nil, env)
+ require.ErrorIs(t, err, ErrNoLauncher)
+}
+
+func TestCmdControl_LauncherNotFound(t *testing.T) {
+ t.Parallel()
+
+ mockPlatform := mocks.NewMockPlatform()
+ mockPlatform.On("Launchers", (*config.Instance)(nil)).Return([]platforms.Launcher{
+ {ID: "other-launcher"},
+ })
+
+ env := platforms.CmdEnv{
+ Cmd: zapscript.Command{
+ Name: "control",
+ Args: []string{"toggle_pause"},
+ },
+ ExprEnv: newControlExprEnv("missing-launcher"),
+ }
+
+ _, err := cmdControl(mockPlatform, env)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "launcher not found")
+}
+
+func TestCmdControl_NoControls(t *testing.T) {
+ t.Parallel()
+
+ mockPlatform := mocks.NewMockPlatform()
+ mockPlatform.On("Launchers", (*config.Instance)(nil)).Return([]platforms.Launcher{
+ {ID: "test-launcher"},
+ })
+
+ env := platforms.CmdEnv{
+ Cmd: zapscript.Command{
+ Name: "control",
+ Args: []string{"toggle_pause"},
+ },
+ ExprEnv: newControlExprEnv("test-launcher"),
+ }
+
+ _, err := cmdControl(mockPlatform, env)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "no control capabilities")
+}
+
+func TestCmdControl_UnknownAction(t *testing.T) {
+ t.Parallel()
+
+ mockPlatform := mocks.NewMockPlatform()
+ mockPlatform.On("Launchers", (*config.Instance)(nil)).Return([]platforms.Launcher{
+ {
+ ID: "test-launcher",
+ Controls: map[string]platforms.Control{
+ "toggle_pause": {Func: func(context.Context, *config.Instance, platforms.ControlParams) error {
+ return nil
+ }},
+ },
+ },
+ })
+
+ env := platforms.CmdEnv{
+ Cmd: zapscript.Command{
+ Name: "control",
+ Args: []string{"nonexistent_action"},
+ },
+ ExprEnv: newControlExprEnv("test-launcher"),
+ }
+
+ _, err := cmdControl(mockPlatform, env)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "not supported by launcher")
+}
+
+func TestCmdControl_NoImplementation(t *testing.T) {
+ t.Parallel()
+
+ mockPlatform := mocks.NewMockPlatform()
+ mockPlatform.On("Launchers", (*config.Instance)(nil)).Return([]platforms.Launcher{
+ {
+ ID: "test-launcher",
+ Controls: map[string]platforms.Control{
+ "toggle_pause": {}, // neither Func nor Script
+ },
+ },
+ })
+
+ env := platforms.CmdEnv{
+ Cmd: zapscript.Command{
+ Name: "control",
+ Args: []string{"toggle_pause"},
+ },
+ ExprEnv: newControlExprEnv("test-launcher"),
+ }
+
+ _, err := cmdControl(mockPlatform, env)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "no implementation")
+}
+
+func TestCmdControl_AdvArgsPassThrough(t *testing.T) {
+ t.Parallel()
+
+ var calledWith platforms.ControlParams
+ controlFunc := func(_ context.Context, _ *config.Instance, params platforms.ControlParams) error {
+ calledWith = params
+ return nil
+ }
+
+ mockPlatform := mocks.NewMockPlatform()
+ mockPlatform.On("Launchers", (*config.Instance)(nil)).Return([]platforms.Launcher{
+ {
+ ID: "test-launcher",
+ Controls: map[string]platforms.Control{
+ "save_state": {Func: controlFunc},
+ },
+ },
+ })
+
+ env := platforms.CmdEnv{
+ Cmd: zapscript.Command{
+ Name: "control",
+ Args: []string{"save_state"},
+ AdvArgs: zapscript.NewAdvArgs(map[string]string{
+ "slot": "3",
+ "name": "quicksave",
+ }),
+ },
+ ExprEnv: newControlExprEnv("test-launcher"),
+ }
+
+ _, err := cmdControl(mockPlatform, env)
+ require.NoError(t, err)
+ assert.Equal(t, map[string]string{"slot": "3", "name": "quicksave"}, calledWith.Args)
+}
+
+func TestCmdControl_WhenKeyStripped(t *testing.T) {
+ t.Parallel()
+
+ var calledWith platforms.ControlParams
+ controlFunc := func(_ context.Context, _ *config.Instance, params platforms.ControlParams) error {
+ calledWith = params
+ return nil
+ }
+
+ mockPlatform := mocks.NewMockPlatform()
+ mockPlatform.On("Launchers", (*config.Instance)(nil)).Return([]platforms.Launcher{
+ {
+ ID: "test-launcher",
+ Controls: map[string]platforms.Control{
+ "save_state": {Func: controlFunc},
+ },
+ },
+ })
+
+ env := platforms.CmdEnv{
+ Cmd: zapscript.Command{
+ Name: "control",
+ Args: []string{"save_state"},
+ AdvArgs: zapscript.NewAdvArgs(map[string]string{
+ "when": "true",
+ "slot": "1",
+ }),
+ },
+ ExprEnv: newControlExprEnv("test-launcher"),
+ }
+
+ _, err := cmdControl(mockPlatform, env)
+ require.NoError(t, err)
+ assert.Equal(t, map[string]string{"slot": "1"}, calledWith.Args)
+ assert.NotContains(t, calledWith.Args, "when")
+}
+
+func TestCmdControl_WhenOnlyAdvArg(t *testing.T) {
+ t.Parallel()
+
+ var calledWith platforms.ControlParams
+ controlFunc := func(_ context.Context, _ *config.Instance, params platforms.ControlParams) error {
+ calledWith = params
+ return nil
+ }
+
+ mockPlatform := mocks.NewMockPlatform()
+ mockPlatform.On("Launchers", (*config.Instance)(nil)).Return([]platforms.Launcher{
+ {
+ ID: "test-launcher",
+ Controls: map[string]platforms.Control{
+ "toggle_pause": {Func: controlFunc},
+ },
+ },
+ })
+
+ env := platforms.CmdEnv{
+ Cmd: zapscript.Command{
+ Name: "control",
+ Args: []string{"toggle_pause"},
+ AdvArgs: zapscript.NewAdvArgs(map[string]string{
+ "when": "true",
+ }),
+ },
+ ExprEnv: newControlExprEnv("test-launcher"),
+ }
+
+ _, err := cmdControl(mockPlatform, env)
+ require.NoError(t, err)
+ assert.Nil(t, calledWith.Args, "args should be nil when only 'when' is present")
+}
+
+func TestCmdControl_FuncError(t *testing.T) {
+ t.Parallel()
+
+ controlFunc := func(_ context.Context, _ *config.Instance, _ platforms.ControlParams) error {
+ return errors.New("kodi connection refused")
+ }
+
+ mockPlatform := mocks.NewMockPlatform()
+ mockPlatform.On("Launchers", (*config.Instance)(nil)).Return([]platforms.Launcher{
+ {
+ ID: "test-launcher",
+ Controls: map[string]platforms.Control{
+ "toggle_pause": {Func: controlFunc},
+ },
+ },
+ })
+
+ env := platforms.CmdEnv{
+ Cmd: zapscript.Command{
+ Name: "control",
+ Args: []string{"toggle_pause"},
+ },
+ ExprEnv: newControlExprEnv("test-launcher"),
+ }
+
+ _, err := cmdControl(mockPlatform, env)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "kodi connection refused")
+ assert.Contains(t, err.Error(), "control action")
+}
diff --git a/pkg/zapscript/control_test.go b/pkg/zapscript/control_test.go
index 92184bb2..f54c71c1 100644
--- a/pkg/zapscript/control_test.go
+++ b/pkg/zapscript/control_test.go
@@ -23,27 +23,12 @@ import (
"testing"
gozapscript "github.com/ZaparooProject/go-zapscript"
- "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/config"
- "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/state"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/mocks"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-func drainNotifications(t *testing.T, ns <-chan models.Notification) {
- t.Helper()
- t.Cleanup(func() {
- for {
- select {
- case <-ns:
- default:
- return
- }
- }
- })
-}
-
func TestRunControlScript_SingleCommand(t *testing.T) {
t.Parallel()
@@ -52,10 +37,9 @@ func TestRunControlScript_SingleCommand(t *testing.T) {
mockPlatform.On("KeyboardPress", "{f2}").Return(nil)
cfg := &config.Instance{}
- st, ns := state.NewState(mockPlatform, "test-boot-uuid")
- drainNotifications(t, ns)
+ exprEnv := gozapscript.ArgExprEnv{Platform: "test"}
- err := RunControlScript(mockPlatform, cfg, nil, st, "**input.keyboard:{f2}")
+ err := RunControlScript(mockPlatform, cfg, nil, "**input.keyboard:{f2}", &exprEnv)
require.NoError(t, err)
mockPlatform.AssertCalled(t, "KeyboardPress", "{f2}")
}
@@ -69,10 +53,9 @@ func TestRunControlScript_MultiCommand(t *testing.T) {
mockPlatform.On("KeyboardPress", "{f5}").Return(nil)
cfg := &config.Instance{}
- st, ns := state.NewState(mockPlatform, "test-boot-uuid")
- drainNotifications(t, ns)
+ exprEnv := gozapscript.ArgExprEnv{Platform: "test"}
- err := RunControlScript(mockPlatform, cfg, nil, st, "**input.keyboard:{f2}||**input.keyboard:{f5}")
+ err := RunControlScript(mockPlatform, cfg, nil, "**input.keyboard:{f2}||**input.keyboard:{f5}", &exprEnv)
require.NoError(t, err)
mockPlatform.AssertCalled(t, "KeyboardPress", "{f2}")
mockPlatform.AssertCalled(t, "KeyboardPress", "{f5}")
@@ -84,11 +67,7 @@ func TestRunControlScript_RejectsLaunchCommands(t *testing.T) {
mockPlatform := mocks.NewMockPlatform()
mockPlatform.On("ID").Return("test")
- cfg := &config.Instance{}
- st, ns := state.NewState(mockPlatform, "test-boot-uuid")
- drainNotifications(t, ns)
-
- err := RunControlScript(mockPlatform, cfg, nil, st, "**launch:/path/to/game")
+ err := RunControlScript(mockPlatform, &config.Instance{}, nil, "**launch:/path/to/game", nil)
require.ErrorIs(t, err, ErrControlCommandNotAllowed)
assert.Contains(t, err.Error(), "launch")
}
@@ -99,11 +78,7 @@ func TestRunControlScript_RejectsPlaylistCommands(t *testing.T) {
mockPlatform := mocks.NewMockPlatform()
mockPlatform.On("ID").Return("test")
- cfg := &config.Instance{}
- st, ns := state.NewState(mockPlatform, "test-boot-uuid")
- drainNotifications(t, ns)
-
- err := RunControlScript(mockPlatform, cfg, nil, st, "**playlist.play")
+ err := RunControlScript(mockPlatform, &config.Instance{}, nil, "**playlist.play", nil)
require.ErrorIs(t, err, ErrControlCommandNotAllowed)
assert.Contains(t, err.Error(), "playlist.play")
}
@@ -111,25 +86,14 @@ func TestRunControlScript_RejectsPlaylistCommands(t *testing.T) {
func TestRunControlScript_EmptyScript(t *testing.T) {
t.Parallel()
- mockPlatform := mocks.NewMockPlatform()
- cfg := &config.Instance{}
- st, ns := state.NewState(mockPlatform, "test-boot-uuid")
- drainNotifications(t, ns)
-
- err := RunControlScript(mockPlatform, cfg, nil, st, "")
+ err := RunControlScript(nil, &config.Instance{}, nil, "", nil)
require.Error(t, err)
}
func TestRunControlScript_InvalidSyntax(t *testing.T) {
t.Parallel()
- mockPlatform := mocks.NewMockPlatform()
- cfg := &config.Instance{}
- st, ns := state.NewState(mockPlatform, "test-boot-uuid")
- drainNotifications(t, ns)
-
- // An invalid script (just the prefix with no command)
- err := RunControlScript(mockPlatform, cfg, nil, st, "**")
+ err := RunControlScript(nil, &config.Instance{}, nil, "**", nil)
require.Error(t, err)
}
@@ -139,13 +103,9 @@ func TestRunControlScript_RejectsBeforeExecuting(t *testing.T) {
mockPlatform := mocks.NewMockPlatform()
mockPlatform.On("ID").Return("test")
- cfg := &config.Instance{}
- st, ns := state.NewState(mockPlatform, "test-boot-uuid")
- drainNotifications(t, ns)
-
// Valid command first, then a forbidden launch command.
// The valid command must NOT execute.
- err := RunControlScript(mockPlatform, cfg, nil, st, "**input.keyboard:{f2}||**launch:/path/to/game")
+ err := RunControlScript(mockPlatform, &config.Instance{}, nil, "**input.keyboard:{f2}||**launch:/path/to/game", nil)
require.ErrorIs(t, err, ErrControlCommandNotAllowed)
mockPlatform.AssertNotCalled(t, "KeyboardPress", "{f2}")
}
@@ -156,15 +116,44 @@ func TestRunControlScript_RejectsPlaylistInMultiCommand(t *testing.T) {
mockPlatform := mocks.NewMockPlatform()
mockPlatform.On("ID").Return("test")
- cfg := &config.Instance{}
- st, ns := state.NewState(mockPlatform, "test-boot-uuid")
- drainNotifications(t, ns)
-
- err := RunControlScript(mockPlatform, cfg, nil, st, "**input.keyboard:{f2}||**playlist.play")
+ err := RunControlScript(mockPlatform, &config.Instance{}, nil, "**input.keyboard:{f2}||**playlist.play", nil)
require.ErrorIs(t, err, ErrControlCommandNotAllowed)
mockPlatform.AssertNotCalled(t, "KeyboardPress", "{f2}")
}
+func TestRunControlScript_RejectsControlCommand(t *testing.T) {
+ t.Parallel()
+
+ mockPlatform := mocks.NewMockPlatform()
+ mockPlatform.On("ID").Return("test")
+
+ err := RunControlScript(mockPlatform, &config.Instance{}, nil, "**control:toggle_pause", nil)
+ require.ErrorIs(t, err, ErrControlCommandNotAllowed)
+ assert.Contains(t, err.Error(), "control")
+}
+
+func TestIsControlCommand(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ cmdName string
+ want bool
+ }{
+ {name: "control", cmdName: gozapscript.ZapScriptCmdControl, want: true},
+ {name: "launch is not control", cmdName: "launch", want: false},
+ {name: "stop is not control", cmdName: "stop", want: false},
+ {name: "empty string", cmdName: "", want: false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ assert.Equal(t, tt.want, IsControlCommand(tt.cmdName))
+ })
+ }
+}
+
func TestIsPlaylistCommand(t *testing.T) {
t.Parallel()
@@ -182,6 +171,7 @@ func TestIsPlaylistCommand(t *testing.T) {
{name: "playlist.load", cmdName: gozapscript.ZapScriptCmdPlaylistLoad, want: true},
{name: "playlist.open", cmdName: gozapscript.ZapScriptCmdPlaylistOpen, want: true},
{name: "launch is not playlist", cmdName: "launch", want: false},
+ {name: "control is not playlist", cmdName: gozapscript.ZapScriptCmdControl, want: false},
{name: "input.keyboard is not playlist", cmdName: "input.keyboard", want: false},
{name: "empty string", cmdName: "", want: false},
}
diff --git a/scripts/tasks/utils/makezip/main.go b/scripts/tasks/utils/makezip/main.go
index 3ee81fbc..759a3329 100644
--- a/scripts/tasks/utils/makezip/main.go
+++ b/scripts/tasks/utils/makezip/main.go
@@ -387,6 +387,7 @@ func addDirToZip(zipWriter *zip.Writer, dirPath, buildDir string) error {
}
destPath := filepath.Join(buildDir, filepath.Base(dirPath), relPath)
+ //nolint:gosec // G703: paths from internal walk, not user input
if err := os.MkdirAll(filepath.Dir(destPath), 0o750); err != nil {
return fmt.Errorf("failed to create directory: %w", err)
}
@@ -524,6 +525,7 @@ func addDirToTar(tarWriter *tar.Writer, dirPath, buildDir string) error {
}
destPath := filepath.Join(buildDir, filepath.Base(dirPath), relPath)
+ //nolint:gosec // G703: paths from internal walk, not user input
if err := os.MkdirAll(filepath.Dir(destPath), 0o750); err != nil {
return fmt.Errorf("failed to create directory: %w", err)
}