code refactor & sync bugfixes
This commit is contained in:
parent
2bcb47076a
commit
9edcc9841b
20 changed files with 820 additions and 812 deletions
56
README.md
56
README.md
|
|
@ -24,63 +24,47 @@ npm start
|
||||||
|
|
||||||
## Building & installing
|
## Building & installing
|
||||||
|
|
||||||
`./build.sh` produces `build/PLWL-<version>.tar.gz`: the Electron runtime renamed to `olcxja.plwl/` (binary `olcxja.plwl/olcxja.plwl`) with the whole app (code, renderer and bundled `files/`) packed into `olcxja.plwl/resources/app.asar`, plus the `olcxja.plwl.png` icon, `install.sh` and `createshortcut.sh`:
|
`./build.sh` produces `build/PLWL-<version>.tar.gz`: the Electron runtime renamed to `olcxja.plwl/` (binary `olcxja.plwl/olcxja.plwl`, whole app in `olcxja.plwl/resources/app.asar`), plus the icon, `install.sh` and `createshortcut.sh`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
tar -xzf PLWL-<version>.tar.gz
|
./install.sh # install to ~/.local/bin/olcxja.plwl/ (icon + shortcut)
|
||||||
cd PLWL
|
./createshortcut.sh # portable mode: icon + shortcut only
|
||||||
./install.sh # copies everything to ~/.local/bin/olcxja.plwl/,
|
./olcxja.plwl/olcxja.plwl # or run without installing
|
||||||
# installs the icon (olcxja.plwl.png) + shortcut
|
|
||||||
# removes the extraction folder, launches
|
|
||||||
|
|
||||||
./createshortcut.sh # portable mode: only installs the icon + shortcut
|
|
||||||
|
|
||||||
# or run without any shortcuts:
|
|
||||||
./olcxja.plwl/olcxja.plwl
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Importing from a wine prefix
|
## Importing from a wine prefix
|
||||||
|
|
||||||
Besides copy/move/leave, an app can be imported **from a wine prefix**: pick the prefix, then the app folder inside its `drive_c`. Nothing is copied — paths are stored relative to the prefix, so wine sees the app as if it were normally installed on `C:\` (registry entries pointing to `C:\...` keep working). Both layouts work: plain wine prefixes (`drive_c` at the top) and Proton-managed ones (`drive_c` under `pfx/`). The app stays tied to that prefix; removing the launcher entry never deletes files from the prefix. A prefix can be renamed later (Wine prefixes): the folder moves on disk and everything running in it follows the new name, because those paths are stored relative to the prefix. Edits wait for running apps: while anything from a prefix is playing, renaming or deleting it (and switching the default prefix away from an app that has none of its own) is refused with the name of the app to close first — wine keeps its registry and user files open in that folder. Deleting is also refused while a prefix still has apps registered in it, so an app is never silently orphaned into the default prefix. Imports keep the folder as it was: symlinks are recreated (rewritten to relative whenever the target is part of the launcher) and file permissions are carried over, so an imported game or build runs straight away.
|
Besides copy/move/leave, an app can be imported **from a prefix**: nothing is copied — paths are stored relative to the prefix, so wine sees the app as installed on `C:\` (registry entries keep working). Plain prefixes (`drive_c` on top) and Proton ones (`pfx/`) both work. The app stays tied to that prefix; removing the entry never deletes prefix files. A prefix can be renamed later — the folder moves and everything stored relative follows. Edits wait for running apps: renaming/deleting a prefix in use (or moving the default away from an app without its own) is refused with the app to close first; deleting a prefix that still has apps is refused too. Imports recreate symlinks (relative when the target belongs to the launcher) and keep permissions, so an imported game runs straight away.
|
||||||
|
|
||||||
## "Open with PLWL" in the file manager
|
## "Open with PLWL"
|
||||||
|
|
||||||
The desktop entry (`MimeType` + `Exec %f`) makes PLWL show up in Dolphin/Nautilus under **Open With** for Windows executables (`.exe`, `.msi`, `.bat`, `.cmd`, `.lnk`). Opening a file that way shows a small popup in the launcher where you pick the **Wine/Proton version** and the **prefix** for this run — both default to the launcher's defaults, nothing is imported into the library. A second launcher instance hands the path to the running one and exits (same single-instance mechanism the shortcuts use).
|
The desktop entry registers `.exe/.msi/.bat/.cmd/.lnk` in file managers. Opening one shows a popup to pick the **Wine/Proton build** and **prefix** for that run (defaults preselected, nothing imported). A second instance hands the path to the running one. App shortcuts work as **Open With** targets too: the app receives the file as the Windows path it would get on Windows — `C:\...` inside its prefix, `Z:\...` otherwise. A file handed to an already running app goes to that instance instead of a second tracked run.
|
||||||
|
|
||||||
The app shortcuts created in an app's configuration work as **Open With** targets too: pick one in the file manager (right-click a photo → Open With → your Photoshop shortcut) and that app starts with the file, receiving it as the Windows path it would get on Windows — `C:\...` when the file sits inside its prefix, `Z:\...` for the rest of the machine. Giving a file to an app that is already running hands it to that instance instead of starting a second tracked run.
|
|
||||||
|
|
||||||
## LAN syncing
|
## LAN syncing
|
||||||
|
|
||||||
Two PLWL installs on the same LAN can mirror each other's content - no cloud, no accounts. Open **LAN syncing** in the sidebar (under Settings):
|
Two PLWL installs on one LAN can mirror each other (sidebar → **LAN syncing**). No cloud, no accounts.
|
||||||
|
|
||||||
- **Device ID** - 24 characters, generated once per install (current timestamp + random characters) and re-rollable at any time.
|
- **Device ID** (24 chars) and **Sync code** (8 chars, letters+digits, case-insensitive), both re-rollable. A caller must prove it knows its own code; each verification costs 2 s and one may run at a time, so guessing is pointless. A correct code still opens an **Accept/Decline popup** on the receiver (60 s timeout); declining or ignoring never touches this machine and keeps the pairing.
|
||||||
- **Sync code** - 8 characters, letters and digits, case-insensitive. A device only accepts a connection when the caller proves it knows *its own* code; each verification costs two seconds and a device may only have one in flight, so guessing over the LAN is pointless. Both the ID and the code can be re-rolled. A correct code is still not enough: the receiving device shows an **Accept / Decline popup** before anything moves, and without a click within a minute the sender is turned down. Declining (or ignoring) the popup never touches this machine and does not unpair the two devices.
|
- **Devices on your network** are found over UDP and visible only while PLWL runs there (a few seconds after it closes they are gone).
|
||||||
- **Devices on your network** - other PLWL installs, found over UDP; a device shows up only while PLWL is running there, and disappears a few seconds after it is closed.
|
- Pick what to push: **portable apps**, **prefix-imported apps**, **Wine/Proton builds**, **prefixes**, **Sync settings**. Only what is ticked travels — without **Sync settings** the other machine's defaults and app configs are untouched. An accepted code is stored with the device ID, so later syncs need no typing; a re-rolled code is forgotten and asked for again.
|
||||||
|
|
||||||
Pick a device and choose what to push: **portable apps**, **apps imported from a prefix**, **Wine/Proton builds**, **prefixes** and **Sync settings**. Only what is ticked travels - a sync without **Sync settings** never touches the other machine's defaults, names or app configurations. The code has to be typed once - an accepted code is stored next to that device ID, so every later sync needs no typing. If the other side re-rolled its code, the stored one is forgotten and the launcher asks for the new one instead of failing silently.
|
**Both machines show a progress popup** (phase, unit, files, per-category counters like `Apps: 2/5`, transferred vs. planned bytes; the bar follows the current phase). The popups cannot be dismissed, and a sender hashing a big unit shows *Waiting for the other device…* instead of a dead popup — the sender pings while it works. They close on completion, or after 15 minutes of silence (and reopen if the sync turns out to be alive).
|
||||||
|
|
||||||
**Both machines show what is happening.** The sender gets a progress popup and the receiving device gets the same popup titled *Receiving from X…*, driven by the transfer as it arrives. Both show the current phase and unit, the files being moved, a sent/total counter per category (`Apps: 2/5 · Prefixes: 1/2`) and the transferred vs. planned size. The bar follows **bytes, not file counts**, and it belongs to the phase being worked on: hashing fills its own bar and the transfer starts again from empty, so nothing ever looks finished while it has just begun (the byte line next to it keeps the overall transferred vs. planned total). Both popups warn that the launcher must stay open, and a running sync is not cancellable from the outside: clicking the backdrop or pressing Escape does nothing. If the other device goes quiet for a few seconds (it hashes before it sends) the popup says *Waiting for the other device…* rather than closing, and the sending side keeps signalling while it works, so even a long hashing phase never looks like a dead peer. The popup closes itself when the transfer ends, or after fifteen minutes of complete silence when the other side has really vanished — and comes back if the sync turns out to be alive after all.
|
The **sender is the source of truth**: differing files are overwritten, missing ones written, extras inside the synced trees (`files/apps/`, `files/wines/`, `files/wineprefixes/`, `files/data/icons/`) deleted. Files are compared with BLAKE2b-512 (SHA-512/SHA-256 fallback); **Force sync all files** skips the comparison. Hashes are cached per file (size + mtime), so an unchanged library costs nothing to re-sync. Permissions travel with the files, and symlinks are sent as **relative** paths whenever the target belongs to the launcher (a Proton prefix's links into its build keep working on the other machine); `dosdevices` and targets outside `files/` stay absolute, and links wine itself rewrote to absolute are relativized again on the wire, on import and before every run.
|
||||||
|
|
||||||
The **sending device is the source of truth**: files that differ are overwritten, missing ones are written, and anything living only on the receiving side inside the synced trees (`files/apps/`, `files/wines/`, `files/wineprefixes/`, `files/data/icons/`) is deleted. Files are compared with a fast, collision-resistant hash (BLAKE2b-512, SHA-512/SHA-256 fallback); **Force sync all files** skips the comparison and overwrites everything instead. Hashes are remembered per file (size + modification time), so a repeat sync of a library that did not change reads nothing at all.
|
A sync also carries **how apps are set up**: wine build and prefix, args, env, DLL overrides, playtime and last played. **Sync settings** carries the registry-level part even when no files move: default wine/prefix, custom build names, prefix registry, every app's configuration and the library order. It only patches what already exists (unknown ids are ignored, unknown apps/prefixes stay), deletes nothing, and playtime only grows — the higher counter wins. Language, window settings, logs and external (leave) apps stay local.
|
||||||
|
|
||||||
**Wine builds and prefixes survive the trip.** Permissions travel with a sync, so the `proton`, `wine` and `wineserver` binaries are still executable on the other machine (a build whose executable bit was lost is additionally repaired at startup, without re-importing it), and symlinks are sent as RELATIVE paths whenever the file they point at belongs to the launcher: a Proton prefix is full of links into its build, and the receiving side gets links that resolve to its own copy instead of the sender's `/home/...` folders. Links that leave the launcher on purpose (`dosdevices` drive mappings, a mounted drive, the user's home folder) stay absolute, and links into a folder that does not exist yet on the other machine are still written relative, so they start working the moment that folder appears. Wine and Proton rewrite a prefix's links with local absolute targets while they update it; PLWL relativizes them again on the wire, on import and before every run.
|
|
||||||
|
|
||||||
A sync also carries **how the apps are set up**: the Wine/Proton build and prefix each app runs in, its arguments, environment variables and DLL overrides, plus playtime and last played. The **Sync settings** category carries the registry-level part of that even when no files move: the launcher's **default Wine build and default prefix**, the custom **names** you gave Wine/Proton builds (and their disabled flags), the **prefix registry**, every **app's configuration**, and the **order of the library**. It only patches what already exists on the other machine - an unknown Wine id or prefix is ignored instead of leaving a dangling default or a broken app behind, apps and prefixes the sender does not know stay where they are, and a settings-only sync deletes nothing (that is what the file categories are for). Playtime only ever grows - the higher counter wins - so pushing from a freshly installed machine cannot wipe a library's play history. Everything else stays local: language and window settings, logs, translations, and external (leave) apps - prefixes are always kept usable, so app entries pointing inside a prefix keep their prefix-relative paths and the receiving side never loses its own `Main` prefix.
|
|
||||||
|
|
||||||
## Wine & Proton support
|
## Wine & Proton support
|
||||||
|
|
||||||
- **System Wine** — uses the `wine` binary from PATH.
|
- **System Wine** — `wine` from PATH.
|
||||||
- **Imported builds** — drop any wine or Proton build into `files/wines/`. The folder may contain the build directly, or one level of nesting (zip-extracted layouts are detected automatically). After the copy, PLWL rewires the build's absolute symlinks to relative ones and restores the executable bit on the binaries it needs to run (`proton`, `wine`, `wineserver`, `winetricks`), so a build that arrived from a zip or another machine works without further fiddling. A build can also be given a **displayed name** of its own (Wine versions): it is used everywhere the build is offered — library config, settings and the *Open with PLWL* popup — while the folder in `files/wines/` keeps its name and the symlinks inside prefixes stay valid.
|
- **Imported builds** — drop any wine/Proton build into `files/wines/` (one nesting level auto-detected). Symlinks are rewired to relative and executable bits restored, so builds from a zip or another machine run as they are. A build can get a **display name** of its own; the folder keeps its name.
|
||||||
- **Proton without Steam** — fully supported. Proton is invoked like Steam does it: `STEAM_COMPAT_DATA_PATH` is a per-build compat folder linked to the launcher's own prefix, and `STEAM_COMPAT_CLIENT_INSTALL_PATH` points at an installed Steam client when there is one (Proton reads its legacy DLLs and `steamclient.so` from there, read-only) and at the empty `files/data/fake-steam` otherwise. GE builds work out of the box: PLWL launches through Proton's built-in UMU path (`UMU_ID`), which needs no Steam client at all. An app that breaks under umu.exe has a per-app **Disable UMU** switch in its config — PLWL then runs the build's own wine directly (Proton's `runinprefix` verb): no umu.exe/steam.exe launcher and no Proton prefix setup take part, so the run stays as close to plain wine as possible. (Proton's session init still reads `STEAM_COMPAT_CLIENT_INSTALL_PATH` on every verb; a disableUmu run gets the empty `fake-steam` folder and nothing is copied out of it.)
|
- **Proton without Steam** — invoked like Steam does it: `STEAM_COMPAT_DATA_PATH` linked to the launcher's prefix, `STEAM_COMPAT_CLIENT_INSTALL_PATH` pointing at a real Steam client when there is one (read-only) and at the empty `files/data/fake-steam` otherwise. GE builds run through Proton's UMU path (`UMU_ID`) with no Steam client at all. A per-app **Disable UMU** switch runs the build's own wine directly (`runinprefix`), as close to plain wine as it gets.
|
||||||
- **One C: drive per prefix** — Proton normally keeps its prefix one level deeper (`<compat>/pfx/drive_c`) than wine (`<prefix>/drive_c`), which left a prefix used with both kinds with two separate C: drives (two registries, two sets of saves). Whenever a prefix has no `pfx` yet (a fresh prefix, or a plain-wine prefix first used through Proton), PLWL makes `pfx` a relative symlink back to the prefix itself, so Proton and wine share one `drive_c`, one registry and one `dosdevices`. Existing layouts are left exactly as they are — prefixes from older versions keep their real `pfx/` folder, and a prefix with both `drive_c` trees keeps both, because two live Windows installs cannot be merged safely (both stay visible to the prefix import picker).
|
- **One C: drive per prefix** — when a prefix has no `pfx` yet, PLWL makes `pfx` a relative symlink back to the prefix itself, so Proton and wine share one `drive_c`, registry and `dosdevices`. Existing layouts (a real `pfx/` folder, a prefix with both trees) are left exactly as they are.
|
||||||
|
|
||||||
## Translations
|
## Translations
|
||||||
|
|
||||||
All UI text comes from JSON files in `files/translations/`. The `data` object carries the language names; `texts` maps keys to strings, with `{0}`, `{1}` ... placeholders filled at runtime.
|
All UI text comes from JSON files in `files/translations/`: `data` holds the language names, `texts` maps keys to strings with `{0}`, `{1}` ... placeholders. The bundled files are refreshed into that folder on every start when their content changed (so updates deliver new keys); user-added languages are never touched.
|
||||||
|
|
||||||
The translations bundled with a release are launcher-owned: on every start they are refreshed into `files/translations/` whenever their content changed, so launcher updates deliver new/changed keys to existing installs. Extra language files the bundle does not ship (user-added) are never touched.
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
Codebase overview, conventions and rules for contributors: [development.md](development.md).
|
Codebase overview and contributor rules: [development.md](development.md).
|
||||||
|
|
||||||
|
|
|
||||||
214
development.md
214
development.md
|
|
@ -30,9 +30,14 @@ src/ main process (Node/Electron backend)
|
||||||
paths.js portable layout, FILES-relative path helpers, translation sync
|
paths.js portable layout, FILES-relative path helpers, translation sync
|
||||||
i18n.js translation loading + {0} placeholder filling
|
i18n.js translation loading + {0} placeholder filling
|
||||||
preload.js contextBridge API exposed to the renderer
|
preload.js contextBridge API exposed to the renderer
|
||||||
lansync.js LAN sync: identity + pairing, hashing, HTTP receiver (with its
|
lansync.js LAN sync receiver: identity + pairing, hashing, HTTP server
|
||||||
receive-progress hook), UDP discovery
|
(with its receive-progress hook); uses hashcache.js,
|
||||||
|
lansync-identity.js, lansync-manifest.js, lansync-discovery.js
|
||||||
lansyncpush.js the pushing side (createPusher factory, keeps helper state out)
|
lansyncpush.js the pushing side (createPusher factory, keeps helper state out)
|
||||||
|
hashcache.js file hash cache (path -> size/mtime/hash) for LAN sync
|
||||||
|
lansync-identity.js identity + sync code (createIdentity factory)
|
||||||
|
lansync-manifest.js sender-side manifest building (createManifestBuilder)
|
||||||
|
lansync-discovery.js UDP beacon discovery (createDiscovery factory)
|
||||||
appicon.js window/taskbar icon
|
appicon.js window/taskbar icon
|
||||||
renderer/ UI (plain HTML/CSS/JS, no framework)
|
renderer/ UI (plain HTML/CSS/JS, no framework)
|
||||||
app.js views, modals, custom-select, all renderer logic
|
app.js views, modals, custom-select, all renderer logic
|
||||||
|
|
@ -45,182 +50,59 @@ test/ plain node scripts, run them with `node test/<name>.test.js`;
|
||||||
tools/ install.sh / createshortcut.sh / build helpers
|
tools/ install.sh / createshortcut.sh / build helpers
|
||||||
```
|
```
|
||||||
|
|
||||||
Main process vs renderer: the renderer never touches Node/Electron directly -
|
Renderer never touches Node/Electron directly — everything goes through the `window.plwl` bridge (preload.js) and ipcMain handlers in src/ipc/. Keep new APIs in that pattern.
|
||||||
everything goes through the `window.plwl` bridge (preload.js) and ipcMain
|
|
||||||
handlers in src/ipc/. Keep new APIs in that pattern.
|
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
- Keep it simple. Plain functions over classes, no abstractions "for later".
|
- Keep it simple. Plain functions over classes, no abstractions "for later". Comments explain the WHY (quirks, workarounds, races), not the WHAT — none at all when nothing needs explaining.
|
||||||
- Comments explain the WHY (quirks, workarounds, races), not the WHAT. If the
|
- No duplicate logic: the second time you copy something, move it into a helper and call it from both places.
|
||||||
code says it, don't repeat it in words. No comments at all when nothing
|
- Everything user-visible goes through the translation files (en-us.json, pl.json) with `{0}`, `{1}` placeholders. No hardcoded strings in app.js.
|
||||||
needs explaining.
|
- Paths stored on disk are FILES-relative (portable layout): write with toPortablePath(), read with resolvePortablePath() — never store absolute paths for anything inside files/.
|
||||||
- No duplicate logic: the second time you copy something, move it into a
|
- Registries (apps.json, wines.json, prefixes.json) are rewritten whole via writeJson(); playtime lives in its own tiny file so the registry is not rewritten while a game runs.
|
||||||
helper (a shared module in src/ or a function next to its users) and call
|
- Deleting user data needs a confirm modal; best-effort cleanup is try/catch-and-ignore with a comment saying why it is safe to ignore.
|
||||||
it from both places.
|
- Windows apps run through runGame()/buildEnv() — never spawn wine directly elsewhere (prefix handling, Proton env and playtime live there).
|
||||||
- Everything user-visible goes through the translation files (en-us.json,
|
- A prefix or wine build a running app uses cannot be renamed/deleted (wine holds files open there): the guards sit next to the handlers in src/ipc/, use the games service's running list plus library.js helpers (prefixNameForGame/gamesUsingPrefix/is*InUse) and answer `{ ok:false, error:'inUse'|'hasApps', apps:[names] }` for the toast.
|
||||||
pl.json) with `{0}`, `{1}` placeholders. No hardcoded strings in app.js.
|
- Long operations stream progress through the single import:progress slot as `{ phase: 'count'|'copy'|'remove', files, bytes, totalFiles, totalBytes }` (runWithProgress for deletes/imports). Emit 'count' (a treeSize walk) first, copy/remove through the fsutil helpers.
|
||||||
- Paths stored on disk are FILES-relative (portable layout): write with
|
- CSS: reuse the existing variables/classes (.button, .field-label, .custom-select, form-card); spacing comes from --button-margin and flex gaps, not per-element magic numbers.
|
||||||
toPortablePath(), read with resolvePortablePath() - never store absolute
|
|
||||||
paths for anything inside files/.
|
### LAN sync
|
||||||
- Registries (apps.json, wines.json, prefixes.json) are rewritten whole via
|
|
||||||
writeJson(); playtime lives in its own tiny file so the registry is not
|
- Two sides: the receiver lives in lansync.js, the sender in lansyncpush.js, built by createPusher(helpers) — several launcher states share the module, so nothing may sit in its module scope. Both sides report their own progress (receiver through the onReceive hook).
|
||||||
rewritten while a game runs.
|
- An incoming sync needs CONSENT: a correct code opens the gate, but the receiver's onIncoming hook (registered in main.js) shows Accept/Decline, answered through lansync:respondIncoming. Unanswered requests expire (INCOMING_TIMEOUT_MS); a decline returns 'rejected' and KEEPS the pairing (the code was proven). Without an onIncoming hook (tests, headless) the gate stays open.
|
||||||
- Deleting user data needs a confirm modal; best-effort cleanup is
|
- Progress is bytes plus per-category counters (items = announced units, itemsDone = finished, totals = everything picked) — "Apps: 2/5". The bar follows the CURRENT PHASE's bytes (barBytes/barTotalBytes) so a phase starts from an empty bar. Hashing and copying walk tens of thousands of files: every per-file update goes through progressGate (~10/s, phase changes forced) — never one IPC message per file.
|
||||||
try/catch-and-ignore with a comment saying why it is safe to ignore.
|
- Registry-level settings are opt-in (the "settings" category, old name "defaults" still accepted as an alias on both sides). The payload carries defaults, wineSettings (displayName + disabled per build), prefixSettings, appSettings (name, wine, prefix, args, env, dllOverrides, disableUmu per app) and appOrder. The receiver only patches what already exists (unknown ids skipped, nothing created or deleted, unknown local entries kept at the end of the order) through the normal helpers (setWineDisplayName, library.updateGame, reorderGames).
|
||||||
- Windows apps run through runGame()/buildEnv() - never spawn wine directly
|
- File hashes are remembered in files/data/lansync-hashes.json (path -> size + mtime + hash; machine-local, never synced): equal size and mtime means equal bytes, so a repeat sync only stats the tree. Hashing and the receiver's comparison both run HASH_CONCURRENCY files at a time.
|
||||||
somewhere else (prefix handling, Proton env and playtime live there).
|
- File permissions travel with the manifest (`mode`) and are applied after the bytes; a file with the right hash but wrong mode is repaired without re-upload.
|
||||||
- A prefix or wine build that a running app uses cannot be renamed/deleted
|
- A sender hashing a big unit is minutes of silence: it pings `/plwl/ping` every HEARTBEAT_MS, the receiver re-arms its popup watchdog (stage 'wait' changes nothing on screen). A receiving popup its watchdog closed reopens on the next real event — only 'done' ends a sync, and reopening never asks for consent again (consent is the onIncoming popup at verify time; the progress popup is driven by the same token's events).
|
||||||
(wine holds its registry and user files open in that folder): the guards sit
|
- A sync must never walk through a symlinked ROOT: pruneTree/healUnitRoots (sync), removeTree/treeSize (fsutil) and healBuild (wines) lstat the root first. `listDirs()` counts symlinked folders as units, so a build on another disk still travels — as the link it is (`wineUnits` maps nested builds to their TOP folder under wines/).
|
||||||
next to the handlers in src/ipc/, use the games service's running list plus
|
- Wine/Proton rewrite prefix links to absolute targets whenever they update a prefix, so on-disk links are not always relative after a run: the sync relativizes them on the wire (portableLinkTarget) and refreshWines()/healPrefix() repair what arrived from an older PLWL. A build whose binaries lost the executable bit is detected in refreshWines() and healed once (spawn() on a non-executable `proton` fails with EACCES while the game looks started).
|
||||||
the helpers in library.js (prefixNameForGame/gamesUsingPrefix/is*InUse) and
|
|
||||||
answer { ok:false, error:'inUse'|'hasApps', apps:[names] } for the toast.
|
### Paths and prefixes
|
||||||
- Long operations stream progress through the single import:progress slot as
|
|
||||||
{ phase: 'count'|'copy'|'remove', files, bytes, totalFiles, totalBytes } and
|
- Symlinks inside everything PLWL owns are stored RELATIVE whenever the target can be expressed locally (symlinks.js owns this: rewireSymlinks + restoreExecutableBits; every writer calls it — imports, healPrefix, sync on the wire and after a unit lands). dosdevices drive mappings and targets leaving files/ stay absolute — machine-specific by design.
|
||||||
the renderer drives a bar from it (runWithProgress for deletes/imports).
|
- A prefix with NO `pfx` yet gets ONE C: drive: `pfx` is a relative symlink to the prefix itself (`prefixes.ensureUnifiedPrefix` only CREATES it), so Proton's `<compatdata>/pfx` shares drive_c, registry and dosdevices with plain wine. Existing layouts are left as they are — hoisting files one level up was tried and removed (it breaks relative links, the umu.exe link kills a Proton launch). Never assume a single drive_c: use `prefixDriveRoots()` and compare realpaths.
|
||||||
Emit the 'count' phase first (a treeSize walk) and copy/remove through the
|
- App launch env is built in runner.buildEnv only. WINEDLLOVERRIDES is an ordered semicolon list (inherited first, app's last; an empty `dll=` is "disabled" and must survive the filter). Proton appends its dxvk/nvapi defaults AFTER everything. `disableUmu` drops UMU_ID and runs Proton's `runinprefix` verb. STEAM_COMPAT_CLIENT_INSTALL_PATH is required in BOTH modes (Proton's session init reads it on every verb; missing it aborts the launch before wine starts): a real Steam client when found, else the empty files/data/fake-steam.
|
||||||
fsutil helpers; never ship a progress bar no data can move.
|
|
||||||
- CSS: reuse the existing variables/classes (.button, .field-label,
|
### Launch and shortcuts
|
||||||
.custom-select, form-card); spacing rhythm comes from --button-margin and
|
|
||||||
the flex gap of the container, not per-element magic numbers.
|
- Generated shortcuts are file handlers too: `Exec=<launcher> --launch <id> --file %f` (a literal % is `%%`). argv.js takes the file from `--file` or a bare trailing path, and runner.windowsPathFor passes it to the app as Windows would (`C:\...` inside the prefix, `Z:\...` otherwise). A file given to an already running app is a handoff (untracked process, no log/playtime writes).
|
||||||
- LAN sync has two sides: the receiver lives in lansync.js, the sender in
|
- The second-instance handoff must never read the token next to its option: Electron RE-SERIALIZES argv (`--launch <id> --file <path>` arrives as `--launch --file --allow-file-access-from-files <id> <path>`), so argv.js pairs options with positionals IN ORDER (inline `--name=value` wins, the dev app path is skipped). Reading the next token used to take the launch id as the opened file. `electron test/argv-handoff.test.js` proves the real serialization.
|
||||||
lansyncpush.js and is built by createPusher(helpers) - several launcher
|
- A modal showing a running sync opens locked: showModal(html, { locked: true }) ignores backdrop and Escape.
|
||||||
states share the module, so nothing may be kept in its module scope. Both
|
- Tests are plain assert-by-print scripts (see test/): set PLWL_BUILD_DIR to a temp dir, check(), exit 1 on failure. Add one when fixing a bug.
|
||||||
sides report their own progress (the receiver through the onReceive hook),
|
|
||||||
so each machine can show the transfer.
|
|
||||||
- An incoming sync needs CONSENT: a correct code opens the gate, but the
|
|
||||||
receiver's onIncoming hook (registered in main.js) shows an Accept/Decline
|
|
||||||
popup and only a click - answered through lansync:respondIncoming - starts
|
|
||||||
the session. Unanswered requests expire after INCOMING_TIMEOUT_MS; a decline
|
|
||||||
returns 'rejected' to the sender and KEEPS the pairing (the code was proven).
|
|
||||||
Without an onIncoming hook (tests, headless) the gate stays open.
|
|
||||||
- Sync progress is bytes plus per-category counters (items = announced units,
|
|
||||||
itemsDone = finished ones, totals = everything the sender picked), never raw
|
|
||||||
file counts: the popup says "Apps: 2/5". The bar follows the CURRENT PHASE's
|
|
||||||
bytes (barBytes/barTotalBytes, not the overall bytes/totalBytes) so a phase
|
|
||||||
starts from an empty bar instead of inheriting a full one - otherwise hashing
|
|
||||||
looks finished the moment it starts. Hashing and copying walk tens of
|
|
||||||
thousands of files, so every per-file update goes through progressGate
|
|
||||||
(~10/s, phase changes forced) - never send one IPC message per file.
|
|
||||||
- Registry-level settings are opt-in (the "settings" sync category, shown as
|
|
||||||
"Sync settings"; the old name "defaults" is still accepted as an alias on
|
|
||||||
BOTH sides - the receiver normalizes payload.categories, so a sender from an
|
|
||||||
older version keeps working). payload carries defaults (default wine and
|
|
||||||
prefix), wineSettings (displayName + disabled per build id), prefixSettings
|
|
||||||
(the registry names; a settings sync never renames or creates folders -
|
|
||||||
that is the prefixes category), appSettings (name, wine, prefix, args, env,
|
|
||||||
dllOverrides, disableUmu per app id) and appOrder. The receiver only patches
|
|
||||||
things that already exist there: an unknown wine id / prefix / app is
|
|
||||||
skipped, nothing is created or deleted, and the order merge keeps local
|
|
||||||
entries the sender does not know at the end. Values are written through the
|
|
||||||
normal helpers (setWineDisplayName clears a custom label when the sender has
|
|
||||||
none, library.updateGame pins the prefix of prefix imports, reorderGames
|
|
||||||
merges by id).
|
|
||||||
- LAN sync remembers file hashes in files/data/lansync-hashes.json, keyed by
|
|
||||||
path with size + mtime (machine-local, never synced): equal size and mtime
|
|
||||||
means equal bytes, so a repeat sync only stat's the tree. Hashing and the
|
|
||||||
receiving side's comparison both run HASH_CONCURRENCY files at a time.
|
|
||||||
- A modal showing a running operation (a sync) opens locked:
|
|
||||||
showModal(html, { locked: true }) ignores the backdrop and Escape, because
|
|
||||||
the user cannot cancel the operation by dismissing it.
|
|
||||||
- Symlinks inside everything PLWL owns (a wine/proton build, a prefix, an
|
|
||||||
imported app) are stored RELATIVE whenever the target can be expressed
|
|
||||||
locally: a Proton prefix is full of links into its build, and an absolute one
|
|
||||||
points at the machine that wrote it. symlinks.js owns this (rewireSymlinks,
|
|
||||||
plus restoreExecutableBits for the executables a content copy drops) and
|
|
||||||
every writer calls it: importWineFolder/healBuild, importer after copy/move,
|
|
||||||
healPrefix, and the sync both on the wire (portableLinkTarget sends the
|
|
||||||
relative target) and after a unit lands (healUnitRoots). dosdevices drive
|
|
||||||
mappings and targets that leave files/ (a mount, a system path, the user's
|
|
||||||
home) stay absolute - they are machine-specific by design.
|
|
||||||
- A prefix that has NO `pfx` yet gets ONE C: drive: `pfx` is a relative
|
|
||||||
symlink to the prefix itself, so Proton's `<compatdata>/pfx`
|
|
||||||
(runner.linkPrefix) resolves to `<prefix>` and shares `drive_c`, the
|
|
||||||
registry and `dosdevices` with plain wine. `prefixes.ensureUnifiedPrefix`
|
|
||||||
only ever CREATES that link; an existing layout (a real `pfx/` folder from
|
|
||||||
an older PLWL, a both-drives prefix) is left exactly as it is. Hoisting a
|
|
||||||
prefix's files one level up was tried and removed: it breaks every relative
|
|
||||||
link written against the old depth (the `umu.exe` link into the build is the
|
|
||||||
one that kills a Proton launch), and moving a live Windows install is not
|
|
||||||
the launcher's call. `healPrefix`/`rewireSymlinks` repairs such a prefix by
|
|
||||||
re-anchoring broken relative links through files/. Never assume a single
|
|
||||||
drive_c path: use `prefixDriveRoots()` (deduplicated) and compare realpaths
|
|
||||||
when deciding whether a folder is inside a prefix's C: drive.
|
|
||||||
- App launch env is built in runner.buildEnv only. WINEDLLOVERRIDES is an
|
|
||||||
ordered semicolon list: inherited value first, the app's overrides last,
|
|
||||||
because Wine lets a later entry replace an earlier one for the same module.
|
|
||||||
An empty setting (`dll=`) is Wine's "disabled" and must survive the filter.
|
|
||||||
Proton appends its own dxvk/nvapi/steam.exe defaults AFTER everything, so an
|
|
||||||
app override only wins where Proton has no default. `disableUmu` (per app)
|
|
||||||
drops UMU_ID and switches Proton to its `runinprefix` verb, so no
|
|
||||||
umu.exe/steam.exe launcher and no Proton prefix setup take part - the
|
|
||||||
build's own wine runs as close to plain wine as it gets.
|
|
||||||
STEAM_COMPAT_CLIENT_INSTALL_PATH must be set in BOTH modes: Proton's session
|
|
||||||
init reads it on every verb (sync_appcache runs even for runinprefix, and
|
|
||||||
with SteamGameId set a missing variable aborts the launch with a KeyError
|
|
||||||
before wine starts). On the default path it prefers a real Steam client
|
|
||||||
found on the machine; a disableUmu run always gets the empty
|
|
||||||
files/data/fake-steam, whose contents are never read.
|
|
||||||
- Wine and Proton create prefix links with ABSOLUTE local targets whenever they
|
|
||||||
update a prefix (font/dll stubs, Steam appcache), so on-disk links are not
|
|
||||||
always relative after a run: the sync payload relativizes them anyway, and
|
|
||||||
refreshWines()/healPrefix() repair what arrived from an older PLWL. A build
|
|
||||||
whose binaries lost their executable bit is detected by a cheap audit in
|
|
||||||
refreshWines() and healed once, because spawn() on a non-executable `proton`
|
|
||||||
fails with EACCES and the game would look like it started while nothing ran.
|
|
||||||
- File permissions travel with a sync (manifest entry `mode`): the receiving
|
|
||||||
side applies them after writing the bytes and repairs a file that has the
|
|
||||||
same hash but the wrong mode without re-uploading it.
|
|
||||||
- A sync must never walk through a symlinked ROOT. pruneTree/healUnitRoots
|
|
||||||
(sync), removeTree/treeSize (fsutil) and healBuild (wines) lstat their root
|
|
||||||
first and treat anything that is not a real directory as the link (or file)
|
|
||||||
it is: a build, prefix or app folder kept on another disk must not have its
|
|
||||||
target deleted, pruned or rewritten through the link. `listDirs()` in the
|
|
||||||
sync counts symlinked folders as units, so such a folder still travels - as
|
|
||||||
the link it is (`wineUnits` maps nested builds to their TOP folder under
|
|
||||||
wines/, otherwise a zip-extracted build becomes a phantom unit whose root
|
|
||||||
does not exist and the "Wine versions" totals grow).
|
|
||||||
- Generated app shortcuts are file handlers too: `Exec=<launcher>
|
|
||||||
--launch <id> --file %f` (the field code is appended after escaping - a
|
|
||||||
literal % is `%%`). argv.js takes the file from `--file` or from a bare
|
|
||||||
trailing path (KDE appends it to Exec lines without a file field), and
|
|
||||||
runner.windowsPathFor passes it to the app the way Windows would: `C:\...`
|
|
||||||
when it sits inside the prefix's drive_c, `Z:\...` otherwise. Giving a file
|
|
||||||
to an app that is ALREADY running is a handoff (runGame `handoff: true`):
|
|
||||||
an extra untracked process, no log/playtime writes, because the app's own
|
|
||||||
single-instance logic is what actually opens the file. The second-instance
|
|
||||||
handoff must never read a value from the token next to its option: Electron
|
|
||||||
RE-SERIALIZES that argv (all switches first, their values after, the dev app
|
|
||||||
path among them), so `--launch <id> --file <path>` arrives as
|
|
||||||
`--launch --file --allow-file-access-from-files <id> <path>`. argv.js pairs
|
|
||||||
options with positionals IN ORDER (inline `--name=value` wins, the dev app
|
|
||||||
path is skipped); reading the next token used to take the launch id as the
|
|
||||||
opened file, so an app started without its image.
|
|
||||||
`electron test/argv-handoff.test.js` proves the real serialization.
|
|
||||||
- A sender hashing a big unit is minutes of network silence: it pings
|
|
||||||
`/plwl/ping` (+token) every HEARTBEAT_MS for the whole sync, the receiver's
|
|
||||||
handler re-arms its popup watchdog (stage 'wait' changes nothing on screen),
|
|
||||||
and a receiving popup its watchdog closed reopens on the next real event -
|
|
||||||
only the 'done' stage ends a sync for good, and reopening NEVER asks for
|
|
||||||
consent again: the Accept/Decline gate is the onIncoming popup at verify
|
|
||||||
time (a fresh session), while the progress popup is driven by the same
|
|
||||||
token's events. A 'wait' event that reopens a closed popup fills the phase
|
|
||||||
line (`recvWaiting`) instead of leaving every line blank.
|
|
||||||
- Tests are plain assert-by-print scripts (see test/): set PLWL_BUILD_DIR to
|
|
||||||
a temp dir, check(), exit 1 on failure. Add one when fixing a bug.
|
|
||||||
|
|
||||||
## Verify before you commit
|
## Verify before you commit
|
||||||
|
|
||||||
```
|
```
|
||||||
node --check <changed js files> # syntax
|
node --check <changed js files> # syntax
|
||||||
node test/import.test.js # ...and the other test files
|
node test/smoke.js # quick sanity
|
||||||
node test/lansync.test.js # LAN sync contract (identity, pairing, mirror)
|
node test/lansync.test.js # LAN sync contract (identity, pairing, mirror)
|
||||||
node test/lansync-receiving.test.js # receive-side progress events (the popup)
|
node test/lansync-receiving.test.js # receive-side progress events (the popup)
|
||||||
node test/lansync-settings.test.js # playtime, app settings, wine/prefix + defaults
|
node test/lansync-settings.test.js # playtime, app settings, wine/prefix + defaults
|
||||||
node test/lansync-links.test.js # wire: exec bits + relative links in a sync
|
node test/lansync-links.test.js # wire: exec bits + relative links
|
||||||
node test/lansync-pending-links.test.js # links to files created later, startup heal
|
node test/lansync-pending-links.test.js # links to files created later, startup heal
|
||||||
node test/lansync-consent.test.js # incoming sync: accept / decline / timeout gate
|
node test/lansync-consent.test.js # accept / decline / timeout gate
|
||||||
node test/bench-sync.js # timing: phases of a cold and a cached sync
|
node test/lansync-token.test.js # long-sync token expiry (sliding TTL)
|
||||||
|
node test/bench-sync.js # timing: cold vs cached sync
|
||||||
npm start # click through the flow you touched
|
npm start # click through the flow you touched
|
||||||
|
|
||||||
# Repairing / testing a real instance (test/ scripts are gitignored helpers):
|
# Repairing / testing a real instance (test/ scripts are gitignored helpers):
|
||||||
|
|
|
||||||
|
|
@ -175,6 +175,11 @@
|
||||||
"settings.saved": "Settings saved",
|
"settings.saved": "Settings saved",
|
||||||
"settings.openFiles": "Open files folder",
|
"settings.openFiles": "Open files folder",
|
||||||
"settings.closeToTray": "Close window to tray (keep tracking playtime in background)",
|
"settings.closeToTray": "Close window to tray (keep tracking playtime in background)",
|
||||||
|
"settings.hashCache": "Sync file cache",
|
||||||
|
"settings.hashCacheHint": "LAN syncing remembers the hash of every file (with its size and modification time), so an unchanged library is not read again on the next sync. Clear it if a transfer compares wrong - everything gets hashed fresh on the next sync.",
|
||||||
|
"settings.clearHashCache": "Clear cache",
|
||||||
|
"settings.hashCacheClearConfirm": "Forget all remembered file hashes? The next sync will hash the whole library again.",
|
||||||
|
"settings.hashCacheCleared": "Hash cache cleared",
|
||||||
"lansync.title": "LAN syncing",
|
"lansync.title": "LAN syncing",
|
||||||
"lansync.subtitle": "Push your apps, prefixes and Wine builds to another PLWL on the same network.",
|
"lansync.subtitle": "Push your apps, prefixes and Wine builds to another PLWL on the same network.",
|
||||||
"lansync.thisDevice": "This device",
|
"lansync.thisDevice": "This device",
|
||||||
|
|
|
||||||
|
|
@ -175,6 +175,11 @@
|
||||||
"settings.saved": "Ustawienia zapisane",
|
"settings.saved": "Ustawienia zapisane",
|
||||||
"settings.openFiles": "Otwórz folder files",
|
"settings.openFiles": "Otwórz folder files",
|
||||||
"settings.closeToTray": "Zamykaj okno do zasobnika systemowego (licz czas gry w tle)",
|
"settings.closeToTray": "Zamykaj okno do zasobnika systemowego (licz czas gry w tle)",
|
||||||
|
"settings.hashCache": "Pamięć podręczna haszy",
|
||||||
|
"settings.hashCacheHint": "Synchronizacja LAN zapamiętuje hash każdego pliku (razem z rozmiarem i datą modyfikacji), dzięki czemu niezmieniona biblioteka nie jest czytana przy każdym syncu. Wyczyść, jeśli transfer porównuje pliki błędnie — przy następnej synchronizacji wszystko zostanie zahaszowane od nowa.",
|
||||||
|
"settings.clearHashCache": "Wyczyść",
|
||||||
|
"settings.hashCacheClearConfirm": "Zapomnieć wszystkie zapamiętane hasze plików? Następna synchronizacja zahashuje całą bibliotekę od nowa.",
|
||||||
|
"settings.hashCacheCleared": "Pamięć podręczna wyczyszczona",
|
||||||
"lansync.title": "Synchronizacja LAN",
|
"lansync.title": "Synchronizacja LAN",
|
||||||
"lansync.subtitle": "Wypchnij swoje aplikacje, prefiksy i buildy Wine na inny PLWL w tej samej sieci.",
|
"lansync.subtitle": "Wypchnij swoje aplikacje, prefiksy i buildy Wine na inny PLWL w tej samej sieci.",
|
||||||
"lansync.thisDevice": "To urządzenie",
|
"lansync.thisDevice": "To urządzenie",
|
||||||
|
|
|
||||||
|
|
@ -1970,6 +1970,12 @@ function wireEvents() {
|
||||||
})
|
})
|
||||||
qs('#btn-settings-save').addEventListener('click', saveSettings)
|
qs('#btn-settings-save').addEventListener('click', saveSettings)
|
||||||
qs('#btn-open-files').addEventListener('click', () => window.plwl.shellOpenPath(window.plwl.FILES_DIR))
|
qs('#btn-open-files').addEventListener('click', () => window.plwl.shellOpenPath(window.plwl.FILES_DIR))
|
||||||
|
qs('#btn-clear-hash-cache').addEventListener('click', async () => {
|
||||||
|
const ok = await confirmModal(t('settings.hashCacheClearConfirm'), t('settings.clearHashCache'))
|
||||||
|
if (!ok) return
|
||||||
|
await window.plwl.lansyncClearHashCache()
|
||||||
|
toast(t('settings.hashCacheCleared'), 'success')
|
||||||
|
})
|
||||||
qs('#btn-gc-back').addEventListener('click', () => showView('library'))
|
qs('#btn-gc-back').addEventListener('click', () => showView('library'))
|
||||||
qs('#btn-gc-save').addEventListener('click', saveGameConfig)
|
qs('#btn-gc-save').addEventListener('click', saveGameConfig)
|
||||||
qs('#btn-gc-shortcut').addEventListener('click', createAppShortcut)
|
qs('#btn-gc-shortcut').addEventListener('click', createAppShortcut)
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,13 @@
|
||||||
<button class="button" id="btn-open-files" data-t="settings.openFiles"></button>
|
<button class="button" id="btn-open-files" data-t="settings.openFiles"></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-card">
|
||||||
|
<h2 data-t="settings.hashCache"></h2>
|
||||||
|
<p class="subtitle" data-t="settings.hashCacheHint"></p>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="button danger" id="btn-clear-hash-cache" data-t="settings.clearHashCache"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="view-lansync" class="view">
|
<section id="view-lansync" class="view">
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,16 @@ const fs = require('fs')
|
||||||
const fsp = require('fs').promises
|
const fsp = require('fs').promises
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
|
|
||||||
|
// accessSync instead of existsSync: same answer, one syscall fewer.
|
||||||
|
function pathExists(p) {
|
||||||
|
try {
|
||||||
|
fs.accessSync(p)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Returns a non-existing path in `base` named `name` (or `name (2)`, `name (3)`...).
|
// Returns a non-existing path in `base` named `name` (or `name (2)`, `name (3)`...).
|
||||||
function dedupeDir(base, name) {
|
function dedupeDir(base, name) {
|
||||||
const candidate = path.join(base, name)
|
const candidate = path.join(base, name)
|
||||||
|
|
@ -50,6 +60,22 @@ function progressGate(emit) {
|
||||||
return push
|
return push
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Run `fn` over `items` with at most `limit` calls in flight, results in
|
||||||
|
// order. Walking a tree is dominated by stat/readlink/open syscalls: a few in
|
||||||
|
// flight keep the disk and cpu busy without exhausting file descriptors.
|
||||||
|
async function mapLimit(items, limit, fn) {
|
||||||
|
const out = new Array(items.length)
|
||||||
|
let next = 0
|
||||||
|
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
||||||
|
while (next < items.length) {
|
||||||
|
const i = next++
|
||||||
|
out[i] = await fn(items[i], i)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await Promise.all(workers)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// What a tree copy will have to move. Symlinks have no size of their own
|
// What a tree copy will have to move. Symlinks have no size of their own
|
||||||
// (they are recreated, not read), so they only count for the file total.
|
// (they are recreated, not read), so they only count for the file total.
|
||||||
// `skipDir` is a single directory name to leave out (a game folder's
|
// `skipDir` is a single directory name to leave out (a game folder's
|
||||||
|
|
@ -218,5 +244,5 @@ async function removeTree(root, onProgress) {
|
||||||
return { files, dirs, bytes }
|
return { files, dirs, bytes }
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { dedupeDir, copyTree, treeSize, removeTree, progressGate }
|
module.exports = { pathExists, dedupeDir, copyTree, treeSize, removeTree, progressGate, mapLimit }
|
||||||
|
|
||||||
|
|
|
||||||
161
src/hashcache.js
Normal file
161
src/hashcache.js
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
'use strict'
|
||||||
|
|
||||||
|
// Content hashing for the LAN sync, plus the per-file digest cache.
|
||||||
|
//
|
||||||
|
// Syncing a library means hashing tens of thousands of files, so the digest of
|
||||||
|
// every file is remembered on disk (files/data/lansync-hashes.json), keyed by
|
||||||
|
// its FILES-relative path with the size and mtime it was computed from: a file
|
||||||
|
// whose size and mtime are unchanged is never read again, only stat'ed. The
|
||||||
|
// cache is machine-local and never travels in a sync.
|
||||||
|
|
||||||
|
const crypto = require('crypto')
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
// BLAKE2b resists collisions like SHA-2 but is noticeably faster. Older
|
||||||
|
// runtimes fall back to SHA-2.
|
||||||
|
const HASH_ALGO = (() => {
|
||||||
|
for (const algo of ['blake2b512', 'sha512', 'sha256']) {
|
||||||
|
try {
|
||||||
|
crypto.createHash(algo)
|
||||||
|
return algo
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return 'sha256'
|
||||||
|
})()
|
||||||
|
|
||||||
|
// Streaming through one callback instead of accumulating 'data' chunks keeps
|
||||||
|
// memory flat for huge files, and a small highWaterMark gives the read pump
|
||||||
|
// real backpressure. Digest speed does not care about the chunk size.
|
||||||
|
function hashFile(file, onBytes) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const hash = crypto.createHash(HASH_ALGO)
|
||||||
|
const stream = fs.createReadStream(file, { highWaterMark: 64 * 1024 })
|
||||||
|
stream.on('error', reject)
|
||||||
|
if (onBytes) stream.on('data', chunk => onBytes(chunk.length))
|
||||||
|
stream.on('data', chunk => hash.update(chunk))
|
||||||
|
stream.on('end', () => resolve(hash.digest('hex')))
|
||||||
|
stream.resume()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// A quarter million entries is a whole game library; beyond that, entries the
|
||||||
|
// longest time untouched are dropped first.
|
||||||
|
const HASH_CACHE_MAX = 250000
|
||||||
|
// Mid-sync saves are throttled to this (the file can hold tens of megabytes);
|
||||||
|
// callers that must not lose entries (sync end, shutdown) pass force=true.
|
||||||
|
const SAVE_MIN_MS = 60000
|
||||||
|
|
||||||
|
function createHashCache(dataDir) {
|
||||||
|
const FILE = path.join(dataDir, 'lansync-hashes.json')
|
||||||
|
let cache = null
|
||||||
|
let dirty = false
|
||||||
|
let savedAt = 0
|
||||||
|
const stats = { hits: 0, misses: 0 }
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
if (cache) return cache
|
||||||
|
try {
|
||||||
|
const stored = JSON.parse(fs.readFileSync(FILE, 'utf8'))
|
||||||
|
cache = stored && typeof stored === 'object' && stored.files && typeof stored.files === 'object' ? stored.files : {}
|
||||||
|
} catch {
|
||||||
|
cache = {}
|
||||||
|
}
|
||||||
|
return cache
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compact JSON (the entries are arrays) keeps a full cache at tens of
|
||||||
|
// milliseconds instead of the seconds pretty-printed JSON needs.
|
||||||
|
function save(force) {
|
||||||
|
if (!dirty || !cache) return
|
||||||
|
if (!force && Date.now() - savedAt < SAVE_MIN_MS) return
|
||||||
|
const names = Object.keys(cache)
|
||||||
|
if (names.length > HASH_CACHE_MAX) {
|
||||||
|
names.sort((a, b) => (cache[b][3] || 0) - (cache[a][3] || 0))
|
||||||
|
const trimmed = {}
|
||||||
|
for (const name of names.slice(0, HASH_CACHE_MAX)) trimmed[name] = cache[name]
|
||||||
|
cache = trimmed
|
||||||
|
}
|
||||||
|
dirty = false
|
||||||
|
savedAt = Date.now()
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.dirname(FILE), { recursive: true })
|
||||||
|
const tmp = `${FILE}.tmp`
|
||||||
|
fs.writeFileSync(tmp, JSON.stringify({ v: 1, algo: HASH_ALGO, files: cache }))
|
||||||
|
fs.renameSync(tmp, FILE)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The remembered digest of a file, or null when it has to be read again.
|
||||||
|
// A hit only marks the cache dirty when its timestamp is actually stale:
|
||||||
|
// marking it on every lookup would force a full rewrite of the file per
|
||||||
|
// sync even though nothing a second sync could reuse has changed.
|
||||||
|
function hit(rel, size, mtimeMs) {
|
||||||
|
const known = load()[rel]
|
||||||
|
if (!known || known[0] !== size || known[1] !== mtimeMs || typeof known[2] !== 'string') {
|
||||||
|
stats.misses++
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
stats.hits++
|
||||||
|
if (Date.now() - (known[3] || 0) > 86400000) {
|
||||||
|
known[3] = Date.now()
|
||||||
|
dirty = true
|
||||||
|
}
|
||||||
|
return known[2]
|
||||||
|
}
|
||||||
|
|
||||||
|
function remember(rel, size, mtimeMs, hash) {
|
||||||
|
load()[rel] = [size, mtimeMs, hash, Date.now()]
|
||||||
|
dirty = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Digest of a file we already stat'ed: size and mtime unchanged means the
|
||||||
|
// bytes are the same, and the digest from an earlier sync still describes
|
||||||
|
// them. Otherwise the file is read (progress through onBytes) and stored.
|
||||||
|
async function cachedHash(rel, abs, size, mtimeMs, onBytes) {
|
||||||
|
const known = hit(rel, size, mtimeMs)
|
||||||
|
if (known) return known
|
||||||
|
const hash = await hashFile(abs, onBytes)
|
||||||
|
if (hash) remember(rel, size, mtimeMs, hash)
|
||||||
|
return hash
|
||||||
|
}
|
||||||
|
|
||||||
|
// The user can drop the cache from the settings view; a corrupted or stale
|
||||||
|
// file is also replaced wholesale this way.
|
||||||
|
function clear() {
|
||||||
|
cache = null
|
||||||
|
dirty = false
|
||||||
|
savedAt = 0
|
||||||
|
stats.hits = 0
|
||||||
|
stats.misses = 0
|
||||||
|
try {
|
||||||
|
fs.rmSync(FILE, { force: true })
|
||||||
|
fs.rmSync(`${FILE}.tmp`, { force: true })
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dropping the in-memory copy makes the next lookup re-read the file from
|
||||||
|
// disk - what tests use to prove a file was hashed instead of remembered.
|
||||||
|
function forget() {
|
||||||
|
cache = null
|
||||||
|
dirty = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
HASH_ALGO,
|
||||||
|
hashFile,
|
||||||
|
hit,
|
||||||
|
remember,
|
||||||
|
cachedHash,
|
||||||
|
save,
|
||||||
|
clear,
|
||||||
|
forget,
|
||||||
|
stats: () => ({ ...stats, size: cache ? Object.keys(cache).length : 0, algo: HASH_ALGO }),
|
||||||
|
resetStats: () => {
|
||||||
|
stats.hits = 0
|
||||||
|
stats.misses = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createHashCache, hashFile, HASH_ALGO }
|
||||||
|
|
@ -68,18 +68,9 @@ function registerIpc({ getWindow, onGamesChanged }) {
|
||||||
ipcMain.handle('settings:set', (e, patch) => library.saveSettings(patch))
|
ipcMain.handle('settings:set', (e, patch) => library.saveSettings(patch))
|
||||||
|
|
||||||
// wines ----------------------------------------------------------------------
|
// wines ----------------------------------------------------------------------
|
||||||
function systemWine() {
|
|
||||||
let version = ''
|
|
||||||
try {
|
|
||||||
const { execFileSync } = require('child_process')
|
|
||||||
version = String(execFileSync('wine', ['--version'], { timeout: 15000 })).trim().replace(/^wine-?/i, '')
|
|
||||||
} catch {}
|
|
||||||
return { id: 'system', type: 'system', label: 'System Wine', version }
|
|
||||||
}
|
|
||||||
|
|
||||||
ipcMain.handle('wines:list', async () => {
|
ipcMain.handle('wines:list', async () => {
|
||||||
await wines.refreshWines()
|
await wines.refreshWines()
|
||||||
return [systemWine(), ...wines.getWines()]
|
return [wines.systemWine(), ...wines.getWines()]
|
||||||
})
|
})
|
||||||
ipcMain.handle('wines:refresh', async () => (await wines.refreshWines()).filter(w => !w.disabled))
|
ipcMain.handle('wines:refresh', async () => (await wines.refreshWines()).filter(w => !w.disabled))
|
||||||
ipcMain.handle('wines:setDisabled', (e, id, disabled) => wines.setWineDisabled(id, disabled))
|
ipcMain.handle('wines:setDisabled', (e, id, disabled) => wines.setWineDisabled(id, disabled))
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,10 @@ function registerLanSyncIpc({ getWindow }) {
|
||||||
lansync.forgetDevice(id)
|
lansync.forgetDevice(id)
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
})
|
})
|
||||||
|
ipcMain.handle('lansync:clearHashCache', () => {
|
||||||
|
lansync.clearHashCache()
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
ipcMain.handle('lansync:respondIncoming', (e, requestId, accept) => lansync.respondIncoming(requestId, accept))
|
ipcMain.handle('lansync:respondIncoming', (e, requestId, accept) => lansync.respondIncoming(requestId, accept))
|
||||||
ipcMain.handle('lansync:sync', async (e, payload) => {
|
ipcMain.handle('lansync:sync', async (e, payload) => {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
135
src/lansync-discovery.js
Normal file
135
src/lansync-discovery.js
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
'use strict'
|
||||||
|
|
||||||
|
// UDP discovery for the LAN sync: every running PLWL beacons on the local
|
||||||
|
// network and listens for the same beacon, so the devices list fills itself.
|
||||||
|
// The beacon doubles as a liveness signal - a device drops off the list a few
|
||||||
|
// seconds after its PLWL is closed.
|
||||||
|
|
||||||
|
const dgram = require('dgram')
|
||||||
|
const os = require('os')
|
||||||
|
|
||||||
|
// Broadcast to the global address AND to every interface's own subnet
|
||||||
|
// broadcast - some networks drop one or the other.
|
||||||
|
function broadcastAddresses() {
|
||||||
|
const out = ['255.255.255.255']
|
||||||
|
const interfaces = os.networkInterfaces()
|
||||||
|
for (const list of Object.values(interfaces)) {
|
||||||
|
for (const iface of list || []) {
|
||||||
|
if (iface.family !== 'IPv4' || iface.internal || !iface.netmask) continue
|
||||||
|
const addr = ipToInt(iface.address)
|
||||||
|
const mask = ipToInt(iface.netmask)
|
||||||
|
if (addr == null || mask == null) continue
|
||||||
|
out.push(intToIp((addr | (~mask >>> 0)) >>> 0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function ipToInt(ip) {
|
||||||
|
const parts = String(ip).split('.')
|
||||||
|
if (parts.length !== 4) return null
|
||||||
|
let out = 0
|
||||||
|
for (const part of parts) {
|
||||||
|
const n = Number(part)
|
||||||
|
if (!Number.isInteger(n) || n < 0 || n > 255) return null
|
||||||
|
out = (out << 8) | n
|
||||||
|
}
|
||||||
|
return out >>> 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function intToIp(int) {
|
||||||
|
return [24, 16, 8, 0].map(shift => (int >>> shift) & 255).join('.')
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDiscovery({ identity, tag, protocol, port, beaconMs, deviceTtlMs, getPort }) {
|
||||||
|
let socket = null
|
||||||
|
let timer = null
|
||||||
|
let devicesCb = null
|
||||||
|
// id -> { id, name, address, port, at }
|
||||||
|
const seen = new Map()
|
||||||
|
|
||||||
|
function listDevices() {
|
||||||
|
const known = identity().known
|
||||||
|
const now = Date.now()
|
||||||
|
return [...seen.values()]
|
||||||
|
.filter(d => now - d.at <= deviceTtlMs)
|
||||||
|
.map(d => {
|
||||||
|
const pair = known[d.id]
|
||||||
|
return { id: d.id, name: d.name, address: d.address, port: d.port, paired: !!pair, code: pair ? pair.code : '' }
|
||||||
|
})
|
||||||
|
.sort((a, b) => String(a.name).localeCompare(String(b.name)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitDevices() {
|
||||||
|
if (devicesCb) devicesCb(listDevices())
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneDevices() {
|
||||||
|
const now = Date.now()
|
||||||
|
let changed = false
|
||||||
|
for (const [id, device] of seen) {
|
||||||
|
if (now - device.at > deviceTtlMs) {
|
||||||
|
seen.delete(id)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) emitDevices()
|
||||||
|
}
|
||||||
|
|
||||||
|
function beacon() {
|
||||||
|
if (!socket || !getPort()) return
|
||||||
|
const me = identity()
|
||||||
|
const payload = Buffer.from(JSON.stringify({ t: tag, v: protocol, id: me.deviceId, name: os.hostname(), port: getPort() }))
|
||||||
|
for (const address of broadcastAddresses()) {
|
||||||
|
try {
|
||||||
|
socket.send(payload, 0, payload.length, port, address)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
pruneDevices()
|
||||||
|
}
|
||||||
|
|
||||||
|
function start(onDevices) {
|
||||||
|
stop()
|
||||||
|
devicesCb = onDevices
|
||||||
|
socket = dgram.createSocket({ type: 'udp4', reuseAddr: true })
|
||||||
|
socket.on('error', () => stop())
|
||||||
|
socket.on('message', (buf, rinfo) => {
|
||||||
|
let msg = null
|
||||||
|
try {
|
||||||
|
msg = JSON.parse(buf.toString('utf8'))
|
||||||
|
} catch {}
|
||||||
|
if (!msg || msg.t !== tag || msg.v !== protocol || !msg.id) return
|
||||||
|
if (msg.id === identity().deviceId) return // our own broadcast
|
||||||
|
const previous = seen.get(msg.id)
|
||||||
|
const next = { id: msg.id, name: String(msg.name || rinfo.address), address: rinfo.address, port: Number(msg.port) || 0, at: Date.now() }
|
||||||
|
const changed = !previous || previous.address !== next.address || previous.port !== next.port || previous.name !== next.name
|
||||||
|
seen.set(msg.id, next)
|
||||||
|
if (changed) emitDevices()
|
||||||
|
})
|
||||||
|
socket.bind(port, () => {
|
||||||
|
try {
|
||||||
|
socket.setBroadcast(true)
|
||||||
|
} catch {}
|
||||||
|
beacon()
|
||||||
|
timer = setInterval(beacon, beaconMs)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop() {
|
||||||
|
if (timer) {
|
||||||
|
clearInterval(timer)
|
||||||
|
timer = null
|
||||||
|
}
|
||||||
|
if (socket) {
|
||||||
|
const closing = socket
|
||||||
|
socket = null
|
||||||
|
try {
|
||||||
|
closing.close()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { start, stop, listDevices }
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createDiscovery }
|
||||||
133
src/lansync-identity.js
Normal file
133
src/lansync-identity.js
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
'use strict'
|
||||||
|
|
||||||
|
// Device identity for the LAN sync: the id this install is known by, the code
|
||||||
|
// other devices must type to pair with it, and the list of paired devices.
|
||||||
|
// Exported as a factory over the config file, so every launcher state (and
|
||||||
|
// every test device) gets its own identity.
|
||||||
|
|
||||||
|
const crypto = require('crypto')
|
||||||
|
const { readJson, writeJson } = require('./paths')
|
||||||
|
|
||||||
|
// The sync code is read out on one machine and typed on the other, so the
|
||||||
|
// alphabet skips the characters people mix up (0/O, 1/I/L).
|
||||||
|
const CODE_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'
|
||||||
|
const ID_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789'
|
||||||
|
|
||||||
|
function randomChars(alphabet, count) {
|
||||||
|
let out = ''
|
||||||
|
while (out.length < count) {
|
||||||
|
for (const byte of crypto.randomBytes(count)) {
|
||||||
|
if (out.length >= count) break
|
||||||
|
out += alphabet[byte % alphabet.length]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// 24 characters: the current timestamp in base36 (unique per millisecond,
|
||||||
|
// 8 chars until 2089) plus random padding, so two machines generating an id
|
||||||
|
// within the same millisecond still get different ids.
|
||||||
|
function makeDeviceId() {
|
||||||
|
const stamp = Date.now().toString(36)
|
||||||
|
return (stamp + randomChars(ID_ALPHABET, Math.max(0, 24 - stamp.length))).slice(0, 24)
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSyncCode() {
|
||||||
|
return randomChars(CODE_ALPHABET, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createIdentity(configFile) {
|
||||||
|
// The identity file is read once and then kept in memory: the beacon calls
|
||||||
|
// identity() every couple of seconds (and every pairing call in between),
|
||||||
|
// and a synchronous read per call is pure overhead. Nothing else writes
|
||||||
|
// this file (the ipc handlers all go through these functions), so the cache
|
||||||
|
// cannot go stale.
|
||||||
|
let cached = null
|
||||||
|
|
||||||
|
function readConfig() {
|
||||||
|
if (cached) return cached
|
||||||
|
const cfg = readJson(configFile, {})
|
||||||
|
cached = cfg && typeof cfg === 'object' ? cfg : {}
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
// The id and the code are generated on FIRST use and then kept forever:
|
||||||
|
// other devices remember the id (they pair with it) and the code (so it
|
||||||
|
// only has to be typed once).
|
||||||
|
function identity() {
|
||||||
|
const cfg = readConfig()
|
||||||
|
let changed = false
|
||||||
|
if (typeof cfg.deviceId !== 'string' || cfg.deviceId.length !== 24) {
|
||||||
|
cfg.deviceId = makeDeviceId()
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if (typeof cfg.syncCode !== 'string' || cfg.syncCode.length !== 8) {
|
||||||
|
cfg.syncCode = makeSyncCode()
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if (!cfg.known || typeof cfg.known !== 'object') {
|
||||||
|
cfg.known = {}
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if (changed) writeJson(configFile, cfg)
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both rotations return the whole identity ({ deviceId, syncCode, known }),
|
||||||
|
// so callers get a consistent view of this device after the change.
|
||||||
|
function rotateDeviceId() {
|
||||||
|
const cfg = identity()
|
||||||
|
const previous = cfg.deviceId
|
||||||
|
cfg.deviceId = makeDeviceId()
|
||||||
|
writeJson(configFile, cfg)
|
||||||
|
if (cfg.deviceId === previous) return rotateDeviceId()
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
function rotateSyncCode() {
|
||||||
|
const cfg = identity()
|
||||||
|
const previous = cfg.syncCode
|
||||||
|
cfg.syncCode = makeSyncCode()
|
||||||
|
writeJson(configFile, cfg)
|
||||||
|
if (cfg.syncCode === previous) return rotateSyncCode() // never a no-op rotation
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// A device the user synced with successfully: its code is stored next to
|
||||||
|
// its id, so the next sync needs no typing. A rejected code is forgotten
|
||||||
|
// again, which is what drives the "type the code again" flow.
|
||||||
|
function knownDevice(id) {
|
||||||
|
return identity().known[id] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
function knownPeers() {
|
||||||
|
return identity().known || {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rememberDevice(id, code, name) {
|
||||||
|
const cfg = identity()
|
||||||
|
const stored = String(code || '').trim().toUpperCase()
|
||||||
|
cfg.known = { ...cfg.known, [id]: { code: stored, syncCode: stored, name: name || '', at: Date.now() } }
|
||||||
|
writeJson(configFile, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
function forgetDevice(id) {
|
||||||
|
const cfg = identity()
|
||||||
|
const known = { ...cfg.known }
|
||||||
|
delete known[id]
|
||||||
|
cfg.known = known
|
||||||
|
writeJson(configFile, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { identity, rotateDeviceId, rotateSyncCode, knownDevice, knownPeers, rememberDevice, forgetDevice, makeDeviceId, makeSyncCode }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare two typed codes without leaking timing: the codes are short, but a
|
||||||
|
// device may only verify once per two seconds anyway.
|
||||||
|
function sameCode(a, b) {
|
||||||
|
const x = Buffer.from(String(a == null ? '' : a).trim().toUpperCase())
|
||||||
|
const y = Buffer.from(String(b == null ? '' : b).trim().toUpperCase())
|
||||||
|
return x.length === y.length && x.length > 0 && crypto.timingSafeEqual(x, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createIdentity, sameCode, makeDeviceId, makeSyncCode }
|
||||||
152
src/lansync-manifest.js
Normal file
152
src/lansync-manifest.js
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
'use strict'
|
||||||
|
|
||||||
|
// Sender-side hashing: walking sync units into a manifest of dirs, links and
|
||||||
|
// files, with the digest of every file. Exported as a factory over the path
|
||||||
|
// guards and the hash store, so every launcher state (and every test device)
|
||||||
|
// builds manifests for its own files.
|
||||||
|
|
||||||
|
const path = require('path')
|
||||||
|
const fs = require('fs')
|
||||||
|
const fsp = fs.promises
|
||||||
|
|
||||||
|
function createManifestBuilder({ filesPath, filesRoot, hash, mapLimit, concurrency }) {
|
||||||
|
// Symlink targets travel RELATIVE whenever they point inside the launcher
|
||||||
|
// data (a prefix linking into its wine build, an app linking to its own
|
||||||
|
// assets): the mirrored trees have the same internal layout on both
|
||||||
|
// machines, so a relative target is correct over there while an absolute one
|
||||||
|
// would point at the sender's own folders - which is exactly how a synced
|
||||||
|
// prefix ends up with hundreds of dangling links. Targets outside files/
|
||||||
|
// (a system font, a mounted drive, /) are machine-specific and stay as they
|
||||||
|
// are.
|
||||||
|
function portableLinkTarget(linkAbs, target) {
|
||||||
|
if (typeof target !== 'string' || !target || !path.isAbsolute(target)) return target
|
||||||
|
const rel = path.relative(path.dirname(linkAbs), target)
|
||||||
|
if (!rel || path.isAbsolute(rel)) return target
|
||||||
|
const root = path.resolve(filesRoot)
|
||||||
|
const resolved = path.resolve(path.dirname(linkAbs), rel)
|
||||||
|
if (resolved !== root && !resolved.startsWith(root + path.sep)) return target
|
||||||
|
return rel
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk without following symlinks: builds and prefixes contain links that
|
||||||
|
// are part of what is being synced (dosdevices, build internals), and
|
||||||
|
// following them would both loop and copy the wrong thing.
|
||||||
|
//
|
||||||
|
// Iterative (a queue instead of an await-per-directory recursion) and with
|
||||||
|
// the stat/readlink calls of one directory in flight together: walking a
|
||||||
|
// prefix is dominated by those two syscalls, and serializing them costs more
|
||||||
|
// than the readdir itself. Regular files are re-verified with one stat
|
||||||
|
// (size, mode, mtime feed both the manifest and the hash cache), everything
|
||||||
|
// else is skipped.
|
||||||
|
async function scanTree(abs, rel, out, mtimes) {
|
||||||
|
const queue = [[abs, rel]]
|
||||||
|
while (queue.length) {
|
||||||
|
const [dirAbs, dirRel] = queue.pop()
|
||||||
|
let entries
|
||||||
|
try {
|
||||||
|
entries = await fsp.readdir(dirAbs, { withFileTypes: true })
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const dirs = []
|
||||||
|
const leaves = []
|
||||||
|
for (const entry of entries) {
|
||||||
|
const relChild = `${dirRel}/${entry.name}`
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
out.push({ path: relChild, type: 'dir' })
|
||||||
|
dirs.push([path.join(dirAbs, entry.name), relChild])
|
||||||
|
} else {
|
||||||
|
leaves.push(entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await mapLimit(leaves, concurrency, async entry => {
|
||||||
|
const absChild = path.join(dirAbs, entry.name)
|
||||||
|
const relChild = `${dirRel}/${entry.name}`
|
||||||
|
if (entry.isSymbolicLink()) {
|
||||||
|
let target = ''
|
||||||
|
try {
|
||||||
|
target = await fsp.readlink(absChild)
|
||||||
|
} catch {}
|
||||||
|
out.push({ path: relChild, type: 'link', target: portableLinkTarget(absChild, target) })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let st
|
||||||
|
try {
|
||||||
|
st = await fsp.stat(absChild)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!st.isFile()) return
|
||||||
|
// mode travels with the file: a wine build's `proton`/`wine` binary is
|
||||||
|
// useless on the other side without its executable bit
|
||||||
|
out.push({ path: relChild, type: 'file', size: st.size, mode: st.mode & 0o777, hash: null })
|
||||||
|
// kept out of the manifest: only this machine can use its own mtimes
|
||||||
|
if (mtimes) mtimes.set(relChild, st.mtimeMs || 0)
|
||||||
|
})
|
||||||
|
queue.push(...dirs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manifest of one sync unit. `force` skips hashing entirely - the receiver
|
||||||
|
// then treats every file as different ("force sync all files").
|
||||||
|
async function buildManifest(roots, force, onProgress) {
|
||||||
|
const entries = []
|
||||||
|
const mtimes = new Map()
|
||||||
|
for (const rel of roots) {
|
||||||
|
const abs = filesPath(rel)
|
||||||
|
if (!abs) continue
|
||||||
|
let st
|
||||||
|
try {
|
||||||
|
st = await fsp.lstat(abs)
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (st.isSymbolicLink()) {
|
||||||
|
let target = ''
|
||||||
|
try {
|
||||||
|
target = await fsp.readlink(abs)
|
||||||
|
} catch {}
|
||||||
|
entries.push({ path: rel, type: 'link', target: portableLinkTarget(abs, target) })
|
||||||
|
} else if (st.isDirectory()) {
|
||||||
|
entries.push({ path: rel, type: 'dir' })
|
||||||
|
await scanTree(abs, rel, entries, mtimes)
|
||||||
|
} else if (st.isFile()) {
|
||||||
|
entries.push({ path: rel, type: 'file', size: st.size, mode: st.mode & 0o777, hash: null })
|
||||||
|
mtimes.set(rel, st.mtimeMs || 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const files = entries.filter(e => e.type === 'file')
|
||||||
|
// the unit's total size is known before a byte is hashed, so the bar can
|
||||||
|
// start empty and fill with the bytes actually read
|
||||||
|
const totalBytes = files.reduce((sum, entry) => sum + (entry.size || 0), 0)
|
||||||
|
if (!force && files.length) {
|
||||||
|
let done = 0
|
||||||
|
let hashed = 0
|
||||||
|
await mapLimit(files, concurrency, async entry => {
|
||||||
|
const abs = filesPath(entry.path)
|
||||||
|
try {
|
||||||
|
// identical size and mtime means identical bytes, so the digest from
|
||||||
|
// an earlier sync still describes this file
|
||||||
|
const mtimeMs = mtimes.get(entry.path) || 0
|
||||||
|
entry.hash = abs
|
||||||
|
? await hash.cachedHash(entry.path, abs, entry.size, mtimeMs, bytes => {
|
||||||
|
hashed += bytes
|
||||||
|
if (onProgress) onProgress(done, files.length, hashed, totalBytes)
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
} catch {
|
||||||
|
entry.hash = null
|
||||||
|
}
|
||||||
|
done++
|
||||||
|
if (!entry.hash) hashed += entry.size || 0 // nothing read: still report the file as passed over
|
||||||
|
if (onProgress) onProgress(done, files.length, hashed, totalBytes)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
hash.save()
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
return { buildManifest, scanTree, portableLinkTarget }
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createManifestBuilder }
|
||||||
574
src/lansync.js
574
src/lansync.js
|
|
@ -7,7 +7,6 @@
|
||||||
// behind, and paths stored in the registries stay relative as they are.
|
// behind, and paths stored in the registries stay relative as they are.
|
||||||
|
|
||||||
const http = require('http')
|
const http = require('http')
|
||||||
const dgram = require('dgram')
|
|
||||||
const crypto = require('crypto')
|
const crypto = require('crypto')
|
||||||
const os = require('os')
|
const os = require('os')
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
|
|
@ -17,10 +16,15 @@ const readline = require('readline')
|
||||||
|
|
||||||
const { DIRS, readJson, writeJson, toPortablePath } = require('./paths')
|
const { DIRS, readJson, writeJson, toPortablePath } = require('./paths')
|
||||||
const { rewireSymlinks, restoreExecutableBits } = require('./symlinks')
|
const { rewireSymlinks, restoreExecutableBits } = require('./symlinks')
|
||||||
const { progressGate } = require('./fsutil')
|
const { progressGate, mapLimit } = require('./fsutil')
|
||||||
|
const { createHashCache } = require('./hashcache')
|
||||||
|
const { createIdentity, sameCode } = require('./lansync-identity')
|
||||||
|
const { createManifestBuilder } = require('./lansync-manifest')
|
||||||
|
const { createDiscovery } = require('./lansync-discovery')
|
||||||
const library = require('./library')
|
const library = require('./library')
|
||||||
const wines = require('./wines')
|
const wines = require('./wines')
|
||||||
const prefixes = require('./prefixes')
|
const prefixes = require('./prefixes')
|
||||||
|
const { isPrefixRel } = prefixes
|
||||||
|
|
||||||
const CONFIG = path.join(DIRS.DATA, 'lansync.json')
|
const CONFIG = path.join(DIRS.DATA, 'lansync.json')
|
||||||
|
|
||||||
|
|
@ -39,7 +43,9 @@ const DEVICE_TTL_MS = 6000
|
||||||
// flight - together they make guessing an 8 character code over the LAN
|
// flight - together they make guessing an 8 character code over the LAN
|
||||||
// pointless (and a failed guess cannot be retried in a tight loop).
|
// pointless (and a failed guess cannot be retried in a tight loop).
|
||||||
const VERIFY_DELAY_MS = 2000
|
const VERIFY_DELAY_MS = 2000
|
||||||
const TOKEN_TTL_MS = 30 * 60 * 1000
|
// How long a session token stays valid without any request. Env override for
|
||||||
|
// tests, which cannot wait half an hour to prove expiry.
|
||||||
|
const TOKEN_TTL_MS = Number(process.env.PLWL_LANSYNC_TOKEN_TTL) || 30 * 60 * 1000
|
||||||
const SESSION_IDLE_MS = 5 * 60 * 1000
|
const SESSION_IDLE_MS = 5 * 60 * 1000
|
||||||
// How long the receiving user has to click Accept before the request expires:
|
// How long the receiving user has to click Accept before the request expires:
|
||||||
// the sender waits for this decision, so its own verify timeout has to be
|
// the sender waits for this decision, so its own verify timeout has to be
|
||||||
|
|
@ -100,7 +106,6 @@ function appCategory(game) {
|
||||||
// inside its prefix ("drive_c/..." for plain wine, "pfx/drive_c/..." for
|
// inside its prefix ("drive_c/..." for plain wine, "pfx/drive_c/..." for
|
||||||
// Proton-managed prefixes) and anything absolute or ".." points outside the
|
// Proton-managed prefixes) and anything absolute or ".." points outside the
|
||||||
// launcher data, so it is never part of a sync.
|
// launcher data, so it is never part of a sync.
|
||||||
const PREFIX_REL_RE = /^(pfx[/\\])?drive_c([/\\]|$)/i
|
|
||||||
function classifyApp(app) {
|
function classifyApp(app) {
|
||||||
if (!app || (typeof app !== 'string' && typeof app !== 'object')) return null
|
if (!app || (typeof app !== 'string' && typeof app !== 'object')) return null
|
||||||
const raw = typeof app === 'string' ? app : app.path || app.exePath || app.workdir
|
const raw = typeof app === 'string' ? app : app.path || app.exePath || app.workdir
|
||||||
|
|
@ -109,7 +114,7 @@ function classifyApp(app) {
|
||||||
if (!raw || typeof raw !== 'string') return 'external'
|
if (!raw || typeof raw !== 'string') return 'external'
|
||||||
const value = raw.replace(/\\/g, '/')
|
const value = raw.replace(/\\/g, '/')
|
||||||
const outside = value.startsWith('..') || path.isAbsolute(raw) || /^[a-z]:\//i.test(value) || /^[a-z][a-z0-9+.-]*:\/\//i.test(value)
|
const outside = value.startsWith('..') || path.isAbsolute(raw) || /^[a-z]:\//i.test(value) || /^[a-z][a-z0-9+.-]*:\/\//i.test(value)
|
||||||
if (PREFIX_REL_RE.test(value)) return 'prefix'
|
if (isPrefixRel(value)) return 'prefix'
|
||||||
if (/^apps\//.test(value)) return 'portable'
|
if (/^apps\//.test(value)) return 'portable'
|
||||||
return outside ? 'external' : null
|
return outside ? 'external' : null
|
||||||
}
|
}
|
||||||
|
|
@ -125,248 +130,21 @@ function normalizeCategories(list) {
|
||||||
|
|
||||||
// --- identity ---------------------------------------------------------------
|
// --- identity ---------------------------------------------------------------
|
||||||
|
|
||||||
// The sync code is read out on one machine and typed on the other, so the
|
const identityApi = createIdentity(CONFIG)
|
||||||
// alphabet skips the characters people mix up (0/O, 1/I/L).
|
const { identity, rotateDeviceId, rotateSyncCode, knownDevice, knownPeers, rememberDevice, forgetDevice, makeDeviceId, makeSyncCode } = identityApi
|
||||||
const CODE_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'
|
|
||||||
const ID_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789'
|
|
||||||
|
|
||||||
function randomChars(alphabet, count) {
|
|
||||||
let out = ''
|
|
||||||
while (out.length < count) {
|
|
||||||
for (const byte of crypto.randomBytes(count)) {
|
|
||||||
if (out.length >= count) break
|
|
||||||
out += alphabet[byte % alphabet.length]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// 24 characters: the current timestamp in base36 (unique per millisecond,
|
|
||||||
// 8 chars until 2089) plus random padding, so two machines generating an id
|
|
||||||
// within the same millisecond still get different ids.
|
|
||||||
function makeDeviceId() {
|
|
||||||
const stamp = Date.now().toString(36)
|
|
||||||
return (stamp + randomChars(ID_ALPHABET, Math.max(0, 24 - stamp.length))).slice(0, 24)
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeSyncCode() {
|
|
||||||
return randomChars(CODE_ALPHABET, 8)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The identity file is read once and then kept in memory: the beacon calls
|
|
||||||
// identity() every couple of seconds (and every pairing call in between), and
|
|
||||||
// a synchronous read per call is pure overhead. Nothing else writes this file
|
|
||||||
// (the ipc handlers all go through the same functions), so the cache cannot
|
|
||||||
// go stale.
|
|
||||||
let identityCache = null
|
|
||||||
|
|
||||||
function readConfig() {
|
|
||||||
if (identityCache) return identityCache
|
|
||||||
const cfg = readJson(CONFIG, {})
|
|
||||||
identityCache = cfg && typeof cfg === 'object' ? cfg : {}
|
|
||||||
return identityCache
|
|
||||||
}
|
|
||||||
|
|
||||||
// The id and the code are generated on FIRST use and then kept forever:
|
|
||||||
// other devices remember the id (they pair with it) and the code (so it only
|
|
||||||
// has to be typed once).
|
|
||||||
function identity() {
|
|
||||||
const cfg = readConfig()
|
|
||||||
let changed = false
|
|
||||||
if (typeof cfg.deviceId !== 'string' || cfg.deviceId.length !== 24) {
|
|
||||||
cfg.deviceId = makeDeviceId()
|
|
||||||
changed = true
|
|
||||||
}
|
|
||||||
if (typeof cfg.syncCode !== 'string' || cfg.syncCode.length !== 8) {
|
|
||||||
cfg.syncCode = makeSyncCode()
|
|
||||||
changed = true
|
|
||||||
}
|
|
||||||
if (!cfg.known || typeof cfg.known !== 'object') {
|
|
||||||
cfg.known = {}
|
|
||||||
changed = true
|
|
||||||
}
|
|
||||||
if (changed) writeJson(CONFIG, cfg)
|
|
||||||
return cfg
|
|
||||||
}
|
|
||||||
|
|
||||||
// Both rotations return the whole identity ({ deviceId, syncCode, known }),
|
|
||||||
// so callers get a consistent view of this device after the change.
|
|
||||||
function rotateDeviceId() {
|
|
||||||
const cfg = identity()
|
|
||||||
const previous = cfg.deviceId
|
|
||||||
cfg.deviceId = makeDeviceId()
|
|
||||||
writeJson(CONFIG, cfg)
|
|
||||||
if (cfg.deviceId === previous) return rotateDeviceId()
|
|
||||||
return cfg
|
|
||||||
}
|
|
||||||
|
|
||||||
function rotateSyncCode() {
|
|
||||||
const cfg = identity()
|
|
||||||
const previous = cfg.syncCode
|
|
||||||
cfg.syncCode = makeSyncCode()
|
|
||||||
writeJson(CONFIG, cfg)
|
|
||||||
if (cfg.syncCode === previous) return rotateSyncCode() // never a no-op rotation
|
|
||||||
return cfg
|
|
||||||
}
|
|
||||||
|
|
||||||
// A device the user synced with successfully: its code is stored next to its
|
|
||||||
// id, so the next sync needs no typing. A rejected code is forgotten again,
|
|
||||||
// which is what drives the "type the code again" flow.
|
|
||||||
function knownDevice(id) {
|
|
||||||
return identity().known[id] || null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Devices this launcher paired with: id -> { code, name, at }. The code is the
|
|
||||||
// one that was accepted, so later syncs need no typing.
|
|
||||||
function knownPeers() {
|
|
||||||
return identity().known || {}
|
|
||||||
}
|
|
||||||
|
|
||||||
function rememberDevice(id, code, name) {
|
|
||||||
const cfg = identity()
|
|
||||||
const stored = String(code || '').trim().toUpperCase()
|
|
||||||
cfg.known = { ...cfg.known, [id]: { code: stored, syncCode: stored, name: name || '', at: Date.now() } }
|
|
||||||
writeJson(CONFIG, cfg)
|
|
||||||
}
|
|
||||||
|
|
||||||
function forgetDevice(id) {
|
|
||||||
const cfg = identity()
|
|
||||||
const known = { ...cfg.known }
|
|
||||||
delete known[id]
|
|
||||||
cfg.known = known
|
|
||||||
writeJson(CONFIG, cfg)
|
|
||||||
}
|
|
||||||
|
|
||||||
function sameCode(a, b) {
|
|
||||||
const x = Buffer.from(String(a == null ? '' : a).trim().toUpperCase())
|
|
||||||
const y = Buffer.from(String(b == null ? '' : b).trim().toUpperCase())
|
|
||||||
return x.length === y.length && x.length > 0 && crypto.timingSafeEqual(x, y)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- files: hashing, paths, manifests ---------------------------------------
|
// --- files: hashing, paths, manifests ---------------------------------------
|
||||||
|
|
||||||
// BLAKE2b resists collisions like SHA-2 but is noticeably faster - syncing a
|
|
||||||
// wine prefix means hashing tens of thousands of files, so this is the hot
|
|
||||||
// path. Older runtimes fall back to SHA-2.
|
|
||||||
const HASH_ALGO = (() => {
|
|
||||||
for (const algo of ['blake2b512', 'sha512', 'sha256']) {
|
|
||||||
try {
|
|
||||||
crypto.createHash(algo)
|
|
||||||
return algo
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
return 'sha256'
|
|
||||||
})()
|
|
||||||
|
|
||||||
// Hashing a tree is dominated by opening the (often tiny) files, not by the
|
// Hashing a tree is dominated by opening the (often tiny) files, not by the
|
||||||
// digest itself: a few in flight at once keeps the disk and the cpu busy
|
// digest itself: a few in flight at once keeps the disk and the cpu busy
|
||||||
// instead of waiting on one file at a time.
|
// instead of waiting on one file at a time.
|
||||||
const HASH_CONCURRENCY = Math.max(4, Math.min(16, (os.cpus() || []).length || 4))
|
const HASH_CONCURRENCY = Math.max(4, Math.min(16, (os.cpus() || []).length || 4))
|
||||||
|
|
||||||
// Hash results are remembered per file (size + mtime), so a library that did
|
// Digests are remembered per file (size + mtime), so a library that did not
|
||||||
// not change since the last sync is never read again - only stat'ed.
|
// change since the last sync is never read again - only stat'ed. One store per
|
||||||
const HASH_CACHE_FILE = path.join(DIRS.DATA, 'lansync-hashes.json')
|
// launcher state (tests re-require this module per device).
|
||||||
const HASH_CACHE_MAX = 250000
|
const hashStore = createHashCache(DIRS.DATA)
|
||||||
let hashCache = null
|
const hashFile = hashStore.hashFile
|
||||||
let hashCacheDirty = false
|
|
||||||
let hashCacheSavedAt = 0
|
|
||||||
const hashStats = { hits: 0, misses: 0 }
|
|
||||||
|
|
||||||
function hashCacheStats() {
|
|
||||||
return { ...hashStats, size: hashCache ? Object.keys(hashCache).length : 0, algo: HASH_ALGO }
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetHashCacheStats() {
|
|
||||||
hashStats.hits = 0
|
|
||||||
hashStats.misses = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dropping the in-memory copy makes the next lookup re-read the file from
|
|
||||||
// disk - what tests use to prove a file was hashed instead of remembered.
|
|
||||||
function forgetHashCache() {
|
|
||||||
hashCache = null
|
|
||||||
hashCacheDirty = false
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadHashCache() {
|
|
||||||
if (!hashCache) {
|
|
||||||
const stored = readJson(HASH_CACHE_FILE, null)
|
|
||||||
hashCache = stored && typeof stored === 'object' && stored.files && typeof stored.files === 'object' ? stored.files : {}
|
|
||||||
}
|
|
||||||
return hashCache
|
|
||||||
}
|
|
||||||
|
|
||||||
// Written once per operation at most: this file can hold a quarter million
|
|
||||||
// entries, and rewriting it per hashed file would cost more than the hashing.
|
|
||||||
// Compact JSON (the entries are already arrays) keeps a full cache at tens of
|
|
||||||
// milliseconds instead of the seconds pretty-printed JSON needs.
|
|
||||||
function saveHashCache() {
|
|
||||||
if (!hashCacheDirty || !hashCache) return
|
|
||||||
const now = Date.now()
|
|
||||||
if (now - hashCacheSavedAt < 60000) return
|
|
||||||
const names = Object.keys(hashCache)
|
|
||||||
if (names.length > HASH_CACHE_MAX) {
|
|
||||||
names.sort((a, b) => (hashCache[b][3] || 0) - (hashCache[a][3] || 0))
|
|
||||||
const trimmed = {}
|
|
||||||
for (const name of names.slice(0, HASH_CACHE_MAX)) trimmed[name] = hashCache[name]
|
|
||||||
hashCache = trimmed
|
|
||||||
}
|
|
||||||
hashCacheDirty = false
|
|
||||||
hashCacheSavedAt = now
|
|
||||||
try {
|
|
||||||
fs.mkdirSync(path.dirname(HASH_CACHE_FILE), { recursive: true })
|
|
||||||
fs.writeFileSync(HASH_CACHE_FILE, JSON.stringify({ v: 1, algo: HASH_ALGO, files: hashCache }))
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The remembered digest of a file, or null when it has to be read again.
|
|
||||||
// A hit only marks the cache dirty when its timestamp is actually stale:
|
|
||||||
// marking it on every lookup would force a full rewrite of the file per sync
|
|
||||||
// even though nothing a second sync could reuse has changed.
|
|
||||||
function hashCacheHit(rel, size, mtimeMs) {
|
|
||||||
const cache = loadHashCache()
|
|
||||||
const known = cache[rel]
|
|
||||||
if (!known || known[0] !== size || known[1] !== mtimeMs || typeof known[2] !== 'string') {
|
|
||||||
hashStats.misses++
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
hashStats.hits++
|
|
||||||
if (Date.now() - (known[3] || 0) > 86400000) {
|
|
||||||
known[3] = Date.now()
|
|
||||||
hashCacheDirty = true
|
|
||||||
}
|
|
||||||
return known[2]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hash of a file we already stat'ed: size and mtime unchanged means the bytes
|
|
||||||
// are the same, and the digest from an earlier sync still describes them.
|
|
||||||
function cachedHash(rel, abs, size, mtimeMs) {
|
|
||||||
const known = hashCacheHit(rel, size, mtimeMs)
|
|
||||||
if (known) return Promise.resolve(known)
|
|
||||||
return hashFile(abs).then(hash => {
|
|
||||||
if (hash) {
|
|
||||||
loadHashCache()[rel] = [size, mtimeMs, hash, Date.now()]
|
|
||||||
hashCacheDirty = true
|
|
||||||
}
|
|
||||||
return hash
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Streaming through one callback instead of accumulating 'data' chunks keeps
|
|
||||||
// memory flat for huge files, and a small highWaterMark gives the read pump
|
|
||||||
// real backpressure. Digest speed does not care about the chunk size, so the
|
|
||||||
// old 1 MB reads only ever paid for themselves in memory, not in time.
|
|
||||||
function hashFile(file, onBytes) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const hash = crypto.createHash(HASH_ALGO)
|
|
||||||
const stream = fs.createReadStream(file, { highWaterMark: 64 * 1024 })
|
|
||||||
stream.on('error', reject)
|
|
||||||
if (onBytes) stream.on('data', chunk => onBytes(chunk.length))
|
|
||||||
stream.on('data', chunk => hash.update(chunk))
|
|
||||||
stream.on('end', () => resolve(hash.digest('hex')))
|
|
||||||
stream.resume()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Protocol paths are FILES-relative with '/' separators. Anything escaping
|
// Protocol paths are FILES-relative with '/' separators. Anything escaping
|
||||||
// files/ (a '..' segment, an absolute path) is rejected - manifests come from
|
// files/ (a '..' segment, an absolute path) is rejected - manifests come from
|
||||||
|
|
@ -386,163 +164,10 @@ function ownedRoot(rel) {
|
||||||
return SYNC_ROOTS.some(prefix => clean.startsWith(prefix)) ? clean : null
|
return SYNC_ROOTS.some(prefix => clean.startsWith(prefix)) ? clean : null
|
||||||
}
|
}
|
||||||
|
|
||||||
async function mapLimit(items, limit, fn) {
|
// --- manifests (the sender's hashing) ---------------------------------------
|
||||||
const out = new Array(items.length)
|
|
||||||
let next = 0
|
|
||||||
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
||||||
while (next < items.length) {
|
|
||||||
const i = next++
|
|
||||||
out[i] = await fn(items[i], i)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
await Promise.all(workers)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// Symlink targets travel RELATIVE whenever they point inside the launcher data
|
const manifest = createManifestBuilder({ filesPath, filesRoot: DIRS.FILES, hash: hashStore, mapLimit, concurrency: HASH_CONCURRENCY })
|
||||||
// (a prefix linking into its wine build, an app linking to its own assets): the
|
const buildManifest = manifest.buildManifest
|
||||||
// mirrored trees have the same internal layout on both machines, so a relative
|
|
||||||
// target is correct over there while an absolute one would point at the
|
|
||||||
// sender's own folders - which is exactly how a synced prefix ends up with
|
|
||||||
// hundreds of dangling links. Targets outside files/ (a system font, a mounted
|
|
||||||
// drive, /) are machine-specific and stay as they are.
|
|
||||||
function portableLinkTarget(linkAbs, target) {
|
|
||||||
if (typeof target !== 'string' || !target || !path.isAbsolute(target)) return target
|
|
||||||
const rel = path.relative(path.dirname(linkAbs), target)
|
|
||||||
if (!rel || path.isAbsolute(rel)) return target
|
|
||||||
const root = path.resolve(DIRS.FILES)
|
|
||||||
const resolved = path.resolve(path.dirname(linkAbs), rel)
|
|
||||||
if (resolved !== root && !resolved.startsWith(root + path.sep)) return target
|
|
||||||
return rel
|
|
||||||
}
|
|
||||||
|
|
||||||
// Walk without following symlinks: builds and prefixes contain links that are
|
|
||||||
// part of what is being synced (dosdevices, build internals), and following
|
|
||||||
// them would both loop and copy the wrong thing.
|
|
||||||
//
|
|
||||||
// Iterative (a queue instead of an await-per-directory recursion) and with the
|
|
||||||
// stat/readlink calls of one directory in flight together: walking a prefix is
|
|
||||||
// dominated by those two syscalls, and serializing them costs more than the
|
|
||||||
// readdir itself. Regular files are re-verified with one stat (size, mode,
|
|
||||||
// mtime feed both the manifest and the hash cache), everything else is skipped.
|
|
||||||
const SCAN_CONCURRENCY = Math.max(4, HASH_CONCURRENCY)
|
|
||||||
|
|
||||||
async function scanTree(abs, rel, out, mtimes) {
|
|
||||||
const queue = [[abs, rel]]
|
|
||||||
while (queue.length) {
|
|
||||||
const [dirAbs, dirRel] = queue.pop()
|
|
||||||
let entries
|
|
||||||
try {
|
|
||||||
entries = await fsp.readdir(dirAbs, { withFileTypes: true })
|
|
||||||
} catch {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const dirs = []
|
|
||||||
const leaves = []
|
|
||||||
for (const entry of entries) {
|
|
||||||
const relChild = `${dirRel}/${entry.name}`
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
out.push({ path: relChild, type: 'dir' })
|
|
||||||
dirs.push([path.join(dirAbs, entry.name), relChild])
|
|
||||||
} else {
|
|
||||||
leaves.push(entry)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await mapLimit(leaves, SCAN_CONCURRENCY, async entry => {
|
|
||||||
const absChild = path.join(dirAbs, entry.name)
|
|
||||||
const relChild = `${dirRel}/${entry.name}`
|
|
||||||
if (entry.isSymbolicLink()) {
|
|
||||||
let target = ''
|
|
||||||
try {
|
|
||||||
target = await fsp.readlink(absChild)
|
|
||||||
} catch {}
|
|
||||||
out.push({ path: relChild, type: 'link', target: portableLinkTarget(absChild, target) })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let st
|
|
||||||
try {
|
|
||||||
st = await fsp.stat(absChild)
|
|
||||||
} catch {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!st.isFile()) return
|
|
||||||
// mode travels with the file: a wine build's `proton`/`wine` binary is
|
|
||||||
// useless on the other side without its executable bit
|
|
||||||
out.push({ path: relChild, type: 'file', size: st.size, mode: st.mode & 0o777, hash: null })
|
|
||||||
// kept out of the manifest: only this machine can use its own mtimes
|
|
||||||
if (mtimes) mtimes.set(relChild, st.mtimeMs || 0)
|
|
||||||
})
|
|
||||||
queue.push(...dirs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Manifest of one sync unit. `force` skips hashing entirely - the receiver
|
|
||||||
// then treats every file as different ("force sync all files").
|
|
||||||
async function buildManifest(roots, force, onProgress) {
|
|
||||||
const entries = []
|
|
||||||
const mtimes = new Map()
|
|
||||||
for (const rel of roots) {
|
|
||||||
const abs = filesPath(rel)
|
|
||||||
if (!abs) continue
|
|
||||||
let st
|
|
||||||
try {
|
|
||||||
st = await fsp.lstat(abs)
|
|
||||||
} catch {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (st.isSymbolicLink()) {
|
|
||||||
let target = ''
|
|
||||||
try {
|
|
||||||
target = await fsp.readlink(abs)
|
|
||||||
} catch {}
|
|
||||||
entries.push({ path: rel, type: 'link', target: portableLinkTarget(abs, target) })
|
|
||||||
} else if (st.isDirectory()) {
|
|
||||||
entries.push({ path: rel, type: 'dir' })
|
|
||||||
await scanTree(abs, rel, entries, mtimes)
|
|
||||||
} else if (st.isFile()) {
|
|
||||||
entries.push({ path: rel, type: 'file', size: st.size, mode: st.mode & 0o777, hash: null })
|
|
||||||
mtimes.set(rel, st.mtimeMs || 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const files = entries.filter(e => e.type === 'file')
|
|
||||||
// the unit's total size is known before a byte is hashed, so the bar can
|
|
||||||
// start empty and fill with the bytes actually read
|
|
||||||
const totalBytes = files.reduce((sum, entry) => sum + (entry.size || 0), 0)
|
|
||||||
if (!force && files.length) {
|
|
||||||
let done = 0
|
|
||||||
let hashed = 0
|
|
||||||
await mapLimit(files, HASH_CONCURRENCY, async entry => {
|
|
||||||
const abs = filesPath(entry.path)
|
|
||||||
try {
|
|
||||||
// identical size and mtime means identical bytes, so the digest from
|
|
||||||
// an earlier sync still describes this file
|
|
||||||
const mtimeMs = mtimes.get(entry.path) || 0
|
|
||||||
const known = hashCacheHit(entry.path, entry.size, mtimeMs)
|
|
||||||
if (known && abs) {
|
|
||||||
entry.hash = known
|
|
||||||
} else {
|
|
||||||
entry.hash = abs
|
|
||||||
? await hashFile(abs, bytes => {
|
|
||||||
hashed += bytes
|
|
||||||
if (onProgress) onProgress(done, files.length, hashed, totalBytes)
|
|
||||||
})
|
|
||||||
: null
|
|
||||||
if (entry.hash) {
|
|
||||||
loadHashCache()[entry.path] = [entry.size, mtimeMs, entry.hash, Date.now()]
|
|
||||||
hashCacheDirty = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
entry.hash = null
|
|
||||||
}
|
|
||||||
done++
|
|
||||||
if (!entry.hash) hashed += entry.size || 0 // nothing read: still report the file as passed over
|
|
||||||
if (onProgress) onProgress(done, files.length, hashed, totalBytes)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
saveHashCache()
|
|
||||||
return entries
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- HTTP helpers (shared with the pusher) ----------------------------------
|
// --- HTTP helpers (shared with the pusher) ----------------------------------
|
||||||
|
|
||||||
|
|
@ -699,7 +324,7 @@ async function entrySame(entry) {
|
||||||
if (!st.isFile() || st.size !== entry.size) return false
|
if (!st.isFile() || st.size !== entry.size) return false
|
||||||
let same = false
|
let same = false
|
||||||
try {
|
try {
|
||||||
same = (await cachedHash(entry.path, abs, st.size, st.mtimeMs || 0)) === entry.hash
|
same = (await hashStore.cachedHash(entry.path, abs, st.size, st.mtimeMs || 0)) === entry.hash
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
@ -1194,6 +819,13 @@ function authorize(state, token) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
session.lastAt = Date.now()
|
session.lastAt = Date.now()
|
||||||
|
// The expiry SLIDES with every request: hashing a big unit takes longer than
|
||||||
|
// any fixed TTL, and the request that finally moves files would arrive on a
|
||||||
|
// token that died while the sender was still hashing - the sync then ends
|
||||||
|
// right when it is ready to send. The heartbeat keeps the token alive through
|
||||||
|
// such a phase; a sender that really vanished still ages out this long after
|
||||||
|
// its last request.
|
||||||
|
session.expires = Date.now() + TOKEN_TTL_MS
|
||||||
if (state.session && state.session.token === token) state.session.lastAt = session.lastAt
|
if (state.session && state.session.token === token) state.session.lastAt = session.lastAt
|
||||||
return session
|
return session
|
||||||
}
|
}
|
||||||
|
|
@ -1371,7 +1003,7 @@ async function handlePlan(state, session, url, req, res, hooks) {
|
||||||
announce({ unit: label })
|
announce({ unit: label })
|
||||||
return same === true
|
return same === true
|
||||||
})
|
})
|
||||||
saveHashCache()
|
hashStore.save()
|
||||||
const paths = new Set()
|
const paths = new Set()
|
||||||
const upload = []
|
const upload = []
|
||||||
const same = []
|
const same = []
|
||||||
|
|
@ -1533,128 +1165,22 @@ function handlePing(session, res, hooks) {
|
||||||
|
|
||||||
// --- discovery --------------------------------------------------------------
|
// --- discovery --------------------------------------------------------------
|
||||||
|
|
||||||
let beaconSocket = null
|
const discovery = createDiscovery({
|
||||||
let beaconTimer = null
|
identity,
|
||||||
let httpPort = 0
|
tag: BEACON_TAG,
|
||||||
let seen = new Map()
|
protocol: PROTOCOL,
|
||||||
let devicesCb = null
|
port: DISCOVERY_PORT,
|
||||||
|
beaconMs: BEACON_MS,
|
||||||
function ipToInt(ip) {
|
deviceTtlMs: DEVICE_TTL_MS,
|
||||||
const parts = String(ip).split('.')
|
getPort: () => httpPort
|
||||||
if (parts.length !== 4) return null
|
})
|
||||||
let out = 0
|
const startDiscovery = discovery.start
|
||||||
for (const part of parts) {
|
const stopDiscovery = discovery.stop
|
||||||
const n = Number(part)
|
const listDevices = discovery.listDevices
|
||||||
if (!Number.isInteger(n) || n < 0 || n > 255) return null
|
|
||||||
out = (out << 8) | n
|
|
||||||
}
|
|
||||||
return out >>> 0
|
|
||||||
}
|
|
||||||
|
|
||||||
function intToIp(int) {
|
|
||||||
return [24, 16, 8, 0].map(shift => (int >>> shift) & 255).join('.')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Broadcast to the global address AND to every interface's own subnet
|
|
||||||
// broadcast - some networks drop one or the other.
|
|
||||||
function broadcastAddresses() {
|
|
||||||
const out = ['255.255.255.255']
|
|
||||||
const interfaces = os.networkInterfaces()
|
|
||||||
for (const list of Object.values(interfaces)) {
|
|
||||||
for (const iface of list || []) {
|
|
||||||
if (iface.family !== 'IPv4' || iface.internal || !iface.netmask) continue
|
|
||||||
const addr = ipToInt(iface.address)
|
|
||||||
const mask = ipToInt(iface.netmask)
|
|
||||||
if (addr == null || mask == null) continue
|
|
||||||
out.push(intToIp((addr | (~mask >>> 0)) >>> 0))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
function listDevices() {
|
|
||||||
const known = identity().known
|
|
||||||
const now = Date.now()
|
|
||||||
return [...seen.values()]
|
|
||||||
.filter(d => now - d.at <= DEVICE_TTL_MS)
|
|
||||||
.map(d => {
|
|
||||||
const pair = known[d.id]
|
|
||||||
return { id: d.id, name: d.name, address: d.address, port: d.port, paired: !!pair, code: pair ? pair.code : '' }
|
|
||||||
})
|
|
||||||
.sort((a, b) => String(a.name).localeCompare(String(b.name)))
|
|
||||||
}
|
|
||||||
|
|
||||||
function emitDevices() {
|
|
||||||
if (devicesCb) devicesCb(listDevices())
|
|
||||||
}
|
|
||||||
|
|
||||||
function pruneDevices() {
|
|
||||||
const now = Date.now()
|
|
||||||
let changed = false
|
|
||||||
for (const [id, device] of seen) {
|
|
||||||
if (now - device.at > DEVICE_TTL_MS) {
|
|
||||||
seen.delete(id)
|
|
||||||
changed = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (changed) emitDevices()
|
|
||||||
}
|
|
||||||
|
|
||||||
function beacon() {
|
|
||||||
if (!beaconSocket || !httpPort) return
|
|
||||||
const me = identity()
|
|
||||||
const payload = Buffer.from(JSON.stringify({ t: BEACON_TAG, v: PROTOCOL, id: me.deviceId, name: os.hostname(), port: httpPort }))
|
|
||||||
for (const address of broadcastAddresses()) {
|
|
||||||
try {
|
|
||||||
beaconSocket.send(payload, 0, payload.length, DISCOVERY_PORT, address)
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
pruneDevices()
|
|
||||||
}
|
|
||||||
|
|
||||||
function startDiscovery(onDevices) {
|
|
||||||
stopDiscovery()
|
|
||||||
devicesCb = onDevices
|
|
||||||
beaconSocket = dgram.createSocket({ type: 'udp4', reuseAddr: true })
|
|
||||||
beaconSocket.on('error', () => stopDiscovery())
|
|
||||||
beaconSocket.on('message', (buf, rinfo) => {
|
|
||||||
let msg = null
|
|
||||||
try {
|
|
||||||
msg = JSON.parse(buf.toString('utf8'))
|
|
||||||
} catch {}
|
|
||||||
if (!msg || msg.t !== BEACON_TAG || msg.v !== PROTOCOL || !msg.id) return
|
|
||||||
if (msg.id === identity().deviceId) return // our own broadcast
|
|
||||||
const previous = seen.get(msg.id)
|
|
||||||
const next = { id: msg.id, name: String(msg.name || rinfo.address), address: rinfo.address, port: Number(msg.port) || 0, at: Date.now() }
|
|
||||||
const changed = !previous || previous.address !== next.address || previous.port !== next.port || previous.name !== next.name
|
|
||||||
seen.set(msg.id, next)
|
|
||||||
if (changed) emitDevices()
|
|
||||||
})
|
|
||||||
beaconSocket.bind(DISCOVERY_PORT, () => {
|
|
||||||
try {
|
|
||||||
beaconSocket.setBroadcast(true)
|
|
||||||
} catch {}
|
|
||||||
beacon()
|
|
||||||
beaconTimer = setInterval(beacon, BEACON_MS)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopDiscovery() {
|
|
||||||
if (beaconTimer) {
|
|
||||||
clearInterval(beaconTimer)
|
|
||||||
beaconTimer = null
|
|
||||||
}
|
|
||||||
if (beaconSocket) {
|
|
||||||
const socket = beaconSocket
|
|
||||||
beaconSocket = null
|
|
||||||
try {
|
|
||||||
socket.close()
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- service ----------------------------------------------------------------
|
// --- service ----------------------------------------------------------------
|
||||||
|
|
||||||
|
let httpPort = 0
|
||||||
let httpServer = null
|
let httpServer = null
|
||||||
let serverHooks = {}
|
let serverHooks = {}
|
||||||
let listening = null // promise resolved once the receiving server is up
|
let listening = null // promise resolved once the receiving server is up
|
||||||
|
|
@ -1686,6 +1212,9 @@ function startServer(options = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopServer() {
|
function stopServer() {
|
||||||
|
// new digests must not die with the process: the throttled mid-sync saves
|
||||||
|
// may have skipped the last few units
|
||||||
|
hashStore.save(true)
|
||||||
if (httpServer) {
|
if (httpServer) {
|
||||||
try {
|
try {
|
||||||
httpServer.close()
|
httpServer.close()
|
||||||
|
|
@ -1705,7 +1234,7 @@ function stopServer() {
|
||||||
function start(hooks = {}) {
|
function start(hooks = {}) {
|
||||||
serverHooks = { ...serverHooks, ...hooks }
|
serverHooks = { ...serverHooks, ...hooks }
|
||||||
return startServer().then(info => {
|
return startServer().then(info => {
|
||||||
if (!beaconSocket) startDiscovery(serverHooks.onDevices)
|
startDiscovery(serverHooks.onDevices)
|
||||||
return info
|
return info
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -1721,7 +1250,7 @@ function serverPort() {
|
||||||
|
|
||||||
function getState() {
|
function getState() {
|
||||||
const me = identity()
|
const me = identity()
|
||||||
return { deviceId: me.deviceId, syncCode: me.syncCode, hash: HASH_ALGO, port: httpPort, devices: listDevices() }
|
return { deviceId: me.deviceId, syncCode: me.syncCode, hash: hashStore.HASH_ALGO, port: httpPort, devices: listDevices() }
|
||||||
}
|
}
|
||||||
|
|
||||||
// The pusher lives in its own module and is built with THIS module's helpers
|
// The pusher lives in its own module and is built with THIS module's helpers
|
||||||
|
|
@ -1810,11 +1339,12 @@ const exported = {
|
||||||
forgetDevice,
|
forgetDevice,
|
||||||
respondIncoming,
|
respondIncoming,
|
||||||
sameCode,
|
sameCode,
|
||||||
HASH_ALGO,
|
HASH_ALGO: hashStore.HASH_ALGO,
|
||||||
hashFile,
|
hashFile,
|
||||||
hashCacheStats,
|
hashCacheStats: hashStore.stats,
|
||||||
resetHashCacheStats,
|
resetHashCacheStats: hashStore.resetStats,
|
||||||
forgetHashCache,
|
forgetHashCache: hashStore.forget,
|
||||||
|
clearHashCache: hashStore.clear,
|
||||||
HASH_CONCURRENCY,
|
HASH_CONCURRENCY,
|
||||||
progressGate,
|
progressGate,
|
||||||
filesPath,
|
filesPath,
|
||||||
|
|
|
||||||
|
|
@ -414,11 +414,16 @@ function createPusher(api) {
|
||||||
if (!plan.data || !plan.data.ok) throw new Error((plan.data && plan.data.error) || `http-${plan.status}`)
|
if (!plan.data || !plan.data.ok) throw new Error((plan.data && plan.data.error) || `http-${plan.status}`)
|
||||||
const upload = new Set(plan.data.upload || [])
|
const upload = new Set(plan.data.upload || [])
|
||||||
const same = new Set(plan.data.same || [])
|
const same = new Set(plan.data.same || [])
|
||||||
// per category every manifest entry counts: created/overwritten vs
|
// per category the FILES count: created/overwritten vs already
|
||||||
// already identical (the plan reports both)
|
// identical (the plan reports both; dirs and links are re-ensured
|
||||||
bucket.copied += entries.length - same.size
|
// for free and are not files that moved)
|
||||||
bucket.skipped += same.size
|
const fileEntries = entries.filter(e => e.type === 'file')
|
||||||
summary.skipped += same.size
|
const filePaths = new Set(fileEntries.map(e => e.path))
|
||||||
|
let sameFiles = 0
|
||||||
|
for (const p of same) if (filePaths.has(p)) sameFiles++
|
||||||
|
bucket.copied += fileEntries.length - sameFiles
|
||||||
|
bucket.skipped += sameFiles
|
||||||
|
summary.skipped += sameFiles
|
||||||
const pending = []
|
const pending = []
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (entry.type !== 'file') continue
|
if (entry.type !== 'file') continue
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
const fs = require('fs')
|
const fs = require('fs')
|
||||||
const { DIRS, readJson, writeJson, toPortablePath, resolvePortablePath } = require('./paths')
|
const { DIRS, readJson, writeJson, toPortablePath, resolvePortablePath } = require('./paths')
|
||||||
const { prefixPath } = require('./prefixes')
|
const { prefixPath, isPrefixRel } = require('./prefixes')
|
||||||
|
|
||||||
const REGISTRY = path.join(DIRS.DATA, 'apps.json')
|
const REGISTRY = path.join(DIRS.DATA, 'apps.json')
|
||||||
const SETTINGS = path.join(DIRS.DATA, 'settings.json')
|
const SETTINGS = path.join(DIRS.DATA, 'settings.json')
|
||||||
|
|
@ -29,12 +29,6 @@ function writePlaySeconds(id, seconds) {
|
||||||
fs.writeFileSync(file, String(Math.max(0, Math.round(seconds))))
|
fs.writeFileSync(file, String(Math.max(0, Math.round(seconds))))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prefix imports keep exe/workdir paths relative to the PREFIX ROOT
|
|
||||||
// ("drive_c/..." for plain wine, "pfx/drive_c/..." for Proton-managed
|
|
||||||
// prefixes) so wine sees them under C:\... - everything else is
|
|
||||||
// FILES-relative/absolute.
|
|
||||||
const isPrefixRel = v => typeof v === 'string' && /^(pfx[/\\])?drive_c([/\\]|$)/i.test(v)
|
|
||||||
|
|
||||||
function loadGames() {
|
function loadGames() {
|
||||||
const games = readJson(REGISTRY, [])
|
const games = readJson(REGISTRY, [])
|
||||||
for (const g of games) {
|
for (const g of games) {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,12 @@ const { rewireSymlinks } = require('./symlinks')
|
||||||
const { removeTree, treeSize } = require('./fsutil')
|
const { removeTree, treeSize } = require('./fsutil')
|
||||||
const { normalizePathName } = require('./names')
|
const { normalizePathName } = require('./names')
|
||||||
|
|
||||||
|
// An app path stored relative to its PREFIX ROOT ("drive_c/..." for plain
|
||||||
|
// wine, "pfx/drive_c/..." for Proton-managed prefixes): wine resolves it
|
||||||
|
// under C:\, everything else is FILES-relative or absolute.
|
||||||
|
const PREFIX_REL_RE = /^(pfx[/\\])?drive_c([/\\]|$)/i
|
||||||
|
const isPrefixRel = v => typeof v === 'string' && PREFIX_REL_RE.test(v)
|
||||||
|
|
||||||
const REGISTRY = path.join(DIRS.DATA, 'prefixes.json')
|
const REGISTRY = path.join(DIRS.DATA, 'prefixes.json')
|
||||||
const MAIN = 'Main'
|
const MAIN = 'Main'
|
||||||
|
|
||||||
|
|
@ -198,4 +204,4 @@ function setDefaultPrefix(name) {
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { listPrefixes, savePrefixes, createPrefix, deletePrefix, renamePrefix, setDefaultPrefix, prefixPath, prefixExists, prefixDriveRoots, isPrefixRoot, healPrefix, ensureUnifiedPrefix, MAIN }
|
module.exports = { isPrefixRel, PREFIX_REL_RE, listPrefixes, savePrefixes, createPrefix, deletePrefix, renamePrefix, setDefaultPrefix, prefixPath, prefixExists, prefixDriveRoots, isPrefixRoot, healPrefix, ensureUnifiedPrefix, MAIN }
|
||||||
|
|
|
||||||
|
|
@ -3,33 +3,26 @@
|
||||||
const { contextBridge, ipcRenderer } = require('electron')
|
const { contextBridge, ipcRenderer } = require('electron')
|
||||||
const { DIRS } = require('./paths')
|
const { DIRS } = require('./paths')
|
||||||
|
|
||||||
// single-slot progress: every import modal used to register ANOTHER listener
|
// Single-slot event callbacks: every import modal used to register ANOTHER
|
||||||
// and old ones never unregistered, so the file counter sped up per import
|
// listener and old ones never unregistered, so the file counter sped up per
|
||||||
let importProgressCb = null
|
// import. Only the latest subscriber receives an event.
|
||||||
ipcRenderer.on('import:progress', (e, data) => {
|
function singleSlot(channel) {
|
||||||
if (importProgressCb) importProgressCb(data)
|
let slot = null
|
||||||
})
|
ipcRenderer.on(channel, (e, data) => {
|
||||||
|
if (slot) slot(data)
|
||||||
|
})
|
||||||
|
return fn => {
|
||||||
|
slot = fn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// "Open with PLWL": single-slot callback for files handed over via --open
|
const onImportProgress = singleSlot('import:progress')
|
||||||
// (Exec %f) - the renderer shows the wine/prefix picker popup for it.
|
// "Open with PLWL": files handed over via --open (Exec %f) - the renderer
|
||||||
let openWithCb = null
|
// shows the wine/prefix picker popup for them.
|
||||||
ipcRenderer.on('openwith:file', (e, openPath) => {
|
const onOpenWith = singleSlot('openwith:file')
|
||||||
if (openWithCb) openWithCb(openPath)
|
// one sync (and one pending consent request, enforced server-side) at a time
|
||||||
})
|
const onLanSyncProgress = singleSlot('lansync:progress')
|
||||||
|
const onLanSyncIncoming = singleSlot('lansync:incoming')
|
||||||
// LAN sync progress: same single-slot rule as the import counter, only one
|
|
||||||
// sync can run at a time anyway.
|
|
||||||
let lanSyncProgressCb = null
|
|
||||||
ipcRenderer.on('lansync:progress', (e, data) => {
|
|
||||||
if (lanSyncProgressCb) lanSyncProgressCb(data)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Incoming sync consent: the receiver's Accept/Decline popup. Single slot for
|
|
||||||
// the same reason - one pending request at a time (the server enforces it).
|
|
||||||
let lanSyncIncomingCb = null
|
|
||||||
ipcRenderer.on('lansync:incoming', (e, data) => {
|
|
||||||
if (lanSyncIncomingCb) lanSyncIncomingCb(data)
|
|
||||||
})
|
|
||||||
const fs = require('fs')
|
const fs = require('fs')
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
|
|
||||||
|
|
@ -54,9 +47,7 @@ contextBridge.exposeInMainWorld('plwl', {
|
||||||
openwithTake: () => ipcRenderer.invoke('openwith:take'),
|
openwithTake: () => ipcRenderer.invoke('openwith:take'),
|
||||||
openwithRun: payload => ipcRenderer.invoke('openwith:run', payload),
|
openwithRun: payload => ipcRenderer.invoke('openwith:run', payload),
|
||||||
openwithTexts: lang => ipcRenderer.invoke('openwith:texts', lang),
|
openwithTexts: lang => ipcRenderer.invoke('openwith:texts', lang),
|
||||||
onOpenWith: cb => {
|
onOpenWith: onOpenWith,
|
||||||
openWithCb = cb
|
|
||||||
},
|
|
||||||
basename: p => (p ? path.basename(p) : ''),
|
basename: p => (p ? path.basename(p) : ''),
|
||||||
fileExists: p => {
|
fileExists: p => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -91,24 +82,19 @@ contextBridge.exposeInMainWorld('plwl', {
|
||||||
dialogPickFile: opts => ipcRenderer.invoke('dialog:pickFile', opts),
|
dialogPickFile: opts => ipcRenderer.invoke('dialog:pickFile', opts),
|
||||||
shellOpenPath: p => ipcRenderer.invoke('shell:openPath', p),
|
shellOpenPath: p => ipcRenderer.invoke('shell:openPath', p),
|
||||||
shellShowItemInFolder: p => ipcRenderer.invoke('shell:showItemInFolder', p),
|
shellShowItemInFolder: p => ipcRenderer.invoke('shell:showItemInFolder', p),
|
||||||
onImportProgress: cb => {
|
onImportProgress: onImportProgress,
|
||||||
importProgressCb = cb
|
|
||||||
},
|
|
||||||
onGamesChanged: cb => ipcRenderer.on('games:changed', () => cb()),
|
onGamesChanged: cb => ipcRenderer.on('games:changed', () => cb()),
|
||||||
lansyncGet: () => ipcRenderer.invoke('lansync:get'),
|
lansyncGet: () => ipcRenderer.invoke('lansync:get'),
|
||||||
lansyncDevices: () => ipcRenderer.invoke('lansync:devices'),
|
lansyncDevices: () => ipcRenderer.invoke('lansync:devices'),
|
||||||
lansyncRerollDeviceId: () => ipcRenderer.invoke('lansync:rerollDeviceId'),
|
lansyncRerollDeviceId: () => ipcRenderer.invoke('lansync:rerollDeviceId'),
|
||||||
lansyncRerollSyncCode: () => ipcRenderer.invoke('lansync:rerollSyncCode'),
|
lansyncRerollSyncCode: () => ipcRenderer.invoke('lansync:rerollSyncCode'),
|
||||||
lansyncForget: id => ipcRenderer.invoke('lansync:forget', id),
|
lansyncForget: id => ipcRenderer.invoke('lansync:forget', id),
|
||||||
|
lansyncClearHashCache: () => ipcRenderer.invoke('lansync:clearHashCache'),
|
||||||
lansyncSync: payload => ipcRenderer.invoke('lansync:sync', payload),
|
lansyncSync: payload => ipcRenderer.invoke('lansync:sync', payload),
|
||||||
lansyncRespondIncoming: (requestId, accept) => ipcRenderer.invoke('lansync:respondIncoming', requestId, accept),
|
lansyncRespondIncoming: (requestId, accept) => ipcRenderer.invoke('lansync:respondIncoming', requestId, accept),
|
||||||
onLanSyncDevices: cb => ipcRenderer.on('lansync:devices', (e, data) => cb(data)),
|
onLanSyncDevices: cb => ipcRenderer.on('lansync:devices', (e, data) => cb(data)),
|
||||||
onLanSyncProgress: cb => {
|
onLanSyncProgress: onLanSyncProgress,
|
||||||
lanSyncProgressCb = cb
|
onLanSyncIncoming: onLanSyncIncoming,
|
||||||
},
|
|
||||||
onLanSyncIncoming: cb => {
|
|
||||||
lanSyncIncomingCb = cb
|
|
||||||
},
|
|
||||||
onLanSyncApplied: cb => ipcRenderer.on('lansync:applied', (e, data) => cb(data)),
|
onLanSyncApplied: cb => ipcRenderer.on('lansync:applied', (e, data) => cb(data)),
|
||||||
onLanSyncReceiving: cb => ipcRenderer.on('lansync:receiving', (e, data) => cb(data))
|
onLanSyncReceiving: cb => ipcRenderer.on('lansync:receiving', (e, data) => cb(data))
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -25,15 +25,7 @@
|
||||||
const fsp = require('fs').promises
|
const fsp = require('fs').promises
|
||||||
const fs = require('fs')
|
const fs = require('fs')
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
|
const { pathExists: exists } = require('./fsutil')
|
||||||
function exists(p) {
|
|
||||||
try {
|
|
||||||
fs.accessSync(p)
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// dosdevices/ holds the prefix's drive mappings (c:, z:, d:, com1 ...). They
|
// dosdevices/ holds the prefix's drive mappings (c:, z:, d:, com1 ...). They
|
||||||
// are *supposed* to point outside the prefix - at the filesystem root, a
|
// are *supposed* to point outside the prefix - at the filesystem root, a
|
||||||
|
|
|
||||||
24
src/wines.js
24
src/wines.js
|
|
@ -4,7 +4,7 @@ const fs = require('fs')
|
||||||
const fsp = fs.promises
|
const fsp = fs.promises
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
const { DIRS, readJson, writeJson, toPortablePath, resolvePortablePath } = require('./paths')
|
const { DIRS, readJson, writeJson, toPortablePath, resolvePortablePath } = require('./paths')
|
||||||
const { dedupeDir, copyTree, treeSize, removeTree } = require('./fsutil')
|
const { dedupeDir, copyTree, treeSize, removeTree, pathExists } = require('./fsutil')
|
||||||
const { normalizeLabel } = require('./names')
|
const { normalizeLabel } = require('./names')
|
||||||
const { rewireSymlinks, restoreExecutableBits, EXEC_NAMES } = require('./symlinks')
|
const { rewireSymlinks, restoreExecutableBits, EXEC_NAMES } = require('./symlinks')
|
||||||
|
|
||||||
|
|
@ -12,14 +12,7 @@ const WINES_DIR = DIRS.WINES
|
||||||
const REGISTRY = path.join(DIRS.DATA, 'wines.json')
|
const REGISTRY = path.join(DIRS.DATA, 'wines.json')
|
||||||
const DEFAULT_KEY = 'defaultWine'
|
const DEFAULT_KEY = 'defaultWine'
|
||||||
|
|
||||||
function exists(p) {
|
const exists = pathExists
|
||||||
try {
|
|
||||||
fs.accessSync(p)
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function detectType(dir) {
|
function detectType(dir) {
|
||||||
if (exists(path.join(dir, 'proton'))) return 'proton'
|
if (exists(path.join(dir, 'proton'))) return 'proton'
|
||||||
|
|
@ -343,4 +336,15 @@ async function importWineFolder(srcDir, onProgress) {
|
||||||
return { ok: true, path: toPortablePath(dst), symlinksFixed: healed.links, executablesFixed: healed.executables }
|
return { ok: true, path: toPortablePath(dst), symlinksFixed: healed.links, executablesFixed: healed.executables }
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { refreshWines, getWines, getWine, resolveWine, setWineDisabled, setWineDisplayName, clearWineDisplayName, getDefaultWine, setDefaultWine, deleteWine, importWineFolder, findWineBinary, detectType, fixBuildSymlinks, healBuild }
|
// The pseudo entry for the distro's wine, offered next to the imported
|
||||||
|
// builds; version probe is best-effort (a PATH without wine still lists it).
|
||||||
|
function systemWine() {
|
||||||
|
let version = ''
|
||||||
|
try {
|
||||||
|
const { execFileSync } = require('child_process')
|
||||||
|
version = String(execFileSync('wine', ['--version'], { timeout: 15000 })).trim().replace(/^wine-?/i, '')
|
||||||
|
} catch {}
|
||||||
|
return { id: 'system', type: 'system', label: 'System Wine', version }
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { refreshWines, getWines, getWine, resolveWine, systemWine, setWineDisabled, setWineDisplayName, clearWineDisplayName, getDefaultWine, setDefaultWine, deleteWine, importWineFolder, findWineBinary, detectType, fixBuildSymlinks, healBuild }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue