Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
97fdd89
sync: "Keep mine" no longer deletes files; hold syncs that would dele…
ChakraFusion Oct 3, 2026
fe9ce64
sync: batch small files, skip unchanged manifests, probe presence on …
ChakraFusion Oct 1, 2026
cf9269a
presence: do not trust "online" left over from the previous run
ChakraFusion Oct 1, 2026
9cbe583
sync: closest devices first, and no fixed 30-second limit on block fe…
ChakraFusion Oct 2, 2026
d9e7933
sync: an unmeasured link is speed-tested again soon
ChakraFusion Oct 2, 2026
02f0ceb
sync: a long pull measures its link every 30 seconds, not only at its…
ChakraFusion Oct 2, 2026
f2189ab
sync: a fast link's speed test is kept, not discarded as too short
ChakraFusion Oct 2, 2026
0acaac3
delta: hash cache and manifest walk that scale to saves of many files
ChakraFusion Oct 1, 2026
93354a4
snapshot: copy unchanged files from the previous snapshot instead of …
ChakraFusion Oct 1, 2026
829f65a
Find a save folder on another drive letter; a drive this PC lacks is …
ChakraFusion Oct 3, 2026
7731db8
app: smaller memory footprint (GOGC 50, 1 GiB soft limit)
ChakraFusion Oct 1, 2026
40c7675
watcher: changes OpenSave makes itself are not new saves
ChakraFusion Oct 1, 2026
33a6abf
sync: batched pulls tell owntouch what they write
ChakraFusion Oct 4, 2026
c0cf59d
sync: decide by save versions, not by guessing from files
ChakraFusion Oct 1, 2026
ad6271a
sync: settle saves from before versions by their files, or by "Use th…
ChakraFusion Oct 1, 2026
c722f55
sync: devices on an OpenSave without save versions can no longer do d…
ChakraFusion Oct 2, 2026
d0f946d
sync: a save from before versions is settled by its most recent work,…
ChakraFusion Oct 2, 2026
33a0a26
sync: two old saves with no file in common are asked about, not replaced
ChakraFusion Oct 2, 2026
0a1dc67
sync: "older version" is said once per state, not every 20 seconds
ChakraFusion Oct 4, 2026
959e0c5
A save made straight after a pull is a new version, not the pull unfi…
ChakraFusion Oct 4, 2026
ee2524b
A file deleted or changed right after a pull, untouched by it, is a c…
ChakraFusion Oct 4, 2026
8b30b75
sync: taking a version reads the save it just wrote directly
ChakraFusion Oct 4, 2026
523d07c
e2e: branchHolds reads a branch's newest snapshot
ChakraFusion Oct 4, 2026
f6a761c
snapshots: never of a save a sync is writing, never incomplete
ChakraFusion Oct 2, 2026
64dfb58
snapshots: named by their content, and kept once
ChakraFusion Oct 2, 2026
9953d58
snapshots: steps on the way to a newer snapshot are removed
ChakraFusion Oct 2, 2026
5a3cf62
snapshots: steps on the way to a newer snapshot go, largest first, as…
ChakraFusion Oct 2, 2026
df54d03
snapshots: an identical newer copy keeps the name of the one it replaces
ChakraFusion Oct 4, 2026
2de4c63
snapshots: the check skips a snapshot removed while it ran
ChakraFusion Oct 4, 2026
4601f92
Launcher files, mirror snapshot speed, unwritable folders
ChakraFusion Oct 3, 2026
e7f48b8
remotecache.vdf: excluded like a rule, not left out of manifests
ChakraFusion Oct 3, 2026
600ba15
Keep each device's game settings out of sync
ChakraFusion Oct 3, 2026
44bbdb4
Find a game's settings by what they hold, and give back what is a save
ChakraFusion Oct 3, 2026
73f8f36
Settings patterns match with either separator
ChakraFusion Oct 4, 2026
146ce0a
Hold a game being played back from syncing until the session ends
ChakraFusion Oct 3, 2026
fa4fcb9
See protected games running, and handle a save that keeps changing as…
ChakraFusion Oct 3, 2026
84e86c1
Find a running game wherever it is installed
ChakraFusion Oct 3, 2026
b4a2383
Sessions end when the game does; no delay or checkpoints left behind
ChakraFusion Oct 3, 2026
c91fa1e
A process doing nothing for a minute is not the game, whatever it holds
ChakraFusion Oct 3, 2026
6166cad
sessions: the install-folder test builds native paths
ChakraFusion Oct 4, 2026
b6f299b
Track a game as the game database knows it; folders of Steam's alone …
ChakraFusion Oct 3, 2026
ec48ed7
One folder, one game; link an entry to the game database instead
ChakraFusion Oct 3, 2026
9ea1050
A save put back is the other device's; emptying is no version until m…
ChakraFusion Oct 3, 2026
fb38b17
Give the database's unused space back to the disk
ChakraFusion Oct 3, 2026
94d6106
Write the database through a WAL; clear deletion records in one go
ChakraFusion Oct 3, 2026
efac5a7
Clear deletion records 500 paths to a statement
ChakraFusion Oct 4, 2026
4e66faf
Restore as many files at a time as the drive does best, with progress
ChakraFusion Oct 3, 2026
23b17b7
iopar: the drive-adaptation test gets three runs
ChakraFusion Oct 4, 2026
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
90 changes: 84 additions & 6 deletions cmd/opensave-app/frontend/src/components/ConflictModal.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script>
import { dialogOut } from '../lib/motion.js';
import { conflicts, conflictResolution, games, peers, settings, toast, view } from '../lib/stores.js';
import { conflicts, conflictResolution, games, peers, settings, toast, view, askConfirm } from '../lib/stores.js';
import { isHandheld } from '../lib/devices.js';
import { visited } from '../lib/later.js';
import { api } from '../lib/api.js';
Expand Down Expand Up @@ -103,9 +103,16 @@
$: diffFiles = conflict?.diffFiles ?? [];
$: diffTotal = conflict?.diffTotal ?? 0;
$: diffCapped = diffTotal > diffFiles.length;
$: changedCount = diffFiles.filter((d) => d.status === 'changed').length;
$: onlyLocalCount = diffFiles.filter((d) => d.status === 'only-local').length;
$: onlyRemoteCount = diffFiles.filter((d) => d.status === 'only-remote').length;
// The uncapped counts when the device sends them; counted from the capped
// list otherwise (an older build), which is exact below the cap.
const countOr = (n, status) => (typeof n === 'number' ? n : diffFiles.filter((d) => d.status === status).length);
$: changedCount = countOr(conflict?.changedTotal, 'changed');
$: onlyLocalCount = countOr(conflict?.onlyLocalTotal, 'only-local');
$: onlyRemoteCount = countOr(conflict?.onlyRemoteTotal, 'only-remote');
// "Keep theirs" deletes every file only this device has. Said before the
// click, in numbers, and loudly once it is a lot.
$: theirsDeletes = onlyLocalCount;
$: theirsDeletesMany = theirsDeletes >= 100;

// Bytes that differ, per side: a changed file counts its own size on each
// side, a file present on only one side counts only there.
Expand Down Expand Up @@ -159,6 +166,31 @@
}
}

// Neither side is the right save — a copy that arrived half-way, a mixture
// — and this device holds the right one: it goes to every device, which
// each keep a snapshot of their own first. A device on which the save was
// changed after the update is still asked.
async function useEverywhere() {
if (!current || busy) return;
const [gameId] = current;
const name = gameName;
const ok = await askConfirm(
`Make this device's save of ${name} the one every device uses, ${peerName} included? Each replaces its save with this one — every file, including removing files this device does not have — and keeps a snapshot of its own first.`,
{ title: 'Use this save everywhere?', confirmText: 'Use this save everywhere', danger: true }
);
if (!ok) return;
busy = true;
try {
await api.post(`/api/games/${gameId}/use-everywhere`);
toast(`${name}: this save goes to your other devices`, 'success');
showDiff = false;
} catch (e) {
toast(e.message, 'error');
} finally {
busy = false;
}
}

const fmtTime = (t) => (t ? new Date(t).toLocaleString() : '—');
const fmtMs = (ms) => (ms ? new Date(ms).toLocaleString() : 'unknown');
const fmtSize = (n) =>
Expand Down Expand Up @@ -217,12 +249,28 @@
<div class="v-time">last change {fmtMs(remoteMs)}</div>
</div>
</div>
{#if diffCapped}
{#if diffCapped && typeof conflict.onlyLocalTotal !== 'number'}
<p class="diff-capped">
Counts above cover the first {diffFiles.length} of {diffTotal} differing files.
</p>
{/if}

<ul class="consequences">
<li>
<strong>Keep mine</strong> / <strong>Keep both</strong>: nothing is deleted.
{#if onlyRemoteCount > 0}The {onlyRemoteCount} file{onlyRemoteCount === 1 ? '' : 's'} only on {peerName} {onlyRemoteCount === 1 ? 'is' : 'are'} brought here;{/if}
{#if changedCount > 0}where both have a file, yours is kept.{/if}
</li>
<li class:danger={theirsDeletesMany}>
<strong>Keep theirs</strong>:
{#if theirsDeletes > 0}
<strong>deletes {theirsDeletes} file{theirsDeletes === 1 ? '' : 's'}</strong> that only this device has (snapshotted first).
{:else}
nothing only this device has is lost.
{/if}
</li>
</ul>

{#if conflict.diffTotal > 0}
<button class="diff-toggle" on:click={() => (showDiff = !showDiff)}>
<Chevron open={showDiff} /> What's different ({conflict.diffTotal} file{conflict.diffTotal === 1 ? '' : 's'})
Expand Down Expand Up @@ -250,11 +298,16 @@
<div class="actions">
<button class="btn ghost later" disabled={busy} on:click={putOff} title="Nothing of this game syncs until you decide. Open the game to decide.">Decide later</button>
<button class="btn" disabled={busy} on:click={() => resolve('keep-local')}>Keep mine</button>
<button class="btn" disabled={busy} on:click={() => resolve('keep-remote')}>Keep theirs</button>
<button class="btn" class:danger={theirsDeletesMany} disabled={busy} on:click={() => resolve('keep-remote')}
title={theirsDeletes > 0 ? `Deletes ${theirsDeletes} file(s) only this device has` : ''}>Keep theirs</button>
<button class="btn primary" disabled={busy} on:click={() => resolve('merge-branch')}>
Keep both (recommended)
</button>
</div>
<p class="hint-line">
Neither is the right save, and this device has it?
<button class="linkish" disabled={busy} on:click={useEverywhere}>Use this device's save everywhere</button>
</p>
<p class="hint-line">
<History size={15} class="inline-icon" /> Nothing is lost whichever you pick. <strong>“Keep both”</strong> (recommended) keeps
playing yours — {peerName} receives it too — and keeps {peerName}'s version here on a branch you can switch
Expand Down Expand Up @@ -362,6 +415,22 @@
color: var(--text-faint);
margin: -8px 0 12px;
}
.consequences {
margin: 0 0 14px;
padding-left: 18px;
font-size: 0.8rem;
color: var(--text-dim);
}
.consequences li {
margin: 2px 0;
}
.consequences li.danger,
.btn.danger {
color: var(--danger, #e5484d);
}
.btn.danger {
border-color: currentColor;
}
.v-time {
font-size: 0.74rem;
color: var(--text-faint);
Expand Down Expand Up @@ -451,4 +520,13 @@
color: var(--text-faint);
line-height: 1.5;
}
.linkish {
background: none;
border: none;
padding: 0;
font: inherit;
color: var(--accent-text);
text-decoration: underline;
cursor: pointer;
}
</style>
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
3 changes: 2 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,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 } } });
}
}
Expand Down
5 changes: 5 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
23 changes: 23 additions & 0 deletions cmd/opensave-app/frontend/src/lib/restoreprogress.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// How far a restore has come, in words: the phase, percent and, once it can
// be told, roughly how long is left (daemon/restoreprogress.go).

const phases = {
rebuilding: 'Rebuilding the snapshot',
checking: 'Checking the snapshot',
writing: 'Writing the files'
};

export function restoreProgressText(p) {
if (!p || !p.phase) return '';
const what = phases[p.phase] ?? 'Restoring';
const pct = p.total > 0 ? Math.floor((p.done / p.total) * 100) : 0;
let text = `${what} — ${pct}% (${p.done.toLocaleString()} of ${p.total.toLocaleString()})`;
if (p.secondsLeft > 0) text += `, ${timeLeft(p.secondsLeft)} left`;
return text;
}

function timeLeft(s) {
if (s < 60) return 'under a minute';
const m = Math.round(s / 60);
return m === 1 ? 'about a minute' : `about ${m} minutes`;
}
17 changes: 17 additions & 0 deletions cmd/opensave-app/frontend/src/lib/restoreprogress.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { describe, it, expect } from 'vitest';
import { restoreProgressText } from './restoreprogress.js';

describe('restoreProgressText', () => {
it('says nothing when no restore is under way', () => {
expect(restoreProgressText(null)).toBe('');
});
it('says the phase, percent and time left', () => {
const t = restoreProgressText({ phase: 'writing', done: 60000, total: 240000, secondsLeft: 150 });
expect(t).toContain('Writing the files');
expect(t).toContain('25%');
expect(t).toContain('about 3 minutes left');
});
it('leaves out the time while it cannot be told', () => {
expect(restoreProgressText({ phase: 'checking', done: 10, total: 100 })).not.toContain('left');
});
});
3 changes: 3 additions & 0 deletions cmd/opensave-app/frontend/src/lib/snapshots.js
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@ export function snapshotKind(snap, now = new Date()) {
if (/^initial snapshot$/i.test(comment)) return { kind: 'start', label: 'Automatic', title: 'When tracking started' };
// The save as a play session left it (daemon/sessions.go).
if (/^after playing/i.test(comment)) return { kind: 'session', label: 'After playing', title: comment };
// Taken while a game was played, in case the session never got to end;
// removed once it did (daemon/sessions.go).
if (/^checkpoint while playing/i.test(comment)) return { kind: 'session', label: 'While playing', title: 'Checkpoint while playing' };
if (safetyPattern.test(comment)) {
return { kind: 'safety', label: 'Safety copy', title: plainTitle(comment) };
}
Expand Down
4 changes: 4 additions & 0 deletions cmd/opensave-app/frontend/src/lib/snapshots.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ describe('snapshotKind', () => {
});
});

