improvemets & fixes:
- added 'open with' handling to desktop shortcuts - move logs button to app configuration - fix sync progress disappearing after long hashing - fix sync double file counter - merge wine and proton prefix folders via symlink
This commit is contained in:
parent
8c6db38fab
commit
59ff0b1088
15 changed files with 520 additions and 113 deletions
|
|
@ -47,6 +47,8 @@ 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 app shortcuts created in an app's configuration work as **Open With** targets too: pick one in the file manager (right-click a photo → Open With → your Photoshop shortcut) and that app starts with the file, receiving it as the Windows path it would get on Windows — `C:\...` when the file sits inside its prefix, `Z:\...` for the rest of the machine. Giving a file to an app that is already running hands it to that instance instead of starting a second tracked run.
|
||||
|
||||
## 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):
|
||||
|
|
@ -55,15 +57,15 @@ Two PLWL installs on the same LAN can mirror each other's content - no cloud, no
|
|||
- **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. A correct code is still not enough: the receiving device shows an **Accept / Decline popup** before anything moves, and without a click within a minute the sender is turned down. Declining (or ignoring) the popup never touches this machine and does not unpair the two devices.
|
||||
- **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**, **prefixes** and **Default settings**. Only what is ticked travels - a sync without **Default settings** never touches the other machine's defaults. 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.
|
||||
Pick a device and choose what to push: **portable apps**, **apps imported from a prefix**, **Wine/Proton builds**, **prefixes** and **Sync settings**. Only what is ticked travels - a sync without **Sync settings** never touches the other machine's defaults, names or app configurations. 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.
|
||||
|
||||
**Both machines show what is happening.** The sender gets a progress popup and the receiving device gets the same popup titled *Receiving from X…*, driven by the transfer as it arrives. Both show the current phase and unit, the files being moved, a sent/total counter per category (`Apps: 2/5 · Prefixes: 1/2`) and the transferred vs. planned size. The bar follows **bytes, not file counts**, and it belongs to the phase being worked on: hashing fills its own bar and the transfer starts again from empty, so nothing ever looks finished while it has just begun (the byte line next to it keeps the overall transferred vs. planned total). Both popups warn that the launcher must stay open, and a running sync is not cancellable from the outside: clicking the backdrop or pressing Escape does nothing. If the other device goes quiet for a few seconds (it hashes before it sends) the popup says *Waiting for the other device…* rather than closing; it closes itself when the transfer ends, or after fifteen minutes of complete silence when the other side has vanished.
|
||||
**Both machines show what is happening.** The sender gets a progress popup and the receiving device gets the same popup titled *Receiving from X…*, driven by the transfer as it arrives. Both show the current phase and unit, the files being moved, a sent/total counter per category (`Apps: 2/5 · Prefixes: 1/2`) and the transferred vs. planned size. The bar follows **bytes, not file counts**, and it belongs to the phase being worked on: hashing fills its own bar and the transfer starts again from empty, so nothing ever looks finished while it has just begun (the byte line next to it keeps the overall transferred vs. planned total). Both popups warn that the launcher must stay open, and a running sync is not cancellable from the outside: clicking the backdrop or pressing Escape does nothing. If the other device goes quiet for a few seconds (it hashes before it sends) the popup says *Waiting for the other device…* rather than closing, and the sending side keeps signalling while it works, so even a long hashing phase never looks like a dead peer. The popup closes itself when the transfer ends, or after fifteen minutes of complete silence when the other side has really vanished — and comes back if the sync turns out to be alive after all.
|
||||
|
||||
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. Hashes are remembered per file (size + modification time), so a repeat sync of a library that did not change reads nothing at all.
|
||||
|
||||
**Wine builds and prefixes survive the trip.** Permissions travel with a sync, so the `proton`, `wine` and `wineserver` binaries are still executable on the other machine (a build whose executable bit was lost is additionally repaired at startup, without re-importing it), and symlinks are sent as RELATIVE paths whenever the file they point at belongs to the launcher: a Proton prefix is full of links into its build, and the receiving side gets links that resolve to its own copy instead of the sender's `/home/...` folders. Links that leave the launcher on purpose (`dosdevices` drive mappings, a mounted drive, the user's home folder) stay absolute, and links into a folder that does not exist yet on the other machine are still written relative, so they start working the moment that folder appears. Wine and Proton rewrite a prefix's links with local absolute targets while they update it; PLWL relativizes them again on the wire, on import and before every run.
|
||||
|
||||
A sync also carries **how the apps are set up**: the Wine/Proton build and prefix each app runs in, its arguments, environment variables and DLL overrides, plus playtime and last played. The launcher's **default Wine build and its default prefix** travel with the **Default settings** category, and only if that build or prefix exists on the other machine - an id it does not have is ignored instead of leaving a dangling default behind. Playtime only ever grows - the higher counter wins - so pushing from a freshly installed machine cannot wipe a library's play history. Everything else stays local: language and window settings, logs, 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.
|
||||
A sync also carries **how the apps are set up**: the Wine/Proton build and prefix each app runs in, its arguments, environment variables and DLL overrides, plus playtime and last played. The **Sync settings** category carries the registry-level part of that even when no files move: the launcher's **default Wine build and default prefix**, the custom **names** you gave Wine/Proton builds (and their disabled flags), the **prefix registry**, every **app's configuration**, and the **order of the library**. It only patches what already exists on the other machine - an unknown Wine id or prefix is ignored instead of leaving a dangling default or a broken app behind, apps and prefixes the sender does not know stay where they are, and a settings-only sync deletes nothing (that is what the file categories are for). Playtime only ever grows - the higher counter wins - so pushing from a freshly installed machine cannot wipe a library's play history. Everything else stays local: language and window settings, logs, 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
|
||||
|
||||
|
|
|
|||
|
|
@ -102,9 +102,20 @@ handlers in src/ipc/. Keep new APIs in that pattern.
|
|||
looks finished the moment it starts. Hashing and copying walk tens of
|
||||
thousands of files, so every per-file update goes through progressGate
|
||||
(~10/s, phase changes forced) - never send one IPC message per file.
|
||||
- The launcher's own defaults are opt-in (the "defaults" sync category):
|
||||
payload.defaults carries the default wine and prefix, and the receiver only
|
||||
applies values that exist there (an unknown wine id or prefix is skipped).
|
||||
- Registry-level settings are opt-in (the "settings" sync category, shown as
|
||||
"Sync settings"; the old name "defaults" is still accepted as an alias on
|
||||
BOTH sides - the receiver normalizes payload.categories, so a sender from an
|
||||
older version keeps working). payload carries defaults (default wine and
|
||||
prefix), wineSettings (displayName + disabled per build id), prefixSettings
|
||||
(the registry names; a settings sync never renames or creates folders -
|
||||
that is the prefixes category), appSettings (name, wine, prefix, args, env,
|
||||
dllOverrides, disableUmu per app id) and appOrder. The receiver only patches
|
||||
things that already exist there: an unknown wine id / prefix / app is
|
||||
skipped, nothing is created or deleted, and the order merge keeps local
|
||||
entries the sender does not know at the end. Values are written through the
|
||||
normal helpers (setWineDisplayName clears a custom label when the sender has
|
||||
none, library.updateGame pins the prefix of prefix imports, reorderGames
|
||||
merges by id).
|
||||
- LAN sync remembers file hashes in files/data/lansync-hashes.json, keyed by
|
||||
path with size + mtime (machine-local, never synced): equal size and mtime
|
||||
means equal bytes, so a repeat sync only stat's the tree. Hashing and the
|
||||
|
|
@ -160,6 +171,33 @@ handlers in src/ipc/. Keep new APIs in that pattern.
|
|||
- File permissions travel with a sync (manifest entry `mode`): the receiving
|
||||
side applies them after writing the bytes and repairs a file that has the
|
||||
same hash but the wrong mode without re-uploading it.
|
||||
- A sync must never walk through a symlinked ROOT. pruneTree/healUnitRoots
|
||||
(sync), removeTree/treeSize (fsutil) and healBuild (wines) lstat their root
|
||||
first and treat anything that is not a real directory as the link (or file)
|
||||
it is: a build, prefix or app folder kept on another disk must not have its
|
||||
target deleted, pruned or rewritten through the link. `listDirs()` in the
|
||||
sync counts symlinked folders as units, so such a folder still travels - as
|
||||
the link it is (`wineUnits` maps nested builds to their TOP folder under
|
||||
wines/, otherwise a zip-extracted build becomes a phantom unit whose root
|
||||
does not exist and the "Wine versions" totals grow).
|
||||
- Generated app shortcuts are file handlers too: `Exec=<launcher>
|
||||
--launch <id> --file %f` (the field code is appended after escaping - a
|
||||
literal % is `%%`). argv.js takes the file from `--file` or from a bare
|
||||
trailing path (KDE appends it to Exec lines without a file field), and
|
||||
runner.windowsPathFor passes it to the app the way Windows would: `C:\...`
|
||||
when it sits inside the prefix's drive_c, `Z:\...` otherwise. Giving a file
|
||||
to an app that is ALREADY running is a handoff (runGame `handoff: true`):
|
||||
an extra untracked process, no log/playtime writes, because the app's own
|
||||
single-instance logic is what actually opens the file.
|
||||
- A sender hashing a big unit is minutes of network silence: it pings
|
||||
`/plwl/ping` (+token) every HEARTBEAT_MS for the whole sync, the receiver's
|
||||
handler re-arms its popup watchdog (stage 'wait' changes nothing on screen),
|
||||
and a receiving popup its watchdog closed reopens on the next real event -
|
||||
only the 'done' stage ends a sync for good, and reopening NEVER asks for
|
||||
consent again: the Accept/Decline gate is the onIncoming popup at verify
|
||||
time (a fresh session), while the progress popup is driven by the same
|
||||
token's events. A 'wait' event that reopens a closed popup fills the phase
|
||||
line (`recvWaiting`) instead of leaving every line blank.
|
||||
- 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@
|
|||
"library.removeFilesNote": "\nIts imported files in files/apps will also be deleted.",
|
||||
"library.removePrefixNote": "\nFiles stay in the prefix, only the launcher entry is removed.",
|
||||
"library.tools": "Wine tools",
|
||||
"library.viewLogs": "View logs",
|
||||
"library.reorderHint": "Drag to change the order",
|
||||
"import.question": "Should the app files be brought into the launcher so it stays portable, or left where they are?",
|
||||
"import.mode.copy": "Copy into launcher (portable)",
|
||||
|
|
@ -93,6 +92,8 @@
|
|||
"config.shortcut": "Create shortcut",
|
||||
"config.shortcutCreated": "Shortcut for {0} created (menu + desktop)",
|
||||
"config.shortcutFailed": "Failed to create the shortcut",
|
||||
"config.viewLogs": "View logs",
|
||||
"config.noLogs": "No logs yet - run the app first",
|
||||
"config.saved": "Configuration saved",
|
||||
"config.back": "Back to library",
|
||||
"config.browse": "Browse",
|
||||
|
|
@ -193,7 +194,7 @@
|
|||
"lansync.appsPrefix": "Apps imported from a prefix",
|
||||
"lansync.wines": "Wine / Proton versions",
|
||||
"lansync.prefixes": "Wine prefixes",
|
||||
"lansync.defaults": "Default settings (default Wine and prefix)",
|
||||
"lansync.settings": "Sync settings (names, app configs and order)",
|
||||
"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.incomingMirror": "The other device is the source of truth: files and items on this device that do not exist there will be deleted.",
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@
|
|||
"library.removeFilesNote": "\nJej zaimportowane pliki w files/apps również zostaną usunięte.",
|
||||
"library.removePrefixNote": "\nPliki zostają w prefiksie, usuwany jest tylko wpis w launcherze.",
|
||||
"library.tools": "Narzędzia Wine",
|
||||
"library.viewLogs": "Podejrzyj logi",
|
||||
"library.reorderHint": "Przeciągnij, aby zmienić kolejność",
|
||||
"import.question": "Czy pliki aplikacji mają trafić do launchera, aby był przenośny, czy mają zostać tam, gdzie są?",
|
||||
"import.mode.copy": "Skopiuj do launchera (przenośne)",
|
||||
|
|
@ -93,6 +92,8 @@
|
|||
"config.shortcut": "Utwórz skrót",
|
||||
"config.shortcutCreated": "Skrót dla {0} utworzony (menu + pulpit)",
|
||||
"config.shortcutFailed": "Nie udało się utworzyć skrótu",
|
||||
"config.viewLogs": "Podejrzyj logi",
|
||||
"config.noLogs": "Brak logów — uruchom najpierw aplikację",
|
||||
"config.saved": "Konfiguracja zapisana",
|
||||
"config.back": "Wróć do biblioteki",
|
||||
"config.browse": "Przeglądaj",
|
||||
|
|
@ -193,7 +194,7 @@
|
|||
"lansync.appsPrefix": "Aplikacje importowane z prefiksu",
|
||||
"lansync.wines": "Wersje Wine / Proton",
|
||||
"lansync.prefixes": "Prefiksy Wine",
|
||||
"lansync.defaults": "Ustawienia domyślne (domyślne Wine i prefiks)",
|
||||
"lansync.settings": "Ustawienia (nazwy, konfiguracje i kolejność aplikacji)",
|
||||
"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.incomingMirror": "Drugie urządzenie jest źródłem prawdy: pliki i elementy na tym urządzeniu, których tam nie ma, zostaną usunięte.",
|
||||
|
|
|
|||
|
|
@ -45,7 +45,6 @@ const ICONS = {
|
|||
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>',
|
||||
trash: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M3 6h18M8 6V4h8v2M6 6l1 14h10l1-14M10 11v6M14 11v6"/></svg>',
|
||||
terminal: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 17l6-5-6-5M12 19h8"/></svg>',
|
||||
logs: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 3h9l5 5v13a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zM9 12h6M9 16h6"/></svg>',
|
||||
star: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M12 3l2.7 5.8 6.3.8-4.6 4.3 1.2 6.1-5.6-3.1-5.6 3.1 1.2-6.1L3 9.6l6.3-.8z"/></svg>',
|
||||
download: () => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12M6 9l6 6 6-6"/><path d="M4 21h16"/></svg>',
|
||||
chevronDown: () => '<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>'
|
||||
|
|
@ -598,7 +597,6 @@ function renderLibrary() {
|
|||
<button class="button small danger" data-act="kill" style="display:none">${t('library.stop')}</button>
|
||||
<button class="button small" data-act="config" title="${t('library.config')}">${ICONS.config()}</button>
|
||||
<button class="button small" data-act="folder" title="${t('library.openFolder')}">${ICONS.folder()}</button>
|
||||
<button class="button small" data-act="logs" title="${t('library.viewLogs')}">${ICONS.logs()}</button>
|
||||
<button class="button small danger" data-act="remove" title="${t('library.remove')}">${ICONS.trash()}</button>
|
||||
</div>
|
||||
`
|
||||
|
|
@ -619,11 +617,6 @@ function renderLibrary() {
|
|||
}
|
||||
card.querySelector('[data-act=config]').onclick = () => openGameConfig(game.id)
|
||||
card.querySelector('[data-act=folder]').onclick = () => window.plwl.shellOpenPath(game.workdir)
|
||||
card.querySelector('[data-act=logs]').onclick = async () => {
|
||||
const logs = await window.plwl.gamesGetLogs(game.id)
|
||||
if (logs.length) window.plwl.shellShowItemInFolder(logs[logs.length - 1])
|
||||
else toast(t('library.viewLogs'))
|
||||
}
|
||||
card.querySelector('[data-act=remove]').onclick = async () => {
|
||||
const note = game.mode === 'copy' || game.mode === 'move'
|
||||
? t('library.removeFilesNote')
|
||||
|
|
@ -1250,6 +1243,16 @@ async function createAppShortcut() {
|
|||
}
|
||||
}
|
||||
|
||||
// Shows the newest run log in the file manager - it lives next to the app
|
||||
// configuration, where the user is when something needs debugging.
|
||||
async function showAppLogs() {
|
||||
const game = state.currentGame
|
||||
if (!game) return
|
||||
const logs = await window.plwl.gamesGetLogs(game.id)
|
||||
if (logs.length) window.plwl.shellShowItemInFolder(logs[logs.length - 1])
|
||||
else toast(t('config.noLogs'), 'error')
|
||||
}
|
||||
|
||||
function renderOverrides(overrides) {
|
||||
const box = qs('#gc-overrides')
|
||||
box.innerHTML = ''
|
||||
|
|
@ -1401,8 +1404,10 @@ const LAN_CATEGORIES = [
|
|||
{ id: 'apps-prefix', label: 'lansync.appsPrefix' },
|
||||
{ id: 'wines', label: 'lansync.wines' },
|
||||
{ id: 'prefixes', label: 'lansync.prefixes' },
|
||||
// opt-in: without it a sync never touches the other machine's defaults
|
||||
{ id: 'defaults', label: 'lansync.defaults' }
|
||||
// registry-level settings: default wine/prefix, custom names of wine builds
|
||||
// and prefixes, and each app's configuration plus its library order. No
|
||||
// files move with this category - it only patches what already exists.
|
||||
{ id: 'settings', label: 'lansync.settings' }
|
||||
]
|
||||
|
||||
// Byte sizes for the progress popups. Decimal units, the way a file manager
|
||||
|
|
@ -1742,7 +1747,11 @@ function onLanIncoming(info) {
|
|||
}
|
||||
|
||||
let receiving = false
|
||||
let receiveClosed = false
|
||||
// true only once the sender's 'done' event ended the transfer. A popup the
|
||||
// safety watchdog closed while the sender was still hashing must be allowed to
|
||||
// reopen when events resume - permanently closing it made a running sync
|
||||
// invisible even though the transfer continued.
|
||||
let receiveEnded = false
|
||||
let receiveWatchdog = null
|
||||
let receiveStall = null
|
||||
let receiveCloseTimer = null
|
||||
|
|
@ -1777,7 +1786,6 @@ function endReceiving(message) {
|
|||
}
|
||||
const wasReceiving = receiving
|
||||
receiving = false
|
||||
receiveClosed = true
|
||||
if (!wasReceiving) return
|
||||
if (message) toast(message, 'error')
|
||||
closeModal()
|
||||
|
|
@ -1801,13 +1809,14 @@ function armReceiveWatchdog() {
|
|||
function onLanReceiving(info) {
|
||||
if (!info || !info.stage) return
|
||||
if (state.lanPushing) return // our own push owns the (single) popup
|
||||
if (info.stage === 'start') receiveClosed = false
|
||||
if (info.stage === 'start') receiveEnded = false
|
||||
if (info.stage === 'done') receiveEnded = true
|
||||
if (!receiving) {
|
||||
// The popup also opens mid-sync: the sender may have been hashing for
|
||||
// minutes when this window starts listening, and a popup that the safety
|
||||
// timeout closed has to come back for the rest of the transfer. A sync
|
||||
// timeout closed comes back for the rest of the transfer. Only a sync
|
||||
// that already ended stays closed.
|
||||
if (receiveClosed && info.stage !== 'start') return
|
||||
if (receiveEnded) return
|
||||
if (qs('#modal-overlay').style.display !== 'none') return
|
||||
receiving = true
|
||||
const modal = showModal(`
|
||||
|
|
@ -1831,6 +1840,15 @@ function onLanReceiving(info) {
|
|||
function renderReceiving(info) {
|
||||
const parts = receiveModalParts()
|
||||
if (!parts) return
|
||||
// A liveness ping from the sender (it is busy hashing a unit): the watchdog
|
||||
// was re-armed by the caller, but what is on screen stays as it is - the
|
||||
// sync is not stalled, just quiet. The exception is the popup this very ping
|
||||
// just reopened (the safety timeout had closed it): every line would sit
|
||||
// blank, so the phase says what is actually happening.
|
||||
if (info.stage === 'wait') {
|
||||
if (!parts.phase.textContent) parts.phase.textContent = t('lansync.recvWaiting')
|
||||
return
|
||||
}
|
||||
// The same phases read from this side's point of view: a file arriving here
|
||||
// is being received, not sent.
|
||||
const phaseKey = RECV_PHASE_KEYS[info.stage] || 'lansync.phase.' + info.stage
|
||||
|
|
@ -1955,6 +1973,7 @@ function wireEvents() {
|
|||
qs('#btn-gc-back').addEventListener('click', () => showView('library'))
|
||||
qs('#btn-gc-save').addEventListener('click', saveGameConfig)
|
||||
qs('#btn-gc-shortcut').addEventListener('click', createAppShortcut)
|
||||
qs('#btn-gc-logs').addEventListener('click', showAppLogs)
|
||||
qs('#gc-override-add').addEventListener('click', addOverrideRow)
|
||||
qs('#gc-exe-browse').addEventListener('click', async () => {
|
||||
// start from the field being edited (exe picker from the exe field) and
|
||||
|
|
|
|||
|
|
@ -196,6 +196,7 @@
|
|||
|
||||
<div class="form-actions">
|
||||
<button class="button danger" id="btn-gc-clear-playtime" data-t="config.clearPlaytime"></button>
|
||||
<button class="button" id="btn-gc-logs" data-t="config.viewLogs"></button>
|
||||
<button class="button" id="btn-gc-shortcut" data-t="config.shortcut"></button>
|
||||
<button class="button primary" id="btn-gc-save" data-t="config.save"></button>
|
||||
</div>
|
||||
|
|
|
|||
29
src/argv.js
29
src/argv.js
|
|
@ -1,5 +1,8 @@
|
|||
'use strict'
|
||||
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
|
||||
// Shared command-line parsing for the launcher process itself. Both boot
|
||||
// (process.argv) and the single-instance handoff (second-instance argv) use
|
||||
// it, so a shortcut, a "Open with PLWL" file-open and Chromium's own injected
|
||||
|
|
@ -7,6 +10,7 @@
|
|||
//
|
||||
// Supported forms:
|
||||
// --launch <id> | --launch=<id> auto-start that library app
|
||||
// --file <path> hand this file to the --launch app
|
||||
// --open <path> | --open=<path> open an .exe with the wine picker
|
||||
// a bare existing-file path same as --open (plumbing for Exec %f)
|
||||
|
||||
|
|
@ -34,9 +38,32 @@ function findValue(args, names) {
|
|||
// PLWL's own options must not swallow Chromium's flags (--flag=... style),
|
||||
// so anything after a flag that itself carries a value is skipped here by
|
||||
// looking only at the KNOWN option names above.
|
||||
// The file an app shortcut was asked to open: `--file <path>` when the
|
||||
// generated shortcut passes it explicitly, or the path a file manager appends
|
||||
// on its own to an Exec line without a file field (KDE does that). Only an
|
||||
// existing absolute FILE counts in the fallback, so Chromium's injected flags
|
||||
// and the launcher's own path can never be mistaken for the opened document.
|
||||
const FIELD_CODE_RE = /^%[fFuUdDnNickvm]?$/
|
||||
function launchFileOf(args, launchId) {
|
||||
const explicit = findValue(args, ['--file'])
|
||||
// a desktop environment that does not expand field codes would hand over
|
||||
// the literal token - never treat that as a file path
|
||||
if (explicit && !FIELD_CODE_RE.test(explicit)) return explicit
|
||||
if (!launchId) return null
|
||||
for (let i = args.length - 1; i > 1; i--) {
|
||||
const arg = args[i]
|
||||
if (typeof arg !== 'string' || arg.startsWith('-') || !path.isAbsolute(arg)) continue
|
||||
try {
|
||||
if (fs.statSync(arg).isFile()) return arg
|
||||
} catch {}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function parseArgv(argv) {
|
||||
const args = Array.isArray(argv) ? argv : process.argv
|
||||
const launchId = findValue(args, ['--launch'])
|
||||
const launchFile = launchFileOf(args, launchId)
|
||||
let openPath = findValue(args, ['--open'])
|
||||
if (!openPath && !launchId) {
|
||||
// "Open with" plumbing (Exec %f): the file path arrives as the only
|
||||
|
|
@ -51,7 +78,7 @@ function parseArgv(argv) {
|
|||
)
|
||||
if (bare) openPath = bare
|
||||
}
|
||||
return { launchId, openPath }
|
||||
return { launchId, openPath, launchFile }
|
||||
}
|
||||
|
||||
module.exports = { parseArgv, findValue }
|
||||
|
|
|
|||
|
|
@ -57,6 +57,15 @@ function progressGate(emit) {
|
|||
async function treeSize(root) {
|
||||
let files = 0
|
||||
let bytes = 0
|
||||
// a symlink (or a plain file) as the root is the whole tree: walking it
|
||||
// would measure whatever it points at, not what is about to be removed
|
||||
let rootStat = null
|
||||
try {
|
||||
rootStat = await fsp.lstat(root)
|
||||
} catch {
|
||||
return { files, bytes }
|
||||
}
|
||||
if (!rootStat.isDirectory()) return { files: 1, bytes: rootStat.isSymbolicLink() ? 0 : rootStat.size }
|
||||
const walk = async dir => {
|
||||
let entries
|
||||
try {
|
||||
|
|
@ -150,6 +159,24 @@ async function removeTree(root, onProgress) {
|
|||
const tick = () => {
|
||||
if (gate) gate({ files, dirs, bytes }, false)
|
||||
}
|
||||
// A symlink (or a file) as the root is removed as itself, never walked
|
||||
// through: the root of an import/prefix can be a link to another disk, and
|
||||
// deleting through it would take the target's files instead of the link.
|
||||
let rootStat = null
|
||||
try {
|
||||
rootStat = await fsp.lstat(root)
|
||||
} catch {
|
||||
return { files, dirs, bytes }
|
||||
}
|
||||
if (!rootStat.isDirectory()) {
|
||||
files = 1
|
||||
bytes = rootStat.isSymbolicLink() ? 0 : rootStat.size
|
||||
try {
|
||||
await fsp.unlink(root)
|
||||
} catch {}
|
||||
if (gate) gate({ files, dirs, bytes }, true)
|
||||
return { files, dirs, bytes }
|
||||
}
|
||||
const walk = async dir => {
|
||||
let entries
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -107,21 +107,33 @@ function createGamesService({ getWindow, onGamesChanged }) {
|
|||
if (onGameExit) onGameExit(id)
|
||||
}
|
||||
|
||||
async function start(win, id) {
|
||||
async function start(win, id, openFile) {
|
||||
const game = library.getGame(id)
|
||||
if (!game) return { ok: false, error: 'game-not-found' }
|
||||
prune()
|
||||
if (running.has(id)) return { ok: false, error: 'already-running' }
|
||||
let wine
|
||||
try {
|
||||
wine = resolveWine(game)
|
||||
} catch {
|
||||
return { ok: false, error: 'wine-not-found' }
|
||||
}
|
||||
if (running.has(id)) {
|
||||
// Opening a document in an app that is already running is a HANDOFF,
|
||||
// exactly like Windows: a second process receives the file and the app's
|
||||
// own single-instance logic takes it over. It is not a tracked run -
|
||||
// playtime, the tray and the kill switch belong to the first process.
|
||||
if (!openFile) return { ok: false, error: 'already-running' }
|
||||
try {
|
||||
await runGame({ game, wine, systemWine: wine.type === 'system', openFile, handoff: true })
|
||||
return { ok: true, pid: null, logFile: null }
|
||||
} catch (err) {
|
||||
return { ok: false, error: String((err && err.message) || err) }
|
||||
}
|
||||
}
|
||||
running.set(id, { pid: null, prefix: library.prefixNameForGame(game), wine: wine.id })
|
||||
startedAt.set(id, Date.now())
|
||||
try {
|
||||
const res = await runGame({ game, wine, systemWine: wine.type === 'system', onExit: () => handleExit(id) })
|
||||
const res = await runGame({ game, wine, systemWine: wine.type === 'system', onExit: () => handleExit(id), openFile })
|
||||
// stop was requested while the process was still starting: kill it and
|
||||
// leave nothing in the running set
|
||||
if (killRequests.has(id)) {
|
||||
|
|
|
|||
270
src/lansync.js
270
src/lansync.js
|
|
@ -53,7 +53,7 @@ const SYNC_ROOTS = ['apps/', 'wines/', 'wineprefixes/', 'data/icons/']
|
|||
|
||||
// What a sync can carry, plus lenient aliases so callers can name the
|
||||
// categories the way the UI/README does.
|
||||
const CATEGORIES = ['apps-portable', 'apps-prefix', 'wines', 'prefixes', 'defaults']
|
||||
const CATEGORIES = ['apps-portable', 'apps-prefix', 'wines', 'prefixes', 'settings']
|
||||
|
||||
const CATEGORY_ALIASES = {
|
||||
// plain "apps" means the whole app library: portable ones AND the ones
|
||||
|
|
@ -74,12 +74,15 @@ const CATEGORY_ALIASES = {
|
|||
wineprefixes: ['prefixes'],
|
||||
prefixes: ['prefixes'],
|
||||
prefix: ['prefixes'],
|
||||
// the launcher's own defaults ("Default settings"): which wine everything
|
||||
// falls back to and where a new import lands
|
||||
defaults: ['defaults'],
|
||||
default: ['defaults'],
|
||||
defaultsettings: ['defaults'],
|
||||
settings: ['defaults']
|
||||
// the launcher's own settings ("Sync settings"): the defaults, the names and
|
||||
// disabled flags of wine builds, the prefix registry, and every app's
|
||||
// configuration plus its place in the library. "defaults" is what this
|
||||
// category used to be called - an older sender still names it that way.
|
||||
settings: ['settings'],
|
||||
syncsettings: ['settings'],
|
||||
defaults: ['settings'],
|
||||
default: ['settings'],
|
||||
defaultsettings: ['settings']
|
||||
}
|
||||
|
||||
// Which sync category an app entry belongs to. Portable copy/move imports own
|
||||
|
|
@ -316,26 +319,33 @@ function saveHashCache() {
|
|||
} catch {}
|
||||
}
|
||||
|
||||
// Hash of a file we already stat'ed: size and mtime unchanged means the bytes
|
||||
// are the same, and the digest from an earlier sync still describes them.
|
||||
function cachedHash(rel, abs, size, mtimeMs) {
|
||||
// The remembered digest of a file, or null when it has to be read again.
|
||||
// A hit only marks the cache dirty when its timestamp is actually stale:
|
||||
// marking it on every lookup would force a full rewrite of the file per sync
|
||||
// even though nothing a second sync could reuse has changed.
|
||||
function hashCacheHit(rel, size, mtimeMs) {
|
||||
const cache = loadHashCache()
|
||||
const known = cache[rel]
|
||||
if (known && known[0] === size && known[1] === mtimeMs && typeof known[2] === 'string') {
|
||||
if (!known || known[0] !== size || known[1] !== mtimeMs || typeof known[2] !== 'string') {
|
||||
hashStats.misses++
|
||||
return null
|
||||
}
|
||||
hashStats.hits++
|
||||
// a hit only marks the cache dirty when its timestamp is actually stale:
|
||||
// marking it every time would force a full rewrite of the file per sync
|
||||
// even though nothing a second sync could reuse has changed
|
||||
if (Date.now() - (known[3] || 0) > 86400000) {
|
||||
known[3] = Date.now()
|
||||
hashCacheDirty = true
|
||||
}
|
||||
return Promise.resolve(known[2])
|
||||
return known[2]
|
||||
}
|
||||
hashStats.misses++
|
||||
|
||||
// Hash of a file we already stat'ed: size and mtime unchanged means the bytes
|
||||
// are the same, and the digest from an earlier sync still describes them.
|
||||
function cachedHash(rel, abs, size, mtimeMs) {
|
||||
const known = hashCacheHit(rel, size, mtimeMs)
|
||||
if (known) return Promise.resolve(known)
|
||||
return hashFile(abs).then(hash => {
|
||||
if (hash) {
|
||||
cache[rel] = [size, mtimeMs, hash, Date.now()]
|
||||
loadHashCache()[rel] = [size, mtimeMs, hash, Date.now()]
|
||||
hashCacheDirty = true
|
||||
}
|
||||
return hash
|
||||
|
|
@ -499,26 +509,18 @@ async function buildManifest(roots, force, onProgress) {
|
|||
// start empty and fill with the bytes actually read
|
||||
const totalBytes = files.reduce((sum, entry) => sum + (entry.size || 0), 0)
|
||||
if (!force && files.length) {
|
||||
const cache = loadHashCache()
|
||||
let done = 0
|
||||
let hashed = 0
|
||||
await mapLimit(files, HASH_CONCURRENCY, async entry => {
|
||||
const abs = filesPath(entry.path)
|
||||
try {
|
||||
// inline cache hit: identical size and mtime means identical bytes, so
|
||||
// the digest from an earlier sync still describes this file - the bar
|
||||
// still advances by the file's size, because the check WAS the work
|
||||
// identical size and mtime means identical bytes, so the digest from
|
||||
// an earlier sync still describes this file
|
||||
const mtimeMs = mtimes.get(entry.path) || 0
|
||||
const known = cache[entry.path]
|
||||
if (abs && known && known[0] === entry.size && known[1] === mtimeMs && typeof known[2] === 'string') {
|
||||
hashStats.hits++
|
||||
if (Date.now() - (known[3] || 0) > 86400000) {
|
||||
known[3] = Date.now()
|
||||
hashCacheDirty = true
|
||||
}
|
||||
entry.hash = known[2]
|
||||
const known = hashCacheHit(entry.path, entry.size, mtimeMs)
|
||||
if (known && abs) {
|
||||
entry.hash = known
|
||||
} else {
|
||||
hashStats.misses++
|
||||
entry.hash = abs
|
||||
? await hashFile(abs, bytes => {
|
||||
hashed += bytes
|
||||
|
|
@ -526,7 +528,7 @@ async function buildManifest(roots, force, onProgress) {
|
|||
})
|
||||
: null
|
||||
if (entry.hash) {
|
||||
cache[entry.path] = [entry.size, mtimeMs, entry.hash, Date.now()]
|
||||
loadHashCache()[entry.path] = [entry.size, mtimeMs, entry.hash, Date.now()]
|
||||
hashCacheDirty = true
|
||||
}
|
||||
}
|
||||
|
|
@ -732,6 +734,10 @@ async function healUnitRoots(roots) {
|
|||
const abs = filesPath(ownedRoot(rel))
|
||||
if (!abs) continue
|
||||
try {
|
||||
// a unit root that is a symlink is the whole unit: its target is not
|
||||
// ours to walk (it may live outside the launcher), and the real folder
|
||||
// behind it is healed through its own unit
|
||||
if (!fs.lstatSync(abs).isDirectory()) continue
|
||||
healed.links += await rewireSymlinks(abs, [DIRS.FILES])
|
||||
if (String(rel).startsWith('wines/')) healed.executables += await restoreExecutableBits(abs)
|
||||
} catch {}
|
||||
|
|
@ -795,19 +801,43 @@ function removeOrphans(dir, keep) {
|
|||
return removed
|
||||
}
|
||||
|
||||
// Folder names in a registry directory. A symlinked folder counts too: a
|
||||
// wine build or prefix kept on another disk is still part of the tree, and the
|
||||
// sync carries it as the link it is (following it would copy the target and,
|
||||
// for the pfx self link, loop).
|
||||
function listDirs(dir) {
|
||||
const out = []
|
||||
let entries
|
||||
try {
|
||||
return fs
|
||||
.readdirSync(dir, { withFileTypes: true })
|
||||
.filter(e => e.isDirectory())
|
||||
.map(e => e.name)
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true })
|
||||
} catch {
|
||||
return []
|
||||
return out
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
out.push(entry.name)
|
||||
continue
|
||||
}
|
||||
if (!entry.isSymbolicLink()) continue
|
||||
try {
|
||||
if (fs.statSync(path.join(dir, entry.name)).isDirectory()) out.push(entry.name)
|
||||
} catch {}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Delete everything under a unit root that is not in the sender's manifest.
|
||||
async function pruneTree(abs, rel, keep) {
|
||||
// A unit root can BE a symlink (a build or prefix kept on another disk).
|
||||
// readdir would follow it and delete the real files behind it, which belong
|
||||
// to the target, not to this unit.
|
||||
let rootStat = null
|
||||
try {
|
||||
rootStat = await fsp.lstat(abs)
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
if (!rootStat.isDirectory()) return 0
|
||||
let entries
|
||||
try {
|
||||
entries = await fsp.readdir(abs, { withFileTypes: true })
|
||||
|
|
@ -950,12 +980,129 @@ function applyPrefixes(payload, useSenderDefault) {
|
|||
return removed
|
||||
}
|
||||
|
||||
// The launcher-wide defaults, asked for with the "Default settings" category:
|
||||
// the wine every app falls back to and the prefix new imports land in. Only
|
||||
// values that exist HERE are taken - a default pointing at a build or prefix
|
||||
// this device does not have would be worse than keeping the current one.
|
||||
function applyDefaults(payload, cats) {
|
||||
if (!cats.has('defaults')) return null
|
||||
// Wine display names and disabled flags ("Sync settings"): registry-only
|
||||
// values, matched by build id. Only builds registered HERE are touched, and an
|
||||
// empty name CLEARS a custom label - syncing from a machine where the build
|
||||
// was never renamed restores the folder-derived label there, exactly like the
|
||||
// sender shows. System Wine has no registry entry and is never renamed.
|
||||
function applyWineSettings(payload) {
|
||||
const incoming = Array.isArray(payload.wineSettings) ? payload.wineSettings : []
|
||||
let applied = 0
|
||||
for (const entry of incoming) {
|
||||
if (!entry || !entry.name) continue
|
||||
const id = String(entry.name)
|
||||
const wine = wines.getWine(id)
|
||||
if (!wine || wine.type === 'system') continue
|
||||
let changed = false
|
||||
const wanted = typeof entry.displayName === 'string' ? entry.displayName.trim() : ''
|
||||
if (wanted !== (wine.displayName || '')) {
|
||||
// a sender that never renamed the build clears the custom label here
|
||||
if (wanted) wines.setWineDisplayName(id, wanted)
|
||||
else wines.clearWineDisplayName(id)
|
||||
changed = true
|
||||
}
|
||||
if (entry.disabled !== undefined && !!entry.disabled !== !!wine.disabled) {
|
||||
wines.setWineDisabled(id, !!entry.disabled)
|
||||
changed = true
|
||||
}
|
||||
if (changed) applied++
|
||||
}
|
||||
return applied
|
||||
}
|
||||
|
||||
// Prefix REGISTRY names ("Sync settings"): a prefix's name IS its folder, so
|
||||
// this never renames or creates folders - folders and their removal belong to
|
||||
// the "Wine prefixes" category. It registers the sender's entries so a prefix
|
||||
// whose folder already arrived (or is about to be used as a default) is known
|
||||
// here, and it leaves every local prefix alone.
|
||||
function applyPrefixSettings(payload) {
|
||||
const sender = payload.prefixSettings && typeof payload.prefixSettings === 'object' ? payload.prefixSettings : null
|
||||
if (!sender) return 0
|
||||
const incoming = (Array.isArray(sender.prefixes) ? sender.prefixes : []).filter(p => p && p.name)
|
||||
if (!incoming.length) return 0
|
||||
const defs = prefixes.listPrefixes()
|
||||
const known = new Set(defs.prefixes.map(p => p.name))
|
||||
let added = 0
|
||||
for (const entry of incoming) {
|
||||
const name = String(entry.name)
|
||||
if (known.has(name)) continue
|
||||
known.add(name)
|
||||
defs.prefixes.push({ name, created: Number(entry.created) || Date.now(), locked: name === prefixes.MAIN ? true : !!entry.locked })
|
||||
added++
|
||||
}
|
||||
if (added) prefixes.savePrefixes(defs)
|
||||
return added
|
||||
}
|
||||
|
||||
// Per-app configuration ("Sync settings"): apps are matched by id and only
|
||||
// apps that already exist here are patched - a settings sync cannot create an
|
||||
// app whose files it does not carry. A wine id or prefix this device does not
|
||||
// know is skipped instead of silently breaking the app (it would fall back to
|
||||
// System Wine / the default prefix), and prefix-import entries keep their own
|
||||
// prefix no matter what the sender has (updateGame pins it).
|
||||
function applyAppSettings(payload) {
|
||||
const incoming = Array.isArray(payload.appSettings) ? payload.appSettings : []
|
||||
if (!incoming.length) return 0
|
||||
const byId = new Map(incoming.filter(a => a && a.id).map(a => [String(a.id), a]))
|
||||
const knownPrefixes = new Set(prefixes.listPrefixes().prefixes.map(p => p.name))
|
||||
let patched = 0
|
||||
for (const game of library.loadGames()) {
|
||||
const from = byId.get(game.id)
|
||||
if (!from) continue
|
||||
const patch = {}
|
||||
if (typeof from.name === 'string' && from.name && from.name !== game.name) patch.name = from.name
|
||||
if (from.wine !== undefined) {
|
||||
const wanted = String(from.wine || 'default')
|
||||
if ((wanted === 'default' || wanted === 'system' || wines.getWine(wanted)) && wanted !== (game.wine || 'default')) patch.wine = wanted
|
||||
}
|
||||
if (from.prefix !== undefined && game.importSource !== 'prefix') {
|
||||
const wanted = String(from.prefix || '')
|
||||
if ((!wanted || knownPrefixes.has(wanted)) && wanted !== (game.prefix || '')) patch.prefix = wanted
|
||||
}
|
||||
if (typeof from.args === 'string' && from.args !== (game.args || '')) patch.args = from.args
|
||||
if (from.env && typeof from.env === 'object' && JSON.stringify(from.env) !== JSON.stringify(game.env || {})) patch.env = from.env
|
||||
if (Array.isArray(from.dllOverrides) && JSON.stringify(from.dllOverrides) !== JSON.stringify(game.dllOverrides || [])) patch.dllOverrides = from.dllOverrides
|
||||
if (from.disableUmu !== undefined && !!from.disableUmu !== !!game.disableUmu) patch.disableUmu = !!from.disableUmu
|
||||
if (Object.keys(patch).length) {
|
||||
library.updateGame(game.id, patch)
|
||||
patched++
|
||||
}
|
||||
}
|
||||
return patched
|
||||
}
|
||||
|
||||
// The library ORDER ("Sync settings"): reorderGames keeps entries this device
|
||||
// has that the sender does not know (they stay at the end), so a settings sync
|
||||
// can reorder a library but never drop an app from it.
|
||||
function applyAppOrder(payload) {
|
||||
const order = Array.isArray(payload.appOrder) ? payload.appOrder.filter(id => typeof id === 'string' && id) : []
|
||||
if (!order.length) return 0
|
||||
const current = library.loadGames().map(g => g.id)
|
||||
if (current.length === order.length && current.every((id, i) => id === order[i])) return 0
|
||||
library.reorderGames(order)
|
||||
return order.length
|
||||
}
|
||||
|
||||
// Everything the "Sync settings" category carries. Order matters: prefix names
|
||||
// are registered before the default prefix is resolved and before app configs
|
||||
// are checked against them; the app order is applied last so the patched
|
||||
// library is written in the sender's order.
|
||||
function applySettings(payload, cats) {
|
||||
if (!cats.has('settings')) return null
|
||||
return {
|
||||
prefixes: applyPrefixSettings(payload),
|
||||
wines: applyWineSettings(payload),
|
||||
apps: applyAppSettings(payload),
|
||||
order: applyAppOrder(payload),
|
||||
defaults: applyDefaults(payload)
|
||||
}
|
||||
}
|
||||
|
||||
// The launcher-wide defaults ("Sync settings"): the wine every app falls back
|
||||
// to and the prefix new imports land in. Only values that exist HERE are taken
|
||||
// - a default pointing at a build or prefix this device does not have would be
|
||||
// worse than keeping the current one.
|
||||
function applyDefaults(payload) {
|
||||
const wanted = payload.defaults && typeof payload.defaults === 'object' ? payload.defaults : {}
|
||||
const applied = {}
|
||||
if (wanted.defaultWine) {
|
||||
|
|
@ -1067,6 +1214,7 @@ function createHttpServer(state, hooks) {
|
|||
if (route === '/plwl/verify' && req.method === 'POST') return handleVerify(state, req, res, hooks)
|
||||
const session = authorize(state, url.searchParams.get('token'))
|
||||
if (!session) return sendJson(res, 401, { ok: false, error: 'unauthorized' })
|
||||
if (route === '/plwl/ping' && req.method === 'POST') return handlePing(session, res, hooks)
|
||||
if (route === '/plwl/unit/plan' && req.method === 'POST') return handlePlan(state, session, url, req, res, hooks)
|
||||
if (route === '/plwl/unit/file' && req.method === 'PUT') return handlePut(session, url, req, res, hooks)
|
||||
if (route === '/plwl/unit/commit' && req.method === 'POST') return handleCommit(session, url, res, hooks)
|
||||
|
|
@ -1216,7 +1364,11 @@ async function handlePlan(state, session, url, req, res, hooks) {
|
|||
compared++
|
||||
comparedBytes += entry.size || 0
|
||||
session.barBytes = comparedBytes
|
||||
announce({ unit: label, done: compared, total: allowed.length })
|
||||
// the file counter is not touched here: compared items include dirs and
|
||||
// links, and until the upload starts nobody knows how many files will
|
||||
// actually be written - the popup's counter freezes instead of jumping
|
||||
// backwards when the copy phase starts its own count
|
||||
announce({ unit: label })
|
||||
return same === true
|
||||
})
|
||||
saveHashCache()
|
||||
|
|
@ -1258,7 +1410,7 @@ async function handlePlan(state, session, url, req, res, hooks) {
|
|||
// is planned, so the later file events can name it in the popup
|
||||
session.unitLabel = label
|
||||
sendJson(res, 200, { ok: true, upload, same, entries: paths.size })
|
||||
announce({ unit: label, done: allowed.length, total: allowed.length }, true)
|
||||
announce({ unit: label }, true)
|
||||
}
|
||||
|
||||
async function handlePut(session, url, req, res, hooks) {
|
||||
|
|
@ -1325,13 +1477,15 @@ async function handleCommit(session, url, res, hooks) {
|
|||
async function handleApply(session, req, res, hooks) {
|
||||
const payload = await readJsonBody(req)
|
||||
if (!payload) return sendJson(res, 400, { ok: false, error: 'bad-request' })
|
||||
const cats = new Set(Array.isArray(payload.categories) ? payload.categories : [])
|
||||
// the categories come from another machine (and an older PLWL names the
|
||||
// settings category "defaults"), so they go through the aliases here too
|
||||
const cats = new Set(normalizeCategories(payload.categories))
|
||||
const appsDeleted = applyApps(payload, cats)
|
||||
const winesDeleted = cats.has('wines') ? await applyWines(payload) : 0
|
||||
const prefixesDeleted = cats.has('prefixes') ? applyPrefixes(payload, cats.has('defaults')) : 0
|
||||
// after the registries are in place, so a synced build/prefix can actually be
|
||||
// chosen as the default
|
||||
const defaults = applyDefaults(payload, cats)
|
||||
const prefixesDeleted = cats.has('prefixes') ? applyPrefixes(payload, cats.has('settings')) : 0
|
||||
// after the registries are in place, so a synced build/prefix can actually
|
||||
// be chosen as the default and app configs can be checked against them
|
||||
const settings = applySettings(payload, cats)
|
||||
const removed = appsDeleted + winesDeleted + prefixesDeleted
|
||||
emitReceive(hooks, session, 'registry', { deleted: removed })
|
||||
if (hooks.onApplied) {
|
||||
|
|
@ -1343,7 +1497,8 @@ async function handleApply(session, req, res, hooks) {
|
|||
bytes: session.bytes || 0,
|
||||
items: { ...(session.items || {}) },
|
||||
deleted: removed,
|
||||
defaults
|
||||
settings,
|
||||
defaults: settings ? settings.defaults : null
|
||||
})
|
||||
}
|
||||
sendJson(res, 200, {
|
||||
|
|
@ -1352,7 +1507,8 @@ async function handleApply(session, req, res, hooks) {
|
|||
apps: { deleted: appsDeleted },
|
||||
wines: { deleted: winesDeleted },
|
||||
prefixes: { deleted: prefixesDeleted },
|
||||
defaults
|
||||
settings,
|
||||
defaults: settings ? settings.defaults : null
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -1363,6 +1519,18 @@ function handleEnd(state, session, res, hooks) {
|
|||
emitReceive(hooks, session, 'done', { deleted: session.deleted || 0 })
|
||||
}
|
||||
|
||||
// Liveness ping from the pushing device. It hashes whole units before it
|
||||
// sends them and that can take many minutes, during which no other request
|
||||
// arrives: without this the receiving popup would go quiet and its watchdog
|
||||
// would eventually close it while the sync is still very much alive. The
|
||||
// event only re-arms that watchdog (the renderer ignores its stage), so a
|
||||
// transfer in progress keeps showing what it is doing.
|
||||
function handlePing(session, res, hooks) {
|
||||
session.lastAt = Date.now()
|
||||
emitReceive(hooks, session, 'wait')
|
||||
sendJson(res, 200, { ok: true })
|
||||
}
|
||||
|
||||
// --- discovery --------------------------------------------------------------
|
||||
|
||||
let beaconSocket = null
|
||||
|
|
|
|||
|
|
@ -17,6 +17,12 @@ const path = require('path')
|
|||
// small files a game or prefix is mostly made of.
|
||||
const UPLOAD_CONCURRENCY = 4
|
||||
|
||||
// Keeps the receiving side's session and popup alive while this device hashes
|
||||
// a unit (minutes of complete silence from the network) - well under the
|
||||
// receiving popup's watchdog, and cheap enough to run through the transfer too.
|
||||
// Env override: tests cannot wait 20 s for a ping.
|
||||
const HEARTBEAT_MS = Number(process.env.PLWL_LANSYNC_HEARTBEAT) || 20000
|
||||
|
||||
function createPusher(api) {
|
||||
const {
|
||||
CATEGORIES,
|
||||
|
|
@ -131,9 +137,15 @@ function createPusher(api) {
|
|||
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)
|
||||
const rel = String(toPortablePath(wine.path) || '')
|
||||
const parts = rel.split('/')
|
||||
// a build folder can sit one level down (zip-extracted layouts are
|
||||
// detected at any depth): the unit is always the TOP folder under
|
||||
// wines/ - that is the folder mirrored as a whole, and listDirs()
|
||||
// names it as well. Using the basename here would point the unit at a
|
||||
// folder that does not exist and inflate the "Wine versions" totals
|
||||
// with a phantom unit.
|
||||
add(parts[0] === 'wines' && parts[1] ? `wines/${parts[1]}` : `wines/${path.basename(wine.path)}`, wine.label)
|
||||
}
|
||||
for (const name of listDirs(DIRS.WINES)) add(`wines/${name}`)
|
||||
return [...units.values()]
|
||||
|
|
@ -178,14 +190,39 @@ function createPusher(api) {
|
|||
// the receiver prunes its own builds down to this list and rescans
|
||||
payload.wines = wineUnits().map(unit => ({ name: unit.id }))
|
||||
}
|
||||
if (cats.has('defaults')) {
|
||||
// "Default settings": which wine everything falls back to and where a new
|
||||
// import lands. Opt-in on purpose - a sync of apps must not silently
|
||||
// change the other machine's defaults.
|
||||
if (cats.has('settings')) {
|
||||
// "Sync settings": every registry-level value that describes how the
|
||||
// launcher is set up here - the defaults, the names/flags the user gave
|
||||
// wine builds, the prefix registry, and each app's configuration plus
|
||||
// its position in the library. The receiver only patches things it
|
||||
// already has; this category creates and deletes no files. Opt-in on
|
||||
// purpose - a sync of apps must not silently rewrite the other
|
||||
// machine's settings.
|
||||
const defs = prefixes.listPrefixes()
|
||||
const games = library.loadGames()
|
||||
payload.defaults = {
|
||||
defaultWine: (library.loadSettings().defaultWine) || 'system',
|
||||
defaultPrefix: prefixes.listPrefixes().default || ''
|
||||
defaultPrefix: defs.default || ''
|
||||
}
|
||||
payload.wineSettings = wines
|
||||
.getWines()
|
||||
.filter(w => w.type !== 'system')
|
||||
.map(w => ({ name: w.id, displayName: w.displayName || '', disabled: !!w.disabled }))
|
||||
payload.prefixSettings = {
|
||||
default: defs.default || '',
|
||||
prefixes: defs.prefixes.map(p => ({ name: p.name, created: p.created, locked: !!p.locked }))
|
||||
}
|
||||
payload.appSettings = games.map(g => ({
|
||||
id: g.id,
|
||||
name: g.name,
|
||||
wine: g.wine || 'default',
|
||||
prefix: g.prefix || '',
|
||||
args: g.args || '',
|
||||
env: g.env || {},
|
||||
dllOverrides: g.dllOverrides || [],
|
||||
disableUmu: !!g.disableUmu
|
||||
}))
|
||||
payload.appOrder = games.map(g => g.id)
|
||||
}
|
||||
if (cats.has('prefixes')) {
|
||||
const defs = prefixes.listPrefixes()
|
||||
|
|
@ -291,6 +328,14 @@ function createPusher(api) {
|
|||
// typing (a rejected code is never stored)
|
||||
rememberDevice(device.id || deviceId, code, device.name)
|
||||
|
||||
// Hashing a big unit happens here, before a single byte is sent: without a
|
||||
// sign of life the other side would see minutes of silence and its
|
||||
// "receiving" popup could close while this device is still working.
|
||||
const heartbeat = setInterval(() => {
|
||||
httpJson(device, 'POST', `/plwl/ping?token=${encodeURIComponent(token)}`, { json: {}, timeout: 10000 }).catch(() => {})
|
||||
}, HEARTBEAT_MS)
|
||||
if (heartbeat.unref) heartbeat.unref()
|
||||
|
||||
const summary = newSummary(units.length)
|
||||
const finished = { apps: 0, wines: 0, prefixes: 0 }
|
||||
// bytes the units planned so far will put on the wire: the popup can then
|
||||
|
|
@ -381,7 +426,6 @@ function createPusher(api) {
|
|||
pending.push(entry)
|
||||
plannedBytes += entry.size || 0
|
||||
}
|
||||
allTotal += pending.length
|
||||
// a few files in flight at once: with thousands of small ones the
|
||||
// round trip to the other machine costs more than the bytes do
|
||||
// the files that were already identical never move, so the bar counts
|
||||
|
|
@ -439,6 +483,8 @@ function createPusher(api) {
|
|||
} catch (err) {
|
||||
await end()
|
||||
return fail(String((err && err.message) || err), summary, events)
|
||||
} finally {
|
||||
clearInterval(heartbeat)
|
||||
}
|
||||
return syncResult(true, null, summary, events)
|
||||
}
|
||||
|
|
|
|||
14
src/main.js
14
src/main.js
|
|
@ -26,6 +26,7 @@ let tray = null
|
|||
let quitting = false
|
||||
let headless = false // started via --launch: no window until the user asks
|
||||
let autoLaunchId = null // game id from --launch <id>
|
||||
let autoLaunchFile = null // file from --file ("Open with" on an app shortcut)
|
||||
let autoOpenPath = null // file path from --open ("Open with PLWL")
|
||||
let autoQuitWhenDone = false // headless boot: quit after that game exits
|
||||
const getWindow = () => win
|
||||
|
|
@ -54,10 +55,10 @@ if (!gotLock) {
|
|||
} else {
|
||||
app.on('second-instance', (e, argv) => {
|
||||
trace(`second-instance argv=${argv.join(' ')}`)
|
||||
const { launchId, openPath } = parseArgv(argv)
|
||||
const { launchId, openPath, launchFile } = parseArgv(argv)
|
||||
lastHandoffAt = Date.now()
|
||||
if (openPath) openWith(openPath)
|
||||
else if (launchId) launchGame(launchId)
|
||||
else if (launchId) launchGame(launchId, launchFile)
|
||||
else showMainWindow()
|
||||
})
|
||||
}
|
||||
|
|
@ -80,6 +81,7 @@ async function openWith(filePath) {
|
|||
|
||||
const auto = parseArgv(process.argv)
|
||||
autoLaunchId = auto.launchId
|
||||
autoLaunchFile = auto.launchFile
|
||||
autoOpenPath = auto.openPath
|
||||
headless = !!autoLaunchId
|
||||
autoQuitWhenDone = headless // booting just to run one game -> exit when it ends
|
||||
|
|
@ -199,10 +201,10 @@ function wireWindow(w) {
|
|||
// --- game auto-launch (shortcuts) -----------------------------------------------
|
||||
let gamesService = null
|
||||
|
||||
async function launchGame(id) {
|
||||
trace(`launchGame ${id} serviceReady=${!!gamesService}`)
|
||||
async function launchGame(id, openFile) {
|
||||
trace(`launchGame ${id} openFile=${openFile || ''} serviceReady=${!!gamesService}`)
|
||||
if (!gamesService) return
|
||||
const res = await gamesService.start(getWindow(), id)
|
||||
const res = await gamesService.start(getWindow(), id, openFile)
|
||||
trace(`launchGame ${id} result=${JSON.stringify(res).slice(0, 200)}`)
|
||||
if (res.ok) {
|
||||
rebuildTrayMenu()
|
||||
|
|
@ -262,7 +264,7 @@ app.whenReady().then(async () => {
|
|||
}
|
||||
if (headless) {
|
||||
// shortcut boot: run the game without opening the launcher window
|
||||
launchGame(autoLaunchId)
|
||||
launchGame(autoLaunchId, autoLaunchFile)
|
||||
} else {
|
||||
win = createMainWindow()
|
||||
wireWindow(win)
|
||||
|
|
|
|||
|
|
@ -221,6 +221,25 @@ function parseArgs(args) {
|
|||
return out
|
||||
}
|
||||
|
||||
// A file handed over from the Linux file manager ("Open with" on an app
|
||||
// shortcut) has to reach the Windows app as a WINDOWS path, the way it would
|
||||
// on Windows: C:\ for anything inside the prefix's C: drive, and wine's Z:\
|
||||
// mapping (the filesystem root) for everything else.
|
||||
function windowsPathFor(file, prefixDir) {
|
||||
let abs = String(file || '')
|
||||
try {
|
||||
abs = fs.realpathSync(abs)
|
||||
} catch {}
|
||||
try {
|
||||
const driveC = fs.realpathSync(path.join(prefixDir, 'drive_c'))
|
||||
const rel = path.relative(driveC, abs)
|
||||
if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {
|
||||
return 'C:\\' + rel.split(path.sep).join('\\')
|
||||
}
|
||||
} catch {}
|
||||
return `Z:${abs.split(path.sep).join('\\')}`
|
||||
}
|
||||
|
||||
const GRACE_MS = 60000
|
||||
|
||||
// Live playtime: while the game runs, update ONLY the tiny playtime file
|
||||
|
|
@ -250,13 +269,15 @@ function openLatestLog(gameId) {
|
|||
return fs.createWriteStream(logFile, { flags: 'w' })
|
||||
}
|
||||
|
||||
async function runGame({ game, wine, systemWine, onExit }) {
|
||||
async function runGame({ game, wine, systemWine, onExit, openFile, handoff = false }) {
|
||||
await healPrefixOnce(game)
|
||||
const isProton = wine && wine.type === 'proton'
|
||||
const { env } = buildEnv({ game, wine })
|
||||
const { env, prefixDir } = buildEnv({ game, wine })
|
||||
const exe = game.exePath
|
||||
const workdir = game.workdir || path.dirname(exe)
|
||||
const argList = parseArgs(game.args)
|
||||
// an opened file is an argument to the exe, exactly like Windows' "Open with"
|
||||
if (openFile) argList.push(windowsPathFor(openFile, prefixDir))
|
||||
|
||||
let cmd
|
||||
let base
|
||||
|
|
@ -275,7 +296,10 @@ async function runGame({ game, wine, systemWine, onExit }) {
|
|||
child = spawn(cmd, base, {
|
||||
cwd: workdir,
|
||||
env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
// a handoff (a file given to an already running app) is not tracked:
|
||||
// its pipes are left unread, so the output goes nowhere instead of
|
||||
// filling up and blocking the app
|
||||
stdio: handoff ? 'ignore' : ['ignore', 'pipe', 'pipe'],
|
||||
detached: true
|
||||
})
|
||||
} catch (err) {
|
||||
|
|
@ -293,6 +317,12 @@ async function runGame({ game, wine, systemWine, onExit }) {
|
|||
})
|
||||
child.once('spawn', () => {
|
||||
started = true
|
||||
// a handoff never writes the app's log or playtime: the tracked run owns
|
||||
// both, and truncating the live log would destroy what it is writing
|
||||
if (handoff) {
|
||||
child.unref()
|
||||
return resolve({ pid: child.pid, logFile: null })
|
||||
}
|
||||
const logFile = latestLogPath(game.id)
|
||||
const ws = openLatestLog(game.id)
|
||||
// a launch line of our own before the raw wine output: when a game
|
||||
|
|
@ -365,4 +395,4 @@ function runAdHoc({ openPath, wine, systemWine, prefix, onExit }) {
|
|||
return runGame({ game, wine, systemWine, onExit })
|
||||
}
|
||||
|
||||
module.exports = { buildEnv, runGame, runTool, runAdHoc, parseArgs, latestLogPath }
|
||||
module.exports = { buildEnv, runGame, runTool, runAdHoc, parseArgs, latestLogPath, windowsPathFor }
|
||||
|
|
|
|||
|
|
@ -44,8 +44,14 @@ function execToken(token) {
|
|||
return /[ $"'\\]/.test(token) ? `"${escaped}"` : escaped
|
||||
}
|
||||
|
||||
// The `%f` file field is appended AFTER escaping (a literal % would have been
|
||||
// doubled into a literal). It makes the shortcut a file handler: the file
|
||||
// manager substitutes the opened file there - and removes the field when the
|
||||
// shortcut is launched from the menu - so "Open with" on the shortcut runs the
|
||||
// app with that file, like it does on Windows.
|
||||
function execLine(extraArgs) {
|
||||
return [...launcherArgv(), ...extraArgs.split(' ').filter(Boolean).map(execToken)].map(execToken).join(' ')
|
||||
const tokens = [...launcherArgv(), ...String(extraArgs).split(' ').filter(Boolean)]
|
||||
return [...tokens.map(execToken), '%f'].join(' ')
|
||||
}
|
||||
|
||||
function sanitizeName(name) {
|
||||
|
|
@ -71,7 +77,7 @@ function buildDesktopEntry(game) {
|
|||
'Type=Application',
|
||||
`Name=${name}`,
|
||||
'Comment=Launch via PLWL',
|
||||
`Exec=${execLine(`--launch ${game.id}`)}`,
|
||||
`Exec=${execLine(`--launch ${game.id} --file`)}`,
|
||||
'Terminal=false',
|
||||
'Categories=Game;'
|
||||
]
|
||||
|
|
|
|||
35
src/wines.js
35
src/wines.js
|
|
@ -167,14 +167,31 @@ function applyDisplayName(w) {
|
|||
// A build keeps its folder and id (apps point at the id, and prefix symlinks
|
||||
// point inside the folder), so "renaming" only changes the shown label.
|
||||
function setWineDisplayName(id, name) {
|
||||
const clean = normalizeLabel(name)
|
||||
if (!clean) return { ok: false, error: 'invalidName' }
|
||||
return writeWineLabel(id, clean)
|
||||
}
|
||||
|
||||
// Clears a custom label back to the folder-derived one: what the LAN sync
|
||||
// calls when the sender never renamed the build. The rename dialog never
|
||||
// clears - an empty name there is an error (setWineDisplayName above).
|
||||
function clearWineDisplayName(id) {
|
||||
return writeWineLabel(id, '')
|
||||
}
|
||||
|
||||
function writeWineLabel(id, clean) {
|
||||
const wines = getWines()
|
||||
const w = wines.find(x => x.id === id)
|
||||
if (!w) return { ok: false, error: 'notFound' }
|
||||
if (w.type === 'system') return { ok: false, error: 'isSystem' }
|
||||
const clean = normalizeLabel(name)
|
||||
if (!clean) return { ok: false, error: 'invalidName' }
|
||||
if (clean) {
|
||||
w.displayName = clean
|
||||
applyDisplayName(w)
|
||||
w.label = clean
|
||||
} else {
|
||||
if (!w.displayName) return { ok: true, displayName: '' }
|
||||
delete w.displayName
|
||||
w.label = labelFor(w.type, w.path, w.version)
|
||||
}
|
||||
writeJson(REGISTRY, wines.map(wineRel))
|
||||
return { ok: true, displayName: clean }
|
||||
}
|
||||
|
|
@ -241,6 +258,16 @@ async function fixBuildSymlinks(root) {
|
|||
// Everything a freshly arrived build needs before it can run: relative links
|
||||
// and executable binaries. Returns what was changed, for the callers' reports.
|
||||
async function healBuild(root) {
|
||||
// a build folder that IS a symlink (kept on another disk) is left alone:
|
||||
// the walk would follow the link and edit its target, which is not
|
||||
// necessarily part of this launcher's trees
|
||||
let st = null
|
||||
try {
|
||||
st = fs.lstatSync(root)
|
||||
} catch {
|
||||
return { links: 0, executables: 0 }
|
||||
}
|
||||
if (!st.isDirectory()) return { links: 0, executables: 0 }
|
||||
const links = await fixBuildSymlinks(root)
|
||||
const executables = await restoreExecutableBits(root)
|
||||
return { links, executables }
|
||||
|
|
@ -316,4 +343,4 @@ async function importWineFolder(srcDir, onProgress) {
|
|||
return { ok: true, path: toPortablePath(dst), symlinksFixed: healed.links, executablesFixed: healed.executables }
|
||||
}
|
||||
|
||||
module.exports = { refreshWines, getWines, getWine, resolveWine, setWineDisabled, setWineDisplayName, getDefaultWine, setDefaultWine, deleteWine, importWineFolder, findWineBinary, detectType, fixBuildSymlinks, healBuild }
|
||||
module.exports = { refreshWines, getWines, getWine, resolveWine, setWineDisabled, setWineDisplayName, clearWineDisplayName, getDefaultWine, setDefaultWine, deleteWine, importWineFolder, findWineBinary, detectType, fixBuildSymlinks, healBuild }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue