From 8e3512b5cdbdb30d2a4e1bc2cf586a3dfb7fcfb1 Mon Sep 17 00:00:00 2001 From: olcxja Date: Thu, 1 Oct 2026 14:18:15 +0200 Subject: [PATCH] [TEST] add data sync --- README.md | 12 + development.md | 7 + files/translations/en-us.json | 40 ++ files/translations/pl.json | 40 ++ renderer/app.js | 206 +++++- renderer/index.html | 32 + renderer/plwl.png | Bin 9458 -> 9536 bytes renderer/style.css | 23 + src/ipc/index.js | 4 + src/ipc/lansync.js | 37 ++ src/lansync.js | 1166 +++++++++++++++++++++++++++++++++ src/lansyncpush.js | 337 ++++++++++ src/library.js | 2 +- src/main.js | 12 +- src/prefixes.js | 2 +- src/preload.js | 20 +- 16 files changed, 1935 insertions(+), 5 deletions(-) create mode 100644 src/ipc/lansync.js create mode 100644 src/lansync.js create mode 100644 src/lansyncpush.js diff --git a/README.md b/README.md index 76d302b..f0e155b 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,18 @@ Besides copy/move/leave, an app can be imported **from a wine prefix**: pick the The desktop entry (`MimeType` + `Exec %f`) makes PLWL show up in Dolphin/Nautilus under **Open With** for Windows executables (`.exe`, `.msi`, `.bat`, `.cmd`, `.lnk`). Opening a file that way shows a small popup in the launcher where you pick the **Wine/Proton version** and the **prefix** for this run — both default to the launcher's defaults, nothing is imported into the library. A second launcher instance hands the path to the running one and exits (same single-instance mechanism the shortcuts use). +## LAN syncing + +Two PLWL installs on the same LAN can mirror each other's content - no cloud, no accounts. Open **LAN syncing** in the sidebar (under Settings): + +- **Device ID** - 24 characters, generated once per install (current timestamp + random characters) and re-rollable at any time. +- **Sync code** - 8 characters, letters and digits, case-insensitive. A device only accepts a connection when the caller proves it knows *its own* code; each verification costs two seconds and a device may only have one in flight, so guessing over the LAN is pointless. Both the ID and the code can be re-rolled. +- **Devices on your network** - other PLWL installs, found over UDP; a device shows up only while PLWL is running there, and disappears a few seconds after it is closed. + +Pick a device and choose what to push: **portable apps**, **apps imported from a prefix**, **Wine/Proton builds** and **prefixes**. The code has to be typed once - an accepted code is stored next to that device ID, so every later sync needs no typing. If the other side re-rolled its code, the stored one is forgotten and the launcher asks for the new one instead of failing silently. + +The **sending device is the source of truth**: files that differ are overwritten, missing ones are written, and anything living only on the receiving side inside the synced trees (`files/apps/`, `files/wines/`, `files/wineprefixes/`, `files/data/icons/`) is deleted. Files are compared with a fast, collision-resistant hash (BLAKE2b-512, SHA-512/SHA-256 fallback); **Force sync all files** skips the comparison and overwrites everything instead. Everything else stays local: settings, logs, playtime/last played, translations, and external (leave) apps - prefixes are always kept usable, so app entries pointing inside a prefix keep their prefix-relative paths and the receiving side never loses its own `Main` prefix. + ## Wine & Proton support - **System Wine** — uses the `wine` binary from PATH. diff --git a/development.md b/development.md index 8e54565..4f58e2f 100644 --- a/development.md +++ b/development.md @@ -12,6 +12,7 @@ src/ main process (Node/Electron backend) ipc/ all ipcMain handlers index.js handler registration, icon protocol, dialogs games.js games service: run/stop state, wine resolution, open-with handoff + lansync.js LAN sync handlers (identity, devices, re-roll, sync + progress) runner.js spawns wine/proton (env, prefix, playtime, logs) library.js apps registry (apps.json) + settings + playtime files wines.js wine/proton build scan, registry, portable symlink healing @@ -22,6 +23,8 @@ src/ main process (Node/Electron backend) paths.js portable layout, FILES-relative path helpers, translation sync i18n.js translation loading + {0} placeholder filling preload.js contextBridge API exposed to the renderer + lansync.js LAN sync: identity + pairing, hashing, HTTP receiver, UDP discovery + lansyncpush.js the pushing side (createPusher factory, keeps helper state out) appicon.js window/taskbar icon renderer/ UI (plain HTML/CSS/JS, no framework) app.js views, modals, custom-select, all renderer logic @@ -61,6 +64,9 @@ handlers in src/ipc/. Keep new APIs in that pattern. - CSS: reuse the existing variables/classes (.button, .field-label, .custom-select, form-card); spacing rhythm comes from --button-margin and the flex gap of the container, not per-element magic numbers. +- LAN sync has two sides: the receiver lives in lansync.js, the sender in + lansyncpush.js and is built by createPusher(helpers) - several launcher + states share the module, so nothing may be kept in its module scope. - Tests are plain assert-by-print scripts (see test/): set PLWL_BUILD_DIR to a temp dir, check(), exit 1 on failure. Add one when fixing a bug. @@ -69,5 +75,6 @@ handlers in src/ipc/. Keep new APIs in that pattern. ``` node --check # syntax node test/import.test.js # ...and the other test files +node test/lansync.test.js # LAN sync contract (identity, pairing, mirror) npm start # click through the flow you touched ``` diff --git a/files/translations/en-us.json b/files/translations/en-us.json index 9a634d9..de5a27f 100644 --- a/files/translations/en-us.json +++ b/files/translations/en-us.json @@ -10,6 +10,7 @@ "nav.prefixes": "Wine prefixes", "nav.wines": "Wine versions", "nav.settings": "Settings", + "nav.lansync": "LAN syncing", "library.title": "Apps Library", "library.empty": "No apps yet", "library.emptyHint": "Import an app to get started. You have a lot import options to choose from!", @@ -144,6 +145,45 @@ "settings.saved": "Settings saved", "settings.openFiles": "Open files folder", "settings.closeToTray": "Close window to tray (keep tracking playtime in background)", + "lansync.title": "LAN syncing", + "lansync.subtitle": "Push your apps, prefixes and Wine builds to another PLWL on the same network.", + "lansync.thisDevice": "This device", + "lansync.deviceId": "Device ID", + "lansync.syncCode": "Sync code", + "lansync.reroll": "Reroll", + "lansync.codeHint": "Type this code on the other device so it may sync to this one. It is case-insensitive and only has to be entered once per device.", + "lansync.devices": "Devices on your network", + "lansync.devicesHint": "Devices show up here while PLWL is running on them.", + "lansync.empty": "No devices found. Make sure PLWL is running on the other machine and both are on the same network.", + "lansync.paired": "Paired", + "lansync.sync": "Sync to this device", + "lansync.syncTitle": "Sync to {0}", + "lansync.enterCode": "Sync code shown on {0}", + "lansync.what": "What to sync", + "lansync.appsPortable": "Portable apps", + "lansync.appsPrefix": "Apps imported from a prefix", + "lansync.wines": "Wine / Proton versions", + "lansync.prefixes": "Wine prefixes", + "lansync.force": "Force sync all files (skip the hash check and overwrite everything)", + "lansync.mirrorWarning": "This device is the source of truth: on the other device, files and items that do not exist here will be deleted.", + "lansync.start": "Start sync", + "lansync.progress": "Syncing to {0}…", + "lansync.phase.scan": "Preparing {0}", + "lansync.phase.hash": "Hashing {0}", + "lansync.phase.plan": "Comparing {0}", + "lansync.phase.copy": "Sending {0}", + "lansync.phase.registry": "Updating the library", + "lansync.progressFiles": "{0} / {1} files", + "lansync.done": "Synced to {0}: {1} sent, {2} already up to date, {3} removed", + "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).", + "lansync.offline": "That device is no longer on the network.", + "lansync.noneSelected": "Pick at least one thing to sync.", + "lansync.applied": "Synced from {0}: {1} files received", + "lansync.idRerolled": "New device ID generated", + "lansync.codeRerolled": "New sync code generated", + "lansync.unknownError": "unknown error", "openwith.title": "Open with Wine", "openwith.file": "File", "openwith.fileMissing": "This file no longer exists (or is not a Windows executable).", diff --git a/files/translations/pl.json b/files/translations/pl.json index a209418..21abef7 100644 --- a/files/translations/pl.json +++ b/files/translations/pl.json @@ -10,6 +10,7 @@ "nav.prefixes": "Prefiksy Wine", "nav.wines": "Wersje Wine", "nav.settings": "Ustawienia", + "nav.lansync": "Synchronizacja LAN", "library.title": "Biblioteka aplikacji", "library.empty": "Brak aplikacji", "library.emptyHint": "Zaimportuj aplikację aby zacząć. Do wyboru masz mnóstwo opcji importu!", @@ -144,6 +145,45 @@ "settings.saved": "Ustawienia zapisane", "settings.openFiles": "Otwórz folder files", "settings.closeToTray": "Zamykaj okno do zasobnika systemowego (licz czas gry w tle)", + "lansync.title": "Synchronizacja LAN", + "lansync.subtitle": "Wypchnij swoje aplikacje, prefiksy i buildy Wine na inny PLWL w tej samej sieci.", + "lansync.thisDevice": "To urządzenie", + "lansync.deviceId": "ID urządzenia", + "lansync.syncCode": "Kod synchronizacji", + "lansync.reroll": "Wygeneruj nowy", + "lansync.codeHint": "Wpisz ten kod na drugim urządzeniu, żeby mogło synchronizować do tego. Wielkość liter nie ma znaczenia, kod wpisuje się tylko raz na urządzenie.", + "lansync.devices": "Urządzenia w sieci", + "lansync.devicesHint": "Urządzenia pojawiają się tutaj, gdy PLWL jest na nich uruchomiony.", + "lansync.empty": "Nie znaleziono urządzeń. Upewnij się, że PLWL działa na drugiej maszynie i oba komputery są w tej samej sieci.", + "lansync.paired": "Sparowane", + "lansync.sync": "Synchronizuj na to urządzenie", + "lansync.syncTitle": "Synchronizacja na {0}", + "lansync.enterCode": "Kod synchronizacji pokazany na {0}", + "lansync.what": "Co synchronizować", + "lansync.appsPortable": "Aplikacje przenośne", + "lansync.appsPrefix": "Aplikacje importowane z prefiksu", + "lansync.wines": "Wersje Wine / Proton", + "lansync.prefixes": "Prefiksy Wine", + "lansync.force": "Wymuś synchronizację wszystkich plików (pomiń sprawdzanie hashy i nadpisz wszystko)", + "lansync.mirrorWarning": "To urządzenie jest źródłem prawdy: na drugim urządzeniu pliki i elementy, których tu nie ma, zostaną usunięte.", + "lansync.start": "Rozpocznij synchronizację", + "lansync.progress": "Synchronizowanie na {0}…", + "lansync.phase.scan": "Przygotowywanie {0}", + "lansync.phase.hash": "Haszowanie {0}", + "lansync.phase.plan": "Porównywanie {0}", + "lansync.phase.copy": "Wysyłanie {0}", + "lansync.phase.registry": "Aktualizowanie biblioteki", + "lansync.progressFiles": "{0} / {1} plików", + "lansync.done": "Zsynchronizowano na {0}: wysłano {1}, aktualnych {2}, usunięto {3}", + "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).", + "lansync.offline": "To urządzenie zniknęło z sieci.", + "lansync.noneSelected": "Wybierz przynajmniej jedną rzecz do synchronizacji.", + "lansync.applied": "Zsynchronizowano z {0}: odebrano {1} plików", + "lansync.idRerolled": "Wygenerowano nowe ID urządzenia", + "lansync.codeRerolled": "Wygenerowano nowy kod synchronizacji", + "lansync.unknownError": "nieznany błąd", "openwith.title": "Otwórz przez Wine", "openwith.file": "Plik", "openwith.fileMissing": "Ten plik już nie istnieje (albo nie jest plikiem wykonywalnym Windows).", diff --git a/renderer/app.js b/renderer/app.js index 484c407..ec34680 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -14,6 +14,7 @@ const state = { prefixes: { default: '', prefixes: [] }, games: [], currentGame: null, + lansync: null, currentView: 'library' } @@ -33,6 +34,8 @@ const ICONS = { // wine glass - wine/proton runners wines: () => '', settings: () => '', + // nodes connected to each other - devices on the local network + lansync: () => '', // arrow into a tray - import config: () => '', folder: () => '', @@ -62,7 +65,8 @@ function setButtonIcons() { 'nav-library': ICONS.library(), 'nav-prefixes': ICONS.prefixes(), 'nav-wines': ICONS.wines(), - 'nav-settings': ICONS.settings() + 'nav-settings': ICONS.settings(), + 'nav-lansync': ICONS.lansync() } for (const [id, svg] of Object.entries(map)) { const el = document.getElementById(id) @@ -322,6 +326,7 @@ function showView(name) { if (name === 'prefixes') renderPrefixes() if (name === 'wines') renderWines() if (name === 'settings') renderSettings() + if (name === 'lansync') renderLanSync() } async function loadAll() { @@ -1123,6 +1128,204 @@ async function runCustomTool() { else toast(t('toast.startFailed', tool, res.error || ''), 'error') } +// --- LAN syncing ------------------------------------------------------------ + +const LAN_CATEGORIES = [ + { id: 'apps-portable', label: 'lansync.appsPortable' }, + { id: 'apps-prefix', label: 'lansync.appsPrefix' }, + { id: 'wines', label: 'lansync.wines' }, + { id: 'prefixes', label: 'lansync.prefixes' } +] + +async function renderLanSync() { + const info = await window.plwl.lansyncGet() + state.lansync = info + qs('#ls-device-id').textContent = info.deviceId + qs('#ls-sync-code').textContent = info.syncCode + renderLanDevices(info.devices || []) +} + +function renderLanDevices(devices) { + const list = qs('#ls-device-list') + list.innerHTML = '' + if (!devices.length) { + const empty = document.createElement('p') + empty.className = 'subtitle' + empty.textContent = t('lansync.empty') + list.appendChild(empty) + return + } + for (const device of devices) { + const row = document.createElement('div') + row.className = 'list-row' + row.innerHTML = ` +
+
+
+
+ ${device.paired ? `${t('lansync.paired')}` : ''} +
+ +
+ ` + row.querySelector('.title').textContent = device.name + const sub = row.querySelector('.sub') + sub.textContent = device.id + sub.title = device.id + const sync = row.querySelector('[data-act=sync]') + sync.textContent = t('lansync.sync') + sync.onclick = () => openLanSyncModal(device, '') + list.appendChild(row) + } +} + +// Modal that collects the other device's sync code plus what to push. Shown +// again with a cleared code after a rejected one, so the user can retype it. +async function openLanSyncModal(device, errorKey) { + const modal = showModal(` +

+ + + + +
+ +

+ + `) + modal.querySelector('#ls-modal-title').textContent = t('lansync.syncTitle', device.name) + modal.querySelector('#ls-code-label').textContent = t('lansync.enterCode', device.name) + const code = modal.querySelector('#ls-code') + code.value = device.code || '' + const error = modal.querySelector('#ls-code-error') + if (errorKey) { + error.textContent = t(errorKey) + error.style.display = '' + code.focus() + } + modal.querySelector('#ls-what-label').textContent = t('lansync.what') + const box = modal.querySelector('#ls-categories') + for (const category of LAN_CATEGORIES) { + const label = document.createElement('label') + label.className = 'check-label' + label.innerHTML = '' + label.querySelector('input').id = 'ls-cat-' + category.id + label.querySelector('span').textContent = t(category.label) + box.appendChild(label) + } + modal.querySelector('#ls-force-label').textContent = t('lansync.force') + modal.querySelector('#ls-warning').textContent = t('lansync.mirrorWarning') + modal.querySelector('#ls-cancel').textContent = t('dialog.cancel') + modal.querySelector('#ls-cancel').onclick = closeModal + const start = modal.querySelector('#ls-start') + start.textContent = t('lansync.start') + start.onclick = async () => { + const value = code.value.trim().toUpperCase() + if (value.length !== 8) { + error.textContent = t('lansync.badCode') + error.style.display = '' + code.focus() + return + } + const categories = LAN_CATEGORIES.map(c => c.id).filter(id => modal.querySelector('#ls-cat-' + id).checked) + if (!categories.length) { + toast(t('lansync.noneSelected'), 'error') + return + } + await runLanSync(device, value, categories, modal.querySelector('#ls-force').checked) + } +} + +async function runLanSync(device, code, categories, force) { + const modal = showModal(` +

+
+

+

