diff --git a/docs/remote-save-ssh-dialog.png b/docs/remote-save-ssh-dialog.png new file mode 100644 index 0000000..1cf4d03 Binary files /dev/null and b/docs/remote-save-ssh-dialog.png differ diff --git a/docs/remote-save-ssh.md b/docs/remote-save-ssh.md new file mode 100644 index 0000000..21382f5 --- /dev/null +++ b/docs/remote-save-ssh.md @@ -0,0 +1,205 @@ +# Load the save off the machine that runs the game (desktop app) + +The desktop app can load the `.sav` straight off the gaming PC and keep +reloading it as the game autosaves. It does that with what that machine already +has — **its OpenSSH server** — rather than by asking it to host the file: +nothing listens, no port is opened for the app, and the authentication is the +key and `~/.ssh/config` the user already has. + +## The dialog: three steps, one decision each + +**Your save → From another machine.** + +![The dialog](remote-save-ssh-dialog.png) + +1. **Protocol** — two choices, each explained in a line: + | Protocol | Needs on the game machine | Gives | + |---|---|---| + | **SSH / SFTP** | OpenSSH server (built into Windows 10/11 and every Linux) | folder browsing, a cheap change check, rsync when it is installed | + | **Mounted share** | SMB/NFS/GVFS share mounted by the OS | the same browsing and change check, no protocol code at all | +2. **Connection** — the answers that protocol needs, then *Validate connection*: + it reports the login, whether the SFTP subsystem answers (browsing and the + change check depend on it), and whether the far side has rsync. A failure + shows the ssh client's own words and the one thing to try next — a refused + key, an unknown host key, a port nothing answers on, a machine whose own + `ssh` is broken, a server with no SFTP subsystem. +3. **Save to load** — the folder, walked like a file dialog (folders first, + `.sav` marked, click a folder 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 + to the home directory and says so. *Newest `.sav`* follows + the autosave rotation; *Keep it fresh* reloads the map when the save + changes -- see below for what "when" means. + +The stepper's numbers go back to a step already reached, and a remembered +source reopens straight at the file it was left on. + +### 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), and it drives exactly the same machinery: the +folder is watched, the filesystem decides events or interval, and the bytes +decide whether the map reloads. + +A file you picked means *that* file -- following "newest" would swap the map onto +a save you did not open -- so when a newer save lands in the folder the status +line says which one, and the dialog's *Newest `.sav`* toggle is one click away. +The switch keeps its setting across loads and restarts, and disappears when the +loaded save is a remote one or nothing is loaded. + +## Setting up the game machine (Windows) + +Run these in an **elevated PowerShell**. The save folder comes with the server +capability; nothing else is installed. + +```powershell +Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0 +Set-Service -Name sshd -StartupType Automatic +Start-Service sshd +New-NetFirewallRule -Name sshd -DisplayName 'OpenSSH Server (sshd)' -Enabled True ` + -Direction Inbound -Protocol TCP -Action Allow -LocalPort 22 +``` + +Then, as the account that owns the saves (add your own public key): + +```powershell +mkdir -Force $env:USERPROFILE\.ssh | Out-Null +Add-Content $env:USERPROFILE\.ssh\authorized_keys 'ssh-ed25519 AAAA... you@laptop' +icacls $env:USERPROFILE\.ssh\authorized_keys /inheritance:r /grant "$($env:USERNAME):R" +``` + +The saves are at +`C:\Users\\AppData\Local\FactoryGame\Saved\SaveGames\\`, which +is why the dialog opens on the SFTP form of that path: `/C:/Users/...`. On +Linux the game writes into the Proton prefix; checked on Bazzite, that is +`~/.steam/steam/steamapps/compatdata/526870/pfx/drive_c/users/steamuser/AppData/Local/FactoryGame/Saved/SaveGames//`. +The `` level is the game's own layout, so the dialog opens the folder +above it and walks into the only folder when that folder is the most recently +written thing — otherwise "newest `.sav`" there would pick the dedicated +server's 116-byte `ServerManager_V2.sav`, which is not a save. + +An admin password is never asked for and never stored: the dialog sets +`BatchMode=yes`, so authentication is key/agent only by design. A host whose +key is not in `known_hosts` is refused until **Trust this host's key the first +time** is ticked, which adds it once (trust on first use, the same disclosure as +the dedicated-server certificate pin). A *changed* key is still refused. + +## While it is watching + +**Changes are pushed when the machine can push them, and checked on an interval +when it cannot.** `inotifywait` is run *on the machine that holds the folder* — +locally, or over the ssh connection — and every name it prints wakes the same +check; on Windows the equivalent is PowerShell's `FileSystemWatcher`, sent as a +base64 `-EncodedCommand` so nothing in the path can be reinterpreted by +`cmd.exe`. Nothing is polled while events are live. + +Two cases fall back to the interval, and the dialog says which one you got: + +- **No `inotifywait` on that machine** (Debian/Ubuntu: `apt install + inotify-tools`; Fedora: `dnf install inotify-tools`; Bluefin: + `rpm-ostree install inotify-tools`). Windows needs nothing — the watcher is + part of .NET. +- **A mounted share on Linux.** `inotify` only reports writes made through this + machine's own kernel, so the game machine writing into an SMB or NFS share is + invisible to it — the watch would be silent rather than late, which is worse. + The filesystem type is checked before choosing, so this is decided rather than + hoped. Windows is the exception: `FileSystemWatcher` does receive SMB + notifications, so a UNC path or a mapped drive there still gets events. + +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. + +The status line says what each check saw. Reloading is refused while the editor +holds pending edits (a reload discards them), while a parse is already running, +and while the window is hidden — it resumes on its own. Remote names are +rendered as text, never as markup. + +A fetched save lands in the app's data directory under `remote-saves/`, written +to a `.part` file and renamed only when complete, and capped at 8 GiB, so a +wrong path cannot fill the disk and a truncated transfer cannot become the file +the parser reads. + +## Running it locally + +**The dialog, without building anything** (needs only Python): + +```sh +python3 tools/serve_save_source_demo.py --dir /path/to/SaveGames +# then open http://127.0.0.1:8793/ and click Your save -> From another machine +``` + +That serves `map/static/map/` with a stubbed desktop bridge: 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 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 in the desktop build and `dist/pkg` is the +wasm build, neither of which that server has -- so the status line says so +rather than pretending. + +**The real thing on Linux** (the app itself, real SSH, real parsing). The Tauri +tree needs the WebKitGTK/GTK development packages, which an immutable distro +does not carry, so build it in a container: + +```sh +distrobox create --name satisfactory-map --image registry.fedoraproject.org/fedora:41 +distrobox enter satisfactory-map +sudo dnf install -y webkit2gtk4.1-devel gtk3-devel libsoup3-devel librsvg2-devel \ + openssl-devel cairo-devel pango-devel gdk-pixbuf2-devel +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh +cd /rust_parser && cargo build -p sav_tauri --release +cargo tauri dev # or: cargo tauri build +``` + +If you cannot install `-devel` packages (an immutable distro, no root), note that +the Tauri tree's `-sys` crates only ask pkg-config *where* the libraries are — +they compile no C headers — and the runtime libraries are usually already +installed. Hand-written `.pc` files for the missing names plus the unversioned +`libfoo.so` symlinks the linker wants, both in a directory of your own, are +enough to build and link against them; the container above remains the tidy +route. Remember that `dist/` is embedded at compile time, so build the site +first. + +**The real thing on Windows** is the repo's normal build (`cargo tauri build`) +plus the sshd setup above -- and Windows is where the game machine is, so this +is the path that ends in a working map. + +### "Failed to fetch" in the Connection step + +That is not ssh talking: it is the app's own command channel. 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) shows up this way, and the hint +under the message says so. Restart the app; if the frontend changed, rebuild it. +The demo server prints its own version of this when it is not running. + +### "Bad owner or permissions on /etc/ssh/ssh_config.d/..." + +OpenSSH refuses to read a config file it does not consider root-owned, and +inside a **user namespace** the host's root-owned files map to an unmapped uid, +so they look like `nfsnobody` to the client. Every `ssh` invocation then fails +before it connects, and the dialog shows exactly that line. It is the sandbox, +not the machine: run the app (or `ssh`) outside it, or use a container that keeps +your uid — `distrobox` does by default, `podman run --userns=keep-id` says so +explicitly. `ssh -F /dev/null` also gets past it, by not reading the system +config at all. + +## Implementation notes + +- `map/static/map/save_source.js` — the dialog, the browser and the watch loop. +- `rust_parser/tauri/src/ssh_source.rs` — runs `ssh`, `sftp` and `rsync` as + subprocesses with a deadline, parses their output, and hashes what arrives. + The app ships no SSH stack: host keys, keys, agents and `ProxyJump` are + OpenSSH's, so the app never grows a second implementation of any of it. +- Commands: `source_validate`, `source_browse`, `source_fetch` for the three + jobs the dialog has, plus `source_watch_start` / `source_watch_stop` for the + event watcher (a long-lived child process, local or over ssh, kept in the + Tauri state because a webview cannot hold one). `source_watch_start` picks the + mechanism itself when the caller does not know it, which is what a locally + opened save needs -- it never went through a validate. The watch probe is a `source_browse`, because a listing + is what says both what is in the folder and whether it moved. 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 too. diff --git a/docs/server-fetch.md b/docs/server-fetch.md index 0bf7c23..472e9e7 100644 --- a/docs/server-fetch.md +++ b/docs/server-fetch.md @@ -9,7 +9,7 @@ server install. Nothing about the game was reverse-engineered. ## Using it -In the Save File panel, expand **From dedicated server** and enter: +In the sidebar, expand **From dedicated server** and enter: - **Server address** — a bare host or IP means the default port 7777; `host:port` overrides it (bracketed IPv6 like `[::1]:7777` works too; diff --git a/map/static/map/data.js b/map/static/map/data.js index 0f0d780..b79de32 100644 --- a/map/static/map/data.js +++ b/map/static/map/data.js @@ -185,7 +185,7 @@ clearSaveBtn.style.display = "flex"; } - // "Unload" (the eject button next to the Save File header): drop the + // "Unload" (the eject button next to the panel header): drop the // loaded save entirely -- map, sidebar, editor state, and the parse // session's memory (SaveClient.reset terminates the wasm worker; native // side frees its session). Pending edits die with the session, so those @@ -220,6 +220,7 @@ MapApp.refreshMaxBounds(); // back to the vanilla pan limits clearSaveBtn.style.display = "none"; hideProgress(); + SaveSource.forgetLocal(); setStatus("Save unloaded -- drop a .sav anywhere or click above."); } @@ -342,6 +343,10 @@ currentFile = null; currentUrl = null; EditorTool.onSaveLoaded(name); + // A save loaded from a path on this machine is watchable too: hand it to + // the module that owns the watch, which shows the sidebar switch and + // keeps it fresh if it was already on. + SaveSource.adoptLocal(path); applyPayload(payload); if (pinnedSelection) { restorePinnedSelection(pinnedSelection); @@ -642,6 +647,21 @@ // handlers below. if (isTauri) { setupServerFetch(); + // The save on another machine (save_source.js). It owns its own dialog + // and watch loop; all it needs from here is the path loader and the two + // questions the watch loop must ask before reloading the map. + SaveSource.init({ + options: { + loadPath: loadLocalPath, + isBusy: function() { return loadInFlight; }, + pendingEdits: function() { return EditorTool.opCount(); }, + // The sidebar's own "Keep it fresh" row lives in save_source.js, which + // owns the watch; this is only so a load can tell it what happened. + onWatch: function(watching, mode) { + console.info("SaveSource: watching=" + watching + " mode=" + (mode || "interval")); + }, + }, + }); } if (isTauri && window.__TAURI__.event) { window.__TAURI__.event.listen("tauri://drag-drop", function(e) { diff --git a/map/static/map/index.html b/map/static/map/index.html index 2405e99..bbc62fa 100644 --- a/map/static/map/index.html +++ b/map/static/map/index.html @@ -167,7 +167,7 @@

No save loaded

-

Drop a .sav anywhere on this window, or use the Save File panel below. It is parsed in your browser — nothing is uploaded.

+

Drop a .sav anywhere on this window, or use the panel below. It is parsed in your browser — nothing is uploaded.

