diff options
| author | xXJSONDeruloXx <danielhimebauch@gmail.com> | 2026-09-08 22:18:13 -0400 |
|---|---|---|
| committer | xXJSONDeruloXx <danielhimebauch@gmail.com> | 2026-09-08 22:18:13 -0400 |
| commit | 790132668c4421c68c32bdc8fc9792b0d6028f97 (patch) | |
| tree | 28542975b8a20f3ac4a86eb5594c129fbb6ca02d | |
| parent | bec26fe025c97c00d398e7a4fb571195706b9e76 (diff) | |
| download | decky-lsfg-vk-790132668c4421c68c32bdc8fc9792b0d6028f97.tar.gz decky-lsfg-vk-790132668c4421c68c32bdc8fc9792b0d6028f97.zip | |
I really dont want to but here you go little guy
| -rw-r--r-- | py_modules/lsfg_vk/flatpak_service.py | 134 | ||||
| -rw-r--r-- | src/components/ConfigurationTab.tsx | 6 | ||||
| -rw-r--r-- | src/components/GameConfigurationSelector.tsx | 29 | ||||
| -rw-r--r-- | src/components/ProfileDetails.tsx | 4 | ||||
| -rw-r--r-- | src/components/WorkaroundsSection.tsx | 21 | ||||
| -rw-r--r-- | src/hooks/useGameConfiguration.ts | 46 | ||||
| -rw-r--r-- | src/i18n/languages.json | 12 | ||||
| -rw-r--r-- | src/utils/steamLaunchOptionParser.ts | 40 | ||||
| -rw-r--r-- | src/utils/steamLaunchOptions.ts | 15 | ||||
| -rw-r--r-- | tests/steamLaunchOptions.test.ts | 73 | ||||
| -rw-r--r-- | tests/test_flatpak_overrides.py | 158 |
11 files changed, 465 insertions, 73 deletions
diff --git a/py_modules/lsfg_vk/flatpak_service.py b/py_modules/lsfg_vk/flatpak_service.py index 6aebf11..0e89d3c 100644 --- a/py_modules/lsfg_vk/flatpak_service.py +++ b/py_modules/lsfg_vk/flatpak_service.py @@ -1,5 +1,6 @@ import os import pwd +import re import shutil import subprocess from pathlib import Path @@ -19,6 +20,10 @@ from .types import BaseResponse class FlatpakService(BaseService): EXTENSION_ID = "org.freedesktop.Platform.VulkanLayer.lsfgvk" SUPPORTED_RUNTIMES = ("23.08", "24.08", "25.08") + COMPATIBILITY_ENV = ( + ("ENABLE_GAMESCOPE_WSI", "0"), + ("DXVK_HDR", "0"), + ) def __init__(self, logger=None): super().__init__(logger) @@ -170,7 +175,9 @@ class FlatpakService(BaseService): capture_output=True, text=True, ) - return result.stdout if result.returncode == 0 else "" + if result.returncode != 0: + raise OSError(result.stderr.strip() or "Failed to read Flatpak overrides") + return result.stdout def _dll_directory(self) -> Path: if self.config_file_path.exists(): @@ -199,15 +206,99 @@ class FlatpakService(BaseService): "legacy_script": str(self.legacy_script_path), } + def _override_file_path(self, app_id: str) -> Path: + if not app_id or Path(app_id).name != app_id or app_id in {".", ".."}: + raise ValueError("Invalid Flatpak application ID") + return self.user_home / ".local/share/flatpak/overrides" / app_id + + @staticmethod + def _filesystem_entry_path(entry: str) -> str: + value = entry.strip() + if value.startswith("!"): + value = value[1:] + for suffix in (":ro", ":rw", ":create"): + if value.endswith(suffix): + return value[: -len(suffix)] + return value + + @staticmethod + def _override_value(content: str, key: str) -> str: + match = re.search(rf"(?m)^[ \t]*{re.escape(key)}[ \t]*=([^\r\n]*)", content) + return match.group(1).strip() if match else "" + + def _clean_override_file(self, app_id: str, paths: Dict[str, str]) -> bool: + path = self._override_file_path(app_id) + if not path.is_file(): + return False + + owner = path.stat().st_uid, path.stat().st_gid + original = path.read_text(encoding="utf-8") + managed_paths = { + paths[name] + for name in ("config_dir", "dll_dir", "legacy_home", "legacy_dll", "legacy_script") + } + managed_env = { + "LSFGVK_CONFIG", + "LSFG_CONFIG", + *(name for name, _ in self.COMPATIBILITY_ENV), + } + def clean_list(match): + key, value, newline = match.groups() + is_filesystem = key.split("=", 1)[0].strip() == "filesystems" + keep = [item for item in value.split(";") if item and ( + self._filesystem_entry_path(item) not in managed_paths + if is_filesystem else item.strip() not in managed_env + )] + return f"{key}{';'.join(keep)}{newline}" if keep else "" + + updated = re.sub( + r"(?m)^([ \t]*(?:filesystems|unset-environment)[ \t]*=)([^\r\n]*)(\r?\n|$)", + clean_list, + original, + ) + env_pattern = "|".join(re.escape(name) for name in managed_env) + updated = re.sub( + rf"(?m)^[ \t]*(?:{env_pattern})[ \t]*=[^\r\n]*(?:\r?\n|$)", + "", + updated, + ) + if updated != original: + self._write_file(path, updated) + if os.geteuid() == 0: + os.chown(path, *owner) + return updated != original + def _check_app_override_status(self, app_id: str) -> Dict[str, bool]: output = self._override_output(app_id) paths = self._override_paths() + filesystem_entries = self._override_value(output, "filesystems").split(";") + positive_filesystems = { + self._filesystem_entry_path(entry) + for entry in filesystem_entries + if not entry.strip().startswith("!") + } + blocked_filesystems = { + self._filesystem_entry_path(entry) + for entry in filesystem_entries + if entry.strip().startswith("!") + } + unset_environment = set( + item.strip() + for item in self._override_value(output, "unset-environment").split(";") + if item.strip() + ) return { - "filesystem": ( - paths["config_dir"] in output - and paths["dll_dir"] in output + "filesystem": all( + path in positive_filesystems and path not in blocked_filesystems + for path in (paths["config_dir"], paths["dll_dir"]) + ), + "env": all( + self._override_value(output, name) == value and name not in unset_environment + for name, value in ( + ("LSFGVK_CONFIG", paths["config_file"]), + *self.COMPATIBILITY_ENV, + ) ), - "env": f"LSFGVK_CONFIG={paths['config_file']}" in output, } def get_flatpak_apps(self) -> Dict[str, Any]: @@ -253,6 +344,7 @@ class FlatpakService(BaseService): if not self.check_flatpak_available(): raise FileNotFoundError("Flatpak is not available on this system") paths = self._override_paths() + self._clean_override_file(app_id, paths) result = self._run_flatpak_command( [ "override", @@ -260,12 +352,7 @@ class FlatpakService(BaseService): f"--filesystem={paths['config_dir']}:rw", f"--filesystem={paths['dll_dir']}:ro", f"--env=LSFGVK_CONFIG={paths['config_file']}", - # Remove permissions/env from the pre-v2 plugin when an - # existing app is explicitly migrated or reconfigured. - f"--nofilesystem={paths['legacy_home']}", - f"--nofilesystem={paths['legacy_dll']}", - f"--nofilesystem={paths['legacy_script']}", - "--unset-env=LSFG_CONFIG", + *(f"--env={name}={value}" for name, value in self.COMPATIBILITY_ENV), app_id, ], capture_output=True, @@ -273,6 +360,9 @@ class FlatpakService(BaseService): ) if result.returncode != 0: raise OSError(result.stderr.strip() or "Failed to set Flatpak overrides") + status = self._check_app_override_status(app_id) + if not status["filesystem"] or not status["env"]: + raise RuntimeError("Flatpak overrides could not be verified after setting") return self._success_response( BaseResponse, f"lsfg-vk overrides set for {app_id}", @@ -292,24 +382,10 @@ class FlatpakService(BaseService): if not self.check_flatpak_available(): raise FileNotFoundError("Flatpak is not available on this system") paths = self._override_paths() - result = self._run_flatpak_command( - [ - "override", - "--user", - f"--nofilesystem={paths['config_dir']}", - f"--nofilesystem={paths['dll_dir']}", - f"--nofilesystem={paths['legacy_home']}", - f"--nofilesystem={paths['legacy_dll']}", - f"--nofilesystem={paths['legacy_script']}", - "--unset-env=LSFGVK_CONFIG", - "--unset-env=LSFG_CONFIG", - app_id, - ], - capture_output=True, - text=True, - ) - if result.returncode != 0: - raise OSError(result.stderr.strip() or "Failed to remove Flatpak overrides") + self._clean_override_file(app_id, paths) + status = self._check_app_override_status(app_id) + if status["filesystem"] or status["env"]: + raise RuntimeError("Flatpak overrides could not be verified after removal") return self._success_response( BaseResponse, f"lsfg-vk overrides removed for {app_id}", diff --git a/src/components/ConfigurationTab.tsx b/src/components/ConfigurationTab.tsx index 2bb0f26..c41c941 100644 --- a/src/components/ConfigurationTab.tsx +++ b/src/components/ConfigurationTab.tsx @@ -33,6 +33,7 @@ export function ConfigurationTab({ const [detailAppId, setDetailAppId] = useState<string | null>(null); const [focusFpsMultiplier, setFocusFpsMultiplier] = useState(false); const [focusDetailAction, setFocusDetailAction] = useState<"enable" | "fps" | null>(null); + const [focusConfiguredToggle, setFocusConfiguredToggle] = useState(false); const enableRef = useRef<HTMLDivElement>(null); const promptedRunningAppId = useRef<string | null>(null); const closeDetails = useCallback(() => { @@ -41,6 +42,7 @@ export function ConfigurationTab({ setDetailAppId(null); }, []); const clearFpsFocusRequest = useCallback(() => setFocusFpsMultiplier(false), []); + const clearConfiguredToggleFocusRequest = useCallback(() => setFocusConfiguredToggle(false), []); useEffect(() => { if (!focusDetailAction) return; @@ -77,12 +79,15 @@ export function ConfigurationTab({ targets={targets} runningGame={runningGame} onSelect={(appid) => { + setFocusConfiguredToggle(false); setFocusDetailAction(targets.find((target) => target.appid === appid)?.configured ? "fps" : "enable"); onSelect(appid); setDetailAppId(appid); }} onEnableAll={onEnableAll} onResetAll={onResetAll} + focusConfiguredToggle={focusConfiguredToggle} + onConfiguredToggleFocused={clearConfiguredToggleFocusRequest} /> </PanelSection> ); @@ -96,6 +101,7 @@ export function ConfigurationTab({ if (selectedTarget?.configured) { promptedRunningAppId.current = detailAppId; await onReset(); + setFocusConfiguredToggle(true); closeDetails(); } else if (detailAppId && await onEnable(detailAppId)) { setFocusFpsMultiplier(true); diff --git a/src/components/GameConfigurationSelector.tsx b/src/components/GameConfigurationSelector.tsx index 5f23b91..50b2cf8 100644 --- a/src/components/GameConfigurationSelector.tsx +++ b/src/components/GameConfigurationSelector.tsx @@ -1,5 +1,5 @@ import { ButtonItem, ConfirmModal, Field, PanelSectionRow, showModal } from "@decky/ui"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState, type RefObject } from "react"; import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri"; import { GameTarget } from "../hooks/useGameConfiguration"; @@ -9,6 +9,8 @@ interface Props { onSelect: (appid: string) => void; onEnableAll: () => Promise<void>; onResetAll: () => Promise<void>; + focusConfiguredToggle?: boolean; + onConfiguredToggleFocused?: () => void; } const CONFIGURED_COLLAPSED_KEY = "lsfg-configured-games-collapsed-v3"; @@ -40,12 +42,14 @@ function GameGroup({ collapsed, onToggle, onSelect, + toggleRef, }: { title: string; games: GameTarget[]; collapsed: boolean; onToggle: () => void; onSelect: (appid: string) => void; + toggleRef?: RefObject<HTMLDivElement>; }) { if (games.length === 0) return null; @@ -56,6 +60,7 @@ function GameGroup({ </PanelSectionRow> <PanelSectionRow> <div + ref={toggleRef} className="LSFG_GameGroupCollapseButton_Container" style={{ marginTop: "-2px", marginBottom: "4px" }} > @@ -86,7 +91,15 @@ function GameGroup({ ); } -export function GameConfigurationSelector({ targets, runningGame, onSelect, onEnableAll, onResetAll }: Props) { +export function GameConfigurationSelector({ + targets, + runningGame, + onSelect, + onEnableAll, + onResetAll, + focusConfiguredToggle = false, + onConfiguredToggleFocused, +}: Props) { const sortGames = (games: GameTarget[]) => [...games].sort((a, b) => { if (a.appid === runningGame?.appid) return -1; if (b.appid === runningGame?.appid) return 1; @@ -102,6 +115,17 @@ export function GameConfigurationSelector({ targets, runningGame, onSelect, onEn const [configuredNonSteamCollapsed, toggleConfiguredNonSteam] = usePersistentCollapsed(`${CONFIGURED_COLLAPSED_KEY}-non-steam`); const [availableCollapsed, toggleAvailable] = usePersistentCollapsed(AVAILABLE_COLLAPSED_KEY); const [availableNonSteamCollapsed, toggleAvailableNonSteam] = usePersistentCollapsed(`${AVAILABLE_COLLAPSED_KEY}-non-steam`); + const configuredToggleRef = useRef<HTMLDivElement>(null); + + useEffect(() => { + if (!focusConfiguredToggle) return; + const frame = requestAnimationFrame(() => { + configuredToggleRef.current?.querySelector<HTMLElement>('[role="button"], button')?.focus(); + onConfiguredToggleFocused?.(); + }); + return () => cancelAnimationFrame(frame); + }, [configuredGames.length, focusConfiguredToggle, onConfiguredToggleFocused]); + const confirmResetAll = () => { showModal( <ConfirmModal @@ -163,6 +187,7 @@ export function GameConfigurationSelector({ targets, runningGame, onSelect, onEn collapsed={configuredCollapsed} onToggle={toggleConfigured} onSelect={onSelect} + toggleRef={configuredToggleRef} /> <GameGroup title="LSFG-VK Enabled (Non-Steam)" diff --git a/src/components/ProfileDetails.tsx b/src/components/ProfileDetails.tsx index 056abbb..4dd8891 100644 --- a/src/components/ProfileDetails.tsx +++ b/src/components/ProfileDetails.tsx @@ -24,12 +24,12 @@ export function ProfileDetails({ description }: ProfileDetailsProps) { bottomSeparator={expanded ? "none" : "standard"} onClick={() => setExpanded((value) => !value)} > - {expanded ? <RiArrowUpSFill /> : <RiArrowDownSFill />} Details + {expanded ? <RiArrowUpSFill /> : <RiArrowDownSFill />} Game Details </ButtonItem> </PanelSectionRow> {expanded && ( <PanelSectionRow> - <Field label="Details" description={description} /> + <Field label="Game Details" description={description} /> </PanelSectionRow> )} </Focusable> diff --git a/src/components/WorkaroundsSection.tsx b/src/components/WorkaroundsSection.tsx index e990784..d783620 100644 --- a/src/components/WorkaroundsSection.tsx +++ b/src/components/WorkaroundsSection.tsx @@ -10,7 +10,7 @@ interface WorkaroundsSectionProps { nonSteam: boolean; } -const WORKAROUNDS_COLLAPSED_KEY = "lsfg-workarounds-collapsed"; +const WORKAROUNDS_COLLAPSED_KEY = "lsfg-workarounds-collapsed-v2"; type ToggleWorkaroundField = Exclude<WorkaroundField, "dxvkFrameRate">; const TOGGLE_ROWS: readonly { @@ -21,18 +21,25 @@ const TOGGLE_ROWS: readonly { description: string; }[] = [ { + field: "disableSteamdeckMode", + labelKey: "CONFIG_DISABLE_STEAMDECK_MODE", + label: "Disable Steam Deck Mode", + descriptionKey: "CONFIG_DISABLE_STEAMDECK_MODE_DESC", + description: "Disables a game-specific Steam Deck compatibility switch. Requires game restart to apply.", + }, + { field: "disableGamescopeWsi", labelKey: "CONFIG_DISABLE_GAMESCOPE_WSI", label: "Disable Gamescope WSI", descriptionKey: "CONFIG_DISABLE_GAMESCOPE_WSI_DESC", - description: "Adds ENABLE_GAMESCOPE_WSI=0 without changing HDR. Requires game restart to apply.", + description: "Adds ENABLE_GAMESCOPE_WSI=0. Requires game restart to apply.", }, { - field: "disableSteamdeckMode", - labelKey: "CONFIG_DISABLE_STEAMDECK_MODE", - label: "Disable Steam Deck Mode", - descriptionKey: "CONFIG_DISABLE_STEAMDECK_MODE_DESC", - description: "Disables a game-specific Steam Deck compatibility switch. Requires game restart to apply.", + field: "disableHdr", + labelKey: "CONFIG_DISABLE_HDR", + label: "Disable HDR", + descriptionKey: "CONFIG_DISABLE_HDR_DESC", + description: "Prevents DXVK from exposing HDR to the game. Requires game restart to apply.", }, { field: "disableVkbasalt", diff --git a/src/hooks/useGameConfiguration.ts b/src/hooks/useGameConfiguration.ts index 46607cb..6d1fe6a 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 { getGameConfigs, getInstalledGames, updateGameConfig, resetGameConfig, resetAllGameConfigs, type GameConfigEntry, type GlobalConfig, type InstalledGame } from "../api/lsfgApi"; import { ConfigurationData, getDefaults } from "../config/configSchema"; -import { cleanupSteamLaunchOptions } from "../utils/steamLaunchOptions"; +import { applyWorkaroundState, cleanupLegacySteamLaunchOptions, cleanupSteamLaunchOptions, getDefaultWorkaroundState, updateSteamLaunchOptions } from "../utils/steamLaunchOptions"; import { showErrorToast } from "../utils/toastUtils"; export interface GameTarget extends InstalledGame { configured: boolean; } @@ -98,7 +98,7 @@ export function useGameConfiguration() { const cleanupTargetLaunchOptions = useCallback(async (target: GameTarget): Promise<boolean> => { if (!installedGames.some((game) => game.appid === target.appid)) return true; try { - await cleanupSteamLaunchOptions(Number(target.appid), target.nonSteam); + await cleanupLegacySteamLaunchOptions(Number(target.appid), target.nonSteam); return true; } catch (error) { showErrorToast("Could not update Steam launch options", error instanceof Error ? error.message : String(error)); @@ -106,6 +106,32 @@ export function useGameConfiguration() { } }, [installedGames]); + const removeTargetLaunchOptions = useCallback(async (target: GameTarget): Promise<boolean> => { + if (!installedGames.some((game) => game.appid === target.appid)) return true; + try { + await cleanupSteamLaunchOptions(Number(target.appid), target.nonSteam); + return true; + } catch (error) { + showErrorToast("Could not clean up Steam launch options", error instanceof Error ? error.message : String(error)); + return false; + } + }, [installedGames]); + + const initializeTargetLaunchOptions = useCallback(async (target: GameTarget): Promise<boolean> => { + if (!installedGames.some((game) => game.appid === target.appid)) return true; + try { + await updateSteamLaunchOptions( + Number(target.appid), + target.nonSteam, + (options) => applyWorkaroundState(options, getDefaultWorkaroundState()), + ); + return true; + } catch (error) { + showErrorToast("Could not initialize Steam launch options", error instanceof Error ? error.message : String(error)); + return false; + } + }, [installedGames]); + const save = useCallback(async (next: ConfigurationData, cleanupLaunchOptions = false) => { const selectedTarget = targets.find((target) => target.appid === selectedAppId); if (!selectedTarget?.name) return; @@ -117,16 +143,16 @@ export function useGameConfiguration() { const enable = useCallback(async (appid: string) => { const target = targets.find((item) => item.appid === appid); if (!target?.name) return false; - if (!(await cleanupTargetLaunchOptions(target))) return false; + if (!(await initializeTargetLaunchOptions(target))) return false; const result = await updateGameConfig(appid, target.name, template); if (result.success) await load(); return result.success; - }, [cleanupTargetLaunchOptions, load, targets, template]); + }, [initializeTargetLaunchOptions, load, targets, template]); const enableAll = useCallback(async (): Promise<void> => { const available = targets.filter((target) => !target.configured && target.name); if (available.length === 0) return; for (const target of available) { - if (!(await cleanupTargetLaunchOptions(target))) return; + if (!(await initializeTargetLaunchOptions(target))) return; const result = await updateGameConfig(target.appid, target.name, template); if (!result.success) { showErrorToast("Could not enable all games", result.error || "A game profile could not be created"); @@ -134,12 +160,12 @@ export function useGameConfiguration() { } } await load(); - }, [cleanupTargetLaunchOptions, load, targets, template]); + }, [initializeTargetLaunchOptions, load, targets, template]); const resetSelected = useCallback(async () => { if (selectedAppId) { const selectedTarget = targets.find((target) => target.appid === selectedAppId); - if (selectedTarget && !(await cleanupTargetLaunchOptions(selectedTarget))) return; + if (selectedTarget && !(await removeTargetLaunchOptions(selectedTarget))) return; const result = await resetGameConfig(selectedAppId); if (result.success) { setRunningGame((current) => current?.appid === selectedAppId ? { ...current, configured: false } : current); @@ -147,10 +173,10 @@ export function useGameConfiguration() { await load(); } } - }, [cleanupTargetLaunchOptions, load, selectedAppId, targets]); + }, [load, removeTargetLaunchOptions, selectedAppId, targets]); const resetAll = useCallback(async () => { for (const target of targets.filter((item) => item.configured)) { - if (!(await cleanupTargetLaunchOptions(target))) return; + if (!(await removeTargetLaunchOptions(target))) return; } const result = await resetAllGameConfigs(); if (result.success) { @@ -158,7 +184,7 @@ export function useGameConfiguration() { setSelectedAppId(""); await load(); } - }, [cleanupTargetLaunchOptions, load, targets]); + }, [load, removeTargetLaunchOptions, targets]); return { config, games, targets, runningGame, selectedAppId, setSelectedAppId, save, enable, enableAll, resetSelected, resetAll, reload: load }; } diff --git a/src/i18n/languages.json b/src/i18n/languages.json index 3f1992c..7e5676b 100644 --- a/src/i18n/languages.json +++ b/src/i18n/languages.json @@ -22,7 +22,9 @@ "CONFIG_ENABLE_WSI": "WSIを有効化", "CONFIG_ENABLE_WSI_DESC": "Gamescope WSIレイヤーを再有効化します。ゲームの再起動が必要。", "CONFIG_DISABLE_GAMESCOPE_WSI": "Gamescope WSIを無効化", - "CONFIG_DISABLE_GAMESCOPE_WSI_DESC": "HDRを変更せずENABLE_GAMESCOPE_WSI=0を追加します。ゲームの再起動が必要です。", + "CONFIG_DISABLE_GAMESCOPE_WSI_DESC": "ENABLE_GAMESCOPE_WSI=0を追加します。ゲームの再起動が必要です。", + "CONFIG_DISABLE_HDR": "HDRを無効化", + "CONFIG_DISABLE_HDR_DESC": "DXVKがゲームにHDRを公開しないようにします。ゲームの再起動が必要です。", "CONFIG_DISABLE_STEAMDECK_MODE": "Steam Deckモードを無効化", "CONFIG_DISABLE_STEAMDECK_MODE_DESC": "ゲーム固有のSteam Deck互換スイッチを無効化します。ゲームの再起動が必要です。", "CONFIG_DISABLE_VKBASALT": "vkBasaltを無効化", @@ -109,7 +111,9 @@ "CONFIG_ENABLE_WSI": "WSI 활성화", "CONFIG_ENABLE_WSI_DESC": "Gamescope WSI 레이어를 다시 활성화합니다. 게임 재시작 필요.", "CONFIG_DISABLE_GAMESCOPE_WSI": "Gamescope WSI 비활성화", - "CONFIG_DISABLE_GAMESCOPE_WSI_DESC": "HDR을 변경하지 않고 ENABLE_GAMESCOPE_WSI=0을 추가합니다. 게임 재시작 필요.", + "CONFIG_DISABLE_GAMESCOPE_WSI_DESC": "ENABLE_GAMESCOPE_WSI=0을 추가합니다. 게임 재시작 필요.", + "CONFIG_DISABLE_HDR": "HDR 비활성화", + "CONFIG_DISABLE_HDR_DESC": "DXVK가 게임에 HDR을 노출하지 않도록 합니다. 게임 재시작 필요.", "CONFIG_DISABLE_STEAMDECK_MODE": "Steam Deck 모드 비활성화", "CONFIG_DISABLE_STEAMDECK_MODE_DESC": "게임별 Steam Deck 호환 스위치를 비활성화합니다. 게임 재시작 필요.", "CONFIG_DISABLE_VKBASALT": "vkBasalt 비활성화", @@ -224,7 +228,9 @@ "CONFIG_ENABLE_WSI": "Enable WSI", "CONFIG_ENABLE_WSI_DESC": "Re-Enable Gamescope WSI Layer. Requires game restart to apply.", "CONFIG_DISABLE_GAMESCOPE_WSI": "Disable Gamescope WSI", - "CONFIG_DISABLE_GAMESCOPE_WSI_DESC": "Adds ENABLE_GAMESCOPE_WSI=0 without changing HDR. Requires game restart to apply.", + "CONFIG_DISABLE_GAMESCOPE_WSI_DESC": "Adds ENABLE_GAMESCOPE_WSI=0. Requires game restart to apply.", + "CONFIG_DISABLE_HDR": "Disable HDR", + "CONFIG_DISABLE_HDR_DESC": "Prevents DXVK from exposing HDR to the game. Requires game restart to apply.", "CONFIG_DISABLE_STEAMDECK_MODE": "Disable Steam Deck Mode", "CONFIG_DISABLE_STEAMDECK_MODE_DESC": "Disables a game-specific Steam Deck compatibility switch. Requires game restart to apply.", "CONFIG_DISABLE_VKBASALT": "Disable vkBasalt", diff --git a/src/utils/steamLaunchOptionParser.ts b/src/utils/steamLaunchOptionParser.ts index 5b8156c..9449b33 100644 --- a/src/utils/steamLaunchOptionParser.ts +++ b/src/utils/steamLaunchOptionParser.ts @@ -1,6 +1,7 @@ export interface WorkaroundState { dxvkFrameRate: number; disableGamescopeWsi: boolean; + disableHdr: boolean; disableSteamdeckMode: boolean; disableVkbasalt: boolean; enableZink: boolean; @@ -41,8 +42,10 @@ const LEGACY_WRAPPER_TOKENS = new Set([ "/home/deck/.local/bin/lsfg-vk-experimental", "~/.local/bin/mako-run", "/home/deck/.local/bin/mako-run", + "mako-run", "~/.local/bin/mako-launch", "/home/deck/.local/bin/mako-launch", + "mako-launch", ]); const DXVK_FRAME_RATE_KEYS: readonly DxvkFrameRateKey[] = [ "dxvk.maxFrameRate", @@ -53,7 +56,12 @@ const DXVK_MANAGED_KEYS = new Set(["DXVK_CONFIG", "DXVK_FRAME_RATE"]); const WORKAROUND_DEFINITIONS = { disableGamescopeWsi: { spec: ["ENABLE_GAMESCOPE_WSI", "0"], - clear: ["DISABLE_GAMESCOPE_WSI", "ENABLE_GAMESCOPE_WSI", "DXVK_HDR"], + clear: ["DISABLE_GAMESCOPE_WSI", "ENABLE_GAMESCOPE_WSI"], + }, + disableHdr: { + spec: ["DXVK_HDR", "0"], + clear: ["DXVK_HDR"], + label: "Disable HDR", }, disableSteamdeckMode: { spec: ["SteamDeck", "0"], @@ -72,24 +80,34 @@ const WORKAROUND_DEFINITIONS = { } as const satisfies Record<BooleanWorkaroundField, WorkaroundDefinition>; const BOOLEAN_WORKAROUND_FIELDS: readonly BooleanWorkaroundField[] = [ "disableGamescopeWsi", + "disableHdr", "disableSteamdeckMode", "disableVkbasalt", "enableZink", ]; const WSI_DISABLE_KEY = "DISABLE_GAMESCOPE_WSI"; const WSI_ENABLE_KEY = "ENABLE_GAMESCOPE_WSI"; +const WORKAROUND_ENV_KEYS = new Set( + BOOLEAN_WORKAROUND_FIELDS.flatMap((field) => WORKAROUND_DEFINITIONS[field].clear), +); const MANAGED_ENV_KEYS = new Set([ ...DXVK_MANAGED_KEYS, - ...BOOLEAN_WORKAROUND_FIELDS.flatMap((field) => WORKAROUND_DEFINITIONS[field].clear), + ...WORKAROUND_ENV_KEYS, ]); -const DEFAULT_WORKAROUND_STATE: WorkaroundState = { +const EMPTY_WORKAROUND_STATE: WorkaroundState = { dxvkFrameRate: 0, disableGamescopeWsi: false, + disableHdr: false, disableSteamdeckMode: false, disableVkbasalt: false, enableZink: false, }; +const DEFAULT_WORKAROUND_STATE: WorkaroundState = { + ...EMPTY_WORKAROUND_STATE, + disableGamescopeWsi: true, + disableHdr: true, +}; export function getDefaultWorkaroundState(): WorkaroundState { return { ...DEFAULT_WORKAROUND_STATE }; @@ -117,8 +135,6 @@ function decodeToken(raw: string): string { return value; } -// Steam stores one shell-like line. Keep each token's raw spelling beside its -// decoded value so managed edits leave unrelated quoting and arguments alone. function tokenize(options: string): LaunchToken[] { const tokens: LaunchToken[] = []; let start = -1; @@ -358,7 +374,7 @@ function readBoolean( export function parseWorkaroundOptions(options: string): ParsedWorkaroundOptions { const tokens = tokenize(options); const entries = effectiveEnvironmentEntries(tokens); - const state = getDefaultWorkaroundState(); + const state = { ...EMPTY_WORKAROUND_STATE }; const issues: string[] = []; for (const [key, entry] of entries) { @@ -418,7 +434,7 @@ export function parseWorkaroundOptions(options: string): ParsedWorkaroundOptions state.disableGamescopeWsi = wsiSignals.some(Boolean); } - for (const field of ["disableSteamdeckMode", "disableVkbasalt"] as const) { + for (const field of ["disableHdr", "disableSteamdeckMode", "disableVkbasalt"] as const) { const { spec, label } = WORKAROUND_DEFINITIONS[field]; state[field] = readBoolean(entries, spec[0], label || field, spec[1], issues); } @@ -455,7 +471,7 @@ export function applyWorkaroundState(options: string, state: WorkaroundState): s removeLegacyWrapperFromTokens(tokens); rewriteDxvkFrameRate(tokens, state.dxvkFrameRate); const keysToClear = new Set<string>( - BOOLEAN_WORKAROUND_FIELDS.flatMap((field) => WORKAROUND_DEFINITIONS[field].clear), + WORKAROUND_ENV_KEYS, ); if (state.disableVkbasalt) keysToClear.add("ENABLE_VKBASALT"); removeAllAssignments(tokens, keysToClear); @@ -496,6 +512,14 @@ export function cleanupLegacyLaunchOptions(options: string): string { return serialize(tokens); } +export function cleanupPluginLaunchOptions(options: string): string { + const tokens = tokenize(options); + removeLegacyWrapperFromTokens(tokens); + rewriteDxvkFrameRate(tokens, 0); + removeAllAssignments(tokens, WORKAROUND_ENV_KEYS); + return serialize(tokens); +} + export function cleanupLegacyWrapper(options: string): string { return cleanupLegacyLaunchOptions(options); } diff --git a/src/utils/steamLaunchOptions.ts b/src/utils/steamLaunchOptions.ts index 39125bd..82ff94b 100644 --- a/src/utils/steamLaunchOptions.ts +++ b/src/utils/steamLaunchOptions.ts @@ -1,5 +1,5 @@ // @ts-expect-error Node's built-in TypeScript loader requires explicit source extensions in tests. -import { cleanupLegacyLaunchOptions, isLegacyWrapperToken, normalizeLaunchOptions } from "./steamLaunchOptionParser.ts"; +import { cleanupLegacyLaunchOptions, cleanupPluginLaunchOptions, isLegacyWrapperToken, normalizeLaunchOptions } from "./steamLaunchOptionParser.ts"; // @ts-expect-error Node's built-in TypeScript loader requires explicit source extensions in tests. export * from "./steamLaunchOptionParser.ts"; @@ -203,6 +203,19 @@ export function cleanupSteamLaunchOptions( ): Promise<SteamLaunchOptionsSnapshot> { return queueSteamAppOperation(appId, nonSteam, async () => { const current = await readSteamLaunchOptions(appId, nonSteam); + const next = cleanupPluginLaunchOptions(current.options); + if (next === current.options) return current; + await setSteamLaunchOptions(appId, nonSteam, next); + return waitForLaunchOptions(appId, nonSteam, next); + }); +} + +export function cleanupLegacySteamLaunchOptions( + appId: number, + nonSteam: boolean, +): Promise<SteamLaunchOptionsSnapshot> { + return queueSteamAppOperation(appId, nonSteam, async () => { + const current = await readSteamLaunchOptions(appId, nonSteam); const next = cleanupLegacyLaunchOptions(current.options); if (next === current.options) return current; await setSteamLaunchOptions(appId, nonSteam, next); diff --git a/tests/steamLaunchOptions.test.ts b/tests/steamLaunchOptions.test.ts index 0662fbb..c4dede3 100644 --- a/tests/steamLaunchOptions.test.ts +++ b/tests/steamLaunchOptions.test.ts @@ -4,6 +4,7 @@ import { applyWorkaroundChange, applyWorkaroundState, cleanupLegacyLaunchOptions, + cleanupPluginLaunchOptions, cleanupLegacyWrapper, getDefaultWorkaroundState, isLegacyWrapperToken, @@ -17,6 +18,7 @@ test("maps the supported workarounds to current launch variables", () => { const options = applyWorkaroundState('gamemoderun %command% --profile "high quality"', { dxvkFrameRate: 30, disableGamescopeWsi: true, + disableHdr: true, disableSteamdeckMode: true, disableVkbasalt: true, enableZink: true, @@ -24,12 +26,13 @@ test("maps the supported workarounds to current launch variables", () => { assert.equal( options, - 'ENABLE_GAMESCOPE_WSI=0 SteamDeck=0 DISABLE_VKBASALT=1 MESA_LOADER_DRIVER_OVERRIDE=zink DXVK_CONFIG="dxvk.maxFrameRate = 30" gamemoderun %command% --profile "high quality"', + 'ENABLE_GAMESCOPE_WSI=0 DXVK_HDR=0 SteamDeck=0 DISABLE_VKBASALT=1 MESA_LOADER_DRIVER_OVERRIDE=zink DXVK_CONFIG="dxvk.maxFrameRate = 30" gamemoderun %command% --profile "high quality"', ); assert.deepEqual(parseWorkaroundOptions(options), { state: { dxvkFrameRate: 30, disableGamescopeWsi: true, + disableHdr: true, disableSteamdeckMode: true, disableVkbasalt: true, enableZink: true, @@ -45,10 +48,24 @@ test("uses SteamDeck=0 before %command% without a wrapper", () => { ); }); -test("keeps WSI disable opt-in and does not add HDR assignments", () => { +test("defaults new profiles to disable Gamescope WSI and HDR", () => { const defaults = getDefaultWorkaroundState(); - assert.equal(applyWorkaroundState("%command%", defaults), ""); + assert.equal(defaults.disableGamescopeWsi, true); + assert.equal(defaults.disableHdr, true); + assert.equal( + applyWorkaroundState("%command%", defaults), + "ENABLE_GAMESCOPE_WSI=0 DXVK_HDR=0 %command%", + ); assert.equal(parseWorkaroundOptions("%command%").state.disableGamescopeWsi, false); + assert.equal(parseWorkaroundOptions("%command%").state.disableHdr, false); + assert.equal( + parseWorkaroundOptions(applyWorkaroundState("%command%", defaults)).state.disableGamescopeWsi, + true, + ); + assert.equal( + parseWorkaroundOptions(applyWorkaroundState("%command%", defaults)).state.disableHdr, + true, + ); assert.equal( applyWorkaroundChange("%command%", "disableGamescopeWsi", true), "ENABLE_GAMESCOPE_WSI=0 %command%", @@ -58,14 +75,6 @@ test("keeps WSI disable opt-in and does not add HDR assignments", () => { "", ); - const legacy = parseWorkaroundOptions("ENABLE_GAMESCOPE_WSI=0 DXVK_HDR=0 %command%"); - assert.equal(legacy.state.disableGamescopeWsi, true); - assert.deepEqual(legacy.issues, []); - assert.equal( - applyWorkaroundChange("ENABLE_GAMESCOPE_WSI=0 DXVK_HDR=0 %command%", "disableGamescopeWsi", false), - "", - ); - const invalid = parseWorkaroundOptions("ENABLE_GAMESCOPE_WSI=maybe %command%"); assert.equal(invalid.state.disableGamescopeWsi, false); assert.equal(invalid.issues.length, 1); @@ -74,6 +83,27 @@ test("keeps WSI disable opt-in and does not add HDR assignments", () => { assert.match(conflicting.issues.join(" "), /conflicting/); }); +test("manages DXVK HDR independently from Gamescope WSI", () => { + assert.equal( + applyWorkaroundChange("%command%", "disableHdr", true), + "DXVK_HDR=0 %command%", + ); + assert.equal(parseWorkaroundOptions("DXVK_HDR=0 %command%").state.disableHdr, true); + assert.equal(parseWorkaroundOptions("DXVK_HDR=1 %command%").state.disableHdr, false); + assert.equal( + applyWorkaroundChange("DXVK_HDR=0 %command%", "disableHdr", false), + "", + ); + assert.equal( + applyWorkaroundChange("DXVK_HDR=0 %command%", "disableGamescopeWsi", true), + "ENABLE_GAMESCOPE_WSI=0 DXVK_HDR=0 %command%", + ); + + const invalid = parseWorkaroundOptions("DXVK_HDR=maybe %command%"); + assert.equal(invalid.state.disableHdr, false); + assert.match(invalid.issues.join(" "), /Disable HDR has an unsupported value/); +}); + test("preserves unrelated prefixes, quoted tokens, suffix arguments, and dropped variables", () => { const options = applyWorkaroundChange( 'PROTON_USE_WOW64=1 MANGOHUD=1 MANGOHUD_CONFIG="alpha=0.01" ENABLE_VKBASALT=1 VK_INSTANCE_LAYERS="one:two" FOO="hello world" gamemoderun %command% --flag "two words"', @@ -203,6 +233,8 @@ test("cleans only the known legacy wrapper and preserves launch options", () => 'FOO=bar %command% --arg "~/lsfg"', ); assert.equal(cleanupLegacyWrapper("/home/deck/lsfg %command%"), ""); + assert.equal(cleanupLegacyWrapper("mako-run %command%"), ""); + assert.equal(cleanupLegacyWrapper("mako-launch %command%"), ""); assert.equal( cleanupLegacyWrapper("DXVK_FRAME_RATE=30 LSFG_PROCESS=decky-lsfg-vk %command%"), "DXVK_FRAME_RATE=30 LSFG_PROCESS=decky-lsfg-vk %command%", @@ -214,6 +246,25 @@ test("cleans only the known legacy wrapper and preserves launch options", () => assert.equal(isLegacyWrapperToken("/home/kurt/lsfg"), false); }); +test("removes plugin-managed launch options when a profile is removed", () => { + assert.equal( + cleanupPluginLaunchOptions( + 'FOO="keep this" ENABLE_GAMESCOPE_WSI=0 DXVK_HDR=0 SteamDeck=0 DISABLE_VKBASALT=1 MESA_LOADER_DRIVER_OVERRIDE=zink DXVK_CONFIG="dxgi.syncInterval = 0; dxvk.maxFrameRate = 30" ~/lsfg %command% --windowed', + ), + 'DXVK_CONFIG="dxgi.syncInterval = 0" FOO="keep this" %command% --windowed', + ); + assert.equal( + cleanupPluginLaunchOptions( + 'PROTON_USE_WOW64=1 MANGOHUD=1 MANGOHUD_CONFIG="alpha=0.01" LSFG_PROCESS=decky-lsfg-vk %command%', + ), + 'PROTON_USE_WOW64=1 MANGOHUD=1 MANGOHUD_CONFIG="alpha=0.01" LSFG_PROCESS=decky-lsfg-vk %command%', + ); + assert.equal( + cleanupPluginLaunchOptions('DXVK_CONFIG="dxvk.maxFrameRate = 30" %command%'), + "", + ); +}); + test("canonicalizes a bare command token without removing real arguments", () => { assert.equal(normalizeLaunchOptions("%command%"), ""); assert.equal(normalizeLaunchOptions("%COMMAND%"), ""); diff --git a/tests/test_flatpak_overrides.py b/tests/test_flatpak_overrides.py new file mode 100644 index 0000000..ed3ef6a --- /dev/null +++ b/tests/test_flatpak_overrides.py @@ -0,0 +1,158 @@ +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.modules.setdefault("tomllib", types.SimpleNamespace(loads=Mock())) +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "py_modules")) + +from lsfg_vk.flatpak_service import FlatpakService + + +class FlatpakOverrideTests(unittest.TestCase): + def setUp(self): + self.tempdir = tempfile.TemporaryDirectory() + home = Path(self.tempdir.name) / "home" / "deck" + home.mkdir(parents=True) + self.service = FlatpakService() + self.service.user_home = home + self.service.config_dir = home / ".config/lsfg-vk" + self.service.config_file_path = self.service.config_dir / "conf.toml" + self.service.legacy_script_path = home / "lsfg" + self.service.check_flatpak_available = Mock(return_value=True) + self.service._run_flatpak_command = Mock( + return_value=types.SimpleNamespace(returncode=0, stderr="", stdout="") + ) + self.app_id = "com.example.Game" + self.override_path = self.service._override_file_path(self.app_id) + + def tearDown(self): + self.tempdir.cleanup() + sys.modules.pop("lsfg_vk.plugin", None) + sys.modules.pop("lsfg_vk", None) + + def _paths(self): + return self.service._override_paths() + + def _write_override(self, content): + self.override_path.parent.mkdir(parents=True, exist_ok=True) + self.override_path.write_text(content, encoding="utf-8") + + def _show_response(self, content): + return types.SimpleNamespace(returncode=0, stderr="", stdout=content) + + def test_set_cleans_legacy_entries_and_verifies_readback(self): + paths = self._paths() + self._write_override( + "[Context]\n" + f"filesystems=/home/deck/keep;{paths['config_dir']}:rw;!{paths['legacy_home']};" + f"{paths['legacy_script']};{paths['legacy_dll']}:ro;{paths['dll_dir']}:ro;\n" + "unset-environment=KEEP_UNSET;LSFG_CONFIG;\n\n" + "[Environment]\n" + "KEEP_ENV=1\n" + "LSFG_CONFIG=\n" + "LSFGVK_CONFIG=old\n" + "ENABLE_GAMESCOPE_WSI=1\n" + "DXVK_HDR=1\n" + ) + expected = ( + "[Context]\n" + f"filesystems={paths['config_dir']}:rw;{paths['dll_dir']}:ro\n" + "[Environment]\n" + f"LSFGVK_CONFIG={paths['config_file']}\n" + "ENABLE_GAMESCOPE_WSI=0\n" + "DXVK_HDR=0\n" + ) + self.service._run_flatpak_command.side_effect = [ + self._show_response(""), + self._show_response(expected), + ] + + response = self.service.set_app_override(self.app_id) + command_args = self.service._run_flatpak_command.call_args_list[0].args[0] + cleaned = self.override_path.read_text(encoding="utf-8") + + self.assertTrue(response["success"]) + self.assertIn("--env=ENABLE_GAMESCOPE_WSI=0", command_args) + self.assertIn("--env=DXVK_HDR=0", command_args) + self.assertNotIn("--nofilesystem=/home/deck", command_args) + self.assertNotIn("--unset-env=LSFG_CONFIG", command_args) + self.assertIn("/home/deck/keep", cleaned) + self.assertIn("KEEP_ENV=1", cleaned) + self.assertNotIn("LSFG_CONFIG", cleaned) + self.assertNotIn(paths["legacy_home"], cleaned) + + def test_set_reports_failed_readback(self): + paths = self._paths() + self.service._run_flatpak_command.side_effect = [ + self._show_response(""), + self._show_response( + f"[Context]\nfilesystems={paths['config_dir']};{paths['dll_dir']}\n" + f"[Environment]\nLSFGVK_CONFIG={paths['config_file']}\n" + ), + ] + + response = self.service.set_app_override(self.app_id) + + self.assertFalse(response["success"]) + self.assertIn("verified", response["error"]) + + def test_remove_cleans_known_entries_preserves_unrelated_and_verifies(self): + paths = self._paths() + self._write_override( + "[Context]\n" + f"filesystems=/home/deck/keep;{paths['config_dir']};!{paths['legacy_home']};" + f"{paths['legacy_dll']};{paths['legacy_script']}\n" + "unset-environment=KEEP_UNSET;LSFG_CONFIG;ENABLE_GAMESCOPE_WSI\n\n" + "[Environment]\n" + "KEEP_ENV=1\n" + "LSFGVK_CONFIG=/old/path\n" + "DXVK_HDR=0\n" + ) + self.service._run_flatpak_command.side_effect = [ + self._show_response( + "[Context]\nfilesystems=/home/deck/keep\n" + "[Environment]\nKEEP_ENV=1\n" + ) + ] + + response = self.service.remove_app_override(self.app_id) + cleaned = self.override_path.read_text(encoding="utf-8") + + self.assertTrue(response["success"]) + self.assertEqual(self.service._run_flatpak_command.call_count, 1) + self.assertIn("/home/deck/keep", cleaned) + self.assertIn("KEEP_UNSET", cleaned) + self.assertIn("KEEP_ENV", cleaned) + for name in ("LSFGVK_CONFIG", "LSFG_CONFIG", "ENABLE_GAMESCOPE_WSI", "DXVK_HDR"): + self.assertNotIn(name, cleaned) + for path in paths.values(): + if path != paths["config_file"]: + self.assertNotIn(path, cleaned) + + def test_remove_reports_failed_readback(self): + self._write_override("[Context]\nfilesystems=/home/deck/keep\n") + paths = self._paths() + self.service._run_flatpak_command.side_effect = [ + self._show_response( + f"[Context]\nfilesystems={paths['config_dir']};{paths['dll_dir']}\n" + f"[Environment]\nLSFGVK_CONFIG={paths['config_file']}\n" + "ENABLE_GAMESCOPE_WSI=0\nDXVK_HDR=0\n" + ) + ] + + response = self.service.remove_app_override(self.app_id) + + self.assertFalse(response["success"]) + self.assertIn("verified", response["error"]) + + +if __name__ == "__main__": + unittest.main() |