it('names the checkpoints taken while a game is played', () => {
expect(snapshotKind(auto('Checkpoint while playing'))).toMatchObject({ kind: 'session', title: 'Checkpoint while playing' });
});

it('says a save changed for the watcher’s own snapshots', () => {
expect(snapshotKind(auto(''))).toMatchObject({ kind: 'auto', title: 'Save changed' });
expect(snapshotKind(auto('Auto backup'))).toMatchObject({ kind: 'auto', title: 'Save changed' });
Expand Down
19 changes: 19 additions & 0 deletions cmd/opensave-app/frontend/src/views/Devices.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,15 @@
};

const fmtTime = (t) => (t ? new Date(t).toLocaleString() : 'never');

// How this device reaches the peer, and how fast it has found that to be.
const linkKinds = { lan: 'home network', vpn: 'VPN (e.g. Tailscale)', relay: 'internet relay', internet: 'internet' };
const fmtRate = (b) => (b >= 1048576 ? (b / 1048576).toFixed(1) + ' MB/s' : Math.max(1, Math.round(b / 1024)) + ' KB/s');
function linkLabel(link) {
const kind = linkKinds[link.kind] ?? link.kind;
if (!link.bytesPerSec) return `${kind} · speed not measured yet`;
return `${kind} · ${fmtRate(link.bytesPerSec)} (measured ${fmtTime(link.measuredMs)})`;
}
</script>

<div class="head">
Expand All @@ -99,12 +108,22 @@
<span class="badge" class:online={peer.status === 'online'} class:offline={peer.status !== 'online'}>
{peer.status}
</span>
{#if peer.needsUpdate}
<span class="badge warn" title="Its OpenSave keeps no save versions: nothing is taken from it, and its deletions are refused, until it is updated">
needs update
</span>
{/if}
</div>
<div class="peer-meta">
{peer.address === 'relay' ? 'internet relay' : `${peer.address}:${peer.port}`}
· last synced {fmtTime(peer.lastSynced)}
{#if peer.appVersion}· OpenSave {peer.appVersion}{/if}
</div>
{#if peer.link}
<div class="peer-meta" title="Saves go to the fastest connections first, and a device that is behind takes the newer save from the fastest device that has it.">
{linkLabel(peer.link)}
</div>
{/if}
<!-- Per device, next to how it is reached, because that is the
context the answer depends on. -->
<div class="peer-protection">
Expand Down
8 changes: 8 additions & 0 deletions cmd/opensave-app/frontend/src/views/Home.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import { gameStatus, conflictedIds } from '../lib/gamestatus.js';
import OfferedGames from './home/OfferedGames.svelte';
import AddGameCard from './home/AddGameCard.svelte';
import TrackGameDialog from './home/TrackGameDialog.svelte';
import ScanDialog from './home/ScanDialog.svelte';
import HomeSummary from './home/HomeSummary.svelte';
import LibraryGrid from './home/LibraryGrid.svelte';
Expand All @@ -30,6 +31,8 @@
// on the params object, so each request — the sidebar's +, Ctrl+K — opens
// it once, and not again on every re-render.
let showAdd = false;
// Tracking a game the game database knows (TrackGameDialog).
let showTrackGame = false;
let addPath = '';
let handledAdd = null;
$: if (params.add && params !== handledAdd) {
Expand Down Expand Up @@ -108,6 +111,7 @@
</button>
<button class="btn" on:click={syncAll} disabled={$gameList.length === 0 || $syncPause.paused} title={$syncPause.paused ? 'Syncing is paused' : ''}><RefreshCw size={15} />Sync all</button>
<PauseButton />
<button class="btn" on:click={() => (showTrackGame = true)} title="Pick a running game, find one by name, or choose its program"><Gamepad2 size={16} />Track game</button>
<button class="btn" on:click={trackFolder} title="Pick a save folder or file to track"><FolderPlus size={16} />Track folder</button>
</div>
</div>
Expand All @@ -118,6 +122,10 @@
<AddGameCard path={addPath} on:close={() => (showAdd = false)} />
{/if}

{#if showTrackGame}
<TrackGameDialog on:close={() => (showTrackGame = false)} />
{/if}

<ScanDialog bind:this={scanner} bind:scanning />

{#if !$stateLoaded}
Expand Down
Loading