280 lines
8.8 KiB
JavaScript
280 lines
8.8 KiB
JavaScript
'use strict'
|
|
|
|
const { app } = require('electron')
|
|
const { ensureDirs, DIRS } = require('./paths')
|
|
const i18n = require('./i18n')
|
|
const wines = require('./wines')
|
|
const { registerIpc, sendToWindow } = require('./ipc')
|
|
const { createMainWindow } = require('./window')
|
|
const { setAppIcon } = require('./appicon')
|
|
const { parseArgv } = require('./argv')
|
|
const openwith = require('./openwith')
|
|
const lansync = require('./lansync')
|
|
const path = require('path')
|
|
const fs = require('fs')
|
|
|
|
app.setName('PLWL')
|
|
|
|
// The launcher keeps running with only a tray icon, so closing the window
|
|
// must not quit the app (playtime keeps counting while a game runs).
|
|
app.on('window-all-closed', e => {
|
|
// no-op on purpose: tray Quit / menu Quit is the way out
|
|
})
|
|
|
|
let win = null
|
|
let tray = null
|
|
let quitting = false
|
|
let headless = false // started via --launch: no window until the user asks
|
|
let autoLaunchId = null // game id from --launch <id>
|
|
let autoOpenPath = null // file path from --open ("Open with PLWL")
|
|
let autoQuitWhenDone = false // headless boot: quit after that game exits
|
|
const getWindow = () => win
|
|
|
|
// --- single instance ---------------------------------------------------------
|
|
// Diagnostics: the shortcut handoff path is invisible otherwise (a failing
|
|
// handoff just "does nothing" for the user). Everything lands in a small
|
|
// trace file next to the launcher data so problems are reproducible.
|
|
function trace(msg) {
|
|
try {
|
|
const file = path.join(DIRS.DATA, 'instance-trace.log')
|
|
// keep the trace tiny: drop it once it grows past ~64 KiB
|
|
try {
|
|
if (fs.existsSync(file) && fs.statSync(file).size > 64 * 1024) fs.rmSync(file, { force: true })
|
|
} catch {}
|
|
fs.appendFileSync(file, `${new Date().toISOString()} ${msg}\n`)
|
|
} catch {}
|
|
}
|
|
|
|
// A second launcher invocation (e.g. a desktop shortcut with --launch <id>)
|
|
// hands its arguments to the running instance and exits immediately.
|
|
const gotLock = app.requestSingleInstanceLock()
|
|
trace(`boot argv=${process.argv.slice(1).join(' ')} gotLock=${gotLock}`)
|
|
if (!gotLock) {
|
|
app.quit()
|
|
} else {
|
|
app.on('second-instance', (e, argv) => {
|
|
trace(`second-instance argv=${argv.join(' ')}`)
|
|
const { launchId, openPath } = parseArgv(argv)
|
|
lastHandoffAt = Date.now()
|
|
if (openPath) openWith(openPath)
|
|
else if (launchId) launchGame(launchId)
|
|
else showMainWindow()
|
|
})
|
|
}
|
|
|
|
// set on every second-instance handoff: the auto-quit check skips itself for
|
|
// a moment afterwards, so "one game exits" racing "a shortcut just started
|
|
// another game" can never kill the launcher mid-handoff
|
|
let lastHandoffAt = 0
|
|
|
|
// --- "Open with PLWL" (Exec %f from the file manager) --------------------------
|
|
// Hands the file to the window (renderer shows the wine/prefix picker popup);
|
|
// headless boots open the window for it - a file cannot be launched without
|
|
// the user choosing how.
|
|
async function openWith(filePath) {
|
|
trace(`openWith ${filePath} serviceReady=${!!gamesService}`)
|
|
showMainWindow()
|
|
if (gamesService) gamesService.openExternally(filePath)
|
|
else openwith.setPending(filePath)
|
|
}
|
|
|
|
const auto = parseArgv(process.argv)
|
|
autoLaunchId = auto.launchId
|
|
autoOpenPath = auto.openPath
|
|
headless = !!autoLaunchId
|
|
autoQuitWhenDone = headless // booting just to run one game -> exit when it ends
|
|
|
|
// --- tray ---------------------------------------------------------------------
|
|
// Lives as long as the app does: shows "active in background" even when the
|
|
// window is closed, and offers running games + Quit.
|
|
function trayIconPath() {
|
|
return require('./paths').launcherIconPath() || undefined
|
|
}
|
|
|
|
function createTray() {
|
|
const { Tray, Menu } = require('electron')
|
|
const icon = trayIconPath()
|
|
try {
|
|
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()
|
|
}
|
|
|
|
function rebuildTrayMenu() {
|
|
if (!tray) return
|
|
const { Menu } = require('electron')
|
|
const lang = loadLangSafe()
|
|
const gamesSvc = gamesService
|
|
const running = gamesSvc ? gamesSvc.listRunning() : []
|
|
const items = []
|
|
|
|
items.push({
|
|
label: i18n.translate(lang, 'tray.open'),
|
|
click: () => showMainWindow()
|
|
})
|
|
|
|
if (running.length) {
|
|
items.push({ type: 'separator' })
|
|
items.push({ label: i18n.translate(lang, 'tray.running'), enabled: false })
|
|
for (const id of running) {
|
|
const game = require('./library').getGame(id)
|
|
if (!game) continue
|
|
items.push({
|
|
label: `• ${game.name}`,
|
|
click: () => {
|
|
// click toggles: stop the running game
|
|
gamesSvc.kill(id)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
items.push({ type: 'separator' })
|
|
items.push({
|
|
label: i18n.translate(lang, 'tray.quit'),
|
|
click: () => {
|
|
quitting = true
|
|
app.quit()
|
|
}
|
|
})
|
|
tray.setContextMenu(Menu.buildFromTemplate(items))
|
|
const tip = running.length
|
|
? `PLWL - ${i18n.translate(lang, 'tray.tooltipRunning', running.length)}`
|
|
: 'PLWL'
|
|
tray.setToolTip(tip)
|
|
}
|
|
|
|
function loadLangSafe() {
|
|
try {
|
|
return require('./library').loadSettings().language || 'en-us'
|
|
} catch {
|
|
return 'en-us'
|
|
}
|
|
}
|
|
|
|
function showMainWindow() {
|
|
// the user is present now - headless auto-quit must never fire again
|
|
headless = false
|
|
autoQuitWhenDone = false
|
|
if (gamesService) gamesService.setOnGameExit(null)
|
|
if (win && !win.isDestroyed()) {
|
|
win.show()
|
|
win.focus()
|
|
return
|
|
}
|
|
win = createMainWindow()
|
|
wireWindow(win)
|
|
}
|
|
|
|
function wireWindow(w) {
|
|
// closing the window hides it to the tray (playtime keeps counting in the
|
|
// background) unless the user disabled that in settings - then it quits
|
|
w.on('close', e => {
|
|
if (quitting) return
|
|
let closeToTray = true
|
|
try {
|
|
closeToTray = require('./library').loadSettings().closeToTray !== false
|
|
} catch {}
|
|
if (closeToTray) {
|
|
e.preventDefault()
|
|
w.hide()
|
|
} else {
|
|
quitting = true
|
|
app.quit()
|
|
}
|
|
})
|
|
w.on('closed', () => {
|
|
win = null
|
|
})
|
|
}
|
|
|
|
// --- game auto-launch (shortcuts) -----------------------------------------------
|
|
let gamesService = null
|
|
|
|
async function launchGame(id) {
|
|
trace(`launchGame ${id} serviceReady=${!!gamesService}`)
|
|
if (!gamesService) return
|
|
const res = await gamesService.start(getWindow(), id)
|
|
trace(`launchGame ${id} result=${JSON.stringify(res).slice(0, 200)}`)
|
|
if (res.ok) {
|
|
rebuildTrayMenu()
|
|
// headless boot: when the auto-started game finishes and nothing else is
|
|
// running, quit the launcher
|
|
if (autoQuitWhenDone) {
|
|
gamesService.setOnGameExit(() => {
|
|
// fires for EVERY game exit; quit only when the last one just ended,
|
|
// the user never opened the window and no shortcut handed a new game
|
|
// over in the last few seconds (start/exit race guard)
|
|
if (Date.now() - lastHandoffAt < 5000) return
|
|
if (!gamesService.listRunning().length) {
|
|
quitting = true
|
|
app.quit()
|
|
}
|
|
})
|
|
}
|
|
} else if (headless && autoQuitWhenDone) {
|
|
// nothing to run (bad shortcut target) -> do not linger in the tray
|
|
quitting = true
|
|
app.quit()
|
|
}
|
|
}
|
|
|
|
app.whenReady().then(async () => {
|
|
if (process.platform === 'linux') {
|
|
try {
|
|
setAppIcon(app)
|
|
} catch {}
|
|
}
|
|
ensureDirs()
|
|
await i18n.init()
|
|
await wines.refreshWines()
|
|
const { games } = registerIpc({ getWindow, onGamesChanged: rebuildTrayMenu })
|
|
gamesService = games
|
|
|
|
// 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)
|
|
} else {
|
|
win = createMainWindow()
|
|
wireWindow(win)
|
|
// file open handed over while the service was still booting (or queued
|
|
// by a second instance racing the first boot)
|
|
if (autoOpenPath || openwith.hasPending()) {
|
|
openWith(autoOpenPath || openwith.getPending())
|
|
}
|
|
}
|
|
})
|
|
|
|
app.on('before-quit', () => {
|
|
quitting = true
|
|
try {
|
|
lansync.stop()
|
|
} catch {}
|
|
})
|