@@ -196,7 +196,7 @@ - Save File + Your save
@@ -229,6 +229,12 @@ Drop a .sav here or click to browse
+ + + + +
+
+ Save on another machine + +
+ +
+ + + +
+
+
+ + +
+ + + + +
+
+ + + + +
+
+
+ @@ -656,6 +775,7 @@ + diff --git a/map/static/map/map.css b/map/static/map/map.css index 87e6e30..13131aa 100644 --- a/map/static/map/map.css +++ b/map/static/map/map.css @@ -3074,3 +3074,274 @@ button:focus-visible, select:focus-visible, input[type="checkbox"]:focus-visible .leaflet-left { left: var(--dock-left-inset); } + +/* ===== Save on another machine (save_source.js) ========================== + A three-step dialog: protocol, connection, file. Only the current step is + laid out, so the crowded screen of seven fields, three checkboxes and a + folder listing competing for one panel is gone -- each step answers one + question. Chrome comes from ui.css (.dlg, .btn, .field, .list, .row). */ + +/* The stepper: a progress line that also goes back. */ +.srcSteps { + flex: none; + display: flex; + gap: var(--sp-1); + margin-bottom: var(--sp-3); + border-bottom: 1px solid var(--border-sub); + padding-bottom: var(--sp-2); +} + +.srcStepBtn { + display: inline-flex; + align-items: center; + gap: var(--sp-15); + padding: var(--sp-1) var(--sp-2); + font: inherit; + font-size: var(--fs-sm); + font-weight: 600; + color: var(--faint); + background: none; + border: 1px solid transparent; + border-radius: var(--r-sm); + cursor: pointer; +} + +.srcStepBtn:disabled { + cursor: default; + opacity: 0.55; +} + +.srcStepBtn.is-on { + color: var(--text-bright); + border-color: var(--border-popup); + background: var(--raised); +} + +.srcStepBtn.is-done { + color: var(--muted); +} + +.srcStepNum { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border-radius: var(--r-pill); + border: 1px solid currentColor; + font-size: var(--fs-2xs); +} + +/* Step 1: two protocols, each a row you can read and click anywhere on. */ +.srcPick { + display: flex; + align-items: flex-start; + gap: var(--sp-2); + padding: var(--sp-25); + margin-bottom: var(--sp-2); + border: 1px solid var(--border); + border-radius: var(--r-sm); + cursor: pointer; +} + +.srcPick:hover { + border-color: var(--border-hover); + background: var(--raised); +} + +.srcPick:has(input:checked) { + border-color: var(--accent-border); + background: var(--accent-bg); +} + +.srcPickBody { + display: flex; + flex-direction: column; + gap: var(--sp-05); +} + +.srcPickName { + color: var(--text); + font-size: var(--fs-lg); + font-weight: 600; +} + +.srcPickNote { + color: var(--faint); + font-size: var(--fs-sm); +} + +/* Host / user / port on one line: three short fields, one question. */ +.srcGrid { + display: grid; + grid-template-columns: 2fr 1fr 0.8fr; + gap: var(--sp-2); + margin-bottom: var(--sp-2); +} + +#srcSshFields > .field, +#srcLocalFields > .field, +.srcGrid > .field { + margin-bottom: var(--sp-2); + width: 100%; +} + +.srcCheckLine { + display: flex; + align-items: center; + gap: var(--sp-15); + font-size: var(--fs-sm); + color: var(--muted); + cursor: pointer; +} + +.srcHint { + margin: var(--sp-1) 0 var(--sp-2); + font-size: var(--fs-sm); + color: var(--faint); +} + +/* One line per capability the connection has. "No rsync on the remote" is not + a failure -- SFTP carries the transfer -- so only the SFTP subsystem and the + login can go red. */ +.srcChecks { + margin-top: var(--sp-2); +} + +.srcCheck { + display: flex; + align-items: baseline; + gap: var(--sp-2); + padding: var(--sp-1) 0; + font-size: var(--fs-sm); + border-top: 1px solid var(--border-sub); +} + +.srcCheckMark { + flex: none; + width: 12px; + color: var(--ok-soft); +} + +.srcCheck.is-bad .srcCheckMark { + color: var(--danger-soft); +} + +.srcCheck.is-off .srcCheckMark { + color: var(--faint); +} + +.srcCheckName { + flex: none; + min-width: 120px; + color: var(--text); +} + +.srcCheckDetail { + min-width: 0; + color: var(--faint); + overflow-wrap: anywhere; +} + +/* Step 2's debug output: the client's own error, then what to try. */ +.srcError { + margin-top: var(--sp-2); + padding: var(--sp-2); + border: 1px solid var(--danger-border); + border-radius: var(--r-sm); + background: var(--danger-bg); +} + +.srcErrorText { + font-family: ui-monospace, monospace; + font-size: var(--fs-sm); + color: var(--danger-soft); + overflow-wrap: anywhere; +} + +.srcErrorHint { + margin-top: var(--sp-15); + font-size: var(--fs-sm); + color: var(--text); +} + +.srcPathBar { + display: flex; + gap: var(--sp-2); + margin-bottom: var(--sp-2); +} + +.srcPathBar .field { + flex: 1 1 auto; + min-width: 0; +} + +/* Fixed height, so browsing between folders does not resize the dialog. */ +.srcList { + height: 200px; +} + +.srcRow { + width: 100%; + text-align: left; + background: none; + border: none; + border-bottom: 1px solid var(--border-sub); + font: inherit; +} + +.srcRowIcon { + flex: none; + width: 14px; + color: var(--faint); +} + +.srcRow.is-dir .srcRowIcon { + color: var(--accent-soft); +} + +.srcRow.is-sav .srcRowIcon { + color: var(--ok-soft); +} + +.srcRow:focus-visible { + outline: 2px solid var(--accent-soft); + outline-offset: -2px; +} + +.srcEmpty { + padding: var(--sp-3); + color: var(--faint); + font-size: var(--fs-sm); + overflow-wrap: anywhere; +} + +.srcPicked { + margin-top: var(--sp-2); + font-size: var(--fs-sm); + color: var(--muted); +} + +/* Step 3's three toggles on one line: what to take, whether to watch, how often. */ +.srcWatchRow { + display: flex; + align-items: center; + gap: var(--sp-3); + margin-top: var(--sp-2); +} + +.srcWatchRow select { + width: auto; + margin-left: auto; +} + +/* The status line rides in the foot, so "validating…" is next to the button + that started it. */ +.dlg-foot #srcStatus { + flex: 1 1 auto; + min-width: 0; + color: var(--faint); + font-size: var(--fs-sm); + align-self: center; + overflow-wrap: anywhere; +} diff --git a/map/static/map/save_source.js b/map/static/map/save_source.js new file mode 100644 index 0000000..329ff8f --- /dev/null +++ b/map/static/map/save_source.js @@ -0,0 +1,1089 @@ +// SaveSource: load a save that lives on the machine running the game, without +// asking that machine to host anything. The transports are the ones the gaming +// PC already speaks -- its OpenSSH server (ssh/sftp, plus rsync when it has it) +// and any share the OS has mounted -- and both are reached through the Tauri +// commands in rust_parser/tauri/src/ssh_source.rs. +// +// Three jobs, one dialog: +// - validate: say what the connection can actually do (keys, SFTP subsystem, +// rsync) before the user commits to a path; +// - browse: list the remote folder and pick the .sav, the way the local file +// dialog works; +// - watch: notice when the save changes and reload the map, so a game +// autosaving on another machine keeps this map current. The change check is +// a listing (name/size/timestamp), and the downloaded bytes are hashed +// before reloading, so a coarse timestamp can cost a transfer but never a +// wrong reload. +// +// Desktop only: every command here is Tauri. The browser build never calls +// init(), and nothing in this file runs without window.__TAURI__. + +window.SaveSource = (function() { + "use strict"; + + var KEY = "smap.saveSource"; + // The game's own save location, per platform -- prefilled after a successful + // validate purely as a convenience; browsing corrects it in one click. + var SAVE_DIR = { + windows: "AppData/Local/FactoryGame/Saved/SaveGames", + linux: ".steam/steam/steamapps/compatdata/526870/pfx/drive_c/users/steamuser/AppData/Local/FactoryGame/Saved/SaveGames", + }; + + var tauriInvoke = null; // window.__TAURI__.core.invoke, set by init + var options = {}; + var timer = null; // the fallback interval + var burst = null; // a change event waiting for the write burst to settle + var el = {}; // cached elements + + var state = { + transport: "ssh", // "ssh" | "local" + target: { host: "", user: "", port: 0, identity: "", acceptNewHostKey: false }, + dir: "", + file: "", + takeNewest: true, + watch: false, + interval: 60, + watchMode: "", // "inotify" | "watcher" while events are live, "" while polling + descended: false, // the folder-with-one-subfolder shortcut has been taken + watchKey: "", // target+dir the running watcher belongs to + step: 1, // 1 protocol, 2 connection, 3 save file + reached: 1, // the furthest step visited, so the stepper can go back + capabilities: null, // last validate result + seen: null, // "name|size|mtime" of the save we last loaded from + sha: null, // hash of the bytes we last loaded (null = not loaded yet) + loadedPath: "", + }; + + // ---- Small helpers ------------------------------------------------------- + + function remember() { + try { + window.localStorage.setItem(KEY, JSON.stringify({ + transport: state.transport, + target: state.target, + dir: state.dir, + file: state.file, + takeNewest: state.takeNewest, + watch: state.watch, + interval: state.interval, + })); + } catch (e) { /* storage blocked: the dialog still works, it just forgets */ } + } + + function recall() { + try { + var saved = JSON.parse(window.localStorage.getItem(KEY) || "null"); + if (!saved || typeof saved !== "object") { + return; + } + state.transport = saved.transport === "local" ? "local" : "ssh"; + state.target = Object.assign(state.target, saved.target || {}); + state.dir = saved.dir || ""; + state.file = saved.file || ""; + state.takeNewest = saved.takeNewest !== false; + state.watch = !!saved.watch; + state.interval = parseInt(saved.interval, 10) || 60; + } catch (e) { /* corrupt or unreadable: start from defaults */ } + } + + function say(text) { + if (el.status) { + el.status.textContent = text; + } + } + + function bytes(n) { + if (!(n > 0)) { + return ""; + } + var units = ["B", "KB", "MB", "GB"]; + var at = 0; + while (n >= 1024 && at < units.length - 1) { + n /= 1024; + at++; + } + return (at === 0 ? n : n.toFixed(1)) + " " + units[at]; + } + + function clock() { + return new Date().toTimeString().slice(0, 8); + } + + function when(seconds) { + if (!seconds) { + return ""; + } + var days = Math.round((Date.now() / 1000 - seconds) / 86400); + if (days === 0) { + return "today"; + } + return days === 1 ? "yesterday" : days + " days ago"; + } + + function isSave(name) { + return /\.sav$/i.test(name); + } + + function invoke(command, args) { + return tauriInvoke(command, args || {}); + } + + function message(error) { + return String((error && error.message) || error); + } + + // ---- The dialog ---------------------------------------------------------- + + function init(bridge) { + options = (bridge && bridge.options) || {}; + tauriInvoke = (bridge && bridge.invoke) || (window.__TAURI__ && window.__TAURI__.core.invoke); + if (!tauriInvoke) { + return; + } + el = { + trigger: document.getElementById("saveSourceBtn"), + dialogApi: UI.dialog("saveSourceModal"), + host: document.getElementById("srcHost"), + user: document.getElementById("srcUser"), + port: document.getElementById("srcPort"), + identity: document.getElementById("srcIdentity"), + acceptNew: document.getElementById("srcAcceptNew"), + steps: document.querySelectorAll("#srcSteps .srcStepBtn"), + sections: document.querySelectorAll("#saveSourceModal .srcStep"), + protocols: document.querySelectorAll('input[name="srcProtocol"]'), + sshFields: document.getElementById("srcSshFields"), + localFields: document.getElementById("srcLocalFields"), + localPath: document.getElementById("srcLocalPath"), + validate: document.getElementById("srcValidate"), + checks: document.getElementById("srcChecks"), + error: document.getElementById("srcError"), + back: document.getElementById("srcBack"), + next: document.getElementById("srcNext"), + path: document.getElementById("srcPath"), + up: document.getElementById("srcUp"), + go: document.getElementById("srcGo"), + list: document.getElementById("srcList"), + picked: document.getElementById("srcPicked"), + newest: document.getElementById("srcNewestBox"), + watch: document.getElementById("srcWatchBox"), + watchNote: document.getElementById("srcWatchNote"), + interval: document.getElementById("srcInterval"), + load: document.getElementById("srcLoad"), + status: document.getElementById("srcStatus"), + localRow: document.getElementById("localWatchRow"), + localBox: document.getElementById("localWatchBox"), + }; + if (!el.dialogApi || !el.list || !el.sshFields || !el.localFields || !el.next) { + return; // markup missing (older dist): stay inert rather than throw + } + if (el.dialogApi.el.dataset.saveSourceWired) { + return; // a second init would double every click handler and every fetch + } + recall(); + fillForm(); + wire(); + syncWatchNote(); + el.dialogApi.el.dataset.saveSourceWired = "1"; + if (window.__TAURI__.event && window.__TAURI__.event.listen) { + window.__TAURI__.event.listen("save-source-changed", function(event) { + onChanged(String((event && event.payload) || "")); + }); + } + if (el.trigger) { + el.trigger.style.display = ""; + el.trigger.addEventListener("click", open); + } + if (el.localBox) { + el.localBox.addEventListener("change", function() { + state.watch = el.localBox.checked; + remember(); + if (state.watch) { + arm(); + } else { + stop(); + say("Watching off."); + } + notifyWatch(); + }); + } + // The source is remembered, so the checkbox survives a restart -- but the + // poll only starts once a save has been loaded from it in this session + // (see arm()), or it would download and load a save nobody asked for. No + // status line for that: step 1 is a choice, not a report. + } + + // A save opened from this machine -- the file dialog, a drag-drop, or the + // dialog's mounted-share transport -- is watched by the same machinery as a + // remote one: the folder is what is watched, the filesystem decides whether + // that means events or an interval, and the bytes decide whether the map + // reloads. Called by data.js once a path load succeeded; `watch` is left as + // the user set it, so loading another save does not silently start or stop + // watching. + function adoptLocal(path, watch) { + if (!path) { + return; + } + state.transport = "local"; + state.dir = path.replace(/[\\/][^\\/]*$/, "") || path; + state.file = path.split(/[\\/]/).pop(); + state.filePath = path; + // A picked file means that file: following "newest" would swap the map onto + // a save the user did not open. The dialog's toggle is right there for + // anyone who wants the rotation instead. + state.takeNewest = false; + if (typeof watch === "boolean") { + state.watch = watch; + } + // No validate ran for a local path, so the mode is not known yet: empty + // means "work it out", which the command does from the filesystem. + state.capabilities = null; + remember(); + showLocalRow(true); + if (state.watch) { + arm(); + } else { + stop(); + say("Watching off. Tick \"Keep it fresh\" to reload this save as it changes."); + } + notifyWatch(); + } + + /// The loaded save is no longer a local path (a remote source took over, or + /// the save was unloaded): the sidebar switch goes away with it. + function forgetLocal() { + showLocalRow(false); + if (state.transport === "local") { + stop(); + state.watch = false; + remember(); + } + } + + function showLocalRow(show) { + if (!el.localRow) { + return; + } + el.localRow.style.display = show ? "" : "none"; + if (el.localBox) { + el.localBox.checked = !!state.watch; + } + } + + function notifyWatch() { + if (typeof options.onWatch === "function") { + options.onWatch(state.watch, state.watchMode); + } + } + + function open() { + fillForm(); + renderCapabilities(state.capabilities); + say(""); // whatever the last tick said is stale by the time this opens + el.dialogApi.open(); + // Continue where the last session stopped: a remembered folder means the + // protocol and the connection are already answered. + showStep(state.dir ? 3 : 1); + state.descended = false; + state.fellBack = false; + if (state.dir) { + browse(state.dir); + } + } + + function fillForm() { + var t = state.target; + if (el.host) { + el.host.value = t.host || ""; + el.user.value = t.user || ""; + el.port.value = t.port || ""; + el.identity.value = t.identity || ""; + el.acceptNew.checked = !!t.acceptNewHostKey; + } + if (el.localPath) { + el.localPath.value = state.dir || ""; + } + if (el.path) { + el.path.value = state.dir || ""; + } + if (el.newest) { + el.newest.checked = state.takeNewest; + } + if (el.watch) { + el.watch.checked = state.watch; + } + if (el.interval) { + el.interval.value = String(state.interval); + } + syncTransport(); + renderPicked(); + } + + function readForm() { + state.target = { + host: el.host.value.trim(), + user: el.user.value.trim(), + port: parseInt(el.port.value, 10) || 0, + identity: el.identity.value.trim(), + acceptNewHostKey: el.acceptNew.checked, + }; + state.takeNewest = el.newest.checked; + state.watch = el.watch.checked; + state.interval = parseInt(el.interval.value, 10) || 60; + remember(); + } + + function syncTransport() { + var local = state.transport === "local"; + el.sshFields.style.display = local ? "none" : ""; + el.localFields.style.display = local ? "" : "none"; + el.protocols.forEach(function(radio) { + radio.checked = radio.value === state.transport; + }); + } + + // One step at a time, and the stepper only offers steps already reached. + function showStep(step) { + state.step = step; + state.reached = Math.max(state.reached, step); + el.sections.forEach(function(section) { + section.style.display = Number(section.dataset.step) === step ? "" : "none"; + }); + el.steps.forEach(function(button) { + var n = Number(button.dataset.step); + button.disabled = n > state.reached; + button.classList.toggle("is-on", n === step); + button.classList.toggle("is-done", n < step); + if (n === step) { + button.setAttribute("aria-current", "step"); + } else { + button.removeAttribute("aria-current"); + } + }); + el.back.style.display = step > 1 ? "" : "none"; + el.next.style.display = step < 3 ? "" : "none"; + el.load.style.display = step === 3 ? "" : "none"; + el.next.textContent = step === 1 ? "Connection" : "Save to load"; + if (step === 3) { + renderPicked(); + } + if (step === 3 && !state.dir) { + el.list.innerHTML = ""; + var hint = document.createElement("div"); + hint.className = "srcEmpty"; + hint.textContent = "No folder open yet. Go back and press Validate connection to start at " + + "that machine's home, or type a path above and press Open."; + el.list.appendChild(hint); + } + var body = el.dialogApi.el.querySelector(".dlg-body"); + if (body) { + body.scrollTop = 0; + } + } + + // "Validate/debug": the ssh client's own words are the error, and the hint is + // the one thing to try next. Anything not listed gets no invented advice. + function hintFor(error) { + var text = String(error || ""); + // A transport-level failure never came from ssh: on Linux the desktop shell + // carries its own commands over a local fetch, so this is the app's channel + // to its backend, not the remote host answering. + if (/Failed to fetch|NetworkError|ipc:|not answering/i.test(text)) { + return "That is the app's own command channel failing, not ssh: the window has lost its backend, or the build is stale. Restart the app (and rebuild it if the frontend changed since)."; + } + if (text.indexOf("Bad owner or permissions") !== -1) { + return "This machine's own ssh config is unreadable (a non-root owner under /etc/ssh/ssh_config.d). ssh fails outside this app too until it is fixed."; + } + if (/host key|Host key/i.test(text) && /known|verification/i.test(text)) { + return "Tick \"Trust this host's key the first time\" above, then validate again."; + } + if (/Permission denied/i.test(text)) { + return "The server refused the key. Check the user name, or add your public key to the server's authorized_keys."; + } + if (/Connection refused|timed out|No route to host|Could not resolve/i.test(text)) { + return "Nothing answered on that host and port. Is the machine awake, sshd running, and the firewall open?"; + } + if (/subsystem request failed|subsystem/i.test(text)) { + return "The server has no SFTP subsystem, so the folder cannot be listed or watched. Type the save's path in step 3; rsync can still pull it."; + } + if (/Could not run ssh|Could not run sftp/i.test(text)) { + return "No ssh client on this machine. Install OpenSSH and reopen this dialog."; + } + return ""; + } + + // Whether watching is even possible: SFTP is what gives us a cheap listing + // to compare, so a server without the subsystem gets a manual path and a + // manual reload instead of a checkbox that can never fire. Before the first + // validate we do not know, and a remembered source that worked yesterday is + // allowed to try again. + function watchPossible() { + return state.transport === "local" || !state.capabilities || !!state.capabilities.sftp; + } + + function syncWatchNote() { + if (!el.watchNote) { + return; + } + if (watchPossible()) { + el.watch.checked = state.watch; + el.watch.disabled = false; + var mode = state.capabilities && state.capabilities.watch; + el.watchNote.textContent = !state.watch ? "" + : (mode + ? "The machine holding the save pushes changes, so the map reloads as they land." + : "No event source on that machine, so the folder is checked every " + state.interval + "s."); + return; + } + el.watch.checked = false; + el.watch.disabled = true; + el.watchNote.textContent = "This server has no SFTP subsystem, so the folder cannot be listed or watched. Type the save's path and load it by hand."; + } + + function wire() { + el.protocols.forEach(function(radio) { + radio.addEventListener("change", function() { + if (!radio.checked) { + return; + } + state.transport = radio.value; + state.capabilities = null; + if (state.transport === "local" && el.localPath.value.trim()) { + state.dir = el.localPath.value.trim(); + el.path.value = state.dir; + } + renderCapabilities(null); + syncTransport(); + syncWatchNote(); + remember(); + }); + }); + el.steps.forEach(function(button) { + button.addEventListener("click", function() { + if (!button.disabled) { + showStep(Number(button.dataset.step)); + } + }); + }); + el.back.addEventListener("click", function() { + showStep(Math.max(1, state.step - 1)); + }); + el.next.addEventListener("click", function() { + readForm(); + if (state.step === 1) { + showStep(2); + (state.transport === "local" ? el.localPath : el.host).focus(); + return; + } + // Step 2 -> 3: land on the folder, whether or not validate was run. + if (state.transport === "local") { + state.dir = el.localPath.value.trim() || state.dir; + el.path.value = state.dir; + } + showStep(3); + if (state.dir) { + browse(state.dir); + } else { + el.path.focus(); + } + }); + el.validate.addEventListener("click", validate); + el.go.addEventListener("click", function() { + state.fellBack = false; + browse(el.path.value.trim()); + }); + el.path.addEventListener("keydown", function(e) { + if (e.key === "Enter") { + e.preventDefault(); + browse(el.path.value.trim()); + } + }); + el.up.addEventListener("click", function() { + if (state.parent) { + state.fellBack = false; + browse(state.parent); + } + }); + ["host", "user", "port", "identity"].forEach(function(name) { + el[name].addEventListener("change", readForm); + }); + el.acceptNew.addEventListener("change", readForm); + el.newest.addEventListener("change", function() { + readForm(); + renderPicked(); + }); + el.watch.addEventListener("change", function() { + readForm(); + syncWatchNote(); + if (!state.watch) { + stop(); + say("Watching off."); + return; + } + // arm() decides between events and the interval, and says which it got. + arm(); + showLocalRow(state.transport === "local"); + notifyWatch(); + }); + el.interval.addEventListener("change", function() { + readForm(); + syncWatchNote(); + schedule(); + }); + el.load.addEventListener("click", function() { load(true); }); + el.dialogApi.onClose(function() { + if (!state.watch) { + stop(); + } + }); + } + + // ---- Validate ------------------------------------------------------------ + + function validate() { + readForm(); + if (state.transport === "ssh" && !state.target.host) { + say("Enter the host name or IP of the machine running the game."); + el.host.focus(); + return; + } + el.validate.disabled = true; + renderCapabilities(null); + say("Validating " + (state.transport === "local" ? state.dir : state.target.host) + "…"); + invoke("source_validate", { + target: state.target, + dir: state.transport === "ssh" ? el.path.value.trim() : "", + }).then(function(result) { + el.validate.disabled = false; + state.capabilities = result; + state.fellBack = false; + renderCapabilities(result); + syncWatchNote(); + if (!result.ok) { + showError(result.detail || "That connection did not work."); + say("Not connected."); + return; + } + showError(""); + say("Connected. Next: pick the save."); + // Land the browser somewhere useful: the folder the caller gave, else the + // game's usual save location under this user's home. + if (!el.path.value.trim() && state.transport === "ssh") { + state.dir = suggestDir(result); + el.path.value = state.dir; + } + }).catch(function(error) { + el.validate.disabled = false; + showError(message(error)); + say("Not connected."); + }); + } + + // Where to open the browser: the game's usual folder when the platform is one + // we know, and otherwise the home directory -- the one path validate always + // reports, and one the user can walk down from. An empty path here is what + // left step 3 blank with nothing to click. + function suggestDir(result) { + var home = (result.home || "").replace(/[\\/]+$/, ""); + if (!home) { + return ""; + } + if (result.platform === "Windows") { + return home + "/" + SAVE_DIR.windows; + } + if (result.platform === "Linux/macOS") { + return home + "/" + SAVE_DIR.linux; + } + return home; + } + + // The connection's own words, plus the one thing to try next. + function showError(text) { + el.error.textContent = ""; + if (!text) { + el.error.style.display = "none"; + return; + } + var line = document.createElement("div"); + line.className = "srcErrorText"; + line.textContent = text; + el.error.appendChild(line); + var hint = hintFor(text); + if (hint) { + var advice = document.createElement("div"); + advice.className = "srcErrorHint"; + advice.textContent = hint; + el.error.appendChild(advice); + } + el.error.style.display = ""; + } + + function renderCapabilities(result) { + el.checks.innerHTML = ""; + if (!result) { + return; + } + // Three states, because "no rsync on the remote" is not a failure: the + // connection is fine and SFTP carries the transfer. Red is reserved for the + // one thing browsing and watching actually depend on. + var rows = [ + [result.ok ? "ok" : "bad", "SSH login", result.ok ? (result.detail || "signed in") : "not connected"], + [result.sftp ? "ok" : "bad", "SFTP subsystem", result.sftp + ? "browsing and change detection available (" + (result.platform || "unknown") + + (result.home ? ", " + result.home : "") + ")" + : "not available -- type the save's path by hand"], + [result.rsync ? "ok" : "off", "rsync on the remote", result.rsync + ? "available (delta transfers, less to re-fetch)" + : "not installed there -- SFTP is used instead"], + ]; + if (!result.ok) { + return; // the error block says it better, in the client's own words + } + rows.forEach(function(row) { + var line = document.createElement("div"); + line.className = "srcCheck is-" + row[0]; + var mark = document.createElement("span"); + mark.className = "srcCheckMark"; + mark.textContent = row[0] === "ok" ? "✓" : (row[0] === "bad" ? "✕" : "·"); + var name = document.createElement("span"); + name.className = "srcCheckName"; + name.textContent = row[1]; + var detail = document.createElement("span"); + detail.className = "srcCheckDetail"; + detail.textContent = row[2] || ""; + line.appendChild(mark); + line.appendChild(name); + line.appendChild(detail); + el.checks.appendChild(line); + }); + } + + // ---- Browse -------------------------------------------------------------- + + function browse(path, quiet) { + if (!path) { + say("Enter a folder to open."); + return Promise.resolve(); + } + if (state.transport === "ssh" && !state.target.host) { + say("Set the host first (and press Validate)."); + return Promise.resolve(); + } + state.dir = path; + el.path.value = path; + if (state.transport === "local") { + el.localPath.value = path; + } + if (!quiet) { + el.list.innerHTML = ""; + var loading = document.createElement("div"); + loading.className = "srcEmpty"; + loading.textContent = "Listing " + path + "…"; + el.list.appendChild(loading); + say("Listing " + path + "…"); + } + return invoke("source_browse", { + target: state.target, + path: path, + local: state.transport === "local", + }).then(function(listing) { + renderListing(listing, quiet); + remember(); + }).catch(function(error) { + var home = state.capabilities && state.capabilities.home; + if (!state.fellBack && home && path !== home && path === state.dir) { + // The guess was wrong (a different Steam library, a host we only know + // the home of). Start where we know there is something. + state.fellBack = true; + var reason = message(error); + return browse(home, quiet).then(function() { + say(reason + " -- showing " + home + " instead."); + }); + } + if (!quiet) { + el.list.innerHTML = ""; + var failed = document.createElement("div"); + failed.className = "srcEmpty"; + failed.textContent = message(error); + el.list.appendChild(failed); + say(message(error)); + } + throw error; + }); + } + + function renderListing(listing, quiet) { + state.parent = listing.parent; + state.entries = listing.entries || []; + el.list.innerHTML = ""; + if (state.entries.length === 0) { + var empty = document.createElement("div"); + empty.className = "srcEmpty"; + empty.textContent = "Nothing in " + listing.path + "."; + el.list.appendChild(empty); + } + state.entries.forEach(function(entry) { + // A button, so the whole list is reachable by keyboard and Enter works + // the way a file dialog's does. + var row = document.createElement("button"); + row.type = "button"; + row.className = "row row-clickable srcRow" + (entry.isDir ? " is-dir" : "") + + (isSave(entry.name) ? " is-sav" : ""); + var icon = document.createElement("span"); + icon.className = "srcRowIcon"; + icon.textContent = entry.isDir ? "▸" : (isSave(entry.name) ? "●" : "·"); + var label = document.createElement("span"); + label.className = "row-label"; + // Remote names are untrusted data: textContent, never innerHTML. + label.textContent = entry.name; + var meta = document.createElement("span"); + meta.className = "row-meta"; + meta.textContent = entry.isDir ? "folder" : bytes(entry.size) + " · " + when(entry.mtime); + row.appendChild(icon); + row.appendChild(label); + row.appendChild(meta); + if (state.file === entry.name && !entry.isDir) { + row.classList.add("row-active"); + } + row.addEventListener("click", function() { + if (entry.isDir) { + browse(entry.path); + return; + } + if (!isSave(entry.name)) { + say(entry.name + " is not a .sav file."); + return; + } + state.file = entry.name; + state.filePath = entry.path; + // Picking one file means watching that file, not the newest in the + // folder -- otherwise the pick would be overwritten by the next tick. + el.newest.checked = false; + readForm(); + remember(); + renderListing(listing); + renderPicked(); + }); + row.addEventListener("keydown", function(e) { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + row.click(); + } + }); + el.list.appendChild(row); + }); + var saves = state.entries.filter(function(entry) { + return !entry.isDir && isSave(entry.name); + }); + var folders = state.entries.filter(function(entry) { return entry.isDir; }); + // The game's own layout is //*.sav, and the level above + // it holds that one folder plus the dedicated server's own 116-byte + // ServerManager file -- so "newest .sav" there picks a file that is not a + // save. When the single folder beside them is the most recently written + // thing, the saves are in it: walk in once, the way a person would. + var newestSave = saves.reduce(function(latest, entry) { + return Math.max(latest, entry.mtime || 0); + }, 0); + var savesAreInside = !quiet && !state.descended && folders.length === 1 + && (saves.length === 0 || (folders[0].mtime || 0) > newestSave); + if (savesAreInside) { + state.descended = true; + browse(folders[0].path); + return; + } + var saveCount = saves.length; + if (!quiet) { + say(saveCount + " .sav file" + (saveCount === 1 ? "" : "s") + " in " + listing.path + "."); + } + if (state.file && !el.newest.checked) { + // The picked file may be gone; the folder listing is the truth. + var stillThere = state.entries.some(function(entry) { + return entry.name === state.file && !entry.isDir; + }); + if (!stillThere) { + state.file = ""; + state.filePath = ""; + } + } + renderPicked(); + } + + function renderPicked() { + if (!el.picked) { + return; + } + var chosen = state.takeNewest + ? "newest .sav in the folder" + : (state.file || "nothing picked yet -- click a .sav above"); + el.picked.textContent = "Will load: " + chosen; + el.load.disabled = !(state.takeNewest ? state.dir : state.filePath); + } + + // ---- Load and watch ------------------------------------------------------ + + // The one place a save is actually pulled and handed to the map. + // + // `manual` means the user pressed the button: always load, and leave the + // watch baseline unset so the next tick re-establishes it (that tick may + // fetch once more, but the hash comparison then keeps the map as it is). + // A watch tick passes the stat key it saw, so a folder that has not changed + // again stays quiet. + function load(manual, seenKey) { + if (state.transport === "ssh" && !state.target.host) { + say("Set the host and validate the connection first."); + return Promise.resolve(); + } + // Re-read the folder first: "newest" only exists in a listing, and the one + // on screen may be minutes old -- a tick that just noticed a new autosave + // has to see it, or it would load the file it already had. + return browse(state.dir, true).then(function() { + var entry = currentTarget(); + if (!entry) { + say(state.takeNewest + ? "No .sav file in " + state.dir + "." + : "Pick a .sav file to load" + (state.file ? " (" + state.file + " is gone)" : "") + "."); + return null; + } + state.file = entry.name; + state.filePath = entry.path; + remember(); + if (state.transport === "local") { + // A mounted file is read in place, and it carries its own timestamp, + // so the baseline is exact and the next tick has nothing to confirm. + state.sha = null; + state.seen = seenKey || statKey(entry); + return options.loadPath(entry.path).then(function() { + say("Loaded " + entry.name + " (" + bytes(entry.size) + ") at " + clock() + "."); + arm(); + }).catch(function(error) { + say("Load failed: " + message(error)); + }); + } + // rsync only when the far side actually has it (validate says so); SFTP is + // the transport that works against a stock Windows OpenSSH server. + showLocalRow(false); // the loaded save is remote now, not a path here + var transport = state.capabilities && state.capabilities.rsync ? "rsync" : "sftp"; + say("Fetching " + entry.name + " over " + transport + "\u2026"); + return invoke("source_fetch", { + target: state.target, + remotePath: entry.path, + transport: transport, + }).then(function(fetched) { + if (!manual && fetched.sha256 === state.sha) { + state.seen = seenKey || null; + say("Auto-refresh " + clock() + ": " + fetched.saveName + " re-fetched, bytes unchanged."); + return null; + } + state.sha = fetched.sha256; + state.seen = manual ? null : (seenKey || null); + state.loadedPath = fetched.path; + return options.loadPath(fetched.path).then(function() { + say("Loaded " + fetched.saveName + " (" + bytes(fetched.bytes) + ") over " + transport + " at " + clock() + "."); + arm(); + }); + }).catch(function(error) { + say((manual ? "Fetch failed: " : "Auto-refresh " + clock() + ": ") + message(error)); + }); + }); + } + + // The picked file, as the last listing described it. + function localEntry(name) { + return (state.entries || []).filter(function(entry) { + return !entry.isDir && entry.name === name; + })[0] || null; + } + + function newestSaveEntry() { + var saves = (state.entries || []).filter(function(entry) { + return !entry.isDir && isSave(entry.name); + }); + saves.sort(function(a, b) { + return (b.mtime || 0) - (a.mtime || 0); + }); + return saves[0] || null; + } + + // What "load" means right now: the newest .sav in the folder, or the one the + // user picked. The listing is the only source for both. + function currentTarget() { + return state.takeNewest ? newestSaveEntry() : localEntry(state.file); + } + + // The identity of "the file we would load": the three things a listing gives + // us, and the only things a change can move. + function statKey(stat) { + if (!stat || !stat.name) { + return null; + } + return stat.name + "|" + stat.size + "|" + stat.mtime; + } + + // One watch tick: re-read the folder and go further only when the file we + // would load has changed. Paused while the tab is hidden, while a parse is + // running, and while the editor holds pending edits -- a reload discards + // those, so the user finishes first and polling resumes on its own. + function tick() { + if (!state.watch || document.hidden || options.isBusy()) { + return; + } + var pending = options.pendingEdits(); + if (pending > 0) { + say("Auto-refresh paused: " + pending + " edit(s) pending -- undo or export them and it resumes."); + return; + } + if (!state.dir) { + return; + } + browse(state.dir, true).then(function() { + var entry = currentTarget(); + if (!entry) { + say("Auto-refresh " + clock() + ": " + (state.takeNewest + ? "no .sav in " + state.dir + " yet" + : state.file + " is no longer in " + state.dir + + " -- tick \"always take the newest\" to follow the autosaves")); + return null; + } + var key = statKey(entry); + if (key === state.seen) { + // Strictly newer, not merely first in the listing: two saves written in + // the same minute share a timestamp, and calling one of them "newer" + // would be a guess. + var newer = newestSaveEntry(); + var movedOn = newer && newer.name !== entry.name + && (newer.mtime || 0) > (entry.mtime || 0); + say("Auto-refresh " + clock() + ": " + entry.name + " unchanged." + + (movedOn ? " (a newer save is in the folder: " + newer.name + ")" : "")); + return null; + } + // Changed (or first look): fetch, hash, and let load() decide whether the + // map actually needs rebuilding. + say("Auto-refresh " + clock() + ": " + entry.name + " changed, fetching\u2026"); + return load(false, key); + }).catch(function(error) { + // Machine asleep, sshd stopped, share unmounted: say so and keep trying. + say("Auto-refresh " + clock() + ": " + message(error) + " (will retry)."); + }); + } + + // Two triggers, one decision. When the machine that holds the save can push + // events, nothing is polled: `inotifywait` (or PowerShell's FileSystemWatcher + // on Windows) runs there and each name it prints wakes the same tick(). When + // it cannot -- no inotify-tools, or a CIFS/NFS mount, where inotify would + // never fire because the writes are not made through this kernel -- the + // interval is the trigger instead. Either way the decision is a listing + // comparison and a byte hash, so a missed or spurious event cannot reload the + // map wrongly. + function arm() { + if (!state.watch || !watchPossible()) { + stop(); + return; + } + // Remote: what validate found. Local: nothing to ask -- the command works + // it out from the filesystem (and from whether inotifywait is installed). + var mode = state.transport === "local" + ? "" + : ((state.capabilities && state.capabilities.watch) || ""); + var key = state.transport + "|" + state.dir + "|" + mode; + if (!mode || key !== state.watchKey) { + startWatch(key, mode); + return; + } + if (!state.watchMode) { + schedule(); + } + } + + function startWatch(key, mode) { + stop(); + if (!state.dir) { + schedule(); + return; + } + invoke("source_watch_start", { + target: state.target, + dir: state.dir, + mode: mode, + platform: (state.capabilities && state.capabilities.platform) || "", + local: state.transport === "local", + }).then(function(started) { + state.watchKey = key; + state.watchMode = started; + say("Watching " + state.dir + " \u2014 changes are pushed to the map as they land (" + + (started === "watcher" ? "FileSystemWatcher" : "inotifywait") + ")."); + }).catch(function(error) { + state.watchKey = key; + state.watchMode = ""; + if (el.watchNote) { + el.watchNote.textContent = "No event source there, so the folder is checked every " + + state.interval + "s."; + } + say("No event watcher on that machine (" + message(error) + ") \u2014 checking every " + + state.interval + "s instead."); + schedule(); + }); + } + + // The interval, used only while no event source is live. + function schedule() { + if (timer !== null) { + window.clearInterval(timer); + timer = null; + } + if (!state.watch || state.watchMode || !watchPossible()) { + return; + } + timer = window.setInterval(tick, Math.max(5, state.interval) * 1000); + } + + function stop() { + if (timer !== null) { + window.clearInterval(timer); + timer = null; + } + if (burst !== null) { + window.clearTimeout(burst); + burst = null; + } + if (state.watchMode) { + state.watchMode = ""; + state.watchKey = ""; + invoke("source_watch_stop").catch(function() { /* nothing to stop */ }); + } + } + + // An event is a nudge, not a verdict: a save write arrives as a burst + // (create, several close_writes, a rename), so wait for the burst to settle + // and let one tick answer for all of it. + function onChanged(name) { + if (!state.watch) { + return; + } + if (name && !isSave(name)) { + return; // the folder also holds backups, screenshots and logs + } + if (burst !== null) { + window.clearTimeout(burst); + } + burst = window.setTimeout(function() { + burst = null; + tick(); + }, 1500); + } + + return { + init: function(bridge) { + try { + init(bridge); + } catch (error) { + // A missing dialog or a broken bridge must not take the load flow down. + console.warn("SaveSource: not available:", error); + } + }, + open: open, + // data.js tells us what was just loaded from this machine, and whether the + // save is still a local one. + adoptLocal: adoptLocal, + forgetLocal: forgetLocal, + state: state, + }; +})(); diff --git a/rust_parser/tauri/src/lib.rs b/rust_parser/tauri/src/lib.rs index 556fc24..441548b 100644 --- a/rust_parser/tauri/src/lib.rs +++ b/rust_parser/tauri/src/lib.rs @@ -5,3 +5,4 @@ pub mod server_api; pub mod session; +pub mod ssh_source; diff --git a/rust_parser/tauri/src/main.rs b/rust_parser/tauri/src/main.rs index c4c96ca..4eb30cf 100644 --- a/rust_parser/tauri/src/main.rs +++ b/rust_parser/tauri/src/main.rs @@ -12,9 +12,10 @@ use sav_tauri_lib::server_api; use sav_tauri_lib::session::AppSession; +use sav_tauri_lib::ssh_source; use std::sync::Mutex; use tauri::ipc::{Channel, Response}; -use tauri::{Manager, State}; +use tauri::{Emitter, Manager, State}; use tauri_plugin_dialog::DialogExt; struct AppState { @@ -35,6 +36,11 @@ struct AppState { /// monotonic id; entries live until the app exits because committed /// pasteExternal ops replay from them on every undo, across save loads. clipboard_slots: Mutex<(u64, std::collections::HashMap)>, + /// The change watcher, when one is running: one long-lived process (an + /// `inotifywait -m`, local or over ssh, or a PowerShell FileSystemWatcher), + /// killed when watching stops or the target changes. Kept here rather than + /// in the frontend because a webview cannot hold a child process. + watch: Mutex>, } /// Payloads at most this big go back as one raw `Response` (the standing @@ -551,6 +557,129 @@ async fn server_fetch_latest( }) } +// ---- Save sources over SSH / a mounted share (src/ssh_source.rs) ----------- +// The HTTP alternative to these is a listener on the game machine; these use +// the OpenSSH server that is already there. All of them run on blocking +// threads, because each one is a subprocess with a deadline. + +/// "Can this connection do what the dialog needs?" -- authentication, the +/// SFTP subsystem (browsing and change polling depend on it), rsync on the far +/// side, and how many saves are in `dir` when the caller already knows one. +#[tauri::command] +async fn source_validate( + target: ssh_source::SshTarget, + dir: Option, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + ssh_source::validate(&target, dir.as_deref().unwrap_or("")) + }) + .await + .map_err(|e| format!("Validation task failed: {e}")) +} + +/// One directory listing: over SFTP when `local` is false, straight off the +/// filesystem when it is true (a mounted SMB/NFS share needs no protocol code +/// -- the OS already speaks it). +#[tauri::command] +async fn source_browse( + target: ssh_source::SshTarget, + path: String, + local: bool, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + if local { + ssh_source::browse_local(&path) + } else { + ssh_source::browse(&target, &path) + } + }) + .await + .map_err(|e| format!("Browse task failed: {e}"))? +} + +/// Pull the save into the app's own cache folder and hash it. `transport` is +/// "sftp" (works against a stock Windows OpenSSH server) or "rsync" (delta +/// transfer, when the far side has it -- the same 50 MB save re-fetched on +/// every autosave is what that earns). +#[tauri::command] +async fn source_fetch( + target: ssh_source::SshTarget, + remote_path: String, + transport: String, + app: tauri::AppHandle, +) -> Result { + let cache = app + .path() + .app_local_data_dir() + .map_err(|e| format!("No app data dir: {e}"))? + .join("remote-saves"); + tauri::async_runtime::spawn_blocking(move || { + ssh_source::fetch(&target, &remote_path, &transport, &cache) + }) + .await + .map_err(|e| format!("Fetch task failed: {e}"))? +} + +/// Start watching the folder and push a `save-source-changed` event per name +/// that lands in it. `mode` is what `source_validate` reported ("inotify" or +/// "watcher"); empty means "work it out" -- which is what a locally opened save +/// needs, since it never went through a validate. An error here is the +/// frontend's cue to fall back to the interval poll, so the message is the +/// tool's own words (no `inotifywait` on that machine, no such folder, a +/// refused key). +#[tauri::command] +async fn source_watch_start( + target: ssh_source::SshTarget, + dir: String, + mode: String, + platform: Option, + local: bool, + state: State<'_, AppState>, + app: tauri::AppHandle, +) -> Result { + stop_watch(&state); + let handle = tauri::async_runtime::spawn_blocking(move || { + let mode = if mode.is_empty() { + ssh_source::watch_mode( + &target, + platform.as_deref().unwrap_or(""), + local, + &dir, + ) + .to_string() + } else { + mode + }; + if mode.is_empty() { + return Err("No event watcher on that machine.".to_string()); + } + ssh_source::spawn_watch(&target, &dir, local, &mode, move |name| { + // The webview decides whether the name matters; this is just the + // notification that something moved. + let _ = app.emit("save-source-changed", name); + }) + }) + .await + .map_err(|e| format!("Watch task failed: {e}"))??; + let mode = handle.mode.clone(); + *state.watch.lock().unwrap() = Some(handle.child); + Ok(mode) +} + +/// Stop watching. The remote `inotifywait` dies with the connection it was +/// started on. +#[tauri::command] +fn source_watch_stop(state: State<'_, AppState>) { + stop_watch(&state); +} + +fn stop_watch(state: &AppState) { + if let Some(mut child) = state.watch.lock().unwrap().take() { + let _ = child.kill(); + let _ = child.wait(); + } +} + /// Where the TOFU certificate pins live (see server_api::fetch_latest). fn cert_pin_path(app: &tauri::AppHandle) -> Result { Ok(app @@ -716,6 +845,7 @@ fn main() { session: Mutex::new(None), chunked_payload: Mutex::new((0, None)), clipboard_slots: Mutex::new((0, std::collections::HashMap::new())), + watch: Mutex::new(None), }) .invoke_handler(tauri::generate_handler![ load, @@ -736,6 +866,11 @@ fn main() { payload_slice, payload_done, server_fetch_latest, + source_validate, + source_browse, + source_fetch, + source_watch_start, + source_watch_stop, server_forget_pin, server_password_store, server_password_get, diff --git a/rust_parser/tauri/src/ssh_source.rs b/rust_parser/tauri/src/ssh_source.rs new file mode 100644 index 0000000..1483dd1 --- /dev/null +++ b/rust_parser/tauri/src/ssh_source.rs @@ -0,0 +1,1222 @@ +//! Save sources on the machine that runs the game: its own SSH server. +//! +//! The alternative to this module is a small HTTP file server on the gaming PC +//! (`tools/save-http-server`). This one opens no listening port and adds no +//! service to keep patched: OpenSSH is already installed on Windows 10/11 and +//! on every Linux box, it authenticates with the key the user already has, and +//! it offers what a static file endpoint cannot -- a directory listing we can +//! actually browse, and a change signal cheap enough to poll. +//! +//! Nothing here implements the SSH protocol. It runs the user's own `ssh`, +//! `sftp` and `rsync`, so `~/.ssh/config`, `known_hosts`, ssh-agent, keys and +//! `ProxyJump` all apply for free, and the app ships no SSH stack to keep +//! current. The trade-off is that authentication and host keys stay OpenSSH's +//! business: every call sets `BatchMode=yes`, so key/agent auth only -- a +//! hidden-stdin password prompt would otherwise hang until the timeout -- and a +//! first connection to an unknown host fails unless the user opts into +//! `StrictHostKeyChecking=accept-new` (trust on first use, same disclosure as +//! the dedicated-server certificate pin in `server_api`). + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::io::{Read, Write}; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +const CONNECT_TIMEOUT_SECS: u32 = 10; +const PROBE_TIMEOUT: Duration = Duration::from_secs(45); +const FETCH_TIMEOUT: Duration = Duration::from_secs(1800); +/// Ceiling on a remote save. Far above any real one (a 600k-object endgame +/// save is ~50 MB) and low enough that a wrong path cannot fill the disk. +const MAX_SAVE_BYTES: u64 = 8 * 1024 * 1024 * 1024; + +/// Everything needed to reach the game machine over SSH. Persisted by the +/// frontend in localStorage; only `identity` and `accept_new_host_key` touch +/// anything on disk outside our own app-data dir. +#[derive(Deserialize, Serialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct SshTarget { + pub host: String, + #[serde(default)] + pub user: String, + /// 0 means "the client's default" (22 unless ~/.ssh/config says otherwise). + #[serde(default)] + pub port: u16, + #[serde(default)] + pub identity: String, + #[serde(default)] + pub accept_new_host_key: bool, +} + +/// Result of `source_validate`: what the connection can actually do. Each +/// field maps to one line in the dialog, so a partial capability (SSH works, +/// no SFTP subsystem) is reported rather than collapsing to "failed". +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ValidateResult { + pub ok: bool, + /// "Windows" | "Linux" | "macOS" | "" -- from the shape of the SFTP home. + pub platform: String, + /// Remote home directory as SFTP reports it (`/home/x`, `/C:/Users/x`). + pub home: String, + /// SFTP subsystem: without it there is no browsing and no change polling, + /// only a typed path plus an rsync pull. + pub sftp: bool, + /// rsync on the REMOTE side: what makes rsync-over-ssh usable. + pub rsync: bool, + /// How changes can be noticed: "inotify" or "watcher" when the far side can + /// push them, "" when only polling is possible. See `watch_mode`. + pub watch: String, + /// How many `.sav` files are in `dir`, when the caller passed one. + pub save_count: usize, + pub newest_save: String, + /// Human-readable last word: the error when `ok` is false. + pub detail: String, +} + +#[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct DirEntry { + pub name: String, + pub path: String, + pub is_dir: bool, + pub size: u64, + /// Unix seconds; the listing's own resolution (a minute). + pub mtime: i64, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Listing { + pub path: String, + pub parent: String, + pub entries: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FetchedSave { + pub path: String, + pub save_name: String, + pub sha256: String, + pub bytes: u64, + pub remote_path: String, +} + +// ---- Running the tools ---------------------------------------------------- + +struct Cmd { + ok: bool, + stdout: String, + stderr: String, + timed_out: bool, +} + +/// Run a local tool with a hard deadline, draining both pipes on their own +/// threads. Without that, a remote process that writes more than a pipe buffer +/// stalls forever waiting for a reader that is waiting for it to exit. +fn run(bin: &str, args: &[String], stdin: Option<&str>, timeout: Duration) -> Result { + let mut child = Command::new(bin) + .args(args) + .stdin(if stdin.is_some() { Stdio::piped() } else { Stdio::null() }) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("Could not run {bin}: {e}"))?; + if let Some(text) = stdin { + if let Some(mut pipe) = child.stdin.take() { + // Our batch scripts are a few hundred bytes; writing them before + // reading stdout cannot fill a pipe. + pipe.write_all(text.as_bytes()) + .map_err(|e| format!("Could not talk to {bin}: {e}"))?; + } + } + let mut out = child.stdout.take().expect("stdout piped"); + let mut err = child.stderr.take().expect("stderr piped"); + let out_thread = std::thread::spawn(move || { + let mut buf = Vec::new(); + let _ = out.read_to_end(&mut buf); + buf + }); + let err_thread = std::thread::spawn(move || { + let mut buf = Vec::new(); + let _ = err.read_to_end(&mut buf); + buf + }); + let deadline = Instant::now() + timeout; + let mut timed_out = false; + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break Some(status), + Ok(None) => { + if Instant::now() >= deadline { + timed_out = true; + let _ = child.kill(); + break child.wait().ok(); + } + std::thread::sleep(Duration::from_millis(40)); + } + Err(e) => return Err(format!("Could not wait for {bin}: {e}")), + } + }; + let stdout = String::from_utf8_lossy(&out_thread.join().unwrap_or_default()).into_owned(); + let stderr = String::from_utf8_lossy(&err_thread.join().unwrap_or_default()).into_owned(); + Ok(Cmd { + ok: status.map(|s| s.success()).unwrap_or(false), + stdout, + stderr, + timed_out, + }) +} + +/// Reject anything that would reach the client as an option instead of a +/// host, and anything that would break out of the quoting we do for the SFTP +/// batch script. There is no shell in the middle (argv is passed straight to +/// the tool), so this is about the tools' own argument parsing, not `sh -c`. +fn check_token(what: &str, value: &str, allowed: &str) -> Result<(), String> { + if value.is_empty() { + return Err(format!("{what} is empty.")); + } + if value.starts_with('-') { + return Err(format!("{what} cannot start with '-'.")); + } + if !value.chars().all(|c| allowed.contains(c)) { + return Err(format!("{what} contains a character that is not allowed: {value}")); + } + Ok(()) +} + +/// A remote path travels to `sftp` inside a batch script, where a newline +/// would be read as the next command. Reject control characters and the +/// double quote we wrap paths in; both are impossible in a real save path. +fn check_remote_path(path: &str) -> Result<(), String> { + if path.trim().is_empty() { + return Err("Enter a folder on the remote machine first.".to_string()); + } + if path.len() > 4096 { + return Err("That path is implausibly long.".to_string()); + } + if path.chars().any(|c| c.is_control()) { + return Err("That path has a control character in it.".to_string()); + } + if path.contains('"') { + return Err("That path has a double quote in it.".to_string()); + } + Ok(()) +} + +impl SshTarget { + fn check(&self) -> Result<(), String> { + check_token( + "The host", + self.host.trim(), + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-_:[]", + )?; + if !self.user.trim().is_empty() { + check_token( + "The user name", + self.user.trim(), + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-@\\", + )?; + } + // `port` is a u16: an out-of-range number fails to deserialize before + // it gets here, so there is no range to re-check. + if !self.identity.trim().is_empty() && !std::path::Path::new(self.identity.trim()).is_file() { + return Err(format!("No key file at {}.", self.identity.trim())); + } + Ok(()) + } + + fn destination(&self) -> String { + if self.user.trim().is_empty() { + self.host.trim().to_string() + } else { + format!("{}@{}", self.user.trim(), self.host.trim()) + } + } + + /// Options shared by ssh and sftp. `BatchMode=yes` is what keeps a + /// password or host-key question from hanging the app on an invisible + /// stdin; `accept-new` is the user's explicit opt-in to trust a host we + /// have never seen (it still refuses a CHANGED key). + fn opts(&self) -> Vec { + let mut opts: Vec = vec![ + "-o".into(), + "BatchMode=yes".into(), + "-o".into(), + format!("ConnectTimeout={CONNECT_TIMEOUT_SECS}"), + "-o".into(), + if self.accept_new_host_key { + "StrictHostKeyChecking=accept-new".into() + } else { + "StrictHostKeyChecking=yes".into() + }, + ]; + if !self.identity.trim().is_empty() { + opts.push("-i".into()); + opts.push(self.identity.trim().to_string()); + } + opts + } + + fn ssh_args(&self, remote_command: &str) -> Vec { + let mut args = self.opts(); + if self.port != 0 && self.port != 22 { + args.push("-p".into()); + args.push(self.port.to_string()); + } + args.push(self.destination()); + args.push(remote_command.to_string()); + args + } + + /// sftp spells the port `-P`, and takes the batch script on stdin. + fn sftp_args(&self) -> Vec { + let mut args = self.opts(); + if self.port != 0 && self.port != 22 { + args.push("-P".into()); + args.push(self.port.to_string()); + } + args.push("-b".into()); + args.push("-".into()); + args.push(self.destination()); + args + } +} + +/// Is this tool on PATH? `which` is not on a stock Windows box, so ask the +/// shell that is. +fn on_path(bin: &str) -> bool { + let probe = if cfg!(windows) { "where" } else { "which" }; + run(probe, &[bin.to_string()], None, Duration::from_secs(5)) + .map(|cmd| cmd.ok) + .unwrap_or(false) +} + +fn first_line(text: &str) -> String { + text.lines().next().unwrap_or("").trim().to_string() +} + +/// The client's own words are the useful half of a failure ("Permission +/// denied (publickey)", "Host key verification failed"), so prefer stderr and +/// only fall back to a generic line. +fn tool_error(bin: &str, cmd: &Cmd) -> String { + let detail = if cmd.stderr.trim().is_empty() { + first_line(&cmd.stdout) + } else { + first_line(&cmd.stderr) + }; + if cmd.timed_out { + return format!("{bin} timed out. Is the machine awake and reachable?"); + } + if detail.is_empty() { + return format!("{bin} failed (exit status unknown)."); + } + detail +} + +fn sftp_script(t: &SshTarget, commands: &[String], timeout: Duration) -> Result { + t.check()?; + let script = format!("{}\n", commands.join("\n")); + let cmd = run("sftp", &t.sftp_args(), Some(&script), timeout)?; + if cmd.timed_out { + return Err(tool_error("sftp", &cmd)); + } + if !cmd.ok { + return Err(tool_error("sftp", &cmd)); + } + Ok(cmd.stdout) +} + +// ---- Parsing the SFTP client's output ------------------------------------- + +/// Pull the path out of `pwd`'s "Remote working directory: ". +fn parse_pwd(text: &str) -> Option { + text.lines() + .find_map(|line| line.trim().strip_prefix("Remote working directory:")) + .map(|path| path.trim().to_string()) + .filter(|path| !path.is_empty()) +} + +/// The Windows OpenSSH server exposes drives as `/C:/...`; the POSIX one as a +/// plain absolute path. That is enough to say which OS we are talking to +/// without a second round trip (`uname` does not exist in cmd.exe). +fn platform_from_home(home: &str) -> &'static str { + let rest = home.trim_start_matches('/'); + let mut chars = rest.chars(); + match (chars.next(), chars.next()) { + (Some(letter), Some(':')) if letter.is_ascii_alphabetic() => "Windows", + _ => "Linux/macOS", + } +} + +const MONTHS: [&str; 12] = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", +]; + +/// `ls -l` prints either "Mon DD HH:MM" (within the last six months) or +/// "Mon DD YYYY". Turn that into Unix seconds so the browser can sort by it +/// and show something meaningful; the year is inferred the way `ls` does, with +/// a clock-skew allowance so a file written a moment ago in another timezone +/// is not pushed a year into the future. +fn parse_when(month: &str, day: &str, third: &str, now: SystemTime) -> i64 { + let Some(m) = MONTHS.iter().position(|name| name.eq_ignore_ascii_case(month)) else { + return 0; + }; + let Ok(day) = day.trim().parse::() else { + return 0; + }; + let now_secs = now + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + let now_year = 1970 + (now_secs / 31_556_952) as i32; // ~mean year, fine for inference + let (year, hour, minute) = if third.contains(':') { + let mut parts = third.split(':'); + let hour = parts.next().and_then(|h| h.trim().parse::().ok()).unwrap_or(0); + let minute = parts.next().and_then(|m| m.trim().parse::().ok()).unwrap_or(0); + // `ls` omits the year for recent files: assume this year, and step + // back one when that lands in the future by more than a day (a + // December file read in January). + let mut year = now_year; + if month_secs(year, m, day, hour, minute) > now_secs + 86_400 { + year -= 1; + } + (year, hour, minute) + } else { + (third.trim().parse::().unwrap_or(now_year), 0, 0) + }; + month_secs(year, m, day, hour, minute) +} + +/// Days-from-civil (Howard Hinnant's algorithm), so no chrono dependency for +/// what is only ever a sort key and a timestamp. +fn month_secs(year: i32, month: usize, day: u32, hour: u32, minute: u32) -> i64 { + let m = month as i32 + 1; + let y = if m <= 2 { year - 1 } else { year }; + let era = if y >= 0 { y } else { y - 399 } / 400; + let yoe = (y - era * 400) as i64; + let mp = ((m + 9) % 12) as i64; + let doy = (153 * mp + 2) / 5 + day as i64 - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + let days = era as i64 * 146_097 + doe - 719_468; + days * 86_400 + hour as i64 * 3600 + minute as i64 * 60 +} + +/// One `ls -l` line from the SFTP client into an entry. +/// +/// The client prints `mode nlink owner group size `, and the +/// fields in between vary in both shape and count: OpenSSH sends no link +/// count, so the client prints `?` there, an SSH server with no owner +/// information prints `?` twice more, and the name is the path as the server +/// saw it -- the full `/home/x/saves/a.sav` when we asked for that directory +/// by absolute path. Field positions are therefore not fixed, so the date is +/// the anchor: it is always three tokens and always shaped the same (a month +/// name, a day, then either `HH:MM` or a year), which also leaves names with +/// spaces intact. +fn parse_ls_line(line: &str, dir: &str, now: SystemTime) -> Option { + let trimmed = line.trim_end_matches(['\r', '\n']); + let mode = trimmed.split_whitespace().next()?; + let mut chars = mode.chars(); + let kind = chars.next()?; + if !matches!(kind, '-' | 'd' | 'l' | '?') || mode.len() < 10 { + return None; + } + if !mode[1..].chars().all(|c| "rwxstST-".contains(c)) { + return None; + } + // Whatever the server called it, the entry's own name is the last segment + // (a file name cannot contain a separator). + let name = entry_name(trimmed, mode)?; + let name = name.rsplit(['/', '\\']).next().unwrap_or("").to_string(); + if name.is_empty() || name == "." || name == ".." { + return None; + } + let rest = field_after(trimmed, mode)?; + let tokens = tokens_at(rest); + let month_at = date_at(&tokens)?; + // The size sits immediately before the date; counting fields from the left + // would instead land on the `?` the client prints for an unknown link + // count, which is the one field every remote listing has. + let size = month_at + .checked_sub(1) + .and_then(|at| tokens.get(at)) + .and_then(|(_, token)| token.parse::().ok()) + .unwrap_or(0); + let mtime = match tokens.get(month_at..month_at + 3) { + Some(triple) => parse_when(triple[0].1, triple[1].1, triple[2].1, now), + None => 0, + }; + Some(DirEntry { + path: join_remote(dir, &name), + name, + is_dir: kind == 'd', + size, + mtime, + }) +} + +/// Text after the mode field, where size and the date live. +fn field_after<'a>(line: &'a str, mode: &str) -> Option<&'a str> { + let at = line.find(mode)? + mode.len(); + Some(line[at..].trim_start()) +} + +/// Whitespace-separated tokens with the byte offset each starts at, so the +/// name can be sliced back out of the original line. `ls -l` pads its date +/// columns ("Jan 3 2025"), and rebuilding the date from its parts would +/// silently lose an entry whose name follows a double space. +fn tokens_at(text: &str) -> Vec<(usize, &str)> { + let mut out = Vec::new(); + let mut start = None; + for (at, ch) in text.char_indices() { + if ch.is_whitespace() { + if let Some(from) = start.take() { + out.push((from, &text[from..at])); + } + } else if start.is_none() { + start = Some(at); + } + } + if let Some(from) = start { + out.push((from, &text[from..])); + } + out +} + +/// The `(month, day, time-or-year)` triple the line carries, located by month +/// name so a varying number of owner/group fields cannot shift it. +fn date_at(tokens: &[(usize, &str)]) -> Option { + tokens.iter().position(|(_, token)| { + MONTHS.iter().any(|name| name.eq_ignore_ascii_case(token)) + }) +} + +fn entry_name(line: &str, mode: &str) -> Option { + let rest = field_after(line, mode)?; + let tokens = tokens_at(rest); + let month_at = date_at(&tokens)?; + // The name starts where the token after the date starts: month, day, then + // the time-or-year, then the name (spaces and all). + let (offset, _) = *tokens.get(month_at + 3)?; + Some(rest[offset..].to_string()) +} + +fn parse_listing(text: &str, dir: &str) -> Vec { + let now = SystemTime::now(); + text.lines() + .filter_map(|line| parse_ls_line(line, dir, now)) + .collect() +} + +/// `/a/b/c` -> `/a/b`, `/C:/` -> `/C:/`, and a Windows-style path keeps its +/// backslash separator (we only ever see the SFTP form, but a typed one is +/// cheap to handle). A root is its own parent, so the dialog's "Up" button +/// goes nowhere rather than somewhere invalid. +fn parent_of(path: &str) -> String { + let path = path.trim_end_matches(['/', '\\']); + if path.is_empty() { + return "/".to_string(); // "/" or a bare drive-relative path + } + if is_drive_root(path) { + return format!("{path}/"); + } + match path.rfind(['/', '\\']) { + Some(0) => "/".to_string(), + Some(at) => path[..at].to_string(), + None => String::new(), + } +} + +/// `/C:` or `C:` -- the top of a Windows drive, where SFTP's `cd ..` stops. +fn is_drive_root(path: &str) -> bool { + let bare = path.trim_start_matches('/'); + bare.len() == 2 + && bare.as_bytes()[1] == b':' + && (bare.as_bytes()[0] as char).is_ascii_alphabetic() +} + +fn join_remote(dir: &str, name: &str) -> String { + let dir = dir.trim_end_matches(['/', '\\']); + if dir.is_empty() { + name.to_string() + } else { + format!("{dir}/{name}") + } +} + +// ---- Commands ------------------------------------------------------------- + +pub fn validate(target: &SshTarget, dir: &str) -> ValidateResult { + let mut result = ValidateResult { + ok: false, + platform: String::new(), + home: String::new(), + sftp: false, + rsync: false, + watch: String::new(), + save_count: 0, + newest_save: String::new(), + detail: String::new(), + }; + if let Err(error) = target.check() { + result.detail = error; + return result; + } + // One SFTP call answers three questions at once: does SSH authenticate at + // all, is the SFTP subsystem available, and where do we land? `pwd` is the + // cheapest command that proves the subsystem works end to end. + match sftp_script(target, &["pwd".to_string()], PROBE_TIMEOUT) { + Ok(out) => { + result.sftp = true; + result.home = parse_pwd(&out).unwrap_or_default(); + result.platform = if result.home.is_empty() { + String::new() + } else { + platform_from_home(&result.home).to_string() + }; + } + Err(error) => { + // "subsystem request failed" is a different problem from a bad key, + // and only one of them is fixable on our side. + result.detail = error; + return result; + } + } + // rsync on the FAR side is what makes rsync-over-ssh work; without it SFTP + // carries the transfer. + let cmd = run("ssh", &target.ssh_args("rsync --version"), None, PROBE_TIMEOUT); + result.rsync = cmd.map(|c| c.ok).unwrap_or(false); + result.watch = if result.sftp { + watch_mode(target, &result.platform, false, dir).to_string() + } else { + String::new() + }; + if !dir.trim().is_empty() && result.sftp { + if let Ok(entries) = listing(target, dir) { + let saves: Vec<&DirEntry> = entries.iter().filter(|e| is_save(&e.name)).collect(); + result.save_count = saves.len(); + result.newest_save = newest_save(&entries).map(|e| e.name.clone()).unwrap_or_default(); + } + } + result.ok = result.sftp; + if result.ok { + let as_user = if target.user.trim().is_empty() { + "the SSH user".to_string() + } else { + target.user.trim().to_string() + }; + result.detail = format!("Signed in as {as_user}@{}.", target.host.trim()); + } + result +} + +fn is_save(name: &str) -> bool { + name.to_ascii_lowercase().ends_with(".sav") +} + +/// The newest `.sav` by listing timestamp -- the map's "latest autosave", +/// which is what the game itself writes over and over. Ties (same minute) fall +/// back to the name so the answer is at least stable. +fn newest_save(entries: &[DirEntry]) -> Option<&DirEntry> { + entries + .iter() + .filter(|entry| !entry.is_dir && is_save(&entry.name)) + .max_by(|a, b| a.mtime.cmp(&b.mtime).then_with(|| a.name.cmp(&b.name))) +} + +fn listing(target: &SshTarget, dir: &str) -> Result, String> { + check_remote_path(dir)?; + // `-l` is what gives us mode/size/date; the client does the formatting, so + // this works against a Windows or a POSIX server without a remote shell. + let out = sftp_script(target, &[format!("ls -l \"{dir}\"")], PROBE_TIMEOUT)?; + Ok(parse_listing(&out, dir)) +} + +pub fn browse(target: &SshTarget, dir: &str) -> Result { + Ok(listing_object(listing(target, dir)?, dir)) +} + +/// Folders first, then saves, then anything else; names within a group. Shared +/// by the SFTP and mounted-share listings so the two read the same. +fn listing_object(mut entries: Vec, dir: &str) -> Listing { + entries.sort_by(|a, b| { + b.is_dir + .cmp(&a.is_dir) + .then_with(|| is_save(&b.name).cmp(&is_save(&a.name))) + .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) + }); + Listing { + path: dir.trim_end_matches('/').to_string(), + parent: parent_of(dir), + entries, + } +} + +/// A mounted network share (SMB/CIFS from Windows, NFS, a GVFS mount) is just +/// a path -- the OS already did the hard part, so browsing it needs no +/// protocol code at all. +pub fn browse_local(dir: &str) -> Result { + let read = std::fs::read_dir(dir).map_err(|e| format!("Cannot read {dir}: {e}"))?; + let mut entries = Vec::new(); + for item in read.flatten() { + let path = item.path(); + let meta = match std::fs::metadata(&path) { + Ok(meta) => meta, + Err(_) => continue, // vanished mid-listing, or a dangling symlink + }; + entries.push(DirEntry { + name: item.file_name().to_string_lossy().into_owned(), + path: path.to_string_lossy().into_owned(), + is_dir: meta.is_dir(), + size: if meta.is_dir() { 0 } else { meta.len() }, + mtime: meta + .modified() + .ok() + .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) + .map(|d| d.as_secs() as i64) + .unwrap_or(0), + }); + } + Ok(listing_object(entries, dir)) +} + +/// Copy a `.sav` down into the app's own `remote-saves/` folder and hash it. +/// The bytes go to a `.part` file that is renamed only once complete, so a +/// truncated transfer can never become the file the parser reads; the hash is +/// what lets the watch loop skip a reload when only the timestamp moved. +pub fn fetch( + target: &SshTarget, + remote_path: &str, + transport: &str, + cache_dir: &std::path::Path, +) -> Result { + check_remote_path(remote_path)?; + target.check()?; + let name = remote_path + .rsplit(['/', '\\']) + .next() + .unwrap_or("remote.sav") + .to_string(); + if !is_save(&name) { + return Err(format!("{name} is not a .sav file.")); + } + // The file on disk is always ours, never a name from the network. + let safe_name: String = name + .chars() + .filter(|c| !c.is_control() && *c != '/' && *c != '\\') + .take(120) + .collect(); + std::fs::create_dir_all(cache_dir) + .map_err(|e| format!("Failed to create {}: {e}", cache_dir.display()))?; + let final_path = cache_dir.join(&safe_name); + let part = cache_dir.join(format!("{safe_name}.part")); + let _ = std::fs::remove_file(&part); + + match transport { + "rsync" => { + let mut args: Vec = vec![ + "-e".into(), + format!("ssh {}", target.opts().join(" ")), + "--partial".into(), + "--compress".into(), + "--timeout=120".into(), + ]; + if target.port != 0 && target.port != 22 { + // rsync's ssh gets the port through -e, not its own flag. + args[1] = format!("ssh -p {} {}", target.port, target.opts().join(" ")); + } + let source = format!("{}:{}", target.destination(), remote_path.replace('\\', "/")); + args.push(source); + args.push(part.to_string_lossy().into_owned()); + let cmd = run("rsync", &args, None, FETCH_TIMEOUT)?; + if !cmd.ok || cmd.timed_out { + let _ = std::fs::remove_file(&part); + return Err(tool_error("rsync", &cmd)); + } + } + _ => { + // SFTP's own `get`, which also works where rsync does not exist + // (i.e. on a Windows game machine). + sftp_script( + target, + &[ + format!("get \"{remote_path}\" \"{}\"", part.to_string_lossy()), + "quit".to_string(), + ], + FETCH_TIMEOUT, + ) + .map_err(|e| { + let _ = std::fs::remove_file(&part); + e + })?; + } + } + + let bytes = std::fs::metadata(&part) + .map_err(|e| format!("The transfer produced no file: {e}"))? + .len(); + if bytes > MAX_SAVE_BYTES { + let _ = std::fs::remove_file(&part); + return Err(format!( + "That save is {bytes} bytes, over the {MAX_SAVE_BYTES}-byte limit." + )); + } + let sha256 = hash_file(&part)?; + std::fs::rename(&part, &final_path) + .map_err(|e| format!("Failed to move the download into {}: {e}", final_path.display()))?; + Ok(FetchedSave { + path: final_path.to_string_lossy().into_owned(), + save_name: safe_name, + sha256, + bytes, + remote_path: remote_path.to_string(), + }) +} + +fn hash_file(path: &std::path::Path) -> Result { + let mut file = + std::fs::File::open(path).map_err(|e| format!("Cannot read {}: {e}", path.display()))?; + let mut hasher = Sha256::new(); + let mut buf = [0u8; 256 * 1024]; + loop { + let read = file + .read(&mut buf) + .map_err(|e| format!("Cannot read {}: {e}", path.display()))?; + if read == 0 { + break; + } + hasher.update(&buf[..read]); + } + Ok(format!("{:x}", hasher.finalize())) +} + +/// How changes can be noticed on the machine that holds the save. +/// +/// Events beat an interval whenever the filesystem can produce them, which is +/// not everywhere: `inotify` on a mounted CIFS/NFS share never fires, because +/// the kernel only reports writes made through this machine's own VFS -- the +/// game machine's writes land on the server's disk and nothing tells the client. +/// Windows is the exception that makes the mounted transport useful: its +/// `FileSystemWatcher` does receive SMB notifications, so a UNC path or a mapped +/// drive reports changes even though Linux could not. +pub fn watch_mode(target: &SshTarget, platform: &str, local: bool, dir: &str) -> &'static str { + if local { + if cfg!(windows) { + return "watcher"; + } + return if on_path("inotifywait") && is_local_filesystem(dir) { + "inotify" + } else { + "" + }; + } + if platform == "Windows" { + // FileSystemWatcher is part of .NET: nothing to install, nothing to probe. + return "watcher"; + } + if platform == "Linux/macOS" && !dir.trim().is_empty() { + let probe = run( + "ssh", + &target.ssh_args("command -v inotifywait"), + None, + PROBE_TIMEOUT, + ); + if probe.map(|c| c.ok).unwrap_or(false) { + return "inotify"; + } + } + "" +} + +/// Is this path on a filesystem whose writes we can actually see? `findmnt` +/// answers in one line; when it is missing, assume no -- a silent inotify watch +/// on a network mount is worse than an honest interval. +fn is_local_filesystem(path: &str) -> bool { + if path.trim().is_empty() || !std::path::Path::new(path).exists() { + return false; + } + let Ok(cmd) = run( + "findmnt", + &[ + "-no".to_string(), + "FSTYPE".to_string(), + "-T".to_string(), + path.to_string(), + ], + None, + Duration::from_secs(10), + ) else { + return false; + }; + if !cmd.ok { + return false; + } + let fstype = first_line(&cmd.stdout).to_ascii_lowercase(); + !(fstype.starts_with("nfs") + || fstype.starts_with("cifs") + || fstype.starts_with("smb") + || fstype.contains("fuse") + || fstype.starts_with("sshfs")) +} + +/// A running change watcher: the child to kill, and which mechanism it is. +pub struct WatchHandle { + pub child: std::process::Child, + pub mode: String, +} + +/// Start watching `dir` and call `on_change` with each name that lands in it. +/// +/// Two mechanisms, chosen by `mode`: +/// - "inotify": `inotifywait -m` on the machine that holds the folder, run +/// locally or over ssh, printing one name per event; +/// - "watcher": PowerShell's `FileSystemWatcher` for a Windows folder, sent as +/// a base64 `-EncodedCommand` -- the only quoting that survives cmd.exe, the +/// default shell of Windows OpenSSH. +/// The process is long-lived: the caller kills it when the watch is turned off. +pub fn spawn_watch( + target: &SshTarget, + dir: &str, + local: bool, + mode: &str, + on_change: F, +) -> Result +where + F: Fn(String) + Send + 'static, +{ + let remote = !local; + let (bin, args) = match mode { + "inotify" => { + if remote { + // The folder is embedded in a command the REMOTE SHELL parses, + // so a single quote in it would end the quoting; refuse rather + // than escape, because no real save folder has one. + if dir.contains('\'') { + return Err("That folder has a quote in it, so it cannot be watched.".to_string()); + } + target.check()?; + let script = format!( + "inotifywait -m -q -e close_write -e moved_to -e create --format %f -- '{dir}'" + ); + ("ssh".to_string(), target.ssh_args(&script)) + } else { + let mut args: Vec = ["-m", "-q", "-e", "close_write", "-e", "moved_to", "-e", "create", + "--format", "%f", "--"] + .iter() + .map(|s| s.to_string()) + .collect(); + args.push(dir.to_string()); + ("inotifywait".to_string(), args) + } + } + "watcher" => { + let command = powershell_watch_command(dir)?; + if remote { + target.check()?; + let mut args = target.opts(); + if target.port != 0 && target.port != 22 { + args.push("-p".to_string()); + args.push(target.port.to_string()); + } + args.push(target.destination()); + args.extend(command); + ("ssh".to_string(), args) + } else { + ("powershell".to_string(), command) + } + } + other => return Err(format!("No event watcher for mode {other:?}.")), + }; + + let mut child = Command::new(&bin) + .args(&args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("Could not start {bin}: {e}"))?; + let mut out = child.stdout.take().expect("stdout piped"); + let mut err = child.stderr.take().expect("stderr piped"); + + // A watcher that dies at once is the interesting failure -- no inotifywait, + // no such folder, a refused key -- so say why instead of reporting success + // and then never firing. `inotifywait -m` otherwise runs until killed. + let deadline = Instant::now() + Duration::from_millis(700); + loop { + match child.try_wait() { + Ok(Some(_)) => { + let mut text = String::new(); + let _ = err.read_to_string(&mut text); + let mut out_text = String::new(); + let _ = out.read_to_string(&mut out_text); + let detail = if text.trim().is_empty() { + first_line(&out_text) + } else { + first_line(&text) + }; + return Err(if detail.is_empty() { + format!("The watcher stopped immediately ({bin}).") + } else { + detail + }); + } + Ok(None) => { + if Instant::now() >= deadline { + break; + } + std::thread::sleep(Duration::from_millis(50)); + } + Err(e) => return Err(format!("Could not watch {dir}: {e}")), + } + } + + std::thread::spawn(move || { + use std::io::BufRead; + let reader = std::io::BufReader::new(out); + for line in reader.lines().map_while(Result::ok) { + let name = line.trim().to_string(); + if !name.is_empty() { + on_change(name); + } + } + }); + // Drain stderr too, or a chatty watcher fills the pipe and blocks. + std::thread::spawn(move || { + let mut sink = Vec::new(); + let _ = err.read_to_end(&mut sink); + }); + + Ok(WatchHandle { + child, + mode: mode.to_string(), + }) +} + +/// `powershell ... -EncodedCommand `: the script is +/// UTF-16LE base64, so no quoting in it can be reinterpreted by cmd.exe. +pub fn powershell_watch_command(dir: &str) -> Result, String> { + let folder = windows_path(dir)?; + let script = format!( + "$w = New-Object System.IO.FileSystemWatcher '{folder}', '*.sav'; \ +$w.IncludeSubdirectories = $false; $w.EnableRaisingEvents = $true; \ +while ($true) {{ $e = $w.WaitForChanged('All', 60000); \ +if (-not $e.TimedOut) {{ [Console]::Out.WriteLine($e.Name); [Console]::Out.Flush() }} }}" + ); + Ok(vec![ + "-NoProfile".to_string(), + "-NonInteractive".to_string(), + "-EncodedCommand".to_string(), + base64_utf16le(&script), + ]) +} + +/// SFTP reports a Windows folder as `/C:/Users/x`; PowerShell needs `C:\Users\x`. +/// A quote would end the single-quoted PowerShell literal, so it is refused. +fn windows_path(dir: &str) -> Result { + if dir.contains('\'') || dir.chars().any(|c| c.is_control()) { + return Err("That folder cannot be watched: it has a quote or a control character.".to_string()); + } + let trimmed = dir.trim().trim_start_matches('/'); + let bytes = trimmed.as_bytes(); + let drive = bytes.len() >= 2 && bytes[1] == b':' && (bytes[0] as char).is_ascii_alphabetic(); + if !drive { + return Err(format!("{dir} is not a Windows drive path.")); + } + Ok(trimmed.replace('/', "\\")) +} + +/// UTF-16LE (what PowerShell's -EncodedCommand wants), base64'd. Written out +/// rather than pulling in a crate for the one encoding. +pub fn base64_utf16le(text: &str) -> String { + let mut bytes = Vec::with_capacity(text.len() * 2); + for unit in text.encode_utf16() { + bytes.push((unit & 0xff) as u8); + bytes.push((unit >> 8) as u8); + } + const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4); + for chunk in bytes.chunks(3) { + let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)]; + let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32; + out.push(ALPHABET[(n >> 18) as usize & 63] as char); + out.push(ALPHABET[(n >> 12) as usize & 63] as char); + out.push(if chunk.len() > 1 { ALPHABET[(n >> 6) as usize & 63] as char } else { '=' }); + out.push(if chunk.len() > 2 { ALPHABET[n as usize & 63] as char } else { '=' }); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + // Verbatim `sftp -b -` output from a real OpenSSH 10.2 sftp-server, minus + // the sizes: the batch-mode echo of the command, the `?` link count + // OpenSSH never sends, the total path in the name, and a dated file. + const POSIX_LISTING: &str = "\ +sftp> ls -l \"/saves/SaveGames\" +-rw-r--r-- ? dion dion 27000000 Oct 10 10:03 /saves/SaveGames/1.2_autosave_0.sav +-rw-r--r-- ? dion dion 27513344 Oct 10 10:03 /saves/SaveGames/1.2_autosave_1.sav +-rw-r--r-- ? dion dion 3 Oct 10 10:03 /saves/SaveGames/notes.txt +-rw-r--r-- ? dion dion 1024 Jan 3 2025 /saves/SaveGames/old autosave.sav +drwxr-xr-x ? dion dion 4096 Oct 9 08:02 /saves/SaveGames/Backup +"; + + // What an OpenSSH-for-Windows server looks like: no owner or group, and + // the drive spelled `/C:/`, which is the only way to tell it from a POSIX + // box without a second round trip (`uname` does not exist in cmd.exe). + const WINDOWS_LISTING: &str = "\ +sftp> ls -l \"/C:/saves\" +-rw-r--r-- ? ? 0 Oct 10 13:41 /C:/saves/1.2_autosave_1.sav +-rw-r--r-- ? ? 27513344 Oct 10 13:41 /C:/saves/notes.txt +drwxr-xr-x ? ? 0 Oct 10 13:41 /C:/saves/76561198036921297 +"; + + fn at(month: u32) -> SystemTime { + // 2026-10-10T12:00:00Z, built without chrono. + UNIX_EPOCH + Duration::from_secs(month_secs(2026, month as usize, 10, 12, 0) as u64) + } + + #[test] + fn listing_parses_real_sftp_output() { + let entries = parse_listing(POSIX_LISTING, "/saves/SaveGames"); + assert_eq!(entries.len(), 5, "the echoed command line is not an entry"); + let save = &entries[1]; + assert_eq!(save.name, "1.2_autosave_1.sav", "the path prefix is stripped"); + assert_eq!(save.path, "/saves/SaveGames/1.2_autosave_1.sav"); + assert_eq!(save.size, 27_513_344); + assert!(!save.is_dir); + assert!(entries[4].is_dir); + assert_eq!(entries[3].name, "old autosave.sav", "names keep their spaces"); + assert!(entries[3].mtime < save.mtime, "an explicit year sorts earlier"); + assert_eq!(newest_save(&entries).map(|e| e.name.as_str()), Some("1.2_autosave_1.sav")); + } + + #[test] + fn listing_parses_windows_output_too() { + let entries = parse_listing(WINDOWS_LISTING, "/C:/saves"); + assert_eq!(entries.len(), 3); + assert_eq!(entries[1].name, "notes.txt"); + assert!(entries[2].is_dir); + } + + #[test] + fn newest_save_ignores_everything_but_sav_files() { + let mut entries = parse_listing(WINDOWS_LISTING, "/C:/saves"); + // The newest thing in the folder is not a save; the newest .sav wins. + entries.push(DirEntry { + name: "zzz.txt".to_string(), + path: "/C:/saves/zzz.txt".to_string(), + is_dir: false, + size: 1, + mtime: 4_000_000_000, + }); + assert_eq!(newest_save(&entries).map(|e| e.name.as_str()), Some("1.2_autosave_1.sav")); + assert_eq!(newest_save(&[]).map(|e| e.name.as_str()), None); + assert!(is_save("1.2_autosave_1.SAV"), "the game's own casing can vary"); + } + + #[test] + fn listing_rejects_junk() { + for line in [ + "", + "sftp> ls -l", + "total 12", + "not a listing at all", + "drwxr-xr-x", + "sftp> ls -l \"/saves\"", + ] { + assert!(parse_ls_line(line, "/x", at(10)).is_none(), "accepted {line:?}"); + } + } + + #[test] + fn when_parses_both_date_forms_and_infers_the_year() { + let now = at(10); + let recent = parse_when("Oct", "10", "13:41", now); + let explicit = parse_when("Jan", "3", "2025", now); + assert!(recent > explicit); + assert_eq!(recent, month_secs(2026, 9, 10, 13, 41)); + assert_eq!(explicit, month_secs(2025, 0, 3, 0, 0)); + // A December file read in January belongs to last year, not next. + let january = month_secs(2026, 0, 5, 12, 0) as u64; + let then = parse_when("Dec", "30", "09:00", UNIX_EPOCH + Duration::from_secs(january)); + assert_eq!(then, month_secs(2025, 11, 30, 9, 0)); + } + + #[test] + fn paths_are_joined_and_trimmed_the_way_sftp_reports_them() { + assert_eq!(parent_of("/C:/saves/765"), "/C:/saves"); + assert_eq!(parent_of("/C:/"), "/C:/"); + assert_eq!(parent_of("/home/dion"), "/home"); + assert_eq!(parent_of("/"), "/"); + assert_eq!(join_remote("/C:/saves/", "a.sav"), "/C:/saves/a.sav"); + } + + #[test] + fn pwd_and_platform_come_from_the_sftp_banner() { + assert_eq!( + parse_pwd("Remote working directory: /C:/Users/dion\n"), + Some("/C:/Users/dion".to_string()) + ); + assert_eq!(parse_pwd("no banner here"), None); + assert_eq!(platform_from_home("/C:/Users/dion"), "Windows"); + assert_eq!(platform_from_home("/home/dion"), "Linux/macOS"); + } + + #[test] + fn base64_matches_utf16le_powershell_encoding() { + // "AB" in UTF-16LE is 41 00 42 00 -> "QQBCAA==", the shape + // powershell -EncodedCommand expects (verified against PowerShell's own + // output for the same string). + assert_eq!(base64_utf16le("AB"), "QQBCAA=="); + assert_eq!(base64_utf16le("A"), "QQA="); + assert_eq!(base64_utf16le("ABC"), "QQBCAEMA"); + assert_eq!(base64_utf16le(""), ""); + } + + #[test] + fn powershell_watch_script_carries_the_folder_and_stays_unquotable() { + let args = powershell_watch_command("/C:/Users/dion/SaveGames").unwrap(); + assert_eq!(args[0], "-NoProfile"); + assert_eq!(args[2], "-EncodedCommand"); + assert!(args[3].chars().all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '=')); + // The path is in the script as a Windows path, not as an SFTP one. + let decoded = args[3].clone(); + assert!(!decoded.contains('\''), "nothing in the payload can be requoted"); + // A path that is not a drive path, or carries a quote, is refused rather + // than escaped into a command string. + assert!(powershell_watch_command("/home/dion/saves").is_err()); + assert!(powershell_watch_command("/C:/Users/o'brien/saves").is_err()); + } + + #[test] + fn a_folder_with_a_quote_is_refused_by_the_shell_watcher() { + let target = SshTarget { host: "192.168.1.50".to_string(), ..Default::default() }; + let called = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let flag = called.clone(); + let result = spawn_watch(&target, "/saves/o'brien", false, "inotify", move |_| { + flag.store(true, std::sync::atomic::Ordering::SeqCst); + }); + assert!(result.is_err(), "a quote must not reach the remote shell"); + assert!(!called.load(std::sync::atomic::Ordering::SeqCst)); + } + + #[test] + fn injection_through_the_batch_script_is_refused() { + assert!(check_remote_path("/C:/saves\nrm -rf /").is_err()); + assert!(check_remote_path("/C:/saves/\"x\".sav").is_err()); + assert!(check_remote_path("").is_err()); + assert!(check_remote_path("/C:/saves").is_ok()); + let mut target = SshTarget { + host: "-oProxyCommand=evil".to_string(), + ..Default::default() + }; + assert!(target.check().is_err()); + target.host = "192.168.1.50".to_string(); + assert!(target.check().is_ok()); + target.user = "dion nightly".to_string(); + assert!(target.check().is_err()); + } +} diff --git a/tools/serve_save_source_demo.py b/tools/serve_save_source_demo.py new file mode 100644 index 0000000..653bf65 --- /dev/null +++ b/tools/serve_save_source_demo.py @@ -0,0 +1,212 @@ +"""Run the app with a stubbed desktop bridge, so the "Save on another machine" +dialog (map/static/map/save_source.js) can be clicked through without a Tauri +build and without a game machine. + + py tools/serve_save_source_demo.py [port] [--dir FOLDER] + +Serves map/static/map/ and injects a script that installs window.__TAURI__ with +the three commands the dialog uses (source_validate, source_browse, +source_fetch). FOLDER -- the current directory by default -- is what the dialog +browses, so the listing, its sizes and timestamps, the "newest .sav" pick and +the watch's change check are all real; only the SSH transport is canned. + +Loading is where the demo stops: the parser lives in sav_core (desktop build) or +in dist/pkg (wasm build) and this server has neither, so the dialog's status +line says so instead of pretending. Validate against a host containing "fail" +to see the debug path, and call `__demoEmit("1.2_autosave_2.sav")` from the +console to see a change event reload the map (set `window.__demoFailWatch = true` +first to see the polling fallback). For the real thing see docs/remote-save-ssh.md. +""" + +import argparse +import http.server +import json +import os +import sys +import urllib.parse + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +STATIC = os.path.join(REPO, "map", "static", "map") + +# Injected before the app scripts: window.__TAURI__ has to exist by the time +# save_client.js reads it, or the app takes the browser transport instead. +STUB = r""" +""" + + +def snapshot(folder): + """The folder as the dialog sees it: real names, sizes and timestamps.""" + entries = [] + for item in sorted(os.scandir(folder), key=lambda e: e.name.lower()): + try: + stat = item.stat() + except OSError: + continue + entries.append({ + "name": item.name, + "path": item.path, + "isDir": item.is_dir(), + "size": 0 if item.is_dir() else stat.st_size, + "mtime": int(stat.st_mtime), + }) + entries.sort(key=lambda e: (not e["isDir"], not e["name"].lower().endswith(".sav"), e["name"].lower())) + return entries + + +class Handler(http.server.SimpleHTTPRequestHandler): + folder = os.getcwd() + extensions_map = {**http.server.SimpleHTTPRequestHandler.extensions_map, ".js": "text/javascript"} + + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=STATIC, **kwargs) + + def do_GET(self): + path = self.path.split("?")[0] + if path == "/__demo/listing": + folder = os.path.abspath(self.folder) + wanted = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query).get("path", [""])[0] + # Only inside the folder the demo was pointed at: a local dev server + # has no business listing the rest of the disk. + if wanted and not (os.path.isdir(wanted) and os.path.abspath(wanted).startswith(folder)): + # Outside the folder the demo was pointed at, or not there at all: + # say so, the way the real backend does. + body = json.dumps({"error": "Can't ls: \"" + wanted + "\" not found"}).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-cache") + self.end_headers() + self.wfile.write(body) + return + if wanted: + folder = os.path.abspath(wanted) + body = json.dumps({ + "path": folder, + "parent": os.path.dirname(folder), + "entries": snapshot(folder), + }).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-cache") + self.end_headers() + self.wfile.write(body) + return + # index.html carries the injected bridge; everything else is the app. + if path in ("/", "/index.html"): + with open(os.path.join(STATIC, "index.html"), encoding="utf-8") as f: + page = f.read() + stub = STUB % {"dir": json.dumps(os.path.abspath(self.folder))} + page = page.replace('', stub + '', 1) + body = page.encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-cache") + self.end_headers() + self.wfile.write(body) + return + super().do_GET() + + def end_headers(self): + # The watch loop polls by re-listing; a cached stub would freeze it. + self.send_header("Cache-Control", "no-cache") + super().end_headers() + + def log_message(self, *args): + pass + + +def main(): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("port", nargs="?", type=int, default=8793) + parser.add_argument("--dir", default=os.getcwd(), help="folder the dialog browses (default: cwd)") + args = parser.parse_args() + if not os.path.isdir(args.dir): + sys.exit("not a folder: " + args.dir) + Handler.folder = os.path.abspath(args.dir) + print(f"demo bridge on http://127.0.0.1:{args.port}/ (browsing {Handler.folder})") + print("Save File -> From another machine -> Validate connection") + with http.server.ThreadingHTTPServer(("127.0.0.1", args.port), Handler) as server: + try: + server.serve_forever() + except KeyboardInterrupt: + pass + + +if __name__ == "__main__": + main()