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..562805af 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,13 @@ "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" + }, + "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 f35d728e..d4718ae1 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,10 @@ "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} 个文件", + "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} +
+ + +
+ + {$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 645cc12c..4d40a280 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 @@ -559,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/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/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 bb2b4b20..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. @@ -90,6 +94,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 @@ -171,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 @@ -229,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) @@ -240,9 +252,19 @@ 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) + }, + 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. @@ -301,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 5b8d8d10..f145e523 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 @@ -150,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/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/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/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 5d1f86e5..ec5047e2 100644 --- a/internal/snapshot/snapshot.go +++ b/internal/snapshot/snapshot.go @@ -41,6 +41,19 @@ import ( // before starting one. type UploadHook func(zipPath, remoteFileName string) +// 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 +} + +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 { @@ -145,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. // @@ -169,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 @@ -185,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() @@ -253,9 +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 - 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)) + // An automatic snapshot missing files is not kept, and any other is (see + // CreateAutomatic). + if len(skipped) > 0 { + 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 f2d81dde..81c0db41 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) @@ -126,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/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/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; 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..9431b166 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -114,11 +114,34 @@ 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) // 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 +794,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 +813,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. @@ -928,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 }