diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index e76fed63..8bbfa378 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -21,7 +21,7 @@ permissions:
contents: write
jobs:
- release:
+ prepare:
runs-on: ubuntu-22.04-arm
steps:
- uses: actions/checkout@v4
@@ -47,13 +47,6 @@ jobs:
--arg changelog "${{ inputs.changelog }}" \
'.version = $version | .changelog[$version] = $changelog' \
pak.json > pak.json.tmp && mv pak.json.tmp pak.json
-
- # Update build pak.json (will be copied during build)
- mkdir -p build64/Grout.pak
- jq --arg version "${{ steps.version.outputs.value }}" \
- --arg changelog "${{ inputs.changelog }}" \
- '.version = $version | .changelog[$version] = $changelog' \
- pak.json > build64/Grout.pak/pak.json
else
echo "Version is already ${{ steps.version.outputs.value }}, skipping pak.json update"
fi
@@ -71,6 +64,16 @@ jobs:
git commit -m "Update pak.json to ${{ steps.version.outputs.value }}"
git push
fi
+ outputs:
+ version: ${{ steps.version.outputs.value }}
+
+ build-arm64:
+ needs: prepare
+ runs-on: ubuntu-22.04-arm
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: ${{ github.ref }}
- name: Install Task
uses: arduino/setup-task@v2
@@ -81,51 +84,180 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- - name: Build and Package
- run: task build extract package-next package-muos package-knulli package-spruce package-rocknix package-trimui package-batocera
+ - name: Build ARM64
+ run: task build extract
- - name: Create NextUI distribution
- run: |
- cd build64/Grout.pak
- zip -r ../Grout.pak.zip .
+ - name: Package ARM64 platforms
+ run: task package-next package-muos package-knulli package-spruce package-rocknix package-trimui package-minui package-batocera
- - name: Create muOS distribution
- run: |
- cd build64/muOS
- zip -r Grout.muxapp Grout
- mv Grout.muxapp ../Grout.muxapp
+ - name: Rename binary
+ run: cp build64/grout build64/grout-arm64
- - name: Create Knulli distribution
+ - name: Create distributions
run: |
- cd build64/Knulli
- zip -r ../Grout-Knulli.zip Grout
+ cd dist/Grout.pak && zip -r ../Grout.pak.zip . && cd ../..
+ cd dist/muOS && zip -r Grout.muxapp Grout && mv Grout.muxapp ../Grout.muxapp && cd ../..
+ cd dist/Knulli && zip -r ../Grout-Knulli.zip Grout && cd ../..
+ cd dist/Spruce && zip -r Grout.spruce.zip Grout && mv Grout.spruce.zip ../Grout.spruce.zip && cd ../..
+ cd dist/ROCKNIX && zip -r ../Grout-ROCKNIX.zip Grout.sh Grout && cd ../..
+ cd dist/Trimui && zip -r ../Grout-Trimui.zip Grout && cd ../..
+ cd dist/MinUI-arm64 && zip -r ../Grout-MinUI-arm64.zip Grout && cd ../..
+ cd dist/Batocera-arm64 && zip -r ../Grout-Batocera-arm64.zip Grout.sh Grout && cd ../..
- - name: Create spruce distribution
- run: |
- cd build64/Spruce
- zip -r Grout.spruce.zip Grout
- mv Grout.spruce.zip ../Grout.spruce.zip
+ - name: Upload artifacts
+ uses: actions/upload-artifact@v4
+ with:
+ name: arm64-artifacts
+ path: |
+ dist/Grout.pak.zip
+ dist/Grout.muxapp
+ dist/Grout.spruce.zip
+ dist/Grout-Knulli.zip
+ dist/Grout-ROCKNIX.zip
+ dist/Grout-Trimui.zip
+ dist/Grout-MinUI-arm64.zip
+ dist/Grout-Batocera-arm64.zip
+ build64/grout
+ build64/grout-arm64
+
+ build-amd64:
+ needs: prepare
+ runs-on: ubuntu-22.04
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: ${{ github.ref }}
+
+ - name: Install Task
+ uses: arduino/setup-task@v2
+ with:
+ version: 3.x
+ repo-token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
- - name: Create ROCKNIX distribution
+ - name: Build AMD64
+ run: task build-amd64 extract-amd64
+
+ - name: Package Batocera AMD64
+ run: task package-batocera-amd64
+
+ - name: Rename binary
+ run: cp build/grout build/grout-amd64
+
+ - name: Create distribution
run: |
- cd build64/ROCKNIX
- zip -r ../Grout-ROCKNIX.zip Grout.sh Grout
+ cd dist/Batocera-amd64 && zip -r ../Grout-Batocera-amd64.zip Grout.sh Grout
+
+ - name: Upload artifacts
+ uses: actions/upload-artifact@v4
+ with:
+ name: amd64-artifacts
+ path: |
+ dist/Grout-Batocera-amd64.zip
+ build/grout-amd64
+
+ build-x86:
+ needs: prepare
+ runs-on: ubuntu-22.04
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: ${{ github.ref }}
- - name: Create Trimui distribution
+ - name: Install Task
+ uses: arduino/setup-task@v2
+ with:
+ version: 3.x
+ repo-token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v3
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Build x86
+ run: task build-x86 extract-x86
+
+ - name: Package Batocera x86
+ run: task package-batocera-x86
+
+ - name: Rename binary
+ run: cp buildx86/grout buildx86/grout-x86
+
+ - name: Create distribution
run: |
- cd build64/Trimui
- zip -r ../Grout-Trimui.zip Grout
+ cd dist/Batocera-x86 && zip -r ../Grout-Batocera-x86.zip Grout.sh Grout
+
+ - name: Upload artifacts
+ uses: actions/upload-artifact@v4
+ with:
+ name: x86-artifacts
+ path: |
+ dist/Grout-Batocera-x86.zip
+ buildx86/grout-x86
- - name: Create Batocera distribution
+ build-arm32:
+ needs: prepare
+ runs-on: ubuntu-22.04-arm
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: ${{ github.ref }}
+
+ - name: Install Task
+ uses: arduino/setup-task@v2
+ with:
+ version: 3.x
+ repo-token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v3
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Build ARM32
+ run: task build-32 extract-32
+
+ - name: Package ARM32 platforms
+ run: task package-allium package-onion package-minui-arm32
+
+ - name: Rename binary
+ run: cp build32/grout build32/grout-arm32
+
+ - name: Create distributions
run: |
- cd build64/Batocera
- zip -r ../Grout-Batocera.zip Grout.sh Grout
+ cd dist/Allium && zip -r ../Grout-Allium.zip Grout.pak && cd ../..
+ cd dist/Onion && zip -r ../Grout-Onion.zip Grout && cd ../..
+ cd dist/MinUI-arm32 && zip -r ../Grout-MinUI-arm32.zip Grout && cd ../..
+
+ - name: Upload artifacts
+ uses: actions/upload-artifact@v4
+ with:
+ name: arm32-artifacts
+ path: |
+ dist/Grout-Allium.zip
+ dist/Grout-Onion.zip
+ dist/Grout-MinUI-arm32.zip
+ build32/grout-arm32
+
+ release:
+ needs: [prepare, build-arm64, build-amd64, build-x86, build-arm32]
+ runs-on: ubuntu-22.04
+ steps:
+ - name: Download all artifacts
+ uses: actions/download-artifact@v4
+ with:
+ merge-multiple: true
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
- tag_name: ${{ steps.version.outputs.value }}
- name: ${{ steps.version.outputs.value }}
+ tag_name: ${{ needs.prepare.outputs.version }}
+ name: ${{ needs.prepare.outputs.version }}
body: |
${{ inputs.beta && '⚠️ **This is a beta release.** It may contain bugs or incomplete features.' || '' }}
@@ -133,13 +265,162 @@ jobs:
${{ inputs.beta && format('Built from branch: `{0}`', github.ref_name) || '' }}
files: |
- build64/Grout.pak.zip
- build64/Grout.muxapp
- build64/Grout.spruce.zip
- build64/Grout-Knulli.zip
- build64/Grout-ROCKNIX.zip
- build64/Grout-Trimui.zip
- build64/Grout-Batocera.zip
+ dist/Grout.pak.zip
+ dist/Grout.muxapp
+ dist/Grout.spruce.zip
+ dist/Grout-Allium.zip
+ dist/Grout-Onion.zip
+ dist/Grout-Knulli.zip
+ dist/Grout-ROCKNIX.zip
+ dist/Grout-Trimui.zip
+ dist/Grout-MinUI-arm64.zip
+ dist/Grout-MinUI-arm32.zip
+ dist/Grout-Batocera-arm64.zip
+ dist/Grout-Batocera-x86.zip
+ dist/Grout-Batocera-amd64.zip
build64/grout
+ build64/grout-arm64
+ build/grout-amd64
+ buildx86/grout-x86
+ build32/grout-arm32
draft: false
prerelease: ${{ inputs.beta }}
+
+ update-versions:
+ needs: [prepare, release]
+ runs-on: ubuntu-22.04
+ permissions:
+ contents: write
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: main
+ token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Download build artifacts
+ uses: actions/download-artifact@v4
+ with:
+ merge-multiple: true
+
+ - name: Update versions.json
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ VERSION: ${{ needs.prepare.outputs.version }}
+ IS_BETA: ${{ inputs.beta }}
+ CHANGELOG: ${{ inputs.changelog }}
+ run: |
+ REPO="rommapp/grout"
+ DOWNLOAD_BASE="https://github.com/${REPO}/releases/download/${VERSION}"
+
+ # Hash local build artifacts and get their sizes
+ get_asset_info() {
+ local path="$1"
+ if [ -f "$path" ]; then
+ local size=$(stat --format=%s "$path")
+ local sha256=$(sha256sum "$path" | cut -d' ' -f1)
+ echo "${size} ${sha256}"
+ else
+ echo "0 "
+ fi
+ }
+
+ # Verify uploaded release assets match local build artifacts
+ verify_upload() {
+ local name="$1"
+ local expected_sha256="$2"
+ if [ -z "$expected_sha256" ]; then return; fi
+
+ local url="${DOWNLOAD_BASE}/${name}"
+ if curl -sfL -o "/tmp/${name}" "$url"; then
+ local actual_sha256=$(sha256sum "/tmp/${name}" | cut -d' ' -f1)
+ rm -f "/tmp/${name}"
+ if [ "$actual_sha256" != "$expected_sha256" ]; then
+ echo "::error::Upload verification failed for ${name}: expected ${expected_sha256}, got ${actual_sha256}"
+ exit 1
+ fi
+ echo "Verified ${name}: ${actual_sha256}"
+ else
+ echo "::warning::Could not download ${name} for verification"
+ fi
+ }
+
+ # Distribution zip assets
+ DIST_ASSETS=(
+ "Grout.pak.zip:dist/Grout.pak.zip"
+ "Grout.muxapp:dist/Grout.muxapp"
+ "Grout-Knulli.zip:dist/Grout-Knulli.zip"
+ "Grout.spruce.zip:dist/Grout.spruce.zip"
+ "Grout-ROCKNIX.zip:dist/Grout-ROCKNIX.zip"
+ "Grout-Trimui.zip:dist/Grout-Trimui.zip"
+ "Grout-Allium.zip:dist/Grout-Allium.zip"
+ "Grout-Onion.zip:dist/Grout-Onion.zip"
+ "Grout-MinUI-arm64.zip:dist/Grout-MinUI-arm64.zip"
+ "Grout-MinUI-arm32.zip:dist/Grout-MinUI-arm32.zip"
+ "Grout-Batocera-arm64.zip:dist/Grout-Batocera-arm64.zip"
+ "Grout-Batocera-amd64.zip:dist/Grout-Batocera-amd64.zip"
+ "Grout-Batocera-x86.zip:dist/Grout-Batocera-x86.zip"
+ )
+
+ # Build the assets JSON object
+ ASSETS_JSON="{}"
+ for entry in "${DIST_ASSETS[@]}"; do
+ name="${entry%%:*}"
+ path="${entry##*:}"
+
+ read size sha256 <<< $(get_asset_info "$path")
+ if [ "$size" = "0" ]; then
+ echo "::warning::Asset not found: ${path}"
+ continue
+ fi
+
+ verify_upload "$name" "$sha256"
+
+ ASSETS_JSON=$(echo "$ASSETS_JSON" | jq \
+ --arg name "$name" \
+ --arg url "${DOWNLOAD_BASE}/${name}" \
+ --argjson size "${size}" \
+ --arg sha256 "${sha256}" \
+ '.[$name] = { url: $url, size: $size, sha256: $sha256 }')
+ done
+
+ # Build the release entry
+ RELEASE_ENTRY=$(jq -n \
+ --arg version "$VERSION" \
+ --arg notes "$CHANGELOG" \
+ --argjson assets "$ASSETS_JSON" \
+ '{ version: $version, notes: $notes, assets: $assets }')
+
+ # Parse major.minor.patch for the romm key
+ ROMM_KEY=$(echo "$VERSION" | sed 's/^v//' | grep -oE '^[0-9]+\.[0-9]+\.[0-9]+')
+
+ # Initialize versions.json if it doesn't exist
+ if [ ! -f docs/versions.json ]; then
+ mkdir -p docs
+ echo '{"stable":null,"beta":null,"romm":{}}' > docs/versions.json
+ fi
+
+ # Update versions.json
+ if [ "$IS_BETA" = "true" ]; then
+ jq --argjson entry "$RELEASE_ENTRY" --arg key "$ROMM_KEY" \
+ '.beta = $entry | .romm[$key] = $entry' \
+ docs/versions.json > docs/versions.json.tmp
+ else
+ jq --argjson entry "$RELEASE_ENTRY" --arg key "$ROMM_KEY" \
+ '.stable = $entry | .romm[$key] = $entry' \
+ docs/versions.json > docs/versions.json.tmp
+ fi
+
+ mv docs/versions.json.tmp docs/versions.json
+
+ - name: Commit and push versions.json
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+
+ if git diff --quiet docs/versions.json; then
+ echo "No changes to versions.json"
+ else
+ git add docs/versions.json
+ git commit -m "Update versions.json for ${{ needs.prepare.outputs.version }}"
+ git push
+ fi
diff --git a/.gitignore b/.gitignore
index cee04ed8..8754ffad 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,6 +8,7 @@
# Build
build64/
build32/
+dist/
build-miyoo/
grout
app/app
@@ -17,6 +18,7 @@ es_restart_request
# Local config
.env
config.json
+!scripts/**/config.json
config-*.json
# Cache & temp
diff --git a/README.md b/README.md
index 4a6042ba..1d648167 100644
--- a/README.md
+++ b/README.md
@@ -6,9 +6,11 @@
+[Allium][allium] · [Batocera][batocera] · [Knulli][knulli] · [muOS][muos] · [NextUI][nextui] · [Onion][onion] · [ROCKNIX][rocknix] · [Spruce][spruce] · [TrimUI][trimui]
+
@@ -92,6 +94,18 @@ If you enjoy using Grout and feel inclined to pay it forward, go do something ni
✌🏻
+
+
+[allium]: https://github.com/goweiwen/Allium
+[batocera]: https://batocera.org
+[knulli]: https://knulli.org
+[muos]: https://muos.dev
+[nextui]: https://nextui.loveretro.games
+[onion]: https://onionui.github.io
+[rocknix]: https://rocknix.org
+[spruce]: https://spruceui.github.io/
+[trimui]: https://github.com/trimui
+
[docs-badge-img]: https://img.shields.io/badge/user%20guide-007C77?style=for-the-badge
diff --git a/app/grout.go b/app/grout.go
index 421370f0..d713116a 100644
--- a/app/grout.go
+++ b/app/grout.go
@@ -2,6 +2,7 @@ package main
import (
"grout/cfw"
+ "grout/update"
"os"
_ "github.com/BrandonKowalski/certifiable"
@@ -31,5 +32,6 @@ func cleanup() {
if err := os.RemoveAll(".tmp"); err != nil {
gaba.GetLogger().Error("Failed to clean .tmp directory", "error", err)
}
+ update.CleanupUpdateArtifacts()
gaba.Close()
}
diff --git a/app/helpers.go b/app/helpers.go
index 38167c07..7d33f0e9 100644
--- a/app/helpers.go
+++ b/app/helpers.go
@@ -59,7 +59,7 @@ func handlePlatformMappingUpdateUI(state *AppState, r ui.PlatformMappingOutput)
state.Config.PlatformOrder = internal.PrunePlatformOrder(state.Config.PlatformOrder, r.Mappings)
internal.SaveConfig(state.Config)
- platforms, err := internal.GetMappedPlatforms(state.Host, r.Mappings, state.Config.ApiTimeout)
+ platforms, err := internal.GetMappedPlatforms(state.Host, r.Mappings, state.Config.ApiTimeout.Duration())
if err != nil {
gaba.GetLogger().Error("Failed to refresh platforms after mapping update", "error", err)
return
@@ -120,7 +120,7 @@ func handleLogout(state *AppState) {
screen := ui.NewPlatformMappingScreen()
result, err := screen.Draw(ui.PlatformMappingInput{
Host: state.Config.Hosts[0],
- ApiTimeout: state.Config.ApiTimeout,
+ ApiTimeout: state.Config.ApiTimeout.Duration(),
CFW: state.CFW,
RomDirectory: cfw.GetRomDirectory(),
AutoSelect: false,
@@ -135,10 +135,10 @@ func handleLogout(state *AppState) {
}
if err := cache.InitCacheManager(state.Config.Hosts[0], state.Config); err != nil {
- logger.Error("Failed to initialize cache manager after re-login", "error", err)
+ logger.Error("Failed to re-initialize cache manager", "error", err)
}
- platforms, err := internal.GetMappedPlatforms(state.Config.Hosts[0], state.Config.DirectoryMappings, state.Config.ApiTimeout)
+ platforms, err := internal.GetMappedPlatforms(state.Config.Hosts[0], state.Config.DirectoryMappings, state.Config.ApiTimeout.Duration())
if err != nil {
logger.Error("Failed to load platforms after re-login", "error", err)
return
diff --git a/app/router.go b/app/router.go
index 16d628b7..4cc187dc 100644
--- a/app/router.go
+++ b/app/router.go
@@ -177,7 +177,7 @@ func registerScreens(r *router.Router, state *AppState) {
r.Register(ScreenArtworkSync, func(input any) (any, error) {
in := input.(ui.ArtworkSyncInput)
screen := ui.NewArtworkSyncScreen()
- screen.Execute(in.Config, in.Host)
+ screen.Execute(in)
return ui.ArtworkSyncOutput{}, nil
})
@@ -228,6 +228,20 @@ func registerScreens(r *router.Router, state *AppState) {
return screen.Draw(input.(ui.SaveMappingInput))
})
+ r.Register(ScreenSwitchToToken, func(input any) (any, error) {
+ screen := ui.NewSwitchToTokenScreen()
+ result := screen.Execute(state.Config, state.Host)
+ if result.Success {
+ state.Host = result.Host
+ }
+ return result, nil
+ })
+
+ r.Register(ScreenToolsSettings, func(input any) (any, error) {
+ screen := ui.NewToolsSettingsScreen()
+ return screen.Draw(input.(ui.ToolsSettingsInput))
+ })
+
r.Register(ScreenServerAddress, func(input any) (any, error) {
screen := ui.NewServerAddressScreen()
return screen.Draw(input.(ui.ServerAddressInput))
diff --git a/app/screens.go b/app/screens.go
index 2cd74598..40cea9ac 100644
--- a/app/screens.go
+++ b/app/screens.go
@@ -36,4 +36,6 @@ const (
ScreenSyncHistory
ScreenSaveMapping
ScreenServerAddress
+ ScreenToolsSettings
+ ScreenSwitchToToken
)
diff --git a/app/setup.go b/app/setup.go
index 5c51285f..0cdbac7c 100644
--- a/app/setup.go
+++ b/app/setup.go
@@ -6,6 +6,7 @@ import (
"grout/cfw"
"grout/cfw/allium"
"grout/cfw/muos"
+ "grout/cfw/onion"
"grout/internal"
"grout/internal/environment"
"grout/internal/fileutil"
@@ -46,6 +47,8 @@ func setup() SetupResult {
mappingBytes, mappingErr = muos.GetInputMappingBytes()
case cfw.Allium:
mappingBytes, mappingErr = allium.GetInputMappingBytes()
+ case cfw.Onion:
+ mappingBytes, mappingErr = onion.GetInputMappingBytes()
}
if mappingBytes != nil && mappingErr == nil {
gaba.SetInputMappingBytes(mappingBytes)
@@ -117,8 +120,8 @@ func setup() SetupResult {
if config == nil {
config = &internal.Config{
ShowRegularCollections: true,
- ApiTimeout: 30 * time.Minute,
- DownloadTimeout: 60 * time.Minute,
+ ApiTimeout: internal.DurationSeconds(30 * time.Second),
+ DownloadTimeout: internal.DurationSeconds(60 * time.Minute),
}
}
config.Language = selectedLanguage
@@ -130,6 +133,7 @@ func setup() SetupResult {
loginConfig, loginErr := ui.LoginFlow(romm.Host{})
if loginErr != nil {
logger.Error("Login flow failed", "error", loginErr)
+ gaba.Close()
log.SetOutput(os.Stderr)
log.Fatalf("Login failed: %v", loginErr)
}
@@ -176,7 +180,7 @@ func setup() SetupResult {
screen := ui.NewPlatformMappingScreen()
result, err := screen.Draw(ui.PlatformMappingInput{
Host: config.Hosts[0],
- ApiTimeout: config.ApiTimeout,
+ ApiTimeout: config.ApiTimeout.Duration(),
CFW: currentCFW,
RomDirectory: cfw.GetRomDirectory(),
AutoSelect: false,
@@ -198,32 +202,111 @@ func setup() SetupResult {
}
var platforms []romm.Platform
- var loadErr error
splashBytes, _ := resources.GetSplashImageBytes()
for {
+ var connErr error
+ var authErr error
+ var loadErr error
+
gaba.ProcessMessage("", gaba.ProcessMessageOptions{
ImageBytes: splashBytes,
ImageWidth: 768,
ImageHeight: 540,
}, func() (interface{}, error) {
- // Load platform bindings from RomM server (non-fatal if it fails)
- if err := config.LoadPlatformsBinding(config.Hosts[0], config.ApiTimeout); err != nil {
+ host := config.Hosts[0]
+
+ // Step 1: Validate server connectivity
+ client := romm.NewClient(host.URL(), romm.WithInsecureSkipVerify(host.InsecureSkipVerify), romm.WithTimeout(internal.ValidationTimeout))
+ if err := client.ValidateConnection(); err != nil {
+ connErr = err
+ return nil, nil
+ }
+
+ // Step 2: Validate credentials/token
+ authClient := romm.NewClientFromHost(host, internal.LoginTimeout)
+ if host.HasTokenAuth() {
+ if err := authClient.ValidateToken(); err != nil {
+ authErr = err
+ return nil, nil
+ }
+ // Re-fetch username if missing
+ if host.Username == "" {
+ if user, err := authClient.GetCurrentUser(); err == nil {
+ host.Username = user.Username
+ config.Hosts[0] = host
+ internal.SaveConfig(config)
+ }
+ }
+ } else {
+ if err := authClient.Login(host.Username, host.Password); err != nil {
+ authErr = err
+ return nil, nil
+ }
+ }
+
+ // Step 3: Load platforms
+ if err := config.LoadPlatformsBinding(config.Hosts[0], config.ApiTimeout.Duration()); err != nil {
logger.Debug("Failed to load platform bindings", "error", err)
}
var err error
- platforms, err = internal.GetMappedPlatforms(config.Hosts[0], config.DirectoryMappings, config.ApiTimeout)
+ platforms, err = internal.GetMappedPlatforms(config.Hosts[0], config.DirectoryMappings, config.ApiTimeout.Duration())
if err != nil {
loadErr = err
- return nil, err
+ return nil, nil
}
- loadErr = nil
platforms = internal.SortPlatformsByOrder(platforms, config.PlatformOrder)
return nil, nil
})
+ // Handle connectivity failure
+ if connErr != nil {
+ logger.Warn("Server connectivity failed", "error", connErr)
+ errorMessage := classifyStartupError(connErr)
+ errorMsg := i18n.Localize(errorMessage, nil)
+ retry := showStartupError(errorMsg)
+ if !retry {
+ gaba.Close()
+ os.Exit(1)
+ }
+ continue
+ }
+
+ // Handle auth failure — show message and offer re-login
+ if authErr != nil {
+ logger.Warn("Auth validation failed", "error", authErr)
+
+ var msg string
+ if config.Hosts[0].HasTokenAuth() {
+ msg = i18n.Localize(&goi18n.Message{ID: "startup_error_token_invalid", Other: "Your API token is invalid or expired.\nPlease set up a new one."}, nil)
+ } else {
+ msg = i18n.Localize(&goi18n.Message{ID: "startup_error_credentials_invalid", Other: "Your credentials are invalid.\nPlease log in again."}, nil)
+ }
+
+ gaba.ConfirmationMessage(msg, []gaba.FooterHelpItem{
+ {ButtonName: "A", HelpText: i18n.Localize(&goi18n.Message{ID: "button_continue", Other: "Continue"}, nil)},
+ }, gaba.MessageOptions{})
+
+ loginConfig, loginErr := ui.LoginFlow(config.Hosts[0])
+ if loginErr != nil {
+ logger.Error("Re-login failed", "error", loginErr)
+ gaba.Close()
+ log.SetOutput(os.Stderr)
+ log.Fatalf("Login failed: %v", loginErr)
+ }
+ config.Hosts = loginConfig.Hosts
+ config.PlatformsBinding = loginConfig.PlatformsBinding
+ internal.SaveConfig(config)
+
+ // Re-initialize cache manager with new credentials
+ if err := cache.InitCacheManager(config.Hosts[0], config); err != nil {
+ logger.Error("Failed to re-initialize cache manager", "error", err)
+ }
+ continue
+ }
+
if loadErr == nil {
break
}
diff --git a/app/transitions.go b/app/transitions.go
index 19936b10..86550e40 100644
--- a/app/transitions.go
+++ b/app/transitions.go
@@ -56,6 +56,8 @@ func buildTransitionFunc(state *AppState, quitOnBack bool, initialShowCollection
return transitionGeneralSettings(ctx, result)
case ScreenCollectionsSettings:
return transitionCollectionsSettings(ctx, result)
+ case ScreenToolsSettings:
+ return transitionToolsSettings(ctx, result)
case ScreenAdvancedSettings:
return transitionAdvancedSettings(ctx, result)
case ScreenPlatformMapping:
@@ -70,6 +72,14 @@ func buildTransitionFunc(state *AppState, quitOnBack bool, initialShowCollection
return popOrExit(stack)
case ScreenArtworkSync:
return popOrExit(stack)
+ case ScreenSwitchToToken:
+ // Pop the stacked settings entry so we don't go back to stale state
+ stack.Pop()
+ return ScreenSettings, ui.SettingsInput{
+ Config: ctx.state.Config,
+ CFW: ctx.state.CFW,
+ Host: ctx.state.Host,
+ }
case ScreenUpdateCheck:
return transitionUpdateCheck(ctx, result)
case ScreenGameFilters:
@@ -616,6 +626,14 @@ func transitionSettings(ctx *transitionContext, result any) (router.Screen, any)
ctx.stack.Push(ScreenSettings, pushInput, r)
return ScreenCollectionsSettings, ui.CollectionsSettingsInput{Config: ctx.state.Config}
+ case ui.SettingsActionSwitchToToken:
+ ctx.stack.Push(ScreenSettings, pushInput, r)
+ return ScreenSwitchToToken, nil
+
+ case ui.SettingsActionTools:
+ ctx.stack.Push(ScreenSettings, pushInput, r)
+ return ScreenToolsSettings, ui.ToolsSettingsInput{Config: ctx.state.Config, Host: ctx.state.Host}
+
case ui.SettingsActionAdvanced:
ctx.stack.Push(ScreenSettings, pushInput, r)
return ScreenAdvancedSettings, ui.AdvancedSettingsInput{Config: ctx.state.Config, Host: ctx.state.Host}
@@ -624,7 +642,7 @@ func transitionSettings(ctx *transitionContext, result any) (router.Screen, any)
ctx.stack.Push(ScreenSettings, pushInput, r)
return ScreenPlatformMapping, ui.PlatformMappingInput{
Host: ctx.state.Host,
- ApiTimeout: ctx.state.Config.ApiTimeout,
+ ApiTimeout: ctx.state.Config.ApiTimeout.Duration(),
CFW: ctx.state.CFW,
RomDirectory: cfw.GetRomDirectory(),
ExistingMappings: ctx.state.Config.DirectoryMappings,
@@ -689,6 +707,25 @@ func transitionCollectionsSettings(ctx *transitionContext, result any) (router.S
return popOrExit(ctx.stack)
}
+func transitionToolsSettings(ctx *transitionContext, result any) (router.Screen, any) {
+ r := result.(ui.ToolsSettingsOutput)
+
+ pushInput := ui.ToolsSettingsInput{Config: ctx.state.Config, Host: ctx.state.Host}
+
+ switch r.Action {
+ case ui.ToolsSettingsActionSyncLocalArtwork:
+ ctx.stack.Push(ScreenToolsSettings, pushInput, r)
+ return ScreenArtworkSync, ui.ArtworkSyncInput{
+ Config: *ctx.state.Config,
+ Host: ctx.state.Host,
+ DownloadedOnly: true,
+ }
+
+ default:
+ return popOrExit(ctx.stack)
+ }
+}
+
func transitionAdvancedSettings(ctx *transitionContext, result any) (router.Screen, any) {
r := result.(ui.AdvancedSettingsOutput)
diff --git a/cache/artwork.go b/cache/artwork.go
index c7fc9046..35fbf560 100644
--- a/cache/artwork.go
+++ b/cache/artwork.go
@@ -138,7 +138,7 @@ func DownloadAndCacheArtwork(rom romm.Rom, kind artutil.ArtKind, host romm.Host)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
- req.Header.Set("Authorization", host.BasicAuthHeader())
+ req.Header.Set("Authorization", host.AuthHeader())
client := &http.Client{Timeout: romm.DefaultClientTimeout}
resp, err := client.Do(req)
diff --git a/cache/manager.go b/cache/manager.go
index 77aee2d0..9119bd15 100644
--- a/cache/manager.go
+++ b/cache/manager.go
@@ -54,9 +54,9 @@ func (s *Stats) recordError() {
}
var (
- cacheManager *Manager
- cacheManagerOnce sync.Once
- cacheManagerErr error
+ cacheManager *Manager
+ cacheManagerMu sync.Mutex
+ cacheManagerErr error
)
func GetCacheManager() *Manager {
@@ -64,9 +64,20 @@ func GetCacheManager() *Manager {
}
func InitCacheManager(host romm.Host, config Config) error {
- cacheManagerOnce.Do(func() {
- cacheManager, cacheManagerErr = newCacheManager(host, config)
- })
+ cacheManagerMu.Lock()
+ defer cacheManagerMu.Unlock()
+
+ if cacheManager != nil {
+ // Already initialized — just update the host
+ cacheManager.mu.Lock()
+ cacheManager.host = host
+ cacheManager.config = config
+ cacheManager.mu.Unlock()
+ cacheManagerErr = nil
+ return nil
+ }
+
+ cacheManager, cacheManagerErr = newCacheManager(host, config)
return cacheManagerErr
}
@@ -217,7 +228,7 @@ func (cm *Manager) ClearMetadata() error {
cm.mu.Lock()
defer cm.mu.Unlock()
- tables := []string{"games", "game_collections", "collections", "platforms", "bios_availability", "cache_metadata"}
+ tables := []string{"games", "game_collections", "collections", "platforms", "cache_metadata"}
tables = append(tables, junctionTables...)
tables = append(tables, lookupTables...)
@@ -458,19 +469,17 @@ func GetCacheDir() string {
}
// DeleteCacheFolder removes the entire cache directory and resets the singleton
-// so InitCacheManager can be called again. This must only be called from the
-// main UI goroutine while no background sync is running; concurrent access to
-// cacheManagerOnce is not safe.
+// so InitCacheManager can create a fresh instance.
func DeleteCacheFolder() error {
logger := gaba.GetLogger()
+ cacheManagerMu.Lock()
if cacheManager != nil {
cacheManager.Close()
cacheManager = nil
}
-
- cacheManagerOnce = sync.Once{}
cacheManagerErr = nil
+ cacheManagerMu.Unlock()
cacheDir := GetCacheDir()
if err := os.RemoveAll(cacheDir); err != nil {
diff --git a/cache/platforms.go b/cache/platforms.go
index 1794d692..6b86b993 100644
--- a/cache/platforms.go
+++ b/cache/platforms.go
@@ -1,9 +1,7 @@
package cache
import (
- "database/sql"
"encoding/json"
- "errors"
"grout/romm"
gaba "github.com/BrandonKowalski/gabagool/v2/pkg/gabagool"
@@ -74,8 +72,8 @@ func (cm *Manager) SavePlatforms(platforms []romm.Platform) error {
stmt, err := tx.Prepare(`
INSERT OR REPLACE INTO platforms
- (id, slug, fs_slug, name, api_name, custom_name, rom_count, has_bios, data_json, updated_at, cached_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ (id, slug, fs_slug, name, api_name, custom_name, rom_count, data_json, updated_at, cached_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`)
if err != nil {
return newCacheError("save", "platforms", "", err)
@@ -89,11 +87,6 @@ func (cm *Manager) SavePlatforms(platforms []romm.Platform) error {
return newCacheError("save", "platforms", p.Slug, err)
}
- hasBIOS := 0
- if p.HasBIOS {
- hasBIOS = 1
- }
-
_, err = stmt.Exec(
p.ID,
p.Slug,
@@ -102,7 +95,6 @@ func (cm *Manager) SavePlatforms(platforms []romm.Platform) error {
p.ApiName,
p.CustomName,
p.ROMCount,
- hasBIOS,
string(dataJSON),
p.UpdatedAt,
now,
@@ -120,57 +112,9 @@ func (cm *Manager) SavePlatforms(platforms []romm.Platform) error {
return nil
}
-func (cm *Manager) HasBIOS(platformID int) (bool, bool) {
- if cm == nil || !cm.initialized {
- return false, false
- }
-
- cm.mu.RLock()
- defer cm.mu.RUnlock()
-
- var hasBIOS int
- err := cm.db.QueryRow(`
- SELECT has_bios FROM bios_availability WHERE platform_id = ?
- `, platformID).Scan(&hasBIOS)
-
- if errors.Is(err, sql.ErrNoRows) {
- return false, false
- }
- if err != nil {
- return false, false
- }
-
- return hasBIOS == 1, true
-}
-
-func (cm *Manager) SetBIOSAvailability(platformID int, hasBIOS bool) error {
- if cm == nil || !cm.initialized {
- return ErrNotInitialized
- }
-
- cm.mu.Lock()
- defer cm.mu.Unlock()
-
- biosInt := 0
- if hasBIOS {
- biosInt = 1
- }
-
- _, err := cm.db.Exec(`
- INSERT OR REPLACE INTO bios_availability (platform_id, has_bios, checked_at)
- VALUES (?, ?, ?)
- `, platformID, biosInt, nowUTC())
-
- if err != nil {
- return newCacheError("save", "bios", "", err)
- }
-
- return nil
-}
-
// PurgeDeletedPlatforms removes cached platforms whose IDs are not in the provided
-// list of valid IDs from the server. Also cleans up bios_availability and
-// platform_sync_status for the deleted platforms.
+// list of valid IDs from the server. Also cleans up platform_sync_status for the
+// deleted platforms.
func (cm *Manager) PurgeDeletedPlatforms(validIDs []int) (int64, error) {
if cm == nil || !cm.initialized {
return 0, ErrNotInitialized
@@ -217,10 +161,6 @@ func (cm *Manager) PurgeDeletedPlatforms(validIDs []int) (int64, error) {
}
}
- if _, err := tx.Exec("DELETE FROM bios_availability WHERE platform_id NOT IN (SELECT id FROM _valid_platform_ids)"); err != nil {
- return 0, newCacheError("purge", "platforms", "bios_availability", err)
- }
-
if _, err := tx.Exec("DELETE FROM platform_sync_status WHERE platform_id NOT IN (SELECT id FROM _valid_platform_ids)"); err != nil {
return 0, newCacheError("purge", "platforms", "platform_sync_status", err)
}
diff --git a/cache/populate.go b/cache/populate.go
index db4cae1e..74107807 100644
--- a/cache/populate.go
+++ b/cache/populate.go
@@ -107,9 +107,6 @@ func (cm *Manager) populateCache(platforms []romm.Platform, progress *atomic.Flo
}
}
- // BIOS availability - fire and forget
- go cm.fetchBIOSAvailability(platforms, client)
-
// Fetch all games in bulk (in goroutine so UI can update)
var wg sync.WaitGroup
var firstErr error
@@ -454,38 +451,6 @@ func (cm *Manager) purgeDeletedItems(client *romm.Client) {
}
}
-func (cm *Manager) fetchBIOSAvailability(platforms []romm.Platform, client *romm.Client) {
- logger := gaba.GetLogger()
-
- if client == nil {
- client = romm.NewClientFromHost(cm.host, cm.config.GetApiTimeout())
- }
-
- var wg sync.WaitGroup
- sem := make(chan struct{}, MaxConcurrentPlatformFetches)
-
- for _, platform := range platforms {
- wg.Add(1)
- go func(p romm.Platform) {
- defer wg.Done()
- sem <- struct{}{}
- defer func() { <-sem }()
-
- firmware, err := client.GetFirmware(p.ID)
- if err != nil {
- logger.Debug("Failed to fetch BIOS info", "platform", p.Name, "error", err)
- cm.SetBIOSAvailability(p.ID, false)
- return
- }
-
- hasBIOS := len(firmware) > 0
- cm.SetBIOSAvailability(p.ID, hasBIOS)
- }(platform)
- }
-
- wg.Wait()
-}
-
func (cm *Manager) RefreshPlatformGames(platform romm.Platform) error {
if cm == nil || !cm.initialized {
return ErrNotInitialized
diff --git a/cache/schema.go b/cache/schema.go
index ca4ad708..6977596b 100644
--- a/cache/schema.go
+++ b/cache/schema.go
@@ -9,7 +9,7 @@ import (
gaba "github.com/BrandonKowalski/gabagool/v2/pkg/gabagool"
)
-const schemaVersion = 10
+const schemaVersion = 11
// nowUTC returns the current UTC time formatted as RFC3339 for consistent datetime storage
func nowUTC() string {
@@ -82,6 +82,13 @@ func migrateIfNeeded(db *sql.DB) error {
}
}
+ // v11 removes bios_availability table (now derived from platform firmware_count)
+ if currentVersion < 11 {
+ if _, err := db.Exec("DROP TABLE IF EXISTS bios_availability"); err != nil {
+ return fmt.Errorf("migration to v11: drop bios_availability: %w", err)
+ }
+ }
+
return nil
}
@@ -136,7 +143,6 @@ func createTables(db *sql.DB) error {
api_name TEXT DEFAULT '',
custom_name TEXT DEFAULT '',
rom_count INTEGER DEFAULT 0,
- has_bios INTEGER DEFAULT 0,
data_json TEXT NOT NULL,
updated_at TEXT,
cached_at TEXT NOT NULL
@@ -324,17 +330,6 @@ func createTables(db *sql.DB) error {
}
}
- _, err = tx.Exec(`
- CREATE TABLE IF NOT EXISTS bios_availability (
- platform_id INTEGER PRIMARY KEY,
- has_bios INTEGER NOT NULL DEFAULT 0,
- checked_at TEXT NOT NULL
- )
- `)
- if err != nil {
- return err
- }
-
// Track per-platform game sync status
_, err = tx.Exec(`
CREATE TABLE IF NOT EXISTS platform_sync_status (
diff --git a/cfw/allium/allium.go b/cfw/allium/allium.go
index 0b51f491..7ec99840 100644
--- a/cfw/allium/allium.go
+++ b/cfw/allium/allium.go
@@ -37,7 +37,7 @@ func GetBIOSDirectory() string {
}
func GetBaseSavePath() string {
- return filepath.Join(GetBasePath(), "Saves", "saves")
+ return filepath.Join(GetBasePath(), "Saves", "CurrentProfile", "saves")
}
func GetArtDirectory(romDir string) string {
diff --git a/cfw/allium/data/save_directories.json b/cfw/allium/data/save_directories.json
index 07216d82..795ffa88 100644
--- a/cfw/allium/data/save_directories.json
+++ b/cfw/allium/data/save_directories.json
@@ -1,92 +1,41 @@
{
- "3do": [
- "Opera"
- ],
"acpc": [
- "CPC/GX4000 (Caprice)",
"CPC (CrocoDS)"
],
"amiga": [
- "PUAE 2021",
- "Amiberry (External)",
- "PUAE",
- "PUAE Xtreme",
- "UAE4ARM"
+ "PUAE 2021"
],
"arcade": [
- "FinalBurn Neo Amped",
+ "MAME 2003-Plus",
"FB Alpha 2012 CPS-1",
"FB Alpha 2012 CPS-2",
"FB Alpha 2012 CPS-3",
"FB Alpha 2012 Neo Geo",
- "FB Alpha 2012",
- "FinalBurn Neo",
- "MAME 2000",
- "MAME 2003",
- "MAME 2003-Midway",
- "MAME 2003-Plus",
- "MAME 2003-Xtreme",
- "MAME 078 Plus",
- "MAME 139",
- "MAME 2010",
- "MAME"
+ "FinalBurn Neo"
],
"arduboy": [
- "Ardens",
- "Arduous"
+ "Ardens"
],
"atari-st": [
- "Hatari",
- "HatariB"
+ "Hatari"
],
"atari2600": [
- "Stella",
"Stella 2014"
],
"atari5200": [
- "a5200",
- "Atari 800"
+ "a5200"
],
"atari7800": [
"ProSystem"
],
- "c128": [
- "VICE x128"
- ],
"c64": [
- "VICE x64, fast",
- "VICE x64sc, accurate",
- "VICE xscpu64"
- ],
- "cave-story": [
- "NXEngine"
- ],
- "cbm-ii": [
- "VICE xcbm2",
- "VICE xcmb5x0"
- ],
- "chailove": [
- "ChaiLove"
- ],
- "chip-8": [
- "JAXE"
+ "VICE x64, fast"
],
"colecovision": [
- "Gearcoleco",
"blueMSX"
],
- "cpet": [
- "VICE xpet"
- ],
"dc": [
- "Flycast VL",
- "Flycast (External)",
- "Flycast",
- "Flycast Xtreme",
- "Morpheuscast"
- ],
- "doom": [
- "PrBoom"
+ "Flycast"
],
"dos": [
"DOSBox-Pure"
@@ -95,324 +44,134 @@
"FreeChaF"
],
"famicom": [
- "FCEUmm",
- "BNES",
- "Mesen",
- "Nestopia",
- "QuickNES"
+ "FCEUmm"
],
"fds": [
- "FCEUmm",
- "Mesen",
- "Nestopia"
+ "FCEUmm"
],
"g-and-w": [
"Handheld Electronic"
],
- "galaksija": [
- "Galaksija"
- ],
"gamegear": [
- "PicoDrive",
- "Gearsystem",
- "Genesis Plus GX"
+ "PicoDrive"
],
"gb": [
- "Gambatte",
- "DoubleCherryGB",
- "FixGB",
- "Gearboy",
- "mGBA",
- "SameBoy",
- "TGB Dual",
- "VBA-M"
+ "Gambatte"
],
"gba": [
"gpSP",
- "mGBA Rumble",
- "mGBA",
- "Beetle GBA",
- "VBA-M",
- "VBA-Next"
+ "mGBA"
],
"gbc": [
- "Gambatte",
- "DoubleCherryGB",
- "FixGB",
- "Gearboy",
- "mGBA",
- "SameBoy",
- "TGB Dual",
- "VBA-M"
+ "Gambatte"
],
"genesis": [
- "Genesis Plus GX",
- "Genesis Plus GX Wide",
"PicoDrive"
],
"intellivision": [
"FreeIntv"
],
- "j2me": [
- "FreeJ2ME"
- ],
- "jaguar": [
- "Virtual Jaguar"
- ],
- "karaoke": [
- "PocketCDG"
- ],
- "lowres": [
- "Lowres NX"
- ],
- "lua": [
- "Lutro"
- ],
"lynx": [
- "Handy",
- "Beetle Lynx"
- ],
- "media-player": [
- "FFPlay",
- "MPV",
- "Live TV"
+ "Handy"
],
"mega-duck-slash-cougar-boy": [
"SameDuck"
],
"msx": [
- "blueMSX",
- "fMSX"
+ "blueMSX"
],
"n64": [
- "Mupen64Plus-Next",
- "Mupen64Plus (External - GLideN64)",
- "Mupen64Plus (External - Rice)",
- "Mupen64Plus",
- "ParaLLel N64"
- ],
- "naomi": [
- "Flycast VL",
- "Flycast (External)",
- "Flycast",
- "Flycast Xtreme",
- "Morpheuscast"
+ "Mupen64Plus-Next"
],
"nds": [
- "DraStic (External)",
- "DeSmuME 2015",
- "DraStic-Legacy (External)",
- "melonDS",
- "melonDS-DS"
+ "DraStic"
],
"neo-geo-cd": [
"NeoCD"
],
"neo-geo-pocket": [
- "Beetle NeoPop",
- "RACE"
+ "Beetle NeoPop"
],
"neo-geo-pocket-color": [
- "Beetle NeoPop",
- "RACE"
+ "Beetle NeoPop"
],
"neogeoaes": [
- "FinalBurn Neo",
- "FB Alpha 2012 Neo Geo",
- "Geolith"
+ "FB Alpha 2012 Neo Geo"
],
"neogeomvs": [
- "FinalBurn Neo",
- "FB Alpha 2012 Neo Geo",
- "Geolith"
+ "FB Alpha 2012 Neo Geo"
],
"nes": [
- "FCEUmm",
- "BNES",
- "Mesen",
- "Nestopia",
- "QuickNES"
+ "FCEUmm"
],
"odyssey": [
"Odyssey2"
],
- "onscripter": [
- "Onscripter",
- "Onscripter-Yuri"
- ],
- "openbor": [
- "OpenBOR7530 (External)",
- "OpenBOR4432 (External)",
- "OpenBOR6412 (External)",
- "OpenBOR7142 (External)"
- ],
- "pc-8000": [
- "QUASI8800"
- ],
- "pc-9800-series": [
- "Neko Project II Kai",
- "Neko Project II"
- ],
- "pc-fx": [
- "Beetle PC-FX"
- ],
- "philips-cd-i": [
- "SAME CDi"
- ],
- "pico": [
- "PicoDrive",
- "Genesis Plus GX",
- "Genesis Plus GX Wide"
- ],
"pico-8": [
- "FAKE-08",
- "PICO-8 (External)",
- "PICO-8 (External Pixel-Perfect)",
- "Retro8"
+ "FAKE-08"
],
"pokemon-mini": [
"PokeMini"
],
- "ports": [
- "External"
- ],
- "psp": [
- "PPSSPP (External)",
- "PPSSPP"
- ],
"psx": [
- "PCSX ReARMed",
- "Beetle PSX",
- "DuckStation",
- "SwanStation"
- ],
- "quake": [
- "TyrQuake"
- ],
- "rpg-maker": [
- "EasyRPG"
+ "PCSX ReARMed"
],
"saturn": [
- "YabaSanshiro (External)",
- "Beetle Saturn",
- "YabaSanshiro",
- "YabaSanshiro (External - No BIOS)",
- "Yabause"
- ],
- "scummvm": [
- "ScummVM (External)",
- "ScummVM"
+ "Beetle Saturn"
],
"sega32": [
"PicoDrive"
],
"segacd": [
- "Genesis Plus GX",
- "Genesis Plus GX Wide",
"PicoDrive"
],
"sfam": [
- "Snes9x",
"Beetle Supafaust",
- "BSNES",
- "BSNES 2014 Performance",
- "BSNES C++98",
- "BSNES Mercury",
- "ChimeraSNES",
- "Mesen-S",
- "Snes9x 2002",
- "Snes9x 2005",
- "Snes9x 2005 Plus",
- "Snes9x 2010",
- "Snes9x Next"
+ "Snes9x"
],
"sg1000": [
- "Genesis Plus GX",
- "blueMSX",
"Gearsystem"
],
"sharp-x68000": [
"PX68k"
],
"sms": [
- "SMS Plus GX",
- "Gearsystem",
- "Genesis Plus GX",
"PicoDrive"
],
"snes": [
- "Snes9x",
"Beetle Supafaust",
- "BSNES",
- "BSNES 2014 Performance",
- "BSNES C++98",
- "BSNES Mercury",
- "ChimeraSNES",
- "Mesen-S",
- "Snes9x 2002",
- "Snes9x 2005",
- "Snes9x 2005 Plus",
- "Snes9x 2010",
- "Snes9x Next"
+ "Snes9x"
],
"supergrafx": [
- "Beetle SuperGrafx",
- "Geargrafx",
- "Beetle PCE"
+ "Beetle SuperGrafx"
],
"supervision": [
"Potator"
],
"tg16": [
- "Beetle PCE Fast",
- "Beetle PCE"
- ],
- "ti-83": [
- "Numero"
+ "Beetle PCE Fast"
],
"tic-80": [
"TIC-80"
],
"turbografx-cd": [
- "Beetle PCE Fast",
- "Beetle PCE"
- ],
- "uzebox": [
- "Uzem"
+ "Beetle PCE Fast"
],
"vectrex": [
"vecx"
],
- "vemulator": [
- "VeMUlator"
- ],
"vic-20": [
"VICE xvic"
],
- "vircon-32": [
- "Vircon32"
- ],
"virtualboy": [
"Beetle VB"
],
- "wasm-4": [
- "WASM-4"
- ],
- "wolfenstein-3d": [
- "ECWolf"
- ],
"wonderswan": [
"Beetle Cygne"
],
"wonderswan-color": [
"Beetle Cygne"
],
- "x1": [
- "X Millennium"
- ],
- "zx81": [
- "EightyOne"
- ],
"zxs": [
"Fuse"
]
diff --git a/cfw/allium/input_mappings/miyoo.json b/cfw/allium/input_mappings/miyoo.json
index 21fac41a..4e97bc7b 100644
--- a/cfw/allium/input_mappings/miyoo.json
+++ b/cfw/allium/input_mappings/miyoo.json
@@ -13,7 +13,7 @@
"13": 13,
"32": 5,
"8": 12,
- "9": 10
+ "9": 15
},
"controller_button_map": {},
"controller_hat_map": {},
diff --git a/cfw/batocera/batocera.go b/cfw/batocera/batocera.go
index 105a1cc2..9237ff58 100644
--- a/cfw/batocera/batocera.go
+++ b/cfw/batocera/batocera.go
@@ -40,3 +40,15 @@ func GetArtDirectory(romDir string) string {
func GetGroutGamelist() string {
return filepath.Join(GetRomDirectory(), "ports", "gamelist.xml")
}
+
+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")
+}
diff --git a/cfw/cfw.go b/cfw/cfw.go
index 8caea384..4e628f0e 100644
--- a/cfw/cfw.go
+++ b/cfw/cfw.go
@@ -16,7 +16,9 @@ const (
ROCKNIX CFW = "ROCKNIX"
Trimui CFW = "TRIMUI"
Allium CFW = "ALLIUM"
+ Onion CFW = "ONION"
Batocera CFW = "BATOCERA"
+ MinUI CFW = "MINUI"
)
func GetCFW() CFW {
@@ -24,11 +26,20 @@ func GetCFW() CFW {
cfw := CFW(cfwEnv)
switch cfw {
- case MuOS, NextUI, Knulli, Spruce, ROCKNIX, Trimui, Allium, Batocera:
+ case MuOS, NextUI, Knulli, Spruce, ROCKNIX, Trimui, Allium, Onion, Batocera, MinUI:
return cfw
default:
log.SetOutput(os.Stderr)
- log.Fatalf("Unsupported CFW: '%s'. Valid options: NextUI, muOS, Knulli, Spruce, ROCKNIX, Trimui, Allium, Batocera", cfwEnv)
+ log.Fatalf("Unsupported CFW: '%s'. Valid options: NextUI, muOS, Knulli, Spruce, ROCKNIX, Trimui, Allium, Onion, Batocera, MinUI", cfwEnv)
return ""
}
}
+
+func (c CFW) IsBasedOnEmulationStation() bool {
+ switch c {
+ case Knulli, ROCKNIX, Batocera:
+ return true
+ default:
+ return false
+ }
+}
diff --git a/cfw/directories.go b/cfw/directories.go
index e0c142c9..1488377d 100644
--- a/cfw/directories.go
+++ b/cfw/directories.go
@@ -4,8 +4,10 @@ import (
"grout/cfw/allium"
"grout/cfw/batocera"
"grout/cfw/knulli"
+ "grout/cfw/minui"
"grout/cfw/muos"
"grout/cfw/nextui"
+ "grout/cfw/onion"
"grout/cfw/rocknix"
"grout/cfw/spruce"
"grout/cfw/trimui"
@@ -29,8 +31,12 @@ func GetRomDirectory() string {
return trimui.GetRomDirectory()
case Allium:
return allium.GetRomDirectory()
+ case Onion:
+ return onion.GetRomDirectory()
case Batocera:
return batocera.GetRomDirectory()
+ case MinUI:
+ return minui.GetRomDirectory()
}
return ""
}
@@ -38,8 +44,11 @@ func GetRomDirectory() string {
// RomFolderBase returns the base folder name for ROM matching.
// tagParser is a function that extracts tags from paths (for NextUI).
func RomFolderBase(path string, tagParser func(string) string) string {
- if GetCFW() == NextUI {
+ switch GetCFW() {
+ case NextUI:
return nextui.RomFolderBase(path, tagParser)
+ case MinUI:
+ return minui.RomFolderBase(path, tagParser)
}
return path
}
@@ -61,16 +70,23 @@ func GetBIOSDirectory() string {
return trimui.GetBIOSDirectory()
case Allium:
return allium.GetBIOSDirectory()
+ case Onion:
+ return onion.GetBIOSDirectory()
case Batocera:
return batocera.GetBIOSDirectory()
+ case MinUI:
+ return minui.GetBIOSDirectory()
}
return ""
}
// GetBIOSFilePaths returns the BIOS file paths for a given relative path and platform.
func GetBIOSFilePaths(relativePath string, platformFSSlug string) []string {
- if GetCFW() == NextUI {
+ switch GetCFW() {
+ case NextUI:
return nextui.GetBIOSFilePaths(relativePath, platformFSSlug)
+ case MinUI:
+ return minui.GetBIOSFilePaths(relativePath, platformFSSlug)
}
return []string{filepath.Join(GetBIOSDirectory(), relativePath)}
}
@@ -103,8 +119,12 @@ func GetArtDirectory(romDir string, platformFSSlug, platformName string) string
return trimui.GetArtDirectory(platformFSSlug, platformName)
case Allium:
return allium.GetArtDirectory(romDir)
+ case Onion:
+ return onion.GetArtDirectory(romDir)
case Batocera:
return batocera.GetArtDirectory(romDir)
+ case MinUI:
+ return minui.GetArtDirectory(romDir)
default:
return ""
}
@@ -145,8 +165,104 @@ func BaseSavePath() string {
return trimui.GetBaseSavePath()
case Allium:
return allium.GetBaseSavePath()
+ case Onion:
+ return onion.GetBaseSavePath()
case Batocera:
return batocera.GetBaseSavePath()
+ case MinUI:
+ return minui.GetBaseSavePath()
}
return ""
}
+
+func GetArtMarqueeDirectory(romDir string, platformFSSlug, platformName string) string {
+ switch GetCFW() {
+ case ROCKNIX:
+ return rocknix.GetArtDirectory(romDir)
+ case Knulli:
+ return knulli.GetArtDirectory(romDir)
+ case Batocera:
+ return batocera.GetArtDirectory(romDir)
+ default:
+ return ""
+ }
+}
+
+func GetArtVideoDirectory(romDir string, platformFSSlug, platformName string) string {
+ switch GetCFW() {
+ case ROCKNIX:
+ return rocknix.GetVideoDirectory(romDir)
+ case Knulli:
+ return knulli.GetVideoDirectory(romDir)
+ case Batocera:
+ return batocera.GetVideoDirectory(romDir)
+ default:
+ return ""
+
+ }
+}
+
+func GetArtThumbnailDirectory(romDir string, platformFSSlug, platformName string) string {
+ switch GetCFW() {
+ case ROCKNIX:
+ return rocknix.GetArtDirectory(romDir)
+ case Knulli:
+ return knulli.GetArtDirectory(romDir)
+ case Batocera:
+ return knulli.GetArtDirectory(romDir)
+ default:
+ return ""
+ }
+}
+
+func GetArtBezelDirectory(romDir string, platformFSSlug, platformName string) string {
+ switch GetCFW() {
+ case ROCKNIX:
+ return rocknix.GetBezelDirectory(romDir)
+ case Knulli:
+ return knulli.GetBezelDirectory(romDir)
+ case Batocera:
+ return batocera.GetBezelDirectory(romDir)
+ default:
+ return ""
+ }
+}
+
+func GetManualDirectory(romDir string, platformFSSlug, platformName string) string {
+ switch GetCFW() {
+ case ROCKNIX:
+ return rocknix.GetManualDirectory(romDir)
+ case Knulli:
+ return knulli.GetManualDirectory(romDir)
+ case Batocera:
+ return batocera.GetManualDirectory(romDir)
+ default:
+ return ""
+ }
+}
+
+func GetBoxbackDirectory(romDir string, platformFSSlug, platformName string) string {
+ switch GetCFW() {
+ case ROCKNIX:
+ return rocknix.GetArtDirectory(romDir)
+ case Knulli:
+ return knulli.GetArtDirectory(romDir)
+ case Batocera:
+ return batocera.GetArtDirectory(romDir)
+ default:
+ return ""
+ }
+}
+
+func GetFanartDirectory(romDir string, platformFSSlug, platformName string) string {
+ switch GetCFW() {
+ case ROCKNIX:
+ return rocknix.GetArtDirectory(romDir)
+ case Knulli:
+ return knulli.GetArtDirectory(romDir)
+ case Batocera:
+ return batocera.GetArtDirectory(romDir)
+ default:
+ return ""
+ }
+}
diff --git a/cfw/knulli/knulli.go b/cfw/knulli/knulli.go
index df2a8297..6bf7589f 100644
--- a/cfw/knulli/knulli.go
+++ b/cfw/knulli/knulli.go
@@ -40,3 +40,15 @@ func GetArtDirectory(romDir string) string {
func GetGroutGamelist() string {
return filepath.Join(GetRomDirectory(), "tools", "gamelist.xml")
}
+
+func GetVideoDirectory(romDir string) string {
+ return filepath.Join(romDir, "videos")
+}
+
+func GetManualDirectory(romDir string) string {
+ return filepath.Join(romDir, "manuals")
+}
+
+func GetBezelDirectory(romDir string) string {
+ return filepath.Join(romDir, "bezels")
+}
diff --git a/cfw/metadata.go b/cfw/metadata.go
index d611d3d3..bde69160 100644
--- a/cfw/metadata.go
+++ b/cfw/metadata.go
@@ -40,7 +40,7 @@ func FillGamesMetadata(entries []gamelist.RomGameEntry) {
logger.Warn("Failed to add games to ES gamelist.xml", "error", err)
}
scheduleESRestart()
- case Spruce, Allium:
+ case Spruce, Allium, Onion:
if err := gamelist.AddRomGamesToGamelist(entries, gamelist.MiyooGameListFileName); err != nil {
logger.Warn("Failed to add games to miyoogamelist.xml", "error", err)
}
diff --git a/cfw/minui/data/platforms.json b/cfw/minui/data/platforms.json
new file mode 100644
index 00000000..4d7666e4
--- /dev/null
+++ b/cfw/minui/data/platforms.json
@@ -0,0 +1,163 @@
+{
+ "3do": [],
+ "3ds": [
+ "Nintendo 3DS (3DS)"
+ ],
+ "acpc": [
+ "Amstrad CPC (CPC)"
+ ],
+ "amiga": [
+ "Amiga (PUAE)"
+ ],
+ "arcade": [
+ "Arcade (FBN)"
+ ],
+ "arduboy": [],
+ "atari-st": [],
+ "atari2600": [
+ "Atari 2600 (A2600)"
+ ],
+ "atari5200": [
+ "Atari 5200 (A5200)"
+ ],
+ "atari7800": [
+ "Atari 7800 (A7800)"
+ ],
+ "c128": [
+ "Commodore 128 (C128)"
+ ],
+ "c64": [
+ "Commodore 64 (C64)"
+ ],
+ "cave-story": [],
+ "colecovision": [
+ "Colecovision (COLECO)"
+ ],
+ "cpet": [
+ "Commodore PET (PET)"
+ ],
+ "dc": [
+ "Sega Dreamcast (DC)"
+ ],
+ "dos": [],
+ "fairchild-channel-f": [],
+ "famicom": [
+ "Nintendo Entertainment System (FC)"
+ ],
+ "fds": [
+ "Famicom Disk System (FDS)"
+ ],
+ "g-and-w": [],
+ "galaksija": [],
+ "gamegear": [
+ "Sega Game Gear (GG)"
+ ],
+ "gb": [
+ "Game Boy (GB)"
+ ],
+ "gba": [
+ "Game Boy Advance (MGBA)",
+ "Game Boy Advance (GBA)"
+ ],
+ "gbc": [
+ "Game Boy Color (GBC)"
+ ],
+ "genesis": [
+ "Sega Genesis (MD)"
+ ],
+ "intellivision": [],
+ "j2me": [],
+ "jaguar": [],
+ "lynx": [
+ "Atari Lynx (LYNX)"
+ ],
+ "mega-duck-slash-cougar-boy": [],
+ "msx": [
+ "Microsoft MSX (MSX)"
+ ],
+ "n64": [
+ "Nintendo 64 (N64)"
+ ],
+ "nds": [
+ "Nintendo DS (NDS)"
+ ],
+ "neo-geo-cd": [],
+ "neo-geo-pocket": [
+ "Neo Geo Pocket (NGP)"
+ ],
+ "neo-geo-pocket-color": [
+ "Neo Geo Pocket Color (NGPC)"
+ ],
+ "neogeoaes": [],
+ "neogeomvs": [],
+ "nes": [
+ "Nintendo Entertainment System (FC)"
+ ],
+ "odyssey": [],
+ "openbor": [],
+ "pc-8000": [],
+ "pc-9800-series": [],
+ "pc-fx": [],
+ "philips-cd-i": [],
+ "pico": [
+ "Pico-8 (P8)"
+ ],
+ "pico-8": [
+ "Pico-8 (P8)"
+ ],
+ "pokemon-mini": [
+ "Pokemon mini (PKM)"
+ ],
+ "ps2": [
+ "Sony PlayStation 2 (PS2)"
+ ],
+ "psp": [
+ "Sony PlayStation Portable (PSP)"
+ ],
+ "psx": [
+ "Sony PlayStation (PS)"
+ ],
+ "saturn": [],
+ "sega32": [
+ "Sega 32X (32X)"
+ ],
+ "segacd": [
+ "Sega CD (SEGACD)"
+ ],
+ "sfam": [
+ "Super Nintendo Entertainment System (SFC)"
+ ],
+ "sg1000": [
+ "Sega SG-1000 (SG1000)"
+ ],
+ "sharp-x68000": [],
+ "sms": [
+ "Sega Master System (SMS)"
+ ],
+ "snes": [
+ "Super Nintendo Entertainment System (SFC)"
+ ],
+ "supergrafx": [],
+ "supervision": [],
+ "tg16": [
+ "TurboGrafx-16 (PCE)"
+ ],
+ "tic-80": [],
+ "turbografx-cd": [],
+ "vectrex": [],
+ "vic-20": [
+ "Commodore VIC20 (VIC)"
+ ],
+ "virtualboy": [
+ "Virtual Boy (VB)"
+ ],
+ "wonderswan": [
+ "WS"
+ ],
+ "wonderswan-color": [
+ "WSC"
+ ],
+ "x1": [],
+ "zx81": [],
+ "zxs": []
+}
\ No newline at end of file
diff --git a/cfw/minui/data/save_directories.json b/cfw/minui/data/save_directories.json
new file mode 100644
index 00000000..27dca5b8
--- /dev/null
+++ b/cfw/minui/data/save_directories.json
@@ -0,0 +1,183 @@
+{
+ "3do": [],
+ "3ds": [
+ "3DS"
+ ],
+ "acpc": [
+ "CPC"
+ ],
+ "amiga": [
+ "PUAE"
+ ],
+ "arcade": [
+ "FBN"
+ ],
+ "arduboy": [],
+ "atari-st": [],
+ "atari2600": [
+ "A2600"
+ ],
+ "atari5200": [
+ "A5200"
+ ],
+ "atari7800": [
+ "A7800"
+ ],
+ "c128": [
+ "C128"
+ ],
+ "c64": [
+ "C64"
+ ],
+ "cave-story": [],
+ "cbm-ii": [],
+ "chailove": [],
+ "chip-8": [],
+ "colecovision": [
+ "COLECO"
+ ],
+ "cpet": [
+ "PET"
+ ],
+ "dc": [
+ "DC"
+ ],
+ "doom": [
+ "PRBOOM"
+ ],
+ "dos": [],
+ "fairchild-channel-f": [],
+ "famicom": [
+ "FC"
+ ],
+ "fds": [
+ "FDS"
+ ],
+ "g-and-w": [],
+ "galaksija": [],
+ "gamegear": [
+ "GG"
+ ],
+ "gb": [
+ "GB"
+ ],
+ "gba": [
+ "GBA",
+ "MGBA"
+ ],
+ "gbc": [
+ "GBC"
+ ],
+ "genesis": [
+ "MD"
+ ],
+ "intellivision": [],
+ "j2me": [],
+ "jaguar": [],
+ "karaoke": [],
+ "lowres": [],
+ "lua": [],
+ "lynx": [
+ "LYNX"
+ ],
+ "media-player": [],
+ "mega-duck-slash-cougar-boy": [],
+ "msx": [
+ "MSX"
+ ],
+ "n64": [
+ "N64"
+ ],
+ "naomi": [],
+ "nds": [
+ "NDS"
+ ],
+ "neo-geo-cd": [],
+ "neo-geo-pocket": [
+ "NGP"
+ ],
+ "neo-geo-pocket-color": [
+ "NGPC"
+ ],
+ "neogeoaes": [],
+ "neogeomvs": [],
+ "nes": [
+ "FC"
+ ],
+ "odyssey": [],
+ "onscripter": [],
+ "openbor": [],
+ "pc-8000": [],
+ "pc-9800-series": [],
+ "pc-fx": [],
+ "philips-cd-i": [],
+ "pico": [],
+ "pico-8": [
+ "P8"
+ ],
+ "pokemon-mini": [
+ "PKM"
+ ],
+ "ports": [],
+ "ps2": [
+ "PS2"
+ ],
+ "psp": [
+ "PSP"
+ ],
+ "psx": [
+ "PS"
+ ],
+ "quake": [],
+ "rpg-maker": [],
+ "saturn": [],
+ "scummvm": [],
+ "sega32": [
+ "32X"
+ ],
+ "segacd": [
+ "SEGACD"
+ ],
+ "sfam": [
+ "SFC"
+ ],
+ "sg1000": [
+ "SG1000"
+ ],
+ "sharp-x68000": [],
+ "sms": [
+ "SMS"
+ ],
+ "snes": [
+ "SFC"
+ ],
+ "supergrafx": [],
+ "supervision": [],
+ "tg16": [
+ "PCE"
+ ],
+ "ti-83": [],
+ "tic-80": [],
+ "turbografx-cd": [],
+ "uzebox": [],
+ "vectrex": [],
+ "vemulator": [],
+ "vic-20": [
+ "VIC"
+ ],
+ "vircon-32": [],
+ "virtualboy": [
+ "VB"
+ ],
+ "wasm-4": [],
+ "wolfenstein-3d": [],
+ "wonderswan": [
+ "WS"
+ ],
+ "wonderswan-color": [
+ "WSC"
+ ],
+ "x1": [],
+ "zx81": [],
+ "zxs": []
+}
diff --git a/cfw/minui/minui.go b/cfw/minui/minui.go
new file mode 100644
index 00000000..7f80ea84
--- /dev/null
+++ b/cfw/minui/minui.go
@@ -0,0 +1,63 @@
+package minui
+
+import (
+ "embed"
+ "grout/internal/jsonutil"
+ "os"
+ "path/filepath"
+)
+
+//go:embed data/*.json
+var embeddedFiles embed.FS
+
+var (
+ Platforms = jsonutil.MustLoadJSONMap[string, []string](embeddedFiles, "data/platforms.json")
+ SaveDirectories = jsonutil.MustLoadJSONMap[string, []string](embeddedFiles, "data/save_directories.json")
+)
+
+func GetBasePath() string {
+ if basePath := os.Getenv("BASE_PATH"); basePath != "" {
+ return basePath
+ }
+ return "/mnt/SDCARD"
+}
+
+func GetRomDirectory() string {
+ return filepath.Join(GetBasePath(), "Roms")
+}
+
+func GetBIOSDirectory() string {
+ return filepath.Join(GetBasePath(), "Bios")
+}
+
+func GetBaseSavePath() string {
+ return filepath.Join(GetBasePath(), "Saves")
+}
+
+func GetArtDirectory(romDir string) string {
+ return filepath.Join(romDir, ".media")
+}
+
+func GetBIOSFilePaths(relativePath, platformFSSlug string) []string {
+ biosDir := GetBIOSDirectory()
+
+ tags, ok := SaveDirectories[platformFSSlug]
+ if ok && len(tags) > 0 {
+ paths := make([]string, 0, len(tags))
+ filename := filepath.Base(relativePath)
+ for _, platformTag := range tags {
+ paths = append(paths, filepath.Join(biosDir, platformTag, filename))
+ }
+ return paths
+ }
+
+ return []string{filepath.Join(biosDir, relativePath)}
+}
+
+// RomFolderBase returns the base folder name for ROM matching using the tag parser.
+func RomFolderBase(path string, tagParser func(string) string) string {
+ if tagParser != nil {
+ return tagParser(path)
+ }
+ return path
+}
diff --git a/cfw/muos/muos.go b/cfw/muos/muos.go
index 93023212..ddf9fa31 100644
--- a/cfw/muos/muos.go
+++ b/cfw/muos/muos.go
@@ -23,14 +23,14 @@ var (
func GetBasePath() string {
if basePath := os.Getenv("BASE_PATH"); basePath != "" {
- return filepath.Join(basePath + "MUOS")
+ return filepath.Join(basePath, "MUOS")
}
return StoragePath
}
func GetRomDirectory() string {
- if os.Getenv("BASE_PATH") != "" {
- return filepath.Join(GetBasePath(), "ROMS")
+ if basePath := os.Getenv("BASE_PATH"); basePath != "" {
+ return filepath.Join(basePath, "ROMS")
}
return RomsFolderUnion
}
diff --git a/cfw/onion/data/platforms.json b/cfw/onion/data/platforms.json
new file mode 100644
index 00000000..1aa39569
--- /dev/null
+++ b/cfw/onion/data/platforms.json
@@ -0,0 +1,221 @@
+{
+ "3do": [],
+ "3ds": [],
+ "acpc": [
+ "CPC"
+ ],
+ "amiga": [
+ "AMIGA"
+ ],
+ "arcade": [
+ "ARCADE"
+ ],
+ "arduboy": [
+ "ARDUBOY"
+ ],
+ "atari-st": [
+ "ATARIST"
+ ],
+ "atari2600": [
+ "ATARI"
+ ],
+ "atari5200": [
+ "FIFTYTWOHUNDRED"
+ ],
+ "atari7800": [
+ "SEVENTYEIGHTHUNDRED"
+ ],
+ "c128": [],
+ "c64": [
+ "COMMODORE"
+ ],
+ "cave-story": [],
+ "colecovision": [
+ "COLECO"
+ ],
+ "cpet": [],
+ "cps1": [
+ "CPS1"
+ ],
+ "cps2": [
+ "CPS2"
+ ],
+ "cps3": [
+ "CPS3"
+ ],
+ "dc": [
+ "DC"
+ ],
+ "dos": [
+ "DOS"
+ ],
+ "fairchild-channel-f": [
+ "FAIRCHILD"
+ ],
+ "famicom": [
+ "FC"
+ ],
+ "fds": [
+ "FDS"
+ ],
+ "g-and-w": [
+ "GW"
+ ],
+ "galaksija": [],
+ "gamegear": [
+ "GG"
+ ],
+ "gb": [
+ "GB"
+ ],
+ "gba": [
+ "GBA"
+ ],
+ "gbc": [
+ "GBC"
+ ],
+ "genesis": [
+ "MD"
+ ],
+ "intellivision": [
+ "INTELLIVISION"
+ ],
+ "j2me": [],
+ "jaguar": [],
+ "lynx": [
+ "LYNX"
+ ],
+ "mega-duck-slash-cougar-boy": [
+ "MEGADUCK"
+ ],
+ "msx": [
+ "MSX"
+ ],
+ "n64": [
+ "N64"
+ ],
+ "nds": [
+ "NDS"
+ ],
+ "neo-geo-cd": [
+ "NEOCD"
+ ],
+ "neo-geo-pocket": [
+ "NGP"
+ ],
+ "neo-geo-pocket-color": [
+ "NGP"
+ ],
+ "neogeoaes": [
+ "NEOGEO"
+ ],
+ "neogeomvs": [
+ "NEOGEO"
+ ],
+ "nes": [
+ "FC"
+ ],
+ "odyssey": [
+ "ODYSSEY"
+ ],
+ "openbor": [
+ "OPENBOR"
+ ],
+ "pc-8000": [],
+ "pc-9800-series": [],
+ "pc-fx": [],
+ "philips-cd-i": [],
+ "pico": [
+ "PICO"
+ ],
+ "pico-8": [
+ "PICO"
+ ],
+ "pokemon-mini": [
+ "POKE"
+ ],
+ "ports": [
+ "PORTS"
+ ],
+ "ps2": [
+ "PS2"
+ ],
+ "psp": [
+ "PSP"
+ ],
+ "psx": [
+ "PS"
+ ],
+ "satellaview": [
+ "SATELLAVIEW"
+ ],
+ "saturn": [],
+ "scummvm": [
+ "SCUMMVM"
+ ],
+ "sega32": [
+ "THIRTYTWOX"
+ ],
+ "segacd": [
+ "SEGACD"
+ ],
+ "sfam": [
+ "SFC"
+ ],
+ "sg1000": [
+ "SEGASGONE"
+ ],
+ "sgb": [
+ "SGB"
+ ],
+ "sharp-x68000": [
+ "X68000"
+ ],
+ "sms": [
+ "MS"
+ ],
+ "snes": [
+ "SFC"
+ ],
+ "sufami-turbo": [
+ "SUFAMI"
+ ],
+ "supergrafx": [
+ "SGFX"
+ ],
+ "supervision": [
+ "SUPERVISION"
+ ],
+ "tg16": [
+ "PCE"
+ ],
+ "tic-80": [
+ "TIC"
+ ],
+ "turbografx-cd": [
+ "PCECD"
+ ],
+ "vectrex": [
+ "VECTREX"
+ ],
+ "vic-20": [
+ "VIC20"
+ ],
+ "videopac": [
+ "VIDEOPAC"
+ ],
+ "virtualboy": [
+ "VB"
+ ],
+ "wonderswan": [
+ "WS"
+ ],
+ "wonderswan-color": [
+ "WS"
+ ],
+ "x1": [],
+ "zx81": [],
+ "zxs": [
+ "ZXS"
+ ]
+}
\ No newline at end of file
diff --git a/cfw/onion/data/save_directories.json b/cfw/onion/data/save_directories.json
new file mode 100644
index 00000000..795ffa88
--- /dev/null
+++ b/cfw/onion/data/save_directories.json
@@ -0,0 +1,178 @@
+{
+ "acpc": [
+ "CPC (CrocoDS)"
+ ],
+ "amiga": [
+ "PUAE 2021"
+ ],
+ "arcade": [
+ "MAME 2003-Plus",
+ "FB Alpha 2012 CPS-1",
+ "FB Alpha 2012 CPS-2",
+ "FB Alpha 2012 CPS-3",
+ "FB Alpha 2012 Neo Geo",
+ "FinalBurn Neo"
+ ],
+ "arduboy": [
+ "Ardens"
+ ],
+ "atari-st": [
+ "Hatari"
+ ],
+ "atari2600": [
+ "Stella 2014"
+ ],
+ "atari5200": [
+ "a5200"
+ ],
+ "atari7800": [
+ "ProSystem"
+ ],
+ "c64": [
+ "VICE x64, fast"
+ ],
+ "colecovision": [
+ "blueMSX"
+ ],
+ "dc": [
+ "Flycast"
+ ],
+ "dos": [
+ "DOSBox-Pure"
+ ],
+ "fairchild-channel-f": [
+ "FreeChaF"
+ ],
+ "famicom": [
+ "FCEUmm"
+ ],
+ "fds": [
+ "FCEUmm"
+ ],
+ "g-and-w": [
+ "Handheld Electronic"
+ ],
+ "gamegear": [
+ "PicoDrive"
+ ],
+ "gb": [
+ "Gambatte"
+ ],
+ "gba": [
+ "gpSP",
+ "mGBA"
+ ],
+ "gbc": [
+ "Gambatte"
+ ],
+ "genesis": [
+ "PicoDrive"
+ ],
+ "intellivision": [
+ "FreeIntv"
+ ],
+ "lynx": [
+ "Handy"
+ ],
+ "mega-duck-slash-cougar-boy": [
+ "SameDuck"
+ ],
+ "msx": [
+ "blueMSX"
+ ],
+ "n64": [
+ "Mupen64Plus-Next"
+ ],
+ "nds": [
+ "DraStic"
+ ],
+ "neo-geo-cd": [
+ "NeoCD"
+ ],
+ "neo-geo-pocket": [
+ "Beetle NeoPop"
+ ],
+ "neo-geo-pocket-color": [
+ "Beetle NeoPop"
+ ],
+ "neogeoaes": [
+ "FB Alpha 2012 Neo Geo"
+ ],
+ "neogeomvs": [
+ "FB Alpha 2012 Neo Geo"
+ ],
+ "nes": [
+ "FCEUmm"
+ ],
+ "odyssey": [
+ "Odyssey2"
+ ],
+ "pico-8": [
+ "FAKE-08"
+ ],
+ "pokemon-mini": [
+ "PokeMini"
+ ],
+ "psx": [
+ "PCSX ReARMed"
+ ],
+ "saturn": [
+ "Beetle Saturn"
+ ],
+ "sega32": [
+ "PicoDrive"
+ ],
+ "segacd": [
+ "PicoDrive"
+ ],
+ "sfam": [
+ "Beetle Supafaust",
+ "Snes9x"
+ ],
+ "sg1000": [
+ "Gearsystem"
+ ],
+ "sharp-x68000": [
+ "PX68k"
+ ],
+ "sms": [
+ "PicoDrive"
+ ],
+ "snes": [
+ "Beetle Supafaust",
+ "Snes9x"
+ ],
+ "supergrafx": [
+ "Beetle SuperGrafx"
+ ],
+ "supervision": [
+ "Potator"
+ ],
+ "tg16": [
+ "Beetle PCE Fast"
+ ],
+ "tic-80": [
+ "TIC-80"
+ ],
+ "turbografx-cd": [
+ "Beetle PCE Fast"
+ ],
+ "vectrex": [
+ "vecx"
+ ],
+ "vic-20": [
+ "VICE xvic"
+ ],
+ "virtualboy": [
+ "Beetle VB"
+ ],
+ "wonderswan": [
+ "Beetle Cygne"
+ ],
+ "wonderswan-color": [
+ "Beetle Cygne"
+ ],
+ "zxs": [
+ "Fuse"
+ ]
+}
diff --git a/cfw/onion/input_mappings/miyoo.json b/cfw/onion/input_mappings/miyoo.json
new file mode 100644
index 00000000..4e97bc7b
--- /dev/null
+++ b/cfw/onion/input_mappings/miyoo.json
@@ -0,0 +1,23 @@
+{
+ "keyboard_map": {
+ "101": 9,
+ "1073741903": 4,
+ "1073741904": 3,
+ "1073741905": 2,
+ "1073741906": 1,
+ "1073742048": 6,
+ "1073742049": 7,
+ "1073742050": 8,
+ "1073742052": 14,
+ "116": 11,
+ "13": 13,
+ "32": 5,
+ "8": 12,
+ "9": 15
+ },
+ "controller_button_map": {},
+ "controller_hat_map": {},
+ "joystick_axis_map": {},
+ "joystick_button_map": {},
+ "joystick_hat_map": {}
+}
\ No newline at end of file
diff --git a/cfw/onion/onion.go b/cfw/onion/onion.go
new file mode 100644
index 00000000..a3b74c1d
--- /dev/null
+++ b/cfw/onion/onion.go
@@ -0,0 +1,59 @@
+package onion
+
+import (
+ "embed"
+ "fmt"
+ "grout/internal/jsonutil"
+ "os"
+ "path/filepath"
+)
+
+//go:embed data/*.json
+var embeddedFiles embed.FS
+
+//go:embed input_mappings/*.json
+var embeddedInputMappings embed.FS
+
+var (
+ Platforms = jsonutil.MustLoadJSONMap[string, []string](embeddedFiles, "data/platforms.json")
+ SaveDirectories = jsonutil.MustLoadJSONMap[string, []string](embeddedFiles, "data/save_directories.json")
+)
+
+func GetBasePath() string {
+ if basePath := os.Getenv("BASE_PATH"); basePath != "" {
+ return basePath
+ }
+ return "/mnt/SDCARD"
+}
+
+func GetRomDirectory() string {
+ return filepath.Join(GetBasePath(), "Roms")
+}
+
+func GetBIOSDirectory() string {
+ return filepath.Join(GetBasePath(), "BIOS")
+}
+
+func GetBaseSavePath() string {
+ return filepath.Join(GetBasePath(), "Saves", "CurrentProfile", "saves")
+}
+
+func GetArtDirectory(romDir string) string {
+ return filepath.Join(romDir, "Imgs")
+}
+
+// GetInputMappingBytes returns the embedded input mapping JSON for Onion (Miyoo Mini/Mini+)
+func GetInputMappingBytes() ([]byte, error) {
+ filename := "input_mappings/miyoo.json"
+
+ overridePath := filepath.Join("overrides", "cfw", "onion", filename)
+ data, err := os.ReadFile(overridePath)
+ if err != nil {
+ data, err = embeddedInputMappings.ReadFile(filename)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read embedded input mapping %s: %w", filename, err)
+ }
+ }
+
+ return data, nil
+}
diff --git a/cfw/platforms.go b/cfw/platforms.go
index b7af1792..750eb582 100644
--- a/cfw/platforms.go
+++ b/cfw/platforms.go
@@ -4,8 +4,10 @@ import (
"grout/cfw/allium"
"grout/cfw/batocera"
"grout/cfw/knulli"
+ "grout/cfw/minui"
"grout/cfw/muos"
"grout/cfw/nextui"
+ "grout/cfw/onion"
"grout/cfw/rocknix"
"grout/cfw/spruce"
"grout/cfw/trimui"
@@ -26,7 +28,9 @@ func buildPlatformAliasMap() map[string][]string {
spruce.Platforms,
trimui.Platforms,
allium.Platforms,
+ onion.Platforms,
batocera.Platforms,
+ minui.Platforms,
}
// Build reverse map: primary folder -> list of RomM slugs that use it as primary
@@ -124,8 +128,12 @@ func GetPlatformMap(c CFW) map[string][]string {
return trimui.Platforms
case Allium:
return allium.Platforms
+ case Onion:
+ return onion.Platforms
case Batocera:
return batocera.Platforms
+ case MinUI:
+ return minui.Platforms
default:
return nil
}
diff --git a/cfw/rocknix/rocknix.go b/cfw/rocknix/rocknix.go
index f4cc5edd..8193b8f2 100644
--- a/cfw/rocknix/rocknix.go
+++ b/cfw/rocknix/rocknix.go
@@ -41,3 +41,15 @@ func GetArtDirectory(romDir string) string {
func GetGroutGamelist() string {
return filepath.Join(GetRomDirectory(), "ports", "gamelist.xml")
}
+
+func GetVideoDirectory(romDir string) string {
+ return filepath.Join(romDir, "videos")
+}
+
+func GetManualDirectory(romDir string) string {
+ return filepath.Join(romDir, "manuals")
+}
+
+func GetBezelDirectory(romDir string) string {
+ return filepath.Join(romDir, "bezels")
+}
diff --git a/cfw/saves.go b/cfw/saves.go
index a1dabad3..7004881b 100644
--- a/cfw/saves.go
+++ b/cfw/saves.go
@@ -4,8 +4,10 @@ import (
"grout/cfw/allium"
"grout/cfw/batocera"
"grout/cfw/knulli"
+ "grout/cfw/minui"
"grout/cfw/muos"
"grout/cfw/nextui"
+ "grout/cfw/onion"
"grout/cfw/rocknix"
"grout/cfw/spruce"
"grout/cfw/trimui"
@@ -27,10 +29,14 @@ func EmulatorFolderMap(c CFW) map[string][]string {
return rocknix.Platforms // ROCKNIX stores saves alongside ROMs
case Allium:
return allium.SaveDirectories
+ case Onion:
+ return onion.SaveDirectories
case Trimui:
return trimui.SaveDirectories
case Batocera:
return batocera.Platforms
+ case MinUI:
+ return minui.SaveDirectories
default:
return nil
}
diff --git a/docs/_includes/cfw-links.md b/docs/_includes/cfw-links.md
new file mode 100644
index 00000000..f585ce2a
--- /dev/null
+++ b/docs/_includes/cfw-links.md
@@ -0,0 +1,9 @@
+[allium]: https://github.com/goweiwen/Allium
+[batocera]: https://batocera.org
+[knulli]: https://knulli.org
+[muos]: https://muos.dev
+[nextui]: https://nextui.loveretro.games
+[onion]: https://onionui.github.io
+[rocknix]: https://rocknix.org
+[spruce]: https://spruceui.github.io/
+[trimui]: https://github.com/trimui
diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md
index 87018942..3751d966 100644
--- a/docs/getting-started/index.md
+++ b/docs/getting-started/index.md
@@ -1,13 +1,13 @@
# Quick Start Guide
-Get up and running with Grout in 5 steps.
+Get up and running with Grout in five steps.
## Before You Begin
Make sure you have:
- A RomM server running and accessible
-- A compatible device running muOS, Knulli, ROCKNIX, Spruce v4, NextUI, or Batocera
+- A compatible device running [Allium][allium], [Batocera][batocera], [Knulli][knulli], [muOS][muos], [NextUI][nextui], [Onion][onion], [ROCKNIX][rocknix], [Spruce v4][spruce], or [TrimUI][trimui]
- Your device connected to Wi-Fi
---
@@ -18,12 +18,15 @@ Make sure you have:
Choose your platform:
-- [muOS Installation](install-muos.md)
+- [Allium Installation](install-allium.md)
+- [Batocera Installation](install-batocera.md)
- [Knulli Installation](install-knulli.md)
+- [muOS Installation](install-muos.md)
+- [NextUI Installation](install-nextui.md)
+- [Onion Installation](install-onion.md)
- [ROCKNIX Installation](install-rocknix.md)
- [Spruce Installation](install-spruce.md)
-- [NextUI Installation](install-nextui.md)
-- [Batocera Installation](install-batocera.md)
+- [TrimUI Installation](install-trimui.md)
### Step 2: Launch and Select Language
@@ -31,17 +34,9 @@ When you first launch Grout, select your preferred language using `Left/Right`,
### Step 3: Connect to Your RomM Server
-Enter your connection details:
-
-1. **Protocol** - HTTP or HTTPS
-2. **Hostname** - Your server address (without `http://` or `https://`)
-3. **Port** - Only if using a non-standard port
-4. **Username** and **Password** - Your RomM credentials
-
-Press `Start` to connect.
+Enter your connection details and authenticate.
-!!! tip
-Using a self-signed certificate? Set **SSL Certificates** to **Skip Verification**.
+See the [User Guide](../usage/guide.md#server-connection) for detailed instructions.
### Step 4: Map Your Platforms
@@ -69,3 +64,14 @@ That's it! Your game is now on your device.
- [Full User Guide](../usage/guide.md) - Complete feature documentation
- [Settings Reference](../usage/settings.md) - Configure Grout to your preferences
- [Save Sync Guide](../usage/save-sync.md) - Keep saves synchronized across devices
+
+---
+
+## A small request: Spread Joy!
+
+A significant amount of time has been spent building Grout.
+
+If you enjoy using Grout and feel inclined to pay it forward, go do something nice for someone!
+
+--8<-- "docs/_includes/cfw-links.md"
+
diff --git a/docs/getting-started/install-allium.md b/docs/getting-started/install-allium.md
new file mode 100644
index 00000000..871c583f
--- /dev/null
+++ b/docs/getting-started/install-allium.md
@@ -0,0 +1,44 @@
+# Installation Guide for Allium
+
+This guide will help you install Grout on Miyoo Mini devices running [Allium][allium].
+
+## Tested Devices
+
+Grout has been tested on the following devices running Allium:
+
+| Manufacturer | Device |
+|--------------|-----------------|
+| Miyoo | Miyoo Mini Flip |
+
+_Please help verify compatibility on other devices by reporting your results!_
+
+## Prerequisites
+
+- Miyoo Mini device with Allium installed on an SD card
+- Device connected to a Wi-Fi network
+
+## Installation Steps
+
+1. Download the latest Grout release for Allium from the [releases page](https://github.com/rommapp/grout/releases/latest).
+2. Unzip the downloaded archive.
+3. Place the `Grout.pak` directory into `/mnt/SDCARD/Apps/` on your SD card.
+4. Launch Grout from the Apps menu 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.pak` folder in `/mnt/SDCARD/Apps/`. 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.
+
+## 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/getting-started/install-batocera.md b/docs/getting-started/install-batocera.md
index b8488569..6e751da4 100644
--- a/docs/getting-started/install-batocera.md
+++ b/docs/getting-started/install-batocera.md
@@ -1,6 +1,6 @@
# Installation Guide for Batocera
-This guide will help you install Grout on devices running [Batocera](https://batocera.org).
+This guide will help you install Grout on devices running [Batocera][batocera].
## Tested Devices
@@ -39,3 +39,5 @@ file if you do not want to authenticate again, and configure platforms folder ma
## 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/getting-started/install-knulli.md b/docs/getting-started/install-knulli.md
index 33a5ee1f..b2de5165 100644
--- a/docs/getting-started/install-knulli.md
+++ b/docs/getting-started/install-knulli.md
@@ -1,6 +1,6 @@
# Installation Guide for Knulli
-This guide will help you install Grout on devices running [Knulli](https://knulli.org).
+This guide will help you install Grout on devices running [Knulli][knulli].
## Tested Devices
@@ -48,3 +48,5 @@ file if you do not want to authenticate again, and configure platforms folder ma
## 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/getting-started/install-muos.md b/docs/getting-started/install-muos.md
index 71903ddf..0df5509f 100644
--- a/docs/getting-started/install-muos.md
+++ b/docs/getting-started/install-muos.md
@@ -1,6 +1,6 @@
# Installation Guide for muOS
-This guide will help you install Grout on devices running [muOS](https://muos.dev).
+This guide will help you install Grout on devices running [muOS][muos].
## Tested Devices
@@ -46,3 +46,5 @@ sure to keep the `config.json` file if you do not want to authenticate again, an
## 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/getting-started/install-nextui.md b/docs/getting-started/install-nextui.md
index f0a9e4c9..c976d0a4 100644
--- a/docs/getting-started/install-nextui.md
+++ b/docs/getting-started/install-nextui.md
@@ -1,6 +1,6 @@
# Installation Guide for NextUI
-This guide will help you install Grout on TrimUI devices running [NextUI](https://nextui.loveretro.games).
+This guide will help you install Grout on TrimUI devices running [NextUI][nextui].
## Tested Devices
@@ -54,3 +54,5 @@ file if you do not want to authenticate again, and configure platforms folder ma
## 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/getting-started/install-onion.md b/docs/getting-started/install-onion.md
new file mode 100644
index 00000000..ce8202e9
--- /dev/null
+++ b/docs/getting-started/install-onion.md
@@ -0,0 +1,52 @@
+# Installation Guide for Onion
+
+This guide will help you install Grout on Miyoo Mini devices running [Onion][onion].
+
+## Tested Devices
+
+Grout has been tested on the following devices running Onion:
+
+| Manufacturer | Device |
+|--------------|----------------|
+| Miyoo | Miyoo Mini Plus |
+
+_Please help verify compatibility on other devices by reporting your results!_
+
+## Prerequisites
+
+- Miyoo Mini device with Onion installed on an SD card
+- Device connected to a Wi-Fi network
+
+## Installation Steps
+
+1. Download the latest Grout release for Onion from the [releases page](https://github.com/rommapp/grout/releases/latest).
+2. Unzip the downloaded archive.
+3. Place the `Grout` directory into `/mnt/SDCARD/App/` on your SD card.
+4. Launch Grout from the Apps menu and enjoy!
+
+## Enable RTC for save sync support
+
+1. Install the Clock app from OnionOS Package Manager
+2. Open Clock app and set the time
+3. (recommended) Open the Tweaks app
+4. (recommended) Go to System, then Date and Time section and enable "set time from internet"
+5. (optional) Enable "Wait for sync on startup"
+
+## 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 `/mnt/SDCARD/App/`. 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.
+
+## 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/getting-started/install-rocknix.md b/docs/getting-started/install-rocknix.md
index b904fdf4..a0ff98d8 100644
--- a/docs/getting-started/install-rocknix.md
+++ b/docs/getting-started/install-rocknix.md
@@ -1,6 +1,6 @@
# Installation Guide for ROCKNIX
-This guide will help you install Grout on devices running [ROCKNIX](https://rocknix.org).
+This guide will help you install Grout on devices running [ROCKNIX][rocknix].
## Tested Devices
@@ -51,3 +51,5 @@ file if you do not want to authenticate again, and configure platforms folder ma
## 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/getting-started/install-spruce.md b/docs/getting-started/install-spruce.md
index e755c30b..d84a747a 100644
--- a/docs/getting-started/install-spruce.md
+++ b/docs/getting-started/install-spruce.md
@@ -1,6 +1,6 @@
# Installation Guide for Spruce
-This guide will help you install Grout on devices running [Spruce](https://spruceui.github.io/) v4.
+This guide will help you install Grout on devices running [Spruce][spruce] v4.
## Tested Devices
@@ -43,3 +43,5 @@ file if you do not want to authenticate again, and configure platforms folder ma
## 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/getting-started/install-trimui.md b/docs/getting-started/install-trimui.md
index db97cad4..24205946 100644
--- a/docs/getting-started/install-trimui.md
+++ b/docs/getting-started/install-trimui.md
@@ -43,3 +43,5 @@ file if you do not want to authenticate again, and configure platforms folder ma
## 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 25637451..c458736f 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.
-[muOS](getting-started/install-muos.md) · [Knulli](getting-started/install-knulli.md) · [ROCKNIX](getting-started/install-rocknix.md) · [Spruce](getting-started/install-spruce.md) · [NextUI](getting-started/install-nextui.md) · [TrimUI](getting-started/install-trimui.md) · [Batocera](getting-started/install-batocera.md)
+[Allium](getting-started/install-allium.md) · [Batocera](getting-started/install-batocera.md) · [Knulli](getting-started/install-knulli.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)
[:fontawesome-solid-gamepad: Get Started](getting-started/index.md){ .md-button .md-button--primary }
diff --git a/docs/platforms/allium.md b/docs/platforms/allium.md
new file mode 100644
index 00000000..10c6317c
--- /dev/null
+++ b/docs/platforms/allium.md
@@ -0,0 +1,80 @@
+# Allium Platform Mappings
+
+This table shows the mappings of RomM Fs Slug to Allium's platform folders.
+
+| Platform Name | RomM Fs Slug | Folder(s) |
+|-------------------------------|----------------------------|-------------------------------------------------|
+| 3DO Interactive Multiplayer | 3do | *(none)* |
+| Amiga | amiga | AMIGA |
+| Amstrad CPC | acpc | CPC |
+| Arcade | arcade | ARCADE |
+| Arduboy | arduboy | ARDUBOY |
+| Atari 2600 | atari2600 | ATARI |
+| Atari 5200 | atari5200 | FIFTYTWOHUNDRED |
+| Atari 7800 | atari7800 | SEVENTYEIGHTHUNDRED |
+| Atari Jaguar | jaguar | *(none)* |
+| Atari Lynx | lynx | LYNX |
+| Atari ST/STE | atari-st | ATARIST |
+| Cave Story | cave-story | *(none)* |
+| ColecoVision | colecovision | COLECO |
+| Commodore 128 | c128 | *(none)* |
+| Commodore C64/128/MAX | c64 | COMMODORE |
+| Commodore PET | cpet | *(none)* |
+| Commodore VIC-20 | vic-20 | VIC20 |
+| DOS | dos | *(none)* |
+| Dreamcast | dc | DC |
+| Fairchild Channel F | fairchild-channel-f | FAIRCHILD |
+| Family Computer | famicom | FC |
+| Family Computer Disk System | fds | FDS |
+| Galaksija | galaksija | *(none)* |
+| Game & Watch | g-and-w | GW |
+| Game Boy | gb | GB |
+| Game Boy Advance | gba | GBA |
+| Game Boy Color | gbc | GBC |
+| Intellivision | intellivision | INTELLIVISION |
+| J2ME | j2me | *(none)* |
+| Mega Duck/Cougar Boy | mega-duck-slash-cougar-boy | *(none)* |
+| MSX | msx | MSX |
+| Neo Geo AES | neogeoaes | *(none)* |
+| Neo Geo CD | neo-geo-cd | NEOCD |
+| Neo Geo MVS | neogeomvs | *(none)* |
+| Neo Geo Pocket | neo-geo-pocket | NGP |
+| Neo Geo Pocket Color | neo-geo-pocket-color | NGPC |
+| Nintendo 3DS | 3ds | *(none)* |
+| Nintendo 64 | n64 | N64 |
+| Nintendo DS | nds | NDS |
+| Nintendo Entertainment System | nes | FC |
+| Odyssey | odyssey | ODYSSEY |
+| OpenBOR | openbor | OPENBOR |
+| PC Engine SuperGrafx | supergrafx | SGFX |
+| PC-8000 | pc-8000 | *(none)* |
+| PC-9800 Series | pc-9800-series | *(none)* |
+| PC-FX | pc-fx | *(none)* |
+| Philips CD-i | philips-cd-i | *(none)* |
+| PICO-8 | pico | PICO8, FAKE08 |
+| PICO-8 | pico-8 | PICO8, FAKE08 |
+| PlayStation | psx | PS |
+| PlayStation 2 | ps2 | PS2 |
+| PlayStation Portable | psp | PSP |
+| Pokemon Mini | pokemon-mini | POKE |
+| Sega 32X | sega32 | THIRTYTWOX |
+| Sega CD | segacd | SEGACD |
+| Sega Game Gear | gamegear | GG |
+| Sega Genesis | genesis | MD |
+| Sega Master System | sms | MS |
+| Sega Saturn | saturn | *(none)* |
+| SG-1000 | sg1000 | SEGASGONE |
+| Sharp X1 | x1 | *(none)* |
+| Sharp X68000 | sharp-x68000 | X68000 |
+| Super Famicom | sfam | SFC |
+| Super Nintendo | snes | SFC |
+| Supervision | supervision | SUPERVISION |
+| TIC-80 | tic-80 | TIC |
+| TurboGrafx-16 | tg16 | PCE |
+| TurboGrafx-CD | turbografx-cd | PCECD |
+| Vectrex | vectrex | VECTREX |
+| Virtual Boy | virtualboy | VB |
+| WonderSwan | wonderswan | WS |
+| WonderSwan Color | wonderswan-color | WSC |
+| ZX Spectrum | zxs | ZXS |
+| ZX81 | zx81 | *(none)* |
diff --git a/docs/platforms/minui.md b/docs/platforms/minui.md
new file mode 100644
index 00000000..1a66f1fd
--- /dev/null
+++ b/docs/platforms/minui.md
@@ -0,0 +1,80 @@
+# MinUI Platform Mappings
+
+This table shows the mappings of RomM Fs Slug to MinUI's platform folders.
+
+| Platform Name | RomM Fs Slug | Folder(s) |
+|-------------------------------|----------------------------|-------------------------------------------------|
+| 3DO Interactive Multiplayer | 3do | *(none)* |
+| Amiga | amiga | Amiga (PUAE) |
+| Amstrad CPC | acpc | Amstrad CPC (CPC) |
+| Arcade | arcade | Arcade (FBN) |
+| Arduboy | arduboy | *(none)* |
+| Atari 2600 | atari2600 | Atari 2600 (A2600) |
+| Atari 5200 | atari5200 | Atari 5200 (A5200) |
+| Atari 7800 | atari7800 | Atari 7800 (A7800) |
+| Atari Jaguar | jaguar | *(none)* |
+| Atari Lynx | lynx | Atari Lynx (LYNX) |
+| Atari ST/STE | atari-st | *(none)* |
+| Cave Story | cave-story | *(none)* |
+| ColecoVision | colecovision | Colecovision (COLECO) |
+| Commodore 128 | c128 | Commodore 128 (C128) |
+| Commodore C64/128/MAX | c64 | Commodore 64 (C64) |
+| Commodore PET | cpet | Commodore PET (PET) |
+| Commodore VIC-20 | vic-20 | Commodore VIC20 (VIC) |
+| DOS | dos | *(none)* |
+| Dreamcast | dc | Sega Dreamcast (DC) |
+| Fairchild Channel F | fairchild-channel-f | *(none)* |
+| Family Computer | famicom | Nintendo Entertainment System (FC) |
+| Family Computer Disk System | fds | Famicom Disk System (FDS) |
+| Galaksija | galaksija | *(none)* |
+| Game & Watch | g-and-w | *(none)* |
+| Game Boy | gb | Game Boy (GB) |
+| Game Boy Advance | gba | Game Boy Advance (MGBA), Game Boy Advance (GBA) |
+| Game Boy Color | gbc | Game Boy Color (GBC) |
+| Intellivision | intellivision | *(none)* |
+| J2ME | j2me | *(none)* |
+| Mega Duck/Cougar Boy | mega-duck-slash-cougar-boy | *(none)* |
+| MSX | msx | Microsoft MSX (MSX) |
+| Neo Geo AES | neogeoaes | *(none)* |
+| Neo Geo CD | neo-geo-cd | *(none)* |
+| Neo Geo MVS | neogeomvs | *(none)* |
+| Neo Geo Pocket | neo-geo-pocket | Neo Geo Pocket (NGP) |
+| Neo Geo Pocket Color | neo-geo-pocket-color | Neo Geo Pocket Color (NGPC) |
+| Nintendo 3DS | 3ds | Nintendo 3DS (3DS) |
+| Nintendo 64 | n64 | Nintendo 64 (N64) |
+| Nintendo DS | nds | Nintendo DS (NDS) |
+| Nintendo Entertainment System | nes | Nintendo Entertainment System (FC) |
+| Odyssey | odyssey | *(none)* |
+| OpenBOR | openbor | *(none)* |
+| PC Engine SuperGrafx | supergrafx | *(none)* |
+| PC-8000 | pc-8000 | *(none)* |
+| PC-9800 Series | pc-9800-series | *(none)* |
+| PC-FX | pc-fx | *(none)* |
+| Philips CD-i | philips-cd-i | *(none)* |
+| PICO-8 | pico | Pico-8 (P8) |
+| PICO-8 | pico-8 | Pico-8 (P8) |
+| PlayStation | psx | Sony PlayStation (PS) |
+| PlayStation 2 | ps2 | Sony PlayStation 2 (PS2) |
+| PlayStation Portable | psp | Sony PlayStation Portable (PSP) |
+| Pokemon Mini | pokemon-mini | Pokemon mini (PKM) |
+| Sega 32X | sega32 | Sega 32X (32X) |
+| Sega CD | segacd | Sega CD (SEGACD) |
+| Sega Game Gear | gamegear | Sega Game Gear (GG) |
+| Sega Genesis | genesis | Sega Genesis (MD) |
+| Sega Master System | sms | Sega Master System (SMS) |
+| Sega Saturn | saturn | *(none)* |
+| SG-1000 | sg1000 | Sega SG-1000 (SG1000) |
+| Sharp X1 | x1 | *(none)* |
+| Sharp X68000 | sharp-x68000 | *(none)* |
+| Super Famicom | sfam | Super Nintendo Entertainment System (SFC) |
+| Super Nintendo | snes | Super Nintendo Entertainment System (SFC) |
+| Supervision | supervision | *(none)* |
+| TIC-80 | tic-80 | *(none)* |
+| TurboGrafx-16 | tg16 | TurboGrafx-16 (PCE) |
+| TurboGrafx-CD | turbografx-cd | *(none)* |
+| Vectrex | vectrex | *(none)* |
+| Virtual Boy | virtualboy | Virtual Boy (VB) |
+| WonderSwan | wonderswan | WS |
+| WonderSwan Color | wonderswan-color | WSC |
+| ZX Spectrum | zxs | *(none)* |
+| ZX81 | zx81 | *(none)* |
diff --git a/docs/platforms/onion.md b/docs/platforms/onion.md
new file mode 100644
index 00000000..76193fff
--- /dev/null
+++ b/docs/platforms/onion.md
@@ -0,0 +1,89 @@
+# Onion Platform Mappings
+
+This table shows the mappings of RomM Fs Slug to Onion's platform folders.
+
+| Platform Name | RomM Fs Slug | Folder(s) |
+|-------------------------------|----------------------------|-------------------------------------------------|
+| 3DO Interactive Multiplayer | 3do | *(none)* |
+| Amiga | amiga | AMIGA |
+| Amstrad CPC | acpc | CPC |
+| Arcade | arcade | ARCADE |
+| Arduboy | arduboy | ARDUBOY |
+| Atari 2600 | atari2600 | ATARI |
+| Atari 5200 | atari5200 | FIFTYTWOHUNDRED |
+| Atari 7800 | atari7800 | SEVENTYEIGHTHUNDRED |
+| Atari Jaguar | jaguar | *(none)* |
+| Atari Lynx | lynx | LYNX |
+| Atari ST/STE | atari-st | ATARIST |
+| Capcom Play System 1 | cps1 | CPS1 |
+| Capcom Play System 2 | cps2 | CPS2 |
+| Capcom Play System 3 | cps3 | CPS3 |
+| Cave Story | cave-story | *(none)* |
+| ColecoVision | colecovision | COLECO |
+| Commodore 128 | c128 | *(none)* |
+| Commodore C64/128/MAX | c64 | COMMODORE |
+| Commodore PET | cpet | *(none)* |
+| Commodore VIC-20 | vic-20 | VIC20 |
+| DOS | dos | DOS |
+| Dreamcast | dc | DC |
+| Fairchild Channel F | fairchild-channel-f | FAIRCHILD |
+| Family Computer | famicom | FC |
+| Family Computer Disk System | fds | FDS |
+| Galaksija | galaksija | *(none)* |
+| Game & Watch | g-and-w | GW |
+| Game Boy | gb | GB |
+| Game Boy Advance | gba | GBA |
+| Game Boy Color | gbc | GBC |
+| Intellivision | intellivision | INTELLIVISION |
+| J2ME | j2me | *(none)* |
+| Mega Duck/Cougar Boy | mega-duck-slash-cougar-boy | MEGADUCK |
+| MSX | msx | MSX |
+| Neo Geo AES | neogeoaes | NEOGEO |
+| Neo Geo CD | neo-geo-cd | NEOCD |
+| Neo Geo MVS | neogeomvs | NEOGEO |
+| Neo Geo Pocket | neo-geo-pocket | NGP |
+| Neo Geo Pocket Color | neo-geo-pocket-color | NGP |
+| Nintendo 3DS | 3ds | *(none)* |
+| Nintendo 64 | n64 | N64 |
+| Nintendo DS | nds | NDS |
+| Nintendo Entertainment System | nes | FC |
+| Odyssey | odyssey | ODYSSEY |
+| OpenBOR | openbor | OPENBOR |
+| PC Engine SuperGrafx | supergrafx | SGFX |
+| PC-8000 | pc-8000 | *(none)* |
+| PC-9800 Series | pc-9800-series | *(none)* |
+| PC-FX | pc-fx | *(none)* |
+| Philips CD-i | philips-cd-i | *(none)* |
+| PICO-8 | pico | PICO |
+| PICO-8 | pico-8 | PICO |
+| PlayStation | psx | PS |
+| PlayStation 2 | ps2 | PS2 |
+| PlayStation Portable | psp | PSP |
+| Pokemon Mini | pokemon-mini | POKE |
+| Ports | ports | PORTS |
+| Satellaview | satellaview | SATELLAVIEW |
+| ScummVM | scummvm | SCUMMVM |
+| Sega 32X | sega32 | THIRTYTWOX |
+| Sega CD | segacd | SEGACD |
+| Sega Game Gear | gamegear | GG |
+| Sega Genesis | genesis | MD |
+| Sega Master System | sms | MS |
+| Sega Saturn | saturn | *(none)* |
+| SG-1000 | sg1000 | SEGASGONE |
+| Sharp X1 | x1 | *(none)* |
+| Sharp X68000 | sharp-x68000 | X68000 |
+| Sufami Turbo | sufami-turbo | SUFAMI |
+| Super Famicom | sfam | SFC |
+| Super Game Boy | sgb | SGB |
+| Super Nintendo | snes | SFC |
+| Supervision | supervision | SUPERVISION |
+| TIC-80 | tic-80 | TIC |
+| TurboGrafx-16 | tg16 | PCE |
+| TurboGrafx-CD | turbografx-cd | PCECD |
+| Vectrex | vectrex | VECTREX |
+| VideoPac | videopac | VIDEOPAC |
+| Virtual Boy | virtualboy | VB |
+| WonderSwan | wonderswan | WS |
+| WonderSwan Color | wonderswan-color | WS |
+| ZX Spectrum | zxs | ZXS |
+| ZX81 | zx81 | *(none)* |
diff --git a/docs/resources/img/user_guide/auth_creds.png b/docs/resources/img/user_guide/auth_creds.png
new file mode 100644
index 00000000..467d63b3
Binary files /dev/null and b/docs/resources/img/user_guide/auth_creds.png differ
diff --git a/docs/resources/img/user_guide/auth_pairing.png b/docs/resources/img/user_guide/auth_pairing.png
new file mode 100644
index 00000000..bab7b6d2
Binary files /dev/null and b/docs/resources/img/user_guide/auth_pairing.png differ
diff --git a/docs/resources/img/user_guide/login.png b/docs/resources/img/user_guide/login.png
deleted file mode 100644
index 69d6b270..00000000
Binary files a/docs/resources/img/user_guide/login.png and /dev/null differ
diff --git a/docs/resources/img/user_guide/server_info.png b/docs/resources/img/user_guide/server_info.png
new file mode 100644
index 00000000..c7440e7d
Binary files /dev/null and b/docs/resources/img/user_guide/server_info.png differ
diff --git a/docs/usage/faq.md b/docs/usage/faq.md
index b4434b41..4a8111b5 100644
--- a/docs/usage/faq.md
+++ b/docs/usage/faq.md
@@ -1,220 +1,162 @@
# Frequently Asked Questions
+Can't find what you're looking for? [Open an issue](https://github.com/rommapp/grout/issues/new/choose){:target="_blank"} on GitHub.
+
---
## General
-### What RomM version do I need?
-
-Grout aggressively adopts new RomM features. The required RomM version matches the **first three components** of Grout's
-version number. For example, Grout `v4.6.1.0` requires RomM `4.6.1` or newer. The fourth component is for Grout-specific
-patches.
-
-Grout may still function on older RomM versions, but support will not be provided.
-
-### Which devices and firmware are supported?
-
-Grout
-supports [muOS](../getting-started/install-muos.md), [Knulli](../getting-started/install-knulli.md), [ROCKNIX](../getting-started/install-rocknix.md), [Spruce](../getting-started/install-spruce.md), [NextUI](../getting-started/install-nextui.md),
-and [TrimUI Stock OS](../getting-started/install-trimui.md). See each installation guide for tested devices.
+???+ question "What RomM version do I need?"
+ Grout aggressively adopts new RomM features. The required RomM version matches the **first three components** of Grout's
+ version number.
-### Hey! What about Miyoo devices?
+ For example, Grout `v4.6.1.0` requires RomM `4.6.1` or newer. The fourth component is for Grout-specific patches.
-Grout is not currently supported on Miyoo devices. We hope to change that in the future.
+ Grout may still function on older RomM versions, but support will not be provided.
-### Can I use Grout with OIDC / Single Sign-On?
+??? question "Which devices and firmware are supported?"
+ See the [Quick Start Guide](../getting-started/index.md) for the full list of supported platforms and installation instructions.
-Grout requires a username and password to authenticate. If your RomM instance uses OIDC, you can still use Grout by
-setting a password for your user account. Grout will support API Keys once they are available in RomM.
-
-For more details, see [this GitHub issue](https://github.com/rommapp/romm/issues/1767#issuecomment-2744215262){:
-target="_blank"}.
+??? question "Hey! What about Miyoo devices?"
+ Grout is not currently supported on Miyoo devices. We hope to change that in the future.
---
## Connection & Login
-### I can't connect to my RomM server. What should I check?
-
-1. **Wi-Fi** -Confirm your device is connected to Wi-Fi and can reach the server.
-2. **Hostname** -Make sure the hostname or IP address is correct and reachable from your device's network.
-3. **Protocol** -Try switching between HTTP and HTTPS. If you get a "protocol mismatch" error, you're using the wrong
- one.
-4. **Port** -If your RomM instance runs on a non-standard port, include it in the port field.
-5. **Firewall** -Ensure your server's firewall allows connections from your device's network.
-
-### I'm getting SSL / certificate errors
+???+ question "I can't connect to my RomM server. What should I check?"
+ 1. **Wi-Fi** - Confirm your device is connected to Wi-Fi and can reach the server.
+ 2. **Hostname** - Make sure the hostname or IP address is correct and reachable from your device's network.
+ 3. **Protocol** - Try switching between HTTP and HTTPS. If you get a "protocol mismatch" error, you're using the wrong
+ one.
+ 4. **Port** - If your RomM instance runs on a non-standard port, include it in the port field.
+ 5. **Firewall** - Ensure your server's firewall allows connections from your device's network.
-If you're using a self-signed certificate, set **SSL Certificates** to **Skip Verification** on the login screen.
+??? question "I'm getting SSL / certificate errors"
+ If you're using a self-signed certificate, set **SSL Certificates** to **Skip Verification** on the login screen.
-### My connection keeps timing out
+??? question "My connection keeps timing out"
+ Increase the **API Timeout** and **Download Timeout** in Settings > Advanced.
-Increase the **API Timeout** and **Download Timeout** in Settings > Advanced.
-
-The default may be too short for slow networks, remote servers, large downloads, or RomM instances with a large games
-collection.
+ The default may be too short for slow networks, remote servers, large downloads, or RomM instances with a large games
+ collection.
---
## Downloading Games
-### Can I filter the games list?
-
-Yes. Press `Y` from any game list to filter by genre, franchise, company, game mode, region, language, age rating, or tag. Only categories with available values for the current platform are shown. Press `B` to clear all filters.
+???+ question "Can I filter the games list?"
+ Yes. Press `Y` from any game list to filter by genre, franchise, company, game mode, region, language, age rating, or tag. Only categories with available values for the current platform are shown. Press `B` to clear all filters.
-### Can I download multiple games at once?
+??? question "Can I download multiple games at once?"
+ Yes. Press `Select` to enter multi-select mode, then use `A` to toggle individual games, `R1` to select all, or `L1` to
+ deselect all. Press `Start` to confirm and begin downloading.
-Yes. Press `Select` to enter multi-select mode, then use `A` to toggle individual games, `R1` to select all, or `L1` to
-deselect all. Press `Start` to confirm and begin downloading.
+??? question "How do multi-disc games work?"
+ When you download a multi-disc game, Grout automatically extracts and creates an `.m3u` playlist file.
-### How do multi-disc games work?
+??? question "What does the "Archived Downloads" setting do?"
+ When set to **Uncompress**, Grout will automatically extract downloaded `.zip` and `.7z` archives after downloading.
+ When set to **Do Nothing**, the archive is saved as-is.
-When you download a multi-disc game, Grout automatically extracts the disc images and creates an `.m3u` playlist file.
+??? question "What's the difference between the downloaded game indicators?"
+ In Settings, the **Downloaded Games** option controls how already-downloaded games appear in the games list:
-### What does the "Archived Downloads" setting do?
-
-When set to **Uncompress**, Grout will automatically extract downloaded `.zip` and `.7z` archives after downloading.
-When set to **Do Nothing**, the archive is saved as-is.
-
-### What's the difference between the downloaded game indicators?
-
-In Settings, the **Downloaded Games** option controls how already-downloaded games appear in the games list:
-
-- **Do Nothing** -No visual difference
-- **Mark** -Downloaded games are shown with a download icon
-- **Filter** -Downloaded games are hidden from the list entirely
+ - **Do Nothing** - No visual difference
+ - **Mark** - Downloaded games are shown with a download icon
+ - **Filter** - Downloaded games are hidden from the list entirely
---
## Box Art & Artwork
-### What are the different art types?
-
-The **Download Art Kind** setting controls which artwork Grout downloads from RomM:
+???+ question "What are the different art types?"
+ The **Download Art Kind** setting controls which artwork Grout downloads from RomM:
-- **Default** -Uses whatever artwork RomM provides as the default cover
-- **Box2D** -Flat front box art
-- **Box3D** -3D rendered box art with perspective
-- **MixImage** -Composite image combining box art, screenshots, and system logos
+ - **Default** - Uses whatever artwork RomM provides as the default cover
+ - **Box2D** - Flat front box art
+ - **Box3D** - 3D rendered box art with perspective
+ - **MixImage** - Composite image combining box art, screenshots, and system logos
-### Can I download artwork for games I already have?
-
-This is currently not supported.
-
-The **Preload Artwork** setting only downloads artwork to be displayed within Grout's interface.
-
-See [this GitHub issue](https://github.com/rommapp/grout/issues/130){:target="_blank"} to track this feature.
+??? question "Can I download artwork for games I already have?"
+ Yes! Use [Tools > Download Missing Art](settings.md#download-missing-art) to scan all mapped platforms and download cover art for any games missing cached artwork.
---
## Save Sync
-### How does Grout match my local games to RomM?
-
-Grout uses three methods in order:
-
-1. **Filename match** -If the local filename (without extension) matches a ROM in RomM, it's an instant match.
-2. **Hash match** -Grout computes CRC32/SHA1 hashes of the local ROM and queries RomM. Successful matches are remembered
- for future instant lookups.
-3. **Fuzzy title match** -Grout normalizes both names and compares them. If similarity is 80% or higher, you'll be asked
- to confirm the match.
+???+ question "How does Grout match my local games to RomM?"
+ Grout matches by **platform and filename** - if the local save filename (without extension) exactly matches a ROM's
+ filename in RomM for the same platform, it's considered a match. For the best experience, keep your local ROM
+ filenames consistent with the names in your RomM library.
-For more details, see the [Save Sync Guide](save-sync.md).
+ For more details, see the [Save Sync Guide](save-sync.md).
-### What's the difference between save files and save states?
+??? question "What's the difference between save files and save states?"
+ **Save files** (`.srm`, `.sav`, etc.) are created by the emulated game itself - like saving at a save point. These are
+ what Grout syncs.
-**Save files** (`.srm`, `.sav`, etc.) are created by the emulated game itself -like saving at a save point. These are
-what Grout syncs.
+ **Save states** are snapshots of the entire emulator state at a moment in time. These are emulator-specific and are
+ **not currently synced** by Grout.
-**Save states** are snapshots of the entire emulator state at a moment in time. These are emulator-specific and are *
-*not synced** by Grout.
-
-### Will my saves be overwritten?
-
-Grout always creates a backup of the existing local save before downloading a newer one from RomM. The newer save (by
-timestamp) always wins.
-
-### Why are some saves "unmatched"?
-
-A save is unmatched when Grout can't find a corresponding ROM in your RomM library. This can happen if:
-
-- The ROM was renamed locally and doesn't match any name or hash in RomM
-- The ROM isn't in your RomM library at all
-- A previous fuzzy match was declined (24-hour cooldown before re-prompting)
-
-The sync summary shows unmatched saves with diagnostic info to help you resolve them.
-
-### What's the difference between Manual and Automatic sync?
-
-- **Manual** -Press `Y` from the main menu to trigger a sync. A summary is shown when complete.
-- **Automatic** -Grout syncs in the background every time you launch the app. Progress is shown via status bar icons.
+??? question "Will my saves be overwritten?"
+ Grout always creates a backup of the existing local save before downloading a newer one from RomM. The newer save (by
+ timestamp) always wins.
---
## Platform Mappings
-### Can I change my platform mappings later?
-
-Yes. Go to Settings > **Directory Mappings** to reconfigure which local folder maps to each RomM platform.
-
-### What happens if I skip a platform during mapping?
+???+ question "Can I change my platform mappings later?"
+ Yes. Go to Settings > **Directory Mappings** to reconfigure which local folder maps to each RomM platform.
-Games for that platform won't be visible in Grout and can't be downloaded until a mapping is configured.
+??? question "What happens if I skip a platform during mapping?"
+ Games for that platform won't be visible in Grout and can't be downloaded until a mapping is configured.
---
## BIOS Files
-### How do I download BIOS files?
+???+ question "How do I download BIOS files?"
+ Navigate to a platform's game list. If the platform in RomM has BIOS files, Grout will show a prompt in the footer to press `Menu`.
+ From there Grout will list the BIOS files along with their status (Ready or Missing).
-Navigate to a platform's game list. If the platform in RomM has BIOS files, Grout will show a prompt in the footer to press `Menu`.
-From there Grout will list the BIOS files along with their status (Ready or Missing).
-
-### Not all platforms show a BIOS option. Why?
-
-Many platforms don't require BIOS files, and some may not have BIOS files available in your RomM library. The option
-only appears when applicable.
+??? question "Not all platforms show a BIOS option. Why?"
+ The option will only appear when the platform in RomM has BIOS files associated with it.
---
## Settings & Configuration
-### What is Kids Mode?
-
-Kids Mode hides the Settings and other advanced screens, leaving only game browsing and downloading. To temporarily
-access settings while Kids Mode is enabled, press `L1 + R1 + Menu`.
-
-### Will updating Grout erase my settings?
-
-No. The in-app updater and manual updates both preserve your `config.json` file, which contains your login credentials
-and platform mappings.
+???+ question "What is Kids Mode?"
+ Kids Mode hides Settings, Save Sync, Game Options, and BIOS downloads, leaving only game browsing and downloading.
+ To temporarily access these while Kids Mode is enabled, press `L1 + R1 + Menu` during the Grout splash screen.
-### What log level should I use?
+??? question "Will updating Grout erase my settings?"
+ No. The in-app updater and manual updates both preserve your `config.json` file, which contains your credentials
+ and platform mappings.
-- **Error** -Only shows errors. Use this for normal operation.
-- **Info** -Shows general activity. Useful for understanding what Grout is doing.
-- **Debug** -Verbose logging. Use this when troubleshooting issues or filing bug reports.
+??? question "What log level should I use?"
+ - **Error** - Only shows errors. Use this for normal operation.
+ - **Info** - Shows general activity. Useful for understanding what Grout is doing.
+ - **Debug** - Verbose logging. Use this when troubleshooting issues or filing bug reports.
---
## Troubleshooting
-### Where are the log files?
-
-Log files are stored alongside the Grout binary in a `logs` directory. The exact path depends on your firmware and
-installation location.
-
-### My cache seems wrong or outdated
-
-Go to Settings > Advanced > **Rebuild Cache**. This clears and rebuilds the local database from your RomM server.
-
-### Downloads are slow or failing
+???+ question "Where are the log files?"
+ Log files are stored alongside the Grout binary in a `logs` directory. The exact path depends on your firmware and
+ installation location.
-1. Increase the **Download Timeout** in Settings > Advanced.
-2. Check your Wi-Fi signal strength -handheld devices often have limited range.
-3. Verify your RomM server isn't under heavy load.
+??? question "My cache seems wrong or outdated"
+ Go to Settings > Advanced > **Rebuild Cache**. This clears and rebuilds the local database from your RomM server.
-### I found a bug or have a feature request
+??? question "Downloads are slow or failing"
+ 1. Increase the **Download Timeout** in Settings > Advanced.
+ 2. Check your Wi-Fi signal strength - handheld devices often have limited range.
+ 3. Verify your RomM server isn't under heavy load.
-Please [create an issue](https://github.com/rommapp/grout/issues/new/choose) on GitHub and fill out the template
-completely. Include your Grout version, RomM version, device, firmware, and relevant log output.
+??? question "I found a bug or have a feature request"
+ Please [create an issue](https://github.com/rommapp/grout/issues/new/choose) on GitHub and fill out the template
+ completely. Include your Grout version, RomM version, device, firmware, and relevant log output.
diff --git a/docs/usage/guide.md b/docs/usage/guide.md
index 12e0365c..e099d3d2 100644
--- a/docs/usage/guide.md
+++ b/docs/usage/guide.md
@@ -10,18 +10,18 @@ This guide walks you through using Grout to download games from your RomM instan
Grout attempts to be consistent with its controls. Here's a quick reference:
-| Button | Description |
-|--------------|-------------------------------------------|
-| `A` | Confirm / Select |
-| `B` | Back / Cancel |
-| `X` | Secondary action (Search, Settings) |
+| Button | Description |
+|--------------|----------------------------------------------------|
+| `A` | Confirm / Select |
+| `B` | Back / Cancel |
+| `X` | Secondary action (Search, Settings) |
| `Y` | Tertiary action (Filters, Save Sync, Game Options) |
-| `Start` | Confirm / Save settings |
-| `Select` | Toggle list mode (multi-select, reorder) |
-| `L1` / `R1` | Shoulder buttons (used in button combos) |
-| `Menu` | Context action (BIOS, also used in button combos) |
-| `Up/Down` | Navigate lists |
-| `Left/Right` | Cycle options / Jump pages in list |
+| `Start` | Confirm / Save settings |
+| `Select` | Toggle list mode (multi-select, reorder) |
+| `L1` / `R1` | Shoulder buttons (used in button combos) |
+| `Menu` | Context action (BIOS, also used in button combos) |
+| `Up/Down` | Navigate lists |
+| `Left/Right` | Cycle options / Jump pages in list |
## Status Bar Icons
@@ -32,23 +32,23 @@ The status bar displays icons to indicate background activity. Here's what each
These icons appear during the [background cache sync](#background-cache-sync) process:
-| Icon | Description |
-|---------------------------------------------------------------------------------------------------|---|
-| { width="50" } | Cache sync in progress |
+| Icon | Description |
+|---------------------------------------------------------------------------------------------------|-----------------------------------|
+| { width="50" } | Cache sync in progress |
| { width="50" } | Cache sync completed successfully |
-| { width="50" } | Cache sync encountered an error |
+| { width="50" } | Cache sync encountered an error |
### Save Sync Icons
These icons appear during [Save Sync](save-sync.md) operations:
-| Icon | Description |
-|---|---|
-| { width="50" } | Save sync in progress |
-| { width="50" } | Uploading saves to RomM |
-| { width="50" } | Downloading saves from RomM |
-| { width="50" } | Save sync completed successfully |
-| { width="50" } | Save sync encountered an error, check the log file |
+| Icon | Description |
+|---------------------------------------------------------------------------------------------------|----------------------------------------------------|
+| { width="50" } | Save sync in progress |
+| { width="50" } | Uploading saves to RomM |
+| { width="50" } | Downloading saves from RomM |
+| { width="50" } | Save sync completed successfully |
+| { width="50" } | Save sync encountered an error, check the log file |
## First Launch and Login
@@ -73,34 +73,72 @@ Use `Left/Right` to cycle through the available languages. Press `A` to confirm
You can change your language later from the [Settings](settings.md#language).
-### Login
+### Server Connection
-
+
-1. **Protocol** - Choose between HTTP and HTTPS.
+First, enter your server connection details:
+
+1. **Protocol** - Choose between HTTP and HTTPS using `Left/Right`.
2. **Hostname** - Enter your RomM server address without the protocol.
-3. **Port (optional)** - If your RomM instance runs on a non-standard port, enter it here.
-4. **Username** - Your RomM username.
-5. **Password** - Your RomM password.
-6. **SSL Certificates** (HTTPS only) - Choose whether to verify SSL certificates:
+3. **Port** (optional) - If your RomM instance runs on a non-standard port, enter it here.
+4. **SSL Certificates** (HTTPS only) - Choose whether to verify SSL certificates:
- **Verify** - Validate SSL certificates (recommended)
- **Skip Verification** - Skip SSL certificate validation (useful for self-signed certificates or internal CAs)
-Use the left and right buttons to cycle through options for Protocol. For the text fields (Hostname, Username,
-Password), pressing `A` will open an on-screen keyboard.
+### Authentication
-Press `Start` to login. If your credentials are correct and Grout can reach your server, you'll move
-to the next step. If something goes wrong, you'll get a message telling you what happened, and you can try again.
+After connecting to your server, choose an authentication method.
+
+!!! warning
+ The next Grout release will phase out password-based authentication. You will be required to use an API Token.
+
+#### Pairing Code
+
+
+
+API tokens are the recommended way to authenticate with your RomM server. They are more secure than passwords, can be
+revoked individually, and work with all authentication setups including OIDC.
+
+1. Select **Pair with Token** as the authentication method
+2. Grout will display a pairing code on screen
+3. In your RomM web interface, go to **Settings > API Tokens** and enter the pairing code
+4. Once paired, Grout will automatically receive an API token
+
+The token is saved to your device and used for all future connections.
!!! tip
- If you're using a self-signed certificate or a certificate from an internal Certificate Authority, set
- **SSL Certificates** to **Skip Verification** to avoid connection errors.
+ You can view token details (name, expiry) on the [Grout Info](settings.md#main-settings) screen.
-!!! important
- **OIDC Users:** If your RomM instance uses OIDC authentication, you can still use Grout by setting a password for your
- user account. Grout will support API Keys once they are available in RomM. For more details,
- see [this GitHub issue](https://github.com/rommapp/romm/issues/1767#issuecomment-2744215262).
+**Required token permissions:**
+When creating a token for Grout, ensure it has the following scopes:
+
+| Scope | Purpose |
+|---------------------|-------------------------------|
+| `me.read` | Read your user profile |
+| `me.write` | Update user preferences |
+| `roms.read` | Browse and search ROMs |
+| `roms.user.read` | Read your saves and states |
+| `roms.user.write` | Upload and sync saves |
+| `platforms.read` | List platforms |
+| `collections.read` | Browse collections |
+| `collections.write` | Manage collection preferences |
+| `assets.read` | Download artwork and covers |
+| `assets.write` | Upload screenshots |
+| `devices.read` | Read device registrations |
+| `devices.write` | Register and update devices |
+| `firmware.read` | Download BIOS files |
+
+#### Username and Password
+
+
+
+1. **Username** - Your RomM username.
+2. **Password** - Your RomM password.
+
+Press `Start` to login. If your credentials are correct and Grout can reach your server, you'll move
+to the next step. If something goes wrong, you'll get a message telling you what happened, and you can try again.
## Platform Directory Mapping
@@ -131,7 +169,7 @@ pre-selected. If not, it'll suggest creating one with the correct name for your
When you select **Custom...**, an on-screen keyboard appears where you can type your desired folder name. If you return
to this screen later, any custom folder names you entered will be remembered and shown in place of "Custom...".
-You can change these mappings later from [Settings](settings.md).
+You can change these mappings later from [Settings](settings.md#directory-mappings).
### Mappings Reference
@@ -143,6 +181,7 @@ different folder naming conventions. Use these references to see the exact folde
- [NextUI](../platforms/nextui.md) - Descriptive names with tags (e.g., `Game Boy (GB)`)
- [ROCKNIX](../platforms/rocknix.md) - ES-DE style folder names (e.g., `gb`, `snes`, `psx`)
- [Spruce](../platforms/spruce.md) - Uppercase short codes (e.g., `GB`, `SFC`, `PS`)
+- [Batocera](../platforms/BATOCERA.md) - ES-DE style folder names (e.g., `gb`, `megadrive`, `psx`)
## Background Cache Sync
@@ -339,8 +378,8 @@ different revisions (Rev A, Rev B). When a game has multiple versions available:
a sync automatically. See [Save Slots](save-sync.md#save-slots) for details.
!!! important
- **Kids Mode Impact:** When Kids Mode is enabled, the Game Options screen is hidden.
- See [Settings Reference](settings.md#kids-mode) to learn how to temporarily or permanently disable Kids Mode.
+ **Kid Mode Impact:** When Kid Mode is enabled, the Game Options screen is hidden.
+ See [Settings Reference](settings.md#kid-mode) to learn how to temporarily or permanently disable Kid Mode.
## Downloading Games
@@ -377,8 +416,8 @@ Many emulators require BIOS files to function properly. Grout can download these
the correct location on your device.
!!! important
- **Kids Mode Impact:** When Kids Mode is enabled, the BIOS download screen is hidden.
- See [Settings Reference](settings.md#kids-mode) to learn how to temporarily or permanently disable Kids Mode.
+ **Kid Mode Impact:** When Kid Mode is enabled, the BIOS download screen is hidden.
+ See [Settings Reference](settings.md#kid-mode) to learn how to temporarily or permanently disable Kid Mode.

@@ -386,10 +425,3 @@ the correct location on your device.
From the game list, press `Menu` on a platform that has BIOS files available in your RomM library. You'll see
a "BIOS" option in the footer when BIOS files are available for that platform.
-
-
-## Spread Joy!
-
-A significant amount of time has been spent building Grout.
-
-If you enjoy using Grout and feel inclined to pay it forward, go do something nice for someone!
diff --git a/docs/usage/save-sync.md b/docs/usage/save-sync.md
index 5e5b1c7e..61ca643c 100644
--- a/docs/usage/save-sync.md
+++ b/docs/usage/save-sync.md
@@ -39,60 +39,13 @@ When you run Save Sync, Grout:
## ROM Matching
-For Save Sync to work, Grout must match your local ROM files with ROMs in your RomM library. Grout uses several methods
-to find matches, tried in order:
+For Save Sync to work, Grout must match your local save files with ROMs in your RomM library.
-### 1. Filename Match
-
-If the local ROM filename (without extension) exactly matches a ROM's filename in RomM, it's considered a match. This is
-the fastest and most common matching method.
-
-### 2. Hash Match
-
-If filename matching fails, Grout can compute the CRC32 or SHA1 hash of your local ROM file and compare it against hashes
-stored in RomM. This is useful when:
-
-- Your local ROM has a different filename than in RomM
-- You renamed a ROM locally, but it's the same file
-
-### 3. Fuzzy Title Match
-
-If both filename and hash matching fail, Grout attempts to match based on title similarity. This helps when ROM names
-differ slightly between your device and RomM.
-
-**What fuzzy matching handles:**
-
-- **Accented characters** - "Pokemon Red" matches "Pokemon Red"
-- **User-added suffixes** - "Pokemon Red Nuzlocke" matches "Pokemon Red Version" (both share "Pokemon Red" as a common
- prefix)
-- **Naming convention differences** - "Pokemon - Red Version" matches "Pokemon Red"
-
-When a potential match is found with at least **80% similarity**, Grout displays a confirmation prompt:
-
-```
-+-------------------------------------+
-| Potential Match Found |
-| |
-| Local: "Pokemon Red Nuzlocke" |
-| Match: "Pokemon Red Version" |
-| Similarity: 85% |
-| |
-| Is this the same game? |
-| |
-| [B] No [X] Yes |
-+-------------------------------------+
-```
-
-- Press `X` to confirm the match
-- Press `B` to decline
-
-**Confirmed matches are remembered** - once you confirm a fuzzy match, Grout saves the association and won't ask again
-for that ROM. This is useful for maintaining separate saves (like a Nuzlocke run alongside a regular playthrough).
-
-**Declined matches have a cooldown** - if you decline a fuzzy match, Grout won't prompt you again for 24 hours.
+Grout matches by **platform and filename** - if the local save filename (without extension) exactly matches a ROM's
+filename in RomM for the same platform, it's considered a match.
!!! tip
- When you refresh the games cache, any saved matches for ROMs that no longer exist in RomM are automatically cleaned up.
+ For the best experience, keep your local ROM filenames consistent with the names in your RomM library.
---
@@ -115,6 +68,7 @@ The newer save (based on last modified time) determines the action:
- **If the local save is newer:** It is uploaded to RomM with the last modified timestamp appended to the filename
- **If the RomM save is newer:**
- The current local save is backed up to `.backup/` within the platform's save directory
+ - If the backup fails, the download is aborted to protect your local save
- The RomM save is downloaded to your device
### Conflicts
diff --git a/docs/usage/settings.md b/docs/usage/settings.md
index 8c05cfe5..a3d2d188 100644
--- a/docs/usage/settings.md
+++ b/docs/usage/settings.md
@@ -3,28 +3,33 @@
Press `X` from the main menu to access Settings.
!!! important
- **Kids Mode Impact:** When Kids Mode is enabled, the Settings screen is hidden. To access settings temporarily, press `L1` + `R1` + `Menu` during the Grout splash screen. See [Kids Mode](#kids-mode) for details.
+ **Kid Mode Impact:** When Kid Mode is enabled, the Settings screen is hidden. To access settings temporarily, press `L1` + `R1` + `Menu` during the Grout splash screen. See [Kid Mode](#kid-mode) for details.
---
## Main Settings
+**Switch to API Token** - Shown only when using password authentication. Initiates the [pairing code flow](guide.md#pairing-code) to switch your connection to token-based authentication.
+
**General** - Opens a sub-menu for general display and download options.
See [General Settings](#general-settings) below.
**Collections** - Opens a sub-menu for configuring collection display options.
See [Collections Settings](#collections-settings) below.
-**Directory Mappings** - Change which device directories are mapped to which RomM platforms. This takes you back to
-the platform mapping screen that appeared during setup.
+**Directory Mappings** - Change which device directories are mapped to which RomM platforms.
+See [Directory Mappings](#directory-mappings) below.
**Save Sync** - Opens a sub-menu for configuring save sync. See [Save Sync Settings](#save-sync-settings) below.
+**Tools** - Opens a sub-menu for artwork management and parental controls. See [Tools](#tools) below.
+
**Advanced** - Opens a sub-menu for advanced configuration options. See [Advanced Settings](#advanced-settings) below.
-**Grout Info** - View version information, build details, server connection info, and the GitHub repository QR code.
+**Grout Info** - View version information, build details, server connection info (including API token name and expiry
+when using token authentication), and the GitHub repository QR code.
-**Check for Updates** - Will allow Grout to update itself.
+**Check for Updates** - Check for and install Grout updates.
---
@@ -102,6 +107,34 @@ Controls how collections display their games:
---
+## Directory Mappings
+
+Opens the platform directory mapping screen, which is the same screen shown during initial setup. This lets you change
+which device directories are mapped to which RomM platforms.
+
+For each platform, you can select:
+
+- **Skip** - Don't map this platform. Games from this platform won't be available to download.
+- **Create {Directory Name}** - Create a new directory for this platform. Grout suggests directory names that match your
+ custom firmware's expected structure.
+- **/{Existing Directory}** - Map to an existing directory on your device.
+- **Custom...** - Enter a custom folder name using the on-screen keyboard.
+
+For detailed documentation on platform mapping, see the [User Guide](guide.md#platform-directory-mapping).
+
+### Mappings Reference
+
+Each CFW uses different folder naming conventions:
+
+- [KNULLI](../platforms/knulli.md) - ES-DE style folder names (e.g., `gb`, `snes`, `psx`)
+- [muOS](../platforms/muos.md) - Mixed short codes and descriptive names (e.g., `gb`, `Nintendo Game Boy`)
+- [NextUI](../platforms/nextui.md) - Descriptive names with tags (e.g., `Game Boy (GB)`)
+- [ROCKNIX](../platforms/rocknix.md) - ES-DE style folder names (e.g., `gb`, `snes`, `psx`)
+- [Spruce](../platforms/spruce.md) - Uppercase short codes (e.g., `GB`, `SFC`, `PS`)
+- [Batocera](../platforms/BATOCERA.md) - ES-DE style folder names (e.g., `gb`, `megadrive`, `psx`)
+
+---
+
## Save Sync Settings
This sub-menu configures save synchronization. For complete save sync documentation, see the [Save Sync Guide](save-sync.md).
@@ -111,6 +144,11 @@ This sub-menu configures save synchronization. For complete save sync documentat
Register or rename this device with your RomM server. Each device needs a unique name so RomM can track which saves
belong to which device. Selecting this opens a keyboard to enter or change the device name.
+### Save Sync Mappings
+
+Configure which emulator save directory is used for each platform. This tells Grout where to find and place save files
+on your device. You can override per-game mappings from the [Game Options](guide.md#game-options) screen.
+
### Save Backups
Controls how many backup copies of local saves are retained when a newer save is downloaded from the server:
@@ -122,6 +160,33 @@ Backups are stored in a `.backup/` directory within each platform's save directo
---
+## Tools
+
+This sub-menu contains artwork management and parental controls.
+
+### Download Missing Art
+
+Scans all mapped platforms and downloads cover art for any games that don't already have cached artwork. Useful after
+adding new games to your library.
+
+Note that this artwork is only displayed within Grout's interface - it does not affect the artwork shown in your CFW's
+game list.
+
+### Kid Mode
+
+Hides some of the more advanced features for a simplified experience. When enabled, Kid Mode will hide:
+
+- The Settings screen
+- The Save Sync screen
+- The Game Options screen
+- The BIOS download screen
+
+**Temporary Override:** You can temporarily disable Kid Mode for a single session by pressing `L1` + `R1` + `Menu` during the Grout splash screen.
+
+**Permanent Disable:** Return to this menu and turn off Kid Mode.
+
+---
+
## Advanced Settings
This sub-menu contains advanced configuration and system settings.
@@ -153,6 +218,11 @@ slow connections. Options range from 15 to 120 minutes.
How long Grout waits for responses from your RomM server before giving up. If you have a slow
connection or are a completionist with a heavily loaded server, increase this. Options range from 15 to 300 seconds.
+### Server Address
+
+Change the protocol, hostname, or port of your RomM server without logging out. Useful if your server's address
+changes or you need to switch between HTTP and HTTPS.
+
### Release Channel
Controls which release channel Grout uses for updates:
@@ -161,18 +231,6 @@ Controls which release channel Grout uses for updates:
- **Stable** - Only receive stable releases
- **Beta** - Receive beta releases for early access to new features
-### Kids Mode
-
-Hides some of the more advanced settings for a simplified experience. When enabled, Kids Mode will hide:
-
-- The Settings screen
-- The BIOS download screen
-- The Game Options screen
-
-**Temporary Override:** You can temporarily disable Kids Mode for a single session by pressing `L1` + `R1` + `Menu` during the Grout splash screen.
-
-**Permanent Disable:** Return to this menu and turn off Kids Mode.
-
### Log Level
Controls the verbosity of Grout's log output:
diff --git a/docs/versions.json b/docs/versions.json
new file mode 100644
index 00000000..93adbe53
--- /dev/null
+++ b/docs/versions.json
@@ -0,0 +1,5 @@
+{
+ "stable": null,
+ "beta": null,
+ "romm": {}
+}
diff --git a/go.mod b/go.mod
index dc964992..78e4970c 100644
--- a/go.mod
+++ b/go.mod
@@ -4,7 +4,7 @@ go 1.25.6
require (
github.com/BrandonKowalski/certifiable v1.3.0
- github.com/BrandonKowalski/gabagool/v2 v2.13.0
+ github.com/BrandonKowalski/gabagool/v2 v2.13.7
github.com/beevik/etree v1.6.0
github.com/bodgit/sevenzip v1.6.1
github.com/nicksnyder/go-i18n/v2 v2.6.1
@@ -13,7 +13,7 @@ require (
go.uber.org/atomic v1.11.0
golang.org/x/image v0.37.0
golang.org/x/text v0.35.0
- modernc.org/sqlite v1.46.1
+ modernc.org/sqlite v1.47.0
)
require (
@@ -25,7 +25,7 @@ require (
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83 // indirect
- github.com/klauspost/compress v1.18.4 // indirect
+ github.com/klauspost/compress v1.18.5 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pierrec/lz4/v4 v4.1.26 // indirect
@@ -40,7 +40,6 @@ require (
go4.org v0.0.0-20260112195520-a5071408f32f // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sys v0.42.0 // indirect
- golang.org/x/tools v0.42.0 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
diff --git a/go.sum b/go.sum
index d6a7a837..0b44f499 100644
--- a/go.sum
+++ b/go.sum
@@ -1,9 +1,9 @@
github.com/BrandonKowalski/certifiable v1.3.0 h1:o4VbMMVqBQ8kYFqUFD7J3P6tJg9dVKWFVXx2GJdeAug=
github.com/BrandonKowalski/certifiable v1.3.0/go.mod h1:fnYhDBTxp3ZeTYFkWDXSAo5m8ytUwGG/3dZHSzDH9UU=
-github.com/BrandonKowalski/gabagool/v2 v2.12.0 h1:jzVBPfPli1TUKFbZRXOHaIKxdSXFLDcJLBICNlfiTA8=
-github.com/BrandonKowalski/gabagool/v2 v2.12.0/go.mod h1:Iq/YFTz7+w8O78IE21kOkHbg3a4ZD2mjcd0S/jVhI94=
-github.com/BrandonKowalski/gabagool/v2 v2.13.0 h1:o71+XMenU2nUsXKUB1TqnQmeFzudwrZRs0pFWQIjbWU=
-github.com/BrandonKowalski/gabagool/v2 v2.13.0/go.mod h1:Iq/YFTz7+w8O78IE21kOkHbg3a4ZD2mjcd0S/jVhI94=
+github.com/BrandonKowalski/gabagool/v2 v2.13.6 h1:3yuB6+7/Q/veEllq6+NiNEmLULXW5ZBfEXeDUIp9fu4=
+github.com/BrandonKowalski/gabagool/v2 v2.13.6/go.mod h1:Iq/YFTz7+w8O78IE21kOkHbg3a4ZD2mjcd0S/jVhI94=
+github.com/BrandonKowalski/gabagool/v2 v2.13.7 h1:A/pGqtU1QV3aUXFtLxyWcx6zp0l0jjLLK5Lptmxjjkk=
+github.com/BrandonKowalski/gabagool/v2 v2.13.7/go.mod h1:Iq/YFTz7+w8O78IE21kOkHbg3a4ZD2mjcd0S/jVhI94=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
@@ -29,16 +29,14 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83 h1:B+A58zGFuDrvEZpPN+yS6swJA0nzqgZvDzgl/OPyefU=
github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83/go.mod h1:iHAf8OIncO2gcQ8XOjS7CMJ2aPbX2Bs0wl5pZyanEqk=
-github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
-github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
+github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
+github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
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/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
-github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0=
-github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY=
github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/piglig/go-qr v0.2.6 h1:oE8v9NGXHWahoFrnS44xsq0Lq0oIRIV7gMBM8cfRGwE=
@@ -77,26 +75,17 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw=
go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0=
-golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
-golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA=
golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
-golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
-golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
-golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
-golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
+golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
-golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
-golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
-golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
@@ -108,9 +97,8 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
-modernc.org/ccgo/v4 v4.31.0 h1:/bsaxqdgX3gy/0DboxcvWrc3NpzH+6wpFfI/ZaA/hrg=
-modernc.org/ccgo/v4 v4.31.0/go.mod h1:jKe8kPBjIN/VdGTVqARTQ8N1gAziBmiISY8j5HoKwjg=
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
+modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
@@ -119,8 +107,6 @@ modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
-modernc.org/libc v1.69.0 h1:YQJ5QMSReTgQ3QFmI0dudfjXIjCcYTUxcH8/9P9f0D8=
-modernc.org/libc v1.69.0/go.mod h1:YfLLduUEbodNV2xLU5JOnRHBTAHVHsVW3bVYGw0ZCV4=
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
@@ -131,8 +117,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
-modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
-modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
+modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk=
+modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
diff --git a/internal/artutil/artutil.go b/internal/artutil/artutil.go
index 148ef52f..65c05b49 100644
--- a/internal/artutil/artutil.go
+++ b/internal/artutil/artutil.go
@@ -9,6 +9,7 @@ const (
ArtKindBox3D ArtKind = "Box3D"
ArtKindMixImage ArtKind = "Miximage"
ArtKindMarquee ArtKind = "Marquee"
+ ArtKindLogo ArtKind = "Logo"
ArtKindTitle ArtKind = "Title"
ArtKindScreenshot ArtKind = "Screenshot"
ArtKindVideo ArtKind = "Video"
diff --git a/internal/config.go b/internal/config.go
index 3a4ccc2e..71057e9e 100644
--- a/internal/config.go
+++ b/internal/config.go
@@ -17,6 +17,43 @@ import (
var kidModeEnabled atomic.Bool
+type AdditionalDownloads struct {
+ Marquee artutil.ArtKind `json:"marquee,omitempty"`
+ Video bool `json:"video,omitempty"`
+ Thumbnail artutil.ArtKind `json:"thumbnail,omitempty"`
+ Bezel bool `json:"bezel,omitempty"`
+ Manual bool `json:"manual,omitempty"`
+ BoxBack bool `json:"box_back,omitempty"`
+ Fanart bool `json:"fanart,omitempty"`
+}
+
+// DurationSeconds is a time.Duration that marshals to/from JSON as whole seconds.
+// Existing configs with nanosecond values are handled by detecting large values on unmarshal.
+type DurationSeconds time.Duration
+
+func (d DurationSeconds) MarshalJSON() ([]byte, error) {
+ return json.Marshal(int64(time.Duration(d).Seconds()))
+}
+
+func (d *DurationSeconds) UnmarshalJSON(b []byte) error {
+ var raw int64
+ if err := json.Unmarshal(b, &raw); err != nil {
+ return err
+ }
+ // Values over 1,000,000 are nanoseconds from old configs (e.g. 1800000000000 = 30min).
+ // Convert them to the equivalent duration directly.
+ if raw > 1_000_000 {
+ *d = DurationSeconds(time.Duration(raw))
+ } else {
+ *d = DurationSeconds(time.Duration(raw) * time.Second)
+ }
+ return nil
+}
+
+func (d DurationSeconds) Duration() time.Duration {
+ return time.Duration(d)
+}
+
type Config struct {
Hosts []romm.Host `json:"hosts,omitempty"`
DirectoryMappings map[string]DirectoryMapping `json:"directory_mappings,omitempty"`
@@ -27,8 +64,8 @@ type Config struct {
ShowSmartCollections bool `json:"show_smart_collections"`
ShowVirtualCollections bool `json:"show_virtual_collections"`
DownloadedGames DownloadedGamesMode `json:"downloaded_games,omitempty"`
- ApiTimeout time.Duration `json:"api_timeout"`
- DownloadTimeout time.Duration `json:"download_timeout"`
+ ApiTimeout DurationSeconds `json:"api_timeout"`
+ DownloadTimeout DurationSeconds `json:"download_timeout"`
LogLevel LogLevel `json:"log_level,omitempty"`
Language string `json:"language,omitempty"`
CollectionView CollectionView `json:"collection_view,omitempty"`
@@ -37,6 +74,7 @@ type Config struct {
ArtKind artutil.ArtKind `json:"art_kind,omitempty"`
DownloadArtScreenshotPreview bool `json:"download_art_screenshot_preview,omitempty"`
DownloadSplashArt artutil.ArtKind `json:"download_splash_art,omitempty"`
+ AdditionalDownloads AdditionalDownloads `json:"additional_downloads,omitempty"`
SwapFaceButtons bool `json:"swap_face_buttons,omitempty"`
PlatformOrder []string `json:"platform_order,omitempty"`
@@ -87,11 +125,16 @@ func LoadConfig() (*Config, error) {
}
if config.ApiTimeout == 0 {
- config.ApiTimeout = 30 * time.Minute
+ config.ApiTimeout = DurationSeconds(30 * time.Second)
}
if config.DownloadTimeout == 0 {
- config.DownloadTimeout = 60 * time.Minute
+ config.DownloadTimeout = DurationSeconds(60 * time.Minute)
+ }
+
+ // Clamp API timeout to valid picker range (15s–300s)
+ if config.ApiTimeout.Duration() > 300*time.Second {
+ config.ApiTimeout = DurationSeconds(30 * time.Second)
}
if config.Language == "" {
@@ -110,6 +153,14 @@ func LoadConfig() (*Config, error) {
config.ArtKind = artutil.ArtKindDefault
}
+ if config.AdditionalDownloads.Thumbnail == "" {
+ config.AdditionalDownloads.Thumbnail = artutil.ArtKindNone
+ }
+
+ if config.AdditionalDownloads.Marquee == "" {
+ config.AdditionalDownloads.Marquee = artutil.ArtKindNone
+ }
+
// Load slot preferences from dedicated file
config.SlotPreferences = LoadSlotPreferences()
@@ -141,6 +192,14 @@ func SaveConfig(config *Config) error {
config.ArtKind = artutil.ArtKindDefault
}
+ if config.AdditionalDownloads.Thumbnail == "" {
+ config.AdditionalDownloads.Thumbnail = artutil.ArtKindNone
+ }
+
+ if config.AdditionalDownloads.Marquee == "" {
+ config.AdditionalDownloads.Marquee = artutil.ArtKindNone
+ }
+
gaba.SetRawLogLevel(string(config.LogLevel))
if err := i18n.SetWithCode(config.Language); err != nil {
@@ -243,7 +302,7 @@ func (c *Config) SetSlotPreference(romID int, slot string) {
}
}
-func (c Config) GetApiTimeout() time.Duration { return c.ApiTimeout }
+func (c Config) GetApiTimeout() time.Duration { return c.ApiTimeout.Duration() }
func (c Config) GetShowCollections() bool { return c.ShowRegularCollections }
func (c Config) GetShowSmartCollections() bool { return c.ShowSmartCollections }
func (c Config) GetShowVirtualCollections() bool { return c.ShowVirtualCollections }
@@ -305,6 +364,41 @@ func (c Config) GetArtSplashDirectory(platform romm.Platform) string {
return cfw.GetArtSplashDirectory(romDir, platform.FSSlug, platform.Name)
}
+func (c Config) GetArtMarqueeDirectory(platform romm.Platform) string {
+ romDir := c.GetPlatformRomDirectory(platform)
+ return cfw.GetArtMarqueeDirectory(romDir, platform.FSSlug, platform.Name)
+}
+
+func (c Config) GetArtVideoDirectory(platform romm.Platform) string {
+ romDir := c.GetPlatformRomDirectory(platform)
+ return cfw.GetArtVideoDirectory(romDir, platform.FSSlug, platform.Name)
+}
+
+func (c Config) GetArtThumbnailDirectory(platform romm.Platform) string {
+ romDir := c.GetPlatformRomDirectory(platform)
+ return cfw.GetArtThumbnailDirectory(romDir, platform.FSSlug, platform.Name)
+}
+
+func (c Config) GetArtBezelDirectory(platform romm.Platform) string {
+ romDir := c.GetPlatformRomDirectory(platform)
+ return cfw.GetArtBezelDirectory(romDir, platform.FSSlug, platform.Name)
+}
+
+func (c Config) GetManualDirectory(platform romm.Platform) string {
+ romDir := c.GetPlatformRomDirectory(platform)
+ return cfw.GetManualDirectory(romDir, platform.FSSlug, platform.Name)
+}
+
+func (c Config) GetFanartDirectory(platform romm.Platform) string {
+ romDir := c.GetPlatformRomDirectory(platform)
+ return cfw.GetFanartDirectory(romDir, platform.FSSlug, platform.Name)
+}
+
+func (c Config) GetBoxbackDirectory(platform romm.Platform) string {
+ romDir := c.GetPlatformRomDirectory(platform)
+ return cfw.GetBoxbackDirectory(romDir, platform.FSSlug, platform.Name)
+}
+
func (c Config) ShowCollections(host romm.Host) bool {
if !c.ShowRegularCollections && !c.ShowSmartCollections && !c.ShowVirtualCollections {
return false
@@ -316,7 +410,7 @@ func (c Config) ShowCollections(host romm.Host) bool {
}
// Fallback to network check
- rc := romm.NewClientFromHost(host, c.ApiTimeout)
+ rc := romm.NewClientFromHost(host, c.ApiTimeout.Duration())
if c.ShowRegularCollections {
col, err := rc.GetCollections()
diff --git a/internal/environment/environment.go b/internal/environment/environment.go
index 08e3d142..2e37a4d1 100644
--- a/internal/environment/environment.go
+++ b/internal/environment/environment.go
@@ -5,3 +5,7 @@ import "os"
func IsDevelopment() bool {
return os.Getenv("ENVIRONMENT") == "DEV"
}
+
+func IsMiyoo() bool {
+ return os.Getenv("IS_MIYOO") == "1"
+}
diff --git a/internal/gamelist/add.go b/internal/gamelist/add.go
index cd354f96..e842ad44 100644
--- a/internal/gamelist/add.go
+++ b/internal/gamelist/add.go
@@ -6,6 +6,7 @@ import (
"grout/internal/stringutil"
"grout/romm"
"os"
+ "strconv"
"strings"
"time"
@@ -17,9 +18,20 @@ type GameListEntry struct {
Path string
}
+type artLocation struct {
+ ImagePath string
+ MarqueePath string
+ VideoPath string
+ BezelPath string
+ ManualPath string
+ BoxBackPath string
+ FanartPath string
+ ThumbnailPath string
+}
+
type RomGameEntry struct {
Game *romm.Rom
- ArtLocation string
+ ArtLocation artLocation
GamePath string
RomDirectory string
Platform *romm.Platform
@@ -40,8 +52,36 @@ func (gl *GameList) AddRomGame(entry RomGameEntry) {
gameMetadata[ReleaseDateElement] = fmt.Sprintf("%s", formatted)
}
- if entry.ArtLocation != "" {
- gameMetadata[ImageElement] = entry.ArtLocation
+ if entry.ArtLocation.ImagePath != "" {
+ gameMetadata[ImageElement] = entry.ArtLocation.ImagePath
+ }
+
+ if entry.ArtLocation.ThumbnailPath != "" {
+ gameMetadata[ThumbnailElement] = entry.ArtLocation.ThumbnailPath
+ }
+
+ if entry.ArtLocation.MarqueePath != "" {
+ gameMetadata[MarqueeElement] = entry.ArtLocation.MarqueePath
+ }
+
+ if entry.ArtLocation.VideoPath != "" {
+ gameMetadata[VideoElement] = entry.ArtLocation.VideoPath
+ }
+
+ if entry.ArtLocation.BezelPath != "" {
+ gameMetadata[BezelElement] = entry.ArtLocation.BezelPath
+ }
+
+ if entry.ArtLocation.ManualPath != "" {
+ gameMetadata[ManualElement] = entry.ArtLocation.ManualPath
+ }
+
+ if entry.ArtLocation.BoxBackPath != "" {
+ gameMetadata[BoxbackElement] = entry.ArtLocation.BoxBackPath
+ }
+
+ if entry.ArtLocation.FanartPath != "" {
+ gameMetadata[FanartElement] = entry.ArtLocation.FanartPath
}
if entry.GamePath != "" {
@@ -73,6 +113,20 @@ func (gl *GameList) AddRomGame(entry RomGameEntry) {
gameMetadata[DeveloperElement] = strings.Join(entry.Game.ScreenScraperMetadata.Companies, ", ")
}
+ if entry.Game.ScreenScraperID > 0 {
+ screenscraperID := strconv.Itoa(entry.Game.ScreenScraperID)
+ gameMetadata[ScraperIDElement] = screenscraperID
+ gl.SetGameID(entry.Game.Name, screenscraperID)
+ }
+
+ if entry.Game.RetroAchievementsID > 0 {
+ gameMetadata[CheevosIDElement] = strconv.Itoa(entry.Game.RetroAchievementsID)
+ }
+
+ if entry.Game.RetroAchievementsHash != "" {
+ gameMetadata[CheevosHashElement] = entry.Game.RetroAchievementsHash
+ }
+
gl.AdddOrUpdateEntry(entry.Game.Name, gameMetadata)
}
diff --git a/internal/gamelist/gamelist.go b/internal/gamelist/gamelist.go
index 5e9e58c8..ea078a88 100644
--- a/internal/gamelist/gamelist.go
+++ b/internal/gamelist/gamelist.go
@@ -20,9 +20,16 @@ const (
MD5Element = "md5"
VideoElement = "video"
MarqueeElement = "marquee"
+ BezelElement = "bezel"
+ ManualElement = "manual"
+ FanartElement = "fanart"
+ BoxbackElement = "boxback"
ThumbnailElement = "thumbnail"
LangElement = "lang"
RegionElement = "region"
+ CheevosHashElement = "cheevosHash"
+ CheevosIDElement = "cheevosId"
+ ScraperIDElement = "scraperId"
)
type FileName string
@@ -129,3 +136,12 @@ func (gl *GameList) AdddOrUpdateEntry(name string, info map[string]string) {
}
}
+
+func (gl *GameList) SetGameID(name, id string) {
+ game := gl.GetGameElementByName(name)
+ if game == nil {
+ return
+ }
+
+ game.CreateAttr("id", id)
+}
diff --git a/mkdocs.yml b/mkdocs.yml
index 5af6f0a6..bfbd8a4d 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -66,22 +66,28 @@ nav:
- Home: index.md
- Quick Start:
- getting-started/index.md
- - muOS: getting-started/install-muos.md
+ - Allium: getting-started/install-allium.md
+ - Batocera: getting-started/install-batocera.md
- Knulli: getting-started/install-knulli.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
- - NextUI: getting-started/install-nextui.md
- TrimUI: getting-started/install-trimui.md
- Usage:
- User Guide: usage/guide.md
- Settings Reference: usage/settings.md
- Save Sync: usage/save-sync.md
- CFW Specific Info:
- - muOS: platforms/muos.md
+ - Allium: platforms/allium.md
+ - Batocera: platforms/BATOCERA.md
- Knulli: platforms/knulli.md
+ - muOS: platforms/muos.md
+ - NextUI: platforms/nextui.md
+ - Onion: platforms/onion.md
- ROCKNIX: platforms/rocknix.md
- Spruce: platforms/spruce.md
- - NextUI: platforms/nextui.md
- TrimUI: platforms/trimui.md
- Contributing:
- contributing/index.md
diff --git a/resources/locales/active.de.toml b/resources/locales/active.de.toml
index c79b6ca0..6accf466 100644
--- a/resources/locales/active.de.toml
+++ b/resources/locales/active.de.toml
@@ -2,10 +2,6 @@
hash = "sha1-cde437c528224c2a08f0719f3e8b923a17642295"
other = "%d Artwork-Bilder erfolgreich heruntergeladen."
-[artwork_sync_confirm]
-hash = "sha1-19bd664135df62cf7f839f7fa5810b40f6e3e007"
-other = "Artwork für %d Spiele herunterladen?"
-
[artwork_sync_failed]
hash = "sha1-869ec904d8bde854dcf9489791abb3eef66818f9"
other = "Fehler beim Herunterladen von %d Artwork-Bildern."
@@ -47,10 +43,6 @@ other = "Select Platforms"
hash = "sha1-344ccb404588981250e2016ac355ac798d0b7e31"
other = "%d BIOS-Datei(en) erfolgreich heruntergeladen."
-[bios_download_complete_with_warnings]
-hash = "sha1-aebdf53b01e8b0931fcbddce77db5b1477cc151f"
-other = "%d BIOS-Datei(en) mit %d Hash-Warnung(en) heruntergeladen. Dateien sind möglicherweise nicht die richtige Version."
-
[bios_download_failed]
hash = "sha1-f8f2757f474c368063876856535dc676c1281ff3"
other = "Fehler beim Herunterladen von %d BIOS-Datei(en)."
@@ -511,10 +503,6 @@ other = "Überprüfung überspringen"
hash = "sha1-dda6ac27b9d3b234a68e8b2c412e90ab5a03a4e1"
other = "Überprüfen"
-[login_title]
-hash = "sha1-5fa984fa2afc3712f99f20f5947ada0de5d3044f"
-other = "Bei RomM anmelden"
-
[login_username]
hash = "sha1-84c29015de33e5d22422382a372caba5c58f8c01"
other = "Benutzername"
@@ -759,10 +747,6 @@ other = "Veröffentlichungskanal"
hash = "sha1-3caa02c76bc2b97f136e706433de562fd060f48d"
other = "Spielstand-Synchronisierung"
-[settings_save_sync_settings]
-hash = "sha1-d3cc05947f3967eb75f60603d7fe6d6d88dbcf7f"
-other = "Speicherstand-Sync-Zuordnungen"
-
[settings_show_collections]
hash = "sha1-4bbb632f02fd69807705c0179999c17d35c93b0f"
other = "Sammlungen"
@@ -1019,3 +1003,114 @@ other = "Größe: {{.Size}}"
[update_up_to_date]
hash = "sha1-a534c74416c491ff56e75a3335ea2ee9b4e5b4ad"
other = "Sie haben die neueste Version ({{.Version}})"
+
+[button_sync]
+other = "Sync"
+
+[collections_syncing]
+other = "Syncing collections..."
+
+[filter_platform]
+other = "Platform"
+
+[info_cfw]
+other = "CFW"
+
+[info_romm_version]
+other = "Version"
+
+[info_token_expires]
+other = "Expires"
+
+[info_token_name]
+other = "Token"
+
+[info_token_never_expires]
+other = "Never"
+
+[info_unknown]
+other = "Unknown"
+
+[login_auth_credentials]
+other = "Credentials"
+
+[login_auth_method]
+other = "Auth Method"
+
+[login_auth_pairing_code]
+other = "Pairing Code"
+
+[login_auth_title]
+other = "Authentication"
+
+[login_error_invalid_code]
+other = "Invalid or expired pairing code.\nPlease try again."
+
+[login_pairing_code]
+other = "Pairing Code"
+
+[login_server_title]
+other = "Server"
+
+[login_validating_connection]
+other = "Validating connection..."
+
+[save_mapping_no_platforms]
+other = "No platforms with multiple emulators found."
+
+[save_mapping_title]
+other = "Save Sync Mappings"
+
+[server_address_validating]
+other = "Validating new server address..."
+
+[settings_download_art_kind_marquee]
+other = "Marquee"
+
+[settings_download_art_kind_none]
+other = "None"
+
+[settings_download_art_kind_title]
+other = "Title"
+
+[settings_download_art_preview]
+other = "Download Screenshot Preview"
+
+[settings_download_art_splash]
+other = "Download Splash Art"
+
+[settings_server_address]
+other = "Server Address"
+
+[settings_swap_face_buttons]
+other = "Swap Face Buttons"
+
+[settings_switch_to_token]
+other = "Switch to API Token"
+
+[settings_sync_local_artwork]
+other = "Download Missing Art"
+
+[settings_token_switch_success]
+other = "Successfully switched to API Token!"
+
+[settings_tools]
+other = "Tools"
+
+[startup_error_credentials_invalid]
+other = "Your credentials are invalid.\nPlease log in again."
+
+[startup_error_token_invalid]
+other = "Your API token is invalid or expired.\nPlease set up a new one."
+
+[sync_history_col_game]
+other = "Game"
+
+[sync_history_col_platform]
+other = "Platform"
+
+[sync_history_col_time]
+other = "Time"
+
+[sync_menu_save_mapping]
+other = "Save Mapping"
diff --git a/resources/locales/active.en.toml b/resources/locales/active.en.toml
index 06cd9e4e..a8b3c12b 100644
--- a/resources/locales/active.en.toml
+++ b/resources/locales/active.en.toml
@@ -1,16 +1,14 @@
artwork_sync_complete = "Successfully downloaded %d artwork images."
-artwork_sync_confirm = "Download artwork for %d games?"
artwork_sync_failed = "Failed to download %d artwork images."
artwork_sync_no_platforms = "No platforms with directory mappings found."
-artwork_sync_processing = "Processing artwork..."
-artwork_sync_scanning = "Scanning platform %d/%d: %s..."
-artwork_sync_up_to_date = "All artwork is already cached!"
artwork_sync_preload_all = "All"
artwork_sync_preload_choice = "Do you want to preload all or missing artwork ?"
artwork_sync_preload_missing = "Missing Only"
+artwork_sync_processing = "Processing artwork..."
+artwork_sync_scanning = "Scanning platform %d/%d: %s..."
artwork_sync_select_platforms = "Select Platforms"
+artwork_sync_up_to_date = "All artwork is already cached!"
bios_download_complete = "Successfully downloaded %d BIOS file(s)."
-bios_download_complete_with_warnings = "Downloaded %d BIOS file(s) with %d hash warning(s). Files may not be the correct version."
bios_download_failed = "Failed to download %d BIOS file(s)."
bios_no_files_required = "This platform doesn't require any BIOS files."
bios_status_not_installed = "Missing"
@@ -38,6 +36,7 @@ button_save_sync = "Sync"
button_search = "Search"
button_select = "Select"
button_settings = "Settings"
+button_sync = "Sync"
cache_building = "Building cache..."
cache_clear_artwork = "Artwork"
cache_clear_both = "All"
@@ -48,12 +47,24 @@ collection_platform_no_mapped = "No platforms with mapped games in\n{{.Name}}"
collection_platform_title = "{{.Name}} - Platforms"
collection_view_platform = "Platform"
collection_view_unified = "Unified"
+collections_syncing = "Syncing collections..."
common_default = "Default"
common_false = "False"
common_hide = "Hide"
common_show = "Show"
common_skip = "Skip"
common_true = "True"
+device_registration_prompt = "Enter a name for this device"
+device_registration_registering = "Registering device..."
+device_registration_updating = "Updating device..."
+download_artwork = "Downloading artwork..."
+download_extracting = "Extracting {{.Name}}..."
+downloaded_games_do_nothing = "Do Nothing"
+downloaded_games_filter = "Filter"
+downloaded_games_mark = "Mark"
+emulator_saves_count = " ({{.Count}} saves)"
+emulator_selection_title = "Select {{.Platform}} Emulator"
+error_loading_platforms = "Error loading platforms!\nPlease check the logs for more info."
filter_age_rating = "Age Rating"
filter_all = "All"
filter_company = "Company"
@@ -61,16 +72,9 @@ filter_franchise = "Franchise"
filter_game_mode = "Game Mode"
filter_genre = "Genre"
filter_language = "Language"
+filter_platform = "Platform"
filter_region = "Region"
filter_tag = "Tag"
-download_artwork = "Downloading artwork..."
-download_extracting = "Extracting {{.Name}}..."
-downloaded_games_do_nothing = "Do Nothing"
-downloaded_games_filter = "Filter"
-downloaded_games_mark = "Mark"
-emulator_saves_count = " ({{.Count}} saves)"
-emulator_selection_title = "Select {{.Platform}} Emulator"
-error_loading_platforms = "Error loading platforms!\nPlease check the logs for more info."
game_details_average_rating = "Average Rating"
game_details_companies = "Companies"
game_details_description = "Description"
@@ -86,12 +90,12 @@ game_details_platform = "Platform"
game_details_regions = "Regions"
game_details_release_date = "Release Date"
game_details_type = "Type"
+game_filters_title = "Filters"
game_options_new_slot = "New Slot..."
game_options_save_slot = "Save Slot"
game_options_show_qr = "Show QR Code"
game_options_title = "Game Options"
game_qr_title = "RomM Game Page"
-game_filters_title = "Filters"
games_list_filtered = "[Filtered]"
games_list_filtered_out = "No games in {{.Name}} match your platform mappings"
games_list_help_body = "A - Select a game\nB - Go back to the previous screen\nX - Search for games by name\nSelect - Toggle multi-select mode\n In multi-select mode:\n - Use D-Pad to navigate\n - Press A to toggle selection\n - Press L1 to deselect all\n - Press R1 to select all\n - Press Start to confirm selections\nMenu - Show this help screen\nD-Pad - Navigate the game list"
@@ -104,17 +108,28 @@ games_list_no_results = "No results found for \"{{.Query}}\""
games_list_search_prefix = "[Search: \"{{.Query}}\"]"
help_exit_text = "Press any button to close help"
info_build_date = "Build Date"
+info_cfw = "CFW"
info_commit = "Commit"
info_repository = "GitHub Repository"
+info_romm_version = "Version"
info_server = "Server"
+info_token_expires = "Expires"
+info_token_name = "Token"
+info_token_never_expires = "Never"
+info_unknown = "Unknown"
info_user = "User"
info_version = "Version"
log_level_debug = "Debug"
log_level_error = "Error"
log_level_info = "Info"
+login_auth_credentials = "Credentials"
+login_auth_method = "Auth Method"
+login_auth_pairing_code = "Pairing Code"
+login_auth_title = "Authentication"
login_error_connection_refused = "Could not connect to host!\nPlease check the hostname and port are correct."
login_error_credentials = "Invalid Username or Password."
login_error_forbidden = "Access Forbidden!\nCheck your username/password and try switching between http and https."
+login_error_invalid_code = "Invalid or expired pairing code.\nPlease try again."
login_error_invalid_hostname = "Could not resolve hostname!\nPlease check the hostname is correct."
login_error_server = "RomM server error!\nPlease check the RomM server logs."
login_error_timeout = "Connection timed out!\nPlease check your network connection and that the host is reachable."
@@ -123,17 +138,19 @@ login_error_use_http = "Protocol mismatch!\nPlease use HTTP instead of HTTPS."
login_error_use_https = "Protocol mismatch!\nPlease use HTTPS instead of HTTP."
login_error_wrong_protocol = "Protocol mismatch!\nTry switching between http and https."
login_hostname = "Hostname"
+login_pairing_code = "Pairing Code"
login_password = "Password"
login_port = "Port (optional)"
login_protocol = "Protocol"
login_protocol_http = "HTTP"
login_protocol_https = "HTTPS"
+login_server_title = "Server"
login_ssl_certificates = "SSL Certificates"
login_ssl_skip = "Skip Verification"
login_ssl_verify = "Verify"
-login_title = "Login to RomM"
login_username = "Username"
login_validating = "Validating connection..."
+login_validating_connection = "Validating connection..."
logout_confirm_message = "Are you sure you want to logout?"
option_disabled = "Disabled"
option_enabled = "Enabled"
@@ -149,6 +166,8 @@ release_stable = "Stable"
save_conflict_keep_local = "Keep Local"
save_conflict_keep_remote = "Keep Remote"
save_conflict_title = "Resolve Conflicts"
+save_mapping_no_platforms = "No platforms with multiple emulators found."
+save_mapping_title = "Save Sync Mappings"
save_sync_backup_limit = "Save Backups"
save_sync_backup_no_limit = "No Limit"
save_sync_conflicts = "Conflicts"
@@ -162,9 +181,7 @@ save_sync_results_title = "Sync Complete"
save_sync_scanning = "Scanning saves..."
save_sync_syncing = "Syncing saves..."
save_sync_uploaded = "Uploaded"
-device_registration_prompt = "Enter a name for this device"
-device_registration_registering = "Registering device..."
-device_registration_updating = "Updating device..."
+server_address_validating = "Validating new server address..."
settings_advanced = "Advanced"
settings_api_timeout = "API Timeout"
settings_box_art = "Box Art"
@@ -178,7 +195,12 @@ settings_download_art_kind = "Download Art Kind"
settings_download_art_kind_box2d = "Box2D"
settings_download_art_kind_box3d = "Box3D"
settings_download_art_kind_default = "Default"
+settings_download_art_kind_marquee = "Marquee"
settings_download_art_kind_miximage = "MixImage"
+settings_download_art_kind_none = "None"
+settings_download_art_kind_title = "Title"
+settings_download_art_preview = "Download Screenshot Preview"
+settings_download_art_splash = "Download Splash Art"
settings_download_timeout = "Download Timeout"
settings_downloaded_games = "Downloaded Games"
settings_edit_mappings = "Directory Mappings"
@@ -198,16 +220,36 @@ settings_log_level = "Log Level"
settings_rebuild_cache = "Rebuild Cache"
settings_release_channel = "Release Channel"
settings_save_sync = "Save Sync"
-settings_save_sync_settings = "Save Sync Settings"
+settings_server_address = "Server Address"
settings_show_collections = "Collections"
settings_show_smart_collections = "Smart Collections"
settings_show_virtual_collections = "Virtual Collections"
+settings_swap_face_buttons = "Swap Face Buttons"
+settings_switch_to_token = "Switch to API Token"
settings_sync_artwork = "Preload Artwork"
+settings_sync_local_artwork = "Download Missing Art"
settings_title = "Settings"
+settings_token_switch_success = "Successfully switched to API Token!"
+settings_tools = "Tools"
+startup_error_action_exit = "Exit"
+startup_error_action_retry = "Retry Connection"
+startup_error_connection_refused = "Could not connect to RomM!\nPlease check the server is running."
+startup_error_credentials = "Invalid credentials!\nPlease check your username and password."
+startup_error_credentials_invalid = "Your credentials are invalid.\nPlease log in again."
+startup_error_forbidden = "Access forbidden!\nCheck your server permissions."
+startup_error_invalid_hostname = "Could not resolve hostname!\nPlease check your server configuration."
+startup_error_server = "RomM server error!\nPlease check the RomM server logs."
+startup_error_timeout = "Connection timed out!\nPlease check your network connection."
+startup_error_token_invalid = "Your API token is invalid or expired.\nPlease set up a new one."
+startup_error_wrong_protocol = "Protocol mismatch!\nCheck your server configuration."
+sync_history_col_game = "Game"
+sync_history_col_platform = "Platform"
+sync_history_col_time = "Time"
sync_history_empty = "No sync history found."
sync_history_no_cache = "Cache not available."
sync_history_title = "Sync History"
sync_menu_history = "View History"
+sync_menu_save_mapping = "Save Mapping"
sync_menu_sync_now = "Sync Now"
sync_menu_synced_games = "Synced Games"
sync_menu_title = "Save Sync"
@@ -223,19 +265,6 @@ synced_games_overview = "Overview"
synced_games_save_count = "Save Count"
synced_games_title = "Synced Games"
synced_games_total_saves = "Total Saves"
-startup_error_action_exit = "Exit"
-startup_error_action_retry = "Retry Connection"
-startup_error_connection_refused = "Could not connect to RomM!\nPlease check the server is running."
-startup_error_credentials = "Invalid credentials!\nPlease check your username and password."
-startup_error_forbidden = "Access forbidden!\nCheck your server permissions."
-startup_error_invalid_hostname = "Could not resolve hostname!\nPlease check your server configuration."
-startup_error_server = "RomM server error!\nPlease check the RomM server logs."
-startup_error_timeout = "Connection timed out!\nPlease check your network connection."
-startup_error_wrong_protocol = "Protocol mismatch!\nCheck your server configuration."
-time_days_ago = "{{.Count}}d ago"
-time_hours_ago = "{{.Count}}h ago"
-time_just_now = "just now"
-time_minutes_ago = "{{.Count}}m ago"
time_105_minutes = "105 Minutes"
time_120_minutes = "120 Minutes"
time_120_seconds = "120 Seconds"
@@ -254,6 +283,10 @@ time_75_minutes = "75 Minutes"
time_75_seconds = "75 Seconds"
time_90_minutes = "90 Minutes"
time_90_seconds = "90 Seconds"
+time_days_ago = "{{.Count}}d ago"
+time_hours_ago = "{{.Count}}h ago"
+time_just_now = "just now"
+time_minutes_ago = "{{.Count}}m ago"
update_available = "Update available: {{.Version}}"
update_check_error = "{{.Error}}"
update_check_for_updates = "Check for Updates"
diff --git a/resources/locales/active.es.toml b/resources/locales/active.es.toml
index 977a3222..3f605a71 100644
--- a/resources/locales/active.es.toml
+++ b/resources/locales/active.es.toml
@@ -2,10 +2,6 @@
hash = "sha1-cde437c528224c2a08f0719f3e8b923a17642295"
other = "Se descargaron exitosamente %d imágenes de artwork."
-[artwork_sync_confirm]
-hash = "sha1-19bd664135df62cf7f839f7fa5810b40f6e3e007"
-other = "¿Descargar artwork para %d juegos?"
-
[artwork_sync_failed]
hash = "sha1-869ec904d8bde854dcf9489791abb3eef66818f9"
other = "Fallo al descargar %d imágenes de artwork."
@@ -47,10 +43,6 @@ other = "Select Platforms"
hash = "sha1-344ccb404588981250e2016ac355ac798d0b7e31"
other = "Se descargaron exitosamente %d archivo(s) BIOS."
-[bios_download_complete_with_warnings]
-hash = "sha1-aebdf53b01e8b0931fcbddce77db5b1477cc151f"
-other = "Se descargaron %d archivo(s) BIOS con %d advertencia(s) de hash. Los archivos pueden no ser la versión correcta."
-
[bios_download_failed]
hash = "sha1-f8f2757f474c368063876856535dc676c1281ff3"
other = "Fallo al descargar %d archivo(s) BIOS."
@@ -511,10 +503,6 @@ other = "Omitir verificación"
hash = "sha1-dda6ac27b9d3b234a68e8b2c412e90ab5a03a4e1"
other = "Verificar"
-[login_title]
-hash = "sha1-5fa984fa2afc3712f99f20f5947ada0de5d3044f"
-other = "Iniciar Sesión en RomM"
-
[login_username]
hash = "sha1-84c29015de33e5d22422382a372caba5c58f8c01"
other = "Usuario"
@@ -759,10 +747,6 @@ other = "Canal de lanzamiento"
hash = "sha1-3caa02c76bc2b97f136e706433de562fd060f48d"
other = "Sincronización de Guardados"
-[settings_save_sync_settings]
-hash = "sha1-d3cc05947f3967eb75f60603d7fe6d6d88dbcf7f"
-other = "Mapeos de Sincronización"
-
[settings_show_collections]
hash = "sha1-4bbb632f02fd69807705c0179999c17d35c93b0f"
other = "Colecciones"
@@ -1019,3 +1003,114 @@ other = "Tamaño: {{.Size}}"
[update_up_to_date]
hash = "sha1-a534c74416c491ff56e75a3335ea2ee9b4e5b4ad"
other = "Tiene la última versión ({{.Version}})"
+
+[button_sync]
+other = "Sync"
+
+[collections_syncing]
+other = "Syncing collections..."
+
+[filter_platform]
+other = "Platform"
+
+[info_cfw]
+other = "CFW"
+
+[info_romm_version]
+other = "Version"
+
+[info_token_expires]
+other = "Expires"
+
+[info_token_name]
+other = "Token"
+
+[info_token_never_expires]
+other = "Never"
+
+[info_unknown]
+other = "Unknown"
+
+[login_auth_credentials]
+other = "Credentials"
+
+[login_auth_method]
+other = "Auth Method"
+
+[login_auth_pairing_code]
+other = "Pairing Code"
+
+[login_auth_title]
+other = "Authentication"
+
+[login_error_invalid_code]
+other = "Invalid or expired pairing code.\nPlease try again."
+
+[login_pairing_code]
+other = "Pairing Code"
+
+[login_server_title]
+other = "Server"
+
+[login_validating_connection]
+other = "Validating connection..."
+
+[save_mapping_no_platforms]
+other = "No platforms with multiple emulators found."
+
+[save_mapping_title]
+other = "Save Sync Mappings"
+
+[server_address_validating]
+other = "Validating new server address..."
+
+[settings_download_art_kind_marquee]
+other = "Marquee"
+
+[settings_download_art_kind_none]
+other = "None"
+
+[settings_download_art_kind_title]
+other = "Title"
+
+[settings_download_art_preview]
+other = "Download Screenshot Preview"
+
+[settings_download_art_splash]
+other = "Download Splash Art"
+
+[settings_server_address]
+other = "Server Address"
+
+[settings_swap_face_buttons]
+other = "Swap Face Buttons"
+
+[settings_switch_to_token]
+other = "Switch to API Token"
+
+[settings_sync_local_artwork]
+other = "Download Missing Art"
+
+[settings_token_switch_success]
+other = "Successfully switched to API Token!"
+
+[settings_tools]
+other = "Tools"
+
+[startup_error_credentials_invalid]
+other = "Your credentials are invalid.\nPlease log in again."
+
+[startup_error_token_invalid]
+other = "Your API token is invalid or expired.\nPlease set up a new one."
+
+[sync_history_col_game]
+other = "Game"
+
+[sync_history_col_platform]
+other = "Platform"
+
+[sync_history_col_time]
+other = "Time"
+
+[sync_menu_save_mapping]
+other = "Save Mapping"
diff --git a/resources/locales/active.fr.toml b/resources/locales/active.fr.toml
index 6f826ff4..0c39bb18 100644
--- a/resources/locales/active.fr.toml
+++ b/resources/locales/active.fr.toml
@@ -2,10 +2,6 @@
hash = "sha1-cde437c528224c2a08f0719f3e8b923a17642295"
other = "%d images d'illustration téléchargées avec succès."
-[artwork_sync_confirm]
-hash = "sha1-19bd664135df62cf7f839f7fa5810b40f6e3e007"
-other = "Télécharger les illustrations pour %d jeux ?"
-
[artwork_sync_failed]
hash = "sha1-869ec904d8bde854dcf9489791abb3eef66818f9"
other = "Échec du téléchargement de %d images d'illustration."
@@ -47,10 +43,6 @@ other = "Select Platforms"
hash = "sha1-344ccb404588981250e2016ac355ac798d0b7e31"
other = "%d fichier(s) BIOS téléchargé(s) avec succès."
-[bios_download_complete_with_warnings]
-hash = "sha1-aebdf53b01e8b0931fcbddce77db5b1477cc151f"
-other = "%d fichier(s) BIOS téléchargé(s) avec %d avertissement(s) de hash. Les fichiers peuvent ne pas être la bonne version."
-
[bios_download_failed]
hash = "sha1-f8f2757f474c368063876856535dc676c1281ff3"
other = "Échec du téléchargement de %d fichier(s) BIOS."
@@ -511,10 +503,6 @@ other = "Ignorer la vérification"
hash = "sha1-dda6ac27b9d3b234a68e8b2c412e90ab5a03a4e1"
other = "Vérifier"
-[login_title]
-hash = "sha1-5fa984fa2afc3712f99f20f5947ada0de5d3044f"
-other = "Connexion à RomM"
-
[login_username]
hash = "sha1-84c29015de33e5d22422382a372caba5c58f8c01"
other = "Nom de compte"
@@ -759,10 +747,6 @@ other = "Canal de diffusion"
hash = "sha1-3caa02c76bc2b97f136e706433de562fd060f48d"
other = "Synchronisation des Sauvegardes"
-[settings_save_sync_settings]
-hash = "sha1-d3cc05947f3967eb75f60603d7fe6d6d88dbcf7f"
-other = "Mappages de Synchronisation"
-
[settings_show_collections]
hash = "sha1-4bbb632f02fd69807705c0179999c17d35c93b0f"
other = "Collections"
@@ -1019,3 +1003,114 @@ other = "Taille : {{.Size}}"
[update_up_to_date]
hash = "sha1-a534c74416c491ff56e75a3335ea2ee9b4e5b4ad"
other = "Vous avez la dernière version ({{.Version}})"
+
+[button_sync]
+other = "Sync"
+
+[collections_syncing]
+other = "Syncing collections..."
+
+[filter_platform]
+other = "Platform"
+
+[info_cfw]
+other = "CFW"
+
+[info_romm_version]
+other = "Version"
+
+[info_token_expires]
+other = "Expires"
+
+[info_token_name]
+other = "Token"
+
+[info_token_never_expires]
+other = "Never"
+
+[info_unknown]
+other = "Unknown"
+
+[login_auth_credentials]
+other = "Credentials"
+
+[login_auth_method]
+other = "Auth Method"
+
+[login_auth_pairing_code]
+other = "Pairing Code"
+
+[login_auth_title]
+other = "Authentication"
+
+[login_error_invalid_code]
+other = "Invalid or expired pairing code.\nPlease try again."
+
+[login_pairing_code]
+other = "Pairing Code"
+
+[login_server_title]
+other = "Server"
+
+[login_validating_connection]
+other = "Validating connection..."
+
+[save_mapping_no_platforms]
+other = "No platforms with multiple emulators found."
+
+[save_mapping_title]
+other = "Save Sync Mappings"
+
+[server_address_validating]
+other = "Validating new server address..."
+
+[settings_download_art_kind_marquee]
+other = "Marquee"
+
+[settings_download_art_kind_none]
+other = "None"
+
+[settings_download_art_kind_title]
+other = "Title"
+
+[settings_download_art_preview]
+other = "Download Screenshot Preview"
+
+[settings_download_art_splash]
+other = "Download Splash Art"
+
+[settings_server_address]
+other = "Server Address"
+
+[settings_swap_face_buttons]
+other = "Swap Face Buttons"
+
+[settings_switch_to_token]
+other = "Switch to API Token"
+
+[settings_sync_local_artwork]
+other = "Download Missing Art"
+
+[settings_token_switch_success]
+other = "Successfully switched to API Token!"
+
+[settings_tools]
+other = "Tools"
+
+[startup_error_credentials_invalid]
+other = "Your credentials are invalid.\nPlease log in again."
+
+[startup_error_token_invalid]
+other = "Your API token is invalid or expired.\nPlease set up a new one."
+
+[sync_history_col_game]
+other = "Game"
+
+[sync_history_col_platform]
+other = "Platform"
+
+[sync_history_col_time]
+other = "Time"
+
+[sync_menu_save_mapping]
+other = "Save Mapping"
diff --git a/resources/locales/active.it.toml b/resources/locales/active.it.toml
index 1e52aa18..a939c22a 100644
--- a/resources/locales/active.it.toml
+++ b/resources/locales/active.it.toml
@@ -2,10 +2,6 @@
hash = "sha1-cde437c528224c2a08f0719f3e8b923a17642295"
other = "Scaricate con successo %d immagini artwork."
-[artwork_sync_confirm]
-hash = "sha1-19bd664135df62cf7f839f7fa5810b40f6e3e007"
-other = "Scaricare artwork per %d giochi?"
-
[artwork_sync_failed]
hash = "sha1-869ec904d8bde854dcf9489791abb3eef66818f9"
other = "Impossibile scaricare %d immagini artwork."
@@ -47,10 +43,6 @@ other = "Select Platforms"
hash = "sha1-344ccb404588981250e2016ac355ac798d0b7e31"
other = "Scaricati con successo %d file BIOS."
-[bios_download_complete_with_warnings]
-hash = "sha1-aebdf53b01e8b0931fcbddce77db5b1477cc151f"
-other = "Scaricati %d file BIOS con %d avvisi hash. I file potrebbero non essere della versione corretta."
-
[bios_download_failed]
hash = "sha1-f8f2757f474c368063876856535dc676c1281ff3"
other = "Impossibile scaricare %d file BIOS."
@@ -514,10 +506,6 @@ other = "Salta verifica"
hash = "sha1-dda6ac27b9d3b234a68e8b2c412e90ab5a03a4e1"
other = "Verifica"
-[login_title]
-hash = "sha1-5fa984fa2afc3712f99f20f5947ada0de5d3044f"
-other = "Accedi a RomM"
-
[login_username]
hash = "sha1-84c29015de33e5d22422382a372caba5c58f8c01"
other = "Nome utente"
@@ -762,10 +750,6 @@ other = "Canale di rilascio"
hash = "sha1-3caa02c76bc2b97f136e706433de562fd060f48d"
other = "Sincronizzazione Salvataggi"
-[settings_save_sync_settings]
-hash = "sha1-d3cc05947f3967eb75f60603d7fe6d6d88dbcf7f"
-other = "Mappature Sincronizzazione"
-
[settings_show_collections]
hash = "sha1-4bbb632f02fd69807705c0179999c17d35c93b0f"
other = "Collezioni"
@@ -1022,3 +1006,114 @@ other = "Dimensione: {{.Size}}"
[update_up_to_date]
hash = "sha1-a534c74416c491ff56e75a3335ea2ee9b4e5b4ad"
other = "Hai l'ultima versione ({{.Version}})"
+
+[button_sync]
+other = "Sync"
+
+[collections_syncing]
+other = "Syncing collections..."
+
+[filter_platform]
+other = "Platform"
+
+[info_cfw]
+other = "CFW"
+
+[info_romm_version]
+other = "Version"
+
+[info_token_expires]
+other = "Expires"
+
+[info_token_name]
+other = "Token"
+
+[info_token_never_expires]
+other = "Never"
+
+[info_unknown]
+other = "Unknown"
+
+[login_auth_credentials]
+other = "Credentials"
+
+[login_auth_method]
+other = "Auth Method"
+
+[login_auth_pairing_code]
+other = "Pairing Code"
+
+[login_auth_title]
+other = "Authentication"
+
+[login_error_invalid_code]
+other = "Invalid or expired pairing code.\nPlease try again."
+
+[login_pairing_code]
+other = "Pairing Code"
+
+[login_server_title]
+other = "Server"
+
+[login_validating_connection]
+other = "Validating connection..."
+
+[save_mapping_no_platforms]
+other = "No platforms with multiple emulators found."
+
+[save_mapping_title]
+other = "Save Sync Mappings"
+
+[server_address_validating]
+other = "Validating new server address..."
+
+[settings_download_art_kind_marquee]
+other = "Marquee"
+
+[settings_download_art_kind_none]
+other = "None"
+
+[settings_download_art_kind_title]
+other = "Title"
+
+[settings_download_art_preview]
+other = "Download Screenshot Preview"
+
+[settings_download_art_splash]
+other = "Download Splash Art"
+
+[settings_server_address]
+other = "Server Address"
+
+[settings_swap_face_buttons]
+other = "Swap Face Buttons"
+
+[settings_switch_to_token]
+other = "Switch to API Token"
+
+[settings_sync_local_artwork]
+other = "Download Missing Art"
+
+[settings_token_switch_success]
+other = "Successfully switched to API Token!"
+
+[settings_tools]
+other = "Tools"
+
+[startup_error_credentials_invalid]
+other = "Your credentials are invalid.\nPlease log in again."
+
+[startup_error_token_invalid]
+other = "Your API token is invalid or expired.\nPlease set up a new one."
+
+[sync_history_col_game]
+other = "Game"
+
+[sync_history_col_platform]
+other = "Platform"
+
+[sync_history_col_time]
+other = "Time"
+
+[sync_menu_save_mapping]
+other = "Save Mapping"
diff --git a/resources/locales/active.ja.toml b/resources/locales/active.ja.toml
index f9dd7d42..dea82603 100644
--- a/resources/locales/active.ja.toml
+++ b/resources/locales/active.ja.toml
@@ -2,10 +2,6 @@
hash = "sha1-cde437c528224c2a08f0719f3e8b923a17642295"
other = "%d個のアートワーク画像を正常にダウンロードしました。"
-[artwork_sync_confirm]
-hash = "sha1-19bd664135df62cf7f839f7fa5810b40f6e3e007"
-other = "%d個のゲームのアートワークをダウンロードしますか?"
-
[artwork_sync_failed]
hash = "sha1-869ec904d8bde854dcf9489791abb3eef66818f9"
other = "%d個のアートワーク画像のダウンロードに失敗しました。"
@@ -47,10 +43,6 @@ other = "Select Platforms"
hash = "sha1-344ccb404588981250e2016ac355ac798d0b7e31"
other = "%d個のBIOSファイルを正常にダウンロードしました。"
-[bios_download_complete_with_warnings]
-hash = "sha1-aebdf53b01e8b0931fcbddce77db5b1477cc151f"
-other = "%d個のBIOSファイルをダウンロードしましたが、%d個のハッシュ警告があります。ファイルが正しいバージョンでない可能性があります。"
-
[bios_download_failed]
hash = "sha1-f8f2757f474c368063876856535dc676c1281ff3"
other = "%d個のBIOSファイルのダウンロードに失敗しました。"
@@ -514,10 +506,6 @@ other = "検証をスキップ"
hash = "sha1-dda6ac27b9d3b234a68e8b2c412e90ab5a03a4e1"
other = "検証する"
-[login_title]
-hash = "sha1-5fa984fa2afc3712f99f20f5947ada0de5d3044f"
-other = "RomMにログイン"
-
[login_username]
hash = "sha1-84c29015de33e5d22422382a372caba5c58f8c01"
other = "ユーザー名"
@@ -762,10 +750,6 @@ other = "リリースチャンネル"
hash = "sha1-3caa02c76bc2b97f136e706433de562fd060f48d"
other = "セーブ同期"
-[settings_save_sync_settings]
-hash = "sha1-d3cc05947f3967eb75f60603d7fe6d6d88dbcf7f"
-other = "セーブ同期マッピング"
-
[settings_show_collections]
hash = "sha1-4bbb632f02fd69807705c0179999c17d35c93b0f"
other = "コレクション"
@@ -1022,3 +1006,114 @@ other = "サイズ: {{.Size}}"
[update_up_to_date]
hash = "sha1-a534c74416c491ff56e75a3335ea2ee9b4e5b4ad"
other = "最新バージョンです ({{.Version}})"
+
+[button_sync]
+other = "Sync"
+
+[collections_syncing]
+other = "Syncing collections..."
+
+[filter_platform]
+other = "Platform"
+
+[info_cfw]
+other = "CFW"
+
+[info_romm_version]
+other = "Version"
+
+[info_token_expires]
+other = "Expires"
+
+[info_token_name]
+other = "Token"
+
+[info_token_never_expires]
+other = "Never"
+
+[info_unknown]
+other = "Unknown"
+
+[login_auth_credentials]
+other = "Credentials"
+
+[login_auth_method]
+other = "Auth Method"
+
+[login_auth_pairing_code]
+other = "Pairing Code"
+
+[login_auth_title]
+other = "Authentication"
+
+[login_error_invalid_code]
+other = "Invalid or expired pairing code.\nPlease try again."
+
+[login_pairing_code]
+other = "Pairing Code"
+
+[login_server_title]
+other = "Server"
+
+[login_validating_connection]
+other = "Validating connection..."
+
+[save_mapping_no_platforms]
+other = "No platforms with multiple emulators found."
+
+[save_mapping_title]
+other = "Save Sync Mappings"
+
+[server_address_validating]
+other = "Validating new server address..."
+
+[settings_download_art_kind_marquee]
+other = "Marquee"
+
+[settings_download_art_kind_none]
+other = "None"
+
+[settings_download_art_kind_title]
+other = "Title"
+
+[settings_download_art_preview]
+other = "Download Screenshot Preview"
+
+[settings_download_art_splash]
+other = "Download Splash Art"
+
+[settings_server_address]
+other = "Server Address"
+
+[settings_swap_face_buttons]
+other = "Swap Face Buttons"
+
+[settings_switch_to_token]
+other = "Switch to API Token"
+
+[settings_sync_local_artwork]
+other = "Download Missing Art"
+
+[settings_token_switch_success]
+other = "Successfully switched to API Token!"
+
+[settings_tools]
+other = "Tools"
+
+[startup_error_credentials_invalid]
+other = "Your credentials are invalid.\nPlease log in again."
+
+[startup_error_token_invalid]
+other = "Your API token is invalid or expired.\nPlease set up a new one."
+
+[sync_history_col_game]
+other = "Game"
+
+[sync_history_col_platform]
+other = "Platform"
+
+[sync_history_col_time]
+other = "Time"
+
+[sync_menu_save_mapping]
+other = "Save Mapping"
diff --git a/resources/locales/active.pt.toml b/resources/locales/active.pt.toml
index 37eb26c4..90ceb85d 100644
--- a/resources/locales/active.pt.toml
+++ b/resources/locales/active.pt.toml
@@ -2,10 +2,6 @@
hash = "sha1-cde437c528224c2a08f0719f3e8b923a17642295"
other = "%d imagens de artwork baixadas com sucesso."
-[artwork_sync_confirm]
-hash = "sha1-19bd664135df62cf7f839f7fa5810b40f6e3e007"
-other = "Baixar artwork para %d jogos?"
-
[artwork_sync_failed]
hash = "sha1-869ec904d8bde854dcf9489791abb3eef66818f9"
other = "Falha ao baixar %d imagens de artwork."
@@ -47,10 +43,6 @@ other = "Select Platforms"
hash = "sha1-344ccb404588981250e2016ac355ac798d0b7e31"
other = "%d arquivo(s) BIOS baixado(s) com sucesso."
-[bios_download_complete_with_warnings]
-hash = "sha1-aebdf53b01e8b0931fcbddce77db5b1477cc151f"
-other = "%d arquivo(s) BIOS baixado(s) com %d aviso(s) de hash. Os arquivos podem não ser da versão correta."
-
[bios_download_failed]
hash = "sha1-f8f2757f474c368063876856535dc676c1281ff3"
other = "Falha ao baixar %d arquivo(s) BIOS."
@@ -511,10 +503,6 @@ other = "Ignorar verificação"
hash = "sha1-dda6ac27b9d3b234a68e8b2c412e90ab5a03a4e1"
other = "Verificar"
-[login_title]
-hash = "sha1-5fa984fa2afc3712f99f20f5947ada0de5d3044f"
-other = "Entrar no RomM"
-
[login_username]
hash = "sha1-84c29015de33e5d22422382a372caba5c58f8c01"
other = "Nome de usuário"
@@ -759,10 +747,6 @@ other = "Canal de lançamento"
hash = "sha1-3caa02c76bc2b97f136e706433de562fd060f48d"
other = "Sincronização de Saves"
-[settings_save_sync_settings]
-hash = "sha1-d3cc05947f3967eb75f60603d7fe6d6d88dbcf7f"
-other = "Mapeamentos de Sincronização"
-
[settings_show_collections]
hash = "sha1-4bbb632f02fd69807705c0179999c17d35c93b0f"
other = "Coleções"
@@ -1019,3 +1003,114 @@ other = "Tamanho: {{.Size}}"
[update_up_to_date]
hash = "sha1-a534c74416c491ff56e75a3335ea2ee9b4e5b4ad"
other = "Você tem a versão mais recente ({{.Version}})"
+
+[button_sync]
+other = "Sync"
+
+[collections_syncing]
+other = "Syncing collections..."
+
+[filter_platform]
+other = "Platform"
+
+[info_cfw]
+other = "CFW"
+
+[info_romm_version]
+other = "Version"
+
+[info_token_expires]
+other = "Expires"
+
+[info_token_name]
+other = "Token"
+
+[info_token_never_expires]
+other = "Never"
+
+[info_unknown]
+other = "Unknown"
+
+[login_auth_credentials]
+other = "Credentials"
+
+[login_auth_method]
+other = "Auth Method"
+
+[login_auth_pairing_code]
+other = "Pairing Code"
+
+[login_auth_title]
+other = "Authentication"
+
+[login_error_invalid_code]
+other = "Invalid or expired pairing code.\nPlease try again."
+
+[login_pairing_code]
+other = "Pairing Code"
+
+[login_server_title]
+other = "Server"
+
+[login_validating_connection]
+other = "Validating connection..."
+
+[save_mapping_no_platforms]
+other = "No platforms with multiple emulators found."
+
+[save_mapping_title]
+other = "Save Sync Mappings"
+
+[server_address_validating]
+other = "Validating new server address..."
+
+[settings_download_art_kind_marquee]
+other = "Marquee"
+
+[settings_download_art_kind_none]
+other = "None"
+
+[settings_download_art_kind_title]
+other = "Title"
+
+[settings_download_art_preview]
+other = "Download Screenshot Preview"
+
+[settings_download_art_splash]
+other = "Download Splash Art"
+
+[settings_server_address]
+other = "Server Address"
+
+[settings_swap_face_buttons]
+other = "Swap Face Buttons"
+
+[settings_switch_to_token]
+other = "Switch to API Token"
+
+[settings_sync_local_artwork]
+other = "Download Missing Art"
+
+[settings_token_switch_success]
+other = "Successfully switched to API Token!"
+
+[settings_tools]
+other = "Tools"
+
+[startup_error_credentials_invalid]
+other = "Your credentials are invalid.\nPlease log in again."
+
+[startup_error_token_invalid]
+other = "Your API token is invalid or expired.\nPlease set up a new one."
+
+[sync_history_col_game]
+other = "Game"
+
+[sync_history_col_platform]
+other = "Platform"
+
+[sync_history_col_time]
+other = "Time"
+
+[sync_menu_save_mapping]
+other = "Save Mapping"
diff --git a/resources/locales/active.ru.toml b/resources/locales/active.ru.toml
index 2d7d16f1..937d8b7a 100644
--- a/resources/locales/active.ru.toml
+++ b/resources/locales/active.ru.toml
@@ -2,10 +2,6 @@
hash = "sha1-cde437c528224c2a08f0719f3e8b923a17642295"
other = "Успешно загружено %d изображений обложек."
-[artwork_sync_confirm]
-hash = "sha1-19bd664135df62cf7f839f7fa5810b40f6e3e007"
-other = "Загрузить обложки для %d игр?"
-
[artwork_sync_failed]
hash = "sha1-869ec904d8bde854dcf9489791abb3eef66818f9"
other = "Не удалось загрузить %d изображений обложек."
@@ -47,10 +43,6 @@ other = "Select Platforms"
hash = "sha1-344ccb404588981250e2016ac355ac798d0b7e31"
other = "Успешно загружено %d файл(ов) BIOS."
-[bios_download_complete_with_warnings]
-hash = "sha1-aebdf53b01e8b0931fcbddce77db5b1477cc151f"
-other = "Загружено %d файл(ов) BIOS с %d предупреждением(ями) о хэше. Файлы могут быть неправильной версии."
-
[bios_download_failed]
hash = "sha1-f8f2757f474c368063876856535dc676c1281ff3"
other = "Не удалось загрузить %d файл(ов) BIOS."
@@ -511,10 +503,6 @@ other = "Пропустить проверку"
hash = "sha1-dda6ac27b9d3b234a68e8b2c412e90ab5a03a4e1"
other = "Проверять"
-[login_title]
-hash = "sha1-5fa984fa2afc3712f99f20f5947ada0de5d3044f"
-other = "Вход в RomM"
-
[login_username]
hash = "sha1-84c29015de33e5d22422382a372caba5c58f8c01"
other = "Имя пользователя"
@@ -759,10 +747,6 @@ other = "Канал выпуска"
hash = "sha1-3caa02c76bc2b97f136e706433de562fd060f48d"
other = "Синхронизация сохранений"
-[settings_save_sync_settings]
-hash = "sha1-d3cc05947f3967eb75f60603d7fe6d6d88dbcf7f"
-other = "Настройки синхронизации"
-
[settings_show_collections]
hash = "sha1-4bbb632f02fd69807705c0179999c17d35c93b0f"
other = "Коллекции"
@@ -1019,3 +1003,114 @@ other = "Размер: {{.Size}}"
[update_up_to_date]
hash = "sha1-a534c74416c491ff56e75a3335ea2ee9b4e5b4ad"
other = "У вас последняя версия ({{.Version}})"
+
+[button_sync]
+other = "Sync"
+
+[collections_syncing]
+other = "Syncing collections..."
+
+[filter_platform]
+other = "Platform"
+
+[info_cfw]
+other = "CFW"
+
+[info_romm_version]
+other = "Version"
+
+[info_token_expires]
+other = "Expires"
+
+[info_token_name]
+other = "Token"
+
+[info_token_never_expires]
+other = "Never"
+
+[info_unknown]
+other = "Unknown"
+
+[login_auth_credentials]
+other = "Credentials"
+
+[login_auth_method]
+other = "Auth Method"
+
+[login_auth_pairing_code]
+other = "Pairing Code"
+
+[login_auth_title]
+other = "Authentication"
+
+[login_error_invalid_code]
+other = "Invalid or expired pairing code.\nPlease try again."
+
+[login_pairing_code]
+other = "Pairing Code"
+
+[login_server_title]
+other = "Server"
+
+[login_validating_connection]
+other = "Validating connection..."
+
+[save_mapping_no_platforms]
+other = "No platforms with multiple emulators found."
+
+[save_mapping_title]
+other = "Save Sync Mappings"
+
+[server_address_validating]
+other = "Validating new server address..."
+
+[settings_download_art_kind_marquee]
+other = "Marquee"
+
+[settings_download_art_kind_none]
+other = "None"
+
+[settings_download_art_kind_title]
+other = "Title"
+
+[settings_download_art_preview]
+other = "Download Screenshot Preview"
+
+[settings_download_art_splash]
+other = "Download Splash Art"
+
+[settings_server_address]
+other = "Server Address"
+
+[settings_swap_face_buttons]
+other = "Swap Face Buttons"
+
+[settings_switch_to_token]
+other = "Switch to API Token"
+
+[settings_sync_local_artwork]
+other = "Download Missing Art"
+
+[settings_token_switch_success]
+other = "Successfully switched to API Token!"
+
+[settings_tools]
+other = "Tools"
+
+[startup_error_credentials_invalid]
+other = "Your credentials are invalid.\nPlease log in again."
+
+[startup_error_token_invalid]
+other = "Your API token is invalid or expired.\nPlease set up a new one."
+
+[sync_history_col_game]
+other = "Game"
+
+[sync_history_col_platform]
+other = "Platform"
+
+[sync_history_col_time]
+other = "Time"
+
+[sync_menu_save_mapping]
+other = "Save Mapping"
diff --git a/romm/auth.go b/romm/auth.go
index 6068f937..b34a7158 100644
--- a/romm/auth.go
+++ b/romm/auth.go
@@ -98,6 +98,45 @@ func (c *Client) tryAlternateProtocol(originalScheme string, isSuccess func(resp
return nil
}
+type TokenExchangeRequest struct {
+ Code string `json:"code"`
+}
+
+type TokenExchangeResponse struct {
+ RawToken string `json:"raw_token"`
+ Name string `json:"name"`
+ Scopes []string `json:"scopes"`
+ ExpiresAt string `json:"expires_at"`
+}
+
+// ExchangeToken exchanges a pairing code for an API token. This is an unauthenticated endpoint.
+func ExchangeToken(baseURL string, code string, insecureSkipVerify bool) (*TokenExchangeResponse, error) {
+ client := NewClient(baseURL, WithInsecureSkipVerify(insecureSkipVerify))
+ var result TokenExchangeResponse
+ err := client.doRequest("POST", endpointTokenExchange, nil, TokenExchangeRequest{Code: code}, &result)
+ if err != nil {
+ return nil, err
+ }
+ return &result, nil
+}
+
+// ValidateToken checks that the client's auth credentials work by hitting an authenticated endpoint.
+func (c *Client) ValidateToken() error {
+ var platforms []Platform
+ return c.doRequest("GET", endpointPlatforms, nil, nil, &platforms)
+}
+
+type CurrentUser struct {
+ Username string `json:"username"`
+}
+
+// GetCurrentUser fetches the authenticated user's info.
+func (c *Client) GetCurrentUser() (CurrentUser, error) {
+ var user CurrentUser
+ err := c.doRequest("GET", endpointCurrentUser, nil, nil, &user)
+ return user, err
+}
+
func (c *Client) Login(username, password string) error {
req, err := http.NewRequest("POST", c.baseURL+endpointLogin, nil)
if err != nil {
diff --git a/romm/client.go b/romm/client.go
index 32bfc8d1..1420f0bd 100644
--- a/romm/client.go
+++ b/romm/client.go
@@ -20,8 +20,7 @@ const (
type Client struct {
baseURL string
httpClient *http.Client
- username string
- password string
+ authHeader string
}
type queryParam interface {
@@ -36,10 +35,9 @@ func WithTimeout(timeout time.Duration) ClientOption {
}
}
-func WithBasicAuth(username, password string) ClientOption {
+func WithAuthHeader(header string) ClientOption {
return func(c *Client) {
- c.username = username
- c.password = password
+ c.authHeader = header
}
}
@@ -70,7 +68,7 @@ func NewClient(baseURL string, opts ...ClientOption) *Client {
func NewClientFromHost(host Host, timeout ...time.Duration) *Client {
opts := []ClientOption{
- WithBasicAuth(host.Username, host.Password),
+ WithAuthHeader(host.AuthHeader()),
WithInsecureSkipVerify(host.InsecureSkipVerify),
}
if len(timeout) > 0 {
@@ -107,8 +105,8 @@ func (c *Client) doRequest(method string, path string, queryParams queryParam, b
req.Header.Set("Content-Type", "application/json")
}
- if c.username != "" && c.password != "" {
- req.SetBasicAuth(c.username, c.password)
+ if c.authHeader != "" {
+ req.Header.Set("Authorization", c.authHeader)
}
resp, err := c.httpClient.Do(req)
@@ -152,8 +150,8 @@ func (c *Client) doRequestRaw(method, path string, body interface{}) ([]byte, er
req.Header.Set("Content-Type", "application/json")
}
- if c.username != "" && c.password != "" {
- req.SetBasicAuth(c.username, c.password)
+ if c.authHeader != "" {
+ req.Header.Set("Authorization", c.authHeader)
}
resp, err := c.httpClient.Do(req)
@@ -189,8 +187,8 @@ func (c *Client) doRequestRawWithQuery(method, path string, queryParams queryPar
}
}
- if c.username != "" && c.password != "" {
- req.SetBasicAuth(c.username, c.password)
+ if c.authHeader != "" {
+ req.Header.Set("Authorization", c.authHeader)
}
resp, err := c.httpClient.Do(req)
@@ -220,8 +218,8 @@ func (c *Client) doMultipartRequest(method, path string, queryParams queryParam,
req.Header.Set("Content-Type", contentType)
- if c.username != "" && c.password != "" {
- req.SetBasicAuth(c.username, c.password)
+ if c.authHeader != "" {
+ req.Header.Set("Authorization", c.authHeader)
}
if queryParams != nil && queryParams.Valid() {
diff --git a/romm/endpoints.go b/romm/endpoints.go
index 5bc97b96..856ddcfd 100644
--- a/romm/endpoints.go
+++ b/romm/endpoints.go
@@ -32,4 +32,7 @@ const (
endpointDevices = "/api/devices"
endpointDeviceByID = "/api/devices/%s"
+
+ endpointTokenExchange = "/api/client-tokens/exchange"
+ endpointCurrentUser = "/api/users/me"
)
diff --git a/romm/host.go b/romm/host.go
index 2842597a..ef97338f 100644
--- a/romm/host.go
+++ b/romm/host.go
@@ -13,12 +13,19 @@ type Host struct {
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
+ Token string `json:"token,omitempty"`
+ TokenName string `json:"token_name,omitempty"`
+ TokenExpiresAt string `json:"token_expires_at,omitempty"`
InsecureSkipVerify bool `json:"insecure_skip_verify,omitempty"`
DeviceID string `json:"device_id,omitempty"`
DeviceName string `json:"device_name,omitempty"`
}
+func (h Host) HasTokenAuth() bool {
+ return h.Token != ""
+}
+
func (h Host) ToLoggable() map[string]any {
temp := map[string]any{
"display_name": h.DisplayName,
@@ -26,6 +33,7 @@ func (h Host) ToLoggable() map[string]any {
"port": h.Port,
"username": h.Username,
"password": strings.Repeat("*", len(h.Password)),
+ "token": strings.Repeat("*", len(h.Token)),
"insecure_skip_verify": h.InsecureSkipVerify,
}
@@ -39,7 +47,10 @@ func (h Host) URL() string {
return h.RootURI
}
-func (h Host) BasicAuthHeader() string {
+func (h Host) AuthHeader() string {
+ if h.Token != "" {
+ return "Bearer " + h.Token
+ }
auth := h.Username + ":" + h.Password
return "Basic " + base64.StdEncoding.EncodeToString([]byte(auth))
}
diff --git a/romm/platforms.go b/romm/platforms.go
index e8bfe12b..ec2aa887 100644
--- a/romm/platforms.go
+++ b/romm/platforms.go
@@ -15,6 +15,7 @@ type Platform struct {
ShortName string `json:"short_name"`
LogoPath string `json:"logo_path"`
ROMCount int `json:"rom_count"`
+ FirmwareCount int `json:"firmware_count"`
Firmware []Firmware `json:"Firmware"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
diff --git a/romm/roms.go b/romm/roms.go
index bc1c4596..2d81b283 100644
--- a/romm/roms.go
+++ b/romm/roms.go
@@ -15,6 +15,10 @@ import (
"github.com/sonh/qs"
)
+const (
+ RommAssetPrefix = "/assets/romm/resources"
+)
+
type PlatformDirResolver interface {
GetPlatformRomDirectory(Platform) string
}
@@ -66,6 +70,8 @@ type Rom struct {
CrcHash string `json:"crc_hash,omitempty"`
Md5Hash string `json:"md5_hash,omitempty"`
Sha1Hash string `json:"sha1_hash,omitempty"`
+ RetroAchievementsHash string `json:"ra_hash,omitempty"`
+ RetroAchievementsID int `json:"ra_id,omitempty"`
HasSimpleSingleFile bool `json:"has_simple_single_file,omitempty"`
HasNestedSingleFile bool `json:"has_nested_single_file,omitempty"`
HasMultipleFiles bool `json:"has_multiple_files,omitempty"`
@@ -75,6 +81,7 @@ type Rom struct {
UpdatedAt time.Time `json:"updated_at,omitempty"`
MissingFromFs bool `json:"missing_from_fs,omitempty"`
Siblings []any `json:"siblings,omitempty"`
+ PathVideo string `json:"path_video,omitempty"`
ScreenScraperMetadata ScreenScrapper `json:"ss_metadata,omitempty"`
}
@@ -112,6 +119,7 @@ type RomFile struct {
CrcHash string `json:"crc_hash,omitempty"`
Md5Hash string `json:"md5_hash,omitempty"`
Sha1Hash string `json:"sha1_hash,omitempty"`
+ RAHash string `json:"ra_hash,omitempty"`
Category any `json:"category,omitempty"`
}
@@ -197,6 +205,20 @@ func (c *Client) DownloadRoms(romIDs []int) ([]byte, error) {
return c.doRequestRaw("GET", path, nil)
}
+func joinPathWithQuery(base string, elem ...string) (string, error) {
+ last := elem[len(elem)-1]
+ // because url.JoinPath doesn't handle query parameters without encoding all the string
+ // we need to check if the last element contains a "?" and split it before joining
+ if idx := strings.Index(last, "?"); idx != -1 {
+ joined, err := url.JoinPath(base, append(elem[:len(elem)-1], last[:idx])...)
+ if err != nil {
+ return "", err
+ }
+ return joined + last[idx:], nil
+ }
+ return url.JoinPath(base, elem...)
+}
+
func (r Rom) GetGamePage(host Host) string {
u, _ := url.JoinPath(host.URL(), "rom", strconv.Itoa(r.ID))
return u
@@ -283,61 +305,95 @@ func (r Rom) MaxPlayerCount() int {
}
func (r Rom) GetArtworkURL(kind artutil.ArtKind, host Host) string {
- var coverURL string
+ var (
+ coverURL string
+ boxPath string
+ )
+ var err error
logger := gaba.GetLogger()
logger.Debug("Getting artwork URL for ROM", "romID", r.ID, "romName", r.Name, "artKind", kind)
if kind == artutil.ArtKindBox2D {
if r.ScreenScraperMetadata.Box2DURL != "" {
coverURL = r.ScreenScraperMetadata.Box2DURL
+ boxPath = r.ScreenScraperMetadata.Box2DURL
}
} else if kind == artutil.ArtKindBox3D {
if r.ScreenScraperMetadata.Box3DPath != "" {
- coverURL = host.URL() + r.ScreenScraperMetadata.Box3DPath
+ if !strings.Contains(r.ScreenScraperMetadata.Box3DPath, RommAssetPrefix) {
+ coverURL, err = joinPathWithQuery(host.URL(), RommAssetPrefix, r.ScreenScraperMetadata.Box3DPath)
+ } else {
+ coverURL, err = joinPathWithQuery(host.URL(), r.ScreenScraperMetadata.Box3DPath)
+ }
+ boxPath = r.ScreenScraperMetadata.Box3DPath
} else if r.ScreenScraperMetadata.Box3DURL != "" {
coverURL = r.ScreenScraperMetadata.Box3DURL
+ boxPath = r.ScreenScraperMetadata.Box3DURL
}
} else if kind == artutil.ArtKindMixImage {
if r.ScreenScraperMetadata.MiximagePath != "" {
- coverURL = host.URL() + r.ScreenScraperMetadata.MiximagePath
+ if !strings.Contains(r.ScreenScraperMetadata.MiximagePath, RommAssetPrefix) {
+ coverURL, err = joinPathWithQuery(host.URL(), RommAssetPrefix, r.ScreenScraperMetadata.MiximagePath)
+ } else {
+ coverURL, err = joinPathWithQuery(host.URL(), r.ScreenScraperMetadata.MiximagePath)
+ }
+ boxPath = r.ScreenScraperMetadata.MiximagePath
} else if r.ScreenScraperMetadata.MiximageURL != "" {
coverURL = r.ScreenScraperMetadata.MiximageURL
+ boxPath = r.ScreenScraperMetadata.MiximageURL
}
}
if kind == artutil.ArtKindDefault || coverURL == "" {
if r.PathCoverSmall != "" {
- coverURL = host.URL() + r.PathCoverSmall
+ coverURL, err = joinPathWithQuery(host.URL(), r.PathCoverSmall)
+ boxPath = r.PathCoverSmall
} else if r.PathCoverLarge != "" {
- coverURL = host.URL() + r.PathCoverLarge
+ coverURL, err = joinPathWithQuery(host.URL(), r.PathCoverLarge)
+ boxPath = r.PathCoverLarge
} else if r.URLCover != "" {
coverURL = r.URLCover
+ boxPath = r.URLCover
}
}
logger.Debug("Using cover URL", "url", coverURL)
+ if coverURL == "" && err != nil {
+ logger.Error("Error joining host URL with box path", "error", err, "hostURL", host.ToLoggable(), "boxPath", boxPath)
+ }
return strings.ReplaceAll(coverURL, " ", "%20")
}
func (r Rom) GetScreenshotURL(host Host) string {
- screenshotURL := ""
+ var screenshotURL string
+ var err error
+ logger := gaba.GetLogger()
if len(r.UserScreenshots) > 0 {
- screenshotURL = host.URL() + r.UserScreenshots[0].URLPath
+ screenshotURL, err = joinPathWithQuery(host.URL(), r.UserScreenshots[0].URLPath)
} else if len(r.MergedScreenshots) > 0 {
- screenshotURL = host.URL() + r.MergedScreenshots[0]
+ screenshotURL, err = joinPathWithQuery(host.URL(), r.MergedScreenshots[0])
} else if r.ScreenScraperMetadata.ScreenshotURL != "" {
screenshotURL = r.ScreenScraperMetadata.ScreenshotURL
}
+ if screenshotURL == "" || err != nil {
+ logger.Error("No screenshot found in UserScreenshots, MergedScreenshots or ScreenScraper", "error", err, "hostURL", host.ToLoggable())
+ }
+
return strings.ReplaceAll(screenshotURL, " ", "%20")
}
func (r Rom) GetSplashArtURL(kind artutil.ArtKind, host Host) string {
+ var err error
splashArtURL := ""
if kind == artutil.ArtKindMarquee {
if r.ScreenScraperMetadata.MarqueePath != "" {
- splashArtURL = host.URL() + r.ScreenScraperMetadata.MarqueePath
+ if !strings.Contains(r.ScreenScraperMetadata.MarqueePath, RommAssetPrefix) {
+ splashArtURL, err = joinPathWithQuery(host.URL(), RommAssetPrefix, r.ScreenScraperMetadata.MarqueePath)
+ } else {
+ splashArtURL, err = joinPathWithQuery(host.URL(), r.ScreenScraperMetadata.MarqueePath)
+ }
} else if r.ScreenScraperMetadata.MarqueeURL != "" {
splashArtURL = r.ScreenScraperMetadata.MarqueeURL
}
@@ -345,5 +401,157 @@ func (r Rom) GetSplashArtURL(kind artutil.ArtKind, host Host) string {
splashArtURL = r.ScreenScraperMetadata.TitleScreenURL
}
+ if splashArtURL == "" && err != nil {
+ logger := gaba.GetLogger()
+ logger.Error("Error joining host URL with marquee path", "error", err, "hostURL", host.ToLoggable(), "marqueePath", r.ScreenScraperMetadata.MarqueePath)
+ }
+
return strings.ReplaceAll(splashArtURL, " ", "%20")
}
+
+func (r Rom) GetMarqueeURL(host Host) string {
+ var err error
+ marqueeURL := ""
+ if r.ScreenScraperMetadata.MarqueePath != "" {
+ if !strings.Contains(r.ScreenScraperMetadata.MarqueePath, RommAssetPrefix) {
+ marqueeURL, err = joinPathWithQuery(host.URL(), RommAssetPrefix, r.ScreenScraperMetadata.MarqueePath)
+ } else {
+ marqueeURL, err = joinPathWithQuery(host.URL(), r.ScreenScraperMetadata.MarqueePath)
+ }
+ if err != nil {
+ gaba.GetLogger().Error("Error joining host URL with marquee path", "error", err, "hostURL", host.ToLoggable(), "marqueePath", r.ScreenScraperMetadata.MarqueePath)
+ return ""
+ }
+ return marqueeURL
+ } else if r.ScreenScraperMetadata.MarqueeURL != "" {
+ return r.ScreenScraperMetadata.MarqueeURL
+ }
+ return ""
+}
+
+func (r Rom) GetLogoURL(host Host) string {
+ var err error
+ logoURL := ""
+ if r.ScreenScraperMetadata.LogoPath != "" {
+ if !strings.Contains(r.ScreenScraperMetadata.LogoPath, RommAssetPrefix) {
+ logoURL, err = joinPathWithQuery(host.URL(), RommAssetPrefix, r.ScreenScraperMetadata.LogoPath)
+ } else {
+ logoURL, err = joinPathWithQuery(host.URL(), r.ScreenScraperMetadata.LogoPath)
+ }
+ if err != nil {
+ gaba.GetLogger().Error("Error joining host URL with logo path", "error", err, "hostURL", host.ToLoggable(), "logoPath", r.ScreenScraperMetadata.LogoPath)
+ return ""
+ }
+ return logoURL
+ } else if r.ScreenScraperMetadata.LogoURL != "" {
+ return r.ScreenScraperMetadata.LogoURL
+ }
+ return ""
+}
+
+func (r Rom) GetVideoURL(host Host) string {
+ var err error
+ videoURL := ""
+ if r.ScreenScraperMetadata.VideoPath != "" {
+ if !strings.Contains(r.ScreenScraperMetadata.VideoPath, RommAssetPrefix) {
+ videoURL, err = joinPathWithQuery(host.URL(), RommAssetPrefix, r.ScreenScraperMetadata.VideoPath)
+ } else {
+ videoURL, err = joinPathWithQuery(host.URL(), r.ScreenScraperMetadata.VideoPath)
+ }
+ if err != nil {
+ gaba.GetLogger().Error("Error joining host URL with video path", "error", err, "hostURL", host.ToLoggable(), "videoPath", r.ScreenScraperMetadata.VideoPath)
+ return ""
+ }
+ return videoURL
+ } else if r.ScreenScraperMetadata.VideoURL != "" {
+ return r.ScreenScraperMetadata.VideoURL
+ }
+ return ""
+}
+
+func (r Rom) GetBezelURL(host Host) string {
+ var err error
+ bezelURL := ""
+ if r.ScreenScraperMetadata.BezelPath != "" {
+ if !strings.Contains(r.ScreenScraperMetadata.BezelPath, RommAssetPrefix) {
+ bezelURL, err = joinPathWithQuery(host.URL(), RommAssetPrefix, r.ScreenScraperMetadata.BezelPath)
+ } else {
+ bezelURL, err = joinPathWithQuery(host.URL(), r.ScreenScraperMetadata.BezelPath)
+ }
+ if err != nil {
+ gaba.GetLogger().Error("Error joining host URL with bezel path", "error", err, "hostURL", host.ToLoggable(), "bezelPath", r.ScreenScraperMetadata.BezelPath)
+ return ""
+ }
+ return bezelURL
+ } else if r.ScreenScraperMetadata.BezelURL != "" {
+ return r.ScreenScraperMetadata.BezelURL
+ }
+ return ""
+}
+
+func (r Rom) GetManualURL(host Host) string {
+ manualURL := ""
+ var err error
+ if r.PathManual != "" && r.HasManual {
+ if !strings.Contains(r.PathManual, RommAssetPrefix) {
+ manualURL, err = joinPathWithQuery(host.URL(), RommAssetPrefix, r.PathManual)
+ } else {
+ manualURL, err = joinPathWithQuery(host.URL(), r.PathManual)
+ }
+ if err != nil {
+ gaba.GetLogger().Error("Error joining host URL with manual path", "error", err, "hostURL", host.ToLoggable(), "manualPath", r.PathManual)
+ return ""
+ }
+ return manualURL
+ } else if r.URLManual != "" {
+ return r.URLManual
+ }
+
+ if r.ScreenScraperMetadata.ManualURL != "" {
+ return r.ScreenScraperMetadata.ManualURL
+ }
+
+ return ""
+}
+
+func (r Rom) GetBoxbackURL(host Host) string {
+ var err error
+ boxbackURL := ""
+ if r.ScreenScraperMetadata.Box2DBackPath != "" {
+ if !strings.Contains(r.ScreenScraperMetadata.Box2DBackPath, RommAssetPrefix) {
+ boxbackURL, err = joinPathWithQuery(host.URL(), RommAssetPrefix, r.ScreenScraperMetadata.Box2DBackPath)
+ } else {
+ boxbackURL, err = joinPathWithQuery(host.URL(), r.ScreenScraperMetadata.Box2DBackPath)
+ }
+ if err != nil {
+ gaba.GetLogger().Error("Error joining host URL with boxback path", "error", err, "hostURL", host.ToLoggable(), "boxbackPath", r.ScreenScraperMetadata.Box2DBackPath)
+ return ""
+ }
+ return boxbackURL
+ } else if r.ScreenScraperMetadata.Box2DBackURL != "" {
+ return r.ScreenScraperMetadata.Box2DBackURL
+ }
+
+ return ""
+}
+
+func (r Rom) GetFanartURL(host Host) string {
+ var err error
+ fanartURL := ""
+ if r.ScreenScraperMetadata.FanartPath != "" {
+ if !strings.Contains(r.ScreenScraperMetadata.FanartPath, RommAssetPrefix) {
+ fanartURL, err = joinPathWithQuery(host.URL(), RommAssetPrefix, r.ScreenScraperMetadata.FanartPath)
+ } else {
+ fanartURL, err = joinPathWithQuery(host.URL(), r.ScreenScraperMetadata.FanartPath)
+ }
+ if err != nil {
+ gaba.GetLogger().Error("Error joining host URL with fanart path", "error", err, "hostURL", host.ToLoggable(), "fanartPath", r.ScreenScraperMetadata.FanartPath)
+ return ""
+ }
+ return fanartURL
+ } else if r.ScreenScraperMetadata.FanartURL != "" {
+ return r.ScreenScraperMetadata.FanartURL
+ }
+
+ return ""
+}
diff --git a/scripts/Allium/config.json b/scripts/Allium/config.json
new file mode 100644
index 00000000..3ecf080f
--- /dev/null
+++ b/scripts/Allium/config.json
@@ -0,0 +1,6 @@
+{
+ "label": "Grout",
+ "icon": "/mnt/SDCARD/App/Grout/grout.png",
+ "launch": "launch.sh",
+ "description": "A RomM client for your retro doo-dad"
+}
\ No newline at end of file
diff --git a/scripts/Allium/grout.png b/scripts/Allium/grout.png
new file mode 100644
index 00000000..e99b7604
Binary files /dev/null and b/scripts/Allium/grout.png differ
diff --git a/scripts/Allium/launch.sh b/scripts/Allium/launch.sh
index 03fbe8bd..1f5f5f15 100644
--- a/scripts/Allium/launch.sh
+++ b/scripts/Allium/launch.sh
@@ -2,7 +2,14 @@
CUR_DIR="$(dirname "$0")"
cd "$CUR_DIR"/grout || exit 1
+# Apply pending update
+if [ -d "../../.update" ]; then
+ cp -rf ../../.update/* ../..
+ rm -rf ../../.update
+fi
+
export CFW=ALLIUM
+export IS_MIYOO=1
export LD_LIBRARY_PATH=/mnt/SDCARD/Apps/Grout.pak/grout/lib:$LD_LIBRARY_PATH
export SDL_VIDEODRIVER=mmiyoo
diff --git a/scripts/Batocera/Grout.sh b/scripts/Batocera/Grout.sh
index 438ff622..98f7868f 100644
--- a/scripts/Batocera/Grout.sh
+++ b/scripts/Batocera/Grout.sh
@@ -3,6 +3,12 @@ CUR_DIR="$(dirname "$0")"
FLAG_FILE="./es_restart_request"
cd "$CUR_DIR/Grout" || exit 1
+# Apply pending update
+if [ -d "../.update" ]; then
+ cp -rf ../.update/* ..
+ rm -rf ../.update
+fi
+
export CFW=BATOCERA
export LD_LIBRARY_PATH="$CUR_DIR/Grout/lib:$LD_LIBRARY_PATH"
diff --git a/scripts/Knulli/Grout.sh b/scripts/Knulli/Grout.sh
index fca88b84..269abb9e 100644
--- a/scripts/Knulli/Grout.sh
+++ b/scripts/Knulli/Grout.sh
@@ -3,6 +3,12 @@ CUR_DIR="$(dirname "$0")"
FLAG_FILE="./es_restart_request"
cd "$CUR_DIR" || exit 1
+# Apply pending update
+if [ -d "../.update" ]; then
+ cp -rf ../.update/* ..
+ rm -rf ../.update
+fi
+
export CFW=KNULLI
export LD_LIBRARY_PATH=$CUR_DIR/lib:$LD_LIBRARY_PATH
@@ -10,8 +16,7 @@ export LD_LIBRARY_PATH=$CUR_DIR/lib:$LD_LIBRARY_PATH
if [ -f "$FLAG_FILE" ]; then
rm -f "$FLAG_FILE"
- # batocera-es-swissknife --update-gamelists
- batocera-es-swissknife --restart
+ nohup bash -c "sleep 3 && batocera-es-swissknife --restart" >/dev/null 2>&1 &
fi
-exit 0
\ No newline at end of file
+exit 0
diff --git a/scripts/MinUI/launch.sh b/scripts/MinUI/launch.sh
new file mode 100644
index 00000000..d30e97a1
--- /dev/null
+++ b/scripts/MinUI/launch.sh
@@ -0,0 +1,14 @@
+#!/bin/sh
+CUR_DIR="$(dirname "$0")"
+cd "$CUR_DIR" || exit 1
+
+# Apply pending update
+if [ -d "../.update" ]; then
+ cp -rf ../.update/* ..
+ rm -rf ../.update
+fi
+
+export CFW=MinUI
+export LD_LIBRARY_PATH=$CUR_DIR/lib:$LD_LIBRARY_PATH
+
+./grout
diff --git a/scripts/NextUI/launch.sh b/scripts/NextUI/launch.sh
index 9f9f688f..146c10d7 100644
--- a/scripts/NextUI/launch.sh
+++ b/scripts/NextUI/launch.sh
@@ -2,6 +2,12 @@
CUR_DIR="$(dirname "$0")"
cd "$CUR_DIR" || exit 1
+# Apply pending update
+if [ -d ".update" ]; then
+ cp -rf .update/* .
+ rm -rf .update
+fi
+
export CFW=NEXTUI
export LD_LIBRARY_PATH=$CUR_DIR/lib:$LD_LIBRARY_PATH
diff --git a/scripts/Onion/config.json b/scripts/Onion/config.json
new file mode 100644
index 00000000..3ecf080f
--- /dev/null
+++ b/scripts/Onion/config.json
@@ -0,0 +1,6 @@
+{
+ "label": "Grout",
+ "icon": "/mnt/SDCARD/App/Grout/grout.png",
+ "launch": "launch.sh",
+ "description": "A RomM client for your retro doo-dad"
+}
\ No newline at end of file
diff --git a/scripts/Onion/grout.png b/scripts/Onion/grout.png
new file mode 100644
index 00000000..e99b7604
Binary files /dev/null and b/scripts/Onion/grout.png differ
diff --git a/scripts/Onion/launch.sh b/scripts/Onion/launch.sh
new file mode 100644
index 00000000..cb1b0a58
--- /dev/null
+++ b/scripts/Onion/launch.sh
@@ -0,0 +1,20 @@
+#!/bin/sh
+CUR_DIR="$(dirname "$0")"
+cd "$CUR_DIR"/grout || exit 1
+
+# Apply pending update
+if [ -d "../../.update" ]; then
+ cp -rf ../../.update/* ../..
+ rm -rf ../../.update
+fi
+
+export CFW=ONION
+export IS_MIYOO=1
+export LD_LIBRARY_PATH=/mnt/SDCARD/App/Grout/grout/lib:$LD_LIBRARY_PATH
+
+export SDL_VIDEODRIVER=mmiyoo
+export SDL_AUDIODRIVER=mmiyoo
+export EGL_VIDEODRIVER=mmiyoo
+export SDL_MMIYOO_DOUBLE_BUFFER=1
+
+./grout
diff --git a/scripts/ROCKNIX/Grout.sh b/scripts/ROCKNIX/Grout.sh
index eefc4e76..c48aff84 100644
--- a/scripts/ROCKNIX/Grout.sh
+++ b/scripts/ROCKNIX/Grout.sh
@@ -3,6 +3,12 @@ CUR_DIR="$(dirname "$0")"
FLAG_FILE="./es_restart_request"
cd "$CUR_DIR/Grout" || exit 1
+# Apply pending update
+if [ -d "../.update" ]; then
+ cp -rf ../.update/* ..
+ rm -rf ../.update
+fi
+
export CFW=ROCKNIX
export LD_LIBRARY_PATH="$CUR_DIR/Grout/lib:$LD_LIBRARY_PATH"
export FLIP_FACE_BUTTONS=1
@@ -15,4 +21,4 @@ if [ -f "$FLAG_FILE" ]; then
# or `systemctl restart essway`
fi
-exit 0
\ No newline at end of file
+exit 0
diff --git a/scripts/Spruce/config.json b/scripts/Spruce/config.json
index 55971963..3ecf080f 100644
--- a/scripts/Spruce/config.json
+++ b/scripts/Spruce/config.json
@@ -2,5 +2,5 @@
"label": "Grout",
"icon": "/mnt/SDCARD/App/Grout/grout.png",
"launch": "launch.sh",
- "description": "A RomM client for NextUI, muOS, spruce and knulli"
+ "description": "A RomM client for your retro doo-dad"
}
\ No newline at end of file
diff --git a/scripts/Spruce/launch-universal.sh b/scripts/Spruce/launch-universal.sh
index 9121144c..b681209d 100644
--- a/scripts/Spruce/launch-universal.sh
+++ b/scripts/Spruce/launch-universal.sh
@@ -2,6 +2,12 @@
CUR_DIR="$(dirname "$0")"
cd "$CUR_DIR"/grout || exit 1
+# Apply pending update
+if [ -d "../../.update" ]; then
+ cp -rf ../../.update/* ../..
+ rm -rf ../../.update
+fi
+
export CFW=SPRUCE
export INPUT_CAPTURE=true
#export LD_LIBRARY_PATH=$CUR_DIR/grout/lib:$LD_LIBRARY_PATH
diff --git a/scripts/Spruce/launch.sh b/scripts/Spruce/launch.sh
index 64496f49..e6875b1d 100644
--- a/scripts/Spruce/launch.sh
+++ b/scripts/Spruce/launch.sh
@@ -2,7 +2,13 @@
CUR_DIR="$(dirname "$0")"
cd "$CUR_DIR"/grout || exit 1
+# Apply pending update
+if [ -d "../../.update" ]; then
+ cp -rf ../../.update/* ../..
+ rm -rf ../../.update
+fi
+
export CFW=SPRUCE
export LD_LIBRARY_PATH=$CUR_DIR/grout/lib:$LD_LIBRARY_PATH
-./grout
\ No newline at end of file
+./grout
diff --git a/scripts/Trimui/config.json b/scripts/Trimui/config.json
index 725a885d..bd8741c9 100644
--- a/scripts/Trimui/config.json
+++ b/scripts/Trimui/config.json
@@ -3,5 +3,5 @@
"icon": "grout.png",
"iconsel":"grout.png",
"launch": "launch.sh",
- "description": "A RomM client for NextUI, muOS, Spruce v4, Knulli and TrimUI"
+ "description": "A RomM client for your retro doo-dad"
}
diff --git a/scripts/Trimui/launch.sh b/scripts/Trimui/launch.sh
index a36a63f9..77ea79f9 100644
--- a/scripts/Trimui/launch.sh
+++ b/scripts/Trimui/launch.sh
@@ -2,6 +2,12 @@
CUR_DIR="$(dirname "$0")"
cd "$CUR_DIR"/grout || exit 1
+# Apply pending update
+if [ -d "../../.update" ]; then
+ cp -rf ../../.update/* ../..
+ rm -rf ../../.update
+fi
+
export CFW=TRIMUI
export LD_LIBRARY_PATH=$CUR_DIR/lib:$LD_LIBRARY_PATH
diff --git a/scripts/muOS/mux_launch.sh b/scripts/muOS/mux_launch.sh
index 3016e826..c7677bb3 100644
--- a/scripts/muOS/mux_launch.sh
+++ b/scripts/muOS/mux_launch.sh
@@ -43,6 +43,12 @@ fi
cd "$CUR_DIR" || exit 1
+# Apply pending update
+if [ -d "../.update" ]; then
+ cp -rf ../.update/* ..
+ rm -rf ../.update
+fi
+
export CFW=MUOS
export LD_LIBRARY_PATH=$CUR_DIR/lib:$LD_LIBRARY_PATH
diff --git a/sync/flow.go b/sync/flow.go
index 1a04d19f..780426a8 100644
--- a/sync/flow.go
+++ b/sync/flow.go
@@ -534,9 +534,11 @@ func download(client *romm.Client, config *internal.Config, deviceID string, ite
backupPath := filepath.Join(backupDir, fmt.Sprintf("%s [%s]%s", base, timestamp, ext))
if err := os.MkdirAll(backupDir, 0755); err != nil {
- logger.Warn("Failed to create backup directory", "path", backupDir, "error", err)
+ logger.Error("Failed to create backup directory, aborting download", "path", backupDir, "error", err)
+ return false
} else if err := fileutil.CopyFile(item.LocalSave.FilePath, backupPath); err != nil {
- logger.Warn("Failed to backup save before download", "path", item.LocalSave.FilePath, "error", err)
+ logger.Error("Failed to backup save before download, aborting download", "path", item.LocalSave.FilePath, "error", err)
+ return false
} else {
logger.Debug("Backed up save before download", "backup", backupPath)
if config != nil && config.SaveBackupLimit > 0 {
diff --git a/taskfile.yml b/taskfile.yml
index 0ce6ba87..09d7da75 100644
--- a/taskfile.yml
+++ b/taskfile.yml
@@ -22,11 +22,18 @@ tasks:
- docker buildx build {{.NO_CACHE}} --load --platform=linux/amd64 --build-arg GITHUB_ACTIONS=false --label {{.LABEL}} -t {{.IMAGE_NAME}}-amd64 -f docker/Dockerfile .
silent: true
+ build-x86:
+ desc: Build for x86 (32-bit)
+ cmds:
+ - rm -rf buildx86
+ - docker buildx build {{.NO_CACHE}} --load --platform=linux/386 --build-arg GITHUB_ACTIONS=false --label {{.LABEL}} -t {{.IMAGE_NAME}}-x86 -f docker/Dockerfile .
+ silent: true
+
build-32:
desc: Build for ARM32
cmds:
- rm -rf build32
- - docker buildx build {{.NO_CACHE}} --platform=linux/arm/v7 --build-arg GITHUB_ACTIONS=false --label {{.LABEL}} -t {{.IMAGE_NAME}} -f docker/32.Dockerfile .
+ - docker buildx build {{.NO_CACHE}} --platform=linux/arm/v7 --build-arg GITHUB_ACTIONS=false --label {{.LABEL}} --load -t {{.IMAGE_NAME}} -f docker/32.Dockerfile .
silent: true
build-local:
@@ -40,7 +47,7 @@ tasks:
desc: Build for ARM32 using local gabagool workspace
cmds:
- rm -rf build32
- - cd .. && docker buildx build {{.NO_CACHE}} --platform=linux/arm/v7 --build-arg USE_LOCAL_GABAGOOL=true --build-arg GITHUB_ACTIONS=false --label {{.LABEL}} -f grout/docker/32.Dockerfile -t {{.IMAGE_NAME}} .
+ - cd .. && docker buildx build {{.NO_CACHE}} --platform=linux/arm/v7 --build-arg USE_LOCAL_GABAGOOL=true --build-arg GITHUB_ACTIONS=false --label {{.LABEL}} --load -f grout/docker/32.Dockerfile -t {{.IMAGE_NAME}} .
silent: true
extract:
@@ -65,6 +72,17 @@ tasks:
- docker rm {{.CONTAINER_NAME}}
silent: true
+ extract-x86:
+ desc: Extract binary and libraries from x86 Docker image
+ cmds:
+ - mkdir -p buildx86/lib
+ - docker rm {{.CONTAINER_NAME}} 2>/dev/null || true
+ - docker create --name {{.CONTAINER_NAME}} --label {{.LABEL}} {{.IMAGE_NAME}}-x86
+ - docker cp {{.CONTAINER_NAME}}:/build/grout buildx86/grout
+ - docker cp {{.CONTAINER_NAME}}:/usr/lib/i386-linux-gnu/libSDL2_gfx-1.0.so.0.0.2 buildx86/lib/libSDL2_gfx-1.0.so.0
+ - docker rm {{.CONTAINER_NAME}}
+ silent: true
+
extract-32:
desc: Extract binary and libraries from Docker image
cmds:
@@ -78,93 +96,145 @@ tasks:
package:
desc: Package for all platforms
- deps: [ package-next, package-muos, package-knulli, package-spruce, package-rocknix, package-trimui, package-allium, package-batocera ]
+ deps: [ package-next, package-muos, package-knulli, package-spruce, package-rocknix, package-trimui, package-allium, package-onion, package-minui, package-minui-arm32, package-batocera, package-batocera-x86, package-batocera-amd64 ]
silent: true
package-next:
desc: Package for NextUI
cmds:
- - rm -rf build64/Grout.pak
- - mkdir -p build64/Grout.pak/lib
- - cp build64/grout scripts/NextUI/launch.sh README.md LICENSE pak.json build64/Grout.pak
- - cp -R build64/lib/* build64/Grout.pak/lib/
- - chmod +x build64/Grout.pak/grout build64/Grout.pak/launch.sh
+ - rm -rf dist/Grout.pak
+ - mkdir -p dist/Grout.pak/lib
+ - cp build64/grout scripts/NextUI/launch.sh README.md LICENSE pak.json dist/Grout.pak
+ - cp -R build64/lib/* dist/Grout.pak/lib/
+ - chmod a+x dist/Grout.pak/grout dist/Grout.pak/launch.sh
silent: true
package-muos:
desc: Package for muOS
cmds:
- - rm -rf build64/muOS build64/Grout.muxapp
- - mkdir -p build64/muOS/Grout/lib
- - cp build64/grout scripts/muOS/mux_launch.sh README.md LICENSE build64/muOS/Grout
- - cp -R build64/lib/* build64/muOS/Grout/lib/
- - cp -R scripts/muOS/resources build64/muOS/Grout/
- - chmod +x build64/muOS/Grout/grout build64/muOS/Grout/mux_launch.sh
- - cd build64/muOS && zip -r ../Grout.muxapp Grout
+ - rm -rf dist/muOS dist/Grout.muxapp
+ - mkdir -p dist/muOS/Grout/lib
+ - cp build64/grout scripts/muOS/mux_launch.sh README.md LICENSE dist/muOS/Grout
+ - cp -R build64/lib/* dist/muOS/Grout/lib/
+ - cp -R scripts/muOS/resources dist/muOS/Grout/
+ - chmod a+x dist/muOS/Grout/grout dist/muOS/Grout/mux_launch.sh
+ - cd dist/muOS && zip -r ../Grout.muxapp Grout
silent: true
package-knulli:
desc: Package for Knulli
cmds:
- - rm -rf build64/Knulli
- - mkdir -p build64/Knulli/Grout/lib
- - cp build64/grout scripts/Knulli/Grout.sh scripts/Knulli/logo.png README.md LICENSE build64/Knulli/Grout
- - cp -R build64/lib/* build64/Knulli/Grout/lib/
- - chmod +x build64/Knulli/Grout/grout build64/Knulli/Grout/Grout.sh
+ - rm -rf dist/Knulli
+ - mkdir -p dist/Knulli/Grout/lib
+ - cp build64/grout scripts/Knulli/Grout.sh scripts/Knulli/logo.png README.md LICENSE dist/Knulli/Grout
+ - cp -R build64/lib/* dist/Knulli/Grout/lib/
+ - chmod a+x dist/Knulli/Grout/grout dist/Knulli/Grout/Grout.sh
silent: true
package-spruce:
desc: Package for Spruce
cmds:
- - rm -rf build64/Spruce
- - mkdir -p build64/Spruce/Grout/grout/lib
- - cp scripts/Spruce/* README.md LICENSE build64/Spruce/Grout
- - cp build64/grout build64/Spruce/Grout/grout
- - cp -R build64/lib/* build64/Spruce/Grout/grout/lib/
- - chmod +x build64/Spruce/Grout/grout/grout build64/Spruce/Grout/launch.sh build64/Spruce/Grout/launch-universal.sh
+ - rm -rf dist/Spruce
+ - mkdir -p dist/Spruce/Grout/grout/lib
+ - cp scripts/Spruce/* README.md LICENSE dist/Spruce/Grout
+ - cp build64/grout dist/Spruce/Grout/grout
+ - cp -R build64/lib/* dist/Spruce/Grout/grout/lib/
+ - chmod a+x dist/Spruce/Grout/grout/grout dist/Spruce/Grout/launch.sh dist/Spruce/Grout/launch-universal.sh
silent: true
package-allium:
desc: Package for Allium
cmds:
- - rm -rf build64/Allium
- - mkdir -p build64/Allium/Grout.pak/grout/lib
- - cp -R scripts/Allium/* build64/Allium/Grout.pak
- - cp build32/grout README.md LICENSE build64/Allium/Grout.pak/grout
-# - cp -R build64/lib/* build64/Allium/Grout.pak/grout/lib/ # Come back for these libs
+ - rm -rf dist/Allium
+ - mkdir -p dist/Allium/Grout.pak/grout/lib
+ - cp -R scripts/Allium/* dist/Allium/Grout.pak
+ - cp build32/grout README.md LICENSE dist/Allium/Grout.pak/grout
+ - cp -R vendored/miyoo/* dist/Allium/Grout.pak/grout/lib/
+ silent: true
+
+ package-onion:
+ desc: Package for Onion
+ cmds:
+ - rm -rf dist/Onion
+ - mkdir -p dist/Onion/Grout/grout/lib
+ - cp -R scripts/Onion/* dist/Onion/Grout
+ - cp build32/grout README.md LICENSE dist/Onion/Grout/grout
+ - cp -R vendored/miyoo/* dist/Onion/Grout/grout/lib/
silent: true
package-rocknix:
desc: Package for ROCKNIX
cmds:
- - rm -rf build64/ROCKNIX
- - mkdir -p build64/ROCKNIX/Grout/lib
- - cp scripts/ROCKNIX/Grout.sh build64/ROCKNIX/
- - cp build64/grout scripts/ROCKNIX/logo.png README.md LICENSE build64/ROCKNIX/Grout/
- - cp -R build64/lib/* build64/ROCKNIX/Grout/lib/
- - chmod +x build64/ROCKNIX/Grout/grout build64/ROCKNIX/Grout.sh
+ - rm -rf dist/ROCKNIX
+ - mkdir -p dist/ROCKNIX/Grout/lib
+ - cp scripts/ROCKNIX/Grout.sh dist/ROCKNIX/
+ - cp build64/grout scripts/ROCKNIX/logo.png README.md LICENSE dist/ROCKNIX/Grout/
+ - cp -R build64/lib/* dist/ROCKNIX/Grout/lib/
+ - chmod a+x dist/ROCKNIX/Grout/grout dist/ROCKNIX/Grout.sh
silent: true
package-trimui:
desc: Package for Trimui
cmds:
- - rm -rf build64/Trimui
- - mkdir -p build64/Trimui/Grout/grout/lib
- - cp scripts/Trimui/* README.md LICENSE build64/Trimui/Grout
- - cp build64/grout build64/Trimui/Grout/grout
- - cp -R build64/lib/* build64/Trimui/Grout/grout/lib/
- - chmod +x build64/Trimui/Grout/grout/grout build64/Trimui/Grout/launch.sh
+ - rm -rf dist/Trimui
+ - mkdir -p dist/Trimui/Grout/grout/lib
+ - cp scripts/Trimui/* README.md LICENSE dist/Trimui/Grout
+ - cp build64/grout dist/Trimui/Grout/grout
+ - cp -R build64/lib/* dist/Trimui/Grout/grout/lib/
+ - chmod a+x dist/Trimui/Grout/grout/grout dist/Trimui/Grout/launch.sh
+ silent: true
+
+ package-minui:
+ desc: Package MinUI for ARM64
+ cmds:
+ - rm -rf dist/MinUI-arm64
+ - mkdir -p dist/MinUI-arm64/Grout/lib
+ - cp build64/grout scripts/MinUI/launch.sh README.md LICENSE dist/MinUI-arm64/Grout
+ - cp -R build64/lib/* dist/MinUI-arm64/Grout/lib/
+ - chmod a+x dist/MinUI-arm64/Grout/grout dist/MinUI-arm64/Grout/launch.sh
+ silent: true
+
+ package-minui-arm32:
+ desc: Package MinUI for ARM32
+ cmds:
+ - rm -rf dist/MinUI-arm32
+ - mkdir -p dist/MinUI-arm32/Grout/lib
+ - cp build32/grout scripts/MinUI/launch.sh README.md LICENSE dist/MinUI-arm32/Grout
+ - cp -R vendored/miyoo/* dist/MinUI-arm32/Grout/lib/
+ - chmod a+x dist/MinUI-arm32/Grout/grout dist/MinUI-arm32/Grout/launch.sh
silent: true
package-batocera:
- desc: Package for Batocera
+ desc: Package for Batocera (ARM64)
+ cmds:
+ - rm -rf dist/Batocera-arm64
+ - mkdir -p dist/Batocera-arm64/Grout/lib
+ - cp scripts/Batocera/Grout.sh dist/Batocera-arm64/
+ - cp build64/grout README.md LICENSE dist/Batocera-arm64/Grout/
+ - cp -R build64/lib/* dist/Batocera-arm64/Grout/lib/
+ - chmod a+x dist/Batocera-arm64/Grout/grout dist/Batocera-arm64/Grout.sh
+ silent: true
+
+ package-batocera-x86:
+ desc: Package for Batocera (x86 32-bit)
+ cmds:
+ - rm -rf dist/Batocera-x86
+ - mkdir -p dist/Batocera-x86/Grout/lib
+ - cp scripts/Batocera/Grout.sh dist/Batocera-x86/
+ - cp buildx86/grout README.md LICENSE dist/Batocera-x86/Grout/
+ - cp -R buildx86/lib/* dist/Batocera-x86/Grout/lib/
+ - chmod a+x dist/Batocera-x86/Grout/grout dist/Batocera-x86/Grout.sh
+ silent: true
+
+ package-batocera-amd64:
+ desc: Package for Batocera (AMD64/x86_64)
cmds:
- - rm -rf build64/Batocera
- - mkdir -p build64/Batocera/Grout/lib
- - cp scripts/Batocera/Grout.sh build64/Batocera/
- - cp build64/grout README.md LICENSE build64/Batocera/Grout/
- - cp -R build64/lib/* build64/Batocera/Grout/lib/
- - chmod +x build64/Batocera/Grout/grout build64/Batocera/Grout.sh
+ - rm -rf dist/Batocera-amd64
+ - mkdir -p dist/Batocera-amd64/Grout/lib
+ - cp scripts/Batocera/Grout.sh dist/Batocera-amd64/
+ - cp build/grout README.md LICENSE dist/Batocera-amd64/Grout/
+ - cp -R build/lib/* dist/Batocera-amd64/Grout/lib/
+ - chmod a+x dist/Batocera-amd64/Grout/grout dist/Batocera-amd64/Grout.sh
silent: true
batocera-amd64:
@@ -172,12 +242,7 @@ tasks:
cmds:
- task: build-amd64
- task: extract-amd64
- - rm -rf build/Batocera
- - mkdir -p build/Batocera/Grout/lib
- - cp scripts/Batocera/Grout.sh build/Batocera/
- - cp build/grout README.md LICENSE build/Batocera/Grout/
- - cp -R build/lib/* build/Batocera/Grout/lib/
- - chmod +x build/Batocera/Grout/grout build/Batocera/Grout.sh
+ - task: package-batocera-amd64
silent: true
all:
@@ -209,6 +274,7 @@ tasks:
- task: build-32
- task: extract-32
- task: package-allium
+ - task: package-onion
silent: true
all-local:
@@ -225,7 +291,7 @@ tasks:
desc: Deploy to NextUI device via ADB
cmds:
- adb shell rm -rf /mnt/SDCARD/Tools/tg5040/Grout.pak
- - adb push build64/Grout.pak /mnt/SDCARD/Tools/tg5040
+ - adb push dist/Grout.pak /mnt/SDCARD/Tools/tg5040
- adb push config-platformless.json /mnt/SDCARD/Tools/tg5040/Grout.pak/config.json
silent: true
@@ -233,7 +299,7 @@ tasks:
desc: Deploy to muOS device via ADB (SD1)
cmds:
- adb shell rm -rf /mnt/mmc/MUOS/application/Grout
- - adb push build64/muOS/Grout /mnt/mmc/MUOS/application
+ - adb push dist/muOS/Grout /mnt/mmc/MUOS/application
- adb push config-platformless.json /mnt/mmc/MUOS/application/Grout/config.json
silent: true
@@ -241,7 +307,7 @@ tasks:
desc: Deploy to muOS device via ADB (SD2)
cmds:
- adb shell rm -rf /mnt/sdcard/MUOS/application/Grout
- - adb push build64/muOS/Grout /mnt/sdcard/MUOS/application
+ - adb push dist/muOS/Grout /mnt/sdcard/MUOS/application
- adb push config-platformless.json /mnt/sdcard/MUOS/application/Grout/config.json
silent: true
@@ -249,7 +315,7 @@ tasks:
desc: Deploy to Knulli device via ADB
cmds:
- adb shell rm -rf /userdata/roms/ports/Grout
- - adb push build64/Knulli/Grout /userdata/roms/ports
+ - adb push dist/Knulli/Grout /userdata/roms/ports
- adb push config-platformless.json /userdata/roms/ports/Grout/config.json
silent: true
@@ -326,7 +392,7 @@ tasks:
clean:
desc: Remove build artifacts
cmds:
- - rm -rf build64 build32
+ - rm -rf build64 build buildx86 build32 dist
silent: true
hooks-setup:
diff --git a/tools/create-conflict/main.go b/tools/create-conflict/main.go
index bd73a706..61b90d81 100644
--- a/tools/create-conflict/main.go
+++ b/tools/create-conflict/main.go
@@ -56,7 +56,7 @@ func main() {
os.Exit(1)
}
- client := romm.NewClientFromHost(host, config.ApiTimeout)
+ client := romm.NewClientFromHost(host, config.ApiTimeout.Duration())
fmt.Printf("Host: %s\n", host.URL())
fmt.Printf("Device: %s\n", host.DeviceID)
diff --git a/tools/gen-platforms/main.go b/tools/gen-platforms/main.go
index 015109ec..933aff3b 100644
--- a/tools/gen-platforms/main.go
+++ b/tools/gen-platforms/main.go
@@ -18,10 +18,13 @@ import (
)
var cfwMapping = map[string]string{
+ "ALLIUM": "allium",
"BATOCERA": "batocera",
"KNULLI": "knulli",
"MUOS": "muos",
"NEXTUI": "nextui",
+ "MINUI": "minui",
+ "ONION": "onion",
"ROCKNIX": "rocknix",
"SPRUCE": "spruce",
"TRIMUI": "trimui",
@@ -34,7 +37,7 @@ func main() {
arg := strings.ToUpper(os.Args[1])
if _, ok := cfwMapping[arg]; !ok {
fmt.Fprintf(os.Stderr, "Unknown CFW: %s\n", os.Args[1])
- fmt.Fprintf(os.Stderr, "Valid options: batocera, knulli, muos, nextui, rocknix, spruce, trimui\n")
+ fmt.Fprintf(os.Stderr, "Valid options: allium, batocera, knulli, muos, nextui, onion, rocknix, spruce, trimui\n")
os.Exit(1)
}
targets = []string{arg}
diff --git a/tools/save-sync-dry-run/main.go b/tools/save-sync-dry-run/main.go
index 82c4de72..99dbb141 100644
--- a/tools/save-sync-dry-run/main.go
+++ b/tools/save-sync-dry-run/main.go
@@ -42,7 +42,7 @@ func main() {
}
defer cache.GetCacheManager().Close()
- client := romm.NewClientFromHost(host, config.ApiTimeout)
+ client := romm.NewClientFromHost(host, config.ApiTimeout.Duration())
fmt.Printf("Host: %s\n", host.URL())
fmt.Printf("Device: %s\n", host.DeviceID)
diff --git a/ui/actions.go b/ui/actions.go
index 3b3cee75..543ddbc3 100644
--- a/ui/actions.go
+++ b/ui/actions.go
@@ -68,9 +68,11 @@ type SettingsAction int
const (
SettingsActionSaved SettingsAction = iota
+ SettingsActionSwitchToToken
SettingsActionGeneral
SettingsActionCollections
SettingsActionAdvanced
+ SettingsActionTools
SettingsActionPlatformMapping
SettingsActionInfo
SettingsActionCheckUpdate
@@ -102,6 +104,14 @@ const (
AdvancedSettingsActionBack
)
+type ToolsSettingsAction int
+
+const (
+ ToolsSettingsActionSaved ToolsSettingsAction = iota
+ ToolsSettingsActionSyncLocalArtwork
+ ToolsSettingsActionBack
+)
+
type PlatformMappingAction int
const (
diff --git a/ui/advanced_settings.go b/ui/advanced_settings.go
index 9b04372c..6fe049bc 100644
--- a/ui/advanced_settings.go
+++ b/ui/advanced_settings.go
@@ -88,6 +88,7 @@ func (s *AdvancedSettingsScreen) Draw(input AdvancedSettingsInput) (AdvancedSett
output.Action = AdvancedSettingsActionServerAddress
return output, nil
}
+
}
s.applySettings(config, result.Items)
@@ -103,7 +104,7 @@ func (s *AdvancedSettingsScreen) Draw(input AdvancedSettingsInput) (AdvancedSett
}
func (s *AdvancedSettingsScreen) buildMenuItems(config *internal.Config) []gaba.ItemWithOptions {
- return []gaba.ItemWithOptions{
+ items := []gaba.ItemWithOptions{
{
Item: gaba.MenuItem{Text: i18n.Localize(&goi18n.Message{ID: "settings_sync_artwork", Other: "Preload Artwork"}, nil)},
Options: []gaba.Option{{Type: gaba.OptionTypeClickable}},
@@ -124,7 +125,7 @@ func (s *AdvancedSettingsScreen) buildMenuItems(config *internal.Config) []gaba.
{DisplayName: i18n.Localize(&goi18n.Message{ID: "time_105_minutes", Other: "105 Minutes"}, nil), Value: 105 * time.Minute},
{DisplayName: i18n.Localize(&goi18n.Message{ID: "time_120_minutes", Other: "120 Minutes"}, nil), Value: 120 * time.Minute},
},
- SelectedOption: s.findDownloadTimeoutIndex(config.DownloadTimeout),
+ SelectedOption: s.findDownloadTimeoutIndex(config.DownloadTimeout.Duration()),
},
{
Item: gaba.MenuItem{Text: i18n.Localize(&goi18n.Message{ID: "settings_api_timeout", Other: "API Timeout"}, nil)},
@@ -140,15 +141,7 @@ func (s *AdvancedSettingsScreen) buildMenuItems(config *internal.Config) []gaba.
{DisplayName: i18n.Localize(&goi18n.Message{ID: "time_240_seconds", Other: "240 Seconds"}, nil), Value: 240 * time.Second},
{DisplayName: i18n.Localize(&goi18n.Message{ID: "time_300_seconds", Other: "300 Seconds"}, nil), Value: 300 * time.Second},
},
- SelectedOption: s.findApiTimeoutIndex(config.ApiTimeout),
- },
- {
- Item: gaba.MenuItem{Text: i18n.Localize(&goi18n.Message{ID: "settings_kid_mode", Other: "Kid Mode"}, nil)},
- Options: []gaba.Option{
- {DisplayName: i18n.Localize(&goi18n.Message{ID: "option_disabled", Other: "Disabled"}, nil), Value: false},
- {DisplayName: i18n.Localize(&goi18n.Message{ID: "option_enabled", Other: "Enabled"}, nil), Value: true},
- },
- SelectedOption: boolToIndex(config.KidMode),
+ SelectedOption: s.findApiTimeoutIndex(config.ApiTimeout.Duration()),
},
{
Item: gaba.MenuItem{Text: i18n.Localize(&goi18n.Message{ID: "settings_server_address", Other: "Server Address"}, nil)},
@@ -173,6 +166,8 @@ func (s *AdvancedSettingsScreen) buildMenuItems(config *internal.Config) []gaba.
SelectedOption: logLevelToIndex(config.LogLevel),
},
}
+
+ return items
}
func (s *AdvancedSettingsScreen) applySettings(config *internal.Config, items []gaba.ItemWithOptions) {
@@ -182,12 +177,12 @@ func (s *AdvancedSettingsScreen) applySettings(config *internal.Config, items []
switch selectedText {
case i18n.Localize(&goi18n.Message{ID: "settings_download_timeout", Other: "Download Timeout"}, nil):
if val, ok := item.Options[item.SelectedOption].Value.(time.Duration); ok {
- config.DownloadTimeout = val
+ config.DownloadTimeout = internal.DurationSeconds(val)
}
case i18n.Localize(&goi18n.Message{ID: "settings_api_timeout", Other: "API Timeout"}, nil):
if val, ok := item.Options[item.SelectedOption].Value.(time.Duration); ok {
- config.ApiTimeout = val
+ config.ApiTimeout = internal.DurationSeconds(val)
}
case i18n.Localize(&goi18n.Message{ID: "settings_log_level", Other: "Log Level"}, nil):
@@ -200,11 +195,6 @@ func (s *AdvancedSettingsScreen) applySettings(config *internal.Config, items []
config.ReleaseChannel = val
}
- case i18n.Localize(&goi18n.Message{ID: "settings_kid_mode", Other: "Kid Mode"}, nil):
- if val, ok := item.Options[item.SelectedOption].Value.(bool); ok {
- config.KidMode = val
- internal.SetKidMode(val)
- }
}
}
}
diff --git a/ui/artwork_sync.go b/ui/artwork_sync.go
index c6157cf2..545b7aeb 100644
--- a/ui/artwork_sync.go
+++ b/ui/artwork_sync.go
@@ -4,8 +4,12 @@ import (
"fmt"
"grout/cache"
"grout/internal"
+ "grout/internal/artutil"
+ "grout/internal/fileutil"
"grout/internal/imageutil"
"grout/romm"
+ "net/url"
+ "path/filepath"
"sync"
"sync/atomic"
@@ -21,8 +25,9 @@ const (
)
type ArtworkSyncInput struct {
- Config internal.Config
- Host romm.Host
+ Config internal.Config
+ Host romm.Host
+ DownloadedOnly bool
}
type ArtworkSyncOutput struct{}
@@ -33,11 +38,8 @@ func NewArtworkSyncScreen() *ArtworkSyncScreen {
return &ArtworkSyncScreen{}
}
-func (s *ArtworkSyncScreen) Execute(config internal.Config, host romm.Host) ArtworkSyncOutput {
- s.draw(ArtworkSyncInput{
- Config: config,
- Host: host,
- })
+func (s *ArtworkSyncScreen) Execute(input ArtworkSyncInput) ArtworkSyncOutput {
+ s.draw(input)
return ArtworkSyncOutput{}
}
@@ -51,7 +53,7 @@ func (s *ArtworkSyncScreen) draw(input ArtworkSyncInput) {
platforms, err = cm.GetPlatforms()
}
if len(platforms) == 0 {
- client := romm.NewClientFromHost(input.Host, input.Config.ApiTimeout)
+ client := romm.NewClientFromHost(input.Host, input.Config.ApiTimeout.Duration())
platforms, err = client.GetPlatforms()
if err != nil {
logger.Error("Failed to fetch platforms", "error", err)
@@ -137,7 +139,19 @@ func (s *ArtworkSyncScreen) draw(input ArtworkSyncInput) {
return nil, nil
}
- if artForceRes.SelectedValue == SyncMissingOnlyOption {
+ if input.DownloadedOnly {
+ var downloaded []romm.Rom
+ for _, r := range roms {
+ if r.IsDownloaded(input.Config) {
+ downloaded = append(downloaded, r)
+ }
+ }
+ roms = downloaded
+
+ if artForceRes.SelectedValue == SyncMissingOnlyOption {
+ roms = filterMissingCFWArt(roms, p, input.Config, input.Host)
+ }
+ } else if artForceRes.SelectedValue == SyncMissingOnlyOption {
roms = cache.GetMissingArtwork(roms)
}
@@ -186,31 +200,34 @@ func (s *ArtworkSyncScreen) draw(input ArtworkSyncInput) {
}
// Collect artwork from selected platforms
- var allMissingArtwork []romm.Rom
+ var selectedResults []platformRoms
for _, idx := range sel.Selected {
pr := sel.Items[idx].Metadata.(platformArtwork)
- allMissingArtwork = append(allMissingArtwork, pr.roms...)
+ selectedResults = append(selectedResults, platformRoms{platform: pr.platform, roms: pr.roms})
}
var downloads []gaba.Download
- romsByLocation := make(map[string]romm.Rom)
-
- for _, rom := range allMissingArtwork {
- downloadURL := cache.GetArtworkCoverPath(rom, input.Config.ArtKind, input.Host)
- if downloadURL == "" {
- continue
- }
- cachePath := cache.GetArtworkCachePath(rom.PlatformFSSlug, rom.ID)
+ if input.DownloadedOnly {
+ downloads = buildCFWArtDownloads(selectedResults, input.Config, input.Host)
+ } else {
+ for _, sr := range selectedResults {
+ for _, rom := range sr.roms {
+ downloadURL := cache.GetArtworkCoverPath(rom, input.Config.ArtKind, input.Host)
+ if downloadURL == "" {
+ continue
+ }
- cache.EnsureArtworkCacheDir(rom.PlatformFSSlug)
+ cachePath := cache.GetArtworkCachePath(rom.PlatformFSSlug, rom.ID)
+ cache.EnsureArtworkCacheDir(rom.PlatformFSSlug)
- downloads = append(downloads, gaba.Download{
- URL: downloadURL,
- Location: cachePath,
- DisplayName: rom.Name,
- })
- romsByLocation[cachePath] = rom
+ downloads = append(downloads, gaba.Download{
+ URL: downloadURL,
+ Location: cachePath,
+ DisplayName: rom.Name,
+ })
+ }
+ }
}
if len(downloads) == 0 {
@@ -223,7 +240,7 @@ func (s *ArtworkSyncScreen) draw(input ArtworkSyncInput) {
}
headers := make(map[string]string)
- headers["Authorization"] = input.Host.BasicAuthHeader()
+ headers["Authorization"] = input.Host.AuthHeader()
res, err := gaba.DownloadManager(downloads, headers, gaba.DownloadManagerOptions{
AutoContinueOnComplete: true,
@@ -275,6 +292,19 @@ func (s *ArtworkSyncScreen) draw(input ArtworkSyncInput) {
finalCount := int(atomic.LoadInt32(&successCount))
logger.Info("Artwork sync complete", "success", finalCount, "failed", len(res.Failed))
+ for _, failed := range res.Failed {
+ path := failed.Download.URL
+ if u, err := url.Parse(failed.Download.URL); err == nil {
+ path = u.Path
+ }
+ logger.Error("Failed to download artwork",
+ "path", path,
+ "name", failed.Download.DisplayName,
+ "timeout", failed.Download.Timeout,
+ "error", failed.Error,
+ )
+ }
+
if finalCount > 0 {
gaba.ConfirmationMessage(
fmt.Sprintf(i18n.Localize(&goi18n.Message{ID: "artwork_sync_complete", Other: "Successfully downloaded %d artwork images."}, nil), finalCount),
@@ -289,3 +319,97 @@ func (s *ArtworkSyncScreen) draw(input ArtworkSyncInput) {
)
}
}
+
+// filterMissingCFWArt returns only roms that are missing art in the CFW art directory.
+func filterMissingCFWArt(roms []romm.Rom, platform romm.Platform, config internal.Config, host romm.Host) []romm.Rom {
+ var missing []romm.Rom
+ for _, rom := range roms {
+ if !cache.HasArtworkURL(rom) {
+ continue
+ }
+ artDir := config.GetArtDirectory(platform)
+ artPath := filepath.Join(artDir, rom.FsNameNoExt+".png")
+ if !fileutil.FileExists(artPath) {
+ missing = append(missing, rom)
+ continue
+ }
+ // Also check preview and splash if configured
+ if config.DownloadArtScreenshotPreview {
+ previewDir := config.GetArtPreviewDirectory(platform)
+ if previewDir != "" && rom.GetScreenshotURL(host) != "" {
+ if !fileutil.FileExists(filepath.Join(previewDir, rom.FsNameNoExt+".png")) {
+ missing = append(missing, rom)
+ continue
+ }
+ }
+ }
+ if config.DownloadSplashArt != artutil.ArtKindNone {
+ splashDir := config.GetArtSplashDirectory(platform)
+ if splashDir != "" && rom.GetSplashArtURL(config.DownloadSplashArt, host) != "" {
+ if !fileutil.FileExists(filepath.Join(splashDir, rom.FsNameNoExt+".png")) {
+ missing = append(missing, rom)
+ continue
+ }
+ }
+ }
+ }
+ return missing
+}
+
+type platformRoms struct {
+ platform romm.Platform
+ roms []romm.Rom
+}
+
+// buildCFWArtDownloads builds download entries targeting CFW art directories.
+func buildCFWArtDownloads(results []platformRoms, config internal.Config, host romm.Host) []gaba.Download {
+ var downloads []gaba.Download
+
+ for _, sr := range results {
+ for _, rom := range sr.roms {
+ artFileName := rom.FsNameNoExt + ".png"
+
+ // Cover art
+ coverURL := rom.GetArtworkURL(config.ArtKind, host)
+ if coverURL != "" {
+ artDir := config.GetArtDirectory(sr.platform)
+ artLocation := filepath.Join(artDir, artFileName)
+ downloads = append(downloads, gaba.Download{
+ URL: coverURL,
+ Location: artLocation,
+ DisplayName: rom.Name,
+ })
+ }
+
+ // Screenshot preview
+ if config.DownloadArtScreenshotPreview {
+ previewDir := config.GetArtPreviewDirectory(sr.platform)
+ if previewDir != "" {
+ if screenshotURL := rom.GetScreenshotURL(host); screenshotURL != "" {
+ downloads = append(downloads, gaba.Download{
+ URL: screenshotURL,
+ Location: filepath.Join(previewDir, artFileName),
+ DisplayName: rom.Name,
+ })
+ }
+ }
+ }
+
+ // Splash art
+ if config.DownloadSplashArt != artutil.ArtKindNone {
+ splashDir := config.GetArtSplashDirectory(sr.platform)
+ if splashDir != "" {
+ if splashURL := rom.GetSplashArtURL(config.DownloadSplashArt, host); splashURL != "" {
+ downloads = append(downloads, gaba.Download{
+ URL: splashURL,
+ Location: filepath.Join(splashDir, artFileName),
+ DisplayName: rom.Name,
+ })
+ }
+ }
+ }
+ }
+ }
+
+ return downloads
+}
diff --git a/ui/bios_download.go b/ui/bios_download.go
index c3e36e02..275966d6 100644
--- a/ui/bios_download.go
+++ b/ui/bios_download.go
@@ -56,7 +56,7 @@ func (s *BIOSDownloadScreen) draw(input BIOSDownloadInput) (BIOSDownloadOutput,
}
// Fetch firmware list from RomM first
- client := romm.NewClientFromHost(input.Host, input.Config.ApiTimeout)
+ client := romm.NewClientFromHost(input.Host, input.Config.ApiTimeout.Duration())
firmwareList, err := client.GetFirmware(input.Platform.ID)
if err != nil {
logger.Error("Failed to fetch firmware from RomM", "error", err, "platform_id", input.Platform.ID)
@@ -218,7 +218,7 @@ func (s *BIOSDownloadScreen) draw(input BIOSDownloadInput) (BIOSDownloadOutput,
}
headers := make(map[string]string)
- headers["Authorization"] = input.Host.BasicAuthHeader()
+ headers["Authorization"] = input.Host.AuthHeader()
res, err := gaba.DownloadManager(downloads, headers, gaba.DownloadManagerOptions{
AutoContinueOnComplete: true,
diff --git a/ui/device_registration.go b/ui/device_registration.go
index 30349cef..f8841689 100644
--- a/ui/device_registration.go
+++ b/ui/device_registration.go
@@ -166,7 +166,7 @@ func (s *SaveSyncSettingsScreen) drawRegistered(input SaveSyncSettingsInput) (Sa
return output, nil
}
- client := romm.NewClientFromHost(input.Host, input.Config.ApiTimeout)
+ client := romm.NewClientFromHost(input.Host, input.Config.ApiTimeout.Duration())
var updateErr error
gaba.ProcessMessage(
@@ -209,7 +209,7 @@ func (s *SaveSyncSettingsScreen) registerDevice(output SaveSyncSettingsOutput) (
return output, nil
}
- client := romm.NewClientFromHost(output.Host, output.Config.ApiTimeout)
+ client := romm.NewClientFromHost(output.Host, output.Config.ApiTimeout.Duration())
var device romm.Device
var regErr error
diff --git a/ui/download.go b/ui/download.go
index a0db33b1..56e82413 100644
--- a/ui/download.go
+++ b/ui/download.go
@@ -98,7 +98,7 @@ func (s *DownloadScreen) draw(input DownloadInput) (DownloadOutput, error) {
downloads, artDownloads, gamelistEntries := s.buildDownloads(input.Config, input.Host, input.Platform, input.SelectedGames, input.SelectedFileID)
headers := make(map[string]string)
- headers["Authorization"] = input.Host.BasicAuthHeader()
+ headers["Authorization"] = input.Host.AuthHeader()
slices.SortFunc(downloads, func(a, b gaba.Download) int {
return strings.Compare(strings.ToLower(a.DisplayName), strings.ToLower(b.DisplayName))
@@ -345,11 +345,8 @@ func (s *DownloadScreen) buildDownloads(config internal.Config, host romm.Host,
for _, g := range games {
gamelistRomEntry := gamelist.RomGameEntry{
- Game: &g,
- ArtLocation: "",
- GamePath: "",
- RomDirectory: "",
- Platform: &platform,
+ Game: &g,
+ Platform: &platform,
}
gamePlatform := platform
if platform.ID == 0 && g.PlatformID != 0 {
@@ -392,7 +389,7 @@ func (s *DownloadScreen) buildDownloads(config internal.Config, host romm.Host,
URL: sourceURL,
Location: downloadLocation,
DisplayName: g.Name,
- Timeout: config.DownloadTimeout,
+ Timeout: config.DownloadTimeout.Duration(),
})
if config.DownloadArt && (g.PathCoverLarge != "" || g.PathCoverSmall != "" || g.URLCover != "") {
@@ -402,7 +399,7 @@ func (s *DownloadScreen) buildDownloads(config internal.Config, host romm.Host,
artLocation := filepath.Join(artDir, artFileName)
coverURL := g.GetArtworkURL(config.ArtKind, host)
- gamelistRomEntry.ArtLocation = artLocation
+ gamelistRomEntry.ArtLocation.ImagePath = artLocation
artDownloads = append(artDownloads, artDownload{
URL: coverURL,
@@ -426,9 +423,23 @@ func (s *DownloadScreen) buildDownloads(config internal.Config, host romm.Host,
}
artSplashDir := config.GetArtSplashDirectory(gamePlatform)
- if config.DownloadSplashArt != artutil.ArtKindNone && artSplashDir != "" {
- splashArtLocation := filepath.Join(artSplashDir, artFileName)
- if splashURL := g.GetSplashArtURL(config.DownloadSplashArt, host); splashURL != "" {
+ if (config.DownloadSplashArt != artutil.ArtKindNone || config.AdditionalDownloads.Thumbnail != artutil.ArtKindNone) && artSplashDir != "" {
+ artSplashFileName := g.FsNameNoExt
+ isESBased := cfw.GetCFW().IsBasedOnEmulationStation()
+ if isESBased {
+ artSplashFileName += "-thumb.png"
+ } else {
+ artSplashFileName += ".png"
+ }
+ splashArtLocation := filepath.Join(artSplashDir, artSplashFileName)
+ kind := config.DownloadSplashArt
+ if config.AdditionalDownloads.Thumbnail != artutil.ArtKindNone {
+ kind = config.AdditionalDownloads.Thumbnail
+ }
+ if splashURL := g.GetSplashArtURL(kind, host); splashURL != "" {
+ if isESBased {
+ gamelistRomEntry.ArtLocation.ThumbnailPath = splashArtLocation
+ }
artDownloads = append(artDownloads, artDownload{
URL: splashURL,
Location: splashArtLocation,
@@ -437,6 +448,119 @@ func (s *DownloadScreen) buildDownloads(config internal.Config, host romm.Host,
})
}
}
+
+ artMarqueeDir := config.GetArtMarqueeDirectory(gamePlatform)
+ if config.AdditionalDownloads.Marquee != artutil.ArtKindNone && artMarqueeDir != "" {
+ marqueeArtFileName := g.FsNameNoExt
+ // is cfw is ES based, use -marquee suffix to avoid conflicts with cover art
+ if cfw.GetCFW().IsBasedOnEmulationStation() {
+ marqueeArtFileName += "-marquee.png"
+ } else {
+ marqueeArtFileName += ".png"
+ }
+ marqueeArtLocation := filepath.Join(artMarqueeDir, marqueeArtFileName)
+ marqueeURL := ""
+ switch config.AdditionalDownloads.Marquee {
+ case artutil.ArtKindMarquee:
+ marqueeURL = g.GetMarqueeURL(host)
+ case artutil.ArtKindLogo:
+ marqueeURL = g.GetLogoURL(host)
+ }
+ if marqueeURL != "" {
+ gamelistRomEntry.ArtLocation.MarqueePath = marqueeArtLocation
+ artDownloads = append(artDownloads, artDownload{
+ URL: marqueeURL,
+ Location: marqueeArtLocation,
+ GameName: g.Name,
+ IsImage: true,
+ })
+ }
+ }
+
+ artVideoDir := config.GetArtVideoDirectory(gamePlatform)
+ if config.AdditionalDownloads.Video && artVideoDir != "" {
+ videoLocation := filepath.Join(artVideoDir, g.FsNameNoExt+".mp4")
+ if videoURL := g.GetVideoURL(host); videoURL != "" {
+ gamelistRomEntry.ArtLocation.VideoPath = videoLocation
+ artDownloads = append(artDownloads, artDownload{
+ URL: videoURL,
+ Location: videoLocation,
+ GameName: g.Name,
+ IsImage: false,
+ })
+ }
+ }
+
+ artBezelDir := config.GetArtBezelDirectory(gamePlatform)
+ if config.AdditionalDownloads.Bezel && artBezelDir != "" {
+ bezelArtLocation := filepath.Join(artBezelDir, artFileName)
+ if bezelURL := g.GetBezelURL(host); bezelURL != "" {
+ gamelistRomEntry.ArtLocation.BezelPath = bezelArtLocation
+ artDownloads = append(artDownloads, artDownload{
+ URL: bezelURL,
+ Location: bezelArtLocation,
+ GameName: g.Name,
+ IsImage: true,
+ })
+ }
+ }
+
+ manualDir := config.GetManualDirectory(gamePlatform)
+ if config.AdditionalDownloads.Manual && manualDir != "" {
+ manualLocation := filepath.Join(manualDir, g.FsNameNoExt+".pdf")
+ if manualURL := g.GetManualURL(host); manualURL != "" {
+ gamelistRomEntry.ArtLocation.ManualPath = manualLocation
+ artDownloads = append(artDownloads, artDownload{
+ URL: manualURL,
+ Location: manualLocation,
+ GameName: g.Name,
+ IsImage: false,
+ })
+ }
+ }
+
+ boxbackDir := config.GetBoxbackDirectory(gamePlatform)
+ if config.AdditionalDownloads.BoxBack && boxbackDir != "" {
+ boxbackArtFileName := g.FsNameNoExt
+ // is cfw is ES based, use -boxback suffix to avoid conflicts with cover art
+ if cfw.GetCFW().IsBasedOnEmulationStation() {
+ boxbackArtFileName += "-boxback.png"
+ } else {
+ boxbackArtFileName += ".png"
+ }
+ boxbackArtLocation := filepath.Join(boxbackDir, boxbackArtFileName)
+ if boxbackURL := g.GetBoxbackURL(host); boxbackURL != "" {
+ gamelistRomEntry.ArtLocation.BoxBackPath = boxbackArtLocation
+ artDownloads = append(artDownloads, artDownload{
+ URL: boxbackURL,
+ Location: boxbackArtLocation,
+ GameName: g.Name,
+ IsImage: true,
+ })
+ }
+ }
+
+ fanartDir := config.GetFanartDirectory(gamePlatform)
+ if config.AdditionalDownloads.Fanart && fanartDir != "" {
+ fanartFileName := g.FsNameNoExt
+ // is cfw is ES based, use -fanart suffix to avoid conflicts with cover art
+ if cfw.GetCFW().IsBasedOnEmulationStation() {
+ fanartFileName += "-fanart.png"
+ } else {
+ fanartFileName += ".png"
+ }
+ fanartLocation := filepath.Join(fanartDir, fanartFileName)
+ if fanartURL := g.GetFanartURL(host); fanartURL != "" {
+ gamelistRomEntry.ArtLocation.FanartPath = fanartLocation
+ artDownloads = append(artDownloads, artDownload{
+ URL: fanartURL,
+ Location: fanartLocation,
+ GameName: g.Name,
+ IsImage: true,
+ })
+ }
+ }
+
}
gamesSummaries = append(gamesSummaries, gamelistRomEntry)
}
@@ -549,15 +673,17 @@ func (s *DownloadScreen) downloadArt(artDownloads []artDownload, downloadedGames
continue
}
- if err := imageutil.ProcessArtImage(art.Location); err != nil {
- logger.Warn("Failed to process art image", "game", art.GameName, "location", art.Location, "error", err)
- os.Remove(art.Location)
- failCount++
- processedCount++
- if totalArt > 0 {
- progress.Store(float64(processedCount) / float64(totalArt))
+ if art.IsImage {
+ if err := imageutil.ProcessArtImage(art.Location); err != nil {
+ logger.Warn("Failed to process art image", "game", art.GameName, "location", art.Location, "error", err, "url", art.URL)
+ os.Remove(art.Location)
+ failCount++
+ processedCount++
+ if totalArt > 0 {
+ progress.Store(float64(processedCount) / float64(totalArt))
+ }
+ continue
}
- continue
}
successCount++
@@ -567,5 +693,4 @@ func (s *DownloadScreen) downloadArt(artDownloads []artDownload, downloadedGames
progress.Store(float64(processedCount) / float64(totalArt))
}
}
-
}
diff --git a/ui/game_details.go b/ui/game_details.go
index 1601e34a..b941f728 100644
--- a/ui/game_details.go
+++ b/ui/game_details.go
@@ -319,7 +319,7 @@ func (s *GameDetailsScreen) fetchImageFromURL(host romm.Host, imageURL string) [
return nil
}
- req.SetBasicAuth(host.Username, host.Password)
+ req.Header.Set("Authorization", host.AuthHeader())
client := &http.Client{Timeout: internal.DefaultHTTPTimeout}
resp, err := client.Do(req)
diff --git a/ui/game_options.go b/ui/game_options.go
index a9ba309a..649fdfeb 100644
--- a/ui/game_options.go
+++ b/ui/game_options.go
@@ -37,7 +37,7 @@ func (s *GameOptionsScreen) Draw(input GameOptionsInput) (GameOptionsOutput, err
// Fetch save summary to determine available slots
var slotNames []string
if input.Host.DeviceID != "" {
- client := romm.NewClientFromHost(input.Host, config.ApiTimeout)
+ client := romm.NewClientFromHost(input.Host, config.ApiTimeout.Duration())
gaba.ProcessMessage(
i18n.Localize(&goi18n.Message{ID: "synced_games_loading_detail", Other: "Loading save details..."}, nil),
gaba.ProcessMessageOptions{ShowThemeBackground: true},
diff --git a/ui/games_list.go b/ui/games_list.go
index 93961464..3cd0af58 100644
--- a/ui/games_list.go
+++ b/ui/games_list.go
@@ -5,6 +5,7 @@ import (
"fmt"
"grout/cache"
"grout/internal"
+ "grout/internal/environment"
"grout/internal/stringutil"
"grout/romm"
"slices"
@@ -267,7 +268,11 @@ func (s *GameListScreen) Draw(input GameListInput) (GameListOutput, error) {
footerItems = append(footerItems, gaba.FooterHelpItem{ButtonName: "B", HelpText: i18n.Localize(&goi18n.Message{ID: "button_back", Other: "Back"}, nil)})
if hasBIOS && !internal.IsKidModeEnabled() {
- footerItems = append(footerItems, gaba.FooterHelpItem{ButtonName: i18n.Localize(&goi18n.Message{ID: "button_menu", Other: "Menu"}, nil), HelpText: i18n.Localize(&goi18n.Message{ID: "button_bios", Other: "BIOS"}, nil)})
+ menuButtonName := i18n.Localize(&goi18n.Message{ID: "button_menu", Other: "Menu"}, nil)
+ if environment.IsMiyoo() {
+ menuButtonName = "L2"
+ }
+ footerItems = append(footerItems, gaba.FooterHelpItem{ButtonName: menuButtonName, HelpText: i18n.Localize(&goi18n.Message{ID: "button_bios", Other: "BIOS"}, nil)})
}
footerItems = append(footerItems, gaba.FooterHelpItem{ButtonName: "Y", HelpText: i18n.Localize(&goi18n.Message{ID: "button_filters", Other: "Filters"}, nil), Group: gaba.FooterGroupRight})
@@ -329,8 +334,6 @@ type loadGamesResult struct {
}
func (s *GameListScreen) loadGames(input GameListInput) (loadGamesResult, error) {
- config := input.Config
- host := input.Host
platform := input.Platform
collection := input.Collection
@@ -364,11 +367,9 @@ func (s *GameListScreen) loadGames(input GameListInput) (loadGamesResult, error)
logger.Debug("Loaded games from cache (no loading screen)", "type", ft, "id", id, "count", len(cached))
result.games = cached
- // Check BIOS availability from cached data
+ // Check BIOS availability from platform firmware_count
if platform.ID != 0 && !isCollectionSet(collection) {
- if hasBIOS, wasFetched := cm.HasBIOS(platform.ID); wasFetched {
- result.hasBIOS = hasBIOS
- }
+ result.hasBIOS = platform.FirmwareCount > 0
}
return result, nil
@@ -389,8 +390,6 @@ func (s *GameListScreen) loadGames(input GameListInput) (loadGamesResult, error)
Progress: progress,
},
func() (interface{}, error) {
- rc := romm.NewClientFromHost(host, config.ApiTimeout)
-
// Fetch games with progress and BIOS info in parallel
var wg sync.WaitGroup
var gamesFetchErr error
@@ -411,22 +410,8 @@ func (s *GameListScreen) loadGames(input GameListInput) (loadGamesResult, error)
}
}()
- // Check BIOS availability
- if hasBIOS, wasFetched := cm.HasBIOS(platform.ID); wasFetched {
- result.hasBIOS = hasBIOS
- } else {
- wg.Add(1)
- go func() {
- defer wg.Done()
- firmware, err := rc.GetFirmware(platform.ID)
- if err == nil && len(firmware) > 0 {
- result.hasBIOS = true
- cm.SetBIOSAvailability(platform.ID, true)
- } else {
- cm.SetBIOSAvailability(platform.ID, false)
- }
- }()
- }
+ // Check BIOS availability from platform firmware_count
+ result.hasBIOS = platform.FirmwareCount > 0
wg.Wait()
@@ -450,8 +435,6 @@ func (s *GameListScreen) loadGames(input GameListInput) (loadGamesResult, error)
i18n.Localize(&goi18n.Message{ID: "games_list_loading", Other: "Loading {{.Name}}..."}, map[string]interface{}{"Name": displayName}),
gaba.ProcessMessageOptions{ShowThemeBackground: true},
func() (interface{}, error) {
- rc := romm.NewClientFromHost(host, config.ApiTimeout)
-
// Fetch games and BIOS info in parallel
var wg sync.WaitGroup
var gamesFetchErr error
@@ -468,38 +451,9 @@ func (s *GameListScreen) loadGames(input GameListInput) (loadGamesResult, error)
result.games = roms
}()
- // Check BIOS availability (only for platforms, not collections)
+ // Check BIOS availability from platform firmware_count
if platform.ID != 0 && !isCollectionSet(collection) {
- // First check cached BIOS info
- if cm := cache.GetCacheManager(); cm != nil {
- if hasBIOS, wasFetched := cm.HasBIOS(platform.ID); wasFetched {
- result.hasBIOS = hasBIOS
- } else {
- // Fall back to network fetch if not cached
- wg.Add(1)
- go func() {
- defer wg.Done()
- firmware, err := rc.GetFirmware(platform.ID)
- if err == nil && len(firmware) > 0 {
- result.hasBIOS = true
- // Cache the BIOS availability
- cm.SetBIOSAvailability(platform.ID, true)
- } else {
- cm.SetBIOSAvailability(platform.ID, false)
- }
- }()
- }
- } else {
- // No cache manager, do network fetch
- wg.Add(1)
- go func() {
- defer wg.Done()
- firmware, err := rc.GetFirmware(platform.ID)
- if err == nil && len(firmware) > 0 {
- result.hasBIOS = true
- }
- }()
- }
+ result.hasBIOS = platform.FirmwareCount > 0
}
wg.Wait()
diff --git a/ui/general_settings.go b/ui/general_settings.go
index ee884abf..a684f588 100644
--- a/ui/general_settings.go
+++ b/ui/general_settings.go
@@ -64,15 +64,20 @@ func (s *GeneralSettingsScreen) Draw(input GeneralSettingsInput) (GeneralSetting
}
func (s *GeneralSettingsScreen) buildMenuItems(config *internal.Config) []gaba.ItemWithOptions {
- isMuOS := cfw.GetCFW() == cfw.MuOS
+ c := cfw.GetCFW()
+ isMuOS := c == cfw.MuOS
+ isESBasedOS := c == cfw.Knulli || c == cfw.ROCKNIX
showArtKind := atomic.Bool{}
showArtKind.Store(config.DownloadArt)
displayDownloadArtPreview := atomic.Bool{}
displayDownloadArtPreview.Store(showArtKind.Load() && isMuOS)
+ displayEmulationStationOptions := atomic.Bool{}
+ displayEmulationStationOptions.Store(showArtKind.Load() && isESBasedOS)
downloadArtUpdateFunc := func(val interface{}) {
showArtKind.Store(val.(bool))
displayDownloadArtPreview.Store(showArtKind.Load() && isMuOS)
+ displayEmulationStationOptions.Store(showArtKind.Load() && isESBasedOS)
}
return []gaba.ItemWithOptions{
@@ -139,6 +144,71 @@ func (s *GeneralSettingsScreen) buildMenuItems(config *internal.Config) []gaba.I
SelectedOption: boxArtToIndex(config.DownloadSplashArt),
VisibleWhen: &displayDownloadArtPreview,
},
+ {
+ Item: gaba.MenuItem{Text: i18n.Localize(&goi18n.Message{ID: "settings_download_emulationstation_art_thumbnail", Other: "Download Game Thumbnail"}, nil)},
+ Options: []gaba.Option{
+ {DisplayName: i18n.Localize(&goi18n.Message{ID: "settings_download_art_kind_none", Other: "None"}, nil), Value: artutil.ArtKindNone},
+ {DisplayName: i18n.Localize(&goi18n.Message{ID: "settings_download_art_kind_box2d", Other: "Box2D"}, nil), Value: artutil.ArtKindBox2D},
+ {DisplayName: i18n.Localize(&goi18n.Message{ID: "settings_download_art_kind_box3d", Other: "Box3D"}, nil), Value: artutil.ArtKindBox3D},
+ },
+ SelectedOption: boxArtToIndex(config.AdditionalDownloads.Thumbnail),
+ VisibleWhen: &displayEmulationStationOptions,
+ },
+ {
+ Item: gaba.MenuItem{Text: i18n.Localize(&goi18n.Message{ID: "settings_download_emulationstation_art_marquee", Other: "Download Marquee Image"}, nil)},
+ Options: []gaba.Option{
+ {DisplayName: i18n.Localize(&goi18n.Message{ID: "settings_download_art_kind_none", Other: "None"}, nil), Value: artutil.ArtKindNone},
+ {DisplayName: i18n.Localize(&goi18n.Message{ID: "settings_download_art_kind_marquee", Other: "Marquee"}, nil), Value: artutil.ArtKindMarquee},
+ {DisplayName: i18n.Localize(&goi18n.Message{ID: "settings_download_art_kind_logo", Other: "Logo"}, nil), Value: artutil.ArtKindLogo},
+ },
+ SelectedOption: marqueeArtToIndex(config.AdditionalDownloads.Marquee),
+ VisibleWhen: &displayEmulationStationOptions,
+ },
+ {
+ Item: gaba.MenuItem{Text: i18n.Localize(&goi18n.Message{ID: "settings_download_emulationstation_art_video", Other: "Download Game Video"}, nil)},
+ Options: []gaba.Option{
+ {DisplayName: i18n.Localize(&goi18n.Message{ID: "common_true", Other: "True"}, nil), Value: true},
+ {DisplayName: i18n.Localize(&goi18n.Message{ID: "common_false", Other: "False"}, nil), Value: false},
+ },
+ SelectedOption: boolToIndex(!config.AdditionalDownloads.Video),
+ VisibleWhen: &displayEmulationStationOptions,
+ },
+ {
+ Item: gaba.MenuItem{Text: i18n.Localize(&goi18n.Message{ID: "settings_download_emulationstation_art_bezel", Other: "Download Game Bezel"}, nil)},
+ Options: []gaba.Option{
+ {DisplayName: i18n.Localize(&goi18n.Message{ID: "common_true", Other: "True"}, nil), Value: true},
+ {DisplayName: i18n.Localize(&goi18n.Message{ID: "common_false", Other: "False"}, nil), Value: false},
+ },
+ SelectedOption: boolToIndex(!config.AdditionalDownloads.Bezel),
+ VisibleWhen: &displayEmulationStationOptions,
+ },
+ {
+ Item: gaba.MenuItem{Text: i18n.Localize(&goi18n.Message{ID: "settings_download_emulationstation_art_manual", Other: "Download Game Manual"}, nil)},
+ Options: []gaba.Option{
+ {DisplayName: i18n.Localize(&goi18n.Message{ID: "common_true", Other: "True"}, nil), Value: true},
+ {DisplayName: i18n.Localize(&goi18n.Message{ID: "common_false", Other: "False"}, nil), Value: false},
+ },
+ SelectedOption: boolToIndex(!config.AdditionalDownloads.Manual),
+ VisibleWhen: &displayEmulationStationOptions,
+ },
+ {
+ Item: gaba.MenuItem{Text: i18n.Localize(&goi18n.Message{ID: "settings_download_emulationstation_art_boxback", Other: "Download Game Box back"}, nil)},
+ Options: []gaba.Option{
+ {DisplayName: i18n.Localize(&goi18n.Message{ID: "common_true", Other: "True"}, nil), Value: true},
+ {DisplayName: i18n.Localize(&goi18n.Message{ID: "common_false", Other: "False"}, nil), Value: false},
+ },
+ SelectedOption: boolToIndex(!config.AdditionalDownloads.BoxBack),
+ VisibleWhen: &displayEmulationStationOptions,
+ },
+ {
+ Item: gaba.MenuItem{Text: i18n.Localize(&goi18n.Message{ID: "settings_download_emulationstation_art_fanart", Other: "Download Game Fan Art"}, nil)},
+ Options: []gaba.Option{
+ {DisplayName: i18n.Localize(&goi18n.Message{ID: "common_true", Other: "True"}, nil), Value: true},
+ {DisplayName: i18n.Localize(&goi18n.Message{ID: "common_false", Other: "False"}, nil), Value: false},
+ },
+ SelectedOption: boolToIndex(!config.AdditionalDownloads.Fanart),
+ VisibleWhen: &displayEmulationStationOptions,
+ },
{
Item: gaba.MenuItem{Text: i18n.Localize(&goi18n.Message{ID: "settings_language", Other: "Language"}, nil)},
Options: []gaba.Option{
@@ -201,6 +271,39 @@ func (s *GeneralSettingsScreen) applySettings(config *internal.Config, items []g
config.UnzipDownloads = val
}
+ case i18n.Localize(&goi18n.Message{ID: "settings_download_emulationstation_art_marquee", Other: "Download Marquee Image"}, nil):
+ if val, ok := item.Options[item.SelectedOption].Value.(artutil.ArtKind); ok {
+ config.AdditionalDownloads.Marquee = val
+ }
+
+ case i18n.Localize(&goi18n.Message{ID: "settings_download_emulationstation_art_video", Other: "Download Game Video"}, nil):
+ if val, ok := item.Options[item.SelectedOption].Value.(bool); ok {
+ config.AdditionalDownloads.Video = val
+ }
+
+ case i18n.Localize(&goi18n.Message{ID: "settings_download_emulationstation_art_thumbnail", Other: "Download Game Thumbnail"}, nil):
+ if val, ok := item.Options[item.SelectedOption].Value.(artutil.ArtKind); ok {
+ config.AdditionalDownloads.Thumbnail = val
+ }
+
+ case i18n.Localize(&goi18n.Message{ID: "settings_download_emulationstation_art_bezel", Other: "Download Game Bezel"}, nil):
+ if val, ok := item.Options[item.SelectedOption].Value.(bool); ok {
+ config.AdditionalDownloads.Bezel = val
+ }
+
+ case i18n.Localize(&goi18n.Message{ID: "settings_download_emulationstation_art_manual", Other: "Download Game Manual"}, nil):
+ if val, ok := item.Options[item.SelectedOption].Value.(bool); ok {
+ config.AdditionalDownloads.Manual = val
+ }
+ case i18n.Localize(&goi18n.Message{ID: "settings_download_emulationstation_art_boxback", Other: "Download Game Box back"}, nil):
+ if val, ok := item.Options[item.SelectedOption].Value.(bool); ok {
+ config.AdditionalDownloads.BoxBack = val
+ }
+ case i18n.Localize(&goi18n.Message{ID: "settings_download_emulationstation_art_fanart", Other: "Download Game Fan Art"}, nil):
+ if val, ok := item.Options[item.SelectedOption].Value.(bool); ok {
+ config.AdditionalDownloads.Fanart = val
+ }
+
case i18n.Localize(&goi18n.Message{ID: "settings_language", Other: "Language"}, nil):
if val, ok := item.Options[item.SelectedOption].Value.(string); ok {
config.Language = val
diff --git a/ui/info.go b/ui/info.go
index 5d9bb571..d5b1f3f1 100644
--- a/ui/info.go
+++ b/ui/info.go
@@ -6,6 +6,7 @@ import (
"grout/internal/imageutil"
"grout/romm"
"grout/version"
+ "time"
gaba "github.com/BrandonKowalski/gabagool/v2/pkg/gabagool"
buttons "github.com/BrandonKowalski/gabagool/v2/pkg/gabagool/constants"
@@ -91,12 +92,35 @@ func (s *InfoScreen) buildSections(input InfoInput) []gaba.Section {
Label: i18n.Localize(&goi18n.Message{ID: "info_user", Other: "User"}, nil),
Value: input.Host.Username,
},
- {
- Label: i18n.Localize(&goi18n.Message{ID: "info_romm_version", Other: "Version"}, nil),
- Value: rommVersion,
- },
}
+ if input.Host.HasTokenAuth() {
+ if input.Host.TokenName != "" {
+ metadata = append(metadata, gaba.MetadataItem{
+ Label: i18n.Localize(&goi18n.Message{ID: "info_token_name", Other: "Token"}, nil),
+ Value: input.Host.TokenName,
+ })
+ }
+
+ expiresValue := i18n.Localize(&goi18n.Message{ID: "info_token_never_expires", Other: "Never"}, nil)
+ if input.Host.TokenExpiresAt != "" {
+ if t, err := time.Parse(time.RFC3339, input.Host.TokenExpiresAt); err == nil {
+ expiresValue = t.Local().Format("2006-01-02 15:04")
+ } else {
+ expiresValue = input.Host.TokenExpiresAt
+ }
+ }
+ metadata = append(metadata, gaba.MetadataItem{
+ Label: i18n.Localize(&goi18n.Message{ID: "info_token_expires", Other: "Expires"}, nil),
+ Value: expiresValue,
+ })
+ }
+
+ metadata = append(metadata, gaba.MetadataItem{
+ Label: i18n.Localize(&goi18n.Message{ID: "info_romm_version", Other: "Version"}, nil),
+ Value: rommVersion,
+ })
+
sections = append(sections, gaba.NewInfoSection("RomM", metadata))
qrText := "https://github.com/rommapp/grout"
diff --git a/ui/login.go b/ui/login.go
index 905a8b77..0a0d472e 100644
--- a/ui/login.go
+++ b/ui/login.go
@@ -18,10 +18,6 @@ import (
goi18n "github.com/nicksnyder/go-i18n/v2/i18n"
)
-type loginInput struct {
- ExistingHost romm.Host
-}
-
type loginOutput struct {
Host romm.Host
Config *internal.Config
@@ -40,10 +36,8 @@ func newLoginScreen() *LoginScreen {
return &LoginScreen{}
}
-func (s *LoginScreen) draw(input loginInput) (loginOutput, error) {
- host := input.ExistingHost
-
- // SSL option visibility - only show when HTTPS is selected
+// drawServerInfo collects server connection details (protocol, hostname, port, SSL).
+func (s *LoginScreen) drawServerInfo(host romm.Host) (romm.Host, bool, error) {
sslVisible := &atomic.Bool{}
sslVisible.Store(strings.Contains(host.RootURI, "https"))
@@ -121,6 +115,100 @@ func (s *LoginScreen) draw(input loginInput) (loginOutput, error) {
},
},
},
+ {
+ Item: gabagool.MenuItem{
+ Text: i18n.Localize(&goi18n.Message{ID: "login_ssl_certificates", Other: "SSL Certificates"}, nil),
+ },
+ Options: []gabagool.Option{
+ {DisplayName: i18n.Localize(&goi18n.Message{ID: "login_ssl_verify", Other: "Verify"}, nil), Value: false},
+ {DisplayName: i18n.Localize(&goi18n.Message{ID: "login_ssl_skip", Other: "Skip Verification"}, nil), Value: true},
+ },
+ SelectedOption: func() int {
+ if host.InsecureSkipVerify {
+ return 1
+ }
+ return 0
+ }(),
+ VisibleWhen: sslVisible,
+ },
+ }
+
+ res, err := gabagool.OptionsList(
+ i18n.Localize(&goi18n.Message{ID: "login_server_title", Other: "Server"}, nil),
+ gabagool.OptionListSettings{
+ DisableBackButton: false,
+ FooterHelpItems: []gabagool.FooterHelpItem{
+ {ButtonName: "B", HelpText: i18n.Localize(&goi18n.Message{ID: "button_quit", Other: "Quit"}, nil)},
+ {ButtonName: icons.LeftRight, HelpText: i18n.Localize(&goi18n.Message{ID: "button_cycle", Other: "Cycle"}, nil)},
+ {ButtonName: icons.Start, HelpText: i18n.Localize(&goi18n.Message{ID: "button_continue", Other: "Continue"}, nil)},
+ },
+ },
+ items,
+ )
+
+ if err != nil {
+ return host, true, nil
+ }
+
+ settings := res.Items
+
+ newHost := romm.Host{
+ RootURI: fmt.Sprintf("%s%s", settings[0].Value(), settings[1].Value()),
+ Port: func(s string) int {
+ if n, err := strconv.Atoi(s); err == nil {
+ return n
+ }
+ return 0
+ }(settings[2].Value().(string)),
+ InsecureSkipVerify: settings[3].Options[settings[3].SelectedOption].Value.(bool),
+ DeviceID: host.DeviceID,
+ DeviceName: host.DeviceName,
+ }
+
+ return newHost, false, nil
+}
+
+const (
+ authModeCredentials = "credentials"
+ authModePairingCode = "pairing_code"
+)
+
+// drawAuth collects authentication details (credentials or pairing code).
+func (s *LoginScreen) drawAuth(host romm.Host) (romm.Host, bool, error) {
+ authModeVisible := &atomic.Bool{}
+ authModeVisible.Store(true)
+
+ credentialsVisible := &atomic.Bool{}
+ credentialsVisible.Store(false)
+
+ pairingVisible := &atomic.Bool{}
+ pairingVisible.Store(true)
+
+ items := []gabagool.ItemWithOptions{
+ {
+ Item: gabagool.MenuItem{
+ Text: i18n.Localize(&goi18n.Message{ID: "login_auth_method", Other: "Auth Method"}, nil),
+ },
+ Options: []gabagool.Option{
+ {
+ DisplayName: i18n.Localize(&goi18n.Message{ID: "login_auth_credentials", Other: "Credentials"}, nil),
+ Value: authModeCredentials,
+ OnUpdate: func(v interface{}) {
+ credentialsVisible.Store(true)
+ pairingVisible.Store(false)
+ },
+ },
+ {
+ DisplayName: i18n.Localize(&goi18n.Message{ID: "login_auth_pairing_code", Other: "Pairing Code"}, nil),
+ Value: authModePairingCode,
+ OnUpdate: func(v interface{}) {
+ credentialsVisible.Store(false)
+ pairingVisible.Store(true)
+ },
+ },
+ },
+ SelectedOption: 1,
+ },
{
Item: gabagool.MenuItem{
Text: i18n.Localize(&goi18n.Message{ID: "login_username", Other: "Username"}, nil),
@@ -133,6 +221,7 @@ func (s *LoginScreen) draw(input loginInput) (loginOutput, error) {
Value: host.Username,
},
},
+ VisibleWhen: credentialsVisible,
},
{
Item: gabagool.MenuItem{
@@ -147,31 +236,30 @@ func (s *LoginScreen) draw(input loginInput) (loginOutput, error) {
Value: host.Password,
},
},
+ VisibleWhen: credentialsVisible,
},
{
Item: gabagool.MenuItem{
- Text: i18n.Localize(&goi18n.Message{ID: "login_ssl_certificates", Other: "SSL Certificates"}, nil),
+ Text: i18n.Localize(&goi18n.Message{ID: "login_pairing_code", Other: "Pairing Code"}, nil),
},
Options: []gabagool.Option{
- {DisplayName: i18n.Localize(&goi18n.Message{ID: "login_ssl_verify", Other: "Verify"}, nil), Value: false},
- {DisplayName: i18n.Localize(&goi18n.Message{ID: "login_ssl_skip", Other: "Skip Verification"}, nil), Value: true},
+ {
+ Type: gabagool.OptionTypeKeyboard,
+ DisplayName: "",
+ KeyboardPrompt: "",
+ Value: "",
+ },
},
- SelectedOption: func() int {
- if host.InsecureSkipVerify {
- return 1
- }
- return 0
- }(),
- VisibleWhen: sslVisible,
+ VisibleWhen: pairingVisible,
},
}
res, err := gabagool.OptionsList(
- i18n.Localize(&goi18n.Message{ID: "login_title", Other: "Login to RomM"}, nil),
+ i18n.Localize(&goi18n.Message{ID: "login_auth_title", Other: "Authentication"}, nil),
gabagool.OptionListSettings{
DisableBackButton: false,
FooterHelpItems: []gabagool.FooterHelpItem{
- {ButtonName: "B", HelpText: i18n.Localize(&goi18n.Message{ID: "button_quit", Other: "Quit"}, nil)},
+ FooterBack(),
{ButtonName: icons.LeftRight, HelpText: i18n.Localize(&goi18n.Message{ID: "button_cycle", Other: "Cycle"}, nil)},
{ButtonName: icons.Start, HelpText: i18n.Localize(&goi18n.Message{ID: "button_login", Other: "Login"}, nil)},
},
@@ -180,91 +268,151 @@ func (s *LoginScreen) draw(input loginInput) (loginOutput, error) {
)
if err != nil {
- return loginOutput{Cancelled: true}, nil
+ return host, true, nil
}
- loginSettings := res.Items
-
- newHost := romm.Host{
- RootURI: fmt.Sprintf("%s%s", loginSettings[0].Value(), loginSettings[1].Value()),
- Port: func(s string) int {
- if n, err := strconv.Atoi(s); err == nil {
- return n
- }
- return 0
- }(loginSettings[2].Value().(string)),
- Username: loginSettings[3].Options[0].Value.(string),
- Password: loginSettings[4].Options[0].Value.(string),
- InsecureSkipVerify: loginSettings[5].Options[loginSettings[5].SelectedOption].Value.(bool),
+ settings := res.Items
+ authMode := settings[0].Options[settings[0].SelectedOption].Value.(string)
+
+ newHost := host
+ if authMode == authModeCredentials {
+ newHost.Username = settings[1].Options[0].Value.(string)
+ newHost.Password = settings[2].Options[0].Value.(string)
+ newHost.Token = ""
+ } else {
+ newHost.Username = ""
+ newHost.Password = ""
+ // Token will be exchanged during login attempt
+ newHost.Token = settings[3].Options[0].Value.(string)
}
- return loginOutput{Host: newHost}, nil
+ return newHost, false, nil
}
func LoginFlow(existingHost romm.Host) (*internal.Config, error) {
screen := newLoginScreen()
for {
- result, err := screen.draw(loginInput{ExistingHost: existingHost})
+ // Step 1: Server info
+ host, cancelled, err := screen.drawServerInfo(existingHost)
if err != nil {
gabagool.ProcessMessage(i18n.Localize(&goi18n.Message{ID: "login_error_unexpected", Other: "Something unexpected happened!\nCheck the logs for more info."}, nil), gabagool.ProcessMessageOptions{}, func() (interface{}, error) {
time.Sleep(3 * time.Second)
return nil, nil
})
- return nil, fmt.Errorf("unable to get login information: %w", err)
+ return nil, fmt.Errorf("unable to get server information: %w", err)
+ }
+ if cancelled {
+ gabagool.Close()
+ os.Exit(0)
}
- if result.Cancelled {
- os.Exit(1)
+ // Validate connection before asking for auth
+ connResult := validateConnection(host)
+ if !connResult.Success {
+ gabagool.ConfirmationMessage(
+ i18n.Localize(connResult.ErrorMsg, nil),
+ ContinueFooter(),
+ gabagool.MessageOptions{},
+ )
+ existingHost = host
+ continue
}
- host := result.Host
+ // Step 2: Auth (loop until success or back)
+ for {
+ authHost, authCancelled, authErr := screen.drawAuth(host)
+ if authErr != nil {
+ break
+ }
+ if authCancelled {
+ // Go back to server info
+ existingHost = host
+ break
+ }
- loginResult := attemptLogin(host)
+ loginOutput := attemptLogin(authHost)
- if loginResult.Success {
- config := &internal.Config{
- Hosts: []romm.Host{host},
+ if loginOutput.Result.Success {
+ config := &internal.Config{
+ Hosts: []romm.Host{loginOutput.Host},
+ }
+ _ = config.LoadPlatformsBinding(loginOutput.Host)
+ return config, nil
}
- _ = config.LoadPlatformsBinding(host)
- return config, nil
+ gabagool.ConfirmationMessage(
+ i18n.Localize(loginOutput.Result.ErrorMsg, nil),
+ ContinueFooter(),
+ gabagool.MessageOptions{},
+ )
+ host = loginOutput.Host
}
-
- gabagool.ConfirmationMessage(
- i18n.Localize(loginResult.ErrorMsg, nil),
- []gabagool.FooterHelpItem{
- {ButtonName: "A", HelpText: i18n.Localize(&goi18n.Message{ID: "button_continue", Other: "Continue"}, nil)},
- },
- gabagool.MessageOptions{},
- )
- existingHost = host
}
}
-func attemptLogin(host romm.Host) loginAttemptResult {
- validationClient := romm.NewClientFromHost(host, internal.ValidationTimeout)
+func validateConnection(host romm.Host) loginAttemptResult {
+ validationClient := romm.NewClient(host.URL(), romm.WithInsecureSkipVerify(host.InsecureSkipVerify), romm.WithTimeout(internal.ValidationTimeout))
result, _ := gabagool.ProcessMessage(
- i18n.Localize(&goi18n.Message{ID: "login_validating", Other: "Validating connection..."}, nil),
+ i18n.Localize(&goi18n.Message{ID: "login_validating_connection", Other: "Validating connection..."}, nil),
gabagool.ProcessMessageOptions{},
func() (interface{}, error) {
err := validationClient.ValidateConnection()
if err != nil {
return classifyLoginError(err), nil
}
+ return loginAttemptResult{Success: true}, nil
+ },
+ )
- loginClient := romm.NewClientFromHost(host, internal.LoginTimeout)
- err = loginClient.Login(host.Username, host.Password)
- if err != nil {
- return classifyLoginError(err), nil
+ return result.(loginAttemptResult)
+}
+
+type loginAttemptOutput struct {
+ Result loginAttemptResult
+ Host romm.Host
+}
+
+func attemptLogin(host romm.Host) loginAttemptOutput {
+ result, _ := gabagool.ProcessMessage(
+ i18n.Localize(&goi18n.Message{ID: "login_validating", Other: "Logging in..."}, nil),
+ gabagool.ProcessMessageOptions{},
+ func() (interface{}, error) {
+ if host.HasTokenAuth() {
+ // Token field contains a pairing code — exchange it for a real token
+ tokenResp, err := romm.ExchangeToken(host.URL(), host.Token, host.InsecureSkipVerify)
+ if err != nil {
+ return loginAttemptOutput{Result: classifyLoginError(err), Host: host}, nil
+ }
+ host.Token = tokenResp.RawToken
+ host.TokenName = tokenResp.Name
+ host.TokenExpiresAt = tokenResp.ExpiresAt
+
+ // Validate the token works
+ client := romm.NewClientFromHost(host, internal.LoginTimeout)
+ if err := client.ValidateToken(); err != nil {
+ return loginAttemptOutput{Result: classifyLoginError(err), Host: host}, nil
+ }
+
+ // Fetch username for display purposes if not already known
+ if host.Username == "" {
+ if user, err := client.GetCurrentUser(); err == nil {
+ host.Username = user.Username
+ }
+ }
+ } else {
+ client := romm.NewClientFromHost(host, internal.LoginTimeout)
+ if err := client.Login(host.Username, host.Password); err != nil {
+ return loginAttemptOutput{Result: classifyLoginError(err), Host: host}, nil
+ }
}
- return loginAttemptResult{Success: true}, nil
+ return loginAttemptOutput{Result: loginAttemptResult{Success: true}, Host: host}, nil
},
)
- return result.(loginAttemptResult)
+ return result.(loginAttemptOutput)
}
func classifyLoginError(err error) loginAttemptResult {
@@ -310,12 +458,12 @@ func classifyLoginError(err error) loginAttemptResult {
case errors.Is(err, romm.ErrUnauthorized):
return loginAttemptResult{
ErrorType: "credentials",
- ErrorMsg: &goi18n.Message{ID: "login_error_credentials", Other: "Invalid Username or Password."},
+ ErrorMsg: &goi18n.Message{ID: "login_error_credentials", Other: "Invalid credentials."},
}
case errors.Is(err, romm.ErrForbidden):
return loginAttemptResult{
ErrorType: "forbidden",
- ErrorMsg: &goi18n.Message{ID: "login_error_forbidden", Other: "Access Forbidden!\nCheck your username/password and try switching between http and https."},
+ ErrorMsg: &goi18n.Message{ID: "login_error_forbidden", Other: "Access Forbidden!\nCheck your credentials and try switching between http and https."},
}
case errors.Is(err, romm.ErrServerError):
return loginAttemptResult{
@@ -323,6 +471,15 @@ func classifyLoginError(err error) loginAttemptResult {
ErrorMsg: &goi18n.Message{ID: "login_error_server", Other: "RomM server error!\nPlease check the RomM server logs."},
}
default:
+ // Check if this is a token exchange error (API error with status code)
+ errMsg := err.Error()
+ if strings.Contains(errMsg, "status 404") || strings.Contains(errMsg, "status 429") {
+ return loginAttemptResult{
+ ErrorType: "pairing",
+ ErrorMsg: &goi18n.Message{ID: "login_error_invalid_code", Other: "Invalid or expired pairing code.\nPlease try again."},
+ }
+ }
+
gabagool.GetLogger().Warn("Unclassified login error", "error", err)
return loginAttemptResult{
ErrorType: "unknown",
diff --git a/ui/rebuild_cache.go b/ui/rebuild_cache.go
index 5bd57d46..46621f3c 100644
--- a/ui/rebuild_cache.go
+++ b/ui/rebuild_cache.go
@@ -94,7 +94,7 @@ func (s *RebuildCacheScreen) Draw(input RebuildCacheInput) (RebuildCacheOutput,
// Only rebuild metadata cache if metadata was cleared
if selected == clearOptionMetadata || selected == clearOptionBoth {
- platforms, err := internal.GetMappedPlatforms(input.Host, input.Config.DirectoryMappings, input.Config.ApiTimeout)
+ platforms, err := internal.GetMappedPlatforms(input.Host, input.Config.DirectoryMappings, input.Config.ApiTimeout.Duration())
if err != nil {
logger.Error("Failed to fetch platforms", "error", err)
return RebuildCacheOutput{Action: RebuildCacheActionError}, err
diff --git a/ui/save_sync.go b/ui/save_sync.go
index 71e9a44f..4230e7d7 100644
--- a/ui/save_sync.go
+++ b/ui/save_sync.go
@@ -36,7 +36,7 @@ func NewSaveSyncScreen() *SaveSyncScreen {
func (s *SaveSyncScreen) Execute(input SaveSyncInput) SaveSyncOutput {
config := input.Config
host := input.Host
- client := romm.NewClientFromHost(host, config.ApiTimeout)
+ client := romm.NewClientFromHost(host, config.ApiTimeout.Duration())
// If we have resolved items from the conflict screen, skip to execute phase
if input.ResolvedItems != nil {
diff --git a/ui/server_address.go b/ui/server_address.go
index c05aabcc..06da0bbf 100644
--- a/ui/server_address.go
+++ b/ui/server_address.go
@@ -216,10 +216,15 @@ func validateServerAddress(host romm.Host) loginAttemptResult {
return classifyLoginError(err), nil
}
- loginClient := romm.NewClientFromHost(host, internal.LoginTimeout)
- err = loginClient.Login(host.Username, host.Password)
- if err != nil {
- return classifyLoginError(err), nil
+ client := romm.NewClientFromHost(host, internal.LoginTimeout)
+ if host.HasTokenAuth() {
+ if err := client.ValidateToken(); err != nil {
+ return classifyLoginError(err), nil
+ }
+ } else {
+ if err := client.Login(host.Username, host.Password); err != nil {
+ return classifyLoginError(err), nil
+ }
}
return loginAttemptResult{Success: true}, nil
diff --git a/ui/settings.go b/ui/settings.go
index b6bac404..d2ad25cf 100644
--- a/ui/settings.go
+++ b/ui/settings.go
@@ -45,10 +45,12 @@ const (
SettingGeneralSettings SettingType = "general_settings"
SettingCollectionsSettings SettingType = "collections_settings"
SettingDirectoryMappings SettingType = "directory_mappings"
+ SettingToolsSettings SettingType = "tools_settings"
SettingAdvancedSettings SettingType = "advanced_settings"
SettingInfo SettingType = "info"
SettingCheckUpdates SettingType = "check_updates"
SettingSaveSync SettingType = "save_sync"
+ SettingSwitchToToken SettingType = "switch_to_token"
)
var settingsOrder = []SettingType{
@@ -56,6 +58,7 @@ var settingsOrder = []SettingType{
SettingCollectionsSettings,
SettingDirectoryMappings,
SettingSaveSync,
+ SettingToolsSettings,
SettingAdvancedSettings,
SettingInfo,
SettingCheckUpdates,
@@ -65,7 +68,7 @@ func (s *SettingsScreen) Draw(input SettingsInput) (SettingsOutput, error) {
config := input.Config
output := SettingsOutput{Action: SettingsActionBack, Config: config}
- items := s.buildMenuItems(config)
+ items := s.buildMenuItems(config, input.Host)
result, err := gaba.OptionsList(
i18n.Localize(&goi18n.Message{ID: "settings_title", Other: "Settings"}, nil),
@@ -119,6 +122,16 @@ func (s *SettingsScreen) Draw(input SettingsInput) (SettingsOutput, error) {
return output, nil
}
+ if selectedText == i18n.Localize(&goi18n.Message{ID: "settings_switch_to_token", Other: "Switch to API Token"}, nil) {
+ output.Action = SettingsActionSwitchToToken
+ return output, nil
+ }
+
+ if selectedText == i18n.Localize(&goi18n.Message{ID: "settings_tools", Other: "Tools"}, nil) {
+ output.Action = SettingsActionTools
+ return output, nil
+ }
+
if selectedText == i18n.Localize(&goi18n.Message{ID: "settings_advanced", Other: "Advanced"}, nil) {
output.AdvancedSettingsClicked = true
output.Action = SettingsActionAdvanced
@@ -142,9 +155,15 @@ func (s *SettingsScreen) Draw(input SettingsInput) (SettingsOutput, error) {
return output, nil
}
-func (s *SettingsScreen) buildMenuItems(config *internal.Config) []gaba.ItemWithOptions {
- items := make([]gaba.ItemWithOptions, 0, len(settingsOrder))
- for _, settingType := range settingsOrder {
+func (s *SettingsScreen) buildMenuItems(config *internal.Config, host romm.Host) []gaba.ItemWithOptions {
+ order := make([]SettingType, 0, len(settingsOrder)+1)
+ if !host.HasTokenAuth() {
+ order = append(order, SettingSwitchToToken)
+ }
+ order = append(order, settingsOrder...)
+
+ items := make([]gaba.ItemWithOptions, 0, len(order))
+ for _, settingType := range order {
items = append(items, s.buildMenuItem(settingType))
}
return items
@@ -170,6 +189,18 @@ func (s *SettingsScreen) buildMenuItem(settingType SettingType) gaba.ItemWithOpt
Options: []gaba.Option{{Type: gaba.OptionTypeClickable}},
}
+ case SettingSwitchToToken:
+ return gaba.ItemWithOptions{
+ Item: gaba.MenuItem{Text: i18n.Localize(&goi18n.Message{ID: "settings_switch_to_token", Other: "Switch to API Token"}, nil)},
+ Options: []gaba.Option{{Type: gaba.OptionTypeClickable}},
+ }
+
+ case SettingToolsSettings:
+ return gaba.ItemWithOptions{
+ Item: gaba.MenuItem{Text: i18n.Localize(&goi18n.Message{ID: "settings_tools", Other: "Tools"}, nil)},
+ Options: []gaba.Option{{Type: gaba.OptionTypeClickable}},
+ }
+
case SettingAdvancedSettings:
return gaba.ItemWithOptions{
Item: gaba.MenuItem{Text: i18n.Localize(&goi18n.Message{ID: "settings_advanced", Other: "Advanced"}, nil)},
@@ -255,6 +286,19 @@ func boxArtToIndex(boxArt artutil.ArtKind) int {
}
}
+func marqueeArtToIndex(boxArt artutil.ArtKind) int {
+ switch boxArt {
+ case artutil.ArtKindNone:
+ return 0
+ case artutil.ArtKindMarquee:
+ return 1
+ case artutil.ArtKindLogo:
+ return 2
+ default:
+ return 0
+ }
+}
+
func languageToIndex(lang string) int {
switch lang {
case "en":
diff --git a/ui/settings_platform_mapping.go b/ui/settings_platform_mapping.go
index e866b284..472a8c6c 100644
--- a/ui/settings_platform_mapping.go
+++ b/ui/settings_platform_mapping.go
@@ -178,7 +178,7 @@ func (s *PlatformMappingScreen) buildPlatformOptions(
if !dirExists {
displayName := cfwDir
- if input.CFW == cfw.NextUI {
+ if input.CFW == cfw.NextUI || input.CFW == cfw.MinUI {
displayName = stringutil.ParseTag(cfwDir)
}
options = append(options, gaba.Option{
@@ -199,7 +199,7 @@ func (s *PlatformMappingScreen) buildPlatformOptions(
if s.isValidDirectoryForPlatform(dirName, input.CFW, cfwDirectories) {
displayName := dirName
- if input.CFW == cfw.NextUI {
+ if input.CFW == cfw.NextUI || input.CFW == cfw.MinUI {
displayName = stringutil.ParseTag(dirName)
}
@@ -262,7 +262,7 @@ func (s *PlatformMappingScreen) directoryMatchesPlatform(
romFolderBase := cfw.RomFolderBase(dirName, stringutil.ParseTag)
switch c {
- case cfw.NextUI:
+ case cfw.NextUI, cfw.MinUI:
return stringutil.ParseTag(cfwFSSlug) == romFolderBase
default:
return cfwFSSlug == romFolderBase
@@ -291,7 +291,7 @@ func (s *PlatformMappingScreen) getCFWDirectoriesForPlatform(fsSlug string, c cf
}
func (s *PlatformMappingScreen) directoriesMatch(dir1, dir2 string, c cfw.CFW) bool {
- if c == cfw.NextUI {
+ if c == cfw.NextUI || c == cfw.MinUI {
return stringutil.ParseTag(dir1) == stringutil.ParseTag(dir2)
}
return dir1 == dir2
diff --git a/ui/switch_to_token.go b/ui/switch_to_token.go
new file mode 100644
index 00000000..f72a9ada
--- /dev/null
+++ b/ui/switch_to_token.go
@@ -0,0 +1,129 @@
+package ui
+
+import (
+ "grout/cache"
+ "grout/internal"
+ "grout/romm"
+
+ "github.com/BrandonKowalski/gabagool/v2/pkg/gabagool"
+ icons "github.com/BrandonKowalski/gabagool/v2/pkg/gabagool/constants"
+ "github.com/BrandonKowalski/gabagool/v2/pkg/gabagool/i18n"
+ goi18n "github.com/nicksnyder/go-i18n/v2/i18n"
+)
+
+type SwitchToTokenOutput struct {
+ Success bool
+ Host romm.Host
+}
+
+type SwitchToTokenScreen struct{}
+
+func NewSwitchToTokenScreen() *SwitchToTokenScreen {
+ return &SwitchToTokenScreen{}
+}
+
+func (s *SwitchToTokenScreen) Execute(config *internal.Config, host romm.Host) SwitchToTokenOutput {
+ logger := gabagool.GetLogger()
+
+ items := []gabagool.ItemWithOptions{
+ {
+ Item: gabagool.MenuItem{
+ Text: i18n.Localize(&goi18n.Message{ID: "login_pairing_code", Other: "Pairing Code"}, nil),
+ },
+ Options: []gabagool.Option{
+ {
+ Type: gabagool.OptionTypeKeyboard,
+ DisplayName: "",
+ KeyboardPrompt: "",
+ Value: "",
+ },
+ },
+ },
+ }
+
+ res, err := gabagool.OptionsList(
+ i18n.Localize(&goi18n.Message{ID: "settings_switch_to_token", Other: "Switch to API Token"}, nil),
+ gabagool.OptionListSettings{
+ FooterHelpItems: []gabagool.FooterHelpItem{
+ FooterBack(),
+ {ButtonName: icons.LeftRight, HelpText: i18n.Localize(&goi18n.Message{ID: "button_cycle", Other: "Cycle"}, nil)},
+ {ButtonName: icons.Start, HelpText: i18n.Localize(&goi18n.Message{ID: "button_continue", Other: "Continue"}, nil)},
+ },
+ UseSmallTitle: true,
+ },
+ items,
+ )
+
+ if err != nil {
+ return SwitchToTokenOutput{}
+ }
+
+ code := res.Items[0].Options[0].Value.(string)
+ if code == "" {
+ return SwitchToTokenOutput{}
+ }
+
+ // Exchange the pairing code for a token
+ result, _ := gabagool.ProcessMessage(
+ i18n.Localize(&goi18n.Message{ID: "login_validating", Other: "Logging in..."}, nil),
+ gabagool.ProcessMessageOptions{},
+ func() (interface{}, error) {
+ tokenResp, err := romm.ExchangeToken(host.URL(), code, host.InsecureSkipVerify)
+ if err != nil {
+ return nil, err
+ }
+
+ newHost := host
+ newHost.Token = tokenResp.RawToken
+ newHost.TokenName = tokenResp.Name
+ newHost.TokenExpiresAt = tokenResp.ExpiresAt
+ newHost.Password = ""
+
+ // Validate the token works
+ client := romm.NewClientFromHost(newHost, internal.LoginTimeout)
+ if err := client.ValidateToken(); err != nil {
+ return nil, err
+ }
+
+ // Fetch username if missing
+ if newHost.Username == "" {
+ if user, err := client.GetCurrentUser(); err == nil {
+ newHost.Username = user.Username
+ }
+ }
+
+ return newHost, nil
+ },
+ )
+
+ if result == nil {
+ logger.Warn("Token exchange failed")
+ gabagool.ConfirmationMessage(
+ i18n.Localize(&goi18n.Message{ID: "login_error_invalid_code", Other: "Invalid or expired pairing code.\nPlease try again."}, nil),
+ ContinueFooter(),
+ gabagool.MessageOptions{},
+ )
+ return SwitchToTokenOutput{}
+ }
+
+ newHost := result.(romm.Host)
+
+ // Update config
+ config.Hosts[0] = newHost
+ if err := internal.SaveConfig(config); err != nil {
+ logger.Error("Failed to save config after token switch", "error", err)
+ }
+
+ // Re-initialize cache manager with new credentials
+ if err := cache.InitCacheManager(newHost, config); err != nil {
+ logger.Error("Failed to re-initialize cache manager", "error", err)
+ }
+
+ gabagool.ConfirmationMessage(
+ i18n.Localize(&goi18n.Message{ID: "settings_token_switch_success", Other: "Successfully switched to API Token!"}, nil),
+ ContinueFooter(),
+ gabagool.MessageOptions{},
+ )
+
+ return SwitchToTokenOutput{Success: true, Host: newHost}
+}
diff --git a/ui/synced_games.go b/ui/synced_games.go
index 726339bc..a2fdc015 100644
--- a/ui/synced_games.go
+++ b/ui/synced_games.go
@@ -124,7 +124,7 @@ func (s *SyncedGamesScreen) Draw(input SyncedGamesInput) (SyncedGamesOutput, err
})
// Outer loop: platform list
- client := romm.NewClientFromHost(input.Host, input.Config.ApiTimeout)
+ client := romm.NewClientFromHost(input.Host, input.Config.ApiTimeout.Duration())
platformIndex := 0
platformVisibleStart := 0
diff --git a/ui/tools_settings.go b/ui/tools_settings.go
new file mode 100644
index 00000000..34b796b6
--- /dev/null
+++ b/ui/tools_settings.go
@@ -0,0 +1,119 @@
+package ui
+
+import (
+ "errors"
+ "grout/internal"
+ "grout/romm"
+
+ gaba "github.com/BrandonKowalski/gabagool/v2/pkg/gabagool"
+ "github.com/BrandonKowalski/gabagool/v2/pkg/gabagool/i18n"
+ goi18n "github.com/nicksnyder/go-i18n/v2/i18n"
+)
+
+type ToolsSettingsInput struct {
+ Config *internal.Config
+ Host romm.Host
+ LastSelectedIndex int
+ LastVisibleStartIndex int
+}
+
+type ToolsSettingsOutput struct {
+ Action ToolsSettingsAction
+ SyncLocalArtworkClicked bool
+ LastSelectedIndex int
+ LastVisibleStartIndex int
+}
+
+type ToolsSettingsScreen struct{}
+
+func NewToolsSettingsScreen() *ToolsSettingsScreen {
+ return &ToolsSettingsScreen{}
+}
+
+func (s *ToolsSettingsScreen) Draw(input ToolsSettingsInput) (ToolsSettingsOutput, error) {
+ config := input.Config
+ output := ToolsSettingsOutput{Action: ToolsSettingsActionBack}
+
+ items := s.buildMenuItems(config)
+
+ result, err := gaba.OptionsList(
+ i18n.Localize(&goi18n.Message{ID: "settings_tools", Other: "Tools"}, nil),
+ gaba.OptionListSettings{
+ FooterHelpItems: []gaba.FooterHelpItem{
+ FooterBack(),
+ FooterCycle(),
+ FooterSave(),
+ },
+ InitialSelectedIndex: input.LastSelectedIndex,
+ VisibleStartIndex: input.LastVisibleStartIndex,
+ StatusBar: StatusBar(),
+ UseSmallTitle: true,
+ },
+ items,
+ )
+
+ if result != nil {
+ output.LastSelectedIndex = result.Selected
+ output.LastVisibleStartIndex = result.VisibleStartIndex
+ }
+
+ if err != nil {
+ if errors.Is(err, gaba.ErrCancelled) {
+ return output, nil
+ }
+ gaba.GetLogger().Error("Tools settings error", "error", err)
+ return output, err
+ }
+
+ if result.Action == gaba.ListActionSelected {
+ selectedText := items[result.Selected].Item.Text
+
+ if selectedText == i18n.Localize(&goi18n.Message{ID: "settings_sync_local_artwork", Other: "Download Missing Art"}, nil) {
+ output.SyncLocalArtworkClicked = true
+ output.Action = ToolsSettingsActionSyncLocalArtwork
+ return output, nil
+ }
+ }
+
+ s.applySettings(config, result.Items)
+
+ err = internal.SaveConfig(config)
+ if err != nil {
+ gaba.GetLogger().Error("Error saving tools settings", "error", err)
+ return output, err
+ }
+
+ output.Action = ToolsSettingsActionSaved
+ return output, nil
+}
+
+func (s *ToolsSettingsScreen) buildMenuItems(config *internal.Config) []gaba.ItemWithOptions {
+ return []gaba.ItemWithOptions{
+ {
+ Item: gaba.MenuItem{Text: i18n.Localize(&goi18n.Message{ID: "settings_sync_local_artwork", Other: "Download Missing Art"}, nil)},
+ Options: []gaba.Option{{Type: gaba.OptionTypeClickable}},
+ },
+ {
+ Item: gaba.MenuItem{Text: i18n.Localize(&goi18n.Message{ID: "settings_kid_mode", Other: "Kid Mode"}, nil)},
+ Options: []gaba.Option{
+ {DisplayName: i18n.Localize(&goi18n.Message{ID: "option_disabled", Other: "Disabled"}, nil), Value: false},
+ {DisplayName: i18n.Localize(&goi18n.Message{ID: "option_enabled", Other: "Enabled"}, nil), Value: true},
+ },
+ SelectedOption: boolToIndex(config.KidMode),
+ },
+ }
+}
+
+func (s *ToolsSettingsScreen) applySettings(config *internal.Config, items []gaba.ItemWithOptions) {
+ for _, item := range items {
+ selectedText := item.Item.Text
+
+ switch selectedText {
+ case i18n.Localize(&goi18n.Message{ID: "settings_kid_mode", Other: "Kid Mode"}, nil):
+ if val, ok := item.Options[item.SelectedOption].Value.(bool); ok {
+ config.KidMode = val
+ internal.SetKidMode(val)
+ }
+ }
+ }
+}
diff --git a/ui/update.go b/ui/update.go
index ac977e92..100eed91 100644
--- a/ui/update.go
+++ b/ui/update.go
@@ -116,7 +116,7 @@ func (s *UpdateScreen) Draw(input UpdateInput) (UpdateOutput, error) {
Progress: progress,
},
func() (interface{}, error) {
- updateErr = update.PerformUpdate(updateInfo.DownloadURL, progress)
+ updateErr = update.PerformUpdate(input.CFW, updateInfo.DownloadURL, updateInfo.AssetSize, updateInfo.AssetSHA256, progress)
return nil, updateErr
},
)
diff --git a/update/auto_update.go b/update/auto_update.go
index beaed947..d0ba07f3 100644
--- a/update/auto_update.go
+++ b/update/auto_update.go
@@ -4,6 +4,7 @@ import (
"grout/cfw"
"grout/internal"
"grout/romm"
+ "sync"
"sync/atomic"
gaba "github.com/BrandonKowalski/gabagool/v2/pkg/gabagool"
@@ -13,13 +14,14 @@ const updateIcon = "\U000F06B0"
type AutoUpdate struct {
cfwType cfw.CFW
- releaseChannel internal.ReleaseChannel
host *romm.Host
icon *gaba.DynamicStatusBarIcon
running atomic.Bool
updateAvailable atomic.Bool
done chan struct{}
- updateInfo *Info
+ mu sync.Mutex
+ releaseChannel internal.ReleaseChannel
+ updateInfo atomic.Pointer[Info]
}
func NewAutoUpdate(c cfw.CFW, r internal.ReleaseChannel, host *romm.Host) *AutoUpdate {
@@ -53,7 +55,7 @@ func (a *AutoUpdate) UpdateAvailable() bool {
}
func (a *AutoUpdate) UpdateInfo() *Info {
- return a.updateInfo
+ return a.updateInfo.Load()
}
// Recheck updates the release channel and re-runs the update check.
@@ -63,9 +65,12 @@ func (a *AutoUpdate) Recheck(releaseChannel internal.ReleaseChannel) {
return // Already running, skip
}
+ a.mu.Lock()
a.releaseChannel = releaseChannel
+ a.mu.Unlock()
+
a.updateAvailable.Store(false)
- a.updateInfo = nil
+ a.updateInfo.Store(nil)
a.icon.SetText("") // Clear the icon
a.Start()
@@ -80,13 +85,17 @@ func (a *AutoUpdate) run() {
logger.Debug("AutoUpdate: Checking for updates in background")
- info, err := CheckForUpdate(a.cfwType, a.releaseChannel, a.host)
+ a.mu.Lock()
+ channel := a.releaseChannel
+ a.mu.Unlock()
+
+ info, err := CheckForUpdate(a.cfwType, channel, a.host)
if err != nil {
logger.Debug("AutoUpdate: Failed to check for updates", "error", err)
return
}
- a.updateInfo = info
+ a.updateInfo.Store(info)
if info.UpdateAvailable {
logger.Debug("AutoUpdate: Update available", "current", info.CurrentVersion, "latest", info.LatestVersion)
diff --git a/update/github.go b/update/github.go
deleted file mode 100644
index 2ed0a33d..00000000
--- a/update/github.go
+++ /dev/null
@@ -1,170 +0,0 @@
-package update
-
-import (
- "encoding/json"
- "fmt"
- "grout/internal"
- "net/http"
- "time"
-
- gaba "github.com/BrandonKowalski/gabagool/v2/pkg/gabagool"
-)
-
-const (
- repoOwner = "rommapp"
- repoName = "grout"
- githubAPIURL = "https://api.github.com"
- defaultTimeout = 30 * time.Second
-)
-
-type GitHubRelease struct {
- TagName string `json:"tag_name"`
- Name string `json:"name"`
- Body string `json:"body"`
- Prerelease bool `json:"prerelease"`
- Draft bool `json:"draft"`
- HTMLURL string `json:"html_url"`
- Assets []GitHubAsset `json:"assets"`
-}
-
-type GitHubAsset struct {
- Name string `json:"name"`
- BrowserDownloadURL string `json:"browser_download_url"`
- Size int64 `json:"size"`
- ContentType string `json:"content_type"`
-}
-
-func FetchLatestRelease(releaseChannel internal.ReleaseChannel) (*GitHubRelease, error) {
- url := fmt.Sprintf("%s/repos/%s/%s/releases", githubAPIURL, repoOwner, repoName)
-
- client := &http.Client{
- Timeout: defaultTimeout,
- }
-
- req, err := http.NewRequest(http.MethodGet, url, nil)
- if err != nil {
- return nil, fmt.Errorf("failed to create request: %w", err)
- }
-
- req.Header.Set("Accept", "application/vnd.github+json")
- req.Header.Set("User-Agent", "Grout-Updater")
-
- resp, err := client.Do(req)
- if err != nil {
- return nil, fmt.Errorf("failed to fetch release: %w", err)
- }
- defer resp.Body.Close()
-
- if resp.StatusCode == http.StatusNotFound {
- return nil, fmt.Errorf("no releases found")
- }
-
- if resp.StatusCode != http.StatusOK {
- return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
- }
-
- var releases []GitHubRelease
- if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
- return nil, fmt.Errorf("failed to decode response: %w", err)
- }
-
- if len(releases) == 0 {
- return nil, fmt.Errorf("no releases found")
- }
-
- if releaseChannel == internal.ReleaseChannelBeta {
- gaba.GetLogger().Debug("latest release", "release", releases[0].TagName)
- return &releases[0], nil
- }
-
- for _, release := range releases {
- if !release.Prerelease && !release.Draft {
- gaba.GetLogger().Debug("latest stable release", "release", release.TagName)
- return &release, nil
- }
- }
-
- return &releases[0], nil
-}
-
-func (r *GitHubRelease) FindAsset(name string) *GitHubAsset {
- for i := range r.Assets {
- if r.Assets[i].Name == name {
- return &r.Assets[i]
- }
- }
- return nil
-}
-
-// FetchReleaseForRomMVersion fetches the latest Grout release that matches
-// the first 3 semver components (major.minor.patch) of the given RomM version.
-// For example, if rommVersion is "4.6.0-alpha.3", this will find Grout releases
-// like "4.6.0", "4.6.0.1", "4.6.0-beta.1", etc.
-func FetchReleaseForRomMVersion(rommVersion string) (*GitHubRelease, error) {
- rommVer, err := ParseVersion(rommVersion)
- if err != nil {
- return nil, fmt.Errorf("failed to parse RomM version: %w", err)
- }
-
- url := fmt.Sprintf("%s/repos/%s/%s/releases", githubAPIURL, repoOwner, repoName)
-
- client := &http.Client{
- Timeout: defaultTimeout,
- }
-
- req, err := http.NewRequest(http.MethodGet, url, nil)
- if err != nil {
- return nil, fmt.Errorf("failed to create request: %w", err)
- }
-
- req.Header.Set("Accept", "application/vnd.github+json")
- req.Header.Set("User-Agent", "Grout-Updater")
-
- resp, err := client.Do(req)
- if err != nil {
- return nil, fmt.Errorf("failed to fetch release: %w", err)
- }
- defer resp.Body.Close()
-
- if resp.StatusCode == http.StatusNotFound {
- return nil, fmt.Errorf("no releases found")
- }
-
- if resp.StatusCode != http.StatusOK {
- return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
- }
-
- var releases []GitHubRelease
- if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
- return nil, fmt.Errorf("failed to decode response: %w", err)
- }
-
- if len(releases) == 0 {
- return nil, fmt.Errorf("no releases found")
- }
-
- // Find the latest release that matches the RomM version's major.minor.patch
- for _, release := range releases {
- if release.Draft {
- continue
- }
-
- releaseVer, err := ParseVersion(release.TagName)
- if err != nil {
- gaba.GetLogger().Debug("skipping release with unparseable version", "tag", release.TagName, "error", err)
- continue
- }
-
- // Check if major.minor.patch match
- if releaseVer.Major == rommVer.Major &&
- releaseVer.Minor == rommVer.Minor &&
- releaseVer.Patch == rommVer.Patch {
- gaba.GetLogger().Debug("found matching release for RomM version",
- "rommVersion", rommVersion, "release", release.TagName)
- return &release, nil
- }
- }
-
- return nil, fmt.Errorf("No Grout release found matching RomM version %d.%d.%d",
- rommVer.Major, rommVer.Minor, rommVer.Patch)
-}
diff --git a/update/updater.go b/update/updater.go
index 15821ca6..1d03d127 100644
--- a/update/updater.go
+++ b/update/updater.go
@@ -1,6 +1,9 @@
package update
import (
+ "archive/zip"
+ "crypto/sha256"
+ "encoding/hex"
"fmt"
"grout/cfw"
"grout/internal"
@@ -10,6 +13,8 @@ import (
"net/http"
"os"
"path/filepath"
+ "runtime"
+ "strings"
gaba "github.com/BrandonKowalski/gabagool/v2/pkg/gabagool"
"go.uber.org/atomic"
@@ -21,18 +26,83 @@ type Info struct {
ReleaseNotes string
DownloadURL string
AssetSize int64
+ AssetSHA256 string
UpdateAvailable bool
}
-func GetAssetName(c cfw.CFW) string {
+// GetDistributionAssetName returns the distribution zip asset name for a given CFW and architecture.
+func GetDistributionAssetName(c cfw.CFW) string {
switch c {
- case cfw.MuOS, cfw.Knulli, cfw.Spruce, cfw.NextUI, cfw.ROCKNIX, cfw.Trimui, cfw.Batocera:
- return "grout"
+ case cfw.NextUI:
+ return "Grout.pak.zip"
+ case cfw.MuOS:
+ return "Grout.muxapp"
+ case cfw.Knulli:
+ return "Grout-Knulli.zip"
+ case cfw.Spruce:
+ return "Grout.spruce.zip"
+ case cfw.ROCKNIX:
+ return "Grout-ROCKNIX.zip"
+ case cfw.Trimui:
+ return "Grout-Trimui.zip"
+ case cfw.Allium:
+ return "Grout-Allium.zip"
+ case cfw.Onion:
+ return "Grout-Onion.zip"
+ case cfw.MinUI:
+ switch runtime.GOARCH {
+ case "arm64":
+ return "Grout-MinUI-arm64.zip"
+ case "arm":
+ return "Grout-MinUI-arm32.zip"
+ default:
+ return ""
+ }
+ case cfw.Batocera:
+ switch runtime.GOARCH {
+ case "arm64":
+ return "Grout-Batocera-arm64.zip"
+ case "amd64":
+ return "Grout-Batocera-amd64.zip"
+ case "386":
+ return "Grout-Batocera-x86.zip"
+ default:
+ return ""
+ }
default:
return ""
}
}
+// getInstallRoot returns the top-level install directory where the
+// distribution zip contents should be extracted.
+func getInstallRoot(c cfw.CFW) (string, error) {
+ execPath, err := os.Executable()
+ if err != nil {
+ return "", fmt.Errorf("failed to get executable path: %w", err)
+ }
+ execPath, err = filepath.EvalSymlinks(execPath)
+ if err != nil {
+ return "", fmt.Errorf("failed to resolve executable path: %w", err)
+ }
+
+ // Number of directory levels up from the binary to the zip extraction root.
+ levels := 2
+ switch c {
+ case cfw.NextUI:
+ levels = 1 // zip has no wrapper dir
+ case cfw.Spruce, cfw.Allium, cfw.Onion, cfw.Trimui:
+ levels = 3 // binary is nested: e.g. Grout.pak/grout/grout
+ }
+
+ root := execPath
+ for i := 0; i < levels; i++ {
+ root = filepath.Dir(root)
+ }
+ return root, nil
+}
+
+
// CheckForUpdate checks for available updates based on the release channel.
// For ReleaseChannelMatchRomM, the host parameter is required to fetch the RomM version.
// For other channels, the host parameter is optional and ignored.
@@ -46,107 +116,229 @@ func CheckForUpdate(c cfw.CFW, releaseChannel internal.ReleaseChannel, host *rom
}, nil
}
- var release *GitHubRelease
- var err error
+ logger := gaba.GetLogger()
+
+ versions, err := FetchVersionsFile()
+ if err != nil {
+ return nil, fmt.Errorf("failed to check for updates: %w", err)
+ }
+
+ var release *ChannelRelease
- if releaseChannel == internal.ReleaseChannelMatchRomM {
+ switch releaseChannel {
+ case internal.ReleaseChannelMatchRomM:
if host == nil {
return nil, fmt.Errorf("host is required for Match RomM release channel")
}
- // Fetch RomM version from heartbeat
client := romm.NewClientFromHost(*host)
heartbeat, err := client.GetHeartbeat()
if err != nil {
return nil, fmt.Errorf("failed to get RomM version: %w", err)
}
- gaba.GetLogger().Debug("fetched RomM version for update check", "version", heartbeat.System.Version)
+ logger.Debug("fetched RomM version for update check", "version", heartbeat.System.Version)
- // Find a Grout release matching the RomM version
- release, err = FetchReleaseForRomMVersion(heartbeat.System.Version)
+ rommVer, err := ParseVersion(heartbeat.System.Version)
if err != nil {
- return nil, err
+ return nil, fmt.Errorf("failed to parse RomM version: %w", err)
}
- } else {
- release, err = FetchLatestRelease(releaseChannel)
- if err != nil {
- return nil, fmt.Errorf("failed to check for updates: %w", err)
+
+ key := fmt.Sprintf("%d.%d.%d", rommVer.Major, rommVer.Minor, rommVer.Patch)
+ release = versions.RomM[key]
+ if release == nil {
+ return nil, fmt.Errorf("no Grout release found matching RomM version %s", key)
+ }
+
+ case internal.ReleaseChannelBeta:
+ release = versions.Beta
+ if release == nil {
+ return nil, fmt.Errorf("no beta release available")
+ }
+
+ default:
+ release = versions.Stable
+ if release == nil {
+ return nil, fmt.Errorf("no stable release available")
}
}
info := &Info{
CurrentVersion: currentVersion,
- LatestVersion: release.TagName,
- ReleaseNotes: release.Body,
+ LatestVersion: release.Version,
+ ReleaseNotes: release.Notes,
}
- if !IsNewerVersion(currentVersion, release.TagName) {
+ if !IsNewerVersion(currentVersion, release.Version) {
info.UpdateAvailable = false
return info, nil
}
- assetName := GetAssetName(c)
+ assetName := GetDistributionAssetName(c)
if assetName == "" {
return nil, fmt.Errorf("unsupported platform for updates")
}
- asset := release.FindAsset(assetName)
- if asset == nil {
- return nil, fmt.Errorf("update binary not found for platform: %s", assetName)
+ asset, ok := release.Assets[assetName]
+ if !ok {
+ return nil, fmt.Errorf("update not found for platform: %s", assetName)
}
info.UpdateAvailable = true
- info.DownloadURL = asset.BrowserDownloadURL
+ info.DownloadURL = asset.URL
info.AssetSize = asset.Size
+ info.AssetSHA256 = asset.SHA256
return info, nil
}
-func PerformUpdate(downloadURL string, progress *atomic.Float64) error {
- execPath, err := os.Executable()
+func PerformUpdate(c cfw.CFW, downloadURL string, expectedSize int64, expectedSHA256 string, progress *atomic.Float64) error {
+ installRoot, err := getInstallRoot(c)
if err != nil {
- return fmt.Errorf("failed to get executable path: %w", err)
+ return err
}
- execPath, err = filepath.EvalSymlinks(execPath)
- if err != nil {
- return fmt.Errorf("failed to resolve executable path: %w", err)
+ tmpZip := filepath.Join(os.TempDir(), "grout-update.zip")
+ defer os.Remove(tmpZip)
+
+ if err := downloadFile(downloadURL, tmpZip, expectedSize, progress); err != nil {
+ return fmt.Errorf("failed to download update: %w", err)
}
- tmpPath := execPath + ".new"
- oldPath := execPath + ".old"
+ if expectedSHA256 != "" {
+ if err := verifySHA256(tmpZip, expectedSHA256); err != nil {
+ return err
+ }
+ }
- if err := downloadBinary(downloadURL, tmpPath, progress); err != nil {
- os.Remove(tmpPath)
- return fmt.Errorf("failed to download update: %w", err)
+ // 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")
+ os.RemoveAll(updateDir)
+
+ if err := extractZip(tmpZip, updateDir); err != nil {
+ os.RemoveAll(updateDir)
+ return fmt.Errorf("failed to extract update: %w", err)
}
- if err := os.Chmod(tmpPath, 0755); err != nil {
- os.Remove(tmpPath)
- return fmt.Errorf("failed to set permissions: %w", err)
+ // Immediately replace the launch script so the new version
+ // (with the update preamble) is in place for next startup.
+ scriptRel := getLaunchScriptPath(c)
+ stagedScript := filepath.Join(updateDir, scriptRel)
+ targetScript := filepath.Join(installRoot, scriptRel)
+
+ if data, err := os.ReadFile(stagedScript); err == nil {
+ os.Remove(targetScript)
+ if err := os.WriteFile(targetScript, data, 0755); err != nil {
+ return fmt.Errorf("failed to update launch script: %w", err)
+ }
+ os.Remove(stagedScript)
}
- os.Remove(oldPath)
+ return nil
+}
+
+func getLaunchScriptPath(c cfw.CFW) string {
+ switch c {
+ case cfw.NextUI:
+ return "launch.sh"
+ case cfw.MuOS:
+ return "Grout/mux_launch.sh"
+ case cfw.Knulli:
+ return "Grout/Grout.sh"
+ case cfw.Spruce:
+ return "Grout/launch.sh"
+ case cfw.ROCKNIX:
+ return "Grout.sh"
+ case cfw.Trimui:
+ return "Grout/launch.sh"
+ case cfw.Allium:
+ return "Grout.pak/launch.sh"
+ case cfw.Onion:
+ return "Grout/launch.sh"
+ case cfw.MinUI:
+ return "Grout/launch.sh"
+ case cfw.Batocera:
+ return "Grout.sh"
+ default:
+ return ""
+ }
+}
- if err := os.Rename(execPath, oldPath); err != nil {
- os.Remove(tmpPath)
- return fmt.Errorf("failed to backup current binary: %w", err)
+func extractZip(zipPath, destDir string) error {
+ r, err := zip.OpenReader(zipPath)
+ if err != nil {
+ return fmt.Errorf("failed to open zip: %w", err)
}
+ defer r.Close()
- if err := os.Rename(tmpPath, execPath); err != nil {
- if rollbackErr := os.Rename(oldPath, execPath); rollbackErr != nil {
- return fmt.Errorf("failed to install update and rollback failed: install=%w, rollback=%v", err, rollbackErr)
+ for _, f := range r.File {
+ targetPath := filepath.Join(destDir, f.Name)
+
+ // Prevent zip slip
+ cleanDest := filepath.Clean(destDir) + string(os.PathSeparator)
+ if !strings.HasPrefix(targetPath, cleanDest) && targetPath != filepath.Clean(destDir) {
+ return fmt.Errorf("illegal file path in zip: %s", f.Name)
+ }
+
+ if f.FileInfo().IsDir() {
+ os.MkdirAll(targetPath, 0755)
+ continue
+ }
+
+ if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
+ return fmt.Errorf("failed to create directory: %w", err)
}
- return fmt.Errorf("failed to install update (rolled back): %w", err)
+
+ outFile, err := os.OpenFile(targetPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
+ if err != nil {
+ return fmt.Errorf("failed to create file %s: %w", f.Name, err)
+ }
+
+ rc, err := f.Open()
+ if err != nil {
+ outFile.Close()
+ return fmt.Errorf("failed to read zip entry %s: %w", f.Name, err)
+ }
+
+ _, err = io.Copy(outFile, rc)
+ rc.Close()
+ outFile.Close()
+ if err != nil {
+ return fmt.Errorf("failed to write file %s: %w", f.Name, err)
+ }
+ }
+
+ return nil
+}
+
+// CleanupUpdateArtifacts removes any leftover files from a previous update.
+func CleanupUpdateArtifacts() {
+ os.Remove(filepath.Join(os.TempDir(), "grout-update.zip"))
+}
+
+func verifySHA256(filePath, expected string) error {
+ f, err := os.Open(filePath)
+ if err != nil {
+ return fmt.Errorf("failed to open file for verification: %w", err)
}
+ defer f.Close()
- os.Remove(oldPath)
+ h := sha256.New()
+ if _, err := io.Copy(h, f); err != nil {
+ return fmt.Errorf("failed to hash file: %w", err)
+ }
+
+ actual := hex.EncodeToString(h.Sum(nil))
+ if actual != expected {
+ return fmt.Errorf("download did not match expected hash")
+ }
return nil
}
-func downloadBinary(url, destPath string, progress *atomic.Float64) error {
+func downloadFile(url, destPath string, expectedSize int64, progress *atomic.Float64) error {
client := &http.Client{
Timeout: internal.UpdaterTimeout,
}
@@ -174,7 +366,11 @@ func downloadBinary(url, destPath string, progress *atomic.Float64) error {
}
defer out.Close()
- totalSize := resp.ContentLength
+ // Use the known asset size for progress tracking; fall back to Content-Length
+ totalSize := expectedSize
+ if totalSize <= 0 {
+ totalSize = resp.ContentLength
+ }
if totalSize <= 0 {
totalSize = 1
}
diff --git a/update/version.go b/update/version.go
index 4bef14f2..32d9d1af 100644
--- a/update/version.go
+++ b/update/version.go
@@ -120,14 +120,41 @@ func CompareVersions(current, latest string) int {
return -1
}
if currentHasPrerelease {
- // Both are prereleases - compare prerelease strings lexicographically
- // For simplicity, we'll just do a string comparison
- // In practice, this handles cases like "beta.1" vs "beta.2"
- if currentVer.Prerelease < latestVer.Prerelease {
- return -1
+ // Both are prereleases - compare identifiers per semver:
+ // split on ".", compare each segment numerically if both are numbers,
+ // otherwise lexicographically.
+ currentParts := strings.Split(currentVer.Prerelease, ".")
+ latestParts := strings.Split(latestVer.Prerelease, ".")
+ maxLen := len(currentParts)
+ if len(latestParts) > maxLen {
+ maxLen = len(latestParts)
}
- if currentVer.Prerelease > latestVer.Prerelease {
- return 1
+ for i := 0; i < maxLen; i++ {
+ if i >= len(currentParts) {
+ return -1 // current has fewer identifiers, so it's less
+ }
+ if i >= len(latestParts) {
+ return 1 // latest has fewer identifiers, so current is greater
+ }
+ cNum, cErr := strconv.Atoi(currentParts[i])
+ lNum, lErr := strconv.Atoi(latestParts[i])
+ if cErr == nil && lErr == nil {
+ // Both numeric — compare as integers
+ if cNum < lNum {
+ return -1
+ }
+ if cNum > lNum {
+ return 1
+ }
+ } else {
+ // At least one non-numeric — compare as strings
+ if currentParts[i] < latestParts[i] {
+ return -1
+ }
+ if currentParts[i] > latestParts[i] {
+ return 1
+ }
+ }
}
}
diff --git a/update/version_test.go b/update/version_test.go
index 0dfce705..8b48dd5b 100644
--- a/update/version_test.go
+++ b/update/version_test.go
@@ -93,6 +93,12 @@ func TestCompareVersions(t *testing.T) {
{"4.6.0.0-beta.1", "4.6.0.1", -1, "4-component beta vs higher build"},
{"4.6.0.1", "4.6.0.0-beta.1", 1, "4-component higher build vs beta"},
+ // Multi-digit prerelease numbers (must compare numerically, not lexicographically)
+ {"v4.8.0.0-beta.6", "v4.8.0.0-beta.10", -1, "beta.10 should be newer than beta.6"},
+ {"v4.8.0.0-beta.10", "v4.8.0.0-beta.6", 1, "beta.6 should be older than beta.10"},
+ {"v4.8.0.0-beta.10", "v4.8.0.0-beta.10", 0, "same multi-digit beta"},
+ {"v1.0.0-beta.9", "v1.0.0-beta.20", -1, "beta.20 newer than beta.9"},
+
// Mixed 3 and 4 component (3-component defaults build to 0)
{"1.2.3", "1.2.3.0", 0, "3-component equals 4-component with build 0"},
{"1.2.3", "1.2.3.1", -1, "3-component older than 4-component with build 1"},
diff --git a/update/versions.go b/update/versions.go
new file mode 100644
index 00000000..c8ae734d
--- /dev/null
+++ b/update/versions.go
@@ -0,0 +1,59 @@
+package update
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "time"
+)
+
+const (
+ versionsURL = "https://grout.romm.app/versions.json"
+ defaultTimeout = 30 * time.Second
+)
+
+type VersionsFile struct {
+ Stable *ChannelRelease `json:"stable"`
+ Beta *ChannelRelease `json:"beta"`
+ RomM map[string]*ChannelRelease `json:"romm"`
+}
+
+type ChannelRelease struct {
+ Version string `json:"version"`
+ Notes string `json:"notes"`
+ Assets map[string]*ChannelAsset `json:"assets"`
+}
+
+type ChannelAsset struct {
+ URL string `json:"url"`
+ Size int64 `json:"size"`
+ SHA256 string `json:"sha256"`
+}
+
+func FetchVersionsFile() (*VersionsFile, error) {
+ client := &http.Client{Timeout: defaultTimeout}
+
+ req, err := http.NewRequest(http.MethodGet, versionsURL, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ req.Header.Set("User-Agent", "Grout-Updater")
+
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("failed to fetch versions: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("failed to fetch versions: status %d", resp.StatusCode)
+ }
+
+ var versions VersionsFile
+ if err := json.NewDecoder(resp.Body).Decode(&versions); err != nil {
+ return nil, fmt.Errorf("failed to decode versions file: %w", err)
+ }
+
+ return &versions, nil
+}
diff --git a/vendored/miyoo/README.md b/vendored/miyoo/README.md
new file mode 100644
index 00000000..6c20ac6d
--- /dev/null
+++ b/vendored/miyoo/README.md
@@ -0,0 +1,7 @@
+# Vendored Miyoo Shared Libraries
+
+I've spend hours trying to find the provenance of these damn libraries.
+
+These were shamelessly cribbed from Spruce.
+
+If you know how these are built, please let us know either in a GH Issue or in the RomM Discord.
\ No newline at end of file
diff --git a/vendored/miyoo/libEGL.so b/vendored/miyoo/libEGL.so
new file mode 100644
index 00000000..9b14b5b3
Binary files /dev/null and b/vendored/miyoo/libEGL.so differ
diff --git a/vendored/miyoo/libGLESv2.so b/vendored/miyoo/libGLESv2.so
new file mode 100644
index 00000000..febf6360
Binary files /dev/null and b/vendored/miyoo/libGLESv2.so differ
diff --git a/vendored/miyoo/libSDL2-2.0.so.0 b/vendored/miyoo/libSDL2-2.0.so.0
new file mode 100644
index 00000000..517262d4
Binary files /dev/null and b/vendored/miyoo/libSDL2-2.0.so.0 differ
diff --git a/vendored/miyoo/libSDL2_gfx-1.0.so.0 b/vendored/miyoo/libSDL2_gfx-1.0.so.0
new file mode 100644
index 00000000..4c8ef91c
Binary files /dev/null and b/vendored/miyoo/libSDL2_gfx-1.0.so.0 differ
diff --git a/vendored/miyoo/libSDL2_image-2.0.so.0 b/vendored/miyoo/libSDL2_image-2.0.so.0
new file mode 100644
index 00000000..1392aae3
Binary files /dev/null and b/vendored/miyoo/libSDL2_image-2.0.so.0 differ
diff --git a/vendored/miyoo/libSDL2_ttf-2.0.so b/vendored/miyoo/libSDL2_ttf-2.0.so
new file mode 100644
index 00000000..1c5b7b51
Binary files /dev/null and b/vendored/miyoo/libSDL2_ttf-2.0.so differ
diff --git a/vendored/miyoo/libSDL2_ttf-2.0.so.0 b/vendored/miyoo/libSDL2_ttf-2.0.so.0
new file mode 100644
index 00000000..50d5afcc
Binary files /dev/null and b/vendored/miyoo/libSDL2_ttf-2.0.so.0 differ
diff --git a/vendored/miyoo/libm.so.6 b/vendored/miyoo/libm.so.6
new file mode 100644
index 00000000..8bf7907e
Binary files /dev/null and b/vendored/miyoo/libm.so.6 differ
diff --git a/vendored/miyoo/libneonarmmiyoo.so b/vendored/miyoo/libneonarmmiyoo.so
new file mode 100644
index 00000000..75bf916c
Binary files /dev/null and b/vendored/miyoo/libneonarmmiyoo.so differ
diff --git a/vendored/miyoo/libnsl.so.1 b/vendored/miyoo/libnsl.so.1
new file mode 100644
index 00000000..dcfb9e49
Binary files /dev/null and b/vendored/miyoo/libnsl.so.1 differ
diff --git a/vendored/miyoo/libpcre.so.1 b/vendored/miyoo/libpcre.so.1
new file mode 100644
index 00000000..12448c25
Binary files /dev/null and b/vendored/miyoo/libpcre.so.1 differ
diff --git a/vendored/miyoo/libpng16.so.16 b/vendored/miyoo/libpng16.so.16
new file mode 100644
index 00000000..771c6629
Binary files /dev/null and b/vendored/miyoo/libpng16.so.16 differ
diff --git a/vendored/miyoo/libtdb.so.1 b/vendored/miyoo/libtdb.so.1
new file mode 100644
index 00000000..5896ebad
Binary files /dev/null and b/vendored/miyoo/libtdb.so.1 differ
diff --git a/vendored/miyoo/libz.so.1 b/vendored/miyoo/libz.so.1
new file mode 100644
index 00000000..142fe853
Binary files /dev/null and b/vendored/miyoo/libz.so.1 differ