diff --git a/README.md b/README.md index 005b7fe..614e3e0 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/files/translations/en-us.json b/files/translations/en-us.json index c06afd5..8c455f7 100644 --- a/files/translations/en-us.json +++ b/files/translations/en-us.json @@ -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", diff --git a/files/translations/pl.json b/files/translations/pl.json index 7c8568e..7536f51 100644 --- a/files/translations/pl.json +++ b/files/translations/pl.json @@ -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", diff --git a/package.json b/package.json index 7a04c09..3180480 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/renderer/app.js b/renderer/app.js index f9ccae2..009e501 100644 --- a/renderer/app.js +++ b/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(` +

${ot('openwith.title')}

+

${ot('openwith.file')}:

+ + + + + + + `) + 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') diff --git a/renderer/style.css b/renderer/style.css index ab71bc6..2af013a 100644 --- a/renderer/style.css +++ b/renderer/style.css @@ -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; diff --git a/src/argv.js b/src/argv.js new file mode 100644 index 0000000..cf93cc0 --- /dev/null +++ b/src/argv.js @@ -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 | --launch= auto-start that library app +// --open | --open= 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 } diff --git a/src/ipc/games.js b/src/ipc/games.js index 1679fa6..5c89a12 100644 --- a/src/ipc/games.js +++ b/src/ipc/games.js @@ -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 } diff --git a/src/ipc/index.js b/src/ipc/index.js index 19f8211..ab720c8 100644 --- a/src/ipc/index.js +++ b/src/ipc/index.js @@ -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)) diff --git a/src/main.js b/src/main.js index f66d261..15f2697 100644 --- a/src/main.js +++ b/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 +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()) + } } }) diff --git a/src/openwith.js b/src/openwith.js new file mode 100644 index 0000000..57ec493 --- /dev/null +++ b/src/openwith.js @@ -0,0 +1,108 @@ +'use strict' + +// "Open with PLWL" (file manager -> Open With -> PLWL): a file handed in via +// `--open ` (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 } diff --git a/src/preload.js b/src/preload.js index fbecbb2..0472793 100644 --- a/src/preload.js +++ b/src/preload.js @@ -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), diff --git a/src/runner.js b/src/runner.js index 24cf499..2dee951 100644 --- a/src/runner.js +++ b/src/runner.js @@ -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 } diff --git a/tools/createshortcut.sh b/tools/createshortcut.sh index 5eac756..54a0d29 100644 --- a/tools/createshortcut.sh +++ b/tools/createshortcut.sh @@ -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 diff --git a/tools/install.sh b/tools/install.sh index 8dab12f..6d04b9b 100644 --- a/tools/install.sh +++ b/tools/install.sh @@ -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"