add support for directly launching windows exes from file exporer using .desktop shortcut
This commit is contained in:
parent
66f42f30cc
commit
5e217472aa
15 changed files with 440 additions and 28 deletions
|
|
@ -43,6 +43,10 @@ cd PLWL
|
|||
|
||||
Besides copy/move/leave, an app can be imported **from a wine prefix**: pick the prefix, then the app folder inside its `drive_c`. Nothing is copied — paths are stored relative to the prefix, so wine sees the app as if it were normally installed on `C:\` (registry entries pointing to `C:\...` keep working). Both layouts work: plain wine prefixes (`drive_c` at the top) and Proton-managed ones (`drive_c` under `pfx/`). The app stays tied to that prefix; removing the launcher entry never deletes files from the prefix.
|
||||
|
||||
## "Open with PLWL" in the file manager
|
||||
|
||||
The desktop entry (`MimeType` + `Exec %f`) makes PLWL show up in Dolphin/Nautilus under **Open With** for Windows executables (`.exe`, `.msi`, `.bat`, `.cmd`, `.lnk`). Opening a file that way shows a small popup in the launcher where you pick the **Wine/Proton version** and the **prefix** for this run — both default to the launcher's defaults, nothing is imported into the library. A second launcher instance hands the path to the running one and exits (same single-instance mechanism the shortcuts use).
|
||||
|
||||
## Wine & Proton support
|
||||
|
||||
- **System Wine** — uses the `wine` binary from PATH.
|
||||
|
|
|
|||
|
|
@ -144,6 +144,16 @@
|
|||
"settings.saved": "Settings saved",
|
||||
"settings.openFiles": "Open files folder",
|
||||
"settings.closeToTray": "Close window to tray (keep tracking playtime in background)",
|
||||
"openwith.title": "Open with Wine",
|
||||
"openwith.file": "File",
|
||||
"openwith.fileMissing": "This file no longer exists (or is not a Windows executable).",
|
||||
"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).",
|
||||
"dialog.ok": "OK",
|
||||
"dialog.cancel": "Cancel",
|
||||
"dialog.close": "Close",
|
||||
|
|
|
|||
|
|
@ -144,6 +144,16 @@
|
|||
"settings.saved": "Ustawienia zapisane",
|
||||
"settings.openFiles": "Otwórz folder files",
|
||||
"settings.closeToTray": "Zamykaj okno do zasobnika systemowego (licz czas gry w tle)",
|
||||
"openwith.title": "Otwórz przez Wine",
|
||||
"openwith.file": "Plik",
|
||||
"openwith.fileMissing": "Ten plik już nie istnieje (albo nie jest plikiem wykonywalnym Windows).",
|
||||
"openwith.wine": "Wersja Wine / Proton",
|
||||
"openwith.wineDefault": "Domyślna ({0})",
|
||||
"openwith.prefix": "Prefiks Wine",
|
||||
"openwith.prefixDefault": "Prefiks domyślny ({0})",
|
||||
"openwith.run": "Uruchom",
|
||||
"openwith.startFailed": "Nie udało się uruchomić {0}: {1}",
|
||||
"openwith.notWindows": "To nie jest plik wykonywalny Windows (exe/msi/bat/cmd/lnk).",
|
||||
"dialog.ok": "OK",
|
||||
"dialog.cancel": "Anuluj",
|
||||
"dialog.close": "Zamknij",
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@
|
|||
"main": "src/main.js",
|
||||
"scripts": {
|
||||
"start": "electron .",
|
||||
"test": "node test/import.test.js && node test/prefix-import.test.js && node test/icons.test.js && node test/shortcuts.test.js && node test/wines-portable.test.js",
|
||||
"smoke": "node test/import.test.js && node test/prefix-import.test.js && node test/icons.test.js && node test/shortcuts.test.js && node test/wines-portable.test.js"
|
||||
"test": "node test/import.test.js && node test/prefix-import.test.js && node test/icons.test.js && node test/shortcuts.test.js && node test/wines-portable.test.js && node test/openwith.test.js",
|
||||
"smoke": "node test/import.test.js && node test/prefix-import.test.js && node test/icons.test.js && node test/shortcuts.test.js && node test/wines-portable.test.js && node test/openwith.test.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron/asar": "^3.4.1",
|
||||
|
|
|
|||
109
renderer/app.js
109
renderer/app.js
|
|
@ -597,6 +597,114 @@ async function doImport(srcDir, mode) {
|
|||
}
|
||||
}
|
||||
|
||||
// --- "Open with PLWL" (file manager -> Open With -> PLWL) -----------------------
|
||||
|
||||
const openwithTexts = {}
|
||||
|
||||
function ot(key, ...args) {
|
||||
let text = openwithTexts[key] ?? t(key)
|
||||
args.forEach((v, i) => {
|
||||
text = String(text).split(`{${i}}`).join(String(v))
|
||||
})
|
||||
return text
|
||||
}
|
||||
|
||||
async function loadOpenwithTexts() {
|
||||
try {
|
||||
Object.assign(openwithTexts, await window.plwl.openwithTexts(state.lang))
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function isWindowsExecutable(p) {
|
||||
return /\.(exe|msi|bat|cmd|lnk)$/i.test(p || '')
|
||||
}
|
||||
|
||||
// The popup: wine version + prefix selects defaulting to the LAUNCHER's
|
||||
// defaults (settings.defaultWine / prefixes.default), like the per-app
|
||||
// config's "Default (...)" options - just without importing anything.
|
||||
async function showOpenWithModal(openPath) {
|
||||
// always fresh: wines/prefixes/default can change while the launcher sits
|
||||
// in the tray
|
||||
try {
|
||||
const [wines, prefixes, settings] = await Promise.all([
|
||||
window.plwl.winesList(),
|
||||
window.plwl.prefixesList(),
|
||||
window.plwl.settingsGet()
|
||||
])
|
||||
state.wines = wines
|
||||
state.prefixes = prefixes
|
||||
state.settings = settings
|
||||
} catch {}
|
||||
const modal = showModal(`
|
||||
<h2>${ot('openwith.title')}</h2>
|
||||
<p class="subtitle openwith-file-row">${ot('openwith.file')}: <code id="ow-file"></code></p>
|
||||
<p class="subtitle" id="ow-missing" style="display:none">${ot('openwith.fileMissing')}</p>
|
||||
<label class="field-label">${ot('openwith.wine')}</label>
|
||||
<span id="ow-wine"></span>
|
||||
<label class="field-label">${ot('openwith.prefix')}</label>
|
||||
<span id="ow-prefix"></span>
|
||||
<div class="modal-actions">
|
||||
<button class="button" id="ow-cancel">${t('dialog.cancel')}</button>
|
||||
<button class="button primary" id="ow-run">${ot('openwith.run')}</button>
|
||||
</div>
|
||||
`)
|
||||
modal.querySelector('#ow-file').textContent = openPath
|
||||
modal.querySelector('#ow-file').title = openPath
|
||||
const missing = !window.plwl.fileExists(openPath) || !isWindowsExecutable(openPath)
|
||||
modal.querySelector('#ow-missing').style.display = missing ? '' : 'none'
|
||||
const defWine = (state.settings && state.settings.defaultWine) || 'system'
|
||||
const defPrefix = state.prefixes.default || ''
|
||||
const wineOpts = [
|
||||
{ value: 'default', label: ot('openwith.wineDefault', wineLabel(defWine)) },
|
||||
...state.wines.map(w => ({ value: w.id, label: w.label }))
|
||||
]
|
||||
const prefixOpts = [
|
||||
{ value: '', label: ot('openwith.prefixDefault', defPrefix || 'Main') },
|
||||
...state.prefixes.prefixes.filter(p => p.name !== defPrefix).map(p => ({ value: p.name, label: p.name }))
|
||||
]
|
||||
new CustomSelect(modal.querySelector('#ow-wine'), wineOpts, 'default')
|
||||
new CustomSelect(modal.querySelector('#ow-prefix'), prefixOpts, '')
|
||||
modal.querySelector('#ow-cancel').onclick = closeModal
|
||||
modal.querySelector('#ow-run').onclick = async () => {
|
||||
if (missing) {
|
||||
closeModal()
|
||||
return
|
||||
}
|
||||
const runBtn = modal.querySelector('#ow-run')
|
||||
runBtn.disabled = true
|
||||
const res = await window.plwl.openwithRun({
|
||||
openPath,
|
||||
wine: modal.querySelector('#ow-wine').dataset.value || 'default',
|
||||
prefix: modal.querySelector('#ow-prefix').dataset.value || ''
|
||||
})
|
||||
closeModal()
|
||||
if (res.ok) {
|
||||
toast(t('toast.started', window.plwl.basename(openPath)), 'success')
|
||||
} else {
|
||||
const raw = res.error === 'file-missing' ? ot('openwith.fileMissing') : (res.error === 'not-windows' ? ot('openwith.notWindows') : (res.error || ''))
|
||||
toast(ot('openwith.startFailed', window.plwl.basename(openPath), raw), 'error')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Entry point: the main process hands over a file opened through PLWL
|
||||
// (boot argv or a second-instance handoff).
|
||||
async function handleOpenWith(openPath) {
|
||||
if (!openPath) return
|
||||
await loadOpenwithTexts()
|
||||
showOpenWithModal(openPath)
|
||||
}
|
||||
|
||||
function wireOpenWith() {
|
||||
// Both delivery paths (live handoff and the boot queue) converge on
|
||||
// openwithTake(): the main-process slot is drained exactly when a popup
|
||||
// shows, so a reloaded window can never resurrect an old popup and two
|
||||
// quick handoffs collapse into one (the newest file wins).
|
||||
window.plwl.onOpenWith(() => window.plwl.openwithTake().then(handleOpenWith))
|
||||
// file queued before the window existed (boot handoff): pick it up once
|
||||
window.plwl.openwithTake().then(handleOpenWith)
|
||||
}
|
||||
|
||||
// Import from a prefix, step 2: register the app in place - nothing is
|
||||
// copied, paths stay prefix-relative.
|
||||
async function doPrefixImport(prefix, srcDir) {
|
||||
|
|
@ -1075,6 +1183,7 @@ async function boot() {
|
|||
applyI18n()
|
||||
wireEvents()
|
||||
wireRealtime()
|
||||
wireOpenWith()
|
||||
qs('#loading').remove()
|
||||
qs('#main').style.display = 'flex'
|
||||
showView('library')
|
||||
|
|
|
|||
|
|
@ -627,6 +627,36 @@ select {
|
|||
.modal .mode-buttons { display: flex; flex-direction: column; gap: 0.6rem; }
|
||||
.modal .mode-buttons .button { justify-content: flex-start; }
|
||||
|
||||
/* --- "Open with PLWL" picker ---------------------------------------------- */
|
||||
.modal .openwith-file-row {
|
||||
word-break: break-all;
|
||||
margin-top: -0.3rem;
|
||||
}
|
||||
.modal .openwith-file-row code {
|
||||
font-family: inherit;
|
||||
color: var(--text-color);
|
||||
background-color: rgba(255, 255, 255, 0.06);
|
||||
border-radius: 0.4rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
}
|
||||
.button:disabled {
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* --- form fields inside modals (shared by every modal with inputs) --------- */
|
||||
/* .modal is a flex column with gap: 0.8rem. That gap ADDS to the field
|
||||
margins reused from the config forms (.field-label margin-top + control
|
||||
margin), so label->control spacing in modals ended up ~3x bigger than in
|
||||
.form-card (where block-layout margins collapse). Inside modals the gap
|
||||
owns the group spacing, so the repeated field styles collapse to the same
|
||||
rhythm as the config forms: label hugs its control (~0.4rem), groups stay
|
||||
0.8rem apart. */
|
||||
.modal .field-label { margin-top: 0; margin-bottom: -0.4rem; }
|
||||
.modal .custom-select,
|
||||
.modal input,
|
||||
.modal textarea { margin: 0; }
|
||||
|
||||
.toasts {
|
||||
position: fixed;
|
||||
bottom: 1rem;
|
||||
|
|
|
|||
57
src/argv.js
Normal file
57
src/argv.js
Normal 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 }
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
41
src/main.js
41
src/main.js
|
|
@ -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
108
src/openwith.js
Normal 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 }
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -24,16 +24,20 @@ if [ -f "$ICON_SRC" ]; then
|
|||
fi
|
||||
|
||||
echo "2. Creating shortcut..."
|
||||
# MimeType + Exec %f: "Open with ..." in Dolphin/Nautilus lists PLWL for
|
||||
# Windows executables and hands the file path over as an argument (the
|
||||
# launcher then shows the wine/prefix picker).
|
||||
mkdir -p "$APP_DIR"
|
||||
cat > "$APP_DIR/olcxja.plwl.desktop" << EOF
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=PLWL
|
||||
Comment=Portable Linux Wine Launcher
|
||||
Exec="$EXE"
|
||||
Exec="$EXE" %f
|
||||
Icon=olcxja.plwl
|
||||
Terminal=false
|
||||
Categories=Game;
|
||||
MimeType=application/x-ms-dos-executable;application/x-msi;application/x-msdownload;
|
||||
EOF
|
||||
chmod +x "$APP_DIR/olcxja.plwl.desktop"
|
||||
command -v update-desktop-database >/dev/null 2>&1 && update-desktop-database "$APP_DIR" 2>/dev/null || true
|
||||
|
|
|
|||
|
|
@ -40,15 +40,19 @@ if [ -f "$ICON_SRC" ]; then
|
|||
fi
|
||||
|
||||
echo "3. Creating shortcut..."
|
||||
# MimeType + Exec %f: "Open with ..." in Dolphin/Nautilus lists PLWL for
|
||||
# Windows executables and hands the file path over as an argument (the
|
||||
# launcher then shows the wine/prefix picker).
|
||||
cat > "$APP_DIR/olcxja.plwl.desktop" << EOF
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=PLWL
|
||||
Comment=Portable Linux Wine Launcher
|
||||
Exec="$EXE"
|
||||
Exec="$EXE" %f
|
||||
Icon=olcxja.plwl
|
||||
Terminal=false
|
||||
Categories=Game;
|
||||
MimeType=application/x-ms-dos-executable;application/x-msi;application/x-msdownload;
|
||||
EOF
|
||||
chmod +x "$APP_DIR/olcxja.plwl.desktop"
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue