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