diff options
| -rw-r--r-- | py_modules/lsfg_vk/flatpak_service.py | 88 | ||||
| -rw-r--r-- | src/components/SetupTab.tsx | 128 | ||||
| -rw-r--r-- | src/hooks/useGameConfiguration.ts | 39 | ||||
| -rw-r--r-- | src/hooks/usePerAppWorkarounds.ts | 35 | ||||
| -rw-r--r-- | src/utils/steamLaunchOptions.ts | 24 | ||||
| -rw-r--r-- | tests/steamLaunchOptions.test.ts | 62 | ||||
| -rw-r--r-- | tests/test_flatpak_service.py | 174 |
7 files changed, 306 insertions, 244 deletions
diff --git a/py_modules/lsfg_vk/flatpak_service.py b/py_modules/lsfg_vk/flatpak_service.py index d2241ab..f486b74 100644 --- a/py_modules/lsfg_vk/flatpak_service.py +++ b/py_modules/lsfg_vk/flatpak_service.py @@ -24,6 +24,8 @@ from .constants import ( class FlatpakService(BaseService): EXTENSION_ID = "org.freedesktop.Platform.VulkanLayer.lsfgvk" SUPPORTED_RUNTIMES = ("23.08", "24.08", "25.08") + DERIVED_RUNTIME_IDS = {"org.gnome.Platform", "org.kde.Platform"} + RUNTIME_METADATA_SECTION = "Extension org.freedesktop.Platform.GL" OWNERSHIP_FILENAME = "flatpak_extensions.json" OWNERSHIP_VERSION = 1 APP_ID_PATTERN = re.compile( @@ -94,6 +96,26 @@ class FlatpakService(BaseService): return cls._validate_runtime(parts[2]) @classmethod + def runtime_branch_from_metadata(cls, metadata: str) -> str: + section = None + versions = [] + for raw_line in metadata.splitlines() if isinstance(metadata, str) else []: + line = raw_line.strip() + if line.startswith("[") and line.endswith("]"): + section = line[1:-1].strip() + continue + if section != cls.RUNTIME_METADATA_SECTION: + continue + key, separator, value = line.partition("=") + if separator and key.strip() == "versions": + versions.extend(part.strip() for part in value.split(";")) + for value in versions: + for branch in cls.SUPPORTED_RUNTIMES: + if value == branch or value.startswith(f"{branch}-"): + return branch + raise ValueError("Could not determine a supported Freedesktop base runtime from Flatpak metadata") + + @classmethod def _extension_ref(cls, branch: str) -> str: return f"{cls.EXTENSION_ID}/x86_64/{cls._validate_runtime(branch)}" @@ -105,18 +127,22 @@ class FlatpakService(BaseService): }[self._validate_runtime(branch)] return Path(__file__).resolve().parent.parent.parent / BIN_DIR / filename - def _installed_extension_branches(self) -> Set[str]: - result = self._run_flatpak_command( - ["list", "--runtime", "--columns=application,arch,branch"], - capture_output=True, - text=True, - check=True, - ) + def _installed_extension_branches(self, scope: Optional[str] = None) -> Set[str]: + scopes = ("user", "system") if scope is None else (scope,) + if any(item not in ("user", "system") for item in scopes): + raise ValueError("Flatpak installation scope must be user or system") installed = set() - for line in result.stdout.splitlines(): - fields = line.split("\t") if "\t" in line else line.split() - if len(fields) >= 3 and fields[0] == self.EXTENSION_ID and fields[1] == "x86_64": - installed.add(fields[2]) + for item in scopes: + result = self._run_flatpak_command( + ["list", f"--{item}", "--runtime", "--columns=application,arch,branch"], + capture_output=True, + text=True, + check=True, + ) + for line in result.stdout.splitlines(): + fields = line.split("\t") if "\t" in line else line.split() + if len(fields) >= 3 and fields[0] == self.EXTENSION_ID and fields[1] == "x86_64": + installed.add(fields[2]) return installed def _owned_branches(self) -> Set[str]: @@ -188,7 +214,26 @@ class FlatpakService(BaseService): if result.returncode != 0: raise OSError(result.stderr.strip() or f"Could not inspect Flatpak app {app_id}") runtime = result.stdout.strip().splitlines()[0] if result.stdout.strip() else "" - return runtime, self.runtime_branch_from_ref(runtime) + parts = runtime.split("/") + if len(parts) != 3: + raise ValueError(f"Unsupported Flatpak runtime reference: {runtime}") + if parts[0] == "org.freedesktop.Platform": + branch = self._validate_runtime(parts[2]) + elif parts[0] in self.DERIVED_RUNTIME_IDS: + metadata_result = self._run_flatpak_command( + ["info", "--show-metadata", runtime], + capture_output=True, + text=True, + ) + if metadata_result.returncode != 0: + raise OSError( + metadata_result.stderr.strip() + or f"Could not inspect Flatpak runtime {runtime}" + ) + branch = self.runtime_branch_from_metadata(metadata_result.stdout) + else: + raise ValueError(f"Unsupported Flatpak runtime reference: {runtime}") + return runtime, branch def resolve_app_support(self, app_id: str): try: @@ -249,7 +294,7 @@ class FlatpakService(BaseService): ) if result.returncode != 0: raise OSError(result.stderr.strip() or "Flatpak installation failed") - if branch not in self._installed_extension_branches(): + if branch not in self._installed_extension_branches("user"): raise RuntimeError(f"Flatpak install completed but {self._extension_ref(branch)} was not visible afterwards") owned = self._owned_branches() owned.add(branch) @@ -259,7 +304,7 @@ class FlatpakService(BaseService): return self._error_response(dict, str(error), runtime_branch=branch, installed=False, enabled=False) def _remove_extension(self, branch: str) -> bool: - if branch not in self._installed_extension_branches(): + if branch not in self._installed_extension_branches("user"): return False result = self._run_flatpak_command( ["uninstall", "--user", "--noninteractive", self._extension_ref(branch)], @@ -268,7 +313,7 @@ class FlatpakService(BaseService): ) if result.returncode != 0: raise OSError(result.stderr.strip() or "Flatpak uninstall failed") - if branch in self._installed_extension_branches(): + if branch in self._installed_extension_branches("user"): raise RuntimeError(f"Flatpak uninstall completed but {self._extension_ref(branch)} is still installed") return True @@ -288,12 +333,15 @@ class FlatpakService(BaseService): if not self.check_flatpak_available(): raise FileNotFoundError("Flatpak is not available on this system") with self._lock: - removed = self._remove_extension(branch) owned = self._owned_branches() - if branch in owned: - owned.remove(branch) - self._write_owned_branches(owned) - return self._extension_result(branch, False, removed, "uninstalled") + if branch not in owned: + installed = branch in self._installed_extension_branches() + return self._extension_result(branch, installed, False, "preserved (not plugin-owned)") + removed = self._remove_extension(branch) + owned.remove(branch) + self._write_owned_branches(owned) + installed = branch in self._installed_extension_branches() + return self._extension_result(branch, installed, removed, "uninstalled") except Exception as error: return self._error_response( dict, diff --git a/src/components/SetupTab.tsx b/src/components/SetupTab.tsx index 624be54..d769200 100644 --- a/src/components/SetupTab.tsx +++ b/src/components/SetupTab.tsx @@ -1,13 +1,6 @@ -import { ButtonItem, Field, PanelSection, PanelSectionRow, ToggleField } from "@decky/ui"; -import { useEffect, useState } from "react"; -import { - getFlatpakSupportStatus, - setFlatpakExtensionEnabled, - type FlatpakExtensionStatus, - type SteamBranchStatus, -} from "../api/lsfgApi"; +import { ButtonItem, Field, PanelSection, PanelSectionRow } from "@decky/ui"; +import { type SteamBranchStatus } from "../api/lsfgApi"; import t from "../i18n/i18n"; -import { showErrorToast } from "../utils/toastUtils"; interface SetupTabProps { isInstalled: boolean; @@ -21,72 +14,6 @@ interface SetupTabProps { onUninstall: () => void; } -function FlatpakSupportDiagnostics() { - const [status, setStatus] = useState<FlatpakExtensionStatus | null>(null); - const [operation, setOperation] = useState<string | null>(null); - - const refresh = async () => { - try { - setStatus(await getFlatpakSupportStatus()); - } catch (error) { - setStatus({ - success: false, - message: "", - error: String(error), - available: false, - extension_id: "", - supported_branches: [], - installed_branches: [], - }); - } - }; - - useEffect(() => { - void refresh(); - }, []); - - if (!status?.available) return null; - - const setEnabled = async (branch: string, enabled: boolean) => { - setOperation(`${enabled ? "enable" : "disable"}-${branch}`); - try { - const result = await setFlatpakExtensionEnabled(branch, enabled); - if (!result.success) throw new Error(result.error || result.message || "Flatpak runtime update failed"); - await refresh(); - } catch (error) { - showErrorToast("Flatpak runtime update failed", String(error)); - } finally { - setOperation(null); - } - }; - - return ( - <PanelSection title="Flatpak runtimes"> - <PanelSectionRow> - <Field - label="LSFG-VK runtime extensions" - description={status.message || "Toggle a branch to install or uninstall it."} - /> - </PanelSectionRow> - {status.supported_branches.map((branch) => { - const installed = status.installed_branches.includes(branch); - const pending = operation?.endsWith(`-${branch}`); - return ( - <PanelSectionRow key={branch}> - <ToggleField - label={branch} - description={pending ? (operation?.startsWith("enable") ? "Installing..." : "Uninstalling...") : installed ? "Installed" : "Not installed"} - checked={installed} - onChange={(enabled) => void setEnabled(branch, enabled)} - disabled={operation !== null} - /> - </PanelSectionRow> - ); - })} - </PanelSection> - ); -} - export function SetupTab(props: SetupTabProps) { const { isInstalled, @@ -109,36 +36,33 @@ export function SetupTab(props: SetupTabProps) { : t("INSTALL_INSTALL_BTN", "Install LSFG-VK"); return ( - <> - <PanelSection title="Setup"> + <PanelSection title="Setup"> + <PanelSectionRow> + <Field + label="Lossless Scaling" + description={losslessScalingAppInstalled ? "Installed" : losslessScalingStatus || "Not installed"} + /> + </PanelSectionRow> + <PanelSectionRow> + <Field label="LSFG-VK" description={installationStatus} /> + </PanelSectionRow> + {steamBranchStatus?.installed && ( <PanelSectionRow> <Field - label="Lossless Scaling" - description={losslessScalingAppInstalled ? "Installed" : losslessScalingStatus || "Not installed"} + label="Steam branch" + description={`${steamBranchStatus.current_branch || "public"}${steamBranchStatus.needs_switch ? ` - ${steamBranchStatus.message}` : ""}`} /> </PanelSectionRow> - <PanelSectionRow> - <Field label="LSFG-VK" description={installationStatus} /> - </PanelSectionRow> - {steamBranchStatus?.installed && ( - <PanelSectionRow> - <Field - label="Steam branch" - description={`${steamBranchStatus.current_branch || "public"}${steamBranchStatus.needs_switch ? ` - ${steamBranchStatus.message}` : ""}`} - /> - </PanelSectionRow> - )} - <PanelSectionRow> - <ButtonItem - layout="below" - onClick={isInstalled ? onUninstall : onInstall} - disabled={isInstalling || isUninstalling} - > - {buttonLabel} - </ButtonItem> - </PanelSectionRow> - </PanelSection> - <FlatpakSupportDiagnostics /> - </> + )} + <PanelSectionRow> + <ButtonItem + layout="below" + onClick={isInstalled ? onUninstall : onInstall} + disabled={isInstalling || isUninstalling} + > + {buttonLabel} + </ButtonItem> + </PanelSectionRow> + </PanelSection> ); } diff --git a/src/hooks/useGameConfiguration.ts b/src/hooks/useGameConfiguration.ts index c70d589..e120426 100644 --- a/src/hooks/useGameConfiguration.ts +++ b/src/hooks/useGameConfiguration.ts @@ -48,11 +48,6 @@ function selectShortcutExecutable( .map((candidate) => candidate?.trim()) .find((candidate) => candidate && candidate.startsWith("/")); if (absolute) return absolute; - - // Steam's app-details API can report a Flatpak Target as just "flatpak" - // even when the shortcut's canonical VDF executable is /usr/bin/flatpak. - // Keep the stored original executable absolute so SetShortcutExe and the - // generated dispatcher agree on the same direct transport. if (target.transport.kind === "flatpak") return "/usr/bin/flatpak"; return candidates.map((candidate) => candidate?.trim()).find(Boolean); } @@ -158,25 +153,26 @@ export function useGameConfiguration() { const oldShortcutExe = existing.shortcut_exe || undefined; const oldCommandTokenAdded = existing.command_token_added === true; const oldTransport = existing.transport || target.transport; - if (target.nonSteam && oldState && current.target === wrapperPath && !oldShortcutExe) { + const usesShortcutTarget = target.nonSteam && target.transport.kind === "flatpak"; + if (usesShortcutTarget && oldState && current.target === wrapperPath && !oldShortcutExe) { throw new Error("Managed shortcut Target has no saved original executable"); } - if (target.nonSteam && oldState && current.target !== wrapperPath && current.target !== oldShortcutExe) { + if (usesShortcutTarget && oldState && current.target !== wrapperPath && current.target !== oldShortcutExe) { throw new Error("Shortcut Target changed externally; refusing to replace it"); } - if (target.nonSteam && !oldState && current.target === wrapperPath) { + if (usesShortcutTarget && !oldState && current.target === wrapperPath) { throw new Error("Shortcut Target is already the managed wrapper but its original Target is unknown"); } const state = oldState || { ...DEFAULT_WORKAROUND_STATE }; - const originalExecutable = target.nonSteam + const originalExecutable = usesShortcutTarget ? selectShortcutExecutable( target, oldShortcutExe, - target.transport.kind === "flatpak" ? target.executable : undefined, + target.executable, current.target, ) : undefined; - const initialIntegration = target.nonSteam + const initialIntegration = usesShortcutTarget ? current.target === wrapperPath : hasWrapperLaunchIntegration(current.options, wrapperPath); const initialStateResult = await setWorkaroundState( @@ -190,16 +186,22 @@ export function useGameConfiguration() { let integration: Awaited<ReturnType<typeof installWrapperIntegration>> | null = null; try { - integration = await installWrapperIntegration(appId, target.nonSteam, wrapperPath, oldCommandTokenAdded); + integration = await installWrapperIntegration( + appId, + target.nonSteam, + wrapperPath, + oldCommandTokenAdded, + target.transport.kind, + ); const finalStateResult = await setWorkaroundState( target.appid, state, - target.nonSteam + usesShortcutTarget ? (selectShortcutExecutable( target, integration.originalExecutable, originalExecutable, - target.transport.kind === "flatpak" ? target.executable : undefined, + target.executable, ) || null) : null, integration.commandTokenAdded, @@ -215,15 +217,16 @@ export function useGameConfiguration() { appId, target.nonSteam, wrapperPath, - target.nonSteam + usesShortcutTarget ? (selectShortcutExecutable( target, integration?.originalExecutable, originalExecutable, - target.transport.kind === "flatpak" ? target.executable : undefined, + target.executable, ) || undefined) : undefined, integration?.commandTokenAdded ?? oldCommandTokenAdded, + target.transport.kind, ); } catch (rollbackError) { showErrorToast("Workaround rollback failed", asError(rollbackError).message); @@ -257,6 +260,7 @@ export function useGameConfiguration() { const existing = await getWorkaroundState(target.appid); if (!existing.success) throw new Error(existing.error || "Could not read workaround state"); const wrapperPath = existing.wrapper_path || getDefaultWrapperPath(); + const usesShortcutTarget = target.nonSteam && target.transport.kind === "flatpak"; if (existing.state) { await removeWrapperIntegration( appId, @@ -264,10 +268,11 @@ export function useGameConfiguration() { wrapperPath, existing.shortcut_exe || undefined, existing.command_token_added === true, + target.transport.kind, ); } else { const current = await readSteamLaunchOptions(appId, target.nonSteam); - if (target.nonSteam && (current.target === wrapperPath || isLegacyWrapperToken(current.target))) { + if (usesShortcutTarget && (current.target === wrapperPath || isLegacyWrapperToken(current.target))) { throw new Error("Shortcut Target is a frame-generation wrapper but its original Target is unknown"); } await cleanupLegacySteamLaunchOptions(appId, target.nonSteam, wrapperPath); diff --git a/src/hooks/usePerAppWorkarounds.ts b/src/hooks/usePerAppWorkarounds.ts index e9e44e1..ebb12e2 100644 --- a/src/hooks/usePerAppWorkarounds.ts +++ b/src/hooks/usePerAppWorkarounds.ts @@ -73,22 +73,31 @@ function selectShortcutExecutable( return candidates.map((candidate) => candidate?.trim()).find(Boolean); } +function usesShortcutTarget(nonSteam: boolean, transport: TargetTransport): boolean { + return nonSteam && transport.kind === "flatpak"; +} + function integrationIsInstalled( steam: SteamLaunchOptionsSnapshot, nonSteam: boolean, + transport: TargetTransport, wrapperPath: string, ): boolean { - return nonSteam ? steam.target === wrapperPath : hasWrapperLaunchIntegration(steam.options, wrapperPath); + return usesShortcutTarget(nonSteam, transport) + ? steam.target === wrapperPath + : hasWrapperLaunchIntegration(steam.options, wrapperPath); } function makeSnapshot( steam: SteamLaunchOptionsSnapshot, result: Awaited<ReturnType<typeof getWorkaroundState>>, nonSteam: boolean, + transport: TargetTransport, ): WorkaroundSnapshot { if (!result.state) throw new Error("Workaround state is not initialized for this profile"); const wrapperPath = result.wrapper_path || getDefaultWrapperPath(); - if (nonSteam && steam.target === wrapperPath && !result.shortcut_exe) { + const selectedTransport = result.transport || transport; + if (usesShortcutTarget(nonSteam, selectedTransport) && steam.target === wrapperPath && !result.shortcut_exe) { throw new Error("Managed shortcut Target has no saved original executable"); } return { @@ -96,10 +105,10 @@ function makeSnapshot( state: result.state, wrapperPath, wrapperOwned: result.wrapper_owned === true, - integrationInstalled: integrationIsInstalled(steam, nonSteam, wrapperPath), + integrationInstalled: integrationIsInstalled(steam, nonSteam, selectedTransport, wrapperPath), commandTokenAdded: result.command_token_added === true, shortcutExe: result.shortcut_exe, - transport: result.transport || { kind: "host" }, + transport: selectedTransport, }; } @@ -110,10 +119,11 @@ async function adoptWorkaroundState( steam: SteamLaunchOptionsSnapshot, wrapperPath: string, ): Promise<WorkaroundSnapshot> { - if (nonSteam && (!steam.target || steam.target === wrapperPath || isLegacyWrapperToken(steam.target))) { + const shortcutTarget = usesShortcutTarget(nonSteam, transport); + if (shortcutTarget && (!steam.target || steam.target === wrapperPath || isLegacyWrapperToken(steam.target))) { throw new Error("Shortcut Target is a wrapper but its original Target is unknown"); } - const originalExecutable = nonSteam + const originalExecutable = shortcutTarget ? selectShortcutExecutable(transport, steam.target) : null; const initial = await setWorkaroundState( @@ -130,18 +140,20 @@ async function adoptWorkaroundState( Number(appId), nonSteam, wrapperPath, + false, + transport.kind, ); const finalized = await setWorkaroundState( appId, DEFAULT_WORKAROUND_STATE, - nonSteam + shortcutTarget ? (selectShortcutExecutable(transport, integration.originalExecutable, originalExecutable) || null) : null, integration.commandTokenAdded, transport, ); if (!finalized.success) throw new Error(finalized.error || "Could not finalize workaround state"); - return makeSnapshot(integration.snapshot, finalized, nonSteam); + return makeSnapshot(integration.snapshot, finalized, nonSteam, transport); } catch (error) { let rollbackSucceeded = true; if (integration) { @@ -150,10 +162,11 @@ async function adoptWorkaroundState( Number(appId), nonSteam, wrapperPath, - nonSteam + shortcutTarget ? (selectShortcutExecutable(transport, integration?.originalExecutable, originalExecutable) || undefined) : undefined, integration?.commandTokenAdded ?? false, + transport.kind, ); } catch { // Leave the owned integration in place rather than guessing at cleanup. @@ -194,7 +207,7 @@ export function usePerAppWorkarounds( result.wrapper_path || getDefaultWrapperPath(), ); } - return makeSnapshot(steam, result, nonSteam); + return makeSnapshot(steam, result, nonSteam, transport); }, [appId, nonSteam, numericAppId, transport]); const applySnapshot = useCallback((next: WorkaroundSnapshot) => { @@ -230,7 +243,7 @@ export function usePerAppWorkarounds( setSnapshot((current) => current ? { ...current, steam, - integrationInstalled: integrationIsInstalled(steam, nonSteam, current.wrapperPath), + integrationInstalled: integrationIsInstalled(steam, nonSteam, current.transport, current.wrapperPath), } : current); }, (subscriptionError) => { diff --git a/src/utils/steamLaunchOptions.ts b/src/utils/steamLaunchOptions.ts index 0af1bbf..65541d8 100644 --- a/src/utils/steamLaunchOptions.ts +++ b/src/utils/steamLaunchOptions.ts @@ -172,7 +172,11 @@ function removeMatchingWrappers(tokens: LaunchToken[], predicate: (value: string return true; } -export function installWrapperLaunchOption(options: string, wrapperPath = DEFAULT_WRAPPER_PATH) { +export function installWrapperLaunchOption( + options: string, + wrapperPath = DEFAULT_WRAPPER_PATH, + allowCommandArgs = false, +) { const tokens = tokenize(options); removeMatchingWrappers(tokens, isLegacyToken); let command = commandIndex(tokens); @@ -185,7 +189,7 @@ export function installWrapperLaunchOption(options: string, wrapperPath = DEFAUL } let insertion = 0; while (insertion < tokens.length && isAssignment(tokens[insertion])) insertion++; - if (insertion < tokens.length && !tokens[insertion].value.startsWith("-")) { + if (!allowCommandArgs && insertion < tokens.length && !tokens[insertion].value.startsWith("-")) { throw new Error("Launch options do not contain %command%; refusing to guess a launcher command"); } tokens.splice(insertion, 0, @@ -336,10 +340,11 @@ export function installWrapperIntegration( nonSteam: boolean, wrapperPath: string, commandTokenAdded = false, + transport: "host" | "flatpak" = "host", ): Promise<WrapperIntegrationResult> { return queued(appId, nonSteam, async () => { let current = await readSteamLaunchOptions(appId, nonSteam); - if (nonSteam) { + if (nonSteam && transport === "flatpak") { if (!current.target) throw new Error("Steam shortcut Target is empty; refusing to replace it"); if (current.target !== wrapperPath && isWrapperToken(current.target, wrapperPath)) { throw new Error("The shortcut Target points to a legacy frame-generation wrapper; restore it first"); @@ -364,11 +369,11 @@ export function installWrapperIntegration( const cleaned = cleanupPluginAssignments(cleanupLegacyLaunchOptions(current.options)); const alreadyInstalled = hasWrapperLaunchIntegration(current.options, wrapperPath); - const rewrite = installWrapperLaunchOption(cleaned, wrapperPath); + const rewrite = installWrapperLaunchOption(cleaned, wrapperPath, nonSteam); if (rewrite.options === current.options) return { snapshot: current, commandTokenAdded }; const value = await writeVerified( - appId, false, current.options, rewrite.options, - (options) => writeOptions(appId, false, options), readOptions, + appId, nonSteam, current.options, rewrite.options, + (options) => writeOptions(appId, nonSteam, options), readOptions, "Steam did not accept the launch options", ); return { snapshot: value, commandTokenAdded: alreadyInstalled ? commandTokenAdded : rewrite.commandTokenAdded }; @@ -381,10 +386,11 @@ export function removeWrapperIntegration( wrapperPath: string, originalExecutable?: string, commandTokenAdded = false, + transport: "host" | "flatpak" = "host", ): Promise<SteamLaunchOptionsSnapshot> { return queued(appId, nonSteam, async () => { let current = await readSteamLaunchOptions(appId, nonSteam); - if (nonSteam) { + if (nonSteam && transport === "flatpak") { if (!originalExecutable || isWrapperToken(originalExecutable, wrapperPath)) { throw new Error("Original shortcut Target is unavailable; refusing to overwrite the current Target"); } @@ -408,8 +414,8 @@ export function removeWrapperIntegration( } const next = cleanupPluginAssignments(removeWrapperLaunchOption(current.options, wrapperPath, commandTokenAdded)); return next === current.options ? current : writeVerified( - appId, false, current.options, next, - (options) => writeOptions(appId, false, options), readOptions, + appId, nonSteam, current.options, next, + (options) => writeOptions(appId, nonSteam, options), readOptions, "Steam did not clean the launch options", ); }); diff --git a/tests/steamLaunchOptions.test.ts b/tests/steamLaunchOptions.test.ts index 3170fe8..215e3f7 100644 --- a/tests/steamLaunchOptions.test.ts +++ b/tests/steamLaunchOptions.test.ts @@ -37,6 +37,10 @@ test("normalizes blank and argument-only fields while refusing ambiguous launche options: `FOO=bar ${wrapper} %command% --windowed`, commandTokenAdded: true, }); + assert.deepEqual(installWrapperLaunchOption('FOO=bar "/home/deck/game.AppImage"', wrapper, true), { + options: 'FOO=bar ~/.lsfg %command% "/home/deck/game.AppImage"', + commandTokenAdded: true, + }); assert.throws(() => installWrapperLaunchOption("gamemoderun --windowed", wrapper), /refusing to guess/); assert.throws(() => installWrapperLaunchOption('"%command%"', wrapper), /refusing to guess/); }); @@ -122,11 +126,11 @@ test("reads the matching app-details field and installs/removes Steam integratio assert.equal(appWrites.length, 1); assert.equal(shortcutWrites.length, 0); - const shortcut = await installWrapperIntegration(43, true, wrapper); + const shortcut = await installWrapperIntegration(43, true, wrapper, false, "flatpak"); assert.equal(shortcut.originalExecutable, "/usr/bin/example-game"); assert.equal(shortcut.snapshot.target, wrapper); assert.deepEqual(targetWrites, [wrapper]); - const restored = await removeWrapperIntegration(43, true, wrapper, shortcut.originalExecutable); + const restored = await removeWrapperIntegration(43, true, wrapper, shortcut.originalExecutable, false, "flatpak"); assert.equal(restored.target, "/usr/bin/example-game"); assert.deepEqual(targetWrites, [wrapper, "/usr/bin/example-game"]); assert.equal(shortcutWrites.length, 0); @@ -143,6 +147,54 @@ test("reads the matching app-details field and installs/removes Steam integratio } }); +test("uses shortcut launch options for a host shortcut without changing its Target", async () => { + const previousWindow = (globalThis as Record<string, unknown>).window; + const previousSteamClient = (globalThis as Record<string, unknown>).SteamClient; + const originalOptions = 'DESKTOPINTEGRATION=1 "/home/deck/AppImages/dusk.appimage"'; + let shortcutOptions = originalOptions; + let shortcutTarget = "env"; + const shortcutWrites: string[] = []; + const targetWrites: string[] = []; + const apps = { + RegisterForAppDetails(_appId: number, callback: (details: SteamAppDetails) => void) { + callback({ strShortcutExe: shortcutTarget, strShortcutLaunchOptions: shortcutOptions }); + return { unregister() {} }; + }, + SetShortcutLaunchOptions(_appId: number, options: string) { + shortcutWrites.push(options); + shortcutOptions = options; + }, + SetShortcutExe(_appId: number, executable: string) { + targetWrites.push(executable); + shortcutTarget = executable; + }, + }; + (globalThis as Record<string, unknown>).window = { setTimeout, clearTimeout }; + (globalThis as Record<string, unknown>).SteamClient = { Apps: apps }; + try { + const installed = await installWrapperIntegration(44, true, wrapper, false, "host"); + assert.equal(installed.originalExecutable, undefined); + assert.equal(installed.snapshot.target, "env"); + assert.equal(installed.snapshot.options, 'DESKTOPINTEGRATION=1 ~/.lsfg %command% "/home/deck/AppImages/dusk.appimage"'); + assert.deepEqual(targetWrites, []); + assert.deepEqual(shortcutWrites, [installed.snapshot.options]); + + const secondInstall = await installWrapperIntegration(44, true, wrapper, false, "host"); + assert.equal(secondInstall.snapshot.options, installed.snapshot.options); + assert.deepEqual(shortcutWrites, [installed.snapshot.options]); + + const restored = await removeWrapperIntegration(44, true, wrapper, undefined, installed.commandTokenAdded, "host"); + assert.equal(restored.target, "env"); + assert.equal(restored.options, originalOptions); + assert.deepEqual(targetWrites, []); + } finally { + if (previousWindow === undefined) delete (globalThis as Record<string, unknown>).window; + else (globalThis as Record<string, unknown>).window = previousWindow; + if (previousSteamClient === undefined) delete (globalThis as Record<string, unknown>).SteamClient; + else (globalThis as Record<string, unknown>).SteamClient = previousSteamClient; + } +}); + test("fails closed when shortcut Target ownership or setters are unavailable", async () => { const previousWindow = (globalThis as Record<string, unknown>).window; const previousSteamClient = (globalThis as Record<string, unknown>).SteamClient; @@ -156,8 +208,8 @@ test("fails closed when shortcut Target ownership or setters are unavailable", a }, }; try { - await assert.rejects(installWrapperIntegration(99, true, wrapper), /Target API is unavailable/); - await assert.rejects(removeWrapperIntegration(99, true, wrapper, "/usr/bin/original"), /Target changed externally/); + await assert.rejects(installWrapperIntegration(99, true, wrapper, false, "flatpak"), /Target API is unavailable/); + await assert.rejects(removeWrapperIntegration(99, true, wrapper, "/usr/bin/original", false, "flatpak"), /Target changed externally/); } finally { if (previousWindow === undefined) delete (globalThis as Record<string, unknown>).window; else (globalThis as Record<string, unknown>).window = previousWindow; @@ -198,7 +250,7 @@ test("restores launch options and shortcut Target when a setter fails after chan assert.equal(appOptions, "FOO=bar %command%"); assert.deepEqual(appWrites, [`FOO=bar ${wrapper} %command%`, "FOO=bar %command%"]); - await assert.rejects(installWrapperIntegration(43, true, wrapper), /simulated Target write failure/); + await assert.rejects(installWrapperIntegration(43, true, wrapper, false, "flatpak"), /simulated Target write failure/); assert.equal(shortcutTarget, "/usr/bin/original"); assert.deepEqual(targetWrites, [wrapper, "/usr/bin/original"]); } finally { diff --git a/tests/test_flatpak_service.py b/tests/test_flatpak_service.py index 70ba228..d5baf61 100644 --- a/tests/test_flatpak_service.py +++ b/tests/test_flatpak_service.py @@ -26,7 +26,12 @@ class FlatpakServiceTests(unittest.TestCase): self.service.config_dir = self.home / ".config/lsfg-vk" self.service.config_file_path = self.service.config_dir / "conf.toml" self.service.check_flatpak_available = Mock(return_value=True) - self.service._run_flatpak_command = Mock() + self.service._run_flatpak_command = Mock(side_effect=self._run_flatpak_command) + self.runtime_ref = "org.freedesktop.Platform/x86_64/24.08" + self.runtime_metadata = "" + self.user_branches = set() + self.system_branches = set() + self.install_branch = "24.08" self.bundle = self.home / "lsfg-vk-24.08.flatpak" self.bundle.write_bytes(b"bundle") self.service._bundled_extension_path = Mock(return_value=self.bundle) @@ -42,6 +47,22 @@ class FlatpakServiceTests(unittest.TestCase): def _extension_line(branch): return f"org.freedesktop.Platform.VulkanLayer.lsfgvk\tx86_64\t{branch}\n" + def _run_flatpak_command(self, args, **_kwargs): + if args[0] == "info" and args[1] == "--show-runtime": + return self._result(self.runtime_ref + "\n") + if args[0] == "info" and args[1] == "--show-metadata": + return self._result(self.runtime_metadata) + if args[0] == "list": + branches = self.user_branches if "--user" in args else self.system_branches + return self._result("".join(self._extension_line(branch) for branch in sorted(branches))) + if args[0] == "install": + self.user_branches.add(self.install_branch) + return self._result() + if args[0] == "uninstall": + self.user_branches.discard(args[-1].rsplit("/", 1)[-1]) + return self._result() + raise AssertionError(f"Unexpected Flatpak command: {args}") + def test_runtime_branch_mapping_is_strict_and_branch_specific(self): self.assertEqual( FlatpakService.runtime_branch_from_ref( @@ -62,11 +83,20 @@ class FlatpakServiceTests(unittest.TestCase): "org.freedesktop.Platform/x86_64/26.08" ) + def test_runtime_branch_mapping_reads_documented_gl_metadata(self): + metadata = """ +[Extension org.freedesktop.Platform.GL] +versions=25.08;25.08-extra;1.4 +version=1.4 +""" + self.assertEqual(FlatpakService.runtime_branch_from_metadata(metadata), "25.08") + with self.assertRaises(ValueError): + FlatpakService.runtime_branch_from_metadata( + "[Extension org.freedesktop.Platform.GL]\nversions=26.08;26.08-extra;1.4\n" + ) + def test_resolve_reads_required_runtime_instead_of_any_installed_branch(self): - self.service._run_flatpak_command.side_effect = [ - self._result("org.freedesktop.Platform/x86_64/24.08\n"), - self._result(self._extension_line("23.08")), - ] + self.user_branches = {"23.08"} response = self.service.resolve_app_support("com.example.Game") @@ -80,22 +110,48 @@ class FlatpakServiceTests(unittest.TestCase): ) self.assertEqual( self.service._run_flatpak_command.call_args_list[1].args[0], - ["list", "--runtime", "--columns=application,arch,branch"], + ["list", "--user", "--runtime", "--columns=application,arch,branch"], + ) + self.assertEqual( + self.service._run_flatpak_command.call_args_list[2].args[0], + ["list", "--system", "--runtime", "--columns=application,arch,branch"], ) - def test_install_records_only_a_new_user_owned_branch(self): - self.service._run_flatpak_command.side_effect = [ - self._result(""), - self._result(""), - self._result(self._extension_line("24.08")), - ] + def test_resolve_maps_kde_and_gnome_runtimes_from_gl_metadata(self): + metadata = "[Extension org.freedesktop.Platform.GL]\nversions=25.08;25.08-extra;1.4\n" + for runtime in ("org.kde.Platform/x86_64/6.10", "org.gnome.Platform/x86_64/49"): + with self.subTest(runtime=runtime): + self.service._run_flatpak_command.reset_mock() + self.runtime_ref = runtime + self.runtime_metadata = metadata + response = self.service.resolve_app_support("com.example.Game") + self.assertEqual(response["runtime_branch"], "25.08") + self.assertEqual(response["support_status"], "needs-runtime") + self.assertEqual( + self.service._run_flatpak_command.call_args_list[1].args[0], + ["info", "--show-metadata", runtime], + ) + + def test_system_extension_is_ready_without_installing_a_user_copy(self): + self.system_branches = {"24.08"} + + response = self.service.ensure_app_support("com.example.Game") + + self.assertTrue(response["success"]) + self.assertEqual(response["support_status"], "ready") + self.assertEqual( + [call.args[0][0] for call in self.service._run_flatpak_command.call_args_list], + ["info", "list", "list"], + ) + self.assertFalse(any(call.args[0][0] == "install" for call in self.service._run_flatpak_command.call_args_list)) + def test_install_records_only_a_new_user_owned_branch(self): response = self.service.install_extension("24.08") self.assertTrue(response["success"]) self.assertTrue(response["enabled"]) self.assertTrue(response["installed"]) - install_args = self.service._run_flatpak_command.call_args_list[1].args[0] + install_args = self.service._run_flatpak_command.call_args_list[2].args[0] self.assertEqual(install_args[:4], ["install", "--user", "--noninteractive", "--or-update"]) self.assertEqual( json.loads(self.service.ownership_path.read_text(encoding="utf-8")), @@ -103,9 +159,7 @@ class FlatpakServiceTests(unittest.TestCase): ) def test_preexisting_branch_is_not_claimed_or_removed(self): - self.service._run_flatpak_command.return_value = self._result( - self._extension_line("24.08") - ) + self.user_branches = {"24.08"} install_response = self.service.install_extension("24.08") cleanup_response = self.service.remove_plugin_owned_extensions() @@ -115,15 +169,10 @@ class FlatpakServiceTests(unittest.TestCase): self.assertTrue(install_response["installed"]) self.assertFalse(self.service.ownership_path.exists()) self.assertTrue(cleanup_response["success"]) - self.assertEqual(self.service._run_flatpak_command.call_count, 1) - - def test_extension_toggle_installs_and_uninstalls_preexisting_branch(self): - self.service._run_flatpak_command.side_effect = [ - self._result(self._extension_line("24.08")), - self._result(self._extension_line("24.08")), - self._result(""), - self._result(""), - ] + self.assertEqual(self.service._run_flatpak_command.call_count, 2) + + def test_extension_toggle_preserves_preexisting_branch(self): + self.user_branches = {"24.08"} enable_response = self.service.set_extension_enabled("24.08", True) disable_response = self.service.set_extension_enabled("24.08", False) @@ -132,36 +181,36 @@ class FlatpakServiceTests(unittest.TestCase): self.assertTrue(enable_response["enabled"]) self.assertTrue(enable_response["installed"]) self.assertTrue(disable_response["success"]) - self.assertFalse(disable_response["enabled"]) - self.assertFalse(disable_response["installed"]) - self.assertTrue(disable_response["removed"]) + self.assertTrue(disable_response["enabled"]) + self.assertTrue(disable_response["installed"]) + self.assertFalse(disable_response["removed"]) + self.assertEqual(self.user_branches, {"24.08"}) self.assertEqual( [call.args[0][0] for call in self.service._run_flatpak_command.call_args_list], - ["list", "list", "uninstall", "list"], + ["list", "list", "list", "list"], ) - def test_extension_toggle_removes_owned_branch_and_can_repeat_disable(self): + def test_extension_toggle_removes_owned_user_branch_but_preserves_system_branch(self): self.service.ownership_path.parent.mkdir(parents=True, exist_ok=True) self.service.ownership_path.write_text( json.dumps({"version": 1, "plugin_owned_branches": ["24.08"]}), encoding="utf-8", ) - self.service._run_flatpak_command.side_effect = [ - self._result(self._extension_line("24.08")), - self._result(""), - self._result(""), - self._result(""), - ] + self.user_branches = {"24.08"} + self.system_branches = {"24.08"} disable_response = self.service.set_extension_enabled("24.08", False) repeat_response = self.service.set_extension_enabled("24.08", False) self.assertTrue(disable_response["success"]) - self.assertFalse(disable_response["enabled"]) + self.assertTrue(disable_response["enabled"]) self.assertTrue(disable_response["removed"]) self.assertTrue(repeat_response["success"]) - self.assertFalse(repeat_response["enabled"]) - self.assertFalse(repeat_response["installed"]) + self.assertTrue(repeat_response["enabled"]) + self.assertTrue(repeat_response["installed"]) + self.assertEqual(self.user_branches, set()) + self.assertEqual(self.system_branches, {"24.08"}) + self.assertFalse(self.service.ownership_path.exists()) uninstall_commands = [ call.args[0] for call in self.service._run_flatpak_command.call_args_list @@ -190,23 +239,16 @@ class FlatpakServiceTests(unittest.TestCase): self.assertEqual(self.service._run_flatpak_command.call_count, 0) def test_ensure_app_support_installs_only_the_app_runtime_branch(self): - self.service._run_flatpak_command.side_effect = [ - self._result("org.freedesktop.Platform/x86_64/24.08\n"), - self._result(""), - self._result(""), - self._result(""), - self._result(""), - self._result(self._extension_line("24.08")), - self._result("org.freedesktop.Platform/x86_64/24.08\n"), - self._result(self._extension_line("24.08")), - ] - response = self.service.ensure_app_support("com.example.Game") self.assertTrue(response["success"]) self.assertEqual(response["support_status"], "ready") self.assertEqual(response["runtime_branch"], "24.08") - install_args = self.service._run_flatpak_command.call_args_list[4].args[0] + install_args = next( + call.args[0] + for call in self.service._run_flatpak_command.call_args_list + if call.args[0][0] == "install" + ) self.assertEqual(install_args[0], "install") self.assertIn("--user", install_args) self.assertNotIn("23.08", install_args) @@ -216,19 +258,6 @@ class FlatpakServiceTests(unittest.TestCase): ) def test_two_shortcuts_using_one_flatpak_share_one_extension_branch(self): - self.service._run_flatpak_command.side_effect = [ - self._result("org.freedesktop.Platform/x86_64/24.08\n"), - self._result(""), - self._result(""), - self._result(""), - self._result(""), - self._result(self._extension_line("24.08")), - self._result("org.freedesktop.Platform/x86_64/24.08\n"), - self._result(self._extension_line("24.08")), - self._result("org.freedesktop.Platform/x86_64/24.08\n"), - self._result(self._extension_line("24.08")), - ] - first = self.service.ensure_app_support("net.pcsx2.PCSX2") second = self.service.ensure_app_support("net.pcsx2.PCSX2.Dev") @@ -251,22 +280,7 @@ class FlatpakServiceTests(unittest.TestCase): json.dumps({"version": 1, "plugin_owned_branches": ["23.08", "24.08"]}), encoding="utf-8", ) - self.service._run_flatpak_command.side_effect = [ - self._result( - "\n".join( - [ - "\t".join([FlatpakService.EXTENSION_ID, "x86_64", "23.08"]), - "\t".join([FlatpakService.EXTENSION_ID, "x86_64", "24.08"]), - ] - ) - + "\n" - ), - self._result(""), - self._result("\t".join([FlatpakService.EXTENSION_ID, "x86_64", "24.08"]) + "\n"), - self._result("\t".join([FlatpakService.EXTENSION_ID, "x86_64", "24.08"]) + "\n"), - self._result(""), - self._result(""), - ] + self.user_branches = {"23.08", "24.08"} response = self.service.remove_plugin_owned_extensions() |
