[TEST] add data sync

This commit is contained in:
olcxja 2026-10-01 14:18:15 +02:00
commit 8e3512b5cd
16 changed files with 1935 additions and 5 deletions

View file

@ -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). 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 ## Wine & Proton support
- **System Wine** — uses the `wine` binary from PATH. - **System Wine** — uses the `wine` binary from PATH.

View file

@ -12,6 +12,7 @@ src/ main process (Node/Electron backend)
ipc/ all ipcMain handlers ipc/ all ipcMain handlers
index.js handler registration, icon protocol, dialogs index.js handler registration, icon protocol, dialogs
games.js games service: run/stop state, wine resolution, open-with handoff 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) runner.js spawns wine/proton (env, prefix, playtime, logs)
library.js apps registry (apps.json) + settings + playtime files library.js apps registry (apps.json) + settings + playtime files
wines.js wine/proton build scan, registry, portable symlink healing 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 paths.js portable layout, FILES-relative path helpers, translation sync
i18n.js translation loading + {0} placeholder filling i18n.js translation loading + {0} placeholder filling
preload.js contextBridge API exposed to the renderer 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 appicon.js window/taskbar icon
renderer/ UI (plain HTML/CSS/JS, no framework) renderer/ UI (plain HTML/CSS/JS, no framework)
app.js views, modals, custom-select, all renderer logic 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, - CSS: reuse the existing variables/classes (.button, .field-label,
.custom-select, form-card); spacing rhythm comes from --button-margin and .custom-select, form-card); spacing rhythm comes from --button-margin and
the flex gap of the container, not per-element magic numbers. 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 - 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. 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 <changed js files> # syntax node --check <changed js files> # syntax
node test/import.test.js # ...and the other test files 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 npm start # click through the flow you touched
``` ```

View file

@ -10,6 +10,7 @@
"nav.prefixes": "Wine prefixes", "nav.prefixes": "Wine prefixes",
"nav.wines": "Wine versions", "nav.wines": "Wine versions",
"nav.settings": "Settings", "nav.settings": "Settings",
"nav.lansync": "LAN syncing",
"library.title": "Apps Library", "library.title": "Apps Library",
"library.empty": "No apps yet", "library.empty": "No apps yet",
"library.emptyHint": "Import an app to get started. You have a lot import options to choose from!", "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.saved": "Settings saved",
"settings.openFiles": "Open files folder", "settings.openFiles": "Open files folder",
"settings.closeToTray": "Close window to tray (keep tracking playtime in background)", "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.title": "Open with Wine",
"openwith.file": "File", "openwith.file": "File",
"openwith.fileMissing": "This file no longer exists (or is not a Windows executable).", "openwith.fileMissing": "This file no longer exists (or is not a Windows executable).",

View file

@ -10,6 +10,7 @@
"nav.prefixes": "Prefiksy Wine", "nav.prefixes": "Prefiksy Wine",
"nav.wines": "Wersje Wine", "nav.wines": "Wersje Wine",
"nav.settings": "Ustawienia", "nav.settings": "Ustawienia",
"nav.lansync": "Synchronizacja LAN",
"library.title": "Biblioteka aplikacji", "library.title": "Biblioteka aplikacji",
"library.empty": "Brak aplikacji", "library.empty": "Brak aplikacji",
"library.emptyHint": "Zaimportuj aplikację aby zacząć. Do wyboru masz mnóstwo opcji importu!", "library.emptyHint": "Zaimportuj aplikację aby zacząć. Do wyboru masz mnóstwo opcji importu!",
@ -144,6 +145,45 @@
"settings.saved": "Ustawienia zapisane", "settings.saved": "Ustawienia zapisane",
"settings.openFiles": "Otwórz folder files", "settings.openFiles": "Otwórz folder files",
"settings.closeToTray": "Zamykaj okno do zasobnika systemowego (licz czas gry w tle)", "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.title": "Otwórz przez Wine",
"openwith.file": "Plik", "openwith.file": "Plik",
"openwith.fileMissing": "Ten plik już nie istnieje (albo nie jest plikiem wykonywalnym Windows).", "openwith.fileMissing": "Ten plik już nie istnieje (albo nie jest plikiem wykonywalnym Windows).",

View file

@ -14,6 +14,7 @@ const state = {
prefixes: { default: '', prefixes: [] }, prefixes: { default: '', prefixes: [] },
games: [], games: [],
currentGame: null, currentGame: null,
lansync: null,
currentView: 'library' currentView: 'library'
} }
@ -33,6 +34,8 @@ const ICONS = {
// wine glass - wine/proton runners // wine glass - wine/proton runners
wines: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 22h8"/><path d="M12 15v7"/><path d="M7 10h10"/><path d="M12 15a5 5 0 0 0 5-5c0-2-.5-4-2-8H9c-1.5 4-2 6-2 8a5 5 0 0 0 5 5Z"/></svg>', wines: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 22h8"/><path d="M12 15v7"/><path d="M7 10h10"/><path d="M12 15a5 5 0 0 0 5-5c0-2-.5-4-2-8H9c-1.5 4-2 6-2 8a5 5 0 0 0 5 5Z"/></svg>',
settings: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19 12a7 7 0 0 0-.1-1.2l2-1.5-2-3.5-2.4 1a7 7 0 0 0-2-1.2L14 3h-4l-.5 2.6a7 7 0 0 0-2 1.2l-2.4-1-2 3.5 2 1.5a7 7 0 0 0 0 2.4l-2 1.5 2 3.5 2.4-1a7 7 0 0 0 2 1.2L10 21h4l.5-2.6a7 7 0 0 0 2-1.2l2.4 1 2-3.5-2-1.5c.06-.4.1-.8.1-1.2z"/></svg>', settings: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19 12a7 7 0 0 0-.1-1.2l2-1.5-2-3.5-2.4 1a7 7 0 0 0-2-1.2L14 3h-4l-.5 2.6a7 7 0 0 0-2 1.2l-2.4-1-2 3.5 2 1.5a7 7 0 0 0 0 2.4l-2 1.5 2 3.5 2.4-1a7 7 0 0 0 2 1.2L10 21h4l.5-2.6a7 7 0 0 0 2-1.2l2.4 1 2-3.5-2-1.5c.06-.4.1-.8.1-1.2z"/></svg>',
// nodes connected to each other - devices on the local network
lansync: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="4.5" r="2.5"/><circle cx="5" cy="19" r="2.5"/><circle cx="19" cy="19" r="2.5"/><path d="M10.6 6.8 6.5 16.7M13.4 6.8l4.1 9.9M7.5 19h9"/></svg>',
// arrow into a tray - import // arrow into a tray - import
config: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6"/></svg>', config: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6"/></svg>',
folder: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>', folder: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>',
@ -62,7 +65,8 @@ function setButtonIcons() {
'nav-library': ICONS.library(), 'nav-library': ICONS.library(),
'nav-prefixes': ICONS.prefixes(), 'nav-prefixes': ICONS.prefixes(),
'nav-wines': ICONS.wines(), 'nav-wines': ICONS.wines(),
'nav-settings': ICONS.settings() 'nav-settings': ICONS.settings(),
'nav-lansync': ICONS.lansync()
} }
for (const [id, svg] of Object.entries(map)) { for (const [id, svg] of Object.entries(map)) {
const el = document.getElementById(id) const el = document.getElementById(id)
@ -322,6 +326,7 @@ function showView(name) {
if (name === 'prefixes') renderPrefixes() if (name === 'prefixes') renderPrefixes()
if (name === 'wines') renderWines() if (name === 'wines') renderWines()
if (name === 'settings') renderSettings() if (name === 'settings') renderSettings()
if (name === 'lansync') renderLanSync()
} }
async function loadAll() { async function loadAll() {
@ -1123,6 +1128,204 @@ async function runCustomTool() {
else toast(t('toast.startFailed', tool, res.error || ''), 'error') 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 = `
<div class="grow">
<div class="title"></div>
<div class="sub"></div>
</div>
${device.paired ? `<span class="badge primary">${t('lansync.paired')}</span>` : ''}
<div class="actions">
<button class="button small primary" data-act="sync"></button>
</div>
`
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(`
<h2 id="ls-modal-title"></h2>
<label class="field-label" id="ls-code-label"></label>
<input type="text" id="ls-code" maxlength="8" autocomplete="off" spellcheck="false">
<p class="subtitle" id="ls-code-error" style="display:none"></p>
<label class="field-label" id="ls-what-label"></label>
<div class="check-group" id="ls-categories"></div>
<label class="check-label">
<input type="checkbox" id="ls-force">
<span id="ls-force-label"></span>
</label>
<p class="subtitle" id="ls-warning"></p>
<div class="modal-actions">
<button class="button" id="ls-cancel"></button>
<button class="button primary" id="ls-start"></button>
</div>
`)
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 = '<input type="checkbox" checked><span></span>'
label.querySelector('input').id = 'ls-cat-' + category.id
label.querySelector('span').textContent = t(category.label)
box.appendChild(label)
}
modal.querySelector('#ls-force-label').textContent = t('lansync.force')
modal.querySelector('#ls-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(`
<h2 id="ls-run-title"></h2>
<div class="progress-row"><progress></progress></div>
<p class="subtitle" id="ls-run-phase"></p>
<p class="subtitle" id="ls-run-count"></p>
`)
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() { function wireEvents() {
qsa('[data-nav]').forEach(btn => { qsa('[data-nav]').forEach(btn => {
btn.addEventListener('click', () => showView(btn.dataset.nav)) btn.addEventListener('click', () => showView(btn.dataset.nav))
@ -1233,6 +1436,7 @@ async function boot() {
wireEvents() wireEvents()
wireRealtime() wireRealtime()
wireOpenWith() wireOpenWith()
wireLanSync()
qs('#loading').remove() qs('#loading').remove()
qs('#main').style.display = 'flex' qs('#main').style.display = 'flex'
showView('library') showView('library')

View file

@ -29,6 +29,10 @@
<indicator></indicator> <indicator></indicator>
<button class="icon-button" data-nav="settings"></button> <button class="icon-button" data-nav="settings"></button>
</sidebarelement> </sidebarelement>
<sidebarelement id="nav-lansync">
<indicator></indicator>
<button class="icon-button" data-nav="lansync"></button>
</sidebarelement>
</div> </div>
</sidebar> </sidebar>
@ -101,6 +105,34 @@
</div> </div>
</section> </section>
<section id="view-lansync" class="view">
<div class="view-header">
<div>
<h1 data-t="lansync.title"></h1>
<p class="subtitle" data-t="lansync.subtitle"></p>
</div>
</div>
<div class="form-card">
<h2 data-t="lansync.thisDevice"></h2>
<label class="field-label" data-t="lansync.deviceId"></label>
<div class="code-row">
<code id="ls-device-id"></code>
<button class="button small" id="ls-reroll-id" data-t="lansync.reroll"></button>
</div>
<label class="field-label" data-t="lansync.syncCode"></label>
<div class="code-row">
<code id="ls-sync-code"></code>
<button class="button small" id="ls-reroll-code" data-t="lansync.reroll"></button>
</div>
<p class="subtitle" data-t="lansync.codeHint"></p>
</div>
<div class="form-card">
<h2 data-t="lansync.devices"></h2>
<p class="subtitle" data-t="lansync.devicesHint"></p>
<div id="ls-device-list" class="list"></div>
</div>
</section>
<section id="view-gameconfig" class="view"> <section id="view-gameconfig" class="view">
<div class="view-header"> <div class="view-header">
<div> <div>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

Before After
Before After

View file

@ -445,6 +445,29 @@ select {
color: var(--darker-text-color); color: var(--darker-text-color);
margin-top: 0.9rem; 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 { display: flex; gap: 0.6rem; align-items: center; }
.path-row input { flex: 1; min-width: 0; } .path-row input { flex: 1; min-width: 0; }
.path-row .custom-select { flex: 1; margin: var(--button-margin) 0; } .path-row .custom-select { flex: 1; margin: var(--button-margin) 0; }

View file

@ -13,6 +13,7 @@ const { extractIconFromExe } = require('../icons')
const { createGamesService, sendToWindow } = require('./games') const { createGamesService, sendToWindow } = require('./games')
const shortcuts = require('../shortcuts') const shortcuts = require('../shortcuts')
const openwith = require('../openwith') const openwith = require('../openwith')
const { registerLanSyncIpc } = require('./lansync')
const ICON_EXTS = ['png', 'jpe?g', 'svg', 'ico', 'webp', 'gif', 'bmp'] 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 --------------------------------------------------------------------------- // dialogs & shell ---------------------------------------------------------------------------
ipcMain.handle('dialog:pickFile', async (e, opts) => { ipcMain.handle('dialog:pickFile', async (e, opts) => {
const o = { ...(opts || {}) } const o = { ...(opts || {}) }

37
src/ipc/lansync.js Normal file
View file

@ -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 }

1166
src/lansync.js Normal file

File diff suppressed because it is too large Load diff

337
src/lansyncpush.js Normal file
View file

@ -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

View file

@ -177,4 +177,4 @@ function resolvePrefixDir(game) {
return prefixPath(defs.default || 'default') 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 }

View file

@ -4,11 +4,12 @@ const { app } = require('electron')
const { ensureDirs, DIRS } = require('./paths') const { ensureDirs, DIRS } = require('./paths')
const i18n = require('./i18n') const i18n = require('./i18n')
const wines = require('./wines') const wines = require('./wines')
const { registerIpc } = require('./ipc') const { registerIpc, sendToWindow } = require('./ipc')
const { createMainWindow } = require('./window') const { createMainWindow } = require('./window')
const { setAppIcon } = require('./appicon') const { setAppIcon } = require('./appicon')
const { parseArgv } = require('./argv') const { parseArgv } = require('./argv')
const openwith = require('./openwith') const openwith = require('./openwith')
const lansync = require('./lansync')
const path = require('path') const path = require('path')
const fs = require('fs') const fs = require('fs')
@ -232,6 +233,14 @@ app.whenReady().then(async () => {
const { games } = registerIpc({ getWindow, onGamesChanged: rebuildTrayMenu }) const { games } = registerIpc({ getWindow, onGamesChanged: rebuildTrayMenu })
gamesService = games 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() createTray()
if (headless) { if (headless) {
@ -250,4 +259,5 @@ app.whenReady().then(async () => {
app.on('before-quit', () => { app.on('before-quit', () => {
quitting = true quitting = true
lansync.stop()
}) })

View file

@ -93,4 +93,4 @@ function setDefaultPrefix(name) {
return { ok: true } 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 }

View file

@ -16,6 +16,13 @@ let openWithCb = null
ipcRenderer.on('openwith:file', (e, openPath) => { ipcRenderer.on('openwith:file', (e, openPath) => {
if (openWithCb) openWithCb(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 fs = require('fs')
const path = require('path') const path = require('path')
@ -77,5 +84,16 @@ contextBridge.exposeInMainWorld('plwl', {
onImportProgress: cb => { onImportProgress: cb => {
importProgressCb = 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))
}) })