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:
olcxja 2026-10-04 19:12:33 +02:00
commit 59ff0b1088
15 changed files with 520 additions and 113 deletions

View file

@ -1,14 +1,18 @@
'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
// flags are handled the same way everywhere.
//
// Supported forms:
// --launch <id> | --launch=<id> auto-start that library app
// --open <path> | --open=<path> open an .exe with the wine picker
// a bare existing-file path same as --open (plumbing for Exec %f)
// --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)
function firstNonFlagAfter(args, i) {
// Chromium injects its own flags into the second instance's argv and they
@ -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 }

View file

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

View file

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

View file

@ -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 {}
}
// 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') {
hashStats.misses++
return null
}
hashStats.hits++
if (Date.now() - (known[3] || 0) > 86400000) {
known[3] = Date.now()
hashCacheDirty = true
}
return known[2]
}
// 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 cache = loadHashCache()
const known = cache[rel]
if (known && known[0] === size && known[1] === mtimeMs && typeof known[2] === 'string') {
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])
}
hashStats.misses++
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

View file

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

View file

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

View file

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

View file

@ -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;'
]

View file

@ -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' }
w.displayName = clean
applyDisplayName(w)
if (clean) {
w.displayName = clean
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 }