summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--py_modules/lsfg_vk/configuration.py26
-rw-r--r--py_modules/lsfg_vk/installation.py31
-rw-r--r--py_modules/lsfg_vk/plugin.py38
-rw-r--r--py_modules/lsfg_vk/wrapper_service.py74
-rw-r--r--src/api/lsfgApi.ts19
-rw-r--r--src/components/ConfigurationSection.tsx5
-rw-r--r--src/components/ConfigurationTab.tsx15
-rw-r--r--src/components/Content.tsx13
-rw-r--r--src/components/SetupTab.tsx82
-rw-r--r--src/hooks/useGameConfiguration.ts46
-rw-r--r--src/hooks/useLsfgHooks.ts9
-rw-r--r--src/hooks/usePerAppWorkarounds.ts2
-rw-r--r--tests/test_configuration_profiles.py19
-rw-r--r--tests/test_installation_cleanup.py63
-rw-r--r--tests/test_plugin_migration.py8
-rw-r--r--tests/test_wrapper_service.py29
16 files changed, 406 insertions, 73 deletions
diff --git a/py_modules/lsfg_vk/configuration.py b/py_modules/lsfg_vk/configuration.py
index 17dcaf3..d1852a0 100644
--- a/py_modules/lsfg_vk/configuration.py
+++ b/py_modules/lsfg_vk/configuration.py
@@ -72,20 +72,30 @@ class ConfigurationService(BaseService):
self.log.error(f"Error reading game configs: {error}")
return self._error_response(dict, str(error), games=[])
+ def update_global_config(self, config: Dict[str, Any]) -> Dict[str, Any]:
+ try:
+ data = self._get_profile_data()
+ merged_config = {**data["global_config"], **config}
+ validated = self._public_config(merged_config)
+ data["global_config"] = {
+ "dll": validated["dll"],
+ "no_fp16": validated["no_fp16"],
+ }
+ self._save_profile_data(data)
+ return self._success_response(dict, global_config=dict(data["global_config"]))
+ except Exception as error:
+ return self._error_response(dict, str(error), global_config=None)
+
def update_game_config(self, appid: str, game_name: str, config: Dict[str, Any]) -> Dict[str, Any]:
try:
data = self._get_profile_data()
old_name, _ = self._profile_for_appid(data, appid)
name = self._profile_name(data, appid, game_name)
- merged_config = {**data["global_config"], **config}
+ merged_config = {**data["global_config"], **{key: value for key, value in config.items() if key != "no_fp16"}}
if not config.get("dll"):
merged_config["dll"] = data["global_config"].get("dll", "")
validated = self._public_config(merged_config)
validated["active_in"] = [str(appid)]
- data["global_config"] = {
- "dll": validated["dll"],
- "no_fp16": validated["no_fp16"],
- }
if old_name and old_name != name:
data["profiles"].pop(old_name, None)
data["profiles"][name] = validated
@@ -114,15 +124,11 @@ class ConfigurationService(BaseService):
try:
data = self._get_profile_data()
name = self.flatpak_profile_name(app_id)
- merged_config = {**data["global_config"], **config}
+ merged_config = {**data["global_config"], **{key: value for key, value in config.items() if key != "no_fp16"}}
if not config.get("dll"):
merged_config["dll"] = data["global_config"].get("dll", "")
validated = self._public_config(merged_config)
validated["active_in"] = []
- data["global_config"] = {
- "dll": validated["dll"],
- "no_fp16": validated["no_fp16"],
- }
data["profiles"][name] = validated
self._save_profile_data(data)
return self._success_response(dict, app_id=str(app_id), profile=name, exists=True, config=validated)
diff --git a/py_modules/lsfg_vk/installation.py b/py_modules/lsfg_vk/installation.py
index a73706c..e7366ee 100644
--- a/py_modules/lsfg_vk/installation.py
+++ b/py_modules/lsfg_vk/installation.py
@@ -162,6 +162,25 @@ class InstallationService(BaseService):
for path in (self.legacy_lib_file, self.legacy_json_file):
self._remove_if_exists(path)
+ def _prune_empty_directories(self) -> None:
+ # Only prune directories created by this plugin. Never remove a
+ # non-empty directory because the user's other tools may use it.
+ candidates = (
+ self.config_dir,
+ self.local_bin_dir,
+ self.local_lib_dir,
+ self.local_share_dir,
+ self.user_home / LOCAL_SHARE / "applications",
+ self.user_home / LOCAL_SHARE / "icons/hicolor/256x256/apps",
+ )
+ for directory in candidates:
+ try:
+ if directory.is_dir() and not directory.is_symlink():
+ directory.rmdir()
+ self.log.info(f"Removed empty directory {directory}")
+ except OSError:
+ continue
+
def check_installation(self) -> InstallationCheckResponse:
try:
installation_error = None
@@ -200,9 +219,12 @@ class InstallationService(BaseService):
self.user_home / LOCAL_SHARE / "icons/hicolor/256x256/apps" / UI_ICON_FILENAME,
self.legacy_lib_file,
self.legacy_json_file,
+ self.legacy_script_path,
+ self.config_file_path,
)
if self._remove_if_exists(path)
]
+ self._prune_empty_directories()
if not removed:
return self._success_response(
UninstallationResponse,
@@ -221,9 +243,14 @@ class InstallationService(BaseService):
removed_files=None,
)
- def cleanup_on_uninstall(self) -> None:
+ def cleanup_on_uninstall(self) -> bool:
try:
- self.uninstall()
+ result = self.uninstall()
+ if not result.get("success"):
+ self.log.error(f"Error cleaning up lsfg-vk files during uninstall: {result.get('error')}")
+ return False
+ return True
except Exception as error:
self.log.error(f"Error cleaning up lsfg-vk files during uninstall: {error}")
self.log.error(traceback.format_exc())
+ return False
diff --git a/py_modules/lsfg_vk/plugin.py b/py_modules/lsfg_vk/plugin.py
index d20fabf..918c3f8 100644
--- a/py_modules/lsfg_vk/plugin.py
+++ b/py_modules/lsfg_vk/plugin.py
@@ -34,21 +34,35 @@ class Plugin:
async def check_lsfg_vk_installed(self):
return self.installation_service.check_installation()
- async def uninstall_lsfg_vk(self):
+ def _cleanup_runtime_state(self):
flatpak = self.flatpak_service.remove_plugin_owned_environment()
if not flatpak.get("success"):
+ return flatpak.get("error") or "Could not clean up Flatpak support"
+ profiles = self.configuration_service.reset_all_flatpak_configs()
+ if not profiles.get("success"):
+ return profiles.get("error") or "Could not remove Flatpak profiles"
+ wrapper = self.wrapper_service.purge()
+ if not wrapper.get("success"):
+ return wrapper.get("error") or "Could not remove workaround state"
+ return None
+
+ async def uninstall_lsfg_vk(self):
+ error = self._cleanup_runtime_state()
+ if error:
return {
"success": False,
"message": "",
- "error": flatpak.get("error") or "Could not clean up Flatpak support",
+ "error": error,
"removed_files": None,
}
- self.configuration_service.reset_all_flatpak_configs()
return self.installation_service.uninstall()
async def get_game_configs(self):
return self.configuration_service.get_game_configs()
+ async def update_global_config(self, config: Dict[str, Any]):
+ return self.configuration_service.update_global_config(config)
+
async def get_installed_games(self):
return self.steam_service.get_installed_games()
@@ -64,13 +78,17 @@ class Plugin:
async def get_workaround_state(self, appid: str):
return self.wrapper_service.get(appid)
+ async def get_workaround_apps(self):
+ return self.wrapper_service.list_apps()
+
async def set_workaround_state(
self,
appid: str,
state: Dict[str, Any],
command_token_added: bool = False,
+ non_steam: bool = False,
):
- return self.wrapper_service.set(appid, state, command_token_added)
+ return self.wrapper_service.set(appid, state, command_token_added, non_steam)
async def remove_workaround_state(self, appid: str):
return self.wrapper_service.remove(appid)
@@ -172,13 +190,13 @@ class Plugin:
async def _uninstall(self):
decky.logger.info("decky-lsfg-vk plugin being uninstalled")
try:
- result = self.flatpak_service.remove_plugin_owned_environment()
- if result.get("success"):
- self.configuration_service.reset_all_flatpak_configs()
- else:
- decky.logger.warning(result.get("error"))
+ error = self._cleanup_runtime_state()
+ if error:
+ decky.logger.warning(f"Preserving lsfg-vk files because uninstall cleanup failed: {error}")
+ return
except Exception as error:
- decky.logger.error(f"Error during Flatpak cleanup: {error}")
+ decky.logger.error(f"Error during lsfg-vk cleanup: {error}")
+ return
self.installation_service.cleanup_on_uninstall()
decky.logger.info("decky-lsfg-vk plugin uninstall cleanup completed")
diff --git a/py_modules/lsfg_vk/wrapper_service.py b/py_modules/lsfg_vk/wrapper_service.py
index ebe9526..906e55b 100644
--- a/py_modules/lsfg_vk/wrapper_service.py
+++ b/py_modules/lsfg_vk/wrapper_service.py
@@ -87,9 +87,12 @@ class WrapperService(BaseService):
entry = {
"state": cls._validate_state(raw.get("state")),
"command_token_added": raw.get("command_token_added", False),
+ "non_steam": raw.get("non_steam", False),
}
if type(entry["command_token_added"]) is not bool:
raise ValueError("command_token_added must be a boolean")
+ if type(entry["non_steam"]) is not bool:
+ raise ValueError("non_steam must be a boolean")
return entry
@classmethod
@@ -263,6 +266,7 @@ class WrapperService(BaseService):
"wrapper_path": self.WRAPPER_TOKEN,
"wrapper_owned": self._wrapper_marker() if document["apps"] else False,
"command_token_added": entry.get("command_token_added", False) if entry else False,
+ "non_steam": entry.get("non_steam", False) if entry else False,
}
def get(self, appid: str) -> Dict[str, Any]:
@@ -281,6 +285,7 @@ class WrapperService(BaseService):
"state": None,
"wrapper_path": self.WRAPPER_TOKEN,
"wrapper_owned": False,
+ "non_steam": False,
}
def set(
@@ -288,18 +293,22 @@ class WrapperService(BaseService):
appid: str,
state: Dict[str, Any],
command_token_added: bool = False,
+ non_steam: bool = False,
) -> Dict[str, Any]:
try:
normalized = self._valid_appid(appid)
validated_state = self._validate_state(state)
if type(command_token_added) is not bool:
raise ValueError("command_token_added must be a boolean")
+ if type(non_steam) is not bool:
+ raise ValueError("non_steam must be a boolean")
with self._lock:
self._assert_wrapper_owned_or_absent()
document, _, _ = self._read_document()
document["apps"][normalized] = {
"state": validated_state,
"command_token_added": command_token_added,
+ "non_steam": non_steam,
}
self._write_pair(document)
return self._response(document, normalized)
@@ -312,6 +321,7 @@ class WrapperService(BaseService):
"state": None,
"wrapper_path": self.WRAPPER_TOKEN,
"wrapper_owned": False,
+ "non_steam": False,
}
def remove(self, appid: str) -> Dict[str, Any]:
@@ -334,6 +344,7 @@ class WrapperService(BaseService):
"state": None,
"wrapper_path": self.WRAPPER_TOKEN,
"wrapper_owned": False,
+ "non_steam": False,
}
def repair(self) -> Dict[str, Any]:
@@ -353,3 +364,66 @@ class WrapperService(BaseService):
"wrapper_path": self.WRAPPER_TOKEN,
"wrapper_owned": False,
}
+
+ def list_apps(self) -> Dict[str, Any]:
+ try:
+ with self._lock:
+ document, _, _ = self._read_document()
+ self._assert_wrapper_owned_or_absent()
+ apps = [
+ {
+ "appid": appid,
+ "non_steam": entry.get("non_steam", False),
+ "command_token_added": entry.get("command_token_added", False),
+ }
+ for appid, entry in document["apps"].items()
+ ]
+ return {
+ "success": True,
+ "message": "",
+ "error": None,
+ "apps": apps,
+ "wrapper_path": self.WRAPPER_TOKEN,
+ }
+ except Exception as error:
+ return {
+ "success": False,
+ "message": "",
+ "error": str(error),
+ "apps": [],
+ "wrapper_path": self.WRAPPER_TOKEN,
+ }
+
+ def purge(self) -> Dict[str, Any]:
+ """Remove the plugin-owned wrapper and its sidecar during uninstall.
+
+ This is deliberately separate from ``remove``: normal profile removal
+ leaves a safe passthrough wrapper for the remaining profiles, while an
+ uninstall should remove the wrapper entirely. Both files are
+ validated before anything is removed so a user's replacement wrapper
+ or damaged state is left untouched.
+ """
+ removed = []
+ try:
+ with self._lock:
+ _document, sidecar_exists, _ = self._read_document()
+ wrapper_owned = self._assert_wrapper_owned_or_absent()
+ if wrapper_owned:
+ self.wrapper_path.unlink()
+ removed.append(str(self.wrapper_path))
+ if sidecar_exists:
+ self.sidecar_path.unlink()
+ removed.append(str(self.sidecar_path))
+ return {
+ "success": True,
+ "message": "Removed lsfg-vk workaround state",
+ "error": None,
+ "removed_files": removed,
+ }
+ except Exception as error:
+ return {
+ "success": False,
+ "message": "",
+ "error": str(error),
+ "removed_files": removed or None,
+ }
diff --git a/src/api/lsfgApi.ts b/src/api/lsfgApi.ts
index f3b7fa1..e050f62 100644
--- a/src/api/lsfgApi.ts
+++ b/src/api/lsfgApi.ts
@@ -64,6 +64,18 @@ export interface WorkaroundStateResult extends ApiResult {
wrapper_path?: string;
wrapper_owned?: boolean;
command_token_added?: boolean;
+ non_steam?: boolean;
+}
+
+export interface WorkaroundApp {
+ appid: string;
+ non_steam: boolean;
+ command_token_added: boolean;
+}
+
+export interface WorkaroundAppsResult extends ApiResult {
+ apps?: WorkaroundApp[];
+ wrapper_path?: string;
}
export interface GameConfigsResult extends ApiResult {
@@ -71,6 +83,10 @@ export interface GameConfigsResult extends ApiResult {
games?: GameConfigEntry[];
}
+export interface GlobalConfigResult extends ApiResult {
+ global_config?: GlobalConfig;
+}
+
export interface GameConfigResult extends ApiResult {
appid?: string;
exists?: boolean;
@@ -149,11 +165,14 @@ export const getInstalledGames = callable<[], InstalledGamesResult>("get_install
export const updateGameConfig = callable<[string, string, LsfgConfig], GameConfigResult>("update_game_config");
export const resetGameConfig = callable<[string], GameConfigResult>("reset_game_config");
export const resetAllGameConfigs = callable<[], GameConfigsResult>("reset_all_game_configs");
+export const updateGlobalConfig = callable<[GlobalConfig], GlobalConfigResult>("update_global_config");
export const getWorkaroundState = callable<[string], WorkaroundStateResult>("get_workaround_state");
export const setWorkaroundState = callable<[
string,
WorkaroundState,
boolean,
+ boolean,
], WorkaroundStateResult>("set_workaround_state");
export const removeWorkaroundState = callable<[string], WorkaroundStateResult>("remove_workaround_state");
+export const getWorkaroundApps = callable<[], WorkaroundAppsResult>("get_workaround_apps");
export const getDebugFileContents = callable<[], DebugFileContentsResult>("get_debug_file_contents");
diff --git a/src/components/ConfigurationSection.tsx b/src/components/ConfigurationSection.tsx
index 1f17264..6996bcd 100644
--- a/src/components/ConfigurationSection.tsx
+++ b/src/components/ConfigurationSection.tsx
@@ -1,6 +1,6 @@
import { PanelSectionRow, ToggleField, SliderField } from "@decky/ui";
import { ConfigurationData } from "../config/configSchema";
-import { FLOW_SCALE, PERFORMANCE_MODE, OVERRIDE_PRESENT_MODE, PRESERVE_SWAPCHAIN_IMAGE_COUNT, NO_FP16 } from "../config/configSchema";
+import { FLOW_SCALE, PERFORMANCE_MODE, OVERRIDE_PRESENT_MODE, PRESERVE_SWAPCHAIN_IMAGE_COUNT } from "../config/configSchema";
interface ConfigurationSectionProps {
config: ConfigurationData;
@@ -13,9 +13,6 @@ export function ConfigurationSection({ config, onConfigChange }: ConfigurationSe
<SliderField label={`Flow Scale (${Math.round(config.flow_scale * 100)}%)`} value={config.flow_scale} min={0.25} max={1} step={0.01} onChange={(value) => onConfigChange(FLOW_SCALE, value)} />
</PanelSectionRow>
<PanelSectionRow>
- <ToggleField label="FP16 Acceleration" checked={!config.no_fp16} onChange={(value) => onConfigChange(NO_FP16, !value)} />
- </PanelSectionRow>
- <PanelSectionRow>
<ToggleField label="Performance Mode" checked={config.performance_mode} onChange={(value) => onConfigChange(PERFORMANCE_MODE, value)} />
</PanelSectionRow>
<PanelSectionRow>
diff --git a/src/components/ConfigurationTab.tsx b/src/components/ConfigurationTab.tsx
index 12f36a5..37a7867 100644
--- a/src/components/ConfigurationTab.tsx
+++ b/src/components/ConfigurationTab.tsx
@@ -1,4 +1,4 @@
-import { ButtonItem, ConfirmModal, DialogButton, Focusable, PanelSection, PanelSectionRow, ToggleField, gamepadDialogClasses, showModal } from "@decky/ui";
+import { ButtonItem, ConfirmModal, DialogButton, Focusable, PanelSection, PanelSectionRow, gamepadDialogClasses, showModal } from "@decky/ui";
import { useCallback, useEffect, useRef, useState } from "react";
import { FaArrowLeft } from "react-icons/fa";
import { ConfigurationData } from "../config/configSchema";
@@ -11,8 +11,6 @@ interface ConfigurationTabProps {
config: ConfigurationData;
targets: GameTarget[];
runningGame: GameTarget | null;
- showDebugTab: boolean;
- onShowDebugTabChange: (value: boolean) => void;
onSelect: (appid: string) => void;
onConfigChange: (fieldName: keyof ConfigurationData, value: boolean | number | string | string[]) => Promise<void>;
onEnable: (appid: string) => Promise<boolean>;
@@ -26,8 +24,6 @@ export function ConfigurationTab({
config,
targets,
runningGame,
- showDebugTab,
- onShowDebugTabChange,
onSelect,
onConfigChange,
onEnable,
@@ -84,15 +80,6 @@ export function ConfigurationTab({
onConfiguredToggleFocused={clearConfiguredToggleFocusRequest}
/>
</PanelSection>
- <PanelSection title="Settings">
- <PanelSectionRow>
- <ToggleField
- label="Show debug tab"
- checked={showDebugTab}
- onChange={onShowDebugTabChange}
- />
- </PanelSectionRow>
- </PanelSection>
</>
);
}
diff --git a/src/components/Content.tsx b/src/components/Content.tsx
index 32e9bf8..91775df 100644
--- a/src/components/Content.tsx
+++ b/src/components/Content.tsx
@@ -47,16 +47,19 @@ export function Content() {
const {
config,
runningConfig,
+ globalConfig,
targets,
runningGame,
setSelectedAppId,
save,
saveFor,
+ updateGlobal,
enable,
enableAll,
repair,
resetSelected,
resetAll,
+ cleanupAllWorkarounds,
reload,
} = useGameConfiguration();
const {
@@ -69,7 +72,7 @@ export function Content() {
isUninstalling,
install,
uninstall,
- } = useInstallation(reload);
+ } = useInstallation(reload, cleanupAllWorkarounds);
const setupComplete =
isInstalled &&
losslessScalingInstalled &&
@@ -78,7 +81,7 @@ export function Content() {
!steamBranchStatus.needs_switch;
const flatpak = useFlatpakConfiguration(setupComplete);
const [tab, setTab] = useState("Setup");
- const [showDebugTab, setShowDebugTab] = usePersistentBoolean(DEBUG_TAB_VISIBILITY_KEY, true);
+ const [showDebugTab, setShowDebugTab] = usePersistentBoolean(DEBUG_TAB_VISIBILITY_KEY, false);
const [contentFocused, setContentFocused] = useState(false);
const previousRunningWorkload = useRef<string | null>(null);
const runningFlatpak = flatpak.runningApp;
@@ -136,6 +139,10 @@ export function Content() {
steamBranchStatus={steamBranchStatus}
isInstalling={isInstalling}
isUninstalling={isUninstalling}
+ globalConfig={globalConfig}
+ showDebugTab={showDebugTab}
+ onGlobalConfigChange={updateGlobal}
+ onShowDebugTabChange={setShowDebugTab}
onInstall={() => void install()}
onUninstall={() => void uninstall()}
/>
@@ -172,8 +179,6 @@ export function Content() {
config={config}
targets={targets}
runningGame={runningGame}
- showDebugTab={showDebugTab}
- onShowDebugTabChange={setShowDebugTab}
onSelect={setSelectedAppId}
onConfigChange={(field, value) => handleConfigChange(field, value, true)}
onEnable={enable}
diff --git a/src/components/SetupTab.tsx b/src/components/SetupTab.tsx
index d769200..ff072cb 100644
--- a/src/components/SetupTab.tsx
+++ b/src/components/SetupTab.tsx
@@ -1,5 +1,5 @@
-import { ButtonItem, Field, PanelSection, PanelSectionRow } from "@decky/ui";
-import { type SteamBranchStatus } from "../api/lsfgApi";
+import { ButtonItem, Field, PanelSection, PanelSectionRow, ToggleField } from "@decky/ui";
+import { type GlobalConfig, type SteamBranchStatus } from "../api/lsfgApi";
import t from "../i18n/i18n";
interface SetupTabProps {
@@ -10,6 +10,10 @@ interface SetupTabProps {
steamBranchStatus: SteamBranchStatus | null;
isInstalling: boolean;
isUninstalling: boolean;
+ globalConfig: GlobalConfig;
+ showDebugTab: boolean;
+ onGlobalConfigChange: (config: GlobalConfig) => Promise<boolean>;
+ onShowDebugTabChange: (value: boolean) => void;
onInstall: () => void;
onUninstall: () => void;
}
@@ -23,6 +27,10 @@ export function SetupTab(props: SetupTabProps) {
steamBranchStatus,
isInstalling,
isUninstalling,
+ globalConfig,
+ showDebugTab,
+ onGlobalConfigChange,
+ onShowDebugTabChange,
onInstall,
onUninstall,
} = props;
@@ -36,33 +44,57 @@ export function SetupTab(props: SetupTabProps) {
: t("INSTALL_INSTALL_BTN", "Install LSFG-VK");
return (
- <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 && (
+ <>
+ <PanelSection title="Setup">
<PanelSectionRow>
<Field
- label="Steam branch"
- description={`${steamBranchStatus.current_branch || "public"}${steamBranchStatus.needs_switch ? ` - ${steamBranchStatus.message}` : ""}`}
+ label="Lossless Scaling"
+ description={losslessScalingAppInstalled ? "Installed" : losslessScalingStatus || "Not installed"}
/>
</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>
+ {isInstalled && (
+ <>
+ <PanelSection title="Global settings">
+ <PanelSectionRow>
+ <ToggleField
+ label="FP16 Acceleration"
+ checked={!globalConfig.no_fp16}
+ onChange={(value) => void onGlobalConfigChange({ ...globalConfig, no_fp16: !value })}
+ />
+ </PanelSectionRow>
+ </PanelSection>
+ <PanelSection title="Advanced">
+ <PanelSectionRow>
+ <ToggleField
+ label="Show config file tab"
+ checked={showDebugTab}
+ onChange={onShowDebugTabChange}
+ />
+ </PanelSectionRow>
+ </PanelSection>
+ </>
)}
- <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 2e39f5e..fd8dfb1 100644
--- a/src/hooks/useGameConfiguration.ts
+++ b/src/hooks/useGameConfiguration.ts
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuickAccessVisible } from "@decky/api";
import { Router } from "@decky/ui";
-import { getGameConfigs, getInstalledGames, getWorkaroundState, removeWorkaroundState, resetGameConfig, resetAllGameConfigs, setWorkaroundState, updateGameConfig, type GameConfigEntry, type GlobalConfig, type InstalledGame, type WorkaroundState } from "../api/lsfgApi";
+import { getGameConfigs, getInstalledGames, getWorkaroundApps, getWorkaroundState, removeWorkaroundState, resetGameConfig, resetAllGameConfigs, setWorkaroundState, updateGameConfig, updateGlobalConfig as saveGlobalConfig, type GameConfigEntry, type GlobalConfig, type InstalledGame, type WorkaroundState } from "../api/lsfgApi";
import { ConfigurationData, getDefaults } from "../config/configSchema";
import { getDefaultWrapperPath, installWrapperIntegration, removeWrapperIntegration } from "../utils/steamLaunchOptions";
import { showErrorToast } from "../utils/toastUtils";
@@ -153,6 +153,7 @@ export function useGameConfiguration() {
target.appid,
state,
integration.commandTokenAdded,
+ target.nonSteam,
);
if (!saved.success) throw new Error(saved.error || "Could not save workaround state");
return true;
@@ -205,6 +206,40 @@ export function useGameConfiguration() {
}
}, [installedGames]);
+ const cleanupAllWorkarounds = useCallback(async (): Promise<boolean> => {
+ try {
+ const result = await getWorkaroundApps();
+ if (!result.success) throw new Error(result.error || "Could not read workaround state");
+ const targetsByAppId = new Map(targets.map((target) => [target.appid, target]));
+ const cleaned = new Set<string>();
+ const wrapperPath = result.wrapper_path || getDefaultWrapperPath();
+
+ for (const entry of result.apps || []) {
+ const target = targetsByAppId.get(entry.appid);
+ const nonSteam = target?.nonSteam ?? entry.non_steam;
+ await removeWrapperIntegration(
+ Number(entry.appid),
+ nonSteam,
+ wrapperPath,
+ entry.command_token_added,
+ );
+ const removed = await removeWorkaroundState(entry.appid);
+ if (!removed.success) throw new Error(removed.error || "Could not remove workaround state");
+ cleaned.add(entry.appid);
+ }
+
+ // Also clean configured targets whose sidecar entry was lost. This
+ // removes an old wrapper and only the plugin-managed launch pieces.
+ for (const target of targets.filter((item) => item.configured && installedGames.some((game) => game.appid === item.appid))) {
+ if (!cleaned.has(target.appid) && !(await removeTargetWorkarounds(target))) return false;
+ }
+ return true;
+ } catch (error) {
+ showErrorToast("Could not clean up game launch options", asError(error).message);
+ return false;
+ }
+ }, [installedGames, removeTargetWorkarounds, targets]);
+
const saveFor = useCallback(async (appid: string, next: ConfigurationData, cleanupLaunchOptions = false) => {
const target = targets.find((item) => item.appid === appid);
if (!target?.name) return false;
@@ -222,6 +257,13 @@ export function useGameConfiguration() {
[saveFor, selectedAppId],
);
+ const updateGlobal = useCallback(async (next: GlobalConfig): Promise<boolean> => {
+ const result = await saveGlobalConfig(next);
+ if (!result.success) return false;
+ setGlobalConfig(result.global_config || next);
+ return true;
+ }, []);
+
const enable = useCallback(async (appid: string) => {
const target = targets.find((item) => item.appid === appid);
if (!target?.name) return false;
@@ -283,5 +325,5 @@ export function useGameConfiguration() {
}
}, [load, removeTargetWorkarounds, targets]);
- return { config, runningConfig, games, targets, runningGame, selectedAppId, setSelectedAppId, save, saveFor, enable, enableAll, repair, resetSelected, resetAll, reload: load };
+ return { config, runningConfig, globalConfig, targets, runningGame, selectedAppId, setSelectedAppId, save, saveFor, updateGlobal, enable, enableAll, repair, resetSelected, resetAll, cleanupAllWorkarounds, reload: load };
}
diff --git a/src/hooks/useLsfgHooks.ts b/src/hooks/useLsfgHooks.ts
index 9beb749..0f51e90 100644
--- a/src/hooks/useLsfgHooks.ts
+++ b/src/hooks/useLsfgHooks.ts
@@ -13,7 +13,10 @@ import {
showUninstallSuccessToast,
} from "../utils/toastUtils";
-export function useInstallation(reloadConfig?: () => Promise<void>) {
+export function useInstallation(
+ reloadConfig?: () => Promise<void>,
+ beforeUninstall?: () => Promise<boolean>,
+) {
const [isInstalled, setIsInstalled] = useState(false);
const [installationStatus, setInstallationStatus] = useState("");
const [losslessScalingInstalled, setLosslessScalingInstalled] = useState(false);
@@ -77,6 +80,10 @@ export function useInstallation(reloadConfig?: () => Promise<void>) {
setIsUninstalling(true);
setInstallationStatus("Uninstalling lsfg-vk...");
try {
+ if (beforeUninstall && !(await beforeUninstall())) {
+ setInstallationStatus("Uninstallation cancelled: could not clean up launch options");
+ return;
+ }
const result = await uninstallLsfgVk();
if (!result.success) {
setInstallationStatus(`Uninstallation failed: ${result.error}`);
diff --git a/src/hooks/usePerAppWorkarounds.ts b/src/hooks/usePerAppWorkarounds.ts
index c2b8904..ab33bb2 100644
--- a/src/hooks/usePerAppWorkarounds.ts
+++ b/src/hooks/usePerAppWorkarounds.ts
@@ -86,6 +86,7 @@ async function adoptWorkaroundState(
appId,
DEFAULT_WORKAROUND_STATE,
integration.commandTokenAdded,
+ nonSteam,
);
if (!finalized.success) throw new Error(finalized.error || "Could not finalize workaround state");
return makeSnapshot(integration.snapshot, finalized, nonSteam);
@@ -206,6 +207,7 @@ export function usePerAppWorkarounds(appId: string, nonSteam: boolean): PerAppWo
appId,
nextState,
current.commandTokenAdded,
+ nonSteam,
);
if (!result.success || !result.state) throw new Error(result.error || "Could not save workaround state");
applySnapshot({
diff --git a/tests/test_configuration_profiles.py b/tests/test_configuration_profiles.py
index 789842e..64db0f1 100644
--- a/tests/test_configuration_profiles.py
+++ b/tests/test_configuration_profiles.py
@@ -75,6 +75,25 @@ preserve_swapchain_image_count = false
self.assertIn("Steam Game", data["profiles"])
self.assertNotIn("flatpak:org.example.Game", data["profiles"])
+ def test_global_config_update_does_not_change_profile_values(self):
+ self.service.update_game_config("123", "Steam Game", {"multiplier": 2})
+
+ result = self.service.update_global_config({"no_fp16": True})
+ data = self.service._get_profile_data()
+
+ self.assertTrue(result["success"])
+ self.assertTrue(result["global_config"]["no_fp16"])
+ self.assertTrue(data["global_config"]["no_fp16"])
+ self.assertEqual(data["profiles"]["Steam Game"]["multiplier"], 2)
+
+ def test_profile_update_cannot_overwrite_global_fp16_setting(self):
+ self.service.update_global_config({"no_fp16": True})
+
+ self.service.update_game_config("123", "Steam Game", {"multiplier": 3, "no_fp16": False})
+
+ data = self.service._get_profile_data()
+ self.assertTrue(data["global_config"]["no_fp16"])
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_installation_cleanup.py b/tests/test_installation_cleanup.py
new file mode 100644
index 0000000..3336505
--- /dev/null
+++ b/tests/test_installation_cleanup.py
@@ -0,0 +1,63 @@
+import sys
+import tempfile
+import types
+import unittest
+from pathlib import Path
+from unittest.mock import Mock
+
+
+sys.modules.setdefault(
+ "decky",
+ types.SimpleNamespace(DECKY_USER_HOME="/home/deck", logger=Mock()),
+)
+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "py_modules"))
+
+from lsfg_vk.base_service import BaseService
+from lsfg_vk.installation import InstallationService
+
+
+class InstallationCleanupTests(unittest.TestCase):
+ def test_uninstall_removes_legacy_files_and_prunes_only_empty_directories(self):
+ with tempfile.TemporaryDirectory() as temporary:
+ home = Path(temporary) / "home" / "deck"
+ service = InstallationService.__new__(InstallationService)
+ BaseService.__init__(service)
+ service.log = Mock()
+ service.user_home = home
+ service.local_bin_dir = home / ".local/bin"
+ service.local_lib_dir = home / ".local/lib"
+ service.local_share_dir = home / ".local/share/vulkan/implicit_layer.d"
+ service.config_dir = home / ".config/lsfg-vk"
+ service.config_file_path = service.config_dir / "conf.toml"
+ service.legacy_script_path = home / "lsfg"
+ service.lib_file = service.local_lib_dir / "liblsfg-vk-layer.so"
+ service.lib_x86_file = service.local_lib_dir / "liblsfg-vk-layer.x86.so"
+ service.json_file = service.local_share_dir / "VkLayer_LSFGVK_frame_generation.json"
+ service.json_x86_file = service.local_share_dir / "VkLayer_LSFGVK_frame_generation.x86.json"
+ service.cli_file = service.local_bin_dir / "lsfg-vk-cli"
+ service.legacy_lib_file = service.local_lib_dir / "liblsfg-vk.so"
+ service.legacy_json_file = service.local_share_dir / "VkLayer_LS_frame_generation.json"
+
+ for path in (
+ service.lib_file,
+ service.config_file_path,
+ service.legacy_script_path,
+ ):
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text("owned", encoding="utf-8")
+ unrelated = home / ".local/bin/keep-me"
+ unrelated.parent.mkdir(parents=True, exist_ok=True)
+ unrelated.write_text("user file", encoding="utf-8")
+
+ result = service.uninstall()
+
+ self.assertTrue(result["success"])
+ self.assertFalse(service.lib_file.exists())
+ self.assertFalse(service.config_file_path.exists())
+ self.assertFalse(service.legacy_script_path.exists())
+ self.assertTrue(unrelated.exists())
+ self.assertTrue(service.local_bin_dir.exists())
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_plugin_migration.py b/tests/test_plugin_migration.py
index 7ab4621..1a7cb29 100644
--- a/tests/test_plugin_migration.py
+++ b/tests/test_plugin_migration.py
@@ -63,12 +63,16 @@ class PluginMigrationTests(unittest.TestCase):
plugin.installation_service = Mock()
plugin.flatpak_service = Mock()
plugin.configuration_service = Mock()
+ plugin.wrapper_service = Mock()
plugin.flatpak_service.remove_plugin_owned_environment.return_value = {"success": True}
+ plugin.configuration_service.reset_all_flatpak_configs.return_value = {"success": True}
+ plugin.wrapper_service.purge.return_value = {"success": True}
asyncio.run(plugin._uninstall())
plugin.flatpak_service.remove_plugin_owned_environment.assert_called_once_with()
plugin.configuration_service.reset_all_flatpak_configs.assert_called_once_with()
+ plugin.wrapper_service.purge.assert_called_once_with()
plugin.installation_service.cleanup_on_uninstall.assert_called_once_with()
finally:
self._restore(previous_decky, previous_tomllib, previous_plugin)
@@ -80,12 +84,14 @@ class PluginMigrationTests(unittest.TestCase):
plugin.installation_service = Mock()
plugin.flatpak_service = Mock()
plugin.configuration_service = Mock()
+ plugin.wrapper_service = Mock()
plugin.flatpak_service.remove_plugin_owned_environment.return_value = {"success": False, "error": "changed"}
asyncio.run(plugin._uninstall())
plugin.configuration_service.reset_all_flatpak_configs.assert_not_called()
- plugin.installation_service.cleanup_on_uninstall.assert_called_once_with()
+ plugin.wrapper_service.purge.assert_not_called()
+ plugin.installation_service.cleanup_on_uninstall.assert_not_called()
finally:
self._restore(previous_decky, previous_tomllib, previous_plugin)
diff --git a/tests/test_wrapper_service.py b/tests/test_wrapper_service.py
index 5c291c7..20a08ff 100644
--- a/tests/test_wrapper_service.py
+++ b/tests/test_wrapper_service.py
@@ -158,6 +158,35 @@ class WrapperServiceTests(unittest.TestCase):
)
self.assertEqual(result.stdout, "ok")
+ def test_purge_removes_owned_wrapper_and_state(self):
+ self.service.set("123", self._state(), non_steam=True)
+ self.assertTrue(self.service.get("123")["non_steam"])
+ self.assertEqual(self.service.list_apps()["apps"][0]["non_steam"], True)
+ response = self.service.purge()
+ self.assertTrue(response["success"])
+ self.assertEqual(response["removed_files"], [str(self.service.wrapper_path), str(self.service.sidecar_path)])
+ self.assertFalse(self.service.wrapper_path.exists())
+ self.assertFalse(self.service.sidecar_path.exists())
+
+ def test_purge_refuses_foreign_wrapper(self):
+ self.service.wrapper_path.write_text("#!/bin/sh\necho foreign\n", encoding="utf-8")
+ response = self.service.purge()
+ self.assertFalse(response["success"])
+ self.assertIn("unowned", response["error"])
+ self.assertTrue(self.service.wrapper_path.exists())
+
+ def test_purge_refuses_invalid_state(self):
+ self.service.config_dir.mkdir(parents=True, exist_ok=True)
+ self.service.sidecar_path.write_text("not json", encoding="utf-8")
+ self.service.wrapper_path.write_text(
+ f"#!/bin/sh\n{self.service.MARKER}\nexec \"$@\"\n",
+ encoding="utf-8",
+ )
+ response = self.service.purge()
+ self.assertFalse(response["success"])
+ self.assertTrue(self.service.wrapper_path.exists())
+ self.assertTrue(self.service.sidecar_path.exists())
+
if __name__ == "__main__":
unittest.main()