From 6565ded4d532eb012220bb6941c499a97ad7be66 Mon Sep 17 00:00:00 2001 From: olcxja Date: Sun, 4 Oct 2026 20:50:10 +0200 Subject: [PATCH] improve shortcut 'open with' handling --- development.md | 10 +++++- src/argv.js | 97 +++++++++++++++++++++++++++++++++----------------- 2 files changed, 73 insertions(+), 34 deletions(-) diff --git a/development.md b/development.md index 51bf94b..358dc02 100644 --- a/development.md +++ b/development.md @@ -188,7 +188,15 @@ handlers in src/ipc/. Keep new APIs in that pattern. when it sits inside the prefix's drive_c, `Z:\...` otherwise. Giving a file to an app that is ALREADY running is a handoff (runGame `handoff: true`): an extra untracked process, no log/playtime writes, because the app's own - single-instance logic is what actually opens the file. + single-instance logic is what actually opens the file. The second-instance + handoff must never read a value from the token next to its option: Electron + RE-SERIALIZES that argv (all switches first, their values after, the dev app + path among them), so `--launch --file ` arrives as + `--launch --file --allow-file-access-from-files `. argv.js pairs + options with positionals IN ORDER (inline `--name=value` wins, the dev app + path is skipped); reading the next token used to take the launch id as the + opened file, so an app started without its image. + `electron test/argv-handoff.test.js` proves the real serialization. - A sender hashing a big unit is minutes of network silence: it pings `/plwl/ping` (+token) every HEARTBEAT_MS for the whole sync, the receiver's handler re-arms its popup watchdog (stage 'wait' changes nothing on screen), diff --git a/src/argv.js b/src/argv.js index 7ad29d3..699f54c 100644 --- a/src/argv.js +++ b/src/argv.js @@ -10,49 +10,79 @@ const fs = require('fs') // // Supported forms: // --launch | --launch= auto-start that library app -// --file hand this file to the --launch app +// --file | --file= hand this file to the --launch app // --open | --open= open an .exe with the wine picker // a bare existing-file path same as --open (plumbing for Exec %f) +// +// Electron hands the second instance's argv over RE-SERIALIZED: every switch +// comes first and its value follows as a positional argument, e.g. a shortcut +// `--launch --file ` arrives as +// `--launch --file --allow-file-access-from-files `. Taking the +// token next to an option therefore picks the wrong value (it used to take +// the launch id as the opened file, so an app started without its image). +// Options and positionals are matched IN ORDER here instead. -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] +const VALUE_OPTIONS = ['--launch', '--file', '--open'] + +// The options that carry a value, the positional arguments that follow the +// switches, and the values written inline (`--name=value`, which keeps its +// value no matter how the rest is ordered). +// +// Tokens before the first switch are the executable - never values. In dev +// mode (`electron ...`) Electron re-serializes the app path ALONG WITH +// the values, so it is matched against the running app's own path and skipped: +// it would otherwise be paired with the first option and shift every value. +function splitArgv(argv) { + const options = [] + const positional = [] + const inline = new Map() + const appPath = process.defaultApp && process.argv[1] ? path.resolve(process.argv[1]) : null + let switched = false + for (const arg of Array.isArray(argv) ? argv : []) { + if (typeof arg !== 'string' || !arg) continue + if (arg.startsWith('-')) { + switched = true + const eq = arg.indexOf('=') + const name = eq === -1 ? arg : arg.slice(0, eq) + if (!VALUE_OPTIONS.includes(name)) continue + if (eq === -1) options.push(name) + else inline.set(name, arg.slice(eq + 1)) + continue + } + // absolute in the handed-over argv, possibly relative here + if (appPath && path.resolve(arg) === appPath) continue + if (switched) positional.push(arg) } - return null + return { options, positional, inline } } -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 +// The value of an option: inline when it was written as `--name=value`, +// otherwise the positional argument with the SAME position as the option +// among our value options (Chromium pushes all switches before all values, so +// any other pairing would read another option's value). +function optionValue(parsed, name) { + if (parsed.inline.has(name)) return parsed.inline.get(name) + const index = parsed.options.indexOf(name) + if (index === -1) return null + const value = parsed.positional[index] + return value === undefined ? null : value } -// 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. // The file an app shortcut was asked to open: `--file ` when the // generated shortcut passes it explicitly, or the path a file manager appends // on its own to an Exec line without a file field (KDE does that). Only an -// existing absolute FILE counts in the fallback, so Chromium's injected flags -// and the launcher's own path can never be mistaken for the opened document. +// absolute path counts in either place: the launch id, Chromium's injected +// flags and the launcher's own path can never be mistaken for the document. const FIELD_CODE_RE = /^%[fFuUdDnNickvm]?$/ -function launchFileOf(args, launchId) { - const explicit = findValue(args, ['--file']) +function launchFileOf(parsed, launchId) { + const explicit = optionValue(parsed, '--file') // a desktop environment that does not expand field codes would hand over // the literal token - never treat that as a file path - if (explicit && !FIELD_CODE_RE.test(explicit)) return explicit + if (explicit && !FIELD_CODE_RE.test(explicit) && path.isAbsolute(explicit)) return explicit if (!launchId) return null - for (let i = args.length - 1; i > 1; i--) { - const arg = args[i] - if (typeof arg !== 'string' || arg.startsWith('-') || !path.isAbsolute(arg)) continue + for (let i = parsed.positional.length - 1; i >= 0; i--) { + const arg = parsed.positional[i] + if (!path.isAbsolute(arg)) continue try { if (fs.statSync(arg).isFile()) return arg } catch {} @@ -62,9 +92,10 @@ function launchFileOf(args, launchId) { function parseArgv(argv) { const args = Array.isArray(argv) ? argv : process.argv - const launchId = findValue(args, ['--launch']) - const launchFile = launchFileOf(args, launchId) - let openPath = findValue(args, ['--open']) + const parsed = splitArgv(args) + const launchId = optionValue(parsed, '--launch') + const launchFile = launchFileOf(parsed, launchId) + let openPath = optionValue(parsed, '--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 @@ -74,11 +105,11 @@ function parseArgv(argv) { a => !a.startsWith('-') && /\.(exe|msi|bat|cmd|lnk)$/i.test(a) && - !/(electron|olcxja\.plwl)(\.exe)?$/i.test(a) + !/\b(electron|olcxja\.plwl)(\.exe)?$/i.test(a) ) if (bare) openPath = bare } return { launchId, openPath, launchFile } } -module.exports = { parseArgv, findValue } +module.exports = { parseArgv }