From bfdbfcff9a4e31c50fc93f0761c14fd04c1932f4 Mon Sep 17 00:00:00 2001 From: "Brandon T. Kowalski" Date: Mon, 19 Jan 2026 17:59:39 -0500 Subject: [PATCH] Add new release channel to tie to RomM version. --- .env-dev | 1 + app/states.go | 10 ++++-- internal/config.go | 7 ++-- romm/heartbeat.go | 13 ++++++++ ui/advanced_settings.go | 1 + ui/settings.go | 6 ++-- ui/update.go | 4 ++- update/auto_update.go | 22 +++++++++++-- update/github.go | 73 +++++++++++++++++++++++++++++++++++++++++ update/updater.go | 37 ++++++++++++++++++--- update/version_test.go | 2 +- version/version.go | 8 ++++- 12 files changed, 168 insertions(+), 16 deletions(-) create mode 100644 romm/heartbeat.go diff --git a/.env-dev b/.env-dev index c3f28c2..a5cfb31 100644 --- a/.env-dev +++ b/.env-dev @@ -1,4 +1,5 @@ ENVIRONMENT=DEV +# GROUT_VERSION= # Window Size overrides (defaults to full) # WINDOW_WIDTH=1024 diff --git a/app/states.go b/app/states.go index deaf665..9140d6a 100644 --- a/app/states.go +++ b/app/states.go @@ -146,9 +146,9 @@ func buildFSM(config *internal.Config, c cfw.CFW, platforms []romm.Platform, qui }) } - // Start auto-update check on first platform menu view autoUpdateOnce.Do(func() { - autoUpdate = update.NewAutoUpdate(currentCFW, config.ReleaseChannel) + host, _ := gaba.Get[romm.Host](ctx) + autoUpdate = update.NewAutoUpdate(currentCFW, config.ReleaseChannel, &host) ui.AddStatusBarIcon(autoUpdate.Icon()) autoUpdate.Start() }) @@ -661,6 +661,10 @@ func buildFSM(config *internal.Config, c cfw.CFW, platforms []romm.Platform, qui nav.AdvancedSettingsPos.Index = result.Value.LastSelectedIndex nav.AdvancedSettingsPos.VisibleStartIndex = result.Value.LastVisibleStartIndex + if result.ExitCode == gaba.ExitCodeSuccess && autoUpdate != nil { + autoUpdate.Recheck(config.ReleaseChannel) + } + return result.Value, result.ExitCode }). On(gaba.ExitCodeSuccess, settings). @@ -958,11 +962,13 @@ func buildFSM(config *internal.Config, c cfw.CFW, platforms []romm.Platform, qui gaba.AddState(fsm, updateCheck, func(ctx *gaba.Context) (ui.UpdateOutput, gaba.ExitCode) { currentCFW, _ := gaba.Get[cfw.CFW](ctx) + host, _ := gaba.Get[romm.Host](ctx) screen := ui.NewUpdateScreen() result, err := screen.Draw(ui.UpdateInput{ CFW: currentCFW, ReleaseChannel: config.ReleaseChannel, + Host: &host, }) if err != nil { diff --git a/internal/config.go b/internal/config.go index 0a83d40..01b9630 100644 --- a/internal/config.go +++ b/internal/config.go @@ -17,8 +17,9 @@ import ( type ReleaseChannel string const ( - ReleaseChannelStable ReleaseChannel = "stable" - ReleaseChannelBeta ReleaseChannel = "beta" + ReleaseChannelMatchRomM ReleaseChannel = "match_romm" + ReleaseChannelStable ReleaseChannel = "stable" + ReleaseChannelBeta ReleaseChannel = "beta" ) var kidModeEnabled atomic.Bool @@ -138,7 +139,7 @@ func SaveConfig(config *Config) error { } if config.ReleaseChannel == "" { - config.ReleaseChannel = ReleaseChannelStable + config.ReleaseChannel = ReleaseChannelMatchRomM } gaba.SetRawLogLevel(config.LogLevel) diff --git a/romm/heartbeat.go b/romm/heartbeat.go new file mode 100644 index 0000000..068f2ac --- /dev/null +++ b/romm/heartbeat.go @@ -0,0 +1,13 @@ +package romm + +type HeartbeatResponse struct { + System struct { + Version string `json:"VERSION"` + } `json:"SYSTEM"` +} + +func (c *Client) GetHeartbeat() (HeartbeatResponse, error) { + var heartbeat HeartbeatResponse + err := c.doRequest("GET", endpointHeartbeat, nil, nil, &heartbeat) + return heartbeat, err +} diff --git a/ui/advanced_settings.go b/ui/advanced_settings.go index ae439e6..d0a17c5 100644 --- a/ui/advanced_settings.go +++ b/ui/advanced_settings.go @@ -143,6 +143,7 @@ func (s *AdvancedSettingsScreen) buildMenuItems(config *internal.Config) []gaba. { Item: gaba.MenuItem{Text: i18n.Localize(&goi18n.Message{ID: "settings_release_channel", Other: "Release Channel"}, nil)}, Options: []gaba.Option{ + {DisplayName: i18n.Localize(&goi18n.Message{ID: "release_match_romm", Other: "Match RomM"}, nil), Value: internal.ReleaseChannelMatchRomM}, {DisplayName: i18n.Localize(&goi18n.Message{ID: "release_stable", Other: "Stable"}, nil), Value: internal.ReleaseChannelStable}, {DisplayName: i18n.Localize(&goi18n.Message{ID: "release_beta", Other: "Beta"}, nil), Value: internal.ReleaseChannelBeta}, }, diff --git a/ui/settings.go b/ui/settings.go index 90775dc..cacabe7 100644 --- a/ui/settings.go +++ b/ui/settings.go @@ -258,10 +258,12 @@ func logLevelToIndex(level string) int { func releaseChannelToIndex(releaseChannel internal.ReleaseChannel) int { switch releaseChannel { - case internal.ReleaseChannelStable: + case internal.ReleaseChannelMatchRomM: return 0 - case internal.ReleaseChannelBeta: + case internal.ReleaseChannelStable: return 1 + case internal.ReleaseChannelBeta: + return 2 default: return 0 } diff --git a/ui/update.go b/ui/update.go index 58b1c07..84b10c3 100644 --- a/ui/update.go +++ b/ui/update.go @@ -6,6 +6,7 @@ import ( "grout/cfw" "grout/internal" "grout/internal/stringutil" + "grout/romm" "grout/update" gaba "github.com/BrandonKowalski/gabagool/v2/pkg/gabagool" @@ -18,6 +19,7 @@ import ( type UpdateInput struct { CFW cfw.CFW ReleaseChannel internal.ReleaseChannel + Host *romm.Host } type UpdateOutput struct { @@ -43,7 +45,7 @@ func (s *UpdateScreen) Draw(input UpdateInput) (ScreenResult[UpdateOutput], erro ShowThemeBackground: true, }, func() (interface{}, error) { - updateInfo, checkErr = update.CheckForUpdate(input.CFW, input.ReleaseChannel) + updateInfo, checkErr = update.CheckForUpdate(input.CFW, input.ReleaseChannel, input.Host) return nil, checkErr }, ) diff --git a/update/auto_update.go b/update/auto_update.go index 6c77ec1..beaed94 100644 --- a/update/auto_update.go +++ b/update/auto_update.go @@ -3,6 +3,7 @@ package update import ( "grout/cfw" "grout/internal" + "grout/romm" "sync/atomic" gaba "github.com/BrandonKowalski/gabagool/v2/pkg/gabagool" @@ -13,6 +14,7 @@ const updateIcon = "\U000F06B0" type AutoUpdate struct { cfwType cfw.CFW releaseChannel internal.ReleaseChannel + host *romm.Host icon *gaba.DynamicStatusBarIcon running atomic.Bool updateAvailable atomic.Bool @@ -20,10 +22,11 @@ type AutoUpdate struct { updateInfo *Info } -func NewAutoUpdate(c cfw.CFW, r internal.ReleaseChannel) *AutoUpdate { +func NewAutoUpdate(c cfw.CFW, r internal.ReleaseChannel, host *romm.Host) *AutoUpdate { return &AutoUpdate{ cfwType: c, releaseChannel: r, + host: host, icon: gaba.NewDynamicStatusBarIcon(""), // Start empty, will show icon if update available done: make(chan struct{}), } @@ -53,6 +56,21 @@ func (a *AutoUpdate) UpdateInfo() *Info { return a.updateInfo } +// Recheck updates the release channel and re-runs the update check. +// This should be called when the user changes the release channel in settings. +func (a *AutoUpdate) Recheck(releaseChannel internal.ReleaseChannel) { + if a.running.Load() { + return // Already running, skip + } + + a.releaseChannel = releaseChannel + a.updateAvailable.Store(false) + a.updateInfo = nil + a.icon.SetText("") // Clear the icon + + a.Start() +} + func (a *AutoUpdate) run() { logger := gaba.GetLogger() defer func() { @@ -62,7 +80,7 @@ func (a *AutoUpdate) run() { logger.Debug("AutoUpdate: Checking for updates in background") - info, err := CheckForUpdate(a.cfwType, a.releaseChannel) + info, err := CheckForUpdate(a.cfwType, a.releaseChannel, a.host) if err != nil { logger.Debug("AutoUpdate: Failed to check for updates", "error", err) return diff --git a/update/github.go b/update/github.go index 8a0b022..d807a78 100644 --- a/update/github.go +++ b/update/github.go @@ -95,3 +95,76 @@ func (r *GitHubRelease) FindAsset(name string) *GitHubAsset { } 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 95a14ab..d4df37d 100644 --- a/update/updater.go +++ b/update/updater.go @@ -5,12 +5,14 @@ import ( "grout/cfw" "grout/internal" "grout/internal/constants" + "grout/romm" "grout/version" "io" "net/http" "os" "path/filepath" + gaba "github.com/BrandonKowalski/gabagool/v2/pkg/gabagool" "go.uber.org/atomic" ) @@ -32,7 +34,10 @@ func GetAssetName(c cfw.CFW) string { } } -func CheckForUpdate(c cfw.CFW, releaseChannel internal.ReleaseChannel) (*Info, error) { +// 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. +func CheckForUpdate(c cfw.CFW, releaseChannel internal.ReleaseChannel, host *romm.Host) (*Info, error) { currentVersion := version.Get().Version if currentVersion == "dev" { @@ -42,9 +47,33 @@ func CheckForUpdate(c cfw.CFW, releaseChannel internal.ReleaseChannel) (*Info, e }, nil } - release, err := FetchLatestRelease(releaseChannel) - if err != nil { - return nil, fmt.Errorf("failed to check for updates: %w", err) + var release *GitHubRelease + var err error + + if releaseChannel == 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) + + // Find a Grout release matching the RomM version + release, err = FetchReleaseForRomMVersion(heartbeat.System.Version) + if err != nil { + return nil, fmt.Errorf("failed to find matching release: %w", err) + } + } else { + release, err = FetchLatestRelease(releaseChannel) + if err != nil { + return nil, fmt.Errorf("failed to check for updates: %w", err) + } } info := &Info{ diff --git a/update/version_test.go b/update/version_test.go index a18ac4c..0dfce70 100644 --- a/update/version_test.go +++ b/update/version_test.go @@ -58,7 +58,7 @@ func TestCompareVersions(t *testing.T) { {"2.0.0", "1.0.0", 1, "major version older"}, {"1.0.0", "1.0.0", 0, "same version"}, - // Beta vs full release - THE KEY FIX + // Beta vs full release {"v1.2.0-beta.1", "v1.2.0", -1, "beta should recognize full release as newer"}, {"v1.2.0", "v1.2.0-beta.1", 1, "full release should be newer than beta"}, {"1.2.0-beta.1", "1.2.0", -1, "beta should recognize full release as newer (no v prefix)"}, diff --git a/version/version.go b/version/version.go index 64e06f3..845a2ac 100644 --- a/version/version.go +++ b/version/version.go @@ -1,5 +1,7 @@ package version +import "os" + var ( Version = "dev" GitCommit = "unknown" @@ -13,8 +15,12 @@ type BuildInfo struct { } func Get() BuildInfo { + v := Version + if override := os.Getenv("GROUT_VERSION"); override != "" { + v = override + } return BuildInfo{ - Version: Version, + Version: v, GitCommit: GitCommit, BuildDate: BuildDate, }