From 0caff15b3e90e1a8ba6c70da7229dcf41512dd10 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 28 Jul 2026 17:57:44 +0000 Subject: [PATCH] fix(desktop): Linux AppImage tray menu and system file-manager icons Resize Linux tray icons so StatusNotifier hosts show them, add Show/Hide/Close context-menu actions, and resolve FreeDesktop theme icons for Open-in apps (including the default file manager) instead of skipping Linux icon fetch. Co-authored-by: Serhii Dziupin --- bun.lock | 8 +- packages/electron/README.md | 2 +- packages/electron/linux-app-discovery.mjs | 269 ++++++++++++++++-- packages/electron/main.mjs | 32 ++- .../scripts/smoke-linux-app-discovery.mjs | 72 ++++- packages/electron/tray.mjs | 44 ++- .../components/desktop/OpenInAppButton.tsx | 6 +- packages/ui/src/lib/desktop.ts | 5 - 8 files changed, 388 insertions(+), 50 deletions(-) diff --git a/bun.lock b/bun.lock index b9f71ea0..01739c0e 100644 --- a/bun.lock +++ b/bun.lock @@ -97,7 +97,7 @@ }, "packages/electron": { "name": "@openchamber/electron", - "version": "1.16.3", + "version": "1.17.0", "dependencies": { "@openchamber/web": "workspace:*", "better-sqlite3": "^12.10.0", @@ -134,7 +134,7 @@ }, "packages/ui": { "name": "@openchamber/ui", - "version": "1.16.3", + "version": "1.17.0", "dependencies": { "@aparajita/capacitor-secure-storage": "^8.0.0", "@base-ui/react": "^1.4.0", @@ -239,7 +239,7 @@ }, "packages/vscode": { "name": "openchamber", - "version": "1.16.3", + "version": "1.17.0", "dependencies": { "@openchamber/ui": "workspace:*", "@opencode-ai/sdk": "1.18.8", @@ -262,7 +262,7 @@ }, "packages/web": { "name": "@openchamber/web", - "version": "1.16.3", + "version": "1.17.0", "bin": { "openchamber": "./bin/cli.js", }, diff --git a/packages/electron/README.md b/packages/electron/README.md index f3418bad..4eaa3dc5 100644 --- a/packages/electron/README.md +++ b/packages/electron/README.md @@ -88,7 +88,7 @@ Linux updates are supported only when the packaged app is running from a writabl A loopback-only updater fixture is available for contributor QA of N-to-N+1 AppImage replacement and restart behavior. It is test infrastructure, not a user-configurable update source. See [`scripts/updater-e2e-fixture.md`](./scripts/updater-e2e-fixture.md) for the controlled test procedure. Unit tests cover feed selection, check failures, no-update results, and fixture generation; actual AppImage replacement and restart remains a manual native N-to-N+1 release boundary because it requires executing two packaged versions on each supported architecture. -The package supports macOS, Windows, and Linux desktop features. Linux AppImage builds include in-app window controls, auto-update, system tray, and launch-at-login (XDG autostart). Some native discovery helpers are platform-specific. For example, app icon fetching and app filtering currently only work on macOS, while opening files in installed apps and installed-app discovery work on macOS, Windows, and Linux. +The package supports macOS, Windows, and Linux desktop features. Linux AppImage builds include in-app window controls, auto-update, system tray (right-click Show / Hide / Close), and launch-at-login (XDG autostart). Opening files in installed apps, installed-app discovery, and FreeDesktop icon lookup (including the default file manager) work on macOS, Windows, and Linux. The macOS menu bar item is enabled by default and can be disabled in General settings. The setting applies after restart; while disabled, Desktop does not create the native tray controller or start the renderer subscriptions, polling, quota refresh, or IPC updates that feed it. diff --git a/packages/electron/linux-app-discovery.mjs b/packages/electron/linux-app-discovery.mjs index 4c8482c3..397aac76 100644 --- a/packages/electron/linux-app-discovery.mjs +++ b/packages/electron/linux-app-discovery.mjs @@ -1,3 +1,4 @@ +import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import fsp from 'node:fs/promises'; import os from 'node:os'; @@ -280,39 +281,259 @@ export const filterLinuxInstalledApps = async (apps, options = {}) => { .filter((appName) => appName && entries.some((entry) => desktopEntryMatchesApp(entry, appName))); }; +const FILE_MANAGER_FALLBACK_IDS = [ + 'org.gnome.Nautilus', + 'org.xfce.thunar', + 'thunar', + 'nemo', + 'org.kde.dolphin', + 'dolphin', + 'pcmanfm', + 'caja', + 'nautilus', + 'xfce4-file-manager', +]; + +const FILE_MANAGER_ICON_FALLBACKS = [ + 'system-file-manager', + 'org.xfce.thunar', + 'org.gnome.Nautilus', + 'folder', +]; + +const ICON_SIZE_DIRS = [ + '48x48', '48', + '32x32', '32', + '64x64', '64', + '24x24', '24', + '22x22', '22', + '16x16', '16', + '128x128', '128', + '256x256', '256', + 'scalable', +]; + +const ICON_CATEGORIES = ['apps', 'places', 'status', 'devices', 'mimetypes', 'legacy']; + +const pathExistsSync = (candidate) => { + try { + fs.accessSync(candidate, fs.constants.R_OK); + return true; + } catch { + return false; + } +}; + +export const linuxIconThemeDirs = ({ env = process.env, homeDir = os.homedir() } = {}) => { + const dataHome = typeof env.XDG_DATA_HOME === 'string' && env.XDG_DATA_HOME.trim() + ? env.XDG_DATA_HOME.trim() + : path.join(homeDir || os.homedir(), '.local', 'share'); + const dataDirs = typeof env.XDG_DATA_DIRS === 'string' && env.XDG_DATA_DIRS.trim() + ? env.XDG_DATA_DIRS.split(':').filter(Boolean) + : DEFAULT_XDG_DATA_DIRS; + return uniqueStrings([ + path.join(dataHome, 'icons'), + path.join(homeDir || os.homedir(), '.icons'), + ...dataDirs.map((dir) => path.join(dir, 'icons')), + '/usr/local/share/icons', + '/usr/share/icons', + ]).map((entry) => path.resolve(entry)); +}; + +const listThemeNames = (iconsRoot) => { + let entries; + try { + entries = fs.readdirSync(iconsRoot, { withFileTypes: true }); + } catch { + return []; + } + const themes = entries + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name); + // Prefer the freedesktop fallback theme first, then whatever else is installed. + themes.sort((left, right) => { + if (left === 'hicolor') return -1; + if (right === 'hicolor') return 1; + return left.localeCompare(right); + }); + return themes; +}; + +const lookForIconInTheme = (themeRoot, iconName) => { + let pngMatch = null; + let svgMatch = null; + for (const size of ICON_SIZE_DIRS) { + for (const category of ICON_CATEGORIES) { + const pngPath = path.join(themeRoot, size, category, `${iconName}.png`); + if (pathExistsSync(pngPath)) { + // Prefer mid-size PNGs that UI list icons can display without SVG tooling. + if (size !== 'scalable') return pngPath; + pngMatch = pngMatch || pngPath; + } + const svgPath = path.join(themeRoot, size, category, `${iconName}.svg`); + if (!svgMatch && pathExistsSync(svgPath)) svgMatch = svgPath; + } + } + return pngMatch || svgMatch; +}; + +export const resolveLinuxIconFile = (iconName, options = {}) => { + const raw = typeof iconName === 'string' ? iconName.trim() : ''; + if (!raw) return null; + if (path.isAbsolute(raw) && pathExistsSync(raw)) return raw; + if (raw.includes(path.sep) && pathExistsSync(raw)) return path.resolve(raw); + + const baseName = raw.replace(/\.(png|svg|xpm|ico)$/i, ''); + const iconRoots = linuxIconThemeDirs(options); + for (const iconsRoot of iconRoots) { + for (const theme of listThemeNames(iconsRoot)) { + const match = lookForIconInTheme(path.join(iconsRoot, theme), baseName); + if (match) return match; + } + } + + const dataHome = typeof options.env?.XDG_DATA_HOME === 'string' && options.env.XDG_DATA_HOME.trim() + ? options.env.XDG_DATA_HOME.trim() + : path.join(options.homeDir || os.homedir(), '.local', 'share'); + const dataDirs = typeof options.env?.XDG_DATA_DIRS === 'string' && options.env.XDG_DATA_DIRS.trim() + ? options.env.XDG_DATA_DIRS.split(':').filter(Boolean) + : DEFAULT_XDG_DATA_DIRS; + for (const pixmapsDir of uniqueStrings([ + path.join(dataHome, 'pixmaps'), + ...dataDirs.map((dir) => path.join(dir, 'pixmaps')), + '/usr/share/pixmaps', + '/usr/local/share/pixmaps', + ])) { + for (const ext of ['.png', '.svg', '.xpm']) { + const candidate = path.join(pixmapsDir, `${baseName}${ext}`); + if (pathExistsSync(candidate)) return candidate; + } + } + return null; +}; + +export const resolveDefaultLinuxFileManagerId = ({ env = process.env, execFileSyncImpl = execFileSync } = {}) => { + try { + const output = String(execFileSyncImpl('xdg-mime', ['query', 'default', 'inode/directory'], { + encoding: 'utf8', + timeout: 1500, + env, + }) || '').trim(); + if (!output) return null; + return output.replace(/\.desktop$/i, ''); + } catch { + return null; + } +}; + +export const findLinuxFileManagerEntry = (entries, options = {}) => { + const list = Array.isArray(entries) ? entries : []; + const defaultId = resolveDefaultLinuxFileManagerId(options); + if (defaultId) { + const match = list.find((entry) => ( + entry.id === defaultId + || path.basename(entry.filePath || '', '.desktop') === defaultId + || normalizeComparable(entry.id) === normalizeComparable(defaultId) + )); + if (match) return match; + } + for (const fallbackId of FILE_MANAGER_FALLBACK_IDS) { + const match = list.find((entry) => ( + entry.id === fallbackId + || path.basename(entry.filePath || '', '.desktop') === fallbackId + || normalizeComparable(entry.id) === normalizeComparable(fallbackId) + )); + if (match) return match; + } + return list.find((entry) => { + const categories = Array.isArray(entry.categories) ? entry.categories : []; + return categories.includes('FileManager') || categories.includes('FileTools'); + }) || null; +}; + +const iconFileToDataUrl = (filePath) => { + if (!filePath || !/\.png$/i.test(filePath)) return null; + try { + return `data:image/png;base64,${fs.readFileSync(filePath).toString('base64')}`; + } catch { + return null; + } +}; + +const resolveIconDataUrlForName = (iconNames, options = {}) => { + for (const iconName of uniqueStrings(iconNames)) { + const filePath = resolveLinuxIconFile(iconName, options); + const dataUrl = iconFileToDataUrl(filePath); + if (dataUrl) return dataUrl; + } + return null; +}; + +const isLinuxFileManagerName = (name) => { + const normalized = normalizeComparable(name); + return normalized === 'finder' + || normalized === 'file manager' + || normalized === 'file explorer'; +}; + +const knownLinuxAppIdForName = (name) => { + const normalized = normalizeComparable(name); + const knownIdByName = new Map([ + ['visual studio code', 'vscode'], + ['cursor', 'cursor'], + ['vscodium', 'vscodium'], + ['windsurf', 'windsurf'], + ['zed', 'zed'], + ['sublime text', 'sublime-text'], + ]); + if (knownIdByName.has(normalized)) return knownIdByName.get(normalized); + return Object.entries(LINUX_CLI_BY_APP_ID).find(([, cli]) => { + return normalized.includes(normalizeComparable(cli)) || normalizeCompactComparable(name).includes(cli); + })?.[0] || null; +}; + export const buildLinuxInstalledApps = async (apps, options = {}) => { const entries = options.entries || await readLinuxDesktopEntries(options); const env = options.env || process.env; const names = uniqueStrings(Array.isArray(apps) ? apps.map(String) : []); + const fileManagerEntry = findLinuxFileManagerEntry(entries, { ...options, env }); return names .filter((name) => { const normalized = normalizeComparable(name); - if (normalized === 'finder' || normalized === 'file manager' || normalized === 'file explorer') { - return true; - } - if (normalized === 'terminal') { - return true; - } - if (entries.some((entry) => desktopEntryMatchesApp(entry, name))) { - return true; - } - const appId = Object.entries(LINUX_CLI_BY_APP_ID).find(([, cli]) => { - return normalizeComparable(name).includes(normalizeComparable(cli)) || normalizeCompactComparable(name).includes(cli); - })?.[0]; - // Prefer direct name→id mapping from known Open In apps. - const knownIdByName = new Map([ - ['visual studio code', 'vscode'], - ['cursor', 'cursor'], - ['vscodium', 'vscodium'], - ['windsurf', 'windsurf'], - ['zed', 'zed'], - ['sublime text', 'sublime-text'], - ]); - const mappedId = knownIdByName.get(normalized) || appId; + if (isLinuxFileManagerName(name)) return true; + if (normalized === 'terminal') return true; + if (entries.some((entry) => desktopEntryMatchesApp(entry, name))) return true; + const mappedId = knownLinuxAppIdForName(name); const cli = mappedId ? LINUX_CLI_BY_APP_ID[mappedId] : ''; return Boolean(cli && commandExists(cli, env)); }) - .map((name) => ({ name, iconDataUrl: null })); + .map((name) => { + let iconDataUrl = null; + if (isLinuxFileManagerName(name)) { + iconDataUrl = resolveIconDataUrlForName([ + fileManagerEntry?.icon, + ...FILE_MANAGER_ICON_FALLBACKS, + ], { ...options, env }); + } else if (normalizeComparable(name) === 'terminal') { + const terminalEntry = findEntry(entries, 'terminal', name) + || findEntry(entries, 'ghostty', 'Ghostty'); + iconDataUrl = resolveIconDataUrlForName([ + terminalEntry?.icon, + 'utilities-terminal', + 'org.gnome.Terminal', + 'terminal', + ], { ...options, env }); + } else { + const entry = findEntry(entries, knownLinuxAppIdForName(name) || '', name); + iconDataUrl = resolveIconDataUrlForName([entry?.icon], { ...options, env }); + } + return { name, iconDataUrl }; + }); }; -export const fetchLinuxAppIcons = async () => []; +export const fetchLinuxAppIcons = async (apps = [], options = {}) => { + const infos = await buildLinuxInstalledApps(apps, options); + return infos + .filter((entry) => typeof entry.iconDataUrl === 'string' && entry.iconDataUrl) + .map((entry) => ({ app: entry.name, data_url: entry.iconDataUrl })); +}; diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index a7c0a20b..ccf988fd 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -4785,10 +4785,10 @@ ipcMain.handle('openchamber:file:grant-existing', async (event, filePath) => { }); // --- Native tray / menu bar --------------------------------------------------- -// Tray lives on macOS and Windows; the renderer streams a compact state snapshot via -// the `desktop_tray_update` IPC command (see the command switch). Tray clicks -// flow back through dispatchTrayAction → renderer (focus/respond) or native -// handlers (show window / quit). +// Tray lives on macOS, Windows, and Linux. The renderer streams a compact state +// snapshot via the `desktop_tray_update` IPC command (see the command switch). +// Tray clicks flow back through dispatchTrayAction → renderer (focus/respond) or +// native handlers (show / hide / toggle / quit). // Icon assets: a calm outline (idle), a statically filled cube (a finished // session left unread), and an eased sequence the busy state breathes through. @@ -4916,6 +4916,30 @@ const dispatchTrayAction = async (action) => { return; } + if (action.type === 'hide-main-window') { + const target = (state.mainWindow && !state.mainWindow.isDestroyed()) + ? state.mainWindow + : BrowserWindow.getFocusedWindow(); + if (target && !target.isDestroyed() && target.isVisible()) { + debounceWindowStatePersist(target, true); + target.hide(); + } + return; + } + + if (action.type === 'toggle-main-window') { + const target = (state.mainWindow && !state.mainWindow.isDestroyed()) + ? state.mainWindow + : null; + if (target && target.isVisible() && !target.isMinimized()) { + debounceWindowStatePersist(target, true); + target.hide(); + return; + } + await revealMainWindow(); + return; + } + // Responding to a permission doesn't need to steal focus — just deliver it. if (action.type === 'respond-permission') { const target = (state.mainWindow && !state.mainWindow.isDestroyed()) diff --git a/packages/electron/scripts/smoke-linux-app-discovery.mjs b/packages/electron/scripts/smoke-linux-app-discovery.mjs index dd6fc0a7..9a0ff13d 100644 --- a/packages/electron/scripts/smoke-linux-app-discovery.mjs +++ b/packages/electron/scripts/smoke-linux-app-discovery.mjs @@ -6,10 +6,13 @@ import { buildCommandFromDesktopExec, buildLinuxInstalledApps, buildLinuxOpenSpecs, + fetchLinuxAppIcons, filterLinuxInstalledApps, + findLinuxFileManagerEntry, linuxApplicationDirs, parseDesktopEntry, readLinuxDesktopEntries, + resolveLinuxIconFile, } from '../linux-app-discovery.mjs'; const assert = (condition, message) => { @@ -22,8 +25,17 @@ try { const dataDir = path.join(tempRoot, 'system-data'); const userApps = path.join(dataHome, 'applications'); const systemApps = path.join(dataDir, 'applications'); + 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'); 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 }); + // Minimal valid 1x1 PNG. + const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64'); + await fs.writeFile(thunarIcon, png); + await fs.writeFile(codeIcon, png); const codeDesktopPath = path.join(userApps, 'code.desktop'); await fs.writeFile(codeDesktopPath, [ @@ -41,6 +53,15 @@ try { 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'); 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]', + 'Type=Application', + 'Name=Thunar File Manager', + 'Exec=thunar %F', + 'Icon=org.xfce.thunar', + 'Categories=System;FileTools;FileManager;', + '', + ].join('\n'), 'utf8'); const env = { XDG_DATA_HOME: dataHome, XDG_DATA_DIRS: dataDir, PATH: '/no/such/bin' }; const dirs = linuxApplicationDirs({ env, homeDir: tempRoot }); @@ -48,10 +69,11 @@ 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 === 3, `expected 3 visible valid entries, got ${entries.length}`); + assert(entries.length === 4, `expected 4 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'); + assert(entries.some((entry) => entry.name === 'Thunar File Manager'), 'file manager entry should be parsed'); assert(!entries.some((entry) => entry.name === 'Hidden App'), 'Hidden=true entry should be skipped'); assert(!entries.some((entry) => entry.name === 'No Display App'), 'NoDisplay=true entry should be skipped'); assert(!entries.some((entry) => entry.name === 'Missing Exec'), 'missing Exec entry should be skipped'); @@ -83,9 +105,36 @@ try { const installed = await filterLinuxInstalledApps(['Visual Studio Code', 'Hidden App', 'Missing App'], { entries }); assert(installed.length === 1 && installed[0] === 'Visual Studio Code', 'filter should return only visible installed apps'); - const appInfos = await buildLinuxInstalledApps(['Visual Studio Code', 'Ghostty'], { entries }); - assert(appInfos.length === 2, 'installed app info should include matching entries'); + const resolvedCodeIcon = resolveLinuxIconFile('code', { env, homeDir: tempRoot }); + assert(resolvedCodeIcon === codeIcon, `resolveLinuxIconFile should find themed PNG, got ${resolvedCodeIcon}`); + + const fileManager = findLinuxFileManagerEntry(entries, { + env, + execFileSyncImpl: () => 'thunar.desktop', + }); + assert(fileManager?.id === 'thunar', `default file manager should resolve via xdg-mime, got ${fileManager?.id}`); + + const appInfos = await buildLinuxInstalledApps(['Finder', 'Visual Studio Code', 'Ghostty'], { + entries, + env, + homeDir: tempRoot, + execFileSyncImpl: () => 'thunar.desktop', + }); + assert(appInfos.length === 3, 'installed app info should include matching entries'); assert(appInfos.every((entry) => Object.hasOwn(entry, 'iconDataUrl')), 'installed app info should include iconDataUrl key'); + const finderInfo = appInfos.find((entry) => entry.name === 'Finder'); + assert(typeof finderInfo?.iconDataUrl === 'string' && finderInfo.iconDataUrl.startsWith('data:image/png;base64,'), 'Finder/file manager should use system PNG icon data URL'); + 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 fetchedIcons = await fetchLinuxAppIcons(['Finder', 'Visual Studio Code'], { + entries, + env, + homeDir: tempRoot, + execFileSyncImpl: () => 'thunar.desktop', + }); + assert(fetchedIcons.length === 2, 'fetchLinuxAppIcons should return resolved icons'); + assert(fetchedIcons.every((entry) => entry.data_url?.startsWith('data:image/png;base64,')), 'fetched icons should be PNG data URLs'); const specs = buildLinuxOpenSpecs({ targetPath: '/tmp/My Project', appId: 'vscode', appName: 'Visual Studio Code', targetKind: 'project', entries, env }); assert(specs.length === 1, 'desktop entry should provide an opener when CLI is absent'); @@ -106,7 +155,22 @@ try { 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'); - console.log(JSON.stringify({ ok: true, dirs, entries: entries.map((entry) => entry.name), command, ghosttyCommand, plainCommand, installed, specs, terminalFileSpecs, fallbackTerminalSpecs, defaultSpecs }, null, 2)); + console.log(JSON.stringify({ + ok: true, + dirs, + entries: entries.map((entry) => entry.name), + command, + ghosttyCommand, + plainCommand, + installed, + finderIcon: Boolean(finderInfo?.iconDataUrl), + codeIcon: Boolean(codeInfo?.iconDataUrl), + fetchedIcons: fetchedIcons.map((entry) => entry.app), + specs, + terminalFileSpecs, + fallbackTerminalSpecs, + defaultSpecs, + }, null, 2)); } finally { await fs.rm(tempRoot, { recursive: true, force: true }); } diff --git a/packages/electron/tray.mjs b/packages/electron/tray.mjs index f18413c8..2fbc5b3a 100644 --- a/packages/electron/tray.mjs +++ b/packages/electron/tray.mjs @@ -18,6 +18,10 @@ import { Tray, Menu, nativeImage } from 'electron'; const isMac = process.platform === 'darwin'; +const isLinux = process.platform === 'linux'; +// Linux StatusNotifier hosts often blank or drop oversized tray images; keep +// the icon at a panel-typical size so AppImage trays stay visible. +const LINUX_TRAY_ICON_PX = 22; const MAX_SESSIONS = 8; const MAX_APPROVALS = 10; @@ -88,8 +92,19 @@ const computeTooltip = (counts, sessionCount) => { const ANIM_INTERVAL_MS = 75; const toTemplateImage = (p) => { - const image = nativeImage.createFromPath(p); + let image = nativeImage.createFromPath(p); + if (image.isEmpty()) return image; if (isMac) image.setTemplateImage(true); + if (isLinux) { + const { width, height } = image.getSize(); + if (width > LINUX_TRAY_ICON_PX || height > LINUX_TRAY_ICON_PX) { + image = image.resize({ + width: LINUX_TRAY_ICON_PX, + height: LINUX_TRAY_ICON_PX, + quality: 'best', + }); + } + } return image; }; @@ -161,7 +176,11 @@ export const createTrayController = ({ idleIconPath, unseenIconPath, breathIconP tray = new Tray(idleFrame); tray.setIgnoreDoubleClickEvents(true); if (!isMac) { - tray.on('click', () => onAction({ type: 'show-main-window' })); + // Windows: left-click shows. Linux: left-click toggles show/hide so the + // panel icon stays useful when the window is already open. + tray.on('click', () => onAction({ + type: isLinux ? 'toggle-main-window' : 'show-main-window', + })); } return tray; }; @@ -268,11 +287,26 @@ export const createTrayController = ({ idleIconPath, unseenIconPath, breathIconP { type: 'separator' }, { label: 'New Session', click: () => onAction({ type: 'new-session' }) }, { label: 'New Mini Chat', click: () => onAction({ type: 'new-mini-chat' }) }, - { label: 'Show OpenChamber', click: () => onAction({ type: 'show-main-window' }) }, - { type: 'separator' }, - { label: 'Quit OpenChamber', click: () => onAction({ type: 'quit' }) }, ); + if (isLinux || process.platform === 'win32') { + // Right-click context menu: show / hide / close (quit). Matches the + // expected AppImage / Windows tray controls. + template.push( + { type: 'separator' }, + { label: 'Show Window', click: () => onAction({ type: 'show-main-window' }) }, + { label: 'Hide Window', click: () => onAction({ type: 'hide-main-window' }) }, + { type: 'separator' }, + { label: 'Close', click: () => onAction({ type: 'quit' }) }, + ); + } else { + template.push( + { label: 'Show OpenChamber', click: () => onAction({ type: 'show-main-window' }) }, + { type: 'separator' }, + { label: 'Quit OpenChamber', click: () => onAction({ type: 'quit' }) }, + ); + } + return Menu.buildFromTemplate(template); }; diff --git a/packages/ui/src/components/desktop/OpenInAppButton.tsx b/packages/ui/src/components/desktop/OpenInAppButton.tsx index bfa6eed0..3e08bc3e 100644 --- a/packages/ui/src/components/desktop/OpenInAppButton.tsx +++ b/packages/ui/src/components/desktop/OpenInAppButton.tsx @@ -24,11 +24,11 @@ type OpenInAppOptionWithFallback = OpenInAppOption & { const withFallbackIcon = (app: OpenInAppOption): OpenInAppOptionWithFallback => ({ ...app, - fallbackIconDataUrl: app.id === 'finder' && window.__OPENCHAMBER_PLATFORM__ !== 'win32' + fallbackIconDataUrl: app.id === 'finder' && window.__OPENCHAMBER_PLATFORM__ === 'darwin' ? FINDER_DEFAULT_ICON_DATA_URL : app.id === 'terminal' - ? TERMINAL_DEFAULT_ICON_DATA_URL - : undefined, + ? TERMINAL_DEFAULT_ICON_DATA_URL + : undefined, }); const AppIcon = ({ diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index 8a4075e1..c283c543 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -936,11 +936,6 @@ export const fetchDesktopInstalledApps = async ( return { apps: [], success: false, hasCache: false, isCacheStale: false }; } - // Linux desktop does not resolve installed GUI apps; skip the IPC round-trip. - if (getElectronPlatform() === 'linux') { - return { apps: [], success: true, hasCache: false, isCacheStale: false }; - } - const candidate = Array.isArray(apps) ? apps.filter((value) => typeof value === 'string') : []; if (candidate.length === 0) { return { apps: [], success: true, hasCache: false, isCacheStale: false };