PLWL/src/main.js

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 {}
})