diff --git a/.golangci.yml b/.golangci.yml
index 984376dc6..0f6a25f78 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -1,7 +1,7 @@
version: "2"
run:
- go: "1.25"
+ go: "1.26.3"
linters:
exclusions:
@@ -17,6 +17,12 @@ linters:
- linters:
- gocritic
path: pkg/helpers/usb_darwin\.go
+ # Standalone plugin module: separate go.mod with its own dependency surface.
+ # zerolog/syncutil are Zaparoo Core internals and not imported by the bridge.
+ - linters:
+ - depguard
+ - forbidigo
+ path: scripts/windows/hyperhq-plugin/
enable:
- staticcheck
- errcheck
diff --git a/Taskfile.dist.yml b/Taskfile.dist.yml
index ec4b1b73d..824c324af 100644
--- a/Taskfile.dist.yml
+++ b/Taskfile.dist.yml
@@ -138,11 +138,15 @@ tasks:
desc: Run golangci-lint
cmds:
- golangci-lint run ./...
+ # The HyperHQ plugin module under scripts/windows/hyperhq-plugin/ is
+ # Windows-only. Local typechecking cannot resolve winio symbols on Linux,
+ # so it is linted via `task cross-lint:windows` instead.
lint-fix:
desc: Run golangci-lint with auto-fixes
cmds:
- golangci-lint run --fix ./...
+ # See note on lint above — Windows-only plugin lints via cross-lint:windows.
vulncheck:
desc: Run govulncheck for security vulnerabilities
diff --git a/pkg/assets/systems/Custom.json b/pkg/assets/systems/Custom.json
new file mode 100644
index 000000000..4f8687dce
--- /dev/null
+++ b/pkg/assets/systems/Custom.json
@@ -0,0 +1,7 @@
+{
+ "id": "Custom",
+ "name": "Custom",
+ "category": "Other",
+ "releaseDate": "",
+ "manufacturer": ""
+}
diff --git a/pkg/database/systemdefs/systemdefs.go b/pkg/database/systemdefs/systemdefs.go
index dc527806a..65c4313c1 100644
--- a/pkg/database/systemdefs/systemdefs.go
+++ b/pkg/database/systemdefs/systemdefs.go
@@ -448,6 +448,7 @@ const (
SystemJ2ME = "J2ME"
SystemGroovy = "Groovy"
SystemPlugNPlay = "PlugNPlay"
+ SystemCustom = "Custom"
SystemDevErr = "DevErr"
)
@@ -1151,6 +1152,9 @@ var Systems = map[string]System{
ID: SystemPlugNPlay,
Slugs: []string{"plugandplay", "tvgame", "tvgames"},
},
+ SystemCustom: {
+ ID: SystemCustom,
+ },
SystemIOS: {
ID: SystemIOS,
Slugs: []string{"iphone", "ipad", "applegame", "applegames"},
diff --git a/pkg/platforms/shared/schemes.go b/pkg/platforms/shared/schemes.go
index b509da94a..c8862b40c 100644
--- a/pkg/platforms/shared/schemes.go
+++ b/pkg/platforms/shared/schemes.go
@@ -32,6 +32,7 @@ const (
SchemeLutris = "lutris"
SchemeHeroic = "heroic"
SchemeGOG = "gog"
+ SchemeHyperHq = "hyperhq"
)
// Kodi URI scheme constants for Kodi media library items.
@@ -59,6 +60,7 @@ var customSchemes = []string{
SchemeLutris,
SchemeHeroic,
SchemeGOG,
+ SchemeHyperHq,
SchemeKodiMovie,
SchemeKodiEpisode,
SchemeKodiSong,
diff --git a/pkg/platforms/windows/hyperhq.go b/pkg/platforms/windows/hyperhq.go
new file mode 100644
index 000000000..436793033
--- /dev/null
+++ b/pkg/platforms/windows/hyperhq.go
@@ -0,0 +1,985 @@
+//go:build windows
+
+// 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 windows
+
+import (
+ "bufio"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/Microsoft/go-winio"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/assets"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/config"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/database/systemdefs"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers/syncutil"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers/virtualpath"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms/shared"
+ "github.com/rs/zerolog/log"
+)
+
+const (
+ hyperHqPipeName = `\\.\pipe\zaparoo-hyperhq-ipc`
+
+ // hyperHqScannerMaxBuffer is the maximum buffer size for reading from the HyperHQ pipe.
+ // Must be large enough to handle JSON responses for systems with thousands of games.
+ hyperHqScannerMaxBuffer = 16 * 1024 * 1024 // 16MB
+)
+
+// HyperHQ wire-protocol types. PascalCase to match the bridge plugin's serialiser.
+//
+//nolint:tagliatelle // JSON tags must match HyperHQ plugin structure (PascalCase)
+type hqEvent struct {
+ Event string `json:"Event"`
+ ID string `json:"Id,omitempty"`
+ Title string `json:"Title,omitempty"`
+ Platform string `json:"Platform,omitempty"`
+ SystemReferenceID string `json:"SystemReferenceId,omitempty"`
+}
+
+//nolint:tagliatelle // JSON tags must match HyperHQ plugin structure (PascalCase)
+type hqCommand struct {
+ Command string `json:"Command"`
+ ID string `json:"Id,omitempty"`
+ SystemID string `json:"SystemId,omitempty"`
+ SystemName string `json:"SystemName,omitempty"`
+ SystemReferenceID string `json:"SystemReferenceId,omitempty"`
+}
+
+type hqSystemQueryTarget struct {
+ ID string
+ Name string
+ ReferenceID string
+}
+
+// HqSystemInfo represents a HyperHQ system as reported by the plugin.
+//
+//nolint:tagliatelle // JSON tags must match HyperHQ plugin structure (PascalCase)
+type HqSystemInfo struct {
+ ID string `json:"Id"`
+ Name string `json:"Name"`
+ ReferenceID string `json:"ReferenceId"`
+ Platform string `json:"Platform"`
+}
+
+//nolint:tagliatelle // JSON tags must match HyperHQ plugin structure (PascalCase)
+type hqSystemsEvent struct {
+ Event string `json:"Event"`
+ Systems []HqSystemInfo `json:"Systems"`
+}
+
+// HqGameInfo represents a HyperHQ game as reported by the plugin.
+//
+//nolint:tagliatelle // JSON tags must match HyperHQ plugin structure (PascalCase)
+type HqGameInfo struct {
+ ID string `json:"Id"`
+ Title string `json:"Title"`
+ Platform string `json:"Platform"`
+}
+
+//nolint:tagliatelle // JSON tags must match HyperHQ plugin structure (PascalCase)
+type hqGamesEvent struct {
+ Event string `json:"Event"`
+ SystemID string `json:"SystemId,omitempty"`
+ SystemName string `json:"SystemName,omitempty"`
+ SystemReferenceID string `json:"SystemReferenceId"`
+ Error string `json:"Error,omitempty"`
+ Games []HqGameInfo `json:"Games"`
+}
+
+// hqGamesResponse is used internally for the synchronous request channel.
+type hqGamesResponse struct {
+ Error string
+ Games []HqGameInfo
+}
+
+// hqSystemAliases maps HyperHQ system names to Zaparoo system IDs. HyperHQ
+// also supports custom systems, so unmapped names are logged for future aliases.
+var hqSystemAliases = map[string]string{
+ "3DO Interactive Multiplayer": systemdefs.System3DO,
+ "Acorn Archimedes": systemdefs.SystemArchimedes,
+ "Acorn Atom": systemdefs.SystemAcornAtom,
+ "Acorn BBC Micro": systemdefs.SystemBBCMicro,
+ "Acorn Electron": systemdefs.SystemAcornElectron,
+ "Android": systemdefs.SystemAndroid,
+ "Apogee BK-01": systemdefs.SystemApogee,
+ "Apple II": systemdefs.SystemAppleII,
+ "Apple iOS": systemdefs.SystemIOS,
+ "Apple Mac OS": systemdefs.SystemMacOS,
+ "Arcade (MAME)": systemdefs.SystemArcade,
+ "Arcade (TeknoParrot)": systemdefs.SystemArcade,
+ "Atari 2600": systemdefs.SystemAtari2600,
+ "Atari 5200": systemdefs.SystemAtari5200,
+ "Atari 7800": systemdefs.SystemAtari7800,
+ "Atari 800": systemdefs.SystemAtari800,
+ "Atari Jaguar": systemdefs.SystemJaguar,
+ "Atari Jaguar CD": systemdefs.SystemJaguarCD,
+ "Atari Lynx": systemdefs.SystemAtariLynx,
+ "Atari ST": systemdefs.SystemAtariST,
+ "Atari XEGS": systemdefs.SystemAtariXEGS,
+ "Bally Astrocade": systemdefs.SystemAstrocade,
+ "Bandai Sufami Turbo": systemdefs.SystemSufami,
+ "Bandai WonderSwan": systemdefs.SystemWonderSwan,
+ "Bandai WonderSwan Color": systemdefs.SystemWonderSwanColor,
+ "BBC Microcomputer System": systemdefs.SystemBBCMicro,
+ "Casio PV-1000": systemdefs.SystemCasioPV1000,
+ "Casio PV-2000": systemdefs.SystemCasioPV2000,
+ "Coleco ADAM": systemdefs.SystemColecoAdam,
+ "ColecoVision": systemdefs.SystemColecoVision,
+ "Commodore 16": systemdefs.SystemC16,
+ "Commodore 64": systemdefs.SystemC64,
+ "Commodore Amiga": systemdefs.SystemAmiga,
+ "Commodore Amiga CD32": systemdefs.SystemAmigaCD32,
+ "Commodore PET": systemdefs.SystemPET2001,
+ "Commodore Plus 4": systemdefs.SystemC16,
+ "Commodore VIC-20": systemdefs.SystemVIC20,
+ "Creatronic Mega Duck": systemdefs.SystemMegaDuck,
+ "Daphne": systemdefs.SystemDAPHNE,
+ "DICE": systemdefs.SystemDICE,
+ "Elektronika BK 0011": systemdefs.SystemBK0011M,
+ "Emerson Arcadia 2001": systemdefs.SystemArcadia,
+ "Entex Adventure Vision": systemdefs.SystemAdventureVision,
+ "Epoch Game Pocket Computer": systemdefs.SystemGamePocket,
+ "Fairchild Channel F": systemdefs.SystemChannelF,
+ "Fujitsu FM Towns": systemdefs.SystemFMTowns,
+ "Fujitsu FM Towns Marty": systemdefs.SystemFMTowns,
+ "Fujitsu FM-7": systemdefs.SystemFM7,
+ "Funtech Super Acan": systemdefs.SystemSuperACan,
+ "GamePark GP32": systemdefs.SystemGP32,
+ "GCE Vectrex": systemdefs.SystemVectrex,
+ "Hartung Game Master": systemdefs.SystemGameMaster,
+ "Hypseus Singe": systemdefs.SystemSinge,
+ "Interton VC 4000": systemdefs.SystemVC4000,
+ "Jupiter Ace": systemdefs.SystemJupiter,
+ "Matra and Hachette Alice": systemdefs.SystemAliceMC10,
+ "Mattel Aquarius": systemdefs.SystemAquarius,
+ "Mattel Intellivision": systemdefs.SystemIntellivision,
+ "Microsoft MS-DOS": systemdefs.SystemDOS,
+ "Microsoft MSX": systemdefs.SystemMSX,
+ "Microsoft MSX2": systemdefs.SystemMSX2,
+ "Microsoft MSX2+": systemdefs.SystemMSX2Plus,
+ "Microsoft Windows": systemdefs.SystemWindows,
+ "Microsoft Windows 3.x": systemdefs.SystemWindows,
+ "Microsoft Xbox": systemdefs.SystemXbox,
+ "Microsoft Xbox 360": systemdefs.SystemXbox360,
+ "Microsoft Xbox One": systemdefs.SystemXboxOne,
+ "NEC PC Engine SuperGrafx": systemdefs.SystemSuperGrafx,
+ "NEC PC-8801": systemdefs.SystemPC88,
+ "NEC PC-9801": systemdefs.SystemPC98,
+ "NEC PC-FX": systemdefs.SystemPCFX,
+ "NEC TurboGrafx-16": systemdefs.SystemTurboGrafx16,
+ "NEC TurboGrafx-CD": systemdefs.SystemTurboGrafx16CD,
+ "Nintendo 3DS": systemdefs.System3DS,
+ "Nintendo 64": systemdefs.SystemNintendo64,
+ "Nintendo DS": systemdefs.SystemNDS,
+ "Nintendo Entertainment System": systemdefs.SystemNES,
+ "Nintendo Famicom Disk System": systemdefs.SystemFDS,
+ "Nintendo Game Boy": systemdefs.SystemGameboy,
+ "Nintendo Game Boy Advance": systemdefs.SystemGBA,
+ "Nintendo Game Boy Color": systemdefs.SystemGameboyColor,
+ "Nintendo GameCube": systemdefs.SystemGameCube,
+ "Nintendo Pokémon Mini": systemdefs.SystemPokemonMini,
+ "Nintendo Satellaview": systemdefs.SystemSufami,
+ "Nintendo Super Gameboy": systemdefs.SystemSuperGameboy,
+ "Nintendo Switch": systemdefs.SystemSwitch,
+ "Nintendo Virtual Boy": systemdefs.SystemVirtualBoy,
+ "Nintendo Wii": systemdefs.SystemWii,
+ "Nintendo Wii U": systemdefs.SystemWiiU,
+ "Philips CD-i": systemdefs.SystemCDI,
+ "Sammy Atomiswave": systemdefs.SystemAtomiswave,
+ "ScummVM": systemdefs.SystemScummVM,
+ "Sega 32X": systemdefs.SystemSega32X,
+ "Sega CD": systemdefs.SystemMegaCD,
+ "Sega Dreamcast": systemdefs.SystemDreamcast,
+ "Sega Game Gear": systemdefs.SystemGameGear,
+ "Sega Genesis": systemdefs.SystemGenesis,
+ "Sega Hikaru": systemdefs.SystemHikaru,
+ "Sega Master System": systemdefs.SystemMasterSystem,
+ "Sega Model 2": systemdefs.SystemModel2,
+ "Sega Model 3": systemdefs.SystemModel3,
+ "Sega Naomi": systemdefs.SystemNAOMI,
+ "Sega Naomi 2": systemdefs.SystemNAOMI2,
+ "Sega Saturn": systemdefs.SystemSaturn,
+ "Sega SG-1000": systemdefs.SystemSG1000,
+ "Sega ST-V": systemdefs.SystemArcade,
+ "Sega Triforce": systemdefs.SystemTriforce,
+ "Sharp X1": systemdefs.SystemX1,
+ "Sharp X68000": systemdefs.SystemX68000,
+ "Sinclair ZX Spectrum": systemdefs.SystemZXSpectrum,
+ "Sinclair ZX81": systemdefs.SystemZX81,
+ "SNK Neo Geo AES": systemdefs.SystemNeoGeoAES,
+ "SNK Neo Geo CD": systemdefs.SystemNeoGeoCD,
+ "SNK Neo Geo MVS": systemdefs.SystemNeoGeoMVS,
+ "SNK Neo Geo Pocket": systemdefs.SystemNeoGeoPocket,
+ "SNK Neo Geo Pocket Color": systemdefs.SystemNeoGeoPocketColor,
+ "Sony Playstation": systemdefs.SystemPSX,
+ "Sony Playstation 2": systemdefs.SystemPS2,
+ "Sony Playstation 3": systemdefs.SystemPS3,
+ "Sony Playstation 4": systemdefs.SystemPS4,
+ "Sony Playstation 5": systemdefs.SystemPS5,
+ "Sony Playstation Portable": systemdefs.SystemPSP,
+ "Sony Playstation Vita": systemdefs.SystemVita,
+ "Sony PSP Minis": systemdefs.SystemPSP,
+ "Sord M5": systemdefs.SystemSordM5,
+ "Spectravideo": systemdefs.SystemSpectravideo,
+ "Super Nintendo Entertainment System": systemdefs.SystemSNES,
+ "Tandy TRS-80": systemdefs.SystemTRS80,
+ "Tandy TRS-80 Color Computer": systemdefs.SystemCoCo2,
+ "Tangerine Oric Atmos": systemdefs.SystemOric,
+ "Texas Instruments TI 99/4A": systemdefs.SystemTI994A,
+ "Tiger Game.com": systemdefs.SystemGameCom,
+ "Tomy Tutor": systemdefs.SystemTomyTutor,
+ "Vector-06C": systemdefs.SystemVector06C,
+ "VTech CreatiVision": systemdefs.SystemCreatiVision,
+ "VTech Socrates": systemdefs.SystemSocrates,
+ "VTech V.Smile": systemdefs.SystemVSmile,
+ "Watara Supervision": systemdefs.SystemSuperVision,
+}
+
+// hqInstallSubdirs is the set of well-known relative paths a HyperHQ install can occupy
+// under a parent directory. We probe each candidate parent (LOCALAPPDATA, PROGRAMDATA,
+// drive roots, configured override) joined with these. Verify exact paths during the
+// real-install validation step.
+var hqInstallSubdirs = []string{
+ "HyperHQ",
+ filepath.Join("HyperSpin", "HyperHQ"),
+ filepath.Join("HyperSpin2", "HyperHQ"),
+}
+
+func findHyperHqDir(cfg *config.Instance) (string, error) {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", fmt.Errorf("failed to get user home directory: %w", err)
+ }
+
+ parents := []string{
+ os.Getenv("LOCALAPPDATA"),
+ os.Getenv("PROGRAMDATA"),
+ filepath.Join(home, "Documents"),
+ home,
+ "C:\\Program Files",
+ "C:\\Program Files (x86)",
+ "C:\\",
+ "D:\\",
+ "E:\\",
+ }
+
+ var dirs []string
+ for _, parent := range parents {
+ if parent == "" {
+ continue
+ }
+ for _, sub := range hqInstallSubdirs {
+ dirs = append(dirs, filepath.Join(parent, sub))
+ }
+ }
+
+ if def := cfg.LookupLauncherDefaults("HyperHQ", nil); def.InstallDir != "" {
+ dirs = append([]string{def.InstallDir}, dirs...)
+ }
+
+ for _, dir := range dirs {
+ // #nosec G304 G703 -- candidate paths are well-known install locations
+ // composed from Windows environment variables, used only for an existence check.
+ if _, err := os.Stat(dir); err == nil {
+ return dir, nil
+ }
+ }
+
+ return "", errors.New("HyperHQ directory not found")
+}
+
+// pendingHqGamesRequest tracks a pending synchronous game request during scanning.
+//
+// Single-in-flight is enforced at the call site so concurrent requests can't
+// race. The slot is matched on the pair of HyperHQ system id and reference id.
+type pendingHqGamesRequest struct {
+ response chan hqGamesResponse
+ queryKey string
+}
+
+// HyperHqPipeServer manages named pipe communication with the HyperHQ bridge plugin.
+type HyperHqPipeServer struct {
+ ctx context.Context
+ listener net.Listener
+ conn net.Conn
+ onGameStarted func(id, title, platform, systemReferenceID string)
+ onGameExited func(id, title string)
+ onSystemsReceived func(systems []HqSystemInfo)
+ cancel context.CancelFunc
+ writer *bufio.Writer
+ pendingGamesReq pendingHqGamesRequest
+ connMu syncutil.Mutex
+ pendingGamesReqMu syncutil.Mutex
+}
+
+// NewHyperHqPipeServer creates a new named pipe server for the HyperHQ bridge.
+func NewHyperHqPipeServer() *HyperHqPipeServer {
+ ctx, cancel := context.WithCancel(context.Background())
+ return &HyperHqPipeServer{
+ ctx: ctx,
+ cancel: cancel,
+ }
+}
+
+// Start begins listening for HyperHQ plugin connections.
+func (s *HyperHqPipeServer) Start() error {
+ listener, err := winio.ListenPipe(hyperHqPipeName, nil)
+ if err != nil {
+ return fmt.Errorf("failed to create named pipe: %w", err)
+ }
+
+ s.listener = listener
+ log.Info().Msgf("HyperHQ named pipe server listening on %s", hyperHqPipeName)
+
+ go s.acceptConnections()
+
+ return nil
+}
+
+// Stop gracefully shuts down the pipe server.
+func (s *HyperHqPipeServer) Stop() {
+ s.cancel()
+
+ s.connMu.Lock()
+ if s.conn != nil {
+ if err := s.conn.Close(); err != nil {
+ log.Warn().Err(err).Msg("error closing HyperHQ pipe connection")
+ }
+ s.conn = nil
+ s.writer = nil
+ }
+ s.connMu.Unlock()
+
+ if s.listener != nil {
+ if err := s.listener.Close(); err != nil {
+ log.Warn().Err(err).Msg("error closing HyperHQ pipe listener")
+ }
+ }
+
+ log.Debug().Msg("HyperHQ named pipe server stopped")
+}
+
+// SetGameStartedHandler sets the callback for game started events.
+func (s *HyperHqPipeServer) SetGameStartedHandler(
+ handler func(id, title, platform, systemReferenceID string),
+) {
+ s.onGameStarted = handler
+}
+
+// SetGameExitedHandler sets the callback for game exited events.
+func (s *HyperHqPipeServer) SetGameExitedHandler(handler func(id, title string)) {
+ s.onGameExited = handler
+}
+
+// SetSystemsReceivedHandler sets the callback for the Systems list event.
+func (s *HyperHqPipeServer) SetSystemsReceivedHandler(handler func(systems []HqSystemInfo)) {
+ s.onSystemsReceived = handler
+}
+
+// RequestSystems sends a GetSystems command to the HyperHQ plugin.
+func (s *HyperHqPipeServer) RequestSystems() error {
+ s.connMu.Lock()
+ defer s.connMu.Unlock()
+
+ if s.writer == nil {
+ return errors.New("HyperHQ plugin not connected")
+ }
+
+ cmd := hqCommand{Command: "GetSystems"}
+ data, err := json.Marshal(cmd)
+ if err != nil {
+ return fmt.Errorf("failed to marshal GetSystems command: %w", err)
+ }
+
+ if _, err := s.writer.WriteString(string(data) + "\n"); err != nil {
+ return fmt.Errorf("failed to write GetSystems command: %w", err)
+ }
+
+ if err := s.writer.Flush(); err != nil {
+ return fmt.Errorf("failed to flush GetSystems command: %w", err)
+ }
+
+ log.Debug().Msg("sent GetSystems command to HyperHQ plugin")
+ return nil
+}
+
+// RequestGamesForSystemSync sends a GetGamesForSystem command and waits for the response.
+// Used by the scanner to query games on-demand per-system.
+func (s *HyperHqPipeServer) RequestGamesForSystemSync(
+ ctx context.Context,
+ target hqSystemQueryTarget,
+) ([]HqGameInfo, error) {
+ s.connMu.Lock()
+ if s.writer == nil {
+ s.connMu.Unlock()
+ return nil, errors.New("HyperHQ plugin not connected")
+ }
+ s.connMu.Unlock()
+
+ respChan := make(chan hqGamesResponse, 1)
+
+ s.pendingGamesReqMu.Lock()
+ if s.pendingGamesReq.response != nil {
+ s.pendingGamesReqMu.Unlock()
+ return nil, errors.New("games request already in flight")
+ }
+ queryKey := hqSystemQueryKey(target)
+ s.pendingGamesReq.queryKey = queryKey
+ s.pendingGamesReq.response = respChan
+ s.pendingGamesReqMu.Unlock()
+
+ defer func() {
+ s.pendingGamesReqMu.Lock()
+ s.pendingGamesReq.queryKey = ""
+ s.pendingGamesReq.response = nil
+ s.pendingGamesReqMu.Unlock()
+ }()
+
+ cmd := hqCommand{
+ Command: "GetGamesForSystem",
+ SystemID: target.ID,
+ SystemName: target.Name,
+ SystemReferenceID: target.ReferenceID,
+ }
+ data, err := json.Marshal(cmd)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal GetGamesForSystem command: %w", err)
+ }
+
+ s.connMu.Lock()
+ if s.writer == nil {
+ s.connMu.Unlock()
+ return nil, errors.New("HyperHQ plugin not connected")
+ }
+ if _, err := s.writer.WriteString(string(data) + "\n"); err != nil {
+ s.connMu.Unlock()
+ return nil, fmt.Errorf("failed to write GetGamesForSystem command: %w", err)
+ }
+ if err := s.writer.Flush(); err != nil {
+ s.connMu.Unlock()
+ return nil, fmt.Errorf("failed to flush GetGamesForSystem command: %w", err)
+ }
+ s.connMu.Unlock()
+
+ log.Debug().Msgf(
+ "sent GetGamesForSystem command for HyperHQ system: id=%q referenceId=%q",
+ target.ID, target.ReferenceID,
+ )
+
+ select {
+ case resp := <-respChan:
+ if resp.Error != "" {
+ return nil, fmt.Errorf("HyperHQ plugin error: %s", resp.Error)
+ }
+ return resp.Games, nil
+ case <-time.After(30 * time.Second):
+ return nil, errors.New("timeout waiting for games from HyperHQ")
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ }
+}
+
+// LaunchGame sends a launch command to the HyperHQ plugin.
+func (s *HyperHqPipeServer) LaunchGame(gameID string) error {
+ s.connMu.Lock()
+ defer s.connMu.Unlock()
+
+ if s.writer == nil {
+ return errors.New("HyperHQ plugin not connected")
+ }
+
+ cmd := hqCommand{Command: "Launch", ID: gameID}
+ data, err := json.Marshal(cmd)
+ if err != nil {
+ return fmt.Errorf("failed to marshal launch command: %w", err)
+ }
+
+ if _, err := s.writer.WriteString(string(data) + "\n"); err != nil {
+ return fmt.Errorf("failed to write launch command: %w", err)
+ }
+
+ if err := s.writer.Flush(); err != nil {
+ return fmt.Errorf("failed to flush launch command: %w", err)
+ }
+
+ log.Debug().Msgf("sent launch command for game ID: %s", gameID)
+ return nil
+}
+
+// IsConnected returns true if the HyperHQ plugin is connected.
+func (s *HyperHqPipeServer) IsConnected() bool {
+ s.connMu.Lock()
+ defer s.connMu.Unlock()
+ return s.conn != nil
+}
+
+func (s *HyperHqPipeServer) sendPing() error {
+ s.connMu.Lock()
+ defer s.connMu.Unlock()
+
+ if s.writer == nil {
+ return errors.New("writer not available")
+ }
+
+ cmd := hqCommand{Command: "Ping"}
+ data, err := json.Marshal(cmd)
+ if err != nil {
+ return fmt.Errorf("failed to marshal ping command: %w", err)
+ }
+
+ if _, err := s.writer.WriteString(string(data) + "\n"); err != nil {
+ return fmt.Errorf("failed to write ping command: %w", err)
+ }
+
+ if err := s.writer.Flush(); err != nil {
+ return fmt.Errorf("failed to flush ping command: %w", err)
+ }
+
+ return nil
+}
+
+func (s *HyperHqPipeServer) acceptConnections() {
+ for {
+ select {
+ case <-s.ctx.Done():
+ return
+ default:
+ }
+
+ conn, err := s.listener.Accept()
+ if err != nil {
+ select {
+ case <-s.ctx.Done():
+ return
+ default:
+ log.Warn().Err(err).Msg("failed to accept HyperHQ pipe connection")
+ continue
+ }
+ }
+
+ log.Info().Msg("HyperHQ plugin connected")
+
+ s.connMu.Lock()
+ if s.conn != nil {
+ if closeErr := s.conn.Close(); closeErr != nil {
+ log.Warn().Err(closeErr).Msg("error closing previous HyperHQ connection")
+ }
+ }
+ s.conn = conn
+ s.writer = bufio.NewWriter(conn)
+ s.connMu.Unlock()
+
+ // Request system mappings as soon as the bridge is ready.
+ if err := s.RequestSystems(); err != nil {
+ log.Warn().Err(err).Msg("failed to request systems from HyperHQ plugin")
+ }
+
+ go s.handleConnection(conn)
+ }
+}
+
+func (s *HyperHqPipeServer) handleConnection(conn net.Conn) {
+ defer func() {
+ s.connMu.Lock()
+ if s.conn == conn {
+ s.conn = nil
+ s.writer = nil
+ log.Info().Msg("HyperHQ plugin disconnected")
+ }
+ s.connMu.Unlock()
+ if err := conn.Close(); err != nil {
+ log.Debug().Err(err).Msg("error closing HyperHQ pipe connection")
+ }
+ }()
+
+ ticker := time.NewTicker(5 * time.Second)
+ defer ticker.Stop()
+
+ scanDone := make(chan struct{})
+
+ go func() {
+ defer close(scanDone)
+ scanner := bufio.NewScanner(conn)
+ scanner.Buffer(make([]byte, 4096), hyperHqScannerMaxBuffer)
+
+ for scanner.Scan() {
+ select {
+ case <-s.ctx.Done():
+ return
+ default:
+ }
+
+ s.handleEvent(scanner.Text())
+ }
+
+ if err := scanner.Err(); err != nil && !errors.Is(err, io.EOF) {
+ log.Warn().Err(err).Msg("error reading from HyperHQ pipe")
+ }
+ }()
+
+ for {
+ select {
+ case <-s.ctx.Done():
+ return
+ case <-scanDone:
+ return
+ case <-ticker.C:
+ if err := s.sendPing(); err != nil {
+ log.Debug().Err(err).Msg("failed to send heartbeat ping")
+ return
+ }
+ }
+ }
+}
+
+func (s *HyperHqPipeServer) handleEvent(data string) {
+ var event hqEvent
+ if err := json.Unmarshal([]byte(data), &event); err != nil {
+ log.Warn().Err(err).Msg("failed to unmarshal HyperHQ event")
+ return
+ }
+
+ if event.Event == "" {
+ log.Warn().Msg("HyperHQ event missing 'Event' field")
+ return
+ }
+
+ switch event.Event {
+ case "MediaStarted":
+ log.Info().Msgf("HyperHQ game started: %s (ID: %s)", event.Title, event.ID)
+ if s.onGameStarted != nil {
+ s.onGameStarted(event.ID, event.Title, event.Platform, event.SystemReferenceID)
+ }
+
+ case "MediaStopped":
+ log.Info().Msgf("HyperHQ game stopped: %s (ID: %s)", event.Title, event.ID)
+ if s.onGameExited != nil {
+ s.onGameExited(event.ID, event.Title)
+ }
+
+ case "Systems":
+ var systemsEvent hqSystemsEvent
+ if err := json.Unmarshal([]byte(data), &systemsEvent); err != nil {
+ log.Warn().Err(err).Msg("failed to unmarshal HyperHQ Systems event")
+ return
+ }
+
+ log.Info().Msgf("received %d systems from HyperHQ", len(systemsEvent.Systems))
+
+ if s.onSystemsReceived != nil {
+ s.onSystemsReceived(systemsEvent.Systems)
+ }
+
+ case "Games":
+ var gamesEvent hqGamesEvent
+ if err := json.Unmarshal([]byte(data), &gamesEvent); err != nil {
+ log.Warn().Err(err).Msg("failed to unmarshal HyperHQ Games event")
+ return
+ }
+
+ if gamesEvent.Error != "" {
+ log.Warn().Msgf("HyperHQ plugin error for system %s: %s",
+ gamesEvent.SystemReferenceID, gamesEvent.Error)
+ } else {
+ log.Debug().Msgf("received %d games from HyperHQ for system %s",
+ len(gamesEvent.Games), gamesEvent.SystemReferenceID)
+ }
+
+ s.pendingGamesReqMu.Lock()
+ if s.pendingGamesReq.response != nil &&
+ s.pendingGamesReq.queryKey == hqSystemQueryKey(hqSystemQueryTarget{
+ ID: gamesEvent.SystemID,
+ Name: gamesEvent.SystemName,
+ ReferenceID: gamesEvent.SystemReferenceID,
+ }) {
+ s.pendingGamesReq.response <- hqGamesResponse{
+ Games: gamesEvent.Games,
+ Error: gamesEvent.Error,
+ }
+ }
+ s.pendingGamesReqMu.Unlock()
+
+ default:
+ log.Debug().Msgf("unknown HyperHQ event type: %s", event.Event)
+ }
+}
+
+// buildHqMappings derives the runtime maps from a HyperHQ Systems event.
+// Pure function so it can be unit-tested without touching Platform state.
+func shouldIgnoreEmptyHqSystemsRefresh(
+ systems []HqSystemInfo,
+ hqSystemKeyToSystem map[string]string,
+ systemToHqSystems map[string][]hqSystemQueryTarget,
+) bool {
+ return len(systems) == 0 && (len(hqSystemKeyToSystem) > 0 || len(systemToHqSystems) > 0)
+}
+
+func buildHqMappings(
+ systems []HqSystemInfo,
+) (hqSystemKeyToSystem map[string]string, systemToHqSystems map[string][]hqSystemQueryTarget) {
+ hqSystemKeyToSystem = make(map[string]string)
+ systemToHqSystems = make(map[string][]hqSystemQueryTarget)
+ lookup := buildHqSystemLookup()
+
+ for _, sys := range systems {
+ sysID := systemdefs.SystemCustom
+ for _, candidate := range []string{sys.Platform, sys.Name, sys.ReferenceID, sys.ID} {
+ if mappedID, ok := lookup[hqSystemLookupKey(candidate)]; ok {
+ sysID = mappedID
+ break
+ }
+ }
+
+ if sys.ID != "" || sys.Name != "" || sys.ReferenceID != "" {
+ systemToHqSystems[sysID] = append(systemToHqSystems[sysID], hqSystemQueryTarget{
+ ID: sys.ID,
+ Name: sys.Name,
+ ReferenceID: sys.ReferenceID,
+ })
+ }
+ if sys.ID != "" {
+ hqSystemKeyToSystem[sys.ID] = sysID
+ }
+ if sys.ReferenceID != "" {
+ hqSystemKeyToSystem[sys.ReferenceID] = sysID
+ }
+ }
+
+ return hqSystemKeyToSystem, systemToHqSystems
+}
+
+func hqSystemQueryKey(target hqSystemQueryTarget) string {
+ return target.ID + "\x00" + target.Name + "\x00" + target.ReferenceID
+}
+
+func buildHqSystemLookup() map[string]string {
+ lookup := make(map[string]string, len(hqSystemAliases)+len(systemdefs.Systems))
+ for alias, sysID := range hqSystemAliases {
+ lookup[hqSystemLookupKey(alias)] = sysID
+ }
+ for sysID := range systemdefs.Systems {
+ lookup[hqSystemLookupKey(sysID)] = sysID
+ }
+ return lookup
+}
+
+func hqSystemLookupKey(value string) string {
+ return strings.ToLower(strings.TrimSpace(value))
+}
+
+func (p *Platform) initHyperHqPipe(cfg *config.Instance) {
+ hqDir, err := findHyperHqDir(cfg)
+ if err != nil {
+ log.Debug().Msg("HyperHQ not detected, skipping named pipe server initialization")
+ return
+ }
+ log.Debug().Msgf("HyperHQ detected at: %s", hqDir)
+
+ pipe := NewHyperHqPipeServer()
+
+ pipe.SetGameStartedHandler(func(id, title, platform, systemReferenceID string) {
+ // Resolve the Zaparoo system ID, accepting either HyperHQ's system id or
+ // reference id because event payloads have varied across app versions.
+ p.hqMappingsMu.RLock()
+ systemID, ok := p.hqSystemKeyToSystem[systemReferenceID]
+ p.hqMappingsMu.RUnlock()
+
+ if !ok {
+ if sysID, found := buildHqSystemLookup()[hqSystemLookupKey(platform)]; found {
+ systemID = sysID
+ } else {
+ systemID = systemdefs.SystemCustom
+ log.Warn().Msgf(
+ "using Custom system for unmapped HyperHQ system: refId=%q platform=%q",
+ systemReferenceID, platform,
+ )
+ }
+ }
+
+ systemName := platform
+ if systemID != systemdefs.SystemCustom {
+ if systemMeta, err := assets.GetSystemMetadata(systemID); err == nil {
+ systemName = systemMeta.Name
+ } else {
+ log.Debug().Err(err).Msgf("no system metadata for: %s", systemID)
+ }
+ }
+ if systemName == "" {
+ systemName = systemID
+ }
+
+ virtualPath := virtualpath.CreateVirtualPath(shared.SchemeHyperHq, id, title)
+
+ activeMedia := models.NewActiveMedia(
+ systemID,
+ systemName,
+ virtualPath,
+ title,
+ "HyperHQ",
+ )
+
+ log.Info().Msgf(
+ "HyperHQ game started: SystemID='%s', SystemName='%s', Path='%s', Name='%s', LauncherID='%s'",
+ activeMedia.SystemID, activeMedia.SystemName, activeMedia.Path,
+ activeMedia.Name, activeMedia.LauncherID,
+ )
+
+ p.setActiveMedia(activeMedia)
+ })
+
+ pipe.SetGameExitedHandler(func(_, title string) {
+ log.Info().Msgf("HyperHQ game stopped: %s", title)
+ p.setActiveMedia(nil)
+ })
+
+ pipe.SetSystemsReceivedHandler(func(systems []HqSystemInfo) {
+ p.hqMappingsMu.RLock()
+ ignoreEmpty := shouldIgnoreEmptyHqSystemsRefresh(systems, p.hqSystemKeyToSystem, p.systemToHqSystems)
+ p.hqMappingsMu.RUnlock()
+ if ignoreEmpty {
+ log.Warn().Msg("ignoring empty HyperHQ systems response; keeping existing mappings")
+ return
+ }
+
+ systemKeyToSys, sysToHqSystems := buildHqMappings(systems)
+
+ p.hqMappingsMu.Lock()
+ p.hqSystemKeyToSystem = systemKeyToSys
+ p.systemToHqSystems = sysToHqSystems
+ p.hqMappingsMu.Unlock()
+
+ log.Info().Msgf("built %d HyperHQ system mappings (%d Zaparoo systems covered)",
+ len(systemKeyToSys), len(sysToHqSystems))
+ for _, sys := range systems {
+ queryID := sys.ID
+ if queryID == "" {
+ queryID = sys.ReferenceID
+ }
+ if systemKeyToSys[queryID] == systemdefs.SystemCustom {
+ log.Warn().Msgf(
+ "using Custom system for unmapped HyperHQ system: name=%q referenceId=%q platform=%q",
+ sys.Name, sys.ReferenceID, sys.Platform,
+ )
+ }
+ }
+ })
+
+ if err := pipe.Start(); err != nil {
+ log.Warn().Err(err).Msg("failed to start HyperHQ named pipe server")
+ return
+ }
+
+ p.hyperHqPipeLock.Lock()
+ p.hyperHqPipe = pipe
+ p.hyperHqPipeLock.Unlock()
+
+ log.Info().Msg("HyperHQ named pipe server initialized")
+}
+
+// NewHyperHqLauncher creates the HyperHQ launcher.
+func (p *Platform) NewHyperHqLauncher() platforms.Launcher {
+ return platforms.Launcher{
+ ID: "HyperHQ",
+ Schemes: []string{shared.SchemeHyperHq},
+ SkipFilesystemScan: true,
+ Lifecycle: platforms.LifecycleFireAndForget,
+ Scanner: func(
+ ctx context.Context,
+ _ *config.Instance,
+ systemID string,
+ results []platforms.ScanResult,
+ ) ([]platforms.ScanResult, error) {
+ p.hqMappingsMu.RLock()
+ hqSystems := append([]hqSystemQueryTarget(nil), p.systemToHqSystems[systemID]...)
+ p.hqMappingsMu.RUnlock()
+
+ if len(hqSystems) == 0 {
+ return results, nil
+ }
+
+ p.hyperHqPipeLock.Lock()
+ pipe := p.hyperHqPipe
+ p.hyperHqPipeLock.Unlock()
+
+ if pipe == nil || !pipe.IsConnected() {
+ log.Debug().Msgf(
+ "HyperHQ plugin not connected, skipping scan for system %s", systemID,
+ )
+ return results, nil
+ }
+
+ for _, hqSystem := range hqSystems {
+ games, err := pipe.RequestGamesForSystemSync(ctx, hqSystem)
+ if err != nil {
+ log.Debug().Err(err).Msgf(
+ "HyperHQ query failed for system id=%q referenceId=%q",
+ hqSystem.ID, hqSystem.ReferenceID,
+ )
+ continue
+ }
+ for _, game := range games {
+ results = append(results, platforms.ScanResult{
+ Path: virtualpath.CreateVirtualPath(shared.SchemeHyperHq, game.ID, game.Title),
+ Name: game.Title,
+ NoExt: true,
+ })
+ }
+ log.Debug().Msgf(
+ "scanned %d games from HyperHQ for system id=%q referenceId=%q",
+ len(games), hqSystem.ID, hqSystem.ReferenceID,
+ )
+ }
+
+ return results, nil
+ },
+ Launch: func(_ *config.Instance, path string, _ *platforms.LaunchOptions) (*os.Process, error) {
+ id, err := virtualpath.ExtractSchemeID(path, shared.SchemeHyperHq)
+ if err != nil {
+ return nil, fmt.Errorf("failed to extract HyperHQ game ID from path: %w", err)
+ }
+
+ p.hyperHqPipeLock.Lock()
+ pipe := p.hyperHqPipe
+ p.hyperHqPipeLock.Unlock()
+
+ if pipe == nil || !pipe.IsConnected() {
+ return nil, errors.New("HyperHQ plugin not connected")
+ }
+
+ if err := pipe.LaunchGame(id); err != nil {
+ return nil, fmt.Errorf("failed to send launch command to HyperHQ: %w", err)
+ }
+
+ return nil, nil //nolint:nilnil // HyperHQ launches don't return a process handle
+ },
+ }
+}
diff --git a/pkg/platforms/windows/hyperhq_test.go b/pkg/platforms/windows/hyperhq_test.go
new file mode 100644
index 000000000..2937f0292
--- /dev/null
+++ b/pkg/platforms/windows/hyperhq_test.go
@@ -0,0 +1,499 @@
+//go:build windows
+
+// 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 windows
+
+import (
+ "bufio"
+ "encoding/json"
+ "fmt"
+ "strings"
+ "testing"
+
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/database/systemdefs"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers/virtualpath"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms/shared"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestHqEventJSONSerialization(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ jsonStr string
+ expected hqEvent
+ }{
+ {
+ name: "MediaStarted event",
+ jsonStr: `{"Event":"MediaStarted","Id":"abc123","Title":"Test Game",` +
+ `"Platform":"Nintendo Entertainment System","SystemReferenceId":"sys-nes"}`,
+ expected: hqEvent{
+ Event: "MediaStarted",
+ ID: "abc123",
+ Title: "Test Game",
+ Platform: "Nintendo Entertainment System",
+ SystemReferenceID: "sys-nes",
+ },
+ },
+ {
+ name: "MediaStopped event",
+ jsonStr: `{"Event":"MediaStopped","Id":"abc123","Title":"Test Game"}`,
+ expected: hqEvent{
+ Event: "MediaStopped",
+ ID: "abc123",
+ Title: "Test Game",
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ var event hqEvent
+ err := json.Unmarshal([]byte(tt.jsonStr), &event)
+ require.NoError(t, err)
+ assert.Equal(t, tt.expected, event)
+ })
+ }
+}
+
+func TestHqCommandJSONSerialization(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ expected string
+ command hqCommand
+ }{
+ {
+ name: "Launch command",
+ command: hqCommand{Command: "Launch", ID: "abc123"},
+ expected: `{"Command":"Launch","Id":"abc123"}`,
+ },
+ {
+ name: "Ping command",
+ command: hqCommand{Command: "Ping"},
+ expected: `{"Command":"Ping"}`,
+ },
+ {
+ name: "GetSystems command",
+ command: hqCommand{Command: "GetSystems"},
+ expected: `{"Command":"GetSystems"}`,
+ },
+ {
+ name: "GetGamesForSystem command",
+ command: hqCommand{
+ Command: "GetGamesForSystem",
+ SystemReferenceID: "sys-nes",
+ },
+ expected: `{"Command":"GetGamesForSystem","SystemReferenceId":"sys-nes"}`,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ data, err := json.Marshal(tt.command)
+ require.NoError(t, err)
+ assert.JSONEq(t, tt.expected, string(data))
+ })
+ }
+}
+
+func TestHyperHqPlatformMapping(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ systemID string
+ expectedPlatform string
+ exists bool
+ }{
+ {systemdefs.SystemNES, "Nintendo Entertainment System", true},
+ {systemdefs.SystemSNES, "Super Nintendo Entertainment System", true},
+ {systemdefs.SystemGenesis, "Sega Genesis", true},
+ {systemdefs.SystemPSX, "Sony Playstation", true},
+ {systemdefs.SystemGameboy, "Nintendo Game Boy", true},
+ {systemdefs.SystemGBA, "Nintendo Game Boy Advance", true},
+ {systemdefs.SystemNintendo64, "Nintendo 64", true},
+ {systemdefs.SystemPC, "Windows", true},
+ {"nonexistent-system", "", false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.systemID, func(t *testing.T) {
+ t.Parallel()
+
+ lookup := buildHqSystemLookup()
+ if !tt.exists {
+ _, exists := lookup[hqSystemLookupKey(tt.systemID)]
+ assert.False(t, exists)
+ return
+ }
+
+ assert.Equal(t, tt.systemID, lookup[hqSystemLookupKey(tt.systemID)])
+ assert.Equal(t, tt.systemID, lookup[hqSystemLookupKey(tt.expectedPlatform)])
+ })
+ }
+}
+
+func TestHqSystemsEventJSONDeserialization(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ jsonStr string
+ expected hqSystemsEvent
+ }{
+ {
+ name: "single system",
+ jsonStr: `{"Event":"Systems","Systems":[` +
+ `{"Name":"Nintendo Entertainment System","ReferenceId":"nes-1",` +
+ `"Platform":"Nintendo Entertainment System"}]}`,
+ expected: hqSystemsEvent{
+ Event: "Systems",
+ Systems: []HqSystemInfo{
+ {
+ Name: "Nintendo Entertainment System",
+ ReferenceID: "nes-1",
+ Platform: "Nintendo Entertainment System",
+ },
+ },
+ },
+ },
+ {
+ name: "multiple systems with custom names",
+ jsonStr: `{"Event":"Systems","Systems":[` +
+ `{"Name":"Mame Arcade","ReferenceId":"arc-1","Platform":"Arcade"},` +
+ `{"Name":"My SNES Games","ReferenceId":"snes-1","Platform":"Super Nintendo Entertainment System"}]}`,
+ expected: hqSystemsEvent{
+ Event: "Systems",
+ Systems: []HqSystemInfo{
+ {Name: "Mame Arcade", ReferenceID: "arc-1", Platform: "Arcade"},
+ {Name: "My SNES Games", ReferenceID: "snes-1", Platform: "Super Nintendo Entertainment System"},
+ },
+ },
+ },
+ {
+ name: "empty systems list",
+ jsonStr: `{"Event":"Systems","Systems":[]}`,
+ expected: hqSystemsEvent{Event: "Systems", Systems: []HqSystemInfo{}},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ var event hqSystemsEvent
+ err := json.Unmarshal([]byte(tt.jsonStr), &event)
+ require.NoError(t, err)
+ assert.Equal(t, tt.expected, event)
+ })
+ }
+}
+
+func TestHqGamesEventJSONDeserialization(t *testing.T) {
+ t.Parallel()
+
+ jsonStr := `{"Event":"Games","SystemReferenceId":"nes-1","Games":[` +
+ `{"Id":"g1","Title":"Super Mario Bros","Platform":"Nintendo Entertainment System"},` +
+ `{"Id":"g2","Title":"Zelda","Platform":"Nintendo Entertainment System"}]}`
+
+ var event hqGamesEvent
+ err := json.Unmarshal([]byte(jsonStr), &event)
+ require.NoError(t, err)
+ assert.Equal(t, "Games", event.Event)
+ assert.Equal(t, "nes-1", event.SystemReferenceID)
+ assert.Empty(t, event.Error)
+ assert.Len(t, event.Games, 2)
+ assert.Equal(t, "Super Mario Bros", event.Games[0].Title)
+}
+
+func TestHqGamesEventErrorPath(t *testing.T) {
+ t.Parallel()
+
+ jsonStr := `{"Event":"Games","SystemReferenceId":"nes-1","Error":"system not found","Games":[]}`
+
+ var event hqGamesEvent
+ err := json.Unmarshal([]byte(jsonStr), &event)
+ require.NoError(t, err)
+ assert.Equal(t, "system not found", event.Error)
+ assert.Empty(t, event.Games)
+}
+
+func TestHyperHqPipeServerIsConnected(t *testing.T) {
+ t.Parallel()
+
+ server := NewHyperHqPipeServer()
+ assert.NotNil(t, server)
+ assert.False(t, server.IsConnected())
+}
+
+func TestHyperHqPipeServerLaunchGameNotConnected(t *testing.T) {
+ t.Parallel()
+
+ server := NewHyperHqPipeServer()
+ err := server.LaunchGame("test-id")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "not connected")
+}
+
+func TestHyperHqPipeServerRequestSystemsNotConnected(t *testing.T) {
+ t.Parallel()
+
+ server := NewHyperHqPipeServer()
+ err := server.RequestSystems()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "not connected")
+}
+
+func TestShouldIgnoreEmptyHqSystemsRefresh(t *testing.T) {
+ t.Parallel()
+
+ assert.False(t, shouldIgnoreEmptyHqSystemsRefresh(nil, nil, nil))
+ assert.False(t, shouldIgnoreEmptyHqSystemsRefresh(
+ []HqSystemInfo{{Name: "Arcade"}},
+ map[string]string{"arcade": systemdefs.SystemArcade},
+ nil,
+ ))
+ assert.True(t, shouldIgnoreEmptyHqSystemsRefresh(nil, map[string]string{"arcade": systemdefs.SystemArcade}, nil))
+ assert.True(t, shouldIgnoreEmptyHqSystemsRefresh(nil, nil, map[string][]hqSystemQueryTarget{
+ systemdefs.SystemArcade: {{ReferenceID: "arcade"}},
+ }))
+}
+
+func TestBuildHqMappings(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ expectedKeyToSys map[string]string
+ expectedSystemToHqs map[string][]hqSystemQueryTarget
+ name string
+ systems []HqSystemInfo
+ }{
+ {
+ name: "canonical platform name",
+ systems: []HqSystemInfo{
+ {Name: "Arcade", ReferenceID: "arc-1", Platform: "Arcade"},
+ },
+ expectedKeyToSys: map[string]string{"arc-1": systemdefs.SystemArcade},
+ expectedSystemToHqs: map[string][]hqSystemQueryTarget{
+ systemdefs.SystemArcade: {{ReferenceID: "arc-1"}},
+ },
+ },
+ {
+ name: "system id is used for game query and both ids map to system",
+ systems: []HqSystemInfo{
+ {
+ ID: "sys-nes",
+ Name: "My NES Collection",
+ ReferenceID: "nes-99",
+ Platform: "Nintendo Entertainment System",
+ },
+ },
+ expectedKeyToSys: map[string]string{
+ "sys-nes": systemdefs.SystemNES,
+ "nes-99": systemdefs.SystemNES,
+ },
+ expectedSystemToHqs: map[string][]hqSystemQueryTarget{
+ systemdefs.SystemNES: {{ID: "sys-nes", ReferenceID: "nes-99"}},
+ },
+ },
+ {
+ name: "custom system name with canonical Platform",
+ systems: []HqSystemInfo{
+ {Name: "My NES Collection", ReferenceID: "nes-99", Platform: "Nintendo Entertainment System"},
+ },
+ expectedKeyToSys: map[string]string{"nes-99": systemdefs.SystemNES},
+ expectedSystemToHqs: map[string][]hqSystemQueryTarget{
+ systemdefs.SystemNES: {{ReferenceID: "nes-99"}},
+ },
+ },
+ {
+ name: "multiple systems mapping to same Zaparoo system",
+ systems: []HqSystemInfo{
+ {Name: "SNES Hacks", ReferenceID: "snes-h", Platform: "Super Nintendo Entertainment System"},
+ {Name: "SNES Romhacks", ReferenceID: "snes-r", Platform: "Super Nintendo Entertainment System"},
+ },
+ expectedKeyToSys: map[string]string{
+ "snes-h": systemdefs.SystemSNES,
+ "snes-r": systemdefs.SystemSNES,
+ },
+ expectedSystemToHqs: map[string][]hqSystemQueryTarget{
+ systemdefs.SystemSNES: {{ReferenceID: "snes-h"}, {ReferenceID: "snes-r"}},
+ },
+ },
+ {
+ name: "empty Platform falls back to Name",
+ systems: []HqSystemInfo{
+ {Name: "Arcade", ReferenceID: "arc-2", Platform: ""},
+ },
+ expectedKeyToSys: map[string]string{"arc-2": systemdefs.SystemArcade},
+ expectedSystemToHqs: map[string][]hqSystemQueryTarget{
+ systemdefs.SystemArcade: {{ReferenceID: "arc-2"}},
+ },
+ },
+ {
+ name: "case insensitive platform matching",
+ systems: []HqSystemInfo{
+ {Name: "My Arcade", ReferenceID: "arc-3", Platform: "arcade"},
+ },
+ expectedKeyToSys: map[string]string{"arc-3": systemdefs.SystemArcade},
+ expectedSystemToHqs: map[string][]hqSystemQueryTarget{
+ systemdefs.SystemArcade: {{ReferenceID: "arc-3"}},
+ },
+ },
+ {
+ name: "zaparoo system id platform matches",
+ systems: []HqSystemInfo{
+ {Name: "NES", ReferenceID: "nes-short", Platform: systemdefs.SystemNES},
+ },
+ expectedKeyToSys: map[string]string{"nes-short": systemdefs.SystemNES},
+ expectedSystemToHqs: map[string][]hqSystemQueryTarget{
+ systemdefs.SystemNES: {{ReferenceID: "nes-short"}},
+ },
+ },
+ {
+ name: "reference id system id matches",
+ systems: []HqSystemInfo{
+ {Name: "Nintendo", ReferenceID: systemdefs.SystemNES, Platform: ""},
+ },
+ expectedKeyToSys: map[string]string{systemdefs.SystemNES: systemdefs.SystemNES},
+ expectedSystemToHqs: map[string][]hqSystemQueryTarget{
+ systemdefs.SystemNES: {{ReferenceID: systemdefs.SystemNES}},
+ },
+ },
+ {
+ name: "HyperSpin alias platform matching",
+ systems: []HqSystemInfo{
+ {Name: "PC Engine CD", ReferenceID: "pcecd", Platform: "NEC TurboGrafx-CD"},
+ },
+ expectedKeyToSys: map[string]string{"pcecd": systemdefs.SystemTurboGrafx16CD},
+ expectedSystemToHqs: map[string][]hqSystemQueryTarget{
+ systemdefs.SystemTurboGrafx16CD: {{ReferenceID: "pcecd"}},
+ },
+ },
+ {
+ name: "unknown platform maps to Custom",
+ systems: []HqSystemInfo{
+ {Name: "Made Up", ReferenceID: "x-1", Platform: "Definitely Not A Real Platform"},
+ },
+ expectedKeyToSys: map[string]string{"x-1": systemdefs.SystemCustom},
+ expectedSystemToHqs: map[string][]hqSystemQueryTarget{
+ systemdefs.SystemCustom: {{ReferenceID: "x-1"}},
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ keyToSys, systemToHqs := buildHqMappings(tt.systems)
+
+ assert.Equal(t, tt.expectedKeyToSys, keyToSys)
+
+ require.Len(t, systemToHqs, len(tt.expectedSystemToHqs))
+ for sysID, expectedTargets := range tt.expectedSystemToHqs {
+ assert.ElementsMatch(t, expectedTargets, systemToHqs[sysID],
+ "systemToHqs[%q] mismatch", sysID)
+ }
+ })
+ }
+}
+
+func TestHyperHqVirtualPathRoundTrip(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ id string
+ title string
+ }{
+ {"abc-123", "Super Mario Bros"},
+ {"uuid-style-1234-5678", "The Legend of Zelda"},
+ {"id with spaces", "Game with / Special : Characters"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.id, func(t *testing.T) {
+ t.Parallel()
+
+ path := virtualpath.CreateVirtualPath(shared.SchemeHyperHq, tt.id, tt.title)
+ extracted, err := virtualpath.ExtractSchemeID(path, shared.SchemeHyperHq)
+ require.NoError(t, err)
+ assert.Equal(t, tt.id, extracted)
+ })
+ }
+}
+
+func TestHyperHqLauncherFields(t *testing.T) {
+ t.Parallel()
+
+ p := &Platform{}
+ launcher := p.NewHyperHqLauncher()
+
+ assert.Equal(t, "HyperHQ", launcher.ID)
+ assert.Equal(t, []string{shared.SchemeHyperHq}, launcher.Schemes)
+ assert.True(t, launcher.SkipFilesystemScan)
+ assert.NotNil(t, launcher.Scanner)
+ assert.NotNil(t, launcher.Launch)
+}
+
+func TestHyperHqScannerBufferHandlesLargeResponses(t *testing.T) {
+ t.Parallel()
+
+ const numGames = 8888
+ games := make([]HqGameInfo, numGames)
+ for i := range games {
+ games[i] = HqGameInfo{
+ ID: fmt.Sprintf("game-%d-with-long-uuid-style-id-12345", i),
+ Title: fmt.Sprintf("Test Game %d with a reasonably long title for testing", i),
+ }
+ }
+
+ event := hqGamesEvent{
+ Event: "Games",
+ SystemReferenceID: "nes-1",
+ Games: games,
+ }
+
+ jsonData, err := json.Marshal(event)
+ require.NoError(t, err)
+
+ require.Greater(t, len(jsonData), 1024*1024,
+ "test JSON should exceed 1MB to be a valid regression test")
+
+ reader := strings.NewReader(string(jsonData) + "\n")
+ scanner := bufio.NewScanner(reader)
+ scanner.Buffer(make([]byte, 4096), hyperHqScannerMaxBuffer)
+
+ require.True(t, scanner.Scan(), "scanner should read large JSON response")
+ require.NoError(t, scanner.Err(), "scanner should not return 'token too long' error")
+
+ var parsed hqGamesEvent
+ err = json.Unmarshal(scanner.Bytes(), &parsed)
+ require.NoError(t, err)
+ assert.Len(t, parsed.Games, numGames)
+}
diff --git a/pkg/platforms/windows/launchbox.go b/pkg/platforms/windows/launchbox.go
index 5f29786f7..61915deb3 100644
--- a/pkg/platforms/windows/launchbox.go
+++ b/pkg/platforms/windows/launchbox.go
@@ -512,6 +512,10 @@ func (s *LaunchBoxPipeServer) RequestGamesForPlatformSync(
}
s.connMu.Lock()
+ if s.writer == nil {
+ s.connMu.Unlock()
+ return nil, errors.New("LaunchBox plugin not connected")
+ }
if _, err := s.writer.WriteString(string(data) + "\n"); err != nil {
s.connMu.Unlock()
return nil, fmt.Errorf("failed to write GetGamesForPlatform command: %w", err)
@@ -785,6 +789,48 @@ func (s *LaunchBoxPipeServer) handleEvent(data string) {
}
}
+func shouldIgnoreEmptyLaunchBoxPlatformsRefresh(
+ platformInfos []launchBoxPlatformInfo,
+ customPlatformToSystem map[string]string,
+ systemToCustomPlatforms map[string][]string,
+) bool {
+ return len(platformInfos) == 0 && (len(customPlatformToSystem) > 0 || len(systemToCustomPlatforms) > 0)
+}
+
+func buildLaunchBoxPlatformMappings(
+ platformInfos []launchBoxPlatformInfo,
+) (customPlatformToSystem map[string]string, systemToCustomPlatforms map[string][]string) {
+ customPlatformToSystem = make(map[string]string)
+ systemToCustomPlatforms = make(map[string][]string)
+
+ for _, plat := range platformInfos {
+ sysID := systemdefs.SystemCustom
+ matchedName := ""
+ for _, candidate := range []string{plat.ScrapeAs, plat.Name} {
+ if candidate == "" {
+ continue
+ }
+ for mappedID, lbName := range lbSysMap {
+ if strings.EqualFold(lbName, candidate) {
+ sysID = mappedID
+ matchedName = lbName
+ break
+ }
+ }
+ if matchedName != "" {
+ break
+ }
+ }
+
+ customPlatformToSystem[plat.Name] = sysID
+ if sysID == systemdefs.SystemCustom || !strings.EqualFold(plat.Name, matchedName) {
+ systemToCustomPlatforms[sysID] = append(systemToCustomPlatforms[sysID], plat.Name)
+ }
+ }
+
+ return customPlatformToSystem, systemToCustomPlatforms
+}
+
func (p *Platform) initLaunchBoxPipe(cfg *config.Instance) {
// Check if LaunchBox is installed
lbDir, err := findLaunchBoxDir(cfg)
@@ -812,21 +858,27 @@ func (p *Platform) initLaunchBoxPipe(cfg *config.Instance) {
p.platformMappingsMu.RUnlock()
if !ok {
- // Fall back to hardcoded reverse map
- systemID, ok = lbSysMapReverse[platform]
- if !ok {
- log.Debug().Msgf("unknown LaunchBox platform: %s, skipping ActiveMedia", platform)
- return
+ // Fall back to hardcoded reverse map, then Custom for user-created platforms.
+ if mappedID, found := lbSysMapReverse[platform]; found {
+ systemID = mappedID
+ } else {
+ systemID = systemdefs.SystemCustom
+ log.Warn().Msgf("using Custom system for unmapped LaunchBox platform: %q", platform)
}
}
// Get system name from metadata
systemName := platform // Fallback to LaunchBox platform name
- systemMeta, err := assets.GetSystemMetadata(systemID)
- if err != nil {
- log.Debug().Err(err).Msgf("no system metadata for: %s", systemID)
- } else {
- systemName = systemMeta.Name
+ if systemID != systemdefs.SystemCustom {
+ systemMeta, err := assets.GetSystemMetadata(systemID)
+ if err != nil {
+ log.Debug().Err(err).Msgf("no system metadata for: %s", systemID)
+ } else {
+ systemName = systemMeta.Name
+ }
+ }
+ if systemName == "" {
+ systemName = systemID
}
// Build virtual path for the game
@@ -875,35 +927,34 @@ func (p *Platform) initLaunchBoxPipe(cfg *config.Instance) {
})
pipe.SetPlatformsReceivedHandler(func(platforms []launchBoxPlatformInfo) {
- p.platformMappingsMu.Lock()
- defer p.platformMappingsMu.Unlock()
+ p.platformMappingsMu.RLock()
+ ignoreEmpty := shouldIgnoreEmptyLaunchBoxPlatformsRefresh(
+ platforms, p.customPlatformToSystem, p.systemToCustomPlatforms,
+ )
+ p.platformMappingsMu.RUnlock()
+ if ignoreEmpty {
+ log.Warn().Msg("ignoring empty LaunchBox platforms response; keeping existing mappings")
+ return
+ }
- p.customPlatformToSystem = make(map[string]string)
- p.systemToCustomPlatforms = make(map[string][]string)
+ customPlatformToSystem, systemToCustomPlatforms := buildLaunchBoxPlatformMappings(platforms)
- for _, plat := range platforms {
- // Use ScrapeAs to find the Zaparoo system ID via lbSysMap
- canonicalName := plat.ScrapeAs
- if canonicalName == "" {
- canonicalName = plat.Name
- }
+ p.platformMappingsMu.Lock()
+ p.customPlatformToSystem = customPlatformToSystem
+ p.systemToCustomPlatforms = systemToCustomPlatforms
+ p.platformMappingsMu.Unlock()
- // Look up in lbSysMap (which maps Zaparoo system ID -> LaunchBox canonical name)
- for sysID, lbName := range lbSysMap {
- if strings.EqualFold(lbName, canonicalName) {
- p.customPlatformToSystem[plat.Name] = sysID
- // Only set reverse mapping if it's a custom name
- if !strings.EqualFold(plat.Name, lbName) {
- p.systemToCustomPlatforms[sysID] = append(p.systemToCustomPlatforms[sysID], plat.Name)
- }
- log.Debug().Msgf("mapped LaunchBox platform %q (ScrapeAs: %q) -> %s",
- plat.Name, plat.ScrapeAs, sysID)
- break
- }
+ for _, plat := range platforms {
+ sysID := customPlatformToSystem[plat.Name]
+ if sysID == systemdefs.SystemCustom {
+ log.Warn().Msgf("using Custom system for unmapped LaunchBox platform: %q", plat.Name)
+ } else {
+ log.Debug().Msgf("mapped LaunchBox platform %q (ScrapeAs: %q) -> %s",
+ plat.Name, plat.ScrapeAs, sysID)
}
}
- log.Info().Msgf("built %d custom platform mappings from LaunchBox", len(p.customPlatformToSystem))
+ log.Info().Msgf("built %d custom platform mappings from LaunchBox", len(customPlatformToSystem))
})
if err := pipe.Start(); err != nil {
diff --git a/pkg/platforms/windows/launchbox_test.go b/pkg/platforms/windows/launchbox_test.go
index 74cc68672..7c6283ce6 100644
--- a/pkg/platforms/windows/launchbox_test.go
+++ b/pkg/platforms/windows/launchbox_test.go
@@ -283,6 +283,21 @@ func TestLaunchBoxPipeServerRequestPlatformsNotConnected(t *testing.T) {
assert.Contains(t, err.Error(), "not connected")
}
+func TestShouldIgnoreEmptyLaunchBoxPlatformsRefresh(t *testing.T) {
+ t.Parallel()
+
+ assert.False(t, shouldIgnoreEmptyLaunchBoxPlatformsRefresh(nil, nil, nil))
+ assert.False(t, shouldIgnoreEmptyLaunchBoxPlatformsRefresh(
+ []launchBoxPlatformInfo{{Name: "Arcade"}}, map[string]string{"Arcade": systemdefs.SystemArcade}, nil,
+ ))
+ assert.True(t, shouldIgnoreEmptyLaunchBoxPlatformsRefresh(
+ nil, map[string]string{"Arcade": systemdefs.SystemArcade}, nil,
+ ))
+ assert.True(t, shouldIgnoreEmptyLaunchBoxPlatformsRefresh(
+ nil, nil, map[string][]string{systemdefs.SystemArcade: {"Arcade"}},
+ ))
+}
+
func TestBuildPlatformMappingsFromPluginData(t *testing.T) {
t.Parallel()
@@ -360,14 +375,18 @@ func TestBuildPlatformMappingsFromPluginData(t *testing.T) {
expectedSystemToCustomsLen: 1,
},
{
- name: "unknown ScrapeAs value",
+ name: "unknown ScrapeAs value maps to Custom",
platforms: []launchBoxPlatformInfo{
{Name: "My Custom Platform", ScrapeAs: "Unknown Platform That Does Not Exist"},
},
- expectedCustomToSystem: map[string]string{},
- expectedSystemToCustoms: map[string][]string{},
- expectedCustomToSystemLen: 0,
- expectedSystemToCustomsLen: 0,
+ expectedCustomToSystem: map[string]string{
+ "My Custom Platform": systemdefs.SystemCustom,
+ },
+ expectedSystemToCustoms: map[string][]string{
+ systemdefs.SystemCustom: {"My Custom Platform"},
+ },
+ expectedCustomToSystemLen: 1,
+ expectedSystemToCustomsLen: 1,
},
{
name: "empty ScrapeAs falls back to Name",
@@ -401,26 +420,7 @@ func TestBuildPlatformMappingsFromPluginData(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
- // Simulate the mapping building logic from initLaunchBoxPipe
- customPlatformToSystem := make(map[string]string)
- systemToCustomPlatforms := make(map[string][]string)
-
- for _, plat := range tt.platforms {
- canonicalName := plat.ScrapeAs
- if canonicalName == "" {
- canonicalName = plat.Name
- }
-
- for sysID, lbName := range lbSysMap {
- if strings.EqualFold(lbName, canonicalName) {
- customPlatformToSystem[plat.Name] = sysID
- if !strings.EqualFold(plat.Name, lbName) {
- systemToCustomPlatforms[sysID] = append(systemToCustomPlatforms[sysID], plat.Name)
- }
- break
- }
- }
- }
+ customPlatformToSystem, systemToCustomPlatforms := buildLaunchBoxPlatformMappings(tt.platforms)
assert.Len(t, customPlatformToSystem, tt.expectedCustomToSystemLen)
assert.Len(t, systemToCustomPlatforms, tt.expectedSystemToCustomsLen)
diff --git a/pkg/platforms/windows/platform.go b/pkg/platforms/windows/platform.go
index 829d21e00..3d9fd4c91 100644
--- a/pkg/platforms/windows/platform.go
+++ b/pkg/platforms/windows/platform.go
@@ -69,13 +69,18 @@ type Platform struct {
setActiveMedia func(*models.ActiveMedia)
customPlatformToSystem map[string]string
systemToCustomPlatforms map[string][]string
+ hqSystemKeyToSystem map[string]string
+ systemToHqSystems map[string][]hqSystemQueryTarget
trackedProcess *os.Process
launchBoxPipe *LaunchBoxPipeServer
+ hyperHqPipe *HyperHqPipeServer
steamTracker *steamtracker.WindowsPlatformIntegration
lastLauncher platforms.Launcher
processMu syncutil.RWMutex
platformMappingsMu syncutil.RWMutex
+ hqMappingsMu syncutil.RWMutex
launchBoxPipeLock syncutil.Mutex
+ hyperHqPipeLock syncutil.Mutex
}
func (*Platform) ID() string {
@@ -128,6 +133,9 @@ func (p *Platform) StartPost(
// Initialize LaunchBox pipe server if LaunchBox is installed
p.initLaunchBoxPipe(cfg)
+ // Initialize HyperHQ pipe server if HyperHQ is installed
+ p.initHyperHqPipe(cfg)
+
// Start Steam tracker for external Steam game detection
p.steamTracker = steamtracker.NewWindowsPlatformIntegration(
p.SetTrackedProcess,
@@ -155,6 +163,14 @@ func (p *Platform) Stop() error {
}
p.launchBoxPipeLock.Unlock()
+ // Stop HyperHQ named pipe server
+ p.hyperHqPipeLock.Lock()
+ if p.hyperHqPipe != nil {
+ p.hyperHqPipe.Stop()
+ p.hyperHqPipe = nil
+ }
+ p.hyperHqPipeLock.Unlock()
+
return nil
}
@@ -289,7 +305,7 @@ func (*Platform) LookupMapping(_ *tokens.Token) (string, bool) {
}
func (p *Platform) Launchers(cfg *config.Instance) []platforms.Launcher {
- const staticLauncherCount = 14
+ const staticLauncherCount = 15
launchers := make([]platforms.Launcher, 0, staticLauncherCount+len(esde.SystemMap))
launchers = append(launchers,
@@ -392,6 +408,7 @@ func (p *Platform) Launchers(cfg *config.Instance) []platforms.Launcher {
},
},
p.NewLaunchBoxLauncher(),
+ p.NewHyperHqLauncher(),
)
launchers = append(launchers, getRetroBatLaunchers()...)
diff --git a/pkg/service/clock_monitor.go b/pkg/service/clock_monitor.go
index c16b3fe9d..83c9a11b7 100644
--- a/pkg/service/clock_monitor.go
+++ b/pkg/service/clock_monitor.go
@@ -86,14 +86,16 @@ func healTimestampsIfClockReliable(
Dur("uptime", systemUptime).
Msg("calculated true boot time")
- // Heal all timestamps for this boot session
+ // Heal all timestamps for this boot session. A successful zero-row heal is
+ // complete too; otherwise reliable clocks with nothing to fix would retry and
+ // log every minute forever.
rowsHealed, healErr := db.UserDB.HealTimestamps(bootUUID, trueBootTime)
if healErr != nil {
log.Error().Err(healErr).Msg("failed to heal timestamps")
- } else if rowsHealed > 0 {
+ return healed
+ }
+ if rowsHealed > 0 {
log.Info().Int64("rows", rowsHealed).Msg("successfully healed timestamps")
- return true
}
-
- return healed
+ return true
}
diff --git a/pkg/service/clock_monitor_test.go b/pkg/service/clock_monitor_test.go
index baac532b1..fd9dc606a 100644
--- a/pkg/service/clock_monitor_test.go
+++ b/pkg/service/clock_monitor_test.go
@@ -29,7 +29,7 @@ import (
"github.com/stretchr/testify/mock"
)
-func TestHealTimestampsIfClockReliable_RetriesUntilRowsHealed(t *testing.T) {
+func TestHealTimestampsIfClockReliable_StopsAfterSuccessfulNoop(t *testing.T) {
t.Parallel()
mockUserDB := &testhelpers.MockUserDBI{}
@@ -38,10 +38,9 @@ func TestHealTimestampsIfClockReliable_RetriesUntilRowsHealed(t *testing.T) {
fixedUptime := func() (time.Duration, error) { return 2 * time.Hour, nil }
mockUserDB.On("HealTimestamps", "boot-uuid", mock.AnythingOfType("time.Time")).Return(int64(0), nil).Once()
- mockUserDB.On("HealTimestamps", "boot-uuid", mock.AnythingOfType("time.Time")).Return(int64(3), nil).Once()
healed := healTimestampsIfClockReliable(db, "boot-uuid", now, false, false, fixedUptime)
- assert.False(t, healed)
+ assert.True(t, healed)
healed = healTimestampsIfClockReliable(db, "boot-uuid", now.Add(time.Minute), true, healed, fixedUptime)
assert.True(t, healed)
diff --git a/scripts/tasks/cross-lint.yml b/scripts/tasks/cross-lint.yml
index c08950764..4077e7ffa 100644
--- a/scripts/tasks/cross-lint.yml
+++ b/scripts/tasks/cross-lint.yml
@@ -23,6 +23,8 @@ tasks:
CXX: "zig c++ -w --target=x86_64-windows-gnu"
EXEC: >-
bash -c 'curl -sSfL https://golangci-lint.run/install.sh | sh -s -- -b /home/build/bin 2>&1 | tail -1
+ && PATH="/home/build/bin:$PATH" golangci-lint run --fix --timeout=5m
+ && cd scripts/windows/hyperhq-plugin
&& PATH="/home/build/bin:$PATH" golangci-lint run --fix --timeout=5m'
EXTRA_DOCKER_ARGS: >-
-e GOOS=windows
diff --git a/scripts/tasks/windows.yml b/scripts/tasks/windows.yml
index b8110fe6f..77d4ae36a 100644
--- a/scripts/tasks/windows.yml
+++ b/scripts/tasks/windows.yml
@@ -69,8 +69,6 @@ tasks:
vars:
OUTPUT_DIR: "{{.ROOT_DIR}}/_build/windows_amd64"
PLUGIN_FOLDER: "Zaparoo LaunchBox Integration"
- status:
- - test -f "{{.OUTPUT_DIR}}/{{.PLUGIN_FOLDER}}.zip"
cmds:
- |
if [ ! -f "lib/Unbroken.LaunchBox.Plugins.dll" ]; then
@@ -84,3 +82,19 @@ tasks:
- docker cp zaparoo-launchbox-plugin-temp:/build/ZaparooLaunchBoxPlugin.dll "{{.OUTPUT_DIR}}/{{.PLUGIN_FOLDER}}/"
- docker rm zaparoo-launchbox-plugin-temp
- cd "{{.OUTPUT_DIR}}" && zip -r "{{.PLUGIN_FOLDER}}.zip" "{{.PLUGIN_FOLDER}}"
+
+ build-hyperhq-plugin:
+ desc: Build HyperHQ plugin executable using Docker
+ dir: scripts/windows/hyperhq-plugin
+ vars:
+ OUTPUT_DIR: "{{.ROOT_DIR}}/_build/windows_amd64"
+ PLUGIN_FOLDER: "zaparoo-hyperhq"
+ cmds:
+ - task: :zigcc:setup
+ - docker build --target build -t zaparoo-hyperhq-plugin "{{.ROOT_DIR}}/scripts/windows/hyperhq-plugin"
+ - docker create --name zaparoo-hyperhq-plugin-temp zaparoo-hyperhq-plugin /bin/true
+ - mkdir -p "{{.OUTPUT_DIR}}/{{.PLUGIN_FOLDER}}"
+ - docker cp zaparoo-hyperhq-plugin-temp:/build/zaparoo-hyperhq.exe "{{.OUTPUT_DIR}}/{{.PLUGIN_FOLDER}}/"
+ - docker rm zaparoo-hyperhq-plugin-temp
+ - cp plugin.json "{{.OUTPUT_DIR}}/{{.PLUGIN_FOLDER}}/"
+ - cd "{{.OUTPUT_DIR}}" && zip -r "{{.PLUGIN_FOLDER}}.zip" "{{.PLUGIN_FOLDER}}"
diff --git a/scripts/windows/hyperhq-plugin/Dockerfile b/scripts/windows/hyperhq-plugin/Dockerfile
new file mode 100644
index 000000000..0d193911b
--- /dev/null
+++ b/scripts/windows/hyperhq-plugin/Dockerfile
@@ -0,0 +1,20 @@
+# Zaparoo HyperHQ Plugin Build Container
+# Copyright (c) 2026 The Zaparoo Project Contributors.
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+FROM zaparoo/zigcc AS build
+
+USER build
+WORKDIR /src
+
+# Copy module files and download deps first to maximise layer caching.
+COPY --chown=build:build go.mod go.sum ./
+RUN go mod download
+
+# Copy source and build a static Windows AMD64 binary. CGO is disabled because
+# none of the plugin's dependencies require it; this keeps the artefact small
+# and self-contained.
+COPY --chown=build:build . .
+RUN env GOOS=windows GOARCH=amd64 CGO_ENABLED=0 \
+ go build -trimpath -ldflags='-s -w -H windowsgui' \
+ -o /build/zaparoo-hyperhq.exe .
diff --git a/scripts/windows/hyperhq-plugin/go.mod b/scripts/windows/hyperhq-plugin/go.mod
new file mode 100644
index 000000000..e16537c60
--- /dev/null
+++ b/scripts/windows/hyperhq-plugin/go.mod
@@ -0,0 +1,36 @@
+module github.com/ZaparooProject/zaparoo-core/v2/scripts/windows/hyperhq-plugin
+
+go 1.26.3
+
+require (
+ github.com/Microsoft/go-winio v0.6.2
+ github.com/karagenc/socket.io-go v0.1.0
+)
+
+require (
+ github.com/deckarep/golang-set/v2 v2.6.0 // indirect
+ github.com/fatih/color v1.17.0 // indirect
+ github.com/fatih/structs v1.1.0 // indirect
+ github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
+ github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 // indirect
+ github.com/karagenc/yeast v0.1.1 // indirect
+ github.com/mattn/go-colorable v0.1.13 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/onsi/ginkgo/v2 v2.19.1 // indirect
+ github.com/petermattis/goid v0.0.0-20240716203034-badd1c0974d6 // indirect
+ github.com/quic-go/qpack v0.4.0 // indirect
+ github.com/quic-go/quic-go v0.45.2 // indirect
+ github.com/quic-go/webtransport-go v0.8.0 // indirect
+ github.com/sasha-s/go-deadlock v0.3.1 // indirect
+ github.com/xiegeo/coloredgoroutine v0.1.1 // indirect
+ go.uber.org/mock v0.4.0 // indirect
+ golang.org/x/crypto v0.25.0 // indirect
+ golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
+ golang.org/x/mod v0.19.0 // indirect
+ golang.org/x/net v0.27.0 // indirect
+ golang.org/x/sync v0.7.0 // indirect
+ golang.org/x/sys v0.22.0 // indirect
+ golang.org/x/text v0.16.0 // indirect
+ golang.org/x/tools v0.23.0 // indirect
+ nhooyr.io/websocket v1.8.11 // indirect
+)
diff --git a/scripts/windows/hyperhq-plugin/go.sum b/scripts/windows/hyperhq-plugin/go.sum
new file mode 100644
index 000000000..bfc5195cc
--- /dev/null
+++ b/scripts/windows/hyperhq-plugin/go.sum
@@ -0,0 +1,90 @@
+github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
+github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
+github.com/NYTimes/gziphandler v1.1.1 h1:ZUDjpQae29j0ryrS0u/B8HZfJBtBQHjqw2rQ2cqUQ3I=
+github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c=
+github.com/cristalhq/jsn v0.2.0 h1:ffVUa6Hn33QNlzjdI/n4xEW236VYF9aU+GHfMxMxivI=
+github.com/cristalhq/jsn v0.2.0/go.mod h1:eUSQvFmPRoW49JNKuwmZNyMq2mb8nRsj3vOHgGJfgkA=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/deckarep/golang-set/v2 v2.6.0 h1:XfcQbWM1LlMB8BsJ8N9vW5ehnnPVIw0je80NsVHagjM=
+github.com/deckarep/golang-set/v2 v2.6.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4=
+github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
+github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4=
+github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI=
+github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
+github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
+github.com/francoispqt/gojay v1.2.13 h1:d2m3sFjloqoIUQU3TsHBgj6qg/BVGlTBeHDUmyJnXKk=
+github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY=
+github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
+github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
+github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
+github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 h1:FKHo8hFI3A+7w0aUQuYXQ+6EN5stWmeY/AZqtM8xk9k=
+github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo=
+github.com/karagenc/socket.io-go v0.1.0 h1:j+5B3uuRXNil7ItuGTPp1vU4Fwi3khxcEFpfHZiL7LM=
+github.com/karagenc/socket.io-go v0.1.0/go.mod h1:nixP20kGPH0qXJnyCL9L2TSzO4Bl3LO9XpX+x7jIDB4=
+github.com/karagenc/yeast v0.1.1 h1:Zv9gOmz7Gp+E90KRIwJVFO4GFXbE7jEqrceXFrBSvy0=
+github.com/karagenc/yeast v0.1.1/go.mod h1:rLE1GH1CTdNXp7Z5fZpOnUCcfI9iW1BShs3uij0EMZ0=
+github.com/madflojo/testcerts v1.2.0 h1:/ng1zJW1G9aM3ez9RXA3dYKT6INc/rc4GpRgqDl/XJw=
+github.com/madflojo/testcerts v1.2.0/go.mod h1:MW8sh39gLnkKh4K0Nc55AyHEDl9l/FBLDUsQhpmkuo0=
+github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
+github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
+github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
+github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
+github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/onsi/ginkgo/v2 v2.19.1 h1:QXgq3Z8Crl5EL1WBAC98A5sEBHARrAJNzAmMxzLcRF0=
+github.com/onsi/ginkgo/v2 v2.19.1/go.mod h1:O3DtEWQkPa/F7fBMgmZQKKsluAy8pd3rEQdrjkPb9zA=
+github.com/onsi/gomega v1.34.0 h1:eSSPsPNp6ZpsG8X1OVmOTxig+CblTc4AxpPBykhe2Os=
+github.com/onsi/gomega v1.34.0/go.mod h1:MIKI8c+f+QLWk+hxbePD4i0LMJSExPaZOVfkoex4cAo=
+github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5/go.mod h1:jvVRKCrJTQWu0XVbaOlby/2lO20uSCHEMzzplHXte1o=
+github.com/petermattis/goid v0.0.0-20240716203034-badd1c0974d6 h1:DUDJI8T/9NcGbbL+AWk6vIYlmQ8ZBS8LZqVre6zbkPQ=
+github.com/petermattis/goid v0.0.0-20240716203034-badd1c0974d6/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/quic-go/qpack v0.4.0 h1:Cr9BXA1sQS2SmDUWjSofMPNKmvF6IiIfDRmgU0w1ZCo=
+github.com/quic-go/qpack v0.4.0/go.mod h1:UZVnYIfi5GRk+zI9UMaCPsmZ2xKJP7XBUvVyT1Knj9A=
+github.com/quic-go/quic-go v0.45.2 h1:DfqBmqjb4ExSdxRIb/+qXhPC+7k6+DUNZha4oeiC9fY=
+github.com/quic-go/quic-go v0.45.2/go.mod h1:1dLehS7TIR64+vxGR70GDcatWTOtMX2PUtnKsjbTurI=
+github.com/quic-go/webtransport-go v0.8.0 h1:HxSrwun11U+LlmwpgM1kEqIqH90IT4N8auv/cD7QFJg=
+github.com/quic-go/webtransport-go v0.8.0/go.mod h1:N99tjprW432Ut5ONql/aUhSLT0YVSlwHohQsuac9WaM=
+github.com/sasha-s/go-deadlock v0.3.1 h1:sqv7fDNShgjcaxkO0JNcOAlr8B9+cV5Ey/OB71efZx0=
+github.com/sasha-s/go-deadlock v0.3.1/go.mod h1:F73l+cr82YSh10GxyRI6qZiCgK64VaZjwesgfQ1/iLM=
+github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+github.com/xiegeo/coloredgoroutine v0.1.1 h1:L6EaQHWIY+oIlKpj5ORu9hIorsLbQwTAStEHSp1SoAs=
+github.com/xiegeo/coloredgoroutine v0.1.1/go.mod h1:d3jyamWlthEBXOL5qUpKOaaKSJM75HuCIn/z9f4ylrs=
+go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU=
+go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc=
+golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
+golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
+golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
+golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
+golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8=
+golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
+golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
+golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
+golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sys v0.0.0-20180831094639-fa5fdf94c789/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
+golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
+golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
+golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
+golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
+golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg=
+golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI=
+google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
+google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+nhooyr.io/websocket v1.8.11 h1:f/qXNc2/3DpoSZkHt1DQu6rj4zGC8JmkkLkWss0MgN0=
+nhooyr.io/websocket v1.8.11/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c=
diff --git a/scripts/windows/hyperhq-plugin/main.go b/scripts/windows/hyperhq-plugin/main.go
new file mode 100644
index 000000000..235550012
--- /dev/null
+++ b/scripts/windows/hyperhq-plugin/main.go
@@ -0,0 +1,1169 @@
+// 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 .
+
+// hyperhq-plugin is the Zaparoo bridge for HyperHQ. HyperHQ launches this
+// executable as a plugin and exposes a Socket.IO endpoint on localhost; the
+// plugin connects to that endpoint, authenticates, and forwards game events to
+// Zaparoo Core via a named pipe. Commands flow the other way: Zaparoo Core
+// requests system/game lists and game launches over the pipe, and this bridge
+// translates them into HyperHQ Socket.IO requestData calls.
+//
+// HyperHQ wire protocol (per https://docs.hyperai.io/docs/plugins/):
+// - authenticate {pluginId, challenge} -> authenticated {success, sessionToken}
+// - plugin:register {id, version, capabilities}
+// - subscribeEvents [event names] -> eventsSubscribed {events}
+// - request {id, method, data} -> emit plugin:response {id, type, data, sessionToken}
+// - emit requestData {method, params, requestId, sessionToken} -> dataResponse {requestId, success, data?, error?}
+// - hyperHqEvent {type, data, timestamp} carries gameLaunched / gameClosed / ...
+package main
+
+import (
+ "bufio"
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "log"
+ "os"
+ "os/signal"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "sync"
+ "syscall"
+ "time"
+
+ sio "github.com/karagenc/socket.io-go"
+ eio "github.com/karagenc/socket.io-go/engine.io"
+)
+
+const (
+ pipeName = `\\.\pipe\zaparoo-hyperhq-ipc`
+ pluginNamespace = "/"
+ pluginVersion = "0.1.0"
+
+ pipeReconnectDelay = 2 * time.Second
+ pipeBufferMax = 16 * 1024 * 1024 // 16MB to match Zaparoo Core scanner buffer
+ requestTimeout = 30 * time.Second
+ launchAckTimeout = 5 * time.Second
+ gameListMethod = "getGamesForSystem"
+ gameListParamKey = "systemId"
+)
+
+// HyperHQ event types carried on the hyperHqEvent envelope.
+const (
+ hqEventGameLaunched = "gameLaunched"
+ hqEventGameClosed = "gameClosed"
+)
+
+// HyperHQ request methods we handle on the lifecycle `request` channel.
+const (
+ hqMethodInitialize = "initialize"
+ hqMethodExecute = "execute"
+ hqMethodTest = "test"
+ hqMethodShutdown = "shutdown"
+)
+
+// Pipe wire-protocol types — mirror the Zaparoo Core side in
+// pkg/platforms/windows/hyperhq.go. PascalCase keys.
+//
+//nolint:tagliatelle // PascalCase tags must match the Zaparoo Core pipe peer.
+type pipeEvent struct {
+ Event string `json:"Event"`
+ ID string `json:"Id,omitempty"`
+ Title string `json:"Title,omitempty"`
+ Platform string `json:"Platform,omitempty"`
+ SystemID string `json:"SystemId,omitempty"`
+ SystemName string `json:"SystemName,omitempty"`
+ SystemReferenceID string `json:"SystemReferenceId,omitempty"`
+ Error string `json:"Error,omitempty"`
+ Systems []hqSystemInfo `json:"Systems,omitempty"`
+ Games []hqGameInfo `json:"Games,omitempty"`
+}
+
+//nolint:tagliatelle // PascalCase tags must match the Zaparoo Core pipe peer.
+type pipeCommand struct {
+ Command string `json:"Command"`
+ ID string `json:"Id,omitempty"`
+ SystemID string `json:"SystemId,omitempty"`
+ SystemName string `json:"SystemName,omitempty"`
+ SystemReferenceID string `json:"SystemReferenceId,omitempty"`
+}
+
+type systemQueryTarget struct {
+ ID string
+ Name string
+ ReferenceID string
+}
+
+//nolint:tagliatelle // PascalCase tags must match the Zaparoo Core pipe peer.
+type hqSystemInfo struct {
+ ID string `json:"Id"`
+ Name string `json:"Name"`
+ ReferenceID string `json:"ReferenceId"`
+ Platform string `json:"Platform"`
+}
+
+//nolint:tagliatelle // PascalCase tags must match the Zaparoo Core pipe peer.
+type hqGameInfo struct {
+ ID string `json:"Id"`
+ Title string `json:"Title"`
+ Platform string `json:"Platform"`
+}
+
+// HyperHQ Socket.IO payload shapes (camelCase per the API reference).
+
+type hqAuthRequest struct {
+ PluginID string `json:"pluginId"`
+ Challenge string `json:"challenge"`
+}
+
+type hqAuthResponse struct {
+ SessionToken string `json:"sessionToken"`
+ Error string `json:"error"`
+ Success bool `json:"success"`
+}
+
+type hqPluginRegister struct {
+ ID string `json:"id"`
+ Version string `json:"version"`
+ Type string `json:"type,omitempty"`
+ SessionToken string `json:"sessionToken,omitempty"`
+ Capabilities []string `json:"capabilities"`
+}
+
+// hqLifecycleRequest is the envelope HyperHQ uses for plugin-directed calls
+// (initialize / execute / test / shutdown).
+type hqLifecycleRequest struct {
+ ID string `json:"id"`
+ Method string `json:"method"`
+ Data json.RawMessage `json:"data,omitempty"`
+}
+
+// hqPluginResponse is the reply sent back via plugin:response.
+type hqPluginResponse struct {
+ Data any `json:"data,omitempty"`
+ ID string `json:"id"`
+ Type string `json:"type"`
+ SessionToken string `json:"sessionToken,omitempty"`
+ Timestamp int64 `json:"timestamp,omitempty"`
+}
+
+// hqRequestData is the envelope plugins send to call HyperHQ data methods.
+type hqRequestData struct {
+ Params map[string]any `json:"params,omitempty"`
+ Method string `json:"method"`
+ RequestID string `json:"requestId"`
+ SessionToken string `json:"sessionToken"`
+}
+
+// hqDataResponse is HyperHQ's reply on the dataResponse channel.
+type hqDataResponse struct {
+ RequestID string `json:"requestId"`
+ Error string `json:"error,omitempty"`
+ Data json.RawMessage `json:"data,omitempty"`
+ Success bool `json:"success"`
+}
+
+// hqEventEnvelope is the wrapper HyperHQ uses to deliver media events on the
+// hyperHqEvent channel.
+type hqEventEnvelope struct {
+ Type string `json:"type"`
+ Timestamp string `json:"timestamp"`
+ Data json.RawMessage `json:"data"`
+}
+
+type hqGameLaunchedPayload struct {
+ GameID string `json:"gameId"`
+ GameName string `json:"gameName"`
+ SystemID string `json:"systemId"`
+ Timestamp string `json:"timestamp"`
+}
+
+type hqGameClosedPayload struct {
+ GameID string `json:"gameId"`
+ GameName string `json:"gameName"`
+ SystemID string `json:"systemId"`
+ Timestamp string `json:"timestamp"`
+ ExitCode int `json:"exitCode"`
+ PlayTime int64 `json:"playTime"`
+}
+
+type hqRawSystem struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ ReferenceID string `json:"referenceId"`
+ Platform string `json:"platform"`
+}
+
+type hqRawGame struct {
+ ID string `json:"id"`
+ GameID string `json:"gameId"`
+ Name string `json:"name"`
+ Title string `json:"title"`
+ ReferenceID string `json:"referenceId"`
+ FileName string `json:"fileName"`
+ ROMPath string `json:"romPath"`
+ Platform string `json:"platform"`
+ SystemName string `json:"systemName"`
+}
+
+type hqSystemsData struct {
+ Systems []hqRawSystem `json:"systems"`
+}
+
+type hqGamesData struct {
+ Games []hqRawGame `json:"games"`
+}
+
+type gameRequestVariant struct {
+ Method string
+ ParamKey string
+ ParamValue string
+ Label string
+}
+
+// bridge owns the HyperHQ Socket.IO connection and forwards activity to the
+// pipe writer. All pipe writes go through writePipeEvent which serialises on
+// pipeMu so the framing stays line-delimited. dataResponse routing keeps a
+// per-requestId channel in pendingData; the dataResponse listener looks up the
+// channel by requestId and hands the payload over.
+type hqSocket interface {
+ Emit(string, ...any) error
+ OnEvent(string, func(any))
+ OnConnect(func())
+ OnConnectError(func(any))
+ OnDisconnect(func(any))
+ Connect()
+ ID() string
+}
+
+type karagencSocket struct {
+ socket sio.ClientSocket
+}
+
+func (s *karagencSocket) Emit(event string, args ...any) error {
+ s.socket.Emit(event, args...)
+ return nil
+}
+
+func (s *karagencSocket) OnEvent(event string, listener func(any)) {
+ s.socket.OnEvent(event, listener)
+}
+
+func (s *karagencSocket) OnConnect(listener func()) {
+ s.socket.OnConnect(listener)
+}
+
+func (s *karagencSocket) OnConnectError(listener func(any)) {
+ s.socket.OnConnectError(listener)
+}
+
+func (s *karagencSocket) OnDisconnect(listener func(any)) {
+ s.socket.OnDisconnect(func(reason sio.Reason) {
+ listener(reason)
+ })
+}
+
+func (s *karagencSocket) Connect() {
+ s.socket.Connect()
+}
+
+func (s *karagencSocket) ID() string {
+ return string(s.socket.ID())
+}
+
+type pipeEventWriter interface {
+ writePipeEvent(*pipeEvent)
+}
+
+type pipeSession struct {
+ ctx context.Context
+ bridge *bridge
+ writer *bufio.Writer
+}
+
+type bridge struct {
+ ctx context.Context
+ cancel context.CancelFunc
+ socket hqSocket
+ pipeWriter *bufio.Writer
+ pendingData map[string]chan hqDataResponse
+ pluginID string
+ authChallenge string
+ sessionToken string
+ sessionMu sync.RWMutex
+ pendingMu sync.Mutex
+ pipeMu sync.Mutex
+}
+
+func main() {
+ logFile := setupLogging()
+ exitCode := 0
+
+ if err := run(); err != nil {
+ log.Printf("fatal: %v", err)
+ exitCode = 1
+ }
+ if logFile != nil {
+ if err := logFile.Close(); err != nil {
+ log.Printf("log file close error: %v", err)
+ }
+ }
+ if exitCode != 0 {
+ os.Exit(exitCode)
+ }
+}
+
+func setupLogging() *os.File {
+ log.SetFlags(log.LstdFlags | log.Lmicroseconds)
+ log.SetPrefix("[zaparoo-hyperhq] ")
+
+ path := pluginLogPath()
+ //nolint:gosec // path comes from HyperHQ plugin env or OS temp dir
+ file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
+ if err != nil {
+ log.Printf("warning: open log file %s: %v", path, err)
+ return nil
+ }
+
+ log.SetOutput(io.MultiWriter(os.Stderr, file))
+ log.Printf("logging to %s", path)
+ return file
+}
+
+func pluginLogPath() string {
+ if dataDir := os.Getenv("PLUGIN_DATA_DIR"); dataDir != "" {
+ //nolint:gosec // PLUGIN_DATA_DIR is provided by HyperHQ
+ if err := os.MkdirAll(dataDir, 0o700); err == nil {
+ return filepath.Join(dataDir, "zaparoo-hyperhq.log")
+ }
+ }
+ return filepath.Join(os.TempDir(), "zaparoo-hyperhq.log")
+}
+
+func run() error {
+ pluginID := os.Getenv("HYPERHQ_PLUGIN_ID")
+ authChallenge := os.Getenv("HYPERHQ_AUTH_CHALLENGE")
+ socketPort := os.Getenv("HYPERHQ_SOCKET_PORT")
+ //nolint:gosec // logs only HyperHQ-provided non-secret env metadata; challenge value is not logged
+ log.Printf(
+ "startup env: pluginId=%q challengePresent=%t challengeLength=%d socketPort=%q",
+ pluginID, authChallenge != "", len(authChallenge), socketPort,
+ )
+
+ if pluginID == "" || authChallenge == "" || socketPort == "" {
+ return fmt.Errorf(
+ "missing required HyperHQ env vars "+
+ "(HYPERHQ_PLUGIN_ID present=%t "+
+ "HYPERHQ_AUTH_CHALLENGE present=%t "+
+ "HYPERHQ_SOCKET_PORT present=%t)",
+ pluginID != "", authChallenge != "", socketPort != "",
+ )
+ }
+
+ if _, err := strconv.Atoi(socketPort); err != nil {
+ return fmt.Errorf("HYPERHQ_SOCKET_PORT is not a valid port: %w", err)
+ }
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ sigCh := make(chan os.Signal, 1)
+ signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
+ go func() {
+ sig := <-sigCh
+ log.Printf("received signal %s, shutting down", sig)
+ cancel()
+ }()
+
+ b := &bridge{
+ ctx: ctx,
+ cancel: cancel,
+ pluginID: pluginID,
+ authChallenge: authChallenge,
+ pendingData: make(map[string]chan hqDataResponse),
+ }
+
+ if err := b.connectSocket(socketPort); err != nil {
+ return fmt.Errorf("failed to connect to HyperHQ: %w", err)
+ }
+
+ // pipe loop runs until ctx is cancelled. Reconnects on disconnect.
+ b.runPipeLoop()
+ log.Print("plugin exiting")
+ return nil
+}
+
+func socketIOManagerURL(port string) string {
+ return fmt.Sprintf("http://localhost:%s/socket.io/", port)
+}
+
+func onSocket(sock hqSocket, event string, listener func(...any)) {
+ sock.OnEvent(event, func(data any) {
+ listener(data)
+ })
+}
+
+func (b *bridge) clearSessionToken() {
+ b.sessionMu.Lock()
+ b.sessionToken = ""
+ b.sessionMu.Unlock()
+}
+
+// connectSocket establishes the Socket.IO connection to HyperHQ and registers
+// all event handlers. It blocks until the initial connect+authenticate cycle
+// completes (or fails). After that the socket runs in the background and
+// reconnects on its own.
+func (b *bridge) connectSocket(port string) error {
+ url := socketIOManagerURL(port)
+ // #nosec G706 -- port is validated as numeric in run() before reaching here.
+ log.Printf("connecting to HyperHQ Socket.IO at %s namespace %s", url, pluginNamespace)
+
+ reconnectDelay := time.Second
+ reconnectDelayMax := 5 * time.Second
+ manager := sio.NewManager(url, &sio.ManagerConfig{
+ EIO: eio.ClientConfig{
+ Transports: []string{"polling", "websocket"},
+ },
+ ReconnectionDelay: &reconnectDelay,
+ ReconnectionDelayMax: &reconnectDelayMax,
+ })
+ sock := &karagencSocket{socket: manager.Socket(pluginNamespace, nil)}
+ b.socket = sock
+
+ authDone := make(chan error, 1)
+ authOnce := sync.Once{}
+ // signalAuth reports the first auth result via authDone (used by the
+ // initial connect). Subsequent reconnect failures are logged so operators
+ // can see them, since Socket.IO re-fires "connect"/"authenticated" on each
+ // reconnect but the channel send is consumed only once.
+ signalAuth := func(err error) {
+ delivered := false
+ authOnce.Do(func() {
+ authDone <- err
+ delivered = true
+ })
+ if !delivered && err != nil {
+ log.Printf("post-reconnect auth error: %v", err)
+ }
+ }
+
+ sock.OnConnect(func() {
+ // #nosec G706 -- sock.ID() is a Socket.IO-generated session token, not user input.
+ log.Printf("HyperHQ socket connected (id=%s); emitting authenticate", sock.ID())
+ req := hqAuthRequest{PluginID: b.pluginID, Challenge: b.authChallenge}
+ if emitErr := sock.Emit("authenticate", req); emitErr != nil {
+ signalAuth(fmt.Errorf("emit authenticate: %w", emitErr))
+ }
+ })
+
+ onSocket(sock, "authenticated", func(args ...any) {
+ var resp hqAuthResponse
+ if err := decodeFirst(args, &resp); err != nil {
+ signalAuth(fmt.Errorf("decode authenticated: %w", err))
+ return
+ }
+ if !resp.Success {
+ signalAuth(fmt.Errorf("authentication rejected: %s", resp.Error))
+ return
+ }
+ log.Printf("HyperHQ authenticated (sessionToken length=%d)", len(resp.SessionToken))
+
+ b.sessionMu.Lock()
+ b.sessionToken = resp.SessionToken
+ b.sessionMu.Unlock()
+
+ // After auth, register the plugin and subscribe to media events.
+ // Either failure leaves the bridge unable to receive game events, so
+ // fail the connect cycle and let Socket.IO reconnect to retry.
+ registerPayload := hqPluginRegister{
+ ID: b.pluginID,
+ Version: pluginVersion,
+ Type: "executable",
+ SessionToken: resp.SessionToken,
+ Capabilities: []string{"games", "launch", "events"},
+ }
+ if err := sock.Emit("plugin:register", registerPayload); err != nil {
+ signalAuth(fmt.Errorf("plugin:register emit: %w", err))
+ return
+ }
+
+ // HyperHQ expects subscribeEvents as a bare array of event names, not
+ // an enveloped {events:[...]} object.
+ if err := sock.Emit("subscribeEvents", []string{hqEventGameLaunched, hqEventGameClosed}); err != nil {
+ signalAuth(fmt.Errorf("subscribeEvents emit: %w", err))
+ return
+ }
+
+ signalAuth(nil)
+ })
+
+ onSocket(sock, "eventsSubscribed", func(args ...any) {
+ log.Printf("HyperHQ confirmed event subscription: %v", args)
+ })
+
+ sock.OnConnectError(func(err any) {
+ log.Printf("HyperHQ connect_error: %v", err)
+ signalAuth(fmt.Errorf("connect error: %v", err))
+ })
+
+ sock.OnDisconnect(func(reason any) {
+ log.Printf("HyperHQ socket disconnected: %v", reason)
+ b.clearSessionToken()
+ })
+
+ onSocket(sock, "request", b.handleLifecycleRequest)
+ onSocket(sock, "dataResponse", b.handleDataResponse)
+ onSocket(sock, "hyperHqEvent", b.handleHyperHqEvent)
+
+ sock.Connect()
+
+ select {
+ case err := <-authDone:
+ return err
+ case <-time.After(requestTimeout):
+ return errors.New("timeout waiting for HyperHQ authentication")
+ case <-b.ctx.Done():
+ return b.ctx.Err()
+ }
+}
+
+// handleLifecycleRequest decodes a `request` event from HyperHQ, dispatches by
+// method, and replies via plugin:response. A missing id means HyperHQ wasn't
+// expecting a reply, so we still process the side effect (e.g. shutdown) but
+// skip the response emit.
+func (b *bridge) handleLifecycleRequest(args ...any) {
+ var req hqLifecycleRequest
+ if err := decodeFirst(args, &req); err != nil {
+ log.Printf("request decode failed: %v", err)
+ return
+ }
+ log.Printf("HyperHQ request: id=%s method=%s", req.ID, req.Method)
+
+ respType := "response"
+ var respData any
+
+ switch req.Method {
+ case hqMethodInitialize:
+ respData = "initialized"
+ case hqMethodExecute:
+ // Zaparoo's bridge doesn't implement UI actions.
+ respData = map[string]any{"success": true}
+ case hqMethodTest:
+ b.sessionMu.RLock()
+ respData = b.sessionToken != ""
+ b.sessionMu.RUnlock()
+ case hqMethodShutdown:
+ log.Print("HyperHQ requested shutdown")
+ respData = "ok"
+ defer b.cancel()
+ default:
+ log.Printf("unknown request method: %s", req.Method)
+ respType = "error"
+ respData = map[string]string{"error": "unknown method: " + req.Method}
+ }
+
+ if req.ID == "" {
+ return
+ }
+
+ b.sessionMu.RLock()
+ token := b.sessionToken
+ b.sessionMu.RUnlock()
+
+ resp := hqPluginResponse{
+ ID: req.ID,
+ Type: respType,
+ Data: respData,
+ SessionToken: token,
+ Timestamp: time.Now().UnixMilli(),
+ }
+ if err := b.socket.Emit("plugin:response", resp); err != nil {
+ log.Printf("plugin:response emit (id=%s): %v", req.ID, err)
+ }
+ if err := b.socket.Emit("response", resp); err != nil {
+ log.Printf("response emit (id=%s): %v", req.ID, err)
+ }
+ log.Printf("HyperHQ response emitted: id=%s method=%s type=%s", req.ID, req.Method, respType)
+}
+
+// handleDataResponse routes a dataResponse to whichever requestData call is
+// waiting on this requestId. Unknown requestIds are logged and dropped; this
+// covers fire-and-forget replies (e.g. launchGame) and stale responses that
+// arrived after a timeout.
+func (b *bridge) handleDataResponse(args ...any) {
+ var resp hqDataResponse
+ if err := decodeFirst(args, &resp); err != nil {
+ log.Printf("dataResponse decode failed: %v", err)
+ return
+ }
+
+ b.pendingMu.Lock()
+ ch, ok := b.pendingData[resp.RequestID]
+ if ok {
+ delete(b.pendingData, resp.RequestID)
+ }
+ b.pendingMu.Unlock()
+
+ if !ok {
+ // No waiter — likely the launchGame fire-and-forget path. Surface
+ // errors so operators can spot bad game IDs.
+ if !resp.Success && resp.Error != "" {
+ log.Printf("dataResponse (no waiter) error for %s: %s", resp.RequestID, resp.Error)
+ }
+ return
+ }
+
+ // Buffered channel of size 1; this never blocks.
+ ch <- resp
+}
+
+// handleHyperHqEvent dispatches the unified hyperHqEvent envelope by type.
+func (b *bridge) handleHyperHqEvent(args ...any) {
+ var env hqEventEnvelope
+ if err := decodeFirst(args, &env); err != nil {
+ log.Printf("hyperHqEvent decode failed: %v", err)
+ return
+ }
+
+ switch env.Type {
+ case hqEventGameLaunched:
+ b.handleGameLaunched(env.Data)
+ case hqEventGameClosed:
+ b.handleGameClosed(env.Data)
+ default:
+ log.Printf("ignoring hyperHqEvent type=%s", env.Type)
+ }
+}
+
+func (b *bridge) handleGameLaunched(data json.RawMessage) {
+ var payload hqGameLaunchedPayload
+ if err := json.Unmarshal(data, &payload); err != nil {
+ log.Printf("gameLaunched decode failed: %v", err)
+ return
+ }
+ log.Printf(
+ "HyperHQ gameLaunched: id=%s name=%q systemId=%s",
+ payload.GameID, payload.GameName, payload.SystemID,
+ )
+ b.writePipeEvent(&pipeEvent{
+ Event: "MediaStarted",
+ ID: payload.GameID,
+ Title: payload.GameName,
+ SystemReferenceID: payload.SystemID,
+ })
+}
+
+func (b *bridge) handleGameClosed(data json.RawMessage) {
+ var payload hqGameClosedPayload
+ if err := json.Unmarshal(data, &payload); err != nil {
+ log.Printf("gameClosed decode failed: %v", err)
+ return
+ }
+ log.Printf(
+ "HyperHQ gameClosed: id=%s name=%q exitCode=%d",
+ payload.GameID, payload.GameName, payload.ExitCode,
+ )
+ b.writePipeEvent(&pipeEvent{
+ Event: "MediaStopped",
+ ID: payload.GameID,
+ Title: payload.GameName,
+ })
+}
+
+// runPipeLoop maintains a persistent connection to the Zaparoo Core named pipe
+// and reconnects on failure until the context is cancelled.
+func (b *bridge) runPipeLoop() {
+ for {
+ select {
+ case <-b.ctx.Done():
+ return
+ default:
+ }
+
+ if err := b.servePipeOnce(); err != nil {
+ if errors.Is(err, context.Canceled) {
+ return
+ }
+ log.Printf("pipe session ended: %v", err)
+ }
+
+ select {
+ case <-b.ctx.Done():
+ return
+ case <-time.After(pipeReconnectDelay):
+ }
+ }
+}
+
+func (b *bridge) servePipeOnce() error {
+ dialCtx, cancel := context.WithTimeout(b.ctx, requestTimeout)
+ defer cancel()
+
+ conn, err := dialPipeContext(dialCtx, pipeName)
+ if err != nil {
+ return fmt.Errorf("dial pipe: %w", err)
+ }
+ defer func() {
+ if closeErr := conn.Close(); closeErr != nil {
+ log.Printf("pipe close error: %v", closeErr)
+ }
+ }()
+
+ log.Printf("connected to Zaparoo Core pipe %s", pipeName)
+
+ writer := bufio.NewWriter(conn)
+ sessionCtx, sessionCancel := context.WithCancel(b.ctx)
+ session := &pipeSession{
+ ctx: sessionCtx,
+ bridge: b,
+ writer: writer,
+ }
+
+ b.pipeMu.Lock()
+ b.pipeWriter = writer
+ b.pipeMu.Unlock()
+
+ defer func() {
+ sessionCancel()
+ b.pipeMu.Lock()
+ b.pipeWriter = nil
+ b.pipeMu.Unlock()
+ }()
+
+ // On every (re)connect, push the current systems list so Zaparoo Core can
+ // refresh its mapping. Best-effort: if HyperHQ isn't ready we log and move on.
+ go b.pushSystems(session)
+
+ scanner := bufio.NewScanner(conn)
+ scanner.Buffer(make([]byte, 4096), pipeBufferMax)
+
+ for scanner.Scan() {
+ select {
+ case <-b.ctx.Done():
+ return b.ctx.Err()
+ default:
+ }
+ b.handlePipeCommand(session, scanner.Text())
+ }
+
+ if err := scanner.Err(); err != nil {
+ return fmt.Errorf("pipe scanner: %w", err)
+ }
+ return errors.New("pipe closed by peer")
+}
+
+func (b *bridge) handlePipeCommand(writer pipeEventWriter, line string) {
+ if line == "" {
+ return
+ }
+
+ var cmd pipeCommand
+ if err := json.Unmarshal([]byte(line), &cmd); err != nil {
+ log.Printf("invalid pipe command %q: %v", line, err)
+ return
+ }
+
+ switch cmd.Command {
+ case "Ping":
+ // Heartbeat — no response required, the connection liveness is enough.
+ case "GetSystems":
+ go b.pushSystems(writer)
+ case "GetGamesForSystem":
+ target := systemQueryTarget{ID: cmd.SystemID, Name: cmd.SystemName, ReferenceID: cmd.SystemReferenceID}
+ go b.pushGames(writer, target)
+ case "Launch":
+ go b.launchGame(cmd.ID)
+ default:
+ log.Printf("unknown pipe command: %s", cmd.Command)
+ }
+}
+
+func (b *bridge) pushSystems(writer pipeEventWriter) {
+ systems, err := b.requestSystems()
+ if err != nil {
+ log.Printf("getSystems failed: %v", err)
+ writer.writePipeEvent(&pipeEvent{Event: "Systems", Error: err.Error()})
+ return
+ }
+
+ log.Printf("received %d HyperHQ systems from requestData", len(systems))
+ out := make([]hqSystemInfo, 0, len(systems))
+ for _, sys := range systems {
+ log.Printf(
+ "HyperHQ system: id=%q referenceId=%q name=%q platform=%q",
+ sys.ID, sys.ReferenceID, sys.Name, sys.Platform,
+ )
+ out = append(out, hqSystemInfo(sys))
+ }
+ writer.writePipeEvent(&pipeEvent{Event: "Systems", Systems: out})
+}
+
+func (b *bridge) pushGames(writer pipeEventWriter, target systemQueryTarget) {
+ if target.ID == "" && target.ReferenceID == "" {
+ writer.writePipeEvent(&pipeEvent{
+ Event: "Games",
+ Error: "missing SystemId and SystemReferenceId",
+ })
+ return
+ }
+
+ games, err := b.requestGames(target)
+ if err != nil {
+ log.Printf(
+ "%s(id=%q name=%q referenceId=%q) failed: %v",
+ gameListMethod, target.ID, target.Name, target.ReferenceID, err,
+ )
+ writer.writePipeEvent(&pipeEvent{
+ Event: "Games",
+ SystemID: target.ID,
+ SystemName: target.Name,
+ SystemReferenceID: target.ReferenceID,
+ Error: err.Error(),
+ })
+ return
+ }
+
+ out := make([]hqGameInfo, 0, len(games))
+ for i := range games {
+ out = append(out, hqGameInfo{
+ ID: games[i].ID,
+ Title: games[i].Title,
+ Platform: games[i].Platform,
+ })
+ }
+ writer.writePipeEvent(&pipeEvent{
+ Event: "Games",
+ SystemID: target.ID,
+ SystemName: target.Name,
+ SystemReferenceID: target.ReferenceID,
+ Games: out,
+ })
+}
+
+// launchGame is fire-and-forget: HyperHQ acknowledges via the next
+// gameLaunched event, not via the immediate dataResponse. We still emit
+// through requestData (with a short waiter) so that errors like an unknown
+// gameId surface in logs.
+func (b *bridge) launchGame(id string) {
+ if id == "" {
+ log.Print("launchGame called with empty id")
+ return
+ }
+
+ ctx, cancel := context.WithTimeout(b.ctx, launchAckTimeout)
+ defer cancel()
+
+ if _, err := b.requestDataCtx(ctx, "launchGame", map[string]any{"gameId": id}); err != nil {
+ // Timeout here is expected — HyperHQ doesn't always send a synchronous
+ // dataResponse for launchGame. Only log non-timeout failures.
+ if !errors.Is(err, context.DeadlineExceeded) {
+ log.Printf("launchGame(%s) failed: %v", id, err)
+ }
+ }
+}
+
+// requestSystems / requestGames issue HyperHQ's `requestData` envelope and
+// decode the data portion of the dataResponse.
+func (b *bridge) requestSystems() ([]hqRawSystem, error) {
+ resp, err := b.requestData("getSystems", nil)
+ if err != nil {
+ return nil, err
+ }
+ systems, err := decodeSystemsData(resp)
+ if err != nil {
+ return nil, fmt.Errorf("decode systems: %w", err)
+ }
+ return systems, nil
+}
+
+func (b *bridge) requestGames(target systemQueryTarget) ([]hqRawGame, error) {
+ variants := gameRequestVariants(target)
+ if len(variants) == 0 {
+ return nil, errors.New("missing HyperHQ system identifiers")
+ }
+
+ failures := make([]string, 0, len(variants))
+ for _, variant := range variants {
+ log.Printf(
+ "requesting HyperHQ games: method=%s param=%s source=%s value=%q",
+ variant.Method, variant.ParamKey, variant.Label, variant.ParamValue,
+ )
+ resp, err := b.requestData(variant.Method, map[string]any{
+ variant.ParamKey: variant.ParamValue,
+ })
+ if err != nil {
+ failures = append(failures, fmt.Sprintf("%s: %v", variant.Label, err))
+ log.Printf("HyperHQ games request failed (%s): %v", variant.Label, err)
+ continue
+ }
+ games, err := decodeGamesData(resp)
+ if err != nil {
+ failures = append(failures, fmt.Sprintf("%s: decode games: %v", variant.Label, err))
+ log.Printf("HyperHQ games decode failed (%s): %v", variant.Label, err)
+ continue
+ }
+ log.Printf("HyperHQ games request succeeded (%s): %d games", variant.Label, len(games))
+ return games, nil
+ }
+
+ return nil, fmt.Errorf("all HyperHQ game request variants failed: %s", strings.Join(failures, "; "))
+}
+
+func gameRequestVariants(target systemQueryTarget) []gameRequestVariant {
+ value := target.Name
+ label := "name"
+ if value == "" {
+ value = target.ID
+ label = "id"
+ }
+ if value == "" {
+ value = target.ReferenceID
+ label = "referenceId"
+ }
+ if value == "" {
+ return nil
+ }
+ return []gameRequestVariant{
+ {
+ Method: gameListMethod,
+ ParamKey: gameListParamKey,
+ ParamValue: value,
+ Label: label,
+ },
+ }
+}
+
+func decodeSystemsData(raw json.RawMessage) ([]hqRawSystem, error) {
+ var systems []hqRawSystem
+ if err := unmarshalIfPresent(raw, &systems); err == nil {
+ return systems, nil
+ }
+
+ var wrapped hqSystemsData
+ if err := unmarshalIfPresent(raw, &wrapped); err != nil {
+ return nil, err
+ }
+ return wrapped.Systems, nil
+}
+
+func decodeGamesData(raw json.RawMessage) ([]hqRawGame, error) {
+ var games []hqRawGame
+ if err := unmarshalIfPresent(raw, &games); err == nil {
+ return normalizeGameTitles(games), nil
+ }
+
+ var wrapped hqGamesData
+ if err := unmarshalIfPresent(raw, &wrapped); err != nil {
+ return nil, err
+ }
+ return normalizeGameTitles(wrapped.Games), nil
+}
+
+func normalizeGameTitles(games []hqRawGame) []hqRawGame {
+ for i := range games {
+ if games[i].ID == "" {
+ games[i].ID = games[i].GameID
+ }
+ if games[i].ID == "" {
+ games[i].ID = games[i].ReferenceID
+ }
+ if games[i].Title == "" {
+ games[i].Title = games[i].Name
+ }
+ if games[i].Title == "" {
+ games[i].Title = games[i].FileName
+ }
+ if games[i].Title == "" {
+ games[i].Title = games[i].ROMPath
+ }
+ if games[i].Platform == "" {
+ games[i].Platform = games[i].SystemName
+ }
+ }
+ return games
+}
+
+// requestData wraps HyperHQ's documented requestData(method, params) RPC. It
+// emits a requestData with a fresh requestId, then waits for the matching
+// dataResponse on the dataResponse channel. The default timeout comes from
+// requestTimeout and the bridge context.
+func (b *bridge) requestData(method string, params map[string]any) (json.RawMessage, error) {
+ ctx, cancel := context.WithTimeout(b.ctx, requestTimeout)
+ defer cancel()
+ return b.requestDataCtx(ctx, method, params)
+}
+
+func (b *bridge) requestDataCtx(
+ ctx context.Context, method string, params map[string]any,
+) (json.RawMessage, error) {
+ if b.socket == nil {
+ return nil, errors.New("socket not connected")
+ }
+
+ b.sessionMu.RLock()
+ token := b.sessionToken
+ b.sessionMu.RUnlock()
+ if token == "" {
+ return nil, errors.New("no session token (not authenticated)")
+ }
+
+ requestID := newRequestID()
+ respCh := make(chan hqDataResponse, 1)
+
+ b.pendingMu.Lock()
+ b.pendingData[requestID] = respCh
+ b.pendingMu.Unlock()
+
+ cleanup := func() {
+ b.pendingMu.Lock()
+ delete(b.pendingData, requestID)
+ b.pendingMu.Unlock()
+ }
+
+ envelope := hqRequestData{
+ Method: method,
+ Params: params,
+ RequestID: requestID,
+ SessionToken: token,
+ }
+ if emitErr := b.socket.Emit("requestData", envelope); emitErr != nil {
+ cleanup()
+ return nil, fmt.Errorf("emit requestData: %w", emitErr)
+ }
+
+ select {
+ case resp := <-respCh:
+ if !resp.Success {
+ if resp.Error != "" {
+ return nil, fmt.Errorf("HyperHQ error: %s", resp.Error)
+ }
+ return nil, errors.New("HyperHQ reported failure with no error message")
+ }
+ return resp.Data, nil
+ case <-ctx.Done():
+ cleanup()
+ return nil, ctx.Err()
+ case <-b.ctx.Done():
+ cleanup()
+ return nil, b.ctx.Err()
+ }
+}
+
+func (s *pipeSession) writePipeEvent(evt *pipeEvent) {
+ data, err := json.Marshal(evt)
+ if err != nil {
+ log.Printf("marshal pipe event %s: %v", evt.Event, err)
+ return
+ }
+
+ select {
+ case <-s.ctx.Done():
+ return
+ default:
+ }
+
+ s.bridge.pipeMu.Lock()
+ defer s.bridge.pipeMu.Unlock()
+
+ select {
+ case <-s.ctx.Done():
+ return
+ default:
+ }
+ if s.writer == nil {
+ return
+ }
+ if _, err := s.writer.Write(append(data, '\n')); err != nil {
+ log.Printf("write pipe event: %v", err)
+ return
+ }
+ if err := s.writer.Flush(); err != nil {
+ log.Printf("flush pipe event: %v", err)
+ }
+}
+
+func (b *bridge) writePipeEvent(evt *pipeEvent) {
+ data, err := json.Marshal(evt)
+ if err != nil {
+ log.Printf("marshal pipe event %s: %v", evt.Event, err)
+ return
+ }
+
+ b.pipeMu.Lock()
+ defer b.pipeMu.Unlock()
+
+ if b.pipeWriter == nil {
+ return
+ }
+ if _, err := b.pipeWriter.Write(append(data, '\n')); err != nil {
+ log.Printf("write pipe event: %v", err)
+ return
+ }
+ if err := b.pipeWriter.Flush(); err != nil {
+ log.Printf("flush pipe event: %v", err)
+ }
+}
+
+// decodeFirst takes the variadic args from a Socket.IO listener, picks the
+// first one, and re-marshals it into target via JSON. Socket.IO surfaces JSON
+// payloads as map[string]any / []any soup, so the round-trip is the simplest
+// way to land the data into a typed struct.
+func decodeFirst(args []any, target any) error {
+ if len(args) == 0 {
+ return errors.New("no args")
+ }
+ raw, err := json.Marshal(args[0])
+ if err != nil {
+ return fmt.Errorf("marshal intermediate: %w", err)
+ }
+ if err := json.Unmarshal(raw, target); err != nil {
+ return fmt.Errorf("unmarshal target: %w", err)
+ }
+ return nil
+}
+
+// unmarshalIfPresent unmarshals raw into target, returning nil if raw is empty
+// (HyperHQ may send dataResponse.success=true with no data field for void
+// methods).
+func unmarshalIfPresent(raw json.RawMessage, target any) error {
+ if len(raw) == 0 || string(raw) == "null" {
+ return nil
+ }
+ if err := json.Unmarshal(raw, target); err != nil {
+ return fmt.Errorf("unmarshal: %w", err)
+ }
+ return nil
+}
+
+// newRequestID generates a short hex id for matching requestData/dataResponse
+// pairs. Using crypto/rand keeps ids unique across reconnects so a stray late
+// response can't be matched to a future request.
+func newRequestID() string {
+ var buf [12]byte
+ if _, err := rand.Read(buf[:]); err != nil {
+ // Extremely unlikely; fall back to a time-based id.
+ return fmt.Sprintf("req-%d", time.Now().UnixNano())
+ }
+ return hex.EncodeToString(buf[:])
+}
diff --git a/scripts/windows/hyperhq-plugin/main_test.go b/scripts/windows/hyperhq-plugin/main_test.go
new file mode 100644
index 000000000..7c53c7db4
--- /dev/null
+++ b/scripts/windows/hyperhq-plugin/main_test.go
@@ -0,0 +1,482 @@
+// 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 main
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+type fakeSocket struct {
+ emitErr error
+ connectListener func()
+ errorListener func(any)
+ disconnectListen func(any)
+ listeners map[string]func(any)
+ emits []fakeEmit
+}
+
+type fakeEmit struct {
+ event string
+ args []any
+}
+
+func (f *fakeSocket) Emit(event string, args ...any) error {
+ f.emits = append(f.emits, fakeEmit{event: event, args: args})
+ return f.emitErr
+}
+
+func (f *fakeSocket) OnEvent(event string, listener func(any)) {
+ if f.listeners == nil {
+ f.listeners = make(map[string]func(any))
+ }
+ f.listeners[event] = listener
+}
+
+func (f *fakeSocket) OnConnect(listener func()) {
+ f.connectListener = listener
+}
+
+func (f *fakeSocket) OnConnectError(listener func(any)) {
+ f.errorListener = listener
+}
+
+func (f *fakeSocket) OnDisconnect(listener func(any)) {
+ f.disconnectListen = listener
+}
+
+func (*fakeSocket) Connect() {}
+
+func (*fakeSocket) ID() string {
+ return "fake-socket"
+}
+
+type manifestSocketIO struct {
+ Namespace string `json:"namespace"`
+ Enabled bool `json:"enabled"`
+}
+
+type manifestCommunicationSocketIO struct {
+ Enabled bool `json:"enabled"`
+}
+
+type manifestCommunication struct {
+ Preferred string `json:"preferred"`
+ Fallback string `json:"fallback"`
+ SocketIO manifestCommunicationSocketIO `json:"socketio"`
+}
+
+type pluginManifest struct {
+ Communication manifestCommunication `json:"communication"`
+ Type string `json:"type"`
+ Executable string `json:"executable"`
+ SocketIO manifestSocketIO `json:"socketio"`
+}
+
+func TestPluginManifestMatchesHyperHQExecutableSocketIODocs(t *testing.T) {
+ t.Parallel()
+
+ data, err := os.ReadFile("plugin.json")
+ if err != nil {
+ t.Fatalf("read plugin.json: %v", err)
+ }
+
+ var manifest pluginManifest
+ if err := json.Unmarshal(data, &manifest); err != nil {
+ t.Fatalf("unmarshal plugin.json: %v", err)
+ }
+
+ if manifest.Type != "executable" {
+ t.Fatalf("manifest type = %q, want executable", manifest.Type)
+ }
+ if manifest.Executable != "zaparoo-hyperhq.exe" {
+ t.Fatalf("manifest executable = %q, want zaparoo-hyperhq.exe", manifest.Executable)
+ }
+ if manifest.Communication.Preferred != "socketio" || manifest.Communication.Fallback != "stdio" {
+ t.Fatalf("manifest communication = %+v, want socketio preferred with stdio fallback", manifest.Communication)
+ }
+ if !manifest.Communication.SocketIO.Enabled {
+ t.Fatal("manifest communication.socketio.enabled = false, want true")
+ }
+ if !manifest.SocketIO.Enabled || manifest.SocketIO.Namespace != "/plugin" {
+ t.Fatalf("manifest socketio = %+v, want enabled namespace /plugin", manifest.SocketIO)
+ }
+}
+
+func TestSocketIOManagerURLUsesDefaultEnginePath(t *testing.T) {
+ got := socketIOManagerURL("52789")
+ want := "http://localhost:52789/socket.io/"
+ if got != want {
+ t.Fatalf("socketIOManagerURL() = %q, want %q", got, want)
+ }
+}
+
+func TestPluginLogPathPrefersPluginDataDir(t *testing.T) {
+ t.Setenv("PLUGIN_DATA_DIR", filepath.Join(t.TempDir(), "plugin-data"))
+
+ path := pluginLogPath()
+ if filepath.Base(path) != "zaparoo-hyperhq.log" {
+ t.Fatalf("pluginLogPath() = %q, want zaparoo-hyperhq.log file", path)
+ }
+ if !strings.Contains(path, "plugin-data") {
+ t.Fatalf("pluginLogPath() = %q, want PLUGIN_DATA_DIR path", path)
+ }
+}
+
+func TestPluginRegisterIncludesAuthFields(t *testing.T) {
+ payload := hqPluginRegister{
+ ID: "zaparoo-hyperhq",
+ Version: pluginVersion,
+ Type: "executable",
+ SessionToken: "session-token",
+ Capabilities: []string{"games"},
+ }
+
+ //nolint:gosec // verifies sessionToken field is serialized without real secret data
+ data, err := json.Marshal(payload)
+ if err != nil {
+ t.Fatalf("marshal register payload: %v", err)
+ }
+ var got map[string]any
+ if err := json.Unmarshal(data, &got); err != nil {
+ t.Fatalf("unmarshal register payload: %v", err)
+ }
+ if got["type"] != "executable" || got["sessionToken"] != "session-token" {
+ t.Fatalf("register payload = %v, want executable type and sessionToken", got)
+ }
+}
+
+func TestClearSessionTokenMakesRequestUnauthenticated(t *testing.T) {
+ b := &bridge{
+ ctx: context.Background(),
+ socket: &fakeSocket{},
+ sessionToken: "stale-token",
+ pendingData: make(map[string]chan hqDataResponse),
+ }
+
+ b.clearSessionToken()
+
+ _, err := b.requestDataCtx(context.Background(), "getSystems", nil)
+ if err == nil || !strings.Contains(err.Error(), "no session token") {
+ t.Fatalf("requestDataCtx() error = %v, want no session token", err)
+ }
+}
+
+func TestHandleDataResponseRoutesAndCleansPendingRequest(t *testing.T) {
+ ch := make(chan hqDataResponse, 1)
+ b := &bridge{pendingData: map[string]chan hqDataResponse{"req-1": ch}}
+
+ b.handleDataResponse(map[string]any{
+ "requestId": "req-1",
+ "success": true,
+ "data": []string{"ok"},
+ })
+
+ select {
+ case resp := <-ch:
+ if resp.RequestID != "req-1" || !resp.Success {
+ t.Fatalf("response = %+v, want req-1 success", resp)
+ }
+ default:
+ t.Fatal("pending response channel did not receive routed response")
+ }
+ if _, ok := b.pendingData["req-1"]; ok {
+ t.Fatal("pendingData still contains completed request")
+ }
+
+ b.handleDataResponse(map[string]any{
+ "requestId": "unknown",
+ "success": false,
+ "error": "boom",
+ })
+ if len(b.pendingData) != 0 {
+ t.Fatalf("pendingData length = %d, want 0", len(b.pendingData))
+ }
+}
+
+func TestRequestDataCtxCleansPendingOnContextCancel(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ fake := &fakeSocket{}
+ b := &bridge{
+ ctx: context.Background(),
+ socket: fake,
+ sessionToken: "token",
+ pendingData: make(map[string]chan hqDataResponse),
+ }
+
+ _, err := b.requestDataCtx(ctx, "getSystems", nil)
+ if !errors.Is(err, context.Canceled) {
+ t.Fatalf("requestDataCtx() error = %v, want context.Canceled", err)
+ }
+ if len(b.pendingData) != 0 {
+ t.Fatalf("pendingData length = %d, want 0", len(b.pendingData))
+ }
+ if len(fake.emits) != 1 || fake.emits[0].event != "requestData" {
+ t.Fatalf("emits = %+v, want one requestData emit", fake.emits)
+ }
+}
+
+func TestHandleLifecycleRequestEmitsResponseAndCancelsShutdown(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ fake := &fakeSocket{}
+ b := &bridge{
+ ctx: ctx,
+ cancel: cancel,
+ socket: fake,
+ pluginID: "plugin-id",
+ sessionToken: "token",
+ }
+
+ b.handleLifecycleRequest(map[string]any{
+ "id": "init-1",
+ "method": hqMethodInitialize,
+ })
+
+ if len(fake.emits) != 2 || fake.emits[0].event != "plugin:response" || fake.emits[1].event != "response" {
+ t.Fatalf("emits = %+v, want plugin:response and response", fake.emits)
+ }
+ resp, ok := fake.emits[0].args[0].(hqPluginResponse)
+ if !ok {
+ t.Fatalf("response type = %T, want hqPluginResponse", fake.emits[0].args[0])
+ }
+ if resp.ID != "init-1" || resp.SessionToken != "token" || resp.Data != "initialized" || resp.Timestamp == 0 {
+ t.Fatalf("response = %+v, want id init-1, token, initialized data, and timestamp", resp)
+ }
+
+ b.handleLifecycleRequest(map[string]any{
+ "id": "test-1",
+ "method": hqMethodTest,
+ })
+ if len(fake.emits) != 4 || fake.emits[2].event != "plugin:response" || fake.emits[3].event != "response" {
+ t.Fatalf("emits = %+v, want second plugin:response and response", fake.emits)
+ }
+ resp, ok = fake.emits[2].args[0].(hqPluginResponse)
+ if !ok {
+ t.Fatalf("test response type = %T, want hqPluginResponse", fake.emits[2].args[0])
+ }
+ data, ok := resp.Data.(bool)
+ if resp.ID != "test-1" || !ok || !data {
+ t.Fatalf("test response = %+v, want id test-1 and true data", resp)
+ }
+
+ b.handleLifecycleRequest(map[string]any{"method": hqMethodTest})
+ if len(fake.emits) != 4 {
+ t.Fatalf("missing-id request emitted response; emits = %+v", fake.emits)
+ }
+
+ b.handleLifecycleRequest(map[string]any{
+ "id": "shutdown-1",
+ "method": hqMethodShutdown,
+ })
+ if len(fake.emits) != 6 || fake.emits[4].event != "plugin:response" || fake.emits[5].event != "response" {
+ t.Fatalf("emits = %+v, want shutdown plugin:response and response", fake.emits)
+ }
+ resp, ok = fake.emits[4].args[0].(hqPluginResponse)
+ if !ok {
+ t.Fatalf("shutdown response type = %T, want hqPluginResponse", fake.emits[4].args[0])
+ }
+ if resp.ID != "shutdown-1" || resp.Data != "ok" {
+ t.Fatalf("shutdown response = %+v, want id shutdown-1 and ok data", resp)
+ }
+ select {
+ case <-ctx.Done():
+ default:
+ t.Fatal("shutdown request did not cancel bridge context")
+ }
+}
+
+func TestPipeSessionWriteUsesCapturedWriterAndDropsAfterCancel(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ var oldBuf bytes.Buffer
+ var newBuf bytes.Buffer
+ b := &bridge{ctx: context.Background(), pipeWriter: bufio.NewWriter(&newBuf)}
+ session := &pipeSession{
+ ctx: ctx,
+ bridge: b,
+ writer: bufio.NewWriter(&oldBuf),
+ }
+
+ session.writePipeEvent(&pipeEvent{Event: "Systems"})
+ if oldBuf.Len() == 0 {
+ t.Fatal("captured session writer received no output")
+ }
+ if newBuf.Len() != 0 {
+ t.Fatal("shared bridge writer received session output")
+ }
+
+ oldBuf.Reset()
+ cancel()
+ session.writePipeEvent(&pipeEvent{Event: "Games"})
+ if oldBuf.Len() != 0 {
+ t.Fatalf("canceled session wrote %q", oldBuf.String())
+ }
+}
+
+func TestDecodeFirst(t *testing.T) {
+ var req hqLifecycleRequest
+ if err := decodeFirst([]any{map[string]any{"id": "1", "method": hqMethodTest}}, &req); err != nil {
+ t.Fatalf("decodeFirst() unexpected error: %v", err)
+ }
+ if req.ID != "1" || req.Method != hqMethodTest {
+ t.Fatalf("decoded request = %+v", req)
+ }
+
+ if err := decodeFirst(nil, &req); err == nil {
+ t.Fatal("decodeFirst(nil) error = nil, want error")
+ }
+ if err := decodeFirst([]any{map[string]any{"id": make(chan int)}}, &req); err == nil {
+ t.Fatal("decodeFirst(unmarshalable) error = nil, want error")
+ }
+}
+
+func TestDecodeSystemsDataAcceptsArrayAndWrappedObject(t *testing.T) {
+ systems, err := decodeSystemsData(json.RawMessage(
+ `[{"id":"sys-1","name":"NES","referenceId":"nes","platform":"nes"}]`,
+ ))
+ if err != nil {
+ t.Fatalf("decodeSystemsData(array) error = %v", err)
+ }
+ if len(systems) != 1 || systems[0].ID != "sys-1" || systems[0].ReferenceID != "nes" {
+ t.Fatalf("decodeSystemsData(array) = %+v, want NES system with id", systems)
+ }
+
+ systems, err = decodeSystemsData(json.RawMessage(
+ `{"systems":[{"id":"sys-2","name":"SNES","referenceId":"snes","platform":"snes"}]}`,
+ ))
+ if err != nil {
+ t.Fatalf("decodeSystemsData(wrapped) error = %v", err)
+ }
+ if len(systems) != 1 || systems[0].ID != "sys-2" || systems[0].ReferenceID != "snes" {
+ t.Fatalf("decodeSystemsData(wrapped) = %+v, want SNES system with id", systems)
+ }
+}
+
+func TestGameRequestVariantsUsesNameAsSystemIDCandidate(t *testing.T) {
+ variants := gameRequestVariants(systemQueryTarget{ID: "sys-id", Name: "System Name", ReferenceID: "ref-id"})
+ if len(variants) != 1 {
+ t.Fatalf("variants length = %d, want 1", len(variants))
+ }
+ if variants[0].Method != gameListMethod || variants[0].ParamKey != gameListParamKey {
+ t.Fatalf("variant = %+v, want getGamesForSystem/systemId", variants[0])
+ }
+ if variants[0].Label != "name" || variants[0].ParamValue != "System Name" {
+ t.Fatalf("variant = %+v, want name", variants[0])
+ }
+
+ variants = gameRequestVariants(systemQueryTarget{ReferenceID: "ref-id"})
+ if len(variants) != 1 || variants[0].Label != "referenceId" || variants[0].ParamValue != "ref-id" {
+ t.Fatalf("reference-only variants = %+v, want referenceId", variants)
+ }
+}
+
+func TestDecodeGamesDataAcceptsArrayAndWrappedObject(t *testing.T) {
+ games, err := decodeGamesData(json.RawMessage(`[{"id":"1","title":"Game","platform":"nes"}]`))
+ if err != nil {
+ t.Fatalf("decodeGamesData(array) error = %v", err)
+ }
+ if len(games) != 1 || games[0].ID != "1" || games[0].Title != "Game" {
+ t.Fatalf("decodeGamesData(array) = %+v, want game 1 title", games)
+ }
+
+ games, err = decodeGamesData(json.RawMessage(`[{"id":"name-1","name":"Named Game","platform":"nes"}]`))
+ if err != nil {
+ t.Fatalf("decodeGamesData(name array) error = %v", err)
+ }
+ if len(games) != 1 || games[0].ID != "name-1" || games[0].Title != "Named Game" {
+ t.Fatalf("decodeGamesData(name array) = %+v, want title from name", games)
+ }
+
+ games, err = decodeGamesData(json.RawMessage(`{"games":[{"id":"2","title":"Game 2","platform":"snes"}]}`))
+ if err != nil {
+ t.Fatalf("decodeGamesData(wrapped) error = %v", err)
+ }
+ if len(games) != 1 || games[0].ID != "2" || games[0].Title != "Game 2" {
+ t.Fatalf("decodeGamesData(wrapped) = %+v, want game 2 title", games)
+ }
+
+ games, err = decodeGamesData(json.RawMessage(
+ `{"games":[{"id":"name-2","name":"Wrapped Named Game","platform":"snes"}]}`,
+ ))
+ if err != nil {
+ t.Fatalf("decodeGamesData(name wrapped) error = %v", err)
+ }
+ if len(games) != 1 || games[0].ID != "name-2" || games[0].Title != "Wrapped Named Game" {
+ t.Fatalf("decodeGamesData(name wrapped) = %+v, want title from name", games)
+ }
+
+ games, err = decodeGamesData(json.RawMessage(
+ `[{"gameId":"game-1","referenceId":"ref-1","fileName":"rom.sfc","systemName":"SNES"}]`,
+ ))
+ if err != nil {
+ t.Fatalf("decodeGamesData(rom fields) error = %v", err)
+ }
+ if len(games) != 1 || games[0].ID != "game-1" || games[0].Title != "rom.sfc" || games[0].Platform != "SNES" {
+ t.Fatalf("decodeGamesData(rom fields) = %+v, want normalized ROM fields", games)
+ }
+}
+
+func TestUnmarshalIfPresent(t *testing.T) {
+ var out []hqRawSystem
+ if err := unmarshalIfPresent(nil, &out); err != nil {
+ t.Fatalf("unmarshalIfPresent(nil) error = %v", err)
+ }
+ if err := unmarshalIfPresent(json.RawMessage("null"), &out); err != nil {
+ t.Fatalf("unmarshalIfPresent(null) error = %v", err)
+ }
+
+ raw := json.RawMessage(`[{"name":"NES","referenceId":"nes","platform":"nes"}]`)
+ if err := unmarshalIfPresent(raw, &out); err != nil {
+ t.Fatalf("unmarshalIfPresent(valid) error = %v", err)
+ }
+ if len(out) != 1 || out[0].ReferenceID != "nes" {
+ t.Fatalf("decoded systems = %+v", out)
+ }
+
+ if err := unmarshalIfPresent(json.RawMessage(`{"bad"`), &out); err == nil {
+ t.Fatal("unmarshalIfPresent(invalid) error = nil, want error")
+ }
+}
+
+func TestNewRequestID(t *testing.T) {
+ seen := make(map[string]bool)
+ for range 100 {
+ id := newRequestID()
+ if len(id) != 24 {
+ t.Fatalf("newRequestID() length = %d, want 24", len(id))
+ }
+ if _, err := hex.DecodeString(id); err != nil {
+ t.Fatalf("newRequestID() produced non-hex string: %v", err)
+ }
+ if seen[id] {
+ t.Fatalf("newRequestID() duplicate id %q", id)
+ }
+ seen[id] = true
+ }
+}
diff --git a/scripts/windows/hyperhq-plugin/pipe_unsupported.go b/scripts/windows/hyperhq-plugin/pipe_unsupported.go
new file mode 100644
index 000000000..b68db8e56
--- /dev/null
+++ b/scripts/windows/hyperhq-plugin/pipe_unsupported.go
@@ -0,0 +1,32 @@
+// 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 .
+
+//go:build !windows
+
+package main
+
+import (
+ "context"
+ "errors"
+ "net"
+)
+
+func dialPipeContext(_ context.Context, _ string) (net.Conn, error) {
+ return nil, errors.New("HyperHQ named pipe is only available on Windows")
+}
diff --git a/scripts/windows/hyperhq-plugin/pipe_windows.go b/scripts/windows/hyperhq-plugin/pipe_windows.go
new file mode 100644
index 000000000..5447b2fd1
--- /dev/null
+++ b/scripts/windows/hyperhq-plugin/pipe_windows.go
@@ -0,0 +1,38 @@
+// 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 .
+
+//go:build windows
+
+package main
+
+import (
+ "context"
+ "fmt"
+ "net"
+
+ "github.com/Microsoft/go-winio"
+)
+
+func dialPipeContext(ctx context.Context, path string) (net.Conn, error) {
+ conn, err := winio.DialPipeContext(ctx, path)
+ if err != nil {
+ return nil, fmt.Errorf("dial named pipe: %w", err)
+ }
+ return conn, nil
+}
diff --git a/scripts/windows/hyperhq-plugin/plugin.json b/scripts/windows/hyperhq-plugin/plugin.json
new file mode 100644
index 000000000..223c7f1a8
--- /dev/null
+++ b/scripts/windows/hyperhq-plugin/plugin.json
@@ -0,0 +1,31 @@
+{
+ "id": "zaparoo-hyperhq",
+ "name": "Zaparoo",
+ "version": "0.1.0",
+ "type": "executable",
+ "description": "Bridges HyperHQ with Zaparoo Core so physical NFC tokens can launch HyperHQ games and active media is reflected in Zaparoo's state.",
+ "author": "The Zaparoo Project",
+ "homepage": "https://zaparoo.org",
+ "license": "GPL-3.0-or-later",
+ "executable": "zaparoo-hyperhq.exe",
+ "capabilities": [
+ "games",
+ "launch",
+ "events"
+ ],
+ "events": [
+ "gameLaunched",
+ "gameClosed"
+ ],
+ "communication": {
+ "preferred": "socketio",
+ "fallback": "stdio",
+ "socketio": {
+ "enabled": true
+ }
+ },
+ "socketio": {
+ "enabled": true,
+ "namespace": "/plugin"
+ }
+}