Files
fluxer/fluxer_desktop/src/main/LinuxDesktopEntry.ts
T

343 lines
11 KiB
TypeScript

// 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<string> {
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<string>;
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<string> {
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<string> {
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;
}