fix(electron): require TerminalEmulator category for terminal appId on Linux
desktopEntryMatchesApp used a loose substring match that accepted any desktop entry whose Exec line mentioned a terminal launcher. A non- terminal app launched via xdg-terminal-exec (e.g. a TUI helper) was mis-attributed to the generic 'terminal' appId, so 'Open in Terminal' launched the helper script and the picker showed the helper's icon. For appId === 'terminal' only, require Categories=TerminalEmulator; ghostty/iterm2 keep name-based matching. The xdg-terminal-exec / gnome-terminal / konsole / xfce4-terminal / x-terminal-emulator fallback chain is unchanged, so non-conformant custom terminal entries still launch. Adds a shared isTerminalEmulatorEntry predicate used by both buildLinuxOpenSpecs (launch) and buildLinuxInstalledApps (icon). Smoke test gains a generic helper-app fixture (non-terminal app whose Exec uses xdg-terminal-exec) plus a TerminalEmulator entry, with assertions that the helper script is never launched and the terminal emulator's icon resolves instead of the helper's. Two byte-distinct PNGs keep the icon assertion a real discriminator.
This commit is contained in:
@@ -234,6 +234,11 @@ const commandExists = (program, env = process.env) => {
|
||||
|
||||
const findEntry = (entries, appId, appName) => entries.find((entry) => desktopEntryMatchesApp(entry, appName, appId)) || null;
|
||||
|
||||
const isTerminalEmulatorEntry = (entry) => {
|
||||
const categories = Array.isArray(entry?.categories) ? entry.categories : [];
|
||||
return categories.some((category) => normalizeComparable(category) === 'terminalemulator');
|
||||
};
|
||||
|
||||
export const buildLinuxOpenSpecs = ({ targetPath, appId, appName, targetKind = 'path', entries = [], env = process.env }) => {
|
||||
if (appId === 'finder') {
|
||||
return [{ kind: 'default', targetKind, targetPath }];
|
||||
@@ -241,7 +246,9 @@ export const buildLinuxOpenSpecs = ({ targetPath, appId, appName, targetKind = '
|
||||
const specs = [];
|
||||
if (TERMINAL_APP_IDS.has(appId)) {
|
||||
const directory = targetKind === 'file' ? path.dirname(targetPath) : targetPath;
|
||||
const terminalEntry = findEntry(entries, appId, appName);
|
||||
const terminalEntry = appId === 'terminal'
|
||||
? entries.find(isTerminalEmulatorEntry) || null
|
||||
: findEntry(entries, appId, appName);
|
||||
if (terminalEntry) {
|
||||
const spec = buildCommandFromDesktopExec(terminalEntry, directory);
|
||||
if (spec) specs.push(spec);
|
||||
@@ -515,7 +522,7 @@ export const buildLinuxInstalledApps = async (apps, options = {}) => {
|
||||
...FILE_MANAGER_ICON_FALLBACKS,
|
||||
], { ...options, env });
|
||||
} else if (normalizeComparable(name) === 'terminal') {
|
||||
const terminalEntry = findEntry(entries, 'terminal', name)
|
||||
const terminalEntry = entries.find(isTerminalEmulatorEntry)
|
||||
|| findEntry(entries, 'ghostty', 'Ghostty');
|
||||
iconDataUrl = resolveIconDataUrlForName([
|
||||
terminalEntry?.icon,
|
||||
|
||||
@@ -28,14 +28,22 @@ try {
|
||||
const iconsRoot = path.join(dataDir, 'icons');
|
||||
const thunarIcon = path.join(iconsRoot, 'hicolor', '48x48', 'apps', 'org.xfce.thunar.png');
|
||||
const codeIcon = path.join(iconsRoot, 'hicolor', '32x32', 'apps', 'code.png');
|
||||
const terminalIcon = path.join(iconsRoot, 'hicolor', '48x48', 'apps', 'utilities-terminal.png');
|
||||
const helperIcon = path.join(iconsRoot, 'hicolor', '48x48', 'apps', 'helper-app.png');
|
||||
await fs.mkdir(userApps, { recursive: true });
|
||||
await fs.mkdir(systemApps, { recursive: true });
|
||||
await fs.mkdir(path.dirname(thunarIcon), { recursive: true });
|
||||
await fs.mkdir(path.dirname(codeIcon), { recursive: true });
|
||||
await fs.mkdir(path.dirname(terminalIcon), { recursive: true });
|
||||
await fs.mkdir(path.dirname(helperIcon), { recursive: true });
|
||||
// Minimal valid 1x1 PNG.
|
||||
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64');
|
||||
// Distinct PNG so the helper-app icon is distinguishable from the terminal theme icon.
|
||||
const helperPng = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBASZNV9oAAAAASUVORK5CYII=', 'base64');
|
||||
await fs.writeFile(thunarIcon, png);
|
||||
await fs.writeFile(codeIcon, png);
|
||||
await fs.writeFile(terminalIcon, png);
|
||||
await fs.writeFile(helperIcon, helperPng);
|
||||
|
||||
const codeDesktopPath = path.join(userApps, 'code.desktop');
|
||||
await fs.writeFile(codeDesktopPath, [
|
||||
@@ -52,6 +60,23 @@ try {
|
||||
await fs.writeFile(path.join(userApps, 'missing-name.desktop'), '[Desktop Entry]\nType=Application\nExec=missing %f\n', 'utf8');
|
||||
await fs.writeFile(path.join(userApps, 'missing-exec.desktop'), '[Desktop Entry]\nType=Application\nName=Missing Exec\nIcon=missing\n', 'utf8');
|
||||
await fs.writeFile(path.join(systemApps, 'ghostty.desktop'), '[Desktop Entry]\nType=Application\nName=Ghostty\nExec=ghostty --working-directory=%f --open-uri=%u\nIcon=ghostty\n', 'utf8');
|
||||
// A non-terminal app that launches itself via xdg-terminal-exec (e.g. a TUI
|
||||
// helper). Its Exec line contains "xdg-terminal-exec", which the loose
|
||||
// substring match in desktopEntryMatchesApp would mis-attribute to the
|
||||
// generic "terminal" appId. Categories=Utility; (not TerminalEmulator) is
|
||||
// the signal that this is NOT a terminal emulator.
|
||||
await fs.writeFile(path.join(systemApps, 'helper-app.desktop'), [
|
||||
'[Desktop Entry]',
|
||||
'Type=Application',
|
||||
'NoDisplay=false',
|
||||
'Terminal=false',
|
||||
'StartupNotify=true',
|
||||
'Exec=/usr/bin/xdg-terminal-exec --app-id=helper-app --title="Helper App" -- /usr/bin/helper-script',
|
||||
`Icon=${helperIcon}`,
|
||||
'Name=Helper App',
|
||||
'Categories=Utility;',
|
||||
'',
|
||||
].join('\n'), 'utf8');
|
||||
await fs.writeFile(path.join(systemApps, 'plain.desktop'), '[Desktop Entry]\nType=Application\nName=Plain Editor\nExec=plain-editor --flag\nIcon=plain\n', 'utf8');
|
||||
await fs.writeFile(path.join(systemApps, 'thunar.desktop'), [
|
||||
'[Desktop Entry]',
|
||||
@@ -69,7 +94,7 @@ try {
|
||||
assert(dirs.includes(systemApps), 'XDG_DATA_DIRS applications dir should be included');
|
||||
|
||||
const entries = await readLinuxDesktopEntries({ applicationDirs: [userApps, systemApps], env, homeDir: tempRoot });
|
||||
assert(entries.length === 4, `expected 4 visible valid entries, got ${entries.length}`);
|
||||
assert(entries.length === 5, `expected 5 visible valid entries, got ${entries.length}`);
|
||||
assert(entries.some((entry) => entry.name === 'Visual Studio Code'), 'valid desktop entry should be parsed');
|
||||
assert(entries.some((entry) => entry.name === 'Ghostty'), 'system desktop entry should be parsed');
|
||||
assert(entries.some((entry) => entry.name === 'Plain Editor'), 'no-placeholder entry should be parsed');
|
||||
@@ -127,6 +152,28 @@ try {
|
||||
const codeInfo = appInfos.find((entry) => entry.name === 'Visual Studio Code');
|
||||
assert(typeof codeInfo?.iconDataUrl === 'string' && codeInfo.iconDataUrl.startsWith('data:image/png;base64,'), 'desktop app should resolve Icon= theme PNG to data URL');
|
||||
|
||||
const terminalEmulatorEntry = parseDesktopEntry([
|
||||
'[Desktop Entry]',
|
||||
'Type=Application',
|
||||
'Name=MyConsole',
|
||||
'Exec=myconsole --working-directory=%f',
|
||||
'Icon=utilities-terminal',
|
||||
'Categories=TerminalEmulator;',
|
||||
'',
|
||||
].join('\n'), path.join(systemApps, 'myconsole.desktop'));
|
||||
const helperEntry = entries.find((entry) => entry.name === 'Helper App');
|
||||
const terminalIconInfos = await buildLinuxInstalledApps(['Terminal'], {
|
||||
entries: [helperEntry, terminalEmulatorEntry],
|
||||
env,
|
||||
homeDir: tempRoot,
|
||||
execFileSyncImpl: () => 'thunar.desktop',
|
||||
});
|
||||
const terminalInfo = terminalIconInfos.find((entry) => entry.name === 'Terminal');
|
||||
const expectedTerminalIconDataUrl = `data:image/png;base64,${png.toString('base64')}`;
|
||||
const expectedHelperIconDataUrl = `data:image/png;base64,${helperPng.toString('base64')}`;
|
||||
assert(terminalInfo?.iconDataUrl === expectedTerminalIconDataUrl, `Terminal should resolve the TerminalEmulator entry icon, got ${terminalInfo?.iconDataUrl}`);
|
||||
assert(terminalInfo?.iconDataUrl !== expectedHelperIconDataUrl, 'Terminal icon must not resolve to a non-terminal entry whose Exec uses a terminal launcher (loose name/exec match regression)');
|
||||
|
||||
const fetchedIcons = await fetchLinuxAppIcons(['Finder', 'Visual Studio Code'], {
|
||||
entries,
|
||||
env,
|
||||
@@ -151,6 +198,27 @@ try {
|
||||
assert(fallbackTerminalSpecs.length >= 1, 'missing terminal desktop entry should include xdg-terminal-exec fallback');
|
||||
assert(fallbackTerminalSpecs[0]?.program === 'xdg-terminal-exec', 'missing terminal entry should use xdg-terminal-exec first');
|
||||
assert(fallbackTerminalSpecs[0]?.args.join('|') === '--working-directory|/tmp/My Project', `xdg-terminal-exec fallback should keep working directory args, got ${fallbackTerminalSpecs[0]?.args.join('|')}`);
|
||||
assert(!fallbackTerminalSpecs.some((spec) => (spec.args || []).some((arg) => arg.includes('helper-script'))), 'non-terminal entry using a terminal launcher must not be launched for the generic terminal appId');
|
||||
|
||||
const ptyxisEntry = parseDesktopEntry([
|
||||
'[Desktop Entry]',
|
||||
'Type=Application',
|
||||
'Name=Ptyxis',
|
||||
'Exec=ptyxis --working-directory=%f',
|
||||
'Categories=TerminalEmulator;',
|
||||
'',
|
||||
].join('\n'), '/tmp/org.gnome.Ptyxis.desktop');
|
||||
const terminalEmulatorSpecs = buildLinuxOpenSpecs({
|
||||
targetPath: '/tmp/My Project',
|
||||
appId: 'terminal',
|
||||
appName: 'Terminal',
|
||||
targetKind: 'project',
|
||||
entries: [ptyxisEntry, ...entries],
|
||||
env,
|
||||
});
|
||||
assert(terminalEmulatorSpecs[0]?.program === 'ptyxis', `terminal emulator entry should be preferred, got ${terminalEmulatorSpecs[0]?.program}`);
|
||||
assert(terminalEmulatorSpecs[0]?.args.join('|') === '--working-directory=/tmp/My Project', `ptyxis should receive working directory, got ${terminalEmulatorSpecs[0]?.args.join('|')}`);
|
||||
assert(!terminalEmulatorSpecs.some((spec) => (spec.args || []).some((arg) => arg.includes('helper-script'))), 'non-terminal entry using a terminal launcher must not be launched when a real terminal emulator entry exists');
|
||||
|
||||
const defaultSpecs = buildLinuxOpenSpecs({ targetPath: '/tmp/My Project', appId: 'finder', appName: 'Finder', targetKind: 'project', entries, env });
|
||||
assert(defaultSpecs[0].kind === 'default', 'finder maps to safe default Linux opener spec');
|
||||
@@ -169,6 +237,7 @@ try {
|
||||
specs,
|
||||
terminalFileSpecs,
|
||||
fallbackTerminalSpecs,
|
||||
terminalEmulatorSpecs,
|
||||
defaultSpecs,
|
||||
}, null, 2));
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user