code cleanup
This commit is contained in:
parent
4680d7f014
commit
d21b873327
12 changed files with 40 additions and 213 deletions
|
|
@ -83,7 +83,7 @@ Renderer never touches Node/Electron directly — everything goes through the `w
|
||||||
### Native Linux apps
|
### Native Linux apps
|
||||||
|
|
||||||
- Entry point detection (linuxapp.js): ELF, `.AppImage`, or a `.desktop` whose `Exec=` points at one; `lib/`, `share/` … are skipped. The icon comes from the `.desktop` `Icon=`, an image next to the program, or the system theme — `extractIconFromExe` is PE-only.
|
- Entry point detection (linuxapp.js): ELF, `.AppImage`, or a `.desktop` whose `Exec=` points at one; `lib/`, `share/` … are skipped. The icon comes from the `.desktop` `Icon=`, an image next to the program, or the system theme — `extractIconFromExe` is PE-only.
|
||||||
- The redirected home starts as a COPY of the desktop preferences (kdeglobals, kdedefaults/kdeglobals, gtk-3.0|gtk-4.0/settings.ini, qt5ct/qt6ct, Trolltech.conf, user-dirs.dirs). Without the seed Qt falls back to its built-in light palette (measured: `#eff0f1` against the host's `#202326`). The copy keeps following the host while the app has not touched it: the hashes of what PLWL wrote live in `{prefix}/linux_pfx/.seeded.json`, OUTSIDE the app's home, and a copy with no marker entry (what an older launcher left) counts as ours and is refreshed. The PulseAudio cookie is seeded once, because with a redirected home Pulse looks in `$HOME/.config/pulse/cookie` and the app otherwise comes up silent; `~/.Xauthority` is refreshed instead, since nothing exports XAUTHORITY to a sandboxed app.
|
- The redirected home starts as a COPY of the desktop preferences (kdeglobals, kdedefaults/kdeglobals, gtk-3.0|gtk-4.0/settings.ini, qt5ct/qt6ct, Trolltech.conf, user-dirs.dirs). Without the seed Qt falls back to its built-in light palette (measured: `#eff0f1` against the host's `#202326`). The copy keeps following the host while the app has not touched it: the hashes of what PLWL wrote live in `{prefix}/linux_pfx/.seeded.json`, OUTSIDE the app's home, and a copy with no marker entry (or one the app has since changed) is the app's own and is never overwritten. The PulseAudio cookie is seeded once, because with a redirected home Pulse looks in `$HOME/.config/pulse/cookie` and the app otherwise comes up silent; `~/.Xauthority` is refreshed instead, since nothing exports XAUTHORITY to a sandboxed app.
|
||||||
- Host fonts/icons/themes/colour schemes are not copied but `--ro-bind` under `/tmp/.plwl-host/…` and prepended to XDG_DATA_DIRS: live, and unable to write back into the user's `~`. Symlinks were measured and rejected — the prefix is mounted ON `~`, so a link to `~/.config/kdeglobals` points at itself (`Too many levels of symbolic links`); without bwrap the same link writes through into the real home.
|
- Host fonts/icons/themes/colour schemes are not copied but `--ro-bind` under `/tmp/.plwl-host/…` and prepended to XDG_DATA_DIRS: live, and unable to write back into the user's `~`. Symlinks were measured and rejected — the prefix is mounted ON `~`, so a link to `~/.config/kdeglobals` points at itself (`Too many levels of symbolic links`); without bwrap the same link writes through into the real home.
|
||||||
- `forceX11` (per app) sets XDG_SESSION_TYPE=x11 + QT_QPA_PLATFORM=xcb + GDK_BACKEND=x11 so the app and everything it launches falls back to XWayland, where window icons still exist at all (Wayland forbids them; a compiled GLFW probe showed XDG_SESSION_TYPE picks X11 vs Wayland, `GLFW_PLATFORM` does not exist). `game.env` still wins.
|
- `forceX11` (per app) sets XDG_SESSION_TYPE=x11 + QT_QPA_PLATFORM=xcb + GDK_BACKEND=x11 so the app and everything it launches falls back to XWayland, where window icons still exist at all (Wayland forbids them; a compiled GLFW probe showed XDG_SESSION_TYPE picks X11 vs Wayland, `GLFW_PLATFORM` does not exist). `game.env` still wins.
|
||||||
- SESSION_MANAGER is dropped in the sandbox: the session socket sits in the private `/tmp`, so the pointer led nowhere (Qt: "Could not open network socket").
|
- SESSION_MANAGER is dropped in the sandbox: the session socket sits in the private `/tmp`, so the pointer led nowhere (Qt: "Could not open network socket").
|
||||||
|
|
@ -106,14 +106,14 @@ Renderer never touches Node/Electron directly — everything goes through the `w
|
||||||
- Progress is bytes plus per-category counters (items = announced units, itemsDone = finished, totals = everything picked) — "Apps: 2/5". The bar follows the CURRENT PHASE's bytes (barBytes/barTotalBytes) so a phase starts from an empty bar. Hashing and copying walk tens of thousands of files: every per-file update goes through progressGate (~10/s, phase changes forced) — never one IPC message per file. The gate holds a snapshot FUNCTION on the sender side: the snapshot is built when it flushes, so a throttled update never shows a byte count one gate window old.
|
- Progress is bytes plus per-category counters (items = announced units, itemsDone = finished, totals = everything picked) — "Apps: 2/5". The bar follows the CURRENT PHASE's bytes (barBytes/barTotalBytes) so a phase starts from an empty bar. Hashing and copying walk tens of thousands of files: every per-file update goes through progressGate (~10/s, phase changes forced) — never one IPC message per file. The gate holds a snapshot FUNCTION on the sender side: the snapshot is built when it flushes, so a throttled update never shows a byte count one gate window old.
|
||||||
- The sender's transferred counter counts bytes AS THEY ARE READ (putFile's onBytes callback), not on upload completion: a multi-GB file has to move it while it flies or the sender shows hundreds of MB behind the receiver. A unit's byte total travels as the `{ unitBytes }` control record BEFORE its files; the receiver has to handle it before the entry filter (it has no path/type, so a filter-first order dropped it and the comparing bar measured against a placeholder total of 1 — read as 100% for the whole phase). The comparing phase ends on a full bar, then the copy bar restarts empty (its first PUT byte emits it).
|
- The sender's transferred counter counts bytes AS THEY ARE READ (putFile's onBytes callback), not on upload completion: a multi-GB file has to move it while it flies or the sender shows hundreds of MB behind the receiver. A unit's byte total travels as the `{ unitBytes }` control record BEFORE its files; the receiver has to handle it before the entry filter (it has no path/type, so a filter-first order dropped it and the comparing bar measured against a placeholder total of 1 — read as 100% for the whole phase). The comparing phase ends on a full bar, then the copy bar restarts empty (its first PUT byte emits it).
|
||||||
- While the receiver answers the consent popup (and whenever its side waits on the other one), both popups show "Waiting for the other device…" (lansync.waiting): the sender emits a 'wait' phase before verify, the receiver's ping handler emits stage 'wait'. A unit counts toward "Apps: 1/5" as soon as its comparison starts (not when it commits), and phases without a file count (hash/compare/commit) clear the counter so a stale "3 / 10 files" never lingers. On the wire the receiver's comparing phase is called 'compare' (sender-side 'plan' is unchanged).
|
- While the receiver answers the consent popup (and whenever its side waits on the other one), both popups show "Waiting for the other device…" (lansync.waiting): the sender emits a 'wait' phase before verify, the receiver's ping handler emits stage 'wait'. A unit counts toward "Apps: 1/5" as soon as its comparison starts (not when it commits), and phases without a file count (hash/compare/commit) clear the counter so a stale "3 / 10 files" never lingers. On the wire the receiver's comparing phase is called 'compare' (sender-side 'plan' is unchanged).
|
||||||
- Registry-level settings are opt-in (the "settings" category, old name "defaults" still accepted as an alias on both sides). The payload carries defaults, wineSettings (displayName + disabled per build), prefixSettings, appSettings (name, wine, prefix, args, env, dllOverrides, disableUmu, isolation and forceX11 per app) and appOrder. The receiver only patches what already exists (unknown ids skipped, nothing created or deleted, unknown local entries kept at the end of the order) through the normal helpers (setWineDisplayName, library.updateGame, reorderGames).
|
- Registry-level settings are opt-in (the "settings" category). The payload carries defaults, wineSettings (displayName + disabled per build), prefixSettings, appSettings (name, wine, prefix, args, env, dllOverrides, disableUmu, isolation and forceX11 per app) and appOrder. The receiver only patches what already exists (unknown ids skipped, nothing created or deleted, unknown local entries kept at the end of the order) through the normal helpers (setWineDisplayName, library.updateGame, reorderGames).
|
||||||
- File hashes are remembered in files/data/lansync-hashes.json (path -> size + mtime + hash; machine-local, never synced): equal size and mtime means equal bytes, so a repeat sync only stats the tree. Hashing and the receiver's comparison both run HASH_CONCURRENCY files at a time.
|
- File hashes are remembered in files/data/lansync-hashes.json (path -> size + mtime + hash; machine-local, never synced): equal size and mtime means equal bytes, so a repeat sync only stats the tree. Hashing and the receiver's comparison both run HASH_CONCURRENCY files at a time.
|
||||||
- A file's unit and path travel in the PUT's query, percent-encoded, and the receiver still reads the old `x-plwl-unit`/`x-plwl-path` headers first: Node rejects any header value holding a code above U+00FF (`ERR_INVALID_CHAR`), and a real library has those names (Minecraft pack names, Polish file names) — one such file killed the whole sync, leaving the receiver with half an app and no registry entry.
|
- A file's unit and path travel in the PUT's query, not in headers: Node rejects any header value holding a code above U+00FF (`ERR_INVALID_CHAR`), and a real library has those names (Minecraft pack names, Polish file names) — one such file killed the whole sync, leaving the receiver with half an app and no registry entry. Categories are matched by exact name; anything this build does not know is ignored.
|
||||||
- File permissions travel with the manifest (`mode`) and are applied after the bytes; a file with the right hash but wrong mode is repaired without re-upload.
|
- File permissions travel with the manifest (`mode`) and are applied after the bytes; a file with the right hash but wrong mode is repaired without re-upload.
|
||||||
- A sender hashing a big unit is minutes of silence: it pings `/plwl/ping` every HEARTBEAT_MS, the receiver re-arms its popup watchdog (stage 'wait' shows "Waiting for the other device…" but never while a file is being written). A receiving popup its watchdog closed reopens on the next real event — only 'done' ends a sync, and reopening never asks for consent again (consent is the onIncoming popup at verify time; the progress popup is driven by the same token's events).
|
- A sender hashing a big unit is minutes of silence: it pings `/plwl/ping` every HEARTBEAT_MS, the receiver re-arms its popup watchdog (stage 'wait' shows "Waiting for the other device…" but never while a file is being written). A receiving popup its watchdog closed reopens on the next real event — only 'done' ends a sync, and reopening never asks for consent again (consent is the onIncoming popup at verify time; the progress popup is driven by the same token's events).
|
||||||
- Native Linux app data (`{prefix}/linux_pfx/`: the redirected home, configs, caches, PulseAudio cookie, state) travels INSIDE its prefix unit like the Windows side of the same prefix: the manifest walks it and the mirror prunes it, so no file of the launcher's own trees is special-cased on the wire. Native apps sit in the same categories as Windows ones (`apps-portable`/`apps-prefix`), and only the per-app fields that are Linux-only (isolation, forceX11) are named as such in the settings payload.
|
- Native Linux app data (`{prefix}/linux_pfx/`: the redirected home, configs, caches, PulseAudio cookie, state) travels INSIDE its prefix unit like the Windows side of the same prefix: the manifest walks it and the mirror prunes it, so no file of the launcher's own trees is special-cased on the wire. Native apps sit in the same categories as Windows ones (`apps-portable`/`apps-prefix`), and only the per-app fields that are Linux-only (isolation, forceX11) are named as such in the settings payload.
|
||||||
- A sync must never walk through a symlinked ROOT: pruneTree/healUnitRoots (sync), removeTree/treeSize (fsutil) and healBuild (wines) lstat the root first. `listDirs()` counts symlinked folders as units, so a build on another disk still travels — as the link it is (`wineUnits` maps nested builds to their TOP folder under wines/).
|
- A sync must never walk through a symlinked ROOT: pruneTree/healUnitRoots (sync), removeTree/treeSize (fsutil) and healBuild (wines) lstat the root first. `listDirs()` counts symlinked folders as units, so a build on another disk still travels — as the link it is (`wineUnits` maps nested builds to their TOP folder under wines/).
|
||||||
- Wine/Proton rewrite prefix links to absolute targets whenever they update a prefix, so on-disk links are not always relative after a run: the sync relativizes them on the wire (portableLinkTarget) and refreshWines()/healPrefix() repair what arrived from an older PLWL. A build whose binaries lost the executable bit is detected in refreshWines() and healed once (spawn() on a non-executable `proton` fails with EACCES while the game looks started).
|
- Wine/Proton rewrite prefix links to absolute targets whenever they update a prefix, so on-disk links are not always relative after a run: the sync relativizes them on the wire (portableLinkTarget) and refreshWines()/healPrefix() repair absolute links that reached disk anyway. A build whose binaries lost the executable bit is detected in refreshWines() and healed once (spawn() on a non-executable `proton` fails with EACCES while the game looks started).
|
||||||
|
|
||||||
### Paths and prefixes
|
### Paths and prefixes
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1597,10 +1597,10 @@ function renderProgressBar(bar, bytes, totalBytes) {
|
||||||
bar.value = Math.min(sent, bar.max)
|
bar.value = Math.min(sent, bar.max)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Older peers only send the overall byte totals - then those drive the bar.
|
// The bar follows the current phase's bytes (a phase starts empty).
|
||||||
function barValues(info) {
|
function barValues(info) {
|
||||||
let bytes = Math.max(0, Number(info.barBytes == null ? info.bytes : info.barBytes) || 0)
|
let bytes = Math.max(0, Number(info.barBytes) || 0)
|
||||||
let total = Math.max(0, Number(info.barTotalBytes == null ? info.totalBytes : info.barTotalBytes) || 0)
|
let total = Math.max(0, Number(info.barTotalBytes) || 0)
|
||||||
// a provisional total can be smaller than what already moved (it grows as
|
// a provisional total can be smaller than what already moved (it grows as
|
||||||
// entries arrive): the bar reads full, never over 100%
|
// entries arrive): the bar reads full, never over 100%
|
||||||
if (total > 0 && bytes > total) bytes = total
|
if (total > 0 && bytes > total) bytes = total
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,6 @@ function createHashCache(dataDir) {
|
||||||
let cache = null
|
let cache = null
|
||||||
let dirty = false
|
let dirty = false
|
||||||
let savedAt = 0
|
let savedAt = 0
|
||||||
const stats = { hits: 0, misses: 0 }
|
|
||||||
|
|
||||||
function load() {
|
function load() {
|
||||||
if (cache) return cache
|
if (cache) return cache
|
||||||
|
|
@ -80,11 +79,7 @@ function createHashCache(dataDir) {
|
||||||
// the cache dirty only when the timestamp is stale.
|
// the cache dirty only when the timestamp is stale.
|
||||||
function hit(rel, size, mtimeMs) {
|
function hit(rel, size, mtimeMs) {
|
||||||
const known = load()[rel]
|
const known = load()[rel]
|
||||||
if (!known || known[0] !== size || known[1] !== mtimeMs || typeof known[2] !== 'string') {
|
if (!known || known[0] !== size || known[1] !== mtimeMs || typeof known[2] !== 'string') return null
|
||||||
stats.misses++
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
stats.hits++
|
|
||||||
if (Date.now() - (known[3] || 0) > 86400000) {
|
if (Date.now() - (known[3] || 0) > 86400000) {
|
||||||
known[3] = Date.now()
|
known[3] = Date.now()
|
||||||
dirty = true
|
dirty = true
|
||||||
|
|
@ -111,35 +106,13 @@ function createHashCache(dataDir) {
|
||||||
cache = null
|
cache = null
|
||||||
dirty = false
|
dirty = false
|
||||||
savedAt = 0
|
savedAt = 0
|
||||||
stats.hits = 0
|
|
||||||
stats.misses = 0
|
|
||||||
try {
|
try {
|
||||||
fs.rmSync(FILE, { force: true })
|
fs.rmSync(FILE, { force: true })
|
||||||
fs.rmSync(`${FILE}.tmp`, { force: true })
|
fs.rmSync(`${FILE}.tmp`, { force: true })
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dropping the memory copy makes the next lookup re-read from disk (tests).
|
return { HASH_ALGO, hashFile, hit, remember, cachedHash, save, clear }
|
||||||
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 }
|
module.exports = { createHashCache, hashFile, HASH_ALGO }
|
||||||
|
|
|
||||||
|
|
@ -170,7 +170,7 @@ function buildIco(images) {
|
||||||
planes = dims.planes
|
planes = dims.planes
|
||||||
bpp = dims.bpp
|
bpp = dims.bpp
|
||||||
} else {
|
} else {
|
||||||
// legacy fallback: ICONDIRENTRY-style first bytes (PNG-era heuristic)
|
// nothing measurable: the first bytes carry the size
|
||||||
w = img.readUInt8(0) || 256
|
w = img.readUInt8(0) || 256
|
||||||
h = img.readUInt8(1) || 256
|
h = img.readUInt8(1) || 256
|
||||||
colors = img.readUInt8(2)
|
colors = img.readUInt8(2)
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,11 @@ function registerLanSyncIpc({ getWindow }) {
|
||||||
ipcMain.handle('lansync:get', () => lansync.getState())
|
ipcMain.handle('lansync:get', () => lansync.getState())
|
||||||
ipcMain.handle('lansync:devices', () => lansync.listDevices())
|
ipcMain.handle('lansync:devices', () => lansync.listDevices())
|
||||||
ipcMain.handle('lansync:rerollDeviceId', () => {
|
ipcMain.handle('lansync:rerollDeviceId', () => {
|
||||||
const identity = lansync.rerollDeviceId()
|
const identity = lansync.rotateDeviceId()
|
||||||
return { deviceId: identity.deviceId }
|
return { deviceId: identity.deviceId }
|
||||||
})
|
})
|
||||||
ipcMain.handle('lansync:rerollSyncCode', () => {
|
ipcMain.handle('lansync:rerollSyncCode', () => {
|
||||||
const identity = lansync.rerollSyncCode()
|
const identity = lansync.rotateSyncCode()
|
||||||
return { syncCode: identity.syncCode }
|
return { syncCode: identity.syncCode }
|
||||||
})
|
})
|
||||||
ipcMain.handle('lansync:forget', (e, id) => {
|
ipcMain.handle('lansync:forget', (e, id) => {
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,7 @@ function createIdentity(configFile) {
|
||||||
function rememberDevice(id, code, name) {
|
function rememberDevice(id, code, name) {
|
||||||
const cfg = identity()
|
const cfg = identity()
|
||||||
const stored = String(code || '').trim().toUpperCase()
|
const stored = String(code || '').trim().toUpperCase()
|
||||||
cfg.known = { ...cfg.known, [id]: { code: stored, syncCode: stored, name: name || '', at: Date.now() } }
|
cfg.known = { ...cfg.known, [id]: { code: stored, name: name || '', at: Date.now() } }
|
||||||
writeJson(configFile, cfg)
|
writeJson(configFile, cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,60 +44,23 @@ const INCOMING_TIMEOUT_MS = Number(process.env.PLWL_LANSYNC_INCOMING_TIMEOUT) ||
|
||||||
// The only trees a sync may touch; the rest of files/ is local state.
|
// The only trees a sync may touch; the rest of files/ is local state.
|
||||||
const SYNC_ROOTS = ['apps/', 'wines/', 'wineprefixes/', 'data/icons/']
|
const SYNC_ROOTS = ['apps/', 'wines/', 'wineprefixes/', 'data/icons/']
|
||||||
|
|
||||||
// Sync categories plus lenient aliases for older senders.
|
// What a sync may carry, one name per category.
|
||||||
const CATEGORIES = ['apps-portable', 'apps-prefix', 'wines', 'prefixes', 'settings']
|
const CATEGORIES = ['apps-portable', 'apps-prefix', 'wines', 'prefixes', 'settings']
|
||||||
|
|
||||||
const CATEGORY_ALIASES = {
|
|
||||||
// the whole app library: portable + prefix-import launcher entries
|
|
||||||
apps: ['apps-portable', 'apps-prefix'],
|
|
||||||
app: ['apps-portable', 'apps-prefix'],
|
|
||||||
appsportable: ['apps-portable'],
|
|
||||||
portableapps: ['apps-portable'],
|
|
||||||
portable: ['apps-portable'],
|
|
||||||
appsprefix: ['apps-prefix'],
|
|
||||||
prefixapps: ['apps-prefix'],
|
|
||||||
appsimportedfromprefix: ['apps-prefix'],
|
|
||||||
wine: ['wines'],
|
|
||||||
wines: ['wines'],
|
|
||||||
wineversions: ['wines'],
|
|
||||||
wineprefix: ['prefixes'],
|
|
||||||
wineprefixes: ['prefixes'],
|
|
||||||
prefixes: ['prefixes'],
|
|
||||||
prefix: ['prefixes'],
|
|
||||||
// launcher settings: defaults, wine labels, prefix registry, app configs
|
|
||||||
settings: ['settings'],
|
|
||||||
syncsettings: ['settings'],
|
|
||||||
defaults: ['settings'],
|
|
||||||
default: ['settings'],
|
|
||||||
defaultsettings: ['settings']
|
|
||||||
}
|
|
||||||
|
|
||||||
// An app entry's category: portable imports own their apps/ folder.
|
// An app entry's category: portable imports own their apps/ folder.
|
||||||
function appCategory(game) {
|
function appCategory(game) {
|
||||||
if (!game || typeof game !== 'object') return null
|
if (!game || typeof game !== 'object') return null
|
||||||
if (game.importSource === 'prefix' || game.mode === 'prefix') return 'apps-prefix'
|
if (game.importSource === 'prefix') return 'apps-prefix'
|
||||||
if (game.imported === true || game.mode === 'copy' || game.mode === 'move') return 'apps-portable'
|
if (game.imported === true) return 'apps-portable'
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// An app entry's files, read off its stored path; outside the data = not synced.
|
// The list comes from the wire: anything this build does not know is ignored.
|
||||||
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
|
|
||||||
// external entry: nothing to sync
|
|
||||||
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 (isPrefixRel(value)) return 'prefix'
|
|
||||||
if (/^apps\//.test(value)) return 'portable'
|
|
||||||
return outside ? 'external' : null
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeCategories(list) {
|
function normalizeCategories(list) {
|
||||||
const out = new Set()
|
const out = new Set()
|
||||||
for (const raw of Array.isArray(list) ? list : []) {
|
for (const raw of Array.isArray(list) ? list : []) {
|
||||||
const aliases = CATEGORY_ALIASES[String(raw || '').toLowerCase().replace(/[^a-z]+/g, '')]
|
const name = String(raw || '')
|
||||||
for (const alias of aliases || []) out.add(alias)
|
if (CATEGORIES.includes(name)) out.add(name)
|
||||||
}
|
}
|
||||||
return [...out]
|
return [...out]
|
||||||
}
|
}
|
||||||
|
|
@ -183,28 +146,6 @@ function targetOf(target) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trade the sync code for a token; a rejected code is never stored.
|
|
||||||
async function verifyPeer(target, code) {
|
|
||||||
// { host, port, code } in one object, or a target plus a separate code
|
|
||||||
if (target && typeof target === 'object' && code === undefined && target.code !== undefined) code = target.code
|
|
||||||
const device = targetOf(target)
|
|
||||||
if (!device || !device.port) return { ok: false, error: 'bad-target' }
|
|
||||||
let res
|
|
||||||
try {
|
|
||||||
res = await httpJson(device, 'POST', '/plwl/verify', {
|
|
||||||
json: { deviceId: identity().deviceId, name: os.hostname(), code: String(code == null ? '' : code).trim().toUpperCase() },
|
|
||||||
timeout: 20000
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
return { ok: false, error: String((err && err.message) || err) }
|
|
||||||
}
|
|
||||||
if (!res.data || !res.data.ok) return { ok: false, error: (res.data && res.data.error) || `http-${res.status}` }
|
|
||||||
const peer = res.data.device || { id: res.data.deviceId, name: res.data.name }
|
|
||||||
const peerId = (target && target.deviceId) || (target && target.id) || (peer && peer.id)
|
|
||||||
if (peerId) rememberDevice(peerId, code, (peer && peer.name) || (target && target.name))
|
|
||||||
return { ok: true, token: res.data.token, deviceId: peer && peer.id, device: peer, peer: peer }
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- receiving side ---------------------------------------------------------
|
// --- receiving side ---------------------------------------------------------
|
||||||
|
|
||||||
function sendJson(res, status, data) {
|
function sendJson(res, status, data) {
|
||||||
|
|
@ -992,10 +933,8 @@ async function handlePlan(state, session, url, req, res, hooks) {
|
||||||
const PUT_IDLE_MS = Number(process.env.PLWL_LANSYNC_PUT_IDLE) || 60000
|
const PUT_IDLE_MS = Number(process.env.PLWL_LANSYNC_PUT_IDLE) || 60000
|
||||||
|
|
||||||
async function handlePut(session, url, req, res, hooks) {
|
async function handlePut(session, url, req, res, hooks) {
|
||||||
// current senders put the pair in the query (percent-encoded); 0.5.x used
|
const key = url.searchParams.get('unit') || ''
|
||||||
// headers, which cannot carry a code above U+00FF at all
|
const rel = url.searchParams.get('path') || ''
|
||||||
const key = req.headers['x-plwl-unit'] || url.searchParams.get('unit') || ''
|
|
||||||
const rel = req.headers['x-plwl-path'] || url.searchParams.get('path') || ''
|
|
||||||
const unit = session.units.get(key)
|
const unit = session.units.get(key)
|
||||||
const abs = filesPath(rel)
|
const abs = filesPath(rel)
|
||||||
if (!unit || !abs || !unit.paths.has(rel)) {
|
if (!unit || !abs || !unit.paths.has(rel)) {
|
||||||
|
|
@ -1066,7 +1005,7 @@ async function handleCommit(session, url, res, hooks) {
|
||||||
async function handleApply(session, req, res, hooks) {
|
async function handleApply(session, req, res, hooks) {
|
||||||
const payload = await readJsonBody(req)
|
const payload = await readJsonBody(req)
|
||||||
if (!payload) return sendJson(res, 400, { ok: false, error: 'bad-request' })
|
if (!payload) return sendJson(res, 400, { ok: false, error: 'bad-request' })
|
||||||
// sender categories go through the aliases (older PLWL says "defaults")
|
// only the categories this build knows, whatever the sender sent
|
||||||
const cats = new Set(normalizeCategories(payload.categories))
|
const cats = new Set(normalizeCategories(payload.categories))
|
||||||
const appsDeleted = applyApps(payload, cats)
|
const appsDeleted = applyApps(payload, cats)
|
||||||
const winesDeleted = cats.has('wines') ? await applyWines(payload) : 0
|
const winesDeleted = cats.has('wines') ? await applyWines(payload) : 0
|
||||||
|
|
@ -1091,9 +1030,6 @@ async function handleApply(session, req, res, hooks) {
|
||||||
sendJson(res, 200, {
|
sendJson(res, 200, {
|
||||||
ok: true,
|
ok: true,
|
||||||
deleted: removed,
|
deleted: removed,
|
||||||
apps: { deleted: appsDeleted },
|
|
||||||
wines: { deleted: winesDeleted },
|
|
||||||
prefixes: { deleted: prefixesDeleted },
|
|
||||||
settings,
|
settings,
|
||||||
defaults: settings ? settings.defaults : null
|
defaults: settings ? settings.defaults : null
|
||||||
})
|
})
|
||||||
|
|
@ -1264,27 +1200,19 @@ const exported = {
|
||||||
startServer,
|
startServer,
|
||||||
stop,
|
stop,
|
||||||
stopServer,
|
stopServer,
|
||||||
closeServer: stopServer,
|
|
||||||
close: stopServer,
|
|
||||||
httpJson,
|
httpJson,
|
||||||
verifyPeer,
|
|
||||||
runSync,
|
runSync,
|
||||||
appCategory,
|
appCategory,
|
||||||
classifyApp,
|
|
||||||
CATEGORIES,
|
CATEGORIES,
|
||||||
normalizeCategories,
|
normalizeCategories,
|
||||||
serverPort,
|
serverPort,
|
||||||
getServerPort: serverPort,
|
|
||||||
getState,
|
getState,
|
||||||
listDevices,
|
listDevices,
|
||||||
identity,
|
identity,
|
||||||
loadIdentity: identity,
|
|
||||||
makeDeviceId,
|
makeDeviceId,
|
||||||
makeSyncCode,
|
makeSyncCode,
|
||||||
rotateDeviceId,
|
rotateDeviceId,
|
||||||
rotateSyncCode,
|
rotateSyncCode,
|
||||||
rerollDeviceId: rotateDeviceId,
|
|
||||||
rerollSyncCode: rotateSyncCode,
|
|
||||||
knownDevice,
|
knownDevice,
|
||||||
knownPeers,
|
knownPeers,
|
||||||
rememberDevice,
|
rememberDevice,
|
||||||
|
|
@ -1293,9 +1221,6 @@ const exported = {
|
||||||
sameCode,
|
sameCode,
|
||||||
HASH_ALGO: hashStore.HASH_ALGO,
|
HASH_ALGO: hashStore.HASH_ALGO,
|
||||||
hashFile,
|
hashFile,
|
||||||
hashCacheStats: hashStore.stats,
|
|
||||||
resetHashCacheStats: hashStore.resetStats,
|
|
||||||
forgetHashCache: hashStore.forget,
|
|
||||||
clearHashCache: hashStore.clear,
|
clearHashCache: hashStore.clear,
|
||||||
HASH_CONCURRENCY,
|
HASH_CONCURRENCY,
|
||||||
progressGate,
|
progressGate,
|
||||||
|
|
|
||||||
|
|
@ -283,42 +283,18 @@ function createPusher(api) {
|
||||||
deleted: 0,
|
deleted: 0,
|
||||||
bytes: 0,
|
bytes: 0,
|
||||||
// the popup reports "3 games, 1 prefix" instead of file counts
|
// the popup reports "3 games, 1 prefix" instead of file counts
|
||||||
items: { apps: 0, wines: 0, prefixes: 0 },
|
items: { apps: 0, wines: 0, prefixes: 0 }
|
||||||
apps: { copied: 0, skipped: 0, deleted: 0 },
|
|
||||||
wines: { copied: 0, skipped: 0, deleted: 0 },
|
|
||||||
prefixes: { copied: 0, skipped: 0, deleted: 0 }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every sync resolves to the same shape, success or failure.
|
// Every sync resolves to the same shape, success or failure.
|
||||||
function syncResult(ok, error, summary, events) {
|
function syncResult(ok, error, summary) {
|
||||||
const buckets = { apps: summary.apps, prefixes: summary.prefixes, wines: summary.wines }
|
return { ok, error: error || null, summary }
|
||||||
summary.progress = events.length
|
|
||||||
summary.events = events.length
|
|
||||||
return {
|
|
||||||
ok,
|
|
||||||
error: error || null,
|
|
||||||
summary,
|
|
||||||
stats: buckets,
|
|
||||||
report: buckets,
|
|
||||||
counts: buckets,
|
|
||||||
details: buckets,
|
|
||||||
perCategory: buckets,
|
|
||||||
categories: buckets,
|
|
||||||
apps: summary.apps,
|
|
||||||
prefixes: summary.prefixes,
|
|
||||||
wines: summary.wines,
|
|
||||||
progress: events,
|
|
||||||
events,
|
|
||||||
progressEvents: events,
|
|
||||||
progressLog: events,
|
|
||||||
progressCount: events.length
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Push to the peer; a rejected code comes back as 'bad-code'.
|
// Push to the peer; a rejected code comes back as 'bad-code'.
|
||||||
async function syncToDevice({ deviceId, device: target, code, categories, force, onProgress }) {
|
async function syncToDevice({ deviceId, device: target, code, categories, force, onProgress }) {
|
||||||
const fail = (error, summary, events) => syncResult(false, error, summary || newSummary(0), events || [])
|
const fail = (error, summary) => syncResult(false, error, summary || newSummary(0))
|
||||||
const device = target || api.listDevices().find(d => d.id === deviceId)
|
const device = target || api.listDevices().find(d => d.id === deviceId)
|
||||||
if (!device || !device.address || !device.port) return fail('offline')
|
if (!device || !device.address || !device.port) return fail('offline')
|
||||||
const cats = new Set(normalizeCategories(categories))
|
const cats = new Set(normalizeCategories(categories))
|
||||||
|
|
@ -329,10 +305,7 @@ function createPusher(api) {
|
||||||
const totals = { apps: 0, wines: 0, prefixes: 0, settings: 0 }
|
const totals = { apps: 0, wines: 0, prefixes: 0, settings: 0 }
|
||||||
for (const unit of units) totals[bucketOf(unit.kind)] += 1
|
for (const unit of units) totals[bucketOf(unit.kind)] += 1
|
||||||
if (cats.has('settings')) totals.settings = 1
|
if (cats.has('settings')) totals.settings = 1
|
||||||
// every progress event is kept, so callers can report without subscribing
|
|
||||||
const events = []
|
|
||||||
const progress = data => {
|
const progress = data => {
|
||||||
events.push(data)
|
|
||||||
if (onProgress) onProgress(data)
|
if (onProgress) onProgress(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -435,7 +408,6 @@ function createPusher(api) {
|
||||||
const roots = unit.roots.filter(ownedRoot)
|
const roots = unit.roots.filter(ownedRoot)
|
||||||
if (!roots.length) continue
|
if (!roots.length) continue
|
||||||
const kind = bucketOf(unit.kind)
|
const kind = bucketOf(unit.kind)
|
||||||
const bucket = summary[kind]
|
|
||||||
summary.items[kind] += 1
|
summary.items[kind] += 1
|
||||||
itemsSeen += 1
|
itemsSeen += 1
|
||||||
barBytes = 0
|
barBytes = 0
|
||||||
|
|
@ -474,13 +446,9 @@ function createPusher(api) {
|
||||||
if (!plan.data || !plan.data.ok) throw new Error((plan.data && plan.data.error) || `http-${plan.status}`)
|
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 upload = new Set(plan.data.upload || [])
|
||||||
const same = new Set(plan.data.same || [])
|
const same = new Set(plan.data.same || [])
|
||||||
// per category: files created/overwritten vs already identical
|
const filePaths = new Set(entries.filter(e => e.type === 'file').map(e => e.path))
|
||||||
const fileEntries = entries.filter(e => e.type === 'file')
|
|
||||||
const filePaths = new Set(fileEntries.map(e => e.path))
|
|
||||||
let sameFiles = 0
|
let sameFiles = 0
|
||||||
for (const p of same) if (filePaths.has(p)) sameFiles++
|
for (const p of same) if (filePaths.has(p)) sameFiles++
|
||||||
bucket.copied += fileEntries.length - sameFiles
|
|
||||||
bucket.skipped += sameFiles
|
|
||||||
summary.skipped += sameFiles
|
summary.skipped += sameFiles
|
||||||
const pending = []
|
const pending = []
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
|
|
@ -523,10 +491,7 @@ function createPusher(api) {
|
||||||
allDone += pending.length
|
allDone += pending.length
|
||||||
allTotal += pending.length
|
allTotal += pending.length
|
||||||
const commit = await httpJson(device, 'POST', `/plwl/unit/commit?token=${encodeURIComponent(token)}&unit=${encodeURIComponent(unit.key)}`, { json: {} })
|
const commit = await httpJson(device, 'POST', `/plwl/unit/commit?token=${encodeURIComponent(token)}&unit=${encodeURIComponent(unit.key)}`, { json: {} })
|
||||||
if (commit.data && commit.data.deleted) {
|
if (commit.data && commit.data.deleted) summary.deleted += commit.data.deleted
|
||||||
summary.deleted += commit.data.deleted
|
|
||||||
bucket.deleted += commit.data.deleted
|
|
||||||
}
|
|
||||||
finished[kind] += 1
|
finished[kind] += 1
|
||||||
// a finished unit leaves a finished bar behind, even one that sent none
|
// a finished unit leaves a finished bar behind, even one that sent none
|
||||||
barTotal = barTotal || 1
|
barTotal = barTotal || 1
|
||||||
|
|
@ -536,10 +501,6 @@ function createPusher(api) {
|
||||||
emit('registry', null, null, null, true)
|
emit('registry', null, null, null, true)
|
||||||
const applied = await httpJson(device, 'POST', `/plwl/apply?token=${encodeURIComponent(token)}`, { json: buildRegistryPayload([...cats]), timeout: 60000 })
|
const applied = await httpJson(device, 'POST', `/plwl/apply?token=${encodeURIComponent(token)}`, { json: buildRegistryPayload([...cats]), timeout: 60000 })
|
||||||
if (!applied.data || !applied.data.ok) throw new Error((applied.data && applied.data.error) || `http-${applied.status}`)
|
if (!applied.data || !applied.data.ok) throw new Error((applied.data && applied.data.error) || `http-${applied.status}`)
|
||||||
for (const key of ['apps', 'wines', 'prefixes']) {
|
|
||||||
const deleted = applied.data[key] && applied.data[key].deleted
|
|
||||||
if (deleted) summary[key].deleted += deleted
|
|
||||||
}
|
|
||||||
if (applied.data.deleted) summary.deleted += applied.data.deleted
|
if (applied.data.deleted) summary.deleted += applied.data.deleted
|
||||||
// a sync with nothing to send still ends with a full bar
|
// a sync with nothing to send still ends with a full bar
|
||||||
barTotal = barTotal || 1
|
barTotal = barTotal || 1
|
||||||
|
|
@ -552,11 +513,11 @@ function createPusher(api) {
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
abort.dead = true
|
abort.dead = true
|
||||||
await end()
|
await end()
|
||||||
return fail(String((err && err.message) || err), summary, events)
|
return fail(String((err && err.message) || err), summary)
|
||||||
} finally {
|
} finally {
|
||||||
clearInterval(heartbeat)
|
clearInterval(heartbeat)
|
||||||
}
|
}
|
||||||
return syncResult(true, null, summary, events)
|
return syncResult(true, null, summary)
|
||||||
}
|
}
|
||||||
|
|
||||||
// putFile is exposed for the stall tests (it is the piece that must never
|
// putFile is exposed for the stall tests (it is the piece that must never
|
||||||
|
|
|
||||||
|
|
@ -109,8 +109,7 @@ function gamesFollowingDefault(running) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Windows is the default: entries written before Linux apps existed have no
|
// `kind` is only ever written as 'linux'; a missing one is a Windows app.
|
||||||
// kind at all.
|
|
||||||
function isLinuxGame(game) {
|
function isLinuxGame(game) {
|
||||||
return !!game && game.kind === 'linux'
|
return !!game && game.kind === 'linux'
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -265,13 +265,11 @@ function syncDesktopConfig(root) {
|
||||||
const dst = path.join(root, 'config', rel)
|
const dst = path.join(root, 'config', rel)
|
||||||
const hostHash = hashOf(src)
|
const hostHash = hashOf(src)
|
||||||
if (!hostHash) continue
|
if (!hostHash) continue
|
||||||
const known = marker[rel]
|
if (fs.existsSync(dst)) {
|
||||||
if (fs.existsSync(dst) && known) {
|
// only a marked copy is provably ours, and only while it is untouched
|
||||||
const current = hashOf(dst)
|
const current = hashOf(dst)
|
||||||
// the app owns it now, or there is nothing to do
|
if (current !== marker[rel] || current === hostHash) continue
|
||||||
if (current !== known || current === hostHash) continue
|
|
||||||
}
|
}
|
||||||
// no marker entry: the copy is ours (seeded before the marker existed)
|
|
||||||
if (copyFromHost(src, dst, { refresh: true })) {
|
if (copyFromHost(src, dst, { refresh: true })) {
|
||||||
marker[rel] = hostHash
|
marker[rel] = hostHash
|
||||||
changed = true
|
changed = true
|
||||||
|
|
|
||||||
|
|
@ -24,25 +24,8 @@ function clearPending() {
|
||||||
pending = null
|
pending = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to English keys on older installs.
|
|
||||||
function textsFor(code) {
|
function textsFor(code) {
|
||||||
let texts = {}
|
return require('./i18n').getLang(code).texts || {}
|
||||||
try {
|
|
||||||
texts = require('./i18n').getLang(code).texts || {}
|
|
||||||
} catch {}
|
|
||||||
const fallback = {
|
|
||||||
'openwith.title': 'Open with Wine',
|
|
||||||
'openwith.file': 'File',
|
|
||||||
'openwith.fileMissing': 'This file no longer exists.',
|
|
||||||
'openwith.wine': 'Wine / Proton version',
|
|
||||||
'openwith.wineDefault': 'Default ({0})',
|
|
||||||
'openwith.prefix': 'Wine prefix',
|
|
||||||
'openwith.prefixDefault': 'Default prefix ({0})',
|
|
||||||
'openwith.run': 'Run',
|
|
||||||
'openwith.startFailed': 'Could not start {0}: {1}',
|
|
||||||
'openwith.notWindows': 'This is not a Windows executable (exe/msi/bat/cmd/lnk).'
|
|
||||||
}
|
|
||||||
return { ...fallback, ...texts }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveWine(id) {
|
function resolveWine(id) {
|
||||||
|
|
|
||||||
|
|
@ -88,28 +88,16 @@ async function healPrefixOnce(game) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Proton's STEAM_COMPAT_DATA_PATH must be a symlink to the launcher prefix.
|
// Proton's STEAM_COMPAT_DATA_PATH must be a symlink to the launcher prefix;
|
||||||
// A leftover EMPTY compat dir (an older PLWL bug) is replaced by the link; a
|
// a real compat dir already sitting there is not ours to replace.
|
||||||
// populated one is left alone.
|
|
||||||
function linkPrefix(compatData, prefixDir) {
|
function linkPrefix(compatData, prefixDir) {
|
||||||
try {
|
try {
|
||||||
fs.mkdirSync(path.dirname(compatData), { recursive: true })
|
fs.mkdirSync(path.dirname(compatData), { recursive: true })
|
||||||
} catch {}
|
} catch {}
|
||||||
try {
|
try {
|
||||||
const st = fs.lstatSync(compatData)
|
if (!fs.lstatSync(compatData).isSymbolicLink()) return
|
||||||
if (st.isSymbolicLink()) {
|
if (fs.readlinkSync(compatData) === prefixDir) return
|
||||||
if (fs.readlinkSync(compatData) === prefixDir) return
|
fs.unlinkSync(compatData)
|
||||||
fs.unlinkSync(compatData)
|
|
||||||
} else if (st.isDirectory()) {
|
|
||||||
let names = []
|
|
||||||
try {
|
|
||||||
names = fs.readdirSync(compatData)
|
|
||||||
} catch {}
|
|
||||||
if (names.length) return
|
|
||||||
fs.rmdirSync(compatData)
|
|
||||||
} else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} catch {}
|
} catch {}
|
||||||
try {
|
try {
|
||||||
fs.symlinkSync(prefixDir, compatData, 'dir')
|
fs.symlinkSync(prefixDir, compatData, 'dir')
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue