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
35
src/argv.js
35
src/argv.js
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
280
src/lansync.js
280
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 {}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -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;'
|
||||
]
|
||||
|
|
|
|||
37
src/wines.js
37
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' }
|
||||
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 }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue