diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e72fc180..362872f3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -140,12 +140,13 @@ jobs: env: GROUT_VERSION: ${{ needs.prepare.outputs.version }} - - name: Package Batocera AMD64 - run: task package:batocera-amd64 + - name: Package AMD64 platforms + run: task package:batocera-amd64 package:retrodeck - name: Create distribution run: | cd dist/Batocera-amd64 && zip -r ../Grout-Batocera-amd64.zip Grout.sh Grout + cd dist/RetroDECK && zip -r ../Grout-RetroDECK.zip Grout.sh Grout - name: Upload artifacts uses: actions/upload-artifact@v4 @@ -153,6 +154,7 @@ jobs: name: amd64-artifacts path: | dist/Grout-Batocera-amd64.zip + dist/Grout-RetroDECK.zip build-x86: needs: prepare @@ -306,6 +308,7 @@ jobs: **/Grout-Batocera-arm64.zip **/Grout-Batocera-x86.zip **/Grout-Batocera-amd64.zip + **/Grout-RetroDECK.zip **/grout draft: false prerelease: ${{ inputs.beta }} @@ -383,6 +386,7 @@ jobs: "Grout-Batocera-arm64.zip" "Grout-Batocera-amd64.zip" "Grout-Batocera-x86.zip" + "Grout-RetroDECK.zip" ) # Build the assets JSON object diff --git a/cache/manager.go b/cache/manager.go index b0eb81b3..bd4b6f16 100644 --- a/cache/manager.go +++ b/cache/manager.go @@ -2,6 +2,7 @@ package cache import ( "database/sql" + "grout/internal/appdir" "grout/internal/fileutil" "grout/romm" "os" @@ -446,27 +447,15 @@ func (cm *Manager) SyncPlatformGames(platforms []romm.Platform) (int, error) { } func getCacheDBPath() string { - wd, err := os.Getwd() - if err != nil { - return filepath.Join(os.TempDir(), ".cache", "grout.db") - } - return filepath.Join(wd, ".cache", "grout.db") + return filepath.Join(appdir.CacheDir(), "grout.db") } func GetArtworkCacheDir() string { - wd, err := os.Getwd() - if err != nil { - return filepath.Join(os.TempDir(), ".cache", "artwork") - } - return filepath.Join(wd, ".cache", "artwork") + return filepath.Join(appdir.CacheDir(), "artwork") } func GetCacheDir() string { - wd, err := os.Getwd() - if err != nil { - return filepath.Join(os.TempDir(), ".cache") - } - return filepath.Join(wd, ".cache") + return appdir.CacheDir() } // DeleteCacheFolder removes the entire cache directory and resets the singleton diff --git a/cfw/cfw.go b/cfw/cfw.go index b45cde6e..f1b58e99 100644 --- a/cfw/cfw.go +++ b/cfw/cfw.go @@ -9,17 +9,18 @@ import ( type CFW string const ( - NextUI CFW = "NEXTUI" - MuOS CFW = "MUOS" - Knulli CFW = "KNULLI" - Spruce CFW = "SPRUCE" - ROCKNIX CFW = "ROCKNIX" - Trimui CFW = "TRIMUI" - Allium CFW = "ALLIUM" - Onion CFW = "ONION" - Koriki CFW = "KORIKI" - Batocera CFW = "BATOCERA" - MinUI CFW = "MINUI" + NextUI CFW = "NEXTUI" + MuOS CFW = "MUOS" + Knulli CFW = "KNULLI" + Spruce CFW = "SPRUCE" + ROCKNIX CFW = "ROCKNIX" + Trimui CFW = "TRIMUI" + Allium CFW = "ALLIUM" + Onion CFW = "ONION" + Koriki CFW = "KORIKI" + Batocera CFW = "BATOCERA" + MinUI CFW = "MINUI" + RetroDECK CFW = "RETRODECK" ) func GetCFW() CFW { @@ -27,18 +28,18 @@ func GetCFW() CFW { cfw := CFW(cfwEnv) switch cfw { - case MuOS, NextUI, Knulli, Spruce, ROCKNIX, Trimui, Allium, Onion, Koriki, Batocera, MinUI: + case MuOS, NextUI, Knulli, Spruce, ROCKNIX, Trimui, Allium, Onion, Koriki, Batocera, MinUI, RetroDECK: return cfw default: log.SetOutput(os.Stderr) - log.Fatalf("Unsupported CFW: '%s'. Valid options: NextUI, muOS, Knulli, Spruce, ROCKNIX, Trimui, Allium, Onion, Koriki, Batocera, MinUI", cfwEnv) + log.Fatalf("Unsupported CFW: '%s'. Valid options: NextUI, muOS, Knulli, Spruce, ROCKNIX, Trimui, Allium, Onion, Koriki, Batocera, MinUI, RetroDECK", cfwEnv) return "" } } func (c CFW) IsBasedOnEmulationStation() bool { switch c { - case Knulli, ROCKNIX, Batocera: + case Knulli, ROCKNIX, Batocera, RetroDECK: return true default: return false diff --git a/cfw/directories.go b/cfw/directories.go index 5d648670..7a9b7e34 100644 --- a/cfw/directories.go +++ b/cfw/directories.go @@ -9,6 +9,7 @@ import ( "grout/cfw/muos" "grout/cfw/nextui" "grout/cfw/onion" + "grout/cfw/retrodeck" "grout/cfw/rocknix" "grout/cfw/spruce" "grout/cfw/trimui" @@ -40,6 +41,8 @@ func GetRomDirectory() string { return batocera.GetRomDirectory() case MinUI: return minui.GetRomDirectory() + case RetroDECK: + return retrodeck.GetRomDirectory() } return "" } @@ -81,6 +84,8 @@ func GetBIOSDirectory() string { return batocera.GetBIOSDirectory() case MinUI: return minui.GetBIOSDirectory() + case RetroDECK: + return retrodeck.GetBIOSDirectory() } return "" } @@ -132,6 +137,8 @@ func GetArtDirectory(romDir string, platformFSSlug, platformName string) string return batocera.GetArtDirectory(romDir) case MinUI: return minui.GetArtDirectory(romDir) + case RetroDECK: + return retrodeck.GetArtDirectory(romDir) default: return "" } @@ -180,6 +187,8 @@ func BaseSavePath() string { return batocera.GetBaseSavePath() case MinUI: return minui.GetBaseSavePath() + case RetroDECK: + return retrodeck.GetBaseSavePath() } return "" } @@ -192,6 +201,8 @@ func GetArtMarqueeDirectory(romDir string, platformFSSlug, platformName string) return knulli.GetArtDirectory(romDir) case Batocera: return batocera.GetArtDirectory(romDir) + case RetroDECK: + return retrodeck.GetArtDirectory(romDir) default: return "" } @@ -205,6 +216,8 @@ func GetArtVideoDirectory(romDir string, platformFSSlug, platformName string) st return knulli.GetVideoDirectory(romDir) case Batocera: return batocera.GetVideoDirectory(romDir) + case RetroDECK: + return retrodeck.GetVideoDirectory(romDir) default: return "" @@ -219,6 +232,8 @@ func GetArtThumbnailDirectory(romDir string, platformFSSlug, platformName string return knulli.GetArtDirectory(romDir) case Batocera: return knulli.GetArtDirectory(romDir) + case RetroDECK: + return retrodeck.GetArtDirectory(romDir) default: return "" } @@ -232,6 +247,8 @@ func GetArtBezelDirectory(romDir string, platformFSSlug, platformName string) st return knulli.GetBezelDirectory(romDir) case Batocera: return batocera.GetBezelDirectory(romDir) + case RetroDECK: + return retrodeck.GetBezelDirectory(romDir) default: return "" } @@ -245,6 +262,8 @@ func GetManualDirectory(romDir string, platformFSSlug, platformName string) stri return knulli.GetManualDirectory(romDir) case Batocera: return batocera.GetManualDirectory(romDir) + case RetroDECK: + return retrodeck.GetManualDirectory(romDir) default: return "" } @@ -258,6 +277,8 @@ func GetBoxbackDirectory(romDir string, platformFSSlug, platformName string) str return knulli.GetArtDirectory(romDir) case Batocera: return batocera.GetArtDirectory(romDir) + case RetroDECK: + return retrodeck.GetArtDirectory(romDir) default: return "" } @@ -271,6 +292,8 @@ func GetFanartDirectory(romDir string, platformFSSlug, platformName string) stri return knulli.GetArtDirectory(romDir) case Batocera: return batocera.GetArtDirectory(romDir) + case RetroDECK: + return retrodeck.GetArtDirectory(romDir) default: return "" } diff --git a/cfw/metadata.go b/cfw/metadata.go index de344ca1..3ccd5388 100644 --- a/cfw/metadata.go +++ b/cfw/metadata.go @@ -4,6 +4,7 @@ import ( "grout/cfw/batocera" "grout/cfw/knulli" "grout/cfw/muos" + "grout/cfw/retrodeck" "grout/cfw/rocknix" "grout/internal/emulationstation" "grout/internal/gamelist" @@ -36,12 +37,21 @@ func FillGamesMetadata(entries []gamelist.RomGameEntry) { logger := gaba.GetLogger() switch GetCFW() { case Knulli, ROCKNIX, Batocera: - if err := gamelist.AddRomGamesToGamelist(entries, gamelist.GameListFileName); err != nil { + if err := gamelist.AddRomGamesToGamelist(entries, gamelist.GameListFileName, nil); err != nil { logger.Warn("Failed to add games to ES gamelist.xml", "error", err) } scheduleESRestart() + case RetroDECK: + options := &gamelist.AddRomGamesToGamelistOptions{ + PathResolver: func(entry gamelist.RomGameEntry, filename gamelist.FileName) string { + return retrodeck.GetGamelistPath(entry.RomDirectory, string(filename)) + }, + } + if err := gamelist.AddRomGamesToGamelist(entries, gamelist.GameListFileName, options); err != nil { + logger.Warn("Failed to add games to ES gamelist.xml", "error", err) + } case Spruce, Allium, Onion, Koriki: - if err := gamelist.AddRomGamesToGamelist(entries, gamelist.MiyooGameListFileName); err != nil { + if err := gamelist.AddRomGamesToGamelist(entries, gamelist.MiyooGameListFileName, nil); err != nil { logger.Warn("Failed to add games to miyoogamelist.xml", "error", err) } case MuOS: diff --git a/cfw/platforms.go b/cfw/platforms.go index 82a5b41c..d9e842c7 100644 --- a/cfw/platforms.go +++ b/cfw/platforms.go @@ -9,6 +9,7 @@ import ( "grout/cfw/muos" "grout/cfw/nextui" "grout/cfw/onion" + "grout/cfw/retrodeck" "grout/cfw/rocknix" "grout/cfw/spruce" "grout/cfw/trimui" @@ -33,6 +34,7 @@ func buildPlatformAliasMap() map[string][]string { koriki.Platforms, batocera.Platforms, minui.Platforms, + retrodeck.Platforms, } // Build reverse map: primary folder -> list of RomM slugs that use it as primary @@ -138,6 +140,8 @@ func GetPlatformMap(c CFW) map[string][]string { return batocera.Platforms case MinUI: return minui.Platforms + case RetroDECK: + return retrodeck.Platforms default: return nil } diff --git a/cfw/retrodeck/config.go b/cfw/retrodeck/config.go new file mode 100644 index 00000000..c957bf58 --- /dev/null +++ b/cfw/retrodeck/config.go @@ -0,0 +1,47 @@ +package retrodeck + +import ( + "encoding/json" + "fmt" + "os" +) + +const configPathEnv = "RETRODECK_CFG" + +// Paths holds the subset of RetroDECK path configuration relevant to Grout. +type Paths struct { + RDHomePath string `json:"rd_home_path"` + RomsPath string `json:"roms_path"` + SavesPath string `json:"saves_path"` + BiosPath string `json:"bios_path"` + DownloadedMediaPath string `json:"downloaded_media_path"` + VideosPath string `json:"videos_path"` +} + +type retrodeckConfig struct { + Paths Paths `json:"paths"` +} + +// LoadConfig reads the RetroDECK config file from the path set in RETRODECK_CFG. +func LoadConfig() (*Paths, error) { + cfgPath := os.Getenv(configPathEnv) + if cfgPath == "" { + return nil, fmt.Errorf("%s environment variable not set", configPathEnv) + } + return ParseConfig(cfgPath) +} + +// ParseConfig reads and parses the RetroDECK config file at the given path. +func ParseConfig(path string) (*Paths, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading retrodeck config: %w", err) + } + + var cfg retrodeckConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parsing retrodeck config: %w", err) + } + + return &cfg.Paths, nil +} diff --git a/cfw/retrodeck/config_test.go b/cfw/retrodeck/config_test.go new file mode 100644 index 00000000..8e92c746 --- /dev/null +++ b/cfw/retrodeck/config_test.go @@ -0,0 +1,113 @@ +package retrodeck + +import ( + "os" + "testing" +) + +const testConfigJSON = `{ + "version": "0.10.8b", + "paths": { + "rd_home_path": "/home/user/retrodeck", + "roms_path": "/home/user/retrodeck/roms", + "saves_path": "/home/user/retrodeck/saves", + "bios_path": "/home/user/retrodeck/bios", + "downloaded_media_path": "/home/user/retrodeck/ES-DE/downloaded_media", + "videos_path": "/home/user/retrodeck/videos", + "states_path": "/home/user/retrodeck/states" + }, + "options": { + "cloud_saves": "false" + } +}` + +func writeTempConfig(t *testing.T, content string) string { + t.Helper() + f, err := os.CreateTemp("", "retrodeck-*.json") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Remove(f.Name()) }) + if _, err := f.WriteString(content); err != nil { + t.Fatal(err) + } + f.Close() + return f.Name() +} + +func TestParseConfig(t *testing.T) { + path := writeTempConfig(t, testConfigJSON) + + paths, err := ParseConfig(path) + if err != nil { + t.Fatalf("ParseConfig failed: %v", err) + } + + tests := []struct { + field string + got string + want string + }{ + {"RDHomePath", paths.RDHomePath, "/home/user/retrodeck"}, + {"RomsPath", paths.RomsPath, "/home/user/retrodeck/roms"}, + {"SavesPath", paths.SavesPath, "/home/user/retrodeck/saves"}, + {"BiosPath", paths.BiosPath, "/home/user/retrodeck/bios"}, + {"DownloadedMediaPath", paths.DownloadedMediaPath, "/home/user/retrodeck/ES-DE/downloaded_media"}, + {"VideosPath", paths.VideosPath, "/home/user/retrodeck/videos"}, + } + + for _, tt := range tests { + if tt.got != tt.want { + t.Errorf("%s = %q, want %q", tt.field, tt.got, tt.want) + } + } +} + +func TestParseConfig_IgnoresUnknownFields(t *testing.T) { + path := writeTempConfig(t, testConfigJSON) + + // states_path is in the JSON but not in Paths — must not error + _, err := ParseConfig(path) + if err != nil { + t.Fatalf("ParseConfig should tolerate unknown fields: %v", err) + } +} + +func TestParseConfig_FileNotFound(t *testing.T) { + _, err := ParseConfig("/nonexistent/path/retrodeck.json") + if err == nil { + t.Fatal("expected error for missing file, got nil") + } +} + +func TestParseConfig_InvalidJSON(t *testing.T) { + path := writeTempConfig(t, `{ not valid json `) + + _, err := ParseConfig(path) + if err == nil { + t.Fatal("expected error for invalid JSON, got nil") + } +} + +func TestLoadConfig(t *testing.T) { + path := writeTempConfig(t, testConfigJSON) + t.Setenv(configPathEnv, path) + + paths, err := LoadConfig() + if err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + + if paths.RomsPath != "/home/user/retrodeck/roms" { + t.Errorf("RomsPath = %q, want %q", paths.RomsPath, "/home/user/retrodeck/roms") + } +} + +func TestLoadConfig_EnvVarNotSet(t *testing.T) { + t.Setenv(configPathEnv, "") + + _, err := LoadConfig() + if err == nil { + t.Fatal("expected error when env var not set, got nil") + } +} diff --git a/cfw/retrodeck/data/platforms.json b/cfw/retrodeck/data/platforms.json new file mode 100644 index 00000000..06dffdc4 --- /dev/null +++ b/cfw/retrodeck/data/platforms.json @@ -0,0 +1,407 @@ +{ + "3do": [ + "3do" + ], + "3ds": [ + "n3ds" + ], + "64dd": [ + "n64dd" + ], + "acorn-archimedes": [ + "archimedes" + ], + "acorn-electron": [ + "electron" + ], + "acpc": [ + "amstradcpc" + ], + "amiga": [ + "amiga" + ], + "amiga-cd32": [ + "amigacd32" + ], + "amstrad-gx4000": [ + "gx4000" + ], + "apple-iigs": [ + "apple2gs" + ], + "appleii": [ + "apple2" + ], + "arcade": [ + "arcade" + ], + "arcadia-2001": [ + "arcadia" + ], + "arduboy": [ + "arduboy" + ], + "astrocade": [ + "astrocde" + ], + "atari-st": [ + "atarist" + ], + "atari2600": [ + "atari2600" + ], + "atari5200": [ + "atari5200" + ], + "atari7800": [ + "atari7800" + ], + "atari800": [ + "atari800" + ], + "atari8bit": [ + "atari8bit" + ], + "atari-xegs": [ + "atarixe" + ], + "bbcmicro": [ + "bbcmicro" + ], + "c-plus-4": [ + "plus4" + ], + "c16": [ + "plus4" + ], + "c64": [ + "c64" + ], + "casio-pv-1000": [ + "pv1000" + ], + "colecoadam": [ + "adam" + ], + "colecovision": [ + "colecovision" + ], + "commodore-cdtv": [ + "cdtv" + ], + "creativision": [ + "crvision" + ], + "dc": [ + "dreamcast" + ], + "dos": [ + "dos" + ], + "dragon-32-slash-64": [ + "dragon32" + ], + "epoch-super-cassette-vision": [ + "scv" + ], + "fairchild-channel-f": [ + "channelf" + ], + "famicom": [ + "famicom" + ], + "fds": [ + "fds" + ], + "fm-7": [ + "fm7" + ], + "fm-towns": [ + "fmtowns" + ], + "g-and-w": [ + "gameandwatch" + ], + "gamate": [ + "gamate" + ], + "game-dot-com": [ + "gamecom" + ], + "gamegear": [ + "gamegear" + ], + "gb": [ + "gb" + ], + "gba": [ + "gba" + ], + "gbc": [ + "gbc" + ], + "genesis": [ + "genesis", + "megadrive", + "megadrivejp" + ], + "hartung": [ + "gmaster" + ], + "intellivision": [ + "intellivision" + ], + "j2me": [ + "j2me" + ], + "jaguar": [ + "atarijaguar" + ], + "lynx": [ + "atarilynx" + ], + "mac": [ + "macintosh" + ], + "mega-duck-slash-cougar-boy": [ + "megaduck" + ], + "model2": [ + "model2" + ], + "model3": [ + "model2" + ], + "msx": [ + "msx" + ], + "msx-turbo": [ + "msxturbor" + ], + "msx2": [ + "msx2" + ], + "msx2plus": [ + "msx2plus" + ], + "mugen": [ + "mugen" + ], + "multivision": [ + "multivision" + ], + "n64": [ + "n64" + ], + "nds": [ + "nds" + ], + "neo-geo-cd": [ + "neogeocd", + "neogeocdjp" + ], + "neo-geo-pocket": [ + "ngp" + ], + "neo-geo-pocket-color": [ + "ngpc" + ], + "neogeoaes": [ + "neogeo" + ], + "neogeomvs": [ + "neogeo" + ], + "nes": [ + "nes" + ], + "new-nintendo-3ds": [ + "n3ds" + ], + "ngc": [ + "gc" + ], + "odyssey-2": [ + "odyssey2" + ], + "openbor": [ + "openbor" + ], + "oric": [ + "oric" + ], + "palm-os": [ + "palm" + ], + "pc-8800-series": [ + "pc88" + ], + "pc-9800-series": [ + "pc98" + ], + "pc-booter": [ + "pc" + ], + "pc-engine": [ + "pcengine", + "tg16" + ], + "pc-fx": [ + "pcfx" + ], + "philips-cd-i": [ + "cdimono1" + ], + "pico": [ + "pico8" + ], + "pokemon-mini": [ + "pokemini" + ], + "ps2": [ + "ps2" + ], + "ps3": [ + "ps3" + ], + "psp": [ + "psp" + ], + "psvita": [ + "psvita" + ], + "psx": [ + "psx" + ], + "satellaview": [ + "satellaview" + ], + "saturn": [ + "saturn", + "saturnjp" + ], + "scummvm": [ + "scummvm" + ], + "sega32": [ + "sega32x", + "sega32xjp", + "sega32xna" + ], + "segacd": [ + "segacd" + ], + "sfam": [ + "sfc" + ], + "sg1000": [ + "sg-1000" + ], + "sharp-x68000": [ + "x68000" + ], + "sms": [ + "mastersystem", + "mark3" + ], + "snes": [ + "snes", + "snesna" + ], + "spectravideo": [ + "spectravideo" + ], + "stv": [ + "stv" + ], + "sufami-turbo": [ + "sufami" + ], + "super-acan": [ + "supracan" + ], + "supergrafx": [ + "supergrafx" + ], + "supervision": [ + "supervision" + ], + "switch": [ + "switch" + ], + "tg16": [ + "tg16", + "pcengine" + ], + "thomson-mo5": [ + "moto" + ], + "thomson-to": [ + "moto", + "to8" + ], + "ti-99": [ + "ti99" + ], + "ti-994a": [ + "ti99" + ], + "to8": [ + "to8" + ], + "turbografx-cd": [ + "pcenginecd", + "tg-cd" + ], + "uzebox": [ + "uzebox" + ], + "vectrex": [ + "vectrex" + ], + "vic-20": [ + "vic20" + ], + "virtualboy": [ + "virtualboy" + ], + "vsmile": [ + "vsmile" + ], + "wasm-4": [ + "wasm4" + ], + "wii": [ + "wii" + ], + "wiiu": [ + "wiiu" + ], + "win": [ + "windows9x" + ], + "win3x": [ + "windows3x" + ], + "wonderswan": [ + "wonderswan" + ], + "wonderswan-color": [ + "wonderswancolor" + ], + "x1": [ + "x1" + ], + "xbox": [ + "xbox" + ], + "z-machine": [ + "zmachine" + ], + "zx81": [ + "zx81" + ], + "zxs": [ + "zxspectrum" + ] +} diff --git a/cfw/retrodeck/retrodeck.go b/cfw/retrodeck/retrodeck.go new file mode 100644 index 00000000..4cc88d74 --- /dev/null +++ b/cfw/retrodeck/retrodeck.go @@ -0,0 +1,113 @@ +package retrodeck + +import ( + "embed" + "grout/internal/jsonutil" + "os" + "path/filepath" + "sync" + + gaba "github.com/BrandonKowalski/gabagool/v2/pkg/gabagool" +) + +//go:embed data/*.json +var embeddedFiles embed.FS + +var ( + Platforms = jsonutil.MustLoadJSONMap[string, []string](embeddedFiles, "data/platforms.json") + + configPathsOnce sync.Once + configPaths *Paths +) + +func GetBasePath() string { + if basePath := os.Getenv("BASE_PATH"); basePath != "" { + return basePath + } + + homeInstall := "/home/deck/retrodeck" + if _, err := os.Stat(homeInstall); err == nil { + return homeInstall + } + + sdcardInstall := "/run/media/mmcblk0p1/retrodeck" + if _, err := os.Stat(sdcardInstall); err == nil { + return sdcardInstall + } + + if homePath := os.Getenv("HOME"); homePath != "" { + customPath := filepath.Join(homePath, "retrodeck") + if _, err := os.Stat(customPath); err == nil { + return customPath + } + } + + return "/retrodeck" +} + +func GetConfigPaths() *Paths { + configPathsOnce.Do(func() { + paths, err := LoadConfig() + if err != nil { + gaba.GetLogger().Error("Failed to load RetroDECK config", "error", err) + return + } + configPaths = paths + }) + return configPaths +} + +func GetRomDirectory() string { + if paths := GetConfigPaths(); paths != nil { + return paths.RomsPath + } + return filepath.Join(GetBasePath(), "roms") +} + +func GetBIOSDirectory() string { + if paths := GetConfigPaths(); paths != nil { + return paths.BiosPath + } + return filepath.Join(GetBasePath(), "bios") +} + +func GetBaseSavePath() string { + if paths := GetConfigPaths(); paths != nil { + return paths.SavesPath + } + return filepath.Join(GetBasePath(), "saves") +} + +func GetArtDirectory(romDir string) string { + if paths := GetConfigPaths(); paths != nil { + return paths.DownloadedMediaPath + } + return filepath.Join(romDir, "images") +} + +func GetVideoDirectory(romDir string) string { + return filepath.Join(romDir, "videos") +} + +func GetBezelDirectory(romDir string) string { + return filepath.Join(romDir, "bezels") +} + +func GetManualDirectory(romDir string) string { + return filepath.Join(romDir, "manuals") +} + +func GetGamelistDirectory() string { + base := os.Getenv("XDG_CONFIG_HOME") + if base == "" { + if home := os.Getenv("HOME"); home != "" { + base = filepath.Join(home, ".config") + } + } + return filepath.Join(base, "ES-DE", "gamelists") +} + +func GetGamelistPath(romDir, filename string) string { + system := filepath.Base(romDir) + return filepath.Join(GetGamelistDirectory(), system, filename) +} diff --git a/cfw/saves.go b/cfw/saves.go index 71ecce50..89ca905e 100644 --- a/cfw/saves.go +++ b/cfw/saves.go @@ -9,6 +9,7 @@ import ( "grout/cfw/muos" "grout/cfw/nextui" "grout/cfw/onion" + "grout/cfw/retrodeck" "grout/cfw/rocknix" "grout/cfw/spruce" "grout/cfw/trimui" @@ -40,6 +41,8 @@ func EmulatorFolderMap(c CFW) map[string][]string { return batocera.Platforms case MinUI: return minui.SaveDirectories + case RetroDECK: + return retrodeck.Platforms default: return nil } diff --git a/docs/_includes/cfw-links.md b/docs/_includes/cfw-links.md index 1fbdf27a..2ec13cbe 100644 --- a/docs/_includes/cfw-links.md +++ b/docs/_includes/cfw-links.md @@ -11,3 +11,4 @@ [sprigui]: https://github.com/spruceUI/sprigUI [twigui]: https://github.com/spruceUI/twigUI [trimui]: https://github.com/trimui +[retrodeck]: https://retrodeck.net diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md index 3751d966..733644f2 100644 --- a/docs/getting-started/index.md +++ b/docs/getting-started/index.md @@ -27,6 +27,7 @@ Choose your platform: - [ROCKNIX Installation](install-rocknix.md) - [Spruce Installation](install-spruce.md) - [TrimUI Installation](install-trimui.md) +- [RetroDECK Installation](install-retrodeck.md) ### Step 2: Launch and Select Language diff --git a/docs/getting-started/install-retrodeck.md b/docs/getting-started/install-retrodeck.md new file mode 100644 index 00000000..361b5446 --- /dev/null +++ b/docs/getting-started/install-retrodeck.md @@ -0,0 +1,67 @@ +# Installation Guide for RetroDECK + +This guide will help you install Grout on devices running [RetroDECK][retrodeck]. + +## Tested Devices + +Grout has been tested on the following devices having RetroDECK installed: + +| Manufacturer | Device | OS | +|--------------|----------|---------| +| Asus | ROG Ally | Bazzite | + +_Please help verify compatibility on other devices by reporting your results!_ + +## Installation Steps + +### Automatic (Recommended) + +Here is an all-in-one install script that will install Grout and add it as a non-Steam game. + +```bash +curl -o- https://raw.githubusercontent.com/rommapp/grout/refs/heads/main/scripts/RetroDECK/install.sh | bash +``` + +```bash +wget -qO- https://raw.githubusercontent.com/rommapp/grout/refs/heads/main/scripts/RetroDECK/install.sh | bash +``` + +### Manual + +1. Ensure your device has RetroDECK installed. +2. Download the [latest Grout release](https://github.com/rommapp/grout/releases/latest/download/Grout-RetroDECK.zip) for + RetroDECK. +3. Unzip the downloaded archive. +4. Copy the `Grout` folder to your home directory (`/home/deck/grout/`) +5. Copy the `Grout.sh` file to the same Ports directory (`/home/deck/grout/Grout.sh`) +6. Open Steam and add Grout as a non-Steam game: + - Target: `env` + - Start In: `/home/deck/grout/` + - Launch options: `/home/deck/grout/Grout.sh` + - You'll find game media in `/home/deck/grout/Grout/media/` +7. Launch Grout from Steam and enjoy! + +## Update + +### In-App update (Recommended) + +Grout has a built-in update mechanism. To update Grout, launch the application and navigate to the `Settings` menu. From there, +select `Check for Updates`. If a new version is available, follow the on-screen prompts to download and install the update. + +### Manual update + +To update Grout, simply download the latest release and replace the existing Grout folder in your home directory (`/home/deck/grout/`). If you +have made any custom configurations, ensure to back them up before replacing the folder. Be sure to keep the `config.json` +file if you do not want to authenticate again, and configure platforms folder mappings again. + +## Additional Notes + +- Grout doesn't currently support custom RetroDECK install location: you must have selected either the internal or the SD card install. +- It seems like Grout doesn't currently play well with Steam Input, ensure it is disabled or you're using an external controller. +- Given the above and if you're running Bazzite, you may need to configure your handheld so it's seen as an Xbox controller. + +## Next Steps + +After installation is complete, check out the [User Guide](../usage/guide.md) to learn how to use Grout. + +--8<-- "docs/_includes/cfw-links.md" diff --git a/docs/index.md b/docs/index.md index 4be37f8f..678a3df5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -13,7 +13,7 @@ hide: Download and manage games from your [RomM](https://romm.app) instance directly on your Linux based retro handheld. -[Allium](getting-started/install-allium.md) · [Batocera](getting-started/install-batocera.md) · [Knulli](getting-started/install-knulli.md) · [MinUI](getting-started/install-minui.md) · [muOS](getting-started/install-muos.md) · [NextUI](getting-started/install-nextui.md) · [Onion](getting-started/install-onion.md) · [ROCKNIX](getting-started/install-rocknix.md) · [Spruce](getting-started/install-spruce.md) · [TrimUI](getting-started/install-trimui.md) +[Allium](getting-started/install-allium.md) · [Batocera](getting-started/install-batocera.md) · [Knulli](getting-started/install-knulli.md) · [MinUI](getting-started/install-minui.md) · [muOS](getting-started/install-muos.md) · [NextUI](getting-started/install-nextui.md) · [Onion](getting-started/install-onion.md) · [ROCKNIX](getting-started/install-rocknix.md) · [Spruce](getting-started/install-spruce.md) · [TrimUI](getting-started/install-trimui.md) · [RetroDECK](getting-started/install-retrodeck.md) [:fontawesome-solid-gamepad: Get Started](getting-started/index.md){ .md-button .md-button--primary }    diff --git a/docs/platforms/RETRODECK.md b/docs/platforms/RETRODECK.md new file mode 100644 index 00000000..5bc7c8ad --- /dev/null +++ b/docs/platforms/RETRODECK.md @@ -0,0 +1,132 @@ +# RetroDECK Platform Mappings + +This table shows the mappings of RomM Fs Slug to RetroDECK's platform folders. + +| Platform Name | RomM Fs Slug | Folder(s) | +|----------------------------------|-----------------------------|---------------------------------| +| 3DO Interactive Multiplayer | 3do | 3do | +| Acorn Archimedes | acorn-archimedes | archimedes | +| Acorn Computers BBC Micro | bbcmicro | bbcmicro | +| Acorn Electron | acorn-electron | electron | +| Amstrad CPC | acpc | amstradcpc | +| Amstrad GX4000 | amstrad-gx4000 | gx4000 | +| Apple II | appleii | apple2 | +| Apple IIGS | apple-iigs | apple2gs | +| Apple Macintosh | mac | macintosh | +| Arcade | arcade | arcade | +| Arduboy Miniature Game System | arduboy | arduboy | +| Atari 2600 | atari2600 | atari2600 | +| Atari 5200 | atari5200 | atari5200 | +| Atari 7800 ProSystem | atari7800 | atari7800 | +| Atari 800 | atari800 | atari800 | +| Atari Jaguar | jaguar | atarijaguar | +| Atari Lynx | lynx | atarilynx | +| Atari ST | atari-st | atarist | +| Atari XE | atari-xegs | atarixe | +| Bally Astrocade | astrocade | astrocde | +| Bandai SuFami Turbo | sufami-turbo | sufami | +| Bandai WonderSwan | wonderswan | wonderswan | +| Bandai WonderSwan Color | wonderswan-color | wonderswancolor | +| Bit Corporation Gamate | gamate | gamate | +| Casio PV-1000 | casio-pv-1000 | pv1000 | +| Coleco Adam | colecoadam | adam | +| Coleco ColecoVision | colecovision | colecovision | +| Commodore 64 | c64 | c64 | +| Commodore Amiga | amiga | amiga | +| Commodore Amiga CD32 | amiga-cd32 | amigacd32 | +| Commodore CDTV | commodore-cdtv | cdtv | +| Commodore Plus/4 | c-plus-4, c16 | plus4 | +| Commodore VIC-20 | vic-20 | vic20 | +| Creatronic Mega Duck | mega-duck-slash-cougar-boy | megaduck | +| DOS (PC) | dos | dos | +| Dragon Data Dragon 32 | dragon-32-slash-64 | dragon32 | +| Emerson Arcadia 2001 | arcadia-2001 | arcadia | +| Epoch Super Cassette Vision | epoch-super-cassette-vision | scv | +| Fairchild Channel F | fairchild-channel-f | channelf | +| Fujitsu FM Towns | fm-towns | fmtowns | +| Fujitsu FM-7 | fm-7 | fm7 | +| Funtech Super A'Can | super-acan | supracan | +| GCE Vectrex | vectrex | vectrex | +| Hartung Game Master | hartung | gmaster | +| IBM PC | pc-booter | pc | +| Infocom Z-machine | z-machine | zmachine | +| Java 2 Micro Edition (J2ME) | j2me | j2me | +| M.U.G.E.N Game Engine | mugen | mugen | +| Magnavox Odyssey 2 | odyssey-2 | odyssey2 | +| Mattel Electronics Intellivision | intellivision | intellivision | +| Microsoft Windows 3.x | win3x | windows3x | +| Microsoft Windows 9x | win | windows9x | +| Microsoft Xbox | xbox | xbox | +| MSX | msx | msx | +| MSX Turbo R | msx-turbo | msxturbor | +| MSX2 | msx2 | msx2 | +| NEC PC Engine | pc-engine | pcengine, tg16 | +| NEC PC Engine CD | turbografx-cd | pcenginecd, tg-cd | +| NEC PC-8800 Series | pc-8800-series | pc88 | +| NEC PC-9800 Series | pc-9800-series | pc98 | +| NEC PC-FX | pc-fx | pcfx | +| NEC SuperGrafx | supergrafx | supergrafx | +| NEC TurboGrafx-16 | tg16 | tg16, pcengine | +| Nintendo 3DS | 3ds, new-nintendo-3ds | n3ds | +| Nintendo 64 | n64 | n64 | +| Nintendo 64DD | 64dd | n64dd | +| Nintendo DS | nds | nds | +| Nintendo Entertainment System | nes | nes | +| Nintendo Famicom Disk System | fds | fds | +| Nintendo Family Computer | famicom | famicom | +| Nintendo Game and Watch | g-and-w | gameandwatch | +| Nintendo Game Boy | gb | gb | +| Nintendo Game Boy Advance | gba | gba | +| Nintendo Game Boy Color | gbc | gbc | +| Nintendo GameCube | ngc | gc | +| Nintendo Pokémon Mini | pokemon-mini | pokemini | +| Nintendo Satellaview | satellaview | satellaview | +| Nintendo SFC (Super Famicom) | sfam | sfc | +| Nintendo SNES (Super Nintendo) | snes | snes, snesna | +| Nintendo Switch | switch | switch | +| Nintendo Virtual Boy | virtualboy | virtualboy | +| Nintendo Wii | wii | wii | +| Nintendo Wii U | wiiu | wiiu | +| OpenBOR Game Engine | openbor | openbor | +| Othello Multivision | multivision | multivision | +| Palm OS | palm-os | palm | +| Philips CD-i | philips-cd-i | cdimono1 | +| PICO-8 Fantasy Console | pico | pico8 | +| ScummVM Game Engine | scummvm | scummvm | +| Sega CD | segacd | segacd | +| Sega Dreamcast | dc | dreamcast | +| Sega Game Gear | gamegear | gamegear | +| Sega Genesis | genesis | genesis, megadrive, megadrivejp | +| Sega Master System | sms | mastersystem, mark3 | +| Sega Mega Drive 32X | sega32 | sega32x, sega32xjp, sega32xna | +| Sega Model 2 | model2, model3 | model2 | +| Sega Saturn | saturn | saturn, saturnjp | +| Sega SG-1000 | sg1000 | sg-1000 | +| Sega Titan Video Game System | stv | stv | +| Sharp X1 | x1 | x1 | +| Sharp X68000 | sharp-x68000 | x68000 | +| Sinclair ZX Spectrum | zxs | zxspectrum | +| Sinclair ZX81 | zx81 | zx81 | +| SNK Neo Geo | neogeoaes, neogeomvs | neogeo | +| SNK Neo Geo CD | neo-geo-cd | neogeocd, neogeocdjp | +| SNK Neo Geo Pocket | neo-geo-pocket | ngp | +| SNK Neo Geo Pocket Color | neo-geo-pocket-color | ngpc | +| Sony PlayStation | psx | psx | +| Sony PlayStation 2 | ps2 | ps2 | +| Sony PlayStation 3 | ps3 | ps3 | +| Sony PlayStation Portable | psp | psp | +| Sony PlayStation Vita | psvita | psvita | +| Spectravideo | spectravideo | spectravideo | +| Tangerine Computer Systems Oric | oric | oric | +| Texas Instruments TI-99 | ti-99, ti-994a | ti99 | +| Thomson MO/TO Series | thomson-mo5 | moto | +| Thomson MO/TO Series | thomson-to | moto, to8 | +| Thomson TO8 | to8 | to8 | +| Tiger Electronics Game.com | game-dot-com | gamecom | +| Unknown | atari8bit | atari8bit | +| Unknown | msx2plus | msx2plus | +| Uzebox Open Source Console | uzebox | uzebox | +| VTech CreatiVision | creativision | crvision | +| VTech V.Smile | vsmile | vsmile | +| WASM-4 Fantasy Console | wasm-4 | wasm4 | +| Watara Supervision | supervision | supervision | \ No newline at end of file diff --git a/internal/appdir/appdir.go b/internal/appdir/appdir.go new file mode 100644 index 00000000..39b33589 --- /dev/null +++ b/internal/appdir/appdir.go @@ -0,0 +1,60 @@ +package appdir + +import ( + "os" + "path/filepath" +) + +// DataDir returns the base directory for config files (config.json, save_slots.json) +// Override with GROUT_DATA_DIR env var; falls back to the process working directory +func DataDir() string { + if d := os.Getenv("GROUT_DATA_DIR"); d != "" { + return d + } + wd, err := os.Getwd() + if err != nil { + return "." + } + return wd +} + +// CacheDir returns the cache directory (SQLite DB, artwork) +// Override with GROUT_CACHE_DIR env var; falls back to {DataDir}/.cache +func CacheDir() string { + if d := os.Getenv("GROUT_CACHE_DIR"); d != "" { + return d + } + dataDir := filepath.Join(DataDir(), ".cache") + if dataDir == "." { + return os.TempDir() + } + + return dataDir +} + +// TmpDir returns the directory used for temporary files (zip archives, downloads) +// Override with GROUT_TMP_DIR env var; falls back to {DataDir}/.tmp +func TmpDir() string { + if d := os.Getenv("GROUT_TMP_DIR"); d != "" { + return d + } + return filepath.Join(DataDir(), ".tmp") +} + +// BackupDir returns the directory used to store save backups +// Override with GROUT_BACKUP_DIR env var; falls back to a .backup/ sibling of saveFilePath +func BackupDir(saveFilePath string) string { + if d := os.Getenv("GROUT_BACKUP_DIR"); d != "" { + return d + } + return filepath.Join(filepath.Dir(saveFilePath), ".backup") +} + +// UpdateStagingDir returns the staging directory for pending updates +// Override with GROUT_UPDATE_DIR env var; falls back to {installRoot}/.update +func UpdateStagingDir(installRoot string) string { + if d := os.Getenv("GROUT_UPDATE_DIR"); d != "" { + return d + } + return filepath.Join(installRoot, ".update") +} diff --git a/internal/config.go b/internal/config.go index 71057e9e..0f86d7e2 100644 --- a/internal/config.go +++ b/internal/config.go @@ -5,9 +5,11 @@ import ( "fmt" "grout/cache" "grout/cfw" + "grout/internal/appdir" "grout/internal/artutil" "grout/romm" "os" + "path/filepath" "sync/atomic" "time" @@ -114,7 +116,7 @@ func (c Config) ToLoggable() any { } func LoadConfig() (*Config, error) { - data, err := os.ReadFile("config.json") + data, err := os.ReadFile(filepath.Join(appdir.DataDir(), "config.json")) if err != nil { return nil, fmt.Errorf("reading config.json: %w", err) } @@ -212,7 +214,7 @@ func SaveConfig(config *Config) error { return err } - if err := os.WriteFile("config.json", pretty, 0644); err != nil { + if err := os.WriteFile(filepath.Join(appdir.DataDir(), "config.json"), pretty, 0644); err != nil { gaba.GetLogger().Error("Failed to write config file", "error", err) return err } @@ -258,7 +260,7 @@ func (c Config) GetDirectoryMapping(fsSlug string) (string, bool) { } func LoadSlotPreferences() map[string]string { - data, err := os.ReadFile("save_slots.json") + data, err := os.ReadFile(filepath.Join(appdir.DataDir(), "save_slots.json")) if err != nil { return nil } @@ -270,15 +272,16 @@ func LoadSlotPreferences() map[string]string { } func SaveSlotPreferences(config *Config) error { + slotPath := filepath.Join(appdir.DataDir(), "save_slots.json") if len(config.SlotPreferences) == 0 { - os.Remove("save_slots.json") + os.Remove(slotPath) return nil } pretty, err := json.MarshalIndent(config.SlotPreferences, "", " ") if err != nil { return err } - return os.WriteFile("save_slots.json", pretty, 0644) + return os.WriteFile(slotPath, pretty, 0644) } func (c Config) GetSlotPreference(romID int) string { diff --git a/internal/fileutil/fileutil.go b/internal/fileutil/fileutil.go index 95d831f4..9e97c76f 100644 --- a/internal/fileutil/fileutil.go +++ b/internal/fileutil/fileutil.go @@ -36,14 +36,6 @@ func (pw *progressWriter) Write(p []byte) (int, error) { return n, err } -func TempDir() string { - wd, err := os.Getwd() - if err != nil { - return os.TempDir() - } - return filepath.Join(wd, ".tmp") -} - func CopyFile(src, dest string) error { sourceFile, err := os.Open(src) if err != nil { diff --git a/internal/gamelist/add.go b/internal/gamelist/add.go index e842ad44..17a9de38 100644 --- a/internal/gamelist/add.go +++ b/internal/gamelist/add.go @@ -6,6 +6,7 @@ import ( "grout/internal/stringutil" "grout/romm" "os" + "path/filepath" "strconv" "strings" "time" @@ -37,6 +38,17 @@ type RomGameEntry struct { Platform *romm.Platform } +// GamelistPathResolver handle gamelist file path +type GamelistPathResolver func(entry RomGameEntry, filename FileName) string + +type AddRomGamesToGamelistOptions struct { + PathResolver GamelistPathResolver +} + +func defaultGamelistPath(entry RomGameEntry, filename FileName) string { + return filepath.Join(entry.RomDirectory, string(filename)) +} + func (gl *GameList) AddRomGame(entry RomGameEntry) { gameMetadata := make(map[string]string) gameMetadata[NameElement] = stringutil.PrepareRomName(entry.Game.Name, entry.Game.Regions) @@ -130,13 +142,18 @@ func (gl *GameList) AddRomGame(entry RomGameEntry) { gl.AdddOrUpdateEntry(entry.Game.Name, gameMetadata) } -func AddRomGamesToGamelist(entry []RomGameEntry, gamelistFilename FileName) error { +func AddRomGamesToGamelist(entry []RomGameEntry, gamelistFilename FileName, options *AddRomGamesToGamelistOptions) error { + resolver := defaultGamelistPath + if options != nil && options.PathResolver != nil { + resolver = options.PathResolver + } + gamelists := make(map[string]GameListEntry) for _, game := range entry { glEntry, exists := gamelists[game.Platform.FSSlug] if !exists { gl := New() - gamelistPath := fmt.Sprintf("%s/%s", game.RomDirectory, gamelistFilename) + gamelistPath := resolver(game, gamelistFilename) if fileutil.FileExists(gamelistPath) { data, err := os.ReadFile(gamelistPath) if err != nil { diff --git a/mkdocs.yml b/mkdocs.yml index 8377774f..a0e84601 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -77,6 +77,7 @@ nav: - ROCKNIX: getting-started/install-rocknix.md - Spruce: getting-started/install-spruce.md - TrimUI: getting-started/install-trimui.md + - RetroDECK: getting-started/install-retrodeck.md - Usage: - User Guide: usage/guide.md - Settings Reference: usage/settings.md @@ -93,6 +94,7 @@ nav: - ROCKNIX: platforms/rocknix.md - Spruce: platforms/spruce.md - TrimUI: platforms/trimui.md + - RetroDECK: platforms/RETRODECK.md - Contributing: - contributing/index.md - Development Guide: contributing/development.md diff --git a/scripts/RetroDECK/Grout.sh b/scripts/RetroDECK/Grout.sh new file mode 100644 index 00000000..f4282559 --- /dev/null +++ b/scripts/RetroDECK/Grout.sh @@ -0,0 +1,23 @@ +#!/bin/bash +CUR_DIR="$(realpath "$(dirname "$0")")" +cd "$CUR_DIR/Grout" || exit 1 + +export CFW=RETRODECK +export LD_LIBRARY_PATH="$CUR_DIR/Grout/lib:$LD_LIBRARY_PATH" +export GROUT_DATA_DIR="$XDG_CONFIG_HOME/Grout" +export GROUT_CACHE_DIR="$XDG_CONFIG_HOME/Grout/.cache" +export GROUT_TMP_DIR="/tmp/Grout" +#export GROUT_BACKUP_DIR="$GROUT_TMP_DIR/backup" +export GROUT_UPDATE_DIR="$GROUT_TMP_DIR/update" + +mkdir -p "$GROUT_DATA_DIR" "$GROUT_CACHE_DIR" "$GROUT_TMP_DIR" + +# Apply pending update +if [ -d "$GROUT_UPDATE_DIR" ]; then + cp -rf "$GROUT_UPDATE_DIR/"* "$CUR_DIR/" + rm -rf "$GROUT_UPDATE_DIR" +fi + +./grout + +exit 0 diff --git a/scripts/RetroDECK/install.sh b/scripts/RetroDECK/install.sh new file mode 100644 index 00000000..352e06fd --- /dev/null +++ b/scripts/RetroDECK/install.sh @@ -0,0 +1,38 @@ +GROUT_VERSION=4.8.1.0 +GROUT_URL=https://github.com/rommapp/grout/releases/download/v$GROUT_VERSION/Grout-RetroDECK.zip + +NONSTEAM_VERSION=0.7.0 +NONSTEAM_URL=https://github.com/cameronhimself/nonsteam/releases/download/$NONSTEAM_VERSION/nonsteam-linux-x64-$NONSTEAM_VERSION.tar.gz + +echo "Creating installation directory..." +mkdir -p "$HOME/grout" && cd "$HOME/grout" +export PATH="$HOME/grout:$PATH" + +echo "Downloading and extracting Grout..." +curl -sL -o grout.zip $GROUT_URL +unzip -d . -o -qq grout.zip +chmod +x Grout.sh Grout/grout +rm grout.zip + +echo "Downloading and extracting nonsteam..." +curl -sL -o nonsteam.tgz $NONSTEAM_URL +tar -xzf nonsteam.tgz --strip-components=1 +rm nonsteam.tgz + +echo "Adding Grout as a non-Steam game..." +nonsteam add -w \ + --app-name "Grout" \ + --exe "env" \ + --start-dir "$HOME/grout/" \ + --launch-options "$HOME/grout/Grout.sh" \ + --image-icon "$HOME/grout/Grout/media/icon.png" \ + --image-grid "$HOME/grout/Grout/media/cover.png" \ + --image-grid-horiz "$HOME/grout/Grout/media/banner.png" \ + --image-hero "$HOME/grout/Grout/media/background.png" \ + --image-logo "$HOME/grout/Grout/media/logo.png" \ + --allow-overlay + +echo "Cleaning up..." +rm nonsteam + +echo "Done! Please restart Steam for changes to take effect." diff --git a/scripts/RetroDECK/media/background.png b/scripts/RetroDECK/media/background.png new file mode 100644 index 00000000..2f5d5a1a Binary files /dev/null and b/scripts/RetroDECK/media/background.png differ diff --git a/scripts/RetroDECK/media/banner.png b/scripts/RetroDECK/media/banner.png new file mode 100644 index 00000000..2e89752b Binary files /dev/null and b/scripts/RetroDECK/media/banner.png differ diff --git a/scripts/RetroDECK/media/cover.png b/scripts/RetroDECK/media/cover.png new file mode 100644 index 00000000..b50ac770 Binary files /dev/null and b/scripts/RetroDECK/media/cover.png differ diff --git a/scripts/RetroDECK/media/icon.png b/scripts/RetroDECK/media/icon.png new file mode 100644 index 00000000..808cfcb0 Binary files /dev/null and b/scripts/RetroDECK/media/icon.png differ diff --git a/scripts/RetroDECK/media/logo.png b/scripts/RetroDECK/media/logo.png new file mode 100644 index 00000000..b04ce273 Binary files /dev/null and b/scripts/RetroDECK/media/logo.png differ diff --git a/sync/flow.go b/sync/flow.go index bce01413..04f0ab07 100644 --- a/sync/flow.go +++ b/sync/flow.go @@ -5,6 +5,7 @@ import ( "grout/cache" "grout/cfw" "grout/internal" + "grout/internal/appdir" "grout/internal/fileutil" "grout/internal/pspdb" "grout/romm" @@ -689,7 +690,7 @@ func download(client *romm.Client, config *internal.Config, deviceID string, ite if item.LocalSave.IsDirectorySave { // Write zip to temp, then extract to the save directory - tmpZip, err := os.CreateTemp("", "grout-save-dl-*.zip") + tmpZip, err := os.CreateTemp(appdir.TmpDir(), "grout-save-dl-*.zip") if err != nil { logger.Error("Failed to create temp file for directory save", "error", err) return false diff --git a/sync/zip_save.go b/sync/zip_save.go index a7a8dcd3..591b38a8 100644 --- a/sync/zip_save.go +++ b/sync/zip_save.go @@ -2,6 +2,7 @@ package sync import ( "archive/zip" + "grout/internal/appdir" "io" "os" "path/filepath" @@ -19,7 +20,7 @@ func ZipDirectory(dirPath string) (string, error) { // allowing a single zip to hold e.g. UCUS98751_DATA00/, UCUS98751_DATA01/, UCUS98751_INSDIR/. // Returns the path to the temporary zip file. Caller is responsible for cleanup. func ZipDirectories(dirPaths []string) (string, error) { - tmpFile, err := os.CreateTemp("", "grout-save-*.zip") + tmpFile, err := os.CreateTemp(appdir.TmpDir(), "grout-save-*.zip") if err != nil { return "", err } diff --git a/taskfiles/build.yml b/taskfiles/build.yml index e875d1f2..bde65f26 100644 --- a/taskfiles/build.yml +++ b/taskfiles/build.yml @@ -22,7 +22,8 @@ tasks: - docker create --name {{.CONTAINER_NAME}}-{{.BUILD_DIR}} --label {{.LABEL}} {{.IMAGE_TAG}} >/dev/null 2>&1 - echo "Extracting {{.BUILD_DIR}}..." - docker cp {{.CONTAINER_NAME}}-{{.BUILD_DIR}}:/build/grout {{.BUILD_DIR}}/grout - - 'if [ -n "{{.LIB_PATH}}" ]; then docker cp {{.CONTAINER_NAME}}-{{.BUILD_DIR}}:{{.LIB_PATH}} {{.BUILD_DIR}}/lib/libSDL2_gfx-1.0.so.0; fi' + - for: { var: LIBS } + cmd: docker cp -L {{.CONTAINER_NAME}}-{{.BUILD_DIR}}:{{.LIBS_PATH}}{{.ITEM}} {{.BUILD_DIR}}/lib/ - docker rm {{.CONTAINER_NAME}}-{{.BUILD_DIR}} >/dev/null 2>&1 - docker image prune --filter "label={{.LABEL}}" -f >/dev/null 2>&1 || true - echo "Extract {{.BUILD_DIR}} complete" @@ -35,7 +36,16 @@ tasks: - task: _build vars: { PLATFORM: linux/arm64, BUILD_DIR: build64, IMAGE_TAG: "{{.IMAGE_NAME}}-arm64", DOCKERFILE: docker/Dockerfile, LOCAL: "{{.LOCAL}}" } - task: _extract - vars: { BUILD_DIR: build64, IMAGE_TAG: "{{.IMAGE_NAME}}-arm64", LIB_PATH: /usr/lib/aarch64-linux-gnu/libSDL2_gfx-1.0.so.0.0.2 } + vars: { BUILD_DIR: build64, IMAGE_TAG: "{{.IMAGE_NAME}}-arm64", LIBS_PATH: "/usr/lib/aarch64-linux-gnu/", LIBS: [ + "libSDL2-2.0.so.0", + "libSDL2_image-2.0.so.0", + "libSDL2_gfx-1.0.so.0", + "libSDL2_ttf-2.0.so.0", + "libjpeg.so.62", + "libtiff.so.5", + "libwebp.so.6", + "libjbig.so.0" + ],} amd64: desc: Build and extract for AMD64 (x86_64) @@ -43,7 +53,17 @@ tasks: - task: _build vars: { PLATFORM: linux/amd64, BUILD_DIR: build, IMAGE_TAG: "{{.IMAGE_NAME}}-amd64", DOCKERFILE: docker/Dockerfile } - task: _extract - vars: { BUILD_DIR: build, IMAGE_TAG: "{{.IMAGE_NAME}}-amd64", LIB_PATH: /usr/lib/x86_64-linux-gnu/libSDL2_gfx-1.0.so.0.0.2 } + vars: { BUILD_DIR: build, IMAGE_TAG: "{{.IMAGE_NAME}}-amd64", LIBS_PATH: "/usr/lib/x86_64-linux-gnu/", LIBS: [ + "libSDL2-2.0.so.0", + "libSDL2_image-2.0.so.0", + "libSDL2_gfx-1.0.so.0", + "libSDL2_ttf-2.0.so.0", + "libjpeg.so.62", + "libtiff.so.5", + "libwebp.so.6", + "libjbig.so.0", + "libdeflate.so.0" + ],} x86: desc: Build and extract for x86 (32-bit) @@ -51,7 +71,17 @@ tasks: - task: _build vars: { PLATFORM: linux/386, BUILD_DIR: buildx86, IMAGE_TAG: "{{.IMAGE_NAME}}-x86", DOCKERFILE: docker/Dockerfile } - task: _extract - vars: { BUILD_DIR: buildx86, IMAGE_TAG: "{{.IMAGE_NAME}}-x86", LIB_PATH: /usr/lib/i386-linux-gnu/libSDL2_gfx-1.0.so.0.0.2 } + vars: { BUILD_DIR: buildx86, IMAGE_TAG: "{{.IMAGE_NAME}}-x86", LIBS_PATH: "/usr/lib/i386-linux-gnu/", LIBS: [ + "libSDL2-2.0.so.0", + "libSDL2_image-2.0.so.0", + "libSDL2_gfx-1.0.so.0", + "libSDL2_ttf-2.0.so.0", + "libjpeg.so.62", + "libtiff.so.5", + "libwebp.so.6", + "libjbig.so.0", + "libdeflate.so.0" + ],} arm32: desc: Build and extract for ARM32 @@ -60,6 +90,6 @@ tasks: cmds: - task: _build vars: { PLATFORM: linux/arm/v7, BUILD_DIR: build32, IMAGE_TAG: "{{.IMAGE_NAME}}-arm32", DOCKERFILE: docker/32.Dockerfile, LOCAL: "{{.LOCAL}}" } - # TODO: add LIB_PATH once custom SDL build for Miyoo is ready + # TODO: add LIBS and LIBS_PATH once custom SDL build for Miyoo is ready - task: _extract - vars: { BUILD_DIR: build32, IMAGE_TAG: "{{.IMAGE_NAME}}-arm32", LIB_PATH: "" } + vars: { BUILD_DIR: build32, IMAGE_TAG: "{{.IMAGE_NAME}}-arm32", LIBS: [], LIBS_PATH: "" } diff --git a/taskfiles/package.yml b/taskfiles/package.yml index 9007c443..fa82359b 100644 --- a/taskfiles/package.yml +++ b/taskfiles/package.yml @@ -4,9 +4,9 @@ tasks: all: desc: Package for all platforms - deps: [ next, muos, knulli, spruce, rocknix, trimui, allium, onion, koriki, minui, batocera, batocera-x86, batocera-amd64 ] + deps: [ next, muos, knulli, spruce, rocknix, trimui, allium, onion, koriki, minui, batocera, batocera-x86, batocera-amd64, retrodeck ] cmds: - - echo "Packaging complete (14 platforms)" + - echo "Packaging complete (15 platforms)" silent: true next: @@ -147,3 +147,14 @@ tasks: - cp -R build/lib/* dist/Batocera-amd64/Grout/lib/ - chmod a+x dist/Batocera-amd64/Grout/grout dist/Batocera-amd64/Grout.sh silent: true + + retrodeck: + cmds: + - rm -rf dist/RetroDECK + - mkdir -p dist/RetroDECK/Grout/{lib,media} + - cp scripts/RetroDECK/Grout.sh dist/RetroDECK/ + - cp scripts/RetroDECK/media/* dist/RetroDECK/Grout/media/ + - cp build/grout README.md LICENSE dist/RetroDECK/Grout/ + - cp -R build/lib/* dist/RetroDECK/Grout/lib/ + - chmod a+x dist/RetroDECK/Grout/grout dist/RetroDECK/Grout.sh + silent: true diff --git a/ui/bios_download.go b/ui/bios_download.go index 275966d6..765484d5 100644 --- a/ui/bios_download.go +++ b/ui/bios_download.go @@ -5,6 +5,7 @@ import ( "grout/bios" "grout/cfw" "grout/internal" + "grout/internal/appdir" "grout/internal/fileutil" "grout/romm" "os" @@ -201,7 +202,7 @@ func (s *BIOSDownloadScreen) draw(input BIOSDownloadInput) (BIOSDownloadOutput, baseURL := input.Host.URL() for _, item := range selectedItems { downloadURL := baseURL + item.firmware.DownloadURL - tempPath := filepath.Join(fileutil.TempDir(), fmt.Sprintf("bios_%s", item.firmware.FileName)) + tempPath := filepath.Join(appdir.TmpDir(), fmt.Sprintf("bios_%s", item.firmware.FileName)) downloads = append(downloads, gaba.Download{ URL: downloadURL, diff --git a/ui/download.go b/ui/download.go index eea66aa0..56a6ea01 100644 --- a/ui/download.go +++ b/ui/download.go @@ -7,6 +7,7 @@ import ( "grout/cfw" "grout/cfw/muos" "grout/internal" + "grout/internal/appdir" "grout/internal/artutil" "grout/internal/fileutil" "grout/internal/gamelist" @@ -165,7 +166,7 @@ func (s *DownloadScreen) draw(input DownloadInput) (DownloadOutput, error) { } } - tmpZipPath := filepath.Join(fileutil.TempDir(), fmt.Sprintf("grout_multirom_%d.zip", g.ID)) + tmpZipPath := filepath.Join(appdir.TmpDir(), fmt.Sprintf("grout_multirom_%d.zip", g.ID)) romDirectory := input.Config.GetPlatformRomDirectory(gamePlatform) extractDir := filepath.Join(romDirectory, g.FsNameNoExt) @@ -374,7 +375,7 @@ func (s *DownloadScreen) buildDownloads(config internal.Config, host romm.Host, sourceURL := "" if g.HasMultipleFiles { - tmpDir := fileutil.TempDir() + tmpDir := appdir.TmpDir() downloadLocation = filepath.Join(tmpDir, fmt.Sprintf("grout_multirom_%d.zip", g.ID)) sourceURL, _ = url.JoinPath(host.URL(), "/api/roms/", strconv.Itoa(g.ID), "content", g.FsName) } else { diff --git a/ui/games_list.go b/ui/games_list.go index 3cd0af58..79aeb2b7 100644 --- a/ui/games_list.go +++ b/ui/games_list.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "grout/cache" + "grout/cfw" "grout/internal" "grout/internal/environment" "grout/internal/stringutil" @@ -260,7 +261,11 @@ func (s *GameListScreen) Draw(input GameListInput) (GameListOutput, error) { options.SecondaryActionButton = gabaconst.VirtualButtonY if hasBIOS && !internal.IsKidModeEnabled() { - options.TertiaryActionButton = gabaconst.VirtualButtonMenu + if environment.IsMiyoo() || cfw.GetCFW() == cfw.RetroDECK { + options.TertiaryActionButton = gabaconst.VirtualButtonL2 + } else { + options.TertiaryActionButton = gabaconst.VirtualButtonMenu + } } var footerItems []gaba.FooterHelpItem @@ -269,7 +274,7 @@ func (s *GameListScreen) Draw(input GameListInput) (GameListOutput, error) { if hasBIOS && !internal.IsKidModeEnabled() { menuButtonName := i18n.Localize(&goi18n.Message{ID: "button_menu", Other: "Menu"}, nil) - if environment.IsMiyoo() { + if environment.IsMiyoo() || cfw.GetCFW() == cfw.RetroDECK { menuButtonName = "L2" } footerItems = append(footerItems, gaba.FooterHelpItem{ButtonName: menuButtonName, HelpText: i18n.Localize(&goi18n.Message{ID: "button_bios", Other: "BIOS"}, nil)}) diff --git a/update/updater.go b/update/updater.go index dd9b0f1a..f11f0299 100644 --- a/update/updater.go +++ b/update/updater.go @@ -7,6 +7,7 @@ import ( "fmt" "grout/cfw" "grout/internal" + "grout/internal/appdir" "grout/romm" "grout/version" "io" @@ -192,7 +193,7 @@ func PerformUpdate(c cfw.CFW, downloadURL string, expectedSize int64, expectedSH return err } - tmpZip := filepath.Join(os.TempDir(), "grout-update.zip") + tmpZip := filepath.Join(appdir.TmpDir(), "grout-update.zip") defer os.Remove(tmpZip) if err := downloadFile(downloadURL, tmpZip, expectedSize, progress); err != nil { @@ -208,7 +209,7 @@ func PerformUpdate(c cfw.CFW, downloadURL string, expectedSize int64, expectedSH // Extract the full zip to a staging directory at the install root. // The launch script will copy everything over on next startup, // avoiding issues with overwriting running files. - updateDir := filepath.Join(installRoot, ".update") + updateDir := appdir.UpdateStagingDir(installRoot) os.RemoveAll(updateDir) if err := extractZip(tmpZip, updateDir); err != nil { @@ -257,6 +258,8 @@ func getLaunchScriptPath(c cfw.CFW) string { return "Grout.pak/launch.sh" case cfw.Batocera: return "Grout.sh" + case cfw.RetroDECK: + return "Grout.sh" default: return "" } @@ -311,7 +314,7 @@ func extractZip(zipPath, destDir string) error { // CleanupUpdateArtifacts removes any leftover files from a previous update. func CleanupUpdateArtifacts() { - os.Remove(filepath.Join(os.TempDir(), "grout-update.zip")) + os.Remove(filepath.Join(appdir.TmpDir(), "grout-update.zip")) } func verifySHA256(filePath, expected string) error {