[TEST] selective sync
This commit is contained in:
parent
be4f67fb60
commit
aee4d3d1f2
15 changed files with 1228 additions and 182 deletions
10
README.md
10
README.md
|
|
@ -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.
|
- **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).
|
- **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).
|
**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. A tree the sender could not fully read is the exception: that unit is still written, but nothing on the other side is deleted for it, because a missing entry may only mean it was unreadable. 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.
|
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.
|
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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,8 @@ src/ main process (Node/Electron backend)
|
||||||
(with its receive-progress hook); uses hashcache.js,
|
(with its receive-progress hook); uses hashcache.js,
|
||||||
lansync-identity.js, lansync-manifest.js, lansync-discovery.js
|
lansync-identity.js, lansync-manifest.js, lansync-discovery.js
|
||||||
lansyncpush.js the pushing side (createPusher factory, keeps helper state out)
|
lansyncpush.js the pushing side (createPusher factory, keeps helper state out)
|
||||||
|
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
|
hashcache.js file hash cache (path -> size/mtime/hash) for LAN sync
|
||||||
lansync-identity.js identity + sync code (createIdentity factory)
|
lansync-identity.js identity + sync code (createIdentity factory)
|
||||||
lansync-manifest.js sender-side manifest building (createManifestBuilder)
|
lansync-manifest.js sender-side manifest building (createManifestBuilder)
|
||||||
|
|
@ -72,6 +74,7 @@ Renderer never touches Node/Electron directly — everything goes through the `w
|
||||||
- Paths stored on disk are FILES-relative (portable layout): write with toPortablePath(), read with resolvePortablePath() — never store absolute paths for anything inside files/.
|
- 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. A settings sync batches its patches instead of one write per app/build (`library.updateGames`, `wines.applySettings`).
|
- 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.
|
- 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.
|
- 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).
|
- 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).
|
- 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).
|
||||||
|
|
@ -105,6 +108,7 @@ Renderer never touches Node/Electron directly — everything goes through the `w
|
||||||
### LAN sync
|
### 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).
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
|
|
||||||
|
|
@ -255,8 +255,8 @@
|
||||||
"lansync.prefixes": "Wine prefixes",
|
"lansync.prefixes": "Wine prefixes",
|
||||||
"lansync.settings": "Sync settings (names, app configs and order)",
|
"lansync.settings": "Sync settings (names, app configs and order)",
|
||||||
"lansync.force": "Force sync all files (skip the hash check and overwrite everything)",
|
"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.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: files and items on this device that do not exist there will be deleted.",
|
"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.start": "Start sync",
|
||||||
"lansync.progress": "Syncing to {0}…",
|
"lansync.progress": "Syncing to {0}…",
|
||||||
"lansync.waiting": "Waiting for the other device…",
|
"lansync.waiting": "Waiting for the other device…",
|
||||||
|
|
@ -267,7 +267,8 @@
|
||||||
"lansync.phase.copy": "Sending {0}",
|
"lansync.phase.copy": "Sending {0}",
|
||||||
"lansync.recvPhase.copy": "Receiving {0}",
|
"lansync.recvPhase.copy": "Receiving {0}",
|
||||||
"lansync.phase.registry": "Updating the library",
|
"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.phase.done": "Finishing up",
|
||||||
"lansync.progressFiles": "{0} / {1} files",
|
"lansync.progressFiles": "{0} / {1} files",
|
||||||
"lansync.progressBytes": "{0} / {1} transferred",
|
"lansync.progressBytes": "{0} / {1} transferred",
|
||||||
|
|
@ -299,6 +300,21 @@
|
||||||
"lansync.summaryDeleted": "Removed",
|
"lansync.summaryDeleted": "Removed",
|
||||||
"lansync.summaryTransferred": "Transferred",
|
"lansync.summaryTransferred": "Transferred",
|
||||||
"lansync.summarySize": "Synced data size",
|
"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.summaryYes": "yes",
|
||||||
"lansync.failed": "Sync failed: {0}",
|
"lansync.failed": "Sync failed: {0}",
|
||||||
"lansync.badCode": "Wrong sync code. Enter the code shown on the other device.",
|
"lansync.badCode": "Wrong sync code. Enter the code shown on the other device.",
|
||||||
|
|
@ -309,6 +325,8 @@
|
||||||
"lansync.idRerolled": "New device ID generated",
|
"lansync.idRerolled": "New device ID generated",
|
||||||
"lansync.codeRerolled": "New sync code generated",
|
"lansync.codeRerolled": "New sync code generated",
|
||||||
"lansync.unknownError": "unknown error",
|
"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.title": "Open with Wine",
|
||||||
"openwith.file": "File",
|
"openwith.file": "File",
|
||||||
"openwith.fileMissing": "This file no longer exists (or is not a Windows executable).",
|
"openwith.fileMissing": "This file no longer exists (or is not a Windows executable).",
|
||||||
|
|
|
||||||
|
|
@ -255,8 +255,8 @@
|
||||||
"lansync.prefixes": "Prefiksy Wine",
|
"lansync.prefixes": "Prefiksy Wine",
|
||||||
"lansync.settings": "Ustawienia (nazwy, konfiguracje i kolejność aplikacji)",
|
"lansync.settings": "Ustawienia (nazwy, konfiguracje i kolejność aplikacji)",
|
||||||
"lansync.force": "Wymuś synchronizację wszystkich plików (pomiń sprawdzanie hashy i nadpisz wszystko)",
|
"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.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: pliki i elementy na tym urządzeniu, których tam nie ma, zostaną usunięte.",
|
"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.start": "Rozpocznij synchronizację",
|
||||||
"lansync.progress": "Synchronizowanie na {0}…",
|
"lansync.progress": "Synchronizowanie na {0}…",
|
||||||
"lansync.waiting": "Oczekiwanie na drugie urządzenie…",
|
"lansync.waiting": "Oczekiwanie na drugie urządzenie…",
|
||||||
|
|
@ -267,7 +267,8 @@
|
||||||
"lansync.phase.copy": "Wysyłanie {0}",
|
"lansync.phase.copy": "Wysyłanie {0}",
|
||||||
"lansync.recvPhase.copy": "Odbieranie {0}",
|
"lansync.recvPhase.copy": "Odbieranie {0}",
|
||||||
"lansync.phase.registry": "Aktualizowanie biblioteki",
|
"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.phase.done": "Kończenie",
|
||||||
"lansync.progressFiles": "{0} / {1} plików",
|
"lansync.progressFiles": "{0} / {1} plików",
|
||||||
"lansync.progressBytes": "{0} / {1} przesłane",
|
"lansync.progressBytes": "{0} / {1} przesłane",
|
||||||
|
|
@ -299,6 +300,21 @@
|
||||||
"lansync.summaryDeleted": "Usunięte",
|
"lansync.summaryDeleted": "Usunięte",
|
||||||
"lansync.summaryTransferred": "Przesłano",
|
"lansync.summaryTransferred": "Przesłano",
|
||||||
"lansync.summarySize": "Rozmiar zsynchronizowanych danych",
|
"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.summaryYes": "tak",
|
||||||
"lansync.failed": "Synchronizacja nie powiodła się: {0}",
|
"lansync.failed": "Synchronizacja nie powiodła się: {0}",
|
||||||
"lansync.badCode": "Błędny kod synchronizacji. Wpisz kod pokazany na drugim urządzeniu.",
|
"lansync.badCode": "Błędny kod synchronizacji. Wpisz kod pokazany na drugim urządzeniu.",
|
||||||
|
|
@ -309,6 +325,8 @@
|
||||||
"lansync.idRerolled": "Wygenerowano nowe ID urządzenia",
|
"lansync.idRerolled": "Wygenerowano nowe ID urządzenia",
|
||||||
"lansync.codeRerolled": "Wygenerowano nowy kod synchronizacji",
|
"lansync.codeRerolled": "Wygenerowano nowy kod synchronizacji",
|
||||||
"lansync.unknownError": "nieznany błąd",
|
"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.title": "Otwórz przez Wine",
|
||||||
"openwith.file": "Plik",
|
"openwith.file": "Plik",
|
||||||
"openwith.fileMissing": "Ten plik już nie istnieje (albo nie jest plikiem wykonywalnym Windows).",
|
"openwith.fileMissing": "Ten plik już nie istnieje (albo nie jest plikiem wykonywalnym Windows).",
|
||||||
|
|
|
||||||
420
renderer/app.js
420
renderer/app.js
|
|
@ -138,6 +138,8 @@ function settleModal(value) {
|
||||||
|
|
||||||
function showModal(html, opts) {
|
function showModal(html, opts) {
|
||||||
modalLocked = !!(opts && opts.locked)
|
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)
|
settleModal(null)
|
||||||
if (modalTeardownTimer) {
|
if (modalTeardownTimer) {
|
||||||
clearTimeout(modalTeardownTimer)
|
clearTimeout(modalTeardownTimer)
|
||||||
|
|
@ -206,7 +208,7 @@ function closeModal() {
|
||||||
modalSwapTimer = null
|
modalSwapTimer = null
|
||||||
}
|
}
|
||||||
overlay.style.display = 'none'
|
overlay.style.display = 'none'
|
||||||
modal.classList.remove('closing', 'swapping', 'resizing')
|
modal.classList.remove('closing', 'swapping', 'resizing', 'wide')
|
||||||
modal.style.height = ''
|
modal.style.height = ''
|
||||||
modal.innerHTML = ''
|
modal.innerHTML = ''
|
||||||
modalLocked = false
|
modalLocked = false
|
||||||
|
|
@ -656,7 +658,8 @@ function renderLibrary() {
|
||||||
applyRunning(false)
|
applyRunning(false)
|
||||||
}
|
}
|
||||||
card.querySelector('[data-act=config]').onclick = () => openGameConfig(game.id)
|
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 () => {
|
card.querySelector('[data-act=remove]').onclick = async () => {
|
||||||
const note = game.mode === 'copy' || game.mode === 'move'
|
const note = game.mode === 'copy' || game.mode === 'move'
|
||||||
? t('library.removeFilesNote')
|
? t('library.removeFilesNote')
|
||||||
|
|
@ -1644,6 +1647,7 @@ function showSyncSummary({ from, received, categories, summary }) {
|
||||||
const files = Math.max(0, Number(summary && summary.uploaded) || 0)
|
const files = Math.max(0, Number(summary && summary.uploaded) || 0)
|
||||||
const skipped = Math.max(0, Number(summary && summary.skipped) || 0)
|
const skipped = Math.max(0, Number(summary && summary.skipped) || 0)
|
||||||
const deleted = Math.max(0, Number(summary && summary.deleted) || 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 bytes = Math.max(0, Number(summary && summary.bytes) || 0)
|
||||||
const sizeBytes = Math.max(0, Number(summary && summary.sizeBytes) || 0)
|
const sizeBytes = Math.max(0, Number(summary && summary.sizeBytes) || 0)
|
||||||
const rows = []
|
const rows = []
|
||||||
|
|
@ -1655,10 +1659,11 @@ function showSyncSummary({ from, received, categories, summary }) {
|
||||||
if (wines || cats.has('wines')) row(t('lansync.wines'), wines)
|
if (wines || cats.has('wines')) row(t('lansync.wines'), wines)
|
||||||
if (prefixes || cats.has('prefixes')) row(t('lansync.prefixes'), prefixes)
|
if (prefixes || cats.has('prefixes')) row(t('lansync.prefixes'), prefixes)
|
||||||
if (cats.has('settings')) row(t('lansync.summarySettings'), t('lansync.summaryYes'))
|
if (cats.has('settings')) row(t('lansync.summarySettings'), t('lansync.summaryYes'))
|
||||||
if (files || skipped || deleted) {
|
if (files || skipped || deleted || kept) {
|
||||||
row(t(received ? 'lansync.summaryReceived' : 'lansync.summarySent'), files)
|
row(t(received ? 'lansync.summaryReceived' : 'lansync.summarySent'), files)
|
||||||
if (skipped) row(t('lansync.summarySkipped'), skipped)
|
if (skipped) row(t('lansync.summarySkipped'), skipped)
|
||||||
if (deleted) row(t('lansync.summaryDeleted'), deleted)
|
if (deleted) row(t('lansync.summaryDeleted'), deleted)
|
||||||
|
if (kept) row(t('lansync.summaryKept'), kept)
|
||||||
}
|
}
|
||||||
if (bytes) row(t('lansync.summaryTransferred'), formatBytes(bytes))
|
if (bytes) row(t('lansync.summaryTransferred'), formatBytes(bytes))
|
||||||
if (sizeBytes) row(t('lansync.summarySize'), formatBytes(sizeBytes))
|
if (sizeBytes) row(t('lansync.summarySize'), formatBytes(sizeBytes))
|
||||||
|
|
@ -1757,15 +1762,7 @@ async function openLanSyncModal(device, errorKey) {
|
||||||
code.focus()
|
code.focus()
|
||||||
}
|
}
|
||||||
modal.querySelector('#ls-what-label').textContent = t('lansync.what')
|
modal.querySelector('#ls-what-label').textContent = t('lansync.what')
|
||||||
const box = modal.querySelector('#ls-categories')
|
const pickers = await wireLanPickers(modal)
|
||||||
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)
|
|
||||||
}
|
|
||||||
modal.querySelector('#ls-force-label').textContent = t('lansync.force')
|
modal.querySelector('#ls-force-label').textContent = t('lansync.force')
|
||||||
modal.querySelector('#ls-warning').textContent = t('lansync.mirrorWarning')
|
modal.querySelector('#ls-warning').textContent = t('lansync.mirrorWarning')
|
||||||
modal.querySelector('#ls-cancel').textContent = t('dialog.cancel')
|
modal.querySelector('#ls-cancel').textContent = t('dialog.cancel')
|
||||||
|
|
@ -1780,16 +1777,172 @@ async function openLanSyncModal(device, errorKey) {
|
||||||
code.focus()
|
code.focus()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const categories = LAN_CATEGORIES.map(c => c.id).filter(id => modal.querySelector('#ls-cat-' + id).checked)
|
const picked = pickers.collect()
|
||||||
if (!categories.length) {
|
if (!picked.categories.length) {
|
||||||
toast(t('lansync.noneSelected'), 'error')
|
toast(t('lansync.noneSelected'), 'error')
|
||||||
return
|
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
|
// locked: a running sync is not cancellable by a stray click on the backdrop
|
||||||
const modal = showModal(`
|
const modal = showModal(`
|
||||||
<h2 id="ls-run-title"></h2>
|
<h2 id="ls-run-title"></h2>
|
||||||
|
|
@ -1823,7 +1976,7 @@ async function runLanSync(device, code, categories, force) {
|
||||||
})
|
})
|
||||||
let res = null
|
let res = null
|
||||||
try {
|
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) {
|
} catch (err) {
|
||||||
res = { ok: false, error: (err && err.message) || '' }
|
res = { ok: false, error: (err && err.message) || '' }
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -1850,6 +2003,7 @@ async function runLanSync(device, code, categories, force) {
|
||||||
: err === 'offline' || err === 'receiver-lost' ? t('lansync.offline')
|
: err === 'offline' || err === 'receiver-lost' ? t('lansync.offline')
|
||||||
: err === 'rejected' ? t('lansync.rejected')
|
: err === 'rejected' ? t('lansync.rejected')
|
||||||
: err === 'incoming-timeout' ? t('lansync.incomingTimedOut')
|
: err === 'incoming-timeout' ? t('lansync.incomingTimedOut')
|
||||||
|
: err === 'old-peer' ? t('lansync.oldPeer')
|
||||||
: (err || t('lansync.unknownError'))
|
: (err || t('lansync.unknownError'))
|
||||||
toast(t('lansync.failed', message), 'error')
|
toast(t('lansync.failed', message), 'error')
|
||||||
}
|
}
|
||||||
|
|
@ -1990,6 +2144,13 @@ function onLanReceiving(info) {
|
||||||
}
|
}
|
||||||
if (info.stage === 'done') {
|
if (info.stage === 'done') {
|
||||||
receiveEnded = true
|
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
|
// the popup may have been closed by the watchdog: still show the result
|
||||||
// if nothing else owns the modal slot
|
// if nothing else owns the modal slot
|
||||||
if (!receiving) {
|
if (!receiving) {
|
||||||
|
|
@ -2075,6 +2236,7 @@ function showReceiveSummary(info) {
|
||||||
uploaded: applied.files ?? info.done,
|
uploaded: applied.files ?? info.done,
|
||||||
skipped: applied.skipped || 0,
|
skipped: applied.skipped || 0,
|
||||||
deleted: applied.deleted ?? info.deleted,
|
deleted: applied.deleted ?? info.deleted,
|
||||||
|
kept: applied.kept ?? 0,
|
||||||
bytes: applied.bytes ?? info.bytes,
|
bytes: applied.bytes ?? info.bytes,
|
||||||
sizeBytes: applied.sizeBytes ?? info.sizeBytes,
|
sizeBytes: applied.sizeBytes ?? info.sizeBytes,
|
||||||
items: applied.items || info.items || {}
|
items: applied.items || info.items || {}
|
||||||
|
|
@ -2082,6 +2244,227 @@ function showReceiveSummary(info) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 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).
|
// A received sync changed local files: reload every list (settings included).
|
||||||
async function reloadLocalData() {
|
async function reloadLocalData() {
|
||||||
const [wines, prefixes, games, settings] = await Promise.all([
|
const [wines, prefixes, games, settings] = await Promise.all([
|
||||||
|
|
@ -2107,6 +2490,7 @@ function wireLanSync() {
|
||||||
})
|
})
|
||||||
window.plwl.onLanSyncReceiving(info => onLanReceiving(info))
|
window.plwl.onLanSyncReceiving(info => onLanReceiving(info))
|
||||||
window.plwl.onLanSyncIncoming(info => onLanIncoming(info))
|
window.plwl.onLanSyncIncoming(info => onLanIncoming(info))
|
||||||
|
window.plwl.onLanSyncReview(info => onLanReview(info))
|
||||||
window.plwl.onLanSyncApplied(async info => {
|
window.plwl.onLanSyncApplied(async info => {
|
||||||
// the summary popup replaces the toast; reload behind it
|
// the summary popup replaces the toast; reload behind it
|
||||||
lastApplied = info
|
lastApplied = info
|
||||||
|
|
@ -2220,7 +2604,7 @@ function wireEvents() {
|
||||||
})
|
})
|
||||||
qs('#gc-icon-browse').addEventListener('click', async () => {
|
qs('#gc-icon-browse').addEventListener('click', async () => {
|
||||||
// start in the app's own folder, where the icon exe most likely lives
|
// 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({
|
const p = await window.plwl.dialogPickFile({
|
||||||
properties: ['openFile'],
|
properties: ['openFile'],
|
||||||
defaultPath: start || window.plwl.FILES_DIR,
|
defaultPath: start || window.plwl.FILES_DIR,
|
||||||
|
|
|
||||||
|
|
@ -209,6 +209,54 @@ content {
|
||||||
.sync-summary-row span { color: var(--darker-text-color); }
|
.sync-summary-row span { color: var(--darker-text-color); }
|
||||||
.sync-summary-row strong { font-weight: 700; font-variant-numeric: tabular-nums; }
|
.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 {
|
.button {
|
||||||
height: var(--button-height);
|
height: var(--button-height);
|
||||||
padding: 0 1rem;
|
padding: 0 1rem;
|
||||||
|
|
@ -711,6 +759,7 @@ select {
|
||||||
from { opacity: 0; }
|
from { opacity: 0; }
|
||||||
to { opacity: 1; }
|
to { opacity: 1; }
|
||||||
}
|
}
|
||||||
|
.modal.wide { max-width: 44rem; }
|
||||||
.modal h2 {
|
.modal h2 {
|
||||||
font-family: 'Nunito Black', 'Nunito', sans-serif;
|
font-family: 'Nunito Black', 'Nunito', sans-serif;
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,9 @@ function registerLanSyncIpc({ getWindow }) {
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
})
|
})
|
||||||
ipcMain.handle('lansync:respondIncoming', (e, requestId, accept) => lansync.respondIncoming(requestId, accept))
|
ipcMain.handle('lansync:respondIncoming', (e, requestId, accept) => lansync.respondIncoming(requestId, accept))
|
||||||
|
ipcMain.handle('lansync: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) => {
|
ipcMain.handle('lansync:sync', async (e, payload) => {
|
||||||
try {
|
try {
|
||||||
return await lansync.syncTo({
|
return await lansync.syncTo({
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ function createManifestBuilder({ filesPath, filesRoot, hash, mapLimit, concurren
|
||||||
// file entries only land in `out` (their digest comes later). `onError`
|
// 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
|
// marks the unit incomplete: an entry we could not read is not an entry the
|
||||||
// sender decided to delete.
|
// sender decided to delete.
|
||||||
async function scanTree(abs, rel, out, mtimes, emit, onError) {
|
async function scanTree(abs, rel, out, mtimes, emit, onError, skip) {
|
||||||
const queue = [[abs, rel]]
|
const queue = [[abs, rel]]
|
||||||
while (queue.length) {
|
while (queue.length) {
|
||||||
const [dirAbs, dirRel] = queue.pop()
|
const [dirAbs, dirRel] = queue.pop()
|
||||||
|
|
@ -41,6 +41,7 @@ function createManifestBuilder({ filesPath, filesRoot, hash, mapLimit, concurren
|
||||||
const leaves = []
|
const leaves = []
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const relChild = `${dirRel}/${entry.name}`
|
const relChild = `${dirRel}/${entry.name}`
|
||||||
|
if (skip && skip.has(relChild)) continue // a selection left it out
|
||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
const record = { path: relChild, type: 'dir' }
|
const record = { path: relChild, type: 'dir' }
|
||||||
out.push(record)
|
out.push(record)
|
||||||
|
|
@ -84,11 +85,13 @@ function createManifestBuilder({ filesPath, filesRoot, hash, mapLimit, concurren
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manifest of one unit, streamed: `handlers` may carry onEntry (a Promise
|
// Manifest of one unit, streamed: `handlers` may carry onEntry (a Promise
|
||||||
// per entry, awaited - backpressure), onTotal, onProgress and onIncomplete
|
// per entry, awaited - backpressure), onTotal, onProgress, onIncomplete
|
||||||
// (called once, when anything in the tree could not be read). File entries
|
// (called once, when anything in the tree could not be read) and skip (unit
|
||||||
// go out as their digests finish, so the receiver compares while this side
|
// paths a selection keeps off the wire). File entries go out as their
|
||||||
// is still hashing; hashing itself stays parallel, a batch at a time.
|
// 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) {
|
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 onEntry = handlers && handlers.onEntry
|
||||||
const onTotal = handlers && handlers.onTotal
|
const onTotal = handlers && handlers.onTotal
|
||||||
const onProgress = handlers && handlers.onProgress
|
const onProgress = handlers && handlers.onProgress
|
||||||
|
|
@ -102,6 +105,7 @@ function createManifestBuilder({ filesPath, filesRoot, hash, mapLimit, concurren
|
||||||
if (onIncomplete) onIncomplete(err, file)
|
if (onIncomplete) onIncomplete(err, file)
|
||||||
}
|
}
|
||||||
for (const rel of roots) {
|
for (const rel of roots) {
|
||||||
|
if (skip && skip.has(rel)) continue
|
||||||
const abs = filesPath(rel)
|
const abs = filesPath(rel)
|
||||||
if (!abs) {
|
if (!abs) {
|
||||||
markIncomplete(new Error('bad-unit-path'), rel)
|
markIncomplete(new Error('bad-unit-path'), rel)
|
||||||
|
|
@ -130,7 +134,7 @@ function createManifestBuilder({ filesPath, filesRoot, hash, mapLimit, concurren
|
||||||
const record = { path: rel, type: 'dir' }
|
const record = { path: rel, type: 'dir' }
|
||||||
entries.push(record)
|
entries.push(record)
|
||||||
if (onEntry) await onEntry(record)
|
if (onEntry) await onEntry(record)
|
||||||
await scanTree(abs, rel, entries, mtimes, onEntry, markIncomplete)
|
await scanTree(abs, rel, entries, mtimes, onEntry, markIncomplete, skip)
|
||||||
} else if (st.isFile()) {
|
} else if (st.isFile()) {
|
||||||
entries.push({ path: rel, type: 'file', size: st.size, mode: st.mode & 0o777, hash: null })
|
entries.push({ path: rel, type: 'file', size: st.size, mode: st.mode & 0o777, hash: null })
|
||||||
mtimes.set(rel, st.mtimeMs || 0)
|
mtimes.set(rel, st.mtimeMs || 0)
|
||||||
|
|
|
||||||
167
src/lansync-plan.js
Normal file
167
src/lansync-plan.js
Normal 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 }
|
||||||
485
src/lansync.js
485
src/lansync.js
|
|
@ -11,13 +11,14 @@ const fs = require('fs')
|
||||||
const fsp = fs.promises
|
const fsp = fs.promises
|
||||||
const readline = require('readline')
|
const readline = require('readline')
|
||||||
|
|
||||||
const { DIRS, readJson, writeJson, toPortablePath } = require('./paths')
|
const { DIRS, toPortablePath } = require('./paths')
|
||||||
const { rewireSymlinks, restoreExecutableBits } = require('./symlinks')
|
const { rewireSymlinks, restoreExecutableBits } = require('./symlinks')
|
||||||
const { progressGate, mapLimit } = require('./fsutil')
|
const { progressGate, mapLimit } = require('./fsutil')
|
||||||
const { createHashCache } = require('./hashcache')
|
const { createHashCache } = require('./hashcache')
|
||||||
const { createIdentity, sameCode } = require('./lansync-identity')
|
const { createIdentity, sameCode } = require('./lansync-identity')
|
||||||
const { createManifestBuilder } = require('./lansync-manifest')
|
const { createManifestBuilder } = require('./lansync-manifest')
|
||||||
const { createDiscovery } = require('./lansync-discovery')
|
const { createDiscovery } = require('./lansync-discovery')
|
||||||
|
const { planEntry, planListed, walkDeletions, removeNodes, totalsOf } = require('./lansync-plan')
|
||||||
const { normalizePathName } = require('./names')
|
const { normalizePathName } = require('./names')
|
||||||
const library = require('./library')
|
const library = require('./library')
|
||||||
const wines = require('./wines')
|
const wines = require('./wines')
|
||||||
|
|
@ -42,6 +43,8 @@ const TOKEN_TTL_MS = Number(process.env.PLWL_LANSYNC_TOKEN_TTL) || 30 * 60 * 100
|
||||||
const SESSION_IDLE_MS = 5 * 60 * 1000
|
const SESSION_IDLE_MS = 5 * 60 * 1000
|
||||||
// Receiver consent window; the sender's verify timeout must exceed it.
|
// Receiver consent window; the sender's verify timeout must exceed it.
|
||||||
const INCOMING_TIMEOUT_MS = Number(process.env.PLWL_LANSYNC_INCOMING_TIMEOUT) || 60 * 1000
|
const INCOMING_TIMEOUT_MS = Number(process.env.PLWL_LANSYNC_INCOMING_TIMEOUT) || 60 * 1000
|
||||||
|
// How long a deletion list may wait for the user. A timeout approves nothing.
|
||||||
|
const REVIEW_TIMEOUT_MS = Number(process.env.PLWL_LANSYNC_REVIEW_TIMEOUT) || 10 * 60 * 1000
|
||||||
|
|
||||||
// The only trees a sync may touch; the rest of files/ is local state.
|
// The only trees a sync may touch; the rest of files/ is local state.
|
||||||
const SYNC_ROOTS = ['apps/', 'wines/', 'wineprefixes/', 'data/icons/']
|
const SYNC_ROOTS = ['apps/', 'wines/', 'wineprefixes/', 'data/icons/']
|
||||||
|
|
@ -322,32 +325,6 @@ function removeAbs(abs) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeTree(rel) {
|
|
||||||
const abs = filesPath(rel)
|
|
||||||
if (!abs) return 0
|
|
||||||
try {
|
|
||||||
if (!fs.lstatSync(abs)) return 0 // nothing to delete -> do not count it
|
|
||||||
} catch {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return removeAbs(abs) ? 1 : 0
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeOrphans(dir, keep) {
|
|
||||||
let names = []
|
|
||||||
try {
|
|
||||||
names = fs.readdirSync(dir)
|
|
||||||
} catch {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
let removed = 0
|
|
||||||
for (const name of names) {
|
|
||||||
if (keep.has(name)) continue
|
|
||||||
if (removeAbs(path.join(dir, name))) removed++
|
|
||||||
}
|
|
||||||
return removed
|
|
||||||
}
|
|
||||||
|
|
||||||
// Registry folder names; symlinked folders count as the link they are.
|
// Registry folder names; symlinked folders count as the link they are.
|
||||||
function listDirs(dir) {
|
function listDirs(dir) {
|
||||||
const out = []
|
const out = []
|
||||||
|
|
@ -370,33 +347,26 @@ function listDirs(dir) {
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete everything under a unit root that is not in the sender's manifest.
|
// What a committed unit would delete: everything under its roots that the
|
||||||
async function pruneTree(abs, rel, keep) {
|
// sender's manifest does not name, minus the folders a selection kept off the
|
||||||
// A symlinked unit root: readdir would delete the target's real files.
|
// wire. Nothing is removed here - the list waits for the receiver's approval.
|
||||||
let rootStat = null
|
async function planUnitDeletions(unit) {
|
||||||
|
const nodes = []
|
||||||
|
const skip = new Set(unit.skip || [])
|
||||||
|
for (const root of unit.roots || []) {
|
||||||
|
const abs = filesPath(root)
|
||||||
|
if (!abs) continue
|
||||||
|
let st
|
||||||
try {
|
try {
|
||||||
rootStat = await fsp.lstat(abs)
|
st = await fsp.lstat(abs)
|
||||||
} catch {
|
} catch {
|
||||||
return 0
|
|
||||||
}
|
|
||||||
if (!rootStat.isDirectory()) return 0
|
|
||||||
let entries
|
|
||||||
try {
|
|
||||||
entries = await fsp.readdir(abs, { withFileTypes: true })
|
|
||||||
} catch {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
let removed = 0
|
|
||||||
for (const entry of entries) {
|
|
||||||
const childAbs = path.join(abs, entry.name)
|
|
||||||
const childRel = `${rel}/${entry.name}`
|
|
||||||
if (!keep.has(childRel)) {
|
|
||||||
if (removeAbs(childAbs)) removed++
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (entry.isDirectory()) removed += await pruneTree(childAbs, childRel, keep)
|
// a symlinked unit root is not ours to walk: its own unit heals it
|
||||||
|
if (!st.isDirectory() || st.isSymbolicLink()) continue
|
||||||
|
nodes.push(...await walkDeletions(abs, root, unit.paths, skip))
|
||||||
}
|
}
|
||||||
return removed
|
return { nodes, ...totalsOf(nodes) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Modes go on after the bytes; a build without the exec bit is unusable.
|
// Modes go on after the bytes; a build without the exec bit is unusable.
|
||||||
|
|
@ -457,15 +427,15 @@ function writeStream(abs, stream, onBytes) {
|
||||||
|
|
||||||
// --- registries on the receiving side ---------------------------------------
|
// --- registries on the receiving side ---------------------------------------
|
||||||
|
|
||||||
|
// Additions and updates land right away; removals wait for the review.
|
||||||
function applyApps(payload, cats) {
|
function applyApps(payload, cats) {
|
||||||
if (!cats.has('apps-portable') && !cats.has('apps-prefix')) return 0
|
if (!cats.has('apps-portable') && !cats.has('apps-prefix')) return
|
||||||
// A malformed/truncated payload carries no list at all; mirroring that as
|
// A malformed/truncated payload carries no list at all; mirroring that as
|
||||||
// "the sender has no apps" would delete every entry. Only an explicit list
|
// "the sender has no apps" would delete every entry. Only an explicit list
|
||||||
// may speak for a category.
|
// may speak for a category.
|
||||||
if (!Array.isArray(payload.apps)) return 0
|
if (!Array.isArray(payload.apps)) return
|
||||||
// A partial app selection only carries the chosen apps: the rest of the
|
// A partial app selection only carries the chosen apps: the rest of the
|
||||||
// category is not the sender's business this time.
|
// category is not the sender's business this time.
|
||||||
const partial = payload.appScope === 'selected'
|
|
||||||
const incoming = []
|
const incoming = []
|
||||||
for (const raw of payload.apps) {
|
for (const raw of payload.apps) {
|
||||||
if (!raw || typeof raw !== 'object') continue
|
if (!raw || typeof raw !== 'object') continue
|
||||||
|
|
@ -478,21 +448,11 @@ function applyApps(payload, cats) {
|
||||||
const incomingIds = new Set(incoming.map(g => g.id))
|
const incomingIds = new Set(incoming.map(g => g.id))
|
||||||
const existing = library.loadGames()
|
const existing = library.loadGames()
|
||||||
const prevById = new Map(existing.map(g => [g.id, g]))
|
const prevById = new Map(existing.map(g => [g.id, g]))
|
||||||
const kept = []
|
// entries the payload leaves out stay until the review approves their removal
|
||||||
const dropped = new Set()
|
const kept = existing.filter(game => {
|
||||||
for (const game of existing) {
|
|
||||||
const category = appCategory(game)
|
const category = appCategory(game)
|
||||||
if (category && cats.has(category)) {
|
return !category || !cats.has(category) || !incomingIds.has(game.id)
|
||||||
if (incomingIds.has(game.id)) continue // replaced by the sender's copy
|
})
|
||||||
if (partial) {
|
|
||||||
kept.push(game)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
dropped.add(game.id)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
kept.push(game)
|
|
||||||
}
|
|
||||||
// App settings travel as stored; playtime only grows (higher wins).
|
// App settings travel as stored; playtime only grows (higher wins).
|
||||||
const merged = [...kept]
|
const merged = [...kept]
|
||||||
const seen = new Set()
|
const seen = new Set()
|
||||||
|
|
@ -508,28 +468,21 @@ function applyApps(payload, cats) {
|
||||||
if (hasPlay) library.writePlaySeconds(g.id, playSeconds)
|
if (hasPlay) library.writePlaySeconds(g.id, playSeconds)
|
||||||
}
|
}
|
||||||
library.saveGames(merged)
|
library.saveGames(merged)
|
||||||
let removed = 0
|
|
||||||
for (const id of dropped) removed += removeTree(`data/icons/${id}`)
|
|
||||||
// files/apps holds only portable imports; the rest is mirror leftover - but
|
|
||||||
// a partial selection never owns the folders it left out.
|
|
||||||
if (!partial && cats.has('apps-portable')) removed += removeOrphans(DIRS.APPS, incomingIds)
|
|
||||||
return removed
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applyWines(payload) {
|
async function applyWines(payload) {
|
||||||
// no list -> nothing to mirror (see applyApps)
|
// no list -> nothing to mirror (see applyApps)
|
||||||
if (!Array.isArray(payload.wines)) return 0
|
if (!Array.isArray(payload.wines)) return
|
||||||
const names = new Set(payload.wines.map(w => w && w.name).filter(Boolean))
|
|
||||||
const removed = removeOrphans(DIRS.WINES, names)
|
|
||||||
// the registry derives from disk; a rescan picks up the synced builds
|
// the registry derives from disk; a rescan picks up the synced builds
|
||||||
await wines.refreshWines()
|
await wines.refreshWines()
|
||||||
return removed
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Metadata lands now; prefixes the payload leaves out are dropped only after
|
||||||
|
// the review approves them. Returns their names.
|
||||||
function applyPrefixes(payload, useSenderDefault) {
|
function applyPrefixes(payload, useSenderDefault) {
|
||||||
const sender = payload.prefixes && typeof payload.prefixes === 'object' ? payload.prefixes : {}
|
const sender = payload.prefixes && typeof payload.prefixes === 'object' ? payload.prefixes : {}
|
||||||
// no list -> nothing to mirror (see applyApps)
|
// no list -> nothing to mirror (see applyApps)
|
||||||
if (!Array.isArray(sender.prefixes)) return 0
|
if (!Array.isArray(sender.prefixes)) return []
|
||||||
const incoming = sender.prefixes.filter(p => p && safePrefixName(p.name))
|
const incoming = sender.prefixes.filter(p => p && safePrefixName(p.name))
|
||||||
const existing = prefixes.listPrefixes()
|
const existing = prefixes.listPrefixes()
|
||||||
const byName = new Map(existing.prefixes.map(p => [p.name, p]))
|
const byName = new Map(existing.prefixes.map(p => [p.name, p]))
|
||||||
|
|
@ -538,13 +491,23 @@ function applyPrefixes(payload, useSenderDefault) {
|
||||||
if (!keep.has(prefixes.MAIN)) {
|
if (!keep.has(prefixes.MAIN)) {
|
||||||
keep.set(prefixes.MAIN, byName.get(prefixes.MAIN) || { name: prefixes.MAIN, created: Date.now() })
|
keep.set(prefixes.MAIN, byName.get(prefixes.MAIN) || { name: prefixes.MAIN, created: Date.now() })
|
||||||
}
|
}
|
||||||
const removed = removeOrphans(DIRS.PREFIXES, new Set(keep.keys()))
|
const list = []
|
||||||
const list = [...keep.values()].map(p => ({ ...p, locked: p.name === prefixes.MAIN ? true : !!p.locked }))
|
const seen = new Set()
|
||||||
|
for (const p of existing.prefixes) {
|
||||||
|
if (seen.has(p.name)) continue
|
||||||
|
seen.add(p.name)
|
||||||
|
const next = keep.get(p.name)
|
||||||
|
list.push(next ? { ...p, created: next.created || p.created, locked: p.name === prefixes.MAIN ? true : !!next.locked } : p)
|
||||||
|
}
|
||||||
|
for (const [name, p] of keep) {
|
||||||
|
if (seen.has(name)) continue
|
||||||
|
list.push({ name, created: p.created || Date.now(), locked: name === prefixes.MAIN ? true : !!p.locked })
|
||||||
|
}
|
||||||
// the default prefix moves only with synced default settings
|
// the default prefix moves only with synced default settings
|
||||||
const wanted = useSenderDefault && safePrefixName(sender.default) ? sender.default : existing.default
|
const wanted = useSenderDefault && safePrefixName(sender.default) ? sender.default : existing.default
|
||||||
const def = keep.has(wanted) ? wanted : prefixes.MAIN
|
const def = keep.has(wanted) ? wanted : prefixes.MAIN
|
||||||
prefixes.savePrefixes({ default: def, prefixes: list })
|
prefixes.savePrefixes({ default: def, prefixes: list })
|
||||||
return removed
|
return existing.prefixes.map(p => p.name).filter(name => !keep.has(name))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wine display names/disabled flags by build id; empty name clears the label.
|
// Wine display names/disabled flags by build id; empty name clears the label.
|
||||||
|
|
@ -657,7 +620,7 @@ function applyDefaults(payload) {
|
||||||
// --- HTTP server (what other devices talk to) -------------------------------
|
// --- HTTP server (what other devices talk to) -------------------------------
|
||||||
|
|
||||||
function createServerState() {
|
function createServerState() {
|
||||||
return { tokens: new Map(), verifying: false, pending: new Map(), session: null }
|
return { tokens: new Map(), verifying: false, pending: new Map(), reviews: new Map(), session: null }
|
||||||
}
|
}
|
||||||
|
|
||||||
// The user's popup answer; unknown/expired ids are a no-op.
|
// The user's popup answer; unknown/expired ids are a no-op.
|
||||||
|
|
@ -748,6 +711,8 @@ function createHttpServer(state, hooks) {
|
||||||
if (route === '/plwl/unit/file' && req.method === 'PUT') return handlePut(session, url, req, res, hooks)
|
if (route === '/plwl/unit/file' && req.method === 'PUT') return handlePut(session, url, req, res, hooks)
|
||||||
if (route === '/plwl/unit/commit' && req.method === 'POST') return handleCommit(session, url, res, hooks)
|
if (route === '/plwl/unit/commit' && req.method === 'POST') return handleCommit(session, url, res, hooks)
|
||||||
if (route === '/plwl/apply' && req.method === 'POST') return handleApply(session, req, res, hooks)
|
if (route === '/plwl/apply' && req.method === 'POST') return handleApply(session, req, res, hooks)
|
||||||
|
if (route === '/plwl/review' && req.method === 'GET') return handleReviewState(session, url, res)
|
||||||
|
if (route === '/plwl/review' && req.method === 'POST') return handleReviewDecision(session, url, req, res, hooks)
|
||||||
if (route === '/plwl/end' && req.method === 'POST') return handleEnd(state, session, res, hooks)
|
if (route === '/plwl/end' && req.method === 'POST') return handleEnd(state, session, res, hooks)
|
||||||
sendJson(res, 404, { ok: false, error: 'not-found' })
|
sendJson(res, 404, { ok: false, error: 'not-found' })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -812,6 +777,11 @@ async function handleVerify(state, req, res, hooks) {
|
||||||
expires: Date.now() + TOKEN_TTL_MS,
|
expires: Date.now() + TOKEN_TTL_MS,
|
||||||
lastAt: Date.now(),
|
lastAt: Date.now(),
|
||||||
units: new Map(),
|
units: new Map(),
|
||||||
|
// deletions a committed unit and the registry pass planned, not made
|
||||||
|
plans: new Map(),
|
||||||
|
removals: [],
|
||||||
|
review: null,
|
||||||
|
applied: null,
|
||||||
// popup counters: files, bytes, sender's totals learned per unit
|
// popup counters: files, bytes, sender's totals learned per unit
|
||||||
counted: new Set(),
|
counted: new Set(),
|
||||||
received: 0,
|
received: 0,
|
||||||
|
|
@ -840,6 +810,9 @@ async function handleVerify(state, req, res, hooks) {
|
||||||
token,
|
token,
|
||||||
deviceId: me.deviceId,
|
deviceId: me.deviceId,
|
||||||
name: os.hostname(),
|
name: os.hostname(),
|
||||||
|
// deletions are planned and reviewed here; a peer that cannot ask for an
|
||||||
|
// answer must not sync units, or it would prune without a review
|
||||||
|
review: 1,
|
||||||
device: { id: me.deviceId, name: os.hostname() }
|
device: { id: me.deviceId, name: os.hostname() }
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -856,6 +829,28 @@ async function handlePlan(state, session, url, req, res, hooks) {
|
||||||
} catch {}
|
} catch {}
|
||||||
const owned = (Array.isArray(roots) ? roots : []).map(ownedRoot).filter(Boolean)
|
const owned = (Array.isArray(roots) ? roots : []).map(ownedRoot).filter(Boolean)
|
||||||
if (!key || !owned.length) return sendJson(res, 400, { ok: false, error: 'bad-unit' })
|
if (!key || !owned.length) return sendJson(res, 400, { ok: false, error: 'bad-unit' })
|
||||||
|
// Folders a selection kept off the wire: they are not in the manifest, so
|
||||||
|
// they must not be planned for deletion here either.
|
||||||
|
let skip = []
|
||||||
|
let badExclude = false
|
||||||
|
const rawExclude = url.searchParams.get('exclude')
|
||||||
|
if (rawExclude) {
|
||||||
|
let parsed = null
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(rawExclude)
|
||||||
|
} catch {}
|
||||||
|
if (!Array.isArray(parsed)) badExclude = true
|
||||||
|
else {
|
||||||
|
for (const raw of parsed) {
|
||||||
|
const rel = ownedRoot(raw)
|
||||||
|
if (!rel || !owned.some(root => rel === root || rel.startsWith(root + '/'))) {
|
||||||
|
badExclude = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
skip.push(rel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// comparing is its own phase: the bar restarts for the copy phase
|
// comparing is its own phase: the bar restarts for the copy phase
|
||||||
const announce = progressGate((data, force) => emitReceive(hooks, session, 'compare', data))
|
const announce = progressGate((data, force) => emitReceive(hooks, session, 'compare', data))
|
||||||
// the unit counts from the start of its comparison, not from its commit
|
// the unit counts from the start of its comparison, not from its commit
|
||||||
|
|
@ -971,8 +966,8 @@ async function handlePlan(state, session, url, req, res, hooks) {
|
||||||
}
|
}
|
||||||
// req.complete is false when the body stopped early (sender died, link lost);
|
// req.complete is false when the body stopped early (sender died, link lost);
|
||||||
// such a manifest is partial just like one the sender flagged itself.
|
// such a manifest is partial just like one the sender flagged itself.
|
||||||
const complete = req.complete !== false && !incomplete
|
const complete = req.complete !== false && !incomplete && !badExclude
|
||||||
session.units.set(key, { roots: owned, paths, label, modes, prune: complete })
|
session.units.set(key, { roots: owned, paths, label, modes, skip, prune: complete })
|
||||||
// this unit's files/bytes, reported as they arrive
|
// this unit's files/bytes, reported as they arrive
|
||||||
session.planned += upload.length
|
session.planned += upload.length
|
||||||
session.plannedBytes += uploadBytes
|
session.plannedBytes += uploadBytes
|
||||||
|
|
@ -1044,27 +1039,268 @@ async function handleCommit(session, url, res, hooks) {
|
||||||
const key = url.searchParams.get('unit') || ''
|
const key = url.searchParams.get('unit') || ''
|
||||||
const unit = session.units.get(key)
|
const unit = session.units.get(key)
|
||||||
if (!unit) return sendJson(res, 400, { ok: false, error: 'no-unit' })
|
if (!unit) return sendJson(res, 400, { ok: false, error: 'no-unit' })
|
||||||
let removed = 0
|
// The unit is written; what it makes redundant is only planned here. A unit
|
||||||
if (unit.prune !== false) {
|
// with an incomplete manifest may only be written, never pruned.
|
||||||
for (const root of unit.roots) {
|
const planned = unit.prune !== false ? await planUnitDeletions(unit) : { nodes: [], files: 0, bytes: 0 }
|
||||||
const abs = filesPath(root)
|
|
||||||
if (abs) removed += await pruneTree(abs, root, unit.paths)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// rewire links only once the whole tree is there
|
// rewire links only once the whole tree is there
|
||||||
try {
|
try {
|
||||||
await healUnitRoots(unit.roots)
|
await healUnitRoots(unit.roots)
|
||||||
} catch {}
|
} catch {}
|
||||||
session.units.delete(key)
|
session.units.delete(key)
|
||||||
session.deleted = (session.deleted || 0) + removed
|
session.plans.set(key, { key, kind: String(key).split(':')[0], label: unit.label || '', nodes: planned.nodes })
|
||||||
// a finished unit completes its "2/5" slice
|
// a finished unit completes its "2/5" slice
|
||||||
const kind = UNIT_KINDS[String(key).split(':')[0]]
|
const kind = UNIT_KINDS[String(key).split(':')[0]]
|
||||||
if (kind) session.itemsDone[kind] = (session.itemsDone[kind] || 0) + 1
|
if (kind) session.itemsDone[kind] = (session.itemsDone[kind] || 0) + 1
|
||||||
// a finished unit leaves a finished bar behind
|
// a finished unit leaves a finished bar behind
|
||||||
session.barTotal = session.barTotal || 1
|
session.barTotal = session.barTotal || 1
|
||||||
session.barBytes = session.barTotal
|
session.barBytes = session.barTotal
|
||||||
sendJson(res, 200, { ok: true, deleted: removed })
|
sendJson(res, 200, { ok: true, deleted: 0, planned: planned.files })
|
||||||
emitReceive(hooks, session, 'commit', { unit: session.unitLabel || '', deleted: removed })
|
emitReceive(hooks, session, 'commit', { unit: session.unitLabel || '', deleted: 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- the deletion review ----------------------------------------------------
|
||||||
|
|
||||||
|
// Registry leftovers: entries and folders this device has and the payload does
|
||||||
|
// not, plus folders with no entry at all. A selection owns nothing outside it,
|
||||||
|
// so the scope markers switch this whole pass off.
|
||||||
|
async function planRegistryRemovals(payload, cats, orphanPrefixes) {
|
||||||
|
const items = []
|
||||||
|
const appsWanted = cats.has('apps-portable') || cats.has('apps-prefix')
|
||||||
|
if (appsWanted && Array.isArray(payload.apps) && payload.appScope !== 'selected') {
|
||||||
|
const incoming = new Set(payload.apps.map(raw => raw && safeName(raw.id)).filter(Boolean))
|
||||||
|
const games = library.loadGames()
|
||||||
|
for (const game of games) {
|
||||||
|
const category = appCategory(game)
|
||||||
|
if (!category || !cats.has(category) || incoming.has(game.id)) continue
|
||||||
|
const nodes = []
|
||||||
|
const iconRel = `data/icons/${game.id}`
|
||||||
|
const icon = await planEntry(filesPath(iconRel), iconRel, new Set())
|
||||||
|
if (icon) nodes.push(icon)
|
||||||
|
if (category === 'apps-portable') {
|
||||||
|
const rel = String(toPortablePath(library.appFolder(game)) || '')
|
||||||
|
if (rel.startsWith('apps/')) {
|
||||||
|
const folder = await planEntry(filesPath(rel), rel, new Set())
|
||||||
|
if (folder) nodes.push(folder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items.push({ key: `entry:app:${game.id}`, kind: 'app', label: game.name || game.id, entry: { kind: 'app', id: game.id }, nodes })
|
||||||
|
}
|
||||||
|
// entries already planned above are not leftovers; only unknown names are
|
||||||
|
const knownIds = new Set(games.map(g => g.id))
|
||||||
|
for (const node of await planListed(DIRS.APPS, 'apps', knownIds)) {
|
||||||
|
items.push({ key: `scan:${node.path}`, kind: 'app', label: node.name, entry: null, nodes: [node] })
|
||||||
|
}
|
||||||
|
for (const node of await planListed(path.join(DIRS.DATA, 'icons'), 'data/icons', knownIds)) {
|
||||||
|
items.push({ key: `scan:${node.path}`, kind: 'app', label: node.name, entry: null, nodes: [node] })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cats.has('wines') && Array.isArray(payload.wines) && payload.wineScope !== 'selected') {
|
||||||
|
const keep = new Set(payload.wines.map(w => w && String(w.name)).filter(Boolean))
|
||||||
|
for (const node of await planListed(DIRS.WINES, 'wines', keep)) {
|
||||||
|
items.push({ key: `scan:${node.path}`, kind: 'wine', label: node.name, entry: null, nodes: [node] })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cats.has('prefixes') && payload.prefixes && Array.isArray(payload.prefixes.prefixes) && payload.prefixScope !== 'selected') {
|
||||||
|
const keep = new Set(payload.prefixes.prefixes.filter(p => p && safePrefixName(p.name)).map(p => p.name))
|
||||||
|
keep.add(prefixes.MAIN)
|
||||||
|
const planned = new Set()
|
||||||
|
for (const node of await planListed(DIRS.PREFIXES, 'wineprefixes', keep)) {
|
||||||
|
planned.add(node.name)
|
||||||
|
items.push({ key: `scan:${node.path}`, kind: 'prefix', label: node.name, entry: { kind: 'prefix', id: node.name }, nodes: [node] })
|
||||||
|
}
|
||||||
|
// a registered prefix whose folder is already gone still has an entry
|
||||||
|
for (const name of orphanPrefixes) {
|
||||||
|
if (planned.has(name)) continue
|
||||||
|
items.push({ key: `entry:prefix:${name}`, kind: 'prefix', label: name, entry: { kind: 'prefix', id: name }, nodes: [] })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
function reviewItems(session) {
|
||||||
|
const items = []
|
||||||
|
for (const plan of session.plans.values()) {
|
||||||
|
if (!plan.nodes.length) continue
|
||||||
|
items.push({ key: plan.key, kind: plan.kind, label: plan.label, entry: null, nodes: plan.nodes })
|
||||||
|
}
|
||||||
|
return items.concat(session.removals || [])
|
||||||
|
}
|
||||||
|
|
||||||
|
// What the popup needs up front; the tree itself is fetched per folder.
|
||||||
|
function reviewSummary(items) {
|
||||||
|
return items.map(item => {
|
||||||
|
const totals = totalsOf(item.nodes)
|
||||||
|
return {
|
||||||
|
key: item.key,
|
||||||
|
kind: item.kind,
|
||||||
|
label: item.label,
|
||||||
|
entry: item.entry ? item.entry.kind : '',
|
||||||
|
files: totals.files,
|
||||||
|
bytes: totals.bytes,
|
||||||
|
top: item.nodes.length
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function publishApplied(session, hooks) {
|
||||||
|
if (!session.applied || !hooks || typeof hooks.onApplied !== 'function') return
|
||||||
|
try {
|
||||||
|
const kept = session.review ? session.review.kept || 0 : 0
|
||||||
|
hooks.onApplied({ ...session.applied, deleted: session.deleted || 0, kept })
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function forgetReview(review) {
|
||||||
|
if (review.timer) clearTimeout(review.timer)
|
||||||
|
review.timer = null
|
||||||
|
if (serverState) serverState.reviews.delete(review.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing is deleted here: an unanswered or skipped review keeps every file.
|
||||||
|
function cancelReview(review, state, hooks) {
|
||||||
|
if (!review || review.state !== 'waiting') return
|
||||||
|
review.state = state
|
||||||
|
review.deleted = 0
|
||||||
|
review.kept = review.files
|
||||||
|
forgetReview(review)
|
||||||
|
// a reviewed sync is settled: the next apply plans from a clean slate
|
||||||
|
review.session.plans.clear()
|
||||||
|
review.session.removals = []
|
||||||
|
publishApplied(review.session, hooks)
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyApprovedRemovals(items) {
|
||||||
|
const appIds = new Set()
|
||||||
|
const prefixNames = new Set()
|
||||||
|
for (const item of items) {
|
||||||
|
if (!item.entry) continue
|
||||||
|
if (item.entry.kind === 'app') appIds.add(item.entry.id)
|
||||||
|
else if (item.entry.kind === 'prefix') prefixNames.add(item.entry.id)
|
||||||
|
}
|
||||||
|
if (appIds.size) library.saveGames(library.loadGames().filter(g => !appIds.has(g.id)))
|
||||||
|
if (prefixNames.size) {
|
||||||
|
const defs = prefixes.listPrefixes()
|
||||||
|
defs.prefixes = defs.prefixes.filter(p => !prefixNames.has(p.name))
|
||||||
|
if (prefixNames.has(defs.default)) defs.default = prefixes.MAIN
|
||||||
|
prefixes.savePrefixes(defs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeReview(review, keep, keepUnits, hooks) {
|
||||||
|
if (!review || review.state !== 'waiting') return { ok: false, error: 'not-waiting' }
|
||||||
|
review.state = 'running'
|
||||||
|
forgetReview(review)
|
||||||
|
const out = { entries: 0, files: 0, bytes: 0 }
|
||||||
|
const approved = []
|
||||||
|
let winesTouched = false
|
||||||
|
for (const item of review.items) {
|
||||||
|
if (keepUnits.has(item.key)) continue // the whole row was left alone
|
||||||
|
if (item.entry) approved.push(item)
|
||||||
|
if (item.kind === 'wine') winesTouched = true
|
||||||
|
const res = await removeNodes(item.nodes, keep, filesPath)
|
||||||
|
out.entries += res.entries
|
||||||
|
out.files += res.files
|
||||||
|
out.bytes += res.bytes
|
||||||
|
}
|
||||||
|
applyApprovedRemovals(approved)
|
||||||
|
if (winesTouched) {
|
||||||
|
try {
|
||||||
|
await wines.refreshWines()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
const session = review.session
|
||||||
|
session.deleted = (session.deleted || 0) + out.entries
|
||||||
|
review.deleted = out.entries
|
||||||
|
review.kept = Math.max(0, review.files - out.files)
|
||||||
|
review.state = 'done'
|
||||||
|
session.plans.clear()
|
||||||
|
session.removals = []
|
||||||
|
emitReceive(hooks, session, 'review', { deleted: review.deleted })
|
||||||
|
publishApplied(session, hooks)
|
||||||
|
return { ok: true, deleted: review.deleted, kept: review.kept }
|
||||||
|
}
|
||||||
|
|
||||||
|
function indexNode(node, out) {
|
||||||
|
out.set(node.path, node)
|
||||||
|
for (const child of node.children || []) indexNode(child, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
function briefNode(node) {
|
||||||
|
return {
|
||||||
|
path: node.path,
|
||||||
|
name: node.name,
|
||||||
|
type: node.type,
|
||||||
|
files: node.files || 0,
|
||||||
|
bytes: node.bytes || 0,
|
||||||
|
full: !!node.full,
|
||||||
|
hasChildren: !!(node.children && node.children.length)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The popup's tree, one folder at a time: a 60k-file prefix must never cross
|
||||||
|
// ipc in one object.
|
||||||
|
function reviewTree(id, rel) {
|
||||||
|
const review = serverState && serverState.reviews ? serverState.reviews.get(String(id || '')) : null
|
||||||
|
if (!review) return { ok: false, error: 'not-found' }
|
||||||
|
const key = String(rel || '')
|
||||||
|
if (!key) return { ok: true, units: reviewSummary(review.items) }
|
||||||
|
const node = review.index.get(key)
|
||||||
|
if (node) return { ok: true, nodes: (node.children || []).map(briefNode) }
|
||||||
|
const item = review.items.find(entry => entry.key === key)
|
||||||
|
return { ok: true, nodes: item ? item.nodes.map(briefNode) : [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
function startReview(session, hooks) {
|
||||||
|
const items = reviewItems(session)
|
||||||
|
if (!items.length) return null
|
||||||
|
const totals = totalsOf(items.flatMap(item => item.nodes))
|
||||||
|
const index = new Map()
|
||||||
|
for (const item of items) for (const node of item.nodes) indexNode(node, index)
|
||||||
|
const review = {
|
||||||
|
id: crypto.randomBytes(8).toString('hex'),
|
||||||
|
items,
|
||||||
|
index,
|
||||||
|
session,
|
||||||
|
files: totals.files,
|
||||||
|
bytes: totals.bytes,
|
||||||
|
state: 'waiting',
|
||||||
|
deleted: 0,
|
||||||
|
kept: 0,
|
||||||
|
timer: null
|
||||||
|
}
|
||||||
|
review.timer = setTimeout(() => cancelReview(review, 'timeout', hooks), REVIEW_TIMEOUT_MS)
|
||||||
|
if (review.timer.unref) review.timer.unref()
|
||||||
|
session.review = review
|
||||||
|
if (serverState) serverState.reviews.set(review.id, review)
|
||||||
|
if (typeof hooks.onReview !== 'function') {
|
||||||
|
// no popup on this build (headless/tests): keep everything, delete nothing
|
||||||
|
cancelReview(review, 'skipped', hooks)
|
||||||
|
return review
|
||||||
|
}
|
||||||
|
let shown = true
|
||||||
|
try {
|
||||||
|
shown = hooks.onReview({
|
||||||
|
id: review.id,
|
||||||
|
deviceId: session.deviceId,
|
||||||
|
name: session.name,
|
||||||
|
files: totals.files,
|
||||||
|
bytes: totals.bytes,
|
||||||
|
units: reviewSummary(items)
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
shown = false
|
||||||
|
}
|
||||||
|
if (shown === false) cancelReview(review, 'skipped', hooks)
|
||||||
|
return review
|
||||||
|
}
|
||||||
|
|
||||||
|
// The popup's answer (ipc goes through here, tests too).
|
||||||
|
function respondReview(id, decision) {
|
||||||
|
const review = serverState && serverState.reviews ? serverState.reviews.get(String(id || '')) : null
|
||||||
|
if (!review) return Promise.resolve({ ok: false, error: 'not-found' })
|
||||||
|
const keep = new Set((decision && Array.isArray(decision.keep) ? decision.keep : []).map(String))
|
||||||
|
const keepUnits = new Set((decision && Array.isArray(decision.keepUnits) ? decision.keepUnits : []).map(String))
|
||||||
|
return executeReview(review, keep, keepUnits, serverHooks)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleApply(session, req, res, hooks) {
|
async function handleApply(session, req, res, hooks) {
|
||||||
|
|
@ -1072,18 +1308,13 @@ async function handleApply(session, req, res, hooks) {
|
||||||
if (!payload) return sendJson(res, 400, { ok: false, error: 'bad-request' })
|
if (!payload) return sendJson(res, 400, { ok: false, error: 'bad-request' })
|
||||||
// only the categories this build knows, whatever the sender sent
|
// only the categories this build knows, whatever the sender sent
|
||||||
const cats = new Set(normalizeCategories(payload.categories))
|
const cats = new Set(normalizeCategories(payload.categories))
|
||||||
const appsDeleted = applyApps(payload, cats)
|
applyApps(payload, cats)
|
||||||
const winesDeleted = cats.has('wines') ? await applyWines(payload) : 0
|
if (cats.has('wines')) await applyWines(payload)
|
||||||
const prefixesDeleted = cats.has('prefixes') ? applyPrefixes(payload, cats.has('settings')) : 0
|
const orphanPrefixes = cats.has('prefixes') ? applyPrefixes(payload, cats.has('settings')) : []
|
||||||
// after the registries, so synced builds/prefixes are usable as defaults
|
// after the registries, so synced builds/prefixes are usable as defaults
|
||||||
const settings = applySettings(payload, cats)
|
const settings = applySettings(payload, cats)
|
||||||
const removed = appsDeleted + winesDeleted + prefixesDeleted
|
session.removals = await planRegistryRemovals(payload, cats, orphanPrefixes)
|
||||||
// the popup reports everything this sync removed: pruned unit files and
|
session.applied = {
|
||||||
// registry entries dropped by the apply
|
|
||||||
session.deleted = (session.deleted || 0) + removed
|
|
||||||
emitReceive(hooks, session, 'registry', { deleted: session.deleted })
|
|
||||||
if (hooks.onApplied) {
|
|
||||||
hooks.onApplied({
|
|
||||||
from: session.name,
|
from: session.name,
|
||||||
deviceId: session.deviceId,
|
deviceId: session.deviceId,
|
||||||
categories: [...cats],
|
categories: [...cats],
|
||||||
|
|
@ -1092,20 +1323,48 @@ async function handleApply(session, req, res, hooks) {
|
||||||
skipped: session.skipped || 0,
|
skipped: session.skipped || 0,
|
||||||
sizeBytes: session.sizeBytes || 0,
|
sizeBytes: session.sizeBytes || 0,
|
||||||
items: { ...(session.items || {}) },
|
items: { ...(session.items || {}) },
|
||||||
deleted: session.deleted,
|
|
||||||
settings,
|
settings,
|
||||||
defaults: settings ? settings.defaults : null
|
defaults: settings ? settings.defaults : null
|
||||||
})
|
}
|
||||||
|
emitReceive(hooks, session, 'registry', { deleted: session.deleted || 0 })
|
||||||
|
// everything is on disk; the deletions wait for the receiver's answer
|
||||||
|
const review = startReview(session, hooks)
|
||||||
|
if (!review) {
|
||||||
|
publishApplied(session, hooks)
|
||||||
|
return sendJson(res, 200, { ok: true, deleted: 0, settings, defaults: session.applied.defaults })
|
||||||
}
|
}
|
||||||
sendJson(res, 200, {
|
sendJson(res, 200, {
|
||||||
ok: true,
|
ok: true,
|
||||||
deleted: removed,
|
deleted: 0,
|
||||||
settings,
|
settings,
|
||||||
defaults: settings ? settings.defaults : null
|
defaults: session.applied.defaults,
|
||||||
|
review: { id: review.id, files: review.files, bytes: review.bytes }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleReviewState(session, url, res) {
|
||||||
|
const review = session.review
|
||||||
|
const id = url.searchParams.get('id') || ''
|
||||||
|
// a review this session never had (or already answered): nothing was removed
|
||||||
|
if (!review || review.id !== id) return sendJson(res, 200, { ok: true, state: 'skipped', deleted: 0, kept: 0 })
|
||||||
|
sendJson(res, 200, { ok: true, state: review.state, deleted: review.deleted || 0, kept: review.kept || 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReviewDecision(session, url, req, res, hooks) {
|
||||||
|
const review = session.review
|
||||||
|
const id = url.searchParams.get('id') || ''
|
||||||
|
if (!review || review.id !== id) return sendJson(res, 404, { ok: false, error: 'no-review' })
|
||||||
|
const body = await readJsonBody(req)
|
||||||
|
// paths come from the network: only FILES-relative ones may survive
|
||||||
|
const keep = new Set((body && Array.isArray(body.keep) ? body.keep : []).map(String).filter(rel => filesPath(rel)))
|
||||||
|
const keepUnits = new Set((body && Array.isArray(body.keepUnits) ? body.keepUnits : []).map(String))
|
||||||
|
const result = await executeReview(review, keep, keepUnits, hooks)
|
||||||
|
sendJson(res, result.ok ? 200 : 409, result)
|
||||||
|
}
|
||||||
|
|
||||||
function handleEnd(state, session, res, hooks) {
|
function handleEnd(state, session, res, hooks) {
|
||||||
|
// a review still waiting when the sender leaves approves nothing
|
||||||
|
if (session.review && session.review.state === 'waiting') cancelReview(session.review, 'cancelled', hooks)
|
||||||
state.tokens.delete(session.token)
|
state.tokens.delete(session.token)
|
||||||
if (state.session && state.session.token === session.token) state.session = null
|
if (state.session && state.session.token === session.token) state.session = null
|
||||||
// a finished sync leaves a finished bar, even one that moved no files
|
// a finished sync leaves a finished bar, even one that moved no files
|
||||||
|
|
@ -1121,7 +1380,9 @@ function handleEnd(state, session, res, hooks) {
|
||||||
// itself and must not flash the waiting text over the receiving phase.
|
// itself and must not flash the waiting text over the receiving phase.
|
||||||
function handlePing(session, res, hooks) {
|
function handlePing(session, res, hooks) {
|
||||||
session.lastAt = Date.now()
|
session.lastAt = Date.now()
|
||||||
if (!session.writing) emitReceive(hooks, session, 'wait')
|
// a pending review is a human, not a stalled peer: keep the review popup
|
||||||
|
const reviewing = session.review && session.review.state === 'waiting'
|
||||||
|
if (!session.writing && !reviewing) emitReceive(hooks, session, 'wait')
|
||||||
sendJson(res, 200, { ok: true })
|
sendJson(res, 200, { ok: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1201,6 +1462,11 @@ function stopServer() {
|
||||||
for (const resolve of serverState.pending.values()) resolve('reject')
|
for (const resolve of serverState.pending.values()) resolve('reject')
|
||||||
serverState.pending.clear()
|
serverState.pending.clear()
|
||||||
}
|
}
|
||||||
|
// an unapproved deletion list dies with the server: nothing is removed
|
||||||
|
if (serverState && serverState.reviews) {
|
||||||
|
for (const review of serverState.reviews.values()) cancelReview(review, 'cancelled', serverHooks)
|
||||||
|
serverState.reviews.clear()
|
||||||
|
}
|
||||||
serverState = null
|
serverState = null
|
||||||
listening = null
|
listening = null
|
||||||
httpPort = 0
|
httpPort = 0
|
||||||
|
|
@ -1319,6 +1585,9 @@ const exported = {
|
||||||
rememberDevice,
|
rememberDevice,
|
||||||
forgetDevice,
|
forgetDevice,
|
||||||
respondIncoming,
|
respondIncoming,
|
||||||
|
respondReview,
|
||||||
|
reviewTree,
|
||||||
|
syncItems: () => getPusher().items(),
|
||||||
isPushing: () => pushing,
|
isPushing: () => pushing,
|
||||||
sameCode,
|
sameCode,
|
||||||
HASH_ALGO: hashStore.HASH_ALGO,
|
HASH_ALGO: hashStore.HASH_ALGO,
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,12 @@ const HEARTBEAT_MS = Number(process.env.PLWL_LANSYNC_HEARTBEAT) || 20000
|
||||||
// One ping round trip; env override for tests.
|
// One ping round trip; env override for tests.
|
||||||
const PING_TIMEOUT_MS = Number(process.env.PLWL_LANSYNC_PING_TIMEOUT) || 10000
|
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) {
|
function createPusher(api) {
|
||||||
const {
|
const {
|
||||||
CATEGORIES,
|
CATEGORIES,
|
||||||
|
|
@ -54,8 +60,10 @@ function createPusher(api) {
|
||||||
function planUnit(device, token, unit, force, meta, produce) {
|
function planUnit(device, token, unit, force, meta, produce) {
|
||||||
// announced up front, so the receiver's popup can say "Apps: 2/5"
|
// announced up front, so the receiver's popup can say "Apps: 2/5"
|
||||||
const totals = meta && meta.totals ? `&totals=${encodeURIComponent(JSON.stringify(meta.totals))}` : ''
|
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) => {
|
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(
|
const req = http.request(
|
||||||
{
|
{
|
||||||
host: device.address,
|
host: device.address,
|
||||||
|
|
@ -166,26 +174,54 @@ function createPusher(api) {
|
||||||
|
|
||||||
// --- units ----------------------------------------------------------------
|
// --- units ----------------------------------------------------------------
|
||||||
|
|
||||||
// `selected` is the optional app selection (a Set of ids or null = all);
|
// A selection lists unit keys ("app:1", "wine:GE-Proton9-20", "prefix:Main");
|
||||||
// the seam a future "sync only these apps" UI builds on.
|
// absent means everything travels. `apps` is the older app-id-only shape.
|
||||||
function appUnits(cats, selected) {
|
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 = []
|
const units = []
|
||||||
for (const game of library.loadGames()) {
|
for (const game of library.loadGames()) {
|
||||||
const category = api.appCategory(game)
|
const category = api.appCategory(game)
|
||||||
if (!category || !cats.has(category)) continue
|
if (!category || !cats.has(category)) continue
|
||||||
if (selected && !selected.has(String(game.id))) continue
|
if (!sel.has(`app:${game.id}`, game.id)) continue
|
||||||
const roots = [`data/icons/${game.id}`]
|
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 })
|
units.push({ key: `app:${game.id}`, kind: 'app', id: game.id, label: game.name, roots })
|
||||||
}
|
}
|
||||||
return units
|
return units
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectedApps(selection) {
|
// A prefix app a selection leaves out stays on this side: its folder must not
|
||||||
return selection && Array.isArray(selection.apps) ? new Set(selection.apps.map(String)) : null
|
// 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() {
|
function wineUnits(sel) {
|
||||||
const units = new Map()
|
const units = new Map()
|
||||||
const add = (root, label) => {
|
const add = (root, label) => {
|
||||||
if (units.has(root)) return
|
if (units.has(root)) return
|
||||||
|
|
@ -198,21 +234,29 @@ function createPusher(api) {
|
||||||
add(parts[0] === 'wines' && parts[1] ? `wines/${parts[1]}` : `wines/${path.basename(wine.path)}`, wine.label)
|
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}`)
|
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))
|
const names = new Set(prefixes.listPrefixes().prefixes.map(p => p.name))
|
||||||
for (const name of listDirs(DIRS.PREFIXES)) names.add(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, selection) {
|
function buildUnits(cats, selection) {
|
||||||
|
const sel = selectionOf(selection)
|
||||||
const units = []
|
const units = []
|
||||||
const apps = selectedApps(selection)
|
if (cats.has('apps-portable') || cats.has('apps-prefix')) units.push(...appUnits(cats, sel))
|
||||||
if (cats.has('apps-portable') || cats.has('apps-prefix')) units.push(...appUnits(cats, apps))
|
if (cats.has('wines')) units.push(...wineUnits(sel))
|
||||||
if (cats.has('wines')) units.push(...wineUnits())
|
if (cats.has('prefixes')) units.push(...prefixUnits(sel))
|
||||||
if (cats.has('prefixes')) units.push(...prefixUnits())
|
|
||||||
return units
|
return units
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -225,24 +269,25 @@ function createPusher(api) {
|
||||||
|
|
||||||
function buildRegistryPayload(categories, selection) {
|
function buildRegistryPayload(categories, selection) {
|
||||||
const cats = new Set(categories)
|
const cats = new Set(categories)
|
||||||
|
const sel = selectionOf(selection)
|
||||||
const payload = { categories: [...cats] }
|
const payload = { categories: [...cats] }
|
||||||
const selected = selectedApps(selection)
|
|
||||||
if (cats.has('apps-portable') || cats.has('apps-prefix')) {
|
if (cats.has('apps-portable') || cats.has('apps-prefix')) {
|
||||||
payload.apps = library
|
payload.apps = library
|
||||||
.loadGames()
|
.loadGames()
|
||||||
.filter(game => {
|
.filter(game => {
|
||||||
const category = api.appCategory(game)
|
const category = api.appCategory(game)
|
||||||
if (!category || !cats.has(category)) return false
|
if (!category || !cats.has(category)) return false
|
||||||
return !selected || selected.has(String(game.id))
|
return sel.has(`app:${game.id}`, game.id)
|
||||||
})
|
})
|
||||||
.map(portableApp)
|
.map(portableApp)
|
||||||
// A partial list means "only these apps travel"; without the marker the
|
// A partial list means "only these apps travel"; without the marker the
|
||||||
// receiver mirrors the category and drops the apps that were left out.
|
// receiver mirrors the category and drops the apps that were left out.
|
||||||
if (selected) payload.appScope = 'selected'
|
if (sel.active) payload.appScope = 'selected'
|
||||||
}
|
}
|
||||||
if (cats.has('wines')) {
|
if (cats.has('wines')) {
|
||||||
// the receiver prunes its own builds down to this list and rescans
|
// the receiver mirrors its own builds down to this list and rescans
|
||||||
payload.wines = wineUnits().map(unit => ({ name: unit.id }))
|
payload.wines = wineUnits(sel).map(unit => ({ name: unit.id }))
|
||||||
|
if (sel.active) payload.wineScope = 'selected'
|
||||||
}
|
}
|
||||||
if (cats.has('settings')) {
|
if (cats.has('settings')) {
|
||||||
// "Sync settings": registry-level values only; opt-in - apps must not
|
// "Sync settings": registry-level values only; opt-in - apps must not
|
||||||
|
|
@ -280,8 +325,11 @@ function createPusher(api) {
|
||||||
const defs = prefixes.listPrefixes()
|
const defs = prefixes.listPrefixes()
|
||||||
payload.prefixes = {
|
payload.prefixes = {
|
||||||
default: defs.default,
|
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
|
return payload
|
||||||
}
|
}
|
||||||
|
|
@ -294,6 +342,8 @@ function createPusher(api) {
|
||||||
uploaded: 0,
|
uploaded: 0,
|
||||||
skipped: 0,
|
skipped: 0,
|
||||||
deleted: 0,
|
deleted: 0,
|
||||||
|
// files the receiver asked to keep when the deletion list was reviewed
|
||||||
|
kept: 0,
|
||||||
bytes: 0,
|
bytes: 0,
|
||||||
// size of the synced trees as they now sit on the other device
|
// size of the synced trees as they now sit on the other device
|
||||||
sizeBytes: 0,
|
sizeBytes: 0,
|
||||||
|
|
@ -387,6 +437,9 @@ function createPusher(api) {
|
||||||
if (error === 'auth-failed' || error === 'bad-code') forgetDevice(device.id || deviceId)
|
if (error === 'auth-failed' || error === 'bad-code') forgetDevice(device.id || deviceId)
|
||||||
return fail(error)
|
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
|
const token = verified.data.token
|
||||||
// paired now: the next sync needs no typing
|
// paired now: the next sync needs no typing
|
||||||
rememberDevice(device.id || deviceId, code, device.name)
|
rememberDevice(device.id || deviceId, code, device.name)
|
||||||
|
|
@ -417,6 +470,24 @@ function createPusher(api) {
|
||||||
await httpJson(device, 'POST', `/plwl/end?token=${encodeURIComponent(token)}`, { json: {} })
|
await httpJson(device, 'POST', `/plwl/end?token=${encodeURIComponent(token)}`, { json: {} })
|
||||||
} catch {}
|
} 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 () => {
|
const runUnits = async () => {
|
||||||
for (const unit of units) {
|
for (const unit of units) {
|
||||||
if (abort.dead) throw new Error('receiver-lost')
|
if (abort.dead) throw new Error('receiver-lost')
|
||||||
|
|
@ -442,6 +513,8 @@ function createPusher(api) {
|
||||||
}
|
}
|
||||||
const plan = await planUnit(device, token, { ...unit, roots }, !!force, { totals }, async write => {
|
const plan = await planUnit(device, token, { ...unit, roots }, !!force, { totals }, async write => {
|
||||||
await buildManifestStream(roots, !!force, {
|
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
|
// the receiver sizes its compare bar from this, before any hashing
|
||||||
onTotal: totalBytes => {
|
onTotal: totalBytes => {
|
||||||
writes = writes.then(() => write({ unitBytes: totalBytes }))
|
writes = writes.then(() => write({ unitBytes: totalBytes }))
|
||||||
|
|
@ -533,6 +606,13 @@ function createPusher(api) {
|
||||||
const applied = await httpJson(device, 'POST', `/plwl/apply?token=${encodeURIComponent(token)}`, { json: buildRegistryPayload([...cats], selection), timeout: 60000 })
|
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 || !applied.data.ok) throw new Error((applied.data && applied.data.error) || `http-${applied.status}`)
|
||||||
if (applied.data.deleted) summary.deleted += applied.data.deleted
|
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
|
// a sync with nothing to send still ends with a full bar
|
||||||
barTotal = barTotal || 1
|
barTotal = barTotal || 1
|
||||||
barBytes = barTotal
|
barBytes = barTotal
|
||||||
|
|
@ -551,9 +631,28 @@ function createPusher(api) {
|
||||||
return syncResult(true, null, summary)
|
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
|
// putFile is exposed for the stall tests (it is the piece that must never
|
||||||
// leave a request half-open)
|
// leave a request half-open)
|
||||||
return { syncToDevice, buildUnits, buildRegistryPayload, putFile, CATEGORIES }
|
return { syncToDevice, buildUnits, buildRegistryPayload, items, putFile, CATEGORIES }
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = createPusher
|
module.exports = createPusher
|
||||||
|
|
|
||||||
|
|
@ -180,20 +180,29 @@ function clearPlaytime(id) {
|
||||||
return updateGame(id, { playSeconds: 0, lastPlayed: 0 })
|
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.
|
// Delete a portable app's folder with measured progress.
|
||||||
async function removeGame(id, onProgress) {
|
async function removeGame(id, onProgress) {
|
||||||
const games = loadGames()
|
const games = loadGames()
|
||||||
const idx = games.findIndex(g => g.id === id)
|
const idx = games.findIndex(g => g.id === id)
|
||||||
if (idx === -1) return false
|
if (idx === -1) return false
|
||||||
const g = games[idx]
|
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) => {
|
const emit = (phase, detail) => {
|
||||||
if (onProgress) onProgress({ phase, label: g.name || id, ...detail })
|
if (onProgress) onProgress({ phase, label: g.name || id, ...detail })
|
||||||
}
|
}
|
||||||
const { treeSize, removeTree } = require('./fsutil')
|
const { treeSize, removeTree } = require('./fsutil')
|
||||||
emit('count', { files: 0, bytes: 0 })
|
emit('count', { files: 0, bytes: 0 })
|
||||||
const total = await treeSize(g.path)
|
const total = await treeSize(folder)
|
||||||
await removeTree(g.path, p => emit('remove', { files: p.files, bytes: p.bytes, totalFiles: total.files, totalBytes: total.bytes }))
|
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
|
// 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)]) {
|
for (const owned of [path.join(DIRS.DATA, 'icons', id), path.join(DIRS.DATA, 'appdata', id)]) {
|
||||||
|
|
@ -283,4 +292,4 @@ function renamePrefixRefs(oldName, newName) {
|
||||||
return updated
|
return updated
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { loadGames, saveGames, portableGame, getGame, isLinuxGame, addGame, updateGame, updateGames, 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 }
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ const os = require('os')
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
const { DIRS, readJson, writeJson } = require('./paths')
|
const { DIRS, readJson, writeJson } = require('./paths')
|
||||||
const { prefixPath, prefixLinuxRoot } = require('./prefixes')
|
const { prefixPath, prefixLinuxRoot } = require('./prefixes')
|
||||||
const { prefixNameForGame, loadSettings } = require('./library')
|
const { prefixNameForGame, loadSettings, appFolder } = require('./library')
|
||||||
const i18n = require('./i18n')
|
const i18n = require('./i18n')
|
||||||
const { spawnTracked, parseArgs } = require('./runner')
|
const { spawnTracked, parseArgs } = require('./runner')
|
||||||
const { buildBwrapArgs, SHARE_ROOT, FS_ROOT } = require('./sandbox')
|
const { buildBwrapArgs, SHARE_ROOT, FS_ROOT } = require('./sandbox')
|
||||||
|
|
@ -46,7 +46,7 @@ function appShapes(game) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function writableAppDir(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
|
// realpathSync('') would answer with the cwd - never hand that out
|
||||||
if (!dir) return ''
|
if (!dir) return ''
|
||||||
let real
|
let real
|
||||||
|
|
|
||||||
12
src/main.js
12
src/main.js
|
|
@ -252,7 +252,17 @@ app.whenReady().then(async () => {
|
||||||
onReceive: info => sendToWindow(win, 'lansync:receiving', info),
|
onReceive: info => sendToWindow(win, 'lansync:receiving', info),
|
||||||
// the consent popup for an incoming sync (see ipc/lansync.js)
|
// the consent popup for an incoming sync (see ipc/lansync.js)
|
||||||
onIncoming: info => sendToWindow(win, 'lansync:incoming', info),
|
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 {
|
try {
|
||||||
Promise.resolve(lansync.start(lanHooks))
|
Promise.resolve(lansync.start(lanHooks))
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ const onOpenWith = singleSlot('openwith:file')
|
||||||
// one sync (and one pending consent request, enforced server-side) at a time
|
// one sync (and one pending consent request, enforced server-side) at a time
|
||||||
const onLanSyncProgress = singleSlot('lansync:progress')
|
const onLanSyncProgress = singleSlot('lansync:progress')
|
||||||
const onLanSyncIncoming = singleSlot('lansync:incoming')
|
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 fs = require('fs')
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
|
|
||||||
|
|
@ -96,6 +98,10 @@ contextBridge.exposeInMainWorld('plwl', {
|
||||||
lansyncClearHashCache: () => ipcRenderer.invoke('lansync:clearHashCache'),
|
lansyncClearHashCache: () => ipcRenderer.invoke('lansync:clearHashCache'),
|
||||||
lansyncSync: payload => ipcRenderer.invoke('lansync:sync', payload),
|
lansyncSync: payload => ipcRenderer.invoke('lansync:sync', payload),
|
||||||
lansyncRespondIncoming: (requestId, accept) => ipcRenderer.invoke('lansync:respondIncoming', requestId, accept),
|
lansyncRespondIncoming: (requestId, accept) => ipcRenderer.invoke('lansync:respondIncoming', requestId, accept),
|
||||||
|
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)),
|
onLanSyncDevices: cb => ipcRenderer.on('lansync:devices', (e, data) => cb(data)),
|
||||||
onLanSyncProgress: onLanSyncProgress,
|
onLanSyncProgress: onLanSyncProgress,
|
||||||
onLanSyncIncoming: onLanSyncIncoming,
|
onLanSyncIncoming: onLanSyncIncoming,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue