diff --git a/packages/desktop/src/autoLaunch.test.ts b/packages/desktop/src/autoLaunch.test.ts new file mode 100644 index 00000000..f3d0e9f3 --- /dev/null +++ b/packages/desktop/src/autoLaunch.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from 'vitest'; +import { + deriveStartMinimizedFromArgs, + parseExecPathFromDesktopFile, + shouldReapplyAppImage, +} from './autoLaunch'; + +describe('deriveStartMinimizedFromArgs', () => { + it('returns true when --hidden is present', () => { + expect(deriveStartMinimizedFromArgs(['--hidden'])).toBe(true); + expect(deriveStartMinimizedFromArgs(['--foo', '--hidden', '--bar'])).toBe(true); + }); + it('returns false when --hidden is absent', () => { + expect(deriveStartMinimizedFromArgs([])).toBe(false); + expect(deriveStartMinimizedFromArgs(['--other'])).toBe(false); + }); + it('handles undefined safely', () => { + expect(deriveStartMinimizedFromArgs(undefined)).toBe(false); + }); +}); + +describe('parseExecPathFromDesktopFile', () => { + it('extracts an unquoted path', () => { + const content = [ + '[Desktop Entry]', + 'Type=Application', + 'Name=Backspace', + 'Exec=/opt/Backspace/backspace --hidden', + 'X-GNOME-Autostart-enabled=true', + ].join('\n'); + expect(parseExecPathFromDesktopFile(content)).toBe('/opt/Backspace/backspace'); + }); + + it('extracts a double-quoted path (handles spaces in path)', () => { + const content = 'Exec="/home/user/Apps/Backspace 1.0.AppImage" --hidden\n'; + expect(parseExecPathFromDesktopFile(content)).toBe('/home/user/Apps/Backspace 1.0.AppImage'); + }); + + it('returns null when no Exec= line is present', () => { + expect(parseExecPathFromDesktopFile('[Desktop Entry]\nName=X\n')).toBeNull(); + }); + + it('ignores commented Exec lines', () => { + expect(parseExecPathFromDesktopFile('#Exec=/wrong\nExec=/right\n')).toBe('/right'); + }); + + it('returns null on empty input', () => { + expect(parseExecPathFromDesktopFile('')).toBeNull(); + }); +}); + +describe('shouldReapplyAppImage', () => { + it('returns true when AppImage path differs from recorded path', () => { + expect(shouldReapplyAppImage('/home/u/Backspace-2.0.AppImage', '/home/u/Backspace-1.0.AppImage')).toBe(true); + }); + it('returns false when paths match', () => { + expect(shouldReapplyAppImage('/home/u/Backspace-2.0.AppImage', '/home/u/Backspace-2.0.AppImage')).toBe(false); + }); + it('returns false when no recorded path exists — treat missing autostart entry as user-disabled', () => { + // A missing .desktop file is a user signal (they disabled via their DE's startup manager + // or removed it manually), not a stale-path-needs-refresh signal. The OS-authoritative + // architecture must treat this as "OS state changed, do not override". + expect(shouldReapplyAppImage('/home/u/Backspace.AppImage', null)).toBe(false); + }); + it('returns false when there is no current AppImage env (not in AppImage runtime)', () => { + expect(shouldReapplyAppImage(null, '/anything')).toBe(false); + expect(shouldReapplyAppImage(null, null)).toBe(false); + }); +}); diff --git a/packages/desktop/src/autoLaunch.ts b/packages/desktop/src/autoLaunch.ts new file mode 100644 index 00000000..808cfcab --- /dev/null +++ b/packages/desktop/src/autoLaunch.ts @@ -0,0 +1,51 @@ +export function deriveStartMinimizedFromArgs(args: string[] | undefined): boolean { + if (!args) return false; + return args.includes('--hidden'); +} + +/** + * Extracts the executable path from a freedesktop .desktop file's Exec= line. + * Handles double-quoted paths (which may contain spaces). Ignores commented + * lines (leading #). Returns null if no Exec= line is present or parseable. + * + * The .desktop spec also defines field codes (%f, %u, etc.) and backslash + * escapes; we don't need to honour them here because we only ever compare the + * leading executable token, never re-execute it. + */ +export function parseExecPathFromDesktopFile(content: string): string | null { + const lines = content.split(/\r?\n/); + for (const raw of lines) { + const line = raw.trim(); + if (!line || line.startsWith('#')) continue; + if (!line.startsWith('Exec=')) continue; + const value = line.slice('Exec='.length).trimStart(); + if (!value) continue; + if (value.startsWith('"')) { + const end = value.indexOf('"', 1); + if (end === -1) return null; + return value.slice(1, end); + } + const sp = value.indexOf(' '); + return sp === -1 ? value : value.slice(0, sp); + } + return null; +} + +/** + * Returns true iff the existing autostart entry's recorded executable path differs + * from the current AppImage path (i.e., the AppImage moved and the entry is stale). + * + * A missing recorded path (recordedExecPath === null, i.e. the .desktop file does + * not exist) is intentionally treated as "do nothing" — it represents the user + * disabling autostart via their desktop environment's startup manager or removing + * the file manually. Re-creating it would override OS-level user intent, which is + * exactly the bug class this whole refactor exists to fix. + */ +export function shouldReapplyAppImage( + currentAppImagePath: string | null, + recordedExecPath: string | null, +): boolean { + if (!currentAppImagePath) return false; + if (!recordedExecPath) return false; + return currentAppImagePath !== recordedExecPath; +}