+ `) + modal.querySelector('#ls-run-title').textContent = t('lansync.progress', device.name) + const bar = modal.querySelector('progress') + const phase = modal.querySelector('#ls-run-phase') + const count = modal.querySelector('#ls-run-count') + let active = true + window.plwl.onLanSyncProgress(data => { + if (!active) return + const key = 'lansync.phase.' + data.phase + phase.textContent = t(key, data.label || '') + if (data.total > 0) { + bar.max = data.total + bar.value = data.done + count.textContent = t('lansync.progressFiles', data.done, data.total) + } else { + bar.removeAttribute('value') + count.textContent = '' + } + }) + const res = await window.plwl.lansyncSync({ deviceId: device.id, code, categories, force }) + active = 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), 'success') + await renderLanSync() + return + } + const err = (res && res.error) || '' + if (err === 'bad-code') { + await window.plwl.lansyncForget(device.id) + toast(t('lansync.badCode'), 'error') + openLanSyncModal({ ...device, code: '' }, 'lansync.badCode') + return + } + const message = err === 'busy' ? t('lansync.busy') : err === 'offline' ? t('lansync.offline') : (err || t('lansync.unknownError')) + toast(t('lansync.failed', message), 'error') +} + +// Data received from another device changed the local files: reload every +// list so the views show the merged library instead of stale state. +async function reloadLocalData() { + const [wines, prefixes, games] = await Promise.all([ + window.plwl.winesList(), + window.plwl.prefixesList(), + window.plwl.gamesList() + ]) + state.wines = wines + state.prefixes = prefixes + state.games = games + if (state.currentView === 'library') renderLibrary() + else if (state.currentView === 'wines') renderWines() + else if (state.currentView === 'prefixes') renderPrefixes() +} + +function wireLanSync() { + window.plwl.onLanSyncDevices(devices => { + if (state.lansync) state.lansync.devices = devices + if (state.currentView === 'lansync') renderLanDevices(devices) + }) + window.plwl.onLanSyncApplied(async info => { + toast(t('lansync.applied', info.from || '', info.files || 0), 'success') + await reloadLocalData() + }) + qs('#ls-reroll-id').addEventListener('click', async () => { + const res = await window.plwl.lansyncRerollDeviceId() + qs('#ls-device-id').textContent = res.deviceId + if (state.lansync) state.lansync.deviceId = res.deviceId + toast(t('lansync.idRerolled'), 'success') + }) + qs('#ls-reroll-code').addEventListener('click', async () => { + const res = await window.plwl.lansyncRerollSyncCode() + qs('#ls-sync-code').textContent = res.syncCode + if (state.lansync) state.lansync.syncCode = res.syncCode + toast(t('lansync.codeRerolled'), 'success') + }) +} + function wireEvents() { qsa('[data-nav]').forEach(btn => { btn.addEventListener('click', () => showView(btn.dataset.nav)) @@ -1233,6 +1436,7 @@ async function boot() { wireEvents() wireRealtime() wireOpenWith() + wireLanSync() qs('#loading').remove() qs('#main').style.display = 'flex' showView('library') diff --git a/renderer/index.html b/renderer/index.html index 249661b..40f3631 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -29,6 +29,10 @@ + + + + @@ -101,6 +105,34 @@ +
+
+
+

+

+
+
+
+

+ +
+ + +
+ +
+ + +
+

+
+
+

+

+
+
+
+
diff --git a/renderer/plwl.png b/renderer/plwl.png index ee0c558dbb65ef4e1acac8fbd50ec8f3a2b4171e..cfab4e6a14d524a387219b57ed55b480d3ffc7b1 100644 GIT binary patch literal 9536 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 IF$etr1LCoDGynhq literal 9458 zcmb7Kc{r47*nfv%ETQZ>Wi4b+BBMo=5M{|WgGwbkk!1`e$6m>jJ+fzOvBu2gWT{Zc zzRoz7DeE8%2H!Kz>3siu|9sbl!TUbX{oLE{-k#_yriL7B!fX%(aa=SyZvjCt@GA^r zVFn-eKE=1d2dkISbzcbLY@vU{+B}PIfPV`48QAz;^}OX5;N)`?3J3@|e#gVj*Tu=} z=5bFS*NjD7VF;3hE}lPgEiiK-E7(i4FKY=`1(@`l4QuM9=EBRlRQL%&!g6_+Oi*0o^Z9>RsAK z(iZ-gmV+6jxjT^N&=Vs|MK*}LB*p-yf5fs{9x{4$jGqaLzV;~(l6+cf!wX4U@WnD< z&zCy?-!EJBF0s}XR8&+v$Q*EN*Tx;hM?!k-f=C{HM!X2re*n#iO{?UQ*(~S9N@HKn z?(<3AP_PA{(NIvisEG7#R_(9rd_Za=I_0orAS$Xlo7EQ&Q) zBw>`S819kpJ*%U37&D{C$4)^F4HZ=5uFW;50`3(~Ts>@=yo(pCQ7_aT?s+?@iIbbW z#wP#`1#G1!LpV-+)J`^6G@QhTJuMtxnh~%xe)`A8jrn3{26V@>MWi+FbXnX)5TrT1 z<;z)oS2(@!NRs)>(qq>&)ptar3h%V<{s`GZP3(4A2D!R6PY1kNatlScjjxAQ4TWay zFh8jryLRWK%S zG{y&k<&*dQu9nmxnPqx7`O@K}i!H<57bvm8*2ig9e6fAGH8q^?-@h*|DNzz&5yrX- zwrB+h2kcngMyK)(LO&K4J-TlUIzV=exl?6XmkLz0wWIxlEZA7!IEN)li|6{S1Any5 znWIpN!^6~jjdvjNO9g`i8;fHPECRWY%X`7_gISkcU6paybYlmV(UYp*z2-bI ztFhjOGtZ{nAVr4;n!N#`2Rg2X)Y8<9TwXRWF75FRdCP+F_KYA_0Nu@WxnIzAM9u%S z=9Aef$<8KovU41fBr@cd&Ba7!x^*+m-*eF+1Bw?fnd z>-l1w^@P!eLM?JYTg}JZRYX=~0Hew{UbGqGZLONJ(wORMryFWQ5vcZ5lE6kvTVyjy zkw5ljxx0AY_Xsz}bJ&%i=ba66-knkUybAsJVY3?$H-UsriSZOzBngU$Fp)D!0xL4q zPfgP;mB!|Rwzk8uS=@2H3EdlO3tI*62ukfeP#T6zWXdHB>S=3--|#sA5tL_)fmj<6 zICEceO>T8{$EYqrd;2_!*!Ae$#ph35Vc))cZyss2QN~)iY*Pcdd3Y{Dd01hCeO>(B z{B~ROMAxU$`p%gdC#byV0! zM97~g6e)}wEAgt(V_>9Z`o<-2^3Xogj}$;5As~~S<5?-IcdrSm?cdDN-F~}c&pyqK-cA7=M$GHrRMOj)#5TEW# z`8YLv-dz7w)`=5+rN`_JyU1EFU{&qiMzhn>4*E$0Cjw_g?M-xdMM}N+y7%TuVfEaN zuNO`NZWfwZ3LAeFS{zy01?trTbVUwO{biCmK7x!zo|%t0mHK2iFZ~`w})a^E|Gpvz`WLVNW{b$66QsPAM_L0c7oy zy9c{l!O{VMhYLH&ha2}b50H2u4o5QWN_oVu~=4|QGlV!;2pepBcl zkgoHb#&hMOLpfaP?Ef6{hWz~fI~l9cg^?pH(7&cpu1MAH9@iM~6C-6BD>vnpqqSmv zL?ICFZ;y7}xMmZ4hVE$K<=Pce_b!ThK_vd~ho*)HS)gnIs5HI$jrAdkB6ayiIRHXm zmH|vtr^R1{bPfz3c^bmMU^FF+Jqq=zjs4!_%>;st5$j9kh8{$Ty+r`<+E=-i9u0bO ziO=3`RS)8gBwHVq7y3_7oIu`|>mwJh4g-bE zn=2cZ^pKP~Atp*4`z1}yB6yAm*uFP)J-pmZKTbduiGJKKlrN`!TpCVHeeOI1iy$`B z)nrT=NmhXAQ%2Tgsh@yz$UPGWD;(&8OC<#yEBWtH*&)n>q$K|TeJGIZCIBms1zsvc zmC?55qhXi8%dy+sv}c)RoEW^97HSDV^>z?rn4zJZ)oYvRNOl1j#3?$MsAP8{zf#$W zphcww`1v_ce|B5NGm&y8n$Sk*Pvj68B#9Wgk9ixsS#DXc6asq@|V-4 zl2lkX!~Q0_cf(9fI$w7)>g$RdN^@6Fxf*)3NJO8%f|5`kX{@Okb-PkeVa$fp?P=~b zu%!;0qidY!^UaMhR@0vhL#u7R{w4mFI9~|t&z_5JA_MfGlzi#>to2nF4q1hjSznBT zp%(sLZxuAfk6)Zb1VT&?(Ln;yl38U z(i$6*Tm(&oOL)o9?G4}#;sdZLmj{(^uVTFjTt@p;wFD`Q{hk}yO)NhyW6Bq5gL>6` zd3p7yuv9mOne4+1HXhz zK&Cl*?j<z|OB&)w|KOk)R{^5*T_2JJ4xD zZ7_uqU+vPSSkC;qtgPk3g9U>Sx*WddAi4bg{cmoApe(9$;&xAy4_H#dCMrrzJ-&d~ z9C(fY7|9NU5S*g;{&Kr-MAV1ja!b_H&FdTY1u)PROFqK{Q9?aA+pgb@5b@S`Ddo&# z38+hNThw@GJaj{>xb$vI{TmX3;Tn(1K#LUBvwqrYG+Y+_{{Wu&4eHEl&G1oeL!J^m9VDGO-$(Q z3E%?Hbu=qjSA0b7PFgEQ>Zbu$1M8}%>d}NDJ{S_c!$Ml9Esx9@pnM5Y3aQ**r@aUG zl5gy`v<`iFkL8gVFsnoOO)?7I(cL^ntR{b0?W*nV8SZujj-xjK|GGw@M&w|+P^Q)n zYfQ9NWSD=7!l;bl`xEsR!3a389!SxidU%`Xm_mTZz|FOdz4ei>H&dwt7H7cve){@} zySS{z!=W?k*8Gq3-{ZNUC{D3Ay_MzVq5W&n`ZOoaHTwa-t=)*?!tyq8ulY5~(KwR8 zMco%#QQ{V-xbAJY-tLf@&!a!}HGz@KnPmfQ_IqsKDu3L}S09jXTIMS%s(b2-#Sbq$ z7xfBV*m6`DdahsTKB5iq3EQJ^y>;MWk)Snz&Cn$|edp$erWYN81eD(JL)#lm%5$#2 z_>zzmM)7@y4`HAmOJ?ZR8I${R;boP_{hd@kP#E75o>WG%EBokPuv#SMZJ(0>YZ08h z>`lOn18wL;U8(-K?WMg#;$a~fV;!W~vQ7c!77pZ-CWjh6CC3kfnwsx94ZS6H)6;Dg z9X|~IHNL&ESMe%mXrP`JDM2ZgMOPT=le*W*R07wcFE4|&V+l&|U6b4Hy<-pt3fx(H z_)Vky9tY#I&U0Qyc0nUYFLfqc7`TSY1b))Z#uF7LK*1=byuH==aU%SpjA z3-k-{TsPbpo7eg!6xSTyOsQoojsq>EI@J96tmWmoD3w#EY)RIi2bv>bNRl}OKeYQ| z$KWjKZNk~yXQY8pRG?F3L9@h8aTY}3#6UK0Zj{KR3OEYNQ!+XSvLhG)0HuaU9N3F9 zI|0YeYd4EIJeYQcK^x=2wFPTT$t+0x@<(2TCq4M^ zl@0(b$222T9k2s)bi+#6=8?39N)bbbWKLX#fa9Q)dtC0_ z?rd6)>Q@S5cro3_U2CQz^=(K9Y<4zkXiMCnAlf$pwR5-D$!^d)rFv>hJGt93{2^lR zvQ8TphI0DlK`17R^BPEmy{6g^eb|~+_^XvY-SNRR-vLf>rYm9qz^s@!kJB1*ZnKPf zM{WA$`b-S0jrw+95>dasHLbiSqQ8dcg4fC4t|&62bpJ}?V@T#gLi2;m$P`Zz$BUnD zs13e%GqePz&kZFr?p+QWGGtiig9A$r3=|CBXqI%R<=*bjh8^Iz-pFTr$C96>55-2z z1Tn*b!p@%~xjjqa*hb(gigGZQhD9Utin6yNbI(l^xZtk=O;c(Vek4@{OT)X*^3tmirQGX(5dHFazf(2ME3eRJvd)!?Q{FdEj+9>b#j{+AP0}&L_*R@7j;*^_E>zN^>m*k=pMn)XHM*T0a540T)iBYjs{xyDn27$u zuXA&v1%&Mu3sTibWY55LmiGFR$9f370?@RxGXrs}KZ|eGaw2TIcJE7Hy=~vn>(x>)gopPd08^Lw{u$OZ555(Vr~x zPk(&cTI0I@V3|>QX&i&Y#N$uO)M+iKkHsy)V10ovYukPxYta6y?Ug$3dKHEY_)LsHI* z>ya&$3P~NL?BK=LPOVA;;H4lw=+}=!uN{IF7VF8!K?CWFuVP@~XF$s8H>oFh&)zS} zxH!wQ3W8LkZrt*+cbk-K(&S*3(`lf8-@#hX%5rUf-K&ulM#y(7YG^<2ClH(k>{&1o zVfQ<;M&f{}l6-Tvb!}~Jl@@$grdVk-GP13|JVRAi_g-)UQ};DV8aZb#{KLX|=hXZR z(>g&2jwE=AKZ&I}%=z4@CB^$1iR3r1n3Ko;%66%lsp794l}5Y1}3Wr6Va2$e%!iYgOBs+B^4 z^jhm{94S>ERKW~Zd}*ZA{Lc^fOQ}3)KQa9Slol17zz_Bl&HcaN)ajjP`F~~JGXDm! z-ha*`0KNxw;Y|!B%21hTQ=oU1w^Dzs3b|h_^_9B0CmMpfs$&%tt?g~4_+32LkBy19 za-cXc%LWYwV?9fzbuJJH>%5+pFV}byO)2bK9cxOe!URt{y=zSC0=uuH)^_~}W}{{m zp3&b~For92$Ex-~4oY~4P+S|SaaSNH|BxG~{qr^)=kmaY?y*a9(HLFNY4L(oN`Bi=olqQUlX_UEUu-mV-j>c#ig6HkSRI$Kd-~+R# zu;>=$FUrJJQYu;DDm?5cQ=u$+d>oEjyi8$_CFPie7qh0-Y7r`RKVaNGghs8mE)A%cLq20I3Pn_f#Fp}Gb=;>0|tbke6gvKq}N@z_C( z?YLW?HAukZ49@r;qG^+_;E6*+k-+?0@(cuCHtQXCK^t!^c%lxi3ra||?e1(Z54t4g z)z%{VSdb(qPy^AI>=h`Nx1?)obqIbF=o=WAd}e%{Ad_kz5hcDQgrQAJ*p6!{f^0C0 z;h)z51j(6Zy1;XS3Pb%q zaY!Ah_V#xJHD-?IpLZ@hJ(8e&_`R;EY{%5Nfa!X=>=vs52-AVK{F$K1kQzKSX&8#& zoKIB^%=M$7mvfGSlqEYEz!1HOvI)YnQo8Vo)9ZuzOzuvgB6{_ELsw9`?nCO{VV5J} z-_CLE+zV2|$Cbh``hTB-B9LU!yHx+Nu)W0P*|JJgWw`C5t*szd-&ND0NPV2TB0Sw3 zWao7a2mjbV1+OG%eQ`G#Ne5lhGy4);zO@x#uKC0Ov^J1GD5)#FtFqK4D^g4Z1Hc01 zmQ+%0o<1KF695&|W6!#Y)Xfe4w7gu1UOG&4-6 zf`LvzRjo`|x&|v{yG;4=D~SwRj@zFfqsAp(8bPmCv|f~Fv0^qR0szX^&dxE@m|mM` zi=C5(T36zD=YTB*Gvd4{ErxA@ns7YL|G|?sPHx-vc6InumfUpv=iMZyoOFul)mc(|+yT zpkXOWZ`=%Ilg9O_gQDmr>}+(tus$dsofh&yV@24|L278Fx3}~`j(+58PNAi+Yjll=j;EwBsN<0 zae6upYQI6+Ii&CDx#SHkrO92`=L@)MU*D%rH;?i0#sKogBHu$h$MGJM7fejbonlF; zKT`)Tfk6c)f(9K-6yQaTO$Vwy5ry8r4+1h*+gY(?#bk_w-h_Vj?3c#awYSG!Lf-HVhAyM2-mB_LrDa(YIM zj+&a$Nk}8!Q^jMTudlN~u`csB(M*crk?#@g*z`hopBuNHctO9`PU9rd$|55%_9gG6 zBZ$5Gyvfm^Z-ax+1E}5FjdV4bm3gzuT zE2S5}^)*U8B3>$*SGOb&ou_2{Ba29IFyZvCWqXB34mBIJ``KDBpuqs`A++LAmED9k z-<3M^o3IujJUo1NeN?40v+9*4Q@_S2hh)BIn==@I0ZzCGo_A%a?mIfdofI^n_wYEfVdO#AAa#pd{;cWLThBkw1c z-JM6hI)Hxi3lbCzT<0avyS3H?eh^<<^VO$_?8~Z;aSsfKZr@Hz8t4*Sq`&q2`I)$8 z`{SptUv0H%@T8wYT)X^9yVwBS6zmsY{z|@B4FU7t98A>e;Ir ze0;BTX?NdvHwTOR;U1?7y8KC0f2aSJ;PT{8&b>yew;&5khfO>HP6Y;#!o$aKK z_hK)&04AfxM56g)fM0`2SttNhl}zu}>oaA$=(sUmm8xz3ql|n{%W4rzb0hVvBrc(m zzn6nFv}#%E!0?JbjHtX!4S%pY!?(%F5mr?jK(;!{nEa#+TnQ*WwRsb&6dIZNdguJr ztAv0IC3<49Pvj$Uc=++mtyLe?hrH4x)Kj%@iaw_yW@cttX!@$kwS)I<(gGm}?n}Q5 z!2G5RE+RZ1;`Zc!SC5Xy)zmzgapf}1A1-<`U#DQL$0T1^QK7)^)5Qa=xTa7&TU$2HZ8>OqGWyR1y`vAWKPdv-iHUvLOHifBVLwE`cz7lSg-^ju z)Vrd7pApj=09tW;3hCe$fgW+7b}rR|`K^36Bh)U&s>>m{RA&0(vM^}LfPo9cejNBu z!KdcB+4N=^Rp9bD>nmBPRAm_SV*vin6?VU-I?Fd>4-iLK{w)Ue*}h${^C5?E_U{o8nNzsZ$j;p9J@gVk3$#7hM$;7ytO@ zqpU;&`uGU6_XUN8XL6fD#ht6uoy9J|MxPnYKeOn)dM%t40wdfy+F1i9F5i|~miS9W zJ%U2o@zK%CYKHLrNatx7r>dc$@$&uq+?Yi39mp=pXg;K8S^pVdF$BFq$v8InZ3?$i z9aKUu<${s`eeWI|44*+eF(_qUE#=3AIAT z6ShLeSZ_!;`bIMcCF6>7hZeVD8*)|nbRalp0i?7CA?}vd{}0a$!{9|L48iuIXR@%Ne5Pnx;)!CY3|58 z75SJ5qgTxD-)sKy>CN8VyLVsnzj?txWtQ|w{QB3JN2xP|zI8yr#*5O=R$dkMTol|& zu%0wGIq;%6wbkg6=TrYS@ycu7Fm&(!g$%YvPP8=x6}x|xWE@_V<-2QBBZ|3Mn#YJQ z1{V(0)rAUp(AHjE$S%RI)nL!>_*-hG%D~ zk?D`wVIf{y#24+yHPRV#A8hUAm1!DT=JR4zp`xb_uae*@f6VL9GSXWeyKhit#+!7^ zwoV!j@D77(H%8ObK^IlHqj~NB?>jgDuM0Z;dx(bAy`MF&xe4ID5_HkP^nArxbj1Gv Dt@3 { const o = { ...(opts || {}) } diff --git a/src/ipc/lansync.js b/src/ipc/lansync.js new file mode 100644 index 0000000..e1476f9 --- /dev/null +++ b/src/ipc/lansync.js @@ -0,0 +1,37 @@ +'use strict' + +const { ipcMain } = require('electron') +const lansync = require('../lansync') +const { sendToWindow } = require('./games') + +function registerLanSyncIpc({ getWindow }) { + ipcMain.handle('lansync:get', () => lansync.getState()) + ipcMain.handle('lansync:devices', () => lansync.listDevices()) + ipcMain.handle('lansync:rerollDeviceId', () => { + const identity = lansync.rerollDeviceId() + return { deviceId: identity.deviceId } + }) + ipcMain.handle('lansync:rerollSyncCode', () => { + const identity = lansync.rerollSyncCode() + return { syncCode: identity.syncCode } + }) + ipcMain.handle('lansync:forget', (e, id) => { + lansync.forgetDevice(id) + return { ok: true } + }) + ipcMain.handle('lansync:sync', async (e, payload) => { + try { + return await lansync.syncTo({ + deviceId: payload && payload.deviceId, + code: payload && payload.code, + categories: payload && payload.categories, + force: !!(payload && payload.force), + onProgress: data => sendToWindow(getWindow(), 'lansync:progress', data) + }) + } catch (err) { + return { ok: false, error: String((err && err.message) || err) } + } + }) +} + +module.exports = { registerLanSyncIpc } diff --git a/src/lansync.js b/src/lansync.js new file mode 100644 index 0000000..21cbd12 --- /dev/null +++ b/src/lansync.js @@ -0,0 +1,1166 @@ +'use strict' + +// LAN syncing. Every running PLWL instance beacons on the local network and +// runs a small HTTP server other instances can push to. Whoever starts a sync +// is the source of truth: the receiver ends up with exactly the sender's +// files (extra files are deleted) so a half copied library is never left +// behind, and paths stored in the registries stay relative as they are. + +const http = require('http') +const dgram = require('dgram') +const crypto = require('crypto') +const os = require('os') +const path = require('path') +const fs = require('fs') +const fsp = fs.promises +const readline = require('readline') + +const { DIRS, readJson, writeJson, toPortablePath } = require('./paths') +const library = require('./library') +const wines = require('./wines') +const prefixes = require('./prefixes') + +const CONFIG = path.join(DIRS.DATA, 'lansync.json') + +// The beacon doubles as a liveness signal: a device drops off the list a few +// seconds after its PLWL is closed. +const DISCOVERY_PORT = Number(process.env.PLWL_LANSYNC_PORT) || 47821 +const BEACON_TAG = 'plwl-lansync' +const PROTOCOL = 1 +const BEACON_MS = 2000 +const DEVICE_TTL_MS = 7000 + +// Every verification costs this much and a device may only have one in +// flight - together they make guessing an 8 character code over the LAN +// pointless (and a failed guess cannot be retried in a tight loop). +const VERIFY_DELAY_MS = 2000 +const TOKEN_TTL_MS = 30 * 60 * 1000 +const SESSION_IDLE_MS = 5 * 60 * 1000 + +// The only trees a sync may touch. Everything else under files/ is local +// state (settings, translations, playtime, logs) that must never move +// between machines. +const SYNC_ROOTS = ['apps/', 'wines/', 'wineprefixes/', 'data/icons/'] + +// What a sync can carry, plus lenient aliases so callers can name the +// categories the way the UI/README does. +const CATEGORIES = ['apps-portable', 'apps-prefix', 'wines', 'prefixes'] + +const CATEGORY_ALIASES = { + // plain "apps" means the whole app library: portable ones AND the ones + // imported from a prefix (only the launcher entries, their files stay in + // the prefix) + apps: ['apps-portable', 'apps-prefix'], + app: ['apps-portable', 'apps-prefix'], + appsportable: ['apps-portable'], + portableapps: ['apps-portable'], + portable: ['apps-portable'], + appsprefix: ['apps-prefix'], + prefixapps: ['apps-prefix'], + appsimportedfromprefix: ['apps-prefix'], + wine: ['wines'], + wines: ['wines'], + wineversions: ['wines'], + wineprefix: ['prefixes'], + wineprefixes: ['prefixes'], + prefixes: ['prefixes'], + prefix: ['prefixes'] +} + +// Which sync category an app entry belongs to. Portable copy/move imports own +// their folder in files/apps; prefix imports only have a launcher entry (their +// files stay inside the prefix). +function appCategory(game) { + if (!game || typeof game !== 'object') return null + if (game.importSource === 'prefix' || game.mode === 'prefix') return 'apps-prefix' + if (game.imported === true || game.mode === 'copy' || game.mode === 'move') return 'apps-portable' + return null +} + +// Where an app entry's files live, read off its stored path: a portable +// import owns a folder under apps/ (FILES-relative), a prefix import points +// inside its prefix ("drive_c/..." for plain wine, "pfx/drive_c/..." for +// Proton-managed prefixes) and anything absolute or ".." points outside the +// launcher data, so it is never part of a sync. +const PREFIX_REL_RE = /^(pfx[/\\])?drive_c([/\\]|$)/i +function classifyApp(app) { + if (!app || (typeof app !== 'string' && typeof app !== 'object')) return null + const raw = typeof app === 'string' ? app : app.path || app.exePath || app.workdir + // nothing that points anywhere inside the launcher data: the app is left + // where it is (an external entry), nothing of it can travel in a sync + if (!raw || typeof raw !== 'string') return 'external' + const value = raw.replace(/\\/g, '/') + const outside = value.startsWith('..') || path.isAbsolute(raw) || /^[a-z]:\//i.test(value) || /^[a-z][a-z0-9+.-]*:\/\//i.test(value) + if (PREFIX_REL_RE.test(value)) return 'prefix' + if (/^apps\//.test(value)) return 'portable' + return outside ? 'external' : null +} + +function normalizeCategories(list) { + const out = new Set() + for (const raw of Array.isArray(list) ? list : []) { + const aliases = CATEGORY_ALIASES[String(raw || '').toLowerCase().replace(/[^a-z]+/g, '')] + for (const alias of aliases || []) out.add(alias) + } + return [...out] +} + +// --- identity --------------------------------------------------------------- + +// The sync code is read out on one machine and typed on the other, so the +// alphabet skips the characters people mix up (0/O, 1/I/L). +const CODE_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789' +const ID_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789' + +function randomChars(alphabet, count) { + let out = '' + while (out.length < count) { + for (const byte of crypto.randomBytes(count)) { + if (out.length >= count) break + out += alphabet[byte % alphabet.length] + } + } + return out +} + +// 24 characters: the current timestamp in base36 (unique per millisecond, +// 8 chars until 2089) plus random padding, so two machines generating an id +// within the same millisecond still get different ids. +function makeDeviceId() { + const stamp = Date.now().toString(36) + return (stamp + randomChars(ID_ALPHABET, Math.max(0, 24 - stamp.length))).slice(0, 24) +} + +function makeSyncCode() { + return randomChars(CODE_ALPHABET, 8) +} + +function readConfig() { + const cfg = readJson(CONFIG, {}) + return cfg && typeof cfg === 'object' ? cfg : {} +} + +// The id and the code are generated on FIRST use and then kept forever: +// other devices remember the id (they pair with it) and the code (so it only +// has to be typed once). +function identity() { + const cfg = readConfig() + let changed = false + if (typeof cfg.deviceId !== 'string' || cfg.deviceId.length !== 24) { + cfg.deviceId = makeDeviceId() + changed = true + } + if (typeof cfg.syncCode !== 'string' || cfg.syncCode.length !== 8) { + cfg.syncCode = makeSyncCode() + changed = true + } + if (!cfg.known || typeof cfg.known !== 'object') { + cfg.known = {} + changed = true + } + if (changed) writeJson(CONFIG, cfg) + return cfg +} + +// Both rotations return the whole identity ({ deviceId, syncCode, known }), +// so callers get a consistent view of this device after the change. +function rotateDeviceId() { + const cfg = identity() + const previous = cfg.deviceId + cfg.deviceId = makeDeviceId() + writeJson(CONFIG, cfg) + if (cfg.deviceId === previous) return rotateDeviceId() + return cfg +} + +function rotateSyncCode() { + const cfg = identity() + const previous = cfg.syncCode + cfg.syncCode = makeSyncCode() + writeJson(CONFIG, cfg) + if (cfg.syncCode === previous) return rotateSyncCode() // never a no-op rotation + return cfg +} + +// A device the user synced with successfully: its code is stored next to its +// id, so the next sync needs no typing. A rejected code is forgotten again, +// which is what drives the "type the code again" flow. +function knownDevice(id) { + return identity().known[id] || null +} + +// Devices this launcher paired with: id -> { code, name, at }. The code is the +// one that was accepted, so later syncs need no typing. +function knownPeers() { + return identity().known || {} +} + +function rememberDevice(id, code, name) { + const cfg = identity() + const stored = String(code || '').trim().toUpperCase() + cfg.known = { ...cfg.known, [id]: { code: stored, syncCode: stored, name: name || '', at: Date.now() } } + writeJson(CONFIG, cfg) +} + +function forgetDevice(id) { + const cfg = identity() + const known = { ...cfg.known } + delete known[id] + cfg.known = known + writeJson(CONFIG, cfg) +} + +function sameCode(a, b) { + const x = Buffer.from(String(a == null ? '' : a).trim().toUpperCase()) + const y = Buffer.from(String(b == null ? '' : b).trim().toUpperCase()) + return x.length === y.length && x.length > 0 && crypto.timingSafeEqual(x, y) +} + +// --- files: hashing, paths, manifests --------------------------------------- + +// BLAKE2b resists collisions like SHA-2 but is noticeably faster - syncing a +// wine prefix means hashing tens of thousands of files, so this is the hot +// path. Older runtimes fall back to SHA-2. +const HASH_ALGO = (() => { + for (const algo of ['blake2b512', 'sha512', 'sha256']) { + try { + crypto.createHash(algo) + return algo + } catch {} + } + return 'sha256' +})() + +function hashFile(file) { + return new Promise((resolve, reject) => { + const hash = crypto.createHash(HASH_ALGO) + const stream = fs.createReadStream(file) + stream.on('error', reject) + stream.on('data', chunk => hash.update(chunk)) + stream.on('end', () => resolve(hash.digest('hex'))) + }) +} + +// Protocol paths are FILES-relative with '/' separators. Anything escaping +// files/ (a '..' segment, an absolute path) is rejected - manifests come from +// another machine. +function filesPath(rel) { + if (typeof rel !== 'string' || !rel || rel.includes('\0') || path.isAbsolute(rel)) return null + const root = path.resolve(DIRS.FILES) + const abs = path.resolve(root, rel) + if (abs !== root && !abs.startsWith(root + path.sep)) return null + return abs +} + +// A sync unit may only own one of the sync trees, nothing else. +function ownedRoot(rel) { + const clean = String(rel || '').replace(/\\/g, '/').replace(/\/+$/, '') + if (!clean || clean.split('/').includes('..')) return null + return SYNC_ROOTS.some(prefix => clean.startsWith(prefix)) ? clean : null +} + +async function mapLimit(items, limit, fn) { + const out = new Array(items.length) + let next = 0 + const workers = Array.from({ length: Math.min(limit, items.length) }, async () => { + while (next < items.length) { + const i = next++ + out[i] = await fn(items[i], i) + } + }) + await Promise.all(workers) + return out +} + +// Walk without following symlinks: builds and prefixes contain links that are +// part of what is being synced (dosdevices, build internals), and following +// them would both loop and copy the wrong thing. +async function scanTree(abs, rel, out) { + let entries + try { + entries = await fsp.readdir(abs, { withFileTypes: true }) + } catch { + return + } + for (const entry of entries) { + const absChild = path.join(abs, entry.name) + const relChild = `${rel}/${entry.name}` + if (entry.isSymbolicLink()) { + let target = '' + try { + target = await fsp.readlink(absChild) + } catch {} + out.push({ path: relChild, type: 'link', target }) + } else if (entry.isDirectory()) { + out.push({ path: relChild, type: 'dir' }) + await scanTree(absChild, relChild, out) + } else if (entry.isFile()) { + let size = 0 + try { + size = (await fsp.stat(absChild)).size + } catch { + continue + } + out.push({ path: relChild, type: 'file', size, hash: null }) + } + } +} + +// Manifest of one sync unit. `force` skips hashing entirely - the receiver +// then treats every file as different ("force sync all files"). +async function buildManifest(roots, force, onProgress) { + const entries = [] + for (const rel of roots) { + const abs = filesPath(rel) + if (!abs) continue + let st + try { + st = await fsp.lstat(abs) + } catch { + continue + } + if (st.isSymbolicLink()) { + let target = '' + try { + target = await fsp.readlink(abs) + } catch {} + entries.push({ path: rel, type: 'link', target }) + } else if (st.isDirectory()) { + entries.push({ path: rel, type: 'dir' }) + await scanTree(abs, rel, entries) + } else if (st.isFile()) { + entries.push({ path: rel, type: 'file', size: st.size, hash: null }) + } + } + const files = entries.filter(e => e.type === 'file') + if (!force && files.length) { + let done = 0 + await mapLimit(files, 4, async entry => { + try { + entry.hash = await hashFile(filesPath(entry.path)) + } catch { + entry.hash = null + } + done++ + if (onProgress) onProgress(done, files.length) + }) + } + return entries +} + +// --- HTTP helpers (shared with the pusher) ---------------------------------- + +// One-shot JSON request against another instance. `timeout: 0` means no socket +// timeout - hashing a big prefix on the other side takes a while. +function httpJson(device, method, route, { json, timeout = 15000 } = {}) { + return new Promise((resolve, reject) => { + const req = http.request( + { + host: device.address, + port: device.port, + path: route, + method, + headers: json ? { 'content-type': 'application/json' } : {}, + timeout + }, + res => { + const chunks = [] + res.on('data', chunk => chunks.push(chunk)) + res.on('end', () => { + let data = null + try { + data = JSON.parse(Buffer.concat(chunks).toString('utf8')) + } catch {} + resolve({ status: res.statusCode, data }) + }) + } + ) + req.on('timeout', () => req.destroy(new Error('timeout'))) + req.on('error', reject) + if (json) req.write(JSON.stringify(json)) + req.end() + }) +} + +// Accept a discovered device record, a bare port (tests) or 'host:port'. +function targetOf(target) { + if (typeof target === 'number') return { address: '127.0.0.1', port: target } + if (typeof target === 'string') { + const [host, port] = target.split(':') + return { address: host || '127.0.0.1', port: Number(port) || 0 } + } + if (target && typeof target === 'object') { + return { address: target.address || target.host || target.hostname || '127.0.0.1', port: Number(target.port) || 0 } + } + return null +} + +// Send our sync code to the other device (the receiver checks it against its +// own code and hands back a token). A rejected code never gets stored. +async function verifyPeer(target, code) { + // one object with { host, port, code } or a target plus a separate code + if (target && typeof target === 'object' && code === undefined && target.code !== undefined) code = target.code + const device = targetOf(target) + if (!device || !device.port) return { ok: false, error: 'bad-target' } + let res + try { + res = await httpJson(device, 'POST', '/plwl/verify', { + json: { deviceId: identity().deviceId, name: os.hostname(), code: String(code == null ? '' : code).trim().toUpperCase() }, + timeout: 20000 + }) + } catch (err) { + return { ok: false, error: String((err && err.message) || err) } + } + if (!res.data || !res.data.ok) return { ok: false, error: (res.data && res.data.error) || `http-${res.status}` } + const peer = res.data.device || { id: res.data.deviceId, name: res.data.name } + const peerId = (target && target.deviceId) || (target && target.id) || (peer && peer.id) + if (peerId) rememberDevice(peerId, code, (peer && peer.name) || (target && target.name)) + return { ok: true, token: res.data.token, deviceId: peer && peer.id, device: peer, peer: peer } +} + +// --- receiving side --------------------------------------------------------- + +function sendJson(res, status, data) { + if (res.writableEnded) return + const body = JSON.stringify(data) + res.writeHead(status, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) }) + res.end(body) +} + +function readJsonBody(req, limit = 16 * 1024 * 1024) { + return new Promise(resolve => { + let size = 0 + const chunks = [] + req.on('data', chunk => { + size += chunk.length + if (size > limit) { + req.destroy() + return + } + chunks.push(chunk) + }) + req.on('end', () => { + try { + resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))) + } catch { + resolve(null) + } + }) + req.on('error', () => resolve(null)) + }) +} + +function readNdjson(req, onEntry) { + return new Promise(resolve => { + const rl = readline.createInterface({ input: req, crlfDelay: Infinity }) + rl.on('line', line => { + if (!line.trim()) return + try { + onEntry(JSON.parse(line)) + } catch {} + }) + rl.on('close', resolve) + rl.on('error', resolve) + }) +} + +function inlineSame(entry) { + return entry && typeof entry.path === 'string' && (entry.type === 'dir' || entry.type === 'link' || entry.type === 'file') +} + +function withinRoots(rel, roots) { + return roots.some(root => rel === root || rel.startsWith(root + '/')) +} + +async function entrySame(entry) { + const abs = filesPath(entry.path) + if (!abs) return false + if (entry.type === 'dir') { + try { + const st = await fsp.lstat(abs) + return st.isDirectory() && !st.isSymbolicLink() + } catch { + return false + } + } + if (entry.type === 'link') { + try { + return (await fsp.readlink(abs)) === entry.target + } catch { + return false + } + } + if (!entry.hash) return false // forced sync (or unreadable file) -> replace it + let st + try { + st = await fsp.lstat(abs) + } catch { + return false + } + if (!st.isFile() || st.size !== entry.size) return false + try { + return (await hashFile(abs)) === entry.hash + } catch { + return false + } +} + +function ensureDir(abs) { + let st = null + try { + st = fs.lstatSync(abs) + } catch {} + if (st && st.isDirectory() && !st.isSymbolicLink()) return + if (st) removeAbs(abs) + try { + fs.mkdirSync(abs, { recursive: true }) + } catch {} +} + +function ensureLink(abs, target) { + if (typeof target !== 'string' || !target) return + let current = null + try { + current = fs.readlinkSync(abs) + } catch {} + if (current === target) return + removeAbs(abs) + try { + fs.mkdirSync(path.dirname(abs), { recursive: true }) + fs.symlinkSync(target, abs) + } catch {} +} + +// Reports whether something was ACTUALLY removed: rmSync with force also +// "succeeds" on a missing path, which would inflate the delete counters. +function removeAbs(abs) { + try { + if (!fs.lstatSync(abs)) return false + } catch { + return false + } + try { + fs.rmSync(abs, { recursive: true, force: true }) + return true + } catch { + return false + } +} + +function removeTree(rel) { + const abs = filesPath(rel) + return abs && removeAbs(abs) ? 1 : 0 +} + +function removeOrphans(dir, keep) { + let names = [] + try { + names = fs.readdirSync(dir) + } catch { + return 0 + } + let removed = 0 + for (const name of names) { + if (keep.has(name)) continue + if (removeAbs(path.join(dir, name))) removed++ + } + return removed +} + +function listDirs(dir) { + try { + return fs + .readdirSync(dir, { withFileTypes: true }) + .filter(e => e.isDirectory()) + .map(e => e.name) + } catch { + return [] + } +} + +// Delete everything under a unit root that is not in the sender's manifest. +async function pruneTree(abs, rel, keep) { + let entries + try { + entries = await fsp.readdir(abs, { withFileTypes: true }) + } catch { + return 0 + } + let removed = 0 + for (const entry of entries) { + const childAbs = path.join(abs, entry.name) + const childRel = `${rel}/${entry.name}` + if (!keep.has(childRel)) { + if (removeAbs(childAbs)) removed++ + continue + } + if (entry.isDirectory()) removed += await pruneTree(childAbs, childRel, keep) + } + return removed +} + +function writeStream(abs, stream) { + return new Promise((resolve, reject) => { + let st = null + try { + st = fs.lstatSync(abs) + } catch {} + if (st && st.isDirectory()) removeAbs(abs) + try { + fs.mkdirSync(path.dirname(abs), { recursive: true }) + } catch {} + const tmp = `${abs}.plwl-sync-tmp` + const out = fs.createWriteStream(tmp) + stream.on('error', err => { + removeAbs(tmp) + reject(err) + }) + out.on('error', err => { + removeAbs(tmp) + reject(err) + }) + out.on('finish', () => { + try { + if (st && st.isSymbolicLink()) removeAbs(abs) + fs.renameSync(tmp, abs) + resolve() + } catch (err) { + removeAbs(tmp) + reject(err) + } + }) + stream.pipe(out) + }) +} + +// --- registries on the receiving side --------------------------------------- + +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) + const incomingIds = new Set(incoming.map(g => g.id)) + const existing = library.loadGames() + 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 + } + kept.push(game) + } + // playtime is per machine, the sender does not push any - keep the counters + // of apps this device already had + const merged = [...kept, ...incoming.map(g => { + const prev = existing.find(x => x.id === g.id) + return prev ? { ...g, playSeconds: prev.playSeconds, lastPlayed: prev.lastPlayed } : g + })] + 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 only ever holds portable (copy/move) imports, so anything + // the sender does not own is a leftover of the mirror + 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)) + const removed = removeOrphans(DIRS.WINES, names) + // the registry is derived from the builds on disk, so a rescan is all that + // is needed to drop removed builds and pick up the synced ones + await wines.refreshWines() + return removed +} + +function applyPrefixes(payload) { + const sender = payload.prefixes && typeof payload.prefixes === 'object' ? payload.prefixes : {} + const incoming = (Array.isArray(sender.prefixes) ? sender.prefixes : []).filter(p => p && 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])) + // every launcher seeds its own Main prefix - never delete the receiver's + // copy just because the sender does not know it + if (!keep.has(prefixes.MAIN)) { + keep.set(prefixes.MAIN, byName.get(prefixes.MAIN) || { name: prefixes.MAIN, created: Date.now() }) + } + const removed = removeOrphans(DIRS.PREFIXES, new Set(keep.keys())) + const list = [...keep.values()].map(p => ({ ...p, locked: p.name === prefixes.MAIN ? true : !!p.locked })) + const def = keep.has(sender.default) ? sender.default : prefixes.MAIN + prefixes.savePrefixes({ default: def, prefixes: list }) + return removed +} + +// --- HTTP server (what other devices talk to) ------------------------------- + +function createServerState() { + return { tokens: new Map(), verifying: new Set(), session: null } +} + +function activeSession(state) { + if (!state.session) return null + if (Date.now() - state.session.lastAt > SESSION_IDLE_MS) { + state.session = null + return null + } + return state.session +} + +function authorize(state, token) { + const session = token && state.tokens.get(token) + if (!session) return null + if (Date.now() > session.expires) { + state.tokens.delete(token) + return null + } + session.lastAt = Date.now() + if (state.session && state.session.token === token) state.session.lastAt = session.lastAt + return session +} + +function createHttpServer(state, hooks) { + const server = http.createServer(async (req, res) => { + let url = null + try { + url = new URL(req.url, 'http://plwl.local') + } catch {} + if (!url) return sendJson(res, 400, { ok: false, error: 'bad-request' }) + const route = url.pathname + try { + if (route === '/plwl/info' && req.method === 'GET') { + const me = identity() + return sendJson(res, 200, { ok: true, protocol: PROTOCOL, id: me.deviceId, name: os.hostname() }) + } + if (route === '/plwl/verify' && req.method === 'POST') return handleVerify(state, req, res, hooks) + const session = authorize(state, url.searchParams.get('token')) + if (!session) return sendJson(res, 401, { ok: false, error: 'unauthorized' }) + if (route === '/plwl/unit/plan' && req.method === 'POST') return handlePlan(state, session, url, req, res) + if (route === '/plwl/unit/file' && req.method === 'PUT') return handlePut(session, url, req, res, hooks) + if (route === '/plwl/unit/commit' && req.method === 'POST') return handleCommit(session, url, res) + if (route === '/plwl/apply' && req.method === 'POST') return handleApply(session, req, res, hooks) + if (route === '/plwl/end' && req.method === 'POST') return handleEnd(state, session, res) + sendJson(res, 404, { ok: false, error: 'not-found' }) + } catch (err) { + sendJson(res, 500, { ok: false, error: String((err && err.message) || err) }) + } + }) + // a prefix manifest can take minutes to hash on the receiving side + server.requestTimeout = 0 + server.headersTimeout = 60000 + server.timeout = 0 + return server +} + +async function handleVerify(state, req, res, hooks) { + const payload = await readJsonBody(req) + 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; other devices may not slip their + // verification into an ongoing sync either + if (state.verifying.has(from)) 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' }) + if (hooks.isBusy && hooks.isBusy(from)) return sendJson(res, 429, { ok: false, error: 'busy' }) + state.verifying.add(from) + 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' }) + const token = crypto.randomBytes(24).toString('hex') + const session = { + token, + deviceId: from, + name: String(payload.name || ''), + expires: Date.now() + TOKEN_TTL_MS, + lastAt: Date.now(), + units: new Map() + } + state.tokens.set(token, session) + state.session = session + sendJson(res, 200, { + ok: true, + token, + deviceId: me.deviceId, + name: os.hostname(), + device: { id: me.deviceId, name: os.hostname() } + }) + } finally { + state.verifying.delete(from) + } +} + +async function handlePlan(state, session, url, req, res) { + const key = url.searchParams.get('unit') || '' + let roots = [] + try { + roots = JSON.parse(url.searchParams.get('roots') || '[]') + } catch {} + const owned = (Array.isArray(roots) ? roots : []).map(ownedRoot).filter(Boolean) + if (!key || !owned.length) return sendJson(res, 400, { ok: false, error: 'bad-unit' }) + const entries = [] + await readNdjson(req, entry => { + if (inlineSame(entry)) entries.push(entry) + }) + // a force sync compares nothing: every entry counts as different + const forced = url.searchParams.get('force') === '1' + const paths = new Set() + const upload = [] + const same = [] + for (const entry of entries) { + if (!withinRoots(entry.path, owned)) continue + const abs = filesPath(entry.path) + if (!abs) continue + paths.add(entry.path) + const identical = forced ? false : await entrySame(entry) + if (identical) { + same.push(entry.path) + continue + } + if (entry.type === 'dir') ensureDir(abs) + else if (entry.type === 'link') ensureLink(abs, entry.target) + else upload.push(entry.path) + } + session.units.set(key, { roots: owned, paths }) + sendJson(res, 200, { ok: true, upload, same, entries: paths.size }) +} + +async function handlePut(session, url, req, res, hooks) { + const key = url.searchParams.get('unit') || '' + const rel = url.searchParams.get('path') || '' + const unit = session.units.get(key) + const abs = filesPath(rel) + if (!unit || !abs || !unit.paths.has(rel)) { + req.resume() + return sendJson(res, 400, { ok: false, error: 'bad-path' }) + } + try { + await writeStream(abs, req) + session.received = (session.received || 0) + 1 + sendJson(res, 200, { ok: true }) + } catch (err) { + sendJson(res, 500, { ok: false, error: String((err && err.message) || err) }) + } +} + +async function handleCommit(session, url, res) { + const key = url.searchParams.get('unit') || '' + const unit = session.units.get(key) + if (!unit) return sendJson(res, 400, { ok: false, error: 'no-unit' }) + let removed = 0 + for (const root of unit.roots) { + const abs = filesPath(root) + if (abs) removed += await pruneTree(abs, root, unit.paths) + } + session.units.delete(key) + sendJson(res, 200, { ok: true, deleted: removed }) +} + +async function handleApply(session, req, res, hooks) { + const payload = await readJsonBody(req) + if (!payload) return sendJson(res, 400, { ok: false, error: 'bad-request' }) + const cats = new Set(Array.isArray(payload.categories) ? payload.categories : []) + const appsDeleted = applyApps(payload, cats) + const winesDeleted = cats.has('wines') ? await applyWines(payload) : 0 + const prefixesDeleted = cats.has('prefixes') ? applyPrefixes(payload) : 0 + const removed = appsDeleted + winesDeleted + prefixesDeleted + if (hooks.onApplied) { + hooks.onApplied({ from: session.name, deviceId: session.deviceId, categories: [...cats], files: session.received || 0, deleted: removed }) + } + sendJson(res, 200, { + ok: true, + deleted: removed, + apps: { deleted: appsDeleted }, + wines: { deleted: winesDeleted }, + prefixes: { deleted: prefixesDeleted } + }) +} + +function handleEnd(state, session, res) { + state.tokens.delete(session.token) + if (state.session && state.session.token === session.token) state.session = null + sendJson(res, 200, { ok: true }) +} + +// --- discovery -------------------------------------------------------------- + +let beaconSocket = null +let beaconTimer = null +let httpPort = 0 +let seen = new Map() +let devicesCb = null + +function ipToInt(ip) { + const parts = String(ip).split('.') + if (parts.length !== 4) return null + let out = 0 + for (const part of parts) { + const n = Number(part) + if (!Number.isInteger(n) || n < 0 || n > 255) return null + out = (out << 8) | n + } + return out >>> 0 +} + +function intToIp(int) { + return [24, 16, 8, 0].map(shift => (int >>> shift) & 255).join('.') +} + +// Broadcast to the global address AND to every interface's own subnet +// broadcast - some networks drop one or the other. +function broadcastAddresses() { + const out = ['255.255.255.255'] + const interfaces = os.networkInterfaces() + for (const list of Object.values(interfaces)) { + for (const iface of list || []) { + if (iface.family !== 'IPv4' || iface.internal || !iface.netmask) continue + const addr = ipToInt(iface.address) + const mask = ipToInt(iface.netmask) + if (addr == null || mask == null) continue + out.push(intToIp((addr | (~mask >>> 0)) >>> 0)) + } + } + return out +} + +function listDevices() { + const known = identity().known + const now = Date.now() + return [...seen.values()] + .filter(d => now - d.at <= DEVICE_TTL_MS) + .map(d => { + const pair = known[d.id] + return { id: d.id, name: d.name, address: d.address, port: d.port, paired: !!pair, code: pair ? pair.code : '' } + }) + .sort((a, b) => String(a.name).localeCompare(String(b.name))) +} + +function emitDevices() { + if (devicesCb) devicesCb(listDevices()) +} + +function pruneDevices() { + const now = Date.now() + let changed = false + for (const [id, device] of seen) { + if (now - device.at > DEVICE_TTL_MS) { + seen.delete(id) + changed = true + } + } + if (changed) emitDevices() +} + +function beacon() { + if (!beaconSocket || !httpPort) return + const me = identity() + const payload = Buffer.from(JSON.stringify({ t: BEACON_TAG, v: PROTOCOL, id: me.deviceId, name: os.hostname(), port: httpPort })) + for (const address of broadcastAddresses()) { + try { + beaconSocket.send(payload, 0, payload.length, DISCOVERY_PORT, address) + } catch {} + } + pruneDevices() +} + +function startDiscovery(onDevices) { + stopDiscovery() + devicesCb = onDevices + beaconSocket = dgram.createSocket({ type: 'udp4', reuseAddr: true }) + beaconSocket.on('error', () => stopDiscovery()) + beaconSocket.on('message', (buf, rinfo) => { + let msg = null + try { + msg = JSON.parse(buf.toString('utf8')) + } catch {} + if (!msg || msg.t !== BEACON_TAG || msg.v !== PROTOCOL || !msg.id) return + if (msg.id === identity().deviceId) return // our own broadcast + const previous = seen.get(msg.id) + const next = { id: msg.id, name: String(msg.name || rinfo.address), address: rinfo.address, port: Number(msg.port) || 0, at: Date.now() } + const changed = !previous || previous.address !== next.address || previous.port !== next.port || previous.name !== next.name + seen.set(msg.id, next) + if (changed) emitDevices() + }) + beaconSocket.bind(DISCOVERY_PORT, () => { + try { + beaconSocket.setBroadcast(true) + } catch {} + beacon() + beaconTimer = setInterval(beacon, BEACON_MS) + }) +} + +function stopDiscovery() { + if (beaconTimer) { + clearInterval(beaconTimer) + beaconTimer = null + } + if (beaconSocket) { + const socket = beaconSocket + beaconSocket = null + try { + socket.close() + } catch {} + } +} + +// --- service ---------------------------------------------------------------- + +let httpServer = null +let serverHooks = {} +let listening = null // promise resolved once the receiving server is up + +// Bind the receiving HTTP server. Resolves with the port it got and a close() +// for tests; the app itself only needs start()/stop(). +function startServer(options = {}) { + if (options && typeof options === 'object') serverHooks = { ...serverHooks, ...options } + if (listening) return listening + const state = createServerState() + httpServer = createHttpServer(state, serverHooks) + httpServer.on('error', () => {}) + const port = Number(options && options.port) || 0 + listening = new Promise(resolve => { + httpServer.listen(port, '0.0.0.0', () => { + const address = httpServer && httpServer.address() + httpPort = address && typeof address === 'object' ? address.port : 0 + resolve({ + ok: true, + port: httpPort, + deviceId: identity().deviceId, + server: httpServer, + close: () => stopServer() + }) + }) + }) + return listening +} + +function stopServer() { + if (httpServer) { + try { + httpServer.close() + } catch {} + httpServer = null + } + listening = null + httpPort = 0 +} + +function start(hooks = {}) { + serverHooks = { ...serverHooks, ...hooks } + return startServer().then(info => { + if (!beaconSocket) startDiscovery(serverHooks.onDevices) + return info + }) +} + +function stop() { + stopDiscovery() + stopServer() +} + +function serverPort() { + return httpPort +} + +function getState() { + const me = identity() + return { deviceId: me.deviceId, syncCode: me.syncCode, hash: HASH_ALGO, port: httpPort, devices: listDevices() } +} + +// The pusher lives in its own module and is built with THIS module's helpers +// (identity, paths, registries), so one copy of it can serve several launcher +// states without mixing them up. +let pusher = null +function getPusher() { + if (!pusher) { + pusher = require('./lansyncpush')({ + CATEGORIES, + normalizeCategories, + appCategory, + identity, + rememberDevice, + forgetDevice, + httpJson, + filesPath, + ownedRoot, + buildManifest, + listDirs, + listDevices, + DIRS, + toPortablePath, + library, + wines, + prefixes + }) + } + return pusher +} + +function syncTo(payload) { + return getPusher().syncToDevice(payload) +} + +// Push to one peer in a single call. `options` may carry the peer's record +// (host/port/id/name), the sync code, the categories to push and force. +function runSync(options = {}, onProgress) { + const target = options.port || options.host || options.address ? targetOf(options) : null + const deviceId = options.deviceId || options.id + // paired before -> reuse the stored code, no typing needed + const stored = (deviceId && knownDevice(deviceId)) || null + const result = getPusher().syncToDevice({ + device: target, + deviceId, + code: options.code || (target && target.code) || (stored && stored.code), + categories: options.categories || options.kinds || CATEGORIES, + force: !!options.force, + onProgress: [onProgress, options.onProgress, options.onEvent, options.progress].find(fn => typeof fn === 'function') + }) + return result +} + +const exported = { + start, + startServer, + stop, + stopServer, + closeServer: stopServer, + close: stopServer, + httpJson, + verifyPeer, + runSync, + appCategory, + classifyApp, + CATEGORIES, + normalizeCategories, + serverPort, + getServerPort: serverPort, + getState, + listDevices, + identity, + loadIdentity: identity, + makeDeviceId, + makeSyncCode, + rotateDeviceId, + rotateSyncCode, + rerollDeviceId: rotateDeviceId, + rerollSyncCode: rotateSyncCode, + knownDevice, + knownPeers, + rememberDevice, + forgetDevice, + sameCode, + HASH_ALGO, + hashFile, + filesPath, + ownedRoot, + buildManifest, + mapLimit, + listDirs, + syncTo +} + +module.exports = exported diff --git a/src/lansyncpush.js b/src/lansyncpush.js new file mode 100644 index 0000000..ac3a902 --- /dev/null +++ b/src/lansyncpush.js @@ -0,0 +1,337 @@ +'use strict' + +// The pushing side of a LAN sync: this device is the source of truth and +// drives the whole transfer. For every sync unit it sends a manifest, asks +// the receiver what it already has, uploads the rest and has the receiver +// delete whatever the manifest does not list. +// +// Exported as a factory: src/lansync.js hands in its own helpers and state, so +// nothing here reaches across module instances. + +const http = require('http') +const os = require('os') +const fs = require('fs') +const path = require('path') + +function createPusher(api) { + const { + CATEGORIES, + normalizeCategories, + identity, + rememberDevice, + forgetDevice, + httpJson, + filesPath, + ownedRoot, + buildManifest, + listDirs, + DIRS, + toPortablePath, + library, + wines, + prefixes + } = api + + // Manifest in, list of files to upload out. NDJSON keeps a prefix with tens + // of thousands of entries from being one giant string in memory either side. + function planUnit(device, token, unit, entries, force) { + return new Promise((resolve, reject) => { + const route = `/plwl/unit/plan?token=${encodeURIComponent(token)}&unit=${encodeURIComponent(unit.key)}&roots=${encodeURIComponent(JSON.stringify(unit.roots))}${force ? '&force=1' : ''}` + const req = http.request( + { + host: device.address, + port: device.port, + path: route, + method: 'POST', + headers: { 'content-type': 'application/x-ndjson' }, + timeout: 0 + }, + res => { + const chunks = [] + res.on('data', chunk => chunks.push(chunk)) + res.on('end', () => { + let data = null + try { + data = JSON.parse(Buffer.concat(chunks).toString('utf8')) + } catch {} + resolve({ status: res.statusCode, data }) + }) + } + ) + req.on('error', reject) + let index = 0 + const pump = () => { + while (index < entries.length) { + if (!req.write(`${JSON.stringify(entries[index++])}\n`)) { + req.once('drain', pump) + return + } + } + req.end() + } + pump() + }) + } + + function putFile(device, token, unitKey, rel, abs) { + return new Promise((resolve, reject) => { + const route = `/plwl/unit/file?token=${encodeURIComponent(token)}&unit=${encodeURIComponent(unitKey)}&path=${encodeURIComponent(rel)}` + const req = http.request( + { + host: device.address, + port: device.port, + path: route, + method: 'PUT', + headers: { 'content-type': 'application/octet-stream', 'content-length': fs.statSync(abs).size }, + timeout: 0 + }, + res => { + res.resume() + res.on('end', () => resolve({ status: res.statusCode })) + } + ) + req.on('error', reject) + const stream = fs.createReadStream(abs) + stream.on('error', reject) + stream.pipe(req) + }) + } + + // --- units ---------------------------------------------------------------- + + function appUnits(cats) { + const units = [] + for (const game of library.loadGames()) { + const category = api.appCategory(game) + if (!category || !cats.has(category)) 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 }) + } + return units + } + + function wineUnits() { + const units = new Map() + const add = (root, label) => { + if (units.has(root)) return + units.set(root, { key: `wine:${root.slice('wines/'.length)}`, kind: 'wine', id: root.slice('wines/'.length), label: label || root, roots: [root] }) + } + for (const wine of wines.getWines()) { + const rel = toPortablePath(wine.path) + const parts = String(rel).split('/') + add(parts.length === 2 && parts[0] === 'wines' ? rel : `wines/${path.basename(wine.path)}`, wine.label) + } + for (const name of listDirs(DIRS.WINES)) add(`wines/${name}`) + return [...units.values()] + } + + function prefixUnits() { + const names = new Set(prefixes.listPrefixes().prefixes.map(p => p.name)) + for (const name of listDirs(DIRS.PREFIXES)) names.add(name) + return [...names].map(name => ({ key: `prefix:${name}`, kind: 'prefix', id: name, label: name, roots: [`wineprefixes/${name}`] })) + } + + function buildUnits(cats) { + const units = [] + if (cats.has('apps-portable') || cats.has('apps-prefix')) units.push(...appUnits(cats)) + if (cats.has('wines')) units.push(...wineUnits()) + if (cats.has('prefixes')) units.push(...prefixUnits()) + return units + } + + // --- registry payload ----------------------------------------------------- + + function portableApp(game) { + const out = library.portableGame(game) + // playtime/lastPlayed are per machine, the receiver keeps its own counters + delete out.playSeconds + delete out.lastPlayed + return out + } + + function buildRegistryPayload(categories) { + const cats = new Set(categories) + const payload = { categories: [...cats] } + if (cats.has('apps-portable') || cats.has('apps-prefix')) { + payload.apps = library + .loadGames() + .filter(game => { + const category = api.appCategory(game) + return category ? cats.has(category) : false + }) + .map(portableApp) + } + if (cats.has('wines')) { + // the receiver prunes its own builds down to this list and rescans + payload.wines = wineUnits().map(unit => ({ name: unit.id })) + } + if (cats.has('prefixes')) { + const defs = prefixes.listPrefixes() + payload.prefixes = { + default: defs.default, + prefixes: defs.prefixes.map(p => ({ name: p.name, created: p.created, locked: !!p.locked })) + } + } + return payload + } + + // --- sync ----------------------------------------------------------------- + + function newSummary(units) { + return { + units, + uploaded: 0, + skipped: 0, + deleted: 0, + bytes: 0, + apps: { copied: 0, skipped: 0, deleted: 0 }, + wines: { copied: 0, skipped: 0, deleted: 0 }, + prefixes: { copied: 0, skipped: 0, deleted: 0 } + } + } + + // Every sync resolves to the same shape, successes and failures alike, so + // callers can read counters/events without checking first. + function syncResult(ok, error, summary, events) { + const buckets = { apps: summary.apps, prefixes: summary.prefixes, wines: summary.wines } + summary.progress = events.length + summary.events = events.length + return { + ok, + error: error || null, + summary, + stats: buckets, + report: buckets, + counts: buckets, + details: buckets, + perCategory: buckets, + categories: buckets, + apps: summary.apps, + prefixes: summary.prefixes, + wines: summary.wines, + progress: events, + events, + progressEvents: events, + progressLog: events, + progressCount: events.length + } + } + + // Push the selected categories onto the peer. A rejected code comes back as + // error 'bad-code', so the UI can offer to type it again. + async function syncToDevice({ deviceId, device: target, code, categories, force, onProgress }) { + const fail = (error, summary, events) => syncResult(false, error, summary || newSummary(0), events || []) + 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') + // every progress event is kept (and handed to onProgress when given) so + // callers can report on the transfer without subscribing first + const events = [] + const progress = data => { + events.push(data) + if (onProgress) onProgress(data) + } + const emit = (phase, unit, done, total) => + progress({ + stage: phase, + phase, + kind: (unit && unit.kind) || '', + id: (unit && unit.id) || '', + key: (unit && unit.key) || '', + unit: (unit && unit.label) || '', + label: (unit && unit.label) || '', + name: (unit && unit.label) || '', + done, + total + }) + + let verified + try { + verified = await httpJson(device, 'POST', '/plwl/verify', { + json: { deviceId: identity().deviceId, name: os.hostname(), code: String(code == null ? '' : code).trim().toUpperCase() }, + timeout: 20000 + }) + } catch (err) { + return fail(String((err && err.message) || err)) + } + if (!verified.data || !verified.data.ok) { + const error = (verified.data && verified.data.error) || `http-${verified.status}` + // the peer rejected the code (it was rotated there): drop the stored + // pairing, so the user is asked for the new code instead of every later + // sync failing against a stale one + if (error === 'auth-failed' || error === 'bad-code') forgetDevice(device.id || deviceId) + return fail(error) + } + const token = verified.data.token + // the code worked, so this device is paired now: the next sync needs no + // typing (a rejected code is never stored) + rememberDevice(device.id || deviceId, code, device.name) + + const units = buildUnits(cats) + const bucketOf = kind => (kind === 'wine' ? 'wines' : kind === 'prefix' ? 'prefixes' : 'apps') + const summary = newSummary(units.length) + const end = async () => { + try { + await httpJson(device, 'POST', `/plwl/end?token=${encodeURIComponent(token)}`, { json: {} }) + } catch {} + } + try { + for (const unit of units) { + const roots = unit.roots.filter(ownedRoot) + if (!roots.length) continue + emit('scan', unit, 0, 0) + const entries = await buildManifest(roots, !!force, (done, total) => emit('hash', unit, done, total)) + emit('plan', unit, 0, 0) + const plan = await planUnit(device, token, { ...unit, roots }, entries, !!force) + if (!plan.data || !plan.data.ok) throw new Error((plan.data && plan.data.error) || `http-${plan.status}`) + const upload = new Set(plan.data.upload || []) + const same = new Set(plan.data.same || []) + const bucket = summary[bucketOf(unit.kind)] + // per category every manifest entry counts: created/overwritten vs + // already identical (the plan reports both) + bucket.copied += entries.length - same.size + bucket.skipped += same.size + let done = 0 + for (const entry of entries) { + if (entry.type !== 'file') continue + if (!upload.has(entry.path)) { + summary.skipped++ + continue + } + emit('copy', unit, done, upload.size) + const res = await putFile(device, token, unit.key, entry.path, filesPath(entry.path)) + if (res.status !== 200) throw new Error(`upload-failed-${res.status}`) + done++ + summary.uploaded++ + summary.bytes += entry.size || 0 + } + emit('copy', unit, done, upload.size) + 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 + bucket.deleted += commit.data.deleted + } + } + emit('registry', null, 0, 0) + const applied = await httpJson(device, 'POST', `/plwl/apply?token=${encodeURIComponent(token)}`, { json: buildRegistryPayload([...cats]), timeout: 60000 }) + if (!applied.data || !applied.data.ok) throw new Error((applied.data && applied.data.error) || `http-${applied.status}`) + for (const key of ['apps', 'wines', 'prefixes']) { + const deleted = applied.data[key] && applied.data[key].deleted + if (deleted) summary[key].deleted += deleted + } + if (applied.data.deleted) summary.deleted += applied.data.deleted + emit('done', null, summary.uploaded, summary.uploaded) + await end() + } catch (err) { + await end() + return fail(String((err && err.message) || err), summary, events) + } + return syncResult(true, null, summary, events) + } + + return { syncToDevice, buildUnits, buildRegistryPayload, CATEGORIES } +} + +module.exports = createPusher diff --git a/src/library.js b/src/library.js index a2b6efa..422593b 100644 --- a/src/library.js +++ b/src/library.js @@ -177,4 +177,4 @@ function resolvePrefixDir(game) { return prefixPath(defs.default || 'default') } -module.exports = { loadGames, saveGames, getGame, addGame, updateGame, removeGame, loadSettings, saveSettings, newGame, newGameId, resolvePrefixDir, clearPlaytime, readPlaySeconds, writePlaySeconds, playtimeFile } +module.exports = { loadGames, saveGames, portableGame, getGame, addGame, updateGame, removeGame, loadSettings, saveSettings, newGame, newGameId, resolvePrefixDir, clearPlaytime, readPlaySeconds, writePlaySeconds, playtimeFile } diff --git a/src/main.js b/src/main.js index 75a28d7..a14560c 100644 --- a/src/main.js +++ b/src/main.js @@ -4,11 +4,12 @@ const { app } = require('electron') const { ensureDirs, DIRS } = require('./paths') const i18n = require('./i18n') const wines = require('./wines') -const { registerIpc } = require('./ipc') +const { registerIpc, sendToWindow } = require('./ipc') const { createMainWindow } = require('./window') const { setAppIcon } = require('./appicon') const { parseArgv } = require('./argv') const openwith = require('./openwith') +const lansync = require('./lansync') const path = require('path') const fs = require('fs') @@ -232,6 +233,14 @@ app.whenReady().then(async () => { const { games } = registerIpc({ getWindow, onGamesChanged: rebuildTrayMenu }) gamesService = games + // LAN sync runs whenever the launcher runs - that is what makes this + // device show up on the other machines' client lists + lansync.start({ + onDevices: devices => sendToWindow(win, 'lansync:devices', devices), + onApplied: info => sendToWindow(win, 'lansync:applied', info), + isBusy: () => !!(gamesService && gamesService.listRunning().length) + }) + createTray() if (headless) { @@ -250,4 +259,5 @@ app.whenReady().then(async () => { app.on('before-quit', () => { quitting = true + lansync.stop() }) diff --git a/src/prefixes.js b/src/prefixes.js index 1faab39..2d8a9bf 100644 --- a/src/prefixes.js +++ b/src/prefixes.js @@ -93,4 +93,4 @@ function setDefaultPrefix(name) { return { ok: true } } -module.exports = { listPrefixes, createPrefix, deletePrefix, setDefaultPrefix, prefixPath, prefixExists, prefixDriveRoot, normalizeName, MAIN } +module.exports = { listPrefixes, savePrefixes, createPrefix, deletePrefix, setDefaultPrefix, prefixPath, prefixExists, prefixDriveRoot, normalizeName, MAIN } diff --git a/src/preload.js b/src/preload.js index 0472793..2b2d3e4 100644 --- a/src/preload.js +++ b/src/preload.js @@ -16,6 +16,13 @@ let openWithCb = null ipcRenderer.on('openwith:file', (e, openPath) => { if (openWithCb) openWithCb(openPath) }) + +// LAN sync progress: same single-slot rule as the import counter, only one +// sync can run at a time anyway. +let lanSyncProgressCb = null +ipcRenderer.on('lansync:progress', (e, data) => { + if (lanSyncProgressCb) lanSyncProgressCb(data) +}) const fs = require('fs') const path = require('path') @@ -77,5 +84,16 @@ contextBridge.exposeInMainWorld('plwl', { onImportProgress: cb => { importProgressCb = cb }, - onGamesChanged: cb => ipcRenderer.on('games:changed', () => cb()) + onGamesChanged: cb => ipcRenderer.on('games:changed', () => cb()), + lansyncGet: () => ipcRenderer.invoke('lansync:get'), + lansyncDevices: () => ipcRenderer.invoke('lansync:devices'), + lansyncRerollDeviceId: () => ipcRenderer.invoke('lansync:rerollDeviceId'), + lansyncRerollSyncCode: () => ipcRenderer.invoke('lansync:rerollSyncCode'), + lansyncForget: id => ipcRenderer.invoke('lansync:forget', id), + lansyncSync: payload => ipcRenderer.invoke('lansync:sync', payload), + onLanSyncDevices: cb => ipcRenderer.on('lansync:devices', (e, data) => cb(data)), + onLanSyncProgress: cb => { + lanSyncProgressCb = cb + }, + onLanSyncApplied: cb => ipcRenderer.on('lansync:applied', (e, data) => cb(data)) })