Skip to content

Commit ba6c9e7

Browse files
feat(sync): auto-refresh device version on upgrade + first-time multi-slot picker
Device version: persist the client_version last reported per device (Host.DeviceClientVersion); on startup, if grout was upgraded since, PUT the new version to the server (RefreshDeviceVersion in connectAndLoadPlatforms). Registration stamps the version so the startup check only fires on a real upgrade. Multi-slot first-time pull: when a ROM has no local save and the server offers it in more than one slot, mapOperationsToItems / buildDiscoveryItems now populate AvailableSlots + AllRemoteSaves so the existing slot picker (resolveMultiSlotDownloads) prompts the user instead of silently picking. A ROM that already has a local save is still pinned to its managed slot (no picker).
1 parent 56ba1fe commit ba6c9e7

5 files changed

Lines changed: 152 additions & 4 deletions

File tree

app/setup.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"grout/internal/fileutil"
1919
"grout/resources"
2020
"grout/romm"
21+
"grout/sync"
2122
"grout/ui"
2223
"log"
2324
"log/slog"
@@ -313,6 +314,14 @@ func connectAndLoadPlatforms(config *internal.Config, logger *slog.Logger) []rom
313314
}
314315
}
315316

317+
// If grout was upgraded since this device last reported in, refresh the
318+
// client_version the server has on record (diagnostic/display only).
319+
if v, changed := sync.RefreshDeviceVersion(authClient, host.DeviceID, host.DeviceClientVersion); changed {
320+
host.DeviceClientVersion = v
321+
config.Hosts[0] = host
322+
internal.SaveConfig(config)
323+
}
324+
316325
// Load platforms
317326
if err := config.LoadPlatformsBinding(config.Hosts[0], config.ApiTimeout.Duration()); err != nil {
318327
logger.Debug("Failed to load platform bindings", "error", err)

romm/host.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ type Host struct {
2020

2121
DeviceID string `json:"device_id,omitempty"`
2222
DeviceName string `json:"device_name,omitempty"`
23+
// DeviceClientVersion is the grout version last reported to the server for this
24+
// device; used to refresh the server's record after an app upgrade.
25+
DeviceClientVersion string `json:"device_client_version,omitempty"`
2326
}
2427

2528
func (h Host) HasTokenAuth() bool {

sync/flow.go

Lines changed: 94 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -164,17 +164,75 @@ func buildDiscoveryItems(uncovered map[int]cfw.LocalRomFile, savesByRom map[int]
164164
"romID", romID, "romName", rom.RomName, "saveID", best.ID,
165165
"file", best.FileName, "fetched", len(saves))
166166

167-
items = append(items, SyncItem{
167+
item := SyncItem{
168168
LocalSave: ls,
169169
RemoteSave: best,
170170
TargetSlot: preferredSlot,
171171
Action: ActionDownload,
172-
})
172+
}
173+
// First-time multi-slot pull: offer the slot choice to the UI. Discovery only
174+
// runs for ROMs with no local save, so every multi-slot case is first-time.
175+
if slots := distinctSaveSlots(saves); len(slots) > 1 {
176+
item.AvailableSlots = slots
177+
item.AllRemoteSaves = saves
178+
}
179+
180+
items = append(items, item)
173181
}
174182

175183
return items
176184
}
177185

186+
// distinctSaveSlots returns the sorted distinct slot names across saves (nil/empty slot
187+
// counts as "autosave").
188+
func distinctSaveSlots(saves []romm.Save) []string {
189+
set := make(map[string]bool)
190+
for _, s := range saves {
191+
slot := "autosave"
192+
if s.Slot != nil && *s.Slot != "" {
193+
slot = *s.Slot
194+
}
195+
set[slot] = true
196+
}
197+
out := make([]string, 0, len(set))
198+
for s := range set {
199+
out = append(out, s)
200+
}
201+
sort.Strings(out)
202+
return out
203+
}
204+
205+
// distinctOpSlots returns the sorted distinct slot names across download ops (nil/empty
206+
// slot counts as "autosave").
207+
func distinctOpSlots(ops []romm.SyncOperationSchema) []string {
208+
set := make(map[string]bool)
209+
for _, op := range ops {
210+
slot := "autosave"
211+
if op.Slot != nil && *op.Slot != "" {
212+
slot = *op.Slot
213+
}
214+
set[slot] = true
215+
}
216+
out := make([]string, 0, len(set))
217+
for s := range set {
218+
out = append(out, s)
219+
}
220+
sort.Strings(out)
221+
return out
222+
}
223+
224+
// opStubsToSaves builds romm.Save stubs from download ops for slot re-selection by the
225+
// multi-slot picker.
226+
func opStubsToSaves(ops []romm.SyncOperationSchema) []romm.Save {
227+
saves := make([]romm.Save, 0, len(ops))
228+
for _, op := range ops {
229+
if stub := buildRemoteSaveStub(op); stub != nil {
230+
saves = append(saves, *stub)
231+
}
232+
}
233+
return saves
234+
}
235+
178236
// discoverRemoteOnlySaves finds locally-present ROMs that have no local save and were
179237
// not covered by a negotiate operation, fetches their server saves, and builds download
180238
// items for any save this device has never synced.
@@ -378,12 +436,25 @@ func mapOperationsToItems(
378436
if !ok {
379437
ls = resolveLocalSaveForDownload(op, resolvedRoms, cm)
380438
}
381-
items = append(items, SyncItem{
439+
item := SyncItem{
382440
LocalSave: ls,
383441
RemoteSave: buildRemoteSaveStub(op),
384442
TargetSlot: preferred,
385443
Action: ActionDownload,
386-
})
444+
}
445+
446+
// First-time multi-slot pull: if this ROM has no local save yet and the server
447+
// offers it in more than one slot, surface the choice to the UI instead of
448+
// silently picking. (A ROM that already has a local save was filtered to its
449+
// managed slot above, so it never reaches here multi-slot.)
450+
if _, hasLocal := localByRom[romID]; !hasLocal {
451+
if slots := distinctOpSlots(dops); len(slots) > 1 {
452+
item.AvailableSlots = slots
453+
item.AllRemoteSaves = opStubsToSaves(dops)
454+
}
455+
}
456+
457+
items = append(items, item)
387458
}
388459

389460
return items
@@ -526,6 +597,25 @@ func RegisterDevice(client *romm.Client, name string) (romm.Device, error) {
526597
return dev, nil
527598
}
528599

600+
// RefreshDeviceVersion updates the server's record of this device's client_version when
601+
// the running grout version differs from lastReported (i.e. the app was upgraded since
602+
// the version was last sent). Returns the version now reported and whether an update was
603+
// sent. Best-effort: a failure is logged and leaves lastReported unchanged.
604+
func RefreshDeviceVersion(client *romm.Client, deviceID, lastReported string) (string, bool) {
605+
current := version.Get().Version
606+
if deviceID == "" || current == "" || current == lastReported {
607+
return lastReported, false
608+
}
609+
if _, err := client.UpdateDevice(deviceID, romm.UpdateDeviceRequest{ClientVersion: current}); err != nil {
610+
gaba.GetLogger().Warn("Failed to refresh device client_version on upgrade",
611+
"deviceID", deviceID, "from", lastReported, "to", current, "error", err)
612+
return lastReported, false
613+
}
614+
gaba.GetLogger().Debug("Refreshed device client_version after upgrade",
615+
"deviceID", deviceID, "from", lastReported, "to", current)
616+
return current, true
617+
}
618+
529619
func ScanSaves(config *internal.Config) []LocalSave {
530620
logger := gaba.GetLogger()
531621
currentCFW := cfw.GetCFW()

sync/flow_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,48 @@ func TestMapOperationsToItems_AcceptsSameSlotDownloadWhenLocalSaveExists(t *test
282282
}
283283
}
284284

285+
func TestMapOperationsToItems_FirstTimeMultiSlotOffersChoice(t *testing.T) {
286+
// rom 303 is installed but has no local save; the server offers two slots. The item
287+
// should carry AvailableSlots + AllRemoteSaves so the UI can prompt for a choice.
288+
resolved := map[int]cfw.LocalRomFile{
289+
303: {RomID: 303, RomName: "Pokemon", FSSlug: "gba", FileName: "Pokemon.gba"},
290+
}
291+
now := time.Now()
292+
ops := []romm.SyncOperationSchema{
293+
{Action: "download", RomID: 303, SaveID: ptrInt(235), FileName: "P [a].srm", Slot: ptrStr("autosave"), ServerUpdatedAt: ptrTime(now)},
294+
{Action: "download", RomID: 303, SaveID: ptrInt(228), FileName: "P [q].srm", Slot: ptrStr("quicksave"), ServerUpdatedAt: ptrTime(now)},
295+
}
296+
297+
items := mapOperationsToItems(ops, nil, resolved, nil, nil, nil)
298+
299+
if len(items) != 1 {
300+
t.Fatalf("expected 1 item, got %d", len(items))
301+
}
302+
it := items[0]
303+
if len(it.AvailableSlots) != 2 || it.AvailableSlots[0] != "autosave" || it.AvailableSlots[1] != "quicksave" {
304+
t.Errorf("AvailableSlots = %v, want [autosave quicksave]", it.AvailableSlots)
305+
}
306+
if len(it.AllRemoteSaves) != 2 {
307+
t.Errorf("AllRemoteSaves = %d, want 2", len(it.AllRemoteSaves))
308+
}
309+
}
310+
311+
func TestMapOperationsToItems_LocalSaveDoesNotOfferMultiSlot(t *testing.T) {
312+
// ROM already has a local save in "autosave"; the "quicksave" download is skipped by
313+
// the managed-slot gate, so no picker is offered.
314+
local := []LocalSave{{RomID: 303, FileName: "Pokemon.srm", FilePath: "/x/Pokemon.srm", FSSlug: "gba"}}
315+
recorded := map[saveKey]string{{romID: 303, fileName: "Pokemon.srm"}: "autosave"}
316+
now := time.Now()
317+
ops := []romm.SyncOperationSchema{
318+
{Action: "download", RomID: 303, SaveID: ptrInt(228), FileName: "P [q].srm", Slot: ptrStr("quicksave"), ServerUpdatedAt: ptrTime(now)},
319+
}
320+
321+
items := mapOperationsToItems(ops, local, nil, nil, nil, recorded)
322+
if len(items) != 0 {
323+
t.Fatalf("expected other-slot download skipped (no picker), got %d items", len(items))
324+
}
325+
}
326+
285327
// --- buildClientSaveStates tests ---
286328

287329
func TestBuildClientSaveStates_FileSlotEmulatorHash(t *testing.T) {

ui/device_registration.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"grout/internal"
77
"grout/romm"
88
"grout/sync"
9+
"grout/version"
910
"os"
1011

1112
gaba "github.com/BrandonKowalski/gabagool/v2/pkg/gabagool"
@@ -233,5 +234,8 @@ func (s *SaveSyncSettingsScreen) registerDevice(output SaveSyncSettingsOutput) (
233234

234235
output.Host.DeviceID = device.ID
235236
output.Host.DeviceName = deviceName
237+
// RegisterDevice already pushed the current client_version; record it so the startup
238+
// refresh only fires on a later upgrade.
239+
output.Host.DeviceClientVersion = version.Get().Version
236240
return output, nil
237241
}

0 commit comments

Comments
 (0)