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
5 changes: 4 additions & 1 deletion cmd/opensave-app/frontend/src/lib/gamestatus.js
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions cmd/opensave-app/frontend/src/lib/gamestatus.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
9 changes: 8 additions & 1 deletion cmd/opensave-app/frontend/src/lib/notifications.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,14 @@ 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) {
// 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) {
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 } } });
}
}
Expand Down
14 changes: 14 additions & 0 deletions cmd/opensave-app/frontend/src/lib/notifications.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -57,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' } });
});
});
62 changes: 61 additions & 1 deletion cmd/opensave-app/frontend/src/views/game/GameHeader.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -69,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() {
Expand Down Expand Up @@ -188,7 +209,18 @@
</div>
{/if}

{#if game.savePathMissing && !editPath}
{#if game.savePathMissing && game.saveDriveMissing && !editPath}
<!-- The drive itself isn't on this device: the game lives on another PC's
D:\ or E:\. Nothing was lost here, so this informs rather than warns. -->
<div class="missing elsewhere" role="status">
<Info size={16} />
<div>
<strong>This game isn't on this device.</strong>
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.
</div>
</div>
{:else if game.savePathMissing && !editPath}
<!-- The folder is not there. It is not created again: an empty folder in
its place would read as every file deleted, and syncing that would
delete them on your other devices too. -->
Expand All @@ -203,6 +235,22 @@
</div>
{/if}

{#if game.savePathMissing && game.relocateSuggestion && !editPath}
<!-- Offered, not taken: the same path on another drive can be an old
backup, and syncing from it would spread stale saves. -->
<div class="missing relocate" role="status">
<Info size={16} />
<div>
<strong>The same folder is at {game.relocateSuggestion}.</strong>
Is that where this game saves on this device? Only use it if it holds your current saves, not an old copy.
<div class="relocate-actions">
<button class="btn small primary" disabled={relocating} on:click={() => answerRelocation(true)}>Use this folder</button>
<button class="btn small ghost" disabled={relocating} on:click={() => answerRelocation(false)}>Not this one</button>
</div>
</div>
</div>
{/if}

<style>
.title-row {
display: flex;
Expand Down Expand Up @@ -280,6 +328,11 @@
font-size: 0.84rem;
color: var(--text-dim);
}
.relocate-actions {
display: flex;
gap: 8px;
margin-top: 8px;
}
.missing :global(svg) {
flex-shrink: 0;
margin-top: 2px;
Expand All @@ -288,6 +341,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;
Expand Down
34 changes: 34 additions & 0 deletions internal/api/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ func (s *Server) routes(r chi.Router) {
r.Post("/api/games/{gameId}/branch/switch", s.handleSwitchBranch)
r.Delete("/api/games/{gameId}/branch/{branch}", s.handleDeleteBranch)
r.Post("/api/games/{gameId}/launch", s.handleLaunchGame)
r.Post("/api/games/{gameId}/relocate", s.handleAcceptRelocation)
r.Post("/api/games/{gameId}/relocate/dismiss", s.handleDismissRelocation)

r.Post("/api/backup/export", s.handleBackupExport)
r.Post("/api/backup/restore", s.handleBackupRestore)
Expand Down Expand Up @@ -1036,3 +1038,35 @@ func (s *Server) handleSuggestName(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, http.StatusOK, map[string]string{"name": name})
}

// handleAcceptRelocation switches a game whose save folder is missing to the
// same folder on another drive, offered to the user (relocateSuggestion).
// body.path must be the folder offered, so an answer to an older question
// cannot switch to a folder nobody was shown.
func (s *Server) handleAcceptRelocation(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "gameId")
var body struct {
Path string `json:"path"`
}
if err := readJSON(r, &body); err != nil || body.Path == "" {
writeError(w, http.StatusBadRequest, "path is required")
return
}
if err := s.Daemon.AcceptRelocation(gameID, body.Path); err != nil {
writeError(w, http.StatusConflict, err.Error())
return
}
s.BroadcastGamesUpdate()
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
}

// handleDismissRelocation answers the offer with "not this one".
func (s *Server) handleDismissRelocation(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "gameId")
if err := s.Daemon.DismissRelocation(gameID); err != nil {
writeError(w, http.StatusConflict, err.Error())
return
}
s.BroadcastGamesUpdate()
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
}
15 changes: 11 additions & 4 deletions internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -567,10 +567,17 @@ 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),
// The same folder found on another drive here, offered and not yet
// answered (daemon.RelocateSuggestion); "" when there is none.
"relocateSuggestion": s.Daemon.RelocateSuggestion(g.ID),
"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.
Expand Down
29 changes: 29 additions & 0 deletions internal/cliapp/cliapp.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ func Run(args []string) int {
return cmdConflicts(rest)
case "resolve":
return cmdResolve(rest)
case "relocate":
return cmdRelocate(rest)
case "offers":
return cmdOffers(rest)
case "backup":
Expand Down Expand Up @@ -760,12 +762,16 @@ func cmdStatus(d *daemon.Daemon, args []string) int {
}

playing := runningDaemonPlaying()
relocations := runningDaemonRelocations()
section(fmt.Sprintf("Tracked games %s %d", symDot(), len(games)))
for _, g := range games {
fmt.Printf(" %s %s %s\n", symBullet(), bold(g.Name), faint(g.ID))
fmt.Printf(" %s\n", faint(g.SavePath))
if daemon.SaveFolderMissing(g.SavePath) {
fmt.Printf(" %s\n", warnText("save folder missing — nothing is watched or synced for it until it is back"))
if found := relocations[g.ID]; found != "" {
fmt.Printf(" %s\n", warnText(fmt.Sprintf("the same folder is at %s — use it? (opensave relocate %s use|dismiss)", found, g.ID)))
}
}
if e, held := d.EmptiedSaveOf(g.ID); held && e.State == "held" {
fmt.Printf(" %s\n", warnText(fmt.Sprintf("every save file was deleted here — not synced until you answer (opensave emptied %s delete|restore)", g.ID)))
Expand Down Expand Up @@ -1053,3 +1059,26 @@ func emptiedOrNil(d *daemon.Daemon, gameID string) *daemon.EmptiedSave {
}
return nil
}

// runningDaemonRelocations is, per game, the folder the running daemon offers
// for a save folder that is missing (the same path on another drive). Empty
// when no daemon is running: the offer lives there.
func runningDaemonRelocations() map[string]string {
out := map[string]string{}
raw, err := daemonRequest("GET", "/api/games", nil)
if err != nil {
return out
}
var games map[string]struct {
RelocateSuggestion string `json:"relocateSuggestion"`
}
if json.Unmarshal(raw, &games) != nil {
return out
}
for id, g := range games {
if g.RelocateSuggestion != "" {
out[id] = g.RelocateSuggestion
}
}
return out
}
44 changes: 44 additions & 0 deletions internal/cliapp/commands_relocate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package cliapp

import (
"fmt"
"net/url"
"os"
)

const relocateUsage = `usage: opensave relocate <gameId> use use the folder offered for a missing save folder
opensave relocate <gameId> dismiss not that one — it is not offered again while OpenSave runs`

// cmdRelocate answers the offer the daemon makes when a game's save folder is
// missing and the same path exists on another drive here: use that folder, or
// not. Never switched without this answer — it could be an old copy.
func cmdRelocate(args []string) int {
asJSON, args := jsonFlag(args)
if len(args) != 2 || (args[1] != "use" && args[1] != "dismiss") {
fmt.Fprintln(os.Stderr, relocateUsage)
return 1
}
gameID := args[0]
found := runningDaemonRelocations()[gameID]
if found == "" {
return fail(asJSON, fmt.Errorf("no folder is offered for %s (OpenSave must be running, and the save folder missing)", gameID))
}
path := "/api/games/" + url.PathEscape(gameID) + "/relocate"
var body any = map[string]string{"path": found}
if args[1] == "dismiss" {
path, body = path+"/dismiss", nil
}
raw, err := daemonRequest("POST", path, body)
if err != nil {
return fail(asJSON, err)
}
if asJSON {
return emitRawJSON(raw)
}
if args[1] == "use" {
success("%s now uses %s on this device.", bold(gameID), found)
} else {
success("Not using %s for %s.", found, bold(gameID))
}
return 0
}
1 change: 1 addition & 0 deletions internal/cliapp/usage.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ var commandGroups = []commandGroup{
{"storage", "Space per game, the biggest snapshots, what prune frees; --compact shares unchanged files now"},
{"verify [<gameId>] [--repair|--remove-damaged]", "Check every snapshot can still be restored; repair from the cloud, or remove what cannot be"},
{"emptied [<gameId> delete|restore]", "Saves emptied here, held back from your other devices; answer for one"},
{"relocate <gameId> use|dismiss", "A missing save folder found on another drive here: use it, or not"},
{"files <gameId> <snapId> [path]", "List a snapshot's contents, or restore one file"},
{"export <gameId> <dir>", "Copy the current save out to a folder"},
{"backup export <file.sscb>", "Write a portable backup archive"},
Expand Down
7 changes: 7 additions & 0 deletions internal/daemon/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,13 @@ 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
// relocateSuggested: the folder offered for a missing one, per game;
// relocateDismissed: the one answered "not this one" (missing.go).
relocateSuggested map[string]string
relocateDismissed map[string]string

// Play sessions; see sessions.go.
sessions sessionState
Expand Down
Loading
Loading