From 32acb693e899460a90c00763c20643bef96ee27a Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Sun, 4 Oct 2026 01:49:32 +0200 Subject: [PATCH 1/2] Find a save folder on another drive letter; a drive this PC lacks is no warning - A missing save folder is looked for at the identical path on every other drive letter (a Steam library on D: on one PC and E: on another). Exactly one match is adopted for this device and logged; none or several are logged once. Searched at most once an hour. - A save folder on a drive this device does not have is shown as "Not on this device" (muted) instead of a warning, kept out of the warning headline and the notification bell. A folder gone from a drive that exists is still a warning. Co-Authored-By: Claude Opus 5.5 --- .../frontend/src/lib/gamestatus.js | 5 +- .../frontend/src/lib/gamestatus.test.js | 12 ++ .../frontend/src/lib/notifications.js | 3 +- .../frontend/src/lib/notifications.test.js | 5 + .../frontend/src/views/game/GameHeader.svelte | 21 ++- internal/api/server.go | 12 +- internal/daemon/daemon.go | 3 + internal/daemon/missing.go | 123 +++++++++++++++++- internal/daemon/missing_test.go | 40 ++++++ internal/daemon/relocate_test.go | 82 ++++++++++++ 10 files changed, 298 insertions(+), 8 deletions(-) create mode 100644 internal/daemon/missing_test.go create mode 100644 internal/daemon/relocate_test.go diff --git a/cmd/opensave-app/frontend/src/lib/gamestatus.js b/cmd/opensave-app/frontend/src/lib/gamestatus.js index 4d920c01..2be2f839 100644 --- a/cmd/opensave-app/frontend/src/lib/gamestatus.js +++ b/cmd/opensave-app/frontend/src/lib/gamestatus.js @@ -21,6 +21,9 @@ import { playLength } from './format.js'; export function gameStatus(game, { peers = {}, activity, conflicted = false, now = Date.now() } = {}) { // Before anything else: with its folder gone nothing else about it can // move — no snapshot, no sync, no decision taken. + // …unless the whole drive is absent here: the game's saves live on another + // device's D:\ or E:\, and there is nothing on this one to worry about. + if (game.savePathMissing && game.saveDriveMissing) return { state: 'elsewhere', label: 'Not on this device', tone: 'muted' }; if (game.savePathMissing) return { state: 'missing', label: 'Save folder missing', tone: 'warn' }; // Every save file deleted here at once, held back from the other devices // until someone says whether that was meant (lib/emptied.js). @@ -177,7 +180,7 @@ const stamp = (s) => (s ? Date.parse(s) : null); const snapshotsOf = (game) => Object.values(game.branches ?? {}).flatMap((b) => b.snapshots ?? []); // What needs looking at, in the order the summary above the library reads it. -const URGENCY = { missing: 0, emptied: 1, conflict: 2, error: 3, syncing: 4, restoring: 4, unsynced: 5, empty: 6, paused: 7 }; +const URGENCY = { missing: 0, emptied: 1, conflict: 2, error: 3, syncing: 4, restoring: 4, unsynced: 5, empty: 6, paused: 7, elsewhere: 10 }; // unlisted states (synced, local…) rank 9 /** * The orders the library can be put in. Each compares two rows, {game, diff --git a/cmd/opensave-app/frontend/src/lib/gamestatus.test.js b/cmd/opensave-app/frontend/src/lib/gamestatus.test.js index d144943c..810b197b 100644 --- a/cmd/opensave-app/frontend/src/lib/gamestatus.test.js +++ b/cmd/opensave-app/frontend/src/lib/gamestatus.test.js @@ -119,6 +119,18 @@ describe('a missing save folder', () => { expect(librarySummary(rows)).toEqual({ tone: 'warn', headline: "Hades's save folder is missing" }); expect(sortRows(rows, 'attention').map((r) => r.game.name)).toEqual(['Hades', 'Celeste']); }); + + it('on a drive this device does not have is not a warning, and stays out of the headline', () => { + const g = game({ id: 'a', name: 'ARK', savePathMissing: true, saveDriveMissing: true }); + const s = gameStatus(g); + expect(s).toMatchObject({ state: 'elsewhere', tone: 'muted', label: 'Not on this device' }); + const rows = [ + { game: g, status: s }, + { game: game({ id: 'c', name: 'Celeste', branches: snaps(minsAgo(5)) }), status: { state: 'synced' } } + ]; + expect(librarySummary(rows).tone).not.toBe('warn'); + expect(sortRows(rows, 'attention').map((r) => r.game.name)).toEqual(['Celeste', 'ARK']); + }); }); describe('SORTS', () => { diff --git a/cmd/opensave-app/frontend/src/lib/notifications.js b/cmd/opensave-app/frontend/src/lib/notifications.js index e8633fd5..ece29614 100644 --- a/cmd/opensave-app/frontend/src/lib/notifications.js +++ b/cmd/opensave-app/frontend/src/lib/notifications.js @@ -46,7 +46,8 @@ export function waitingOnYou({ games = {}, conflicts = {}, locationConflicts = [ out.push({ id: `offer:${o.gameId}:${o.snapshotId}`, tone: 'info', gameId: o.gameId, title: `A newer save for ${o.gameName ?? name(o.gameId)}`, detail: `From ${o.deviceName ?? 'another device'}, through the cloud`, go: { view: 'home' } }); } for (const g of Object.values(games)) { - if (g.savePathMissing) { + // A drive this device doesn't have is not waiting on anyone. + if (g.savePathMissing && !g.saveDriveMissing) { out.push({ id: `missing:${g.id}`, tone: 'warn', gameId: g.id, title: `${g.name}'s save folder is missing`, detail: 'Nothing is synced for it until it is back', go: { view: 'game', params: { gameId: g.id } } }); } } diff --git a/cmd/opensave-app/frontend/src/lib/notifications.test.js b/cmd/opensave-app/frontend/src/lib/notifications.test.js index a5828324..1e32a9f7 100644 --- a/cmd/opensave-app/frontend/src/lib/notifications.test.js +++ b/cmd/opensave-app/frontend/src/lib/notifications.test.js @@ -21,6 +21,11 @@ describe('notifications', () => { expect(w.find((x) => x.id.startsWith('damaged')).title).toBe("1 snapshot of Hades can't be restored"); }); + it('does not ask about a game whose drive this device does not have', () => { + const w = waitingOnYou({ games: { ark: { id: 'ark', name: 'ARK', savePathMissing: true, saveDriveMissing: true, branches: {} } } }); + expect(w).toEqual([]); + }); + it('turns activity into events, unread since the bell was last opened', () => { const now = Date.UTC(2026, 8, 25, 12); const items = [ diff --git a/cmd/opensave-app/frontend/src/views/game/GameHeader.svelte b/cmd/opensave-app/frontend/src/views/game/GameHeader.svelte index 19750020..8eb05a2e 100644 --- a/cmd/opensave-app/frontend/src/views/game/GameHeader.svelte +++ b/cmd/opensave-app/frontend/src/views/game/GameHeader.svelte @@ -17,6 +17,7 @@ import Pencil from 'lucide-svelte/icons/pencil'; import Star from 'lucide-svelte/icons/star'; import TriangleAlert from 'lucide-svelte/icons/triangle-alert'; + import Info from 'lucide-svelte/icons/info'; import { collections, isFavourite, toggleFavourite } from '../../lib/collections.js'; export let game; @@ -188,7 +189,18 @@ {/if} -{#if game.savePathMissing && !editPath} +{#if game.savePathMissing && game.saveDriveMissing && !editPath} + +
+ +
+ This game isn't on this device. + Its saves are on {game.savePath.slice(0, 2)}, which this PC doesn't have, so it's skipped here and keeps syncing + between your other devices. If it is installed here, point the game at its save folder with Edit. +
+
+{:else if game.savePathMissing && !editPath} @@ -288,6 +300,13 @@ .missing strong { color: var(--text); } + .missing.elsewhere { + border-color: var(--border); + background: transparent; + } + .missing.elsewhere :global(svg) { + color: var(--text-faint); + } .emptied-actions { display: flex; flex-wrap: wrap; diff --git a/internal/api/server.go b/internal/api/server.go index 66a32562..c1c2e167 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -567,10 +567,14 @@ func (s *Server) gamePayload(g store.Game) map[string]any { // 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), - "lastPlayedAt": lastPlayed, - "playingSince": playingSince, - "playtimeMs": play.PlaytimeMs, - "playSessions": play.Sessions, + // …and of those, the ones whose whole drive is absent here: the game + // lives on another device's D:\ or E:\, which is not a problem to + // warn about (daemon.SaveDriveMissing). + "saveDriveMissing": daemon.SaveFolderMissing(g.SavePath) && daemon.SaveDriveMissing(g.SavePath), + "lastPlayedAt": lastPlayed, + "playingSince": playingSince, + "playtimeMs": play.PlaytimeMs, + "playSessions": play.Sessions, // Whether the game is installed on this device: "found", "not-found", // or "" when there is no telling (daemon.InstallState). A game that is // not here is one nobody plays here, which is why it shows no play. diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 306ce6ff..5683cd88 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -62,6 +62,9 @@ type Daemon struct { // Games whose save folder is not there; see missing.go. missingMu sync.Mutex missing map[string]bool + // relocateChecked is when each missing save folder was last looked for on + // another drive (missing.go). + relocateChecked map[string]time.Time // Play sessions; see sessions.go. sessions sessionState diff --git a/internal/daemon/missing.go b/internal/daemon/missing.go index aa083447..b55c7b1f 100644 --- a/internal/daemon/missing.go +++ b/internal/daemon/missing.go @@ -6,6 +6,8 @@ import ( "io/fs" "os" "path/filepath" + "strings" + "time" "github.com/opensave/opensave/internal/delta" "github.com/opensave/opensave/internal/logging" @@ -34,10 +36,126 @@ func SaveFolderMissing(savePath string) bool { return errors.Is(err, fs.ErrNotExist) } +// SaveDriveMissing reports whether a save path lives on a drive this device +// does not have at all — a D:\ or E:\ Steam library that exists on another +// PC. That is where a game auto-tracked from a peer lands when the peer's +// library is on a drive letter this machine lacks, and it is not something +// to warn about: nothing was lost here, the game just isn't on this device. +// Only a volume that is plainly absent counts; an unreachable network share +// reports some other error and stays a warning. +func SaveDriveMissing(savePath string) bool { + vol := filepath.VolumeName(savePath) + if vol == "" { + return false + } + _, err := os.Stat(vol + string(filepath.Separator)) + return errors.Is(err, fs.ErrNotExist) +} + +// The same save folder under another drive letter. +// +// A save inside a game's install folder — a Steam library, most often — sits +// on whichever drive that library is on, and that differs between devices: D: +// on one, E: on another. A game tracked on one device and auto-tracked on +// another arrives with the first device's drive letter, and on the second the +// folder is simply "missing" though it is right there on another drive. +// +// So a missing folder is looked for at the identical path on every other drive +// letter. Exactly one match is adopted for this device, and said once. None, or +// more than one (which would be a guess), is said once too, and the search is +// not repeated more often than relocateRecheck: it touches every drive, and the +// minute-long watch reconcile asks about missing folders constantly. +const relocateRecheck = time.Hour + +// relocateCandidates lists the folders at savePath's exact path under the other +// drive letters that exist here. Windows drive letters only. +func relocateCandidates(savePath string) []string { + vol := filepath.VolumeName(savePath) + if len(vol) != 2 || vol[1] != ':' { + return nil + } + rest := savePath[2:] + own := strings.ToUpper(vol[:1]) + var found []string + for c := 'C'; c <= 'Z'; c++ { + letter := string(c) + if letter == own { + continue + } + candidate := letter + ":" + rest + if !SaveFolderMissing(candidate) { + found = append(found, candidate) + } + } + return found +} + +// relocateToOtherDrive switches a game whose save folder is missing to the same +// path on another drive, when there is exactly one. Reports whether it did. +func (d *Daemon) relocateToOtherDrive(gameID, name, savePath string) bool { + d.missingMu.Lock() + last, checked := d.relocateChecked[gameID] + if checked && time.Since(last) < relocateRecheck { + d.missingMu.Unlock() + return false + } + if d.relocateChecked == nil { + d.relocateChecked = map[string]time.Time{} + } + d.relocateChecked[gameID] = time.Now() + d.missingMu.Unlock() + + if name == "" { + name = gameID + } + found := relocateCandidates(savePath) + switch { + case len(found) == 0: + if !checked { + d.Log.Log("info", fmt.Sprintf("the save folder of %q is not on another drive here either (looked for %s on every drive letter)", + name, logging.Quote(savePath[min(2, len(savePath)):]))) + } + return false + case len(found) > 1: + if !checked { + d.Log.Log("warn", fmt.Sprintf("the save folder of %q is missing at %s, and the same path exists on %d other drives (%s) — not guessing which; set it with Edit", + name, logging.Quote(savePath), len(found), strings.Join(found, ", "))) + } + return false + } + + newPath, err := d.ValidateSavePath(found[0]) + if err != nil { + d.Log.Log("warn", fmt.Sprintf("the save folder of %q exists at %s, but it cannot be used: %v", name, logging.Quote(found[0]), err)) + return false + } + game, err := d.Store.GetGame(gameID) + if err != nil || game.SavePath != savePath { + return false // changed meanwhile; the next reconcile sees the new path + } + game.SavePath = newPath + if err := d.Store.UpdateGame(game); err != nil { + d.Log.Log("warn", fmt.Sprintf("could not switch %q to %s: %v", name, logging.Quote(newPath), err)) + return false + } + d.Log.Log("success", fmt.Sprintf("the save folder of %q was not at %s but is at %s on this device — using that", + name, logging.Quote(savePath), logging.Quote(newPath))) + d.missingMu.Lock() + delete(d.missing, gameID) + d.missingMu.Unlock() + if d.OnGameChanged != nil { + d.OnGameChanged(gameID) + } + return true +} + // noteMissing records whether a game's save folder is missing, and says so // once when that changes — not on every minute's check — so the log and the // screen follow the folder going and coming back. func (d *Daemon) noteMissing(gameID, name, savePath string, missing bool) { + if missing && d.relocateToOtherDrive(gameID, name, savePath) { + return // found under another drive letter and switched to it + } d.missingMu.Lock() was := d.missing[gameID] if missing { @@ -55,7 +173,10 @@ func (d *Daemon) noteMissing(gameID, name, savePath string, missing bool) { if name == "" { name = gameID } - if missing { + if missing && SaveDriveMissing(savePath) { + d.Log.Log("info", fmt.Sprintf("the save folder of %q is on %s, which this device doesn't have (%s) — the game is skipped here "+ + "and keeps syncing between the devices that have it", name, filepath.VolumeName(savePath), logging.Quote(savePath))) + } else if missing { d.Log.Log("warn", fmt.Sprintf("the save folder of %q is not there (%s) — it is not watched or synced until it is back; "+ "OpenSave will not create it, since an empty folder in its place would read as every file deleted", name, logging.Quote(savePath))) } else { diff --git a/internal/daemon/missing_test.go b/internal/daemon/missing_test.go new file mode 100644 index 00000000..0b5fdf6c --- /dev/null +++ b/internal/daemon/missing_test.go @@ -0,0 +1,40 @@ +package daemon + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +// A save path on a drive letter this machine does not have is "not on this +// device", not "folder gone": the UI informs rather than warns about it. +func TestSaveDriveMissing(t *testing.T) { + existing := t.TempDir() + if SaveDriveMissing(filepath.Join(existing, "saves")) { + t.Error("a folder on an existing drive was reported as on a missing drive") + } + if SaveDriveMissing("relative/path") { + t.Error("a path without a volume was reported as on a missing drive") + } + + if runtime.GOOS != "windows" { + return + } + // Find a drive letter that is not mounted. + for c := 'Z'; c >= 'D'; c-- { + root := string(c) + `:\` + if _, err := os.Stat(root); err == nil { + continue + } + p := string(c) + `:\SteamLibrary\steamapps\common\Game\save` + if !SaveDriveMissing(p) { + t.Errorf("%s: drive %c: is absent but was not reported missing", p, c) + } + if !SaveFolderMissing(p) { + t.Errorf("%s: SaveFolderMissing should still report the folder missing", p) + } + return + } + t.Skip("every drive letter D-Z is mounted") +} diff --git a/internal/daemon/relocate_test.go b/internal/daemon/relocate_test.go new file mode 100644 index 00000000..152755fc --- /dev/null +++ b/internal/daemon/relocate_test.go @@ -0,0 +1,82 @@ +package daemon + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/opensave/opensave/internal/store" +) + +// freeDriveLetter returns a drive letter not mounted here, or "". +func freeDriveLetter() string { + for c := 'Z'; c >= 'D'; c-- { + if _, err := os.Stat(string(c) + `:\`); err != nil { + return string(c) + } + } + return "" +} + +// A game tracked elsewhere with its save on a drive this device lacks, while +// the identical path exists on another drive here, is switched to it — once. +func TestRelocateToOtherDrive(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("drive letters are a Windows thing") + } + free := freeDriveLetter() + if free == "" { + t.Skip("no free drive letter") + } + d := newTestDaemon(t) + real := filepath.Join(t.TempDir(), "SteamLibrary", "steamapps", "common", "Game", "save") + if err := os.MkdirAll(real, 0o777); err != nil { + t.Fatal(err) + } + elsewhere := free + ":" + real[2:] // same path, absent drive + if err := d.Store.CreateGame(store.Game{ID: "g", Name: "Game", SavePath: elsewhere, ActiveBranch: "main", AutoSync: true}); err != nil { + t.Fatal(err) + } + + d.noteMissing("g", "Game", elsewhere, true) + + g, err := d.Store.GetGame("g") + if err != nil { + t.Fatal(err) + } + if !strings.EqualFold(g.SavePath, real) { + t.Fatalf("save path = %s, want it switched to %s", g.SavePath, real) + } + d.missingMu.Lock() + stillMissing := d.missing["g"] + d.missingMu.Unlock() + if stillMissing { + t.Error("the game is still marked missing after being found") + } +} + +// Not found: said once, and not searched again on every reconcile. +func TestRelocateNotFound_IsNotRepeated(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("drive letters are a Windows thing") + } + free := freeDriveLetter() + if free == "" { + t.Skip("no free drive letter") + } + d := newTestDaemon(t) + path := free + `:\NoSuchLibrary\steamapps\common\Nothing\save` + if err := d.Store.CreateGame(store.Game{ID: "n", Name: "Nothing", SavePath: path, ActiveBranch: "main", AutoSync: true}); err != nil { + t.Fatal(err) + } + if d.relocateToOtherDrive("n", "Nothing", path) { + t.Fatal("relocated to a folder that does not exist") + } + first := d.relocateChecked["n"] + d.relocateToOtherDrive("n", "Nothing", path) + if !d.relocateChecked["n"].Equal(first) { + t.Error("searched again straight away; it should wait relocateRecheck") + } +} From bb361851eb9f265eccae34ba38b2d5db17ada646 Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:24:16 +0200 Subject: [PATCH 2/2] Ask before using a save folder found on another drive As asked in review (Liquid-co/OpenSave#24): the same path on another drive can be an old backup copy, and switching to it silently would sync stale saves to every device. The folder found is offered, not adopted: - daemon: exactly one match is remembered as a suggestion and said once in the log, with the command that answers it. The game stays missing - not watched, not synced - until someone accepts. AcceptRelocation switches only to the folder offered; DismissRelocation answers "not this one", which is then not offered again while OpenSave runs. The folder coming back where it was clears the offer. - API: relocateSuggestion in each game; POST /api/games/{id}/relocate {path} and /relocate/dismiss. - app: the game's header asks "The same folder is at E:\... - use it?" with Use this folder / Not this one, and the notification bell lists it as waiting for an answer. - CLI: `opensave relocate use|dismiss`, and `opensave status` shows the offer. The "not on this device" part is unchanged. Co-Authored-By: Claude Opus 5.5 --- .../frontend/src/lib/notifications.js | 6 + .../frontend/src/lib/notifications.test.js | 9 ++ .../frontend/src/views/game/GameHeader.svelte | 41 +++++++ internal/api/routes.go | 34 ++++++ internal/api/server.go | 11 +- internal/cliapp/cliapp.go | 29 +++++ internal/cliapp/commands_relocate.go | 44 ++++++++ internal/cliapp/usage.go | 1 + internal/daemon/daemon.go | 4 + internal/daemon/missing.go | 106 ++++++++++++++---- internal/daemon/relocate_test.go | 74 ++++++++++-- 11 files changed, 325 insertions(+), 34 deletions(-) create mode 100644 internal/cliapp/commands_relocate.go diff --git a/cmd/opensave-app/frontend/src/lib/notifications.js b/cmd/opensave-app/frontend/src/lib/notifications.js index ece29614..8bb495a1 100644 --- a/cmd/opensave-app/frontend/src/lib/notifications.js +++ b/cmd/opensave-app/frontend/src/lib/notifications.js @@ -45,6 +45,12 @@ export function waitingOnYou({ games = {}, conflicts = {}, locationConflicts = [ for (const o of cloudOffers) { out.push({ id: `offer:${o.gameId}:${o.snapshotId}`, tone: 'info', gameId: o.gameId, title: `A newer save for ${o.gameName ?? name(o.gameId)}`, detail: `From ${o.deviceName ?? 'another device'}, through the cloud`, go: { view: 'home' } }); } + for (const g of Object.values(games)) { + // The same folder found on another drive here: offered, waiting for a yes. + if (g.savePathMissing && g.relocateSuggestion) { + out.push({ id: `relocate:${g.id}:${g.relocateSuggestion}`, tone: 'warn', gameId: g.id, title: `${g.name}'s save folder may be at ${g.relocateSuggestion}`, detail: 'Confirm it before it is used', go: { view: 'game', params: { gameId: g.id } } }); + } + } for (const g of Object.values(games)) { // A drive this device doesn't have is not waiting on anyone. if (g.savePathMissing && !g.saveDriveMissing) { diff --git a/cmd/opensave-app/frontend/src/lib/notifications.test.js b/cmd/opensave-app/frontend/src/lib/notifications.test.js index 1e32a9f7..27487862 100644 --- a/cmd/opensave-app/frontend/src/lib/notifications.test.js +++ b/cmd/opensave-app/frontend/src/lib/notifications.test.js @@ -62,3 +62,12 @@ describe('a save arriving', () => { expect(arrivalMessage({ ...ev, kind: 'sent' }, games, {}, t0)).toBeNull(); }); }); + +describe('a missing save folder found on another drive', () => { + it('waits for a yes, whether or not the drive it was on exists', () => { + const w = waitingOnYou({ games: { ark: { id: 'ark', name: 'ARK', savePathMissing: true, saveDriveMissing: true, relocateSuggestion: 'E:\SteamLibrary\ARK', branches: {} } } }); + const r = w.find((x) => x.id.startsWith('relocate:')); + expect(r?.title).toContain('E:\SteamLibrary\ARK'); + expect(r?.go).toEqual({ view: 'game', params: { gameId: 'ark' } }); + }); +}); diff --git a/cmd/opensave-app/frontend/src/views/game/GameHeader.svelte b/cmd/opensave-app/frontend/src/views/game/GameHeader.svelte index 8eb05a2e..3db66f67 100644 --- a/cmd/opensave-app/frontend/src/views/game/GameHeader.svelte +++ b/cmd/opensave-app/frontend/src/views/game/GameHeader.svelte @@ -70,6 +70,26 @@ answering = false; } } + // The save folder is missing here, and the same folder exists on another drive. + // It is offered, never switched to on its own: it could be an old copy. + let relocating = false; + async function answerRelocation(use) { + const found = game.relocateSuggestion; + relocating = true; + try { + if (use) { + await api.post(`/api/games/${game.id}/relocate`, { path: found }); + toast(`${game.name} now uses ${found}`, 'success'); + } else { + await api.post(`/api/games/${game.id}/relocate/dismiss`); + toast(`Not using ${found} for ${game.name}`, 'success'); + } + } catch (e) { + toast(e.message, 'error'); + } finally { + relocating = false; + } + } // Reveal the save location in Explorer / Finder / the Linux file manager. // The bridge returns a message when it can't (e.g. the folder was deleted). async function openSaveFolder() { @@ -215,6 +235,22 @@ {/if} +{#if game.savePathMissing && game.relocateSuggestion && !editPath} + +
+ +
+ The same folder is at {game.relocateSuggestion}. + Is that where this game saves on this device? Only use it if it holds your current saves, not an old copy. +
+ + +
+
+
+{/if} +