summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorKurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com>2025-07-22 00:17:50 -0400
committerGitHub <noreply@github.com>2025-07-22 00:17:50 -0400
commit4fee88babae0dc69a332480c3e491500dab64d7c (patch)
tree2e9ee6360337978e9264173a2e20daf5ec195393
parent2106ef8eb31ee46611fce07dd715d3ac1c4ca0ab (diff)
parente83f026b0d1edf5a7ee1477f4b10eb574f506f95 (diff)
downloaddecky-lsfg-vk-4fee88babae0dc69a332480c3e491500dab64d7c.tar.gz
decky-lsfg-vk-4fee88babae0dc69a332480c3e491500dab64d7c.zip
Merge pull request #63 from xXJSONDeruloXx/cleanup-jul21
refactor: remove unused backend files and improve configuration handl…
-rw-r--r--backend/Dockerfile9
-rw-r--r--backend/Makefile14
-rwxr-xr-xbackend/entrypoint.sh8
-rw-r--r--backend/src/main.c5
-rwxr-xr-xgenerate_ts_config.py132
-rw-r--r--package.json2
-rw-r--r--py_modules/lsfg_vk/base_service.py44
-rw-r--r--py_modules/lsfg_vk/config_schema.py94
-rw-r--r--py_modules/lsfg_vk/configuration.py78
-rw-r--r--py_modules/lsfg_vk/installation.py34
-rw-r--r--shared_config.py106
-rw-r--r--src/components/SmartClipboardButton.tsx71
-rw-r--r--src/components/UsageInstructions.tsx22
-rw-r--r--src/components/index.ts2
-rw-r--r--src/config/configSchema.ts252
-rw-r--r--src/config/generatedConfigSchema.ts127
-rw-r--r--src/hooks/useInstallationActions.ts37
-rw-r--r--src/hooks/useLsfgHooks.ts15
-rw-r--r--src/utils/clipboardUtils.ts70
-rw-r--r--src/utils/toastUtils.ts115
20 files changed, 800 insertions, 437 deletions
diff --git a/backend/Dockerfile b/backend/Dockerfile
deleted file mode 100644
index f46e170..0000000
--- a/backend/Dockerfile
+++ /dev/null
@@ -1,9 +0,0 @@
-# we support images for building with a vanilla SteamOS base,
-# or versions with ootb support for rust or go
-# developers can also customize these images via this Dockerfile
-#FROM ghcr.io/steamdeckhomebrew/holo-toolchain-rust:latest
-#FROM ghcr.io/steamdeckhomebrew/holo-toolchain-go:latest
-FROM ghcr.io/steamdeckhomebrew/holo-base:latest
-
-# entrypoint.sh should always be located in the backend folder
-ENTRYPOINT [ "/backend/entrypoint.sh" ] \ No newline at end of file
diff --git a/backend/Makefile b/backend/Makefile
deleted file mode 100644
index a1e5dc5..0000000
--- a/backend/Makefile
+++ /dev/null
@@ -1,14 +0,0 @@
-# This is the default target, which will be built when
-# you invoke make
-.PHONY: all
-all: hello
-
-# This rule tells make how to build hello from hello.cpp
-hello:
- mkdir -p ./out
- gcc -o ./out/hello ./src/main.c
-
-# This rule tells make to delete hello and hello.o
-.PHONY: clean
-clean:
- rm -f hello \ No newline at end of file
diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh
deleted file mode 100755
index af04d23..0000000
--- a/backend/entrypoint.sh
+++ /dev/null
@@ -1,8 +0,0 @@
-#!/bin/sh
-set -e
-
-echo "Container's IP address: `awk 'END{print $1}' /etc/hosts`"
-
-cd /backend
-
-make \ No newline at end of file
diff --git a/backend/src/main.c b/backend/src/main.c
deleted file mode 100644
index b3b8fed..0000000
--- a/backend/src/main.c
+++ /dev/null
@@ -1,5 +0,0 @@
-#include <stdio.h>
-int main() {
- printf("Hello World\n");
- return 0;
-} \ No newline at end of file
diff --git a/generate_ts_config.py b/generate_ts_config.py
new file mode 100755
index 0000000..4164c37
--- /dev/null
+++ b/generate_ts_config.py
@@ -0,0 +1,132 @@
+#!/usr/bin/env python3
+"""
+Generate TypeScript configuration constants from shared Python config.
+This script is run during development to sync the schemas.
+"""
+
+import sys
+from pathlib import Path
+
+# Add the parent directory to Python path to import shared_config
+sys.path.insert(0, str(Path(__file__).parent))
+
+from shared_config import CONFIG_SCHEMA_DEF, ConfigFieldType, get_field_names, get_defaults, get_field_types
+
+
+def generate_typescript_config():
+ """Generate TypeScript configuration constants"""
+
+ ts_content = '''/**
+ * Auto-generated TypeScript configuration schema.
+ * DO NOT EDIT MANUALLY - Generated from shared_config.py
+ *
+ * To update this file, run: python3 generate_ts_config.py > src/config/generatedConfigSchema.ts
+ */
+
+// Configuration field type enum - matches Python
+export enum ConfigFieldType {
+ BOOLEAN = "boolean",
+ INTEGER = "integer",
+ FLOAT = "float",
+ STRING = "string"
+}
+
+// Configuration field definition
+export interface ConfigField {
+ name: string;
+ fieldType: ConfigFieldType;
+ default: boolean | number | string;
+ description: string;
+}
+
+// Configuration schema - auto-generated from Python
+export const CONFIG_SCHEMA: Record<string, ConfigField> = {
+'''
+
+ # Generate each field
+ for field_name, field_def in CONFIG_SCHEMA_DEF.items():
+ field_type = field_def["fieldType"]
+ default_value = field_def["default"]
+
+ # Format default value for TypeScript
+ if field_type == "string":
+ default_str = f'"{default_value}"'
+ elif field_type == "boolean":
+ default_str = "true" if default_value else "false"
+ else:
+ default_str = str(default_value)
+
+ ts_content += f''' {field_name}: {{
+ name: "{field_def["name"]}",
+ fieldType: ConfigFieldType.{field_type.upper()},
+ default: {default_str},
+ description: "{field_def["description"]}"
+ }},
+'''
+
+ ts_content += '''};
+
+// Type-safe configuration data structure
+export interface ConfigurationData {
+'''
+
+ # Generate interface fields
+ for field_name, field_def in CONFIG_SCHEMA_DEF.items():
+ field_type = field_def["fieldType"]
+
+ # Map Python types to TypeScript types
+ ts_type_map = {
+ "string": "string",
+ "boolean": "boolean",
+ "integer": "number",
+ "float": "number"
+ }
+
+ ts_type = ts_type_map[field_type]
+ ts_content += f' {field_name}: {ts_type};\n'
+
+ ts_content += '''}
+
+// Helper functions
+export function getFieldNames(): string[] {
+ return Object.keys(CONFIG_SCHEMA);
+}
+
+export function getDefaults(): ConfigurationData {
+ return {
+'''
+
+ # Generate defaults object
+ for field_name, field_def in CONFIG_SCHEMA_DEF.items():
+ default_value = field_def["default"]
+ field_type = field_def["fieldType"]
+
+ if field_type == "string":
+ default_str = f'"{default_value}"'
+ elif field_type == "boolean":
+ default_str = "true" if default_value else "false"
+ else:
+ default_str = str(default_value)
+
+ ts_content += f' {field_name}: {default_str},\n'
+
+ ts_content += ''' };
+}
+
+export function getFieldTypes(): Record<string, ConfigFieldType> {
+ return {
+'''
+
+ # Generate field types object
+ for field_name, field_def in CONFIG_SCHEMA_DEF.items():
+ ts_content += f' {field_name}: ConfigFieldType.{field_def["fieldType"].upper()},\n'
+
+ ts_content += ''' };
+}
+'''
+
+ return ts_content
+
+
+if __name__ == "__main__":
+ print(generate_typescript_config())
diff --git a/package.json b/package.json
index 7989109..9fcf10d 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "decky-lossless-scaling-vk",
- "version": "0.6.8",
+ "version": "0.6.9",
"description": "Use Lossless Scaling on the Steam Deck using the lsfg-vk vulkan layer",
"type": "module",
"scripts": {
diff --git a/py_modules/lsfg_vk/base_service.py b/py_modules/lsfg_vk/base_service.py
index b595b07..b684ec9 100644
--- a/py_modules/lsfg_vk/base_service.py
+++ b/py_modules/lsfg_vk/base_service.py
@@ -5,10 +5,13 @@ Base service class with common functionality.
import os
import shutil
from pathlib import Path
-from typing import Any, Optional
+from typing import Any, Optional, TypeVar, Dict
from .constants import LOCAL_LIB, LOCAL_SHARE_BASE, VULKAN_LAYER_DIR, SCRIPT_NAME, CONFIG_DIR, CONFIG_FILENAME
+# Generic type for response dictionaries
+ResponseType = TypeVar('ResponseType', bound=Dict[str, Any])
+
class BaseService:
"""Base service class with common functionality"""
@@ -90,3 +93,42 @@ class BaseService:
except Exception:
self.log.error(f"Failed to write to {path}")
raise
+
+ def _success_response(self, response_type: type, message: str = "", **kwargs) -> Any:
+ """Create a standardized success response
+
+ Args:
+ response_type: The TypedDict response type to create
+ message: Success message
+ **kwargs: Additional response fields
+
+ Returns:
+ Success response dict
+ """
+ response = {
+ "success": True,
+ "message": message,
+ "error": None
+ }
+ response.update(kwargs)
+ return response
+
+ def _error_response(self, response_type: type, error: str, message: str = "", **kwargs) -> Any:
+ """Create a standardized error response
+
+ Args:
+ response_type: The TypedDict response type to create
+ error: Error description
+ message: Optional message
+ **kwargs: Additional response fields
+
+ Returns:
+ Error response dict
+ """
+ response = {
+ "success": False,
+ "message": message,
+ "error": error
+ }
+ response.update(kwargs)
+ return response
diff --git a/py_modules/lsfg_vk/config_schema.py b/py_modules/lsfg_vk/config_schema.py
index 4f036ff..c82d1d3 100644
--- a/py_modules/lsfg_vk/config_schema.py
+++ b/py_modules/lsfg_vk/config_schema.py
@@ -9,18 +9,15 @@ This module defines the complete configuration structure for lsfg-vk, managing T
"""
import re
+import sys
from typing import TypedDict, Dict, Any, Union, cast
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
-
-class ConfigFieldType(Enum):
- """Supported configuration field types"""
- BOOLEAN = "boolean"
- INTEGER = "integer"
- FLOAT = "float"
- STRING = "string"
+# Import shared configuration constants
+sys.path.insert(0, str(Path(__file__).parent.parent.parent))
+from shared_config import CONFIG_SCHEMA_DEF, ConfigFieldType, get_field_names, get_defaults, get_field_types
@dataclass
@@ -36,51 +33,25 @@ class ConfigField:
return value
-# Configuration schema definition
+# Use shared configuration schema as source of truth
CONFIG_SCHEMA: Dict[str, ConfigField] = {
- "dll": ConfigField(
- name="dll",
- field_type=ConfigFieldType.STRING,
- default="", # Will be populated dynamically based on detection
- description="specify where Lossless.dll is stored"
- ),
-
- "multiplier": ConfigField(
- name="multiplier",
- field_type=ConfigFieldType.INTEGER,
- default=1,
- description="change the fps multiplier"
- ),
-
- "flow_scale": ConfigField(
- name="flow_scale",
- field_type=ConfigFieldType.FLOAT,
- default=0.8,
- description="change the flow scale"
- ),
-
- "performance_mode": ConfigField(
- name="performance_mode",
- field_type=ConfigFieldType.BOOLEAN,
- default=True,
- description="toggle performance mode"
- ),
-
- "hdr_mode": ConfigField(
- name="hdr_mode",
- field_type=ConfigFieldType.BOOLEAN,
- default=False,
- description="enable hdr mode"
- ),
-
- "experimental_present_mode": ConfigField(
- name="experimental_present_mode",
- field_type=ConfigFieldType.STRING,
- default="fifo",
- description="experimental: override vulkan present mode (fifo/mailbox/immediate)"
- ),
+ field_name: ConfigField(
+ name=field_def["name"],
+ field_type=ConfigFieldType(field_def["fieldType"]),
+ default=field_def["default"],
+ description=field_def["description"]
+ )
+ for field_name, field_def in CONFIG_SCHEMA_DEF.items()
}
+# Override DLL default to empty (will be populated dynamically)
+CONFIG_SCHEMA["dll"] = ConfigField(
+ name="dll",
+ field_type=ConfigFieldType.STRING,
+ default="", # Will be populated dynamically based on detection
+ description="specify where Lossless.dll is stored"
+)
+
# Fields that should ONLY be in the lsfg script, not in TOML config
SCRIPT_ONLY_FIELDS = {
"dxvk_frame_rate": ConfigField(
@@ -128,10 +99,16 @@ class ConfigurationManager:
@staticmethod
def get_defaults() -> ConfigurationData:
"""Get default configuration values"""
- return cast(ConfigurationData, {
+ # Use shared defaults and add script-only fields
+ shared_defaults = get_defaults()
+
+ # Add script-only fields that aren't in the shared schema
+ script_defaults = {
field.name: field.default
- for field in COMPLETE_CONFIG_SCHEMA.values()
- })
+ for field in SCRIPT_ONLY_FIELDS.values()
+ }
+
+ return cast(ConfigurationData, {**shared_defaults, **script_defaults})
@staticmethod
def get_defaults_with_dll_detection(dll_detection_service=None) -> ConfigurationData:
@@ -164,15 +141,18 @@ class ConfigurationManager:
@staticmethod
def get_field_names() -> list[str]:
"""Get ordered list of configuration field names"""
- return list(COMPLETE_CONFIG_SCHEMA.keys())
+ # Use shared field names and add script-only fields
+ shared_names = get_field_names()
+ script_names = list(SCRIPT_ONLY_FIELDS.keys())
+ return shared_names + script_names
@staticmethod
def get_field_types() -> Dict[str, ConfigFieldType]:
"""Get field type mapping"""
- return {
- field.name: field.field_type
- for field in CONFIG_SCHEMA.values()
- }
+ # Use shared field types and add script-only field types
+ shared_types = {name: ConfigFieldType(type_str) for name, type_str in get_field_types().items()}
+ script_types = {field.name: field.field_type for field in SCRIPT_ONLY_FIELDS.values()}
+ return {**shared_types, **script_types}
@staticmethod
def validate_config(config: Dict[str, Any]) -> ConfigurationData:
diff --git a/py_modules/lsfg_vk/configuration.py b/py_modules/lsfg_vk/configuration.py
index da765e0..47d0ebc 100644
--- a/py_modules/lsfg_vk/configuration.py
+++ b/py_modules/lsfg_vk/configuration.py
@@ -43,22 +43,12 @@ class ConfigurationService(BaseService):
# Merge TOML config with script values
config = ConfigurationManager.merge_config_with_script(toml_config, script_values)
- return {
- "success": True,
- "config": config,
- "message": None,
- "error": None
- }
+ return self._success_response(ConfigurationResponse, config=config)
except (OSError, IOError) as e:
error_msg = f"Error reading lsfg config: {str(e)}"
self.log.error(error_msg)
- return {
- "success": False,
- "config": None,
- "message": None,
- "error": str(e)
- }
+ return self._error_response(ConfigurationResponse, str(e), config=None)
except Exception as e:
error_msg = f"Error parsing config file: {str(e)}"
self.log.error(error_msg)
@@ -66,12 +56,9 @@ class ConfigurationService(BaseService):
from .dll_detection import DllDetectionService
dll_service = DllDetectionService(self.log)
config = ConfigurationManager.get_defaults_with_dll_detection(dll_service)
- return {
- "success": True,
- "config": config,
- "message": f"Using default configuration due to parse error: {str(e)}",
- "error": None
- }
+ return self._success_response(ConfigurationResponse,
+ f"Using default configuration due to parse error: {str(e)}",
+ config=config)
def update_config(self, dll: str, multiplier: int, flow_scale: float,
performance_mode: bool, hdr_mode: bool,
@@ -123,31 +110,18 @@ class ConfigurationService(BaseService):
f"dxvk_frame_rate={dxvk_frame_rate}, "
f"enable_wow64={enable_wow64}, disable_steamdeck_mode={disable_steamdeck_mode}")
- return {
- "success": True,
- "config": config,
- "message": "lsfg configuration updated successfully",
- "error": None
- }
+ return self._success_response(ConfigurationResponse,
+ "lsfg configuration updated successfully",
+ config=config)
except (OSError, IOError) as e:
error_msg = f"Error updating lsfg config: {str(e)}"
self.log.error(error_msg)
- return {
- "success": False,
- "config": None,
- "message": None,
- "error": str(e)
- }
+ return self._error_response(ConfigurationResponse, str(e), config=None)
except ValueError as e:
error_msg = f"Invalid configuration arguments: {str(e)}"
self.log.error(error_msg)
- return {
- "success": False,
- "config": None,
- "message": None,
- "error": str(e)
- }
+ return self._error_response(ConfigurationResponse, str(e), config=None)
def update_dll_path(self, dll_path: str) -> ConfigurationResponse:
"""Update just the DLL path in the configuration
@@ -183,22 +157,14 @@ class ConfigurationService(BaseService):
self.log.info(f"Updated DLL path in lsfg configuration: '{dll_path}'")
- return {
- "success": True,
- "config": config,
- "message": f"DLL path updated to: {dll_path}",
- "error": None
- }
+ return self._success_response(ConfigurationResponse,
+ f"DLL path updated to: {dll_path}",
+ config=config)
except Exception as e:
error_msg = f"Error updating DLL path: {str(e)}"
self.log.error(error_msg)
- return {
- "success": False,
- "config": None,
- "message": None,
- "error": str(e)
- }
+ return self._error_response(ConfigurationResponse, str(e), config=None)
def update_lsfg_script(self, config: ConfigurationData) -> ConfigurationResponse:
"""Update the ~/lsfg launch script with current configuration
@@ -217,22 +183,14 @@ class ConfigurationService(BaseService):
self.log.info(f"Updated lsfg launch script at {self.lsfg_script_path}")
- return {
- "success": True,
- "config": config,
- "message": "Launch script updated successfully",
- "error": None
- }
+ return self._success_response(ConfigurationResponse,
+ "Launch script updated successfully",
+ config=config)
except Exception as e:
error_msg = f"Error updating launch script: {str(e)}"
self.log.error(error_msg)
- return {
- "success": False,
- "config": None,
- "message": None,
- "error": str(e)
- }
+ return self._error_response(ConfigurationResponse, str(e), config=None)
def _generate_script_content(self, config: ConfigurationData) -> str:
"""Generate the content for the ~/lsfg launch script
diff --git a/py_modules/lsfg_vk/installation.py b/py_modules/lsfg_vk/installation.py
index 5bfc88b..b340093 100644
--- a/py_modules/lsfg_vk/installation.py
+++ b/py_modules/lsfg_vk/installation.py
@@ -43,7 +43,7 @@ class InstallationService(BaseService):
if not zip_path.exists():
error_msg = f"{ZIP_FILENAME} not found at {zip_path}"
self.log.error(error_msg)
- return {"success": False, "error": error_msg, "message": ""}
+ return self._error_response(InstallationResponse, error_msg, message="")
# Create directories if they don't exist
self._ensure_directories()
@@ -58,17 +58,17 @@ class InstallationService(BaseService):
self._create_lsfg_launch_script()
self.log.info("lsfg-vk installed successfully")
- return {"success": True, "message": "lsfg-vk installed successfully", "error": None}
+ return self._success_response(InstallationResponse, "lsfg-vk installed successfully")
except (OSError, zipfile.BadZipFile, shutil.Error) as e:
error_msg = f"Error installing lsfg-vk: {str(e)}"
self.log.error(error_msg)
- return {"success": False, "error": str(e), "message": ""}
+ return self._error_response(InstallationResponse, str(e), message="")
except Exception as e:
# Catch unexpected errors but log them separately
error_msg = f"Unexpected error installing lsfg-vk: {str(e)}"
self.log.error(error_msg)
- return {"success": False, "error": str(e), "message": ""}
+ return self._error_response(InstallationResponse, str(e), message="")
def _extract_and_install_files(self, zip_path: Path) -> None:
"""Extract zip file and install files to appropriate locations
@@ -209,30 +209,20 @@ class InstallationService(BaseService):
pass # Directory not empty or other error, ignore
if not removed_files:
- return {
- "success": True,
- "message": "No lsfg-vk files found to remove",
- "removed_files": None,
- "error": None
- }
+ return self._success_response(UninstallationResponse,
+ "No lsfg-vk files found to remove",
+ removed_files=None)
self.log.info("lsfg-vk uninstalled successfully")
- return {
- "success": True,
- "message": f"lsfg-vk uninstalled successfully. Removed {len(removed_files)} files.",
- "removed_files": removed_files,
- "error": None
- }
+ return self._success_response(UninstallationResponse,
+ f"lsfg-vk uninstalled successfully. Removed {len(removed_files)} files.",
+ removed_files=removed_files)
except OSError as e:
error_msg = f"Error uninstalling lsfg-vk: {str(e)}"
self.log.error(error_msg)
- return {
- "success": False,
- "message": "",
- "removed_files": None,
- "error": str(e)
- }
+ return self._error_response(UninstallationResponse, str(e),
+ message="", removed_files=None)
def cleanup_on_uninstall(self) -> None:
"""Clean up lsfg-vk files when the plugin is uninstalled"""
diff --git a/shared_config.py b/shared_config.py
new file mode 100644
index 0000000..46bfc84
--- /dev/null
+++ b/shared_config.py
@@ -0,0 +1,106 @@
+"""
+Shared configuration schema constants.
+
+This file contains the canonical configuration schema that should be used
+by both Python and TypeScript code. Any changes to the configuration
+structure should be made here first.
+"""
+
+from typing import Dict, Any, Union
+from enum import Enum
+
+
+class ConfigFieldType(str, Enum):
+ """Configuration field types - must match TypeScript enum"""
+ BOOLEAN = "boolean"
+ INTEGER = "integer"
+ FLOAT = "float"
+ STRING = "string"
+
+
+# Canonical configuration schema - source of truth
+CONFIG_SCHEMA_DEF = {
+ "dll": {
+ "name": "dll",
+ "fieldType": ConfigFieldType.STRING,
+ "default": "/games/Lossless Scaling/Lossless.dll",
+ "description": "specify where Lossless.dll is stored"
+ },
+
+ "multiplier": {
+ "name": "multiplier",
+ "fieldType": ConfigFieldType.INTEGER,
+ "default": 1,
+ "description": "change the fps multiplier"
+ },
+
+ "flow_scale": {
+ "name": "flow_scale",
+ "fieldType": ConfigFieldType.FLOAT,
+ "default": 0.8,
+ "description": "change the flow scale"
+ },
+
+ "performance_mode": {
+ "name": "performance_mode",
+ "fieldType": ConfigFieldType.BOOLEAN,
+ "default": True,
+ "description": "use a lighter model for FG (recommended for most games)"
+ },
+
+ "hdr_mode": {
+ "name": "hdr_mode",
+ "fieldType": ConfigFieldType.BOOLEAN,
+ "default": False,
+ "description": "enable HDR mode (only for games that support HDR)"
+ },
+
+ "experimental_present_mode": {
+ "name": "experimental_present_mode",
+ "fieldType": ConfigFieldType.STRING,
+ "default": "fifo",
+ "description": "override Vulkan present mode (may cause crashes)"
+ },
+
+ "dxvk_frame_rate": {
+ "name": "dxvk_frame_rate",
+ "fieldType": ConfigFieldType.INTEGER,
+ "default": 0,
+ "description": "base framerate cap for DirectX games before frame multiplier"
+ },
+
+ "enable_wow64": {
+ "name": "enable_wow64",
+ "fieldType": ConfigFieldType.BOOLEAN,
+ "default": False,
+ "description": "enable PROTON_USE_WOW64=1 for 32-bit games (use with ProtonGE to fix crashing)"
+ },
+
+ "disable_steamdeck_mode": {
+ "name": "disable_steamdeck_mode",
+ "fieldType": ConfigFieldType.BOOLEAN,
+ "default": False,
+ "description": "disable Steam Deck mode (unlocks hidden settings in some games)"
+ }
+}
+
+
+def get_field_names() -> list[str]:
+ """Get ordered list of configuration field names"""
+ return list(CONFIG_SCHEMA_DEF.keys())
+
+
+def get_defaults() -> Dict[str, Union[bool, int, float, str]]:
+ """Get default configuration values"""
+ return {
+ field_name: field_def["default"]
+ for field_name, field_def in CONFIG_SCHEMA_DEF.items()
+ }
+
+
+def get_field_types() -> Dict[str, str]:
+ """Get field type mapping"""
+ return {
+ field_name: field_def["fieldType"].value
+ for field_name, field_def in CONFIG_SCHEMA_DEF.items()
+ }
diff --git a/src/components/SmartClipboardButton.tsx b/src/components/SmartClipboardButton.tsx
index 81223bd..098b53d 100644
--- a/src/components/SmartClipboardButton.tsx
+++ b/src/components/SmartClipboardButton.tsx
@@ -1,8 +1,9 @@
import { useState } from "react";
import { PanelSectionRow, ButtonItem } from "@decky/ui";
import { FaClipboard } from "react-icons/fa";
-import { toaster } from "@decky/api";
import { getLaunchOption } from "../api/lsfgApi";
+import { showClipboardSuccessToast, showClipboardErrorToast, showSuccessToast } from "../utils/toastUtils";
+import { copyWithVerification } from "../utils/clipboardUtils";
export function SmartClipboardButton() {
const [isLoading, setIsLoading] = useState(false);
@@ -22,72 +23,20 @@ export function SmartClipboardButton() {
setIsLoading(true);
try {
const text = await getLaunchOptionText();
+ const { success, verified } = await copyWithVerification(text);
- // Use the proven input simulation method
- const tempInput = document.createElement('input');
- tempInput.value = text;
- tempInput.style.position = 'absolute';
- tempInput.style.left = '-9999px';
- document.body.appendChild(tempInput);
-
- // Focus and select the text
- tempInput.focus();
- tempInput.select();
-
- // Try copying using execCommand first (most reliable in gaming mode)
- let copySuccess = false;
- try {
- if (document.execCommand('copy')) {
- copySuccess = true;
- }
- } catch (e) {
- // If execCommand fails, try navigator.clipboard as fallback
- try {
- await navigator.clipboard.writeText(text);
- copySuccess = true;
- } catch (clipboardError) {
- console.error('Both copy methods failed:', e, clipboardError);
- }
- }
-
- // Clean up
- document.body.removeChild(tempInput);
-
- if (copySuccess) {
- // Verify the copy worked by reading back
- try {
- const readBack = await navigator.clipboard.readText();
- if (readBack === text) {
- toaster.toast({
- title: "Copied to Clipboard!",
- body: "Launch option ready to paste"
- });
- } else {
- // Copy worked but verification failed - still consider it success
- toaster.toast({
- title: "Copied to Clipboard!",
- body: "Launch option copied (verification unavailable)"
- });
- }
- } catch (e) {
- // Verification failed but copy likely worked
- toaster.toast({
- title: "Copied to Clipboard!",
- body: "Launch option copied successfully"
- });
+ if (success) {
+ if (verified) {
+ showClipboardSuccessToast();
+ } else {
+ showSuccessToast("Copied to Clipboard!", "Launch option copied (verification unavailable)");
}
} else {
- toaster.toast({
- title: "Copy Failed",
- body: "Unable to copy to clipboard"
- });
+ showClipboardErrorToast();
}
} catch (error) {
- toaster.toast({
- title: "Copy Failed",
- body: `Error: ${String(error)}`
- });
+ showClipboardErrorToast();
} finally {
setIsLoading(false);
}
diff --git a/src/components/UsageInstructions.tsx b/src/components/UsageInstructions.tsx
index bcf258b..0c27517 100644
--- a/src/components/UsageInstructions.tsx
+++ b/src/components/UsageInstructions.tsx
@@ -5,7 +5,7 @@ interface UsageInstructionsProps {
config: ConfigurationData;
}
-export function UsageInstructions({ config }: UsageInstructionsProps) {
+export function UsageInstructions({ config: _config }: UsageInstructionsProps) {
return (
<>
<PanelSectionRow>
@@ -56,26 +56,6 @@ export function UsageInstructions({ config }: UsageInstructionsProps) {
</div>
</PanelSectionRow>
- {/* <PanelSectionRow>
- <div
- style={{
- fontSize: "12px",
- lineHeight: "1.4",
- opacity: "0.8",
- whiteSpace: "pre-wrap"
- }}
- >
- {`Current Configuration:
-• DLL Path: ${config.dll}
-• Multiplier: ${config.multiplier}x
-• Flow Scale: ${Math.round(config.flow_scale * 100)}%
-• Performance Mode: ${config.performance_mode ? "Yes" : "No"}
-• HDR Mode: ${config.hdr_mode ? "Yes" : "No"}
-• Present Mode: ${config.experimental_present_mode || "FIFO (VSync)"}
-• DXVK Frame Rate: ${config.dxvk_frame_rate > 0 ? `${config.dxvk_frame_rate} FPS` : "Off"}`}
- </div>
- </PanelSectionRow> */}
-
<PanelSectionRow>
<div
style={{
diff --git a/src/components/index.ts b/src/components/index.ts
index 682598c..e4568f1 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -2,7 +2,7 @@ export { Content } from "./Content";
export { StatusDisplay } from "./StatusDisplay";
export { InstallationButton } from "./InstallationButton";
export { ConfigurationSection } from "./ConfigurationSection";
-// export { UsageInstructions } from "./UsageInstructions";
+export { UsageInstructions } from "./UsageInstructions";
export { WikiButton } from "./WikiButton";
export { SmartClipboardButton } from "./SmartClipboardButton";
export { LaunchOptionInfo } from "./LaunchOptionInfo";
diff --git a/src/config/configSchema.ts b/src/config/configSchema.ts
index 03b1510..4ab0d25 100644
--- a/src/config/configSchema.ts
+++ b/src/config/configSchema.ts
@@ -1,181 +1,147 @@
/**
- * Centralized configuration schema for lsfg-vk frontend.
+ * Configuration schema and management for LSFG VK plugin
*
- * This mirrors the Python configuration schema to ensure consistency
- * between frontend and backend configuration handling.
+ * This file re-exports auto-generated configuration constants from generatedConfigSchema.ts
+ * and provides the ConfigurationManager class for handling configuration operations.
*/
-// Configuration field type enum
-export enum ConfigFieldType {
- BOOLEAN = "boolean",
- INTEGER = "integer",
- FLOAT = "float",
- STRING = "string"
-}
+import { callable } from "@decky/api";
+import type { ConfigurationData } from './generatedConfigSchema';
+import { getDefaults } from './generatedConfigSchema';
-// Configuration field definition
-export interface ConfigField {
- name: string;
- fieldType: ConfigFieldType;
- default: boolean | number | string;
- description: string;
-}
+// Re-export all auto-generated configuration constants
+export {
+ ConfigFieldType,
+ ConfigField,
+ CONFIG_SCHEMA,
+ ConfigurationData,
+ getFieldNames,
+ getDefaults,
+ getFieldTypes
+} from './generatedConfigSchema';
-// Configuration schema - must match Python CONFIG_SCHEMA
-export const CONFIG_SCHEMA: Record<string, ConfigField> = {
- dll: {
- name: "dll",
- fieldType: ConfigFieldType.STRING,
- default: "/games/Lossless Scaling/Lossless.dll",
- description: "specify where Lossless.dll is stored"
- },
-
- multiplier: {
- name: "multiplier",
- fieldType: ConfigFieldType.INTEGER,
- default: 1,
- description: "change the fps multiplier"
- },
-
- flow_scale: {
- name: "flow_scale",
- fieldType: ConfigFieldType.FLOAT,
- default: 0.8,
- description: "change the flow scale"
- },
-
- performance_mode: {
- name: "performance_mode",
- fieldType: ConfigFieldType.BOOLEAN,
- default: true,
- description: "toggle performance mode"
- },
-
- hdr_mode: {
- name: "hdr_mode",
- fieldType: ConfigFieldType.BOOLEAN,
- default: false,
- description: "enable hdr in games that support it"
- },
-
- experimental_present_mode: {
- name: "experimental_present_mode",
- fieldType: ConfigFieldType.STRING,
- default: "fifo",
- description: "experimental: override vulkan present mode (fifo/mailbox/immediate)"
- },
-
- dxvk_frame_rate: {
- name: "dxvk_frame_rate",
- fieldType: ConfigFieldType.INTEGER,
- default: 0,
- description: "Base framerate cap for DirectX games, before frame multiplier (0 = disabled, requires game re-launch)"
- },
-
- enable_wow64: {
- name: "enable_wow64",
- fieldType: ConfigFieldType.BOOLEAN,
- default: false,
- description: "enable PROTON_USE_WOW64=1 for 32-bit games (use with ProtonGE to fix crashing)"
- },
-
- disable_steamdeck_mode: {
- name: "disable_steamdeck_mode",
- fieldType: ConfigFieldType.BOOLEAN,
- default: false,
- description: "disable Steam Deck mode (unlocks hidden settings in some games)"
- }
-};
+/**
+ * Configuration management class
+ * Handles CRUD operations for plugin configuration
+ */
+export class ConfigurationManager {
+ private static instance: ConfigurationManager;
+ private _config: ConfigurationData | null = null;
-// Type-safe configuration data structure
-export interface ConfigurationData {
- dll: string;
- multiplier: number;
- flow_scale: number;
- performance_mode: boolean;
- hdr_mode: boolean;
- experimental_present_mode: string;
- dxvk_frame_rate: number;
- enable_wow64: boolean;
- disable_steamdeck_mode: boolean;
-}
+ // Callable methods for backend communication
+ private getConfiguration = callable<[], { success: boolean; data?: ConfigurationData; error?: string }>("get_configuration");
+ private setConfiguration = callable<[{ config_data: ConfigurationData }], { success: boolean; error?: string }>("set_configuration");
+ private resetConfiguration = callable<[], { success: boolean; data?: ConfigurationData; error?: string }>("reset_configuration");
+
+ private constructor() {}
+
+ static getInstance(): ConfigurationManager {
+ if (!ConfigurationManager.instance) {
+ ConfigurationManager.instance = new ConfigurationManager();
+ }
+ return ConfigurationManager.instance;
+ }
-// Centralized configuration manager
-export class ConfigurationManager {
/**
* Get default configuration values
*/
static getDefaults(): ConfigurationData {
- const defaults = {} as ConfigurationData;
- Object.values(CONFIG_SCHEMA).forEach(field => {
- (defaults as any)[field.name] = field.default;
- });
- return defaults;
+ return getDefaults();
}
/**
- * Get ordered list of configuration field names
+ * Create args array from config object for lsfg API calls
*/
- static getFieldNames(): string[] {
- return Object.keys(CONFIG_SCHEMA);
+ static createArgsFromConfig(config: ConfigurationData): [string, number, number, boolean, boolean, string, number, boolean, boolean] {
+ return [
+ config.dll,
+ config.multiplier,
+ config.flow_scale,
+ config.performance_mode,
+ config.hdr_mode,
+ config.experimental_present_mode,
+ config.dxvk_frame_rate,
+ config.enable_wow64,
+ config.disable_steamdeck_mode
+ ];
}
/**
- * Get field type mapping
+ * Load configuration from backend
*/
- static getFieldTypes(): Record<string, ConfigFieldType> {
- return Object.values(CONFIG_SCHEMA).reduce((acc, field) => {
- acc[field.name] = field.fieldType;
- return acc;
- }, {} as Record<string, ConfigFieldType>);
+ async loadConfig(): Promise<ConfigurationData> {
+ try {
+ const result = await this.getConfiguration();
+ if (result.success && result.data) {
+ this._config = result.data;
+ return this._config;
+ } else {
+ throw new Error(result.error || 'Failed to load configuration');
+ }
+ } catch (error) {
+ console.error('Error loading configuration:', error);
+ throw error;
+ }
}
/**
- * Create ordered arguments array from configuration object
+ * Save configuration to backend
*/
- static createArgsFromConfig(config: ConfigurationData): (boolean | number | string)[] {
- return this.getFieldNames().map(fieldName =>
- config[fieldName as keyof ConfigurationData]
- );
+ async saveConfig(config: ConfigurationData): Promise<void> {
+ try {
+ const result = await this.setConfiguration({ config_data: config });
+ if (result.success) {
+ this._config = config;
+ } else {
+ throw new Error(result.error || 'Failed to save configuration');
+ }
+ } catch (error) {
+ console.error('Error saving configuration:', error);
+ throw error;
+ }
}
/**
- * Validate configuration object against schema
+ * Update a single configuration field
*/
- static validateConfig(config: Partial<ConfigurationData>): ConfigurationData {
- const defaults = this.getDefaults();
- const validated = { ...defaults };
-
- Object.entries(CONFIG_SCHEMA).forEach(([fieldName, fieldDef]) => {
- const value = config[fieldName as keyof ConfigurationData];
- if (value !== undefined) {
- // Type validation
- if (fieldDef.fieldType === ConfigFieldType.BOOLEAN) {
- (validated as any)[fieldName] = Boolean(value);
- } else if (fieldDef.fieldType === ConfigFieldType.INTEGER) {
- (validated as any)[fieldName] = parseInt(String(value), 10);
- } else if (fieldDef.fieldType === ConfigFieldType.FLOAT) {
- (validated as any)[fieldName] = parseFloat(String(value));
- } else if (fieldDef.fieldType === ConfigFieldType.STRING) {
- (validated as any)[fieldName] = String(value);
- }
- }
- });
-
- return validated;
+ async updateField(fieldName: keyof ConfigurationData, value: any): Promise<void> {
+ if (!this._config) {
+ await this.loadConfig();
+ }
+
+ const updatedConfig = {
+ ...this._config!,
+ [fieldName]: value
+ };
+
+ await this.saveConfig(updatedConfig);
}
/**
- * Get configuration field definition
+ * Get current configuration (cached)
*/
- static getFieldDef(fieldName: string): ConfigField | undefined {
- return CONFIG_SCHEMA[fieldName];
+ getConfig(): ConfigurationData | null {
+ return this._config;
}
/**
- * Get all field definitions
+ * Reset configuration to defaults
*/
- static getAllFieldDefs(): ConfigField[] {
- return Object.values(CONFIG_SCHEMA);
+ async resetToDefaults(): Promise<ConfigurationData> {
+ try {
+ const result = await this.resetConfiguration();
+ if (result.success && result.data) {
+ this._config = result.data;
+ return this._config;
+ } else {
+ throw new Error(result.error || 'Failed to reset configuration');
+ }
+ } catch (error) {
+ console.error('Error resetting configuration:', error);
+ throw error;
+ }
}
}
+
+// Export singleton instance
+export const configManager = ConfigurationManager.getInstance();
diff --git a/src/config/generatedConfigSchema.ts b/src/config/generatedConfigSchema.ts
new file mode 100644
index 0000000..9813f17
--- /dev/null
+++ b/src/config/generatedConfigSchema.ts
@@ -0,0 +1,127 @@
+/**
+ * Auto-generated TypeScript configuration schema.
+ * DO NOT EDIT MANUALLY - Generated from shared_config.py
+ *
+ * To update this file, run: python3 generate_ts_config.py > src/config/generatedConfigSchema.ts
+ */
+
+// Configuration field type enum - matches Python
+export enum ConfigFieldType {
+ BOOLEAN = "boolean",
+ INTEGER = "integer",
+ FLOAT = "float",
+ STRING = "string"
+}
+
+// Configuration field definition
+export interface ConfigField {
+ name: string;
+ fieldType: ConfigFieldType;
+ default: boolean | number | string;
+ description: string;
+}
+
+// Configuration schema - auto-generated from Python
+export const CONFIG_SCHEMA: Record<string, ConfigField> = {
+ dll: {
+ name: "dll",
+ fieldType: ConfigFieldType.STRING,
+ default: "/games/Lossless Scaling/Lossless.dll",
+ description: "specify where Lossless.dll is stored"
+ },
+ multiplier: {
+ name: "multiplier",
+ fieldType: ConfigFieldType.INTEGER,
+ default: 1,
+ description: "change the fps multiplier"
+ },
+ flow_scale: {
+ name: "flow_scale",
+ fieldType: ConfigFieldType.FLOAT,
+ default: 0.8,
+ description: "change the flow scale"
+ },
+ performance_mode: {
+ name: "performance_mode",
+ fieldType: ConfigFieldType.BOOLEAN,
+ default: true,
+ description: "use a lighter model for FG (recommended for most games)"
+ },
+ hdr_mode: {
+ name: "hdr_mode",
+ fieldType: ConfigFieldType.BOOLEAN,
+ default: false,
+ description: "enable HDR mode (only for games that support HDR)"
+ },
+ experimental_present_mode: {
+ name: "experimental_present_mode",
+ fieldType: ConfigFieldType.STRING,
+ default: "fifo",
+ description: "override Vulkan present mode (may cause crashes)"
+ },
+ dxvk_frame_rate: {
+ name: "dxvk_frame_rate",
+ fieldType: ConfigFieldType.INTEGER,
+ default: 0,
+ description: "base framerate cap for DirectX games before frame multiplier"
+ },
+ enable_wow64: {
+ name: "enable_wow64",
+ fieldType: ConfigFieldType.BOOLEAN,
+ default: false,
+ description: "enable PROTON_USE_WOW64=1 for 32-bit games (use with ProtonGE to fix crashing)"
+ },
+ disable_steamdeck_mode: {
+ name: "disable_steamdeck_mode",
+ fieldType: ConfigFieldType.BOOLEAN,
+ default: false,
+ description: "disable Steam Deck mode (unlocks hidden settings in some games)"
+ },
+};
+
+// Type-safe configuration data structure
+export interface ConfigurationData {
+ dll: string;
+ multiplier: number;
+ flow_scale: number;
+ performance_mode: boolean;
+ hdr_mode: boolean;
+ experimental_present_mode: string;
+ dxvk_frame_rate: number;
+ enable_wow64: boolean;
+ disable_steamdeck_mode: boolean;
+}
+
+// Helper functions
+export function getFieldNames(): string[] {
+ return Object.keys(CONFIG_SCHEMA);
+}
+
+export function getDefaults(): ConfigurationData {
+ return {
+ dll: "/games/Lossless Scaling/Lossless.dll",
+ multiplier: 1,
+ flow_scale: 0.8,
+ performance_mode: true,
+ hdr_mode: false,
+ experimental_present_mode: "fifo",
+ dxvk_frame_rate: 0,
+ enable_wow64: false,
+ disable_steamdeck_mode: false,
+ };
+}
+
+export function getFieldTypes(): Record<string, ConfigFieldType> {
+ return {
+ dll: ConfigFieldType.STRING,
+ multiplier: ConfigFieldType.INTEGER,
+ flow_scale: ConfigFieldType.FLOAT,
+ performance_mode: ConfigFieldType.BOOLEAN,
+ hdr_mode: ConfigFieldType.BOOLEAN,
+ experimental_present_mode: ConfigFieldType.STRING,
+ dxvk_frame_rate: ConfigFieldType.INTEGER,
+ enable_wow64: ConfigFieldType.BOOLEAN,
+ disable_steamdeck_mode: ConfigFieldType.BOOLEAN,
+ };
+}
+
diff --git a/src/hooks/useInstallationActions.ts b/src/hooks/useInstallationActions.ts
index 8dcf831..18de6b5 100644
--- a/src/hooks/useInstallationActions.ts
+++ b/src/hooks/useInstallationActions.ts
@@ -1,6 +1,11 @@
import { useState } from "react";
-import { toaster } from "@decky/api";
import { installLsfgVk, uninstallLsfgVk } from "../api/lsfgApi";
+import {
+ showInstallSuccessToast,
+ showInstallErrorToast,
+ showUninstallSuccessToast,
+ showUninstallErrorToast
+} from "../utils/toastUtils";
export function useInstallationActions() {
const [isInstalling, setIsInstalling] = useState<boolean>(false);
@@ -19,10 +24,7 @@ export function useInstallationActions() {
if (result.success) {
setIsInstalled(true);
setInstallationStatus("lsfg-vk installed successfully!");
- toaster.toast({
- title: "Installation Complete",
- body: "lsfg-vk has been installed successfully"
- });
+ showInstallSuccessToast();
// Reload lsfg config after installation
if (reloadConfig) {
@@ -30,17 +32,11 @@ export function useInstallationActions() {
}
} else {
setInstallationStatus(`Installation failed: ${result.error}`);
- toaster.toast({
- title: "Installation Failed",
- body: result.error || "Unknown error occurred"
- });
+ showInstallErrorToast(result.error);
}
} catch (error) {
setInstallationStatus(`Installation failed: ${error}`);
- toaster.toast({
- title: "Installation Failed",
- body: `Error: ${error}`
- });
+ showInstallErrorToast(String(error));
} finally {
setIsInstalling(false);
}
@@ -58,23 +54,14 @@ export function useInstallationActions() {
if (result.success) {
setIsInstalled(false);
setInstallationStatus("lsfg-vk uninstalled successfully!");
- toaster.toast({
- title: "Uninstallation Complete",
- body: result.message || "lsfg-vk has been uninstalled successfully"
- });
+ showUninstallSuccessToast();
} else {
setInstallationStatus(`Uninstallation failed: ${result.error}`);
- toaster.toast({
- title: "Uninstallation Failed",
- body: result.error || "Unknown error occurred"
- });
+ showUninstallErrorToast(result.error);
}
} catch (error) {
setInstallationStatus(`Uninstallation failed: ${error}`);
- toaster.toast({
- title: "Uninstallation Failed",
- body: `Error: ${error}`
- });
+ showUninstallErrorToast(String(error));
} finally {
setIsUninstalling(false);
}
diff --git a/src/hooks/useLsfgHooks.ts b/src/hooks/useLsfgHooks.ts
index e514d72..e5dea63 100644
--- a/src/hooks/useLsfgHooks.ts
+++ b/src/hooks/useLsfgHooks.ts
@@ -1,5 +1,4 @@
import { useState, useEffect, useCallback } from "react";
-import { toaster } from "@decky/api";
import {
checkLsfgVkInstalled,
checkLosslessScalingDll,
@@ -8,6 +7,7 @@ import {
type ConfigUpdateResult
} from "../api/lsfgApi";
import { ConfigurationData, ConfigurationManager } from "../config/configSchema";
+import { showErrorToast, ToastMessages } from "../utils/toastUtils";
export function useInstallationStatus() {
const [isInstalled, setIsInstalled] = useState<boolean>(false);
@@ -95,17 +95,14 @@ export function useLsfgConfig() {
if (result.success) {
setConfig(newConfig);
} else {
- toaster.toast({
- title: "Update Failed",
- body: result.error || "Failed to update configuration"
- });
+ showErrorToast(
+ ToastMessages.CONFIG_UPDATE_ERROR.title,
+ result.error || ToastMessages.CONFIG_UPDATE_ERROR.body
+ );
}
return result;
} catch (error) {
- toaster.toast({
- title: "Update Failed",
- body: `Error: ${error}`
- });
+ showErrorToast(ToastMessages.CONFIG_UPDATE_ERROR.title, String(error));
return { success: false, error: String(error) };
}
}, []);
diff --git a/src/utils/clipboardUtils.ts b/src/utils/clipboardUtils.ts
new file mode 100644
index 0000000..2d480fc
--- /dev/null
+++ b/src/utils/clipboardUtils.ts
@@ -0,0 +1,70 @@
+/**
+ * Clipboard utilities for reliable copy operations across different environments
+ */
+
+/**
+ * Reliably copy text to clipboard using multiple fallback methods
+ * This is especially important in gaming mode where clipboard APIs may behave differently
+ */
+export async function copyToClipboard(text: string): Promise<boolean> {
+ // Use the proven input simulation method
+ const tempInput = document.createElement('input');
+ tempInput.value = text;
+ tempInput.style.position = 'absolute';
+ tempInput.style.left = '-9999px';
+ document.body.appendChild(tempInput);
+
+ try {
+ // Focus and select the text
+ tempInput.focus();
+ tempInput.select();
+
+ // Try copying using execCommand first (most reliable in gaming mode)
+ let copySuccess = false;
+ try {
+ if (document.execCommand('copy')) {
+ copySuccess = true;
+ }
+ } catch (e) {
+ // If execCommand fails, try navigator.clipboard as fallback
+ try {
+ await navigator.clipboard.writeText(text);
+ copySuccess = true;
+ } catch (clipboardError) {
+ console.error('Both copy methods failed:', e, clipboardError);
+ }
+ }
+
+ return copySuccess;
+ } finally {
+ // Clean up
+ document.body.removeChild(tempInput);
+ }
+}
+
+/**
+ * Verify that text was successfully copied to clipboard
+ */
+export async function verifyCopy(expectedText: string): Promise<boolean> {
+ try {
+ const readBack = await navigator.clipboard.readText();
+ return readBack === expectedText;
+ } catch (e) {
+ // Verification not available, assume success
+ return true;
+ }
+}
+
+/**
+ * Copy text with verification and return success status
+ */
+export async function copyWithVerification(text: string): Promise<{ success: boolean; verified: boolean }> {
+ const copySuccess = await copyToClipboard(text);
+
+ if (!copySuccess) {
+ return { success: false, verified: false };
+ }
+
+ const verified = await verifyCopy(text);
+ return { success: true, verified };
+}
diff --git a/src/utils/toastUtils.ts b/src/utils/toastUtils.ts
new file mode 100644
index 0000000..dce0a59
--- /dev/null
+++ b/src/utils/toastUtils.ts
@@ -0,0 +1,115 @@
+/**
+ * Centralized toast notification utilities
+ * Provides consistent success/error messaging patterns
+ */
+
+import { toaster } from "@decky/api";
+
+export interface ToastOptions {
+ title: string;
+ body: string;
+}
+
+/**
+ * Show a success toast notification
+ */
+export function showSuccessToast(title: string, body: string): void {
+ toaster.toast({
+ title,
+ body
+ });
+}
+
+/**
+ * Show an error toast notification
+ */
+export function showErrorToast(title: string, body: string): void {
+ toaster.toast({
+ title,
+ body
+ });
+}
+
+/**
+ * Standard success messages for common operations
+ */
+export const ToastMessages = {
+ INSTALL_SUCCESS: {
+ title: "Installation Complete",
+ body: "lsfg-vk has been installed successfully"
+ },
+ INSTALL_ERROR: {
+ title: "Installation Failed",
+ body: "Unknown error occurred"
+ },
+ UNINSTALL_SUCCESS: {
+ title: "Uninstallation Complete",
+ body: "lsfg-vk has been uninstalled successfully"
+ },
+ UNINSTALL_ERROR: {
+ title: "Uninstallation Failed",
+ body: "Unknown error occurred"
+ },
+ CONFIG_UPDATE_ERROR: {
+ title: "Update Failed",
+ body: "Failed to update configuration"
+ },
+ CLIPBOARD_SUCCESS: {
+ title: "Copied to Clipboard!",
+ body: "Launch option ready to paste"
+ },
+ CLIPBOARD_ERROR: {
+ title: "Copy Failed",
+ body: "Unable to copy to clipboard"
+ }
+} as const;
+
+/**
+ * Show a toast with dynamic error message
+ */
+export function showErrorToastWithMessage(title: string, error: unknown): void {
+ const errorMessage = error instanceof Error ? error.message : String(error);
+ showErrorToast(title, errorMessage);
+}
+
+/**
+ * Show installation success toast
+ */
+export function showInstallSuccessToast(): void {
+ showSuccessToast(ToastMessages.INSTALL_SUCCESS.title, ToastMessages.INSTALL_SUCCESS.body);
+}
+
+/**
+ * Show installation error toast
+ */
+export function showInstallErrorToast(error?: string): void {
+ showErrorToast(ToastMessages.INSTALL_ERROR.title, error || ToastMessages.INSTALL_ERROR.body);
+}
+
+/**
+ * Show uninstallation success toast
+ */
+export function showUninstallSuccessToast(): void {
+ showSuccessToast(ToastMessages.UNINSTALL_SUCCESS.title, ToastMessages.UNINSTALL_SUCCESS.body);
+}
+
+/**
+ * Show uninstallation error toast
+ */
+export function showUninstallErrorToast(error?: string): void {
+ showErrorToast(ToastMessages.UNINSTALL_ERROR.title, error || ToastMessages.UNINSTALL_ERROR.body);
+}
+
+/**
+ * Show clipboard success toast
+ */
+export function showClipboardSuccessToast(): void {
+ showSuccessToast(ToastMessages.CLIPBOARD_SUCCESS.title, ToastMessages.CLIPBOARD_SUCCESS.body);
+}
+
+/**
+ * Show clipboard error toast
+ */
+export function showClipboardErrorToast(): void {
+ showErrorToast(ToastMessages.CLIPBOARD_ERROR.title, ToastMessages.CLIPBOARD_ERROR.body);
+}