Skip to content

feat: add RetroArch launchers for Linux and SteamOS#1066

Merged
wizzomafizzo merged 6 commits into
mainfrom
feat/zapos-retroarch-launcher
Jul 10, 2026
Merged

feat: add RetroArch launchers for Linux and SteamOS#1066
wizzomafizzo merged 6 commits into
mainfrom
feat/zapos-retroarch-launcher

Conversation

@wizzomafizzo

@wizzomafizzo wizzomafizzo commented Jul 10, 2026

Copy link
Copy Markdown
Member

Summary

  • add shared, core-specific RetroArch launchers with controls, media scanning, and runtime availability reporting
  • add built-in RetroArch Flatpak support for generic Linux and improve SteamOS/EmuDeck integration
  • harden blocking launcher lifecycle handling to prevent stale active-media state
  • add appliance entrypoint and ARM64 build/release coverage
  • resolve project lint findings and retain full race-test coverage

Validated with task lint-fix and task test.

Summary by CodeRabbit

  • New Features
    • Added support for the ZapOS Linux ARM64 platform, including a new Linux entrypoint, platform launchers/paths/settings, and build-task wiring.
    • Expanded RetroArch integration for Linux/SteamOS/ZapOS, including Flatpak-based launching, core mappings, UDP controls, and network-command overlay configuration.
    • Launcher listings now include available plus availabilityReason when applicable.
  • Bug Fixes
    • Improved blocking-launch lifecycle to prevent stale “active media” state.
    • Unavailable launchers are now excluded from selection.
  • Documentation
    • Added ZapOS documentation packaging support.

@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5eb22257-a982-4aaf-bc77-353546f1c887

📥 Commits

Reviewing files that changed from the base of the PR and between ab2dd91 and 85995f1.

📒 Files selected for processing (7)
  • pkg/helpers/launcher_cache.go
  • pkg/platforms/launch.go
  • pkg/platforms/launch_test.go
  • pkg/platforms/shared/retroarch/options.go
  • pkg/platforms/shared/retroarch/options_test.go
  • pkg/platforms/steamos/emudeck.go
  • pkg/zapscript/launch.go
🚧 Files skipped from review as they are similar to previous changes (6)
  • pkg/zapscript/launch.go
  • pkg/platforms/launch_test.go
  • pkg/platforms/launch.go
  • pkg/helpers/launcher_cache.go
  • pkg/platforms/shared/retroarch/options.go
  • pkg/platforms/steamos/emudeck.go

📝 Walkthrough

Walkthrough

This PR adds ZapOS support with a shared RetroArch launcher framework, runtime launcher availability metadata, coordinated blocking-process lifecycle handling, Linux and SteamOS integration, build and documentation wiring, and lint and test cleanup.

Changes

ZapOS and RetroArch platform integration

Layer / File(s) Summary
Launcher availability contracts and cache
pkg/platforms/platforms.go, pkg/helpers/launcher_cache.go, pkg/api/methods/launchers.go, pkg/api/models/responses.go, pkg/platforms/launch.go, pkg/zapscript/launch.go, pkg/database/mediascanner/mediascanner.go
Launchers gain availability fields and runtime checks; filtered launchers are used by cache, API responses, launch gating, script inference, and media path discovery.
Blocking process coordination and cleanup
pkg/platforms/launch.go, pkg/platforms/launch_test.go, pkg/platforms/shared/linuxbase/base.go, pkg/platforms/shared/linuxbase/base_test.go
Blocking launches now coordinate tracked-process waiting, custom kill handling, replacement tracking, and conditional active-media clearing.
Shared RetroArch launcher framework
pkg/platforms/shared/retroarch/*
New options, core mapping, command construction, launcher creation, UDP controls, network configuration, and associated tests are added.
Linux, SteamOS, and ZapOS platform wiring
pkg/platforms/linux/*, pkg/platforms/steamos/*, pkg/platforms/zapos/*, pkg/platforms/ids/ids.go, cmd/zapos/main.go
Linux and SteamOS register shared RetroArch launchers and network configuration; EmuDeck uses shared core mappings; ZapOS adds platform behavior, paths, launchers, and a service entrypoint.
ZapOS delivery and supporting cleanup
.github/workflows/*, Taskfile.dist.yml, scripts/tasks/*, go.mod, pkg/database/mediadb/media_user_data.go, pkg/service/*, pkg/zapscript/*, pkg/api/*
ZapOS build and documentation paths are wired in, vulnerability output is filtered for an accepted identifier, the Go version is updated, and lint, cancellation, and row-cleanup changes are applied.

Estimated code review effort: 4 (Complex) | ~75 minutes

Sequence Diagram(s)

sequenceDiagram
  participant LauncherCache
  participant Launcher
  participant DoLaunch
  participant LinuxBase
  participant RetroArch
  LauncherCache->>Launcher: Evaluate Availability
  LauncherCache->>DoLaunch: Select available launcher
  DoLaunch->>RetroArch: Launch blocking process
  DoLaunch->>LinuxBase: Track process
  LinuxBase->>RetroArch: Wait or custom Kill
  LinuxBase->>DoLaunch: Clear matching active media
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 21.62% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: adding RetroArch launchers for Linux and SteamOS.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/zapos-retroarch-launcher

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
pkg/platforms/launch.go (1)

137-155: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Availability/nil-launch checks run after the currently active launcher is already stopped.

StopActiveLauncher(StopForPreemption) executes before the Launcher.Launch == nil and new Launcher.Availability checks. If the target launcher is unavailable (e.g., missing RetroArch core/executable), the current media is preempted/stopped anyway, and the launch then fails, leaving nothing active. Since Availability exists specifically as a pre-flight check, it should be validated before any side effects (stopping the currently running launcher) occur.

🐛 Proposed fix: validate before preempting
-	// Stop any currently running launcher before starting new one
-	// This ensures tracked processes (like videos) are stopped even when
-	// FireAndForget launches (like MGL files) start. UNLESS the new launcher
-	// uses a running instance (e.g., Kodi), in which case the platform's
-	// shouldKeepRunningInstance logic will handle stopping if needed.
-	if slot == mediaslot.Primary && params.Launcher.UsesRunningInstance == "" {
-		if stopErr := params.Platform.StopActiveLauncher(StopForPreemption); stopErr != nil {
-			log.Debug().Err(stopErr).Msg("no active launcher to stop or error stopping")
-		}
-	}
-
 	if params.Launcher.Launch == nil {
 		return fmt.Errorf("launcher %q has no launch function configured", params.Launcher.ID)
 	}
 	if params.Launcher.Availability != nil {
 		if err := params.Launcher.Availability(params.Config); err != nil {
 			return fmt.Errorf("launcher %q is unavailable: %w", params.Launcher.ID, err)
 		}
 	}
+
+	// Stop any currently running launcher before starting new one
+	// This ensures tracked processes (like videos) are stopped even when
+	// FireAndForget launches (like MGL files) start. UNLESS the new launcher
+	// uses a running instance (e.g., Kodi), in which case the platform's
+	// shouldKeepRunningInstance logic will handle stopping if needed.
+	if slot == mediaslot.Primary && params.Launcher.UsesRunningInstance == "" {
+		if stopErr := params.Platform.StopActiveLauncher(StopForPreemption); stopErr != nil {
+			log.Debug().Err(stopErr).Msg("no active launcher to stop or error stopping")
+		}
+	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/platforms/launch.go` around lines 137 - 155, Move the Launcher.Launch nil
check and Launcher.Availability validation before the
StopActiveLauncher(StopForPreemption) call in the launch flow. Ensure
unavailable or misconfigured launchers return their existing errors without
stopping the active launcher, while preserving the current preemption behavior
for validated launchers.
pkg/helpers/launcher_cache.go (1)

49-68: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

extra launchers never get their Availability evaluated.

The availability-evaluation loop only runs over all (lines 53-63) before extra is merged in (lines 64-68). Any extra launcher with a non-nil Availability func will keep the zero-value Available=false/AvailabilityReason="" when rebuildFromSlice runs, since that function only auto-populates availability for launchers whose Availability is nil. This silently excludes genuinely-available extra launchers from GetAvailableLaunchersBySystem (now used by the mediascanner for folder discovery) and reports them as unavailable via the API with no reason.

🐛 Proposed fix: evaluate availability after merging `extra`
 func (lc *LauncherCache) Initialize(pl platforms.Platform, cfg *config.Instance, extra ...platforms.Launcher) {
 	launchableSystems, launchableMedia := launchables.Available(cfg, pl)
 	all := pl.Launchers(cfg)
 	all = append(all, launchables.LaunchersFor(launchableSystems, launchableMedia)...)
+	for i := range extra {
+		if !launcherInSlice(all, extra[i].ID) {
+			all = append(all, extra[i])
+		}
+	}
 	for i := range all {
 		all[i].Available = true
 		all[i].AvailabilityReason = ""
 		if all[i].Availability == nil {
 			continue
 		}
 		if err := all[i].Availability(cfg); err != nil {
 			all[i].Available = false
 			all[i].AvailabilityReason = err.Error()
 		}
 	}
-	for i := range extra {
-		if !launcherInSlice(all, extra[i].ID) {
-			all = append(all, extra[i])
-		}
-	}
 	lc.rebuildFromSlice(all)

As per coding guidelines, "Write tests for all new code — see TESTING.md and pkg/testing/README.md"; please add a test covering an extra launcher with a non-nil Availability to prevent regressions.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/helpers/launcher_cache.go` around lines 49 - 68, Move the
availability-evaluation loop in LauncherCache.Initialize to run after extra
launchers are merged into all, so added launchers with non-nil Availability are
evaluated and receive accurate Available and AvailabilityReason values before
rebuilding the cache. Add a regression test covering an extra launcher with a
non-nil Availability function, verifying available and unavailable outcomes are
handled correctly.

Source: Coding guidelines

pkg/database/mediadb/media_user_data.go (1)

27-29: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Don't drop rows.Close() errors here. Both query helpers now ignore close failures, so an early return can hide them entirely. Keep the warning or merge the close error into the returned error.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/database/mediadb/media_user_data.go` around lines 27 - 29, The query
helper functions in media_user_data.go currently ignore errors from
rows.Close(), including on early returns. Update both helpers to preserve the
existing warning or merge the close error into the returned error, ensuring
rows.Close() is always checked before returning.
pkg/platforms/shared/linuxbase/base.go (1)

122-142: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Killed tracked processes need a wait path.
pkg/platforms/shared/linuxbase/base.go:122-142 kills the previous process and immediately drops the only reference to it. pkg/platforms/shared/steam/steamtracker/integration.go can call this path directly, so a live tracked process can be replaced without ever being waited on. Reap the superseded process before overwriting trackedProcess.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/platforms/shared/linuxbase/base.go` around lines 122 - 142,
SetTrackedProcess must wait for a superseded tracked process after killing it
and before replacing trackedProcess. Update the existing-process branch to
retain the old process, kill it, and wait for its completion (handling/logging
wait errors) before assigning the new process; preserve the existing locking and
state-reset behavior.
🧹 Nitpick comments (9)
pkg/zapscript/launch.go (1)

224-245: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Reorder availability check after the cheaper path match.

Availability(env.Cfg) runs for every launcher before the cheap helpers.PathIsLauncher filter. Since Availability implementations can perform filesystem/dependency checks, most of that work is wasted on launchers that wouldn't have matched the path anyway.

♻️ Proposed reordering
 	for i := range launchers {
-		if launchers[i].Availability != nil && launchers[i].Availability(env.Cfg) != nil {
-			continue
-		}
 		if !helpers.PathIsLauncher(env.Cfg, pl, &launchers[i], path) {
 			continue
 		}
+		if launchers[i].Availability != nil && launchers[i].Availability(env.Cfg) != nil {
+			continue
+		}
 		score := launcherInferenceScore(&launchers[i])
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/zapscript/launch.go` around lines 224 - 245, Reorder the checks in
inferLauncherForPath so helpers.PathIsLauncher runs before the potentially
expensive Availability callback; only evaluate
launchers[i].Availability(env.Cfg) after the path matches, preserving the
existing skip and scoring behavior.
pkg/platforms/steamos/emudeck.go (2)

194-205: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Path-validation logic duplicated (and slightly diverged) between EmuDeck and RetroDECK.

emuDeckPathTest and retrodeck.go's inline Test closure implement nearly identical traversal/extension checks, but with a subtle inconsistency: this function rejects .txt case-insensitively (strings.EqualFold(ext, ".txt")), while RetroDECK's check (pkg/platforms/steamos/retrodeck.go:135) only rejects the exact-case ".txt". On case-sensitive filesystems, a .TXT file would incorrectly pass RetroDECK's test but fail EmuDeck's. Consider extracting a single shared helper (e.g. in pkg/platforms/shared/esde or pkg/platforms/shared/launchers) for both traversal-guard + extension checks to avoid drift.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/platforms/steamos/emudeck.go` around lines 194 - 205, The path-validation
logic is duplicated and differs in case handling between emuDeckPathTest and
RetroDECK’s inline Test closure. Extract a shared helper for traversal
protection and extension validation, update both callers to use it, and ensure
.txt rejection is case-insensitive via strings.EqualFold.

168-192: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicate gamescope-focus wiring instead of reusing withGamescopeFocus.

standaloneEmuDeckLauncher's Launch/Kill reimplement the exact same "call gamescope.ManageFocus/RevertFocus" pattern already centralized in withGamescopeFocus (retroarch.go). Building a bare launcher and calling withGamescopeFocus(&launcher) here (as done for the RetroArch path in createEmuDeckLauncher) would remove this duplication and keep the gamescope-wrapping logic in one place.

♻️ Proposed refactor
 func standaloneEmuDeckLauncher(
 	systemFolder string,
 	systemInfo esde.SystemInfo,
 	emulator EmulatorConfig,
 ) platforms.Launcher {
-	return platforms.Launcher{
+	launcher := platforms.Launcher{
 		ID:        "EmuDeck" + systemInfo.GetLauncherID(),
 		SystemID:  systemInfo.SystemID,
 		Lifecycle: platforms.LifecycleTracked,
 		Launch: func(_ *config.Instance, path string, _ *platforms.LaunchOptions) (*os.Process, error) {
-			proc, err := launchStandaloneEmulator(context.Background(), emulator, path, systemFolder)
-			if err != nil {
-				return nil, err
-			}
-			if proc != nil {
-				go gamescope.ManageFocus(proc)
-			}
-			return proc, nil
+			return launchStandaloneEmulator(context.Background(), emulator, path, systemFolder)
 		},
-		Kill: func(_ *config.Instance) error {
-			gamescope.RevertFocus()
-			return nil
-		},
 	}
+	withGamescopeFocus(&launcher)
+	return launcher
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/platforms/steamos/emudeck.go` around lines 168 - 192, Refactor
standaloneEmuDeckLauncher to build the launcher without inline gamescope focus
handling, then return it wrapped with the existing withGamescopeFocus helper.
Remove the direct gamescope.ManageFocus call from Launch and
gamescope.RevertFocus call from Kill, matching the pattern used by
createEmuDeckLauncher and keeping focus management centralized.
pkg/platforms/linux/retroarch.go (2)

19-23: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

retroArchNetworkAddr constant duplicated across packages.

The same literal "127.0.0.1:55355" is redefined here and in pkg/platforms/zapos/platform.go (line 24), and appears to also be redefined in the steamos package (referenced unqualified in retroarch_test.go). Consider hoisting this default into pkg/platforms/shared/retroarch as an exported constant to avoid drift across the three platforms.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/platforms/linux/retroarch.go` around lines 19 - 23, The RetroArch network
address is duplicated across platform packages. Add an exported shared constant
in pkg/platforms/shared/retroarch, then replace retroArchNetworkAddr in the
Linux and Zapos implementations and the unqualified reference in the SteamOS
tests with that shared constant, removing redundant local definitions.

47-52: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

No filesystem injection point for ensureRetroArchNetworkConfig, unlike SteamOS.

This always writes via the real OS filesystem (nil fs passed to EnsureNetworkCommandConfig), with no way to inject a test double. Compare to pkg/platforms/steamos/platform.go, which added an fs afero.Fs field and fileSystem() helper specifically so TestPlatformStartPreWritesRetroArchConfig can verify the write with afero.NewMemMapFs(). The Linux platform has no equivalent test for its StartPre/ensureRetroArchNetworkConfig write path.
As per coding guidelines, **/*.go: "Use afero for filesystem operations in testable code" and "Write tests for all new code."

