Compare commits

..

2 commits

Author SHA1 Message Date
aee4d3d1f2 [TEST] selective sync 2026-10-19 11:02:52 +02:00
be4f67fb60 cleanup & sync summary 2026-10-19 09:26:02 +02:00
20 changed files with 1817 additions and 341 deletions

View file

@ -54,7 +54,7 @@ The redirected home starts with a **copy of your desktop preferences** — `kdeg
Every prefix in the **Wine prefixes** tab has a **Reset Linux config** button: it deletes the desktop preferences PLWL copied in (and the hashes that track them), so the next app start seeds a clean copy from your system — the way back when an app has hijacked a seeded file with its own theme. App data in `linux_pfx` is never touched, and an app running from that prefix blocks the reset.
Imported trees get **normalised permissions**: every file gets the owner's `rwx` and an `x` mirrored from its `r`, because sources come from archives, FAT sticks or read-only backups where those bits are simply not there.
Imported trees get **normalised permissions**: every file gets the owner's `rwx` and an `x` mirrored from its `r`, because sources come from archives, FAT sticks or read-only backups where those bits are simply not there. An import whose copy could not reproduce every file is **refused** and the partial copy is removed, so a half-copied app is never registered.
Some apps draw their window icon themselves, which Wayland does not allow — Minecraft (LWJGL 3.4+) is the known case: its taskbar icon turns into the generic Wayland one. The per-app **Force X11 (XWayland)** switch moves the app *and the programs it launches* back to X11, where the icon works again.
@ -77,11 +77,17 @@ Two PLWL installs on one LAN can mirror each other (sidebar → **LAN syncing**)
- **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 what to push: **portable apps**, **prefix-imported apps**, **Wine/Proton builds**, **prefixes**, **Sync settings**. Each category also has a **Select** list: everything starts ticked, and unchecking an app, a build or a prefix keeps that item on both machines untouched. 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.
A prefix-imported app's own folder lives inside its prefix, not in `files/apps/`. Unchecking such an app leaves that folder alone even when the prefix itself is synced: the sender takes it out of the prefix unit and marks it as off-limits for deletion. The picker says so when the app is ticked but its prefix is not — the folder then stays behind with the prefix.
**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).
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 **sender is the source of truth for what it sends**: differing files are overwritten, missing ones written, and extras inside the synced trees (`files/apps/`, `files/wines/`, `files/wineprefixes/`, `files/data/icons/`) are removed — but only after the receiver says so. Nothing is deleted while the bytes move: each unit only *plans* its leftovers, and the receiver gets one popup at the end with the top-most folders that would go (a fully redundant folder is a single collapsed row, a partly redundant one can be expanded). Every row is ticked to delete; unchecking a folder or a file keeps it. **Keep everything** is always one click away, an unanswered or expired list removes nothing, and a tree the sender could not fully read is never pruned at all, because a missing entry may only mean it was unreadable.
That review is the only deletion path, so no backup copy is ever made — the files simply stay on disk until the answer. Two things it cannot undo: files that exist on both sides with different content are **overwritten** with the sender's version (the popup says so; if a game is installed on both computers, leave its prefix out of the sync), and a file kept in one sync is proposed again the next time, since the mirror is recomputed from scratch every run.
Only one sync runs at a time (a second attempt on the same machine answers *busy*), and a transfer cut off mid-file leaves no half-written file behind. 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. A peer that predates the review cannot sync units at all (it would prune without asking); update both machines.
A sync also carries **how apps are set up**: wine build and prefix, args, env, DLL overrides, a native Linux app's isolation level and Force X11, 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.

View file

@ -44,6 +44,8 @@ src/ main process (Node/Electron backend)
(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)
lansync-plan.js planned (never immediate) deletions: the receiver's leftovers,
top-most folders, and execution of only what was approved
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)
@ -70,7 +72,9 @@ Renderer never touches Node/Electron directly — everything goes through the `w
- 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.
- 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. A settings sync batches its patches instead of one write per app/build (`library.updateGames`, `wines.applySettings`).
- Tree walks (`copyTree`/`treeSize`/`removeTree`/`chmodTree`) keep TREE_CONCURRENCY operations in flight instead of one syscall per await. `copyTree` returns a `failed` count; an import that could not reproduce every entry is refused and its partial destination removed, never registered with holes.
- The app's own folder is `library.appFolder(game)` — the folder it was imported as, never `workdir` and never `dirname(exePath)`. The properties may point the working directory elsewhere, and the sandbox, the delete path and LAN sync all have to agree on the same tree.
- 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).
- Native Linux apps run through linuxrunner.runLinuxGame()/buildLinuxEnv() the same way: HOME and the XDG folders are redirected into `{prefix}/linux_pfx/`, and bwrap (when the probe says it works) forces the same layout with mounts. Never spawn a native app elsewhere. `game.kind` is absent on old entries — that means Windows; `game.isolation` is one of four levels, resolved by linuxrunner.resolveLevel: `none` (plain run), `relaxed` (bwrap + the whole host filesystem mounted writable at sandbox.FS_ROOT) and `isolated` (bwrap with the stock read-only profile), with `auto` (default) meaning relaxed where bwrap works and none where it does not. Only `auto` may weaken itself — an explicit relaxed/isolated answers `sandbox-required` instead ("no silent downgrade"). library.js keeps exactly these four values, both when reading the registry and when a patch is saved (anything else falls back to `auto`), and the level always lands in the log and the UI. resolveLevel awaits the probe when nothing has measured yet: a shortcut boots HEADLESS, so no renderer ran `sandbox:status` — without that wait `auto` would read an empty cache and quietly pick `none` (and an explicit level would refuse a machine that can sandbox perfectly well).
@ -104,14 +108,21 @@ Renderer never touches Node/Electron directly — everything goes through the `w
### 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).
- **Deletions are planned, never inline.** `handleCommit` only walks the unit (`planUnitDeletions` -> `lansync-plan.js`) and stores the list on the session; `/plwl/apply` turns units plus registry leftovers into one review (`startReview`), and `executeReview` is the only place that removes anything. A path in the keep set survives (including a file inside a fully planned folder), an unreadable subtree is planned as kept, and a selection's excluded folders are protected the same way. Registry removals wait with the files, so unchecking an app row cannot leave its entry behind without its folder or the other way round. A review that times out, is skipped (no popup, headless) or never arrives cancels to **zero deletions**. A peer without the `review` flag in its verify answer cannot sync units (`old-peer`): it would prune inside its commit with no list to approve; a settings-only sync stays allowed because it deletes nothing.
- 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. The gate holds a snapshot FUNCTION on the sender side: the snapshot is built when it flushes, so a throttled update never shows a byte count one gate window old.
- A finished sync ends with a result POPUP, not a toast: the sender swaps its locked progress modal for the summary, the receiver swaps the finished bar after ~1 s. It lists units per category, files moved/unchanged/removed, bytes transferred and the total size of the synced trees (`summary.sizeBytes`; the receiver adds each unit's announced `unitBytes`, replacing a retried unit's entry instead of double-counting). The device name is set with textContent, never interpolated into the modal HTML.
- App selection is a seam, not a UI yet: `buildUnits(cats, selection)` and `buildRegistryPayload(cats, selection)` take `selection = { apps: [ids] }` (a missing selection means every app). The payload then carries `appScope: 'selected'`; `applyApps` keeps the apps of that category that were not sent, and skips the `apps/` orphan sweep, instead of mirroring the category. Without the marker the sender's list is complete and extras are deleted as before.
- The sender's transferred counter counts bytes AS THEY ARE READ (putFile's onBytes callback), not on upload completion: a multi-GB file has to move it while it flies or the sender shows hundreds of MB behind the receiver. A unit's byte total travels as the `{ unitBytes }` control record BEFORE its files; the receiver has to handle it before the entry filter (it has no path/type, so a filter-first order dropped it and the comparing bar measured against a placeholder total of 1 — read as 100% for the whole phase). The comparing phase ends on a full bar, then the copy bar restarts empty (its first PUT byte emits it).
- While the receiver answers the consent popup (and whenever its side waits on the other one), both popups show "Waiting for the other device…" (lansync.waiting): the sender emits a 'wait' phase before verify, the receiver's ping handler emits stage 'wait'. A unit counts toward "Apps: 1/5" as soon as its comparison starts (not when it commits), and phases without a file count (hash/compare/commit) clear the counter so a stale "3 / 10 files" never lingers. On the wire the receiver's comparing phase is called 'compare' (sender-side 'plan' is unchanged).
- Registry-level settings are opt-in (the "settings" category). The payload carries defaults, wineSettings (displayName + disabled per build), prefixSettings, appSettings (name, wine, prefix, args, env, dllOverrides, disableUmu, isolation and forceX11 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).
- Registry-level settings are opt-in (the "settings" category). The payload carries defaults, wineSettings (displayName + disabled per build), prefixSettings, appSettings (name, wine, prefix, args, env, dllOverrides, disableUmu, isolation and forceX11 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 — batched, so a large library is one registry write: `library.updateGames(patches)`, `wines.applySettings(entries)`, `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.
- A file's unit and path travel in the PUT's query, not in headers: Node rejects any header value holding a code above U+00FF (`ERR_INVALID_CHAR`), and a real library has those names (Minecraft pack names, Polish file names) — one such file killed the whole sync, leaving the receiver with half an app and no registry entry. Categories are matched by exact name; anything this build does not know is ignored.
- 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 manifest the sender could not finish is not a licence to delete: a read that fails (a directory or file the user cannot open) sends a `{ unitIncomplete: true }` control record, and an aborted plan body is detected with `req.complete`. Either way the receiver still writes what it got but skips `pruneTree` for that unit — a missing entry may only mean "could not read it". For the same reason `applyApps`/`applyWines`/`applyPrefixes` mirror nothing when the payload has no list at all (malformed/truncated apply), while an explicit empty list still means "the sender has none" (a partial app selection excepted: it keeps the apps it did not send).
- A PUT settles on every failure path (`aborted`/`close` without the body having ended, stream or writer error): the `.plwl-sync-tmp` is removed and the request cannot stay half-open forever. The 60 s `PUT_IDLE_MS` only covers a body that stops sending.
- Verification is GLOBAL one-at-a-time (`state.verifying` is a boolean, set before the first await — parallel requests would pay the 2 s cost in parallel, which is exactly what makes a scheme pointless). One outgoing sync per process (`syncTo` answers 'busy'), so two syncs can never write the same receiver files at once. A failed bind resolves `{ ok: false, error }` instead of leaving `listening` pending forever, and the beacon only starts when the server is up.
- Names arriving on the wire that become paths (app ids in `data/icons/<id>`, prefix folder names) are validated (`safeName`/`safePrefixName`): a corrupted or older peer must not be able to store `../../x` and turn a later Rename/Delete click into a write outside files/.
- 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' shows "Waiting for the other device…" but never while a file is being written). 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).
- Native Linux app data (`{prefix}/linux_pfx/`: the redirected home, configs, caches, PulseAudio cookie, state) travels INSIDE its prefix unit like the Windows side of the same prefix: the manifest walks it and the mirror prunes it, so no file of the launcher's own trees is special-cased on the wire. Native apps sit in the same categories as Windows ones (`apps-portable`/`apps-prefix`), and only the per-app fields that are Linux-only (isolation, forceX11) are named as such in the settings payload.
- 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/).

View file

