From 57639ee43042cbbec77ff10d5a788c8aebe1eca8 Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:44:43 +0200 Subject: [PATCH 1/3] snapshots: never of a save a sync is writing, never incomplete A pull of a save of many files overflowed the watcher's event queue again and again, and each overflow was taken for the game saving: an automatic snapshot of a half-pulled save every few seconds - 76 on one device for one pull - which pushed the complete ones out of retention. - The watcher asks whether a sync is writing the game (BeingWritten). If so, an overflow is taken for the sync's, and the burst is looked at only once the sync has finished. - A snapshot that could not read every file is discarded, not kept: it would stand in the history as a save that never existed. The watcher leaves the change unrecorded and takes it again at its next pass; a sync that needed the copy before replacing files does not replace them. - OpenSave's own temporary files (*.opensave.tmp) are never archived. Builds on the own-writes change (Liquid-co/OpenSave#23). Co-Authored-By: Claude Opus 5.5 --- internal/daemon/daemon.go | 6 ++++++ internal/p2p/syncengine/engine.go | 2 ++ internal/p2p/syncengine/settle.go | 34 +++++++++++++++++++++++++++++ internal/snapshot/snapshot.go | 22 ++++++++++++++++--- internal/snapshot/zip.go | 5 +++++ internal/snapshot/zip_roots.go | 4 ++++ internal/watcher/owntouch_test.go | 30 ++++++++++++++++++++++++++ internal/watcher/watcher.go | 36 ++++++++++++++++++++++++++++++- 8 files changed, 135 insertions(+), 4 deletions(-) diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index bb2b4b20..5f757fc4 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -243,6 +243,12 @@ func New(opts Options) (*Daemon, error) { _, err := snaps.Create(gameID, "", true) return err }, + SyncWriting: func(gameID string) bool { + return d.P2P != nil && d.P2P.Sync != nil && d.P2P.Sync.BeingWritten(gameID) + }, + SyncWritingNow: func(gameID string) bool { + return d.P2P != nil && d.P2P.Sync != nil && d.P2P.Sync.WritingNow(gameID) + }, OnChanged: func(gameID string) { // An emptied save is noticed as it happens, and said on screen, // even with no other device online to hold it back from. diff --git a/internal/p2p/syncengine/engine.go b/internal/p2p/syncengine/engine.go index 5b8d8d10..e5e80bc0 100644 --- a/internal/p2p/syncengine/engine.go +++ b/internal/p2p/syncengine/engine.go @@ -127,6 +127,8 @@ type Engine struct { // on this device right now (settle.go). settleMu sync.Mutex gates map[string]*saveGate + // writtenAt is when a sync last stopped writing each game (BeingWritten). + writtenAt map[string]time.Time // servedMu guards served: the save states this device recently handed // each peer, per game (served.go). servedMu sync.Mutex diff --git a/internal/p2p/syncengine/settle.go b/internal/p2p/syncengine/settle.go index 34942e10..a66c1c8d 100644 --- a/internal/p2p/syncengine/settle.go +++ b/internal/p2p/syncengine/settle.go @@ -131,10 +131,44 @@ func (e *Engine) Writing(gameID string) (done func()) { once = true g := e.gateLocked(gameID) g.writers-- + if e.writtenAt == nil { + e.writtenAt = map[string]time.Time{} + } + e.writtenAt[gameID] = time.Now() e.wakeLocked(gameID, g) } } +// writeEcho is how long after a sync stops writing a game its file events may +// still be arriving. +const writeEcho = 10 * time.Second + +// BeingWritten reports whether a sync on this device is writing a game's save +// now, or was a moment ago — what the watcher asks before taking a burst it +// could not attribute (an overflow of file events) for a change of the game's. +// A pull of a save of many files overflows the event queue again and again, +// and each one read as the game saving: an automatic snapshot of a half-pulled +// save every few seconds, which pushed the real ones out of retention. +func (e *Engine) BeingWritten(gameID string) bool { + if e.WritingNow(gameID) { + return true + } + e.settleMu.Lock() + defer e.settleMu.Unlock() + return time.Since(e.writtenAt[gameID]) < writeEcho +} + +// WritingNow reports whether a sync on this device is writing a game's save at +// this moment — what the watcher waits out before looking at a burst, so it +// never judges a save half-way between two states. Not the moments after: +// a change the game makes then is the game's, and its snapshot is not held up. +func (e *Engine) WritingNow(gameID string) bool { + e.settleMu.Lock() + defer e.settleMu.Unlock() + g := e.gates[gameID] + return g != nil && (g.writers > 0 || g.waitingWriters > 0) +} + // Reading holds the game's save still for a sync to read it: it waits until // nothing is writing it, then keeps writers out until done is called. It // returns ErrSettling if ctx ends first. Hold it only for the reading itself — diff --git a/internal/snapshot/snapshot.go b/internal/snapshot/snapshot.go index 5d1f86e5..2c4660ed 100644 --- a/internal/snapshot/snapshot.go +++ b/internal/snapshot/snapshot.go @@ -41,6 +41,18 @@ import ( // before starting one. type UploadHook func(zipPath, remoteFileName string) +// IncompleteError is a snapshot discarded because files of the save could not +// be read — in use by the game, or changing while it was archived. +type IncompleteError struct { + Game string + Skipped []string +} + +func (e *IncompleteError) Error() string { + return fmt.Sprintf("snapshot of %q discarded: %d file(s) could not be read (e.g. %s) — taken again at the next chance", + e.Game, len(e.Skipped), e.Skipped[0]) +} + // Manager performs snapshot/branch operations against the store and // filesystem. type Manager struct { @@ -253,9 +265,13 @@ func (m *Manager) createOnBranchFrom(gameID, branch, from, comment string, isSys return store.Snapshot{}, fmt.Errorf("zip save data: %w", err) } defer os.Remove(stagingPath) // no-op once renamed - if len(skipped) > 0 && m.Log != nil { - sample := skipped[0] - m.Log("warn", fmt.Sprintf("snapshot of %q skipped %d unreadable file(s), e.g. %s", game.Name, len(skipped), sample)) + // A snapshot missing files is not kept: it would stand in the history as + // a save that never existed — and, counted against retention, push out a + // complete one. The caller tries again at the next chance (the watcher + // leaves the change unrecorded, so its next pass takes it again; a sync + // that needed the copy before replacing files does not replace them). + if len(skipped) > 0 { + return store.Snapshot{}, &IncompleteError{Game: game.Name, Skipped: skipped} } if comment == "" { diff --git a/internal/snapshot/zip.go b/internal/snapshot/zip.go index f2d81dde..3bac4cb6 100644 --- a/internal/snapshot/zip.go +++ b/internal/snapshot/zip.go @@ -95,6 +95,11 @@ func zipPathCapturing(sourcePath, outPath string, reuse *reuseSource) (skipped [ } return nil } + // OpenSave's own temporary file for a file being pulled: never part + // of the save, and gone by the time it is read. + if strings.HasSuffix(rel, delta.TmpSuffix) { + return nil + } info, infoErr := d.Info() if infoErr != nil { skipped = append(skipped, path) diff --git a/internal/snapshot/zip_roots.go b/internal/snapshot/zip_roots.go index a83a8e0a..97de7e42 100644 --- a/internal/snapshot/zip_roots.go +++ b/internal/snapshot/zip_roots.go @@ -144,6 +144,10 @@ func archiveInto(w *zip.Writer, sourcePath, prefix, root string, reuse *reuseSou _, err := w.CreateHeader(&zip.FileHeader{Name: prefix + rel + "/", Method: zip.Store}) return err } + // OpenSave's own temporary file for a file being pulled (zip.go). + if strings.HasSuffix(rel, delta.TmpSuffix) { + return nil + } info, infoErr := d.Info() if infoErr != nil { skipped = append(skipped, path) diff --git a/internal/watcher/owntouch_test.go b/internal/watcher/owntouch_test.go index 6a1e8134..d984bc29 100644 --- a/internal/watcher/owntouch_test.go +++ b/internal/watcher/owntouch_test.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "sync/atomic" "testing" "time" @@ -103,6 +104,35 @@ func TestOwnNewFolders_AreNotSnapshotted(t *testing.T) { } } +// While a sync is writing the game, the watcher looks at what changed only +// once it has finished — never at a save half-way between two states — and a +// change of the game's is still snapshotted then. +func TestWhileASyncWrites_TheWatcherWaits(t *testing.T) { + saveDir := t.TempDir() + col := newCollector() + var writing atomic.Bool + cb := col.callbacks() + cb.SyncWriting = func(string) bool { return writing.Load() } + cb.SyncWritingNow = cb.SyncWriting + eng := New(cb) + defer eng.Stop() + if err := eng.Watch("game1", saveDir); err != nil { + t.Fatal(err) + } + writing.Store(true) + if err := os.WriteFile(filepath.Join(saveDir, "slot1.sav"), []byte("played"), 0o666); err != nil { + t.Fatal(err) + } + time.Sleep(5 * time.Second) + if n := col.snapshotCount(); n != 0 { + t.Fatalf("%d snapshot(s) while a sync was writing the game", n) + } + writing.Store(false) + if !waitFor(t, 10*time.Second, func() bool { return col.snapshotCount() >= 1 }) { + t.Fatal("the game's change was not snapshotted once the sync had finished") + } +} + // A burst that mixes OpenSave's own changes with one from the game is a real // change and is snapshotted. func TestMixedChanges_AreSnapshotted(t *testing.T) { diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 2128ba2f..a68c068b 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -119,6 +119,25 @@ type Callbacks struct { OnChanged func(gameID string) // Log receives human-readable watcher activity. May be nil. Log func(level, msg string) + // SyncWriting reports whether a sync on this device is writing the game + // now, or was a moment ago: a burst the watcher cannot attribute — an + // overflow of file events — is then taken for the sync's. May be nil. + SyncWriting func(gameID string) bool + // SyncWritingNow reports whether a sync is writing the game at this + // moment: the watcher looks at the burst once it has finished. May be nil. + SyncWritingNow func(gameID string) bool +} + +// syncWriting reports whether a sync is or was just writing a game +// (Callbacks.SyncWriting). +func (e *Engine) syncWriting(gameID string) bool { + return e.cb.SyncWriting != nil && e.cb.SyncWriting(gameID) +} + +// syncWritingNow reports whether a sync is writing a game at this moment +// (Callbacks.SyncWritingNow). +func (e *Engine) syncWritingNow(gameID string) bool { + return e.cb.SyncWritingNow != nil && e.cb.SyncWritingNow(gameID) } // Engine owns one watch goroutine per tracked game. @@ -771,7 +790,16 @@ func (e *Engine) run(ctx context.Context, gw *gameWatch) { // directory and allocates no second buffer for one it already // has. gw.rewatch.Store(true) - gw.otherEvents = true // the missed events could be anyone's + // The missed events could be anyone's — unless a sync is + // writing this game, which is what overflows the queue: a + // pull of a save of many files does, again and again, and + // each was taken for the game saving, an automatic snapshot + // of a half-pulled save every few seconds. + if e.syncWriting(gw.gameID) { + gw.ownEvents = true + } else { + gw.otherEvents = true + } } else { e.log("warn", fmt.Sprintf("watching %q: %v", gw.gameID, err)) continue @@ -781,6 +809,12 @@ func (e *Engine) run(ctx context.Context, gw *gameWatch) { case <-debounceC: debounce = nil debounceC = nil + // A sync still writing this game: look at the burst once it has + // finished, not at a save half-way between two states. + if e.syncWritingNow(gw.gameID) { + resetDebounce() + continue + } // Re-register, so a folder that went unwatched is watched from // here on, and read the tree below, so it is found now. Doing only // one of those leaves it correct today and silent tomorrow. From 4dfe3f24e457b30edcb9685e03a5f071b2243786 Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:16:44 +0200 Subject: [PATCH 2/3] snapshots: only automatic ones are discarded when incomplete; said on screen Asked in review: discarding every snapshot that could not read a file also discarded the copy kept before a sync replaces the save, and the sync then did not replace anything, quietly, for as long as a game held a file open (a log, a lock file). - Only the watcher's automatic snapshot (snapshot.CreateAutomatic) is discarded when it misses files; the watcher takes the change again at its next pass. Every other snapshot, the copy before a sync, restore or conflict above all, and one taken by hand, is kept with what could be read and a warning in the log, as before #54. - When a game's automatic snapshots come out incomplete three passes in a row, it is said on screen once, until one succeeds again: the files the game holds open, and that its changes are not backed up or synced until they can be read, usually once the game is closed. The text is in the catalogs (en, zh-CN), with a "common.open" key for the toast's button. Tests: an incomplete automatic snapshot is discarded, while one before replacing and one by hand are kept (a seam on the archive's file open makes a file unreadable on any system); the notice comes once per run of incomplete passes, again after a good snapshot, and never for other failures; the notice's wording, with the files beyond the first three counted. Co-Authored-By: Claude Opus 5.5 --- .../frontend/src/lib/notifications.js | 10 +++ .../frontend/src/lib/notifications.test.js | 21 +++++- cmd/opensave-app/frontend/src/lib/stores.js | 9 ++- cmd/opensave-app/frontend/src/locales/en.json | 6 ++ .../frontend/src/locales/zh-CN.json | 5 +- internal/api/server.go | 3 + internal/daemon/autosnapshot.go | 70 +++++++++++++++++++ internal/daemon/autosnapshot_test.go | 45 ++++++++++++ internal/daemon/daemon.go | 11 ++- internal/snapshot/incomplete_test.go | 69 ++++++++++++++++++ internal/snapshot/snapshot.go | 47 +++++++++---- internal/snapshot/zip.go | 7 +- internal/watcher/watcher.go | 7 ++ 13 files changed, 293 insertions(+), 17 deletions(-) create mode 100644 internal/daemon/autosnapshot.go create mode 100644 internal/daemon/autosnapshot_test.go create mode 100644 internal/snapshot/incomplete_test.go diff --git a/cmd/opensave-app/frontend/src/lib/notifications.js b/cmd/opensave-app/frontend/src/lib/notifications.js index 8bb495a1..d476afbf 100644 --- a/cmd/opensave-app/frontend/src/lib/notifications.js +++ b/cmd/opensave-app/frontend/src/lib/notifications.js @@ -142,6 +142,16 @@ export function arrivalNote(ev, games) { return { title: game.name, body: `Got ${plural(ev.files || 1, 'file')} from ${ev.device}`, game }; } +/** A game whose automatic snapshots keep missing files it holds open + * (daemon/autosnapshot.go), in the words of the catalog: `t` is the + * translate function, `$t` in a component. */ +export function incompleteSnapshotsMessage(data, t) { + const names = data?.files ?? []; + const more = Math.max((data?.count ?? names.length) - names.length, 0); + const files = names.join(', ') + (more ? t('snapshots.incomplete.more', { n: more }) : ''); + return t('snapshots.incomplete', { name: data?.gameName ?? '', files }); +} + /** New games found, as the desktop shows them. */ export function newGamesNote(found) { if (!found?.length) return null; diff --git a/cmd/opensave-app/frontend/src/lib/notifications.test.js b/cmd/opensave-app/frontend/src/lib/notifications.test.js index 27487862..61acda8c 100644 --- a/cmd/opensave-app/frontend/src/lib/notifications.test.js +++ b/cmd/opensave-app/frontend/src/lib/notifications.test.js @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { arrivalMessage, badgeCount, happened, loadSeenAt, saveSeenAt, waitingOnYou, ARRIVAL_QUIET_MS } from './notifications.js'; +import { arrivalMessage, badgeCount, happened, incompleteSnapshotsMessage, loadSeenAt, saveSeenAt, waitingOnYou, ARRIVAL_QUIET_MS } from './notifications.js'; +import { translate } from './i18n.js'; +import en from '../locales/en.json'; const games = { hades: { id: 'hades', name: 'Hades', branches: { main: { snapshots: [{ id: 's1', problem: 'damaged' }] } } }, @@ -71,3 +73,20 @@ describe('a missing save folder found on another drive', () => { expect(r?.go).toEqual({ view: 'game', params: { gameId: 'ark' } }); }); }); + +describe('incompleteSnapshotsMessage', () => { + const t = (key, params) => translate(en, key, params, new Intl.PluralRules('en')); + + it('names the game and the files it holds open', () => { + expect(incompleteSnapshotsMessage({ gameName: 'Hades', files: ['save.lock'], count: 1 }, t)).toBe( + '"Hades": automatic snapshots keep missing save.lock, which the game holds open. Until those can be read, usually once the game is closed, its changes are not backed up or synced.' + ); + }); + + it('says how many more there are than it names', () => { + const one = incompleteSnapshotsMessage({ gameName: 'Hades', files: ['a', 'b', 'c'], count: 4 }, t); + expect(one).toContain('missing a, b, c and one more file, which'); + const more = incompleteSnapshotsMessage({ gameName: 'Hades', files: ['a', 'b', 'c'], count: 9 }, t); + expect(more).toContain('missing a, b, c and 6 more files, which'); + }); +}); diff --git a/cmd/opensave-app/frontend/src/lib/stores.js b/cmd/opensave-app/frontend/src/lib/stores.js index 5343c4c1..00c319e4 100644 --- a/cmd/opensave-app/frontend/src/lib/stores.js +++ b/cmd/opensave-app/frontend/src/lib/stores.js @@ -1,8 +1,9 @@ // Central app state, fed by the daemon's init dump + live WS updates. import { writable, derived, get } from 'svelte/store'; import { notifyPrefs } from './notifyprefs.js'; -import { arrivalMessage, arrivalNote, newGamesNote } from './notifications.js'; +import { arrivalMessage, arrivalNote, incompleteSnapshotsMessage, newGamesNote } from './notifications.js'; import { toDesktop } from './notify.js'; +import { t } from './i18n.js'; // When each game's arrival was last said (lib/notifications.js). const arrivalShown = {}; @@ -186,6 +187,12 @@ export function applyMessage(msg) { case 'cloud-offers': cloudOffers.set(data ?? []); break; + case 'snapshots-incomplete': + // Sent once per run of discarded automatic snapshots, so no rate limit. + toast(incompleteSnapshotsMessage(data, get(t)), 'error', { + action: { label: get(t)('common.open'), run: () => navigate('game', { gameId: data.gameId }) } + }); + break; case 'cloud-pulled': // Taken without asking, because it carried on from the save this // device had and this device had not changed since. Said out loud all diff --git a/cmd/opensave-app/frontend/src/locales/en.json b/cmd/opensave-app/frontend/src/locales/en.json index e699ecf9..5284c023 100644 --- a/cmd/opensave-app/frontend/src/locales/en.json +++ b/cmd/opensave-app/frontend/src/locales/en.json @@ -2,6 +2,7 @@ "settings.language": "Language", "settings.systemDefault": "System default", "common.cancel": "Cancel", + "common.open": "Open", "common.nowTracking": "Now tracking \"{name}\"", "home.addGame.adding": "Adding…", "home.addGame.changeFolder": "Change folder…", @@ -222,5 +223,10 @@ "restoreProgress.aboutMinutes": { "one": "about a minute", "other": "about {n} minutes" + }, + "snapshots.incomplete": "\"{name}\": automatic snapshots keep missing {files}, which the game holds open. Until those can be read, usually once the game is closed, its changes are not backed up or synced.", + "snapshots.incomplete.more": { + "one": " and one more file", + "other": " and {n} more files" } } diff --git a/cmd/opensave-app/frontend/src/locales/zh-CN.json b/cmd/opensave-app/frontend/src/locales/zh-CN.json index f35d728e..7d1fad2b 100644 --- a/cmd/opensave-app/frontend/src/locales/zh-CN.json +++ b/cmd/opensave-app/frontend/src/locales/zh-CN.json @@ -2,6 +2,7 @@ "settings.language": "语言", "settings.systemDefault": "跟随系统", "common.cancel": "取消", + "common.open": "打开", "common.nowTracking": "已开始跟踪“{name}”", "home.addGame.adding": "添加中…", "home.addGame.changeFolder": "更换文件夹…", @@ -198,5 +199,7 @@ "restoreProgress.line": "{phase} — {percent}%({done}/{total})", "restoreProgress.lineWithTimeLeft": "{phase} — {percent}%({done}/{total}),还剩{left}", "restoreProgress.underAMinute": "不到一分钟", - "restoreProgress.aboutMinutes": "约 {n} 分钟" + "restoreProgress.aboutMinutes": "约 {n} 分钟", + "snapshots.incomplete": "“{name}”:自动快照一直缺少游戏正在占用的 {files}。在这些文件可以读取之前(通常是游戏关闭后),它的更改不会被备份或同步。", + "snapshots.incomplete.more": " 等另外 {n} 个文件" } diff --git a/internal/api/server.go b/internal/api/server.go index 645cc12c..5bf129e1 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -70,6 +70,9 @@ func New(d *daemon.Daemon) *Server { // Newly installed games the background scan found. d.OnNewGames = func(games []daemon.NewGame) { s.Hub.Broadcast("new-games", games) } + // A game whose automatic snapshots keep missing files it holds open. + d.OnSnapshotsIncomplete = func(p daemon.SnapshotsIncomplete) { s.Hub.Broadcast("snapshots-incomplete", p) } + // Syncing paused or resumed — from here, the CLI, the tray, or a timer. d.P2P.Pause.OnChange(func(st syncpause.Status) { s.Hub.Broadcast("sync-pause", st) }) return s diff --git a/internal/daemon/autosnapshot.go b/internal/daemon/autosnapshot.go new file mode 100644 index 00000000..54906551 --- /dev/null +++ b/internal/daemon/autosnapshot.go @@ -0,0 +1,70 @@ +package daemon + +import ( + "errors" + "path/filepath" + "sync" + + "github.com/opensave/opensave/internal/snapshot" +) + +// An automatic snapshot that could not read every file of the save is +// discarded (snapshot.CreateAutomatic), and the watcher takes the change again +// at its next pass. That is right once or twice: a file was being written. +// A game that holds a file open the whole time it runs — a log, a lock file — +// has every one discarded until it closes, and with them the sync each would +// have started. Said only in the log, that looked like a save neither backed +// up nor synced for no reason; so after a few passes in a row it is said on +// screen, once, until a snapshot of the game succeeds again. + +// incompleteAutoSnapshotsToSay is how many passes in a row must end with the +// snapshot discarded before it is said on screen. +const incompleteAutoSnapshotsToSay = 3 + +// SnapshotsIncomplete is what is said: the game, and some of the files that +// could not be read. +type SnapshotsIncomplete struct { + GameID string `json:"gameId"` + GameName string `json:"gameName"` + Files []string `json:"files"` // names only, at most three + Count int `json:"count"` // how many could not be read +} + +type incompleteAutoSnapshots struct { + mu sync.Mutex + inARow map[string]int +} + +// autoSnapshotTaken ends a game's run of discarded automatic snapshots. +func (d *Daemon) autoSnapshotTaken(gameID string) { + d.incomplete.mu.Lock() + delete(d.incomplete.inARow, gameID) + d.incomplete.mu.Unlock() +} + +// autoSnapshotGaveUp is the watcher's pass ending without a snapshot. Only an +// incomplete one counts: any other failure is the log's to tell. +func (d *Daemon) autoSnapshotGaveUp(gameID string, err error) { + var inc *snapshot.IncompleteError + if !errors.As(err, &inc) { + return + } + d.incomplete.mu.Lock() + if d.incomplete.inARow == nil { + d.incomplete.inARow = map[string]int{} + } + d.incomplete.inARow[gameID]++ + n := d.incomplete.inARow[gameID] + d.incomplete.mu.Unlock() + if n != incompleteAutoSnapshotsToSay || d.OnSnapshotsIncomplete == nil { + return + } + said := SnapshotsIncomplete{GameID: gameID, GameName: inc.Game, Count: len(inc.Skipped)} + for _, p := range inc.Skipped { + if len(said.Files) == 3 { + break + } + said.Files = append(said.Files, filepath.Base(p)) + } + d.OnSnapshotsIncomplete(said) +} diff --git a/internal/daemon/autosnapshot_test.go b/internal/daemon/autosnapshot_test.go new file mode 100644 index 00000000..3c554865 --- /dev/null +++ b/internal/daemon/autosnapshot_test.go @@ -0,0 +1,45 @@ +package daemon + +import ( + "errors" + "testing" + + "github.com/opensave/opensave/internal/snapshot" +) + +// A game whose automatic snapshots keep coming out incomplete is said on +// screen once per run of them; a snapshot that succeeds ends the run, and +// failures of any other kind are left to the log. +func TestIncompleteAutoSnapshotsAreSaidOncePerRun(t *testing.T) { + var said []SnapshotsIncomplete + d := &Daemon{OnSnapshotsIncomplete: func(s SnapshotsIncomplete) { said = append(said, s) }} + incomplete := &snapshot.IncompleteError{Game: "Hades", Skipped: []string{"/s/a.log", "/s/b.lock", "/s/c", "/s/d"}} + + for i := 0; i < incompleteAutoSnapshotsToSay-1; i++ { + d.autoSnapshotGaveUp("hades", incomplete) + } + d.autoSnapshotGaveUp("hades", errors.New("disk full")) + if len(said) != 0 { + t.Fatalf("said after %d incomplete passes: %v", incompleteAutoSnapshotsToSay-1, said) + } + d.autoSnapshotGaveUp("hades", incomplete) + if len(said) != 1 { + t.Fatalf("said %d time(s) after %d incomplete passes, want once", len(said), incompleteAutoSnapshotsToSay) + } + got := said[0] + if got.GameID != "hades" || got.GameName != "Hades" || got.Count != 4 || len(got.Files) != 3 || got.Files[0] != "a.log" { + t.Errorf("said %+v", got) + } + d.autoSnapshotGaveUp("hades", incomplete) + if len(said) != 1 { + t.Errorf("said again within the same run") + } + + d.autoSnapshotTaken("hades") + for i := 0; i < incompleteAutoSnapshotsToSay; i++ { + d.autoSnapshotGaveUp("hades", incomplete) + } + if len(said) != 2 { + t.Errorf("a new run after a good snapshot was said %d time(s), want once more", len(said)-1) + } +} diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 5f757fc4..c14e4f0f 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -90,6 +90,11 @@ type Daemon struct { // newgames.go. OnNewGames func([]NewGame) newGames newGameState + + // OnSnapshotsIncomplete is told when a game's automatic snapshots keep + // coming out incomplete, once per run of them. See autosnapshot.go. + OnSnapshotsIncomplete func(SnapshotsIncomplete) + incomplete incompleteAutoSnapshots // scanMu runs one save scan at a time. See ScanForSaves. scanMu sync.Mutex @@ -240,9 +245,13 @@ func New(opts Options) (*Daemon, error) { }, SetLastManifestHash: s.SetLastManifestHash, CreateSnapshot: func(gameID string) error { - _, err := snaps.Create(gameID, "", true) + _, err := snaps.CreateAutomatic(gameID) + if err == nil { + d.autoSnapshotTaken(gameID) + } return err }, + SnapshotGaveUp: d.autoSnapshotGaveUp, SyncWriting: func(gameID string) bool { return d.P2P != nil && d.P2P.Sync != nil && d.P2P.Sync.BeingWritten(gameID) }, diff --git a/internal/snapshot/incomplete_test.go b/internal/snapshot/incomplete_test.go new file mode 100644 index 00000000..a9e15053 --- /dev/null +++ b/internal/snapshot/incomplete_test.go @@ -0,0 +1,69 @@ +package snapshot + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +// holdOpen has the named file of the save fail to open while archiving, as a +// file the game holds open does. +func holdOpen(t *testing.T, name string) { + t.Helper() + open := openForArchive + openForArchive = func(p string) (*os.File, error) { + if filepath.Base(p) == name { + return nil, errors.New("the process cannot access the file because it is being used by another process") + } + return open(p) + } + t.Cleanup(func() { openForArchive = open }) +} + +// An automatic snapshot that could not read every file is discarded: it would +// stand in the history as a save that never existed. +func TestCreateAutomatic_DiscardsAnIncompleteSnapshot(t *testing.T) { + env := setup(t) + writeSave(t, env.saveDir, "slot1.sav", "progress") + writeSave(t, env.saveDir, "game.log", "running") + holdOpen(t, "game.log") + + _, err := env.mgr.CreateAutomatic("game1") + var inc *IncompleteError + if !errors.As(err, &inc) { + t.Fatalf("CreateAutomatic = %v, want an IncompleteError", err) + } + if len(inc.Skipped) != 1 || filepath.Base(inc.Skipped[0]) != "game.log" { + t.Errorf("skipped %v, want game.log", inc.Skipped) + } + if snaps, _ := env.store.ListSnapshots("game1", "main"); len(snaps) != 0 { + t.Errorf("%d snapshot(s) kept, want none", len(snaps)) + } +} + +// Every other snapshot is kept with what could be read, as before: above all +// the copy kept before a sync replaces the save. Refusing it would stop the +// sync replacing files for as long as the game holds one open. +func TestOtherSnapshotsAreKeptWhenAFileCannotBeRead(t *testing.T) { + for name, create := range map[string]func(m *Manager) error{ + "before replacing": func(m *Manager) error { + _, err := m.CreateBeforeReplacing("game1", "before sync replaced local files") + return err + }, + "by hand": func(m *Manager) error { _, err := m.Create("game1", "mine", false); return err }, + } { + t.Run(name, func(t *testing.T) { + env := setup(t) + writeSave(t, env.saveDir, "slot1.sav", "progress") + writeSave(t, env.saveDir, "game.log", "running") + holdOpen(t, "game.log") + if err := create(env.mgr); err != nil { + t.Fatalf("snapshot failed: %v", err) + } + if snaps, _ := env.store.ListSnapshots("game1", "main"); len(snaps) != 1 { + t.Errorf("%d snapshot(s) kept, want 1", len(snaps)) + } + }) + } +} diff --git a/internal/snapshot/snapshot.go b/internal/snapshot/snapshot.go index 2c4660ed..ec5047e2 100644 --- a/internal/snapshot/snapshot.go +++ b/internal/snapshot/snapshot.go @@ -41,8 +41,9 @@ import ( // before starting one. type UploadHook func(zipPath, remoteFileName string) -// IncompleteError is a snapshot discarded because files of the save could not -// be read — in use by the game, or changing while it was archived. +// IncompleteError is an automatic snapshot discarded because files of the +// save could not be read — in use by the game, or changing while it was +// archived (CreateAutomatic). type IncompleteError struct { Game string Skipped []string @@ -157,6 +158,25 @@ func (m *Manager) Create(gameID, comment string, isSystemAuto bool) (store.Snaps return m.createOnBranch(gameID, game.ActiveBranch, comment, isSystemAuto, true) } +// CreateAutomatic is the snapshot the watcher takes when a save has changed. +// +// It is the one snapshot not kept when it could not read every file of the +// save (IncompleteError): it would stand in the history as a save that never +// existed and, counted against retention, push out a complete one. Nothing is +// lost by not keeping it: the watcher leaves the change unrecorded and takes +// it again at its next pass. Every other snapshot is kept as it was, missing +// files and all, with a warning: one taken by hand, and above all the copy +// kept before a sync, a restore or a conflict replaces the save — refusing +// that would stop the replacing, quietly, for as long as a game holds a file +// open. +func (m *Manager) CreateAutomatic(gameID string) (store.Snapshot, error) { + game, err := m.Store.GetGame(gameID) + if err != nil { + return store.Snapshot{}, err + } + return m.createOnBranchFrom(gameID, game.ActiveBranch, "", "", true, true, true) +} + // CreateBeforeReplacing keeps a copy of a save that is about to be replaced — // by a restore, a sync, the other side of a conflict, a branch switch. // @@ -181,7 +201,7 @@ func (m *Manager) CreateBeforeReplacing(gameID, comment string) (store.Snapshot, // current reports whether the snapshot is of the save as it now stands and // will go on standing, as opposed to a copy kept before replacing it. func (m *Manager) createOnBranch(gameID, branch, comment string, isSystemAuto, current bool) (store.Snapshot, error) { - return m.createOnBranchFrom(gameID, branch, "", comment, isSystemAuto, current) + return m.createOnBranchFrom(gameID, branch, "", comment, isSystemAuto, current, false) } // CreateOnBranchFrom snapshots a folder that is not the game's save onto one @@ -197,12 +217,13 @@ func (m *Manager) CreateOnBranchFrom(gameID, branch, dir, comment string) (store if dir == "" { return store.Snapshot{}, errors.New("no folder to snapshot") } - return m.createOnBranchFrom(gameID, branch, dir, comment, true, false) + return m.createOnBranchFrom(gameID, branch, dir, comment, true, false, false) } // createOnBranchFrom is createOnBranch with the folder to archive given: "" -// for the game's own save locations, as always. -func (m *Manager) createOnBranchFrom(gameID, branch, from, comment string, isSystemAuto, current bool) (store.Snapshot, error) { +// for the game's own save locations, as always. discardIncomplete is for +// CreateAutomatic alone. +func (m *Manager) createOnBranchFrom(gameID, branch, from, comment string, isSystemAuto, current, discardIncomplete bool) (store.Snapshot, error) { m.inFlight.Add() defer m.inFlight.Done() @@ -265,13 +286,15 @@ func (m *Manager) createOnBranchFrom(gameID, branch, from, comment string, isSys return store.Snapshot{}, fmt.Errorf("zip save data: %w", err) } defer os.Remove(stagingPath) // no-op once renamed - // A snapshot missing files is not kept: it would stand in the history as - // a save that never existed — and, counted against retention, push out a - // complete one. The caller tries again at the next chance (the watcher - // leaves the change unrecorded, so its next pass takes it again; a sync - // that needed the copy before replacing files does not replace them). + // An automatic snapshot missing files is not kept, and any other is (see + // CreateAutomatic). if len(skipped) > 0 { - return store.Snapshot{}, &IncompleteError{Game: game.Name, Skipped: skipped} + if discardIncomplete { + return store.Snapshot{}, &IncompleteError{Game: game.Name, Skipped: skipped} + } + if m.Log != nil { + m.Log("warn", fmt.Sprintf("snapshot of %q skipped %d unreadable file(s), e.g. %s", game.Name, len(skipped), skipped[0])) + } } if comment == "" { diff --git a/internal/snapshot/zip.go b/internal/snapshot/zip.go index 3bac4cb6..81c0db41 100644 --- a/internal/snapshot/zip.go +++ b/internal/snapshot/zip.go @@ -131,8 +131,13 @@ func zipPathCapturing(sourcePath, outPath string, reuse *reuseSource) (skipped [ // It costs nothing to produce here. The bytes are already streaming through // this function on their way into the archive, so hashing them is a second // writer on the same copy rather than a second pass over the disk. +// +// openForArchive is fsx.OpenShared; a test replaces it to have a file the +// game holds open, on any system. +var openForArchive = fsx.OpenShared + func addFileEntry(w *zip.Writer, filePath, entryName string) (string, error) { - f, err := fsx.OpenShared(filePath) + f, err := openForArchive(filePath) if err != nil { return "", err } diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index a68c068b..9431b166 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -114,6 +114,10 @@ type Callbacks struct { SetLastManifestHash func(gameID, hash string) error // CreateSnapshot takes the auto-snapshot. Retried on failure. CreateSnapshot func(gameID string) error + // SnapshotGaveUp is told when a pass gave up on its auto-snapshot after + // every retry, with the last error; the change is taken again at the next + // pass. May be nil. + SnapshotGaveUp func(gameID string, err error) // OnChanged fires after a successful auto-snapshot (the daemon uses it // to kick off P2P sync). Optional-in-behavior: may be nil. OnChanged func(gameID string) @@ -962,6 +966,9 @@ func (e *Engine) handleChangeFrom(ctx context.Context, gw *gameWatch, ownOnly bo } if err != nil { e.log("error", fmt.Sprintf("auto-snapshot permanently failed for %q: %v", gw.gameID, err)) + if e.cb.SnapshotGaveUp != nil { + e.cb.SnapshotGaveUp(gw.gameID, err) + } return } From 60f40d0b0754a5c1dccc08a2cf1aa04b9d3d9031 Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Sat, 3 Oct 2026 11:32:57 +0200 Subject: [PATCH 3/3] Keep each device's game settings out of sync A game that keeps its settings beside its saves had one PC's graphics settings handed to every other: Crimson Desert's save/user_engine_option_save.xml, among others. The game database (Ludusavi manifest) already says which files are a game's settings: it tags them "config", as opposed to "save" - 16,700 entries. The index now keeps those (indexedGame.Config, index format v3, rebuilt once), and for each tracked game the ones inside its save folder become exclusion patterns relative to it (presets.DeviceSettings), matched component by component so a template's wildcard meets the folder actually tracked. An entry tagged both is a save. A pattern that would cover the whole folder is never applied: a wrong exclusion loses saves, a missed one only syncs a setting. They are applied as the game's exclusion rules are, through one function everything that applies rules now takes them from (syncengine.Engine.IgnoreText), exactly like #52's launcher files: never synced in either direction, never deleted on another device, still in every snapshot, and kept on this device by a restore. The game's own rules come after them, so a "!" line brings one back; a per-game switch (games.sync_device_settings, migration 0040, "Sync these settings between devices too" in the configuration tab, with the files listed) syncs them all. The tab's new text is in the catalogs (en, zh-CN). The list changes without anyone writing a rule, at the first start with this build, so at start a game's last-snapshot hash is re-taken under the new list where the save is the one it was recorded for (daemon.adoptExclusionView); otherwise the watcher would snapshot and sync a game nobody played. The base agreed with each device needs nothing: the sync engine already translates a base recorded under other exclusions (#52). Toggling the switch goes through the same path as editing the rules. On top of #54, so the two merge one after the other (#52 is merged). Co-Authored-By: Claude Opus 5.5 --- cmd/opensave-app/frontend/src/locales/en.json | 5 +- .../frontend/src/locales/zh-CN.json | 5 +- .../frontend/src/views/game/ConfigTab.svelte | 40 +++++++- internal/api/routes.go | 4 +- internal/api/routes_backup.go | 2 +- internal/api/routes_cloud.go | 2 +- internal/api/routes_savefiles.go | 5 +- internal/api/server.go | 14 ++- internal/daemon/cloudsync.go | 4 +- internal/daemon/daemon.go | 12 ++- internal/daemon/devicesettings.go | 93 ++++++++++++++++++ internal/daemon/devicesettings_test.go | 84 ++++++++++++++++ .../p2p/syncengine/devicesettings_test.go | 68 +++++++++++++ internal/p2p/syncengine/engine.go | 5 + internal/p2p/syncengine/ignore.go | 40 ++++++-- internal/presets/devicesettings.go | 95 +++++++++++++++++++ internal/presets/devicesettings_test.go | 82 ++++++++++++++++ internal/presets/ludusavi.go | 56 +++++++---- internal/store/games.go | 9 +- .../migrations/0040_sync_device_settings.sql | 5 + 20 files changed, 588 insertions(+), 42 deletions(-) create mode 100644 internal/daemon/devicesettings.go create mode 100644 internal/daemon/devicesettings_test.go create mode 100644 internal/p2p/syncengine/devicesettings_test.go create mode 100644 internal/presets/devicesettings.go create mode 100644 internal/presets/devicesettings_test.go create mode 100644 internal/store/migrations/0040_sync_device_settings.sql diff --git a/cmd/opensave-app/frontend/src/locales/en.json b/cmd/opensave-app/frontend/src/locales/en.json index 5284c023..562805af 100644 --- a/cmd/opensave-app/frontend/src/locales/en.json +++ b/cmd/opensave-app/frontend/src/locales/en.json @@ -228,5 +228,8 @@ "snapshots.incomplete.more": { "one": " and one more file", "other": " and {n} more files" - } + }, + "game.config.deviceSettings": "Device settings", + "game.config.syncDeviceSettings": "Sync these settings between devices too", + "game.config.deviceSettingsHint": "The game database lists these as this game's settings, not its save, so each device keeps its own (graphics, resolution). They are still in every snapshot." } diff --git a/cmd/opensave-app/frontend/src/locales/zh-CN.json b/cmd/opensave-app/frontend/src/locales/zh-CN.json index 7d1fad2b..d4718ae1 100644 --- a/cmd/opensave-app/frontend/src/locales/zh-CN.json +++ b/cmd/opensave-app/frontend/src/locales/zh-CN.json @@ -201,5 +201,8 @@ "restoreProgress.underAMinute": "不到一分钟", "restoreProgress.aboutMinutes": "约 {n} 分钟", "snapshots.incomplete": "“{name}”:自动快照一直缺少游戏正在占用的 {files}。在这些文件可以读取之前(通常是游戏关闭后),它的更改不会被备份或同步。", - "snapshots.incomplete.more": " 等另外 {n} 个文件" + "snapshots.incomplete.more": " 等另外 {n} 个文件", + "game.config.deviceSettings": "设备设置", + "game.config.syncDeviceSettings": "也在设备之间同步这些设置", + "game.config.deviceSettingsHint": "游戏数据库将这些文件列为该游戏的设置而非存档,因此每台设备保留各自的设置(画质、分辨率)。它们仍包含在每个快照中。" } diff --git a/cmd/opensave-app/frontend/src/views/game/ConfigTab.svelte b/cmd/opensave-app/frontend/src/views/game/ConfigTab.svelte index 459703f1..8d2183a5 100644 --- a/cmd/opensave-app/frontend/src/views/game/ConfigTab.svelte +++ b/cmd/opensave-app/frontend/src/views/game/ConfigTab.svelte @@ -3,6 +3,7 @@ import { askConfirm } from '../../lib/stores.js'; import { api, native, coverURL } from '../../lib/api.js'; import { savePathChange } from '../../lib/savepath.js'; + import { t } from '../../lib/i18n.js'; import AppIdField from './AppIdField.svelte'; import SyncIgnoreEditor from './SyncIgnoreEditor.svelte'; import SaveLocations from './SaveLocations.svelte'; @@ -25,8 +26,12 @@ autoSync: game.autoSync ?? true, maxSnapshots: game.maxSnapshots ?? 5, maxManualSnapshots: game.maxManualSnapshots ?? 0, - syncIgnore: game.syncIgnore ?? '' + syncIgnore: game.syncIgnore ?? '', + syncDeviceSettings: game.syncDeviceSettings ?? false }; + // The files the game database names as this game's settings rather than its + // save (graphics, resolution): left out of syncing unless asked for. + $: deviceSettings = game.deviceSettings ?? []; let locations = []; // Cover preview: a custom URL wins, else the proxied Steam art for the App @@ -76,7 +81,8 @@ autoSync: cfg.autoSync, maxSnapshots: Number(cfg.maxSnapshots), maxManualSnapshots: Number(cfg.maxManualSnapshots), - syncIgnore: cfg.syncIgnore ?? '' + syncIgnore: cfg.syncIgnore ?? '', + syncDeviceSettings: !!cfg.syncDeviceSettings }) ); } @@ -160,6 +166,21 @@ can't push them out. 0 = keep forever (the default). + {#if deviceSettings.length > 0} +
+ +
    + {#each deviceSettings as p} +
  • {p.replace(/^\//, '')}
  • + {/each} +
+
+ + {$t('game.config.deviceSettingsHint')} + {/if} 0} />
@@ -206,6 +227,21 @@ .fields .field { margin-bottom: 14px; } + .device-settings { + margin: 4px 0 6px; + padding-left: 18px; + color: var(--text-dim, inherit); + } + .device-settings code { + font-size: 0.9em; + } + .device-block { + margin-bottom: 4px !important; + } + .device-hint { + display: block; + margin-bottom: 14px; + } .hint-warn { color: var(--warn); } diff --git a/internal/api/routes.go b/internal/api/routes.go index 2e0a170b..1797f3bf 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -384,7 +384,7 @@ func (s *Server) handleUpdateGame(w http.ResponseWriter, r *http.Request) { oldSavePath := game.SavePath oldAutoSync := game.AutoSync - oldIgnore := game.SyncIgnore + oldIgnore := s.Daemon.IgnoreText(game) oldAppID := game.AppID if err := readJSON(r, &game); err != nil { writeError(w, http.StatusBadRequest, err.Error()) @@ -425,7 +425,7 @@ func (s *Server) handleUpdateGame(w http.ResponseWriter, r *http.Request) { forgetCoverMiss(game.AppID) } - if game.SyncIgnore != oldIgnore { + if s.Daemon.IgnoreText(game) != oldIgnore { // The merge bases were computed over a save that included files the // new rules exclude, so neither device could ever match them again — // which reads as permanent divergence and prompts a conflict on every diff --git a/internal/api/routes_backup.go b/internal/api/routes_backup.go index 2be3bcaa..d33e2213 100644 --- a/internal/api/routes_backup.go +++ b/internal/api/routes_backup.go @@ -723,7 +723,7 @@ func (s *Server) importBackupV2(zr *zip.Reader, manifest *backupManifest, mode s if err == nil && mode == "overwrite" { // A backup file may come from another device: this one's // excluded files stay its own (Manager.RestoreKeeping). - _, err = s.Daemon.Snapshots.RestoreKeeping(gameID, snapID, ignore.Parse(local.SyncIgnore)) + _, err = s.Daemon.Snapshots.RestoreKeeping(gameID, snapID, ignore.Parse(s.Daemon.IgnoreText(local))) res.Path = local.SavePath res.Action = "restored" } else if err == nil { diff --git a/internal/api/routes_cloud.go b/internal/api/routes_cloud.go index d8253cee..05e868bd 100644 --- a/internal/api/routes_cloud.go +++ b/internal/api/routes_cloud.go @@ -346,7 +346,7 @@ func (s *Server) handleCloudRestore(w http.ResponseWriter, r *http.Request) { // A cloud copy may be another device's: this device's excluded files // stay its own (Manager.RestoreKeeping). - if _, err := s.Daemon.Snapshots.RestoreKeeping(gameID, snapID, ignore.Parse(game.SyncIgnore)); err != nil { + if _, err := s.Daemon.Snapshots.RestoreKeeping(gameID, snapID, ignore.Parse(s.Daemon.IgnoreText(game))); err != nil { writeError(w, http.StatusInternalServerError, fmt.Sprintf("downloaded but restore failed: %v", err)) return } diff --git a/internal/api/routes_savefiles.go b/internal/api/routes_savefiles.go index 663b63fc..297df1d8 100644 --- a/internal/api/routes_savefiles.go +++ b/internal/api/routes_savefiles.go @@ -60,10 +60,11 @@ func (s *Server) handleGameSaveFiles(w http.ResponseWriter, r *http.Request) { return } - text := game.SyncIgnore if raw, ok := r.URL.Query()["rules"]; ok { - text = raw[0] // present-but-empty means "no rules", not "use the saved ones" + game.SyncIgnore = raw[0] // present-but-empty means "no rules", not "use the saved ones" } + // With the game's device settings, which are left out the same way. + text := s.Daemon.IgnoreText(game) rules := ignore.Parse(text) roots := []struct{ name, path string }{{"", game.SavePath}} diff --git a/internal/api/server.go b/internal/api/server.go index 5bf129e1..4d40a280 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -562,11 +562,15 @@ func (s *Server) gamePayload(g store.Game) map[string]any { // Whether the art cached for this game is explicit, so the client can // blur it until someone asks to see it. An cannot read a // response header, so it travels with the game rather than the image. - "coverExplicit": s.CoverIsExplicit(coverKeyFor(g)), - "syncIgnore": g.SyncIgnore, - "branches": branches, - "createdAt": g.CreatedAt, - "lastSyncedWith": lastSyncedWith, + "coverExplicit": s.CoverIsExplicit(coverKeyFor(g)), + "syncIgnore": g.SyncIgnore, + // The files the game database names as this game's settings, left + // out of syncing unless syncDeviceSettings (daemon/devicesettings.go). + "deviceSettings": s.Daemon.DeviceSettings(g), + "syncDeviceSettings": g.SyncDeviceSettings, + "branches": branches, + "createdAt": g.CreatedAt, + "lastSyncedWith": lastSyncedWith, // The save folder is not there — gone, moved, or on a drive not // plugged in. Nothing is watched or synced for it until it is back. "savePathMissing": daemon.SaveFolderMissing(g.SavePath), diff --git a/internal/daemon/cloudsync.go b/internal/daemon/cloudsync.go index 21c9e73d..72e5e682 100644 --- a/internal/daemon/cloudsync.go +++ b/internal/daemon/cloudsync.go @@ -401,7 +401,7 @@ func (d *Daemon) currentContentHash(game store.Game) (string, error) { if err != nil { return "", err } - return watcher.ContentHash(m, game.SyncIgnore), nil + return watcher.ContentHash(m, d.IgnoreText(game)), nil } // verifyWebDAVHeadBootstrap guards exceptional unpublished-head recovery. @@ -677,7 +677,7 @@ func (d *Daemon) pullFromCloud(game store.Game, fileName, deviceName string) err } // Another device's save: the files this device keeps for itself stay // its own. See Manager.RestoreKeeping. - if _, err := d.Snapshots.RestoreKeeping(game.ID, snapID, ignore.Parse(game.SyncIgnore)); err != nil { + if _, err := d.Snapshots.RestoreKeeping(game.ID, snapID, ignore.Parse(d.IgnoreText(game))); err != nil { return err } // What is on disk now is that snapshot, so record it as the baseline. diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index c14e4f0f..de68c7ab 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -78,6 +78,10 @@ type Daemon struct { // Play sessions; see sessions.go. sessions sessionState + // Device settings; see devicesettings.go. + devMu sync.Mutex + dev devSettingsCache + // OnCloudOffers receives the saves from other devices' cloud backups // that are waiting for an answer, whenever that list changes, and // OnCloudPulled each one put in place without asking. See cloudsync.go. @@ -176,6 +180,9 @@ func New(opts Options) (*Daemon, error) { d.initSessions() + // A game's device settings are left out of syncing (devicesettings.go). + d.P2P.Sync.DeviceSettingsFor = d.deviceSettingsFor + // A restore or a branch switch rewrites a save folder; nothing syncing it // may read it half-way (syncengine/settle.go). snaps.WriteGate = d.P2P.Sync.Writing @@ -234,7 +241,7 @@ func New(opts Options) (*Daemon, error) { if err != nil { return "" } - return game.SyncIgnore + return d.IgnoreText(game) }, GetLastManifestHash: func(gameID string) (string, error) { game, err := s.GetGame(gameID) @@ -316,6 +323,9 @@ func (d *Daemon) Start() error { // then evicts entries it is about to want and starts re-reading saves. delta.SetHashCacheBudgetForGames(len(games)) + // Before anything is watched or compared. + d.adoptExclusionView(games) + for _, game := range games { // Backfill cover art for games tracked before covers existed (or // migrated from the JS app without one). diff --git a/internal/daemon/devicesettings.go b/internal/daemon/devicesettings.go new file mode 100644 index 00000000..308f3608 --- /dev/null +++ b/internal/daemon/devicesettings.go @@ -0,0 +1,93 @@ +package daemon + +import ( + "github.com/opensave/opensave/internal/delta" + "github.com/opensave/opensave/internal/presets" + "github.com/opensave/opensave/internal/store" + "github.com/opensave/opensave/internal/watcher" +) + +// Device settings: the files the game database names as a game's settings +// rather than its save are left out of syncing (presets/devicesettings.go), +// through the game's exclusion list (syncengine.IgnoreText). +// +// Worked out once per run and kept: the database is read when first needed, +// and a game's answer only changes when its name, App ID or folder does — +// which makes it a different key here. A database update takes effect at the +// next start, where adoptExclusionView carries every recorded hash over. + +type devSettingsCache struct { + loaded bool + db *presets.DeviceSettings + patterns map[string][]string +} + +func (d *Daemon) deviceSettingsFor(game store.Game) []string { + if d.Scanner == nil { + return nil + } + key := game.Name + "\x00" + game.AppID + "\x00" + game.SavePath + d.devMu.Lock() + defer d.devMu.Unlock() + if !d.dev.loaded { + d.dev.db = d.Scanner.DeviceSettings() + d.dev.patterns = map[string][]string{} + d.dev.loaded = true + } + if p, ok := d.dev.patterns[key]; ok { + return p + } + p := d.dev.db.Patterns(game.Name, game.AppID, []string{game.SavePath}) + d.dev.patterns[key] = p + return p +} + +// IgnoreText is the exclusion list a game is synced under (see +// syncengine.Engine.IgnoreText). +func (d *Daemon) IgnoreText(game store.Game) string { + if d.P2P == nil || d.P2P.Sync == nil { + return game.SyncIgnore + } + return d.P2P.Sync.IgnoreText(game) +} + +// DeviceSettings returns the patterns for a game's device settings, whether +// or not it is set to sync them — what the window lists. +func (d *Daemon) DeviceSettings(game store.Game) []string { + return d.deviceSettingsFor(game) +} + +// adoptExclusionView carries a game's last-snapshot hash over to what the game +// is excluded by now, when the game database names settings files in its save +// folder: at the first start with this build, or when the database starts +// naming them. Without it the watcher takes the new view for a change, and +// snapshots — and syncs — a game nobody played. +// +// Recognised by the hash itself: it still matches the save under the game's +// own rules alone, as it was recorded before any settings were left out. Then +// the save is the one it was recorded for, and the hash is re-taken under the +// new list; a save that did change keeps its old hash, and its change counts. +// A change of the settings list between two runs that both left some out is +// not recognised, and costs at most one automatic snapshot, the safe +// direction (as daemon.LastSnapshotHashUnder). The base agreed with each +// device needs nothing here: the sync engine translates a base recorded under +// other exclusions whenever it compares (syncengine, #52). +func (d *Daemon) adoptExclusionView(games []store.Game) { + for _, game := range games { + now := d.IgnoreText(game) + if now == game.SyncIgnore || game.LastManifestHash == "" { + continue + } + extra, err := d.Store.GameRootPaths(game.ID) + if err != nil { + extra = nil + } + m, failures, err := delta.BuildMultiManifest(game.SavePath, extra) + if err != nil || len(failures) > 0 { + continue // unreadable now: tried again at the next start + } + if game.LastManifestHash == watcher.ContentHash(m, game.SyncIgnore) { + _ = d.Store.SetLastManifestHash(game.ID, watcher.ContentHash(m, now)) + } + } +} diff --git a/internal/daemon/devicesettings_test.go b/internal/daemon/devicesettings_test.go new file mode 100644 index 00000000..2b94c1a4 --- /dev/null +++ b/internal/daemon/devicesettings_test.go @@ -0,0 +1,84 @@ +package daemon + +import ( + "os" + "path/filepath" + "testing" + + "github.com/opensave/opensave/internal/delta" + "github.com/opensave/opensave/internal/store" + "github.com/opensave/opensave/internal/watcher" +) + +// When the game database starts naming a game's settings, the hash its last +// snapshot recorded is carried over — only where the save is the one it was +// recorded for — so the watcher does not take the new view for a change and +// snapshot a game nobody played. +func TestAdoptExclusionView_CarriesTheLastSnapshotHashOver(t *testing.T) { + d := newTestDaemon(t) + mk := func(id string, files map[string]string) store.Game { + dir := filepath.Join(t.TempDir(), id) + for name, data := range files { + if err := os.MkdirAll(dir, 0o777); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, name), []byte(data), 0o666); err != nil { + t.Fatal(err) + } + } + g := store.Game{ID: id, Name: id, SavePath: dir, ActiveBranch: "main", MaxSnapshots: 5} + if err := d.Store.CreateGame(g); err != nil { + t.Fatal(err) + } + m, err := delta.BuildManifest(dir) + if err != nil { + t.Fatal(err) + } + if err := d.Store.SetLastManifestHash(id, watcher.ContentHash(m, "")); err != nil { + t.Fatal(err) + } + g, _ = d.Store.GetGame(id) + return g + } + same := mk("same", map[string]string{"slot1.sav": "a", "graphics.xml": "4k"}) + changed := mk("changed", map[string]string{"slot1.sav": "a", "graphics.xml": "4k"}) + // A real change since the last snapshot, not yet seen by the watcher. + if err := os.WriteFile(filepath.Join(changed.SavePath, "slot1.sav"), []byte("b"), 0o666); err != nil { + t.Fatal(err) + } + delta.InvalidateRoot(changed.SavePath) + + d.devMu.Lock() + d.dev = devSettingsCache{loaded: true, patterns: map[string][]string{}} + for _, g := range []store.Game{same, changed} { + d.dev.patterns[g.Name+"\x00"+g.AppID+"\x00"+g.SavePath] = []string{"/graphics.xml"} + } + d.devMu.Unlock() + + d.adoptExclusionView([]store.Game{same, changed}) + + hashNow := func(g store.Game) string { + m, _ := delta.BuildManifest(g.SavePath) + return watcher.ContentHash(m, d.IgnoreText(g)) + } + if g, _ := d.Store.GetGame("same"); g.LastManifestHash != hashNow(g) { + t.Error("an unchanged save's last-snapshot hash was not carried over: the watcher will snapshot it") + } + if g, _ := d.Store.GetGame("changed"); g.LastManifestHash == hashNow(g) { + t.Error("a changed save's hash was carried over: its change would never be snapshotted") + } + // Again at the next start: nothing more to carry over, and nothing lost. + d.adoptExclusionView([]store.Game{mustGame(t, d, "same")}) + if g, _ := d.Store.GetGame("same"); g.LastManifestHash != hashNow(g) { + t.Error("a second start lost the carried-over hash") + } +} + +func mustGame(t *testing.T, d *Daemon, id string) store.Game { + t.Helper() + g, err := d.Store.GetGame(id) + if err != nil { + t.Fatal(err) + } + return g +} diff --git a/internal/p2p/syncengine/devicesettings_test.go b/internal/p2p/syncengine/devicesettings_test.go new file mode 100644 index 00000000..f2939724 --- /dev/null +++ b/internal/p2p/syncengine/devicesettings_test.go @@ -0,0 +1,68 @@ +package syncengine + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/opensave/opensave/internal/ignore" + "github.com/opensave/opensave/internal/store" +) + +func withDeviceSettings(env *engineEnv, patterns ...string) { + env.engine.DeviceSettingsFor = func(store.Game) []string { return patterns } +} + +// Each device keeps its own graphics settings: a differing settings file is +// not a difference, in either direction — until the game is set to sync them. +func TestDeviceSettings_KeptPerDevice(t *testing.T) { + env := setupEngine(t) + withDeviceSettings(env, "/graphics.xml") + write(t, env.localDir, "slot1.sav", "progress") + write(t, env.localDir, "graphics.xml", "4k ultra") + write(t, env.remoteDir, "slot1.sav", "progress") + write(t, env.remoteDir, "graphics.xml", "720p low") + + res, err := env.engine.SyncWithPeer(context.Background(), "game1", env.peer) + if err != nil || res.Status != "in_sync" { + t.Fatalf("status = %q, %v; want in_sync", res.Status, err) + } + if got, _ := os.ReadFile(filepath.Join(env.localDir, "graphics.xml")); string(got) != "4k ultra" { + t.Errorf("this device's settings became %q", got) + } + if got, _ := os.ReadFile(filepath.Join(env.remoteDir, "graphics.xml")); string(got) != "720p low" { + t.Errorf("the peer's settings became %q", got) + } + + // A save change still travels; the settings beside it still do not. + write(t, env.remoteDir, "slot1.sav", "more progress") + res, err = env.engine.SyncWithPeer(context.Background(), "game1", env.peer) + if err != nil || res.Status != "updated" { + t.Fatalf("status = %q, %v; want updated", res.Status, err) + } + if got, _ := os.ReadFile(filepath.Join(env.localDir, "slot1.sav")); string(got) != "more progress" { + t.Errorf("the save did not arrive: %q", got) + } + if got, _ := os.ReadFile(filepath.Join(env.localDir, "graphics.xml")); string(got) != "4k ultra" { + t.Errorf("this device's settings became %q", got) + } + + game, _ := env.store.GetGame("game1") + game.SyncDeviceSettings = true + if env.engine.IgnoreText(game) != game.SyncIgnore { + t.Error("set to sync its settings, the game still leaves them out") + } +} + +// The game's own rules come after the settings, so "!" brings one back. +func TestDeviceSettings_OwnRuleCanBringOneBack(t *testing.T) { + env := setupEngine(t) + withDeviceSettings(env, "/graphics.xml", "/input.cfg") + game, _ := env.store.GetGame("game1") + game.SyncIgnore = "!input.cfg" + rules := env.engine.IgnoreText(game) + if r := ignore.Parse(rules); !r.Match("graphics.xml") || r.Match("input.cfg") { + t.Errorf("rules %q: graphics.xml excluded %v, input.cfg excluded %v", rules, r.Match("graphics.xml"), r.Match("input.cfg")) + } +} diff --git a/internal/p2p/syncengine/engine.go b/internal/p2p/syncengine/engine.go index e5e80bc0..f145e523 100644 --- a/internal/p2p/syncengine/engine.go +++ b/internal/p2p/syncengine/engine.go @@ -152,6 +152,11 @@ type Engine struct { // rootConflicts holds divergences in a game's EXTRA save locations, keyed // by game and location so several can wait on a decision at once. rootConflicts map[string]*RootConflict + + // DeviceSettingsFor returns the exclusion patterns for a game's device + // settings (presets/devicesettings.go), set by the daemon; nil leaves + // them syncing. See IgnoreText. + DeviceSettingsFor func(store.Game) []string } // New creates an Engine. diff --git a/internal/p2p/syncengine/ignore.go b/internal/p2p/syncengine/ignore.go index ee3cc8d6..dad3e7f0 100644 --- a/internal/p2p/syncengine/ignore.go +++ b/internal/p2p/syncengine/ignore.go @@ -2,6 +2,7 @@ package syncengine import ( "context" + "strings" "github.com/opensave/opensave/internal/delta" "github.com/opensave/opensave/internal/ignore" @@ -39,7 +40,29 @@ func (e *Engine) rulesFor(gameID string) ignore.Rules { if err != nil { return ignore.Rules{} } - return ignore.Parse(game.SyncIgnore) + return ignore.Parse(e.IgnoreText(game)) +} + +// IgnoreText is the exclusion list a game is synced under: the rules written +// for it, and the files the game database names as its settings +// (DeviceSettingsFor) unless it is set to sync those too. Everything that +// applies a game's rules takes them from here, so the two can never be +// applied by one part of the app and not another. +func (e *Engine) IgnoreText(game store.Game) string { + if game.SyncDeviceSettings || e.DeviceSettingsFor == nil { + return game.SyncIgnore + } + settings := e.DeviceSettingsFor(game) + if len(settings) == 0 { + return game.SyncIgnore + } + text := game.SyncIgnore + if text != "" && !strings.HasSuffix(text, "\n") { + text += "\n" + } + // The game's own rules come last, so a "!" line there can still bring a + // settings file back into syncing. + return "# device settings (game database)\n" + strings.Join(settings, "\n") + "\n" + text } // excluded says whether a path takes no part in syncing: a file no save is @@ -91,6 +114,11 @@ func filterManifest(m delta.Manifest, rules ignore.Rules) delta.Manifest { if rules.Empty() && !holdsNeverSynced(m) { return m } + return filterManifestBy(m, func(p string) bool { return excluded(rules, p) }) +} + +// filterManifestBy is filterManifest with what to leave out given directly. +func filterManifestBy(m delta.Manifest, leaveOut func(string) bool) delta.Manifest { out := delta.Manifest{ Timestamp: m.Timestamp, Files: make(map[string]delta.FileEntry, len(m.Files)), @@ -104,7 +132,7 @@ func filterManifest(m delta.Manifest, rules ignore.Rules) delta.Manifest { // would be told their saves diverged, over a file neither of them is // syncing. for p, entry := range m.Files { - if excluded(rules, p) { + if leaveOut(p) { continue } out.Files[p] = entry @@ -113,7 +141,7 @@ func filterManifest(m delta.Manifest, rules ignore.Rules) delta.Manifest { } } for _, d := range m.Dirs { - if excluded(rules, d) { + if leaveOut(d) { continue } out.Dirs = append(out.Dirs, d) @@ -126,13 +154,13 @@ func filterManifest(m delta.Manifest, rules ignore.Rules) delta.Manifest { for name, root := range m.Extra { sub := delta.RootManifest{Files: make(map[string]delta.FileEntry, len(root.Files))} for p, entry := range root.Files { - if excluded(rules, p) { + if leaveOut(p) { continue } sub.Files[p] = entry } for _, d := range root.Dirs { - if excluded(rules, d) { + if leaveOut(d) { continue } sub.Dirs = append(sub.Dirs, d) @@ -195,5 +223,5 @@ func (e *Engine) FilteredContentHash(gameID string, game store.Game) string { if err != nil { return "" } - return filterManifest(m, ignore.Parse(game.SyncIgnore)).ManifestHash() + return filterManifest(m, ignore.Parse(e.IgnoreText(game))).ManifestHash() } diff --git a/internal/presets/devicesettings.go b/internal/presets/devicesettings.go new file mode 100644 index 00000000..6b5513c9 --- /dev/null +++ b/internal/presets/devicesettings.go @@ -0,0 +1,95 @@ +package presets + +import ( + "sort" + "strings" +) + +// DeviceSettings answers, for a tracked game, which of the files in its save +// folders are its settings rather than its save: what the Ludusavi manifest +// tags "config" and not "save" (indexedGame.Config). A device's graphics +// settings are the usual case — a game that keeps them beside its saves had +// one PC's resolution and quality handed to every other. +// +// The answer is a list of exclusion patterns, in the syntax of a game's own +// exclusion rules and relative to the save folder they fall in, so it is +// applied by exactly the machinery that applies those: never synced in either +// direction, never deleted on another device, still in every snapshot. +type DeviceSettings struct { + sc *Scanner + byID map[string]indexedGame + byName map[string]indexedGame + protonIx map[string][]string + bases func([]string) []string +} + +// DeviceSettings loads the manifest index once for any number of lookups. +// Nil when there is no index. +func (sc *Scanner) DeviceSettings() *DeviceSettings { + if sc == nil || sc.CacheFile == "" { + return nil + } + idx := sc.loadManifestIndex() + if len(idx) == 0 { + return nil + } + ds := &DeviceSettings{sc: sc, byID: map[string]indexedGame{}, byName: map[string]indexedGame{}} + for _, g := range idx { + if len(g.Config) == 0 { + continue + } + if g.SteamID != "" { + ds.byID[g.SteamID] = g + } + if key := normalizeGameName(g.Name); key != "" { + ds.byName[key] = g + } + } + if sc.goos() == "linux" { + ds.protonIx = sc.protonPrefixIndex() + } + ds.bases = sc.installBaseCandidates() + return ds +} + +// Patterns returns the exclusion patterns for a game's settings that lie +// inside one of roots (its save folders; the first is the main one), each +// relative to the root it is in. Patterns for any other root than the first +// are not returned: a game's rules are written against its main folder. +func (ds *DeviceSettings) Patterns(name, appID string, roots []string) []string { + if ds == nil || len(roots) == 0 { + return nil + } + g, ok := ds.byID[appID] + if !ok || appID == "" { + g, ok = ds.byName[normalizeGameName(stripNameSuffixes(name))] + } + if !ok { + return nil + } + installBases := ds.bases(g.Installs) + seen := map[string]bool{} + var out []string + for _, vars := range ds.sc.ludusaviVarSets(g, ds.protonIx) { + for _, tpl := range g.Config { + for _, expanded := range expandTemplate(tpl, vars, installBases, g.SteamID) { + rel, ok := relativeUnder(roots[0], expanded) + if !ok || coversEverything(rel) || seen[rel] { + continue + } + seen[rel] = true + out = append(out, "/"+rel) + } + } + } + sort.Strings(out) + return out +} + +// coversEverything reports a relative pattern that would exclude the whole +// folder — a settings entry naming the folder the save is tracked at. Never +// applied: a wrong exclusion loses saves, a missed one only syncs a setting. +func coversEverything(rel string) bool { + first := strings.SplitN(rel, "/", 2)[0] + return strings.Trim(first, "*") == "" || first == "*.*" +} diff --git a/internal/presets/devicesettings_test.go b/internal/presets/devicesettings_test.go new file mode 100644 index 00000000..019641c4 --- /dev/null +++ b/internal/presets/devicesettings_test.go @@ -0,0 +1,82 @@ +package presets + +import ( + "path/filepath" + "reflect" + "runtime" + "testing" +) + +// Crimson Desert's shape: the save is a per-account folder inside the tracked +// one, and the graphics settings sit beside it. The settings are named; the +// save is not; an entry tagged both counts as save. +func TestDeviceSettings_SettingsInsideTheSaveFolder(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("templates below use Windows placeholders") + } + home := t.TempDir() + t.Setenv("USERPROFILE", home) + t.Setenv("HOME", home) + local := filepath.Join(home, "AppData", "Local") + t.Setenv("LOCALAPPDATA", local) + + sc := manifestScanner(t, ` +Pearl Game: + files: + "/Pearl/CD/save/": + tags: [save] + "/Pearl/CD/save/user_engine_option_save.xml": + tags: [config] + "/Pearl/CD/save/both.cfg": + tags: [config, save] + "/Pearl/CD/save/*": + tags: [config] + "/Pearl/Elsewhere/settings.ini": + tags: [config] + steam: + id: 123 +`) + ds := sc.DeviceSettings() + if ds == nil { + t.Fatal("no index") + } + root := filepath.Join(local, "Pearl", "CD", "save") + want := []string{"/user_engine_option_save.xml"} + if got := ds.Patterns("Pearl Game", "123", []string{root}); !reflect.DeepEqual(got, want) { + t.Errorf("by App ID: %v, want %v", got, want) + } + if got := ds.Patterns("Pearl Game", "", []string{root}); !reflect.DeepEqual(got, want) { + t.Errorf("by name: %v, want %v", got, want) + } + // Tracked at the per-account folder: the settings are not inside it. + if got := ds.Patterns("Pearl Game", "123", []string{filepath.Join(root, "22202")}); len(got) != 0 { + t.Errorf("at the account folder: %v, want none", got) + } + if got := ds.Patterns("Another Game", "999", []string{root}); len(got) != 0 { + t.Errorf("unknown game: %v", got) + } +} + +func TestRelativeUnder(t *testing.T) { + for _, c := range []struct { + root, pattern, want string + ok bool + }{ + {`C:\Users\A\Saved Games\Game`, `C:\Users\A\Saved Games\Game\game.cfg`, "game.cfg", true}, + {`C:\Users\A/Saved Games/Game`, `c:\users\a\saved games\game\Config\x.ini`, "config/x.ini", true}, + {`C:\Users\A\Game\save\22202`, `C:\Users\A\Game\save\*\opt.xml`, "opt.xml", true}, + {`C:\Users\A\Game\save`, `C:\Users\A\*\save\opt.xml`, "opt.xml", true}, + {`C:\Users\A\Game`, `C:\Users\A\Game`, "", false}, + {`C:\Users\A\Game`, `C:\Users\A\Other\x.ini`, "", false}, + } { + got, ok := relativeUnder(c.root, c.pattern) + if got != c.want || ok != c.ok { + t.Errorf("relativeUnder(%q, %q) = %q, %v; want %q, %v", c.root, c.pattern, got, ok, c.want, c.ok) + } + } + for rel, want := range map[string]bool{"*": true, "*.*": true, "**/x": true, "*.cfg": false, "config": false} { + if coversEverything(rel) != want { + t.Errorf("coversEverything(%q) = %v", rel, !want) + } + } +} diff --git a/internal/presets/ludusavi.go b/internal/presets/ludusavi.go index f1b56259..c4411c47 100644 --- a/internal/presets/ludusavi.go +++ b/internal/presets/ludusavi.go @@ -68,6 +68,11 @@ type indexedGame struct { // index built by an older release, which reads as "none" — the field is // additive, so an index on disk from a previous version still loads. Registry []string `json:"r,omitempty"` + // Config is where the game keeps its settings and nothing else: entries + // the manifest tags "config" and not "save". A device's own graphics + // settings among them; inside a tracked save folder they are left out of + // syncing (DeviceSettingsPatterns). + Config []string `json:"c,omitempty"` } // manifestPaths derives the manifest + index locations from the scanner's @@ -89,7 +94,7 @@ func (sc *Scanner) manifestPaths() (yamlPath, indexPath string) { // reading the file it knows and a newer one builds its own, so a user moving // between the two is never handed an index whose shape their code predates. // Bump this whenever indexedGame gains or loses a field that a scan depends on. -const indexFileName = "ludusavi-index-v2.json" +const indexFileName = "ludusavi-index-v3.json" // scanLudusavi expands the manifest's save-path templates and returns the // locations that actually exist on this machine. @@ -746,13 +751,17 @@ func buildManifestIndex(yamlPath string) []indexedGame { games := make([]indexedGame, 0, len(manifest)) for name, mg := range manifest { - var paths []string + var paths, config []string for tpl, entry := range mg.Files { + if entryIsConfigEntry(tpl, entry) { + config = append(config, tpl) + } if !entryIsSaveEntry(tpl, entry) { continue } paths = append(paths, tpl) } + sort.Strings(config) var regKeys []string for key, entry := range mg.Registry { if !registryEntryIsSave(key, entry) { @@ -781,7 +790,7 @@ func buildManifestIndex(yamlPath string) []indexedGame { continue } sort.Strings(regKeys) // stable index across rebuilds - g := indexedGame{Name: name, Paths: paths, Registry: regKeys, SteamID: steamID} + g := indexedGame{Name: name, Paths: paths, Registry: regKeys, SteamID: steamID, Config: config} for dir := range mg.InstallDir { g.Installs = append(g.Installs, dir) } @@ -817,6 +826,34 @@ func registryEntryIsSave(key string, entry manifestFileEntry) bool { } func entryIsSaveEntry(tpl string, entry manifestFileEntry) bool { + if len(entry.Tags) > 0 && !hasTag(entry, "save") { + return false + } + return entryResolvable(tpl, entry) +} + +// entryIsConfigEntry keeps manifest file entries that are a game's settings +// and nothing else: tagged "config" and not "save". An entry carrying both +// is part of the save, whatever else it holds. +func entryIsConfigEntry(tpl string, entry manifestFileEntry) bool { + if !hasTag(entry, "config") || hasTag(entry, "save") { + return false + } + return entryResolvable(tpl, entry) +} + +func hasTag(entry manifestFileEntry, tag string) bool { + for _, t := range entry.Tags { + if t == tag { + return true + } + } + return false +} + +// entryResolvable reports whether a file template can plausibly resolve on +// a platform this app runs on. +func entryResolvable(tpl string, entry manifestFileEntry) bool { // Placeholders no supported platform can resolve. if strings.Contains(tpl, "") || strings.Contains(tpl, "") { return false @@ -830,19 +867,6 @@ func entryIsSaveEntry(tpl string, entry manifestFileEntry) bool { return false } - if len(entry.Tags) > 0 { - hasSave := false - for _, t := range entry.Tags { - if t == "save" { - hasSave = true - break - } - } - if !hasSave { - return false - } - } - if len(entry.When) == 0 { return true } diff --git a/internal/store/games.go b/internal/store/games.go index d6acc6de..ebbafbcd 100644 --- a/internal/store/games.go +++ b/internal/store/games.go @@ -72,7 +72,11 @@ type Game struct { // else — snapshots keep capturing every file, so a restore can never be // the thing that deletes an excluded config. SyncIgnore string `db:"sync_ignore" json:"syncIgnore"` - CreatedAt string `db:"created_at" json:"createdAt"` + // SyncDeviceSettings syncs the files the game database names as the + // game's settings, which are otherwise left out of syncing + // (presets/devicesettings.go). Off unless the user turns it on. + SyncDeviceSettings bool `db:"sync_device_settings" json:"syncDeviceSettings"` + CreatedAt string `db:"created_at" json:"createdAt"` } // CreateGame inserts a new game and its default "main" branch in one @@ -159,7 +163,8 @@ func (s *Store) UpdateGame(g Game) error { exe_path = :exe_path, cover_url = :cover_url, last_manifest_hash = :last_manifest_hash, - sync_ignore = :sync_ignore + sync_ignore = :sync_ignore, + sync_device_settings = :sync_device_settings WHERE id = :id`, g) if err != nil { return fmt.Errorf("update game %s: %w", g.ID, err) diff --git a/internal/store/migrations/0040_sync_device_settings.sql b/internal/store/migrations/0040_sync_device_settings.sql new file mode 100644 index 00000000..a5f9d82e --- /dev/null +++ b/internal/store/migrations/0040_sync_device_settings.sql @@ -0,0 +1,5 @@ +-- Whether a game's device settings (the files the Ludusavi manifest tags as +-- its settings and not its save, presets/devicesettings.go) sync like the rest +-- of its save. Off by default: one PC's resolution and graphics quality are +-- not another's. +ALTER TABLE games ADD COLUMN sync_device_settings INTEGER NOT NULL DEFAULT 0;