Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
75 commits
Select commit Hold shift + click to select a range
74476e4
Relay installer strips control characters from secrets
Sep 30, 2026
1c56822
No false emptied hold for a location given its first folder
Sep 30, 2026
da0706c
Window waits for startup before asking for the service address (#17)
Sep 30, 2026
b7c0ab2
Auto-tracked games get a first snapshot and a watch (#16)
Sep 30, 2026
66ca090
A peer coming back online starts one sync of everything (#15)
Sep 30, 2026
2849c8a
Reuse hashing read buffers (#15)
Sep 30, 2026
2b79d18
Coalesce overlapping manifest builds of one folder (#15)
Sep 30, 2026
a68fc14
Changelog for the #15 fixes
Sep 30, 2026
9f7b4f0
Peers can no longer have arbitrary folders tracked; unsigned requests…
Oct 1, 2026
4ba6b8b
2.4.1: changelog and version
Oct 1, 2026
35f5391
E2E: peers' games auto-track only at known save folders
Oct 1, 2026
c053e8f
A Switch save slot in a NAND here is a known save folder
Oct 1, 2026
346d9bd
Changelog for 2.4.1
Oct 2, 2026
afd5b7b
delta: hash cache and manifest walk that scale to saves of many files
ChakraFusion Oct 1, 2026
ae3c2b0
snapshot: copy unchanged files from the previous snapshot instead of …
ChakraFusion Oct 1, 2026
fafb634
sync: stop "Keep mine" deleting files, hold mass deletions, batch sma…
ChakraFusion Oct 1, 2026
297b4ef
Saves on another drive; conflict dialog states what each answer deletes
ChakraFusion Oct 1, 2026
a91527e
app: wait for the daemon before the window asks for its address; smal…
ChakraFusion Oct 1, 2026
10a88e7
presence: do not trust "online" left over from the previous run
ChakraFusion Oct 1, 2026
6eb92be
watcher: changes OpenSave makes itself are not new saves
ChakraFusion Oct 1, 2026
d135694
sync: decide by save versions, not by guessing from files
ChakraFusion Oct 1, 2026
c060252
sync: settle saves from before versions by their files, or by "Use th…
ChakraFusion Oct 1, 2026
e240dc9
frontend: undo package-lock and md5 changes a local build made
ChakraFusion Oct 1, 2026
40db566
sync: devices on an OpenSave without save versions can no longer do d…
ChakraFusion Oct 2, 2026
18f1626
sync: a save from before versions is settled by its most recent work,…
ChakraFusion Oct 2, 2026
e91f203
sync: batches of small files are no longer refused as too large
ChakraFusion Oct 2, 2026
3f85e47
sync: closest devices first, and no fixed 30-second limit on block fe…
ChakraFusion Oct 2, 2026
0ce397a
sync: an unmeasured link is speed-tested again soon; "older version" …
ChakraFusion Oct 2, 2026
f3f0b6b
sync: a long pull measures its link every 30 seconds, not only at its…
ChakraFusion Oct 2, 2026
0ad611f
sync: two old saves with no file in common are asked about, not replaced
ChakraFusion Oct 2, 2026
526b991
sync: a fast link's speed test is kept, not discarded as too short
ChakraFusion Oct 2, 2026
c2cd2f9
snapshots: never of a save a sync is writing, never incomplete
ChakraFusion Oct 2, 2026
6357bcb
snapshots: named by their content, and kept once
ChakraFusion Oct 2, 2026
40d0e55
snapshots: steps on the way to a newer snapshot are removed
ChakraFusion Oct 2, 2026
60c889b
snapshots: steps on the way to a newer snapshot go, largest first, as…
ChakraFusion Oct 2, 2026
67d2383
p2p: drop the reconnect lock 2.4.1 replaced (onlineMu)
ChakraFusion Oct 2, 2026
0672b53
Launcher files, mirror snapshot speed, unwritable folders
ChakraFusion Oct 3, 2026
147a381
remotecache.vdf: excluded like a rule, not left out of manifests
ChakraFusion Oct 3, 2026
9683df7
Keep each device's game settings out of sync
ChakraFusion Oct 3, 2026
3425ba6
Find a game's settings by what they hold, and give back what is a save
ChakraFusion Oct 3, 2026
0441e36
Hold a game being played back from syncing until the session ends
ChakraFusion Oct 3, 2026
db8ace3
See protected games running, and handle a save that keeps changing as…
ChakraFusion Oct 3, 2026
c4b5b00
Find a running game wherever it is installed
ChakraFusion Oct 3, 2026
9764ade
Sessions end when the game does; no delay or checkpoints left behind
ChakraFusion Oct 3, 2026
ee9d18f
Track a game as the game database knows it; folders of Steam's alone …
ChakraFusion Oct 3, 2026
d442237
One folder, one game; link an entry to the game database instead
ChakraFusion Oct 3, 2026
548831b
A save put back is the other device's; emptying is no version until m…
ChakraFusion Oct 3, 2026
6b93271
Give the database's unused space back to the disk
ChakraFusion Oct 3, 2026
1dc38f4
Write the database through a WAL; clear deletion records in one go
ChakraFusion Oct 3, 2026
07baaa9
Restore as many files at a time as the drive does best, with progress
ChakraFusion Oct 3, 2026
cf88373
A process doing nothing for a minute is not the game, whatever it holds
ChakraFusion Oct 3, 2026
a202a93
Settings patterns match with either separator; install-folder test on…
ChakraFusion Oct 3, 2026
d7eec53
Clear deletion records 500 paths to a statement
ChakraFusion Oct 4, 2026
08cd89f
A file OpenSave wrote and someone then deleted is their change
ChakraFusion Oct 4, 2026
fe31e93
A save written straight after a pull is the game's, however soon
ChakraFusion Oct 4, 2026
825a7a2
A save made straight after a pull is a new version, not the pull unfi…
ChakraFusion Oct 4, 2026
fd93691
A pulled file is marked again before it is re-dated to the peer's time
ChakraFusion Oct 4, 2026
4bf604e
iopar: the drive-adaptation test gets three runs
ChakraFusion Oct 4, 2026
f9f8d51
release: fork builds are named and described as unofficial test builds
ChakraFusion Oct 4, 2026
fd44b1e
A file deleted or changed right after a pull, untouched by it, is a c…
ChakraFusion Oct 4, 2026
c17cff0
release: the fork build's text names its own version
ChakraFusion Oct 4, 2026
cd8f59c
snapshots: an identical newer copy keeps the name of the one it replaces
ChakraFusion Oct 4, 2026
cbc54e9
store: upstream's migration numbers, and a fork database renamed to them
ChakraFusion Oct 4, 2026
cb06b66
sync: a deletion in an extra location survives a lost sync-complete r…
ChakraFusion Oct 13, 2026
c8ed0af
sync: an extra location's record keeps deletions in flight, as the ma…
ChakraFusion Oct 13, 2026
851c69e
relay: a reply that never comes fails after its timeout, not the sync's
ChakraFusion Oct 13, 2026
b8cae01
snapshots: no second copy of a save already kept (#56), and versions …
ChakraFusion Oct 14, 2026
a378006
snapshots: a save location change takes no snapshot unless it brings …
ChakraFusion Oct 14, 2026
9b0c9cf
store: apply a new database's migrations in one transaction
claude Oct 14, 2026
8d48171
e2e: make temp folders before the daemons that use them
claude Oct 15, 2026
d431b29
sync: a report stamps the devices synced as of when it compared them
ChakraFusion Oct 15, 2026
a69a306
e2e: the late-report test edits after the skew allowance, not inside it
claude Oct 14, 2026
9343dae
sync: an emptied save started syncing mid-write is still held back
claude Oct 15, 2026
c95b5e3
sync: a lost report no longer brings back a file deleted right after
claude Oct 15, 2026
c8ad612
sync: an emptied location's put-back survives the push-proof repair
claude Oct 15, 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
23 changes: 13 additions & 10 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -562,27 +562,30 @@ jobs:
- name: Publish release
uses: softprops/action-gh-release@v2
with:
name: OpenSave ${{ github.ref_name }}
# Fork test builds (ChakraFusion/OpenSave): named and described as
# unofficial, so nobody takes one for a Liquid-co release.
name: Unofficial fork test build ${{ github.ref_name }}
# Hyphenated tags (v2.1.0-beta.1) publish as pre-releases, so
# /releases/latest ignores them. Only devices on the beta channel
# are offered these: either the user ticked Settings -> Updates, or
# they are already running a pre-release (see selfupdate).
prerelease: ${{ contains(github.ref_name, '-') }}
# Note: re-publishing the same tag appends another generated-notes
# block; if a tag is ever re-cut, tidy the release body by hand.
generate_release_notes: true
# `body` is placed above the generated notes, so the invitation is
# the first thing in every release rather than buried under a
# commit list.
generate_release_notes: false
body: |
### 💬 Questions, bug reports, or just want to say hello?
> [!WARNING]
> **Unofficial test build** from the fork [ChakraFusion/OpenSave](https://github.com/ChakraFusion/OpenSave), branch `fixes-2.4.1`. It is **not** a release of [Liquid-co/OpenSave](https://github.com/Liquid-co/OpenSave). Please don't report problems with it to the OpenSave project or its Discord.

**[Join the OpenSave Discord →](https://discord.gg/hvBv92DZvn)**
OpenSave 2.4.1 with the fixes and features proposed upstream: data-safety fixes in sync, save versions, play sessions, per-device settings, snapshot integrity, network-aware sync, and faster sync and restore of large saves. The full list, with one pull request per topic, is in the overview issue on Liquid-co/OpenSave.

It is the fastest way to get help, and where new builds get
discussed before they ship.
**Tested:** Windows, on four PCs over LAN and VPN. Linux, macOS, Flatpak and the Decky plugin are built by the same workflow and pass the unit and end-to-end tests on Linux, but haven't been tried by hand.

---
**Updates:** the built-in update check still points at Liquid-co/OpenSave. This build calls itself `${{ github.ref_name }}`, a pre-release, so an official release can be offered as an update, which returns you to the official build.

**Before trying it:** OpenSave keeps a snapshot before replacing any save, but back up saves you care about all the same. Devices on this build and on official 2.4.1 can be paired; nothing is taken from a device without save versions, and its deletions are refused.

Changes since v2.4.1: https://github.com/ChakraFusion/OpenSave/compare/v2.4.1...fixes-2.4.1
fail_on_unmatched_files: false
files: |
windows/**/*.exe
Expand Down
59 changes: 59 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,65 @@
All notable changes to OpenSave are documented here. This project adheres to
[Semantic Versioning](https://semver.org/).

## [2.4.1] — 2026-10-02

A security update: please install it on every device. A paired device could
make the one it synced with track any folder and share it; now a game from
another device is only tracked by itself at a folder this one already knows
as a save folder, and requests for save data must be signed. Devices still on
2.3 need updating to keep syncing, and devices paired over the internet before
2.4.0 need to be paired again.

### Security

- **A paired device can no longer reach folders that aren't game saves
(CVE-2026-103398).** When another device synced a game this one didn't
have yet, OpenSave tracked it at whatever folder that device named, and
then served that folder's files to it and accepted files into it. Only
whole drives, profiles and system folders were refused, so a malicious or
impersonated device could read or overwrite other files you can access.
Now a game arriving from another device is tracked by itself only at a
folder this device's own scan recognises as a save folder, or inside an
emulator's save folder here. Anything else appears on Home as offered,
for you to place in a folder you choose. Reported by mansurmavlankulov.
- **Requests for save data must now be signed with the key from pairing.**
A device that had never signed a request, and pairings made over the
internet before 2.4.0 (which never kept a key), were let through on their
word or their network address. Both are now refused. **Update every
device to 2.4.1**, and if two devices were paired over the internet before
2.4.0, unpair them and pair them again. Unpairing still works without a
signature, so an old pairing can always be cleared.

### Fixed

- **Restoring onto a new PC no longer holds a game back for nothing.** After
a backup from the old PC was restored, giving one of a game's other save
locations its folder on the new machine read as every file there deleted:
the game was held back, asking whether that was meant, instead of fetching
the files from your other devices. What a location held before now counts
only from when it got the folder it has.
- **The first launch after boot no longer fails to reach OpenSave.** With a
large library, starting up takes several seconds on a cold disk, and the
window asked for OpenSave's address before there was one: it said it
"can't reach OpenSave's background service at http://" and Retry worked.
The window now waits for startup to finish. Reported with the cause
pinned down in GitHub #17.
- **A game another device brings over gets a history here too.** A game
tracked because a paired device synced it got no first snapshot and no
watch. Where the save was already the same on both devices, as Steam Cloud
often leaves them, nothing was ever pulled, so that device never had a
snapshot of the game at all; and until the periodic check came round, a
change made there was not sent on. It now gets both, as a game tracked by
hand does (GitHub #16).
- **A save of a great many files no longer wears the machine down.** A
device coming back online started a sync of every game once for each
request it sent — some twenty at once — instead of once; a folder was walked
again by everything that asked about it at the same moment, where they now
share one walk; and hashing allocated a fresh buffer for every file, which
for a Project Zomboid save of 238,000 files came to gigabytes per pass.
2.4's hash cache had already stopped most of the re-reading behind the
report (GitHub #15).

## [2.4.0] — 2026-09-30

Everything from the four 2.4 betas below, now stable. Coming from 2.3.1, it
Expand Down
40 changes: 38 additions & 2 deletions cmd/opensave-app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,16 +38,38 @@ type App struct {
reallyQuit bool
updatedFrom string // previous version when this run is the first on a new build
notifyErr error // why desktop notifications cannot be shown, where known (notify_other.go)
// ready is closed when startup has finished, however it finished. The
// window loads while startup is still running, and what startup sets —
// the address, the boot error, the version updated from — is read only
// after this, never while it is being written (see started).
ready chan struct{}
}

// NewApp creates the App shell (daemon boots in startup).
func NewApp() *App {
return &App{}
return &App{ready: make(chan struct{})}
}

// startupWait is how long the window waits for startup before saying it is
// taking too long. Generous: a large library on a cold disk takes a while to
// watch, and every second of it is better spent waiting than showing an error
// that Retry would clear.
var startupWait = 3 * time.Minute

// started waits for startup to finish, and says whether it did in time.
func (a *App) started() bool {
select {
case <-a.ready:
return true
case <-time.After(startupWait):
return false
}
}

// startup boots the daemon + local API server once the webview exists.
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
defer close(a.ready)

// Asked to stop a running OpenSave, and we ARE the only one: there is
// nothing to stop. Exit without booting a daemon, claiming a port or
Expand Down Expand Up @@ -224,7 +246,7 @@ func (a *App) ChangelogReleases() []changelog.Release {
// Empty when this isn't the first run on a new build, so the UI has a single
// condition to check rather than reimplementing the comparison.
func (a *App) WhatsNew() []changelog.Release {
if a.updatedFrom == "" {
if !a.started() || a.updatedFrom == "" {
return nil
}
return changelog.Since(changelog.Parse(opensave.Changelog), a.updatedFrom)
Expand All @@ -233,12 +255,26 @@ func (a *App) WhatsNew() []changelog.Release {
// UpdateGreeting reports the version this install was just updated FROM
// ("" normally) so the UI can announce the update and offer the changelog.
func (a *App) UpdateGreeting() map[string]string {
if !a.started() {
return map[string]string{"updatedFrom": ""}
}
return map[string]string{"updatedFrom": a.updatedFrom}
}

// DaemonAddr returns the local API address (host:port) or an error string
// if the daemon failed to boot.
//
// It waits for startup to finish. The window asks as soon as it loads, and
// with many games to watch startup is still running then; answering at once
// gave it an empty address, and the first launch after boot failed with
// "can't reach OpenSave's background service at http://" while Retry worked
// (GitHub #17). Bound methods are called asynchronously, so the wait only
// holds the window's first request back until there is an answer to give.
func (a *App) DaemonAddr() map[string]string {
if !a.started() {
return map[string]string{"error": "OpenSave's background service is still starting after " +
startupWait.String() + " — its log (opensave.log in the .opensave folder) says what it is doing"}
}
if a.bootErr != "" {
return map[string]string{"error": a.bootErr}
}
Expand Down
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
Loading
Loading