Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 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
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
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
37 changes: 36 additions & 1 deletion cmd/opensave-app/frontend/src/views/game/GameHeader.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
import Play from 'lucide-svelte/icons/play';
import RefreshCw from 'lucide-svelte/icons/refresh-cw';
import Upload from 'lucide-svelte/icons/upload';
import FolderOpen from 'lucide-svelte/icons/folder-open';
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 All @@ -42,6 +44,20 @@
const syncNow = () => run('Sync triggered', () => api.post(`/api/games/${game.id}/sync`));
const launchGame = () => run('Launching…', () => api.post(`/api/games/${game.id}/launch`));

// For when the devices hold states nobody can rank — a copy that arrived
// only half-way, a mixture of two saves — and the person knows which device
// has the right one. Every other device takes this save whole, keeping a
// snapshot of its own first; one that changed the save on its own since is
// still asked.
async function useEverywhere() {
const ok = await askConfirm(
`Make this device's save of ${game.name} the one every other device uses? They each replace theirs with it — every file, including removing files this device does not have — and keep a snapshot of their own first. A device where the save was changed after this update is asked instead.`,
{ title: 'Use this save everywhere?', confirmText: 'Use this save everywhere', danger: true }
);
if (!ok) return;
await run(`${game.name}: this save goes to your other devices`, () => api.post(`/api/games/${game.id}/use-everywhere`));
}

let editPath = false;
let pathDraft = '';

Expand Down Expand Up @@ -137,6 +153,7 @@
{#if canLaunch(game)}
<button class="btn" disabled={$busy} on:click={launchGame}><Play size={15} />Launch</button>
{/if}
<button class="btn" disabled={$busy} on:click={useEverywhere} title="Every other device replaces its save of this game with this one"><Upload size={15} />Use this save everywhere</button>
<button class="btn primary" disabled={$busy} on:click={syncNow}><RefreshCw size={15} />Sync now</button>
</div>
</div>
Expand Down Expand Up @@ -188,7 +205,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 Down Expand Up @@ -288,6 +316,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
17 changes: 17 additions & 0 deletions cmd/opensave-app/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"os"
"runtime"
"runtime/debug"

"github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options"
Expand Down Expand Up @@ -45,6 +46,8 @@ func main() {
// removes its leftover binary) before claiming the single-instance lock.
cleanupReplacedBinary()

tuneGC()

app := NewApp()

err := wails.Run(&options.App{
Expand Down Expand Up @@ -97,3 +100,17 @@ func main() {
os.Exit(1)
}
}

// tuneGC trades a little CPU for a smaller footprint. OpenSave is a
// background app, and Go's default (let the heap double before collecting)
// meant a large save's manifests showed up as twice their size in Task
// Manager. The soft limit makes the runtime collect and hand memory back to
// the OS harder as it approaches 1GiB. Explicit GOGC / GOMEMLIMIT win.
func tuneGC() {
if os.Getenv("GOGC") == "" {
debug.SetGCPercent(50)
}
if os.Getenv("GOMEMLIMIT") == "" {
debug.SetMemoryLimit(1 << 30)
}
}
Loading