♻️ Mirror the SteamOS injectable-fs pattern
-func ensureRetroArchNetworkConfig() error {
-	if err := sharedretroarch.EnsureNetworkCommandConfig(nil, defaultRetroArchAppendConfigPath()); err != nil {
+func ensureRetroArchNetworkConfig(fs afero.Fs) error {
+	if err := sharedretroarch.EnsureNetworkCommandConfig(fs, defaultRetroArchAppendConfigPath()); err != nil {
 		return fmt.Errorf("write RetroArch network config: %w", err)
 	}
 	return nil
 }

(and add an fs afero.Fs field + accessor to Platform, wired through StartPre, plus a memfs-based test.)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/platforms/linux/retroarch.go` around lines 47 - 52, Add an injectable
afero.Fs field and fileSystem() accessor to the Linux Platform, mirroring the
SteamOS implementation, and pass it from StartPre into
ensureRetroArchNetworkConfig and sharedretroarch.EnsureNetworkCommandConfig
instead of nil. Add a memfs-based test covering the StartPre RetroArch
network-config write path, including the expected file contents.

Source: Coding guidelines

pkg/platforms/shared/retroarch/coremap.go (2)

265-268: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

alternateCoreLaunch type is declared mid-file, after several functions.

As per coding guidelines, Define Go types and consts near the top of the file, before functions and methods. alternateCoreLaunch is defined at line 265, well after CoreDefinitions, CoreLaunches, scanSpecForSystem, CoreLaunchForFolder, CorePolicyForFolder, and coreLaunchForDef. Consider moving it (and alternateCoreLaunches) up near CoreDef/other type declarations.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/platforms/shared/retroarch/coremap.go` around lines 265 - 268, Move the
alternateCoreLaunch and alternateCoreLaunches type declarations from their
mid-file location to the top-level type declaration section near CoreDef,
CoreDefinitions, and CoreLaunches, before functions such as scanSpecForSystem
and CoreLaunchForFolder, without changing their definitions or behavior.

Source: Coding guidelines


205-212: 🎯 Functional Correctness | 🔵 Trivial | ⚖️ Poor tradeoff

scanSpecForSystem is ambiguous for systems with multiple ESFolder entries.

Several coreDefinitions share the same SystemID with different ESFolders (e.g., systemdefs.SystemC64 at lines 51 and 53; systemdefs.SystemMSX at lines 93–94). scanSpecForSystem returns the first definition whose SystemID matches, so if an alternateCoreLaunch is ever added for one of these systems, it would silently inherit the folders/extensions of the wrong definition. This doesn't currently trigger a bug (the only entries in alternateCoreLaunches target SystemSNES/SystemNES, each with a single ESFolder), but it's a latent trap for future additions.

Consider having alternateCoreLaunch carry an explicit ESFolder (like the primary definitions do) so scanSpecForSystem can match unambiguously instead of falling back to SystemID-only lookup.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/platforms/shared/retroarch/coremap.go` around lines 205 - 212, Make
alternate core lookups unambiguous by adding an explicit ESFolder field to the
alternateCoreLaunch representation and populating it for existing entries.
Update scanSpecForSystem and related alternate-core selection logic to match
both SystemID and ESFolder, preserving the primary definition’s
folder/extensions instead of selecting the first SystemID match.
pkg/platforms/shared/retroarch/launcher.go (1)

137-156: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Relative Exec[0] bypasses installed-binary validation.

validateLaunch only Stats the executable when it's an absolute path (Line 148). For a relative command (e.g., a flatpak invocation), the function skips this check entirely and relies solely on opts.Preflight. If a caller configures a relative Exec without a Preflight function, Availability()/Launch() will report the launcher as available even when the binary can't actually be resolved on PATH, only failing later at cmd.Start().

Consider falling back to exec.LookPath(executable) when the path is relative and Preflight is nil, so availability reporting stays accurate for all Exec configurations.

♻️ Suggested fallback check
 	executable := opts.Exec[0]
 	if filepath.IsAbs(executable) {
 		info, err := fs.Stat(executable)
 		if err != nil {
 			return fmt.Errorf("retroarch is not installed at %s: %w", executable, err)
 		}
 		if info.IsDir() || info.Mode().Perm()&0o111 == 0 {
 			return fmt.Errorf("retroarch executable is not executable: %s", executable)
 		}
+	} else if opts.Preflight == nil {
+		if _, err := exec.LookPath(executable); err != nil {
+			return fmt.Errorf("retroarch is not installed on PATH: %s: %w", executable, err)
+		}
 	}

Please confirm whether the SteamOS/Linux RetroArch wiring (not in this batch) always supplies a Preflight when Exec[0] is relative.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/platforms/shared/retroarch/launcher.go` around lines 137 - 156,
validateLaunch currently validates only absolute Exec[0] paths, allowing
unresolved relative commands when Preflight is nil. Add a relative-path fallback
using exec.LookPath(executable) when no Preflight validation is configured,
returning a descriptive wrapped error if lookup fails; preserve existing
absolute-path checks and use the result consistently in Availability/Launch.
pkg/platforms/shared/retroarch/networkconfig.go (1)

33-44: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a direct unit test for this package.

EnsureNetworkCommandConfig is only exercised indirectly today, via a SteamOS platform test. As per coding guidelines, **/*.go files should have tests co-located per TESTING.md/pkg/testing/README.md; consider adding a small networkconfig_test.go here (e.g., using afero.NewMemMapFs) covering the nil-fs fallback and directory-creation-failure path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/platforms/shared/retroarch/networkconfig.go` around lines 33 - 44, Add a
co-located networkconfig_test.go with direct unit tests for
EnsureNetworkCommandConfig, using afero.NewMemMapFs to verify successful
operation and the nil-fs fallback, plus a filesystem setup that forces directory
creation to fail and asserts the returned error. Cover the generated file
contents and permissions where supported, and use descriptive test names
referencing EnsureNetworkCommandConfig.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@pkg/platforms/launch.go`:
- Around line 259-289: The fallback path in waitForBlockingProcess
unconditionally clears active media and can overwrite newer media for untracked
processes. Add stale-process protection before calling setActiveMedia(nil),
using the process identity or equivalent lifecycle state to confirm the
completed process still owns the active media; otherwise skip the clear and log
that it was stale, matching the TrackedProcessMediaClearer behavior.

In `@pkg/platforms/shared/retroarch/options_test.go`:
- Around line 85-96: Add a regression test covering empty execution commands in
TestBuildCommand_OmitsEmptyOptions or a separate test: call BuildCommand with
Options{Exec: nil}, then assert spec.Name is empty and verify the expected
arguments and environment. Use buildCommandWithCore/BuildCommand behavior to
ensure the empty Exec case remains covered.

In `@pkg/platforms/shared/retroarch/options.go`:
- Around line 115-145: Update buildCommandWithCore to detect an empty opts.Exec
before appending RetroArch arguments and return a CommandSpec with an empty Name
while preserving the environment, so Launch and Availability can report the
intended “retroarch executable is not configured” error. Do not rely on the
existing len(argv) == 0 check, since the core and media arguments are always
appended.

In `@pkg/platforms/steamos/emudeck.go`:
- Around line 168-192: Set the standalone launcher's Availability in
standaloneEmuDeckLauncher based on whether emulator.FlatpakID is installed,
using the existing launchers.IsFlatpakInstalled check (or the corresponding
availability/preflight convention used by sharedretroarch.NewLauncher). Ensure
unavailable Flatpak-backed emulators are reported as unavailable by cache, API,
and scanner before launch, while preserving the existing Launch and Kill
behavior.

---

Outside diff comments:
In `@pkg/database/mediadb/media_user_data.go`:
- Around line 27-29: The query helper functions in media_user_data.go currently
ignore errors from rows.Close(), including on early returns. Update both helpers
to preserve the existing warning or merge the close error into the returned
error, ensuring rows.Close() is always checked before returning.

In `@pkg/helpers/launcher_cache.go`:
- Around line 49-68: Move the availability-evaluation loop in
LauncherCache.Initialize to run after extra launchers are merged into all, so
added launchers with non-nil Availability are evaluated and receive accurate
Available and AvailabilityReason values before rebuilding the cache. Add a
regression test covering an extra launcher with a non-nil Availability function,
verifying available and unavailable outcomes are handled correctly.

In `@pkg/platforms/launch.go`:
- Around line 137-155: Move the Launcher.Launch nil check and
Launcher.Availability validation before the
StopActiveLauncher(StopForPreemption) call in the launch flow. Ensure
unavailable or misconfigured launchers return their existing errors without
stopping the active launcher, while preserving the current preemption behavior
for validated launchers.

In `@pkg/platforms/shared/linuxbase/base.go`:
- Around line 122-142: SetTrackedProcess must wait for a superseded tracked
process after killing it and before replacing trackedProcess. Update the
existing-process branch to retain the old process, kill it, and wait for its
completion (handling/logging wait errors) before assigning the new process;
preserve the existing locking and state-reset behavior.

---

Nitpick comments:
In `@pkg/platforms/linux/retroarch.go`:
- Around line 19-23: The RetroArch network address is duplicated across platform
packages. Add an exported shared constant in pkg/platforms/shared/retroarch,
then replace retroArchNetworkAddr in the Linux and Zapos implementations and the
unqualified reference in the SteamOS tests with that shared constant, removing
redundant local definitions.
- Around line 47-52: Add an injectable afero.Fs field and fileSystem() accessor
to the Linux Platform, mirroring the SteamOS implementation, and pass it from
StartPre into ensureRetroArchNetworkConfig and
sharedretroarch.EnsureNetworkCommandConfig instead of nil. Add a memfs-based
test covering the StartPre RetroArch network-config write path, including the
expected file contents.

In `@pkg/platforms/shared/retroarch/coremap.go`:
- Around line 265-268: Move the alternateCoreLaunch and alternateCoreLaunches
type declarations from their mid-file location to the top-level type declaration
section near CoreDef, CoreDefinitions, and CoreLaunches, before functions such
as scanSpecForSystem and CoreLaunchForFolder, without changing their definitions
or behavior.
- Around line 205-212: Make alternate core lookups unambiguous by adding an
explicit ESFolder field to the alternateCoreLaunch representation and populating
it for existing entries. Update scanSpecForSystem and related alternate-core
selection logic to match both SystemID and ESFolder, preserving the primary
definition’s folder/extensions instead of selecting the first SystemID match.

In `@pkg/platforms/shared/retroarch/launcher.go`:
- Around line 137-156: validateLaunch currently validates only absolute Exec[0]
paths, allowing unresolved relative commands when Preflight is nil. Add a
relative-path fallback using exec.LookPath(executable) when no Preflight
validation is configured, returning a descriptive wrapped error if lookup fails;
preserve existing absolute-path checks and use the result consistently in
Availability/Launch.

In `@pkg/platforms/shared/retroarch/networkconfig.go`:
- Around line 33-44: Add a co-located networkconfig_test.go with direct unit
tests for EnsureNetworkCommandConfig, using afero.NewMemMapFs to verify
successful operation and the nil-fs fallback, plus a filesystem setup that
forces directory creation to fail and asserts the returned error. Cover the
generated file contents and permissions where supported, and use descriptive
test names referencing EnsureNetworkCommandConfig.

In `@pkg/platforms/steamos/emudeck.go`:
- Around line 194-205: The path-validation logic is duplicated and differs in
case handling between emuDeckPathTest and RetroDECK’s inline Test closure.
Extract a shared helper for traversal protection and extension validation,
update both callers to use it, and ensure .txt rejection is case-insensitive via
strings.EqualFold.
- Around line 168-192: Refactor standaloneEmuDeckLauncher to build the launcher
without inline gamescope focus handling, then return it wrapped with the
existing withGamescopeFocus helper. Remove the direct gamescope.ManageFocus call
from Launch and gamescope.RevertFocus call from Kill, matching the pattern used
by createEmuDeckLauncher and keeping focus management centralized.

In `@pkg/zapscript/launch.go`:
- Around line 224-245: Reorder the checks in inferLauncherForPath so
helpers.PathIsLauncher runs before the potentially expensive Availability
callback; only evaluate launchers[i].Availability(env.Cfg) after the path
matches, preserving the existing skip and scoring behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9403c1f9-06e3-47a0-afd2-347bd4daec6d

📥 Commits

Reviewing files that changed from the base of the PR and between 3cc7043 and 84488a8.

📒 Files selected for processing (52)
  • .github/workflows/build.yml
  • .github/workflows/lint-and-test.yml
  • Taskfile.dist.yml
  • cmd/zapos/main.go
  • go.mod
  • pkg/api/methods/launchers.go
  • pkg/api/methods/media_response_helpers.go
  • pkg/api/methods/media_scrape.go
  • pkg/api/models/responses.go
  • pkg/api/ws_dispatcher.go
  • pkg/api/ws_dispatcher_test.go
  • pkg/database/mediadb/media_user_data.go
  • pkg/database/mediascanner/mediascanner.go
  • pkg/helpers/bgpriority/bgpriority_linux.go
  • pkg/helpers/launcher_cache.go
  • pkg/platforms/ids/ids.go
  • pkg/platforms/launch.go
  • pkg/platforms/launch_test.go
  • pkg/platforms/linux/platform.go
  • pkg/platforms/linux/platform_test.go
  • pkg/platforms/linux/retroarch.go
  • pkg/platforms/mister/tracker/tracker.go
  • pkg/platforms/platforms.go
  • pkg/platforms/shared/linuxbase/base.go
  • pkg/platforms/shared/linuxbase/base_test.go
  • pkg/platforms/shared/retroarch/controls.go
  • pkg/platforms/shared/retroarch/controls_test.go
  • pkg/platforms/shared/retroarch/coremap.go
  • pkg/platforms/shared/retroarch/coremap_test.go
  • pkg/platforms/shared/retroarch/launcher.go
  • pkg/platforms/shared/retroarch/launcher_test.go
  • pkg/platforms/shared/retroarch/networkconfig.go
  • pkg/platforms/shared/retroarch/options.go
  • pkg/platforms/shared/retroarch/options_test.go
  • pkg/platforms/steamos/emudeck.go
  • pkg/platforms/steamos/launchers_test.go
  • pkg/platforms/steamos/platform.go
  • pkg/platforms/steamos/retroarch.go
  • pkg/platforms/steamos/retroarch_test.go
  • pkg/platforms/steamos/retrodeck.go
  • pkg/platforms/zapos/platform.go
  • pkg/platforms/zapos/platform_test.go
  • pkg/readers/opticaldrive/opticaldrive_test.go
  • pkg/service/daemon/daemon.go
  • pkg/service/service_test.go
  • pkg/zapscript/control_test.go
  • pkg/zapscript/launch.go
  • pkg/zapscript/media_context.go
  • pkg/zapscript/zaplinks_test.go
  • scripts/tasks/utils/makezip/main.go
  • scripts/tasks/utils/makezip/main_test.go
  • scripts/tasks/zapos.yml

Comment thread pkg/platforms/launch.go
Comment on lines +259 to +289

func waitForBlockingProcess(
platform Platform,
proc *os.Process,
setActiveMedia func(*models.ActiveMedia),
path string,
) {
var waitErr error
if waiter, ok := platform.(TrackedProcessWaiter); ok {
waitErr = waiter.WaitTrackedProcess(proc)
} else {
_, waitErr = proc.Wait()
}
if waitErr != nil {
log.Debug().Err(waitErr).Msgf("blocking process wait error for: %s", path)
} else {
log.Debug().Msgf("blocking process completed for: %s", path)
}

if clearer, ok := platform.(TrackedProcessMediaClearer); ok {
if clearer.ClearTrackedProcessMedia(proc) {
log.Debug().Msgf("cleared active media after blocking process ended: %s", path)
} else {
log.Debug().Msgf("skipped stale active-media clear after blocking process ended: %s", path)
}
return
}

setActiveMedia(nil)
log.Debug().Msgf("cleared active media after blocking process ended: %s", path)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find Platform implementations using LifecycleBlocking
rg -n 'LifecycleBlocking' --type=go -g '!*_test.go'
# Find implementers of the new optional interfaces
ast-grep run --pattern 'func ($_ *$_) WaitTrackedProcess($_ *os.Process) error { $$$ }' --lang go
ast-grep run --pattern 'func ($_ *$_) ClearTrackedProcessMedia($_ *os.Process) bool { $$$ }' --lang go

Repository: ZaparooProject/zaparoo-core

Length of output: 165


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate relevant files and symbols
git ls-files | rg '^(pkg/platforms/|.*\.go$)' >/tmp/all-go-files.txt

printf '\n== lifecycle blocking refs ==\n'
rg -n 'LifecycleBlocking|TrackedProcessWaiter|TrackedProcessMediaClearer|waitForBlockingProcess' pkg/platforms --type=go -g '!*_test.go' || true

printf '\n== platform implementations in pkg/platforms ==\n'
rg -n 'type .*Platform|struct .*Platform|func .*Lifecycle|func .*Launch' pkg/platforms --type=go -g '!*_test.go' || true

printf '\n== all LifecycleBlocking uses repo-wide ==\n'
rg -n 'LifecycleBlocking' --type=go -g '!*_test.go' . || true

printf '\n== optional interface implementers repo-wide ==\n'
rg -n 'WaitTrackedProcess|ClearTrackedProcessMedia' --type=go -g '!*_test.go' . || true

Repository: ZaparooProject/zaparoo-core

Length of output: 21207


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# If lifecycle refs exist, show surrounding context from candidate files.
for f in $(rg -l 'LifecycleBlocking|TrackedProcessWaiter|TrackedProcessMediaClearer' pkg/platforms --type=go -g '!*_test.go' || true); do
  echo "=== $f ==="
  sed -n '1,260p' "$f" | cat -n | sed -n '1,260p'
done

Repository: ZaparooProject/zaparoo-core

Length of output: 39901


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read only the relevant platform files that mention LifecycleBlocking.
for f in $(rg -l 'LifecycleBlocking' pkg/platforms --type=go -g '!*_test.go' || true); do
  echo "### $f"
  sed -n '1,260p' "$f"
done

Repository: ZaparooProject/zaparoo-core

Length of output: 33221


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files mentioning blocking process handling =="
rg -n 'blocking process|ClearTrackedProcessMedia|WaitTrackedProcess|setActiveMedia\(nil\)' pkg/platforms --type=go -g '!*_test.go' || true

echo
echo "== all platform launchers / implementations with lifecycle keywords =="
rg -n 'Lifecycle|Blocking|TrackedProcess|ActiveMedia|Launch\(' pkg/platforms --type=go -g '!*_test.go' || true

Repository: ZaparooProject/zaparoo-core

Length of output: 29876


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '== shared/retroarch.NewLauncher call sites ==\n'
rg -n 'shared/retroarch|retroarch\.NewLauncher|NewLauncher\(opts .*retroarch|NewLaunchers\(opts .*retroarch' pkg --type=go -g '!*_test.go' || true

printf '\n== windows platform methods that might satisfy optional interfaces ==\n'
rg -n 'func \(.*Platform.*\) (WaitTrackedProcess|ClearTrackedProcessMedia|SetTrackedProcess|StopActiveLauncher|LaunchMedia)' pkg/platforms/windows --type=go -g '!*_test.go' || true

printf '\n== blocking launchers in windows package ==\n'
sed -n '330,405p' pkg/platforms/windows/platform.go | cat -n

printf '\n== interface definitions and implementations candidates ==\n'
sed -n '335,365p' pkg/platforms/platforms.go | cat -n
sed -n '130,210p' pkg/platforms/shared/linuxbase/base.go | cat -n

Repository: ZaparooProject/zaparoo-core

Length of output: 9409


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '== search for Linux/SteamOS/ZapOS cohort mentions with shared/retroarch ==\n'
rg -n 'shared/retroarch' pkg/platforms/{linux,steamos,bazzite,zapos,windows,mac,mister,replayos,retropie,recalbox,libreelec,batocera,chimeraos,shared} --type=go -g '!*_test.go' || true

printf '\n== any other LifecycleBlocking launchers besides RetroArch and Windows ==\n'
rg -n 'LifecycleBlocking' pkg --type=go -g '!*_test.go' || true

Repository: ZaparooProject/zaparoo-core

Length of output: 1495


Guard the fallback clear in waitForBlockingProcess. pkg/platforms/windows/platform.go still has a LifecycleBlocking launcher (GenericExecutable) without TrackedProcessMediaClearer, so the unconditional setActiveMedia(nil) can clear newer active media after an old process exits.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/platforms/launch.go` around lines 259 - 289, The fallback path in
waitForBlockingProcess unconditionally clears active media and can overwrite
newer media for untracked processes. Add stale-process protection before calling
setActiveMedia(nil), using the process identity or equivalent lifecycle state to
confirm the completed process still owns the active media; otherwise skip the
clear and log that it was stale, matching the TrackedProcessMediaClearer
behavior.

Comment thread pkg/platforms/shared/retroarch/options_test.go
Comment thread pkg/platforms/shared/retroarch/options.go
Comment thread pkg/platforms/steamos/emudeck.go
@wizzomafizzo wizzomafizzo merged commit cf471a7 into main Jul 10, 2026
15 checks passed
@wizzomafizzo wizzomafizzo deleted the feat/zapos-retroarch-launcher branch July 10, 2026 09:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant