Skip to content
10 changes: 10 additions & 0 deletions cmd/opensave-app/frontend/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,16 @@
"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.",
"game.config.deviceSettingsDetectedHint": "Settings files you confirm from the suggestions are kept on each device too. A file that turns out to change along with the save syncs again by itself; to sync one yourself, untick it under “Pick from your save folder” below.",
"game.config.sourceDatabase": "game database",
"game.config.sourceDetected": "kept here by you — {reason}",
"game.config.sourceSave": "syncs again — {reason}",
"game.config.suggestedSettings": "Suggested settings files",
"game.config.suggestedHint": "These look like this device’s settings rather than the save, by what they hold. They keep syncing until you decide: keep one on this device, and each device keeps its own.",
"game.config.keepHere": "Keep on this device",
"game.config.keepSyncing": "Keep syncing",
"game.config.keptHere": "{file} is kept on this device now",
"game.config.keptSyncing": "{file} keeps syncing",
"notifications.clearAll": "Clear all",
"notifications.clearAllTitle": "Take these out of the bell. Activity still has them.",
"palette.renameGame": "Rename {game}"
Expand Down
10 changes: 10 additions & 0 deletions cmd/opensave-app/frontend/src/locales/zh-Hans.json
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,16 @@
"game.config.deviceSettings": "设备设置",
"game.config.syncDeviceSettings": "也在设备之间同步这些设置",
"game.config.deviceSettingsHint": "游戏数据库将这些文件列为该游戏的设置而非存档,因此每台设备保留各自的设置(画质、分辨率)。它们仍包含在每个快照中。",
"game.config.deviceSettingsDetectedHint": "你从建议中确认的设置文件也会在每台设备上各自保留。若某个文件被发现会随存档一起变化,它会自动恢复同步;如需手动同步某个文件,请在下方“Pick from your save folder”中取消勾选。",
"game.config.sourceDatabase": "游戏数据库",
"game.config.sourceDetected": "由你保留在本机 — {reason}",
"game.config.sourceSave": "恢复同步 — {reason}",
"game.config.suggestedSettings": "建议的设置文件",
"game.config.suggestedHint": "根据内容判断,这些文件看起来是本设备的设置而非存档。在你决定之前它们会继续同步:选择保留在本设备后,每台设备将各自保留自己的版本。",
"game.config.keepHere": "保留在本设备",
"game.config.keepSyncing": "继续同步",
"game.config.keptHere": "{file} 现在保留在本设备",
"game.config.keptSyncing": "{file} 将继续同步",
"notifications.clearAll": "清除全部",
"notifications.clearAllTitle": "将这些从铃铛移除,活动页仍保留",
"palette.renameGame": "重命名 {game}"
Expand Down
10 changes: 10 additions & 0 deletions cmd/opensave-app/frontend/src/locales/zh-Hant.json
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,16 @@
"game.config.deviceSettings": "裝置設定",
"game.config.syncDeviceSettings": "也在裝置之間同步這些設定",
"game.config.deviceSettingsHint": "遊戲資料庫將這些檔案列為該遊戲的設定而非存檔,因此每臺裝置保留各自的設定(畫質、解析度)。它們仍包含在每個快照中。",
"game.config.deviceSettingsDetectedHint": "你從建議中確認的設定檔案也會在每臺裝置上各自保留。若某個檔案被發現會隨存檔一起變化,它會自動恢復同步;如需手動同步某個檔案,請在下方「Pick from your save folder」中取消勾選。",
"game.config.sourceDatabase": "遊戲資料庫",
"game.config.sourceDetected": "由你保留在本機 — {reason}",
"game.config.sourceSave": "恢復同步 — {reason}",
"game.config.suggestedSettings": "建議的設定檔案",
"game.config.suggestedHint": "根據內容判斷,這些檔案看起來是本裝置的設定而非存檔。在你決定之前它們會繼續同步:選擇保留在本裝置後,每臺裝置將各自保留自己的版本。",
"game.config.keepHere": "保留在本裝置",
"game.config.keepSyncing": "繼續同步",
"game.config.keptHere": "{file} 現在保留在本裝置",
"game.config.keptSyncing": "{file} 將繼續同步",
"notifications.clearAll": "清除全部",
"notifications.clearAllTitle": "將這些從鈴鐺移除,活動頁仍保留",
"palette.renameGame": "重新命名 {game}"
Expand Down
60 changes: 58 additions & 2 deletions cmd/opensave-app/frontend/src/views/game/ConfigTab.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,17 @@
// 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 ?? [];
// Files found by what they hold: suggested, and still syncing, until
// answered here. A wrong guess must not quietly stop part of a save syncing.
$: suggested = game.suggestedSettings ?? [];
let locations = [];

function decide(s, keepHere) {
const path = s.pattern.replace(/^\//, '');
const done = $t(keepHere ? 'game.config.keptHere' : 'game.config.keptSyncing', { file: path });
return run(done, () => api.post(`/api/games/${game.id}/suggested-settings`, { path, keepHere }));
}

// Cover preview: a custom URL wins, else the proxied Steam art for the App
// ID being edited.
$: cover =
Expand Down Expand Up @@ -166,12 +175,39 @@
can't push them out. <strong>0 = keep forever</strong> (the default).
</span>
</div>
{#if suggested.length > 0}
<div class="field device-block">
<span class="label">{$t('game.config.suggestedSettings')}</span>
<ul class="device-settings">
{#each suggested as s (s.pattern)}
<li>
<code>{s.pattern.replace(/^\//, '')}</code>
<span class="source">{s.reason}</span>
<span class="decide">
<button class="btn small" disabled={$busy} on:click={() => decide(s, true)}>
{$t('game.config.keepHere')}
</button>
<button class="btn small ghost" disabled={$busy} on:click={() => decide(s, false)}>
{$t('game.config.keepSyncing')}
</button>
</span>
</li>
{/each}
</ul>
</div>
<span class="hint device-hint">{$t('game.config.suggestedHint')}</span>
{/if}
{#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 deviceSettings as s}
<li class:given-back={s.source === 'save'}>
<code>{s.pattern.replace(/^!?\//, '')}</code>
<span class="source">
{#if s.source === 'database'}{$t('game.config.sourceDatabase')}{:else if s.source === 'detected'}{$t('game.config.sourceDetected', { reason: s.reason })}{:else}{$t('game.config.sourceSave', { reason: s.reason })}{/if}
</span>
</li>
{/each}
</ul>
</div>
Expand All @@ -180,6 +216,7 @@
{$t('game.config.syncDeviceSettings')}
</label>
<span class="hint device-hint">{$t('game.config.deviceSettingsHint')}</span>
<span class="hint device-hint">{$t('game.config.deviceSettingsDetectedHint')}</span>
{/if}
<SyncIgnoreEditor {game} bind:value={cfg.syncIgnore} severalFolders={locations.length > 0} />
<SaveLocations {game} {runner} bind:locations />
Expand Down Expand Up @@ -235,6 +272,25 @@
.device-settings code {
font-size: 0.9em;
}
.device-settings .source {
font-size: 0.82em;
color: var(--text-faint);
margin-left: 6px;
}
.device-settings .given-back code {
text-decoration: line-through;
}
/* A heading over a list, not a control: styled as the field labels are. */
.device-block .label {
font-size: 0.85rem;
color: var(--text-dim);
font-weight: 500;
}
.device-settings .decide {
display: inline-flex;
gap: 6px;
margin-left: 8px;
}
.device-block {
margin-bottom: 4px !important;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,14 +133,19 @@
<span class="file-name">
{#if f.location}<span class="file-loc">{f.location} ›</span>{/if}{f.path}
</span>
<span class="verdict">{f.excluded ? "won't sync" : 'syncs'}</span>
<span class="verdict" title={f.reason ?? ''}>
{#if !f.excluded}syncs{:else if f.source === 'database'}won't sync · settings (game database){:else if f.source === 'detected'}won't
sync · settings (detected){:else}won't sync{/if}
</span>
</label>
{/each}
</div>
<span class="hint">
Ticking a file writes the pattern for you, anchored so it can only ever mean that
one file. Unticking a file caught by a wildcard adds a <code>!</code> exception
rather than deleting the wildcard.
Files marked <em>settings</em> are left out as this game's device settings; unticking
one syncs it after all.
{#if severalFolders}
A pattern applies to <strong>every one of this game's folders</strong>, so a
name that appears in two of them is excluded in both.
Expand Down
1 change: 1 addition & 0 deletions internal/api/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ func (s *Server) routes(r chi.Router) {
r.Post("/api/games/{gameId}/link", s.handleLinkGame)
r.Get("/api/games/{gameId}/roots", s.handleListGameRoots)
r.Post("/api/games/{gameId}/roots", s.handleAddGameRoot)
r.Post("/api/games/{gameId}/suggested-settings", s.handleDecideSuggestedSettings)
r.Delete("/api/games/{gameId}/roots/{root}", s.handleRemoveGameRoot)
r.Delete("/api/games/{gameId}/alias/{aliasId}", s.handleUnlinkGame)

Expand Down
61 changes: 61 additions & 0 deletions internal/api/routes_savefiles.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
package api

import (
"errors"
"net/http"
"os"
"path/filepath"
"sort"
"strings"

"github.com/go-chi/chi/v5"
"github.com/opensave/opensave/internal/daemon"
"github.com/opensave/opensave/internal/delta"
"github.com/opensave/opensave/internal/ignore"
)
Expand Down Expand Up @@ -38,6 +40,11 @@ type saveFile struct {
MtimeMs int64 `json:"mtimeMs"`
// Excluded reports whether the current rules stop this file syncing.
Excluded bool `json:"excluded"`
// Why: "rule" (the game's own exclusion list), "database" (the game
// database names it as the game's settings) or "detected" (found to be
// its settings by what it holds). Empty when it syncs.
Source string `json:"source,omitempty"`
Reason string `json:"reason,omitempty"`
}

// saveFileListCap bounds the response. A save folder with more files than
Expand Down Expand Up @@ -67,6 +74,25 @@ func (s *Server) handleGameSaveFiles(w http.ResponseWriter, r *http.Request) {
// With the game's device settings, which are left out the same way.
text := s.Daemon.IgnoreText(game)
rules := ignore.Parse(text)
ownRules := ignore.Parse(game.SyncIgnore)
var settings []daemon.DeviceSetting
if !game.SyncDeviceSettings {
settings = s.Daemon.DeviceSettings(game)
}
// which says what excludes a file the rules exclude: the game's own
// rules first, as they apply last; then the last device-settings entry
// that names it.
which := func(path string) (string, string) {
if !ownRules.Empty() && ownRules.Match(path) {
return "rule", ""
}
for i := len(settings) - 1; i >= 0; i-- {
if settings[i].Source != "save" && ignore.Parse(settings[i].Pattern).Match(path) {
return settings[i].Source, settings[i].Reason
}
}
return "rule", ""
}

roots := []struct{ name, path string }{{"", game.SavePath}}
if extra, rootsErr := s.Daemon.Store.ListGameRoots(gameID); rootsErr == nil {
Expand Down Expand Up @@ -96,6 +122,11 @@ func (s *Server) handleGameSaveFiles(w http.ResponseWriter, r *http.Request) {
for _, f := range files {
f.Location = root.name
f.Excluded = rules.Match(f.Path)
if f.Excluded && root.name == "" {
f.Source, f.Reason = which(f.Path)
} else if f.Excluded {
f.Source = "rule"
}
out = append(out, f)
}
}
Expand Down Expand Up @@ -204,3 +235,33 @@ func listSaveFiles(root string, limit int) ([]saveFile, bool) {
}
return out, truncated
}

// handleDecideSuggestedSettings answers a suggested settings file from the
// game's configuration tab: {"path": "...", "keepHere": true} leaves it out of
// syncing as this device's settings, false keeps it syncing for good.
func (s *Server) handleDecideSuggestedSettings(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "gameId")
game, err := s.Daemon.Store.GetGame(gameID)
if err != nil {
writeError(w, http.StatusNotFound, "Game not found.")
return
}
var body struct {
Path string `json:"path"`
KeepHere bool `json:"keepHere"`
}
if err := readJSON(r, &body); err != nil || body.Path == "" {
writeError(w, http.StatusBadRequest, "path is required")
return
}
if err := s.Daemon.DecideSuggestedSettings(game, body.Path, body.KeepHere); err != nil {
if errors.Is(err, daemon.ErrNotSuggested) {
writeError(w, http.StatusConflict, err.Error())
return
}
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.BroadcastGamesUpdate()
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
}
5 changes: 4 additions & 1 deletion internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -566,7 +566,10 @@ func (s *Server) gamePayload(g store.Game) map[string]any {
"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),
"deviceSettings": s.Daemon.DeviceSettings(g),
// Found by what they hold, and waiting to be confirmed; they keep
// syncing meanwhile (daemon.SuggestedSettings).
"suggestedSettings": s.Daemon.SuggestedSettings(g),
"syncDeviceSettings": g.SyncDeviceSettings,
"branches": branches,
"createdAt": g.CreatedAt,
Expand Down
27 changes: 27 additions & 0 deletions internal/daemon/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ func New(opts Options) (*Daemon, error) {

// A game's device settings are left out of syncing (devicesettings.go).
d.P2P.Sync.DeviceSettingsFor = d.deviceSettingsFor
d.P2P.Sync.ObserveSettings = d.observeSettings

// A restore or a branch switch rewrites a save folder; nothing syncing it
// may read it half-way (syncengine/settle.go).
Expand Down Expand Up @@ -274,6 +275,9 @@ func New(opts Options) (*Daemon, error) {
return d.P2P != nil && d.P2P.Sync != nil && d.P2P.Sync.WritingNow(gameID)
},
OnChanged: func(gameID string) {
// How the files left out as its settings changed with it
// (detectsettings.go).
d.observeLocalSettings(gameID)
// An emptied save is noticed as it happens, and said on screen,
// even with no other device online to hold it back from.
_, _ = d.P2P.Sync.CheckHold(gameID, false)
Expand Down Expand Up @@ -459,6 +463,29 @@ func (d *Daemon) Start() error {
}
})

// Find each game's settings by what they hold (detectsettings.go): a few
// minutes after start, then hourly for whatever is due.
d.P2P.GoSync(func(ctx context.Context) {
first := time.NewTimer(newGameFirstScan)
defer first.Stop()
select {
case <-ctx.Done():
return
case <-first.C:
d.detectSettingsDue(ctx)
}
ticker := time.NewTicker(time.Hour)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
d.detectSettingsDue(ctx)
}
}
})

// Look for newly installed games: a few minutes after start, then every
// hour. See newgames.go.
d.P2P.GoSync(func(ctx context.Context) {
Expand Down
Loading
Loading