Skip to content

Save sources over SSH/SFTP - #33

Closed
DionJones615 wants to merge 10 commits into
valentinps:mainfrom
DionJones615:feat/desktop-save-source-ssh
Closed

DionJones615 wants to merge 10 commits into
valentinps:mainfrom
DionJones615:feat/desktop-save-source-ssh

Conversation

@DionJones615

Copy link
Copy Markdown

Provenance

Same change as DionJones615/satisfactory-live-map#4, which was merged into that fork; its branch was deleted after the merge, so this re-creates it on top of upstream main (the fork's main is exactly upstream main + this). Nothing here depends on the fork.

What

Instead of asking the gaming PC to host its save folder over HTTP, this loads the .sav off that machine with what it already has: its OpenSSH server. A mounted SMB/NFS share is the second transport.

The pitch is what is not added: no listener on the game machine, no port opened for this app, no service to keep running, no password stored. ssh, sftp and rsync run as subprocesses, so host keys, keys, ssh-agent, ~/.ssh/config and ProxyJump are all OpenSSH's, and the app ships no SSH stack to keep current.

Upstream had an SFTP sync panel in the Flask era (sftp_config.json: host, password, remote_path, sync_interval_seconds); it was deleted with the Python runtime in f0ea245. This brings the capability back client-side, key/agent only — BatchMode=yes means no password is ever asked for or stored.

One dialog, three steps: Your save → From another machine

  1. Protocol — SSH/SFTP or a mounted share, each explained in a line.
  2. Connection — the answers that protocol needs, then Validate connection: the login, whether the SFTP subsystem answers (browsing and the change check depend on it), whether the far side has rsync. A failure shows the ssh client's own words and the one thing to try next — refused key, unknown host key, dead port, a server with no SFTP subsystem. That is the "debug" half of this feature, and it is what turns "it didn't work" into a fix.
  3. Save to load — the folder, walked like a file dialog (folders first, .sav marked, click to descend, Up for the parent, or type a path and press Open), then the file. It opens at the game's usual folder when the platform is known and otherwise at the remote home directory, so there is always something to walk from; a guess that turns out not to exist falls back there and says so.

Only the current step is laid out, the stepper's numbers go back, focus lands on the first field of each step, and a remembered source reopens at the file it was left on. Remote names are rendered as text, never as markup.

Naming: "Save file" reads as a verb phrase, so the step is Save to load and the sidebar panel it opens from is Your save — a determiner, so the noun reading is the only reading.

Changes are pushed, not polled, whenever the machine can push them

inotifywait -m runs on the machine that holds the folder — locally or over the ssh connection — and every name it prints wakes the same check; on Windows it is PowerShell's FileSystemWatcher, sent as a base64 -EncodedCommand (UTF-16LE), the only quoting that survives cmd.exe. Nothing is polled while events are live.

The interval stays for the two cases where events are impossible, and the dialog says which one you got:

  • no inotifywait on that machine (Fedora/Bazzite: rpm-ostree install inotify-tools; Debian: apt install inotify-tools). Windows needs nothing — the watcher is part of .NET.
  • a mounted share on Linux, where inotify can never fire: it only reports writes made through this kernel's own VFS, so the game machine writing into an SMB or NFS share is invisible — silent rather than late, which is worse. The filesystem type is checked with findmnt before choosing. Windows is the exception: FileSystemWatcher does receive SMB notifications.

An event is a nudge, not a verdict: the burst a save write produces is debounced, then the folder is re-listed and the fetched bytes are hashed, so a missed or spurious event cannot reload the map wrongly. A watcher that dies at once — no inotifywait, no such folder, a refused key — reports the tool's own words and falls back to the interval with that reason on screen. Reloading is refused while the editor holds pending edits, while a parse runs, and while the window is hidden.

A save opened from this machine

The same watch applies to a .sav opened from disk — the file dialog, a drag-drop, or the dialog's mounted-share transport. A Keep it fresh switch appears under the loaded save in the sidebar (desktop only: a browser File object has no path to watch), driving the same machinery; source_watch_start picks the mechanism itself when the caller doesn't know it, which is what a local path needs. A file you picked means that file, so when a strictly newer save lands in the folder the status line names it and the dialog's Newest .sav toggle is one click away.

Shape

Three commands for the dialog's three jobs — source_validate, source_browse, source_fetch — plus source_watch_start / source_watch_stop for the event watcher (a long-lived child process, local or over ssh, kept in Tauri state because a webview cannot hold one). The watch probe is a source_browse: a listing is what says both what is in the folder and whether it moved, so there is no separate stat command. main.rs grew ~130 lines; the SSH work lives in one module (ssh_source.rs) with its tests.

Trust boundaries kept: every remote path crosses into an SFTP batch script, so control characters and quotes are rejected at the boundary; a host, user or port that could be read as an option is rejected; a fetched save is written to a .part file and renamed only when complete, capped at 8 GiB.

