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" + } +}