From 88f19a2b409c9a15712974c5567d912c493ca5a4 Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Sat, 2 Nov 2024 16:54:20 +0800 Subject: [PATCH] LaunchBox support for Windows (#110) * Add AutoIt to generic launchers list * Refactor scanner to include systemId parameter, add LaunchBox launching on Windows * Remove app name from windows config setup * Refactor scanner logic and add LaunchBox XML support * Fix scan bug * Initial launchbox system map * Search for launchbox * Fix lb path bug --- pkg/database/gamesdb/gamesdb.go | 43 ++++- pkg/platforms/mister/platform.go | 2 + pkg/platforms/platforms.go | 2 +- pkg/platforms/windows/platform.go | 305 +++++++++++++++++++++++++++++- 4 files changed, 345 insertions(+), 7 deletions(-) diff --git a/pkg/database/gamesdb/gamesdb.go b/pkg/database/gamesdb/gamesdb.go index dc372b09..b08e8b43 100644 --- a/pkg/database/gamesdb/gamesdb.go +++ b/pkg/database/gamesdb/gamesdb.go @@ -253,7 +253,8 @@ func NewNamesIndex( // custom scan function if one exists for _, l := range platform.Launchers() { if l.SystemId == k && l.Scanner != nil { - files, err = l.Scanner(cfg, files) + log.Debug().Msgf("running %s scanner for system: %s", l.Id, systemId) + files, err = l.Scanner(cfg, systemId, files) if err != nil { return status.Files, err } @@ -283,13 +284,13 @@ func NewNamesIndex( for _, l := range platform.Launchers() { systemId := l.SystemId if !scanned[systemId] && l.Scanner != nil { - results, err := l.Scanner(cfg, []platforms.ScanResult{}) + log.Debug().Msgf("running %s scanner for system: %s", l.Id, systemId) + results, err := l.Scanner(cfg, systemId, []platforms.ScanResult{}) if err != nil { return status.Files, err } log.Debug().Msgf("scanned %d files for system: %s", len(results), systemId) - log.Debug().Msgf("files: %v", results) status.Files += len(results) scanned[systemId] = true @@ -301,7 +302,41 @@ func NewNamesIndex( fis = append(fis, fileInfo{SystemId: systemId, Path: p.Path, Name: p.Name}) } log.Debug().Msgf("updating names for system: %s", systemId) - log.Debug().Msgf("files: %v", fis) + return updateNames(db, fis) + }) + } + } + } + + // launcher scanners with no system defined are run against every system + var anyScanners []platforms.Launcher + for _, l := range platform.Launchers() { + if l.SystemId == "" && l.Scanner != nil { + anyScanners = append(anyScanners, l) + } + } + + for _, l := range anyScanners { + for _, s := range systems { + log.Debug().Msgf("running %s scanner for system: %s", l.Id, s.Id) + results, err := l.Scanner(cfg, s.Id, []platforms.ScanResult{}) + if err != nil { + return status.Files, err + } + + log.Debug().Msgf("scanned %d files for system: %s", len(results), s.Id) + + if len(results) > 0 { + status.Files += len(results) + scanned[s.Id] = true + + systemId := s.Id + g.Go(func() error { + fis := make([]fileInfo, 0) + for _, p := range results { + fis = append(fis, fileInfo{SystemId: systemId, Path: p.Path, Name: p.Name}) + } + log.Debug().Msgf("updating names for system: %s", systemId) return updateNames(db, fis) }) } diff --git a/pkg/platforms/mister/platform.go b/pkg/platforms/mister/platform.go index ecaea672..88933d95 100644 --- a/pkg/platforms/mister/platform.go +++ b/pkg/platforms/mister/platform.go @@ -425,6 +425,7 @@ func (p *Platform) Launchers() []platforms.Launcher { Launch: launch, Scanner: func( cfg *config.UserConfig, + systemId string, results []platforms.ScanResult, ) ([]platforms.ScanResult, error) { log.Info().Msg("starting amigavision scan") @@ -481,6 +482,7 @@ func (p *Platform) Launchers() []platforms.Launcher { Launch: launch, Scanner: func( cfg *config.UserConfig, + systemId string, results []platforms.ScanResult, ) ([]platforms.ScanResult, error) { log.Info().Msg("starting neogeo scan") diff --git a/pkg/platforms/platforms.go b/pkg/platforms/platforms.go index 5c85fc24..3773fb9b 100644 --- a/pkg/platforms/platforms.go +++ b/pkg/platforms/platforms.go @@ -42,7 +42,7 @@ type Launcher struct { Launch func(*config.UserConfig, string) error // Optional function to perform custom media scanning. Takes the list of // results from the standard scan, if any, and returns the final list. - Scanner func(*config.UserConfig, []ScanResult) ([]ScanResult, error) + Scanner func(*config.UserConfig, string, []ScanResult) ([]ScanResult, error) // If true, all resolved paths must be in the allow list before they // can be launched. AllowListOnly bool diff --git a/pkg/platforms/windows/platform.go b/pkg/platforms/windows/platform.go index 8f004e72..120d2a76 100644 --- a/pkg/platforms/windows/platform.go +++ b/pkg/platforms/windows/platform.go @@ -1,13 +1,17 @@ package windows import ( + "encoding/xml" "errors" - "github.com/wizzomafizzo/tapto/pkg/api/models" + "fmt" + "io" "os" "os/exec" "path/filepath" "strings" + "github.com/wizzomafizzo/tapto/pkg/api/models" + "github.com/andygrunwald/vdf" "github.com/rs/zerolog/log" "github.com/wizzomafizzo/tapto/pkg/config" @@ -206,6 +210,227 @@ func (p *Platform) LookupMapping(_ tokens.Token) (string, bool) { return "", false } +var lbSysMap = map[string]string{ + //gamesdb.REPLACE: "3DO Interactive Multiplayer", + gamesdb.SystemAmiga: "Commodore Amiga", + gamesdb.SystemAmstrad: "Amstrad CPC", + //gamesdb.REPLACE: "Android", + gamesdb.SystemArcade: "Arcade", + gamesdb.SystemAtari2600: "Atari 2600", + gamesdb.SystemAtari5200: "Atari 5200", + gamesdb.SystemAtari7800: "Atari 7800", + //gamesdb.REPLACE: "Atari Jaguar", + //gamesdb.REPLACE: "Atari Jaguar CD", + gamesdb.SystemAtariLynx: "Atari Lynx", + //gamesdb.REPLACE: "Atari XEGS", + gamesdb.SystemColecoVision: "ColecoVision", + gamesdb.SystemC64: "Commodore 64", + gamesdb.SystemIntellivision: "Mattel Intellivision", + //gamesdb.REPLACE: "Apple iOS", + //gamesdb.REPLACE: "Apple Mac OS", + //gamesdb.REPLACE: "Microsoft Xbox", + //gamesdb.REPLACE: "Microsoft Xbox 360", + //gamesdb.REPLACE: "Microsoft Xbox One", + //gamesdb.REPLACE: "SNK Neo Geo Pocket", + //gamesdb.REPLACE: "SNK Neo Geo Pocket Color", + gamesdb.SystemNeoGeo: "SNK Neo Geo AES", + //gamesdb.REPLACE: "Nintendo 3DS", + gamesdb.SystemNintendo64: "Nintendo 64", + //gamesdb.REPLACE: "Nintendo DS", + gamesdb.SystemNES: "Nintendo Entertainment System", + gamesdb.SystemGameboy: "Nintendo Game Boy", + gamesdb.SystemGBA: "Nintendo Game Boy Advance", + gamesdb.SystemGameboyColor: "Nintendo Game Boy Color", + //gamesdb.REPLACE: "Nintendo GameCube", + //gamesdb.REPLACE: "Nintendo Virtual Boy", + //gamesdb.REPLACE: "Nintendo Wii", + //gamesdb.REPLACE: "Nintendo Wii U", + //gamesdb.REPLACE: "Ouya", + //gamesdb.REPLACE: "Philips CD-i", + gamesdb.SystemSega32X: "Sega 32X", + gamesdb.SystemMegaCD: "Sega CD", + //gamesdb.REPLACE: "Sega Dreamcast", + gamesdb.SystemGameGear: "Sega Game Gear", + gamesdb.SystemGenesis: "Sega Genesis", + gamesdb.SystemMasterSystem: "Sega Master System", + gamesdb.SystemSaturn: "Sega Saturn", + gamesdb.SystemZXSpectrum: "Sinclair ZX Spectrum", + gamesdb.SystemPSX: "Sony Playstation", + //gamesdb.REPLACE: "Sony Playstation 2", + //gamesdb.REPLACE: "Sony Playstation 3", + //gamesdb.REPLACE: "Sony Playstation 4", + //gamesdb.REPLACE: "Sony Playstation Vita", + //gamesdb.REPLACE: "Sony PSP", + gamesdb.SystemSNES: "Super Nintendo Entertainment System", + gamesdb.SystemTurboGrafx16: "NEC TurboGrafx-16", + gamesdb.SystemWonderSwan: "WonderSwan", + gamesdb.SystemWonderSwanColor: "WonderSwan Color", + gamesdb.SystemOdyssey2: "Magnavox Odyssey 2", + gamesdb.SystemChannelF: "Fairchild Channel F", + gamesdb.SystemBBCMicro: "BBC Microcomputer System", + //gamesdb.REPLACE: "Memotech MTX512", + //gamesdb.REPLACE: "Camputers Lynx", + //gamesdb.REPLACE: "Tiger Game.com", + gamesdb.SystemOric: "Oric Atmos", + gamesdb.SystemAcornElectron: "Acorn Electron", + //gamesdb.REPLACE: "Dragon 32/64", + gamesdb.SystemAdventureVision: "Entex Adventure Vision", + //gamesdb.REPLACE: "APF Imagination Machine", + gamesdb.SystemAquarius: "Mattel Aquarius", + gamesdb.SystemJupiter: "Jupiter Ace", + gamesdb.SystemSAMCoupe: "SAM Coupé", + //gamesdb.REPLACE: "Enterprise", + //gamesdb.REPLACE: "EACA EG2000 Colour Genie", + //gamesdb.REPLACE: "Acorn Archimedes", + //gamesdb.REPLACE: "Tapwave Zodiac", + //gamesdb.REPLACE: "Atari ST", + gamesdb.SystemAstrocade: "Bally Astrocade", + //gamesdb.REPLACE: "Magnavox Odyssey", + gamesdb.SystemArcadia: "Emerson Arcadia 2001", + gamesdb.SystemSG1000: "Sega SG-1000", + gamesdb.SystemSuperVision: "Epoch Super Cassette Vision", + gamesdb.SystemMSX: "Microsoft MSX", + gamesdb.SystemDOS: "MS-DOS", + gamesdb.SystemPC: "Windows", + //gamesdb.REPLACE: "Web Browser", + //gamesdb.REPLACE: "Sega Model 2", + //gamesdb.REPLACE: "Namco System 22", + //gamesdb.REPLACE: "Sega Model 3", + //gamesdb.REPLACE: "Sega System 32", + //gamesdb.REPLACE: "Sega System 16", + //gamesdb.REPLACE: "Sammy Atomiswave", + //gamesdb.REPLACE: "Sega Naomi", + //gamesdb.REPLACE: "Sega Naomi 2", + gamesdb.SystemAtari800: "Atari 800", + //gamesdb.REPLACE: "Sega Model 1", + //gamesdb.REPLACE: "Sega Pico", + gamesdb.SystemAcornAtom: "Acorn Atom", + //gamesdb.REPLACE: "Amstrad GX4000", + gamesdb.SystemAppleII: "Apple II", + //gamesdb.REPLACE: "Apple IIGS", + //gamesdb.REPLACE: "Casio Loopy", + gamesdb.SystemCasioPV1000: "Casio PV-1000", + //gamesdb.REPLACE: "Coleco ADAM", + //gamesdb.REPLACE: "Commodore 128", + //gamesdb.REPLACE: "Commodore Amiga CD32", + //gamesdb.REPLACE: "Commodore CDTV", + //gamesdb.REPLACE: "Commodore Plus 4", + //gamesdb.REPLACE: "Commodore VIC-20", + //gamesdb.REPLACE: "Fujitsu FM Towns Marty", + gamesdb.SystemVectrex: "GCE Vectrex", + //gamesdb.REPLACE: "Nuon", + gamesdb.SystemMegaDuck: "Mega Duck", + gamesdb.SystemX68000: "Sharp X68000", + gamesdb.SystemTRS80: "Tandy TRS-80", + //gamesdb.REPLACE: "Elektronika BK", + //gamesdb.REPLACE: "Epoch Game Pocket Computer", + //gamesdb.REPLACE: "Funtech Super Acan", + //gamesdb.REPLACE: "GamePark GP32", + //gamesdb.REPLACE: "Hartung Game Master", + //gamesdb.REPLACE: "Interton VC 4000", + //gamesdb.REPLACE: "MUGEN", + //gamesdb.REPLACE: "OpenBOR", + //gamesdb.REPLACE: "Philips VG 5000", + //gamesdb.REPLACE: "Philips Videopac+", + //gamesdb.REPLACE: "RCA Studio II", + //gamesdb.REPLACE: "ScummVM", + //gamesdb.REPLACE: "Sega Dreamcast VMU", + //gamesdb.REPLACE: "Sega SC-3000", + //gamesdb.REPLACE: "Sega ST-V", + //gamesdb.REPLACE: "Sinclair ZX-81", + gamesdb.SystemSordM5: "Sord M5", + gamesdb.SystemTI994A: "Texas Instruments TI 99/4A", + //gamesdb.REPLACE: "Pinball", + gamesdb.SystemCreatiVision: "VTech CreatiVision", + //gamesdb.REPLACE: "Watara Supervision", + //gamesdb.REPLACE: "WoW Action Max", + //gamesdb.REPLACE: "ZiNc", + gamesdb.SystemFDS: "Nintendo Famicom Disk System", + //gamesdb.REPLACE: "NEC PC-FX", + gamesdb.SystemSuperGrafx: "PC Engine SuperGrafx", + gamesdb.SystemTurboGrafx16CD: "NEC TurboGrafx-CD", + //gamesdb.REPLACE: "TRS-80 Color Computer", + gamesdb.SystemGameNWatch: "Nintendo Game & Watch", + gamesdb.SystemNeoGeoCD: "SNK Neo Geo CD", + //gamesdb.REPLACE: "Nintendo Satellaview", + //gamesdb.REPLACE: "Taito Type X", + //gamesdb.REPLACE: "XaviXPORT", + //gamesdb.REPLACE: "Mattel HyperScan", + //gamesdb.REPLACE: "Game Wave Family Entertainment System", + //gamesdb.SystemSega32X: "Sega CD 32X", + //gamesdb.REPLACE: "Aamber Pegasus", + //gamesdb.REPLACE: "Apogee BK-01", + //gamesdb.REPLACE: "Commodore MAX Machine", + //gamesdb.REPLACE: "Commodore PET", + //gamesdb.REPLACE: "Exelvision EXL 100", + //gamesdb.REPLACE: "Exidy Sorcerer", + //gamesdb.REPLACE: "Fujitsu FM-7", + //gamesdb.REPLACE: "Hector HRX", + //gamesdb.REPLACE: "Matra and Hachette Alice", + //gamesdb.REPLACE: "Microsoft MSX2", + //gamesdb.REPLACE: "Microsoft MSX2+", + //gamesdb.REPLACE: "NEC PC-8801", + //gamesdb.REPLACE: "NEC PC-9801", + //gamesdb.REPLACE: "Nintendo 64DD", + gamesdb.SystemPokemonMini: "Nintendo Pokemon Mini", + //gamesdb.REPLACE: "Othello Multivision", + //gamesdb.REPLACE: "VTech Socrates", + gamesdb.SystemVector06C: "Vector-06C", + gamesdb.SystemTomyTutor: "Tomy Tutor", + //gamesdb.REPLACE: "Spectravideo", + //gamesdb.REPLACE: "Sony PSP Minis", + //gamesdb.REPLACE: "Sony PocketStation", + //gamesdb.REPLACE: "Sharp X1", + //gamesdb.REPLACE: "Sharp MZ-2500", + //gamesdb.REPLACE: "Sega Triforce", + //gamesdb.REPLACE: "Sega Hikaru", + //gamesdb.SystemNeoGeo: "SNK Neo Geo MVS", + //gamesdb.REPLACE: "Nintendo Switch", + //gamesdb.REPLACE: "Windows 3.X", + //gamesdb.REPLACE: "Nokia N-Gage", + //gamesdb.REPLACE: "GameWave", + //gamesdb.REPLACE: "Linux", + //gamesdb.REPLACE: "Sony Playstation 5", + //gamesdb.REPLACE: "PICO-8", + //gamesdb.REPLACE: "VTech V.Smile", + //gamesdb.REPLACE: "Microsoft Xbox Series X/S", +} + +type LaunchBox struct { + Games []LaunchBoxGame `xml:"Game"` +} + +type LaunchBoxGame struct { + Title string `xml:"Title"` + ID string `xml:"ID"` +} + +func findLaunchBoxDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + + dirs := []string{ + filepath.Join(home, "LaunchBox"), + filepath.Join(home, "Documents", "LaunchBox"), + filepath.Join(home, "My Games", "LaunchBox"), + "C:\\Program Files (x86)\\LaunchBox", + "C:\\Program Files\\LaunchBox", + "C:\\LaunchBox", + "D:\\LaunchBox", + "E:\\LaunchBox", + } + + for _, dir := range dirs { + if _, err := os.Stat(dir); err == nil { + return dir, nil + } + } + + return "", fmt.Errorf("launchbox directory not found") +} + func (p *Platform) Launchers() []platforms.Launcher { return []platforms.Launcher{ { @@ -214,6 +439,7 @@ func (p *Platform) Launchers() []platforms.Launcher { Schemes: []string{"steam"}, Scanner: func( cfg *config.UserConfig, + systemId string, results []platforms.ScanResult, ) ([]platforms.ScanResult, error) { // TODO: detect this path from registry @@ -295,11 +521,86 @@ func (p *Platform) Launchers() []platforms.Launcher { }, { Id: "Generic", - Extensions: []string{".exe", ".bat", ".cmd", ".lnk"}, + Extensions: []string{".exe", ".bat", ".cmd", ".lnk", ".a3x"}, AllowListOnly: true, Launch: func(cfg *config.UserConfig, path string) error { return exec.Command("cmd", "/c", path).Start() }, }, + { + Id: "LaunchBox", + Schemes: []string{"launchbox"}, + Scanner: func( + cfg *config.UserConfig, + systemId string, + results []platforms.ScanResult, + ) ([]platforms.ScanResult, error) { + lbSys, ok := lbSysMap[systemId] + if !ok { + return results, nil + } + + lbDir, err := findLaunchBoxDir() + if err != nil { + return results, err + } + + platformsDir := filepath.Join(lbDir, "Data", "Platforms") + if _, err := os.Stat(lbDir); os.IsNotExist(err) { + return results, errors.New("LaunchBox platforms dir not found") + } + + xmlPath := filepath.Join(platformsDir, lbSys+".xml") + if _, err := os.Stat(xmlPath); os.IsNotExist(err) { + log.Debug().Msgf("LaunchBox platform xml not found: %s", xmlPath) + return results, nil + } + + xmlFile, err := os.Open(xmlPath) + if err != nil { + return results, err + } + defer func(xmlFile *os.File) { + err := xmlFile.Close() + if err != nil { + log.Warn().Err(err).Msg("error closing xml file") + } + }(xmlFile) + + data, err := io.ReadAll(xmlFile) + if err != nil { + return results, err + } + + var lbXml LaunchBox + err = xml.Unmarshal(data, &lbXml) + if err != nil { + return results, err + } + + for _, game := range lbXml.Games { + results = append(results, platforms.ScanResult{ + Path: "launchbox://" + game.ID, + Name: game.Title, + }) + } + + return results, nil + }, + Launch: func(cfg *config.UserConfig, path string) error { + lbDir, err := findLaunchBoxDir() + if err != nil { + return err + } + + cliLauncher := filepath.Join(lbDir, "ThirdParty", "CLI_Launcher", "CLI_Launcher.exe") + if _, err := os.Stat(cliLauncher); os.IsNotExist(err) { + return errors.New("CLI_Launcher not found") + } + + id := strings.TrimPrefix(path, "launchbox://") + return exec.Command(cliLauncher, "launch_by_id", id).Start() + }, + }, } }