diff --git a/fluxer_desktop/electron-builder.config.cjs b/fluxer_desktop/electron-builder.config.cjs index dcaff51b8..173407aa2 100644 --- a/fluxer_desktop/electron-builder.config.cjs +++ b/fluxer_desktop/electron-builder.config.cjs @@ -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', diff --git a/fluxer_desktop/packaging/linux/rpm-after-remove.tpl b/fluxer_desktop/packaging/linux/rpm-after-remove.tpl new file mode 100644 index 000000000..4b46db7c3 --- /dev/null +++ b/fluxer_desktop/packaging/linux/rpm-after-remove.tpl @@ -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 diff --git a/fluxer_desktop/packaging/linux/rpm-post-transaction.sh b/fluxer_desktop/packaging/linux/rpm-post-transaction.sh new file mode 100644 index 000000000..2d1c5e876 --- /dev/null +++ b/fluxer_desktop/packaging/linux/rpm-post-transaction.sh @@ -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 diff --git a/fluxer_desktop/src/main/Autostart.ts b/fluxer_desktop/src/main/Autostart.ts index 98cfa2075..823c0e60b 100644 --- a/fluxer_desktop/src/main/Autostart.ts +++ b/fluxer_desktop/src/main/Autostart.ts @@ -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 { return false; } +async function repairLinuxAutostartEntry(): Promise { + 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 => { await enableAutostart(); });