more accurate sync progress report
This commit is contained in:
parent
8e3512b5cd
commit
c0f6e802ed
12 changed files with 390 additions and 63 deletions
|
|
@ -623,7 +623,7 @@ function writeStream(abs, stream) {
|
|||
try {
|
||||
if (st && st.isSymbolicLink()) removeAbs(abs)
|
||||
fs.renameSync(tmp, abs)
|
||||
resolve()
|
||||
resolve(out.bytesWritten || 0)
|
||||
} catch (err) {
|
||||
removeAbs(tmp)
|
||||
reject(err)
|
||||
|
|
@ -701,6 +701,34 @@ function createServerState() {
|
|||
return { tokens: new Map(), verifying: new Set(), session: null }
|
||||
}
|
||||
|
||||
// Which counter a sync unit feeds on the receiving side (the unit key looks
|
||||
// like "app:<id>", "wine:<name>" or "prefix:<name>").
|
||||
const UNIT_KINDS = { app: 'apps', wine: 'wines', prefix: 'prefixes' }
|
||||
|
||||
// Progress of a sync THIS device receives. The pushing device drives the
|
||||
// transfer, but the user here also needs to see what is being written to
|
||||
// their library: the launcher forwards this to its window, which shows a
|
||||
// "Receiving from X…" popup with a bar.
|
||||
function emitReceive(hooks, session, stage, extra = {}) {
|
||||
if (!hooks || !hooks.onReceive) return
|
||||
try {
|
||||
hooks.onReceive({
|
||||
stage,
|
||||
phase: stage,
|
||||
from: session.name || '',
|
||||
deviceId: session.deviceId || '',
|
||||
unit: extra.unit || '',
|
||||
label: extra.unit || '',
|
||||
done: session.received || 0,
|
||||
total: session.planned || 0,
|
||||
bytes: session.bytes || 0,
|
||||
totalBytes: session.plannedBytes || 0,
|
||||
items: { ...(session.items || {}) },
|
||||
deleted: extra.deleted || 0
|
||||
})
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function activeSession(state) {
|
||||
if (!state.session) return null
|
||||
if (Date.now() - state.session.lastAt > SESSION_IDLE_MS) {
|
||||
|
|
@ -738,11 +766,11 @@ function createHttpServer(state, hooks) {
|
|||
if (route === '/plwl/verify' && req.method === 'POST') return handleVerify(state, req, res, hooks)
|
||||
const session = authorize(state, url.searchParams.get('token'))
|
||||
if (!session) return sendJson(res, 401, { ok: false, error: 'unauthorized' })
|
||||
if (route === '/plwl/unit/plan' && req.method === 'POST') return handlePlan(state, session, url, req, res)
|
||||
if (route === '/plwl/unit/plan' && req.method === 'POST') return handlePlan(state, session, url, req, res, hooks)
|
||||
if (route === '/plwl/unit/file' && req.method === 'PUT') return handlePut(session, url, req, res, hooks)
|
||||
if (route === '/plwl/unit/commit' && req.method === 'POST') return handleCommit(session, url, res)
|
||||
if (route === '/plwl/unit/commit' && req.method === 'POST') return handleCommit(session, url, res, hooks)
|
||||
if (route === '/plwl/apply' && req.method === 'POST') return handleApply(session, req, res, hooks)
|
||||
if (route === '/plwl/end' && req.method === 'POST') return handleEnd(state, session, res)
|
||||
if (route === '/plwl/end' && req.method === 'POST') return handleEnd(state, session, res, hooks)
|
||||
sendJson(res, 404, { ok: false, error: 'not-found' })
|
||||
} catch (err) {
|
||||
sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
||||
|
|
@ -777,10 +805,19 @@ async function handleVerify(state, req, res, hooks) {
|
|||
name: String(payload.name || ''),
|
||||
expires: Date.now() + TOKEN_TTL_MS,
|
||||
lastAt: Date.now(),
|
||||
units: new Map()
|
||||
units: new Map(),
|
||||
// counters behind the "Receiving from X…" popup: files written, bytes
|
||||
// written, and the same totals the sender planned (learned per unit)
|
||||
counted: new Set(),
|
||||
received: 0,
|
||||
planned: 0,
|
||||
bytes: 0,
|
||||
plannedBytes: 0,
|
||||
items: { apps: 0, wines: 0, prefixes: 0 }
|
||||
}
|
||||
state.tokens.set(token, session)
|
||||
state.session = session
|
||||
emitReceive(hooks, session, 'start')
|
||||
sendJson(res, 200, {
|
||||
ok: true,
|
||||
token,
|
||||
|
|
@ -793,8 +830,9 @@ async function handleVerify(state, req, res, hooks) {
|
|||
}
|
||||
}
|
||||
|
||||
async function handlePlan(state, session, url, req, res) {
|
||||
async function handlePlan(state, session, url, req, res, hooks) {
|
||||
const key = url.searchParams.get('unit') || ''
|
||||
const label = url.searchParams.get('label') || ''
|
||||
let roots = []
|
||||
try {
|
||||
roots = JSON.parse(url.searchParams.get('roots') || '[]')
|
||||
|
|
@ -810,6 +848,7 @@ async function handlePlan(state, session, url, req, res) {
|
|||
const paths = new Set()
|
||||
const upload = []
|
||||
const same = []
|
||||
let uploadBytes = 0
|
||||
for (const entry of entries) {
|
||||
if (!withinRoots(entry.path, owned)) continue
|
||||
const abs = filesPath(entry.path)
|
||||
|
|
@ -822,10 +861,26 @@ async function handlePlan(state, session, url, req, res) {
|
|||
}
|
||||
if (entry.type === 'dir') ensureDir(abs)
|
||||
else if (entry.type === 'link') ensureLink(abs, entry.target)
|
||||
else upload.push(entry.path)
|
||||
else {
|
||||
upload.push(entry.path)
|
||||
uploadBytes += entry.size || 0
|
||||
}
|
||||
}
|
||||
session.units.set(key, { roots: owned, paths })
|
||||
// what this unit adds to the transfer: the popup reports files and bytes
|
||||
// against these as they arrive
|
||||
session.planned += upload.length
|
||||
session.plannedBytes += uploadBytes
|
||||
const kind = UNIT_KINDS[String(key).split(':')[0]]
|
||||
if (kind && !session.counted.has(key)) {
|
||||
session.counted.add(key)
|
||||
session.items[kind] = (session.items[kind] || 0) + 1
|
||||
}
|
||||
// the unit's human label (game / prefix / wine name) is announced while it
|
||||
// is planned, so the later file events can name it in the popup
|
||||
session.unitLabel = label
|
||||
sendJson(res, 200, { ok: true, upload, same, entries: paths.size })
|
||||
emitReceive(hooks, session, 'plan', { unit: label })
|
||||
}
|
||||
|
||||
async function handlePut(session, url, req, res, hooks) {
|
||||
|
|
@ -838,15 +893,17 @@ async function handlePut(session, url, req, res, hooks) {
|
|||
return sendJson(res, 400, { ok: false, error: 'bad-path' })
|
||||
}
|
||||
try {
|
||||
await writeStream(abs, req)
|
||||
const written = await writeStream(abs, req)
|
||||
session.received = (session.received || 0) + 1
|
||||
session.bytes = (session.bytes || 0) + (written || 0)
|
||||
sendJson(res, 200, { ok: true })
|
||||
emitReceive(hooks, session, 'copy', { unit: session.unitLabel || '' })
|
||||
} catch (err) {
|
||||
sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCommit(session, url, res) {
|
||||
async function handleCommit(session, url, res, hooks) {
|
||||
const key = url.searchParams.get('unit') || ''
|
||||
const unit = session.units.get(key)
|
||||
if (!unit) return sendJson(res, 400, { ok: false, error: 'no-unit' })
|
||||
|
|
@ -856,7 +913,9 @@ async function handleCommit(session, url, res) {
|
|||
if (abs) removed += await pruneTree(abs, root, unit.paths)
|
||||
}
|
||||
session.units.delete(key)
|
||||
session.deleted = (session.deleted || 0) + removed
|
||||
sendJson(res, 200, { ok: true, deleted: removed })
|
||||
emitReceive(hooks, session, 'commit', { unit: session.unitLabel || '', deleted: removed })
|
||||
}
|
||||
|
||||
async function handleApply(session, req, res, hooks) {
|
||||
|
|
@ -867,8 +926,17 @@ async function handleApply(session, req, res, hooks) {
|
|||
const winesDeleted = cats.has('wines') ? await applyWines(payload) : 0
|
||||
const prefixesDeleted = cats.has('prefixes') ? applyPrefixes(payload) : 0
|
||||
const removed = appsDeleted + winesDeleted + prefixesDeleted
|
||||
emitReceive(hooks, session, 'registry', { deleted: removed })
|
||||
if (hooks.onApplied) {
|
||||
hooks.onApplied({ from: session.name, deviceId: session.deviceId, categories: [...cats], files: session.received || 0, deleted: removed })
|
||||
hooks.onApplied({
|
||||
from: session.name,
|
||||
deviceId: session.deviceId,
|
||||
categories: [...cats],
|
||||
files: session.received || 0,
|
||||
bytes: session.bytes || 0,
|
||||
items: { ...(session.items || {}) },
|
||||
deleted: removed
|
||||
})
|
||||
}
|
||||
sendJson(res, 200, {
|
||||
ok: true,
|
||||
|
|
@ -879,10 +947,11 @@ async function handleApply(session, req, res, hooks) {
|
|||
})
|
||||
}
|
||||
|
||||
function handleEnd(state, session, res) {
|
||||
function handleEnd(state, session, res, hooks) {
|
||||
state.tokens.delete(session.token)
|
||||
if (state.session && state.session.token === session.token) state.session = null
|
||||
sendJson(res, 200, { ok: true })
|
||||
emitReceive(hooks, session, 'done', { deleted: session.deleted || 0 })
|
||||
}
|
||||
|
||||
// --- discovery --------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ function createPusher(api) {
|
|||
// of thousands of entries from being one giant string in memory either side.
|
||||
function planUnit(device, token, unit, entries, force) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const route = `/plwl/unit/plan?token=${encodeURIComponent(token)}&unit=${encodeURIComponent(unit.key)}&roots=${encodeURIComponent(JSON.stringify(unit.roots))}${force ? '&force=1' : ''}`
|
||||
const route = `/plwl/unit/plan?token=${encodeURIComponent(token)}&unit=${encodeURIComponent(unit.key)}&label=${encodeURIComponent(unit.label || '')}&roots=${encodeURIComponent(JSON.stringify(unit.roots))}${force ? '&force=1' : ''}`
|
||||
const req = http.request(
|
||||
{
|
||||
host: device.address,
|
||||
|
|
@ -185,6 +185,10 @@ function createPusher(api) {
|
|||
skipped: 0,
|
||||
deleted: 0,
|
||||
bytes: 0,
|
||||
// how many apps / wine builds / prefixes this sync walked over: the
|
||||
// progress popup reports "3 games, 1 prefix" instead of file counts,
|
||||
// which mean nothing to the user
|
||||
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 }
|
||||
|
|
@ -233,19 +237,6 @@ function createPusher(api) {
|
|||
events.push(data)
|
||||
if (onProgress) onProgress(data)
|
||||
}
|
||||
const emit = (phase, unit, done, total) =>
|
||||
progress({
|
||||
stage: phase,
|
||||
phase,
|
||||
kind: (unit && unit.kind) || '',
|
||||
id: (unit && unit.id) || '',
|
||||
key: (unit && unit.key) || '',
|
||||
unit: (unit && unit.label) || '',
|
||||
label: (unit && unit.label) || '',
|
||||
name: (unit && unit.label) || '',
|
||||
done,
|
||||
total
|
||||
})
|
||||
|
||||
let verified
|
||||
try {
|
||||
|
|
@ -272,6 +263,28 @@ function createPusher(api) {
|
|||
const units = buildUnits(cats)
|
||||
const bucketOf = kind => (kind === 'wine' ? 'wines' : kind === 'prefix' ? 'prefixes' : 'apps')
|
||||
const summary = newSummary(units.length)
|
||||
// bytes the units planned so far will put on the wire: the popup can then
|
||||
// show "1.2 GB / 3.4 GB" instead of only what has already arrived
|
||||
let plannedBytes = 0
|
||||
let itemsSeen = 0
|
||||
const emit = (phase, unit, done, total) =>
|
||||
progress({
|
||||
stage: phase,
|
||||
phase,
|
||||
kind: (unit && unit.kind) || '',
|
||||
id: (unit && unit.id) || '',
|
||||
key: (unit && unit.key) || '',
|
||||
unit: (unit && unit.label) || '',
|
||||
label: (unit && unit.label) || '',
|
||||
name: (unit && unit.label) || '',
|
||||
done,
|
||||
total,
|
||||
bytes: summary.bytes,
|
||||
totalBytes: plannedBytes,
|
||||
units: summary.units,
|
||||
unitsDone: itemsSeen,
|
||||
items: { ...summary.items }
|
||||
})
|
||||
const end = async () => {
|
||||
try {
|
||||
await httpJson(device, 'POST', `/plwl/end?token=${encodeURIComponent(token)}`, { json: {} })
|
||||
|
|
@ -281,6 +294,9 @@ function createPusher(api) {
|
|||
for (const unit of units) {
|
||||
const roots = unit.roots.filter(ownedRoot)
|
||||
if (!roots.length) continue
|
||||
const bucket = summary[bucketOf(unit.kind)]
|
||||
summary.items[bucketOf(unit.kind)] += 1
|
||||
itemsSeen += 1
|
||||
emit('scan', unit, 0, 0)
|
||||
const entries = await buildManifest(roots, !!force, (done, total) => emit('hash', unit, done, total))
|
||||
emit('plan', unit, 0, 0)
|
||||
|
|
@ -288,11 +304,13 @@ 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 || [])
|
||||
const bucket = summary[bucketOf(unit.kind)]
|
||||
// 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
|
||||
for (const entry of entries) {
|
||||
if (entry.type === 'file' && upload.has(entry.path)) plannedBytes += entry.size || 0
|
||||
}
|
||||
let done = 0
|
||||
for (const entry of entries) {
|
||||
if (entry.type !== 'file') continue
|
||||
|
|
|
|||
41
src/main.js
41
src/main.js
|
|
@ -93,10 +93,16 @@ function trayIconPath() {
|
|||
|
||||
function createTray() {
|
||||
const { Tray, Menu } = require('electron')
|
||||
const icon = trayIconPath()
|
||||
try {
|
||||
tray = new Tray(trayIconPath())
|
||||
} catch {
|
||||
return // no icon available; window still works normally
|
||||
tray = new Tray(icon)
|
||||
} catch (err) {
|
||||
// never fatal (the window keeps working), but say why the icon is missing
|
||||
// instead of failing silently - a missing tray looks like a hung app when
|
||||
// the launcher runs in the background
|
||||
console.error(`[tray] could not create the tray icon (${icon || 'no icon file found'}): ${(err && err.message) || err}`)
|
||||
tray = null
|
||||
return
|
||||
}
|
||||
tray.setToolTip('PLWL')
|
||||
rebuildTrayMenu()
|
||||
|
|
@ -233,16 +239,25 @@ app.whenReady().then(async () => {
|
|||
const { games } = registerIpc({ getWindow, onGamesChanged: rebuildTrayMenu })
|
||||
gamesService = games
|
||||
|
||||
// LAN sync runs whenever the launcher runs - that is what makes this
|
||||
// device show up on the other machines' client lists
|
||||
lansync.start({
|
||||
onDevices: devices => sendToWindow(win, 'lansync:devices', devices),
|
||||
onApplied: info => sendToWindow(win, 'lansync:applied', info),
|
||||
isBusy: () => !!(gamesService && gamesService.listRunning().length)
|
||||
})
|
||||
|
||||
// the tray comes first: nothing (least of all the network) may keep it from
|
||||
// coming up - a launcher without a tray icon looks simply broken
|
||||
createTray()
|
||||
|
||||
// LAN sync runs whenever the launcher runs - that is what makes this
|
||||
// device show up on the other machines' client lists
|
||||
const lanHooks = {
|
||||
onDevices: devices => sendToWindow(win, 'lansync:devices', devices),
|
||||
onApplied: info => sendToWindow(win, 'lansync:applied', info),
|
||||
onReceive: info => sendToWindow(win, 'lansync:receiving', info),
|
||||
isBusy: () => !!(gamesService && gamesService.listRunning().length)
|
||||
}
|
||||
try {
|
||||
Promise.resolve(lansync.start(lanHooks)).catch(err => {
|
||||
console.error(`[lansync] start failed: ${(err && err.message) || err}`)
|
||||
})
|
||||
} catch (err) {
|
||||
console.error(`[lansync] start failed: ${(err && err.message) || err}`)
|
||||
}
|
||||
if (headless) {
|
||||
// shortcut boot: run the game without opening the launcher window
|
||||
launchGame(autoLaunchId)
|
||||
|
|
@ -259,5 +274,7 @@ app.whenReady().then(async () => {
|
|||
|
||||
app.on('before-quit', () => {
|
||||
quitting = true
|
||||
lansync.stop()
|
||||
try {
|
||||
lansync.stop()
|
||||
} catch {}
|
||||
})
|
||||
|
|
|
|||
48
src/paths.js
48
src/paths.js
|
|
@ -115,21 +115,47 @@ function resolvePortablePath(p) {
|
|||
}
|
||||
|
||||
// The launcher's own icon (renderer/plwl.png), used by the tray, the app
|
||||
// icon and as the .desktop shortcut fallback. Cached: it never moves at
|
||||
// runtime.
|
||||
// icon and as the .desktop shortcut fallback.
|
||||
//
|
||||
// The module-relative copy comes FIRST: it is the only candidate that exists
|
||||
// in every layout, because a packaged build keeps renderer/ inside
|
||||
// app.asar and Electron's patched fs reads straight out of the archive (a
|
||||
// release only ships olcxja.plwl.png next to the portable root, so looking at
|
||||
// APP_ROOT alone made the tray icon vanish in installed builds). Cached: it
|
||||
// never moves at runtime.
|
||||
function launcherIconCandidates() {
|
||||
const bundled = path.join(__dirname, '..', 'renderer')
|
||||
return [
|
||||
path.join(bundled, 'plwl.png'),
|
||||
path.join(bundled, 'plwl.svg'),
|
||||
path.join(APP_ROOT, 'renderer', 'plwl.png'),
|
||||
path.join(APP_ROOT, 'renderer', 'plwl.svg'),
|
||||
path.join(APP_ROOT, 'olcxja.plwl.png')
|
||||
]
|
||||
}
|
||||
|
||||
function firstExisting(candidates) {
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
if (fs.existsSync(candidate)) return candidate
|
||||
} catch {}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
let launcherIcon = null
|
||||
function launcherIconPath() {
|
||||
if (launcherIcon === null) {
|
||||
const p = path.join(APP_ROOT, 'renderer', 'plwl.png')
|
||||
try {
|
||||
launcherIcon = fs.existsSync(p) ? p : ''
|
||||
} catch {
|
||||
launcherIcon = ''
|
||||
}
|
||||
}
|
||||
if (launcherIcon === null) launcherIcon = firstExisting(launcherIconCandidates()) || ''
|
||||
return launcherIcon || null
|
||||
}
|
||||
|
||||
// The same icon, but only as a real file on disk: a .desktop entry's Icon= is
|
||||
// resolved by the desktop environment, which cannot look inside app.asar.
|
||||
function launcherIconDiskPath() {
|
||||
const onDisk = launcherIconCandidates().filter(candidate => !candidate.includes(`.asar${path.sep}`))
|
||||
return firstExisting(onDisk)
|
||||
}
|
||||
|
||||
function readJson(file, fallback) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(file, 'utf8'))
|
||||
|
|
@ -145,4 +171,4 @@ function writeJson(file, data) {
|
|||
fs.renameSync(tmp, file)
|
||||
}
|
||||
|
||||
module.exports = { RESOURCES, APP_ROOT, BUILD, FILES, DATA, DIRS, ensureDirs, syncBundledTranslations, launcherIconPath, readJson, writeJson, toPortablePath, resolvePortablePath }
|
||||
module.exports = { RESOURCES, APP_ROOT, BUILD, FILES, DATA, DIRS, ensureDirs, syncBundledTranslations, launcherIconPath, launcherIconDiskPath, readJson, writeJson, toPortablePath, resolvePortablePath }
|
||||
|
|
|
|||
|
|
@ -95,5 +95,6 @@ contextBridge.exposeInMainWorld('plwl', {
|
|||
onLanSyncProgress: cb => {
|
||||
lanSyncProgressCb = cb
|
||||
},
|
||||
onLanSyncApplied: cb => ipcRenderer.on('lansync:applied', (e, data) => cb(data))
|
||||
onLanSyncApplied: cb => ipcRenderer.on('lansync:applied', (e, data) => cb(data)),
|
||||
onLanSyncReceiving: cb => ipcRenderer.on('lansync:receiving', (e, data) => cb(data))
|
||||
})
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const { DIRS, resolvePortablePath } = require('./paths')
|
||||
const { DIRS, resolvePortablePath, launcherIconDiskPath } = require('./paths')
|
||||
|
||||
const DATA_DIR = process.env.PLWL_BUILD_DIR ? path.join(process.env.PLWL_BUILD_DIR, 'files', 'data') : DIRS.DATA
|
||||
|
||||
|
|
@ -62,8 +62,9 @@ function buildDesktopEntry(game) {
|
|||
if (found) icon = path.join(iconDir, found)
|
||||
} catch {}
|
||||
if (!icon) {
|
||||
// fall back to the launcher icon so the shortcut is never naked
|
||||
icon = require('./paths').launcherIconPath() || ''
|
||||
// fall back to the launcher icon so the shortcut is never naked (the
|
||||
// on-disk copy: the desktop environment cannot read inside app.asar)
|
||||
icon = launcherIconDiskPath() || ''
|
||||
}
|
||||
const lines = [
|
||||
'[Desktop Entry]',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue