summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorxXJSONDeruloXx <danielhimebauch@gmail.com>2026-09-10 11:51:22 -0400
committerxXJSONDeruloXx <danielhimebauch@gmail.com>2026-09-10 11:51:22 -0400
commitb197e25b45d53c6c7175a45dd0b14642f2aab198 (patch)
tree102b2a04d963f3dc192bc126233d124f34ee634b
parent209ab92cc3baeb15ce808fc53ce9041a761d8df4 (diff)
downloaddecky-lsfg-vk-b197e25b45d53c6c7175a45dd0b14642f2aab198.tar.gz
decky-lsfg-vk-b197e25b45d53c6c7175a45dd0b14642f2aab198.zip
fixes for appimage and flatpak
-rw-r--r--py_modules/lsfg_vk/flatpak_service.py88
-rw-r--r--src/components/SetupTab.tsx128
-rw-r--r--src/hooks/useGameConfiguration.ts39
-rw-r--r--src/hooks/usePerAppWorkarounds.ts35
-rw-r--r--src/utils/steamLaunchOptions.ts24
-rw-r--r--tests/steamLaunchOptions.test.ts62
-rw-r--r--tests/test_flatpak_service.py174
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()