summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--py_modules/lsfg_vk/flatpak_service.py134
-rw-r--r--src/components/ConfigurationTab.tsx6
-rw-r--r--src/components/GameConfigurationSelector.tsx29
-rw-r--r--src/components/ProfileDetails.tsx4
-rw-r--r--src/components/WorkaroundsSection.tsx21
-rw-r--r--src/hooks/useGameConfiguration.ts46
-rw-r--r--src/i18n/languages.json12
-rw-r--r--src/utils/steamLaunchOptionParser.ts40
-rw-r--r--src/utils/steamLaunchOptions.ts15
-rw-r--r--tests/steamLaunchOptions.test.ts73
-rw-r--r--tests/test_flatpak_overrides.py158
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()