code refactor & sync bugfixes

This commit is contained in:
olcxja 2026-10-05 13:43:49 +02:00
commit 9edcc9841b
20 changed files with 820 additions and 812 deletions

View file

@ -24,63 +24,47 @@ npm start
## 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
tar -xzf PLWL-<version>.tar.gz
cd PLWL
./install.sh # copies everything to ~/.local/bin/olcxja.plwl/,
# 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
./install.sh # install to ~/.local/bin/olcxja.plwl/ (icon + shortcut)
./createshortcut.sh # portable mode: icon + shortcut only
./olcxja.plwl/olcxja.plwl # or run without installing
```
## 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 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.
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.
## 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.
- **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** - 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.
- **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.
- **Devices on your network** are found over UDP and visible only while PLWL runs there (a few seconds after it closes they are gone).
- 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.
**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.
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 & Proton support
- **System Wine** — uses the `wine` binary 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.
- **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.)
- **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).
- **System Wine** — `wine` from PATH.
- **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** — 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** — 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
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.
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.
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.
## Development
Codebase overview, conventions and rules for contributors: [development.md](development.md).
Codebase overview and contributor rules: [development.md](development.md).

View file

@ -30,9 +30,14 @@ src/ main process (Node/Electron backend)
paths.js portable layout, FILES-relative path helpers, translation sync
i18n.js translation loading + {0} placeholder filling
preload.js contextBridge API exposed to the renderer
lansync.js LAN sync: identity + pairing, hashing, HTTP receiver (with its
receive-progress hook), UDP discovery
lansync.js LAN sync receiver: identity + pairing, hashing, HTTP server
(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)
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
renderer/ UI (plain HTML/CSS/JS, no framework)
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
```
Main process vs renderer: the 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.
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.
## Rules
- Keep it simple. Plain functions over classes, no abstractions "for later".
- Comments explain the WHY (quirks, workarounds, races), not the WHAT. If the
code says it, don't repeat it in words. No comments at all when nothing
needs explaining.
- No duplicate logic: the second time you copy something, move it into a
helper (a shared module in src/ or a function next to its users) and call
it from both places.
- Everything user-visible goes through the translation files (en-us.json,
pl.json) with `{0}`, `{1}` placeholders. No hardcoded strings in app.js.
- Paths stored on disk are FILES-relative (portable layout): write with
toPortablePath(), read with resolvePortablePath() - never store absolute
paths for anything inside files/.
- 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.
- 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.
- Windows apps run through runGame()/buildEnv() - never spawn wine directly
somewhere else (prefix handling, Proton env and playtime live there).
- A prefix or wine build that a running app uses cannot be renamed/deleted
(wine holds its registry and user files open in that folder): the guards sit
next to the handlers in src/ipc/, use the games service's running list plus
the helpers in library.js (prefixNameForGame/gamesUsingPrefix/is*InUse) and
answer { ok:false, error:'inUse'|'hasApps', apps:[names] } for the toast.
- Long operations stream progress through the single import:progress slot as
{ phase: 'count'|'copy'|'remove', files, bytes, totalFiles, totalBytes } and
the renderer drives a bar from it (runWithProgress for deletes/imports).
Emit the 'count' phase first (a treeSize walk) and copy/remove through the
fsutil helpers; never ship a progress bar no data can move.
- CSS: reuse the existing variables/classes (.button, .field-label,
.custom-select, form-card); spacing rhythm comes from --button-margin and
the flex gap of the container, not per-element magic numbers.
- LAN sync has two sides: the receiver lives in lansync.js, the sender in
lansyncpush.js and is built by createPusher(helpers) - several launcher
states share the module, so nothing may be kept in its module scope. Both
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.
- 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.
- No duplicate logic: the second time you copy something, move it into a helper and call it from both places.
- Everything user-visible goes through the translation files (en-us.json, pl.json) with `{0}`, `{1}` placeholders. No hardcoded strings in app.js.
- Paths stored on disk are FILES-relative (portable layout): write with toPortablePath(), read with resolvePortablePath() — never store absolute paths for anything inside files/.
- 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.
- 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.
- Windows apps run through runGame()/buildEnv() — never spawn wine directly elsewhere (prefix handling, Proton env and playtime live there).
- 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.
- 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.
- 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.
### LAN sync
- 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).
- 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.
- 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.
- 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).
- 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.
- 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 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).
- 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/).
- 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).
### Paths and prefixes
- 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.
- 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.
- 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.
### Launch and shortcuts
- 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).
- 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.
- A modal showing a running sync opens locked: showModal(html, { locked: true }) ignores backdrop and Escape.
- 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
```
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-receiving.test.js # receive-side progress events (the popup)
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-consent.test.js # incoming sync: accept / decline / timeout gate
node test/bench-sync.js # timing: phases of a cold and a cached sync
node test/lansync-consent.test.js # accept / decline / timeout gate
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
# Repairing / testing a real instance (test/ scripts are gitignored helpers):

View file

@ -175,6 +175,11 @@
"settings.saved": "Settings saved",
"settings.openFiles": "Open files folder",
"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.subtitle": "Push your apps, prefixes and Wine builds to another PLWL on the same network.",
"lansync.thisDevice": "This device",

View file

@ -175,6 +175,11 @@
"settings.saved": "Ustawienia zapisane",
"settings.openFiles": "Otwórz folder files",
"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.subtitle": "Wypchnij swoje aplikacje, prefiksy i buildy Wine na inny PLWL w tej samej sieci.",
"lansync.thisDevice": "To urządzenie",

View file

@ -1970,6 +1970,12 @@ function wireEvents() {
})
qs('#btn-settings-save').addEventListener('click', saveSettings)
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-save').addEventListener('click', saveGameConfig)
qs('#btn-gc-shortcut').addEventListener('click', createAppShortcut)

View file

@ -103,6 +103,13 @@
<button class="button" id="btn-open-files" data-t="settings.openFiles"></button>
</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 id="view-lansync" class="view">

View file

@ -4,6 +4,16 @@ const fs = require('fs')
const fsp = require('fs').promises
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)`...).
function dedupeDir(base, name) {
const candidate = path.join(base, name)
@ -50,6 +60,22 @@ function progressGate(emit) {
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
// (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
@ -218,5 +244,5 @@ async function removeTree(root, onProgress) {
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
View 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 }

View file

@ -68,18 +68,9 @@ function registerIpc({ getWindow, onGamesChanged }) {
ipcMain.handle('settings:set', (e, patch) => library.saveSettings(patch))
// 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 () => {
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:setDisabled', (e, id, disabled) => wines.setWineDisabled(id, disabled))

View file

@ -19,6 +19,10 @@ function registerLanSyncIpc({ getWindow }) {
lansync.forgetDevice(id)
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:sync', async (e, payload) => {
try {

135
src/lansync-discovery.js Normal file
View 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
View 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
View 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 }

View file

@ -7,7 +7,6 @@
// behind, and paths stored in the registries stay relative as they are.
const http = require('http')
const dgram = require('dgram')
const crypto = require('crypto')
const os = require('os')
const path = require('path')
@ -17,10 +16,15 @@ const readline = require('readline')
const { DIRS, readJson, writeJson, toPortablePath } = require('./paths')
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 wines = require('./wines')
const prefixes = require('./prefixes')
const { isPrefixRel } = prefixes
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
// pointless (and a failed guess cannot be retried in a tight loop).
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
// 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
@ -100,7 +106,6 @@ function appCategory(game) {
// inside its prefix ("drive_c/..." for plain wine, "pfx/drive_c/..." for
// Proton-managed prefixes) and anything absolute or ".." points outside the
// launcher data, so it is never part of a sync.
const PREFIX_REL_RE = /^(pfx[/\\])?drive_c([/\\]|$)/i
function classifyApp(app) {
if (!app || (typeof app !== 'string' && typeof app !== 'object')) return null
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'
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)
if (PREFIX_REL_RE.test(value)) return 'prefix'
if (isPrefixRel(value)) return 'prefix'
if (/^apps\//.test(value)) return 'portable'
return outside ? 'external' : null
}
@ -125,248 +130,21 @@ function normalizeCategories(list) {
// --- identity ---------------------------------------------------------------
// 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)
}
// 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)
}
const identityApi = createIdentity(CONFIG)
const { identity, rotateDeviceId, rotateSyncCode, knownDevice, knownPeers, rememberDevice, forgetDevice, makeDeviceId, makeSyncCode } = identityApi
// --- 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
// digest itself: a few in flight at once keeps the disk and the cpu busy
// instead of waiting on one file at a time.
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
// not change since the last sync is never read again - only stat'ed.
const HASH_CACHE_FILE = path.join(DIRS.DATA, 'lansync-hashes.json')
const HASH_CACHE_MAX = 250000
let hashCache = null
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()
})
}
// Digests are remembered per file (size + mtime), so a library that did not
// change since the last sync is never read again - only stat'ed. One store per
// launcher state (tests re-require this module per device).
const hashStore = createHashCache(DIRS.DATA)
const hashFile = hashStore.hashFile
// Protocol paths are FILES-relative with '/' separators. Anything escaping
// 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
}
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
}
// --- manifests (the sender's hashing) ---------------------------------------
// 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(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
}
const manifest = createManifestBuilder({ filesPath, filesRoot: DIRS.FILES, hash: hashStore, mapLimit, concurrency: HASH_CONCURRENCY })
const buildManifest = manifest.buildManifest
// --- HTTP helpers (shared with the pusher) ----------------------------------
@ -699,7 +324,7 @@ async function entrySame(entry) {
if (!st.isFile() || st.size !== entry.size) return false
let same = false
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 {
return false
}
@ -1194,6 +819,13 @@ function authorize(state, token) {
return null
}
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
return session
}
@ -1371,7 +1003,7 @@ async function handlePlan(state, session, url, req, res, hooks) {
announce({ unit: label })
return same === true
})
saveHashCache()
hashStore.save()
const paths = new Set()
const upload = []
const same = []
@ -1533,128 +1165,22 @@ function handlePing(session, res, hooks) {
// --- discovery --------------------------------------------------------------
let beaconSocket = null
let beaconTimer = null
let httpPort = 0
let seen = new Map()
let devicesCb = null
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('.')
}
// 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 {}
}
}
const discovery = createDiscovery({
identity,
tag: BEACON_TAG,
protocol: PROTOCOL,
port: DISCOVERY_PORT,
beaconMs: BEACON_MS,
deviceTtlMs: DEVICE_TTL_MS,
getPort: () => httpPort
})
const startDiscovery = discovery.start
const stopDiscovery = discovery.stop
const listDevices = discovery.listDevices
// --- service ----------------------------------------------------------------
let httpPort = 0
let httpServer = null
let serverHooks = {}
let listening = null // promise resolved once the receiving server is up
@ -1686,6 +1212,9 @@ function startServer(options = {}) {
}
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) {
try {
httpServer.close()
@ -1705,7 +1234,7 @@ function stopServer() {
function start(hooks = {}) {
serverHooks = { ...serverHooks, ...hooks }
return startServer().then(info => {
if (!beaconSocket) startDiscovery(serverHooks.onDevices)
startDiscovery(serverHooks.onDevices)
return info
})
}
@ -1721,7 +1250,7 @@ function serverPort() {
function getState() {
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
@ -1810,11 +1339,12 @@ const exported = {
forgetDevice,
respondIncoming,
sameCode,
HASH_ALGO,
HASH_ALGO: hashStore.HASH_ALGO,
hashFile,
hashCacheStats,
resetHashCacheStats,
forgetHashCache,
hashCacheStats: hashStore.stats,
resetHashCacheStats: hashStore.resetStats,
forgetHashCache: hashStore.forget,
clearHashCache: hashStore.clear,
HASH_CONCURRENCY,
progressGate,
filesPath,

View file

@ -414,11 +414,16 @@ function createPusher(api) {
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 same = new Set(plan.data.same || [])
// per category every manifest entry counts: created/overwritten vs
// already identical (the plan reports both)
bucket.copied += entries.length - same.size
bucket.skipped += same.size
summary.skipped += same.size
// per category the FILES count: created/overwritten vs already
// identical (the plan reports both; dirs and links are re-ensured
// for free and are not files that moved)
const fileEntries = entries.filter(e => e.type === 'file')
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 = []
for (const entry of entries) {
if (entry.type !== 'file') continue

View file

@ -3,7 +3,7 @@
const path = require('path')
const fs = require('fs')
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 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))))
}
// 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() {
const games = readJson(REGISTRY, [])
for (const g of games) {

View file

@ -7,6 +7,12 @@ const { rewireSymlinks } = require('./symlinks')
const { removeTree, treeSize } = require('./fsutil')
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 MAIN = 'Main'
@ -198,4 +204,4 @@ function setDefaultPrefix(name) {
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 }

View file

@ -3,33 +3,26 @@
const { contextBridge, ipcRenderer } = require('electron')
const { DIRS } = require('./paths')
// single-slot progress: every import modal used to register ANOTHER listener
// and old ones never unregistered, so the file counter sped up per import
let importProgressCb = null
ipcRenderer.on('import:progress', (e, data) => {
if (importProgressCb) importProgressCb(data)
})
// Single-slot event callbacks: every import modal used to register ANOTHER
// listener and old ones never unregistered, so the file counter sped up per
// import. Only the latest subscriber receives an event.
function singleSlot(channel) {
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
// (Exec %f) - the renderer shows the wine/prefix picker popup for it.
let openWithCb = null
ipcRenderer.on('openwith:file', (e, openPath) => {
if (openWithCb) openWithCb(openPath)
})
// 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 onImportProgress = singleSlot('import:progress')
// "Open with PLWL": files handed over via --open (Exec %f) - the renderer
// shows the wine/prefix picker popup for them.
const onOpenWith = singleSlot('openwith:file')
// one sync (and one pending consent request, enforced server-side) at a time
const onLanSyncProgress = singleSlot('lansync:progress')
const onLanSyncIncoming = singleSlot('lansync:incoming')
const fs = require('fs')
const path = require('path')
@ -54,9 +47,7 @@ contextBridge.exposeInMainWorld('plwl', {
openwithTake: () => ipcRenderer.invoke('openwith:take'),
openwithRun: payload => ipcRenderer.invoke('openwith:run', payload),
openwithTexts: lang => ipcRenderer.invoke('openwith:texts', lang),
onOpenWith: cb => {
openWithCb = cb
},
onOpenWith: onOpenWith,
basename: p => (p ? path.basename(p) : ''),
fileExists: p => {
try {
@ -91,24 +82,19 @@ contextBridge.exposeInMainWorld('plwl', {
dialogPickFile: opts => ipcRenderer.invoke('dialog:pickFile', opts),
shellOpenPath: p => ipcRenderer.invoke('shell:openPath', p),
shellShowItemInFolder: p => ipcRenderer.invoke('shell:showItemInFolder', p),
onImportProgress: cb => {
importProgressCb = cb
},
onImportProgress: onImportProgress,
onGamesChanged: cb => ipcRenderer.on('games:changed', () => cb()),
lansyncGet: () => ipcRenderer.invoke('lansync:get'),
lansyncDevices: () => ipcRenderer.invoke('lansync:devices'),
lansyncRerollDeviceId: () => ipcRenderer.invoke('lansync:rerollDeviceId'),
lansyncRerollSyncCode: () => ipcRenderer.invoke('lansync:rerollSyncCode'),
lansyncForget: id => ipcRenderer.invoke('lansync:forget', id),
lansyncClearHashCache: () => ipcRenderer.invoke('lansync:clearHashCache'),
lansyncSync: payload => ipcRenderer.invoke('lansync:sync', payload),
lansyncRespondIncoming: (requestId, accept) => ipcRenderer.invoke('lansync:respondIncoming', requestId, accept),
onLanSyncDevices: cb => ipcRenderer.on('lansync:devices', (e, data) => cb(data)),
onLanSyncProgress: cb => {
lanSyncProgressCb = cb
},
onLanSyncIncoming: cb => {
lanSyncIncomingCb = cb
},
onLanSyncProgress: onLanSyncProgress,
onLanSyncIncoming: onLanSyncIncoming,
onLanSyncApplied: cb => ipcRenderer.on('lansync:applied', (e, data) => cb(data)),
onLanSyncReceiving: cb => ipcRenderer.on('lansync:receiving', (e, data) => cb(data))
})

View file

@ -25,15 +25,7 @@
const fsp = require('fs').promises
const fs = require('fs')
const path = require('path')
function exists(p) {
try {
fs.accessSync(p)
return true
} catch {
return false
}
}
const { pathExists: exists } = require('./fsutil')
// dosdevices/ holds the prefix's drive mappings (c:, z:, d:, com1 ...). They
// are *supposed* to point outside the prefix - at the filesystem root, a

View file

@ -4,7 +4,7 @@ const fs = require('fs')
const fsp = fs.promises
const path = require('path')
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 { rewireSymlinks, restoreExecutableBits, EXEC_NAMES } = require('./symlinks')
@ -12,14 +12,7 @@ const WINES_DIR = DIRS.WINES
const REGISTRY = path.join(DIRS.DATA, 'wines.json')
const DEFAULT_KEY = 'defaultWine'
function exists(p) {
try {
fs.accessSync(p)
return true
} catch {
return false
}
}
const exists = pathExists
function detectType(dir) {
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 }
}
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 }