summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--py_modules/lsfg_vk/wrapper_service.py11
-rw-r--r--src/hooks/useGameConfiguration.ts176
-rw-r--r--src/hooks/usePerAppWorkarounds.ts83
-rw-r--r--src/utils/steamLaunchOptions.ts95
-rw-r--r--tests/steamLaunchOptions.test.ts20
-rw-r--r--tests/test_wrapper_service.py19
6 files changed, 176 insertions, 228 deletions
diff --git a/py_modules/lsfg_vk/wrapper_service.py b/py_modules/lsfg_vk/wrapper_service.py
index dae565b..1ed39bc 100644
--- a/py_modules/lsfg_vk/wrapper_service.py
+++ b/py_modules/lsfg_vk/wrapper_service.py
@@ -119,7 +119,7 @@ class WrapperService(BaseService):
}
if type(entry["command_token_added"]) is not bool:
raise ValueError("command_token_added must be a boolean")
- if "shortcut_exe" in raw and raw["shortcut_exe"] is not None:
+ if entry["transport"]["kind"] == "flatpak" and "shortcut_exe" in raw and raw["shortcut_exe"] is not None:
shortcut_exe = raw["shortcut_exe"]
if (
not isinstance(shortcut_exe, str)
@@ -475,10 +475,11 @@ class WrapperService(BaseService):
"command_token_added": bool(command_token_added),
"transport": selected_transport,
}
- if shortcut_exe is not None:
- entry = self._validate_entry({**entry, "shortcut_exe": shortcut_exe})
- elif previous_entry and "shortcut_exe" in previous_entry:
- entry["shortcut_exe"] = previous_entry["shortcut_exe"]
+ if selected_transport["kind"] == "flatpak":
+ if shortcut_exe is not None:
+ entry = self._validate_entry({**entry, "shortcut_exe": shortcut_exe})
+ elif previous_entry and "shortcut_exe" in previous_entry:
+ entry["shortcut_exe"] = previous_entry["shortcut_exe"]
document["apps"][normalized] = entry
self._write_pair(document)
return self._response(document, normalized)
diff --git a/src/hooks/useGameConfiguration.ts b/src/hooks/useGameConfiguration.ts
index e120426..3260019 100644
--- a/src/hooks/useGameConfiguration.ts
+++ b/src/hooks/useGameConfiguration.ts
@@ -3,7 +3,7 @@ import { useQuickAccessVisible } from "@decky/api";
import { Router } from "@decky/ui";
import { ensureFlatpakSupport, getGameConfigs, getInstalledGames, getWorkaroundState, removeWorkaroundState, repairFlatpakSupport, resetGameConfig, resetAllGameConfigs, setWorkaroundState, updateGameConfig, type GameConfigEntry, type GlobalConfig, type InstalledGame, type WorkaroundState } from "../api/lsfgApi";
import { ConfigurationData, getDefaults } from "../config/configSchema";
-import { cleanupLegacySteamLaunchOptions, getDefaultWrapperPath, hasWrapperLaunchIntegration, installWrapperIntegration, isLegacyWrapperToken, readSteamLaunchOptions, removeWrapperIntegration } from "../utils/steamLaunchOptions";
+import { getDefaultWrapperPath, installWrapperIntegration, removeWrapperIntegration } from "../utils/steamLaunchOptions";
import { showErrorToast } from "../utils/toastUtils";
export interface GameTarget extends InstalledGame { configured: boolean; }
@@ -40,18 +40,6 @@ function mergeInstalledGames(backendGames: InstalledGame[], shortcutGames: Insta
return Array.from(games.values());
}
-function selectShortcutExecutable(
- target: GameTarget,
- ...candidates: Array<string | null | undefined>
-): string | undefined {
- const absolute = candidates
- .map((candidate) => candidate?.trim())
- .find((candidate) => candidate && candidate.startsWith("/"));
- if (absolute) return absolute;
- if (target.transport.kind === "flatpak") return "/usr/bin/flatpak";
- return candidates.map((candidate) => candidate?.trim()).find(Boolean);
-}
-
const DEFAULT_WORKAROUND_STATE: WorkaroundState = {
dxvkFrameRate: 0,
disableGamescopeWsi: true,
@@ -144,110 +132,59 @@ export function useGameConfiguration() {
const ensureTargetWorkarounds = useCallback(async (target: GameTarget): Promise<boolean> => {
if (!installedGames.some((game) => game.appid === target.appid)) return true;
const appId = Number(target.appid);
+ let integration: Awaited<ReturnType<typeof installWrapperIntegration>> | null = null;
+ let newState = false;
+ let stateWriteAttempted = false;
+ let wrapperPath = getDefaultWrapperPath();
try {
const existing = await getWorkaroundState(target.appid);
if (!existing.success) throw new Error(existing.error || "Could not read workaround state");
- const current = await readSteamLaunchOptions(appId, target.nonSteam);
- const wrapperPath = existing.wrapper_path || getDefaultWrapperPath();
- const oldState = existing.state;
- const oldShortcutExe = existing.shortcut_exe || undefined;
- const oldCommandTokenAdded = existing.command_token_added === true;
- const oldTransport = existing.transport || target.transport;
- 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 (usesShortcutTarget && oldState && current.target !== wrapperPath && current.target !== oldShortcutExe) {
- throw new Error("Shortcut Target changed externally; refusing to replace it");
- }
- 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 = usesShortcutTarget
- ? selectShortcutExecutable(
- target,
- oldShortcutExe,
- target.executable,
- current.target,
- )
- : undefined;
- const initialIntegration = usesShortcutTarget
- ? current.target === wrapperPath
- : hasWrapperLaunchIntegration(current.options, wrapperPath);
- const initialStateResult = await setWorkaroundState(
+ wrapperPath = existing.wrapper_path || getDefaultWrapperPath();
+ const state = existing.state || { ...DEFAULT_WORKAROUND_STATE };
+ const commandTokenAdded = existing.command_token_added === true;
+ newState = !existing.state;
+ integration = await installWrapperIntegration(
+ appId,
+ target.nonSteam,
+ wrapperPath,
+ commandTokenAdded,
+ target.transport,
+ target.transport.kind === "flatpak" ? existing.shortcut_exe || undefined : undefined,
+ );
+ stateWriteAttempted = true;
+ const saved = await setWorkaroundState(
target.appid,
state,
- originalExecutable || null,
- oldCommandTokenAdded,
+ integration.originalExecutable ?? null,
+ integration.commandTokenAdded,
target.transport,
);
- if (!initialStateResult.success) throw new Error(initialStateResult.error || "Could not create workaround state");
-
- let integration: Awaited<ReturnType<typeof installWrapperIntegration>> | null = null;
- try {
- integration = await installWrapperIntegration(
- appId,
- target.nonSteam,
- wrapperPath,
- oldCommandTokenAdded,
- target.transport.kind,
- );
- const finalStateResult = await setWorkaroundState(
- target.appid,
- state,
- usesShortcutTarget
- ? (selectShortcutExecutable(
- target,
- integration.originalExecutable,
- originalExecutable,
- target.executable,
- ) || null)
- : null,
- integration.commandTokenAdded,
- target.transport,
- );
- if (!finalStateResult.success) throw new Error(finalStateResult.error || "Could not finalize workaround state");
- return true;
- } catch (error) {
- let rollbackSucceeded = true;
- if (!initialIntegration && integration) {
- try {
- await removeWrapperIntegration(
- appId,
- target.nonSteam,
- wrapperPath,
- usesShortcutTarget
- ? (selectShortcutExecutable(
- target,
- integration?.originalExecutable,
- originalExecutable,
- target.executable,
- ) || undefined)
- : undefined,
- integration?.commandTokenAdded ?? oldCommandTokenAdded,
- target.transport.kind,
- );
- } catch (rollbackError) {
- showErrorToast("Workaround rollback failed", asError(rollbackError).message);
- rollbackSucceeded = false;
- }
+ if (!saved.success) throw new Error(saved.error || "Could not save workaround state");
+ return true;
+ } catch (error) {
+ let rollbackSucceeded = true;
+ if (integration?.changed) {
+ try {
+ await removeWrapperIntegration(
+ appId,
+ target.nonSteam,
+ wrapperPath,
+ integration.originalExecutable,
+ integration.commandTokenAdded,
+ target.transport,
+ );
+ } catch (rollbackError) {
+ showErrorToast("Workaround rollback failed", asError(rollbackError).message);
+ rollbackSucceeded = false;
}
- if (rollbackSucceeded) {
- const restored = oldState
- ? await setWorkaroundState(
- target.appid,
- oldState,
- oldShortcutExe || null,
- oldCommandTokenAdded,
- oldTransport,
- )
- : await removeWorkaroundState(target.appid);
- if (!restored.success) throw new Error(restored.error || "Could not roll back workaround state");
+ }
+ if (rollbackSucceeded && newState && stateWriteAttempted) {
+ const restored = await removeWorkaroundState(target.appid);
+ if (!restored.success) {
+ showErrorToast("Workaround rollback failed", restored.error || "Could not roll back workaround state");
+ rollbackSucceeded = false;
}
- throw error;
}
- } catch (error) {
showErrorToast("Could not initialize workarounds", asError(error).message);
return false;
}
@@ -260,23 +197,14 @@ 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,
- target.nonSteam,
- wrapperPath,
- existing.shortcut_exe || undefined,
- existing.command_token_added === true,
- target.transport.kind,
- );
- } else {
- const current = await readSteamLaunchOptions(appId, target.nonSteam);
- 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);
- }
+ await removeWrapperIntegration(
+ appId,
+ target.nonSteam,
+ wrapperPath,
+ target.transport.kind === "flatpak" ? existing.shortcut_exe || undefined : undefined,
+ existing.command_token_added === true,
+ target.transport,
+ );
const removed = await removeWorkaroundState(target.appid);
if (!removed.success) throw new Error(removed.error || "Could not remove workaround state");
return true;
diff --git a/src/hooks/usePerAppWorkarounds.ts b/src/hooks/usePerAppWorkarounds.ts
index ebb12e2..29e1181 100644
--- a/src/hooks/usePerAppWorkarounds.ts
+++ b/src/hooks/usePerAppWorkarounds.ts
@@ -7,10 +7,10 @@ import {
type WorkaroundState,
} from "../api/lsfgApi";
import {
+ assertKnownShortcutTarget,
getDefaultWrapperPath,
- hasWrapperLaunchIntegration,
installWrapperIntegration,
- isLegacyWrapperToken,
+ isWrapperIntegrationInstalled,
readSteamLaunchOptions,
removeWrapperIntegration,
subscribeSteamLaunchOptions,
@@ -61,33 +61,6 @@ function asError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}
-function selectShortcutExecutable(
- transport: TargetTransport,
- ...candidates: Array<string | null | undefined>
-): string | undefined {
- const absolute = candidates
- .map((candidate) => candidate?.trim())
- .find((candidate) => candidate && candidate.startsWith("/"));
- if (absolute) return absolute;
- if (transport.kind === "flatpak") return "/usr/bin/flatpak";
- 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 usesShortcutTarget(nonSteam, transport)
- ? steam.target === wrapperPath
- : hasWrapperLaunchIntegration(steam.options, wrapperPath);
-}
-
function makeSnapshot(
steam: SteamLaunchOptionsSnapshot,
result: Awaited<ReturnType<typeof getWorkaroundState>>,
@@ -97,17 +70,16 @@ function makeSnapshot(
if (!result.state) throw new Error("Workaround state is not initialized for this profile");
const wrapperPath = result.wrapper_path || getDefaultWrapperPath();
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");
- }
+ const shortcutExe = selectedTransport.kind === "flatpak" ? result.shortcut_exe : undefined;
+ assertKnownShortcutTarget(steam, nonSteam, selectedTransport, wrapperPath, shortcutExe);
return {
steam,
state: result.state,
wrapperPath,
wrapperOwned: result.wrapper_owned === true,
- integrationInstalled: integrationIsInstalled(steam, nonSteam, selectedTransport, wrapperPath),
+ integrationInstalled: isWrapperIntegrationInstalled(steam, nonSteam, selectedTransport, wrapperPath),
commandTokenAdded: result.command_token_added === true,
- shortcutExe: result.shortcut_exe,
+ shortcutExe,
transport: selectedTransport,
};
}
@@ -116,24 +88,8 @@ async function adoptWorkaroundState(
appId: string,
nonSteam: boolean,
transport: TargetTransport,
- steam: SteamLaunchOptionsSnapshot,
wrapperPath: string,
): Promise<WorkaroundSnapshot> {
- 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 = shortcutTarget
- ? selectShortcutExecutable(transport, steam.target)
- : null;
- const initial = await setWorkaroundState(
- appId,
- DEFAULT_WORKAROUND_STATE,
- originalExecutable,
- false,
- transport,
- );
- if (!initial.success) throw new Error(initial.error || "Could not create workaround state");
let integration: Awaited<ReturnType<typeof installWrapperIntegration>> | null = null;
try {
integration = await installWrapperIntegration(
@@ -141,14 +97,12 @@ async function adoptWorkaroundState(
nonSteam,
wrapperPath,
false,
- transport.kind,
+ transport,
);
const finalized = await setWorkaroundState(
appId,
DEFAULT_WORKAROUND_STATE,
- shortcutTarget
- ? (selectShortcutExecutable(transport, integration.originalExecutable, originalExecutable) || null)
- : null,
+ integration.originalExecutable ?? null,
integration.commandTokenAdded,
transport,
);
@@ -156,17 +110,15 @@ async function adoptWorkaroundState(
return makeSnapshot(integration.snapshot, finalized, nonSteam, transport);
} catch (error) {
let rollbackSucceeded = true;
- if (integration) {
+ if (integration?.changed) {
try {
await removeWrapperIntegration(
Number(appId),
nonSteam,
wrapperPath,
- shortcutTarget
- ? (selectShortcutExecutable(transport, integration?.originalExecutable, originalExecutable) || undefined)
- : undefined,
- integration?.commandTokenAdded ?? false,
- transport.kind,
+ integration.originalExecutable,
+ integration.commandTokenAdded,
+ transport,
);
} catch {
// Leave the owned integration in place rather than guessing at cleanup.
@@ -203,7 +155,6 @@ export function usePerAppWorkarounds(
appId,
nonSteam,
transport,
- steam,
result.wrapper_path || getDefaultWrapperPath(),
);
}
@@ -243,7 +194,7 @@ export function usePerAppWorkarounds(
setSnapshot((current) => current ? {
...current,
steam,
- integrationInstalled: integrationIsInstalled(steam, nonSteam, current.transport, current.wrapperPath),
+ integrationInstalled: isWrapperIntegrationInstalled(steam, nonSteam, current.transport, current.wrapperPath),
} : current);
},
(subscriptionError) => {
@@ -278,22 +229,24 @@ export function usePerAppWorkarounds(
setError(null);
const nextState = { ...current.state, [field]: value } as WorkaroundState;
try {
+ const shortcutExe = current.transport.kind === "flatpak" ? current.shortcutExe ?? null : null;
const result = await setWorkaroundState(
appId,
nextState,
- current.shortcutExe ?? null,
+ shortcutExe,
current.commandTokenAdded,
current.transport,
);
if (!result.success || !result.state) throw new Error(result.error || "Could not save workaround state");
+ const selectedTransport = result.transport || current.transport;
applySnapshot({
...current,
state: result.state,
wrapperPath: result.wrapper_path || current.wrapperPath,
wrapperOwned: result.wrapper_owned === true,
- shortcutExe: result.shortcut_exe,
+ shortcutExe: selectedTransport.kind === "flatpak" ? result.shortcut_exe : undefined,
commandTokenAdded: result.command_token_added === true,
- transport: result.transport || current.transport,
+ transport: selectedTransport,
});
return true;
} catch (updateError) {
diff --git a/src/utils/steamLaunchOptions.ts b/src/utils/steamLaunchOptions.ts
index 65541d8..959ce9e 100644
--- a/src/utils/steamLaunchOptions.ts
+++ b/src/utils/steamLaunchOptions.ts
@@ -1,3 +1,5 @@
+import type { TargetTransport } from "../api/lsfgApi";
+
const DEFAULT_WRAPPER_PATH = "~/.lsfg";
const COMMAND_TOKEN = "%command%";
@@ -31,6 +33,7 @@ export interface WrapperIntegrationResult {
snapshot: SteamLaunchOptionsSnapshot;
originalExecutable?: string;
commandTokenAdded: boolean;
+ changed: boolean;
}
function asError(error: unknown): Error {
@@ -159,6 +162,13 @@ const commandIndex = (tokens: readonly LaunchToken[]) => tokens.findIndex((token
const isAssignment = (token: LaunchToken) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(token.value);
const isLegacyToken = (value: string) => LEGACY_WRAPPER_TOKENS.has(value) || LEGACY_ABSOLUTE_WRAPPER.test(value);
const isWrapperToken = (value: string, wrapperPath: string) => decodeToken(value) === wrapperPath || isLegacyWrapperToken(value);
+const usesShortcutTarget = (nonSteam: boolean, transport: TargetTransport) => nonSteam && transport.kind === "flatpak";
+
+function selectFlatpakExecutable(transport: TargetTransport, candidate?: string | null): string | undefined {
+ if (transport.kind !== "flatpak") return undefined;
+ const value = candidate?.trim();
+ return value ? (value.startsWith("/") ? value : "/usr/bin/flatpak") : undefined;
+}
export const normalizeLaunchOptions = (options: string) => serialize(tokenize(options));
export const isLegacyWrapperToken = (value: string) => isLegacyToken(decodeToken(value));
@@ -172,10 +182,10 @@ function removeMatchingWrappers(tokens: LaunchToken[], predicate: (value: string
return true;
}
-export function installWrapperLaunchOption(
+function installLaunchOption(
options: string,
wrapperPath = DEFAULT_WRAPPER_PATH,
- allowCommandArgs = false,
+ shortcutLaunchOptions = false,
) {
const tokens = tokenize(options);
removeMatchingWrappers(tokens, isLegacyToken);
@@ -189,7 +199,7 @@ export function installWrapperLaunchOption(
}
let insertion = 0;
while (insertion < tokens.length && isAssignment(tokens[insertion])) insertion++;
- if (!allowCommandArgs && insertion < tokens.length && !tokens[insertion].value.startsWith("-")) {
+ if (!shortcutLaunchOptions && 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,
@@ -199,6 +209,10 @@ export function installWrapperLaunchOption(
return { options: serialize(tokens), commandTokenAdded: true };
}
+export function installWrapperLaunchOption(options: string, wrapperPath = DEFAULT_WRAPPER_PATH) {
+ return installLaunchOption(options, wrapperPath);
+}
+
export function removeWrapperLaunchOption(
options: string,
wrapperPath = DEFAULT_WRAPPER_PATH,
@@ -249,6 +263,29 @@ export function hasWrapperLaunchIntegration(options: string, wrapperPath = DEFAU
return command > 0 && tokens[command - 1].value === wrapperPath;
}
+export function isWrapperIntegrationInstalled(
+ steam: SteamLaunchOptionsSnapshot,
+ nonSteam: boolean,
+ transport: TargetTransport,
+ wrapperPath = DEFAULT_WRAPPER_PATH,
+): boolean {
+ return usesShortcutTarget(nonSteam, transport)
+ ? steam.target === wrapperPath
+ : hasWrapperLaunchIntegration(steam.options, wrapperPath);
+}
+
+export function assertKnownShortcutTarget(
+ steam: SteamLaunchOptionsSnapshot,
+ nonSteam: boolean,
+ transport: TargetTransport,
+ wrapperPath: string,
+ originalExecutable?: string | null,
+): void {
+ if (usesShortcutTarget(nonSteam, transport) && steam.target === wrapperPath && !originalExecutable) {
+ throw new Error("Managed shortcut Target has no saved original executable");
+ }
+}
+
const queues = new Map<string, Promise<unknown>>();
function queued<T>(appId: number, nonSteam: boolean, operation: () => Promise<T>): Promise<T> {
const key = `${nonSteam ? "shortcut" : "app"}:${appId}`;
@@ -340,43 +377,52 @@ export function installWrapperIntegration(
nonSteam: boolean,
wrapperPath: string,
commandTokenAdded = false,
- transport: "host" | "flatpak" = "host",
+ transport: TargetTransport = { kind: "host" },
+ originalExecutable?: string,
): Promise<WrapperIntegrationResult> {
return queued(appId, nonSteam, async () => {
let current = await readSteamLaunchOptions(appId, nonSteam);
- if (nonSteam && transport === "flatpak") {
+ if (usesShortcutTarget(nonSteam, transport)) {
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");
}
const cleaned = cleanupPluginLaunchOptions(current.options, wrapperPath);
- if (cleaned !== current.options) {
+ const launchOptionsChanged = cleaned !== current.options;
+ if (launchOptionsChanged) {
current = await writeVerified(
appId, true, current.options, cleaned,
(value) => writeOptions(appId, true, value), readOptions,
"Steam did not accept shortcut launch options",
);
}
- if (current.target === wrapperPath) return { snapshot: current, commandTokenAdded: false };
- const originalExecutable = current.target;
+ const savedOriginal = selectFlatpakExecutable(transport, originalExecutable);
+ if (current.target === wrapperPath) {
+ if (!savedOriginal) throw new Error("Managed shortcut Target has no saved original executable");
+ return { snapshot: current, originalExecutable: savedOriginal, commandTokenAdded: false, changed: launchOptionsChanged };
+ }
+ if (savedOriginal && selectFlatpakExecutable(transport, current.target) !== savedOriginal) {
+ throw new Error("Shortcut Target changed externally; refusing to replace it");
+ }
+ const currentOriginal = selectFlatpakExecutable(transport, current.target);
const value = await writeVerified(
- appId, true, originalExecutable, wrapperPath,
+ appId, true, current.target, wrapperPath,
(target) => writeTarget(appId, target), readTarget,
"Steam did not accept the shortcut Target",
);
- return { snapshot: value, originalExecutable, commandTokenAdded: false };
+ return { snapshot: value, originalExecutable: currentOriginal, commandTokenAdded: false, changed: true };
}
const cleaned = cleanupPluginAssignments(cleanupLegacyLaunchOptions(current.options));
const alreadyInstalled = hasWrapperLaunchIntegration(current.options, wrapperPath);
- const rewrite = installWrapperLaunchOption(cleaned, wrapperPath, nonSteam);
- if (rewrite.options === current.options) return { snapshot: current, commandTokenAdded };
+ const rewrite = installLaunchOption(cleaned, wrapperPath, nonSteam);
+ if (rewrite.options === current.options) return { snapshot: current, commandTokenAdded, changed: false };
const value = await writeVerified(
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 };
+ return { snapshot: value, commandTokenAdded: alreadyInstalled ? commandTokenAdded : rewrite.commandTokenAdded, changed: true };
});
}
@@ -386,17 +432,11 @@ export function removeWrapperIntegration(
wrapperPath: string,
originalExecutable?: string,
commandTokenAdded = false,
- transport: "host" | "flatpak" = "host",
+ transport: TargetTransport = { kind: "host" },
): Promise<SteamLaunchOptionsSnapshot> {
return queued(appId, nonSteam, async () => {
let current = await readSteamLaunchOptions(appId, nonSteam);
- if (nonSteam && transport === "flatpak") {
- if (!originalExecutable || isWrapperToken(originalExecutable, wrapperPath)) {
- throw new Error("Original shortcut Target is unavailable; refusing to overwrite the current Target");
- }
- if (current.target !== wrapperPath && current.target !== originalExecutable) {
- throw new Error("Shortcut Target changed externally; refusing to restore it");
- }
+ if (usesShortcutTarget(nonSteam, transport)) {
const cleaned = cleanupPluginLaunchOptions(current.options, wrapperPath);
if (cleaned !== current.options) {
current = await writeVerified(
@@ -405,7 +445,18 @@ export function removeWrapperIntegration(
"Steam did not clean shortcut launch options",
);
}
- if (current.target === originalExecutable) return current;
+ if (current.target !== wrapperPath) {
+ if (isWrapperToken(current.target, wrapperPath)) {
+ throw new Error("Original shortcut Target is unavailable; refusing to overwrite the current Target");
+ }
+ if (originalExecutable && selectFlatpakExecutable(transport, current.target) !== selectFlatpakExecutable(transport, originalExecutable)) {
+ throw new Error("Shortcut Target changed externally; refusing to restore it");
+ }
+ return current;
+ }
+ if (!originalExecutable || isWrapperToken(originalExecutable, wrapperPath)) {
+ throw new Error("Original shortcut Target is unavailable; refusing to overwrite the current Target");
+ }
return writeVerified(
appId, true, wrapperPath, originalExecutable,
(target) => writeTarget(appId, target), readTarget,
diff --git a/tests/steamLaunchOptions.test.ts b/tests/steamLaunchOptions.test.ts
index 215e3f7..456d9d0 100644
--- a/tests/steamLaunchOptions.test.ts
+++ b/tests/steamLaunchOptions.test.ts
@@ -37,10 +37,6 @@ 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/);
});
@@ -126,11 +122,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, false, "flatpak");
+ const shortcut = await installWrapperIntegration(43, true, wrapper, false, { kind: "flatpak", flatpakAppId: "com.example.Game" });
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, false, "flatpak");
+ const restored = await removeWrapperIntegration(43, true, wrapper, shortcut.originalExecutable, false, { kind: "flatpak", flatpakAppId: "com.example.Game" });
assert.equal(restored.target, "/usr/bin/example-game");
assert.deepEqual(targetWrites, [wrapper, "/usr/bin/example-game"]);
assert.equal(shortcutWrites.length, 0);
@@ -172,18 +168,18 @@ test("uses shortcut launch options for a host shortcut without changing its Targ
(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");
+ const installed = await installWrapperIntegration(44, true, wrapper, false, { kind: "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");
+ const secondInstall = await installWrapperIntegration(44, true, wrapper, false, { kind: "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");
+ const restored = await removeWrapperIntegration(44, true, wrapper, undefined, installed.commandTokenAdded, { kind: "host" });
assert.equal(restored.target, "env");
assert.equal(restored.options, originalOptions);
assert.deepEqual(targetWrites, []);
@@ -208,8 +204,8 @@ test("fails closed when shortcut Target ownership or setters are unavailable", a
},
};
try {
- 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/);
+ await assert.rejects(installWrapperIntegration(99, true, wrapper, false, { kind: "flatpak", flatpakAppId: "com.example.Game" }), /Target API is unavailable/);
+ await assert.rejects(removeWrapperIntegration(99, true, wrapper, "/usr/bin/original", false, { kind: "flatpak", flatpakAppId: "com.example.Game" }), /Target changed externally/);
} finally {
if (previousWindow === undefined) delete (globalThis as Record<string, unknown>).window;
else (globalThis as Record<string, unknown>).window = previousWindow;
@@ -250,7 +246,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, false, "flatpak"), /simulated Target write failure/);
+ await assert.rejects(installWrapperIntegration(43, true, wrapper, false, { kind: "flatpak", flatpakAppId: "com.example.Game" }), /simulated Target write failure/);
assert.equal(shortcutTarget, "/usr/bin/original");
assert.deepEqual(targetWrites, [wrapper, "/usr/bin/original"]);
} finally {
diff --git a/tests/test_wrapper_service.py b/tests/test_wrapper_service.py
index 5010d23..0632d93 100644
--- a/tests/test_wrapper_service.py
+++ b/tests/test_wrapper_service.py
@@ -170,6 +170,25 @@ class WrapperServiceTests(unittest.TestCase):
self.assertIn("ARG:com.example.Game", args)
self.assertIn("ARG:--windowed", args)
+ def test_host_transport_does_not_store_shortcut_target(self):
+ self.service.set(
+ "123",
+ self._state(),
+ "/usr/bin/flatpak",
+ False,
+ {"kind": "flatpak", "flatpakAppId": "com.example.Game"},
+ )
+ response = self.service.set(
+ "123",
+ self._state(),
+ "/usr/bin/ignored",
+ False,
+ {"kind": "host"},
+ )
+ self.assertTrue(response["success"])
+ self.assertIsNone(response["shortcut_exe"])
+ self.assertIsNone(self.service.get("123")["shortcut_exe"])
+
def test_flatpak_transport_rejects_non_run_invocation(self):
fake_flatpak = self.home / ".local/bin/flatpak"
fake_flatpak.parent.mkdir(parents=True, exist_ok=True)