fix(desktop): supersede the legacy linux packages on upgrade (#2842)

This commit is contained in:
Hampus
2026-09-19 18:50:37 +02:00
committed by GitHub
parent cc75e1318d
commit 98a42f612b
4 changed files with 278 additions and 7 deletions
+218 -7
View File
@@ -7,7 +7,10 @@ const os = require('node:os');
const path = require('node:path');
const {promisify} = require('node:util');
const execFileAsync = promisify(execFile);
const isLinuxBuild = process.argv.includes('--linux');
const productName = isCanary ? 'Fluxer Canary' : 'Fluxer';
const linuxOptDirName = isCanary ? 'fluxer-canary' : 'Fluxer';
const installedProductName = isLinuxBuild ? linuxOptDirName : productName;
const artifactProductName = isCanary ? 'Fluxer-Canary' : 'Fluxer';
const appId = isCanary ? 'app.fluxer.canary' : 'app.fluxer';
const iconDir = isCanary ? 'icons-canary' : 'icons-stable';
@@ -23,8 +26,22 @@ const rpmBuildIdLinkFpmArgs = [
'--rpm-rpmbuild-define',
'_missing_build_ids_terminate_build 0',
];
const legacyLinuxStableDebPackageName = 'fluxer-app';
const legacyLinuxStableRpmPackageName = 'fluxer_app';
const legacyLinuxStablePackageNames = {
'.deb': legacyLinuxStableDebPackageName,
'.rpm': legacyLinuxStableRpmPackageName,
};
const legacyLinuxStableDebFpmArgs = isCanary
? []
: ['--replaces', legacyLinuxStableDebPackageName, '--conflicts', legacyLinuxStableDebPackageName];
const legacyLinuxStableRpmFpmArgs = isCanary
? []
: ['--replaces', legacyLinuxStableRpmPackageName, '--conflicts', legacyLinuxStableRpmPackageName];
const legacyLinuxCanaryOptDir = '/opt/Fluxer Canary';
const legacyLinuxOptDirSweepScript = path.resolve(__dirname, 'packaging/linux/rpm-post-transaction.sh');
const legacyLinuxOptDirRpmFpmArgs = isCanary ? ['--rpm-posttrans', legacyLinuxOptDirSweepScript] : [];
const macOSMinimumSystemVersion = '13.0';
const isLinuxBuild = process.argv.includes('--linux');
const isMacBuild = process.argv.includes('--mac');
const isWindowsBuild = process.argv.includes('--win');
const targetPlatform = isLinuxBuild ? 'linux' : isMacBuild ? 'darwin' : isWindowsBuild ? 'win32' : process.platform;
@@ -46,6 +63,12 @@ if (targetNativeArch === 'universal' && targetPlatform !== 'darwin') {
throw new Error(`ELECTRON_ARCH=universal is only supported for macOS builds, received platform ${targetPlatform}`);
}
if (isLinuxBuild && /\s/.test(linuxOptDirName)) {
throw new Error(
`Linux install directory /opt/${linuxOptDirName} contains whitespace. Chromium splits the SUID sandbox path on spaces, so the zygote fails to start on hosts without unprivileged user namespaces.`,
);
}
const targetArchs = electronArch && electronArch !== 'universal' ? [electronArch] : supportedTargetArchs;
const macTargetArchs = targetNativeArch ? [targetNativeArch] : supportedTargetArchs;
const winGameCaptureTargetArchs =
@@ -328,7 +351,8 @@ const linuxDesktopEntryWithActions = {
...linuxDesktopEntry,
Actions: linuxDesktopActionList,
};
const linuxInstalledExecPath = quoteDesktopExecArg(path.posix.join('/opt', productName, linuxPackageName));
const linuxInstalledBinaryPath = path.posix.join('/opt', linuxOptDirName, linuxPackageName);
const linuxInstalledExecPath = quoteDesktopExecArg(linuxInstalledBinaryPath);
const linuxDesktopActions = {
'open-settings': {
Name: 'Open Settings',
@@ -1255,8 +1279,193 @@ async function verifyAppImageArtifactsUseSandboxAwareLauncher(buildResult) {
throw new Error(lines.join('\n'));
}
async function readRpmPostUninstallScriptlet(artifactPath) {
try {
const {stdout} = await execFileAsync('rpm', ['-qp', '--qf', '%{POSTUN}', artifactPath], {
maxBuffer: 16 * 1024 * 1024,
});
return stdout;
} catch (error) {
if (error && error.code === 'ENOENT') {
throw new Error(`Cannot inspect RPM artifact ${artifactPath}: rpm executable is not available.`);
}
const stderr = typeof error?.stderr === 'string' ? error.stderr.trim() : '';
throw new Error(`Cannot inspect RPM artifact ${artifactPath}: ${stderr || error?.message || String(error)}`);
}
}
async function verifyRpmArtifactsSurviveASamePathUpgrade(buildResult) {
const rpmArtifacts = (buildResult.artifactPaths ?? []).filter(
(artifactPath) => path.extname(artifactPath) === '.rpm',
);
const guard = `if [ ! -e '${linuxInstalledBinaryPath}' ]; then`;
const violations = [];
for (const artifactPath of rpmArtifacts) {
const scriptlet = await readRpmPostUninstallScriptlet(artifactPath);
if (!scriptlet.includes('update-alternatives --remove')) continue;
if (
scriptlet.indexOf(guard) === -1 ||
scriptlet.indexOf(guard) > scriptlet.indexOf('update-alternatives --remove')
) {
violations.push(artifactPath);
}
}
if (violations.length === 0) return;
const lines = [
'RPM %postun must not remove the /usr/bin alternative unless the installed binary is already gone.',
'rpm runs the new %post before the old %postun and passes the old one $1=1, so an unguarded removal deletes the',
'alternative the new %post just registered whenever both versions install into the same /opt directory.',
"rpm runs the old package's %postun, so this guard only protects upgrades from builds that already have it,",
'not the first upgrade onto this build.',
`Expected the scriptlet to open with ${guard}`,
];
for (const artifactPath of violations) {
lines.push(` - ${path.basename(artifactPath)}`);
}
throw new Error(lines.join('\n'));
}
async function readRpmPostTransactionScriptlet(artifactPath) {
try {
const {stdout} = await execFileAsync('rpm', ['-qp', '--qf', '%{POSTTRANS}', artifactPath], {
maxBuffer: 16 * 1024 * 1024,
});
return stdout === '(none)' ? '' : stdout;
} catch (error) {
if (error && error.code === 'ENOENT') {
throw new Error(`Cannot inspect RPM artifact ${artifactPath}: rpm executable is not available.`);
}
const stderr = typeof error?.stderr === 'string' ? error.stderr.trim() : '';
throw new Error(`Cannot inspect RPM artifact ${artifactPath}: ${stderr || error?.message || String(error)}`);
}
}
async function verifyRpmArtifactsSweepTheRenamedInstallDirectory(buildResult) {
if (!isCanary) return;
const rpmArtifacts = (buildResult.artifactPaths ?? []).filter(
(artifactPath) => path.extname(artifactPath) === '.rpm',
);
const violations = [];
for (const artifactPath of rpmArtifacts) {
const scriptlet = await readRpmPostTransactionScriptlet(artifactPath);
const detail = !scriptlet.includes(legacyLinuxCanaryOptDir)
? `does not reference ${legacyLinuxCanaryOptDir}`
: !scriptlet.includes('rmdir')
? 'does not sweep the directory with rmdir'
: /\brm\s+-[a-zA-Z]*[rf]/.test(scriptlet)
? 'removes files rather than only empty directories'
: null;
if (detail !== null) {
violations.push({artifactPath, detail});
}
}
if (violations.length === 0) return;
const lines = [
`RPM %posttrans must sweep the empty ${legacyLinuxCanaryOptDir} skeleton left by the /opt rename.`,
'fpm emits no directory entries for rpm, so the old directories survive the upgrade unowned by any package and',
'survive a later uninstall too. The sweep must stay rmdir-based so it can never delete a live install.',
];
for (const {artifactPath, detail} of violations) {
lines.push(` - ${path.basename(artifactPath)}: ${detail}`);
}
throw new Error(lines.join('\n'));
}
async function readDebControlField(artifactPath, field) {
try {
const {stdout} = await execFileAsync('dpkg-deb', ['-f', artifactPath, field], {
maxBuffer: 1024 * 1024,
});
return stdout;
} catch (error) {
if (error && error.code === 'ENOENT') {
throw new Error(`Cannot inspect DEB artifact ${artifactPath}: dpkg-deb executable is not available.`);
}
const stderr = typeof error?.stderr === 'string' ? error.stderr.trim() : '';
throw new Error(`Cannot inspect DEB artifact ${artifactPath}: ${stderr || error?.message || String(error)}`);
}
}
async function readRpmRelationNames(artifactPath, queryFlag) {
try {
const {stdout} = await execFileAsync('rpm', ['-qp', queryFlag, artifactPath], {
maxBuffer: 1024 * 1024,
});
return stdout;
} catch (error) {
if (error && error.code === 'ENOENT') {
throw new Error(`Cannot inspect RPM artifact ${artifactPath}: rpm executable is not available.`);
}
const stderr = typeof error?.stderr === 'string' ? error.stderr.trim() : '';
throw new Error(`Cannot inspect RPM artifact ${artifactPath}: ${stderr || error?.message || String(error)}`);
}
}
function parsePackageRelationNames(output) {
return output
.split(/[\r\n,]+/)
.map((entry) => entry.trim())
.filter((entry) => entry && entry !== '(none)')
.map((entry) => entry.split(/\s+/)[0]);
}
async function readLinuxPackageReplacementNames(artifactPath) {
if (path.extname(artifactPath) === '.deb') {
return {
replaces: parsePackageRelationNames(await readDebControlField(artifactPath, 'Replaces')),
conflicts: parsePackageRelationNames(await readDebControlField(artifactPath, 'Conflicts')),
};
}
return {
replaces: parsePackageRelationNames(await readRpmRelationNames(artifactPath, '--obsoletes')),
conflicts: parsePackageRelationNames(await readRpmRelationNames(artifactPath, '--conflicts')),
};
}
async function verifyLinuxPackagesDeclareTheLegacyStableReplacement(buildResult) {
const packageArtifacts = (buildResult.artifactPaths ?? []).filter((artifactPath) =>
['.deb', '.rpm'].includes(path.extname(artifactPath)),
);
const violations = [];
for (const artifactPath of packageArtifacts) {
const legacyName = legacyLinuxStablePackageNames[path.extname(artifactPath)];
const {replaces, conflicts} = await readLinuxPackageReplacementNames(artifactPath);
const declared = [
...(replaces.includes(legacyName) ? ['replaces'] : []),
...(conflicts.includes(legacyName) ? ['conflicts'] : []),
];
if (isCanary && declared.length > 0) {
violations.push({
artifactPath,
detail: `canary declares ${declared.join(' and ')} on ${legacyName}, which belongs to stable only`,
});
} else if (!isCanary && declared.length !== 2) {
violations.push({
artifactPath,
detail: `stable declares ${declared.join(' and ') || 'neither'} on ${legacyName}, expected both`,
});
}
}
if (violations.length === 0) return;
const lines = [
'Stable Linux package artifact(s) must declare both the replaces and the conflicts relation on the legacy package.',
'Without both, dpkg aborts every legacy install on the file-overwrite check and dnf models the new package as a',
'second install that coexists with the old one. Canary never shipped under the legacy names, so it declares neither.',
];
for (const {artifactPath, detail} of violations) {
lines.push(` - ${path.basename(artifactPath)}: ${detail}`);
}
throw new Error(lines.join('\n'));
}
async function verifyLinuxArtifactContracts(buildResult) {
await verifyRpmArtifactsDoNotOwnBuildIds(buildResult);
await verifyRpmArtifactsSurviveASamePathUpgrade(buildResult);
await verifyRpmArtifactsSweepTheRenamedInstallDirectory(buildResult);
await verifyLinuxPackagesDeclareTheLegacyStableReplacement(buildResult);
await verifyLinuxPackagesContainAppArmorProfile(buildResult);
await verifyAppImageArtifactsGlibcCompatibility(buildResult);
await verifyAppImageArtifactsDoNotNeedFuse2(buildResult);
@@ -1265,7 +1474,7 @@ async function verifyLinuxArtifactContracts(buildResult) {
module.exports = {
appId,
productName,
productName: installedProductName,
copyright: 'Copyright © 2026 Fluxer Platform AB',
artifactName: `${artifactProductName}-\${version}-\${os}-\${arch}.\${ext}`,
directories: {
@@ -1475,18 +1684,19 @@ module.exports = {
entry: linuxDesktopEntryWithActions,
desktopActions: linuxDesktopActions,
},
fpm: legacyLinuxStableDebFpmArgs,
depends: [
'libgtk-3-0',
'libgtk-3-0t64 | libgtk-3-0',
'libnotify4',
'libnss3',
'libxss1',
'libxtst6',
'xdg-utils',
'libatspi2.0-0',
'libatspi2.0-0t64 | libatspi2.0-0',
'libuuid1',
'libsecret-1-0',
'libpulse0',
'libpipewire-0.3-0',
'libpipewire-0.3-0t64 | libpipewire-0.3-0',
'libstdc++6',
'libgcc-s1',
],
@@ -1496,7 +1706,8 @@ module.exports = {
entry: linuxDesktopEntryWithActions,
desktopActions: linuxDesktopActions,
},
fpm: rpmBuildIdLinkFpmArgs,
afterRemove: 'packaging/linux/rpm-after-remove.tpl',
fpm: [...rpmBuildIdLinkFpmArgs, ...legacyLinuxStableRpmFpmArgs, ...legacyLinuxOptDirRpmFpmArgs],
depends: [
'gtk3',
'libnotify',
@@ -0,0 +1,21 @@
#!/bin/bash
if [ ! -e '/opt/${sanitizedProductName}/${executable}' ]; then
if type update-alternatives >/dev/null 2>&1; then
update-alternatives --remove '${executable}' '/opt/${sanitizedProductName}/${executable}'
else
rm -f '/usr/bin/${executable}'
fi
fi
if [ "$1" = 0 ]; then
APPARMOR_PROFILE_DEST='/etc/apparmor.d/${executable}'
if [ -f "$APPARMOR_PROFILE_DEST" ]; then
if apparmor_status --enabled > /dev/null 2>&1; then
if ! { [ -x '/usr/bin/ischroot' ] && /usr/bin/ischroot; } && hash apparmor_parser 2>/dev/null; then
apparmor_parser --remove "$APPARMOR_PROFILE_DEST" || true
fi
fi
rm -f "$APPARMOR_PROFILE_DEST"
fi
fi
@@ -0,0 +1,10 @@
#!/bin/bash
LEGACY_DIR='/opt/Fluxer Canary'
if [ -d "$LEGACY_DIR" ]; then
if [ -z "$(find "$LEGACY_DIR" ! -type d -print -quit 2>/dev/null)" ]; then
find "$LEGACY_DIR" -depth -type d -exec rmdir {} + >/dev/null 2>&1 || true
fi
fi
exit 0
+29
View File
@@ -331,6 +331,10 @@ function desktopEntryBoolean(value: string | undefined): boolean {
return value?.trim().toLowerCase() === 'true';
}
function desktopEntryDisabled(value: string | undefined): boolean {
return value?.trim().toLowerCase() === 'false';
}
function parseDesktopExecCommand(value: string | undefined): string | null {
if (!value) return null;
const input = value.trimStart();
@@ -583,7 +587,32 @@ async function isAutostartEnabled(): Promise<boolean> {
return false;
}
async function repairLinuxAutostartEntry(): Promise<void> {
if (!isLinux || isFlatpakRuntime() || isPortableMode()) return;
let contents: string;
try {
contents = fs.readFileSync(getLinuxDesktopFilePath(), 'utf8');
} catch {
return;
}
const entry = tryParseDesktopEntry(contents);
if (entry.get('StartupWMClass')?.trim() !== LINUX_STARTUP_WM_CLASS) return;
if (desktopEntryBoolean(entry.get('Hidden'))) return;
if (desktopEntryDisabled(entry.get('X-GNOME-Autostart-enabled'))) return;
if (linuxDesktopEntryTargetsExistingCommand(entry)) return;
if (!commandExists(getStableLinuxLaunchPath())) return;
try {
await enableLinuxAutostart();
log.info('[Autostart] Rewrote a Linux autostart entry whose command no longer exists', {
execPath: getStableLinuxLaunchPath(),
});
} catch (error) {
log.warn('[Autostart] Failed to rewrite the stale Linux autostart entry:', error);
}
}
export function registerAutostartHandlers(): void {
void repairLinuxAutostartEntry();
ipcMain.handle('autostart-enable', async (): Promise<void> => {
await enableAutostart();
});