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 <makeittech@users.noreply.github.com>
This commit is contained in:
co-authored by
Serhii Dziupin
parent
ca33f68c36
commit
0caff15b3e
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 }));
|
||||
};
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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 = ({
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user