Tested against the real machine

bazzite@172.27.30.50 (Bazzite, 1 ms away), which is where the game actually runs — the app built from this branch was pointed at it and works:

Step Result
validate ok — SFTP, rsync present, platform Linux, home /var/home/bazzite, 166 saves, newest No.42_autosave_1.sav
browse the real folder, names/sizes/timestamps
fetch over SFTP 195,826 bytes, byte-identical
fetch over rsync same bytes, same SHA-256
change check a file added on the far side is seen by the next listing
event watcher inotifywait is not installed there, so the probe chose polling and the dialog shows inotifywait: command not found before falling back

The real layout is <SaveGames>/<steamid64>/*.sav with the dedicated server's 116-byte ServerManager_V2.sav beside it, so the browser walks into the only folder when that folder is the most recently written thing — otherwise "newest .sav" picks a file that is not a save.

Other checks: 11 unit tests over verbatim sftp -b - output (the batch-mode echo, the ? link count OpenSSH never sends, full-path names, Jan 3 2025 double-spaced dates) and the Windows-shaped rows; a live run against a local sshd covering validate/browse/stat/fetch over sftp and rsync, names with spaces, change detection and every refusal path, plus both watchers (a write produced an event locally, and one on the far side arrived over the connection); the module and the command bodies type-checked against real serde/sha2; the base64/UTF-16LE encoder checked against iconv | base64 byte for byte, with the decoded PowerShell read back; and a handler audit (27 registered, none missing — the same audit finds startup_url undefined in #2).

Trying it

python3 tools/build_site.py --skip-wasm      # dist/ is embedded at compile time
cd rust_parser && cargo build -p sav_tauri --release
./target/release/sav_tauri

docs/remote-save-ssh.md has the distrobox recipe for the Linux -devel packages, the Windows build, the sshd setup for the game machine, and a note for hosts where -devel packages cannot be installed at all. tools/serve_save_source_demo.py serves the dialog with a stubbed bridge for anyone who wants to look at its states without a build.

Known gaps

  • Windows sshd was not exercised — the Windows-shaped listing rows are fixtures and the FileSystemWatcher script is verified by decoding, not against a real Windows host.
  • rsync is only usable when the far side has rsync; a stock Windows box does not, so SFTP is the default there.
  • The watch baseline is a listing timestamp (minute resolution): two saves in the same minute with the same size read as unchanged. The byte hash cannot rescue a probe that never fires. Autosave intervals make this a non-issue in practice.
  • The first tick after a manual load over SSH re-fetches once to establish the baseline.
  • If the app is force-killed while watching, the ssh child can outlive it until the remote closes the channel.
  • What bottom panel update + coordinate fix #2 does better: a .sav URL is shareable and launchable from a shortcut, and HTTP needs no sshd on the game machine. The two can coexist — separate commands, separate dialogs.

DionJones615 and others added 10 commits October 10, 2026 10:36
The desktop shell can browse and load the .sav from the machine that runs the
game using what that machine already has -- its OpenSSH server -- with a
mounted SMB/NFS share as the second transport. Nothing listens on the game
machine and no port is opened for it: `ssh`, `sftp` and `rsync` are run as
subprocesses, so host keys, keys, ssh-agent, `~/.ssh/config` and ProxyJump stay
OpenSSH's business and the app ships no SSH stack to keep current. (Upstream
had an SFTP sync panel in the Flask era, with the password in a config file; it
went with the Python runtime. This brings the capability back client-side,
key/agent only.)

One dialog, Save File -> From another machine:

- Validate connection answers what the transport can do before a path is
  typed: SSH login, whether the SFTP subsystem answers (browsing and change
  detection depend on it), whether the far side has rsync, the platform and
  home, and how many .sav files are in the folder. A partial answer stays
  partial -- no SFTP subsystem is reported as such, not as a failed login.
- Save folder lists the remote directory and walks it (folders first, .sav
  marked, Up/parent, keyboard reachable). Remote names are untrusted data and
  are rendered as text. Without SFTP the listing is disabled and the path is
  typed by hand.
- Watch it and reload polls name/size/timestamp -- one listing, not a download
  -- and reloads only when the fetched bytes hash differently, so a coarse
  timestamp can cost a transfer but never a wrong reload. Paused while the
  editor holds pending edits, while a parse runs, and while the window is
  hidden; it resumes on its own.

Rust: rust_parser/tauri/src/ssh_source.rs (listing parse, ssh config, the three
transports, hashing) plus five commands -- ssh_hosts, source_validate,
source_browse, source_stat, source_fetch. Every remote path crosses into an
SFTP batch script, so control characters and quotes are rejected at the
boundary, and a host/user/port that could be read as an option is rejected
too. A fetched save lands in the app data dir under remote-saves/, written to
a .part and renamed only when complete, capped at 8 GiB.

Checks: 9 unit tests over real `sftp -b -` output (including OpenSSH-for-Windows
rows with ? owner/group and full-path names), a live run against a local sshd
covering validate/browse/stat/fetch over sftp and rsync, names with spaces,
change detection and every refusal path, the module plus the command bodies
type-checked against real serde/sha2, and the dialog driven in a browser with a
stubbed bridge (both transports, watch ticks, capability gating, pending-edit
pause, no markup injection).
The dialog is Tauri-gated, and the Tauri tree needs WebKitGTK/GTK development
packages an immutable distro does not carry -- so on the machine reviewing this,
there was no way to look at the thing being reviewed. `tools/serve_save_source_demo.py`
serves map/static/map/ with a stubbed desktop bridge instead: the three commands
answer from a real folder, so the listing, its sizes and timestamps, the
"newest .sav" pick and the watch's change check are all real (drop a file in the
folder and the next tick notices). Only the SSH transport is canned, and loading
stops at the parser -- sav_core is the desktop build, dist/pkg the wasm build --
so the status line says that rather than pretending.

docs/remote-save-ssh.md gains the three ways to run this: the demo server, the
distrobox recipe for the real app on Linux, and the Windows build plus the sshd
setup. It also records the one local trap worth knowing: an ostree install can
leave /etc/ssh/ssh_config.d/* owned by an unmapped uid, which OpenSSH treats as
fatal, so every ssh invocation dies before it connects and the dialog shows the
client's own "Bad owner or permissions" line until it is fixed.
The dialog put every control on one screen: two protocol tabs, seven fields, a
four-row connection report, a folder listing, three checkboxes and two blocks of
prose, all competing for the same panel. It now walks the three decisions in
order -- Protocol, Connection, Save to load -- with only the current step laid
out, a stepper whose numbers go back, Back/Next in the foot, and focus landing
on the first field of each step.

- Protocol is two explained choices instead of two tabs.
- Connection is that protocol's questions, one Validate button, and -- when it
  fails -- the ssh client's own words plus the one thing to try next (a refused
  key, an unknown host key, a dead port, a machine whose own ssh is broken, a
  server with no SFTP subsystem). The old four-row report was mostly a
  restatement of the error; a passing validate shows three lines, a failing one
  shows the error and no rows.
- Save to load is the folder, the file and the freshness toggles, with the
  path guessed only when the platform is one the game's folders are known for:
  it used to append the Proton path to any non-Windows home, which is a wrong
  path rather than no path.
- A remembered source reopens at the file it was left on, and the dialog no
  longer greets the user with the previous session's last watch tick.

Wording: "Save file" reads as a verb phrase. The third step is "Save to load"
and the sidebar panel it opens from is "Your save" -- a determiner, so the noun
reading is the only reading. The docs and the demo server follow.

Also: tools/serve_save_source_demo.py reports platform "demo", which is what
makes the no-guess path above visible, and it serves the failure path when the
host contains "fail" so the debug screen can be looked at without a game machine.
…llback

A change is now pushed to the map instead of waited for. `inotifywait -m` runs
on the machine that holds the folder -- locally, or over the ssh connection --
and each name it prints wakes the same tick that the interval used to: re-list,
compare, hash, reload only if the bytes moved. On Windows the equivalent is
PowerShell's FileSystemWatcher, sent as a base64 `-EncodedCommand` (UTF-16LE),
which is the only quoting that survives cmd.exe, the default shell of Windows
OpenSSH; the encoder is checked against coreutils/iconv byte for byte.

The interval stays, but only where events are impossible, and the dialog says
which one is in use:

- no inotifywait on the machine holding the saves;
- a mounted share on Linux, where inotify can never fire: it only reports writes
  made through this kernel's own VFS, so the game machine writing into an SMB or
  NFS share is invisible -- silent rather than late, which is worse. The
  filesystem type is checked with findmnt before choosing, so this is decided
  rather than hoped. Windows is the exception: FileSystemWatcher does receive
  SMB notifications, so a UNC path or mapped drive there still gets events.

An event is a nudge, not a verdict: the burst a save write produces is debounced
(1.5s), then the listing comparison and the byte hash decide. A missed or
spurious event therefore cannot reload the map wrongly, and a watcher that dies
at once -- no inotifywait, no such folder, a refused key -- reports the tool's
own words and the frontend falls back to the interval with that reason in the
status line.

Verified against a real sshd with an inotifywait test double: the local watcher
(a write produced an event), the remote one (a write on the far side arrived
over the connection), mode selection with and without the tool, and the
dead-watcher error path. The browser run covers arming with events (zero
intervals scheduled), a debounced event causing one check, non-.sav events
ignored, turning it off, and the fallback when start fails.
Checked against the real machine (bazzite@172.27.30.50, Bazzite, 1ms away):
validate, browse, fetch over sftp and rsync, and the change check all work on
166 real saves; rsync is there, so re-fetches are deltas. Two things came out of
it.

The game's layout is <SaveGames>/<steamid64>/*.sav, and the level above it holds
that one folder plus the dedicated server's 116-byte ServerManager_V2.sav -- so
"newest .sav" there picked a file that is not a save. When the single folder
beside the saves is the most recently written thing, the browser now walks into
it once: the dialog opens on the saves instead of one click above them. Up still
goes back, and it does not re-descend.

And a correction: the "Bad owner or permissions on /etc/ssh/ssh_config.d/..."
note added earlier blamed an ostree ownership bug on the user's install. It is
not that -- those files are root-owned, and inside a user namespace root maps to
an unmapped uid, so they look like nfsnobody to the ssh client. The docs now say
what it actually is and what to do (run outside the namespace, keep your uid, or
`ssh -F /dev/null`), instead of telling people to chown a healthy system file.

tools/serve_save_source_demo.py answers a listing for the folder the dialog asks
for, inside the folder it was pointed at, so browsing a real tree -- down, up,
and the walk-into-the-only-folder above -- can be seen locally.
The watch was reachable only through the SSH/mounted-share dialog, so a save
opened from disk -- the file dialog, a drag-drop -- sat there going stale while
the game autosaved. It is the same machinery now, for any loaded save with a
path: the folder is watched, the filesystem decides events or interval, and the
bytes decide whether the map reloads.

- A "Keep it fresh" switch appears under the loaded save in the sidebar (desktop
  only -- a browser File object has no path to watch), and disappears when the
  loaded save is remote or nothing is loaded. save_source.js owns the watch, so
  it owns the switch: data.js only says what was loaded.
- `source_watch_start` picks the mechanism itself when the caller passes no
  mode, which is what a local path needs: it never went through a validate, so
  nothing ever probed that machine for inotifywait or asked findmnt what
  filesystem the folder is on. The platform is an optional argument now.
- A picked file means that file -- following "newest" would swap the map onto a
  save nobody opened -- so when a strictly newer save lands in the folder the
  status line names it and the dialog's "Newest .sav" toggle is one click away.
  Same-minute saves are not called newer than each other.

Verified in the browser: adopting a path shows the switch, ticking it arms the
watcher with zero intervals scheduled, an event on that file reloads through the
same path the dialog uses, the dialog reflects the same state, unloading hides
the switch, and a strictly newer save in the folder is named in the status line.
The Rust side still passes its 11 unit tests, the live sshd run (local and remote
watchers, both transports, every refusal) and the real-host run.
Two failures were wearing the same mask: the browser's own "Failed to fetch",
which says nothing about whether ssh answered.

- The demo server now reports its own death ("the demo server is gone --
  restart tools/serve_save_source_demo.py and reload this page") instead of
  letting the browser's message read like a connection failure.
- The dialog recognises a transport-level failure and says what it is: the
  app's command channel, not the remote host. On Linux the desktop shell carries
  its commands over a local fetch, so a backend that is gone, or a frontend
  rebuilt against an older binary, arrives as exactly this string -- the hint
  now says restart/rebuild rather than leaving the user to guess at ssh.
- docs: the case is listed with the other troubleshooting entries.

Reproduced first: with the stub's fetch rejecting, the dialog shows
"Failed to fetch" with "Not connected." and no check rows, byte for byte what a
dead demo server produces.
Reported as "it connects, but there is no way to navigate the paths on the ssh
host", and reproduced: after a successful validate, step 3 had an empty path and
a completely blank list -- no rows, no text, nothing to click. The browser only
knew where to start when the platform was Windows or Linux/macOS (the game's
usual folder); anything else, including the demo bridge, left it with nothing,
and a blank list says nothing about what to do about it.

- suggestDir falls back to the remote home directory, which validate always
  reports: the game's folder when we know it, somewhere real to walk from
  otherwise. On the real machine that lands on the saves directly (the layout
  fix from the previous commit walks into the <steamid64> folder).
- A guessed folder that does not exist -- a second Steam library, a host whose
  layout we do not know -- falls back to the home directory once and says so,
  instead of dead-ending on "Can't ls: not found". Every Open or Up is its own
  attempt, so a second try is not swallowed by the first fallback.
- Step 3 with no folder at all explains what to do ("go back and press Validate
  connection, or type a path and press Open") rather than rendering nothing.
- A failure that cannot be recovered goes to the status line as well as the
  list, so the line never sits on a stale "Listing /..." .

The demo's listing is strict now too: a path outside the folder it was pointed
at, or one that is not there, is refused the way the real backend refuses it,
instead of silently answering with a different folder.
Save sources over SSH/SFTP (opposing take on #2)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant