cleanup & sync summary
This commit is contained in:
parent
cd0007fc7d
commit
be4f67fb60
17 changed files with 637 additions and 207 deletions
|
|
@ -54,7 +54,7 @@ The redirected home starts with a **copy of your desktop preferences** — `kdeg
|
||||||
|
|
||||||
Every prefix in the **Wine prefixes** tab has a **Reset Linux config** button: it deletes the desktop preferences PLWL copied in (and the hashes that track them), so the next app start seeds a clean copy from your system — the way back when an app has hijacked a seeded file with its own theme. App data in `linux_pfx` is never touched, and an app running from that prefix blocks the reset.
|
Every prefix in the **Wine prefixes** tab has a **Reset Linux config** button: it deletes the desktop preferences PLWL copied in (and the hashes that track them), so the next app start seeds a clean copy from your system — the way back when an app has hijacked a seeded file with its own theme. App data in `linux_pfx` is never touched, and an app running from that prefix blocks the reset.
|
||||||
|
|
||||||
Imported trees get **normalised permissions**: every file gets the owner's `rwx` and an `x` mirrored from its `r`, because sources come from archives, FAT sticks or read-only backups where those bits are simply not there.
|
Imported trees get **normalised permissions**: every file gets the owner's `rwx` and an `x` mirrored from its `r`, because sources come from archives, FAT sticks or read-only backups where those bits are simply not there. An import whose copy could not reproduce every file is **refused** and the partial copy is removed, so a half-copied app is never registered.
|
||||||
|
|
||||||
Some apps draw their window icon themselves, which Wayland does not allow — Minecraft (LWJGL 3.4+) is the known case: its taskbar icon turns into the generic Wayland one. The per-app **Force X11 (XWayland)** switch moves the app *and the programs it launches* back to X11, where the icon works again.
|
Some apps draw their window icon themselves, which Wayland does not allow — Minecraft (LWJGL 3.4+) is the known case: its taskbar icon turns into the generic Wayland one. The per-app **Force X11 (XWayland)** switch moves the app *and the programs it launches* back to X11, where the icon works again.
|
||||||
|
|
||||||
|
|
@ -81,7 +81,7 @@ Two PLWL installs on one LAN can mirror each other (sidebar → **LAN syncing**)
|
||||||
|
|
||||||
**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. 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**: 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.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,8 @@ Renderer never touches Node/Electron directly — everything goes through the `w
|
||||||
- No duplicate logic: the second time you copy something, move it into a helper and call it from both places.
|
- No duplicate logic: the second time you copy something, move it into a helper and call it from both places.
|
||||||
- Everything user-visible goes through the translation files (en-us.json, pl.json) with `{0}`, `{1}` placeholders. No hardcoded strings in app.js.
|
- Everything user-visible goes through the translation files (en-us.json, pl.json) with `{0}`, `{1}` placeholders. No hardcoded strings in app.js.
|
||||||
- Paths stored on disk are FILES-relative (portable layout): write with toPortablePath(), read with resolvePortablePath() — never store absolute paths for anything inside files/.
|
- Paths stored on disk are FILES-relative (portable layout): write with toPortablePath(), read with resolvePortablePath() — never store absolute paths for anything inside files/.
|
||||||
- Registries (apps.json, wines.json, prefixes.json) are rewritten whole via writeJson(); playtime lives in its own tiny file so the registry is not rewritten while a game runs.
|
- Registries (apps.json, wines.json, prefixes.json) are rewritten whole via writeJson(); playtime lives in its own tiny file so the registry is not rewritten while a game runs. A settings sync batches its patches instead of one write per app/build (`library.updateGames`, `wines.applySettings`).
|
||||||
|
- Tree walks (`copyTree`/`treeSize`/`removeTree`/`chmodTree`) keep TREE_CONCURRENCY operations in flight instead of one syscall per await. `copyTree` returns a `failed` count; an import that could not reproduce every entry is refused and its partial destination removed, never registered with holes.
|
||||||
- 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).
|
||||||
|
|
@ -106,12 +107,18 @@ Renderer never touches Node/Electron directly — everything goes through the `w
|
||||||
- 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).
|
||||||
- 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.
|
||||||
|
- App selection is a seam, not a UI yet: `buildUnits(cats, selection)` and `buildRegistryPayload(cats, selection)` take `selection = { apps: [ids] }` (a missing selection means every app). The payload then carries `appScope: 'selected'`; `applyApps` keeps the apps of that category that were not sent, and skips the `apps/` orphan sweep, instead of mirroring the category. Without the marker the sender's list is complete and extras are deleted as before.
|
||||||
- The sender's transferred counter counts bytes AS THEY ARE READ (putFile's onBytes callback), not on upload completion: a multi-GB file has to move it while it flies or the sender shows hundreds of MB behind the receiver. A unit's byte total travels as the `{ unitBytes }` control record BEFORE its files; the receiver has to handle it before the entry filter (it has no path/type, so a filter-first order dropped it and the comparing bar measured against a placeholder total of 1 — read as 100% for the whole phase). The comparing phase ends on a full bar, then the copy bar restarts empty (its first PUT byte emits it).
|
- The sender's transferred counter counts bytes AS THEY ARE READ (putFile's onBytes callback), not on upload completion: a multi-GB file has to move it while it flies or the sender shows hundreds of MB behind the receiver. A unit's byte total travels as the `{ unitBytes }` control record BEFORE its files; the receiver has to handle it before the entry filter (it has no path/type, so a filter-first order dropped it and the comparing bar measured against a placeholder total of 1 — read as 100% for the whole phase). The comparing phase ends on a full bar, then the copy bar restarts empty (its first PUT byte emits it).
|
||||||
- While the receiver answers the consent popup (and whenever its side waits on the other one), both popups show "Waiting for the other device…" (lansync.waiting): the sender emits a 'wait' phase before verify, the receiver's ping handler emits stage 'wait'. A unit counts toward "Apps: 1/5" as soon as its comparison starts (not when it commits), and phases without a file count (hash/compare/commit) clear the counter so a stale "3 / 10 files" never lingers. On the wire the receiver's comparing phase is called 'compare' (sender-side 'plan' is unchanged).
|
- While the receiver answers the consent popup (and whenever its side waits on the other one), both popups show "Waiting for the other device…" (lansync.waiting): the sender emits a 'wait' phase before verify, the receiver's ping handler emits stage 'wait'. A unit counts toward "Apps: 1/5" as soon as its comparison starts (not when it commits), and phases without a file count (hash/compare/commit) clear the counter so a stale "3 / 10 files" never lingers. On the wire the receiver's comparing phase is called 'compare' (sender-side 'plan' is unchanged).
|
||||||
- Registry-level settings are opt-in (the "settings" category). The payload carries defaults, wineSettings (displayName + disabled per build), prefixSettings, appSettings (name, wine, prefix, args, env, dllOverrides, disableUmu, isolation and forceX11 per app) and appOrder. The receiver only patches what already exists (unknown ids skipped, nothing created or deleted, unknown local entries kept at the end of the order) through the normal helpers (setWineDisplayName, library.updateGame, reorderGames).
|
- Registry-level settings are opt-in (the "settings" category). The payload carries defaults, wineSettings (displayName + disabled per build), prefixSettings, appSettings (name, wine, prefix, args, env, dllOverrides, disableUmu, isolation and forceX11 per app) and appOrder. The receiver only patches what already exists (unknown ids skipped, nothing created or deleted, unknown local entries kept at the end of the order) through the normal helpers — batched, so a large library is one registry write: `library.updateGames(patches)`, `wines.applySettings(entries)`, `reorderGames`.
|
||||||
- File hashes are remembered in files/data/lansync-hashes.json (path -> size + mtime + hash; machine-local, never synced): equal size and mtime means equal bytes, so a repeat sync only stats the tree. Hashing and the receiver's comparison both run HASH_CONCURRENCY files at a time.
|
- File hashes are remembered in files/data/lansync-hashes.json (path -> size + mtime + hash; machine-local, never synced): equal size and mtime means equal bytes, so a repeat sync only stats the tree. Hashing and the receiver's comparison both run HASH_CONCURRENCY files at a time.
|
||||||
- A file's unit and path travel in the PUT's query, not in headers: Node rejects any header value holding a code above U+00FF (`ERR_INVALID_CHAR`), and a real library has those names (Minecraft pack names, Polish file names) — one such file killed the whole sync, leaving the receiver with half an app and no registry entry. Categories are matched by exact name; anything this build does not know is ignored.
|
- A file's unit and path travel in the PUT's query, not in headers: Node rejects any header value holding a code above U+00FF (`ERR_INVALID_CHAR`), and a real library has those names (Minecraft pack names, Polish file names) — one such file killed the whole sync, leaving the receiver with half an app and no registry entry. Categories are matched by exact name; anything this build does not know is ignored.
|
||||||
- File permissions travel with the manifest (`mode`) and are applied after the bytes; a file with the right hash but wrong mode is repaired without re-upload.
|
- File permissions travel with the manifest (`mode`) and are applied after the bytes; a file with the right hash but wrong mode is repaired without re-upload.
|
||||||
|
- A manifest the sender could not finish is not a licence to delete: a read that fails (a directory or file the user cannot open) sends a `{ unitIncomplete: true }` control record, and an aborted plan body is detected with `req.complete`. Either way the receiver still writes what it got but skips `pruneTree` for that unit — a missing entry may only mean "could not read it". For the same reason `applyApps`/`applyWines`/`applyPrefixes` mirror nothing when the payload has no list at all (malformed/truncated apply), while an explicit empty list still means "the sender has none" (a partial app selection excepted: it keeps the apps it did not send).
|
||||||
|
- A PUT settles on every failure path (`aborted`/`close` without the body having ended, stream or writer error): the `.plwl-sync-tmp` is removed and the request cannot stay half-open forever. The 60 s `PUT_IDLE_MS` only covers a body that stops sending.
|
||||||
|
- Verification is GLOBAL one-at-a-time (`state.verifying` is a boolean, set before the first await — parallel requests would pay the 2 s cost in parallel, which is exactly what makes a scheme pointless). One outgoing sync per process (`syncTo` answers 'busy'), so two syncs can never write the same receiver files at once. A failed bind resolves `{ ok: false, error }` instead of leaving `listening` pending forever, and the beacon only starts when the server is up.
|
||||||
|
- Names arriving on the wire that become paths (app ids in `data/icons/<id>`, prefix folder names) are validated (`safeName`/`safePrefixName`): a corrupted or older peer must not be able to store `../../x` and turn a later Rename/Delete click into a write outside files/.
|
||||||
- A sender hashing a big unit is minutes of silence: it pings `/plwl/ping` every HEARTBEAT_MS, the receiver re-arms its popup watchdog (stage 'wait' shows "Waiting for the other device…" but never while a file is being written). A receiving popup its watchdog closed reopens on the next real event — only 'done' ends a sync, and reopening never asks for consent again (consent is the onIncoming popup at verify time; the progress popup is driven by the same token's events).
|
- A sender hashing a big unit is minutes of silence: it pings `/plwl/ping` every HEARTBEAT_MS, the receiver re-arms its popup watchdog (stage 'wait' shows "Waiting for the other device…" but never while a file is being written). A receiving popup its watchdog closed reopens on the next real event — only 'done' ends a sync, and reopening never asks for consent again (consent is the onIncoming popup at verify time; the progress popup is driven by the same token's events).
|
||||||
- Native Linux app data (`{prefix}/linux_pfx/`: the redirected home, configs, caches, PulseAudio cookie, state) travels INSIDE its prefix unit like the Windows side of the same prefix: the manifest walks it and the mirror prunes it, so no file of the launcher's own trees is special-cased on the wire. Native apps sit in the same categories as Windows ones (`apps-portable`/`apps-prefix`), and only the per-app fields that are Linux-only (isolation, forceX11) are named as such in the settings payload.
|
- Native Linux app data (`{prefix}/linux_pfx/`: the redirected home, configs, caches, PulseAudio cookie, state) travels INSIDE its prefix unit like the Windows side of the same prefix: the manifest walks it and the mirror prunes it, so no file of the launcher's own trees is special-cased on the wire. Native apps sit in the same categories as Windows ones (`apps-portable`/`apps-prefix`), and only the per-app fields that are Linux-only (isolation, forceX11) are named as such in the settings payload.
|
||||||
- A sync must never walk through a symlinked ROOT: pruneTree/healUnitRoots (sync), removeTree/treeSize (fsutil) and healBuild (wines) lstat the root first. `listDirs()` counts symlinked folders as units, so a build on another disk still travels — as the link it is (`wineUnits` maps nested builds to their TOP folder under wines/).
|
- A sync must never walk through a symlinked ROOT: pruneTree/healUnitRoots (sync), removeTree/treeSize (fsutil) and healBuild (wines) lstat the root first. `listDirs()` counts symlinked folders as units, so a build on another disk still travels — as the link it is (`wineUnits` maps nested builds to their TOP folder under wines/).
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,7 @@
|
||||||
"import.noExe": "no .exe file found in the selected folder",
|
"import.noExe": "no .exe file found in the selected folder",
|
||||||
"import.noLinuxExe": "no runnable Linux program found in the selected folder (ELF binary, .AppImage or .desktop)",
|
"import.noLinuxExe": "no runnable Linux program found in the selected folder (ELF binary, .AppImage or .desktop)",
|
||||||
"import.prefixRoot": "this folder is a wine prefix (or its C: drive) - import an app from inside its drive_c instead",
|
"import.prefixRoot": "this folder is a wine prefix (or its C: drive) - import an app from inside its drive_c instead",
|
||||||
|
"import.copyIncomplete": "some files could not be copied - nothing was imported, the source folder is unchanged",
|
||||||
"config.title": "App configuration",
|
"config.title": "App configuration",
|
||||||
"config.icon.auto": "Extract from exe",
|
"config.icon.auto": "Extract from exe",
|
||||||
"config.icon.autoLinux": "Extract icon",
|
"config.icon.autoLinux": "Extract icon",
|
||||||
|
|
@ -287,6 +288,18 @@
|
||||||
"lansync.unknownDevice": "Unknown device",
|
"lansync.unknownDevice": "Unknown device",
|
||||||
"lansync.recvIdle": "The other device stopped responding, the sync was interrupted.",
|
"lansync.recvIdle": "The other device stopped responding, the sync was interrupted.",
|
||||||
"lansync.done": "Synced to {0}: {1} sent ({4}), {2} already up to date, {3} removed",
|
"lansync.done": "Synced to {0}: {1} sent ({4}), {2} already up to date, {3} removed",
|
||||||
|
"lansync.summaryTitle": "Sync finished",
|
||||||
|
"lansync.summarySentTo": "Sent to {0}",
|
||||||
|
"lansync.summaryReceivedFrom": "Received from {0}",
|
||||||
|
"lansync.summaryApps": "Apps",
|
||||||
|
"lansync.summarySettings": "Settings",
|
||||||
|
"lansync.summarySent": "Files sent",
|
||||||
|
"lansync.summaryReceived": "Files received",
|
||||||
|
"lansync.summarySkipped": "Already up to date",
|
||||||
|
"lansync.summaryDeleted": "Removed",
|
||||||
|
"lansync.summaryTransferred": "Transferred",
|
||||||
|
"lansync.summarySize": "Synced data size",
|
||||||
|
"lansync.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.",
|
||||||
"lansync.busy": "The other device is busy (another sync or a running app).",
|
"lansync.busy": "The other device is busy (another sync or a running app).",
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,7 @@
|
||||||
"import.noExe": "nie znaleziono pliku .exe w wybranym folderze",
|
"import.noExe": "nie znaleziono pliku .exe w wybranym folderze",
|
||||||
"import.noLinuxExe": "nie znaleziono uruchamialnego programu linuksowego w wybranym folderze (binarka ELF, .AppImage lub .desktop)",
|
"import.noLinuxExe": "nie znaleziono uruchamialnego programu linuksowego w wybranym folderze (binarka ELF, .AppImage lub .desktop)",
|
||||||
"import.prefixRoot": "ten folder to prefiks wine (lub jego dysk C:) - zaimportuj aplikację z wnętrza jego drive_c",
|
"import.prefixRoot": "ten folder to prefiks wine (lub jego dysk C:) - zaimportuj aplikację z wnętrza jego drive_c",
|
||||||
|
"import.copyIncomplete": "nie udało się skopiować niektórych plików - nic nie zaimportowano, folder źródłowy pozostał bez zmian",
|
||||||
"config.title": "Konfiguracja aplikacji",
|
"config.title": "Konfiguracja aplikacji",
|
||||||
"config.icon.auto": "Wyciągnij z exe",
|
"config.icon.auto": "Wyciągnij z exe",
|
||||||
"config.icon.autoLinux": "Wyciągnij ikonę",
|
"config.icon.autoLinux": "Wyciągnij ikonę",
|
||||||
|
|
@ -287,6 +288,18 @@
|
||||||
"lansync.unknownDevice": "Nieznane urządzenie",
|
"lansync.unknownDevice": "Nieznane urządzenie",
|
||||||
"lansync.recvIdle": "Drugie urządzenie przestało odpowiadać, synchronizacja została przerwana.",
|
"lansync.recvIdle": "Drugie urządzenie przestało odpowiadać, synchronizacja została przerwana.",
|
||||||
"lansync.done": "Zsynchronizowano na {0}: wysłano {1} ({4}), aktualnych {2}, usunięto {3}",
|
"lansync.done": "Zsynchronizowano na {0}: wysłano {1} ({4}), aktualnych {2}, usunięto {3}",
|
||||||
|
"lansync.summaryTitle": "Synchronizacja zakończona",
|
||||||
|
"lansync.summarySentTo": "Wysłano do {0}",
|
||||||
|
"lansync.summaryReceivedFrom": "Odebrano od {0}",
|
||||||
|
"lansync.summaryApps": "Aplikacje",
|
||||||
|
"lansync.summarySettings": "Ustawienia",
|
||||||
|
"lansync.summarySent": "Wysłane pliki",
|
||||||
|
"lansync.summaryReceived": "Odebrane pliki",
|
||||||
|
"lansync.summarySkipped": "Niezmienione pliki",
|
||||||
|
"lansync.summaryDeleted": "Usunięte",
|
||||||
|
"lansync.summaryTransferred": "Przesłano",
|
||||||
|
"lansync.summarySize": "Rozmiar zsynchronizowanych danych",
|
||||||
|
"lansync.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.",
|
||||||
"lansync.busy": "Drugie urządzenie jest zajęte (inna synchronizacja lub uruchomiona aplikacja).",
|
"lansync.busy": "Drugie urządzenie jest zajęte (inna synchronizacja lub uruchomiona aplikacja).",
|
||||||
|
|
|
||||||
149
renderer/app.js
149
renderer/app.js
|
|
@ -818,6 +818,17 @@ async function startPrefixImportFlow() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One place for the technical error tokens the import handlers answer with.
|
||||||
|
function importErrorText(raw) {
|
||||||
|
if (raw === 'no-exe-found') return t('import.noExe')
|
||||||
|
if (raw === 'no-linux-exe') return t('import.noLinuxExe')
|
||||||
|
if (raw === 'prefix-root') return t('import.prefixRoot')
|
||||||
|
if (raw === 'no-drive-c') return t('import.prefix.noDriveC')
|
||||||
|
if (raw === 'outside-drive-c') return t('import.prefix.outside')
|
||||||
|
if (raw === 'copy-incomplete') return t('import.copyIncomplete')
|
||||||
|
return raw || ''
|
||||||
|
}
|
||||||
|
|
||||||
async function doImport(srcDir, mode, kind) {
|
async function doImport(srcDir, mode, kind) {
|
||||||
const name = srcDir.split('/').filter(Boolean).pop()
|
const name = srcDir.split('/').filter(Boolean).pop()
|
||||||
const modal = showModal(`
|
const modal = showModal(`
|
||||||
|
|
@ -842,13 +853,7 @@ async function doImport(srcDir, mode, kind) {
|
||||||
const game = await window.plwl.gamesImport(srcDir, mode, kind)
|
const game = await window.plwl.gamesImport(srcDir, mode, kind)
|
||||||
closeModal()
|
closeModal()
|
||||||
if (!game || game.ok === false) {
|
if (!game || game.ok === false) {
|
||||||
const raw = (game && game.error) || ''
|
toast(t('import.failed', importErrorText((game && game.error) || '')), 'error')
|
||||||
const errText =
|
|
||||||
raw === 'no-exe-found' ? t('import.noExe')
|
|
||||||
: raw === 'no-linux-exe' ? t('import.noLinuxExe')
|
|
||||||
: raw === 'prefix-root' ? t('import.prefixRoot')
|
|
||||||
: (raw || 'unknown error')
|
|
||||||
toast(t('import.failed', errText), 'error')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
state.games = await window.plwl.gamesList()
|
state.games = await window.plwl.gamesList()
|
||||||
|
|
@ -1005,13 +1010,7 @@ async function doPrefixImport(prefix, srcDir) {
|
||||||
const game = await window.plwl.gamesImportFromPrefix(prefix, srcDir)
|
const game = await window.plwl.gamesImportFromPrefix(prefix, srcDir)
|
||||||
closeModal()
|
closeModal()
|
||||||
if (!game || game.ok === false) {
|
if (!game || game.ok === false) {
|
||||||
const raw = (game && game.error) || ''
|
toast(t('import.failed', importErrorText((game && game.error) || '')), 'error')
|
||||||
const errText =
|
|
||||||
raw === 'no-exe-found' ? t('import.noExe')
|
|
||||||
: raw === 'no-drive-c' ? t('import.prefix.noDriveC')
|
|
||||||
: raw === 'outside-drive-c' ? t('import.prefix.outside')
|
|
||||||
: (raw || 'unknown error')
|
|
||||||
toast(t('import.failed', errText), 'error')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
state.games = await window.plwl.gamesList()
|
state.games = await window.plwl.gamesList()
|
||||||
|
|
@ -1633,6 +1632,52 @@ async function runWithProgress({ title, action }) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// End-of-sync popup: what moved, what was already there, and how much space
|
||||||
|
// the synced trees take. A toast is too easy to miss for a transfer that can
|
||||||
|
// run for minutes.
|
||||||
|
function showSyncSummary({ from, received, categories, summary }) {
|
||||||
|
const items = (summary && summary.items) || {}
|
||||||
|
const cats = new Set(categories || [])
|
||||||
|
const apps = Math.max(0, Number(items.apps) || 0)
|
||||||
|
const wines = Math.max(0, Number(items.wines) || 0)
|
||||||
|
const prefixes = Math.max(0, Number(items.prefixes) || 0)
|
||||||
|
const files = Math.max(0, Number(summary && summary.uploaded) || 0)
|
||||||
|
const skipped = Math.max(0, Number(summary && summary.skipped) || 0)
|
||||||
|
const deleted = Math.max(0, Number(summary && summary.deleted) || 0)
|
||||||
|
const bytes = Math.max(0, Number(summary && summary.bytes) || 0)
|
||||||
|
const sizeBytes = Math.max(0, Number(summary && summary.sizeBytes) || 0)
|
||||||
|
const rows = []
|
||||||
|
const row = (label, value) => rows.push(
|
||||||
|
`<div class="sync-summary-row"><span>${label}</span><strong>${value}</strong></div>`
|
||||||
|
)
|
||||||
|
// a count alone also shows for a peer that did not send its categories
|
||||||
|
if (apps || cats.has('apps-portable') || cats.has('apps-prefix')) row(t('lansync.summaryApps'), apps)
|
||||||
|
if (wines || cats.has('wines')) row(t('lansync.wines'), wines)
|
||||||
|
if (prefixes || cats.has('prefixes')) row(t('lansync.prefixes'), prefixes)
|
||||||
|
if (cats.has('settings')) row(t('lansync.summarySettings'), t('lansync.summaryYes'))
|
||||||
|
if (files || skipped || deleted) {
|
||||||
|
row(t(received ? 'lansync.summaryReceived' : 'lansync.summarySent'), files)
|
||||||
|
if (skipped) row(t('lansync.summarySkipped'), skipped)
|
||||||
|
if (deleted) row(t('lansync.summaryDeleted'), deleted)
|
||||||
|
}
|
||||||
|
if (bytes) row(t('lansync.summaryTransferred'), formatBytes(bytes))
|
||||||
|
if (sizeBytes) row(t('lansync.summarySize'), formatBytes(sizeBytes))
|
||||||
|
const modal = showModal(`
|
||||||
|
<h2>${t('lansync.summaryTitle')}</h2>
|
||||||
|
<p class="subtitle" id="ls-summary-from"></p>
|
||||||
|
<div class="sync-summary">${rows.join('')}</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="button primary" id="ls-summary-ok">${t('dialog.ok')}</button>
|
||||||
|
</div>
|
||||||
|
`)
|
||||||
|
// the device name comes off the network: never interpolate it into HTML
|
||||||
|
modal.querySelector('#ls-summary-from').textContent = t(
|
||||||
|
received ? 'lansync.summaryReceivedFrom' : 'lansync.summarySentTo',
|
||||||
|
from || ''
|
||||||
|
)
|
||||||
|
modal.querySelector('#ls-summary-ok').onclick = closeModal
|
||||||
|
}
|
||||||
|
|
||||||
// "340 MB / 1.2 GB" once the total is known, otherwise just the moved bytes.
|
// "340 MB / 1.2 GB" once the total is known, otherwise just the moved bytes.
|
||||||
function lanBytesLine(bytes, totalBytes) {
|
function lanBytesLine(bytes, totalBytes) {
|
||||||
const sent = formatBytes(bytes)
|
const sent = formatBytes(bytes)
|
||||||
|
|
@ -1785,15 +1830,16 @@ async function runLanSync(device, code, categories, force) {
|
||||||
active = false
|
active = false
|
||||||
state.lanPushing = false
|
state.lanPushing = false
|
||||||
}
|
}
|
||||||
closeModal()
|
|
||||||
if (res && res.ok) {
|
if (res && res.ok) {
|
||||||
const summary = res.summary || {}
|
// swap the locked progress popup into the result, not a toast
|
||||||
toast(t('lansync.done', device.name, summary.uploaded || 0, summary.skipped || 0, summary.deleted || 0, formatBytes(summary.bytes || 0)), 'success')
|
showSyncSummary({ from: device.name, received: false, categories, summary: res.summary || {} })
|
||||||
await renderLanSync()
|
await renderLanSync()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
closeModal()
|
||||||
const err = (res && res.error) || ''
|
const err = (res && res.error) || ''
|
||||||
if (err === 'bad-code') {
|
// the server answers a wrong code as 'auth-failed'; both mean "ask again"
|
||||||
|
if (err === 'bad-code' || err === 'auth-failed') {
|
||||||
await window.plwl.lansyncForget(device.id)
|
await window.plwl.lansyncForget(device.id)
|
||||||
toast(t('lansync.badCode'), 'error')
|
toast(t('lansync.badCode'), 'error')
|
||||||
openLanSyncModal({ ...device, code: '' }, 'lansync.badCode')
|
openLanSyncModal({ ...device, code: '' }, 'lansync.badCode')
|
||||||
|
|
@ -1801,7 +1847,7 @@ async function runLanSync(device, code, categories, force) {
|
||||||
}
|
}
|
||||||
const message =
|
const message =
|
||||||
err === 'busy' ? t('lansync.busy')
|
err === 'busy' ? t('lansync.busy')
|
||||||
: err === 'offline' ? 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 || t('lansync.unknownError'))
|
: (err || t('lansync.unknownError'))
|
||||||
|
|
@ -1877,6 +1923,9 @@ function onLanIncoming(info) {
|
||||||
}
|
}
|
||||||
|
|
||||||
let receiving = false
|
let receiving = false
|
||||||
|
// The last apply payload, for the end-of-sync summary (it carries files,
|
||||||
|
// bytes, skipped files and the total size of the synced trees).
|
||||||
|
let lastApplied = null
|
||||||
// Only the sender's 'done' ends this; a watchdog-closed popup may reopen.
|
// Only the sender's 'done' ends this; a watchdog-closed popup may reopen.
|
||||||
let receiveEnded = false
|
let receiveEnded = false
|
||||||
let receiveWatchdog = null
|
let receiveWatchdog = null
|
||||||
|
|
@ -1935,12 +1984,25 @@ function armReceiveWatchdog() {
|
||||||
function onLanReceiving(info) {
|
function onLanReceiving(info) {
|
||||||
if (!info || !info.stage) return
|
if (!info || !info.stage) return
|
||||||
if (state.lanPushing) return // our own push owns the (single) popup
|
if (state.lanPushing) return // our own push owns the (single) popup
|
||||||
if (info.stage === 'start') receiveEnded = false
|
if (info.stage === 'start') {
|
||||||
if (info.stage === 'done') receiveEnded = true
|
receiveEnded = false
|
||||||
|
lastApplied = null
|
||||||
|
}
|
||||||
|
if (info.stage === 'done') {
|
||||||
|
receiveEnded = true
|
||||||
|
// the popup may have been closed by the watchdog: still show the result
|
||||||
|
// if nothing else owns the modal slot
|
||||||
|
if (!receiving) {
|
||||||
|
if (lastApplied && qs('#modal-overlay').style.display === 'none') showReceiveSummary(info)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!receiving) {
|
if (!receiving) {
|
||||||
// reopen a watchdog-closed popup unless the sync already ended
|
// reopen a watchdog-closed popup unless the sync already ended
|
||||||
if (receiveEnded) return
|
if (receiveEnded) return
|
||||||
if (qs('#modal-overlay').style.display !== 'none') return
|
// a completion summary underneath gives way to the next sync's progress
|
||||||
|
const summaryOpen = !!qs('#modal').querySelector('#ls-summary-ok')
|
||||||
|
if (!summaryOpen && qs('#modal-overlay').style.display !== 'none') return
|
||||||
receiving = true
|
receiving = true
|
||||||
const modal = showModal(`
|
const modal = showModal(`
|
||||||
<h2 id="ls-recv-title"></h2>
|
<h2 id="ls-recv-title"></h2>
|
||||||
|
|
@ -1979,15 +2041,47 @@ function renderReceiving(info) {
|
||||||
parts.stats.textContent = lanStatsLine(info.items, info.itemsDone, info.totals)
|
parts.stats.textContent = lanStatsLine(info.items, info.itemsDone, info.totals)
|
||||||
parts.bytes.textContent = lanBytesLine(info.bytes, info.totalBytes)
|
parts.bytes.textContent = lanBytesLine(info.bytes, info.totalBytes)
|
||||||
if (info.stage === 'done') {
|
if (info.stage === 'done') {
|
||||||
// let the totals be read, then close (the library reloads itself)
|
// hold the finished bar for a moment, then swap it for the summary
|
||||||
if (receiveCloseTimer) clearTimeout(receiveCloseTimer)
|
if (receiveCloseTimer) clearTimeout(receiveCloseTimer)
|
||||||
receiveCloseTimer = setTimeout(() => {
|
receiveCloseTimer = setTimeout(() => {
|
||||||
receiveCloseTimer = null
|
receiveCloseTimer = null
|
||||||
endReceiving('')
|
showReceiveSummary(info)
|
||||||
}, 4000)
|
}, 1200)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Swap the finished progress popup for the result; the library has already
|
||||||
|
// been reloaded by the apply event.
|
||||||
|
function showReceiveSummary(info) {
|
||||||
|
if (receiveWatchdog) {
|
||||||
|
clearTimeout(receiveWatchdog)
|
||||||
|
receiveWatchdog = null
|
||||||
|
}
|
||||||
|
if (receiveStall) {
|
||||||
|
clearTimeout(receiveStall)
|
||||||
|
receiveStall = null
|
||||||
|
}
|
||||||
|
if (receiveCloseTimer) {
|
||||||
|
clearTimeout(receiveCloseTimer)
|
||||||
|
receiveCloseTimer = null
|
||||||
|
}
|
||||||
|
receiving = false
|
||||||
|
const applied = lastApplied || {}
|
||||||
|
showSyncSummary({
|
||||||
|
from: applied.from || info.from || '',
|
||||||
|
received: true,
|
||||||
|
categories: applied.categories || [],
|
||||||
|
summary: {
|
||||||
|
uploaded: applied.files ?? info.done,
|
||||||
|
skipped: applied.skipped || 0,
|
||||||
|
deleted: applied.deleted ?? info.deleted,
|
||||||
|
bytes: applied.bytes ?? info.bytes,
|
||||||
|
sizeBytes: applied.sizeBytes ?? info.sizeBytes,
|
||||||
|
items: applied.items || info.items || {}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 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([
|
||||||
|
|
@ -2014,7 +2108,8 @@ 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.onLanSyncApplied(async info => {
|
window.plwl.onLanSyncApplied(async info => {
|
||||||
toast(t('lansync.applied', info.from || '', info.files || 0, formatBytes(info.bytes || 0)), 'success')
|
// the summary popup replaces the toast; reload behind it
|
||||||
|
lastApplied = info
|
||||||
await reloadLocalData()
|
await reloadLocalData()
|
||||||
})
|
})
|
||||||
qs('#ls-reroll-id').addEventListener('click', async () => {
|
qs('#ls-reroll-id').addEventListener('click', async () => {
|
||||||
|
|
@ -2079,7 +2174,7 @@ function wireEvents() {
|
||||||
toast(t('wines.imported', res.path), 'success')
|
toast(t('wines.imported', res.path), 'success')
|
||||||
state.wines = await window.plwl.winesList()
|
state.wines = await window.plwl.winesList()
|
||||||
} else {
|
} else {
|
||||||
toast(t('toast.importFailed') + ': ' + (res.error || ''), 'error')
|
toast(t('import.failed', importErrorText((res && res.error) || '')), 'error')
|
||||||
}
|
}
|
||||||
renderWines()
|
renderWines()
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -195,6 +195,20 @@ content {
|
||||||
user has to notice, so it stands out from the subtitles around it. */
|
user has to notice, so it stands out from the subtitles around it. */
|
||||||
.sync-warning { color: var(--big-red); margin-top: 0.6rem; }
|
.sync-warning { color: var(--big-red); margin-top: 0.6rem; }
|
||||||
|
|
||||||
|
/* End-of-sync summary: label left, count/size right, one row per fact. */
|
||||||
|
.sync-summary { display: flex; flex-direction: column; }
|
||||||
|
.sync-summary-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.45rem 0;
|
||||||
|
border-bottom: var(--border-width) solid var(--light-border-color);
|
||||||
|
}
|
||||||
|
.sync-summary-row:last-child { border-bottom: none; padding-bottom: 0; }
|
||||||
|
.sync-summary-row span { color: var(--darker-text-color); }
|
||||||
|
.sync-summary-row strong { font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
.button {
|
.button {
|
||||||
height: var(--button-height);
|
height: var(--button-height);
|
||||||
padding: 0 1rem;
|
padding: 0 1rem;
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,10 @@ function progressGate(emit) {
|
||||||
return push
|
return push
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tree walks are dominated by per-file syscalls; a handful in flight keeps a
|
||||||
|
// slow disk busy without flooding the event loop with thousands of them.
|
||||||
|
const TREE_CONCURRENCY = 16
|
||||||
|
|
||||||
// Run `fn` over `items` with at most `limit` in flight, results in order.
|
// Run `fn` over `items` with at most `limit` in flight, results in order.
|
||||||
async function mapLimit(items, limit, fn) {
|
async function mapLimit(items, limit, fn) {
|
||||||
const out = new Array(items.length)
|
const out = new Array(items.length)
|
||||||
|
|
@ -90,7 +94,7 @@ async function treeSize(root) {
|
||||||
} catch {
|
} catch {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for (const entry of entries) {
|
await mapLimit(entries, TREE_CONCURRENCY, async entry => {
|
||||||
const full = path.join(dir, entry.name)
|
const full = path.join(dir, entry.name)
|
||||||
if (entry.isSymbolicLink()) {
|
if (entry.isSymbolicLink()) {
|
||||||
files++
|
files++
|
||||||
|
|
@ -102,7 +106,7 @@ async function treeSize(root) {
|
||||||
bytes += (await fsp.stat(full)).size
|
bytes += (await fsp.stat(full)).size
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
await walk(root)
|
await walk(root)
|
||||||
return { files, bytes }
|
return { files, bytes }
|
||||||
|
|
@ -110,9 +114,12 @@ async function treeSize(root) {
|
||||||
|
|
||||||
// Copy a tree with symlinks recreated and permissions carried (copyFile
|
// Copy a tree with symlinks recreated and permissions carried (copyFile
|
||||||
// drops both); reports cumulative { done, bytes } through onProgress.
|
// drops both); reports cumulative { done, bytes } through onProgress.
|
||||||
|
// `failed` counts entries that could not be recreated: a caller that needs a
|
||||||
|
// complete copy (an import) must not treat a partial tree as success.
|
||||||
async function copyTree(src, dst, onProgress) {
|
async function copyTree(src, dst, onProgress) {
|
||||||
let done = 0
|
let done = 0
|
||||||
let bytes = 0
|
let bytes = 0
|
||||||
|
let failed = 0
|
||||||
const gate = onProgress ? progressGate(onProgress) : null
|
const gate = onProgress ? progressGate(onProgress) : null
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
if (gate) gate({ done, bytes }, false)
|
if (gate) gate({ done, bytes }, false)
|
||||||
|
|
@ -122,10 +129,16 @@ async function copyTree(src, dst, onProgress) {
|
||||||
try {
|
try {
|
||||||
entries = await fsp.readdir(from, { withFileTypes: true })
|
entries = await fsp.readdir(from, { withFileTypes: true })
|
||||||
} catch {
|
} catch {
|
||||||
|
failed++
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
await fsp.mkdir(to, { recursive: true })
|
await fsp.mkdir(to, { recursive: true })
|
||||||
for (const entry of entries) {
|
} catch {
|
||||||
|
failed++
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await mapLimit(entries, TREE_CONCURRENCY, async entry => {
|
||||||
const s = path.join(from, entry.name)
|
const s = path.join(from, entry.name)
|
||||||
const d = path.join(to, entry.name)
|
const d = path.join(to, entry.name)
|
||||||
if (entry.isSymbolicLink()) {
|
if (entry.isSymbolicLink()) {
|
||||||
|
|
@ -133,32 +146,46 @@ async function copyTree(src, dst, onProgress) {
|
||||||
try {
|
try {
|
||||||
target = await fsp.readlink(s)
|
target = await fsp.readlink(s)
|
||||||
} catch {}
|
} catch {}
|
||||||
if (!target) continue
|
if (!target) {
|
||||||
|
failed++
|
||||||
|
return
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await fsp.symlink(target, d)
|
await fsp.symlink(target, d)
|
||||||
} catch {}
|
} catch {
|
||||||
|
failed++
|
||||||
|
}
|
||||||
done++
|
done++
|
||||||
tick()
|
tick()
|
||||||
} else if (entry.isDirectory()) {
|
} else if (entry.isDirectory()) {
|
||||||
await walk(s, d)
|
await walk(s, d)
|
||||||
} else if (entry.isFile()) {
|
} else if (entry.isFile()) {
|
||||||
let size = 0
|
let st = null
|
||||||
try {
|
try {
|
||||||
size = (await fsp.stat(s)).size
|
st = await fsp.stat(s)
|
||||||
} catch {}
|
} catch {}
|
||||||
|
if (!st) {
|
||||||
|
failed++
|
||||||
|
return
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await fsp.copyFile(s, d)
|
await fsp.copyFile(s, d)
|
||||||
await fsp.chmod(d, (await fsp.stat(s)).mode & 0o777)
|
} catch {
|
||||||
|
failed++
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await fsp.chmod(d, st.mode & 0o777)
|
||||||
} catch {}
|
} catch {}
|
||||||
done++
|
done++
|
||||||
bytes += size
|
bytes += st.size
|
||||||
tick()
|
tick()
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
await walk(src, dst)
|
await walk(src, dst)
|
||||||
if (gate) gate({ done, bytes }, true)
|
if (gate) gate({ done, bytes }, true)
|
||||||
return { done, bytes }
|
return { done, bytes, failed }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Imported trees come from media that lose permissions (archives, FAT, a
|
// Imported trees come from media that lose permissions (archives, FAT, a
|
||||||
|
|
@ -176,13 +203,13 @@ async function chmodTree(root) {
|
||||||
} catch {
|
} catch {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for (const entry of entries) {
|
await mapLimit(entries, TREE_CONCURRENCY, async entry => {
|
||||||
const full = path.join(dir, entry.name)
|
const full = path.join(dir, entry.name)
|
||||||
if (entry.isSymbolicLink()) continue
|
if (entry.isSymbolicLink()) return
|
||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
await walk(full)
|
await walk(full)
|
||||||
} else if (!entry.isFile()) {
|
} else if (!entry.isFile()) {
|
||||||
continue
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const before = (await fsp.stat(full)).mode & 0o7777
|
const before = (await fsp.stat(full)).mode & 0o7777
|
||||||
|
|
@ -193,7 +220,7 @@ async function chmodTree(root) {
|
||||||
else files++
|
else files++
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
await walk(root)
|
await walk(root)
|
||||||
return { files, dirs }
|
return { files, dirs }
|
||||||
|
|
@ -275,7 +302,7 @@ async function removeTree(root, onProgress) {
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
for (const entry of entries) {
|
await mapLimit(entries, TREE_CONCURRENCY, async entry => {
|
||||||
const full = path.join(dir, entry.name)
|
const full = path.join(dir, entry.name)
|
||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
await walk(full)
|
await walk(full)
|
||||||
|
|
@ -284,7 +311,7 @@ async function removeTree(root, onProgress) {
|
||||||
} catch {}
|
} catch {}
|
||||||
dirs++
|
dirs++
|
||||||
tick()
|
tick()
|
||||||
continue
|
return
|
||||||
}
|
}
|
||||||
let size = 0
|
let size = 0
|
||||||
try {
|
try {
|
||||||
|
|
@ -297,7 +324,7 @@ async function removeTree(root, onProgress) {
|
||||||
files++
|
files++
|
||||||
bytes += size
|
bytes += size
|
||||||
tick()
|
tick()
|
||||||
}
|
})
|
||||||
try {
|
try {
|
||||||
await fsp.rmdir(dir)
|
await fsp.rmdir(dir)
|
||||||
dirs++
|
dirs++
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,21 @@ const { copyTree, treeSize, chmodTree } = require('./fsutil')
|
||||||
// Directories that typically hold installers/redistributables, not the game.
|
// Directories that typically hold installers/redistributables, not the game.
|
||||||
const SKIPPED_DIRS = new Set(['redist', 'directx', '_commonredist', 'support', 'dotnet', 'vc'])
|
const SKIPPED_DIRS = new Set(['redist', 'directx', '_commonredist', 'support', 'dotnet', 'vc'])
|
||||||
|
|
||||||
|
// A copy that lost files is not an import: drop the partial tree and say so.
|
||||||
|
async function copyComplete(src, dst, onProgress) {
|
||||||
|
const res = await copyTree(src, dst, onProgress)
|
||||||
|
if (!res.failed) return res
|
||||||
|
await fsp.rm(dst, { recursive: true, force: true })
|
||||||
|
throw new Error('copy-incomplete')
|
||||||
|
}
|
||||||
|
|
||||||
// The copy itself lives in fsutil.copyTree; moveFolder falls back to it
|
// The copy itself lives in fsutil.copyTree; moveFolder falls back to it
|
||||||
// across filesystems only.
|
// across filesystems only.
|
||||||
function moveFolder(src, dst, onProgress) {
|
function moveFolder(src, dst, onProgress) {
|
||||||
const fallback = () => copyTree(src, dst, onProgress).then(() => fsp.rm(src, { recursive: true, force: true }))
|
const fallback = async () => {
|
||||||
|
await copyComplete(src, dst, onProgress)
|
||||||
|
await fsp.rm(src, { recursive: true, force: true })
|
||||||
|
}
|
||||||
if (path.parse(src).root !== path.parse(dst).root) return fallback()
|
if (path.parse(src).root !== path.parse(dst).root) return fallback()
|
||||||
return fsp.rename(src, dst).catch(fallback)
|
return fsp.rename(src, dst).catch(fallback)
|
||||||
}
|
}
|
||||||
|
|
@ -44,7 +55,7 @@ async function importGame(srcDir, mode, onProgress, kind = 'windows') {
|
||||||
}
|
}
|
||||||
emit('count', { files: 0, bytes: 0 })
|
emit('count', { files: 0, bytes: 0 })
|
||||||
const total = await treeSize(srcDir)
|
const total = await treeSize(srcDir)
|
||||||
await copyTree(srcDir, dst, p => emit('copy', { files: p.done, bytes: p.bytes, totalFiles: total.files, totalBytes: total.bytes }))
|
await copyComplete(srcDir, dst, p => emit('copy', { files: p.done, bytes: p.bytes, totalFiles: total.files, totalBytes: total.bytes }))
|
||||||
} else await moveFolder(srcDir, dst, onProgress)
|
} else await moveFolder(srcDir, dst, onProgress)
|
||||||
workdir = dst
|
workdir = dst
|
||||||
// the source's permissions are not the source's truth: an app out of a
|
// the source's permissions are not the source's truth: an app out of a
|
||||||
|
|
@ -150,4 +161,4 @@ async function findDefaultExe(dir) {
|
||||||
return best
|
return best
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { importGame, importGameFromPrefix, findDefaultExe, findLinuxExe, moveFolder }
|
module.exports = { importGame, importGameFromPrefix, findDefaultExe, findLinuxExe, moveFolder, copyComplete }
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ function registerLanSyncIpc({ getWindow }) {
|
||||||
code: payload && payload.code,
|
code: payload && payload.code,
|
||||||
categories: payload && payload.categories,
|
categories: payload && payload.categories,
|
||||||
force: !!(payload && payload.force),
|
force: !!(payload && payload.force),
|
||||||
|
selection: payload && payload.selection,
|
||||||
onProgress: data => sendToWindow(getWindow(), 'lansync:progress', data)
|
onProgress: data => sendToWindow(getWindow(), 'lansync:progress', data)
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -23,15 +23,18 @@ function createManifestBuilder({ filesPath, filesRoot, hash, mapLimit, concurren
|
||||||
// Walk without following symlinks (dosdevices etc. are part of the sync).
|
// Walk without following symlinks (dosdevices etc. are part of the sync).
|
||||||
// Iterative, with one directory's stat/readlink calls in flight together.
|
// Iterative, with one directory's stat/readlink calls in flight together.
|
||||||
// `emit` (optional) streams dirs and links out while the walk continues;
|
// `emit` (optional) streams dirs and links out while the walk continues;
|
||||||
// file entries only land in `out` (their digest comes later).
|
// file entries only land in `out` (their digest comes later). `onError`
|
||||||
async function scanTree(abs, rel, out, mtimes, emit) {
|
// marks the unit incomplete: an entry we could not read is not an entry the
|
||||||
|
// sender decided to delete.
|
||||||
|
async function scanTree(abs, rel, out, mtimes, emit, onError) {
|
||||||
const queue = [[abs, rel]]
|
const queue = [[abs, rel]]
|
||||||
while (queue.length) {
|
while (queue.length) {
|
||||||
const [dirAbs, dirRel] = queue.pop()
|
const [dirAbs, dirRel] = queue.pop()
|
||||||
let entries
|
let entries
|
||||||
try {
|
try {
|
||||||
entries = await fsp.readdir(dirAbs, { withFileTypes: true })
|
entries = await fsp.readdir(dirAbs, { withFileTypes: true })
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
if (onError) onError(err, dirAbs)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const dirs = []
|
const dirs = []
|
||||||
|
|
@ -54,7 +57,10 @@ function createManifestBuilder({ filesPath, filesRoot, hash, mapLimit, concurren
|
||||||
let target = ''
|
let target = ''
|
||||||
try {
|
try {
|
||||||
target = await fsp.readlink(absChild)
|
target = await fsp.readlink(absChild)
|
||||||
} catch {}
|
} catch (err) {
|
||||||
|
if (onError) onError(err, absChild)
|
||||||
|
return
|
||||||
|
}
|
||||||
const record = { path: relChild, type: 'link', target: portableLinkTarget(absChild, target) }
|
const record = { path: relChild, type: 'link', target: portableLinkTarget(absChild, target) }
|
||||||
out.push(record)
|
out.push(record)
|
||||||
if (emit) await emit(record)
|
if (emit) await emit(record)
|
||||||
|
|
@ -63,7 +69,8 @@ function createManifestBuilder({ filesPath, filesRoot, hash, mapLimit, concurren
|
||||||
let st
|
let st
|
||||||
try {
|
try {
|
||||||
st = await fsp.stat(absChild)
|
st = await fsp.stat(absChild)
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
if (onError) onError(err, absChild)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!st.isFile()) return
|
if (!st.isFile()) return
|
||||||
|
|
@ -77,29 +84,45 @@ 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 and onProgress. File entries
|
// per entry, awaited - backpressure), onTotal, onProgress and onIncomplete
|
||||||
|
// (called once, when anything in the tree could not be read). File entries
|
||||||
// go out as their digests finish, so the receiver compares while this side
|
// go out as their digests finish, so the receiver compares while this side
|
||||||
// is still hashing; hashing itself stays parallel, a batch at a time.
|
// 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 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
|
||||||
|
const onIncomplete = handlers && handlers.onIncomplete
|
||||||
const entries = []
|
const entries = []
|
||||||
const mtimes = new Map()
|
const mtimes = new Map()
|
||||||
|
let incomplete = false
|
||||||
|
const markIncomplete = (err, file) => {
|
||||||
|
if (incomplete) return
|
||||||
|
incomplete = true
|
||||||
|
if (onIncomplete) onIncomplete(err, file)
|
||||||
|
}
|
||||||
for (const rel of roots) {
|
for (const rel of roots) {
|
||||||
const abs = filesPath(rel)
|
const abs = filesPath(rel)
|
||||||
if (!abs) continue
|
if (!abs) {
|
||||||
|
markIncomplete(new Error('bad-unit-path'), rel)
|
||||||
|
continue
|
||||||
|
}
|
||||||
let st
|
let st
|
||||||
try {
|
try {
|
||||||
st = await fsp.lstat(abs)
|
st = await fsp.lstat(abs)
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
// a root that is simply not there is a unit with nothing in it
|
||||||
|
if (err && err.code !== 'ENOENT') markIncomplete(err, abs)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (st.isSymbolicLink()) {
|
if (st.isSymbolicLink()) {
|
||||||
let target = ''
|
let target = ''
|
||||||
try {
|
try {
|
||||||
target = await fsp.readlink(abs)
|
target = await fsp.readlink(abs)
|
||||||
} catch {}
|
} catch (err) {
|
||||||
|
markIncomplete(err, abs)
|
||||||
|
continue
|
||||||
|
}
|
||||||
const record = { path: rel, type: 'link', target: portableLinkTarget(abs, target) }
|
const record = { path: rel, type: 'link', target: portableLinkTarget(abs, target) }
|
||||||
entries.push(record)
|
entries.push(record)
|
||||||
if (onEntry) await onEntry(record)
|
if (onEntry) await onEntry(record)
|
||||||
|
|
@ -107,7 +130,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)
|
await scanTree(abs, rel, entries, mtimes, onEntry, markIncomplete)
|
||||||
} 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)
|
||||||
|
|
@ -136,8 +159,10 @@ function createManifestBuilder({ filesPath, filesRoot, hash, mapLimit, concurren
|
||||||
if (onProgress) onProgress(done, files.length, hashed, totalBytes)
|
if (onProgress) onProgress(done, files.length, hashed, totalBytes)
|
||||||
})
|
})
|
||||||
: null
|
: null
|
||||||
} catch {
|
if (!entry.hash) markIncomplete(new Error('unreadable-file'), entry.path)
|
||||||
|
} catch (err) {
|
||||||
entry.hash = null
|
entry.hash = null
|
||||||
|
markIncomplete(err, entry.path)
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
260
src/lansync.js
260
src/lansync.js
|
|
@ -18,6 +18,7 @@ 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 { normalizePathName } = require('./names')
|
||||||
const library = require('./library')
|
const library = require('./library')
|
||||||
const wines = require('./wines')
|
const wines = require('./wines')
|
||||||
const prefixes = require('./prefixes')
|
const prefixes = require('./prefixes')
|
||||||
|
|
@ -34,7 +35,8 @@ const BEACON_MS = 2500
|
||||||
const DEVICE_TTL_MS = 6000
|
const DEVICE_TTL_MS = 6000
|
||||||
|
|
||||||
// Cost + one-in-flight limit make guessing the 8 character code pointless.
|
// Cost + one-in-flight limit make guessing the 8 character code pointless.
|
||||||
const VERIFY_DELAY_MS = 2000
|
// Env override for tests.
|
||||||
|
const VERIFY_DELAY_MS = Number(process.env.PLWL_LANSYNC_VERIFY_DELAY) || 2000
|
||||||
// Session token TTL; env override for tests.
|
// Session token TTL; env override for tests.
|
||||||
const TOKEN_TTL_MS = Number(process.env.PLWL_LANSYNC_TOKEN_TTL) || 30 * 60 * 1000
|
const TOKEN_TTL_MS = Number(process.env.PLWL_LANSYNC_TOKEN_TTL) || 30 * 60 * 1000
|
||||||
const SESSION_IDLE_MS = 5 * 60 * 1000
|
const SESSION_IDLE_MS = 5 * 60 * 1000
|
||||||
|
|
@ -47,6 +49,20 @@ const SYNC_ROOTS = ['apps/', 'wines/', 'wineprefixes/', 'data/icons/']
|
||||||
// What a sync may carry, one name per category.
|
// What a sync may carry, one name per category.
|
||||||
const CATEGORIES = ['apps-portable', 'apps-prefix', 'wines', 'prefixes', 'settings']
|
const CATEGORIES = ['apps-portable', 'apps-prefix', 'wines', 'prefixes', 'settings']
|
||||||
|
|
||||||
|
// Names that become paths (app ids, prefix folders) are validated before they
|
||||||
|
// are stored: a paired device sends them, but a corrupted registry must never
|
||||||
|
// turn a later click into a write outside files/.
|
||||||
|
function safeName(value) {
|
||||||
|
const name = typeof value === 'string' ? value : ''
|
||||||
|
if (!name || name.length > 200 || name === '.' || name === '..') return ''
|
||||||
|
return /[\u0000-\u001f\u007f/\\]/.test(name) ? '' : name
|
||||||
|
}
|
||||||
|
|
||||||
|
function safePrefixName(value) {
|
||||||
|
const name = typeof value === 'string' ? value : ''
|
||||||
|
return name && normalizePathName(name) === name ? name : ''
|
||||||
|
}
|
||||||
|
|
||||||
// An app entry's category: portable imports own their apps/ folder.
|
// An app entry's category: portable imports own their apps/ folder.
|
||||||
function appCategory(game) {
|
function appCategory(game) {
|
||||||
if (!game || typeof game !== 'object') return null
|
if (!game || typeof game !== 'object') return null
|
||||||
|
|
@ -228,10 +244,11 @@ async function entrySame(entry) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!entry.hash) return false // forced sync (or unreadable file) -> replace it
|
if (!entry.hash) return false // forced sync (or unreadable file) -> replace it
|
||||||
// A size mismatch alone settles it - no need to hash.
|
// A size mismatch alone settles it - no need to hash. Async: a compare of
|
||||||
|
// tens of thousands of files must not block the launcher's main thread.
|
||||||
let st
|
let st
|
||||||
try {
|
try {
|
||||||
st = fs.lstatSync(abs)
|
st = await fsp.lstat(abs)
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
@ -248,15 +265,15 @@ async function entrySame(entry) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureDir(abs) {
|
async function ensureDir(abs) {
|
||||||
let st = null
|
let st = null
|
||||||
try {
|
try {
|
||||||
st = fs.lstatSync(abs)
|
st = await fsp.lstat(abs)
|
||||||
} catch {}
|
} catch {}
|
||||||
if (st && st.isDirectory() && !st.isSymbolicLink()) return
|
if (st && st.isDirectory() && !st.isSymbolicLink()) return
|
||||||
if (st) removeAbs(abs)
|
if (st) removeAbs(abs)
|
||||||
try {
|
try {
|
||||||
fs.mkdirSync(abs, { recursive: true })
|
await fsp.mkdir(abs, { recursive: true })
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -276,17 +293,17 @@ async function healUnitRoots(roots) {
|
||||||
return healed
|
return healed
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureLink(abs, target) {
|
async function ensureLink(abs, target) {
|
||||||
if (typeof target !== 'string' || !target) return
|
if (typeof target !== 'string' || !target) return
|
||||||
let current = null
|
let current = null
|
||||||
try {
|
try {
|
||||||
current = fs.readlinkSync(abs)
|
current = await fsp.readlink(abs)
|
||||||
} catch {}
|
} catch {}
|
||||||
if (current === target) return
|
if (current === target) return
|
||||||
removeAbs(abs)
|
removeAbs(abs)
|
||||||
try {
|
try {
|
||||||
fs.mkdirSync(path.dirname(abs), { recursive: true })
|
await fsp.mkdir(path.dirname(abs), { recursive: true })
|
||||||
fs.symlinkSync(target, abs)
|
await fsp.symlink(target, abs)
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -397,15 +414,27 @@ function writeStream(abs, stream, onBytes) {
|
||||||
} catch {}
|
} catch {}
|
||||||
const tmp = `${abs}.plwl-sync-tmp`
|
const tmp = `${abs}.plwl-sync-tmp`
|
||||||
const out = fs.createWriteStream(tmp)
|
const out = fs.createWriteStream(tmp)
|
||||||
stream.on('error', err => {
|
let settled = false
|
||||||
removeAbs(tmp)
|
// A sender that dies mid-file must not leave the temp file behind or keep
|
||||||
reject(err)
|
// this request (and its unit) waiting forever: every failure path settles.
|
||||||
})
|
const fail = err => {
|
||||||
out.on('error', err => {
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
try {
|
||||||
|
out.destroy()
|
||||||
|
} catch {}
|
||||||
removeAbs(tmp)
|
removeAbs(tmp)
|
||||||
reject(err)
|
reject(err)
|
||||||
|
}
|
||||||
|
stream.on('error', fail)
|
||||||
|
out.on('error', fail)
|
||||||
|
stream.on('aborted', () => fail(new Error('body-aborted')))
|
||||||
|
stream.on('close', () => {
|
||||||
|
if (!stream.readableEnded) fail(new Error('body-aborted'))
|
||||||
})
|
})
|
||||||
out.on('finish', () => {
|
out.on('finish', () => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
try {
|
try {
|
||||||
if (fs.lstatSync(abs).isDirectory()) removeAbs(abs)
|
if (fs.lstatSync(abs).isDirectory()) removeAbs(abs)
|
||||||
} catch {} // nothing there (the common case) or a link - both fine to rename over
|
} catch {} // nothing there (the common case) or a link - both fine to rename over
|
||||||
|
|
@ -430,23 +459,47 @@ function writeStream(abs, stream, onBytes) {
|
||||||
|
|
||||||
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 0
|
||||||
const incoming = (Array.isArray(payload.apps) ? payload.apps : []).filter(g => g && g.id)
|
// 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
|
||||||
|
// may speak for a category.
|
||||||
|
if (!Array.isArray(payload.apps)) return 0
|
||||||
|
// A partial app selection only carries the chosen apps: the rest of the
|
||||||
|
// category is not the sender's business this time.
|
||||||
|
const partial = payload.appScope === 'selected'
|
||||||
|
const incoming = []
|
||||||
|
for (const raw of payload.apps) {
|
||||||
|
if (!raw || typeof raw !== 'object') continue
|
||||||
|
const id = safeName(raw.id)
|
||||||
|
if (!id) continue
|
||||||
|
const prefix = raw.prefix
|
||||||
|
if (prefix && !safePrefixName(prefix)) continue
|
||||||
|
incoming.push({ ...raw, id, prefix: prefix || '' })
|
||||||
|
}
|
||||||
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 kept = []
|
const kept = []
|
||||||
const dropped = new Set()
|
const dropped = new Set()
|
||||||
for (const game of existing) {
|
for (const game of existing) {
|
||||||
const category = appCategory(game)
|
const category = appCategory(game)
|
||||||
if (category && cats.has(category)) {
|
if (category && cats.has(category)) {
|
||||||
if (!incomingIds.has(game.id)) dropped.add(game.id)
|
if (incomingIds.has(game.id)) continue // replaced by the sender's copy
|
||||||
continue // replaced by the sender's copy
|
if (partial) {
|
||||||
|
kept.push(game)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dropped.add(game.id)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
kept.push(game)
|
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()
|
||||||
for (const g of incoming) {
|
for (const g of incoming) {
|
||||||
const prev = existing.find(x => x.id === g.id)
|
if (seen.has(g.id)) continue
|
||||||
|
seen.add(g.id)
|
||||||
|
const prev = prevById.get(g.id)
|
||||||
const hasPlay = Number.isFinite(Number(g.playSeconds))
|
const hasPlay = Number.isFinite(Number(g.playSeconds))
|
||||||
const playSeconds = hasPlay ? Math.max(Number(prev && prev.playSeconds) || 0, Number(g.playSeconds)) : prev && prev.playSeconds
|
const playSeconds = hasPlay ? Math.max(Number(prev && prev.playSeconds) || 0, Number(g.playSeconds)) : prev && prev.playSeconds
|
||||||
const lastPlayed = Math.max(Number(prev && prev.lastPlayed) || 0, Number(g.lastPlayed) || 0)
|
const lastPlayed = Math.max(Number(prev && prev.lastPlayed) || 0, Number(g.lastPlayed) || 0)
|
||||||
|
|
@ -457,16 +510,16 @@ function applyApps(payload, cats) {
|
||||||
library.saveGames(merged)
|
library.saveGames(merged)
|
||||||
let removed = 0
|
let removed = 0
|
||||||
for (const id of dropped) removed += removeTree(`data/icons/${id}`)
|
for (const id of dropped) removed += removeTree(`data/icons/${id}`)
|
||||||
const iconsRemoved = removed
|
// files/apps holds only portable imports; the rest is mirror leftover - but
|
||||||
if (cats.has('apps-portable')) {
|
// a partial selection never owns the folders it left out.
|
||||||
// files/apps holds only portable imports; the rest is mirror leftover
|
if (!partial && cats.has('apps-portable')) removed += removeOrphans(DIRS.APPS, incomingIds)
|
||||||
removed += removeOrphans(DIRS.APPS, incomingIds)
|
|
||||||
}
|
|
||||||
return removed
|
return removed
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applyWines(payload) {
|
async function applyWines(payload) {
|
||||||
const names = new Set((Array.isArray(payload.wines) ? payload.wines : []).map(w => w && w.name).filter(Boolean))
|
// no list -> nothing to mirror (see applyApps)
|
||||||
|
if (!Array.isArray(payload.wines)) return 0
|
||||||
|
const names = new Set(payload.wines.map(w => w && w.name).filter(Boolean))
|
||||||
const removed = removeOrphans(DIRS.WINES, names)
|
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()
|
||||||
|
|
@ -475,7 +528,9 @@ async function applyWines(payload) {
|
||||||
|
|
||||||
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 : {}
|
||||||
const incoming = (Array.isArray(sender.prefixes) ? sender.prefixes : []).filter(p => p && p.name)
|
// no list -> nothing to mirror (see applyApps)
|
||||||
|
if (!Array.isArray(sender.prefixes)) return 0
|
||||||
|
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]))
|
||||||
const keep = new Map(incoming.map(p => [p.name, p]))
|
const keep = new Map(incoming.map(p => [p.name, p]))
|
||||||
|
|
@ -486,7 +541,7 @@ function applyPrefixes(payload, useSenderDefault) {
|
||||||
const removed = removeOrphans(DIRS.PREFIXES, new Set(keep.keys()))
|
const removed = removeOrphans(DIRS.PREFIXES, new Set(keep.keys()))
|
||||||
const list = [...keep.values()].map(p => ({ ...p, locked: p.name === prefixes.MAIN ? true : !!p.locked }))
|
const list = [...keep.values()].map(p => ({ ...p, locked: p.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 ? 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 removed
|
||||||
|
|
@ -494,41 +549,20 @@ function applyPrefixes(payload, useSenderDefault) {
|
||||||
|
|
||||||
// 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.
|
||||||
function applyWineSettings(payload) {
|
function applyWineSettings(payload) {
|
||||||
const incoming = Array.isArray(payload.wineSettings) ? payload.wineSettings : []
|
return wines.applySettings(Array.isArray(payload.wineSettings) ? payload.wineSettings : [])
|
||||||
let applied = 0
|
|
||||||
for (const entry of incoming) {
|
|
||||||
if (!entry || !entry.name) continue
|
|
||||||
const id = String(entry.name)
|
|
||||||
const wine = wines.getWine(id)
|
|
||||||
if (!wine || wine.type === 'system') continue
|
|
||||||
let changed = false
|
|
||||||
const wanted = typeof entry.displayName === 'string' ? entry.displayName.trim() : ''
|
|
||||||
if (wanted !== (wine.displayName || '')) {
|
|
||||||
// a sender that never renamed the build clears the custom label here
|
|
||||||
if (wanted) wines.setWineDisplayName(id, wanted)
|
|
||||||
else wines.clearWineDisplayName(id)
|
|
||||||
changed = true
|
|
||||||
}
|
|
||||||
if (entry.disabled !== undefined && !!entry.disabled !== !!wine.disabled) {
|
|
||||||
wines.setWineDisabled(id, !!entry.disabled)
|
|
||||||
changed = true
|
|
||||||
}
|
|
||||||
if (changed) applied++
|
|
||||||
}
|
|
||||||
return applied
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prefix registry names only; registers sender entries as known here.
|
// Prefix registry names only; registers sender entries as known here.
|
||||||
function applyPrefixSettings(payload) {
|
function applyPrefixSettings(payload) {
|
||||||
const sender = payload.prefixSettings && typeof payload.prefixSettings === 'object' ? payload.prefixSettings : null
|
const sender = payload.prefixSettings && typeof payload.prefixSettings === 'object' ? payload.prefixSettings : null
|
||||||
if (!sender) return 0
|
if (!sender) return 0
|
||||||
const incoming = (Array.isArray(sender.prefixes) ? sender.prefixes : []).filter(p => p && p.name)
|
const incoming = (Array.isArray(sender.prefixes) ? sender.prefixes : []).filter(p => p && safePrefixName(p.name))
|
||||||
if (!incoming.length) return 0
|
if (!incoming.length) return 0
|
||||||
const defs = prefixes.listPrefixes()
|
const defs = prefixes.listPrefixes()
|
||||||
const known = new Set(defs.prefixes.map(p => p.name))
|
const known = new Set(defs.prefixes.map(p => p.name))
|
||||||
let added = 0
|
let added = 0
|
||||||
for (const entry of incoming) {
|
for (const entry of incoming) {
|
||||||
const name = String(entry.name)
|
const name = entry.name
|
||||||
if (known.has(name)) continue
|
if (known.has(name)) continue
|
||||||
known.add(name)
|
known.add(name)
|
||||||
defs.prefixes.push({ name, created: Number(entry.created) || Date.now(), locked: name === prefixes.MAIN ? true : !!entry.locked })
|
defs.prefixes.push({ name, created: Number(entry.created) || Date.now(), locked: name === prefixes.MAIN ? true : !!entry.locked })
|
||||||
|
|
@ -542,9 +576,16 @@ function applyPrefixSettings(payload) {
|
||||||
function applyAppSettings(payload) {
|
function applyAppSettings(payload) {
|
||||||
const incoming = Array.isArray(payload.appSettings) ? payload.appSettings : []
|
const incoming = Array.isArray(payload.appSettings) ? payload.appSettings : []
|
||||||
if (!incoming.length) return 0
|
if (!incoming.length) return 0
|
||||||
const byId = new Map(incoming.filter(a => a && a.id).map(a => [String(a.id), a]))
|
const byId = new Map()
|
||||||
|
for (const app of incoming) {
|
||||||
|
const id = safeName(app && app.id)
|
||||||
|
if (id) byId.set(id, app)
|
||||||
|
}
|
||||||
const knownPrefixes = new Set(prefixes.listPrefixes().prefixes.map(p => p.name))
|
const knownPrefixes = new Set(prefixes.listPrefixes().prefixes.map(p => p.name))
|
||||||
let patched = 0
|
const knownWines = new Set(wines.getWines().map(w => w.id))
|
||||||
|
// One patch per app, then one registry write: a library of a few hundred
|
||||||
|
// apps used to rewrite (and re-read) apps.json once per app.
|
||||||
|
const patches = new Map()
|
||||||
for (const game of library.loadGames()) {
|
for (const game of library.loadGames()) {
|
||||||
const from = byId.get(game.id)
|
const from = byId.get(game.id)
|
||||||
if (!from) continue
|
if (!from) continue
|
||||||
|
|
@ -552,7 +593,7 @@ function applyAppSettings(payload) {
|
||||||
if (typeof from.name === 'string' && from.name && from.name !== game.name) patch.name = from.name
|
if (typeof from.name === 'string' && from.name && from.name !== game.name) patch.name = from.name
|
||||||
if (from.wine !== undefined) {
|
if (from.wine !== undefined) {
|
||||||
const wanted = String(from.wine || 'default')
|
const wanted = String(from.wine || 'default')
|
||||||
if ((wanted === 'default' || wanted === 'system' || wines.getWine(wanted)) && wanted !== (game.wine || 'default')) patch.wine = wanted
|
if ((wanted === 'default' || wanted === 'system' || knownWines.has(wanted)) && wanted !== (game.wine || 'default')) patch.wine = wanted
|
||||||
}
|
}
|
||||||
if (from.prefix !== undefined && game.importSource !== 'prefix') {
|
if (from.prefix !== undefined && game.importSource !== 'prefix') {
|
||||||
const wanted = String(from.prefix || '')
|
const wanted = String(from.prefix || '')
|
||||||
|
|
@ -565,12 +606,9 @@ function applyAppSettings(payload) {
|
||||||
// Linux-only app config travels with the app's configuration too
|
// Linux-only app config travels with the app's configuration too
|
||||||
if (from.isolation !== undefined && from.isolation !== game.isolation) patch.isolation = from.isolation
|
if (from.isolation !== undefined && from.isolation !== game.isolation) patch.isolation = from.isolation
|
||||||
if (from.forceX11 !== undefined && !!from.forceX11 !== !!game.forceX11) patch.forceX11 = !!from.forceX11
|
if (from.forceX11 !== undefined && !!from.forceX11 !== !!game.forceX11) patch.forceX11 = !!from.forceX11
|
||||||
if (Object.keys(patch).length) {
|
if (Object.keys(patch).length) patches.set(game.id, patch)
|
||||||
library.updateGame(game.id, patch)
|
|
||||||
patched++
|
|
||||||
}
|
}
|
||||||
}
|
return library.updateGames(patches)
|
||||||
return patched
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Library order: entries unknown to the sender stay at the end.
|
// Library order: entries unknown to the sender stay at the end.
|
||||||
|
|
@ -619,7 +657,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: new Set(), pending: new Map(), session: null }
|
return { tokens: new Map(), verifying: false, pending: 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.
|
||||||
|
|
@ -652,6 +690,8 @@ function emitReceive(hooks, session, stage, extra = {}) {
|
||||||
total: extra.total == null ? session.planned || 0 : extra.total,
|
total: extra.total == null ? session.planned || 0 : extra.total,
|
||||||
bytes: session.bytes || 0,
|
bytes: session.bytes || 0,
|
||||||
totalBytes: session.plannedBytes || 0,
|
totalBytes: session.plannedBytes || 0,
|
||||||
|
skipped: session.skipped || 0,
|
||||||
|
sizeBytes: session.sizeBytes || 0,
|
||||||
// items: announced units, itemsDone: written units, totals: sender's plan
|
// items: announced units, itemsDone: written units, totals: sender's plan
|
||||||
items: { ...(session.items || {}) },
|
items: { ...(session.items || {}) },
|
||||||
itemsDone: { ...(session.itemsDone || {}) },
|
itemsDone: { ...(session.itemsDone || {}) },
|
||||||
|
|
@ -726,14 +766,16 @@ async function handleVerify(state, req, res, hooks) {
|
||||||
if (!payload || !payload.deviceId) return sendJson(res, 400, { ok: false, error: 'bad-request' })
|
if (!payload || !payload.deviceId) return sendJson(res, 400, { ok: false, error: 'bad-request' })
|
||||||
const me = identity()
|
const me = identity()
|
||||||
const from = String(payload.deviceId)
|
const from = String(payload.deviceId)
|
||||||
// one verification per device at a time
|
// ONE verification at a time, globally: the 2 s cost is what makes guessing
|
||||||
if (state.verifying.has(from)) return sendJson(res, 429, { ok: false, error: 'busy' })
|
// pointless, and parallel requests would pay it in parallel. The lock is
|
||||||
|
// taken before the first await for exactly that reason.
|
||||||
|
if (state.verifying) return sendJson(res, 429, { ok: false, error: 'busy' })
|
||||||
const running = activeSession(state)
|
const running = activeSession(state)
|
||||||
if (running && running.deviceId !== from) return sendJson(res, 429, { ok: false, error: 'busy' })
|
if (running && running.deviceId !== from) return sendJson(res, 429, { ok: false, error: 'busy' })
|
||||||
// one unanswered consent popup at a time
|
// one unanswered consent popup at a time
|
||||||
if (state.pending.size) return sendJson(res, 429, { ok: false, error: 'busy' })
|
if (state.pending.size) return sendJson(res, 429, { ok: false, error: 'busy' })
|
||||||
if (hooks.isBusy && hooks.isBusy(from)) return sendJson(res, 429, { ok: false, error: 'busy' })
|
if (hooks.isBusy && hooks.isBusy(from)) return sendJson(res, 429, { ok: false, error: 'busy' })
|
||||||
state.verifying.add(from)
|
state.verifying = true
|
||||||
try {
|
try {
|
||||||
await new Promise(resolve => setTimeout(resolve, VERIFY_DELAY_MS))
|
await new Promise(resolve => setTimeout(resolve, VERIFY_DELAY_MS))
|
||||||
if (!sameCode(payload.code, me.syncCode)) return sendJson(res, 403, { ok: false, error: 'auth-failed', reason: 'wrong-code' })
|
if (!sameCode(payload.code, me.syncCode)) return sendJson(res, 403, { ok: false, error: 'auth-failed', reason: 'wrong-code' })
|
||||||
|
|
@ -776,6 +818,10 @@ async function handleVerify(state, req, res, hooks) {
|
||||||
planned: 0,
|
planned: 0,
|
||||||
bytes: 0,
|
bytes: 0,
|
||||||
plannedBytes: 0,
|
plannedBytes: 0,
|
||||||
|
// end-of-sync summary: files already there, trees' total size, bytes
|
||||||
|
skipped: 0,
|
||||||
|
sizeBytes: 0,
|
||||||
|
unitSizes: new Map(),
|
||||||
items: { apps: 0, wines: 0, prefixes: 0 },
|
items: { apps: 0, wines: 0, prefixes: 0 },
|
||||||
itemsDone: { apps: 0, wines: 0, prefixes: 0 },
|
itemsDone: { apps: 0, wines: 0, prefixes: 0 },
|
||||||
barBytes: 0,
|
barBytes: 0,
|
||||||
|
|
@ -797,7 +843,7 @@ async function handleVerify(state, req, res, hooks) {
|
||||||
device: { id: me.deviceId, name: os.hostname() }
|
device: { id: me.deviceId, name: os.hostname() }
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
state.verifying.delete(from)
|
state.verifying = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -840,6 +886,9 @@ async function handlePlan(state, session, url, req, res, hooks) {
|
||||||
let unitTotalBytes = 0
|
let unitTotalBytes = 0
|
||||||
let compared = 0
|
let compared = 0
|
||||||
let comparedBytes = 0
|
let comparedBytes = 0
|
||||||
|
// A manifest the sender could not finish is not a complete picture: the
|
||||||
|
// unit may only be written, never pruned.
|
||||||
|
let incomplete = false
|
||||||
const compareOne = async entry => {
|
const compareOne = async entry => {
|
||||||
const same = forced ? false : await entrySame(entry)
|
const same = forced ? false : await entrySame(entry)
|
||||||
results.set(entry.path, same === true)
|
results.set(entry.path, same === true)
|
||||||
|
|
@ -867,6 +916,10 @@ async function handlePlan(state, session, url, req, res, hooks) {
|
||||||
session.barTotal = unitTotalBytes || 1
|
session.barTotal = unitTotalBytes || 1
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (entry.unitIncomplete === true) {
|
||||||
|
incomplete = true
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!inlineSame(entry)) return
|
if (!inlineSame(entry)) return
|
||||||
entries.push(entry)
|
entries.push(entry)
|
||||||
if (!withinRoots(entry.path, owned)) return
|
if (!withinRoots(entry.path, owned)) return
|
||||||
|
|
@ -885,6 +938,11 @@ async function handlePlan(state, session, url, req, res, hooks) {
|
||||||
})
|
})
|
||||||
await Promise.all(jobs)
|
await Promise.all(jobs)
|
||||||
hashStore.save()
|
hashStore.save()
|
||||||
|
// the sender's unit total is every file in the tree, not only the upload;
|
||||||
|
// a retried plan replaces its own size instead of adding a second one
|
||||||
|
const previousSize = session.unitSizes.get(key) || 0
|
||||||
|
session.unitSizes.set(key, unitTotalBytes)
|
||||||
|
session.sizeBytes = (session.sizeBytes || 0) - previousSize + unitTotalBytes
|
||||||
const allowed = entries.filter(entry => withinRoots(entry.path, owned))
|
const allowed = entries.filter(entry => withinRoots(entry.path, owned))
|
||||||
const identical = allowed.map(entry => results.get(entry.path) === true)
|
const identical = allowed.map(entry => results.get(entry.path) === true)
|
||||||
// the mode each file has to end up with on this side
|
// the mode each file has to end up with on this side
|
||||||
|
|
@ -894,22 +952,27 @@ async function handlePlan(state, session, url, req, res, hooks) {
|
||||||
const upload = []
|
const upload = []
|
||||||
const same = []
|
const same = []
|
||||||
let uploadBytes = 0
|
let uploadBytes = 0
|
||||||
allowed.forEach((entry, index) => {
|
for (let index = 0; index < allowed.length; index++) {
|
||||||
|
const entry = allowed[index]
|
||||||
const abs = filesPath(entry.path)
|
const abs = filesPath(entry.path)
|
||||||
if (!abs) return
|
if (!abs) continue
|
||||||
paths.add(entry.path)
|
paths.add(entry.path)
|
||||||
if (identical[index]) {
|
if (identical[index]) {
|
||||||
same.push(entry.path)
|
same.push(entry.path)
|
||||||
return
|
if (entry.type === 'file') session.skipped = (session.skipped || 0) + 1
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
if (entry.type === 'dir') ensureDir(abs)
|
if (entry.type === 'dir') await ensureDir(abs)
|
||||||
else if (entry.type === 'link') ensureLink(abs, entry.target)
|
else if (entry.type === 'link') await ensureLink(abs, entry.target)
|
||||||
else {
|
else {
|
||||||
upload.push(entry.path)
|
upload.push(entry.path)
|
||||||
uploadBytes += entry.size || 0
|
uploadBytes += entry.size || 0
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
session.units.set(key, { roots: owned, paths, label, modes })
|
// 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.
|
||||||
|
const complete = req.complete !== false && !incomplete
|
||||||
|
session.units.set(key, { roots: owned, paths, label, modes, 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
|
||||||
|
|
@ -982,10 +1045,12 @@ async function handleCommit(session, url, res, hooks) {
|
||||||
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
|
let removed = 0
|
||||||
|
if (unit.prune !== false) {
|
||||||
for (const root of unit.roots) {
|
for (const root of unit.roots) {
|
||||||
const abs = filesPath(root)
|
const abs = filesPath(root)
|
||||||
if (abs) removed += await pruneTree(abs, root, unit.paths)
|
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)
|
||||||
|
|
@ -1013,7 +1078,10 @@ async function handleApply(session, req, res, hooks) {
|
||||||
// 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
|
const removed = appsDeleted + winesDeleted + prefixesDeleted
|
||||||
emitReceive(hooks, session, 'registry', { deleted: removed })
|
// the popup reports everything this sync removed: pruned unit files and
|
||||||
|
// registry entries dropped by the apply
|
||||||
|
session.deleted = (session.deleted || 0) + removed
|
||||||
|
emitReceive(hooks, session, 'registry', { deleted: session.deleted })
|
||||||
if (hooks.onApplied) {
|
if (hooks.onApplied) {
|
||||||
hooks.onApplied({
|
hooks.onApplied({
|
||||||
from: session.name,
|
from: session.name,
|
||||||
|
|
@ -1021,8 +1089,10 @@ async function handleApply(session, req, res, hooks) {
|
||||||
categories: [...cats],
|
categories: [...cats],
|
||||||
files: session.received || 0,
|
files: session.received || 0,
|
||||||
bytes: session.bytes || 0,
|
bytes: session.bytes || 0,
|
||||||
|
skipped: session.skipped || 0,
|
||||||
|
sizeBytes: session.sizeBytes || 0,
|
||||||
items: { ...(session.items || {}) },
|
items: { ...(session.items || {}) },
|
||||||
deleted: removed,
|
deleted: session.deleted,
|
||||||
settings,
|
settings,
|
||||||
defaults: settings ? settings.defaults : null
|
defaults: settings ? settings.defaults : null
|
||||||
})
|
})
|
||||||
|
|
@ -1084,17 +1154,32 @@ function startServer(options = {}) {
|
||||||
const state = createServerState()
|
const state = createServerState()
|
||||||
serverState = state // respondIncoming() answers the popups of THIS server
|
serverState = state // respondIncoming() answers the popups of THIS server
|
||||||
httpServer = createHttpServer(state, serverHooks)
|
httpServer = createHttpServer(state, serverHooks)
|
||||||
httpServer.on('error', () => {})
|
|
||||||
const port = Number(options && options.port) || 0
|
const port = Number(options && options.port) || 0
|
||||||
|
// A bind failure used to be swallowed: the promise never settled, so the
|
||||||
|
// beacon never started and LAN syncing silently did nothing at all.
|
||||||
listening = new Promise(resolve => {
|
listening = new Promise(resolve => {
|
||||||
httpServer.listen(port, '0.0.0.0', () => {
|
const server = httpServer
|
||||||
const address = httpServer && httpServer.address()
|
const fail = err => {
|
||||||
|
if (httpServer === server) {
|
||||||
|
httpServer = null
|
||||||
|
listening = null
|
||||||
|
serverState = null
|
||||||
|
httpPort = 0
|
||||||
|
}
|
||||||
|
resolve({ ok: false, error: String((err && err.message) || err) })
|
||||||
|
}
|
||||||
|
server.once('error', fail)
|
||||||
|
server.listen(port, '0.0.0.0', () => {
|
||||||
|
server.off('error', fail)
|
||||||
|
// stray socket errors after the bind must not kill the launcher
|
||||||
|
server.on('error', () => {})
|
||||||
|
const address = server.address()
|
||||||
httpPort = address && typeof address === 'object' ? address.port : 0
|
httpPort = address && typeof address === 'object' ? address.port : 0
|
||||||
resolve({
|
resolve({
|
||||||
ok: true,
|
ok: true,
|
||||||
port: httpPort,
|
port: httpPort,
|
||||||
deviceId: identity().deviceId,
|
deviceId: identity().deviceId,
|
||||||
server: httpServer,
|
server,
|
||||||
close: () => stopServer()
|
close: () => stopServer()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
@ -1124,7 +1209,9 @@ function stopServer() {
|
||||||
function start(hooks = {}) {
|
function start(hooks = {}) {
|
||||||
serverHooks = { ...serverHooks, ...hooks }
|
serverHooks = { ...serverHooks, ...hooks }
|
||||||
return startServer().then(info => {
|
return startServer().then(info => {
|
||||||
startDiscovery(serverHooks.onDevices)
|
// no usable server means no beacon: a device that cannot receive must not
|
||||||
|
// advertise itself as ready to sync
|
||||||
|
if (info.ok) startDiscovery(serverHooks.onDevices)
|
||||||
return info
|
return info
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -1145,6 +1232,8 @@ function getState() {
|
||||||
|
|
||||||
// The pusher is built with this module's helpers, so launcher states never mix.
|
// The pusher is built with this module's helpers, so launcher states never mix.
|
||||||
let pusher = null
|
let pusher = null
|
||||||
|
// One outgoing sync at a time: two would write the same receiver files at once.
|
||||||
|
let pushing = false
|
||||||
function getPusher() {
|
function getPusher() {
|
||||||
if (!pusher) {
|
if (!pusher) {
|
||||||
pusher = require('./lansyncpush')({
|
pusher = require('./lansyncpush')({
|
||||||
|
|
@ -1175,7 +1264,18 @@ function getPusher() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncTo(payload) {
|
function syncTo(payload) {
|
||||||
return getPusher().syncToDevice(payload)
|
if (pushing) return Promise.resolve({ ok: false, error: 'busy' })
|
||||||
|
pushing = true
|
||||||
|
let job
|
||||||
|
try {
|
||||||
|
job = getPusher().syncToDevice(payload)
|
||||||
|
} catch (err) {
|
||||||
|
pushing = false
|
||||||
|
return Promise.reject(err)
|
||||||
|
}
|
||||||
|
return Promise.resolve(job).finally(() => {
|
||||||
|
pushing = false
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Push to one peer; options: peer record, code, categories, force.
|
// Push to one peer; options: peer record, code, categories, force.
|
||||||
|
|
@ -1190,6 +1290,7 @@ function runSync(options = {}, onProgress) {
|
||||||
code: options.code || (target && target.code) || (stored && stored.code),
|
code: options.code || (target && target.code) || (stored && stored.code),
|
||||||
categories: options.categories || options.kinds || CATEGORIES,
|
categories: options.categories || options.kinds || CATEGORIES,
|
||||||
force: !!options.force,
|
force: !!options.force,
|
||||||
|
selection: options.selection,
|
||||||
onProgress: [onProgress, options.onProgress, options.onEvent, options.progress].find(fn => typeof fn === 'function')
|
onProgress: [onProgress, options.onProgress, options.onEvent, options.progress].find(fn => typeof fn === 'function')
|
||||||
})
|
})
|
||||||
return result
|
return result
|
||||||
|
|
@ -1218,6 +1319,7 @@ const exported = {
|
||||||
rememberDevice,
|
rememberDevice,
|
||||||
forgetDevice,
|
forgetDevice,
|
||||||
respondIncoming,
|
respondIncoming,
|
||||||
|
isPushing: () => pushing,
|
||||||
sameCode,
|
sameCode,
|
||||||
HASH_ALGO: hashStore.HASH_ALGO,
|
HASH_ALGO: hashStore.HASH_ALGO,
|
||||||
hashFile,
|
hashFile,
|
||||||
|
|
|
||||||
|
|
@ -166,11 +166,14 @@ function createPusher(api) {
|
||||||
|
|
||||||
// --- units ----------------------------------------------------------------
|
// --- units ----------------------------------------------------------------
|
||||||
|
|
||||||
function appUnits(cats) {
|
// `selected` is the optional app selection (a Set of ids or null = all);
|
||||||
|
// the seam a future "sync only these apps" UI builds on.
|
||||||
|
function appUnits(cats, selected) {
|
||||||
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
|
||||||
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') roots.unshift(`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 })
|
||||||
|
|
@ -178,6 +181,10 @@ function createPusher(api) {
|
||||||
return units
|
return units
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectedApps(selection) {
|
||||||
|
return selection && Array.isArray(selection.apps) ? new Set(selection.apps.map(String)) : null
|
||||||
|
}
|
||||||
|
|
||||||
function wineUnits() {
|
function wineUnits() {
|
||||||
const units = new Map()
|
const units = new Map()
|
||||||
const add = (root, label) => {
|
const add = (root, label) => {
|
||||||
|
|
@ -200,9 +207,10 @@ function createPusher(api) {
|
||||||
return [...names].map(name => ({ key: `prefix:${name}`, kind: 'prefix', id: name, label: name, roots: [`wineprefixes/${name}`] }))
|
return [...names].map(name => ({ key: `prefix:${name}`, kind: 'prefix', id: name, label: name, roots: [`wineprefixes/${name}`] }))
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildUnits(cats) {
|
function buildUnits(cats, selection) {
|
||||||
const units = []
|
const units = []
|
||||||
if (cats.has('apps-portable') || cats.has('apps-prefix')) units.push(...appUnits(cats))
|
const apps = selectedApps(selection)
|
||||||
|
if (cats.has('apps-portable') || cats.has('apps-prefix')) units.push(...appUnits(cats, apps))
|
||||||
if (cats.has('wines')) units.push(...wineUnits())
|
if (cats.has('wines')) units.push(...wineUnits())
|
||||||
if (cats.has('prefixes')) units.push(...prefixUnits())
|
if (cats.has('prefixes')) units.push(...prefixUnits())
|
||||||
return units
|
return units
|
||||||
|
|
@ -215,17 +223,22 @@ function createPusher(api) {
|
||||||
return library.portableGame(game)
|
return library.portableGame(game)
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRegistryPayload(categories) {
|
function buildRegistryPayload(categories, selection) {
|
||||||
const cats = new Set(categories)
|
const cats = new Set(categories)
|
||||||
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)
|
||||||
return category ? cats.has(category) : false
|
if (!category || !cats.has(category)) return false
|
||||||
|
return !selected || selected.has(String(game.id))
|
||||||
})
|
})
|
||||||
.map(portableApp)
|
.map(portableApp)
|
||||||
|
// A partial list means "only these apps travel"; without the marker the
|
||||||
|
// receiver mirrors the category and drops the apps that were left out.
|
||||||
|
if (selected) 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 prunes its own builds down to this list and rescans
|
||||||
|
|
@ -282,6 +295,8 @@ function createPusher(api) {
|
||||||
skipped: 0,
|
skipped: 0,
|
||||||
deleted: 0,
|
deleted: 0,
|
||||||
bytes: 0,
|
bytes: 0,
|
||||||
|
// size of the synced trees as they now sit on the other device
|
||||||
|
sizeBytes: 0,
|
||||||
// the popup reports "3 games, 1 prefix" instead of file counts
|
// the popup reports "3 games, 1 prefix" instead of file counts
|
||||||
items: { apps: 0, wines: 0, prefixes: 0 }
|
items: { apps: 0, wines: 0, prefixes: 0 }
|
||||||
}
|
}
|
||||||
|
|
@ -293,14 +308,14 @@ function createPusher(api) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Push to the peer; a rejected code comes back as 'bad-code'.
|
// Push to the peer; a rejected code comes back as 'bad-code'.
|
||||||
async function syncToDevice({ deviceId, device: target, code, categories, force, onProgress }) {
|
async function syncToDevice({ deviceId, device: target, code, categories, force, selection, onProgress }) {
|
||||||
const fail = (error, summary) => syncResult(false, error, summary || newSummary(0))
|
const fail = (error, summary) => syncResult(false, error, summary || newSummary(0))
|
||||||
const device = target || api.listDevices().find(d => d.id === deviceId)
|
const device = target || api.listDevices().find(d => d.id === deviceId)
|
||||||
if (!device || !device.address || !device.port) return fail('offline')
|
if (!device || !device.address || !device.port) return fail('offline')
|
||||||
const cats = new Set(normalizeCategories(categories))
|
const cats = new Set(normalizeCategories(categories))
|
||||||
if (!cats.size) return fail('nothing-selected')
|
if (!cats.size) return fail('nothing-selected')
|
||||||
// units are known before anything is read: announce them with the verify
|
// units are known before anything is read: announce them with the verify
|
||||||
const units = buildUnits(cats)
|
const units = buildUnits(cats, selection)
|
||||||
const bucketOf = kind => (kind === 'wine' ? 'wines' : kind === 'prefix' ? 'prefixes' : 'apps')
|
const bucketOf = kind => (kind === 'wine' ? 'wines' : kind === 'prefix' ? 'prefixes' : 'apps')
|
||||||
const totals = { apps: 0, wines: 0, prefixes: 0, settings: 0 }
|
const totals = { apps: 0, wines: 0, prefixes: 0, settings: 0 }
|
||||||
for (const unit of units) totals[bucketOf(unit.kind)] += 1
|
for (const unit of units) totals[bucketOf(unit.kind)] += 1
|
||||||
|
|
@ -420,6 +435,11 @@ function createPusher(api) {
|
||||||
// for a receiver that still has to hash).
|
// for a receiver that still has to hash).
|
||||||
const entries = []
|
const entries = []
|
||||||
let writes = Promise.resolve()
|
let writes = Promise.resolve()
|
||||||
|
// A read failure means this manifest is not the sender's whole tree:
|
||||||
|
// tell the receiver, so it never prunes what only looked missing.
|
||||||
|
const incomplete = () => {
|
||||||
|
writes = writes.then(() => write({ unitIncomplete: true }))
|
||||||
|
}
|
||||||
const plan = await planUnit(device, token, { ...unit, roots }, !!force, { totals }, async write => {
|
const plan = await planUnit(device, token, { ...unit, roots }, !!force, { totals }, async write => {
|
||||||
await buildManifestStream(roots, !!force, {
|
await buildManifestStream(roots, !!force, {
|
||||||
// the receiver sizes its compare bar from this, before any hashing
|
// the receiver sizes its compare bar from this, before any hashing
|
||||||
|
|
@ -430,6 +450,7 @@ function createPusher(api) {
|
||||||
entries.push(entry)
|
entries.push(entry)
|
||||||
writes = writes.then(() => write(entry))
|
writes = writes.then(() => write(entry))
|
||||||
},
|
},
|
||||||
|
onIncomplete: incomplete,
|
||||||
onProgress: (hashedDone, hashedTotal, hashedBytes, totalBytes) => {
|
onProgress: (hashedDone, hashedTotal, hashedBytes, totalBytes) => {
|
||||||
barBytes = hashedBytes
|
barBytes = hashedBytes
|
||||||
barTotal = totalBytes
|
barTotal = totalBytes
|
||||||
|
|
@ -444,6 +465,8 @@ function createPusher(api) {
|
||||||
})
|
})
|
||||||
if (abort.dead) throw new Error('receiver-lost')
|
if (abort.dead) throw new Error('receiver-lost')
|
||||||
if (!plan.data || !plan.data.ok) throw new Error((plan.data && plan.data.error) || `http-${plan.status}`)
|
if (!plan.data || !plan.data.ok) throw new Error((plan.data && plan.data.error) || `http-${plan.status}`)
|
||||||
|
// every file in the unit, including the ones already identical there
|
||||||
|
for (const entry of entries) if (entry.type === 'file') summary.sizeBytes += entry.size || 0
|
||||||
const upload = new Set(plan.data.upload || [])
|
const upload = new Set(plan.data.upload || [])
|
||||||
const same = new Set(plan.data.same || [])
|
const same = new Set(plan.data.same || [])
|
||||||
const filePaths = new Set(entries.filter(e => e.type === 'file').map(e => e.path))
|
const filePaths = new Set(entries.filter(e => e.type === 'file').map(e => e.path))
|
||||||
|
|
@ -485,13 +508,20 @@ function createPusher(api) {
|
||||||
if (typeof api.mapLimit === 'function') await api.mapLimit(pending, UPLOAD_CONCURRENCY, send)
|
if (typeof api.mapLimit === 'function') await api.mapLimit(pending, UPLOAD_CONCURRENCY, send)
|
||||||
else for (const entry of pending) await send(entry)
|
else for (const entry of pending) await send(entry)
|
||||||
if (failed) throw failed
|
if (failed) throw failed
|
||||||
|
// A dead receiver made `send` skip files; committing now would prune
|
||||||
|
// exactly the files that never arrived (the commit itself could even
|
||||||
|
// succeed while only the heartbeats were failing).
|
||||||
|
if (abort.dead) throw new Error('receiver-lost')
|
||||||
}
|
}
|
||||||
emit('copy', unit, allDone + pending.length, allTotal + pending.length, true)
|
emit('copy', unit, allDone + pending.length, allTotal + pending.length, true)
|
||||||
// the totals count a unit's files only once they are on the wire
|
// the totals count a unit's files only once they are on the wire
|
||||||
allDone += pending.length
|
allDone += pending.length
|
||||||
allTotal += pending.length
|
allTotal += pending.length
|
||||||
const commit = await httpJson(device, 'POST', `/plwl/unit/commit?token=${encodeURIComponent(token)}&unit=${encodeURIComponent(unit.key)}`, { json: {} })
|
const commit = await httpJson(device, 'POST', `/plwl/unit/commit?token=${encodeURIComponent(token)}&unit=${encodeURIComponent(unit.key)}`, { json: {} })
|
||||||
if (commit.data && commit.data.deleted) summary.deleted += commit.data.deleted
|
// a unit that did not commit (unknown unit, failed prune) must not be
|
||||||
|
// counted as done while the sync moves on to the registry
|
||||||
|
if (!commit.data || !commit.data.ok) throw new Error((commit.data && commit.data.error) || `commit-${commit.status}`)
|
||||||
|
if (commit.data.deleted) summary.deleted += commit.data.deleted
|
||||||
finished[kind] += 1
|
finished[kind] += 1
|
||||||
// a finished unit leaves a finished bar behind, even one that sent none
|
// a finished unit leaves a finished bar behind, even one that sent none
|
||||||
barTotal = barTotal || 1
|
barTotal = barTotal || 1
|
||||||
|
|
@ -499,7 +529,8 @@ function createPusher(api) {
|
||||||
emit('commit', unit, null, null, true)
|
emit('commit', unit, null, null, true)
|
||||||
}
|
}
|
||||||
emit('registry', null, null, null, true)
|
emit('registry', null, null, null, true)
|
||||||
const applied = await httpJson(device, 'POST', `/plwl/apply?token=${encodeURIComponent(token)}`, { json: buildRegistryPayload([...cats]), timeout: 60000 })
|
if (abort.dead) throw new Error('receiver-lost')
|
||||||
|
const applied = await httpJson(device, 'POST', `/plwl/apply?token=${encodeURIComponent(token)}`, { json: buildRegistryPayload([...cats], selection), timeout: 60000 })
|
||||||
if (!applied.data || !applied.data.ok) throw new Error((applied.data && applied.data.error) || `http-${applied.status}`)
|
if (!applied.data || !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
|
||||||
// a sync with nothing to send still ends with a full bar
|
// a sync with nothing to send still ends with a full bar
|
||||||
|
|
|
||||||
|
|
@ -136,23 +136,46 @@ function addGame(game) {
|
||||||
return game
|
return game
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prefix imports are pinned; their patch fields are ignored. One place, so a
|
||||||
|
// batch update behaves exactly like a single one.
|
||||||
|
function normalizeGamePatch(game, patch) {
|
||||||
|
if (!patch) return {}
|
||||||
|
const out = { ...patch }
|
||||||
|
if (game.importSource === 'prefix') {
|
||||||
|
delete out.prefix
|
||||||
|
delete out.importSource
|
||||||
|
}
|
||||||
|
return sanitizeLinuxPatch(out)
|
||||||
|
}
|
||||||
|
|
||||||
function updateGame(id, patch) {
|
function updateGame(id, patch) {
|
||||||
const games = loadGames()
|
const games = loadGames()
|
||||||
const g = games.find(x => x.id === id)
|
const g = games.find(x => x.id === id)
|
||||||
if (!g) return null
|
if (!g) return null
|
||||||
// prefix imports are pinned; their patch fields are ignored
|
const clean = normalizeGamePatch(g, patch)
|
||||||
if (g.importSource === 'prefix' && patch) {
|
|
||||||
patch = { ...patch }
|
|
||||||
delete patch.prefix
|
|
||||||
delete patch.importSource
|
|
||||||
}
|
|
||||||
// playSeconds lives in its own file; the registry mirrors it
|
// playSeconds lives in its own file; the registry mirrors it
|
||||||
if (patch.playSeconds !== undefined) writePlaySeconds(id, patch.playSeconds)
|
if (clean.playSeconds !== undefined) writePlaySeconds(id, clean.playSeconds)
|
||||||
Object.assign(g, sanitizeLinuxPatch(patch))
|
Object.assign(g, clean)
|
||||||
saveGames(games)
|
saveGames(games)
|
||||||
return g
|
return g
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Same patch semantics for many apps, one registry write (LAN sync settings).
|
||||||
|
function updateGames(patches) {
|
||||||
|
const games = loadGames()
|
||||||
|
let changed = 0
|
||||||
|
for (const g of games) {
|
||||||
|
const patch = patches instanceof Map ? patches.get(g.id) : patches && patches[g.id]
|
||||||
|
if (!patch) continue
|
||||||
|
const clean = normalizeGamePatch(g, patch)
|
||||||
|
if (clean.playSeconds !== undefined) writePlaySeconds(g.id, clean.playSeconds)
|
||||||
|
Object.assign(g, clean)
|
||||||
|
changed++
|
||||||
|
}
|
||||||
|
if (changed) saveGames(games)
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|
||||||
function clearPlaytime(id) {
|
function clearPlaytime(id) {
|
||||||
return updateGame(id, { playSeconds: 0, lastPlayed: 0 })
|
return updateGame(id, { playSeconds: 0, lastPlayed: 0 })
|
||||||
}
|
}
|
||||||
|
|
@ -172,6 +195,12 @@ async function removeGame(id, onProgress) {
|
||||||
const total = await treeSize(g.path)
|
const total = await treeSize(g.path)
|
||||||
await removeTree(g.path, p => emit('remove', { files: p.files, bytes: p.bytes, totalFiles: total.files, totalBytes: total.bytes }))
|
await removeTree(g.path, p => emit('remove', { files: p.files, bytes: p.bytes, totalFiles: total.files, totalBytes: total.bytes }))
|
||||||
}
|
}
|
||||||
|
// icons and playtime belong to the entry, not to any prefix
|
||||||
|
for (const owned of [path.join(DIRS.DATA, 'icons', id), path.join(DIRS.DATA, 'appdata', id)]) {
|
||||||
|
try {
|
||||||
|
await fs.promises.rm(owned, { recursive: true, force: true })
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
// prefix imports: drop only the registry entry, never prefix files
|
// prefix imports: drop only the registry entry, never prefix files
|
||||||
games.splice(idx, 1)
|
games.splice(idx, 1)
|
||||||
saveGames(games)
|
saveGames(games)
|
||||||
|
|
@ -254,4 +283,4 @@ function renamePrefixRefs(oldName, newName) {
|
||||||
return updated
|
return updated
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { loadGames, saveGames, portableGame, getGame, isLinuxGame, addGame, updateGame, removeGame, reorderGames, renamePrefixRefs, prefixNameForGame, gamesUsingPrefix, appsUsingPrefix, isWineInUse, gamesFollowingDefault, loadSettings, saveSettings, newGame, newGameId, clearPlaytime, readPlaySeconds, writePlaySeconds, playtimeFile }
|
module.exports = { loadGames, saveGames, portableGame, getGame, isLinuxGame, addGame, updateGame, updateGames, removeGame, reorderGames, renamePrefixRefs, prefixNameForGame, gamesUsingPrefix, appsUsingPrefix, isWineInUse, gamesFollowingDefault, loadSettings, saveSettings, newGame, newGameId, clearPlaytime, readPlaySeconds, writePlaySeconds, playtimeFile }
|
||||||
|
|
|
||||||
|
|
@ -255,7 +255,11 @@ app.whenReady().then(async () => {
|
||||||
isBusy: () => !!(gamesService && gamesService.listRunning().length)
|
isBusy: () => !!(gamesService && gamesService.listRunning().length)
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
Promise.resolve(lansync.start(lanHooks)).catch(err => {
|
Promise.resolve(lansync.start(lanHooks))
|
||||||
|
.then(info => {
|
||||||
|
if (!info || !info.ok) console.error(`[lansync] server not started: ${(info && info.error) || 'unknown'}`)
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
console.error(`[lansync] start failed: ${(err && err.message) || err}`)
|
console.error(`[lansync] start failed: ${(err && err.message) || err}`)
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -47,10 +47,17 @@ function createOpenRelay({ open = openOnHost, onError = null } = {}) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// a request written before the watcher was up (the app's first window) must
|
// Two apps can share one prefix (the default), so the watcher is refcounted:
|
||||||
// not be missed, so drain once first
|
// the first exit must not take the folder away from the app still running.
|
||||||
|
// A request written before the watcher was up (the app's first window) must
|
||||||
|
// not be missed, so drain once first.
|
||||||
function watch(root) {
|
function watch(root) {
|
||||||
if (!root || watchers.has(root)) return
|
if (!root) return
|
||||||
|
const existing = watchers.get(root)
|
||||||
|
if (existing) {
|
||||||
|
existing.refs++
|
||||||
|
return
|
||||||
|
}
|
||||||
const file = requestFile(root)
|
const file = requestFile(root)
|
||||||
// best effort: a missing folder only means nothing was ever asked for
|
// best effort: a missing folder only means nothing was ever asked for
|
||||||
try {
|
try {
|
||||||
|
|
@ -61,20 +68,27 @@ function createOpenRelay({ open = openOnHost, onError = null } = {}) {
|
||||||
clearTimeout(timer)
|
clearTimeout(timer)
|
||||||
timer = setTimeout(() => drain(root), DRAIN_DEBOUNCE_MS)
|
timer = setTimeout(() => drain(root), DRAIN_DEBOUNCE_MS)
|
||||||
})
|
})
|
||||||
watcher.on('error', () => unwatch(root))
|
watcher.on('error', () => close(root))
|
||||||
watchers.set(root, watcher)
|
watchers.set(root, { watcher, refs: 1 })
|
||||||
drain(root)
|
drain(root)
|
||||||
}
|
}
|
||||||
|
|
||||||
function unwatch(root) {
|
function close(root) {
|
||||||
const watcher = watchers.get(root)
|
const entry = watchers.get(root)
|
||||||
if (!watcher) return
|
if (!entry) return
|
||||||
try {
|
try {
|
||||||
watcher.close()
|
entry.watcher.close()
|
||||||
} catch {}
|
} catch {}
|
||||||
watchers.delete(root)
|
watchers.delete(root)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function unwatch(root) {
|
||||||
|
const entry = watchers.get(root)
|
||||||
|
if (!entry) return
|
||||||
|
entry.refs--
|
||||||
|
if (entry.refs <= 0) close(root)
|
||||||
|
}
|
||||||
|
|
||||||
return { watch, unwatch, drain, requestFile }
|
return { watch, unwatch, drain, requestFile }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -131,15 +131,17 @@ function createPrefix(name) {
|
||||||
async function deletePrefix(name, onProgress) {
|
async function deletePrefix(name, onProgress) {
|
||||||
if (String(name).toLowerCase() === MAIN.toLowerCase()) return { ok: false, error: 'isMain' }
|
if (String(name).toLowerCase() === MAIN.toLowerCase()) return { ok: false, error: 'isMain' }
|
||||||
const defs = listPrefixes()
|
const defs = listPrefixes()
|
||||||
defs.prefixes = defs.prefixes.filter(p => p.name !== name)
|
|
||||||
if (defs.default === name) defs.default = MAIN
|
|
||||||
savePrefixes(defs)
|
|
||||||
const emit = (phase, detail) => {
|
const emit = (phase, detail) => {
|
||||||
if (onProgress) onProgress({ phase, label: name, ...detail })
|
if (onProgress) onProgress({ phase, label: name, ...detail })
|
||||||
}
|
}
|
||||||
emit('count', { files: 0, bytes: 0 })
|
emit('count', { files: 0, bytes: 0 })
|
||||||
const total = await treeSize(prefixPath(name))
|
const total = await treeSize(prefixPath(name))
|
||||||
await removeTree(prefixPath(name), p => emit('remove', { files: p.files, bytes: p.bytes, totalFiles: total.files, totalBytes: total.bytes }))
|
await removeTree(prefixPath(name), p => emit('remove', { files: p.files, bytes: p.bytes, totalFiles: total.files, totalBytes: total.bytes }))
|
||||||
|
// the registry forgets it only once the folder is gone, so an interrupted
|
||||||
|
// delete stays visible and can be retried
|
||||||
|
defs.prefixes = defs.prefixes.filter(p => p.name !== name)
|
||||||
|
if (defs.default === name) defs.default = MAIN
|
||||||
|
savePrefixes(defs)
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
94
src/wines.js
94
src/wines.js
|
|
@ -58,17 +58,8 @@ async function scanBuild(dir, depth) {
|
||||||
path: dir,
|
path: dir,
|
||||||
type
|
type
|
||||||
}
|
}
|
||||||
if (type === 'proton') {
|
if (type === 'proton') entry.wineBin = path.join(dir, 'proton')
|
||||||
entry.wineBin = path.join(dir, 'proton')
|
else if (type === 'wine') entry.wineBin = findWineBinary(dir) || undefined
|
||||||
} else if (type === 'wine') {
|
|
||||||
const bin = findWineBinary(dir)
|
|
||||||
if (bin) {
|
|
||||||
entry.wineBin = bin
|
|
||||||
const ver = versionOf(bin, {})
|
|
||||||
if (ver) entry.version = ver
|
|
||||||
}
|
|
||||||
}
|
|
||||||
entry.label = labelFor(type, dir, entry.version)
|
|
||||||
return [entry]
|
return [entry]
|
||||||
}
|
}
|
||||||
if (depth <= 0) return []
|
if (depth <= 0) return []
|
||||||
|
|
@ -118,6 +109,13 @@ async function refreshWines() {
|
||||||
// display name and disabled flag live in the registry and survive rescans
|
// display name and disabled flag live in the registry and survive rescans
|
||||||
const renamed = new Map(prev.filter(w => w.displayName).map(w => [w.id, w.displayName]))
|
const renamed = new Map(prev.filter(w => w.displayName).map(w => [w.id, w.displayName]))
|
||||||
for (const w of found) {
|
for (const w of found) {
|
||||||
|
const old = prevById.get(w.id)
|
||||||
|
// `wine --version` spawns a process; an unchanged build keeps its answer
|
||||||
|
if (w.type === 'wine' && w.wineBin) {
|
||||||
|
if (old && old.path === w.path && old.wineBin === w.wineBin && old.version) w.version = old.version
|
||||||
|
else w.version = versionOf(w.wineBin, {})
|
||||||
|
}
|
||||||
|
w.label = labelFor(w.type, w.path, w.version)
|
||||||
if (disabled.has(w.id)) w.disabled = true
|
if (disabled.has(w.id)) w.disabled = true
|
||||||
if (renamed.has(w.id)) w.displayName = renamed.get(w.id)
|
if (renamed.has(w.id)) w.displayName = renamed.get(w.id)
|
||||||
applyDisplayName(w)
|
applyDisplayName(w)
|
||||||
|
|
@ -161,23 +159,53 @@ function clearWineDisplayName(id) {
|
||||||
return writeWineLabel(id, '')
|
return writeWineLabel(id, '')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An empty label means "use the folder-derived one".
|
||||||
|
function applyWineLabel(w, clean) {
|
||||||
|
if (clean) {
|
||||||
|
w.displayName = clean
|
||||||
|
w.label = clean
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete w.displayName
|
||||||
|
w.label = labelFor(w.type, w.path, w.version)
|
||||||
|
}
|
||||||
|
|
||||||
function writeWineLabel(id, clean) {
|
function writeWineLabel(id, clean) {
|
||||||
const wines = getWines()
|
const wines = getWines()
|
||||||
const w = wines.find(x => x.id === id)
|
const w = wines.find(x => x.id === id)
|
||||||
if (!w) return { ok: false, error: 'notFound' }
|
if (!w) return { ok: false, error: 'notFound' }
|
||||||
if (w.type === 'system') return { ok: false, error: 'isSystem' }
|
if (w.type === 'system') return { ok: false, error: 'isSystem' }
|
||||||
if (clean) {
|
if (!clean && !w.displayName) return { ok: true, displayName: '' }
|
||||||
w.displayName = clean
|
applyWineLabel(w, clean)
|
||||||
w.label = clean
|
|
||||||
} else {
|
|
||||||
if (!w.displayName) return { ok: true, displayName: '' }
|
|
||||||
delete w.displayName
|
|
||||||
w.label = labelFor(w.type, w.path, w.version)
|
|
||||||
}
|
|
||||||
writeJson(REGISTRY, wines.map(wineRel))
|
writeJson(REGISTRY, wines.map(wineRel))
|
||||||
return { ok: true, displayName: clean }
|
return { ok: true, displayName: clean }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LAN sync: display names + disabled flags for many builds, one registry write.
|
||||||
|
function applySettings(entries) {
|
||||||
|
const wines = getWines()
|
||||||
|
const byId = new Map(wines.map(w => [w.id, w]))
|
||||||
|
let applied = 0
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry || typeof entry.name !== 'string') continue
|
||||||
|
const wine = byId.get(entry.name)
|
||||||
|
if (!wine || wine.type === 'system') continue
|
||||||
|
let changed = false
|
||||||
|
const wanted = normalizeLabel(typeof entry.displayName === 'string' ? entry.displayName : '')
|
||||||
|
if (wanted !== (wine.displayName || '')) {
|
||||||
|
applyWineLabel(wine, wanted)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if (entry.disabled !== undefined && !!entry.disabled !== !!wine.disabled) {
|
||||||
|
wine.disabled = !!entry.disabled
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if (changed) applied++
|
||||||
|
}
|
||||||
|
if (applied) writeJson(REGISTRY, wines.map(wineRel))
|
||||||
|
return applied
|
||||||
|
}
|
||||||
|
|
||||||
function setWineDisabled(id, disabled) {
|
function setWineDisabled(id, disabled) {
|
||||||
const wines = getWines()
|
const wines = getWines()
|
||||||
const w = wines.find(x => x.id === id)
|
const w = wines.find(x => x.id === id)
|
||||||
|
|
@ -219,6 +247,10 @@ async function deleteWine(id, onProgress) {
|
||||||
emit('count', { files: 0, bytes: 0 })
|
emit('count', { files: 0, bytes: 0 })
|
||||||
const total = await treeSize(w.path)
|
const total = await treeSize(w.path)
|
||||||
await removeTree(w.path, p => emit('remove', { files: p.files, bytes: p.bytes, totalFiles: total.files, totalBytes: total.bytes }))
|
await removeTree(w.path, p => emit('remove', { files: p.files, bytes: p.bytes, totalFiles: total.files, totalBytes: total.bytes }))
|
||||||
|
// the Proton compat link points into data/, not into the build we just removed
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(path.join(DIRS.DATA, 'compat', id))
|
||||||
|
} catch {} // no link (plain wine build) or already gone - both fine
|
||||||
writeJson(REGISTRY, wines.filter(x => x.id !== id).map(wineRel))
|
writeJson(REGISTRY, wines.filter(x => x.id !== id).map(wineRel))
|
||||||
if (getDefaultWine() === id) require('./library').saveSettings({ defaultWine: 'system' })
|
if (getDefaultWine() === id) require('./library').saveSettings({ defaultWine: 'system' })
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
|
|
@ -300,7 +332,12 @@ async function importWineFolder(srcDir, onProgress) {
|
||||||
}
|
}
|
||||||
emit('count', { files: 0, bytes: 0 })
|
emit('count', { files: 0, bytes: 0 })
|
||||||
const total = await treeSize(copySrc)
|
const total = await treeSize(copySrc)
|
||||||
await copyTree(copySrc, dst, p => emit('copy', { files: p.done, bytes: p.bytes, totalFiles: total.files, totalBytes: total.bytes }))
|
const copied = await copyTree(copySrc, dst, p => emit('copy', { files: p.done, bytes: p.bytes, totalFiles: total.files, totalBytes: total.bytes }))
|
||||||
|
if (copied.failed) {
|
||||||
|
// a build missing files fails at the first Proton launch; do not register it
|
||||||
|
await fsp.rm(dst, { recursive: true, force: true })
|
||||||
|
throw new Error('copy-incomplete')
|
||||||
|
}
|
||||||
let healed = { links: 0, executables: 0 }
|
let healed = { links: 0, executables: 0 }
|
||||||
try {
|
try {
|
||||||
healed = await healBuild(dst)
|
healed = await healBuild(dst)
|
||||||
|
|
@ -310,14 +347,19 @@ async function importWineFolder(srcDir, onProgress) {
|
||||||
return { ok: true, path: toPortablePath(dst), symlinksFixed: healed.links, executablesFixed: healed.executables }
|
return { ok: true, path: toPortablePath(dst), symlinksFixed: healed.links, executablesFixed: healed.executables }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pseudo entry for the distro's wine; version probe is best-effort.
|
// Pseudo entry for the distro's wine; version probe is best-effort and cached:
|
||||||
|
// this runs on every library refresh, and a synchronous spawn blocked the UI.
|
||||||
|
let systemVersion = null
|
||||||
function systemWine() {
|
function systemWine() {
|
||||||
let version = ''
|
if (systemVersion === null) {
|
||||||
try {
|
try {
|
||||||
const { execFileSync } = require('child_process')
|
const { execFileSync } = require('child_process')
|
||||||
version = String(execFileSync('wine', ['--version'], { timeout: 15000 })).trim().replace(/^wine-?/i, '')
|
systemVersion = String(execFileSync('wine', ['--version'], { timeout: 15000 })).trim().replace(/^wine-?/i, '')
|
||||||
} catch {}
|
} catch {
|
||||||
return { id: 'system', type: 'system', label: 'System Wine', version }
|
systemVersion = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { id: 'system', type: 'system', label: 'System Wine', version: systemVersion }
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { refreshWines, getWines, getWine, resolveWine, systemWine, setWineDisabled, setWineDisplayName, clearWineDisplayName, getDefaultWine, setDefaultWine, deleteWine, importWineFolder, findWineBinary, detectType, fixBuildSymlinks, healBuild }
|
module.exports = { refreshWines, getWines, getWine, resolveWine, systemWine, setWineDisabled, setWineDisplayName, clearWineDisplayName, applySettings, getDefaultWine, setDefaultWine, deleteWine, importWineFolder, findWineBinary, detectType, fixBuildSymlinks, healBuild }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue