add support for directly launching windows exes from file exporer using .desktop shortcut

This commit is contained in:
olcxja 2026-09-30 17:32:30 +02:00
commit 5e217472aa
15 changed files with 440 additions and 28 deletions

57
src/argv.js Normal file
View file

@ -0,0 +1,57 @@
'use strict'
// Shared command-line parsing for the launcher process itself. Both boot
// (process.argv) and the single-instance handoff (second-instance argv) use
// it, so a shortcut, a "Open with PLWL" file-open and Chromium's own injected
// flags are handled the same way everywhere.
//
// Supported forms:
// --launch <id> | --launch=<id> auto-start that library app
// --open <path> | --open=<path> open an .exe with the wine picker
// a bare existing-file path same as --open (plumbing for Exec %f)
function firstNonFlagAfter(args, i) {
// Chromium injects its own flags into the second instance's argv and they
// can land BETWEEN the option and its value (e.g. `--launch
// --allow-file-access-from-files 1790414564627`), so skip anything that
// looks like a flag and take the first non-flag token.
for (let j = i + 1; j < args.length; j++) {
if (!args[j].startsWith('--')) return args[j]
}
return null
}
function findValue(args, names) {
for (const name of names) {
const i = args.indexOf(name)
if (i !== -1) return firstNonFlagAfter(args, i)
const eq = args.find(a => a.startsWith(name + '='))
if (eq) return eq.slice(name.length + 1)
}
return null
}
// PLWL's own options must not swallow Chromium's flags (--flag=... style),
// so anything after a flag that itself carries a value is skipped here by
// looking only at the KNOWN option names above.
function parseArgv(argv) {
const args = Array.isArray(argv) ? argv : process.argv
const launchId = findValue(args, ['--launch'])
let openPath = findValue(args, ['--open'])
if (!openPath && !launchId) {
// "Open with" plumbing (Exec %f): the file path arrives as the only
// non-flag argument - never pick a bare token when a real flag carries
// the intent, and skip this process's own binary (electron dev mode
// passes it as the first argument).
const bare = args.find(
a =>
!a.startsWith('-') &&
/\.(exe|msi|bat|cmd|lnk)$/i.test(a) &&
!/(electron|olcxja\.plwl)(\.exe)?$/i.test(a)
)
if (bare) openPath = bare
}
return { launchId, openPath }
}
module.exports = { parseArgv, findValue }

View file

@ -31,6 +31,14 @@ function createGamesService({ getWindow, onGamesChanged }) {
const isRunning = id => running.has(id)
const listRunning = () => [...running.keys()]
// "Open with PLWL" handoff: queue the file for the renderer (picker popup
// with wine version + prefix). Safe to call at any time - if the window is
// not up yet, the renderer picks the file up right after boot.
function openExternally(openPath) {
require('../openwith').setPending(openPath)
sendToWindow(getWindow(), 'openwith:file', openPath)
}
function resolveWine(game) {
const system = { id: 'system', type: 'system', label: 'System Wine' }
const wanted = game.wine === 'default' ? (library.loadSettings().defaultWine || 'system') : (game.wine || 'system')
@ -120,7 +128,7 @@ function createGamesService({ getWindow, onGamesChanged }) {
return importGameFromPrefix(prefix, srcDir)
}
return { isRunning, listRunning, start, kill, runToolFor, import: importTo, importFromPrefix, setOnGameExit }
return { isRunning, listRunning, start, kill, runToolFor, import: importTo, importFromPrefix, setOnGameExit, openExternally }
}
module.exports = { createGamesService, killProcessGroup, sendToWindow }

View file

@ -12,6 +12,7 @@ const runner = require('../runner')
const { extractIconFromExe } = require('../icons')
const { createGamesService, sendToWindow } = require('./games')
const shortcuts = require('../shortcuts')
const openwith = require('../openwith')
const ICON_EXTS = ['png', 'jpe?g', 'svg', 'ico', 'webp', 'gif', 'bmp']
@ -110,6 +111,7 @@ function registerIpc({ getWindow, onGamesChanged }) {
}
})
ipcMain.handle('games:shortcutPaths', (e, id) => shortcuts.shortcutPaths(id))
ipcMain.handle('openwith:texts', (e, lang) => openwith.textsFor(lang))
ipcMain.handle('games:clearPlaytime', (e, id) => library.clearPlaytime(id))
// games: icons ---------------------------------------------------------------------
@ -156,6 +158,29 @@ function registerIpc({ getWindow, onGamesChanged }) {
return extractGameIcon(id, game.exePath)
})
// "Open with PLWL" ------------------------------------------------------------------
// The renderer asks for the file handed in via `--open` (Exec %f), then
// launches it with the wine/prefix picked in the popup. Nothing is imported
// into the library.
ipcMain.handle('openwith:take', async () => {
const p = openwith.getPending()
if (!p) return null
openwith.clearPending()
return p
})
ipcMain.handle('openwith:run', async (e, payload) => {
try {
return await openwith.run({
openPath: payload && payload.openPath,
wine: payload && payload.wine,
prefix: payload && payload.prefix,
lang: library.loadSettings().language
})
} catch (err) {
return errorResult(err)
}
})
// games: run --------------------------------------------------------------------------
ipcMain.handle('games:run', (e, id) => games.start(getWindow(), id))
ipcMain.handle('games:kill', (e, id) => games.kill(id))

View file

@ -7,6 +7,8 @@ const wines = require('./wines')
const { registerIpc } = require('./ipc')
const { createMainWindow } = require('./window')
const { setAppIcon } = require('./appicon')
const { parseArgv } = require('./argv')
const openwith = require('./openwith')
const path = require('path')
const fs = require('fs')
@ -23,6 +25,7 @@ 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
@ -50,9 +53,10 @@ if (!gotLock) {
} else {
app.on('second-instance', (e, argv) => {
trace(`second-instance argv=${argv.join(' ')}`)
const id = parseLaunchId(argv)
const { launchId, openPath } = parseArgv(argv)
lastHandoffAt = Date.now()
if (id) launchGame(id)
if (openPath) openWith(openPath)
else if (launchId) launchGame(launchId)
else showMainWindow()
})
}
@ -62,24 +66,20 @@ if (!gotLock) {
// another game" can never kill the launcher mid-handoff
let lastHandoffAt = 0
function parseLaunchId(argv) {
const args = Array.isArray(argv) ? argv : process.argv
const i = args.indexOf('--launch')
if (i !== -1) {
// Chromium injects its own flags into the second instance's argv and they
// can land BETWEEN --launch and the value (e.g. `--launch
// --allow-file-access-from-files 1790414564627`), so skip anything that
// looks like a flag and take the first non-flag token.
for (let j = i + 1; j < args.length; j++) {
if (!args[j].startsWith('--')) return args[j]
}
return null
}
const eq = args.find(a => a.startsWith('--launch='))
return eq ? eq.slice('--launch='.length) : null
// --- "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)
}
autoLaunchId = parseLaunchId(process.argv)
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
@ -241,6 +241,11 @@ app.whenReady().then(async () => {
} 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())
}
}
})

108
src/openwith.js Normal file
View file

@ -0,0 +1,108 @@
'use strict'
// "Open with PLWL" (file manager -> Open With -> PLWL): a file handed in via
// `--open <path>` (or the Exec %f plumbing) is parked here until the renderer
// picks it up, then launched ad hoc with the wine version + prefix chosen in
// the picker popup. Nothing is imported into the library.
const fs = require('fs')
const path = require('path')
let pending = null // file path waiting for the picker (service not ready yet)
function setPending(p) {
pending = p
}
function getPending() {
return pending
}
function hasPending() {
return !!pending
}
function clearPending() {
pending = null
}
// The launcher's own translations may not cover openwith texts in older
// installs - fall back to English keys so the UI never shows raw key names.
function textsFor(code) {
let 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) {
const system = { id: 'system', type: 'system', label: 'System Wine' }
const wanted = id === 'default' ? require('./library').loadSettings().defaultWine || 'system' : id
if (!wanted || wanted === 'system') return system
return require('./wines').getWine(wanted) || system
}
function resolvePrefix(name) {
const defs = require('./prefixes').listPrefixes()
const known = name && defs.prefixes.some(p => p.name === name) ? name : ''
return known || defs.default || 'Main'
}
// Ad-hoc run target: shaped exactly like a library game (runner.buildEnv and
// the playtime/log paths expect those fields) but nothing is persisted.
function makeTarget(openPath, wineId, prefixName) {
const dir = path.dirname(openPath)
return {
id: 'openwith',
name: path.basename(openPath),
mode: 'open',
path: dir,
exePath: openPath,
workdir: dir,
wine: wineId || 'default',
prefix: prefixName || '',
args: '',
dllOverrides: [],
env: {},
iconSource: '',
playSeconds: 0,
created: 0,
lastPlayed: 0
}
}
// Launch without importing: resolves the chosen wine/prefix, builds the ad
// hoc target and runs it exactly like a library app (same env, same logging,
// same detached process group).
async function run({ openPath, wine: wineId, prefix: prefixName0, lang, onStarted }) {
if (!openPath || !fs.existsSync(openPath)) return { ok: false, error: 'file-missing' }
if (!/\.(exe|msi|bat|cmd|lnk)$/i.test(openPath)) return { ok: false, error: 'not-windows' }
const wine = resolveWine(wineId)
const prefixName = resolvePrefix(prefixName0)
const runner = require('./runner')
try {
// runAdHoc builds the synthetic target internally (shared `openwith` id,
// so logs/playtime never collide with a library app id); the resolved
// prefix is passed through so buildEnv uses the chosen one
const res = await runner.runAdHoc({ openPath, wine, systemWine: wine.type === 'system', prefix: prefixName })
if (onStarted) onStarted({ name: path.basename(openPath), pid: res.pid })
return { ok: true, pid: res.pid || null, logFile: res.logFile || null }
} catch (err) {
return { ok: false, error: String((err && err.message) || err) }
}
}
module.exports = { setPending, getPending, hasPending, clearPending, run, makeTarget, resolveWine, resolvePrefix, textsFor }

View file

@ -10,6 +10,15 @@ ipcRenderer.on('import:progress', (e, data) => {
if (importProgressCb) importProgressCb(data)
})
// "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)
})
const fs = require('fs')
const path = require('path')
contextBridge.exposeInMainWorld('plwl', {
FILES_DIR: DIRS.FILES,
i18nList: () => ipcRenderer.invoke('i18n:list'),
@ -26,6 +35,20 @@ contextBridge.exposeInMainWorld('plwl', {
prefixesCreate: name => ipcRenderer.invoke('prefixes:create', name),
prefixesDelete: name => ipcRenderer.invoke('prefixes:delete', name),
prefixesSetDefault: name => ipcRenderer.invoke('prefixes:setDefault', name),
openwithTake: () => ipcRenderer.invoke('openwith:take'),
openwithRun: payload => ipcRenderer.invoke('openwith:run', payload),
openwithTexts: lang => ipcRenderer.invoke('openwith:texts', lang),
onOpenWith: cb => {
openWithCb = cb
},
basename: p => (p ? path.basename(p) : ''),
fileExists: p => {
try {
return !!p && fs.existsSync(p)
} catch {
return false
}
},
gamesList: () => ipcRenderer.invoke('games:list'),
gamesGet: id => ipcRenderer.invoke('games:get', id),
gamesUpdate: (id, patch) => ipcRenderer.invoke('games:update', id, patch),

View file

@ -87,9 +87,11 @@ const GRACE_MS = 60000
// (a number of seconds) every 30s - apps.json is left untouched to spare the
// disk. lastPlayed is stamped once at start, final totals are mirrored into
// the registry once on exit.
function startPlaytimeTicker({ game, startedAt }) {
// `base` is the seconds total this run adds up to. It is resolved by the
// CALLER from the live playtime file (not the registry) so ad-hoc runs that
// share one id (see runAdHoc) never mix two apps' playtime.
function startPlaytimeTicker({ game, startedAt, base }) {
const library = require('./library')
const base = game.playSeconds || 0
const timer = setInterval(() => {
const counted = Math.max(0, Math.round((Date.now() - startedAt - GRACE_MS) / 1000))
library.writePlaySeconds(game.id, base + counted)
@ -145,7 +147,10 @@ function runGame({ game, wine, systemWine, onExit }) {
child.on('error', reject)
child.unref()
const startedAt = Date.now()
const stopTicker = startPlaytimeTicker({ game, startedAt })
// read the LIVE playtime file, not the (possibly shared/stale) registry
// value - ad-hoc "open with" runs share one id and must not mix playtime
const basePlaySeconds = require('./library').readPlaySeconds(game.id) ?? (game.playSeconds || 0)
const stopTicker = startPlaytimeTicker({ game, startedAt, base: basePlaySeconds })
// stamp "last played" once at start so it is correct even if we crash later
require('./library').updateGame(game.id, { lastPlayed: startedAt })
child.on('exit', (code, signal) => {
@ -154,7 +159,7 @@ function runGame({ game, wine, systemWine, onExit }) {
const counted = Math.max(0, Math.round((Date.now() - startedAt - GRACE_MS) / 1000))
require('./library').updateGame(game.id, {
lastPlayed: startedAt,
playSeconds: (game.playSeconds || 0) + counted
playSeconds: basePlaySeconds + counted
})
if (onExit) onExit(code, signal)
})
@ -189,4 +194,14 @@ function runTool({ game, wine, tool, args, systemWine }) {
})
}
module.exports = { buildEnv, runGame, runTool, parseArgs, latestLogPath }
// Ad-hoc launch ("Open with PLWL"): the exact runGame pipeline, but with a
// synthetic target under the shared `openwith` id - log and playtime files
// live in files/data/appdata/openwith/ and no library entry is created.
// `prefix` carries the picker's choice ('' = launcher default, resolved by
// buildEnv); wine/systemWine are the already-resolved build objects.
function runAdHoc({ openPath, wine, systemWine, prefix, onExit }) {
const game = require('./openwith').makeTarget(openPath, 'default', prefix || '')
return runGame({ game, wine, systemWine, onExit })
}
module.exports = { buildEnv, runGame, runTool, runAdHoc, parseArgs, latestLogPath }