Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions cmd/opensave-app/frontend/src/lib/notifications.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
21 changes: 20 additions & 1 deletion cmd/opensave-app/frontend/src/lib/notifications.test.js
Original file line number Diff line number Diff line change
@@ -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' }] } } },
Expand Down Expand Up @@ -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');
});
});
9 changes: 8 additions & 1 deletion cmd/opensave-app/frontend/src/lib/stores.js
Original file line number Diff line number Diff line change
@@ -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 = {};
Expand Down Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion cmd/opensave-app/frontend/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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…",
Expand Down Expand Up @@ -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."
}
8 changes: 7 additions & 1 deletion cmd/opensave-app/frontend/src/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"settings.language": "语言",
"settings.systemDefault": "跟随系统",
"common.cancel": "取消",
"common.open": "打开",
"common.nowTracking": "已开始跟踪“{name}”",
"home.addGame.adding": "添加中…",
"home.addGame.changeFolder": "更换文件夹…",
Expand Down Expand Up @@ -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": "游戏数据库将这些文件列为该游戏的设置而非存档,因此每台设备保留各自的设置(画质、分辨率)。它们仍包含在每个快照中。"
}
40 changes: 38 additions & 2 deletions cmd/opensave-app/frontend/src/views/game/ConfigTab.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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
Expand Down Expand Up @@ -76,7 +81,8 @@
autoSync: cfg.autoSync,
maxSnapshots: Number(cfg.maxSnapshots),
maxManualSnapshots: Number(cfg.maxManualSnapshots),
syncIgnore: cfg.syncIgnore ?? ''
syncIgnore: cfg.syncIgnore ?? '',
syncDeviceSettings: !!cfg.syncDeviceSettings
})
);
}
Expand Down Expand Up @@ -160,6 +166,21 @@
can't push them out. <strong>0 = keep forever</strong> (the default).
</span>
</div>
{#if deviceSettings.length > 0}
<div class="field device-block">
<label for="c-devsettings">{$t('game.config.deviceSettings')}</label>
<ul class="device-settings">
{#each deviceSettings as p}
<li><code>{p.replace(/^\//, '')}</code></li>
{/each}
</ul>
</div>
<label class="check">
<input id="c-devsettings" type="checkbox" bind:checked={cfg.syncDeviceSettings} />
{$t('game.config.syncDeviceSettings')}
</label>
<span class="hint device-hint">{$t('game.config.deviceSettingsHint')}</span>
{/if}
<SyncIgnoreEditor {game} bind:value={cfg.syncIgnore} severalFolders={locations.length > 0} />
<SaveLocations {game} {runner} bind:locations />
<div class="save">
Expand Down Expand Up @@ -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);
}
Expand Down
4 changes: 2 additions & 2 deletions internal/api/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion internal/api/routes_backup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion internal/api/routes_cloud.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
5 changes: 3 additions & 2 deletions internal/api/routes_savefiles.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}}
Expand Down
17 changes: 12 additions & 5 deletions internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <img src> 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),
Expand Down
70 changes: 70 additions & 0 deletions internal/daemon/autosnapshot.go
Original file line number Diff line number Diff line change
@@ -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)
}
45 changes: 45 additions & 0 deletions internal/daemon/autosnapshot_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading