[TEST] add data sync
This commit is contained in:
parent
944d8f86c2
commit
8e3512b5cd
16 changed files with 1935 additions and 5 deletions
12
README.md
12
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).
|
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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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).",
|
||||||
|
|
|
||||||
|
|
@ -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).",
|
||||||
|
|
|
||||||
206
renderer/app.js
206
renderer/app.js
|
|
@ -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')
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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
37
src/ipc/lansync.js
Normal 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
1166
src/lansync.js
Normal file
File diff suppressed because it is too large
Load diff
337
src/lansyncpush.js
Normal file
337
src/lansyncpush.js
Normal 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
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
12
src/main.js
12
src/main.js
|
|
@ -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()
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue