From bf4262dbc9eb934157351700455a3dad464a3824 Mon Sep 17 00:00:00 2001 From: ouyangjian28 Date: Tue, 8 Sep 2026 01:25:30 +0800 Subject: [PATCH] fix(desktop): keep non-ASCII desktop entries out of Open In matching (#3403) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit desktopEntryMatchesApp normalized haystack values without dropping empty ones, so a .desktop entry with no ASCII letters or digits in Name, id, file name, or Exec (e.g. Name=抖音) normalized to "" and needle.includes("") matched every requested app — hijacking the installed-apps list and Open In launch specs. Empty normalized haystack values are now filtered, matching the existing needles handling. discovered-apps.json gains a version field (INSTALLED_APPS_CACHE_VERSION = 2); caches written before the fix are treated as stale and refresh through the existing TTL-expiry path instead of serving the poisoned list for the rest of the 24h TTL. --- packages/electron/linux-app-discovery.mjs | 6 +- .../electron/linux-app-discovery.test.mjs | 87 +++++++++++++++++++ packages/electron/main.mjs | 9 +- 3 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 packages/electron/linux-app-discovery.test.mjs diff --git a/packages/electron/linux-app-discovery.mjs b/packages/electron/linux-app-discovery.mjs index c98947d4..8150c8fe 100644 --- a/packages/electron/linux-app-discovery.mjs +++ b/packages/electron/linux-app-discovery.mjs @@ -145,7 +145,11 @@ export const readLinuxDesktopEntries = async (options = {}) => { const desktopEntryMatchesApp = (entry, appName, appId = '') => { const needles = uniqueStrings([appName, appId]).flatMap((value) => [normalizeComparable(value), normalizeCompactComparable(value)]).filter(Boolean); const haystacks = [entry.name, entry.id, path.basename(entry.filePath || ''), entry.exec] - .flatMap((value) => [normalizeComparable(value), normalizeCompactComparable(value)]); + .flatMap((value) => [normalizeComparable(value), normalizeCompactComparable(value)]) + // A value with no ASCII letters or digits (e.g. a CJK-only Name) normalizes to the empty + // string, and needle.includes('') is true for every app — drop it so such entries can + // only match through a field that still carries comparable text. + .filter(Boolean); return needles.some((needle) => haystacks.some((haystack) => haystack === needle || haystack.includes(needle) || needle.includes(haystack))); }; diff --git a/packages/electron/linux-app-discovery.test.mjs b/packages/electron/linux-app-discovery.test.mjs new file mode 100644 index 00000000..d318f77f --- /dev/null +++ b/packages/electron/linux-app-discovery.test.mjs @@ -0,0 +1,87 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + buildLinuxOpenSpecs, + filterLinuxInstalledApps, + parseDesktopEntry, +} from './linux-app-discovery.mjs'; + +const NON_ASCII_ONLY_ENTRY = `[Desktop Entry] +Type=Application +Name=抖音 +Exec=/usr/bin/example --app-url=https://www.douyin.com/ +Icon=example`; + +const TEST_ENV = { PATH: '/nonexistent-openchamber-test-bin' }; + +const parseEntryAt = (content, id) => parseDesktopEntry(content, `/usr/share/applications/${id}.desktop`); + +test('still parses desktop entries whose Name has no ASCII letters or digits', () => { + const entry = parseEntryAt(NON_ASCII_ONLY_ENTRY, 'example'); + assert.ok(entry); + assert.equal(entry.name, '抖音'); + assert.equal(entry.exec, '/usr/bin/example --app-url=https://www.douyin.com/'); +}); + +test('a non-ASCII-only desktop entry does not mark every app as installed', async () => { + const entry = parseEntryAt(NON_ASCII_ONLY_ENTRY, 'example'); + const installed = await filterLinuxInstalledApps( + ['Visual Studio Code', 'Cursor', 'Sublime Text'], + { entries: [entry] }, + ); + assert.deepEqual(installed, []); +}); + +test('Open In specs never launch a non-ASCII-only entry for another app', () => { + const entry = parseEntryAt(NON_ASCII_ONLY_ENTRY, 'example'); + const specs = buildLinuxOpenSpecs({ + targetPath: '/tmp/project', + appId: 'vscode', + appName: 'Visual Studio Code', + entries: [entry], + env: TEST_ENV, + }); + assert.deepEqual(specs, []); +}); + +test('ASCII desktop entries still match their own app and build their own launch spec', async () => { + const entry = parseEntryAt(`[Desktop Entry] +Type=Application +Name=Visual Studio Code +Exec=/usr/bin/code %F +Icon=code`, 'code'); + const installed = await filterLinuxInstalledApps( + ['Visual Studio Code', 'Cursor'], + { entries: [entry] }, + ); + assert.deepEqual(installed, ['Visual Studio Code']); + + const specs = buildLinuxOpenSpecs({ + targetPath: '/tmp/project', + appId: 'vscode', + appName: 'Visual Studio Code', + entries: [entry], + env: TEST_ENV, + }); + assert.equal(specs.length, 1); + assert.equal(specs[0].program, '/usr/bin/code'); + assert.deepEqual(specs[0].args, ['/tmp/project']); +}); + +test('entries mixing ASCII and non-ASCII still match through their ASCII part', () => { + const entry = parseEntryAt(`[Desktop Entry] +Type=Application +Name=VSCode 抖音版 +Exec=/usr/local/bin/vscode-douyin %F +Icon=vscode-douyin`, 'vscode-douyin'); + const specs = buildLinuxOpenSpecs({ + targetPath: '/tmp/project', + appId: 'vscode', + appName: 'Visual Studio Code', + entries: [entry], + env: TEST_ENV, + }); + assert.equal(specs.length, 1); + assert.equal(specs[0].program, '/usr/local/bin/vscode-douyin'); +}); diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index e972e614..fbec383b 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -246,6 +246,9 @@ const GITHUB_FEATURE_REQUEST_URL = 'https://github.com/openchamber/openchamber/i const DISCORD_INVITE_URL = 'https://discord.gg/ZYRSdnwwKA'; const INSTALLED_APPS_CACHE_TTL_SECS = 60 * 60 * 24; const INSTALLED_APPS_CACHE_FILE = 'discovered-apps.json'; +// Bump when discovery results change shape or matching semantics change, so cached +// entries written by an older build are treated as stale and refresh immediately. +const INSTALLED_APPS_CACHE_VERSION = 2; const LINUX_DESKTOP_ENTRIES_CACHE_TTL_MS = 30_000; const OPENCODE_SHUTDOWN_GRACE_MS = 100; const { autoUpdater } = updaterPkg; @@ -4428,11 +4431,13 @@ const handleInvoke = async (browserWindow, command, args = {}) => { } const cachedApps = Array.isArray(cache?.apps) ? cache.apps : []; const hasCache = Boolean(cache); - const isCacheStale = !cache || (now - Number(cache.updatedAt || 0)) > INSTALLED_APPS_CACHE_TTL_SECS; + const isCacheStale = !cache + || cache.version !== INSTALLED_APPS_CACHE_VERSION + || (now - Number(cache.updatedAt || 0)) > INSTALLED_APPS_CACHE_TTL_SECS; const refresh = async () => { const apps = await buildPlatformInstalledApps(Array.isArray(args.apps) ? args.apps : []); await fsp.mkdir(path.dirname(cachePath), { recursive: true }); - await fsp.writeFile(cachePath, JSON.stringify({ updatedAt: now, apps }, null, 2)); + await fsp.writeFile(cachePath, JSON.stringify({ version: INSTALLED_APPS_CACHE_VERSION, updatedAt: now, apps }, null, 2)); emitToAllWindows('openchamber:installed-apps-updated', apps); }; if (process.platform !== 'darwin' && process.platform !== 'win32' && process.platform !== 'linux') {