@ -66,6 +66,7 @@
"import.noExe": "no .exe file found in the selected folder",
"import.noLinuxExe": "no runnable Linux program found in the selected folder (ELF binary, .AppImage or .desktop)",
"import.prefixRoot": "this folder is a wine prefix (or its C: drive) - import an app from inside its drive_c instead",
"import.copyIncomplete": "some files could not be copied - nothing was imported, the source folder is unchanged",
"config.title": "App configuration",
"config.icon.auto": "Extract from exe",
"config.icon.autoLinux": "Extract icon",
@ -254,8 +255,8 @@
"lansync.prefixes": "Wine prefixes",
"lansync.settings": "Sync settings (names, app configs and order)",
"lansync.force": "Force sync all files (skip the hash check and overwrite everything)",
"lansync.mirrorWarning": "This device is the source of truth: on the other device, files and items that do not exist here will be deleted.",
"lansync.incomingMirror": "The other device is the source of truth: files and items on this device that do not exist there will be deleted.",
"lansync.mirrorWarning": "The selected items will match this device exactly. Nothing outside the selection is deleted, and the deletion list is approved on the other device.",
"lansync.incomingMirror": "The other device is the source of truth for the selected items. You will review the list of files to delete before the sync finishes.",
"lansync.start": "Start sync",
"lansync.progress": "Syncing to {0}…",
"lansync.waiting": "Waiting for the other device…",
@ -266,7 +267,8 @@
"lansync.phase.copy": "Sending {0}",
"lansync.recvPhase.copy": "Receiving {0}",
"lansync.phase.registry": "Updating the library",
"lansync.phase.commit": "Removing extra files",
"lansync.phase.commit": "Checking for extra files",
"lansync.phase.review": "Waiting for the deletion approval",
"lansync.phase.done": "Finishing up",
"lansync.progressFiles": "{0} / {1} files",
"lansync.progressBytes": "{0} / {1} transferred",
@ -287,6 +289,33 @@
"lansync.unknownDevice": "Unknown device",
"lansync.recvIdle": "The other device stopped responding, the sync was interrupted.",
"lansync.done": "Synced to {0}: {1} sent ({4}), {2} already up to date, {3} removed",
"lansync.summaryTitle": "Sync finished",
"lansync.summarySentTo": "Sent to {0}",
"lansync.summaryReceivedFrom": "Received from {0}",
"lansync.summaryApps": "Apps",
"lansync.summarySettings": "Settings",
"lansync.summarySent": "Files sent",
"lansync.summaryReceived": "Files received",
"lansync.summarySkipped": "Already up to date",
"lansync.summaryDeleted": "Removed",
"lansync.summaryTransferred": "Transferred",
"lansync.summarySize": "Synced data size",
"lansync.summaryKept": "Kept by the other device",
"lansync.pick": "Select",
"lansync.pickCount": "Select ({0}/{1})",
"lansync.pickSearch": "Search",
"lansync.pickAll": "Select all",
"lansync.pickNone": "Select none",
"lansync.prefixLeftBehind": "Prefix {0} is not selected — this app's folder will not travel.",
"lansync.reviewTitle": "Review deletions",
"lansync.reviewFrom": "Received from {0}. Review the list before anything is removed.",
"lansync.reviewText": "To delete: {0} files ({1}). Uncheck what should stay.",
"lansync.reviewOverwrite": "Files that exist on both sides are overwritten with the other device's version. If you play on both computers, leave this prefix out of the sync.",
"lansync.reviewKeepAll": "Keep everything",
"lansync.reviewDelete": "Delete {0} files ({1})",
"lansync.reviewDeleting": "Deleting…",
"lansync.reviewFiles": "{0} files · {1}",
"lansync.summaryYes": "yes",
"lansync.failed": "Sync failed: {0}",
"lansync.badCode": "Wrong sync code. Enter the code shown on the other device.",
"lansync.busy": "The other device is busy (another sync or a running app).",
@ -296,6 +325,8 @@
"lansync.idRerolled": "New device ID generated",
"lansync.codeRerolled": "New sync code generated",
"lansync.unknownError": "unknown error",
"lansync.oldPeer": "The other device runs an older PLWL. Update it — without the deletion review this sync is not safe.",
"lansync.reviewExpired": "The deletion review expired and nothing was removed.",
"openwith.title": "Open with Wine",
"openwith.file": "File",
"openwith.fileMissing": "This file no longer exists (or is not a Windows executable).",

View file

@ -66,6 +66,7 @@
"import.noExe": "nie znaleziono pliku .exe w wybranym folderze",
"import.noLinuxExe": "nie znaleziono uruchamialnego programu linuksowego w wybranym folderze (binarka ELF, .AppImage lub .desktop)",
"import.prefixRoot": "ten folder to prefiks wine (lub jego dysk C:) - zaimportuj aplikację z wnętrza jego drive_c",
"import.copyIncomplete": "nie udało się skopiować niektórych plików - nic nie zaimportowano, folder źródłowy pozostał bez zmian",
"config.title": "Konfiguracja aplikacji",
"config.icon.auto": "Wyciągnij z exe",
"config.icon.autoLinux": "Wyciągnij ikonę",
@ -254,8 +255,8 @@
"lansync.prefixes": "Prefiksy Wine",
"lansync.settings": "Ustawienia (nazwy, konfiguracje i kolejność aplikacji)",
"lansync.force": "Wymuś synchronizację wszystkich plików (pomiń sprawdzanie hashy i nadpisz wszystko)",
"lansync.mirrorWarning": "To urządzenie jest źródłem prawdy: na drugim urządzeniu pliki i elementy, których tu nie ma, zostaną usunięte.",
"lansync.incomingMirror": "Drugie urządzenie jest źródłem prawdy: pliki i elementy na tym urządzeniu, których tam nie ma, zostaną usunięte.",
"lansync.mirrorWarning": "Zaznaczone elementy będą na drugim urządzeniu dokładnie takie jak tutaj. Niczego poza wyborem nie usuwamy, a listę usunięć zatwierdzisz na drugim urządzeniu.",
"lansync.incomingMirror": "Drugie urządzenie jest źródłem prawdy dla zaznaczonych elementów. Listę plików do usunięcia zatwierdzisz przed końcem synchronizacji.",
"lansync.start": "Rozpocznij synchronizację",
"lansync.progress": "Synchronizowanie na {0}…",
"lansync.waiting": "Oczekiwanie na drugie urządzenie…",
@ -266,7 +267,8 @@
"lansync.phase.copy": "Wysyłanie {0}",
"lansync.recvPhase.copy": "Odbieranie {0}",
"lansync.phase.registry": "Aktualizowanie biblioteki",
"lansync.phase.commit": "Usuwanie zbędnych plików",
"lansync.phase.commit": "Sprawdzanie zbędnych plików",
"lansync.phase.review": "Czekanie na potwierdzenie usunięć",
"lansync.phase.done": "Kończenie",
"lansync.progressFiles": "{0} / {1} plików",
"lansync.progressBytes": "{0} / {1} przesłane",
@ -287,6 +289,33 @@
"lansync.unknownDevice": "Nieznane urządzenie",
"lansync.recvIdle": "Drugie urządzenie przestało odpowiadać, synchronizacja została przerwana.",
"lansync.done": "Zsynchronizowano na {0}: wysłano {1} ({4}), aktualnych {2}, usunięto {3}",
"lansync.summaryTitle": "Synchronizacja zakończona",
"lansync.summarySentTo": "Wysłano do {0}",
"lansync.summaryReceivedFrom": "Odebrano od {0}",
"lansync.summaryApps": "Aplikacje",
"lansync.summarySettings": "Ustawienia",
"lansync.summarySent": "Wysłane pliki",
"lansync.summaryReceived": "Odebrane pliki",
"lansync.summarySkipped": "Niezmienione pliki",
"lansync.summaryDeleted": "Usunięte",
"lansync.summaryTransferred": "Przesłano",
"lansync.summarySize": "Rozmiar zsynchronizowanych danych",
"lansync.summaryKept": "Zachowane przez drugie urządzenie",
"lansync.pick": "Wybierz",
"lansync.pickCount": "Wybierz ({0}/{1})",
"lansync.pickSearch": "Szukaj",
"lansync.pickAll": "Zaznacz wszystko",
"lansync.pickNone": "Odznacz wszystko",
"lansync.prefixLeftBehind": "Prefiks {0} nie jest zaznaczony — folder tej aplikacji nie zostanie przesłany.",
"lansync.reviewTitle": "Przegląd usunięć",
"lansync.reviewFrom": "Odebrano od {0}. Przed usunięciem przejrzyj listę.",
"lansync.reviewText": "Do usunięcia: {0} plików ({1}). Odznacz to, co ma zostać.",
"lansync.reviewOverwrite": "Pliki istniejące po obu stronach zostaną nadpisane wersją z drugiego urządzenia. Jeśli grasz na obu komputerach, wyłącz ten prefiks z synchronizacji.",
"lansync.reviewKeepAll": "Zachowaj wszystko",
"lansync.reviewDelete": "Usuń {0} plików ({1})",
"lansync.reviewDeleting": "Usuwanie…",
"lansync.reviewFiles": "{0} plików · {1}",
"lansync.summaryYes": "tak",
"lansync.failed": "Synchronizacja nie powiodła się: {0}",
"lansync.badCode": "Błędny kod synchronizacji. Wpisz kod pokazany na drugim urządzeniu.",
"lansync.busy": "Drugie urządzenie jest zajęte (inna synchronizacja lub uruchomiona aplikacja).",
@ -296,6 +325,8 @@
"lansync.idRerolled": "Wygenerowano nowe ID urządzenia",
"lansync.codeRerolled": "Wygenerowano nowy kod synchronizacji",
"lansync.unknownError": "nieznany błąd",
"lansync.oldPeer": "Drugie urządzenie ma starszą wersję PLWL. Zaktualizuj je — bez przeglądu usunięć ta synchronizacja nie jest bezpieczna.",
"lansync.reviewExpired": "Przegląd usunięć wygasł i nic nie zostało usunięte.",
"openwith.title": "Otwórz przez Wine",
"openwith.file": "Plik",
"openwith.fileMissing": "Ten plik już nie istnieje (albo nie jest plikiem wykonywalnym Windows).",

View file

@ -138,6 +138,8 @@ function settleModal(value) {
function showModal(html, opts) {
modalLocked = !!(opts && opts.locked)
// a review list needs the room a narrow form does not
qs('#modal').classList.toggle('wide', !!(opts && opts.wide))
settleModal(null)
if (modalTeardownTimer) {
clearTimeout(modalTeardownTimer)
@ -206,7 +208,7 @@ function closeModal() {
modalSwapTimer = null
}
overlay.style.display = 'none'
modal.classList.remove('closing', 'swapping', 'resizing')
modal.classList.remove('closing', 'swapping', 'resizing', 'wide')
modal.style.height = ''
modal.innerHTML = ''
modalLocked = false
@ -656,7 +658,8 @@ function renderLibrary() {
applyRunning(false)
}
card.querySelector('[data-act=config]').onclick = () => openGameConfig(game.id)
card.querySelector('[data-act=folder]').onclick = () => window.plwl.shellOpenPath(game.workdir)
// the app's own folder, not its working directory
card.querySelector('[data-act=folder]').onclick = () => window.plwl.shellOpenPath(game.path || game.workdir)
card.querySelector('[data-act=remove]').onclick = async () => {
const note = game.mode === 'copy' || game.mode === 'move'
? t('library.removeFilesNote')
@ -818,6 +821,17 @@ async function startPrefixImportFlow() {
}
}
// One place for the technical error tokens the import handlers answer with.
function importErrorText(raw) {
if (raw === 'no-exe-found') return t('import.noExe')
if (raw === 'no-linux-exe') return t('import.noLinuxExe')
if (raw === 'prefix-root') return t('import.prefixRoot')
if (raw === 'no-drive-c') return t('import.prefix.noDriveC')
if (raw === 'outside-drive-c') return t('import.prefix.outside')
if (raw === 'copy-incomplete') return t('import.copyIncomplete')
return raw || ''
}
async function doImport(srcDir, mode, kind) {
const name = srcDir.split('/').filter(Boolean).pop()
const modal = showModal(`
@ -842,13 +856,7 @@ async function doImport(srcDir, mode, kind) {
const game = await window.plwl.gamesImport(srcDir, mode, kind)
closeModal()
if (!game || game.ok === false) {
const raw = (game && game.error) || ''
const errText =
raw === 'no-exe-found' ? t('import.noExe')
: raw === 'no-linux-exe' ? t('import.noLinuxExe')
: raw === 'prefix-root' ? t('import.prefixRoot')
: (raw || 'unknown error')
toast(t('import.failed', errText), 'error')
toast(t('import.failed', importErrorText((game && game.error) || '')), 'error')
return
}
state.games = await window.plwl.gamesList()
@ -1005,13 +1013,7 @@ async function doPrefixImport(prefix, srcDir) {
const game = await window.plwl.gamesImportFromPrefix(prefix, srcDir)
closeModal()
if (!game || game.ok === false) {
const raw = (game && game.error) || ''
const errText =
raw === 'no-exe-found' ? t('import.noExe')
: raw === 'no-drive-c' ? t('import.prefix.noDriveC')
: raw === 'outside-drive-c' ? t('import.prefix.outside')
: (raw || 'unknown error')
toast(t('import.failed', errText), 'error')
toast(t('import.failed', importErrorText((game && game.error) || '')), 'error')
return
}
state.games = await window.plwl.gamesList()
@ -1633,6 +1635,54 @@ async function runWithProgress({ title, action }) {
}
}
// End-of-sync popup: what moved, what was already there, and how much space
// the synced trees take. A toast is too easy to miss for a transfer that can
// run for minutes.
function showSyncSummary({ from, received, categories, summary }) {
const items = (summary && summary.items) || {}
const cats = new Set(categories || [])
const apps = Math.max(0, Number(items.apps) || 0)
const wines = Math.max(0, Number(items.wines) || 0)
const prefixes = Math.max(0, Number(items.prefixes) || 0)
const files = Math.max(0, Number(summary && summary.uploaded) || 0)
const skipped = Math.max(0, Number(summary && summary.skipped) || 0)
const deleted = Math.max(0, Number(summary && summary.deleted) || 0)
const kept = Math.max(0, Number(summary && summary.kept) || 0)
const bytes = Math.max(0, Number(summary && summary.bytes) || 0)
const sizeBytes = Math.max(0, Number(summary && summary.sizeBytes) || 0)
const rows = []
const row = (label, value) => rows.push(
`<div class="sync-summary-row"><span>${label}</span><strong>${value}</strong></div>`
)
// a count alone also shows for a peer that did not send its categories
if (apps || cats.has('apps-portable') || cats.has('apps-prefix')) row(t('lansync.summaryApps'), apps)
if (wines || cats.has('wines')) row(t('lansync.wines'), wines)
if (prefixes || cats.has('prefixes')) row(t('lansync.prefixes'), prefixes)
if (cats.has('settings')) row(t('lansync.summarySettings'), t('lansync.summaryYes'))
if (files || skipped || deleted || kept) {
row(t(received ? 'lansync.summaryReceived' : 'lansync.summarySent'), files)
if (skipped) row(t('lansync.summarySkipped'), skipped)
if (deleted) row(t('lansync.summaryDeleted'), deleted)
if (kept) row(t('lansync.summaryKept'), kept)
}
if (bytes) row(t('lansync.summaryTransferred'), formatBytes(bytes))
if (sizeBytes) row(t('lansync.summarySize'), formatBytes(sizeBytes))
const modal = showModal(`
<h2>${t('lansync.summaryTitle')}</h2>
<p class="subtitle" id="ls-summary-from"></p>
<div class="sync-summary">${rows.join('')}</div>
<div class="modal-actions">
<button class="button primary" id="ls-summary-ok">${t('dialog.ok')}</button>
</div>
`)
// the device name comes off the network: never interpolate it into HTML
modal.querySelector('#ls-summary-from').textContent = t(
received ? 'lansync.summaryReceivedFrom' : 'lansync.summarySentTo',
from || ''
)
modal.querySelector('#ls-summary-ok').onclick = closeModal
}
// "340 MB / 1.2 GB" once the total is known, otherwise just the moved bytes.
function lanBytesLine(bytes, totalBytes) {
const sent = formatBytes(bytes)
@ -1712,15 +1762,7 @@ async function openLanSyncModal(device, errorKey) {
code.focus()
}
modal.querySelector('#ls-what-label').textContent = t('lansync.what')
const box = modal.querySelector('#ls-categories')
for (const category of LAN_CATEGORIES) {
const label = document.createElement('label')
label.className = 'check-label'
label.innerHTML = '<input type="checkbox" checked><span></span>'
label.querySelector('input').id = 'ls-cat-' + category.id
label.querySelector('span').textContent = t(category.label)
box.appendChild(label)
}
const pickers = await wireLanPickers(modal)
modal.querySelector('#ls-force-label').textContent = t('lansync.force')
modal.querySelector('#ls-warning').textContent = t('lansync.mirrorWarning')
modal.querySelector('#ls-cancel').textContent = t('dialog.cancel')
@ -1735,16 +1777,172 @@ async function openLanSyncModal(device, errorKey) {
code.focus()
return
}
const categories = LAN_CATEGORIES.map(c => c.id).filter(id => modal.querySelector('#ls-cat-' + id).checked)
if (!categories.length) {
const picked = pickers.collect()
if (!picked.categories.length) {
toast(t('lansync.noneSelected'), 'error')
return
}
await runLanSync(device, value, categories, modal.querySelector('#ls-force').checked)
await runLanSync(device, value, picked.categories, modal.querySelector('#ls-force').checked, picked.selection)
}
}
async function runLanSync(device, code, categories, force) {
// A category's checkbox says "this category travels"; the picker says which of
// its items. Everything starts selected, and a selection only ever narrows what
// is sent - nothing outside it may be deleted on the other side.
async function wireLanPickers(modal) {
const box = modal.querySelector('#ls-categories')
box.innerHTML = ''
let info = { apps: [], wines: [], prefixes: [] }
try {
info = await window.plwl.lansyncItems()
} catch {}
const groups = new Map([
['apps-portable', info.apps.filter(app => app.category === 'apps-portable')],
['apps-prefix', info.apps.filter(app => app.category === 'apps-prefix')],
['wines', info.wines || []],
['prefixes', info.prefixes || []],
['settings', []]
])
const chosen = new Map()
for (const [id, list] of groups) chosen.set(id, new Set(list.map(item => item.key)))
const rows = new Map()
const refreshButton = id => {
const row = rows.get(id)
if (!row || !row.pick) return
const list = groups.get(id) || []
row.pick.textContent = chosen.get(id).size === list.length
? t('lansync.pick')
: t('lansync.pickCount', chosen.get(id).size, list.length)
}
for (const category of LAN_CATEGORIES) {
const list = groups.get(category.id) || []
const wrapper = document.createElement('div')
wrapper.className = 'lan-cat'
const head = document.createElement('div')
head.className = 'lan-cat-head'
const label = document.createElement('label')
label.className = 'check-label'
label.innerHTML = '<input type="checkbox" checked><span></span>'
const input = label.querySelector('input')
input.id = 'ls-cat-' + category.id
label.querySelector('span').textContent = t(category.label)
head.appendChild(label)
const row = { input, list: null, search: null, pick: null }
rows.set(category.id, row)
if (list.length) {
const pick = document.createElement('button')
pick.type = 'button'
pick.className = 'button small'
head.appendChild(pick)
row.pick = pick
const panel = document.createElement('div')
panel.className = 'lan-pick'
panel.hidden = true
const tools = document.createElement('div')
tools.className = 'lan-pick-tools'
if (list.length > 6) {
const search = document.createElement('input')
search.type = 'search'
search.className = 'lan-search'
search.placeholder = t('lansync.pickSearch')
tools.appendChild(search)
row.search = search
search.oninput = () => renderList(category.id)
}
const all = document.createElement('button')
all.type = 'button'
all.className = 'button small'
all.textContent = t('lansync.pickAll')
const none = document.createElement('button')
none.type = 'button'
none.className = 'button small'
none.textContent = t('lansync.pickNone')
tools.appendChild(all)
tools.appendChild(none)
const listEl = document.createElement('div')
listEl.className = 'lan-pick-list'
row.list = listEl
panel.appendChild(tools)
panel.appendChild(listEl)
pick.onclick = () => {
panel.hidden = !panel.hidden
if (!panel.hidden) renderList(category.id)
}
all.onclick = () => {
for (const item of list) chosen.get(category.id).add(item.key)
renderList(category.id)
}
none.onclick = () => {
chosen.get(category.id).clear()
renderList(category.id)
}
wrapper.appendChild(panel)
refreshButton(category.id)
}
wrapper.insertBefore(head, wrapper.firstChild)
box.appendChild(wrapper)
}
function renderList(id) {
const row = rows.get(id)
if (!row || !row.list) return
const list = groups.get(id) || []
const set = chosen.get(id)
const query = row.search ? row.search.value.trim().toLowerCase() : ''
row.list.innerHTML = ''
for (const item of list) {
if (query && !String(item.name || '').toLowerCase().includes(query)) continue
const label = document.createElement('label')
label.className = 'check-label lan-item'
label.innerHTML = '<input type="checkbox"><span></span>'
const input = label.querySelector('input')
input.checked = set.has(item.key)
label.querySelector('span').textContent = item.name
input.onchange = () => {
if (input.checked) set.add(item.key)
else set.delete(item.key)
refreshButton(id)
// a prefix app's own folder travels with its prefix, not with the app
if (id === 'apps-prefix') renderList('apps-prefix')
}
row.list.appendChild(label)
if (id === 'apps-prefix' && input.checked && item.prefix) {
const prefixKey = `prefix:${item.prefix}`
const prefixesOn = rows.get('prefixes').input.checked
if (prefixesOn && !chosen.get('prefixes').has(prefixKey)) {
const hint = document.createElement('p')
hint.className = 'subtitle lan-hint'
hint.textContent = t('lansync.prefixLeftBehind', item.prefix)
row.list.appendChild(hint)
}
}
}
}
return {
collect() {
const categories = []
const units = []
let partial = false
for (const category of LAN_CATEGORIES) {
const input = rows.get(category.id).input
if (!input.checked) continue
const list = groups.get(category.id) || []
const set = chosen.get(category.id)
// a checked category with nothing ticked inside it sends nothing
if (list.length && !set.size) continue
categories.push(category.id)
for (const item of list) if (set.has(item.key)) units.push(item.key)
if (set.size !== list.length) partial = true
}
return { categories, selection: partial ? { units } : undefined }
}
}
}
async function runLanSync(device, code, categories, force, selection) {
// locked: a running sync is not cancellable by a stray click on the backdrop
const modal = showModal(`
<h2 id="ls-run-title"></h2>
@ -1778,22 +1976,23 @@ async function runLanSync(device, code, categories, force) {
})
let res = null
try {
res = await window.plwl.lansyncSync({ deviceId: device.id, code, categories, force })
res = await window.plwl.lansyncSync({ deviceId: device.id, code, categories, force, selection })
} catch (err) {
res = { ok: false, error: (err && err.message) || '' }
} finally {
active = false
state.lanPushing = false
}
closeModal()
if (res && res.ok) {
const summary = res.summary || {}
toast(t('lansync.done', device.name, summary.uploaded || 0, summary.skipped || 0, summary.deleted || 0, formatBytes(summary.bytes || 0)), 'success')
// swap the locked progress popup into the result, not a toast
showSyncSummary({ from: device.name, received: false, categories, summary: res.summary || {} })
await renderLanSync()
return
}
closeModal()
const err = (res && res.error) || ''
if (err === 'bad-code') {
// the server answers a wrong code as 'auth-failed'; both mean "ask again"
if (err === 'bad-code' || err === 'auth-failed') {
await window.plwl.lansyncForget(device.id)
toast(t('lansync.badCode'), 'error')
openLanSyncModal({ ...device, code: '' }, 'lansync.badCode')
@ -1801,9 +2000,10 @@ async function runLanSync(device, code, categories, force) {
}
const message =
err === 'busy' ? t('lansync.busy')
: err === 'offline' ? t('lansync.offline')
: err === 'offline' || err === 'receiver-lost' ? t('lansync.offline')
: err === 'rejected' ? t('lansync.rejected')
: err === 'incoming-timeout' ? t('lansync.incomingTimedOut')
: err === 'old-peer' ? t('lansync.oldPeer')
: (err || t('lansync.unknownError'))
toast(t('lansync.failed', message), 'error')
}
@ -1877,6 +2077,9 @@ function onLanIncoming(info) {
}
let receiving = false
// The last apply payload, for the end-of-sync summary (it carries files,
// bytes, skipped files and the total size of the synced trees).
let lastApplied = null
// Only the sender's 'done' ends this; a watchdog-closed popup may reopen.
let receiveEnded = false
let receiveWatchdog = null
@ -1935,12 +2138,32 @@ function armReceiveWatchdog() {
function onLanReceiving(info) {
if (!info || !info.stage) return
if (state.lanPushing) return // our own push owns the (single) popup
if (info.stage === 'start') receiveEnded = false
if (info.stage === 'done') receiveEnded = true
if (info.stage === 'start') {
receiveEnded = false
lastApplied = null
}
if (info.stage === 'done') {
receiveEnded = true
// the review's decision was the last thing on screen: this is its result
if (reviewFlow) {
reviewFlow = false
closeModal()
showReceiveSummary(info)
return
}
// the popup may have been closed by the watchdog: still show the result
// if nothing else owns the modal slot
if (!receiving) {
if (lastApplied && qs('#modal-overlay').style.display === 'none') showReceiveSummary(info)
return
}
}
if (!receiving) {
// reopen a watchdog-closed popup unless the sync already ended
if (receiveEnded) return
if (qs('#modal-overlay').style.display !== 'none') return
// a completion summary underneath gives way to the next sync's progress
const summaryOpen = !!qs('#modal').querySelector('#ls-summary-ok')
if (!summaryOpen && qs('#modal-overlay').style.display !== 'none') return
receiving = true
const modal = showModal(`
<h2 id="ls-recv-title"></h2>
@ -1979,15 +2202,269 @@ function renderReceiving(info) {
parts.stats.textContent = lanStatsLine(info.items, info.itemsDone, info.totals)
parts.bytes.textContent = lanBytesLine(info.bytes, info.totalBytes)
if (info.stage === 'done') {
// let the totals be read, then close (the library reloads itself)
// hold the finished bar for a moment, then swap it for the summary
if (receiveCloseTimer) clearTimeout(receiveCloseTimer)
receiveCloseTimer = setTimeout(() => {
receiveCloseTimer = null
endReceiving('')
}, 4000)
showReceiveSummary(info)
}, 1200)
}
}
// Swap the finished progress popup for the result; the library has already
// been reloaded by the apply event.
function showReceiveSummary(info) {
if (receiveWatchdog) {
clearTimeout(receiveWatchdog)
receiveWatchdog = null
}
if (receiveStall) {
clearTimeout(receiveStall)
receiveStall = null
}
if (receiveCloseTimer) {
clearTimeout(receiveCloseTimer)
receiveCloseTimer = null
}
receiving = false
const applied = lastApplied || {}
showSyncSummary({
from: applied.from || info.from || '',
received: true,
categories: applied.categories || [],
summary: {
uploaded: applied.files ?? info.done,
skipped: applied.skipped || 0,
deleted: applied.deleted ?? info.deleted,
kept: applied.kept ?? 0,
bytes: applied.bytes ?? info.bytes,
sizeBytes: applied.sizeBytes ?? info.sizeBytes,
items: applied.items || info.items || {}
}
})
}
// --- the receiver's deletion review ----------------------------------------
// Nothing the sender left out is removed before this: the list is planned, the
// user decides, and only then does anything get deleted.
let reviewFlow = false
// matches the receiver's own review timeout: an unanswered list deletes nothing
const REVIEW_WATCHDOG_MS = 10 * 60 * 1000
let reviewTimer = null
function clearReviewTimer() {
if (!reviewTimer) return
clearTimeout(reviewTimer)
reviewTimer = null
}
function onLanReview(info) {
if (!info || !info.id) return
clearReviewTimer()
// the review replaces the receive progress popup and owns the modal slot
if (receiveWatchdog) {
clearTimeout(receiveWatchdog)
receiveWatchdog = null
}
if (receiveStall) {
clearTimeout(receiveStall)
receiveStall = null
}
if (receiveCloseTimer) {
clearTimeout(receiveCloseTimer)
receiveCloseTimer = null
}
receiving = false
// no progress popup may reopen between the decision and the sender's 'done'
receiveEnded = true
reviewFlow = true
reviewTimer = setTimeout(() => {
reviewTimer = null
reviewFlow = false
closeModal()
toast(t('lansync.reviewExpired'), 'error')
}, REVIEW_WATCHDOG_MS)
renderReview(info)
}
function renderReview(info) {
const units = Array.isArray(info.units) ? info.units : []
const files = Math.max(0, Number(info.files) || 0)
const bytes = Math.max(0, Number(info.bytes) || 0)
const kept = new Set()
const keptUnits = new Set()
const nodeInfo = new Map()
const modal = showModal(`
<h2>${t('lansync.reviewTitle')}</h2>
<p class="subtitle" id="ls-review-from"></p>
<p class="subtitle" id="ls-review-text"></p>
<div class="lan-review" id="ls-review-list"></div>
<p class="subtitle sync-warning" id="ls-review-warning"></p>
<div class="lan-review-actions">
<button class="button" id="ls-review-keep"></button>
<button class="button primary" id="ls-review-go"></button>
</div>
`, { locked: true, wide: true })
// the device name comes off the network: never interpolate it into HTML
modal.querySelector('#ls-review-from').textContent = t('lansync.reviewFrom', info.name || info.deviceId || '')
modal.querySelector('#ls-review-text').textContent = t('lansync.reviewText', files, formatBytes(bytes))
modal.querySelector('#ls-review-warning').textContent = t('lansync.reviewOverwrite')
const list = modal.querySelector('#ls-review-list')
const go = modal.querySelector('#ls-review-go')
const keepAll = modal.querySelector('#ls-review-keep')
keepAll.textContent = t('lansync.reviewKeepAll')
const keptBy = path => [...kept].some(entry => path === entry || path.startsWith(entry + '/'))
const keptChild = path => [...kept].some(entry => entry !== path && entry.startsWith(path + '/'))
const keptTotals = () => {
let keptFiles = 0
let keptBytes = 0
for (const unit of units) {
if (!keptUnits.has(unit.key)) continue
keptFiles += Math.max(0, Number(unit.files) || 0)
keptBytes += Math.max(0, Number(unit.bytes) || 0)
}
for (const path of kept) {
if ([...kept].some(entry => entry !== path && path.startsWith(entry + '/'))) continue
const unit = units.find(entry => (nodeInfo.get(path) || {}).unit === entry.key)
if (unit && keptUnits.has(unit.key)) continue
const node = nodeInfo.get(path)
if (!node) continue
keptFiles += node.files
keptBytes += node.bytes
}
return { files: keptFiles, bytes: keptBytes }
}
const refresh = () => {
for (const row of modal.querySelectorAll('.lan-node')) {
const input = row.querySelector('input')
const path = row.dataset.path
const unitKept = keptUnits.has(row.dataset.unit)
input.checked = !unitKept && !keptBy(path)
input.indeterminate = input.checked && keptChild(path)
// kept through an ancestor: re-enable that ancestor instead
input.disabled = unitKept || (keptBy(path) && !kept.has(path))
}
for (const row of modal.querySelectorAll('.lan-review-unit')) {
const input = row.querySelector('input')
const key = row.dataset.key
const unit = units.find(entry => entry.key === key)
input.checked = !keptUnits.has(key)
input.indeterminate = input.checked && [...kept].some(entry => (nodeInfo.get(entry) || {}).unit === key)
row.querySelector('.lan-review-meta').textContent = t('lansync.reviewFiles', unit ? unit.files : 0, formatBytes(unit ? unit.bytes : 0))
}
const left = keptTotals()
go.textContent = t('lansync.reviewDelete', Math.max(0, files - left.files), formatBytes(Math.max(0, bytes - left.bytes)))
}
const nodeRow = (node, unitKey, depth) => {
nodeInfo.set(node.path, { files: node.files || 0, bytes: node.bytes || 0, unit: unitKey })
const row = document.createElement('div')
row.className = 'lan-node'
row.dataset.path = node.path
row.dataset.unit = unitKey
row.style.paddingLeft = `${depth * 18 + 6}px`
row.innerHTML = `
<label class="check-label"><input type="checkbox"><span class="lan-node-name"></span></label>
<span class="lan-node-meta"></span>
<button type="button" class="button small lan-node-open" hidden></button>
`
row.querySelector('.lan-node-name').textContent = node.name
row.querySelector('.lan-node-meta').textContent = t('lansync.reviewFiles', node.files || 0, formatBytes(node.bytes || 0))
const input = row.querySelector('input')
input.onchange = () => {
if (input.checked) {
kept.delete(node.path)
// keep the descendants' own entries from overriding an ancestor
for (const entry of [...kept]) if (entry.startsWith(node.path + '/')) kept.delete(entry)
} else {
for (const entry of [...kept]) if (entry.startsWith(node.path + '/')) kept.delete(entry)
kept.add(node.path)
}
refresh()
}
const childBox = document.createElement('div')
childBox.className = 'lan-nodes'
childBox.hidden = true
const open = row.querySelector('.lan-node-open')
if (node.hasChildren) {
open.hidden = false
open.textContent = '+'
open.onclick = async () => {
if (!childBox.dataset.loaded) {
childBox.dataset.loaded = '1'
const res = await window.plwl.lansyncReviewTree(info.id, node.path)
for (const child of (res && res.nodes) || []) childBox.appendChild(nodeRow(child, unitKey, depth + 1))
}
childBox.hidden = !childBox.hidden
open.textContent = childBox.hidden ? '+' : '−'
refresh()
}
}
const wrap = document.createElement('div')
wrap.className = 'lan-node-wrap'
wrap.appendChild(row)
wrap.appendChild(childBox)
return wrap
}
for (const unit of units) {
const row = document.createElement('div')
row.className = 'lan-review-unit'
row.dataset.key = unit.key
row.innerHTML = `
<label class="check-label"><input type="checkbox" checked><span class="lan-review-name"></span></label>
<span class="lan-review-meta"></span>
`
row.querySelector('.lan-review-name').textContent = unit.label || unit.key
row.querySelector('input').onchange = async event => {
if (event.target.checked) {
keptUnits.delete(unit.key)
for (const entry of [...kept]) if ((nodeInfo.get(entry) || {}).unit === unit.key) kept.delete(entry)
} else {
keptUnits.add(unit.key)
for (const entry of [...kept]) if ((nodeInfo.get(entry) || {}).unit === unit.key) kept.delete(entry)
}
refresh()
}
list.appendChild(row)
const children = document.createElement('div')
children.className = 'lan-nodes'
list.appendChild(children)
// top folders load on first paint; a unit's list is small (collapsed rows)
window.plwl.lansyncReviewTree(info.id, unit.key).then(res => {
for (const node of (res && res.nodes) || []) children.appendChild(nodeRow(node, unit.key, 1))
refresh()
})
}
keepAll.onclick = () => {
for (const unit of units) keptUnits.add(unit.key)
kept.clear()
refresh()
decide()
}
let deciding = false
const decide = async () => {
if (deciding) return
deciding = true
clearReviewTimer()
go.disabled = true
keepAll.disabled = true
go.textContent = t('lansync.reviewDeleting')
try {
await window.plwl.lansyncReviewDecide(info.id, { keep: [...kept], keepUnits: [...keptUnits] })
} catch {}
closeModal()
}
go.onclick = decide
refresh()
}
// A received sync changed local files: reload every list (settings included).
async function reloadLocalData() {
const [wines, prefixes, games, settings] = await Promise.all([
@ -2013,8 +2490,10 @@ function wireLanSync() {
})
window.plwl.onLanSyncReceiving(info => onLanReceiving(info))
window.plwl.onLanSyncIncoming(info => onLanIncoming(info))
window.plwl.onLanSyncReview(info => onLanReview(info))
window.plwl.onLanSyncApplied(async info => {
toast(t('lansync.applied', info.from || '', info.files || 0, formatBytes(info.bytes || 0)), 'success')
// the summary popup replaces the toast; reload behind it
lastApplied = info
await reloadLocalData()
})
qs('#ls-reroll-id').addEventListener('click', async () => {
@ -2079,7 +2558,7 @@ function wireEvents() {
toast(t('wines.imported', res.path), 'success')
state.wines = await window.plwl.winesList()
} else {
toast(t('toast.importFailed') + ': ' + (res.error || ''), 'error')
toast(t('import.failed', importErrorText((res && res.error) || '')), 'error')
}
renderWines()
})
@ -2125,7 +2604,7 @@ function wireEvents() {
})
qs('#gc-icon-browse').addEventListener('click', async () => {
// start in the app's own folder, where the icon exe most likely lives
const start = absGamePathInput(qs('#gc-workdir').value.trim(), state.currentGame)
const start = state.currentGame.path || absGamePathInput(qs('#gc-workdir').value.trim(), state.currentGame)
const p = await window.plwl.dialogPickFile({
properties: ['openFile'],
defaultPath: start || window.plwl.FILES_DIR,

View file

@ -195,6 +195,68 @@ content {
user has to notice, so it stands out from the subtitles around it. */
.sync-warning { color: var(--big-red); margin-top: 0.6rem; }
/* End-of-sync summary: label left, count/size right, one row per fact. */
.sync-summary { display: flex; flex-direction: column; }
.sync-summary-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
padding: 0.45rem 0;
border-bottom: var(--border-width) solid var(--light-border-color);
}
.sync-summary-row:last-child { border-bottom: none; padding-bottom: 0; }
.sync-summary-row span { color: var(--darker-text-color); }
.sync-summary-row strong { font-weight: 700; font-variant-numeric: tabular-nums; }
/* --- LAN sync: per-category item pickers ---------------------------------- */
.lan-cat { display: flex; flex-direction: column; }
.lan-cat-head { display: flex; align-items: center; gap: 0.6rem; }
.lan-cat-head .check-label { flex: 1; min-width: 0; margin: 0.35rem 0; }
.lan-pick {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin: 0 0 0.5rem 2.4rem;
padding: 0.5rem 0.6rem;
border-radius: 0.5rem;
background-color: rgba(255, 255, 255, 0.045);
}
.lan-pick[hidden] { display: none; }
.lan-pick-tools { display: flex; align-items: center; gap: 0.4rem; }
.lan-search { flex: 1; min-width: 0; height: 2.2rem; min-height: 0; }
.lan-pick-list { display: flex; flex-direction: column; max-height: 16rem; overflow-y: auto; }
.lan-pick-list .check-label { margin: 0.15rem 0; }
.lan-pick-list .lan-hint { margin: 0 0 0.3rem 2.4rem; font-size: 0.85em; }
.lan-item span { overflow-wrap: anywhere; }
/* --- LAN sync: the deletion review ---------------------------------------- */
.lan-review { display: flex; flex-direction: column; max-height: 46vh; overflow-y: auto; padding-right: 0.3rem; }
.lan-review-unit {
display: flex;
align-items: center;
gap: 0.6rem;
margin-top: 0.7rem;
padding-top: 0.35rem;
border-top: 1px solid rgba(255, 255, 255, 0.07);
}
.lan-review-unit .check-label { flex: 1; min-width: 0; margin: 0; }
.lan-review-unit .lan-review-name { font-weight: 700; overflow-wrap: anywhere; }
.lan-review-meta, .lan-node-meta {
color: var(--darker-text-color);
font-variant-numeric: tabular-nums;
white-space: nowrap;
font-size: 0.85em;
}
.lan-nodes { display: flex; flex-direction: column; }
.lan-nodes[hidden] { display: none; }
.lan-node { display: flex; align-items: center; gap: 0.5rem; padding: 0.12rem 0.2rem; }
.lan-node .check-label { flex: 1; min-width: 0; margin: 0; gap: 0.5rem; }
.lan-node .lan-node-name { overflow-wrap: anywhere; }
.lan-node input[type='checkbox'] { width: 1.4rem; height: 1.4rem; }
.lan-node-open { padding: 0 0.5rem; min-height: 1.6rem; }
.lan-review-actions { display: flex; gap: 0.6rem; justify-content: flex-end; margin-top: 0.6rem; }
.button {
height: var(--button-height);
padding: 0 1rem;
@ -697,6 +759,7 @@ select {
from { opacity: 0; }
to { opacity: 1; }
}
.modal.wide { max-width: 44rem; }
.modal h2 {
font-family: 'Nunito Black', 'Nunito', sans-serif;
font-size: 1.2rem;

View file

@ -56,6 +56,10 @@ function progressGate(emit) {
return push
}
// Tree walks are dominated by per-file syscalls; a handful in flight keeps a
// slow disk busy without flooding the event loop with thousands of them.
const TREE_CONCURRENCY = 16
// Run `fn` over `items` with at most `limit` in flight, results in order.
async function mapLimit(items, limit, fn) {
const out = new Array(items.length)
@ -90,7 +94,7 @@ async function treeSize(root) {
} catch {
return
}
for (const entry of entries) {
await mapLimit(entries, TREE_CONCURRENCY, async entry => {
const full = path.join(dir, entry.name)
if (entry.isSymbolicLink()) {
files++
@ -102,7 +106,7 @@ async function treeSize(root) {
bytes += (await fsp.stat(full)).size
} catch {}
}
}
})
}
await walk(root)
return { files, bytes }
@ -110,9 +114,12 @@ async function treeSize(root) {
// Copy a tree with symlinks recreated and permissions carried (copyFile
// drops both); reports cumulative { done, bytes } through onProgress.
// `failed` counts entries that could not be recreated: a caller that needs a
// complete copy (an import) must not treat a partial tree as success.
async function copyTree(src, dst, onProgress) {
let done = 0
let bytes = 0
let failed = 0
const gate = onProgress ? progressGate(onProgress) : null
const tick = () => {
if (gate) gate({ done, bytes }, false)
@ -122,10 +129,16 @@ async function copyTree(src, dst, onProgress) {
try {
entries = await fsp.readdir(from, { withFileTypes: true })
} catch {
failed++
return
}
await fsp.mkdir(to, { recursive: true })
for (const entry of entries) {
try {
await fsp.mkdir(to, { recursive: true })
} catch {
failed++
return
}
await mapLimit(entries, TREE_CONCURRENCY, async entry => {
const s = path.join(from, entry.name)
const d = path.join(to, entry.name)
if (entry.isSymbolicLink()) {
@ -133,32 +146,46 @@ async function copyTree(src, dst, onProgress) {
try {
target = await fsp.readlink(s)
} catch {}
if (!target) continue
if (!target) {
failed++
return
}
try {
await fsp.symlink(target, d)
} catch {}
} catch {
failed++
}
done++
tick()
} else if (entry.isDirectory()) {
await walk(s, d)
} else if (entry.isFile()) {
let size = 0
let st = null
try {
size = (await fsp.stat(s)).size
st = await fsp.stat(s)
} catch {}
if (!st) {
failed++
return
}
try {
await fsp.copyFile(s, d)
await fsp.chmod(d, (await fsp.stat(s)).mode & 0o777)
} catch {
failed++
return
}
try {
await fsp.chmod(d, st.mode & 0o777)
} catch {}
done++
bytes += size
bytes += st.size
tick()
}
}
})
}
await walk(src, dst)
if (gate) gate({ done, bytes }, true)
return { done, bytes }
return { done, bytes, failed }
}
// Imported trees come from media that lose permissions (archives, FAT, a
@ -176,13 +203,13 @@ async function chmodTree(root) {
} catch {
return
}
for (const entry of entries) {
await mapLimit(entries, TREE_CONCURRENCY, async entry => {
const full = path.join(dir, entry.name)
if (entry.isSymbolicLink()) continue
if (entry.isSymbolicLink()) return
if (entry.isDirectory()) {
await walk(full)
} else if (!entry.isFile()) {
continue
return
}
try {
const before = (await fsp.stat(full)).mode & 0o7777
@ -193,7 +220,7 @@ async function chmodTree(root) {
else files++
}
} catch {}
}
})
}
await walk(root)
return { files, dirs }
@ -275,7 +302,7 @@ async function removeTree(root, onProgress) {
} catch {
return false
}
for (const entry of entries) {
await mapLimit(entries, TREE_CONCURRENCY, async entry => {
const full = path.join(dir, entry.name)
if (entry.isDirectory()) {
await walk(full)
@ -284,7 +311,7 @@ async function removeTree(root, onProgress) {
} catch {}
dirs++
tick()
continue
return
}
let size = 0
try {
@ -297,7 +324,7 @@ async function removeTree(root, onProgress) {
files++
bytes += size
tick()
}
})
try {
await fsp.rmdir(dir)
dirs++

View file

@ -14,10 +14,21 @@ const { copyTree, treeSize, chmodTree } = require('./fsutil')
// Directories that typically hold installers/redistributables, not the game.
const SKIPPED_DIRS = new Set(['redist', 'directx', '_commonredist', 'support', 'dotnet', 'vc'])
// A copy that lost files is not an import: drop the partial tree and say so.
async function copyComplete(src, dst, onProgress) {
const res = await copyTree(src, dst, onProgress)
if (!res.failed) return res
await fsp.rm(dst, { recursive: true, force: true })
throw new Error('copy-incomplete')
}
// The copy itself lives in fsutil.copyTree; moveFolder falls back to it
// across filesystems only.
function moveFolder(src, dst, onProgress) {
const fallback = () => copyTree(src, dst, onProgress).then(() => fsp.rm(src, { recursive: true, force: true }))
const fallback = async () => {
await copyComplete(src, dst, onProgress)
await fsp.rm(src, { recursive: true, force: true })
}
if (path.parse(src).root !== path.parse(dst).root) return fallback()
return fsp.rename(src, dst).catch(fallback)
}
@ -44,7 +55,7 @@ async function importGame(srcDir, mode, onProgress, kind = 'windows') {
}
emit('count', { files: 0, bytes: 0 })
const total = await treeSize(srcDir)
await copyTree(srcDir, dst, p => emit('copy', { files: p.done, bytes: p.bytes, totalFiles: total.files, totalBytes: total.bytes }))
await copyComplete(srcDir, dst, p => emit('copy', { files: p.done, bytes: p.bytes, totalFiles: total.files, totalBytes: total.bytes }))
} else await moveFolder(srcDir, dst, onProgress)
workdir = dst
// the source's permissions are not the source's truth: an app out of a
@ -150,4 +161,4 @@ async function findDefaultExe(dir) {
return best
}
module.exports = { importGame, importGameFromPrefix, findDefaultExe, findLinuxExe, moveFolder }
module.exports = { importGame, importGameFromPrefix, findDefaultExe, findLinuxExe, moveFolder, copyComplete }

View file

@ -24,6 +24,9 @@ function registerLanSyncIpc({ getWindow }) {
return { ok: true }
})
ipcMain.handle('lansync:respondIncoming', (e, requestId, accept) => lansync.respondIncoming(requestId, accept))
ipcMain.handle('lansync:items', () => lansync.syncItems())
ipcMain.handle('lansync:reviewTree', (e, id, rel) => lansync.reviewTree(id, rel))
ipcMain.handle('lansync:reviewDecide', (e, id, decision) => lansync.respondReview(id, decision))
ipcMain.handle('lansync:sync', async (e, payload) => {
try {
return await lansync.syncTo({
@ -31,6 +34,7 @@ function registerLanSyncIpc({ getWindow }) {
code: payload && payload.code,
categories: payload && payload.categories,
force: !!(payload && payload.force),
selection: payload && payload.selection,
onProgress: data => sendToWindow(getWindow(), 'lansync:progress', data)
})
} catch (err) {

View file

@ -23,21 +23,25 @@ function createManifestBuilder({ filesPath, filesRoot, hash, mapLimit, concurren
// Walk without following symlinks (dosdevices etc. are part of the sync).
// Iterative, with one directory's stat/readlink calls in flight together.
// `emit` (optional) streams dirs and links out while the walk continues;
// file entries only land in `out` (their digest comes later).
async function scanTree(abs, rel, out, mtimes, emit) {
// file entries only land in `out` (their digest comes later). `onError`
// marks the unit incomplete: an entry we could not read is not an entry the
// sender decided to delete.
async function scanTree(abs, rel, out, mtimes, emit, onError, skip) {
const queue = [[abs, rel]]
while (queue.length) {
const [dirAbs, dirRel] = queue.pop()
let entries
try {
entries = await fsp.readdir(dirAbs, { withFileTypes: true })
} catch {
} catch (err) {
if (onError) onError(err, dirAbs)
continue
}
const dirs = []
const leaves = []
for (const entry of entries) {
const relChild = `${dirRel}/${entry.name}`
if (skip && skip.has(relChild)) continue // a selection left it out
if (entry.isDirectory()) {
const record = { path: relChild, type: 'dir' }
out.push(record)
@ -54,7 +58,10 @@ function createManifestBuilder({ filesPath, filesRoot, hash, mapLimit, concurren
let target = ''
try {
target = await fsp.readlink(absChild)
} catch {}
} catch (err) {
if (onError) onError(err, absChild)
return
}
const record = { path: relChild, type: 'link', target: portableLinkTarget(absChild, target) }
out.push(record)
if (emit) await emit(record)
@ -63,7 +70,8 @@ function createManifestBuilder({ filesPath, filesRoot, hash, mapLimit, concurren
let st
try {
st = await fsp.stat(absChild)
} catch {
} catch (err) {
if (onError) onError(err, absChild)
return
}
if (!st.isFile()) return
@ -77,29 +85,48 @@ function createManifestBuilder({ filesPath, filesRoot, hash, mapLimit, concurren
}
// Manifest of one unit, streamed: `handlers` may carry onEntry (a Promise
// per entry, awaited - backpressure), onTotal and onProgress. File entries
// go out as their digests finish, so the receiver compares while this side
// is still hashing; hashing itself stays parallel, a batch at a time.
// per entry, awaited - backpressure), onTotal, onProgress, onIncomplete
// (called once, when anything in the tree could not be read) and skip (unit
// paths a selection keeps off the wire). File entries go out as their
// digests finish, so the receiver compares while this side is still
// hashing; hashing itself stays parallel, a batch at a time.
async function buildManifestStream(roots, force, handlers) {
const skip = handlers && handlers.skip && handlers.skip.length ? new Set(handlers.skip) : null
const onEntry = handlers && handlers.onEntry
const onTotal = handlers && handlers.onTotal
const onProgress = handlers && handlers.onProgress
const onIncomplete = handlers && handlers.onIncomplete
const entries = []
const mtimes = new Map()
let incomplete = false
const markIncomplete = (err, file) => {
if (incomplete) return
incomplete = true
if (onIncomplete) onIncomplete(err, file)
}
for (const rel of roots) {
if (skip && skip.has(rel)) continue
const abs = filesPath(rel)
if (!abs) continue
if (!abs) {
markIncomplete(new Error('bad-unit-path'), rel)
continue
}
let st
try {
st = await fsp.lstat(abs)
} catch {
} catch (err) {
// a root that is simply not there is a unit with nothing in it
if (err && err.code !== 'ENOENT') markIncomplete(err, abs)
continue
}
if (st.isSymbolicLink()) {
let target = ''
try {
target = await fsp.readlink(abs)
} catch {}
} catch (err) {
markIncomplete(err, abs)
continue
}
const record = { path: rel, type: 'link', target: portableLinkTarget(abs, target) }
entries.push(record)
if (onEntry) await onEntry(record)
@ -107,7 +134,7 @@ function createManifestBuilder({ filesPath, filesRoot, hash, mapLimit, concurren
const record = { path: rel, type: 'dir' }
entries.push(record)
if (onEntry) await onEntry(record)
await scanTree(abs, rel, entries, mtimes, onEntry)
await scanTree(abs, rel, entries, mtimes, onEntry, markIncomplete, skip)
} 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)
@ -136,8 +163,10 @@ function createManifestBuilder({ filesPath, filesRoot, hash, mapLimit, concurren
if (onProgress) onProgress(done, files.length, hashed, totalBytes)
})
: null
} catch {
if (!entry.hash) markIncomplete(new Error('unreadable-file'), entry.path)
} catch (err) {
entry.hash = null
markIncomplete(err, entry.path)
}
}))
}

167
src/lansync-plan.js Normal file
View file

@ -0,0 +1,167 @@
'use strict'
// Deletions a sync would make, computed but not executed. A mirror is the goal
// ("the sender's tree wins"), but a file the user still needs is not a
// leftover: everything is held until the receiver approves the list.
const fs = require('fs')
const fsp = fs.promises
const baseName = rel => String(rel || '').split('/').pop()
// `rel` lies at or below `parent`.
function isUnder(rel, parent) {
return rel === parent || rel.startsWith(parent + '/')
}
// A path the receiver must keep lies at or below `rel`.
function keepInside(rel, keep) {
for (const kept of keep) if (isUnder(kept, rel)) return true
return false
}
function keptBy(rel, keep) {
for (const kept of keep) if (isUnder(rel, kept)) return true
return false
}
// One entry and, for a folder, everything in it. `full` means nothing inside
// survives, so the whole subtree can be shown (and deleted) as one row.
async function planEntry(abs, rel, keep) {
let st
try {
st = await fsp.lstat(abs)
} catch {
return null
}
if (!st.isDirectory() || st.isSymbolicLink()) {
const file = st.isFile()
return { path: rel, name: baseName(rel), type: file ? 'file' : 'link', files: 1, bytes: file ? st.size : 0, full: true, children: [] }
}
let entries
try {
entries = await fsp.readdir(abs, { withFileTypes: true })
} catch {
// an unreadable folder is not ours to delete: its contents are unknown
return null
}
const children = []
let survived = 0
let files = 0
let bytes = 0
for (const entry of entries) {
const childRel = `${rel}/${entry.name}`
if (keep.has(childRel)) {
survived++
continue
}
const child = await planEntry(`${abs}/${entry.name}`, childRel, keep)
if (!child) {
survived++
continue
}
children.push(child)
files += child.files
bytes += child.bytes
}
return { path: rel, name: baseName(rel), type: 'dir', files, bytes, full: survived === 0, children }
}
// Walk a folder the sender also has: entries missing from `keep` are planned,
// kept folders are descended into (they may hold files the sender lacks).
// `protect` is a subtree the plan must not touch at all (an app a selection
// left out of the sync).
async function walkDeletions(abs, rel, keep, protect) {
const out = []
let entries
try {
entries = await fsp.readdir(abs, { withFileTypes: true })
} catch {
return out
}
for (const entry of entries) {
const childRel = `${rel}/${entry.name}`
if (protect.has(childRel)) continue
if (keep.has(childRel)) {
if (entry.isDirectory() && !entry.isSymbolicLink()) {
out.push(...await walkDeletions(`${abs}/${entry.name}`, childRel, keep, protect))
}
continue
}
const node = await planEntry(`${abs}/${entry.name}`, childRel, keep)
if (node) out.push(node)
}
return out
}
// Top-level leftovers of a registry folder (`apps/`, `wines/`, `wineprefixes/`):
// names in `keepNames` survive, the rest is planned whole. Same shape as the
// old removeOrphans, but nothing is deleted.
async function planListed(listDirAbs, dirRel, keepNames) {
const out = []
let entries
try {
entries = await fsp.readdir(listDirAbs, { withFileTypes: true })
} catch {
return out
}
for (const entry of entries) {
if (keepNames.has(entry.name)) continue
const node = await planEntry(`${listDirAbs}/${entry.name}`, `${dirRel}/${entry.name}`, new Set())
if (node) out.push(node)
}
return out
}
function totalsOf(nodes) {
let files = 0
let bytes = 0
for (const node of nodes) {
files += node.files || 0
bytes += node.bytes || 0
}
return { files, bytes }
}
function clampNodes(nodes) {
return Array.isArray(nodes) ? nodes.filter(node => node && typeof node.path === 'string' && Array.isArray(node.children)) : []
}
// Delete what was approved, count what actually went. Deletion happens here
// and only here: the plan phase never removes anything.
async function removeNodes(nodes, keep, filesPath, onProgress) {
const out = { entries: 0, files: 0, bytes: 0 }
for (const node of clampNodes(nodes)) {
if (keptBy(node.path, keep)) continue
const abs = filesPath(node.path)
if (!abs) continue
await removeNode(abs, node, keep, out, onProgress)
}
return out
}
async function removeNode(abs, node, keep, out, onProgress) {
// nothing inside survives -> one recursive delete
if (!keepInside(node.path, keep)) {
try {
if (fs.lstatSync(abs)) fs.rmSync(abs, { recursive: true, force: true })
} catch {
return
}
out.entries++
out.files += node.files || 0
out.bytes += node.bytes || 0
if (onProgress) onProgress(out)
return
}
for (const child of node.children || []) {
if (keptBy(child.path, keep)) continue
await removeNode(`${abs}/${child.name}`, child, keep, out, onProgress)
}
// a folder emptied by the approved deletions goes too, but only when empty
try {
if (!fs.readdirSync(abs).length) fs.rmdirSync(abs)
} catch {}
}
module.exports = { planEntry, walkDeletions, planListed, totalsOf, removeNodes }

File diff suppressed because it is too large Load diff

View file

@ -29,6 +29,12 @@ const HEARTBEAT_MS = Number(process.env.PLWL_LANSYNC_HEARTBEAT) || 20000
// One ping round trip; env override for tests.
const PING_TIMEOUT_MS = Number(process.env.PLWL_LANSYNC_PING_TIMEOUT) || 10000
// How often the sender asks the receiver's popup whether the deletion list is
// settled, and how long it waits for a human before giving up (nothing is
// deleted on a timeout, so the safe answer is also the fallback).
const REVIEW_POLL_MS = Number(process.env.PLWL_LANSYNC_REVIEW_POLL) || 1500
const REVIEW_WAIT_MS = Number(process.env.PLWL_LANSYNC_REVIEW_WAIT) || 15 * 60 * 1000
function createPusher(api) {
const {
CATEGORIES,
@ -54,8 +60,10 @@ function createPusher(api) {
function planUnit(device, token, unit, force, meta, produce) {
// announced up front, so the receiver's popup can say "Apps: 2/5"
const totals = meta && meta.totals ? `&totals=${encodeURIComponent(JSON.stringify(meta.totals))}` : ''
// folders a selection kept off the wire must not be pruned on the other side
const exclude = unit.exclude && unit.exclude.length ? `&exclude=${encodeURIComponent(JSON.stringify(unit.exclude))}` : ''
return new Promise((resolve, reject) => {
const route = `/plwl/unit/plan?token=${encodeURIComponent(token)}&unit=${encodeURIComponent(unit.key)}&label=${encodeURIComponent(unit.label || '')}&roots=${encodeURIComponent(JSON.stringify(unit.roots))}${force ? '&force=1' : ''}${totals}`
const route = `/plwl/unit/plan?token=${encodeURIComponent(token)}&unit=${encodeURIComponent(unit.key)}&label=${encodeURIComponent(unit.label || '')}&roots=${encodeURIComponent(JSON.stringify(unit.roots))}${force ? '&force=1' : ''}${totals}${exclude}`
const req = http.request(
{
host: device.address,
@ -166,19 +174,54 @@ function createPusher(api) {
// --- units ----------------------------------------------------------------
function appUnits(cats) {
// A selection lists unit keys ("app:1", "wine:GE-Proton9-20", "prefix:Main");
// absent means everything travels. `apps` is the older app-id-only shape.
function selectionOf(selection) {
const units = selection && Array.isArray(selection.units) ? new Set(selection.units.map(String)) : null
const apps = selection && Array.isArray(selection.apps) ? new Set(selection.apps.map(String)) : null
return {
active: !!units || !!apps,
has(key, id) {
if (units && !units.has(key)) return false
if (apps && String(key).startsWith('app:') && !apps.has(String(id))) return false
return true
}
}
}
function appUnits(cats, sel) {
const units = []
for (const game of library.loadGames()) {
const category = api.appCategory(game)
if (!category || !cats.has(category)) continue
if (!sel.has(`app:${game.id}`, game.id)) continue
const roots = [`data/icons/${game.id}`]
if (category === 'apps-portable') roots.unshift(`apps/${game.id}`)
if (category === 'apps-portable') {
// the app's own folder, never apps/<id> guessed from the entry
const rel = String(toPortablePath(library.appFolder(game)) || '')
roots.unshift(rel.startsWith('apps/') ? rel : `apps/${game.id}`)
}
units.push({ key: `app:${game.id}`, kind: 'app', id: game.id, label: game.name, roots })
}
return units
}
function wineUnits() {
// A prefix app a selection leaves out stays on this side: its folder must not
// travel with the prefix, and the receiver must not prune it as a leftover.
function prefixExcludes(name, sel) {
const rootRel = `wineprefixes/${name}`
const out = []
for (const game of library.loadGames()) {
if (api.appCategory(game) !== 'apps-prefix' || game.prefix !== name) continue
if (sel.has(`app:${game.id}`, game.id)) continue
const rel = String(toPortablePath(library.appFolder(game)) || '')
if (rel === rootRel || !rel.startsWith(rootRel + '/')) continue
out.push(rel)
}
return out
}
function wineUnits(sel) {
const units = new Map()
const add = (root, label) => {
if (units.has(root)) return
@ -191,20 +234,29 @@ function createPusher(api) {
add(parts[0] === 'wines' && parts[1] ? `wines/${parts[1]}` : `wines/${path.basename(wine.path)}`, wine.label)
}
for (const name of listDirs(DIRS.WINES)) add(`wines/${name}`)
return [...units.values()]
return [...units.values()].filter(unit => sel.has(unit.key, unit.id))
}
function prefixUnits() {
function prefixUnits(sel) {
const names = new Set(prefixes.listPrefixes().prefixes.map(p => p.name))
for (const name of listDirs(DIRS.PREFIXES)) names.add(name)
return [...names].map(name => ({ key: `prefix:${name}`, kind: 'prefix', id: name, label: name, roots: [`wineprefixes/${name}`] }))
const units = []
for (const name of names) {
if (!sel.has(`prefix:${name}`)) continue
const unit = { key: `prefix:${name}`, kind: 'prefix', id: name, label: name, roots: [`wineprefixes/${name}`] }
const exclude = prefixExcludes(name, sel)
if (exclude.length) unit.exclude = exclude
units.push(unit)
}
return units
}
function buildUnits(cats) {
function buildUnits(cats, selection) {
const sel = selectionOf(selection)
const units = []
if (cats.has('apps-portable') || cats.has('apps-prefix')) units.push(...appUnits(cats))
if (cats.has('wines')) units.push(...wineUnits())
if (cats.has('prefixes')) units.push(...prefixUnits())
if (cats.has('apps-portable') || cats.has('apps-prefix')) units.push(...appUnits(cats, sel))
if (cats.has('wines')) units.push(...wineUnits(sel))
if (cats.has('prefixes')) units.push(...prefixUnits(sel))
return units
}
@ -215,21 +267,27 @@ function createPusher(api) {
return library.portableGame(game)
}
function buildRegistryPayload(categories) {
function buildRegistryPayload(categories, selection) {
const cats = new Set(categories)
const sel = selectionOf(selection)
const payload = { categories: [...cats] }
if (cats.has('apps-portable') || cats.has('apps-prefix')) {
payload.apps = library
.loadGames()
.filter(game => {
const category = api.appCategory(game)
return category ? cats.has(category) : false
if (!category || !cats.has(category)) return false
return sel.has(`app:${game.id}`, game.id)
})
.map(portableApp)
// A partial list means "only these apps travel"; without the marker the
// receiver mirrors the category and drops the apps that were left out.
if (sel.active) payload.appScope = 'selected'
}
if (cats.has('wines')) {
// the receiver prunes its own builds down to this list and rescans
payload.wines = wineUnits().map(unit => ({ name: unit.id }))
// the receiver mirrors its own builds down to this list and rescans
payload.wines = wineUnits(sel).map(unit => ({ name: unit.id }))
if (sel.active) payload.wineScope = 'selected'
}
if (cats.has('settings')) {
// "Sync settings": registry-level values only; opt-in - apps must not
@ -267,8 +325,11 @@ function createPusher(api) {
const defs = prefixes.listPrefixes()
payload.prefixes = {
default: defs.default,
prefixes: defs.prefixes.map(p => ({ name: p.name, created: p.created, locked: !!p.locked }))
prefixes: defs.prefixes
.filter(p => sel.has(`prefix:${p.name}`))
.map(p => ({ name: p.name, created: p.created, locked: !!p.locked }))
}
if (sel.active) payload.prefixScope = 'selected'
}
return payload
}
@ -281,7 +342,11 @@ function createPusher(api) {
uploaded: 0,
skipped: 0,
deleted: 0,
// files the receiver asked to keep when the deletion list was reviewed
kept: 0,
bytes: 0,
// size of the synced trees as they now sit on the other device
sizeBytes: 0,
// the popup reports "3 games, 1 prefix" instead of file counts
items: { apps: 0, wines: 0, prefixes: 0 }
}
@ -293,14 +358,14 @@ function createPusher(api) {
}
// Push to the peer; a rejected code comes back as 'bad-code'.
async function syncToDevice({ deviceId, device: target, code, categories, force, onProgress }) {
async function syncToDevice({ deviceId, device: target, code, categories, force, selection, onProgress }) {
const fail = (error, summary) => syncResult(false, error, summary || newSummary(0))
const device = target || api.listDevices().find(d => d.id === deviceId)
if (!device || !device.address || !device.port) return fail('offline')
const cats = new Set(normalizeCategories(categories))
if (!cats.size) return fail('nothing-selected')
// units are known before anything is read: announce them with the verify
const units = buildUnits(cats)
const units = buildUnits(cats, selection)
const bucketOf = kind => (kind === 'wine' ? 'wines' : kind === 'prefix' ? 'prefixes' : 'apps')
const totals = { apps: 0, wines: 0, prefixes: 0, settings: 0 }
for (const unit of units) totals[bucketOf(unit.kind)] += 1
@ -372,6 +437,9 @@ function createPusher(api) {
if (error === 'auth-failed' || error === 'bad-code') forgetDevice(device.id || deviceId)
return fail(error)
}
// An older peer prunes inside its commit, with no list to approve: units
// must not travel there at all. Settings alone are additive and fine.
if (!verified.data.review && units.length) return fail('old-peer')
const token = verified.data.token
// paired now: the next sync needs no typing
rememberDevice(device.id || deviceId, code, device.name)
@ -402,6 +470,24 @@ function createPusher(api) {
await httpJson(device, 'POST', `/plwl/end?token=${encodeURIComponent(token)}`, { json: {} })
} catch {}
}
// The receiver's popup is a human, so the sender waits - visibly, and with
// a deadline. A timeout deletes nothing on purpose.
const waitForReview = async (device, token, review, emit, abort) => {
emit('review', null, null, null, true)
const deadline = Date.now() + REVIEW_WAIT_MS
while (Date.now() < deadline) {
if (abort.dead) throw new Error('receiver-lost')
try {
const res = await httpJson(device, 'GET', `/plwl/review?token=${encodeURIComponent(token)}&id=${encodeURIComponent(review.id)}`, { timeout: 20000 })
const data = res && res.data ? res.data : {}
if (data.state === 'done' || data.state === 'timeout' || data.state === 'skipped') {
return { deleted: Number(data.deleted) || 0, kept: Number(data.kept) || 0 }
}
} catch {}
await new Promise(resolve => setTimeout(resolve, REVIEW_POLL_MS))
}
return { deleted: 0, kept: 0 }
}
const runUnits = async () => {
for (const unit of units) {
if (abort.dead) throw new Error('receiver-lost')
@ -420,8 +506,15 @@ function createPusher(api) {
// for a receiver that still has to hash).
const entries = []
let writes = Promise.resolve()
// A read failure means this manifest is not the sender's whole tree:
// tell the receiver, so it never prunes what only looked missing.
const incomplete = () => {
writes = writes.then(() => write({ unitIncomplete: true }))
}
const plan = await planUnit(device, token, { ...unit, roots }, !!force, { totals }, async write => {
await buildManifestStream(roots, !!force, {
// an app the selection left out does not travel with its prefix
skip: unit.exclude,
// the receiver sizes its compare bar from this, before any hashing
onTotal: totalBytes => {
writes = writes.then(() => write({ unitBytes: totalBytes }))
@ -430,6 +523,7 @@ function createPusher(api) {
entries.push(entry)
writes = writes.then(() => write(entry))
},
onIncomplete: incomplete,
onProgress: (hashedDone, hashedTotal, hashedBytes, totalBytes) => {
barBytes = hashedBytes
barTotal = totalBytes
@ -444,6 +538,8 @@ function createPusher(api) {
})
if (abort.dead) throw new Error('receiver-lost')
if (!plan.data || !plan.data.ok) throw new Error((plan.data && plan.data.error) || `http-${plan.status}`)
// every file in the unit, including the ones already identical there
for (const entry of entries) if (entry.type === 'file') summary.sizeBytes += entry.size || 0
const upload = new Set(plan.data.upload || [])
const same = new Set(plan.data.same || [])
const filePaths = new Set(entries.filter(e => e.type === 'file').map(e => e.path))
@ -485,13 +581,20 @@ function createPusher(api) {
if (typeof api.mapLimit === 'function') await api.mapLimit(pending, UPLOAD_CONCURRENCY, send)
else for (const entry of pending) await send(entry)
if (failed) throw failed
// A dead receiver made `send` skip files; committing now would prune
// exactly the files that never arrived (the commit itself could even
// succeed while only the heartbeats were failing).
if (abort.dead) throw new Error('receiver-lost')
}
emit('copy', unit, allDone + pending.length, allTotal + pending.length, true)
// the totals count a unit's files only once they are on the wire
allDone += pending.length
allTotal += pending.length
const commit = await httpJson(device, 'POST', `/plwl/unit/commit?token=${encodeURIComponent(token)}&unit=${encodeURIComponent(unit.key)}`, { json: {} })
if (commit.data && commit.data.deleted) summary.deleted += commit.data.deleted
// a unit that did not commit (unknown unit, failed prune) must not be
// counted as done while the sync moves on to the registry
if (!commit.data || !commit.data.ok) throw new Error((commit.data && commit.data.error) || `commit-${commit.status}`)
if (commit.data.deleted) summary.deleted += commit.data.deleted
finished[kind] += 1
// a finished unit leaves a finished bar behind, even one that sent none
barTotal = barTotal || 1
@ -499,9 +602,17 @@ function createPusher(api) {
emit('commit', unit, null, null, true)
}
emit('registry', null, null, null, true)
const applied = await httpJson(device, 'POST', `/plwl/apply?token=${encodeURIComponent(token)}`, { json: buildRegistryPayload([...cats]), timeout: 60000 })
if (abort.dead) throw new Error('receiver-lost')
const applied = await httpJson(device, 'POST', `/plwl/apply?token=${encodeURIComponent(token)}`, { json: buildRegistryPayload([...cats], selection), timeout: 60000 })
if (!applied.data || !applied.data.ok) throw new Error((applied.data && applied.data.error) || `http-${applied.status}`)
if (applied.data.deleted) summary.deleted += applied.data.deleted
// Deletions wait for the receiver's approval; nothing is removed on a
// timeout, so a lost popup can only ever keep too much, never too little.
if (applied.data.review && applied.data.review.id) {
const outcome = await waitForReview(device, token, applied.data.review, emit, abort)
summary.deleted += outcome.deleted || 0
summary.kept += outcome.kept || 0
}
// a sync with nothing to send still ends with a full bar
barTotal = barTotal || 1
barBytes = barTotal
@ -520,9 +631,28 @@ function createPusher(api) {
return syncResult(true, null, summary)
}
// Everything a selection can name, in the same ids buildUnits uses - the UI
// must never derive a unit key on its own.
function items() {
const all = { active: false, has: () => true }
return {
apps: library
.loadGames()
.filter(game => api.appCategory(game))
.map(game => ({
key: `app:${game.id}`,
name: game.name || game.id,
category: api.appCategory(game),
prefix: game.prefix || ''
})),
wines: wineUnits(all).map(unit => ({ key: unit.key, name: unit.label })),
prefixes: prefixUnits(all).map(unit => ({ key: unit.key, name: unit.label }))
}
}
// putFile is exposed for the stall tests (it is the piece that must never
// leave a request half-open)
return { syncToDevice, buildUnits, buildRegistryPayload, putFile, CATEGORIES }
return { syncToDevice, buildUnits, buildRegistryPayload, items, putFile, CATEGORIES }
}
module.exports = createPusher

View file

@ -136,41 +136,79 @@ function addGame(game) {
return game
}
// Prefix imports are pinned; their patch fields are ignored. One place, so a
// batch update behaves exactly like a single one.
function normalizeGamePatch(game, patch) {
if (!patch) return {}
const out = { ...patch }
if (game.importSource === 'prefix') {
delete out.prefix
delete out.importSource
}
return sanitizeLinuxPatch(out)
}
function updateGame(id, patch) {
const games = loadGames()
const g = games.find(x => x.id === id)
if (!g) return null
// prefix imports are pinned; their patch fields are ignored
if (g.importSource === 'prefix' && patch) {
patch = { ...patch }
delete patch.prefix
delete patch.importSource
}
const clean = normalizeGamePatch(g, patch)
// playSeconds lives in its own file; the registry mirrors it
if (patch.playSeconds !== undefined) writePlaySeconds(id, patch.playSeconds)
Object.assign(g, sanitizeLinuxPatch(patch))
if (clean.playSeconds !== undefined) writePlaySeconds(id, clean.playSeconds)
Object.assign(g, clean)
saveGames(games)
return g
}
// Same patch semantics for many apps, one registry write (LAN sync settings).
function updateGames(patches) {
const games = loadGames()
let changed = 0
for (const g of games) {
const patch = patches instanceof Map ? patches.get(g.id) : patches && patches[g.id]
if (!patch) continue
const clean = normalizeGamePatch(g, patch)
if (clean.playSeconds !== undefined) writePlaySeconds(g.id, clean.playSeconds)
Object.assign(g, clean)
changed++
}
if (changed) saveGames(games)
return changed
}
function clearPlaytime(id) {
return updateGame(id, { playSeconds: 0, lastPlayed: 0 })
}
// The app's own folder - what was imported, never the folder holding the exe:
// the working directory may have been pointed elsewhere in the properties.
function appFolder(game) {
if (!game) return ''
if (game.path) return String(game.path)
return game.exePath ? path.dirname(game.exePath) : ''
}
// Delete a portable app's folder with measured progress.
async function removeGame(id, onProgress) {
const games = loadGames()
const idx = games.findIndex(g => g.id === id)
if (idx === -1) return false
const g = games[idx]
if (g.imported && g.path && g.path.startsWith(DIRS.APPS)) {
const folder = appFolder(g)
if (g.imported && folder && (folder === DIRS.APPS || folder.startsWith(DIRS.APPS + path.sep))) {
const emit = (phase, detail) => {
if (onProgress) onProgress({ phase, label: g.name || id, ...detail })
}
const { treeSize, removeTree } = require('./fsutil')
emit('count', { files: 0, bytes: 0 })
const total = await treeSize(g.path)
await removeTree(g.path, p => emit('remove', { files: p.files, bytes: p.bytes, totalFiles: total.files, totalBytes: total.bytes }))
const total = await treeSize(folder)
await removeTree(folder, p => emit('remove', { files: p.files, bytes: p.bytes, totalFiles: total.files, totalBytes: total.bytes }))
}
// icons and playtime belong to the entry, not to any prefix
for (const owned of [path.join(DIRS.DATA, 'icons', id), path.join(DIRS.DATA, 'appdata', id)]) {
try {
await fs.promises.rm(owned, { recursive: true, force: true })
} catch {}
}
// prefix imports: drop only the registry entry, never prefix files
games.splice(idx, 1)
@ -254,4 +292,4 @@ function renamePrefixRefs(oldName, newName) {
return updated
}
module.exports = { loadGames, saveGames, portableGame, getGame, isLinuxGame, addGame, updateGame, removeGame, reorderGames, renamePrefixRefs, prefixNameForGame, gamesUsingPrefix, appsUsingPrefix, isWineInUse, gamesFollowingDefault, loadSettings, saveSettings, newGame, newGameId, clearPlaytime, readPlaySeconds, writePlaySeconds, playtimeFile }
module.exports = { loadGames, saveGames, portableGame, appFolder, getGame, isLinuxGame, addGame, updateGame, updateGames, removeGame, reorderGames, renamePrefixRefs, prefixNameForGame, gamesUsingPrefix, appsUsingPrefix, isWineInUse, gamesFollowingDefault, loadSettings, saveSettings, newGame, newGameId, clearPlaytime, readPlaySeconds, writePlaySeconds, playtimeFile }

View file

@ -10,7 +10,7 @@ const os = require('os')
const path = require('path')
const { DIRS, readJson, writeJson } = require('./paths')
const { prefixPath, prefixLinuxRoot } = require('./prefixes')
const { prefixNameForGame, loadSettings } = require('./library')
const { prefixNameForGame, loadSettings, appFolder } = require('./library')
const i18n = require('./i18n')
const { spawnTracked, parseArgs } = require('./runner')
const { buildBwrapArgs, SHARE_ROOT, FS_ROOT } = require('./sandbox')
@ -46,7 +46,7 @@ function appShapes(game) {
}
function writableAppDir(game) {
const dir = (game && (game.path || (game.exePath && path.dirname(game.exePath)))) || ''
const dir = appFolder(game)
// realpathSync('') would answer with the cwd - never hand that out
if (!dir) return ''
let real

View file

@ -252,12 +252,26 @@ app.whenReady().then(async () => {
onReceive: info => sendToWindow(win, 'lansync:receiving', info),
// the consent popup for an incoming sync (see ipc/lansync.js)
onIncoming: info => sendToWindow(win, 'lansync:incoming', info),
isBusy: () => !!(gamesService && gamesService.listRunning().length)
// the deletion review: nothing is removed without this popup's answer. No
// window (headless boot, or it went away) means "keep everything".
onReview: info => {
if (!win || win.isDestroyed()) return false
if (!win.isVisible()) win.show()
win.focus()
sendToWindow(win, 'lansync:review', info)
return true
},
// a device mid-push cannot also show a receive review
isBusy: () => !!(gamesService && gamesService.listRunning().length) || lansync.isPushing()
}
try {
Promise.resolve(lansync.start(lanHooks)).catch(err => {
console.error(`[lansync] start failed: ${(err && err.message) || err}`)
})
Promise.resolve(lansync.start(lanHooks))
.then(info => {
if (!info || !info.ok) console.error(`[lansync] server not started: ${(info && info.error) || 'unknown'}`)
})
.catch(err => {
console.error(`[lansync] start failed: ${(err && err.message) || err}`)
})
} catch (err) {
console.error(`[lansync] start failed: ${(err && err.message) || err}`)
}

View file

@ -47,10 +47,17 @@ function createOpenRelay({ open = openOnHost, onError = null } = {}) {
}
}
// a request written before the watcher was up (the app's first window) must
// not be missed, so drain once first
// Two apps can share one prefix (the default), so the watcher is refcounted:
// the first exit must not take the folder away from the app still running.
// A request written before the watcher was up (the app's first window) must
// not be missed, so drain once first.
function watch(root) {
if (!root || watchers.has(root)) return
if (!root) return
const existing = watchers.get(root)
if (existing) {
existing.refs++
return
}
const file = requestFile(root)
// best effort: a missing folder only means nothing was ever asked for
try {
@ -61,20 +68,27 @@ function createOpenRelay({ open = openOnHost, onError = null } = {}) {
clearTimeout(timer)
timer = setTimeout(() => drain(root), DRAIN_DEBOUNCE_MS)
})
watcher.on('error', () => unwatch(root))
watchers.set(root, watcher)
watcher.on('error', () => close(root))
watchers.set(root, { watcher, refs: 1 })
drain(root)
}
function unwatch(root) {
const watcher = watchers.get(root)
if (!watcher) return
function close(root) {
const entry = watchers.get(root)
if (!entry) return
try {
watcher.close()
entry.watcher.close()
} catch {}
watchers.delete(root)
}
function unwatch(root) {
const entry = watchers.get(root)
if (!entry) return
entry.refs--
if (entry.refs <= 0) close(root)
}
return { watch, unwatch, drain, requestFile }
}

View file

@ -131,15 +131,17 @@ function createPrefix(name) {
async function deletePrefix(name, onProgress) {
if (String(name).toLowerCase() === MAIN.toLowerCase()) return { ok: false, error: 'isMain' }
const defs = listPrefixes()
defs.prefixes = defs.prefixes.filter(p => p.name !== name)
if (defs.default === name) defs.default = MAIN
savePrefixes(defs)
const emit = (phase, detail) => {
if (onProgress) onProgress({ phase, label: name, ...detail })
}
emit('count', { files: 0, bytes: 0 })
const total = await treeSize(prefixPath(name))
await removeTree(prefixPath(name), p => emit('remove', { files: p.files, bytes: p.bytes, totalFiles: total.files, totalBytes: total.bytes }))
// the registry forgets it only once the folder is gone, so an interrupted
// delete stays visible and can be retried
defs.prefixes = defs.prefixes.filter(p => p.name !== name)
if (defs.default === name) defs.default = MAIN
savePrefixes(defs)
return { ok: true }
}

View file

@ -22,6 +22,8 @@ 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')
// the receiver's deletion review: an answer here decides what may be removed
const onLanSyncReview = singleSlot('lansync:review')
const fs = require('fs')
const path = require('path')
@ -96,6 +98,10 @@ contextBridge.exposeInMainWorld('plwl', {
lansyncClearHashCache: () => ipcRenderer.invoke('lansync:clearHashCache'),
lansyncSync: payload => ipcRenderer.invoke('lansync:sync', payload),
lansyncRespondIncoming: (requestId, accept) => ipcRenderer.invoke('lansync:respondIncoming', requestId, accept),
lansyncItems: () => ipcRenderer.invoke('lansync:items'),
lansyncReviewTree: (id, rel) => ipcRenderer.invoke('lansync:reviewTree', id, rel),
lansyncReviewDecide: (id, decision) => ipcRenderer.invoke('lansync:reviewDecide', id, decision),
onLanSyncReview: onLanSyncReview,
onLanSyncDevices: cb => ipcRenderer.on('lansync:devices', (e, data) => cb(data)),
onLanSyncProgress: onLanSyncProgress,
onLanSyncIncoming: onLanSyncIncoming,

View file

@ -58,17 +58,8 @@ async function scanBuild(dir, depth) {
path: dir,
type
}
if (type === 'proton') {
entry.wineBin = path.join(dir, 'proton')
} else if (type === 'wine') {
const bin = findWineBinary(dir)
if (bin) {
entry.wineBin = bin
const ver = versionOf(bin, {})
if (ver) entry.version = ver
}
}
entry.label = labelFor(type, dir, entry.version)
if (type === 'proton') entry.wineBin = path.join(dir, 'proton')
else if (type === 'wine') entry.wineBin = findWineBinary(dir) || undefined
return [entry]
}
if (depth <= 0) return []
@ -118,6 +109,13 @@ async function refreshWines() {
// display name and disabled flag live in the registry and survive rescans
const renamed = new Map(prev.filter(w => w.displayName).map(w => [w.id, w.displayName]))
for (const w of found) {
const old = prevById.get(w.id)
// `wine --version` spawns a process; an unchanged build keeps its answer
if (w.type === 'wine' && w.wineBin) {
if (old && old.path === w.path && old.wineBin === w.wineBin && old.version) w.version = old.version
else w.version = versionOf(w.wineBin, {})
}
w.label = labelFor(w.type, w.path, w.version)
if (disabled.has(w.id)) w.disabled = true
if (renamed.has(w.id)) w.displayName = renamed.get(w.id)
applyDisplayName(w)
@ -161,23 +159,53 @@ function clearWineDisplayName(id) {
return writeWineLabel(id, '')
}
// An empty label means "use the folder-derived one".
function applyWineLabel(w, clean) {
if (clean) {
w.displayName = clean
w.label = clean
return
}
delete w.displayName
w.label = labelFor(w.type, w.path, w.version)
}
function writeWineLabel(id, clean) {
const wines = getWines()
const w = wines.find(x => x.id === id)
if (!w) return { ok: false, error: 'notFound' }
if (w.type === 'system') return { ok: false, error: 'isSystem' }
if (clean) {
w.displayName = clean
w.label = clean
} else {
if (!w.displayName) return { ok: true, displayName: '' }
delete w.displayName
w.label = labelFor(w.type, w.path, w.version)
}
if (!clean && !w.displayName) return { ok: true, displayName: '' }
applyWineLabel(w, clean)
writeJson(REGISTRY, wines.map(wineRel))
return { ok: true, displayName: clean }
}
// LAN sync: display names + disabled flags for many builds, one registry write.
function applySettings(entries) {
const wines = getWines()
const byId = new Map(wines.map(w => [w.id, w]))
let applied = 0
for (const entry of entries) {
if (!entry || typeof entry.name !== 'string') continue
const wine = byId.get(entry.name)
if (!wine || wine.type === 'system') continue
let changed = false
const wanted = normalizeLabel(typeof entry.displayName === 'string' ? entry.displayName : '')
if (wanted !== (wine.displayName || '')) {
applyWineLabel(wine, wanted)
changed = true
}
if (entry.disabled !== undefined && !!entry.disabled !== !!wine.disabled) {
wine.disabled = !!entry.disabled
changed = true
}
if (changed) applied++
}
if (applied) writeJson(REGISTRY, wines.map(wineRel))
return applied
}
function setWineDisabled(id, disabled) {
const wines = getWines()
const w = wines.find(x => x.id === id)
@ -219,6 +247,10 @@ async function deleteWine(id, onProgress) {
emit('count', { files: 0, bytes: 0 })
const total = await treeSize(w.path)
await removeTree(w.path, p => emit('remove', { files: p.files, bytes: p.bytes, totalFiles: total.files, totalBytes: total.bytes }))
// the Proton compat link points into data/, not into the build we just removed
try {
fs.unlinkSync(path.join(DIRS.DATA, 'compat', id))
} catch {} // no link (plain wine build) or already gone - both fine
writeJson(REGISTRY, wines.filter(x => x.id !== id).map(wineRel))
if (getDefaultWine() === id) require('./library').saveSettings({ defaultWine: 'system' })
return { ok: true }
@ -300,7 +332,12 @@ async function importWineFolder(srcDir, onProgress) {
}
emit('count', { files: 0, bytes: 0 })
const total = await treeSize(copySrc)
await copyTree(copySrc, dst, p => emit('copy', { files: p.done, bytes: p.bytes, totalFiles: total.files, totalBytes: total.bytes }))
const copied = await copyTree(copySrc, dst, p => emit('copy', { files: p.done, bytes: p.bytes, totalFiles: total.files, totalBytes: total.bytes }))
if (copied.failed) {
// a build missing files fails at the first Proton launch; do not register it
await fsp.rm(dst, { recursive: true, force: true })
throw new Error('copy-incomplete')
}
let healed = { links: 0, executables: 0 }
try {
healed = await healBuild(dst)
@ -310,14 +347,19 @@ async function importWineFolder(srcDir, onProgress) {
return { ok: true, path: toPortablePath(dst), symlinksFixed: healed.links, executablesFixed: healed.executables }
}
// Pseudo entry for the distro's wine; version probe is best-effort.
// Pseudo entry for the distro's wine; version probe is best-effort and cached:
// this runs on every library refresh, and a synchronous spawn blocked the UI.
let systemVersion = null
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 }
if (systemVersion === null) {
try {
const { execFileSync } = require('child_process')
systemVersion = String(execFileSync('wine', ['--version'], { timeout: 15000 })).trim().replace(/^wine-?/i, '')
} catch {
systemVersion = ''
}
}
return { id: 'system', type: 'system', label: 'System Wine', version: systemVersion }
}
module.exports = { refreshWines, getWines, getWine, resolveWine, systemWine, setWineDisabled, setWineDisplayName, clearWineDisplayName, getDefaultWine, setDefaultWine, deleteWine, importWineFolder, findWineBinary, detectType, fixBuildSymlinks, healBuild }
module.exports = { refreshWines, getWines, getWine, resolveWine, systemWine, setWineDisabled, setWineDisplayName, clearWineDisplayName, applySettings, getDefaultWine, setDefaultWine, deleteWine, importWineFolder, findWineBinary, detectType, fixBuildSymlinks, healBuild }