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) }