code refactor & sync bugfixes

This commit is contained in:
olcxja 2026-10-05 13:43:49 +02:00
commit 9edcc9841b
20 changed files with 820 additions and 812 deletions

View file

@ -4,6 +4,16 @@ const fs = require('fs')
const fsp = require('fs').promises
const path = require('path')
// accessSync instead of existsSync: same answer, one syscall fewer.
function pathExists(p) {
try {
fs.accessSync(p)
return true
} catch {
return false
}
}
// Returns a non-existing path in `base` named `name` (or `name (2)`, `name (3)`...).
function dedupeDir(base, name) {
const candidate = path.join(base, name)
@ -50,6 +60,22 @@ function progressGate(emit) {
return push
}
// Run `fn` over `items` with at most `limit` calls in flight, results in
// order. Walking a tree is dominated by stat/readlink/open syscalls: a few in
// flight keep the disk and cpu busy without exhausting file descriptors.
async function mapLimit(items, limit, fn) {
const out = new Array(items.length)
let next = 0
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (next < items.length) {
const i = next++
out[i] = await fn(items[i], i)
}
})
await Promise.all(workers)
return out
}
// What a tree copy will have to move. Symlinks have no size of their own
// (they are recreated, not read), so they only count for the file total.
// `skipDir` is a single directory name to leave out (a game folder's
@ -218,5 +244,5 @@ async function removeTree(root, onProgress) {
return { files, dirs, bytes }
}
module.exports = { dedupeDir, copyTree, treeSize, removeTree, progressGate }
module.exports = { pathExists, dedupeDir, copyTree, treeSize, removeTree, progressGate, mapLimit }

161
src/hashcache.js Normal file
View file

@ -0,0 +1,161 @@
'use strict'
// Content hashing for the LAN sync, plus the per-file digest cache.
//
// Syncing a library means hashing tens of thousands of files, so the digest of
// every file is remembered on disk (files/data/lansync-hashes.json), keyed by
// its FILES-relative path with the size and mtime it was computed from: a file
// whose size and mtime are unchanged is never read again, only stat'ed. The
// cache is machine-local and never travels in a sync.
const crypto = require('crypto')
const fs = require('fs')
const path = require('path')
// BLAKE2b resists collisions like SHA-2 but is noticeably faster. Older
// runtimes fall back to SHA-2.
const HASH_ALGO = (() => {
for (const algo of ['blake2b512', 'sha512', 'sha256']) {
try {
crypto.createHash(algo)
return algo
} catch {}
}
return 'sha256'
})()
// Streaming through one callback instead of accumulating 'data' chunks keeps
// memory flat for huge files, and a small highWaterMark gives the read pump
// real backpressure. Digest speed does not care about the chunk size.
function hashFile(file, onBytes) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash(HASH_ALGO)
const stream = fs.createReadStream(file, { highWaterMark: 64 * 1024 })
stream.on('error', reject)
if (onBytes) stream.on('data', chunk => onBytes(chunk.length))
stream.on('data', chunk => hash.update(chunk))
stream.on('end', () => resolve(hash.digest('hex')))
stream.resume()
})
}
// A quarter million entries is a whole game library; beyond that, entries the
// longest time untouched are dropped first.
const HASH_CACHE_MAX = 250000
// Mid-sync saves are throttled to this (the file can hold tens of megabytes);
// callers that must not lose entries (sync end, shutdown) pass force=true.
const SAVE_MIN_MS = 60000
function createHashCache(dataDir) {
const FILE = path.join(dataDir, 'lansync-hashes.json')
let cache = null
let dirty = false
let savedAt = 0
const stats = { hits: 0, misses: 0 }
function load() {
if (cache) return cache
try {
const stored = JSON.parse(fs.readFileSync(FILE, 'utf8'))
cache = stored && typeof stored === 'object' && stored.files && typeof stored.files === 'object' ? stored.files : {}
} catch {
cache = {}
}
return cache
}
// Compact JSON (the entries are arrays) keeps a full cache at tens of
// milliseconds instead of the seconds pretty-printed JSON needs.
function save(force) {
if (!dirty || !cache) return
if (!force && Date.now() - savedAt < SAVE_MIN_MS) return
const names = Object.keys(cache)
if (names.length > HASH_CACHE_MAX) {
names.sort((a, b) => (cache[b][3] || 0) - (cache[a][3] || 0))
const trimmed = {}
for (const name of names.slice(0, HASH_CACHE_MAX)) trimmed[name] = cache[name]
cache = trimmed
}
dirty = false
savedAt = Date.now()
try {
fs.mkdirSync(path.dirname(FILE), { recursive: true })
const tmp = `${FILE}.tmp`
fs.writeFileSync(tmp, JSON.stringify({ v: 1, algo: HASH_ALGO, files: cache }))
fs.renameSync(tmp, FILE)
} 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 hit(rel, size, mtimeMs) {
const known = load()[rel]
if (!known || known[0] !== size || known[1] !== mtimeMs || typeof known[2] !== 'string') {
stats.misses++
return null
}
stats.hits++
if (Date.now() - (known[3] || 0) > 86400000) {
known[3] = Date.now()
dirty = true
}
return known[2]
}
function remember(rel, size, mtimeMs, hash) {
load()[rel] = [size, mtimeMs, hash, Date.now()]
dirty = true
}
// Digest 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. Otherwise the file is read (progress through onBytes) and stored.
async function cachedHash(rel, abs, size, mtimeMs, onBytes) {
const known = hit(rel, size, mtimeMs)
if (known) return known
const hash = await hashFile(abs, onBytes)
if (hash) remember(rel, size, mtimeMs, hash)
return hash
}
// The user can drop the cache from the settings view; a corrupted or stale
// file is also replaced wholesale this way.
function clear() {
cache = null
dirty = false
savedAt = 0
stats.hits = 0
stats.misses = 0
try {
fs.rmSync(FILE, { force: true })
fs.rmSync(`${FILE}.tmp`, { force: true })
} catch {}
}
// Dropping the in-memory copy makes the next lookup re-read the file from
// disk - what tests use to prove a file was hashed instead of remembered.
function forget() {
cache = null
dirty = false
}
return {
HASH_ALGO,
hashFile,
hit,
remember,
cachedHash,
save,
clear,
forget,
stats: () => ({ ...stats, size: cache ? Object.keys(cache).length : 0, algo: HASH_ALGO }),
resetStats: () => {
stats.hits = 0
stats.misses = 0
}
}
}
module.exports = { createHashCache, hashFile, HASH_ALGO }

View file

@ -68,18 +68,9 @@ function registerIpc({ getWindow, onGamesChanged }) {
ipcMain.handle('settings:set', (e, patch) => library.saveSettings(patch))
// wines ----------------------------------------------------------------------
function systemWine() {
let version = ''
try {
const { execFileSync } = require('child_process')
version = String(execFileSync('wine', ['--version'], { timeout: 15000 })).trim().replace(/^wine-?/i, '')
} catch {}
return { id: 'system', type: 'system', label: 'System Wine', version }
}
ipcMain.handle('wines:list', async () => {
await wines.refreshWines()
return [systemWine(), ...wines.getWines()]
return [wines.systemWine(), ...wines.getWines()]
})
ipcMain.handle('wines:refresh', async () => (await wines.refreshWines()).filter(w => !w.disabled))
ipcMain.handle('wines:setDisabled', (e, id, disabled) => wines.setWineDisabled(id, disabled))

View file

@ -19,6 +19,10 @@ function registerLanSyncIpc({ getWindow }) {
lansync.forgetDevice(id)
return { ok: true }
})
ipcMain.handle('lansync:clearHashCache', () => {
lansync.clearHashCache()
return { ok: true }
})
ipcMain.handle('lansync:respondIncoming', (e, requestId, accept) => lansync.respondIncoming(requestId, accept))
ipcMain.handle('lansync:sync', async (e, payload) => {
try {

135
src/lansync-discovery.js Normal file
View file

@ -0,0 +1,135 @@
'use strict'
// UDP discovery for the LAN sync: every running PLWL beacons on the local
// network and listens for the same beacon, so the devices list fills itself.
// The beacon doubles as a liveness signal - a device drops off the list a few
// seconds after its PLWL is closed.
const dgram = require('dgram')
const os = require('os')
// Broadcast to the global address AND to every interface's own subnet
// broadcast - some networks drop one or the other.
function broadcastAddresses() {
const out = ['255.255.255.255']
const interfaces = os.networkInterfaces()
for (const list of Object.values(interfaces)) {
for (const iface of list || []) {
if (iface.family !== 'IPv4' || iface.internal || !iface.netmask) continue
const addr = ipToInt(iface.address)
const mask = ipToInt(iface.netmask)
if (addr == null || mask == null) continue
out.push(intToIp((addr | (~mask >>> 0)) >>> 0))
}
}
return out
}
function ipToInt(ip) {
const parts = String(ip).split('.')
if (parts.length !== 4) return null
let out = 0
for (const part of parts) {
const n = Number(part)
if (!Number.isInteger(n) || n < 0 || n > 255) return null
out = (out << 8) | n
}
return out >>> 0
}
function intToIp(int) {
return [24, 16, 8, 0].map(shift => (int >>> shift) & 255).join('.')
}
function createDiscovery({ identity, tag, protocol, port, beaconMs, deviceTtlMs, getPort }) {
let socket = null
let timer = null
let devicesCb = null
// id -> { id, name, address, port, at }
const seen = new Map()
function listDevices() {
const known = identity().known
const now = Date.now()
return [...seen.values()]
.filter(d => now - d.at <= deviceTtlMs)
.map(d => {
const pair = known[d.id]
return { id: d.id, name: d.name, address: d.address, port: d.port, paired: !!pair, code: pair ? pair.code : '' }
})
.sort((a, b) => String(a.name).localeCompare(String(b.name)))
}
function emitDevices() {
if (devicesCb) devicesCb(listDevices())
}
function pruneDevices() {
const now = Date.now()
let changed = false
for (const [id, device] of seen) {
if (now - device.at > deviceTtlMs) {
seen.delete(id)
changed = true
}
}
if (changed) emitDevices()
}
function beacon() {
if (!socket || !getPort()) return
const me = identity()
const payload = Buffer.from(JSON.stringify({ t: tag, v: protocol, id: me.deviceId, name: os.hostname(), port: getPort() }))
for (const address of broadcastAddresses()) {
try {
socket.send(payload, 0, payload.length, port, address)
} catch {}
}
pruneDevices()
}
function start(onDevices) {
stop()
devicesCb = onDevices
socket = dgram.createSocket({ type: 'udp4', reuseAddr: true })
socket.on('error', () => stop())
socket.on('message', (buf, rinfo) => {
let msg = null
try {
msg = JSON.parse(buf.toString('utf8'))
} catch {}
if (!msg || msg.t !== tag || msg.v !== protocol || !msg.id) return
if (msg.id === identity().deviceId) return // our own broadcast
const previous = seen.get(msg.id)
const next = { id: msg.id, name: String(msg.name || rinfo.address), address: rinfo.address, port: Number(msg.port) || 0, at: Date.now() }
const changed = !previous || previous.address !== next.address || previous.port !== next.port || previous.name !== next.name
seen.set(msg.id, next)
if (changed) emitDevices()
})
socket.bind(port, () => {
try {
socket.setBroadcast(true)
} catch {}
beacon()
timer = setInterval(beacon, beaconMs)
})
}
function stop() {
if (timer) {
clearInterval(timer)
timer = null
}
if (socket) {
const closing = socket
socket = null
try {
closing.close()
} catch {}
}
}
return { start, stop, listDevices }
}
module.exports = { createDiscovery }

133
src/lansync-identity.js Normal file
View file

@ -0,0 +1,133 @@
'use strict'
// Device identity for the LAN sync: the id this install is known by, the code
// other devices must type to pair with it, and the list of paired devices.
// Exported as a factory over the config file, so every launcher state (and
// every test device) gets its own identity.
const crypto = require('crypto')
const { readJson, writeJson } = require('./paths')
// The sync code is read out on one machine and typed on the other, so the
// alphabet skips the characters people mix up (0/O, 1/I/L).
const CODE_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'
const ID_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789'
function randomChars(alphabet, count) {
let out = ''
while (out.length < count) {
for (const byte of crypto.randomBytes(count)) {
if (out.length >= count) break
out += alphabet[byte % alphabet.length]
}
}
return out
}
// 24 characters: the current timestamp in base36 (unique per millisecond,
// 8 chars until 2089) plus random padding, so two machines generating an id
// within the same millisecond still get different ids.
function makeDeviceId() {
const stamp = Date.now().toString(36)
return (stamp + randomChars(ID_ALPHABET, Math.max(0, 24 - stamp.length))).slice(0, 24)
}
function makeSyncCode() {
return randomChars(CODE_ALPHABET, 8)
}
function createIdentity(configFile) {
// The identity file is read once and then kept in memory: the beacon calls
// identity() every couple of seconds (and every pairing call in between),
// and a synchronous read per call is pure overhead. Nothing else writes
// this file (the ipc handlers all go through these functions), so the cache
// cannot go stale.
let cached = null
function readConfig() {
if (cached) return cached
const cfg = readJson(configFile, {})
cached = cfg && typeof cfg === 'object' ? cfg : {}
return cached
}
// The id and the code are generated on FIRST use and then kept forever:
// other devices remember the id (they pair with it) and the code (so it
// only has to be typed once).
function identity() {
const cfg = readConfig()
let changed = false
if (typeof cfg.deviceId !== 'string' || cfg.deviceId.length !== 24) {
cfg.deviceId = makeDeviceId()
changed = true
}
if (typeof cfg.syncCode !== 'string' || cfg.syncCode.length !== 8) {
cfg.syncCode = makeSyncCode()
changed = true
}
if (!cfg.known || typeof cfg.known !== 'object') {
cfg.known = {}
changed = true
}
if (changed) writeJson(configFile, cfg)
return cfg
}
// Both rotations return the whole identity ({ deviceId, syncCode, known }),
// so callers get a consistent view of this device after the change.
function rotateDeviceId() {
const cfg = identity()
const previous = cfg.deviceId
cfg.deviceId = makeDeviceId()
writeJson(configFile, cfg)
if (cfg.deviceId === previous) return rotateDeviceId()
return cfg
}
function rotateSyncCode() {
const cfg = identity()
const previous = cfg.syncCode
cfg.syncCode = makeSyncCode()
writeJson(configFile, cfg)
if (cfg.syncCode === previous) return rotateSyncCode() // never a no-op rotation
return cfg
}
// A device the user synced with successfully: its code is stored next to
// its id, so the next sync needs no typing. A rejected code is forgotten
// again, which is what drives the "type the code again" flow.
function knownDevice(id) {
return identity().known[id] || null
}
function knownPeers() {
return identity().known || {}
}
function rememberDevice(id, code, name) {
const cfg = identity()
const stored = String(code || '').trim().toUpperCase()
cfg.known = { ...cfg.known, [id]: { code: stored, syncCode: stored, name: name || '', at: Date.now() } }
writeJson(configFile, cfg)
}
function forgetDevice(id) {
const cfg = identity()
const known = { ...cfg.known }
delete known[id]
cfg.known = known
writeJson(configFile, cfg)
}
return { identity, rotateDeviceId, rotateSyncCode, knownDevice, knownPeers, rememberDevice, forgetDevice, makeDeviceId, makeSyncCode }
}
// Compare two typed codes without leaking timing: the codes are short, but a
// device may only verify once per two seconds anyway.
function sameCode(a, b) {
const x = Buffer.from(String(a == null ? '' : a).trim().toUpperCase())
const y = Buffer.from(String(b == null ? '' : b).trim().toUpperCase())
return x.length === y.length && x.length > 0 && crypto.timingSafeEqual(x, y)
}
module.exports = { createIdentity, sameCode, makeDeviceId, makeSyncCode }

152
src/lansync-manifest.js Normal file
View file

@ -0,0 +1,152 @@
'use strict'
// Sender-side hashing: walking sync units into a manifest of dirs, links and
// files, with the digest of every file. Exported as a factory over the path
// guards and the hash store, so every launcher state (and every test device)
// builds manifests for its own files.
const path = require('path')
const fs = require('fs')
const fsp = fs.promises
function createManifestBuilder({ filesPath, filesRoot, hash, mapLimit, concurrency }) {
// Symlink targets travel RELATIVE whenever they point inside the launcher
// data (a prefix linking into its wine build, an app linking to its own
// assets): the mirrored trees have the same internal layout on both
// machines, so a relative target is correct over there while an absolute one
// would point at the sender's own folders - which is exactly how a synced
// prefix ends up with hundreds of dangling links. Targets outside files/
// (a system font, a mounted drive, /) are machine-specific and stay as they
// are.
function portableLinkTarget(linkAbs, target) {
if (typeof target !== 'string' || !target || !path.isAbsolute(target)) return target
const rel = path.relative(path.dirname(linkAbs), target)
if (!rel || path.isAbsolute(rel)) return target
const root = path.resolve(filesRoot)
const resolved = path.resolve(path.dirname(linkAbs), rel)
if (resolved !== root && !resolved.startsWith(root + path.sep)) return target
return rel
}
// Walk without following symlinks: builds and prefixes contain links that
// are part of what is being synced (dosdevices, build internals), and
// following them would both loop and copy the wrong thing.
//
// Iterative (a queue instead of an await-per-directory recursion) and with
// the stat/readlink calls of one directory in flight together: walking a
// prefix is dominated by those two syscalls, and serializing them costs more
// than the readdir itself. Regular files are re-verified with one stat
// (size, mode, mtime feed both the manifest and the hash cache), everything
// else is skipped.
async function scanTree(abs, rel, out, mtimes) {
const queue = [[abs, rel]]
while (queue.length) {
const [dirAbs, dirRel] = queue.pop()
let entries
try {
entries = await fsp.readdir(dirAbs, { withFileTypes: true })
} catch {
continue
}
const dirs = []
const leaves = []
for (const entry of entries) {
const relChild = `${dirRel}/${entry.name}`
if (entry.isDirectory()) {
out.push({ path: relChild, type: 'dir' })
dirs.push([path.join(dirAbs, entry.name), relChild])
} else {
leaves.push(entry)
}
}
await mapLimit(leaves, concurrency, async entry => {
const absChild = path.join(dirAbs, entry.name)
const relChild = `${dirRel}/${entry.name}`
if (entry.isSymbolicLink()) {
let target = ''
try {
target = await fsp.readlink(absChild)
} catch {}
out.push({ path: relChild, type: 'link', target: portableLinkTarget(absChild, target) })
return
}
let st
try {
st = await fsp.stat(absChild)
} catch {
return
}
if (!st.isFile()) return
// mode travels with the file: a wine build's `proton`/`wine` binary is
// useless on the other side without its executable bit
out.push({ path: relChild, type: 'file', size: st.size, mode: st.mode & 0o777, hash: null })
// kept out of the manifest: only this machine can use its own mtimes
if (mtimes) mtimes.set(relChild, st.mtimeMs || 0)
})
queue.push(...dirs)
}
}
// Manifest of one sync unit. `force` skips hashing entirely - the receiver
// then treats every file as different ("force sync all files").
async function buildManifest(roots, force, onProgress) {
const entries = []
const mtimes = new Map()
for (const rel of roots) {
const abs = filesPath(rel)
if (!abs) continue
let st
try {
st = await fsp.lstat(abs)
} catch {
continue
}
if (st.isSymbolicLink()) {
let target = ''
try {
target = await fsp.readlink(abs)
} catch {}
entries.push({ path: rel, type: 'link', target: portableLinkTarget(abs, target) })
} else if (st.isDirectory()) {
entries.push({ path: rel, type: 'dir' })
await scanTree(abs, rel, entries, mtimes)
} else if (st.isFile()) {
entries.push({ path: rel, type: 'file', size: st.size, mode: st.mode & 0o777, hash: null })
mtimes.set(rel, st.mtimeMs || 0)
}
}
const files = entries.filter(e => e.type === 'file')
// the unit's total size is known before a byte is hashed, so the bar can
// start empty and fill with the bytes actually read
const totalBytes = files.reduce((sum, entry) => sum + (entry.size || 0), 0)
if (!force && files.length) {
let done = 0
let hashed = 0
await mapLimit(files, concurrency, async entry => {
const abs = filesPath(entry.path)
try {
// 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
entry.hash = abs
? await hash.cachedHash(entry.path, abs, entry.size, mtimeMs, bytes => {
hashed += bytes
if (onProgress) onProgress(done, files.length, hashed, totalBytes)
})
: null
} catch {
entry.hash = null
}
done++
if (!entry.hash) hashed += entry.size || 0 // nothing read: still report the file as passed over
if (onProgress) onProgress(done, files.length, hashed, totalBytes)
})
}
hash.save()
return entries
}
return { buildManifest, scanTree, portableLinkTarget }
}
module.exports = { createManifestBuilder }

View file

@ -7,7 +7,6 @@
// behind, and paths stored in the registries stay relative as they are.
const http = require('http')
const dgram = require('dgram')
const crypto = require('crypto')
const os = require('os')
const path = require('path')
@ -17,10 +16,15 @@ const readline = require('readline')
const { DIRS, readJson, writeJson, toPortablePath } = require('./paths')
const { rewireSymlinks, restoreExecutableBits } = require('./symlinks')
const { progressGate } = require('./fsutil')
const { progressGate, mapLimit } = require('./fsutil')
const { createHashCache } = require('./hashcache')
const { createIdentity, sameCode } = require('./lansync-identity')
const { createManifestBuilder } = require('./lansync-manifest')
const { createDiscovery } = require('./lansync-discovery')
const library = require('./library')
const wines = require('./wines')
const prefixes = require('./prefixes')
const { isPrefixRel } = prefixes
const CONFIG = path.join(DIRS.DATA, 'lansync.json')
@ -39,7 +43,9 @@ const DEVICE_TTL_MS = 6000
// flight - together they make guessing an 8 character code over the LAN
// pointless (and a failed guess cannot be retried in a tight loop).
const VERIFY_DELAY_MS = 2000
const TOKEN_TTL_MS = 30 * 60 * 1000
// How long a session token stays valid without any request. Env override for
// tests, which cannot wait half an hour to prove expiry.
const TOKEN_TTL_MS = Number(process.env.PLWL_LANSYNC_TOKEN_TTL) || 30 * 60 * 1000
const SESSION_IDLE_MS = 5 * 60 * 1000
// How long the receiving user has to click Accept before the request expires:
// the sender waits for this decision, so its own verify timeout has to be
@ -100,7 +106,6 @@ function appCategory(game) {
// inside its prefix ("drive_c/..." for plain wine, "pfx/drive_c/..." for
// Proton-managed prefixes) and anything absolute or ".." points outside the
// launcher data, so it is never part of a sync.
const PREFIX_REL_RE = /^(pfx[/\\])?drive_c([/\\]|$)/i
function classifyApp(app) {
if (!app || (typeof app !== 'string' && typeof app !== 'object')) return null
const raw = typeof app === 'string' ? app : app.path || app.exePath || app.workdir
@ -109,7 +114,7 @@ function classifyApp(app) {
if (!raw || typeof raw !== 'string') return 'external'
const value = raw.replace(/\\/g, '/')
const outside = value.startsWith('..') || path.isAbsolute(raw) || /^[a-z]:\//i.test(value) || /^[a-z][a-z0-9+.-]*:\/\//i.test(value)
if (PREFIX_REL_RE.test(value)) return 'prefix'
if (isPrefixRel(value)) return 'prefix'
if (/^apps\//.test(value)) return 'portable'
return outside ? 'external' : null
}
@ -125,248 +130,21 @@ function normalizeCategories(list) {
// --- identity ---------------------------------------------------------------
// The sync code is read out on one machine and typed on the other, so the
// alphabet skips the characters people mix up (0/O, 1/I/L).
const CODE_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'
const ID_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789'
function randomChars(alphabet, count) {
let out = ''
while (out.length < count) {
for (const byte of crypto.randomBytes(count)) {
if (out.length >= count) break
out += alphabet[byte % alphabet.length]
}
}
return out
}
// 24 characters: the current timestamp in base36 (unique per millisecond,
// 8 chars until 2089) plus random padding, so two machines generating an id
// within the same millisecond still get different ids.
function makeDeviceId() {
const stamp = Date.now().toString(36)
return (stamp + randomChars(ID_ALPHABET, Math.max(0, 24 - stamp.length))).slice(0, 24)
}
function makeSyncCode() {
return randomChars(CODE_ALPHABET, 8)
}
// The identity file is read once and then kept in memory: the beacon calls
// identity() every couple of seconds (and every pairing call in between), and
// a synchronous read per call is pure overhead. Nothing else writes this file
// (the ipc handlers all go through the same functions), so the cache cannot
// go stale.
let identityCache = null
function readConfig() {
if (identityCache) return identityCache
const cfg = readJson(CONFIG, {})
identityCache = cfg && typeof cfg === 'object' ? cfg : {}
return identityCache
}
// The id and the code are generated on FIRST use and then kept forever:
// other devices remember the id (they pair with it) and the code (so it only
// has to be typed once).
function identity() {
const cfg = readConfig()
let changed = false
if (typeof cfg.deviceId !== 'string' || cfg.deviceId.length !== 24) {
cfg.deviceId = makeDeviceId()
changed = true
}
if (typeof cfg.syncCode !== 'string' || cfg.syncCode.length !== 8) {
cfg.syncCode = makeSyncCode()
changed = true
}
if (!cfg.known || typeof cfg.known !== 'object') {
cfg.known = {}
changed = true
}
if (changed) writeJson(CONFIG, cfg)
return cfg
}
// Both rotations return the whole identity ({ deviceId, syncCode, known }),
// so callers get a consistent view of this device after the change.
function rotateDeviceId() {
const cfg = identity()
const previous = cfg.deviceId
cfg.deviceId = makeDeviceId()
writeJson(CONFIG, cfg)
if (cfg.deviceId === previous) return rotateDeviceId()
return cfg
}
function rotateSyncCode() {
const cfg = identity()
const previous = cfg.syncCode
cfg.syncCode = makeSyncCode()
writeJson(CONFIG, cfg)
if (cfg.syncCode === previous) return rotateSyncCode() // never a no-op rotation
return cfg
}
// A device the user synced with successfully: its code is stored next to its
// id, so the next sync needs no typing. A rejected code is forgotten again,
// which is what drives the "type the code again" flow.
function knownDevice(id) {
return identity().known[id] || null
}
// Devices this launcher paired with: id -> { code, name, at }. The code is the
// one that was accepted, so later syncs need no typing.
function knownPeers() {
return identity().known || {}
}
function rememberDevice(id, code, name) {
const cfg = identity()
const stored = String(code || '').trim().toUpperCase()
cfg.known = { ...cfg.known, [id]: { code: stored, syncCode: stored, name: name || '', at: Date.now() } }
writeJson(CONFIG, cfg)
}
function forgetDevice(id) {
const cfg = identity()
const known = { ...cfg.known }
delete known[id]
cfg.known = known
writeJson(CONFIG, cfg)
}
function sameCode(a, b) {
const x = Buffer.from(String(a == null ? '' : a).trim().toUpperCase())
const y = Buffer.from(String(b == null ? '' : b).trim().toUpperCase())
return x.length === y.length && x.length > 0 && crypto.timingSafeEqual(x, y)
}
const identityApi = createIdentity(CONFIG)
const { identity, rotateDeviceId, rotateSyncCode, knownDevice, knownPeers, rememberDevice, forgetDevice, makeDeviceId, makeSyncCode } = identityApi
// --- files: hashing, paths, manifests ---------------------------------------
// BLAKE2b resists collisions like SHA-2 but is noticeably faster - syncing a
// wine prefix means hashing tens of thousands of files, so this is the hot
// path. Older runtimes fall back to SHA-2.
const HASH_ALGO = (() => {
for (const algo of ['blake2b512', 'sha512', 'sha256']) {
try {
crypto.createHash(algo)
return algo
} catch {}
}
return 'sha256'
})()
// Hashing a tree is dominated by opening the (often tiny) files, not by the
// digest itself: a few in flight at once keeps the disk and the cpu busy
// instead of waiting on one file at a time.
const HASH_CONCURRENCY = Math.max(4, Math.min(16, (os.cpus() || []).length || 4))
// Hash results are remembered per file (size + mtime), so a library that did
// not change since the last sync is never read again - only stat'ed.
const HASH_CACHE_FILE = path.join(DIRS.DATA, 'lansync-hashes.json')
const HASH_CACHE_MAX = 250000
let hashCache = null
let hashCacheDirty = false
let hashCacheSavedAt = 0
const hashStats = { hits: 0, misses: 0 }
function hashCacheStats() {
return { ...hashStats, size: hashCache ? Object.keys(hashCache).length : 0, algo: HASH_ALGO }
}
function resetHashCacheStats() {
hashStats.hits = 0
hashStats.misses = 0
}
// Dropping the in-memory copy makes the next lookup re-read the file from
// disk - what tests use to prove a file was hashed instead of remembered.
function forgetHashCache() {
hashCache = null
hashCacheDirty = false
}
function loadHashCache() {
if (!hashCache) {
const stored = readJson(HASH_CACHE_FILE, null)
hashCache = stored && typeof stored === 'object' && stored.files && typeof stored.files === 'object' ? stored.files : {}
}
return hashCache
}
// Written once per operation at most: this file can hold a quarter million
// entries, and rewriting it per hashed file would cost more than the hashing.
// Compact JSON (the entries are already arrays) keeps a full cache at tens of
// milliseconds instead of the seconds pretty-printed JSON needs.
function saveHashCache() {
if (!hashCacheDirty || !hashCache) return
const now = Date.now()
if (now - hashCacheSavedAt < 60000) return
const names = Object.keys(hashCache)
if (names.length > HASH_CACHE_MAX) {
names.sort((a, b) => (hashCache[b][3] || 0) - (hashCache[a][3] || 0))
const trimmed = {}
for (const name of names.slice(0, HASH_CACHE_MAX)) trimmed[name] = hashCache[name]
hashCache = trimmed
}
hashCacheDirty = false
hashCacheSavedAt = now
try {
fs.mkdirSync(path.dirname(HASH_CACHE_FILE), { recursive: true })
fs.writeFileSync(HASH_CACHE_FILE, JSON.stringify({ v: 1, algo: HASH_ALGO, files: hashCache }))
} 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 known = hashCacheHit(rel, size, mtimeMs)
if (known) return Promise.resolve(known)
return hashFile(abs).then(hash => {
if (hash) {
loadHashCache()[rel] = [size, mtimeMs, hash, Date.now()]
hashCacheDirty = true
}
return hash
})
}
// Streaming through one callback instead of accumulating 'data' chunks keeps
// memory flat for huge files, and a small highWaterMark gives the read pump
// real backpressure. Digest speed does not care about the chunk size, so the
// old 1 MB reads only ever paid for themselves in memory, not in time.
function hashFile(file, onBytes) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash(HASH_ALGO)
const stream = fs.createReadStream(file, { highWaterMark: 64 * 1024 })
stream.on('error', reject)
if (onBytes) stream.on('data', chunk => onBytes(chunk.length))
stream.on('data', chunk => hash.update(chunk))
stream.on('end', () => resolve(hash.digest('hex')))
stream.resume()
})
}
// Digests are remembered per file (size + mtime), so a library that did not
// change since the last sync is never read again - only stat'ed. One store per
// launcher state (tests re-require this module per device).
const hashStore = createHashCache(DIRS.DATA)
const hashFile = hashStore.hashFile
// Protocol paths are FILES-relative with '/' separators. Anything escaping
// files/ (a '..' segment, an absolute path) is rejected - manifests come from
@ -386,163 +164,10 @@ function ownedRoot(rel) {
return SYNC_ROOTS.some(prefix => clean.startsWith(prefix)) ? clean : null
}
async function mapLimit(items, limit, fn) {
const out = new Array(items.length)
let next = 0
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (next < items.length) {
const i = next++
out[i] = await fn(items[i], i)
}
})
await Promise.all(workers)
return out
}
// --- manifests (the sender's hashing) ---------------------------------------
// Symlink targets travel RELATIVE whenever they point inside the launcher data
// (a prefix linking into its wine build, an app linking to its own assets): the
// mirrored trees have the same internal layout on both machines, so a relative
// target is correct over there while an absolute one would point at the
// sender's own folders - which is exactly how a synced prefix ends up with
// hundreds of dangling links. Targets outside files/ (a system font, a mounted
// drive, /) are machine-specific and stay as they are.
function portableLinkTarget(linkAbs, target) {
if (typeof target !== 'string' || !target || !path.isAbsolute(target)) return target
const rel = path.relative(path.dirname(linkAbs), target)
if (!rel || path.isAbsolute(rel)) return target
const root = path.resolve(DIRS.FILES)
const resolved = path.resolve(path.dirname(linkAbs), rel)
if (resolved !== root && !resolved.startsWith(root + path.sep)) return target
return rel
}
// Walk without following symlinks: builds and prefixes contain links that are
// part of what is being synced (dosdevices, build internals), and following
// them would both loop and copy the wrong thing.
//
// Iterative (a queue instead of an await-per-directory recursion) and with the
// stat/readlink calls of one directory in flight together: walking a prefix is
// dominated by those two syscalls, and serializing them costs more than the
// readdir itself. Regular files are re-verified with one stat (size, mode,
// mtime feed both the manifest and the hash cache), everything else is skipped.
const SCAN_CONCURRENCY = Math.max(4, HASH_CONCURRENCY)
async function scanTree(abs, rel, out, mtimes) {
const queue = [[abs, rel]]
while (queue.length) {
const [dirAbs, dirRel] = queue.pop()
let entries
try {
entries = await fsp.readdir(dirAbs, { withFileTypes: true })
} catch {
continue
}
const dirs = []
const leaves = []
for (const entry of entries) {
const relChild = `${dirRel}/${entry.name}`
if (entry.isDirectory()) {
out.push({ path: relChild, type: 'dir' })
dirs.push([path.join(dirAbs, entry.name), relChild])
} else {
leaves.push(entry)
}
}
await mapLimit(leaves, SCAN_CONCURRENCY, async entry => {
const absChild = path.join(dirAbs, entry.name)
const relChild = `${dirRel}/${entry.name}`
if (entry.isSymbolicLink()) {
let target = ''
try {
target = await fsp.readlink(absChild)
} catch {}
out.push({ path: relChild, type: 'link', target: portableLinkTarget(absChild, target) })
return
}
let st
try {
st = await fsp.stat(absChild)
} catch {
return
}
if (!st.isFile()) return
// mode travels with the file: a wine build's `proton`/`wine` binary is
// useless on the other side without its executable bit
out.push({ path: relChild, type: 'file', size: st.size, mode: st.mode & 0o777, hash: null })
// kept out of the manifest: only this machine can use its own mtimes
if (mtimes) mtimes.set(relChild, st.mtimeMs || 0)
})
queue.push(...dirs)
}
}
// Manifest of one sync unit. `force` skips hashing entirely - the receiver
// then treats every file as different ("force sync all files").
async function buildManifest(roots, force, onProgress) {
const entries = []
const mtimes = new Map()
for (const rel of roots) {
const abs = filesPath(rel)
if (!abs) continue
let st
try {
st = await fsp.lstat(abs)
} catch {
continue
}
if (st.isSymbolicLink()) {
let target = ''
try {
target = await fsp.readlink(abs)
} catch {}
entries.push({ path: rel, type: 'link', target: portableLinkTarget(abs, target) })
} else if (st.isDirectory()) {
entries.push({ path: rel, type: 'dir' })
await scanTree(abs, rel, entries, mtimes)
} else if (st.isFile()) {
entries.push({ path: rel, type: 'file', size: st.size, mode: st.mode & 0o777, hash: null })
mtimes.set(rel, st.mtimeMs || 0)
}
}
const files = entries.filter(e => e.type === 'file')
// the unit's total size is known before a byte is hashed, so the bar can
// start empty and fill with the bytes actually read
const totalBytes = files.reduce((sum, entry) => sum + (entry.size || 0), 0)
if (!force && files.length) {
let done = 0
let hashed = 0
await mapLimit(files, HASH_CONCURRENCY, async entry => {
const abs = filesPath(entry.path)
try {
// 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 = hashCacheHit(entry.path, entry.size, mtimeMs)
if (known && abs) {
entry.hash = known
} else {
entry.hash = abs
? await hashFile(abs, bytes => {
hashed += bytes
if (onProgress) onProgress(done, files.length, hashed, totalBytes)
})
: null
if (entry.hash) {
loadHashCache()[entry.path] = [entry.size, mtimeMs, entry.hash, Date.now()]
hashCacheDirty = true
}
}
} catch {
entry.hash = null
}
done++
if (!entry.hash) hashed += entry.size || 0 // nothing read: still report the file as passed over
if (onProgress) onProgress(done, files.length, hashed, totalBytes)
})
}
saveHashCache()
return entries
}
const manifest = createManifestBuilder({ filesPath, filesRoot: DIRS.FILES, hash: hashStore, mapLimit, concurrency: HASH_CONCURRENCY })
const buildManifest = manifest.buildManifest
// --- HTTP helpers (shared with the pusher) ----------------------------------
@ -699,7 +324,7 @@ async function entrySame(entry) {
if (!st.isFile() || st.size !== entry.size) return false
let same = false
try {
same = (await cachedHash(entry.path, abs, st.size, st.mtimeMs || 0)) === entry.hash
same = (await hashStore.cachedHash(entry.path, abs, st.size, st.mtimeMs || 0)) === entry.hash
} catch {
return false
}
@ -1194,6 +819,13 @@ function authorize(state, token) {
return null
}
session.lastAt = Date.now()
// The expiry SLIDES with every request: hashing a big unit takes longer than
// any fixed TTL, and the request that finally moves files would arrive on a
// token that died while the sender was still hashing - the sync then ends
// right when it is ready to send. The heartbeat keeps the token alive through
// such a phase; a sender that really vanished still ages out this long after
// its last request.
session.expires = Date.now() + TOKEN_TTL_MS
if (state.session && state.session.token === token) state.session.lastAt = session.lastAt
return session
}
@ -1371,7 +1003,7 @@ async function handlePlan(state, session, url, req, res, hooks) {
announce({ unit: label })
return same === true
})
saveHashCache()
hashStore.save()
const paths = new Set()
const upload = []
const same = []
@ -1533,128 +1165,22 @@ function handlePing(session, res, hooks) {
// --- discovery --------------------------------------------------------------
let beaconSocket = null
let beaconTimer = null
let httpPort = 0
let seen = new Map()
let devicesCb = null
function ipToInt(ip) {
const parts = String(ip).split('.')
if (parts.length !== 4) return null
let out = 0
for (const part of parts) {
const n = Number(part)
if (!Number.isInteger(n) || n < 0 || n > 255) return null
out = (out << 8) | n
}
return out >>> 0
}
function intToIp(int) {
return [24, 16, 8, 0].map(shift => (int >>> shift) & 255).join('.')
}
// Broadcast to the global address AND to every interface's own subnet
// broadcast - some networks drop one or the other.
function broadcastAddresses() {
const out = ['255.255.255.255']
const interfaces = os.networkInterfaces()
for (const list of Object.values(interfaces)) {
for (const iface of list || []) {
if (iface.family !== 'IPv4' || iface.internal || !iface.netmask) continue
const addr = ipToInt(iface.address)
const mask = ipToInt(iface.netmask)
if (addr == null || mask == null) continue
out.push(intToIp((addr | (~mask >>> 0)) >>> 0))
}
}
return out
}
function listDevices() {
const known = identity().known
const now = Date.now()
return [...seen.values()]
.filter(d => now - d.at <= DEVICE_TTL_MS)
.map(d => {
const pair = known[d.id]
return { id: d.id, name: d.name, address: d.address, port: d.port, paired: !!pair, code: pair ? pair.code : '' }
})
.sort((a, b) => String(a.name).localeCompare(String(b.name)))
}
function emitDevices() {
if (devicesCb) devicesCb(listDevices())
}
function pruneDevices() {
const now = Date.now()
let changed = false
for (const [id, device] of seen) {
if (now - device.at > DEVICE_TTL_MS) {
seen.delete(id)
changed = true
}
}
if (changed) emitDevices()
}
function beacon() {
if (!beaconSocket || !httpPort) return
const me = identity()
const payload = Buffer.from(JSON.stringify({ t: BEACON_TAG, v: PROTOCOL, id: me.deviceId, name: os.hostname(), port: httpPort }))
for (const address of broadcastAddresses()) {
try {
beaconSocket.send(payload, 0, payload.length, DISCOVERY_PORT, address)
} catch {}
}
pruneDevices()
}
function startDiscovery(onDevices) {
stopDiscovery()
devicesCb = onDevices
beaconSocket = dgram.createSocket({ type: 'udp4', reuseAddr: true })
beaconSocket.on('error', () => stopDiscovery())
beaconSocket.on('message', (buf, rinfo) => {
let msg = null
try {
msg = JSON.parse(buf.toString('utf8'))
} catch {}
if (!msg || msg.t !== BEACON_TAG || msg.v !== PROTOCOL || !msg.id) return
if (msg.id === identity().deviceId) return // our own broadcast
const previous = seen.get(msg.id)
const next = { id: msg.id, name: String(msg.name || rinfo.address), address: rinfo.address, port: Number(msg.port) || 0, at: Date.now() }
const changed = !previous || previous.address !== next.address || previous.port !== next.port || previous.name !== next.name
seen.set(msg.id, next)
if (changed) emitDevices()
})
beaconSocket.bind(DISCOVERY_PORT, () => {
try {
beaconSocket.setBroadcast(true)
} catch {}
beacon()
beaconTimer = setInterval(beacon, BEACON_MS)
})
}
function stopDiscovery() {
if (beaconTimer) {
clearInterval(beaconTimer)
beaconTimer = null
}
if (beaconSocket) {
const socket = beaconSocket
beaconSocket = null
try {
socket.close()
} catch {}
}
}
const discovery = createDiscovery({
identity,
tag: BEACON_TAG,
protocol: PROTOCOL,
port: DISCOVERY_PORT,
beaconMs: BEACON_MS,
deviceTtlMs: DEVICE_TTL_MS,
getPort: () => httpPort
})
const startDiscovery = discovery.start
const stopDiscovery = discovery.stop
const listDevices = discovery.listDevices
// --- service ----------------------------------------------------------------
let httpPort = 0
let httpServer = null
let serverHooks = {}
let listening = null // promise resolved once the receiving server is up
@ -1686,6 +1212,9 @@ function startServer(options = {}) {
}
function stopServer() {
// new digests must not die with the process: the throttled mid-sync saves
// may have skipped the last few units
hashStore.save(true)
if (httpServer) {
try {
httpServer.close()
@ -1705,7 +1234,7 @@ function stopServer() {
function start(hooks = {}) {
serverHooks = { ...serverHooks, ...hooks }
return startServer().then(info => {
if (!beaconSocket) startDiscovery(serverHooks.onDevices)
startDiscovery(serverHooks.onDevices)
return info
})
}
@ -1721,7 +1250,7 @@ function serverPort() {
function getState() {
const me = identity()
return { deviceId: me.deviceId, syncCode: me.syncCode, hash: HASH_ALGO, port: httpPort, devices: listDevices() }
return { deviceId: me.deviceId, syncCode: me.syncCode, hash: hashStore.HASH_ALGO, port: httpPort, devices: listDevices() }
}
// The pusher lives in its own module and is built with THIS module's helpers
@ -1810,11 +1339,12 @@ const exported = {
forgetDevice,
respondIncoming,
sameCode,
HASH_ALGO,
HASH_ALGO: hashStore.HASH_ALGO,
hashFile,
hashCacheStats,
resetHashCacheStats,
forgetHashCache,
hashCacheStats: hashStore.stats,
resetHashCacheStats: hashStore.resetStats,
forgetHashCache: hashStore.forget,
clearHashCache: hashStore.clear,
HASH_CONCURRENCY,
progressGate,
filesPath,

View file

@ -414,11 +414,16 @@ function createPusher(api) {
if (!plan.data || !plan.data.ok) throw new Error((plan.data && plan.data.error) || `http-${plan.status}`)
const upload = new Set(plan.data.upload || [])
const same = new Set(plan.data.same || [])
// per category every manifest entry counts: created/overwritten vs
// already identical (the plan reports both)
bucket.copied += entries.length - same.size
bucket.skipped += same.size
summary.skipped += same.size
// per category the FILES count: created/overwritten vs already
// identical (the plan reports both; dirs and links are re-ensured
// for free and are not files that moved)
const fileEntries = entries.filter(e => e.type === 'file')
const filePaths = new Set(fileEntries.map(e => e.path))
let sameFiles = 0
for (const p of same) if (filePaths.has(p)) sameFiles++
bucket.copied += fileEntries.length - sameFiles
bucket.skipped += sameFiles
summary.skipped += sameFiles
const pending = []
for (const entry of entries) {
if (entry.type !== 'file') continue

View file

@ -3,7 +3,7 @@
const path = require('path')
const fs = require('fs')
const { DIRS, readJson, writeJson, toPortablePath, resolvePortablePath } = require('./paths')
const { prefixPath } = require('./prefixes')
const { prefixPath, isPrefixRel } = require('./prefixes')
const REGISTRY = path.join(DIRS.DATA, 'apps.json')
const SETTINGS = path.join(DIRS.DATA, 'settings.json')
@ -29,12 +29,6 @@ function writePlaySeconds(id, seconds) {
fs.writeFileSync(file, String(Math.max(0, Math.round(seconds))))
}
// Prefix imports keep exe/workdir paths relative to the PREFIX ROOT
// ("drive_c/..." for plain wine, "pfx/drive_c/..." for Proton-managed
// prefixes) so wine sees them under C:\... - everything else is
// FILES-relative/absolute.
const isPrefixRel = v => typeof v === 'string' && /^(pfx[/\\])?drive_c([/\\]|$)/i.test(v)
function loadGames() {
const games = readJson(REGISTRY, [])
for (const g of games) {

View file

@ -7,6 +7,12 @@ const { rewireSymlinks } = require('./symlinks')
const { removeTree, treeSize } = require('./fsutil')
const { normalizePathName } = require('./names')
// An app path stored relative to its PREFIX ROOT ("drive_c/..." for plain
// wine, "pfx/drive_c/..." for Proton-managed prefixes): wine resolves it
// under C:\, everything else is FILES-relative or absolute.
const PREFIX_REL_RE = /^(pfx[/\\])?drive_c([/\\]|$)/i
const isPrefixRel = v => typeof v === 'string' && PREFIX_REL_RE.test(v)
const REGISTRY = path.join(DIRS.DATA, 'prefixes.json')
const MAIN = 'Main'
@ -198,4 +204,4 @@ function setDefaultPrefix(name) {
return { ok: true }
}
module.exports = { listPrefixes, savePrefixes, createPrefix, deletePrefix, renamePrefix, setDefaultPrefix, prefixPath, prefixExists, prefixDriveRoots, isPrefixRoot, healPrefix, ensureUnifiedPrefix, MAIN }
module.exports = { isPrefixRel, PREFIX_REL_RE, listPrefixes, savePrefixes, createPrefix, deletePrefix, renamePrefix, setDefaultPrefix, prefixPath, prefixExists, prefixDriveRoots, isPrefixRoot, healPrefix, ensureUnifiedPrefix, MAIN }

View file

@ -3,33 +3,26 @@
const { contextBridge, ipcRenderer } = require('electron')
const { DIRS } = require('./paths')
// single-slot progress: every import modal used to register ANOTHER listener
// and old ones never unregistered, so the file counter sped up per import
let importProgressCb = null
ipcRenderer.on('import:progress', (e, data) => {
if (importProgressCb) importProgressCb(data)
})
// Single-slot event callbacks: every import modal used to register ANOTHER
// listener and old ones never unregistered, so the file counter sped up per
// import. Only the latest subscriber receives an event.
function singleSlot(channel) {
let slot = null
ipcRenderer.on(channel, (e, data) => {
if (slot) slot(data)
})
return fn => {
slot = fn
}
}
// "Open with PLWL": single-slot callback for files handed over via --open
// (Exec %f) - the renderer shows the wine/prefix picker popup for it.
let openWithCb = null
ipcRenderer.on('openwith:file', (e, openPath) => {
if (openWithCb) openWithCb(openPath)
})
// LAN sync progress: same single-slot rule as the import counter, only one
// sync can run at a time anyway.
let lanSyncProgressCb = null
ipcRenderer.on('lansync:progress', (e, data) => {
if (lanSyncProgressCb) lanSyncProgressCb(data)
})
// Incoming sync consent: the receiver's Accept/Decline popup. Single slot for
// the same reason - one pending request at a time (the server enforces it).
let lanSyncIncomingCb = null
ipcRenderer.on('lansync:incoming', (e, data) => {
if (lanSyncIncomingCb) lanSyncIncomingCb(data)
})
const onImportProgress = singleSlot('import:progress')
// "Open with PLWL": files handed over via --open (Exec %f) - the renderer
// shows the wine/prefix picker popup for them.
const onOpenWith = singleSlot('openwith:file')
// one sync (and one pending consent request, enforced server-side) at a time
const onLanSyncProgress = singleSlot('lansync:progress')
const onLanSyncIncoming = singleSlot('lansync:incoming')
const fs = require('fs')
const path = require('path')
@ -54,9 +47,7 @@ contextBridge.exposeInMainWorld('plwl', {
openwithTake: () => ipcRenderer.invoke('openwith:take'),
openwithRun: payload => ipcRenderer.invoke('openwith:run', payload),
openwithTexts: lang => ipcRenderer.invoke('openwith:texts', lang),
onOpenWith: cb => {
openWithCb = cb
},
onOpenWith: onOpenWith,
basename: p => (p ? path.basename(p) : ''),
fileExists: p => {
try {
@ -91,24 +82,19 @@ contextBridge.exposeInMainWorld('plwl', {
dialogPickFile: opts => ipcRenderer.invoke('dialog:pickFile', opts),
shellOpenPath: p => ipcRenderer.invoke('shell:openPath', p),
shellShowItemInFolder: p => ipcRenderer.invoke('shell:showItemInFolder', p),
onImportProgress: cb => {
importProgressCb = cb
},
onImportProgress: onImportProgress,
onGamesChanged: cb => ipcRenderer.on('games:changed', () => cb()),
lansyncGet: () => ipcRenderer.invoke('lansync:get'),
lansyncDevices: () => ipcRenderer.invoke('lansync:devices'),
lansyncRerollDeviceId: () => ipcRenderer.invoke('lansync:rerollDeviceId'),
lansyncRerollSyncCode: () => ipcRenderer.invoke('lansync:rerollSyncCode'),
lansyncForget: id => ipcRenderer.invoke('lansync:forget', id),
lansyncClearHashCache: () => ipcRenderer.invoke('lansync:clearHashCache'),
lansyncSync: payload => ipcRenderer.invoke('lansync:sync', payload),
lansyncRespondIncoming: (requestId, accept) => ipcRenderer.invoke('lansync:respondIncoming', requestId, accept),
onLanSyncDevices: cb => ipcRenderer.on('lansync:devices', (e, data) => cb(data)),
onLanSyncProgress: cb => {
lanSyncProgressCb = cb
},
onLanSyncIncoming: cb => {
lanSyncIncomingCb = cb
},
onLanSyncProgress: onLanSyncProgress,
onLanSyncIncoming: onLanSyncIncoming,
onLanSyncApplied: cb => ipcRenderer.on('lansync:applied', (e, data) => cb(data)),
onLanSyncReceiving: cb => ipcRenderer.on('lansync:receiving', (e, data) => cb(data))
})

View file

@ -25,15 +25,7 @@
const fsp = require('fs').promises
const fs = require('fs')
const path = require('path')
function exists(p) {
try {
fs.accessSync(p)
return true
} catch {
return false
}
}
const { pathExists: exists } = require('./fsutil')
// dosdevices/ holds the prefix's drive mappings (c:, z:, d:, com1 ...). They
// are *supposed* to point outside the prefix - at the filesystem root, a

View file

@ -4,7 +4,7 @@ const fs = require('fs')
const fsp = fs.promises
const path = require('path')
const { DIRS, readJson, writeJson, toPortablePath, resolvePortablePath } = require('./paths')
const { dedupeDir, copyTree, treeSize, removeTree } = require('./fsutil')
const { dedupeDir, copyTree, treeSize, removeTree, pathExists } = require('./fsutil')
const { normalizeLabel } = require('./names')
const { rewireSymlinks, restoreExecutableBits, EXEC_NAMES } = require('./symlinks')
@ -12,14 +12,7 @@ const WINES_DIR = DIRS.WINES
const REGISTRY = path.join(DIRS.DATA, 'wines.json')
const DEFAULT_KEY = 'defaultWine'
function exists(p) {
try {
fs.accessSync(p)
return true
} catch {
return false
}
}
const exists = pathExists
function detectType(dir) {
if (exists(path.join(dir, 'proton'))) return 'proton'
@ -343,4 +336,15 @@ 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, clearWineDisplayName, getDefaultWine, setDefaultWine, deleteWine, importWineFolder, findWineBinary, detectType, fixBuildSymlinks, healBuild }
// The pseudo entry for the distro's wine, offered next to the imported
// builds; version probe is best-effort (a PATH without wine still lists it).
function systemWine() {
let version = ''
try {
const { execFileSync } = require('child_process')
version = String(execFileSync('wine', ['--version'], { timeout: 15000 })).trim().replace(/^wine-?/i, '')
} catch {}
return { id: 'system', type: 'system', label: 'System Wine', version }
}
module.exports = { refreshWines, getWines, getWine, resolveWine, systemWine, setWineDisabled, setWineDisplayName, clearWineDisplayName, getDefaultWine, setDefaultWine, deleteWine, importWineFolder, findWineBinary, detectType, fixBuildSymlinks, healBuild }