diff options
| -rw-r--r-- | py_modules/lsfg_vk/configuration.py | 26 | ||||
| -rw-r--r-- | py_modules/lsfg_vk/installation.py | 31 | ||||
| -rw-r--r-- | py_modules/lsfg_vk/plugin.py | 38 | ||||
| -rw-r--r-- | py_modules/lsfg_vk/wrapper_service.py | 74 | ||||
| -rw-r--r-- | src/api/lsfgApi.ts | 19 | ||||
| -rw-r--r-- | src/components/ConfigurationSection.tsx | 5 | ||||
| -rw-r--r-- | src/components/ConfigurationTab.tsx | 15 | ||||
| -rw-r--r-- | src/components/Content.tsx | 13 | ||||
| -rw-r--r-- | src/components/SetupTab.tsx | 82 | ||||
| -rw-r--r-- | src/hooks/useGameConfiguration.ts | 46 | ||||
| -rw-r--r-- | src/hooks/useLsfgHooks.ts | 9 | ||||
| -rw-r--r-- | src/hooks/usePerAppWorkarounds.ts | 2 | ||||
| -rw-r--r-- | tests/test_configuration_profiles.py | 19 | ||||
| -rw-r--r-- | tests/test_installation_cleanup.py | 63 | ||||
| -rw-r--r-- | tests/test_plugin_migration.py | 8 | ||||
| -rw-r--r-- | tests/test_wrapper_service.py | 29 |
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() |
