// SPDX-License-Identifier: AGPL-3.0-or-later import child_process from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import {APP_PROTOCOL} from '@electron/common/Constants'; import { DESKTOP_APP_NAME, LEGACY_LINUX_DESKTOP_ENTRY_ID, LINUX_DESKTOP_ENTRY_ID, LINUX_ICON_NAME, } from '@electron/common/DesktopIdentity'; import {createChildLogger} from '@electron/common/Logger'; import {TASK_ARG_PREFIX} from '@electron/main/JumpList'; import {getStableLinuxLaunchPath} from '@electron/main/LinuxLaunchPath'; import {isFlatpakRuntime} from '@electron/main/LinuxSandbox'; import {getNativeLocale, t} from '@electron/main/MainI18n'; import {app} from 'electron'; const logger = createChildLogger('LinuxDesktopEntry'); const APP_NAME = DESKTOP_APP_NAME; const APP_ID = LINUX_DESKTOP_ENTRY_ID; const WM_CLASS = APP_ID; const DESKTOP_FILE_BASENAME = `${APP_ID}.desktop`; const LEGACY_DESKTOP_FILE_BASENAME = `${LEGACY_LINUX_DESKTOP_ENTRY_ID}.desktop`; const GENERIC_NAME_DEFAULT = 'Instant Messenger'; const COMMENT_DEFAULT = 'Instant messaging and VoIP'; export const GENERATED_MARKER = '# X-Generated-By=fluxer-desktop'; const DESKTOP_ACTIONS = [ { id: 'open-settings', nameKey: 'desktop.jumpList.openSettings', }, { id: 'new-dm', nameKey: 'desktop.jumpList.newDirectMessage', }, ] as const; const HICOLOR_ICON_SIZES = [16, 24, 32, 48, 64, 128, 256, 512] as const; const PROTOCOL_REGISTRATION_TIMEOUT_MS = 5000; function getXdgDataHome(): string { const override = process.env.XDG_DATA_HOME; if (override && override.length > 0) return override; return path.join(os.homedir(), '.local', 'share'); } function getXdgDataDirs(): Array { const override = process.env.XDG_DATA_DIRS; const raw = override && override.length > 0 ? override : '/usr/local/share:/usr/share'; return raw.split(':').filter((entry) => entry.length > 0); } function getUserApplicationsDir(): string { return path.join(getXdgDataHome(), 'applications'); } function getDesktopFilePath(): string { return path.join(getUserApplicationsDir(), DESKTOP_FILE_BASENAME); } function findSystemDesktopEntry(basename = DESKTOP_FILE_BASENAME): string | null { for (const dataDir of getXdgDataDirs()) { const candidate = path.join(dataDir, 'applications', basename); try { if (fs.existsSync(candidate)) return candidate; } catch {} } return null; } function findThirdPartyDesktopEntry(execPath: string): string | null { const applicationsDir = getUserApplicationsDir(); let entries: Array; try { entries = fs.readdirSync(applicationsDir); } catch { return null; } for (const entry of entries) { if (!entry.endsWith('.desktop') || entry === DESKTOP_FILE_BASENAME) continue; const candidate = path.join(applicationsDir, entry); try { if (fs.readFileSync(candidate, 'utf8').includes(execPath)) return candidate; } catch {} } return null; } export function escapeDesktopValue(value: string): string { return value.replace(/\\/g, '\\\\').replace(/\n/g, '\\n').replace(/\t/g, '\\t').replace(/\r/g, '\\r'); } export function quoteExecArg(value: string): string { return `"${value.replace(/(["`$\\])/g, '\\$1')}"`; } function installHicolorIcons(): void { const iconRoot = path.join(process.resourcesPath, 'icons'); for (const size of HICOLOR_ICON_SIZES) { const source = path.join(iconRoot, `${size}x${size}.png`); const target = path.join(getXdgDataHome(), 'icons', 'hicolor', `${size}x${size}`, 'apps', `${LINUX_ICON_NAME}.png`); try { if (!fs.existsSync(source)) continue; fs.mkdirSync(path.dirname(target), {recursive: true}); fs.copyFileSync(source, target); } catch (error) { logger.debug('Failed to install Linux hicolor icon', {source, target, error}); } } } function buildDesktopActionExecLine(execPath: string, taskId: (typeof DESKTOP_ACTIONS)[number]['id']): string { return `${quoteExecArg(execPath)} ${TASK_ARG_PREFIX}${taskId} %U`; } function buildDesktopActionEntries(execPath: string): Array { const entries = [`Actions=${DESKTOP_ACTIONS.map((action) => action.id).join(';')};`]; for (const action of DESKTOP_ACTIONS) { entries.push( '', `[Desktop Action ${action.id}]`, `Name=${escapeDesktopValue(t(action.nameKey))}`, `Exec=${escapeDesktopValue(buildDesktopActionExecLine(execPath, action.id))}`, ); } return entries; } function buildLocalizedEntryLines(key: string, defaultValue: string, localizedValue: string): Array { const lines = [`${key}=${escapeDesktopValue(defaultValue)}`]; if (localizedValue !== defaultValue) { const entryLocale = getNativeLocale().replace(/-/g, '_'); lines.push(`${key}[${entryLocale}]=${escapeDesktopValue(localizedValue)}`); } return lines; } export function buildDesktopFileContents(execPath: string, hidden: boolean): string { const execLine = `${quoteExecArg(execPath)} %U`; return [ '[Desktop Entry]', GENERATED_MARKER, 'Type=Application', 'Version=1.5', `Name=${escapeDesktopValue(APP_NAME)}`, ...buildLocalizedEntryLines('GenericName', GENERIC_NAME_DEFAULT, t('desktop.linuxEntry.genericName')), ...buildLocalizedEntryLines('Comment', COMMENT_DEFAULT, t('desktop.linuxEntry.comment')), `Exec=${escapeDesktopValue(execLine)}`, `TryExec=${escapeDesktopValue(execPath)}`, `Icon=${escapeDesktopValue(LINUX_ICON_NAME)}`, 'Terminal=false', 'Categories=Network;InstantMessaging;Chat;', `MimeType=x-scheme-handler/${APP_PROTOCOL};`, `StartupWMClass=${WM_CLASS}`, 'SingleMainWindow=true', 'StartupNotify=true', ...(hidden ? ['NoDisplay=true'] : []), ...buildDesktopActionEntries(execPath), '', ].join('\n'); } function readDesktopEntryValue(contents: string, key: string): string | null { for (const line of contents.split('\n')) { const trimmed = line.trim(); if (trimmed.startsWith('[Desktop Action ')) break; if (trimmed.startsWith(`${key}=`)) return trimmed.slice(key.length + 1).trim(); } return null; } function isExecutableFile(candidate: string): boolean { try { if (!fs.statSync(candidate).isFile()) return false; fs.accessSync(candidate, fs.constants.X_OK); return true; } catch { return false; } } function isStaleDesktopFile(contents: string): boolean { const tryExec = readDesktopEntryValue(contents, 'TryExec'); if (tryExec === null || !tryExec.startsWith('/')) return false; return !isExecutableFile(tryExec); } function readExistingDesktopFile(filePath: string): string | null { try { return fs.readFileSync(filePath, 'utf8'); } catch { return null; } } function runUpdateDesktopDatabase(applicationsDir: string): void { child_process.execFile('update-desktop-database', [applicationsDir], {timeout: 5000}, (error) => { if (error) { logger.debug('update-desktop-database returned non-zero or was not found', { message: error.message, }); } }); } function isDefaultProtocolClient(): boolean { try { return app.isDefaultProtocolClient(APP_PROTOCOL); } catch (error) { logger.debug('Failed to read the protocol client registration', {error}); return false; } } function registerProtocolClientInProcess(): void { try { app.setAsDefaultProtocolClient(APP_PROTOCOL); } catch (error) { logger.warn('Failed to register protocol client', {error}); } } function registerProtocolClient(): void { if (isDefaultProtocolClient()) return; const desktopName = process.env.CHROME_DESKTOP; if (!desktopName) { logger.debug('Skipping protocol client registration because the desktop name is not set'); return; } child_process.execFile( 'xdg-mime', ['default', desktopName, `x-scheme-handler/${APP_PROTOCOL}`], {timeout: PROTOCOL_REGISTRATION_TIMEOUT_MS}, (error) => { if (!error) return; logger.debug('xdg-mime could not register the protocol client, registering in process', { message: error.message, }); registerProtocolClientInProcess(); }, ); } function removeFile(filePath: string, reason: string): boolean { try { fs.unlinkSync(filePath); logger.info(reason, {filePath}); return true; } catch (error) { logger.warn('Failed to remove .desktop entry', {filePath, error}); return false; } } function removeLegacyGeneratedDesktopEntry(): boolean { const legacyPath = path.join(getUserApplicationsDir(), LEGACY_DESKTOP_FILE_BASENAME); const contents = readExistingDesktopFile(legacyPath); if (contents === null || !contents.includes(GENERATED_MARKER)) return false; return removeFile(legacyPath, 'Removed the generated .desktop entry for the previous desktop id'); } function writeDesktopFileAtomically(filePath: string, contents: string): void { const tempPath = `${filePath}.${process.pid}.tmp`; try { fs.mkdirSync(path.dirname(filePath), {recursive: true}); fs.writeFileSync(tempPath, contents, {encoding: 'utf8', mode: 0o644}); fs.renameSync(tempPath, filePath); } catch (error) { try { fs.rmSync(tempPath, {force: true}); } catch {} throw error; } } function syncUserDesktopEntry(): boolean { const execPath = getStableLinuxLaunchPath(); const filePath = getDesktopFilePath(); const existing = readExistingDesktopFile(filePath); const systemEntry = findSystemDesktopEntry(); if (systemEntry) { if (existing !== null && (existing.includes(GENERATED_MARKER) || isStaleDesktopFile(existing))) { return removeFile(filePath, 'Removed a user .desktop entry shadowing the system entry'); } logger.debug('System-wide .desktop entry detected; skipping user-local copy', {systemEntry}); return false; } if (existing !== null && !existing.includes(GENERATED_MARKER) && !isStaleDesktopFile(existing)) { logger.debug('Linux .desktop entry was hand-edited; leaving untouched', {filePath}); return false; } installHicolorIcons(); const thirdPartyEntry = findThirdPartyDesktopEntry(execPath) ?? findSystemDesktopEntry(LEGACY_DESKTOP_FILE_BASENAME); if (thirdPartyEntry) { logger.debug('Another .desktop entry manages the app menu entry; keeping ours hidden', {thirdPartyEntry}); } const desired = buildDesktopFileContents(execPath, thirdPartyEntry !== null); if (existing === desired) { logger.debug('Linux .desktop entry already up to date', {filePath}); return false; } writeDesktopFileAtomically(filePath, desired); logger.info('Wrote Linux .desktop entry', {filePath, execPath}); return true; } function desktopEntryResolves(): boolean { return findSystemDesktopEntry() !== null || readExistingDesktopFile(getDesktopFilePath()) !== null; } export function ensureLinuxDesktopEntry(): boolean { if (process.platform !== 'linux') return false; if (!app.isPackaged) { logger.debug('Skipping .desktop entry management for an unpackaged run'); return false; } if (isFlatpakRuntime()) { logger.debug('Skipping .desktop entry creation in Flatpak; package export owns launcher/protocol integration'); registerProtocolClient(); return false; } if (process.env.FLUXER_DISABLE_DESKTOP_FILE === '1') { logger.debug('Skipping .desktop entry management; FLUXER_DISABLE_DESKTOP_FILE=1'); if (findSystemDesktopEntry() !== null) registerProtocolClient(); return desktopEntryResolves(); } let changed = removeLegacyGeneratedDesktopEntry(); try { changed = syncUserDesktopEntry() || changed; } catch (error) { logger.warn('Failed to write Linux .desktop entry; deep links and portals may not resolve the app', { filePath: getDesktopFilePath(), error, }); } if (changed) runUpdateDesktopDatabase(getUserApplicationsDir()); const resolves = desktopEntryResolves(); if (resolves) registerProtocolClient(); return resolves; }