code refactor & sync bugfixes
This commit is contained in:
parent
2bcb47076a
commit
9edcc9841b
20 changed files with 820 additions and 812 deletions
228
development.md
228
development.md
|
|
@ -9,7 +9,7 @@ src/ main process (Node/Electron backend)
|
|||
main.js entry point: tray, single-instance handoff, --launch / --open
|
||||
argv.js command-line parsing shared by boot and second-instance handoff
|
||||
window.js main window creation
|
||||
ipc/ all ipcMain handlers
|
||||
ipc/ all ipcMain handlers
|
||||
index.js handler registration, icon protocol, dialogs
|
||||
games.js games service: run/stop state, wine resolution, open-with handoff
|
||||
lansync.js LAN sync handlers (identity, devices, re-roll, sync + progress)
|
||||
|
|
@ -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,183 +50,60 @@ 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/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-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
|
||||
npm start # click through the flow you touched
|
||||
node --check <changed js files> # syntax
|
||||
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
|
||||
node test/lansync-pending-links.test.js # links to files created later, startup heal
|
||||
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):
|
||||
PLWL_BUILD_DIR=/path/to/instance node test/heal-instance.js # links + exec bits
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue