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 ee0c558..cfab4e6 100644 Binary files a/renderer/plwl.png and b/renderer/plwl.png differ diff --git a/renderer/style.css b/renderer/style.css index ed7e8f5..bfe739a 100644 --- a/renderer/style.css +++ b/renderer/style.css @@ -445,6 +445,29 @@ select { color: var(--darker-text-color); margin-top: 0.9rem; } +/* LAN syncing: generated id/code, shown as read-only machine values */ +.code-row { display: flex; gap: 0.6rem; align-items: center; margin: var(--button-margin) 0; } +.code-row code { + flex: 1; + min-width: 0; + height: var(--button-height); + display: flex; + align-items: center; + padding: 0 0.8rem; + border-radius: 0.8rem; + background-color: rgba(255, 255, 255, 0.05); + border: var(--border-width) solid var(--light-border-color); + font-family: ui-monospace, 'DejaVu Sans Mono', monospace; + letter-spacing: 0.08em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + user-select: text; +} +.code-row #ls-sync-code { font-size: 1.25rem; font-weight: 700; letter-spacing: 0.3em; } +.check-group { display: flex; flex-direction: column; } +.check-group .check-label { margin-top: 0.2rem; margin-bottom: 0.2rem; } + .path-row { display: flex; gap: 0.6rem; align-items: center; } .path-row input { flex: 1; min-width: 0; } .path-row .custom-select { flex: 1; margin: var(--button-margin) 0; } diff --git a/src/ipc/index.js b/src/ipc/index.js index ab720c8..cedccea 100644 --- a/src/ipc/index.js +++ b/src/ipc/index.js @@ -13,6 +13,7 @@ const { extractIconFromExe } = require('../icons') const { createGamesService, sendToWindow } = require('./games') const shortcuts = require('../shortcuts') const openwith = require('../openwith') +const { registerLanSyncIpc } = require('./lansync') const ICON_EXTS = ['png', 'jpe?g', 'svg', 'ico', 'webp', 'gif', 'bmp'] @@ -229,6 +230,9 @@ function registerIpc({ getWindow, onGamesChanged }) { } }) + // lan syncing --------------------------------------------------------------------------------- + registerLanSyncIpc({ getWindow }) + // dialogs & shell --------------------------------------------------------------------------- ipcMain.handle('dialog:pickFile', async (e, opts) => { 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)) })