@@ -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
}