diff options
| author | Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> | 2026-09-12 21:29:08 -0400 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-12 21:29:08 -0400 |
| commit | e580c6bd60c92af36f92d4c29b5d9a44f73d47d7 (patch) | |
| tree | 7fc6799626519e5b01fb137f5440c99fda5faca7 | |
| parent | e997a3fb74fa70f5e60b60807fb0897120e98313 (diff) | |
| parent | 81feb03288166755545401b9df2ff2c9bc3ae7d7 (diff) | |
| download | decky-lsfg-vk-e580c6bd60c92af36f92d4c29b5d9a44f73d47d7.tar.gz decky-lsfg-vk-e580c6bd60c92af36f92d4c29b5d9a44f73d47d7.zip | |
Merge pull request #264 from xXJSONDeruloXx/chore/migrate
the big one
79 files changed, 7783 insertions, 6474 deletions
@@ -1,6 +1,5 @@ # Decky LSFG-VK -> **Note:** > This is an **unofficial community plugin**. It is independently developed and **not officially supported** by the creators of Lossless Scaling or lsfg-vk. For support, please use the [decky-lsfg-vk Discord Channel](https://discord.gg/TwvHdVucC3). @@ -14,78 +13,42 @@ </p> -## What is this? - -A Decky plugin that streamlines the installation of **lsfg-vk** ([Lossless Scaling Frame Generation Vulkan layer](https://github.com/PancakeTAS/lsfg-vk)) on Steam Deck, allowing you to use the Lossless Scaling frame generation features on Linux with a controller friendly UI in SteamOS, Bazzite, or any other Linux platform compatible with Decky Loader. +Decky LSFG-VK is a Decky plugin that streamlines the installation of **lsfg-vk** ([Lossless Scaling Frame Generation Vulkan layer](https://lsfg-vk.dev/)) on Steam Deck, allowing you to use the Lossless Scaling frame generation features on Linux with a controller friendly UI in SteamOS, Bazzite, or any other Linux platform compatible with Decky Loader. ## Installation 1. **Download the plugin** from the [releases tab](https://github.com/xXJSONDeruloXx/decky-lsfg-vk/releases) - - Download the "decky-lsfg-vk.zip" file to your Steam Deck + - Download the "Decky LSFG-VK.zip" file to your Steam Deck 2. **Install manually through Decky**: - In Game Mode, go to the settings cog in the top right of the Decky Loader tab - Enable "Developer Mode" - Go to "Developer" tab and select "Install Plugin from Zip" - - Select the downloaded "decky-lsfg-vk.zip" file + - Select the downloaded "Decky LSFG-VK.zip" file ## How to Use 1. **Purchase and install** [Lossless Scaling](https://store.steampowered.com/app/993090/Lossless_Scaling/) from Steam +2. **Switch Branch** In the Lossless Scaling Steam app, go to Properties > Game Versions & Betas > select "lsfg-vk" branch, and let Steam download the new version 2. **Open the plugin** from the Decky menu 3. **Click "Install lsfg-vk"** to automatically set up the lsfg-vk vulkan layer -4. **Configure settings** using the plugin's UI - adjust FPS multiplier, flow scale, performance mode, HDR settings, and experimental features -5. **Apply launch option** to games you want to use frame generation with: - - Add `~/lsfg %command%` to your game's launch options in Steam Properties - - Or use the "Launch Option Clipboard" button in the plugin to copy the command -6. **Launch your game** - frame generation will activate automatically using your plugin configuration - -## Configuration Options - -The plugin provides several configuration options to optimize frame generation for your games: +4. **Configure settings** using the plugin's UI - select Default or a running/configured game and adjust the upstream lsfg-vk settings +5. **Configure Flatpak apps** in the Flatpak tab individually or with **Enable all** and **Remove all profiles** +6. **Launch your game** - frame generation activates when the game's Steam AppID matches an assigned upstream profile ### Core Settings -- **FPS Multiplier**: Choose between 2x, 3x, or 4x frame generation + +- **FPS Multiplier**: Choose 2x, 3x, or 4x frame generation - **Flow Scale**: Adjust motion estimation quality (lower = better performance, higher = better quality) -- **Performance Mode**: Uses a lighter processing model - recommended for most games -- **HDR Mode**: Enable for games that support HDR output +- **Performance Mode**: Uses a lighter processing model (recommended for most games) +- **FP16 Acceleration**: Use half-precision acceleration when supported ## Feedback and Support For per-game feedback and community support, please join the [decky-lsfg-vk Discord Channel](https://discord.gg/TwvHdVucC3) -## Troubleshooting - -**Frame generation not working?** -- Ensure you've added `~/lsfg %command%` to your game's launch options -- Check that the Lossless Scaling DLL was detected correctly in the plugin -- Try enabling Performance Mode if you're experiencing crashes -- Make sure your game is running in fullscreen mode for best results - -**Performance issues?** -- Lower the Flow Scale setting for better performance -- Enable Performance Mode (recommended for most games) -- Try reducing the FPS multiplier from 4x to 2x or 3x -- Consider using the experimental FPS limit feature for DirectX games - -## What it does - -The plugin: -- Automatically downloads and installs the latest lsfg-vk Vulkan layer to `~/.local/lib/` -- Configures the Vulkan layer in `~/.local/share/vulkan/implicit_layer.d/` -- Creates a TOML configuration file in `~/.config/lsfg-vk/conf.toml` with your settings -- Automatically detects your Lossless Scaling DLL installation -- Provides an easy-to-use interface to configure frame generation settings: - - **FPS Multiplier**: Choose 2x, 3x, or 4x frame generation - - **Flow Scale**: Adjust motion estimation quality vs performance - - **Performance Mode**: Use lighter processing for better performance - - **HDR Mode**: Enable for HDR-compatible games - - **Experimental Features**: Override present mode and set FPS limits -- **Hot-reloading**: Configuration changes apply immediately without restarting games -- Easy uninstallation that removes all installed files when no longer needed - ## Credits -- **[PancakeTAS](https://github.com/PancakeTAS/lsfg-vk)** for creating the lsfg-vk Vulkan compatibility layer +- **[PancakeTAS](https://lsfg-vk.dev/)** for creating the lsfg-vk Vulkan compatibility layer - **[Lossless Scaling](https://store.steampowered.com/app/993090/Lossless_Scaling/)** developers for the original frame generation technology - **[Deck Wizard](https://www.youtube.com/@DeckWizard)** - Extensive community support including comprehensive guides, promotional content, thorough testing and feedback, custom artworks, and tutorial videos. His passionate advocacy and continuous support have been instrumental in this plugin's success. - The **Decky Loader** team for the plugin framework diff --git a/assets/flatpak-target.png b/assets/flatpak-target.png Binary files differdeleted file mode 100644 index 773e567..0000000 --- a/assets/flatpak-target.png +++ /dev/null diff --git a/defaults/i18n/ja.json b/defaults/i18n/ja.json index 08c87df..fb4a3ad 100644 --- a/defaults/i18n/ja.json +++ b/defaults/i18n/ja.json @@ -9,7 +9,7 @@ "CONFIG_FLOW_SCALE_DESC": "内部モーション推定解像度を下げて、パフォーマンスをわずかに向上させます", "CONFIG_BASE_FPS_CAP": "基本FPS上限", "CONFIG_BASE_FPS_CAP_OFF": "オフ", - "CONFIG_BASE_FPS_CAP_DESC": "フレーム倍率適用前のDirectXゲームの基本フレームレート上限。(ゲームの再起動が必要)", + "CONFIG_BASE_FPS_CAP_DESC": "フレーム生成前のDXVKゲームの基本上限。0で無効。ゲームの再起動が必要です。", "CONFIG_PRESENT_MODE": "プレゼンテーションモード", "CONFIG_PRESENT_MODE_FIFO": "FIFO - VSync", "CONFIG_PRESENT_MODE_MAILBOX": "Mailbox", @@ -20,18 +20,14 @@ "CONFIG_HDR_MODE_DESC": "HDRモードを有効化します(HDRをサポートするゲームのみ)", "CONFIG_ENABLE_WSI": "WSIを有効化", "CONFIG_ENABLE_WSI_DESC": "Gamescope WSIレイヤーを再有効化します。ゲームの再起動が必要。", - "CONFIG_ENABLE_WOW64": "32ビットゲーム用WOW64を有効化", - "CONFIG_ENABLE_WOW64_DESC": "32ビットゲームにPROTON_USE_WOW64=1を有効化します(ProtonGEと併用してクラッシュを修正)", + "CONFIG_DISABLE_GAMESCOPE_WSI": "Gamescope WSIを無効化", + "CONFIG_DISABLE_GAMESCOPE_WSI_DESC": "HDRを変更せずENABLE_GAMESCOPE_WSI=0を追加します。ゲームの再起動が必要です。", "CONFIG_DISABLE_STEAMDECK_MODE": "Steam Deckモードを無効化", - "CONFIG_DISABLE_STEAMDECK_MODE_DESC": "Steam Deckモードを無効化します(一部ゲームの隠し設定を解放)", - "CONFIG_MANGOHUD_WORKAROUND": "MangoHudワークアラウンド", - "CONFIG_MANGOHUD_WORKAROUND_DESC": "透明なMangoHudオーバーレイを有効化します。ゲームモードでの2X倍率問題を修正することがあります", + "CONFIG_DISABLE_STEAMDECK_MODE_DESC": "ゲーム固有のSteam Deck互換スイッチを無効化します。ゲームの再起動が必要です。", "CONFIG_DISABLE_VKBASALT": "vkBasaltを無効化", "CONFIG_DISABLE_VKBASALT_DESC": "LSFGと競合する可能性のあるvkBasaltレイヤーを無効化します(Reshade、一部のDeckyプラグイン)", - "CONFIG_FORCE_ENABLE_VKBASALT": "vkBasaltを強制有効化", - "CONFIG_FORCE_ENABLE_VKBASALT_DESC": "ゲームモードのフレームペーシング問題を修正するためにvkBasaltを強制有効化します", - "CONFIG_ENABLE_ZINK": "OpenGLゲーム用Zinkを有効化", - "CONFIG_ENABLE_ZINK_DESC": "OpenGLゲームにVulkanベースのOpenGL実装を使用します(一部のゲームでクラッシュやフリーズが発生する場合があります)", + "CONFIG_ENABLE_ZINK": "OpenGLゲームでZinkを強制", + "CONFIG_ENABLE_ZINK_DESC": "MesaのZink OpenGL-to-Vulkanドライバーを使用します。一部のゲームでクラッシュやフリーズが発生する可能性があります。ゲームの再起動が必要です。", "INSTALL_INSTALLING": "インストール中...", "INSTALL_UNINSTALLING": "アンインストール中...", "INSTALL_UNINSTALL_BTN": "LSFG-VKをアンインストール", @@ -57,21 +53,12 @@ "FLATPAK_ERROR": "エラー", "FLATPAK_ERROR_STATUS": "拡張ステータスの確認に失敗しました", "FLATPAK_ERROR_APPS": "Flatpakアプリケーションの読み込みに失敗しました", - "FLATPAK_STEAM_CONFIG_TITLE": "Steam設定", - "FLATPAK_STEAM_CONFIG_HEADER": "Steam Flatpakショートカットの設定", - "FLATPAK_STEAM_CONFIG_DESC": "Steamでflatpakゲームを開き、歯車アイコンをクリックしてください。", - "FLATPAK_STEAM_CONFIG_IMPORTANT": "重要: 起動オプションではなくターゲット(TARGET)に設定してください", - "FLATPAK_STEP_TRY_FIRST": "まず試す:", - "FLATPAK_STEP_TRY_FULL_PATH": "うまくいかない場合、フルパスを試す:", - "FLATPAK_STEP_FINAL": "最終的な結果はこのようになります:", "FLATPAK_CLOSE": "閉じる", "NERD_LOADING": "情報を読み込み中...", "NERD_DLL_PATH": "DLLパス", "NERD_NOT_AVAILABLE": "利用不可", "NERD_DLL_HASH": "DLL SHA256ハッシュ", "NERD_DETECTION_SOURCE": "検出ソース", - "NERD_LAUNCH_SCRIPT": "起動スクリプト", - "NERD_SCRIPT_NOT_FOUND_PREFIX": "スクリプトが見つかりません:", "NERD_PATH_PREFIX": "パス:", "NERD_NO_CONTENT": "コンテンツなし", "NERD_CONFIG_FILE": "設定ファイル", @@ -96,12 +83,5 @@ "PROFILE_DELETE_DESC_SUFFIX": "この操作は取り消せません。", "PROFILE_DELETE_BTN": "削除", "PROFILE_CANNOT_RENAME_TITLE": "デフォルトプロファイルの名前は変更できません", - "PROFILE_CANNOT_RENAME_MSG": "デフォルトプロファイルの名前は変更できません", - "USAGE_TITLE": "使用方法", - "USAGE_DESC": "「起動オプションをコピー」ボタンをクリックし、Steamゲームの起動オプションに貼り付けてフレーム生成を有効化してください。", - "USAGE_CONFIG_NOTE": "設定は~/.config/lsfg-vk/conf.tomlに保存され、ゲーム実行中もホットリロードされます。", - "CLIPBOARD_COPIED": "クリップボードにコピーしました", - "CLIPBOARD_COPYING": "コピー中...", - "CLIPBOARD_COPY_LAUNCH": "起動オプションをコピー", - "CLIPBOARD_LSFG_FGMOD": "LSFG + DeckyFG" + "PROFILE_CANNOT_RENAME_MSG": "デフォルトプロファイルの名前は変更できません" } diff --git a/defaults/i18n/ko.json b/defaults/i18n/ko.json index 5e6f2b7..29783c4 100644 --- a/defaults/i18n/ko.json +++ b/defaults/i18n/ko.json @@ -9,7 +9,7 @@ "CONFIG_FLOW_SCALE_DESC": "내부 모션 추정 해상도를 낮춰 성능을 약간 향상시킵니다", "CONFIG_BASE_FPS_CAP": "기본 FPS 상한", "CONFIG_BASE_FPS_CAP_OFF": "끄기", - "CONFIG_BASE_FPS_CAP_DESC": "프레임 배율 적용 전 DirectX 게임의 기본 프레임 상한. (게임 재시작 필요)", + "CONFIG_BASE_FPS_CAP_DESC": "프레임 생성 전 DXVK 게임의 기본 제한입니다. 0은 비활성화합니다. 게임 재시작 필요.", "CONFIG_PRESENT_MODE": "프레젠테이션 모드", "CONFIG_PRESENT_MODE_FIFO": "FIFO - VSync", "CONFIG_PRESENT_MODE_MAILBOX": "Mailbox", @@ -20,18 +20,14 @@ "CONFIG_HDR_MODE_DESC": "HDR 모드를 활성화합니다 (HDR을 지원하는 게임에만 해당)", "CONFIG_ENABLE_WSI": "WSI 활성화", "CONFIG_ENABLE_WSI_DESC": "Gamescope WSI 레이어를 다시 활성화합니다. 게임 재시작 필요.", - "CONFIG_ENABLE_WOW64": "32비트 게임용 WOW64 활성화", - "CONFIG_ENABLE_WOW64_DESC": "32비트 게임에 PROTON_USE_WOW64=1을 활성화합니다 (크래시 수정을 위해 ProtonGE와 함께 사용)", + "CONFIG_DISABLE_GAMESCOPE_WSI": "Gamescope WSI 비활성화", + "CONFIG_DISABLE_GAMESCOPE_WSI_DESC": "HDR을 변경하지 않고 ENABLE_GAMESCOPE_WSI=0을 추가합니다. 게임 재시작 필요.", "CONFIG_DISABLE_STEAMDECK_MODE": "Steam Deck 모드 비활성화", - "CONFIG_DISABLE_STEAMDECK_MODE_DESC": "Steam Deck 모드를 비활성화합니다 (일부 게임의 숨겨진 설정 잠금 해제)", - "CONFIG_MANGOHUD_WORKAROUND": "MangoHud 우회", - "CONFIG_MANGOHUD_WORKAROUND_DESC": "투명한 MangoHud 오버레이를 활성화합니다. 게임 모드에서 2X 배율 문제를 수정하는 데 도움이 될 수 있습니다", + "CONFIG_DISABLE_STEAMDECK_MODE_DESC": "게임별 Steam Deck 호환 스위치를 비활성화합니다. 게임 재시작 필요.", "CONFIG_DISABLE_VKBASALT": "vkBasalt 비활성화", "CONFIG_DISABLE_VKBASALT_DESC": "LSFG와 충돌할 수 있는 vkBasalt 레이어를 비활성화합니다 (Reshade, 일부 Decky 플러그인)", - "CONFIG_FORCE_ENABLE_VKBASALT": "vkBasalt 강제 활성화", - "CONFIG_FORCE_ENABLE_VKBASALT_DESC": "게임 모드에서 프레임 페이싱 문제 수정을 위해 vkBasalt를 강제 활성화합니다", - "CONFIG_ENABLE_ZINK": "OpenGL 게임에 Zink 활성화", - "CONFIG_ENABLE_ZINK_DESC": "OpenGL 게임에 Vulkan 기반 OpenGL 구현을 사용합니다 (일부 게임에서 크래시나 멈춤이 발생할 수 있습니다)", + "CONFIG_ENABLE_ZINK": "OpenGL 게임에 Zink 강제", + "CONFIG_ENABLE_ZINK_DESC": "Mesa의 Zink OpenGL-to-Vulkan 드라이버를 사용합니다. 일부 게임에서 충돌 또는 멈춤이 발생할 수 있으며 게임 재시작이 필요합니다.", "INSTALL_INSTALLING": "설치 중...", "INSTALL_UNINSTALLING": "제거 중...", "INSTALL_UNINSTALL_BTN": "LSFG-VK 제거", @@ -57,21 +53,12 @@ "FLATPAK_ERROR": "오류", "FLATPAK_ERROR_STATUS": "확장 상태 확인 실패", "FLATPAK_ERROR_APPS": "Flatpak 애플리케이션 로드 실패", - "FLATPAK_STEAM_CONFIG_TITLE": "Steam 설정", - "FLATPAK_STEAM_CONFIG_HEADER": "Steam Flatpak 단축키 설정", - "FLATPAK_STEAM_CONFIG_DESC": "Steam에서 Flatpak 게임을 열고 톱니바퀴를 클릭하세요.", - "FLATPAK_STEAM_CONFIG_IMPORTANT": "중요: 실행 옵션이 아닌 대상(TARGET)에 설정하세요", - "FLATPAK_STEP_TRY_FIRST": "먼저 시도:", - "FLATPAK_STEP_TRY_FULL_PATH": "작동하지 않으면 전체 경로 시도:", - "FLATPAK_STEP_FINAL": "최종 결과는 다음과 같아야 합니다:", "FLATPAK_CLOSE": "닫기", "NERD_LOADING": "정보 불러오는 중...", "NERD_DLL_PATH": "DLL 경로", "NERD_NOT_AVAILABLE": "사용 불가", "NERD_DLL_HASH": "DLL SHA256 해시", "NERD_DETECTION_SOURCE": "감지 소스", - "NERD_LAUNCH_SCRIPT": "실행 스크립트", - "NERD_SCRIPT_NOT_FOUND_PREFIX": "스크립트 없음:", "NERD_PATH_PREFIX": "경로:", "NERD_NO_CONTENT": "내용 없음", "NERD_CONFIG_FILE": "설정 파일", @@ -96,12 +83,5 @@ "PROFILE_DELETE_DESC_SUFFIX": "이 작업은 취소할 수 없습니다.", "PROFILE_DELETE_BTN": "삭제", "PROFILE_CANNOT_RENAME_TITLE": "기본 프로필 이름 변경 불가", - "PROFILE_CANNOT_RENAME_MSG": "기본 프로필의 이름은 변경할 수 없습니다", - "USAGE_TITLE": "사용 방법", - "USAGE_DESC": "\"실행 옵션 복사\" 버튼을 클릭한 후, Steam 게임의 실행 옵션에 붙여넣어 프레임 생성을 활성화하세요.", - "USAGE_CONFIG_NOTE": "설정은 ~/.config/lsfg-vk/conf.toml에 저장되며 게임 실행 중에도 즉시 반영됩니다.", - "CLIPBOARD_COPIED": "클립보드에 복사됨", - "CLIPBOARD_COPYING": "복사 중...", - "CLIPBOARD_COPY_LAUNCH": "실행 옵션 복사", - "CLIPBOARD_LSFG_FGMOD": "LSFG + DeckyFG" + "PROFILE_CANNOT_RENAME_MSG": "기본 프로필의 이름은 변경할 수 없습니다" } diff --git a/defaults/i18n/template.json b/defaults/i18n/template.json index cb53b95..2a5b06e 100644 --- a/defaults/i18n/template.json +++ b/defaults/i18n/template.json @@ -9,7 +9,7 @@ "CONFIG_FLOW_SCALE_DESC": "Lowers internal motion estimation resolution, improving performance slightly", "CONFIG_BASE_FPS_CAP": "Base FPS Cap", "CONFIG_BASE_FPS_CAP_OFF": "Off", - "CONFIG_BASE_FPS_CAP_DESC": "Base framerate cap for DirectX games, before frame multiplier. (Requires game restart to apply)", + "CONFIG_BASE_FPS_CAP_DESC": "Base cap for DXVK-backed games before frame generation; 0 disables. Requires game restart to apply.", "CONFIG_PRESENT_MODE": "Present Mode", "CONFIG_PRESENT_MODE_FIFO": "FIFO - VSync", "CONFIG_PRESENT_MODE_MAILBOX": "Mailbox", @@ -20,18 +20,14 @@ "CONFIG_HDR_MODE_DESC": "Enables HDR mode (only for games that support HDR)", "CONFIG_ENABLE_WSI": "Enable WSI", "CONFIG_ENABLE_WSI_DESC": "Re-Enable Gamescope WSI Layer. Requires game restart to apply.", - "CONFIG_ENABLE_WOW64": "Enable WOW64 for 32-bit games", - "CONFIG_ENABLE_WOW64_DESC": "Enables PROTON_USE_WOW64=1 for 32-bit games (Use with ProtonGE to fix crashing)", + "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_STEAMDECK_MODE": "Disable Steam Deck Mode", - "CONFIG_DISABLE_STEAMDECK_MODE_DESC": "Disables Steam Deck mode (Unlocks hidden settings in some games)", - "CONFIG_MANGOHUD_WORKAROUND": "MangoHud Workaround", - "CONFIG_MANGOHUD_WORKAROUND_DESC": "Enables a transparent mangohud overlay, sometimes fixes issues with 2X multiplier in game mode", + "CONFIG_DISABLE_STEAMDECK_MODE_DESC": "Disables a game-specific Steam Deck compatibility switch. Requires game restart to apply.", "CONFIG_DISABLE_VKBASALT": "Disable vkBasalt", "CONFIG_DISABLE_VKBASALT_DESC": "Disables vkBasalt layer which can conflict with LSFG (Reshade, some Decky plugins)", - "CONFIG_FORCE_ENABLE_VKBASALT": "Force Enable vkBasalt", - "CONFIG_FORCE_ENABLE_VKBASALT_DESC": "Force vkBasalt to engage to fix framepacing issues in gamemode", - "CONFIG_ENABLE_ZINK": "Enable Zink for OpenGL Games", - "CONFIG_ENABLE_ZINK_DESC": "Use Vulkan-based OpenGL implementation for OpenGL games (may cause crashes or freezes with some games)", + "CONFIG_ENABLE_ZINK": "Force Zink for OpenGL Games", + "CONFIG_ENABLE_ZINK_DESC": "Uses Mesa's Zink OpenGL-to-Vulkan driver. May cause crashes or freezes with some games. Requires game restart to apply.", "INSTALL_INSTALLING": "Installing...", "INSTALL_UNINSTALLING": "Uninstalling...", "INSTALL_UNINSTALL_BTN": "Uninstall LSFG-VK", @@ -57,21 +53,12 @@ "FLATPAK_ERROR": "Error", "FLATPAK_ERROR_STATUS": "Failed to check extension status", "FLATPAK_ERROR_APPS": "Failed to load Flatpak applications", - "FLATPAK_STEAM_CONFIG_TITLE": "Steam Configuration", - "FLATPAK_STEAM_CONFIG_HEADER": "Configure Steam Flatpak Shortcuts", - "FLATPAK_STEAM_CONFIG_DESC": "In Steam, open your flatpak game and click the cog wheel.", - "FLATPAK_STEAM_CONFIG_IMPORTANT": "IMPORTANT: Set this in TARGET (NOT LAUNCH OPTIONS)", - "FLATPAK_STEP_TRY_FIRST": "Try first:", - "FLATPAK_STEP_TRY_FULL_PATH": "If that doesn't work, try full path:", - "FLATPAK_STEP_FINAL": "Final result should look like:", "FLATPAK_CLOSE": "Close", "NERD_LOADING": "Loading information...", "NERD_DLL_PATH": "DLL Path", "NERD_NOT_AVAILABLE": "Not available", "NERD_DLL_HASH": "DLL SHA256 Hash", "NERD_DETECTION_SOURCE": "Detection Source", - "NERD_LAUNCH_SCRIPT": "Launch Script", - "NERD_SCRIPT_NOT_FOUND_PREFIX": "Script not found:", "NERD_PATH_PREFIX": "Path:", "NERD_NO_CONTENT": "No content", "NERD_CONFIG_FILE": "Configuration File", @@ -96,12 +83,5 @@ "PROFILE_DELETE_DESC_SUFFIX": "? This action cannot be undone.", "PROFILE_DELETE_BTN": "Delete", "PROFILE_CANNOT_RENAME_TITLE": "Cannot rename default profile", - "PROFILE_CANNOT_RENAME_MSG": "The default profile cannot be renamed", - "USAGE_TITLE": "Usage Instructions", - "USAGE_DESC": "Click \"Copy Launch Option\" button, then paste it into your Steam game's launch options to enable frame generation.", - "USAGE_CONFIG_NOTE": "The configuration is stored in ~/.config/lsfg-vk/conf.toml and hot-reloads while games are running.", - "CLIPBOARD_COPIED": "Copied to clipboard", - "CLIPBOARD_COPYING": "Copying...", - "CLIPBOARD_COPY_LAUNCH": "Copy Launch Option", - "CLIPBOARD_LSFG_FGMOD": "LSFG + DeckyFG" + "PROFILE_CANNOT_RENAME_MSG": "The default profile cannot be renamed" } @@ -1,14 +1,14 @@ default: - echo "Available recipes: build, test, clean, generate-schema" - -generate-schema: - python3 scripts/generate_ts_schema.py + echo "Available recipes: build, deploy, test, clean" build: - python3 scripts/generate_ts_schema.py && sudo rm -rf node_modules && .vscode/build.sh + pnpm build + +deploy: + ./scripts/deploy-to-deck.sh test: - scp "out/Decky LSFG-VK.zip" deck@192.168.0.6:~/Desktop + pnpm test watch: ssh deck@192.168.0.6 "journalctl -f" @@ -18,4 +18,4 @@ cef: clean: rm -rf node_modules dist - sudo rm -rf /tmp/decky
\ No newline at end of file + sudo rm -rf /tmp/decky diff --git a/package.json b/package.json index 4e32940..735f9c4 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { "name": "decky-lsfg-vk", - "version": "0.12.8", + "version": "0.14.0", "description": "Use Lossless Scaling on the Steam Deck using the lsfg-vk vulkan layer", "type": "module", "scripts": { "build": "rollup -c", "watch": "rollup -c -w", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "node --experimental-strip-types --test tests/steamLaunchOptions.test.ts tests/nowPlaying.test.ts tests/gameTargets.test.ts && python3.12 -m unittest discover -s tests -p 'test_*.py'" }, "repository": { "type": "git", @@ -49,29 +49,9 @@ "remote_binary_bundling" : true, "remote_binary": [ { - "name": "lsfg-vk_noui.zip", - "url": "https://github.com/xXJSONDeruloXx/lsfg-vk/releases/download/fp16-test-2/lsfg-vk_noui.zip", - "sha256hash": "a406b3730144c2011e2c2acd3cf44f3ec6c048ee86099bc9c3ac90aa1515e5ec" - }, - { - "name": "org.freedesktop.Platform.VulkanLayer.lsfg_vk_23.08.flatpak", - "url": "https://github.com/PancakeTAS/lsfg-vk/releases/download/v0.9.0/org.freedesktop.Platform.VulkanLayer.lsfg_vk_23.08.flatpak", - "sha256hash": "8381d1eff0b0786af5a0e66955521eadec783866f73b9b1aa4664c952ed601a4" - }, - { - "name": "org.freedesktop.Platform.VulkanLayer.lsfg_vk_24.08.flatpak", - "url": "https://github.com/PancakeTAS/lsfg-vk/releases/download/v0.9.0/org.freedesktop.Platform.VulkanLayer.lsfg_vk_24.08.flatpak", - "sha256hash": "70ea6d9d2a8adad63b8e5c2c3d94a636764ad4d0266ce9f37cb9dc7943d8fc3a" - }, - { - "name": "org.freedesktop.Platform.VulkanLayer.lsfg_vk_25.08.flatpak", - "url": "https://github.com/PancakeTAS/lsfg-vk/releases/download/v1.0.0/org.freedesktop.Platform.VulkanLayer.lsfg_vk_25.08.flatpak", - "sha256hash": "0651bda96751ef0f1314a5179585926a0cd354476790ca2616662c39fe6fae54" - }, - { - "name": "liblsfg-vk-arm64.so", - "url": "https://github.com/Janleyx/decky-lsfg-vk-for-Ayn-Odin-2/releases/download/arm64-layer-3e89e54/liblsfg-vk-arm64.so", - "sha256hash": "2e84f8d1a1dd0344474846f6252d6f948f72caaaeab3cd316c04254be05a9949" + "name": "lsfg-vk-2.0.0.tar.xz", + "url": "https://builds.lsfg-vk.dev/lsfg-vk-2.0.0.tar.xz", + "sha256hash": "08bdbdf373a111022df87dac7aa87e3b564bb841f961552e3ca85fea12b5aa74" } ], "pnpm": { diff --git a/py_modules/lsfg_vk/base_service.py b/py_modules/lsfg_vk/base_service.py index 262e2b0..036dfc6 100644 --- a/py_modules/lsfg_vk/base_service.py +++ b/py_modules/lsfg_vk/base_service.py @@ -1,132 +1,66 @@ -""" -Base service class with common functionality. -""" - -import logging import os -import shutil +import tempfile from pathlib import Path -from typing import Any, Optional, TypeVar, Dict +from typing import Any, Dict, Optional, TypeVar import decky -from .constants import LOCAL_LIB, LOCAL_SHARE_BASE, VULKAN_LAYER_DIR, SCRIPT_NAME, CONFIG_DIR, CONFIG_FILENAME +from .constants import CONFIG_DIR, CONFIG_FILENAME, LOCAL_BIN, LOCAL_LIB, SCRIPT_NAME, VULKAN_LAYER_DIR -ResponseType = TypeVar('ResponseType', bound=Dict[str, Any]) +ResponseType = TypeVar("ResponseType", bound=Dict[str, Any]) class BaseService: - """Base service class with common functionality""" - def __init__(self, logger: Optional[Any] = None): - """Initialize base service - - Args: - logger: Logger instance, defaults to decky.logger if None - """ - if logger is None: - self.log = decky.logger - else: - self.log = logger - - self.user_home = Path.home() + self.log = decky.logger if logger is None else logger + decky_user_home = getattr(decky, "DECKY_USER_HOME", None) + self.user_home = Path(decky_user_home) if decky_user_home else Path.home() + self.local_bin_dir = self.user_home / LOCAL_BIN self.local_lib_dir = self.user_home / LOCAL_LIB self.local_share_dir = self.user_home / VULKAN_LAYER_DIR - self.lsfg_script_path = self.user_home / SCRIPT_NAME - self.lsfg_launch_script_path = self.user_home / SCRIPT_NAME + self.legacy_script_path = self.user_home / SCRIPT_NAME self.config_dir = self.user_home / CONFIG_DIR self.config_file_path = self.config_dir / CONFIG_FILENAME - + def _ensure_directories(self) -> None: - """Create necessary directories if they don't exist""" - self.local_lib_dir.mkdir(parents=True, exist_ok=True) - self.local_share_dir.mkdir(parents=True, exist_ok=True) - self.config_dir.mkdir(parents=True, exist_ok=True) - self.log.info(f"Ensured directories exist: {self.local_lib_dir}, {self.local_share_dir}, {self.config_dir}") - + for directory in (self.local_bin_dir, self.local_lib_dir, self.local_share_dir, self.config_dir): + directory.mkdir(parents=True, exist_ok=True) + def _remove_if_exists(self, path: Path) -> bool: - """Remove a file if it exists - - Args: - path: Path to the file to remove - - Returns: - True if file was removed, False if it didn't exist - - Raises: - OSError: If removal fails - """ - if path.exists(): - try: - path.unlink() - self.log.info(f"Removed {path}") - return True - except OSError as e: - self.log.error(f"Failed to remove {path}: {e}") - raise - else: - self.log.info(f"File not found: {path}") + if not path.exists() and not path.is_symlink(): return False - + path.unlink() + self.log.info(f"Removed {path}") + return True + def _write_file(self, path: Path, content: str, mode: int = 0o644) -> None: - """Write content to a file - - Args: - path: Target file path - content: Content to write - mode: File permissions (default: 0o644) - - Raises: - OSError: If write fails - """ + path.parent.mkdir(parents=True, exist_ok=True) + temporary_path = None try: - with open(path, 'w', encoding='utf-8') as f: - f.write(content) - f.flush() - os.fsync(f.fileno()) - - path.chmod(mode) - self.log.info(f"Wrote to {path}") - - except (OSError, IOError, PermissionError) as e: - self.log.error(f"Failed to write to {path}: {e}") + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=path.parent, + prefix=f".{path.name}.", + delete=False, + ) as temporary_file: + temporary_path = Path(temporary_file.name) + temporary_file.write(content) + temporary_file.flush() + os.fsync(temporary_file.fileno()) + temporary_path.chmod(mode) + os.replace(temporary_path, path) + except Exception: + if temporary_path is not None: + temporary_path.unlink(missing_ok=True) 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 = {"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 = {"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 f76b5f2..bf3e174 100644 --- a/py_modules/lsfg_vk/config_schema.py +++ b/py_modules/lsfg_vk/config_schema.py @@ -1,605 +1,118 @@ -""" -Centralized configuration schema for lsfg-vk. +"""Small adapter for the upstream lsfg-vk v2 configuration format.""" -This module defines the complete configuration structure for lsfg-vk, managing TOML-based config files, including: -- Field definitions with types, defaults, and metadata -- TOML generation logic -- Validation rules -- Type definitions -""" +import json +import tomllib +from typing import Any, Dict, TypedDict -import logging -import re -import sys -from typing import TypedDict, Dict, Any, Union, cast, List -from dataclasses import dataclass -from enum import Enum -from pathlib import Path +ConfigurationData = Dict[str, Any] -# 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 -# Import auto-generated configuration components -from .config_schema_generated import ConfigurationData, get_script_parsing_logic, get_script_generation_logic - - -@dataclass -class ConfigField: - """Configuration field definition""" - name: str - field_type: ConfigFieldType - default: Union[bool, int, float, str] - description: str - - def get_toml_value(self, value: Union[bool, int, float, str]) -> Union[bool, int, float, str]: - """Get the value for TOML output""" - return value - - -# Use shared configuration schema as source of truth -CONFIG_SCHEMA: Dict[str, ConfigField] = { - 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() +class ProfileData(TypedDict): + profiles: Dict[str, Dict[str, Any]] + global_config: Dict[str, Any] + + +PROFILE_DEFAULTS: Dict[str, Any] = { + "active_in": [], + "pacing_mode": "vsync", + "multiplier": 2, + "flow_scale": 0.8, + "performance_mode": False, + "override_present_mode": True, + "preserve_swapchain_image_count": False, } +GLOBAL_DEFAULTS: Dict[str, Any] = {"dll": "", "no_fp16": False} -# 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" -) - -# Get script-only fields dynamically from shared config -SCRIPT_ONLY_FIELDS = { - 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() - if field_def.get("location") == "script" -} -# Complete configuration schema (TOML + script-only fields) -COMPLETE_CONFIG_SCHEMA = {**CONFIG_SCHEMA, **SCRIPT_ONLY_FIELDS} +def _toml_value(value: Any) -> str: + if isinstance(value, bool): + return str(value).lower() + if isinstance(value, str): + return json.dumps(value) + if isinstance(value, list): + return "[ " + ", ".join(_toml_value(item) for item in value) + " ]" + return str(value) -# Constants for profile management -DEFAULT_PROFILE_NAME = "decky-lsfg-vk" -GLOBAL_SECTION_FIELDS = {"dll", "no_fp16"} -# Note: ConfigurationData is now imported from generated file -# No need to manually maintain the TypedDict anymore! - - -class ProfileData(TypedDict): - """Profile data with current profile tracking""" - current_profile: str - profiles: Dict[str, ConfigurationData] # profile_name -> config - global_config: Dict[str, Any] # Global settings (dll, no_fp16) +def _normalize_active_in(value: Any) -> list[str]: + if value in (None, ""): + return [] + if isinstance(value, str): + return [value] + if not isinstance(value, list): + raise ValueError("active_in must be a string or list of strings") + return [str(item) for item in value if str(item)] class ConfigurationManager: - """Centralized configuration management""" - - @staticmethod - def get_defaults() -> ConfigurationData: - """Get default configuration values""" - # 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 SCRIPT_ONLY_FIELDS.values() - } - - return cast(ConfigurationData, {**shared_defaults, **script_defaults}) - - @staticmethod - def get_defaults_with_dll_detection(dll_detection_service=None) -> ConfigurationData: - """Get default configuration values with DLL path detection - - Args: - dll_detection_service: Optional DLL detection service instance - - Returns: - ConfigurationData with detected DLL path if available - """ - defaults = ConfigurationManager.get_defaults() - - # Try to detect DLL path if service provided - if dll_detection_service: - try: - dll_result = dll_detection_service.check_lossless_scaling_dll() - if dll_result.get("detected") and dll_result.get("path"): - defaults["dll"] = dll_result["path"] - except (OSError, IOError, KeyError, TypeError) as e: - # If detection fails, keep empty default - logging.getLogger(__name__).debug(f"DLL detection failed: {e}") - - # If DLL path is still empty, use a reasonable fallback - if not defaults["dll"]: - defaults["dll"] = "/home/deck/.local/share/Steam/steamapps/common/Lossless Scaling/Lossless.dll" - - return defaults - - @staticmethod - def get_field_names() -> list[str]: - """Get ordered list of configuration field names""" - # 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""" - # 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: - """Validate and convert configuration data""" - validated = {} - - for field_name, field_def in COMPLETE_CONFIG_SCHEMA.items(): - value = config.get(field_name, field_def.default) - - # Type validation and conversion - if field_def.field_type == ConfigFieldType.BOOLEAN: - validated[field_name] = bool(value) - elif field_def.field_type == ConfigFieldType.INTEGER: - validated[field_name] = int(value) - elif field_def.field_type == ConfigFieldType.FLOAT: - validated[field_name] = float(value) - elif field_def.field_type == ConfigFieldType.STRING: - validated[field_name] = str(value) - else: - validated[field_name] = value - - return cast(ConfigurationData, validated) - - @staticmethod - def generate_toml_content(config: ConfigurationData) -> str: - """Generate TOML configuration file content for single profile (backward compatibility)""" - # For backward compatibility, create a single profile structure - profile_data: ProfileData = { - "current_profile": DEFAULT_PROFILE_NAME, - "profiles": {DEFAULT_PROFILE_NAME: config}, - "global_config": { - "dll": config.get("dll", ""), - "no_fp16": config.get("no_fp16", False) - } - } - return ConfigurationManager.generate_toml_content_multi_profile(profile_data) - + def get_defaults() -> Dict[str, Any]: + return {**GLOBAL_DEFAULTS, **PROFILE_DEFAULTS} + + @staticmethod + def validate_config(config: Dict[str, Any]) -> Dict[str, Any]: + result = {**PROFILE_DEFAULTS, **GLOBAL_DEFAULTS} + result.update({key: value for key, value in config.items() if key in result}) + result["active_in"] = _normalize_active_in(result["active_in"]) + result["pacing_mode"] = str(result["pacing_mode"]).lower() + if result["pacing_mode"] != "vsync": + raise ValueError("pacing_mode must be vsync") + result["multiplier"] = int(result["multiplier"]) + if result["multiplier"] < 1: + raise ValueError("multiplier must be 1 or greater") + result["flow_scale"] = float(result["flow_scale"]) + if not 0.25 <= result["flow_scale"] <= 1.0: + raise ValueError("flow_scale must be between 0.25 and 1.0") + for name in ( + "no_fp16", + "performance_mode", + "override_present_mode", + "preserve_swapchain_image_count", + ): + result[name] = bool(result[name]) + result["dll"] = str(result["dll"] or "") + return result + @staticmethod def generate_toml_content_multi_profile(profile_data: ProfileData) -> str: - """Generate TOML configuration file content with multiple profiles""" - lines = ["version = 1"] - lines.append("") - - # Add global section with global fields - lines.append("[global]") - - # Add current_profile field - lines.append(f"# Currently selected profile") - lines.append(f'current_profile = "{profile_data["current_profile"]}"') - lines.append("") - - # Add dll field if specified - dll_path = profile_data["global_config"].get("dll", "") - if dll_path: - lines.append(f"# specify where Lossless.dll is stored") - lines.append(f'dll = "{dll_path}"') - lines.append("") + global_config = {**GLOBAL_DEFAULTS, **profile_data.get("global_config", {})} + lines = ["version = 2", "", "[global]"] + if global_config["dll"]: + lines.append(f"dll = {_toml_value(global_config['dll'])}") + lines.append(f"allow_fp16 = {_toml_value(not bool(global_config['no_fp16']))}") + profiles = sorted(profile_data["profiles"].items()) or [("", {})] + for name, raw in profiles: + config = ConfigurationManager.validate_config({**raw, **global_config}) + lines.extend(["", "[[profile]]", f"name = {_toml_value(name)}"]) + if config["active_in"]: + lines.append(f"active_in = {_toml_value(config['active_in'])}") + lines.extend([ + f"pacing_mode = {_toml_value(config['pacing_mode'])}", + f"multiplier = {config['multiplier']}", + f"flow_scale = {config['flow_scale']}", + f"performance_mode = {_toml_value(config['performance_mode'])}", + f"override_present_mode = {_toml_value(config['override_present_mode'])}", + f"preserve_swapchain_image_count = {_toml_value(config['preserve_swapchain_image_count'])}", + ]) + return "\n".join(lines) + "\n" - lines.append(f"# FP16 acceleration") - no_fp16 = bool(profile_data["global_config"].get("no_fp16", False)) - lines.append(f"no_fp16 = {str(no_fp16).lower()}") - lines.append("") - - # Add game sections for each profile - # Sort profiles to ensure consistent order (default profile first) - sorted_profiles = sorted(profile_data["profiles"].items(), - key=lambda x: (x[0] != DEFAULT_PROFILE_NAME, x[0])) - - for profile_name, config in sorted_profiles: - lines.append("[[game]]") - if profile_name == DEFAULT_PROFILE_NAME: - lines.append("# Plugin-managed game entry (default profile)") - else: - lines.append(f"# Profile: {profile_name}") - lines.append(f'exe = "{profile_name}"') - lines.append("") - - # Add all configuration fields to the game section (excluding global fields) - for field_name, field_def in CONFIG_SCHEMA.items(): - # Skip global fields - they go in global section - if field_name in GLOBAL_SECTION_FIELDS: - continue - - value = config.get(field_name, field_def.default) - - # Add field description comment - lines.append(f"# {field_def.description}") - - # Format value based on type - if isinstance(value, bool): - lines.append(f"{field_name} = {str(value).lower()}") - elif isinstance(value, str) and value: # Only add non-empty strings - lines.append(f'{field_name} = "{value}"') - elif isinstance(value, (int, float)): # Always include numbers, even if 0 or 1 - lines.append(f"{field_name} = {value}") - - lines.append("") # Empty line for readability - - return "\n".join(lines) - - @staticmethod - def parse_toml_content(content: str) -> ConfigurationData: - """Parse TOML content into configuration data for the currently selected profile (backward compatibility)""" - profile_data = ConfigurationManager.parse_toml_content_multi_profile(content) - current_profile = profile_data["current_profile"] - - # Merge global config with current profile config - current_config = profile_data["profiles"].get(current_profile, ConfigurationManager.get_defaults()) - - # Add global fields to the config - for field_name in GLOBAL_SECTION_FIELDS: - if field_name in profile_data["global_config"]: - current_config[field_name] = profile_data["global_config"][field_name] - - return current_config - @staticmethod def parse_toml_content_multi_profile(content: str) -> ProfileData: - """Parse TOML content into profile data structure""" - profiles: Dict[str, ConfigurationData] = {} - global_config: Dict[str, Any] = {} - current_profile = DEFAULT_PROFILE_NAME - - try: - # Look for both [global] and [[game]] sections - lines = content.split('\n') - in_global_section = False - in_game_section = False - current_game_exe = None - current_game_config: Dict[str, Any] = {} - - for line in lines: - line = line.strip() - - # Skip comments and empty lines - if not line or line.startswith('#'): - continue - - # Check for section headers - if line.startswith('[') and line.endswith(']'): - # Save previous game section if we were in one - if in_game_section and current_game_exe: - # Validate and store the profile config - validated_config = ConfigurationManager.get_defaults() - for key, value in current_game_config.items(): - if key in CONFIG_SCHEMA: - field_def = CONFIG_SCHEMA[key] - try: - if field_def.field_type == ConfigFieldType.BOOLEAN: - validated_config[key] = value - elif field_def.field_type == ConfigFieldType.INTEGER: - validated_config[key] = int(value) if not isinstance(value, int) else value - elif field_def.field_type == ConfigFieldType.FLOAT: - validated_config[key] = float(value) if not isinstance(value, float) else value - elif field_def.field_type == ConfigFieldType.STRING: - validated_config[key] = str(value) - except (ValueError, TypeError): - # If conversion fails, keep default value - pass - profiles[current_game_exe] = validated_config - current_game_config = {} - - # Set new section state - if line == '[global]': - in_global_section = True - in_game_section = False - elif line == '[[game]]': - in_global_section = False - in_game_section = True - current_game_exe = None - else: - in_global_section = False - in_game_section = False - continue - - # Parse key = value lines - if '=' in line: - key, value = line.split('=', 1) - key = key.strip() - value = value.strip() - - # Remove quotes from string values - if value.startswith('"') and value.endswith('"'): - value = value[1:-1] - elif value.startswith("'") and value.endswith("'"): - value = value[1:-1] - - # Handle global section - if in_global_section: - if key == "current_profile": - current_profile = value - elif key == "dll": - global_config["dll"] = value - elif key == "no_fp16": - global_config["no_fp16"] = value.lower() in ('true', '1', 'yes', 'on') - - # Handle game section - elif in_game_section: - # Track the exe for this game section - if key == "exe": - current_game_exe = value - # Store config fields for current game - elif key in CONFIG_SCHEMA: - field_def = CONFIG_SCHEMA[key] - try: - if field_def.field_type == ConfigFieldType.BOOLEAN: - current_game_config[key] = value.lower() in ('true', '1', 'yes', 'on') - elif field_def.field_type == ConfigFieldType.INTEGER: - current_game_config[key] = int(value) - elif field_def.field_type == ConfigFieldType.FLOAT: - current_game_config[key] = float(value) - elif field_def.field_type == ConfigFieldType.STRING: - current_game_config[key] = value - except (ValueError, TypeError): - # If conversion fails, keep default value - pass - - # Handle final game section if we were in one - if in_game_section and current_game_exe: - validated_config = ConfigurationManager.get_defaults() - for key, value in current_game_config.items(): - if key in CONFIG_SCHEMA: - field_def = CONFIG_SCHEMA[key] - try: - if field_def.field_type == ConfigFieldType.BOOLEAN: - validated_config[key] = value - elif field_def.field_type == ConfigFieldType.INTEGER: - validated_config[key] = int(value) if not isinstance(value, int) else value - elif field_def.field_type == ConfigFieldType.FLOAT: - validated_config[key] = float(value) if not isinstance(value, float) else value - elif field_def.field_type == ConfigFieldType.STRING: - validated_config[key] = str(value) - except (ValueError, TypeError): - # If conversion fails, keep default value - pass - profiles[current_game_exe] = validated_config - - # Ensure we have at least the default profile - if not profiles: - profiles[DEFAULT_PROFILE_NAME] = ConfigurationManager.get_defaults() - - # Ensure current_profile exists in profiles - if current_profile not in profiles: - current_profile = DEFAULT_PROFILE_NAME - if DEFAULT_PROFILE_NAME not in profiles: - profiles[DEFAULT_PROFILE_NAME] = ConfigurationManager.get_defaults() - - return ProfileData( - current_profile=current_profile, - profiles=profiles, - global_config=global_config - ) - - except (ValueError, KeyError, TypeError, AttributeError) as e: - # If parsing fails completely, return default profile structure - logging.getLogger(__name__).warning(f"Failed to parse TOML profiles, using defaults: {e}") - return ProfileData( - current_profile=DEFAULT_PROFILE_NAME, - profiles={DEFAULT_PROFILE_NAME: ConfigurationManager.get_defaults()}, - global_config={} - ) - - @staticmethod - def parse_script_content(script_content: str) -> Dict[str, Union[bool, int, str]]: - """Parse launch script content to extract environment variable values - - Args: - script_content: Content of the launch script file - - Returns: - Dict containing parsed script-only field values - """ - # Use auto-generated parsing logic - parse_script_values = get_script_parsing_logic() - return parse_script_values(script_content.split('\n')) - - @staticmethod - def merge_config_with_script(toml_config: ConfigurationData, script_values: Dict[str, Union[bool, int, str]]) -> ConfigurationData: - """Merge TOML configuration with script environment variable values - - Args: - toml_config: Configuration loaded from TOML file - script_values: Environment variable values parsed from script - - Returns: - Complete configuration with script values overlaid on TOML config - """ - merged_config = dict(toml_config) - - # Update script-only fields with values from script - for field_name in SCRIPT_ONLY_FIELDS.keys(): - if field_name in script_values: - merged_config[field_name] = script_values[field_name] - - return cast(ConfigurationData, merged_config) - - @staticmethod - def normalize_profile_name(profile_name: str) -> str: - """Normalize profile name by converting spaces to dashes and trimming - - This allows users to enter names with spaces, which are then safely - converted to dashes for storage and shell script compatibility. - - Args: - profile_name: The raw profile name from user input - - Returns: - Normalized profile name with spaces converted to dashes - """ - if not profile_name: - return profile_name - - # Trim whitespace and convert spaces to dashes - normalized = profile_name.strip().replace(' ', '-') - - # Collapse multiple consecutive dashes into one - while '--' in normalized: - normalized = normalized.replace('--', '-') - - # Remove leading/trailing dashes - normalized = normalized.strip('-') - - return normalized - - @staticmethod - def validate_profile_name(profile_name: str) -> bool: - """Validate profile name for safety (after normalization)""" - if not profile_name: - return False - - # Normalize first - this converts spaces to dashes - normalized = ConfigurationManager.normalize_profile_name(profile_name) - - if not normalized: - return False - - # Check for invalid characters that could cause issues in shell scripts or TOML - # Note: spaces are now allowed as input (they get converted to dashes) - invalid_chars = set('\t\n\r\'"\\/$|&;()<>{}[]`*?') - if any(char in invalid_chars for char in normalized): - return False - - # Check for reserved names - reserved_names = {'global', 'game', 'current_profile'} - if normalized.lower() in reserved_names: - return False - - return True - - @staticmethod - def create_profile(profile_data: ProfileData, profile_name: str, source_profile: str = None) -> ProfileData: - """Create a new profile by copying from source profile or defaults""" - if not ConfigurationManager.validate_profile_name(profile_name): - raise ValueError(f"Invalid profile name: {profile_name}") - - # Normalize the profile name (converts spaces to dashes) - profile_name = ConfigurationManager.normalize_profile_name(profile_name) - - if profile_name in profile_data["profiles"]: - raise ValueError(f"Profile '{profile_name}' already exists") - - # Copy from source profile or use defaults - if source_profile and source_profile in profile_data["profiles"]: - new_config = dict(profile_data["profiles"][source_profile]) - else: - new_config = ConfigurationManager.get_defaults() - - # Create new profile data structure - new_profile_data = ProfileData( - current_profile=profile_data["current_profile"], - profiles=dict(profile_data["profiles"]), - global_config=dict(profile_data["global_config"]) - ) - new_profile_data["profiles"][profile_name] = new_config - - return new_profile_data - - @staticmethod - def delete_profile(profile_data: ProfileData, profile_name: str) -> ProfileData: - """Delete a profile (cannot delete default profile)""" - if profile_name == DEFAULT_PROFILE_NAME: - raise ValueError(f"Cannot delete default profile '{DEFAULT_PROFILE_NAME}'") - - if profile_name not in profile_data["profiles"]: - raise ValueError(f"Profile '{profile_name}' does not exist") - - # Create new profile data structure - new_profile_data = ProfileData( - current_profile=profile_data["current_profile"], - profiles=dict(profile_data["profiles"]), - global_config=dict(profile_data["global_config"]) - ) - - # Remove the profile - del new_profile_data["profiles"][profile_name] - - # If we deleted the current profile, switch to default - if new_profile_data["current_profile"] == profile_name: - new_profile_data["current_profile"] = DEFAULT_PROFILE_NAME - # Ensure default profile exists - if DEFAULT_PROFILE_NAME not in new_profile_data["profiles"]: - new_profile_data["profiles"][DEFAULT_PROFILE_NAME] = ConfigurationManager.get_defaults() - - return new_profile_data - - @staticmethod - def rename_profile(profile_data: ProfileData, old_name: str, new_name: str) -> ProfileData: - """Rename a profile""" - if old_name == DEFAULT_PROFILE_NAME: - raise ValueError(f"Cannot rename default profile '{DEFAULT_PROFILE_NAME}'") - - if not ConfigurationManager.validate_profile_name(new_name): - raise ValueError(f"Invalid profile name: {new_name}") - - # Normalize the new name (converts spaces to dashes) - new_name = ConfigurationManager.normalize_profile_name(new_name) - - if old_name not in profile_data["profiles"]: - raise ValueError(f"Profile '{old_name}' does not exist") - - if new_name in profile_data["profiles"]: - raise ValueError(f"Profile '{new_name}' already exists") - - # Create new profile data structure - new_profile_data = ProfileData( - current_profile=profile_data["current_profile"], - profiles={}, - global_config=dict(profile_data["global_config"]) - ) - - # Copy profiles with new name - for profile_name, config in profile_data["profiles"].items(): - if profile_name == old_name: - new_profile_data["profiles"][new_name] = dict(config) - else: - new_profile_data["profiles"][profile_name] = dict(config) - - # Update current_profile if necessary - if new_profile_data["current_profile"] == old_name: - new_profile_data["current_profile"] = new_name - - return new_profile_data - - @staticmethod - def set_current_profile(profile_data: ProfileData, profile_name: str) -> ProfileData: - """Set the current active profile""" - if profile_name not in profile_data["profiles"]: - raise ValueError(f"Profile '{profile_name}' does not exist") - - # Create new profile data structure - new_profile_data = ProfileData( - current_profile=profile_name, - profiles=dict(profile_data["profiles"]), - global_config=dict(profile_data["global_config"]) - ) - - return new_profile_data + data = tomllib.loads(content) + if data.get("version") != 2: + raise ValueError("unsupported lsfg-vk configuration version") + raw_global = data.get("global", {}) + global_config = { + "dll": str(raw_global.get("dll", "") or ""), + "no_fp16": not bool(raw_global.get("allow_fp16", True)), + } + profiles: Dict[str, Dict[str, Any]] = {} + for profile in data.get("profile", []): + name = str(profile.get("name", "")) + config = ConfigurationManager.validate_config({ + **profile, + **global_config, + }) + if name or config["active_in"]: + profiles[name] = config + return {"profiles": profiles, "global_config": global_config} diff --git a/py_modules/lsfg_vk/config_schema_generated.py b/py_modules/lsfg_vk/config_schema_generated.py deleted file mode 100644 index b320a97..0000000 --- a/py_modules/lsfg_vk/config_schema_generated.py +++ /dev/null @@ -1,125 +0,0 @@ -""" -Auto-generated configuration schema components from shared_config.py -DO NOT EDIT THIS FILE MANUALLY - it will be overwritten on build -""" - -from typing import TypedDict, Dict, Any, Union -from enum import Enum -import sys -from pathlib import Path - -# Import shared configuration constants -sys.path.insert(0, str(Path(__file__).parent.parent.parent)) -from shared_config import CONFIG_SCHEMA_DEF, ConfigFieldType - -# Field name constants for type-safe access -DLL = "dll" -NO_FP16 = "no_fp16" -MULTIPLIER = "multiplier" -FLOW_SCALE = "flow_scale" -PERFORMANCE_MODE = "performance_mode" -HDR_MODE = "hdr_mode" -EXPERIMENTAL_PRESENT_MODE = "experimental_present_mode" -DXVK_FRAME_RATE = "dxvk_frame_rate" -ENABLE_WOW64 = "enable_wow64" -DISABLE_STEAMDECK_MODE = "disable_steamdeck_mode" -MANGOHUD_WORKAROUND = "mangohud_workaround" -DISABLE_VKBASALT = "disable_vkbasalt" -FORCE_ENABLE_VKBASALT = "force_enable_vkbasalt" -ENABLE_WSI = "enable_wsi" -ENABLE_ZINK = "enable_zink" - - -class ConfigurationData(TypedDict): - """Type-safe configuration data structure - AUTO-GENERATED""" - dll: str - no_fp16: bool - multiplier: int - flow_scale: float - performance_mode: bool - hdr_mode: bool - experimental_present_mode: str - dxvk_frame_rate: int - enable_wow64: bool - disable_steamdeck_mode: bool - mangohud_workaround: bool - disable_vkbasalt: bool - force_enable_vkbasalt: bool - enable_wsi: bool - enable_zink: bool - - -def get_script_parsing_logic(): - """Return the script parsing logic as a callable""" - def parse_script_values(lines): - script_values = {} - for line in lines: - line = line.strip() - if not line or line.startswith("#") or not line.startswith("export "): - continue - if "=" in line: - export_line = line[len("export "):] - key, value = export_line.split("=", 1) - key = key.strip() - value = value.strip() - - # Auto-generated parsing logic: - if key == "DXVK_FRAME_RATE": - try: - script_values["dxvk_frame_rate"] = int(value) - except ValueError: - pass - if key == "PROTON_USE_WOW64": - script_values["enable_wow64"] = value == "1" - if key == "SteamDeck": - script_values["disable_steamdeck_mode"] = value == "0" - if key == "MANGOHUD": - script_values["mangohud_workaround"] = value == "1" - if key == "DISABLE_VKBASALT": - script_values["disable_vkbasalt"] = value == "1" - if key == "ENABLE_VKBASALT": - script_values["force_enable_vkbasalt"] = value == "1" - if key == "ENABLE_GAMESCOPE_WSI": - script_values["enable_wsi"] = value != "0" - if key == "DXVK_HDR": - script_values["enable_wsi"] = value != "0" - if key == "__GLX_VENDOR_LIBRARY_NAME" and value == "mesa": - script_values["enable_zink"] = True - if key == "MESA_LOADER_DRIVER_OVERRIDE" and value == "zink": - script_values["enable_zink"] = True - if key == "GALLIUM_DRIVER" and value == "zink": - script_values["enable_zink"] = True - - return script_values - return parse_script_values - - -def get_script_generation_logic(): - """Return the script generation logic as a callable""" - def generate_script_lines(config): - lines = [] - dxvk_frame_rate = config.get("dxvk_frame_rate", 0) - if dxvk_frame_rate > 0: - lines.append(f"export DXVK_FRAME_RATE={dxvk_frame_rate}") - if config.get("enable_wow64", False): - lines.append("export PROTON_USE_WOW64=1") - if config.get("disable_steamdeck_mode", False): - lines.append("export SteamDeck=0") - if config.get("mangohud_workaround", False): - lines.append("export MANGOHUD=1") - if config.get("disable_vkbasalt", False): - lines.append("export DISABLE_VKBASALT=1") - if config.get("force_enable_vkbasalt", False): - lines.append("export ENABLE_VKBASALT=1") - if not config.get("enable_wsi", False): - lines.append("export ENABLE_GAMESCOPE_WSI=0") - lines.append("export DXVK_HDR=0") - if config.get("enable_zink", False): - lines.append("export __GLX_VENDOR_LIBRARY_NAME=mesa") - lines.append("export MESA_LOADER_DRIVER_OVERRIDE=zink") - lines.append("export GALLIUM_DRIVER=zink") - return lines - return generate_script_lines - - -ALL_FIELDS = ['dll', 'no_fp16', 'multiplier', 'flow_scale', 'performance_mode', 'hdr_mode', 'experimental_present_mode', 'dxvk_frame_rate', 'enable_wow64', 'disable_steamdeck_mode', 'mangohud_workaround', 'disable_vkbasalt', 'force_enable_vkbasalt', 'enable_wsi', 'enable_zink'] diff --git a/py_modules/lsfg_vk/configuration.py b/py_modules/lsfg_vk/configuration.py index fce3738..3c65972 100644 --- a/py_modules/lsfg_vk/configuration.py +++ b/py_modules/lsfg_vk/configuration.py @@ -1,446 +1,213 @@ -""" -Configuration service for TOML-based lsfg configuration management. -""" - -from pathlib import Path -from typing import Dict, Any +import re +from typing import Any, Dict from .base_service import BaseService -from .config_schema import ConfigurationManager, CONFIG_SCHEMA, ProfileData, DEFAULT_PROFILE_NAME -from .config_schema_generated import ConfigurationData, get_script_generation_logic -from .constants import ARMADA_DEVICE_ENV, ARMADA_GAME_LAUNCH -from .types import ConfigurationResponse, ProfilesResponse, ProfileResponse +from .config_schema import ConfigurationManager, ProfileData +from .runtime_service import RuntimeService class ConfigurationService(BaseService): - """Service for managing TOML-based lsfg configuration""" - - def get_config(self) -> ConfigurationResponse: - """Read current TOML configuration merged with launch script environment variables - - Returns: - ConfigurationResponse with current configuration or error - """ + FLATPAK_PROFILE_PREFIX = "flatpak:" + + def __init__(self, logger=None, runtime_service: RuntimeService = None): + super().__init__(logger) + self.runtime_service = runtime_service or RuntimeService(logger=self.log) + + def _default_data(self) -> ProfileData: + return {"profiles": {}, "global_config": {"dll": "", "no_fp16": False}} + + def _get_profile_data(self) -> ProfileData: + if not self.config_file_path.exists(): + return self._default_data() + return ConfigurationManager.parse_toml_content_multi_profile(self.config_file_path.read_text(encoding="utf-8")) + + def _save_profile_data(self, data: ProfileData) -> None: + content = ConfigurationManager.generate_toml_content_multi_profile(data) + self.runtime_service.validate_config_content(content) + self._write_file(self.config_file_path, content, 0o644) + + @staticmethod + def _profile_name(data: ProfileData, appid: str, game_name: str) -> str: + name = str(game_name).strip() + if not name: + raise ValueError("game name is required") + existing = data["profiles"].get(name) + if existing is not None and str(appid) not in existing.get("active_in", []): + name = f"{name} ({appid})" + return name + + @staticmethod + def _profile_for_appid(data: ProfileData, appid: str): + return next( + ( + (name, profile) + for name, profile in data["profiles"].items() + if str(appid) in profile.get("active_in", []) + ), + (None, None), + ) + + @classmethod + def flatpak_profile_name(cls, app_id: str) -> str: + value = str(app_id).strip() + if not value: + raise ValueError("Flatpak application ID is required") + return f"{cls.FLATPAK_PROFILE_PREFIX}{value}" + + @staticmethod + def _public_config(config: Dict[str, Any]) -> Dict[str, Any]: + return ConfigurationManager.validate_config(config) + + def get_game_configs(self) -> Dict[str, Any]: try: - if not self.config_file_path.exists(): - from .dll_detection import DllDetectionService - dll_service = DllDetectionService(self.log) - toml_config = ConfigurationManager.get_defaults_with_dll_detection(dll_service) - else: - content = self.config_file_path.read_text(encoding='utf-8') - toml_config = ConfigurationManager.parse_toml_content(content) - - script_values = {} - if self.lsfg_script_path.exists(): - try: - script_content = self.lsfg_script_path.read_text(encoding='utf-8') - script_values = ConfigurationManager.parse_script_content(script_content) - self.log.info(f"Parsed script values: {script_values}") - except Exception as e: - self.log.warning(f"Failed to parse launch script: {str(e)}") - - config = ConfigurationManager.merge_config_with_script(toml_config, script_values) - - 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 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) - from .dll_detection import DllDetectionService - dll_service = DllDetectionService(self.log) - config = ConfigurationManager.get_defaults_with_dll_detection(dll_service) - return self._success_response(ConfigurationResponse, - f"Using default configuration due to parse error: {str(e)}", - config=config) - - def update_config_from_dict(self, config: ConfigurationData) -> ConfigurationResponse: - """Update TOML configuration from configuration dictionary (eliminates parameter duplication) - - Args: - config: Complete configuration data dictionary - - Returns: - ConfigurationResponse with success status - """ + data = self._get_profile_data() + games = [] + for name, raw in data["profiles"].items(): + active_in = raw.get("active_in", []) + if len(active_in) != 1 or not re.fullmatch(r"-?[0-9]+", str(active_in[0])): + continue + games.append({"appid": str(active_in[0]), "profile": name, "config": self._public_config(raw)}) + return self._success_response(dict, global_config=dict(data["global_config"]), games=games) + except Exception as error: + self.log.error(f"Error reading game configs: {error}") + return self._error_response(dict, str(error), games=[]) + + def update_global_config(self, config: Dict[str, Any]) -> Dict[str, Any]: try: - profile_data = self._get_profile_data() - current_profile = profile_data["current_profile"] - - return self.update_profile_config(current_profile, config) - - except (OSError, IOError) as e: - error_msg = f"Error updating lsfg config: {str(e)}" - self.log.error(error_msg) - 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 self._error_response(ConfigurationResponse, str(e), config=None) - - def update_lsfg_script(self, config: ConfigurationData) -> ConfigurationResponse: - """Update the ~/lsfg launch script with current configuration - - Args: - config: Configuration data to apply to the script - - Returns: - ConfigurationResponse indicating success or failure - """ + data = self._get_profile_data() + merged_config = {**data["global_config"], **config} + validated = self._public_config(merged_config) + data["global_config"] = { + "dll": validated["dll"], + "no_fp16": validated["no_fp16"], + } + self._save_profile_data(data) + return self._success_response(dict, global_config=dict(data["global_config"])) + except Exception as error: + return self._error_response(dict, str(error), global_config=None) + + def update_game_config(self, appid: str, game_name: str, config: Dict[str, Any]) -> Dict[str, Any]: try: - script_content = self._generate_script_content(config) - - self._write_file(self.lsfg_script_path, script_content, 0o755) - - self.log.info(f"Updated lsfg launch script at {self.lsfg_script_path}") - - 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 self._error_response(ConfigurationResponse, str(e), config=None) - - def _generate_script_content(self, config: ConfigurationData) -> str: - """Generate the content for the ~/lsfg launch script - - Args: - config: Configuration data to apply to the script - - Returns: - The complete script content as a string - """ - lines = [ - "#!/bin/bash", - "# lsfg-vk launch script generated by decky-lossless-scaling-vk plugin", - "# This script sets up the environment for lsfg-vk to work with the plugin configuration", - ] - - generate_script_lines = get_script_generation_logic() - lines.extend(generate_script_lines(config)) - - lines.append("export LSFG_PROCESS=decky-lsfg-vk") - lines.extend(self._generate_game_launch_lines()) - - return "\n".join(lines) + "\n" - - def _generate_script_content_for_profile(self, profile_data: ProfileData) -> str: - """Generate the content for the ~/lsfg launch script with profile support - - Args: - profile_data: Profile data containing current profile and configurations - - Returns: - The complete script content as a string - """ - current_profile = profile_data["current_profile"] - config = profile_data["profiles"].get(current_profile, ConfigurationManager.get_defaults()) - - merged_config = dict(config) - for field_name, value in profile_data["global_config"].items(): - merged_config[field_name] = value - - lines = [ - "#!/bin/bash", - f"# Current profile: {current_profile}", - ] - - generate_script_lines = get_script_generation_logic() - lines.extend(generate_script_lines(merged_config)) - - lines.append(f"export LSFG_PROCESS={current_profile}") - lines.extend(self._generate_game_launch_lines()) - - return "\n".join(lines) + "\n" + data = self._get_profile_data() + old_name, _ = self._profile_for_appid(data, appid) + name = self._profile_name(data, appid, game_name) + merged_config = {**data["global_config"], **{key: value for key, value in config.items() if key != "no_fp16"}} + if not config.get("dll"): + merged_config["dll"] = data["global_config"].get("dll", "") + validated = self._public_config(merged_config) + validated["active_in"] = [str(appid)] + if old_name and old_name != name: + data["profiles"].pop(old_name, None) + data["profiles"][name] = validated + self._save_profile_data(data) + return self._success_response(dict, appid=str(appid), config=validated) + except Exception as error: + return self._error_response(dict, str(error), appid=str(appid), config=None) - @staticmethod - def _generate_game_launch_lines() -> list[str]: - """Generate a portable exec block with Armada's host wrapper.""" - device_env = ARMADA_DEVICE_ENV.as_posix() - game_launch = ARMADA_GAME_LAUNCH.as_posix() - return [ - f'armada_game_launch="{game_launch}"', - 'for argument in "$@"; do', - ' if [ "$argument" = "$armada_game_launch" ]; then', - ' exec "$@"', - " fi", - "done", - f'if [ -f "{device_env}" ] && [ -x "$armada_game_launch" ]; then', - ' exec "$armada_game_launch" "$@"', - "fi", - 'exec "$@"', - ] - - def _get_profile_data(self) -> ProfileData: - """Get current profile data from config file""" - if not self.config_file_path.exists(): - from .dll_detection import DllDetectionService - dll_service = DllDetectionService(self.log) - default_config = ConfigurationManager.get_defaults_with_dll_detection(dll_service) - return ProfileData( - current_profile=DEFAULT_PROFILE_NAME, - profiles={DEFAULT_PROFILE_NAME: default_config}, - global_config={ - "dll": default_config.get("dll", ""), - "no_fp16": False - } - ) - - content = self.config_file_path.read_text(encoding='utf-8') - return ConfigurationManager.parse_toml_content_multi_profile(content) - - def _save_profile_data(self, profile_data: ProfileData) -> None: - """Save profile data to config file""" - toml_content = ConfigurationManager.generate_toml_content_multi_profile(profile_data) - - self.config_dir.mkdir(parents=True, exist_ok=True) - - self._write_file(self.config_file_path, toml_content, 0o644) - - def get_profiles(self) -> ProfilesResponse: - """Get list of all profiles and current profile - - Returns: - ProfilesResponse with profile list and current profile - """ + def get_flatpak_config(self, app_id: str) -> Dict[str, Any]: try: - profile_data = self._get_profile_data() - - return self._success_response(ProfilesResponse, - "Profiles retrieved successfully", - profiles=list(profile_data["profiles"].keys()), - current_profile=profile_data["current_profile"]) - - except Exception as e: - error_msg = f"Error getting profiles: {str(e)}" - self.log.error(error_msg) - return self._error_response(ProfilesResponse, str(e), - profiles=None, current_profile=None) - - def create_profile(self, profile_name: str, source_profile: str = None) -> ProfileResponse: - """Create a new profile - - Args: - profile_name: Name for the new profile (spaces will be converted to dashes) - source_profile: Optional source profile to copy from (default: current profile) - - Returns: - ProfileResponse with success status and the normalized profile name - """ + data = self._get_profile_data() + name = self.flatpak_profile_name(app_id) + raw = data["profiles"].get(name) + return self._success_response( + dict, + app_id=str(app_id), + profile=name, + exists=raw is not None, + config=self._public_config(raw) if raw is not None else None, + global_config=dict(data["global_config"]), + ) + except Exception as error: + return self._error_response(dict, str(error), app_id=str(app_id), config=None, exists=False) + + def update_flatpak_config(self, app_id: str, config: Dict[str, Any]) -> Dict[str, Any]: try: - profile_data = self._get_profile_data() - - if not source_profile: - source_profile = profile_data["current_profile"] - - # Get the normalized name that will be used for storage - normalized_name = ConfigurationManager.normalize_profile_name(profile_name) - - new_profile_data = ConfigurationManager.create_profile(profile_data, profile_name, source_profile) - - self._save_profile_data(new_profile_data) - - self.log.info(f"Created profile '{normalized_name}' from '{source_profile}'") - - # Return the normalized name so frontend can use the actual stored name - return self._success_response(ProfileResponse, - f"Profile '{normalized_name}' created successfully", - profile_name=normalized_name) - - except ValueError as e: - error_msg = f"Invalid profile operation: {str(e)}" - self.log.error(error_msg) - return self._error_response(ProfileResponse, str(e), profile_name=None) - except Exception as e: - error_msg = f"Error creating profile: {str(e)}" - self.log.error(error_msg) - return self._error_response(ProfileResponse, str(e), profile_name=None) - - def delete_profile(self, profile_name: str) -> ProfileResponse: - """Delete a profile - - Args: - profile_name: Name of the profile to delete - - Returns: - ProfileResponse with success status - """ + data = self._get_profile_data() + name = self.flatpak_profile_name(app_id) + merged_config = {**data["global_config"], **{key: value for key, value in config.items() if key != "no_fp16"}} + if not config.get("dll"): + merged_config["dll"] = data["global_config"].get("dll", "") + validated = self._public_config(merged_config) + validated["active_in"] = [] + data["profiles"][name] = validated + self._save_profile_data(data) + return self._success_response(dict, app_id=str(app_id), profile=name, exists=True, config=validated) + except Exception as error: + return self._error_response(dict, str(error), app_id=str(app_id), config=None, exists=False) + + def reset_flatpak_config(self, app_id: str) -> Dict[str, Any]: try: - profile_data = self._get_profile_data() - - new_profile_data = ConfigurationManager.delete_profile(profile_data, profile_name) - - self._save_profile_data(new_profile_data) - - script_result = self.update_lsfg_script_from_profile_data(new_profile_data) - if not script_result["success"]: - self.log.warning(f"Failed to update launch script: {script_result['error']}") - - self.log.info(f"Deleted profile '{profile_name}'") - - return self._success_response(ProfileResponse, - f"Profile '{profile_name}' deleted successfully", - profile_name=profile_name) - - except ValueError as e: - error_msg = f"Invalid profile operation: {str(e)}" - self.log.error(error_msg) - return self._error_response(ProfileResponse, str(e), profile_name=None) - except Exception as e: - error_msg = f"Error deleting profile: {str(e)}" - self.log.error(error_msg) - return self._error_response(ProfileResponse, str(e), profile_name=None) - - def rename_profile(self, old_name: str, new_name: str) -> ProfileResponse: - """Rename a profile - - Args: - old_name: Current profile name - new_name: New profile name (spaces will be converted to dashes) - - Returns: - ProfileResponse with success status and the normalized profile name - """ + data = self._get_profile_data() + name = self.flatpak_profile_name(app_id) + data["profiles"].pop(name, None) + self._save_profile_data(data) + return self._success_response(dict, app_id=str(app_id), profile=name, exists=False) + except Exception as error: + return self._error_response(dict, str(error), app_id=str(app_id), config=None, exists=False) + + def reset_all_flatpak_configs(self) -> Dict[str, Any]: try: - profile_data = self._get_profile_data() - - # Get the normalized name that will be used for storage - normalized_name = ConfigurationManager.normalize_profile_name(new_name) - - new_profile_data = ConfigurationManager.rename_profile(profile_data, old_name, new_name) - - self._save_profile_data(new_profile_data) - - script_result = self.update_lsfg_script_from_profile_data(new_profile_data) - if not script_result["success"]: - self.log.warning(f"Failed to update launch script: {script_result['error']}") - - self.log.info(f"Renamed profile '{old_name}' to '{normalized_name}'") - - # Return the normalized name so frontend can use the actual stored name - return self._success_response(ProfileResponse, - f"Profile renamed from '{old_name}' to '{normalized_name}' successfully", - profile_name=normalized_name) - - except ValueError as e: - error_msg = f"Invalid profile operation: {str(e)}" - self.log.error(error_msg) - return self._error_response(ProfileResponse, str(e), profile_name=None) - except Exception as e: - error_msg = f"Error renaming profile: {str(e)}" - self.log.error(error_msg) - return self._error_response(ProfileResponse, str(e), profile_name=None) - - def set_current_profile(self, profile_name: str) -> ProfileResponse: - """Set the current active profile - - Args: - profile_name: Name of the profile to set as current - - Returns: - ProfileResponse with success status - """ + data = self._get_profile_data() + data["profiles"] = { + name: profile + for name, profile in data["profiles"].items() + if not name.startswith(self.FLATPAK_PROFILE_PREFIX) + } + self._save_profile_data(data) + return self._success_response(dict, global_config=dict(data["global_config"])) + except Exception as error: + return self._error_response(dict, str(error)) + + def reset_game_config(self, appid: str) -> Dict[str, Any]: try: - profile_data = self._get_profile_data() - - new_profile_data = ConfigurationManager.set_current_profile(profile_data, profile_name) - - self._save_profile_data(new_profile_data) - - script_result = self.update_lsfg_script_from_profile_data(new_profile_data) - if not script_result["success"]: - self.log.warning(f"Failed to update launch script: {script_result['error']}") - - self.log.info(f"Set current profile to '{profile_name}'") - - return self._success_response(ProfileResponse, - f"Current profile set to '{profile_name}' successfully", - profile_name=profile_name) - - except ValueError as e: - error_msg = f"Invalid profile operation: {str(e)}" - self.log.error(error_msg) - return self._error_response(ProfileResponse, str(e), profile_name=None) - except Exception as e: - error_msg = f"Error setting current profile: {str(e)}" - self.log.error(error_msg) - return self._error_response(ProfileResponse, str(e), profile_name=None) - - def update_profile_config(self, profile_name: str, config: ConfigurationData) -> ConfigurationResponse: - """Update configuration for a specific profile - - Args: - profile_name: Name of the profile to update - config: Configuration data to apply - - Returns: - ConfigurationResponse with success status - """ + data = self._get_profile_data() + name, _ = self._profile_for_appid(data, appid) + if name: + data["profiles"].pop(name, None) + self._save_profile_data(data) + return self._success_response(dict, appid=str(appid), exists=False) + except Exception as error: + return self._error_response(dict, str(error), appid=str(appid), config=None) + + def reset_game_configs(self, appids: list[str]) -> Dict[str, Any]: try: - profile_data = self._get_profile_data() - - if profile_name not in profile_data["profiles"]: - return self._error_response(ConfigurationResponse, - f"Profile '{profile_name}' does not exist", - config=None) - - # Update the profile's config - profile_data["profiles"][profile_name] = config - - # Update global config fields if they're in the config - for field_name in ["dll", "no_fp16"]: - if field_name in config: - profile_data["global_config"][field_name] = config[field_name] - - self._save_profile_data(profile_data) - - if profile_name == profile_data["current_profile"]: - script_result = self.update_lsfg_script_from_profile_data(profile_data) - if not script_result["success"]: - self.log.warning(f"Failed to update launch script: {script_result['error']}") - - field_values = ", ".join(f"{k}={repr(v)}" for k, v in config.items()) - self.log.info(f"Updated profile '{profile_name}' configuration: {field_values}") - - return self._success_response(ConfigurationResponse, - f"Profile '{profile_name}' configuration updated successfully", - config=config) - - except Exception as e: - error_msg = f"Error updating profile configuration: {str(e)}" - self.log.error(error_msg) - return self._error_response(ConfigurationResponse, str(e), config=None) - - def update_lsfg_script_from_profile_data(self, profile_data: ProfileData) -> ConfigurationResponse: - """Update the ~/lsfg launch script from profile data - - Args: - profile_data: Profile data to apply to the script - - Returns: - ConfigurationResponse indicating success or failure - """ + if not isinstance(appids, list): + raise ValueError("appids must be a list") + requested = set() + for appid in appids: + if isinstance(appid, bool) or not isinstance(appid, (str, int)): + raise ValueError("appids must contain only strings or integers") + value = str(appid) + if not re.fullmatch(r"-?[0-9]+", value): + raise ValueError("appids must contain only numeric App IDs") + requested.add(value) + + data = self._get_profile_data() + data["profiles"] = { + name: profile + for name, profile in data["profiles"].items() + if not ( + len(profile.get("active_in", [])) == 1 + and str(profile["active_in"][0]) in requested + ) + } + self._save_profile_data(data) + return self._success_response(dict, global_config=dict(data["global_config"]), games=[]) + except Exception as error: + return self._error_response(dict, str(error), games=[]) + + def reset_all_game_configs(self) -> Dict[str, Any]: try: - script_content = self._generate_script_content_for_profile(profile_data) - - # Write the script file - self._write_file(self.lsfg_script_path, script_content, 0o755) - - self.log.info(f"Updated lsfg launch script at {self.lsfg_script_path} for profile '{profile_data['current_profile']}'") - - # Get current profile config for response - current_config = profile_data["profiles"].get(profile_data["current_profile"], ConfigurationManager.get_defaults()) - - return self._success_response(ConfigurationResponse, - "Launch script updated successfully", - config=current_config) - - except Exception as e: - error_msg = f"Error updating launch script: {str(e)}" - self.log.error(error_msg) - return self._error_response(ConfigurationResponse, str(e), config=None) + data = self._get_profile_data() + data["profiles"] = { + name: profile + for name, profile in data["profiles"].items() + if not ( + len(profile.get("active_in", [])) == 1 + and re.fullmatch(r"-?[0-9]+", str(profile.get("active_in", [""])[0])) + ) + } + self._save_profile_data(data) + return self._success_response(dict, global_config=dict(data["global_config"]), games=[]) + except Exception as error: + return self._error_response(dict, str(error), games=[]) diff --git a/py_modules/lsfg_vk/constants.py b/py_modules/lsfg_vk/constants.py index 023894f..45ac4c5 100644 --- a/py_modules/lsfg_vk/constants.py +++ b/py_modules/lsfg_vk/constants.py @@ -1,37 +1,25 @@ -""" -Constants for the lsfg-vk plugin. -""" - -from pathlib import Path - +LOCAL_BIN = ".local/bin" +LOCAL_SHARE = ".local/share" LOCAL_LIB = ".local/lib" -LOCAL_SHARE_BASE = ".local/share" VULKAN_LAYER_DIR = ".local/share/vulkan/implicit_layer.d" CONFIG_DIR = ".config/lsfg-vk" SCRIPT_NAME = "lsfg" +WRAPPER_FILENAME = ".lsfg" CONFIG_FILENAME = "conf.toml" -LIB_FILENAME = "liblsfg-vk.so" -JSON_FILENAME = "VkLayer_LS_frame_generation.json" -ZIP_FILENAME = "lsfg-vk_noui.zip" -ARM_LIB_FILENAME = "liblsfg-vk-arm64.so" - -FLATPAK_23_08_FILENAME = "org.freedesktop.Platform.VulkanLayer.lsfg_vk_23.08.flatpak" -FLATPAK_24_08_FILENAME = "org.freedesktop.Platform.VulkanLayer.lsfg_vk_24.08.flatpak" -FLATPAK_25_08_FILENAME = "org.freedesktop.Platform.VulkanLayer.lsfg_vk_25.08.flatpak" - -SO_EXT = ".so" -JSON_EXT = ".json" +ARCHIVE_FILENAME = "lsfg-vk-2.0.0.tar.xz" +LIB_FILENAME = "liblsfg-vk-layer.so" +LIB_X86_FILENAME = "liblsfg-vk-layer.x86.so" +JSON_FILENAME = "VkLayer_LSFGVK_frame_generation.json" +JSON_X86_FILENAME = "VkLayer_LSFGVK_frame_generation.x86.json" +CLI_FILENAME = "lsfg-vk-cli" +UI_FILENAME = "lsfg-vk-ui" +UI_DESKTOP_FILENAME = "gay.pancake.lsfg-vk-ui.desktop" +UI_ICON_FILENAME = "gay.pancake.lsfg-vk-ui.png" +STEAM_LOSSLESS_SCALING_APP_ID = "993090" +STEAM_LOSSLESS_SCALING_BRANCH = "lsfg-vk" + +LEGACY_LIB_FILENAME = "liblsfg-vk.so" +LEGACY_JSON_FILENAME = "VkLayer_LS_frame_generation.json" BIN_DIR = "bin" - -ARMADA_DEVICE_ENV = Path("/usr/libexec/armada/device-env") -ARMADA_GAME_LAUNCH = Path("/usr/libexec/armada/armada-game-launch") - -STEAM_COMMON_PATH = Path("steamapps/common/Lossless Scaling") -LOSSLESS_DLL_NAME = "Lossless.dll" - -ENV_LSFG_DLL_PATH = "LSFG_DLL_PATH" -ENV_XDG_DATA_HOME = "XDG_DATA_HOME" -ENV_HOME = "HOME" - diff --git a/py_modules/lsfg_vk/dll_detection.py b/py_modules/lsfg_vk/dll_detection.py deleted file mode 100644 index f7ba444..0000000 --- a/py_modules/lsfg_vk/dll_detection.py +++ /dev/null @@ -1,223 +0,0 @@ -""" -DLL detection service for Lossless Scaling. -""" - -import os -import re -from pathlib import Path -from typing import Dict, Any, List - -from .base_service import BaseService -from .constants import ( - ENV_LSFG_DLL_PATH, ENV_XDG_DATA_HOME, ENV_HOME, - STEAM_COMMON_PATH, LOSSLESS_DLL_NAME -) -from .types import DllDetectionResponse - - -class DllDetectionService(BaseService): - """Service for detecting Lossless Scaling DLL""" - - def check_lossless_scaling_dll(self) -> DllDetectionResponse: - """Check if Lossless Scaling DLL is available at the expected paths - - Search order: - 1. LSFG_DLL_PATH environment variable - 2. XDG_DATA_HOME Steam directory - 3. HOME/.local/share Steam directory - 4. All Steam library folders (including SD cards) - - Returns: - DllDetectionResponse with detection status and path information - """ - try: - dll_path = self._check_env_dll_path() - if dll_path: - return dll_path - - xdg_path = self._check_xdg_data_home() - if xdg_path: - return xdg_path - - home_path = self._check_home_local_share() - if home_path: - return home_path - - steam_libraries_path = self._check_steam_library_folders() - if steam_libraries_path: - return steam_libraries_path - - return { - "detected": False, - "path": None, - "source": None, - "message": "Lossless Scaling DLL not found in expected locations", - "error": None - } - - except Exception as e: - error_msg = f"Error checking Lossless Scaling DLL: {str(e)}" - self.log.error(error_msg) - return { - "detected": False, - "path": None, - "source": None, - "message": None, - "error": str(e) - } - - def _check_env_dll_path(self) -> DllDetectionResponse | None: - """Check LSFG_DLL_PATH environment variable - - Returns: - DllDetectionResponse if found, None otherwise - """ - dll_path = os.getenv(ENV_LSFG_DLL_PATH) - if dll_path and dll_path.strip(): - dll_path_obj = Path(dll_path.strip()) - if dll_path_obj.exists(): - self.log.info(f"Found DLL via {ENV_LSFG_DLL_PATH}: {dll_path_obj}") - return { - "detected": True, - "path": str(dll_path_obj), - "source": f"{ENV_LSFG_DLL_PATH} environment variable", - "message": None, - "error": None - } - return None - - def _check_xdg_data_home(self) -> DllDetectionResponse | None: - """Check XDG_DATA_HOME Steam directory - - Returns: - DllDetectionResponse if found, None otherwise - """ - data_dir = os.getenv(ENV_XDG_DATA_HOME) - if data_dir and data_dir.strip(): - dll_path = Path(data_dir.strip()) / "Steam" / STEAM_COMMON_PATH / LOSSLESS_DLL_NAME - if dll_path.exists(): - self.log.info(f"Found DLL via {ENV_XDG_DATA_HOME}: {dll_path}") - return { - "detected": True, - "path": str(dll_path), - "source": f"{ENV_XDG_DATA_HOME} Steam directory", - "message": None, - "error": None - } - return None - - def _check_home_local_share(self) -> DllDetectionResponse | None: - """Check HOME/.local/share Steam directory - - Returns: - DllDetectionResponse if found, None otherwise - """ - home_dir = os.getenv(ENV_HOME) - if home_dir and home_dir.strip(): - dll_path = Path(home_dir.strip()) / ".local" / "share" / "Steam" / STEAM_COMMON_PATH / LOSSLESS_DLL_NAME - if dll_path.exists(): - self.log.info(f"Found DLL via {ENV_HOME}/.local/share: {dll_path}") - return { - "detected": True, - "path": str(dll_path), - "source": f"{ENV_HOME}/.local/share Steam directory", - "message": None, - "error": None - } - return None - - def _check_steam_library_folders(self) -> DllDetectionResponse | None: - """Check all Steam library folders for Lossless Scaling DLL - - This method parses Steam's libraryfolders.vdf file to find all - Steam library locations and checks each one for the DLL. - - Returns: - DllDetectionResponse if found, None otherwise - """ - steam_libraries = self._get_steam_library_paths() - - for library_path in steam_libraries: - dll_path = Path(library_path) / STEAM_COMMON_PATH / LOSSLESS_DLL_NAME - if dll_path.exists(): - self.log.info(f"Found DLL in Steam library: {dll_path}") - return { - "detected": True, - "path": str(dll_path), - "source": f"Steam library folder: {library_path}", - "message": None, - "error": None - } - - return None - - def _get_steam_library_paths(self) -> List[str]: - """Get all Steam library folder paths from libraryfolders.vdf - - Returns: - List of Steam library folder paths - """ - library_paths = [] - - steam_paths = [] - - data_dir = os.getenv(ENV_XDG_DATA_HOME) - if data_dir and data_dir.strip(): - steam_paths.append(Path(data_dir.strip()) / "Steam") - - home_dir = os.getenv(ENV_HOME) - if home_dir and home_dir.strip(): - steam_paths.append(Path(home_dir.strip()) / ".local" / "share" / "Steam") - - for steam_path in steam_paths: - if steam_path.exists(): - library_paths.append(str(steam_path)) - - vdf_path = steam_path / "steamapps" / "libraryfolders.vdf" - if vdf_path.exists(): - try: - additional_paths = self._parse_library_folders_vdf(vdf_path) - library_paths.extend(additional_paths) - except Exception as e: - self.log.warning(f"Failed to parse {vdf_path}: {str(e)}") - - seen = set() - unique_paths = [] - for path in library_paths: - if path not in seen: - seen.add(path) - unique_paths.append(path) - - self.log.info(f"Found {len(unique_paths)} Steam library paths: {unique_paths}") - return unique_paths - - def _parse_library_folders_vdf(self, vdf_path: Path) -> List[str]: - """Parse Steam's libraryfolders.vdf file to extract library paths - - Args: - vdf_path: Path to the libraryfolders.vdf file - - Returns: - List of additional Steam library folder paths - """ - library_paths = [] - - try: - with open(vdf_path, 'r', encoding='utf-8', errors='ignore') as f: - content = f.read() - - path_pattern = r'"path"\s*"([^"]+)"' - matches = re.findall(path_pattern, content, re.IGNORECASE) - - for path_match in matches: - path = path_match.replace('\\\\', '/').replace('\\', '/') - library_path = Path(path) - - if library_path.exists() and (library_path / "steamapps").exists(): - library_paths.append(str(library_path)) - self.log.info(f"Found additional Steam library: {library_path}") - - except Exception as e: - self.log.error(f"Error parsing libraryfolders.vdf: {str(e)}") - - return library_paths diff --git a/py_modules/lsfg_vk/flatpak_profile_service.py b/py_modules/lsfg_vk/flatpak_profile_service.py new file mode 100644 index 0000000..9d2d104 --- /dev/null +++ b/py_modules/lsfg_vk/flatpak_profile_service.py @@ -0,0 +1,383 @@ +from __future__ import annotations + +import re +from typing import Any, Dict + +from .configuration import ConfigurationService +from .flatpak_service import FlatpakService + + +class FlatpakProfileService: + STATE_FIELDS = ( + "dxvkFrameRate", + "disableGamescopeWsi", + "disableHdr", + "disableSteamdeckMode", + "disableVkbasalt", + "enableZink", + ) + BOOLEAN_FIELDS = STATE_FIELDS[1:] + DXVK_FRAME_RATE_SEGMENT = re.compile( + r"^(?:dxvk\.maxFrameRate|dxgi\.maxFrameRate|d3d9\.maxFrameRate)\s*=", + re.IGNORECASE, + ) + + def __init__( + self, + flatpak_service: FlatpakService, + configuration_service: ConfigurationService, + ): + self.flatpak_service = flatpak_service + self.configuration_service = configuration_service + + @classmethod + def default_state(cls) -> Dict[str, Any]: + return { + "dxvkFrameRate": 0, + "disableGamescopeWsi": True, + "disableHdr": True, + "disableSteamdeckMode": False, + "disableVkbasalt": False, + "enableZink": False, + } + + @classmethod + def _validate_state(cls, raw: Any) -> Dict[str, Any]: + if not isinstance(raw, dict): + raise ValueError("Workaround state must be an object") + missing = [field for field in cls.STATE_FIELDS if field not in raw] + if missing: + raise ValueError("Workaround state is missing: " + ", ".join(missing)) + state = {field: raw[field] for field in cls.STATE_FIELDS} + frame_rate = state["dxvkFrameRate"] + if isinstance(frame_rate, bool) or not isinstance(frame_rate, int) or not 0 <= frame_rate <= 60: + raise ValueError("Base FPS Cap must be an integer from 0 to 60") + for field in cls.BOOLEAN_FIELDS: + if type(state[field]) is not bool: + raise ValueError(f"{field} must be a boolean") + return state + + def _state_entry(self, app_id: str) -> tuple[Dict[str, object], Dict[str, Any]]: + state = self.flatpak_service._read_state() + entry = state["prepared_apps"].get(app_id) + if not isinstance(entry, dict): + raise RuntimeError("Flatpak application is not owned by this plugin") + return state, entry + + def _baseline_content(self, app_id: str, entry: Dict[str, Any]) -> str: + if not entry.get("override_existed"): + return "" + path = self.flatpak_service._backup_path(app_id) + if path.is_symlink() or not path.is_file(): + raise RuntimeError("Flatpak override backup is unavailable") + return path.read_text(encoding="utf-8") + + @staticmethod + def _environment_value(content: str, key: str) -> str: + section = None + for raw_line in content.splitlines(): + line = raw_line.strip() + if line.startswith("[") and line.endswith("]"): + section = line[1:-1] + continue + if section != "Environment": + continue + name, separator, value = line.partition("=") + if separator and name == key: + return value + return "" + + @classmethod + def _dxvk_config(cls, baseline: str, frame_rate: int) -> str: + existing = cls._environment_value(baseline, "DXVK_CONFIG") + parts = [part.strip() for part in existing.split(";") if part.strip()] + parts = [part for part in parts if not cls.DXVK_FRAME_RATE_SEGMENT.match(part)] + if frame_rate > 0: + parts.append(f"dxvk.maxFrameRate = {frame_rate}") + return "; ".join(parts) + + def _restore_baseline(self, app_id: str, entry: Dict[str, Any]) -> None: + existed, current = self.flatpak_service._snapshot_override(app_id) + current_hash = self.flatpak_service._sha256(current) if existed else self.flatpak_service._sha256(b"") + if current_hash != entry.get("managed_sha256"): + raise RuntimeError("Flatpak override changed after preparation; refusing to overwrite unrelated settings") + path = self.flatpak_service._override_path(app_id) + if entry.get("override_existed"): + self.flatpak_service._write_file(path, self._baseline_content(app_id, entry)) + else: + path.unlink(missing_ok=True) + + def _apply_state(self, app_id: str, workaround_state: Dict[str, Any]) -> Dict[str, Any]: + workaround_state = self._validate_state(workaround_state) + _, entry = self._state_entry(app_id) + baseline = self._baseline_content(app_id, entry) + self._restore_baseline(app_id, entry) + prepared = self.flatpak_service.prepare_app(app_id) + if not prepared.get("success") or not prepared.get("owned"): + raise RuntimeError(prepared.get("error") or "Could not restore plugin-owned Flatpak preparation") + profile = self.configuration_service.flatpak_profile_name(app_id) + args = [ + "override", + "--user", + f"--env=LSFGVK_PROFILE={profile}", + "--unset-env=DISABLE_LSFGVK", + "--unset-env=DISABLE_LSFG", + ] + if workaround_state["disableGamescopeWsi"]: + args.extend(["--env=ENABLE_GAMESCOPE_WSI=0", "--unset-env=DISABLE_GAMESCOPE_WSI"]) + if workaround_state["disableHdr"]: + args.append("--env=DXVK_HDR=0") + if workaround_state["disableSteamdeckMode"]: + args.append("--env=SteamDeck=0") + if workaround_state["disableVkbasalt"]: + args.extend(["--env=DISABLE_VKBASALT=1", "--unset-env=ENABLE_VKBASALT"]) + if workaround_state["enableZink"]: + args.extend([ + "--env=__GLX_VENDOR_LIBRARY_NAME=mesa", + "--env=MESA_LOADER_DRIVER_OVERRIDE=zink", + "--env=GALLIUM_DRIVER=zink", + ]) + dxvk_config = self._dxvk_config(baseline, workaround_state["dxvkFrameRate"]) + if dxvk_config: + args.append(f"--env=DXVK_CONFIG={dxvk_config}") + args.append(app_id) + result = self.flatpak_service._run_flatpak_command(args, capture_output=True, text=True) + if result.returncode != 0: + raise OSError(result.stderr.strip() or f"Could not apply Flatpak workarounds for {app_id}") + existed, managed = self.flatpak_service._snapshot_override(app_id) + if not existed: + raise RuntimeError(f"Flatpak override for {app_id} was not created") + state = self.flatpak_service._read_state() + entry = state["prepared_apps"].get(app_id) + if not isinstance(entry, dict): + raise RuntimeError("Flatpak application ownership state disappeared") + entry["managed_sha256"] = self.flatpak_service._sha256(managed) + entry["workaround_state"] = workaround_state + self.flatpak_service._write_state(state) + return workaround_state + + def enable_app(self, app_id: str) -> Dict[str, Any]: + created_profile = False + newly_owned = False + try: + existing = self.configuration_service.get_flatpak_config(app_id) + before = self.flatpak_service._read_state() + was_owned = app_id in before["prepared_apps"] + prepared = self.flatpak_service.prepare_app(app_id) + if not prepared.get("success"): + raise RuntimeError(prepared.get("error") or "Could not prepare Flatpak application") + if not prepared.get("owned"): + raise RuntimeError("Flatpak application is prepared outside this plugin and cannot be managed safely") + newly_owned = not was_owned + if not existing.get("exists"): + config = { + **self.configuration_service._public_config({}), + **(existing.get("global_config") or {}), + } + config["active_in"] = [] + saved = self.configuration_service.update_flatpak_config(app_id, config) + if not saved.get("success"): + raise RuntimeError(saved.get("error") or "Could not create Flatpak profile") + created_profile = True + _, entry = self._state_entry(app_id) + workaround_state = self._validate_state(entry.get("workaround_state", self.default_state())) + self._apply_state(app_id, workaround_state) + return self.get_app(app_id) + except Exception as error: + if created_profile: + self.configuration_service.reset_flatpak_config(app_id) + if newly_owned: + self.flatpak_service.remove_app_override(app_id) + return { + "success": False, + "message": "", + "error": str(error), + "app_id": str(app_id), + "enabled": False, + } + + def update_config(self, app_id: str, config: Dict[str, Any]) -> Dict[str, Any]: + try: + self._state_entry(app_id) + result = self.configuration_service.update_flatpak_config(app_id, config) + if not result.get("success"): + raise RuntimeError(result.get("error") or "Could not update Flatpak profile") + return result + except Exception as error: + return { + "success": False, + "message": "", + "error": str(error), + "app_id": str(app_id), + "enabled": False, + } + + def get_workaround_state(self, app_id: str) -> Dict[str, Any]: + try: + _, entry = self._state_entry(app_id) + state = self._validate_state(entry.get("workaround_state", self.default_state())) + return { + "success": True, + "message": "", + "error": None, + "app_id": app_id, + "state": state, + } + except Exception as error: + return { + "success": False, + "message": "", + "error": str(error), + "app_id": str(app_id), + "state": None, + } + + def set_workaround_state(self, app_id: str, workaround_state: Dict[str, Any]) -> Dict[str, Any]: + try: + state = self._apply_state(app_id, workaround_state) + return { + "success": True, + "message": "", + "error": None, + "app_id": app_id, + "state": state, + } + except Exception as error: + return { + "success": False, + "message": "", + "error": str(error), + "app_id": str(app_id), + "state": None, + } + + def remove_app(self, app_id: str) -> Dict[str, Any]: + try: + removed = self.flatpak_service.remove_app_override(app_id) + if not removed.get("success"): + raise RuntimeError(removed.get("error") or "Could not remove Flatpak preparation") + reset = self.configuration_service.reset_flatpak_config(app_id) + if not reset.get("success"): + raise RuntimeError(reset.get("error") or "Could not remove Flatpak profile") + return { + "success": True, + "message": "Flatpak profile removed", + "error": None, + "app_id": app_id, + "enabled": False, + } + except Exception as error: + return { + "success": False, + "message": "", + "error": str(error), + "app_id": str(app_id), + "enabled": True, + } + + def get_app(self, app_id: str) -> Dict[str, Any]: + apps = self.get_apps() + if not apps.get("success"): + return { + "success": False, + "message": "", + "error": apps.get("error"), + "app_id": str(app_id), + "enabled": False, + } + app = next((item for item in apps.get("apps", []) if item.get("app_id") == app_id), None) + if app is None: + return { + "success": False, + "message": "", + "error": "Flatpak application is not installed", + "app_id": str(app_id), + "enabled": False, + } + return {"success": True, "message": "", "error": None, **app} + + def get_apps(self) -> Dict[str, Any]: + try: + result = self.flatpak_service.get_flatpak_apps() + if not result.get("success"): + raise RuntimeError(result.get("error") or "Could not list Flatpak applications") + ownership = self.flatpak_service._read_state() + apps = [] + for item in result.get("apps", []): + app_id = item["app_id"] + config_result = self.configuration_service.get_flatpak_config(app_id) + entry = ownership["prepared_apps"].get(app_id) + workarounds = self.default_state() + if isinstance(entry, dict): + workarounds = self._validate_state(entry.get("workaround_state", workarounds)) + profile = self.configuration_service.flatpak_profile_name(app_id) + selector_ready = False + if item.get("prepared"): + shown = self.flatpak_service._run_flatpak_command( + ["override", "--user", "--show", app_id], + capture_output=True, + text=True, + ) + selector_ready = shown.returncode == 0 and f"LSFGVK_PROFILE={profile}" in shown.stdout.splitlines() + apps.append({ + **item, + "profile": profile, + "enabled": bool(item.get("owned") and config_result.get("exists") and selector_ready), + "config": config_result.get("config"), + "workarounds": workarounds, + }) + return { + "success": True, + "message": result.get("message", ""), + "error": None, + "apps": apps, + } + except Exception as error: + return {"success": False, "message": "", "error": str(error), "apps": []} + + def get_running_apps(self) -> Dict[str, Any]: + try: + state = self.flatpak_service._read_state() + enabled = set() + for app_id, entry in state["prepared_apps"].items(): + if not isinstance(entry, dict): + continue + config = self.configuration_service.get_flatpak_config(app_id) + if not config.get("exists"): + continue + existed, content = self.flatpak_service._snapshot_override(app_id) + if not existed or self.flatpak_service._sha256(content) != entry.get("managed_sha256"): + continue + profile = self.configuration_service.flatpak_profile_name(app_id) + try: + text = content.decode("utf-8") + except UnicodeDecodeError: + continue + if self._environment_value(text, "LSFGVK_PROFILE") == profile: + enabled.add(app_id) + if not enabled: + return {"success": True, "message": "", "error": None, "apps": []} + result = self.flatpak_service._run_flatpak_command( + ["ps", "--columns=application,active,pid"], + capture_output=True, + text=True, + ) + if result.returncode != 0: + raise OSError(result.stderr.strip() or "Could not inspect running Flatpak applications") + running = [] + for line in result.stdout.splitlines(): + fields = line.split("\t") if "\t" in line else line.split() + if not fields or fields[0] not in enabled: + continue + active = len(fields) > 1 and fields[1].strip().lower() in {"1", "true", "yes", "active"} + running.append({ + "app_id": fields[0], + "active": active, + "pid": fields[2].strip() if len(fields) > 2 else "", + "start_time": self.flatpak_service._process_start_time( + fields[2].strip() if len(fields) > 2 else "" + ), + }) + return {"success": True, "message": "", "error": None, "apps": running} + except Exception as error: + return {"success": False, "message": "", "error": str(error), "apps": []} diff --git a/py_modules/lsfg_vk/flatpak_service.py b/py_modules/lsfg_vk/flatpak_service.py index c9be0ec..f2ed90c 100644 --- a/py_modules/lsfg_vk/flatpak_service.py +++ b/py_modules/lsfg_vk/flatpak_service.py @@ -1,459 +1,662 @@ -""" -Flatpak service for managing lsfg-vk Flatpak runtime extensions. -""" +from __future__ import annotations -import subprocess +import hashlib +import json import os +import pwd +import re +import shutil +import subprocess +import threading from pathlib import Path -from typing import Dict, Any, List, Optional +from typing import Dict, Optional, Set from .base_service import BaseService -from .constants import ( - FLATPAK_23_08_FILENAME, FLATPAK_24_08_FILENAME, FLATPAK_25_08_FILENAME, BIN_DIR, CONFIG_DIR -) -from .types import BaseResponse - - -class FlatpakExtensionStatus(BaseResponse): - """Response for Flatpak extension status""" - def __init__(self, success: bool = False, message: str = "", error: str = "", - installed_23_08: bool = False, installed_24_08: bool = False, installed_25_08: bool = False): - super().__init__(success, message, error) - self.installed_23_08 = installed_23_08 - self.installed_24_08 = installed_24_08 - self.installed_25_08 = installed_25_08 - - -class FlatpakAppInfo(BaseResponse): - """Response for Flatpak app information""" - def __init__(self, success: bool = False, message: str = "", error: str = "", - apps: List[Dict[str, Any]] = None, total_apps: int = 0): - super().__init__(success, message, error) - self.apps = apps or [] - self.total_apps = total_apps - - -class FlatpakOverrideResponse(BaseResponse): - """Response for Flatpak override operations""" - def __init__(self, success: bool = False, message: str = "", error: str = "", - app_id: str = "", operation: str = ""): - super().__init__(success, message, error) - self.app_id = app_id - self.operation = operation class FlatpakService(BaseService): - """Service for handling Flatpak runtime extensions and app overrides""" + EXTENSION_ID = "org.freedesktop.Platform.VulkanLayer.lsfgvk" + FLATHUB_REMOTE = "flathub" + SUPPORTED_RUNTIMES = ("24.08", "25.08") + DERIVED_RUNTIME_IDS = {"org.gnome.Platform", "org.kde.Platform"} + RUNTIME_METADATA_SECTION = "Extension org.freedesktop.Platform.GL" + OWNERSHIP_FILENAME = "flatpak_state.json" + OWNERSHIP_VERSION = 2 + APP_ID_PATTERN = re.compile( + r"^[A-Za-z0-9][A-Za-z0-9-]*(?:\.[A-Za-z0-9][A-Za-z0-9-]*)+$" + ) def __init__(self, logger=None): super().__init__(logger) - self.extension_id_23_08 = "org.freedesktop.Platform.VulkanLayer.lsfgvk/x86_64/23.08" - self.extension_id_24_08 = "org.freedesktop.Platform.VulkanLayer.lsfgvk/x86_64/24.08" - self.extension_id_25_08 = "org.freedesktop.Platform.VulkanLayer.lsfgvk/x86_64/25.08" - self.flatpak_command = None - - def _get_clean_env(self): - """Get a clean environment without PyInstaller's bundled libraries""" - env = os.environ.copy() - - if 'LD_LIBRARY_PATH' in env: - del env['LD_LIBRARY_PATH'] - - standard_paths = ['/usr/bin', '/usr/local/bin', '/bin'] - current_path = env.get('PATH', '') + self.flatpak_command: Optional[str] = None + self._verified_branches: Set[str] = set() + self._lock = threading.RLock() - path_parts = current_path.split(':') if current_path else [] - for std_path in standard_paths: - if std_path not in path_parts: - path_parts.insert(0, std_path) + @property + def ownership_path(self) -> Path: + return self.config_dir / self.OWNERSHIP_FILENAME - env['PATH'] = ':'.join(path_parts) + @property + def backup_dir(self) -> Path: + return self.config_dir / "flatpak-overrides" + def _clean_env(self) -> Dict[str, str]: + env = os.environ.copy() + env.pop("LD_LIBRARY_PATH", None) + env["HOME"] = str(self.user_home) + try: + user_id = self.user_home.stat().st_uid + except OSError: + user_id = None + if user_id is not None: + env["XDG_RUNTIME_DIR"] = f"/run/user/{user_id}" + env["DBUS_SESSION_BUS_ADDRESS"] = f"unix:path=/run/user/{user_id}/bus" + path = [entry for entry in env.get("PATH", "").split(":") if entry] + for entry in ("/usr/bin", "/usr/local/bin", "/bin"): + if entry not in path: + path.insert(0, entry) + env["PATH"] = ":".join(path) return env - def _run_flatpak_command(self, args: List[str], **kwargs): - """Run flatpak command with clean environment to avoid library conflicts""" - if self.flatpak_command is None: - raise FileNotFoundError("Flatpak command not available") - - env = self._get_clean_env() - - self.log.info(f"Running flatpak with PATH: {env.get('PATH')}") - self.log.info(f"LD_LIBRARY_PATH removed: {'LD_LIBRARY_PATH' not in env}") - - return subprocess.run([self.flatpak_command] + args, env=env, **kwargs) - def check_flatpak_available(self) -> bool: - """Check if flatpak command is available and store the working command""" - self.log.info(f"PATH: {os.environ.get('PATH', 'Not set')}") - self.log.info(f"HOME: {os.environ.get('HOME', 'Not set')}") - self.log.info(f"USER: {os.environ.get('USER', 'Not set')}") - - flatpak_paths = [ - "flatpak", - "/usr/bin/flatpak", - "/var/lib/flatpak/exports/bin/flatpak", - "/home/deck/.local/bin/flatpak" - ] - - for flatpak_path in flatpak_paths: - try: - result = subprocess.run([flatpak_path, "--version"], - capture_output=True, check=True, text=True, - env=self._get_clean_env()) - self.log.info(f"Flatpak found at {flatpak_path}: {result.stdout.strip()}") - self.flatpak_command = flatpak_path - return True - except (subprocess.CalledProcessError, FileNotFoundError): - self.log.debug(f"Flatpak not found at {flatpak_path}") - continue + env = self._clean_env() + self.flatpak_command = shutil.which("flatpak", path=env["PATH"]) + return self.flatpak_command is not None - self.log.error("Flatpak command not found in any known locations") - self.flatpak_command = None - return False - - def get_extension_status(self) -> FlatpakExtensionStatus: - """Check if lsfg-vk Flatpak extensions are installed""" + def _run_flatpak_command(self, args, **kwargs): + if self.flatpak_command is None and not self.check_flatpak_available(): + raise FileNotFoundError("Flatpak command not available") + env = self._clean_env() + command = [self.flatpak_command, *args] try: - if not self.check_flatpak_available(): - error_msg = "Flatpak is not available on this system" - if self.flatpak_command is None: - error_msg += ". Command not found in PATH or common install locations." - self.log.error(error_msg) - return self._error_response(FlatpakExtensionStatus, - error_msg, - installed_23_08=False, installed_24_08=False, installed_25_08=False) - + user = pwd.getpwuid(self.user_home.stat().st_uid) + except (KeyError, OSError) as error: + raise RuntimeError(f"Unable to resolve Flatpak user for {self.user_home}") from error + if os.geteuid() != user.pw_uid: + runuser = shutil.which("runuser", path=env["PATH"]) + if runuser is None: + raise FileNotFoundError("runuser command not available") + command = [runuser, "--user", user.pw_name, "--", *command] + return subprocess.run(command, env=env, **kwargs) + + @classmethod + def _validate_app_id(cls, app_id: str) -> str: + if not isinstance(app_id, str) or not cls.APP_ID_PATTERN.fullmatch(app_id): + raise ValueError("Invalid Flatpak application ID") + return app_id + + @classmethod + def _validate_runtime(cls, branch: str) -> str: + if branch not in cls.SUPPORTED_RUNTIMES: + raise ValueError( + f"Unsupported Flatpak runtime branch {branch}; supported branches are " + + ", ".join(cls.SUPPORTED_RUNTIMES) + ) + return branch + + @classmethod + def runtime_branch_from_metadata(cls, metadata: str) -> str: + section = None + versions = [] + for raw_line in metadata.splitlines() if isinstance(metadata, str) else []: + line = raw_line.strip() + if line.startswith("[") and line.endswith("]"): + section = line[1:-1].strip() + continue + if section != cls.RUNTIME_METADATA_SECTION: + continue + key, separator, value = line.partition("=") + if separator and key.strip() == "versions": + versions.extend(part.strip() for part in value.split(";")) + for value in versions: + for branch in cls.SUPPORTED_RUNTIMES: + if value == branch or value.startswith(f"{branch}-"): + return branch + raise ValueError("Could not determine a supported Freedesktop base runtime from Flatpak metadata") + + @classmethod + def _extension_ref(cls, branch: str) -> str: + return f"{cls.EXTENSION_ID}/x86_64/{cls._validate_runtime(branch)}" + + def _installed_extension_branches(self, scope: Optional[str] = None) -> Set[str]: + scopes = ("user", "system") if scope is None else (scope,) + installed = set() + for item in scopes: result = self._run_flatpak_command( - ["list", "--runtime"], - capture_output=True, text=True, check=True + ["list", f"--{item}", "--runtime", "--columns=application,arch,branch"], + capture_output=True, + text=True, + check=True, ) - - installed_runtimes = result.stdout - - base_extension_name = "org.freedesktop.Platform.VulkanLayer.lsfgvk" - installed_23_08 = False - installed_24_08 = False - installed_25_08 = False - - for line in installed_runtimes.split('\n'): - if base_extension_name in line: - if "23.08" in line: - installed_23_08 = True - elif "24.08" in line: - installed_24_08 = True - elif "25.08" in line: - installed_25_08 = True - - status_msg = [] - if installed_23_08: - status_msg.append("23.08 runtime extension installed") - if installed_24_08: - status_msg.append("24.08 runtime extension installed") - if installed_25_08: - status_msg.append("25.08 runtime extension installed") - - if not status_msg: - status_msg.append("No lsfg-vk runtime extensions installed") - - return self._success_response(FlatpakExtensionStatus, - "; ".join(status_msg), - installed_23_08=installed_23_08, - installed_24_08=installed_24_08, - installed_25_08=installed_25_08) - - except subprocess.CalledProcessError as e: - error_msg = f"Error checking Flatpak extensions: {e.stderr if e.stderr else str(e)}" - self.log.error(error_msg) - return self._error_response(FlatpakExtensionStatus, error_msg, - installed_23_08=False, installed_24_08=False, installed_25_08=False) - - def install_extension(self, version: str) -> BaseResponse: - """Install a specific version of the lsfg-vk Flatpak extension""" + for line in result.stdout.splitlines(): + fields = line.split("\t") if "\t" in line else line.split() + if len(fields) >= 3 and fields[0] == self.EXTENSION_ID and fields[1] == "x86_64": + installed.add(fields[2]) + return installed + + def _user_extension_origin(self, branch: str) -> str: + result = self._run_flatpak_command( + ["info", "--user", "--show-origin", self._extension_ref(branch)], + capture_output=True, + text=True, + ) + return result.stdout.strip() if result.returncode == 0 else "" + + def _empty_state(self) -> Dict[str, object]: + return { + "version": self.OWNERSHIP_VERSION, + "plugin_owned_branches": [], + "prepared_apps": {}, + } + + def _read_state(self) -> Dict[str, object]: + if not self.ownership_path.exists(): + return self._empty_state() + if self.ownership_path.is_symlink() or not self.ownership_path.is_file(): + raise RuntimeError("Flatpak ownership metadata is not a regular file") try: - if version not in ["23.08", "24.08", "25.08"]: - return self._error_response(BaseResponse, "Invalid version. Must be '23.08', '24.08', or '25.08'") - - if not self.check_flatpak_available(): - return self._error_response(BaseResponse, "Flatpak is not available on this system") - - plugin_dir = Path(__file__).parent.parent.parent - if version == "23.08": - filename = FLATPAK_23_08_FILENAME - elif version == "24.08": - filename = FLATPAK_24_08_FILENAME - else: - filename = FLATPAK_25_08_FILENAME - flatpak_path = plugin_dir / BIN_DIR / filename - - if not flatpak_path.exists(): - return self._error_response(BaseResponse, f"Flatpak file not found: {flatpak_path}") - - result = self._run_flatpak_command( - ["install", "--user", "--noninteractive", str(flatpak_path)], - capture_output=True, text=True + data = json.loads(self.ownership_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise RuntimeError(f"Could not read Flatpak ownership metadata: {error}") from error + if data.get("version") != self.OWNERSHIP_VERSION: + raise RuntimeError("Unsupported Flatpak ownership metadata version") + branches = data.get("plugin_owned_branches") + apps = data.get("prepared_apps") + if not isinstance(branches, list) or not isinstance(apps, dict): + raise RuntimeError("Invalid Flatpak ownership metadata") + for branch in branches: + self._validate_runtime(branch) + for app_id, entry in apps.items(): + self._validate_app_id(app_id) + if not isinstance(entry, dict): + raise RuntimeError("Invalid Flatpak app ownership metadata") + if type(entry.get("override_existed")) is not bool: + raise RuntimeError("Invalid Flatpak app ownership metadata") + if not isinstance(entry.get("managed_sha256"), str): + raise RuntimeError("Invalid Flatpak app ownership metadata") + return data + + def _write_state(self, state: Dict[str, object]) -> None: + branches = state.get("plugin_owned_branches", []) + apps = state.get("prepared_apps", {}) + if not branches and not apps: + self.ownership_path.unlink(missing_ok=True) + if self.backup_dir.exists() and not any(self.backup_dir.iterdir()): + self.backup_dir.rmdir() + return + self._write_file( + self.ownership_path, + json.dumps(state, indent=2, sort_keys=True) + "\n", + ) + + def _owned_branches(self, state: Optional[Dict[str, object]] = None) -> Set[str]: + current = state if state is not None else self._read_state() + return {self._validate_runtime(branch) for branch in current["plugin_owned_branches"]} + + def _override_path(self, app_id: str) -> Path: + return self.user_home / ".local/share/flatpak/overrides" / self._validate_app_id(app_id) + + def _backup_path(self, app_id: str) -> Path: + return self.backup_dir / f"{self._validate_app_id(app_id)}.ini" + + @staticmethod + def _sha256(content: bytes) -> str: + return hashlib.sha256(content).hexdigest() + + @staticmethod + def _parse_process_start_time(stat_content: str) -> Optional[int]: + closing_command = stat_content.rfind(")") + if closing_command < 0: + return None + fields = stat_content[closing_command + 2:].split() + if len(fields) <= 19: + return None + try: + return int(fields[19]) + except (TypeError, ValueError): + return None + + @classmethod + def _process_start_time(cls, pid: str) -> Optional[int]: + if not isinstance(pid, str) or re.fullmatch(r"[0-9]+", pid) is None: + return None + try: + stat_content = (Path("/proc") / pid / "stat").read_text(encoding="utf-8") + except OSError: + return None + return cls._parse_process_start_time(stat_content) + + def _snapshot_override(self, app_id: str) -> tuple[bool, bytes]: + path = self._override_path(app_id) + if path.is_symlink(): + raise RuntimeError("Flatpak override path is a symlink") + if not path.exists(): + return False, b"" + if not path.is_file(): + raise RuntimeError("Flatpak override path is not a regular file") + return True, path.read_bytes() + + def _resolve_runtime(self, app_id: str) -> tuple[str, str]: + self._validate_app_id(app_id) + if not self.check_flatpak_available(): + raise FileNotFoundError("Flatpak is not available on this system") + result = self._run_flatpak_command( + ["info", "--show-runtime", app_id], + capture_output=True, + text=True, + ) + if result.returncode != 0: + raise OSError(result.stderr.strip() or f"Could not inspect Flatpak app {app_id}") + runtime = result.stdout.strip().splitlines()[0] if result.stdout.strip() else "" + parts = runtime.split("/") + if len(parts) != 3: + raise ValueError(f"Unsupported Flatpak runtime reference: {runtime}") + if parts[0] == "org.freedesktop.Platform": + return runtime, self._validate_runtime(parts[2]) + if parts[0] not in self.DERIVED_RUNTIME_IDS: + raise ValueError(f"Unsupported Flatpak runtime reference: {runtime}") + metadata_result = self._run_flatpak_command( + ["info", "--show-metadata", runtime], + capture_output=True, + text=True, + ) + if metadata_result.returncode != 0: + raise OSError(metadata_result.stderr.strip() or f"Could not inspect Flatpak runtime {runtime}") + return runtime, self.runtime_branch_from_metadata(metadata_result.stdout) + + def _dll_directory(self) -> Path: + if self.config_file_path.exists(): + try: + content = self.config_file_path.read_text(encoding="utf-8") + match = re.search( + r'(?m)^[ \t]*dll[ \t]*=[ \t]*"((?:\\.|[^"\\])*)"', + content, + ) + if match: + configured_dll = json.loads('"' + match.group(1) + '"') + if configured_dll: + return Path(configured_dll).parent + except Exception: + pass + return self.user_home / ".local/share/Steam/steamapps/common/Lossless Scaling" + + def _filesystem_present(self, entries: str, host_path: Path) -> bool: + accepted = {str(host_path)} + try: + accepted.add(f"~/{host_path.relative_to(self.user_home).as_posix()}") + except ValueError: + pass + enabled = False + for raw in entries.split(";"): + value = raw.strip() + if not value: + continue + denied = value.startswith("!") + path = value[1:] if denied else value + path = path.split(":", 1)[0] + if path in accepted: + if denied: + return False + enabled = True + return enabled + + def _app_override_status(self, app_id: str) -> Dict[str, object]: + result = self._run_flatpak_command( + ["override", "--user", "--show", app_id], + capture_output=True, + text=True, + ) + output = result.stdout if result.returncode == 0 else "" + section = None + filesystems = "" + unset_environment = set() + environment = {} + for raw_line in output.splitlines(): + line = raw_line.strip() + if line.startswith("[") and line.endswith("]"): + section = line[1:-1] + continue + key, separator, value = line.partition("=") + if not separator: + continue + if section == "Context" and key == "filesystems": + filesystems = value + elif section == "Context" and key == "unset-environment": + unset_environment.update(item for item in value.split(";") if item) + elif section == "Environment": + environment[key] = value + config_ready = self._filesystem_present(filesystems, self.config_dir) + dll_ready = self._filesystem_present(filesystems, self._dll_directory()) + env_ready = ( + environment.get("LSFGVK_CONFIG") == str(self.config_file_path) + and environment.get("LSFGVK_FLATPAK") == "1" + and "DISABLE_LSFGVK" in unset_environment + and "DISABLE_LSFG" in unset_environment + ) + return { + "filesystem_ready": config_ready and dll_ready, + "environment_ready": env_ready, + "prepared": config_ready and dll_ready and env_ready, + } + + def get_extension_status(self): + try: + available = self.check_flatpak_available() + installed = self._installed_extension_branches() if available else set() + return self._success_response( + dict, + "Flatpak runtime extension status retrieved" if available else "Flatpak is not available", + available=available, + extension_id=self.EXTENSION_ID, + supported_branches=list(self.SUPPORTED_RUNTIMES), + installed_branches=sorted(installed), + ) + except Exception as error: + return self._error_response( + dict, + str(error), + available=False, + extension_id=self.EXTENSION_ID, + supported_branches=list(self.SUPPORTED_RUNTIMES), + installed_branches=[], ) - if result.returncode != 0: - error_msg = f"Failed to install Flatpak extension: {result.stderr}" - self.log.error(error_msg) - return self._error_response(BaseResponse, error_msg) - - self.log.info(f"Successfully installed lsfg-vk Flatpak extension {version}") - return self._success_response(BaseResponse, f"lsfg-vk {version} runtime extension installed successfully") - - except Exception as e: - error_msg = f"Error installing Flatpak extension {version}: {str(e)}" - self.log.error(error_msg) - return self._error_response(BaseResponse, error_msg) + get_flatpak_support_status = get_extension_status - def uninstall_extension(self, version: str) -> BaseResponse: - """Uninstall a specific version of the lsfg-vk Flatpak extension""" + def install_extension(self, branch: str): try: - if version not in ["23.08", "24.08", "25.08"]: - return self._error_response(BaseResponse, "Invalid version. Must be '23.08', '24.08', or '25.08'") - + branch = self._validate_runtime(branch) if not self.check_flatpak_available(): - return self._error_response(BaseResponse, "Flatpak is not available on this system") - - if version == "23.08": - extension_id = self.extension_id_23_08 - elif version == "24.08": - extension_id = self.extension_id_24_08 - else: - extension_id = self.extension_id_25_08 - - result = self._run_flatpak_command( - ["uninstall", "--user", "--noninteractive", extension_id], - capture_output=True, text=True - ) - - if result.returncode != 0: - error_msg = f"Failed to uninstall Flatpak extension: {result.stderr}" - self.log.error(error_msg) - return self._error_response(BaseResponse, error_msg) - - self.log.info(f"Successfully uninstalled lsfg-vk Flatpak extension {version}") - return self._success_response(BaseResponse, f"lsfg-vk {version} runtime extension uninstalled successfully") - - except Exception as e: - error_msg = f"Error uninstalling Flatpak extension {version}: {str(e)}" - self.log.error(error_msg) - return self._error_response(BaseResponse, error_msg) - - def get_flatpak_apps(self) -> FlatpakAppInfo: - """Get list of installed Flatpak apps and their lsfg-vk override status""" + raise FileNotFoundError("Flatpak is not available on this system") + with self._lock: + if branch in self._verified_branches: + return self._extension_result(branch, True, False, "is ready") + user_installed = self._installed_extension_branches("user") + system_installed = self._installed_extension_branches("system") + if branch in system_installed and branch not in user_installed: + return self._extension_result(branch, True, False, "is ready") + if branch in user_installed and self._user_extension_origin(branch) != self.FLATHUB_REMOTE: + result = self._run_flatpak_command( + ["uninstall", "--user", "--noninteractive", self._extension_ref(branch)], + capture_output=True, + text=True, + ) + if result.returncode != 0: + raise OSError(result.stderr.strip() or "Could not replace the existing Flatpak extension") + result = self._run_flatpak_command( + [ + "install", + "--user", + "--noninteractive", + "--or-update", + self.FLATHUB_REMOTE, + f"{self.EXTENSION_ID}//{branch}", + ], + capture_output=True, + text=True, + ) + if result.returncode != 0: + raise OSError(result.stderr.strip() or "Flatpak installation failed") + if branch not in self._installed_extension_branches("user"): + raise RuntimeError(f"Flatpak install completed but {self._extension_ref(branch)} was not visible afterwards") + state = self._read_state() + owned = self._owned_branches(state) + owned.add(branch) + state["plugin_owned_branches"] = sorted(owned) + self._write_state(state) + self._verified_branches.add(branch) + return self._extension_result(branch, True, False, "installed") + except Exception as error: + return self._error_response(dict, str(error), runtime_branch=branch, installed=False, enabled=False) + + def _remove_extension(self, branch: str) -> bool: + if branch not in self._installed_extension_branches("user"): + return False + result = self._run_flatpak_command( + ["uninstall", "--user", "--noninteractive", self._extension_ref(branch)], + capture_output=True, + text=True, + ) + if result.returncode != 0: + raise OSError(result.stderr.strip() or "Flatpak uninstall failed") + return True + + def _extension_result(self, branch: str, installed: bool, removed: bool, verb: str): + return self._success_response( + dict, + f"lsfg-vk {branch} runtime extension {verb}", + runtime_branch=branch, + installed=installed, + enabled=installed, + removed=removed, + ) + + def uninstall_extension(self, branch: str): try: + branch = self._validate_runtime(branch) if not self.check_flatpak_available(): - error_msg = "Flatpak is not available on this system" - if self.flatpak_command is None: - error_msg += ". Command not found in PATH or common install locations." - return self._error_response(FlatpakAppInfo, - error_msg, - apps=[], total_apps=0) - + raise FileNotFoundError("Flatpak is not available on this system") + with self._lock: + state = self._read_state() + owned = self._owned_branches(state) + if branch not in owned: + installed = branch in self._installed_extension_branches() + return self._extension_result(branch, installed, False, "preserved (not plugin-owned)") + removed = self._remove_extension(branch) + owned.remove(branch) + state["plugin_owned_branches"] = sorted(owned) + self._write_state(state) + installed = branch in self._installed_extension_branches() + return self._extension_result(branch, installed, removed, "uninstalled") + except Exception as error: + return self._error_response(dict, str(error), runtime_branch=branch, removed=False, installed=False, enabled=False) + + def ensure_extension(self, branch: str): + return self.install_extension(branch) + + def set_extension_enabled(self, branch: str, enabled: bool): + if type(enabled) is not bool: + return self._error_response(dict, "enabled must be a boolean", runtime_branch=branch, installed=False, enabled=False) + return self.install_extension(branch) if enabled else self.uninstall_extension(branch) + + def get_flatpak_apps(self): + try: + if not self.check_flatpak_available(): + raise FileNotFoundError("Flatpak is not available on this system") + installed_extensions = self._installed_extension_branches() + state = self._read_state() + owned_apps = state["prepared_apps"] result = self._run_flatpak_command( - ["list", "--app"], - capture_output=True, text=True, check=True + ["list", "--app", "--columns=name,application"], + capture_output=True, + text=True, + check=True, ) - apps = [] - for line in result.stdout.strip().split('\n'): - if not line.strip(): + for line in result.stdout.splitlines(): + fields = line.split("\t") + if len(fields) < 2: continue - - parts = line.split('\t') - if len(parts) >= 2: - app_name = parts[0].strip() - app_id = parts[1].strip() - - # Check override status - override_status = self._check_app_override_status(app_id) - - apps.append({ - "app_id": app_id, - "app_name": app_name, - "has_filesystem_override": override_status["filesystem"], - "has_env_override": override_status["env"] + name, app_id = fields[0].strip(), fields[1].strip() + if not app_id: + continue + item = { + "app_id": app_id, + "app_name": name or app_id, + "runtime": None, + "runtime_branch": None, + "runtime_ready": False, + "prepared": False, + "owned": app_id in owned_apps, + "error": None, + } + try: + runtime, branch = self._resolve_runtime(app_id) + status = self._app_override_status(app_id) + item.update({ + "runtime": runtime, + "runtime_branch": branch, + "runtime_ready": branch in installed_extensions, + "prepared": status["prepared"], }) - - return self._success_response(FlatpakAppInfo, - f"Found {len(apps)} Flatpak applications", - apps=apps, total_apps=len(apps)) - - except subprocess.CalledProcessError as e: - error_msg = f"Error getting Flatpak apps: {e.stderr if e.stderr else str(e)}" - self.log.error(error_msg) - return self._error_response(FlatpakAppInfo, error_msg, apps=[], total_apps=0) - - def _check_app_override_status(self, app_id: str) -> Dict[str, bool]: - """Check if an app has lsfg-vk overrides set""" - try: - result = self._run_flatpak_command( - ["override", "--user", "--show", app_id], - capture_output=True, text=True - ) - - if result.returncode != 0: - return {"filesystem": False, "env": False} - - output = result.stdout - home_path = os.path.expanduser("~") - config_path = f"{home_path}/.config/lsfg-vk" - dll_path = f"{home_path}/.local/share/Steam/steamapps/common/Lossless Scaling/Lossless.dll" - lsfg_path = f"{home_path}/lsfg" - - filesystem_section = "" - in_context = False - - for line in output.split('\n'): - line = line.strip() - if line == "[Context]": - in_context = True - elif line.startswith("[") and line != "[Context]": - in_context = False - elif in_context and line.startswith("filesystems="): - filesystem_section = line - break - - has_config_fs = config_path in filesystem_section - has_dll_fs = dll_path in filesystem_section - has_lsfg_fs = lsfg_path in filesystem_section - - filesystem_override = has_config_fs and has_dll_fs and has_lsfg_fs - - env_override = False - in_environment = False - - for line in output.split('\n'): - line = line.strip() - if line == "[Environment]": - in_environment = True - elif line.startswith("[") and line != "[Environment]": - in_environment = False - elif in_environment and line.startswith(f"LSFG_CONFIG={config_path}/conf.toml"): - env_override = True - break - - self.log.debug(f"Override status for {app_id}: filesystem={filesystem_override} ({has_config_fs}/{has_dll_fs}/{has_lsfg_fs}), env={env_override}") - - return {"filesystem": filesystem_override, "env": env_override} - - except Exception as e: - self.log.error(f"Error checking override status for {app_id}: {e}") - return {"filesystem": False, "env": False} - - def set_app_override(self, app_id: str) -> FlatpakOverrideResponse: - """Set lsfg-vk overrides for a Flatpak app""" + except Exception as error: + item["error"] = str(error) + apps.append(item) + apps.sort(key=lambda item: str(item["app_name"]).lower()) + return self._success_response(dict, f"Found {len(apps)} Flatpak applications", apps=apps) + except Exception as error: + return self._error_response(dict, str(error), apps=[]) + + def prepare_app(self, app_id: str): try: - if not self.check_flatpak_available(): - return self._error_response(FlatpakOverrideResponse, - "Flatpak is not available on this system", - app_id=app_id, operation="set") - - home_path = os.path.expanduser("~") - config_path = f"{home_path}/.config/lsfg-vk" - dll_path = f"{home_path}/.local/share/Steam/steamapps/common/Lossless Scaling/Lossless.dll" - lsfg_path = f"{home_path}/lsfg" - - filesystem_overrides = [ - f"--filesystem={dll_path}", - f"--filesystem={config_path}:rw", - f"--filesystem={lsfg_path}:rw" - ] - - for override in filesystem_overrides: + app_id = self._validate_app_id(app_id) + with self._lock: + runtime, branch = self._resolve_runtime(app_id) + extension = self.ensure_extension(branch) + if not extension.get("success") or not extension.get("installed"): + raise RuntimeError(extension.get("error") or f"Could not install Flatpak runtime {branch}") + state = self._read_state() + apps = state["prepared_apps"] + status = self._app_override_status(app_id) + if status["prepared"] and app_id not in apps: + return self._success_response( + dict, + "Flatpak application is already prepared outside this plugin", + app_id=app_id, + runtime=runtime, + runtime_branch=branch, + prepared=True, + owned=False, + ) + if app_id not in apps: + existed, original = self._snapshot_override(app_id) + backup = self._backup_path(app_id) + if existed: + self._write_file(backup, original.decode("utf-8")) + else: + backup.unlink(missing_ok=True) + apps[app_id] = { + "override_existed": existed, + "managed_sha256": "", + } result = self._run_flatpak_command( - ["override", "--user", override, app_id], - capture_output=True, text=True + [ + "override", + "--user", + f"--filesystem={self.config_dir}:ro", + f"--filesystem={self._dll_directory()}:ro", + f"--env=LSFGVK_CONFIG={self.config_file_path}", + "--env=LSFGVK_FLATPAK=1", + "--unset-env=DISABLE_LSFGVK", + "--unset-env=DISABLE_LSFG", + app_id, + ], + capture_output=True, + text=True, ) if result.returncode != 0: - error_msg = f"Failed to set filesystem override {override}: {result.stderr}" - return self._error_response(FlatpakOverrideResponse, error_msg, - app_id=app_id, operation="set") - - result = self._run_flatpak_command( - ["override", "--user", f"--env=LSFG_CONFIG={config_path}/conf.toml", app_id], - capture_output=True, text=True - ) - - if result.returncode != 0: - error_msg = f"Failed to set environment override: {result.stderr}" - return self._error_response(FlatpakOverrideResponse, error_msg, - app_id=app_id, operation="set") - - self.log.info(f"Successfully set lsfg-vk overrides for {app_id}") - return self._success_response(FlatpakOverrideResponse, - f"lsfg-vk overrides set for {app_id}", - app_id=app_id, operation="set") - - except Exception as e: - error_msg = f"Error setting overrides for {app_id}: {str(e)}" - self.log.error(error_msg) - return self._error_response(FlatpakOverrideResponse, error_msg, - app_id=app_id, operation="set") + raise OSError(result.stderr.strip() or f"Could not prepare Flatpak app {app_id}") + status = self._app_override_status(app_id) + if not status["prepared"]: + raise RuntimeError(f"Flatpak preparation did not become visible for {app_id}") + existed, managed = self._snapshot_override(app_id) + if not existed: + raise RuntimeError(f"Flatpak override for {app_id} was not created") + apps[app_id]["managed_sha256"] = self._sha256(managed) + self._write_state(state) + return self._success_response( + dict, + "Flatpak application prepared for lsfg-vk", + app_id=app_id, + runtime=runtime, + runtime_branch=branch, + prepared=True, + owned=True, + ) + except Exception as error: + return self._error_response(dict, str(error), app_id=app_id, prepared=False, owned=False) - def remove_app_override(self, app_id: str) -> FlatpakOverrideResponse: - """Remove lsfg-vk overrides for a Flatpak app""" + def remove_app_override(self, app_id: str): try: - if not self.check_flatpak_available(): - return self._error_response(FlatpakOverrideResponse, - "Flatpak is not available on this system", - app_id=app_id, operation="remove") - - home_path = os.path.expanduser("~") - config_path = f"{home_path}/.config/lsfg-vk" - dll_path = f"{home_path}/.local/share/Steam/steamapps/common/Lossless Scaling/Lossless.dll" - lsfg_path = f"{home_path}/lsfg" - - reset_result = self._run_flatpak_command( - ["override", "--user", "--reset", app_id], - capture_output=True, text=True - ) - - if reset_result.returncode == 0: - self.log.info(f"Successfully reset all overrides for {app_id}") - return self._success_response(FlatpakOverrideResponse, - f"All overrides reset for {app_id}", - app_id=app_id, operation="remove") - - self.log.debug(f"Reset failed, trying individual removal: {reset_result.stderr}") - - filesystem_overrides = [ - f"--nofilesystem={dll_path}", - f"--nofilesystem={config_path}", - f"--nofilesystem={lsfg_path}" - ] - - removal_errors = [] - - # Remove filesystem overrides - for override in filesystem_overrides: - result = self._run_flatpak_command( - ["override", "--user", override, app_id], - capture_output=True, text=True + app_id = self._validate_app_id(app_id) + with self._lock: + state = self._read_state() + apps = state["prepared_apps"] + entry = apps.get(app_id) + if entry is None: + return self._success_response( + dict, + "Flatpak application is not plugin-owned; existing overrides were preserved", + app_id=app_id, + prepared=self._app_override_status(app_id)["prepared"], + owned=False, + ) + existed, current = self._snapshot_override(app_id) + current_hash = self._sha256(current) if existed else self._sha256(b"") + if current_hash != entry["managed_sha256"]: + raise RuntimeError( + "Flatpak override changed after preparation; refusing to overwrite unrelated settings" + ) + override_path = self._override_path(app_id) + backup_path = self._backup_path(app_id) + if entry["override_existed"]: + if not backup_path.is_file() or backup_path.is_symlink(): + raise RuntimeError("Flatpak override backup is unavailable") + self._write_file(override_path, backup_path.read_text(encoding="utf-8")) + else: + override_path.unlink(missing_ok=True) + backup_path.unlink(missing_ok=True) + apps.pop(app_id, None) + self._write_state(state) + return self._success_response( + dict, + "Plugin-owned Flatpak preparation removed", + app_id=app_id, + prepared=False, + owned=False, ) - if result.returncode != 0: - removal_errors.append(f"{override}: {result.stderr}") + except Exception as error: + return self._error_response(dict, str(error), app_id=app_id, prepared=False, owned=True) - result = self._run_flatpak_command( - ["override", "--user", "--unset-env=LSFG_CONFIG", app_id], - capture_output=True, text=True - ) - - if result.returncode != 0: - removal_errors.append(f"unset-env: {result.stderr}") - - if removal_errors: - self.log.warning(f"Some override removals had issues for {app_id}: {'; '.join(removal_errors)}") - - self.log.info(f"Completed override removal for {app_id}") - return self._success_response(FlatpakOverrideResponse, - f"lsfg-vk overrides removed for {app_id}", - app_id=app_id, operation="remove") - - except Exception as e: - error_msg = f"Error removing overrides for {app_id}: {str(e)}" - self.log.error(error_msg) - return self._error_response(FlatpakOverrideResponse, error_msg, - app_id=app_id, operation="remove")
\ No newline at end of file + def remove_plugin_owned_environment(self): + try: + with self._lock: + state = self._read_state() + failures = [] + removed_apps = [] + for app_id in list(state["prepared_apps"]): + result = self.remove_app_override(app_id) + if result.get("success"): + removed_apps.append(app_id) + else: + failures.append(f"{app_id}: {result.get('error')}") + if failures: + return self._error_response( + dict, + "; ".join(failures), + removed_apps=removed_apps, + removed_branches=[], + ) + state = self._read_state() + removed_branches = [] + for branch in sorted(self._owned_branches(state)): + result = self.uninstall_extension(branch) + if result.get("success"): + removed_branches.append(branch) + else: + failures.append(f"{branch}: {result.get('error')}") + if failures: + return self._error_response( + dict, + "; ".join(failures), + removed_apps=removed_apps, + removed_branches=removed_branches, + ) + return self._success_response( + dict, + "Plugin-owned Flatpak state removed", + removed_apps=removed_apps, + removed_branches=removed_branches, + ) + except Exception as error: + return self._error_response(dict, str(error), removed_apps=[], removed_branches=[]) diff --git a/py_modules/lsfg_vk/installation.py b/py_modules/lsfg_vk/installation.py index ce47268..e7366ee 100644 --- a/py_modules/lsfg_vk/installation.py +++ b/py_modules/lsfg_vk/installation.py @@ -1,443 +1,256 @@ -""" -Installation service for lsfg-vk. -""" - import os -import platform import shutil -import traceback -import zipfile +import tarfile import tempfile -import json +import traceback from pathlib import Path -from typing import Dict, Any +from typing import Dict from .base_service import BaseService +from .config_schema import ConfigurationManager, ProfileData from .constants import ( - LIB_FILENAME, JSON_FILENAME, ZIP_FILENAME, BIN_DIR, - SO_EXT, JSON_EXT, ARM_LIB_FILENAME, ARMADA_DEVICE_ENV + ARCHIVE_FILENAME, + BIN_DIR, + CLI_FILENAME, + JSON_FILENAME, + JSON_X86_FILENAME, + LEGACY_JSON_FILENAME, + LEGACY_LIB_FILENAME, + LIB_FILENAME, + LIB_X86_FILENAME, + LOCAL_SHARE, + UI_DESKTOP_FILENAME, + UI_FILENAME, + UI_ICON_FILENAME, ) -from .config_schema import ConfigurationManager -from .types import InstallationResponse, UninstallationResponse, InstallationCheckResponse +from .runtime_service import RuntimeService +from .steam_service import SteamService +from .types import InstallationCheckResponse, InstallationResponse, UninstallationResponse class InstallationService(BaseService): - """Service for handling lsfg-vk installation and uninstallation""" - - def __init__(self, logger=None): + def __init__( + self, + logger=None, + runtime_service: RuntimeService = None, + steam_service: SteamService = None, + ): super().__init__(logger) - + self.runtime_service = runtime_service or RuntimeService(logger=self.log) + self.steam_service = steam_service or SteamService(logger=self.log) self.lib_file = self.local_lib_dir / LIB_FILENAME + self.lib_x86_file = self.local_lib_dir / LIB_X86_FILENAME self.json_file = self.local_share_dir / JSON_FILENAME - + self.json_x86_file = self.local_share_dir / JSON_X86_FILENAME + self.cli_file = self.local_bin_dir / CLI_FILENAME + self.legacy_lib_file = self.local_lib_dir / LEGACY_LIB_FILENAME + self.legacy_json_file = self.local_share_dir / LEGACY_JSON_FILENAME + def install(self) -> InstallationResponse: - """Install lsfg-vk by extracting the zip file to ~/.local - - Returns: - InstallationResponse with success status and message/error - """ try: - plugin_dir = Path(__file__).parent.parent.parent - zip_path = plugin_dir / BIN_DIR / ZIP_FILENAME - - if not zip_path.exists(): - error_msg = f"{ZIP_FILENAME} not found at {zip_path}" - self.log.error(error_msg) - return self._error_response(InstallationResponse, error_msg, message="") - + archive_path = Path(__file__).parent.parent.parent / BIN_DIR / ARCHIVE_FILENAME + if not archive_path.exists(): + raise FileNotFoundError(f"{ARCHIVE_FILENAME} not found at {archive_path}") self._ensure_directories() - - self._extract_and_install_files(zip_path) - - # If on ARM, overwrite the .so with the ARM version - if self._is_arm_architecture(): - self.log.info("Detected ARM architecture, using ARM binary") - arm_so_path = plugin_dir / BIN_DIR / ARM_LIB_FILENAME - self._copy_plugin_file(arm_so_path, self.lib_file) - self.log.info(f"Overwrote with ARM binary: {self.lib_file}") + profile_data = self._prepare_config() + self._install_archive(archive_path) + content = ConfigurationManager.generate_toml_content_multi_profile(profile_data) + self.runtime_service.validate_config_content(content) + self._write_file(self.config_file_path, content, 0o644) + self._remove_legacy_layer_files() + return self._success_response(InstallationResponse, "lsfg-vk 2.0.0 installed successfully") + except Exception as error: + self.log.error(f"Error installing lsfg-vk: {error}") + return self._error_response(InstallationResponse, str(error)) - self._create_config_file() - - self._create_lsfg_launch_script() - - self.log.info("lsfg-vk installed successfully") - 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 self._error_response(InstallationResponse, str(e), message="") - except Exception as e: - error_msg = f"Unexpected error installing lsfg-vk: {str(e)}" - self.log.error(error_msg) - return self._error_response(InstallationResponse, str(e), message="") - - def _is_arm_architecture(self) -> bool: - """Check if running on ARM architecture + def _payload_destinations(self) -> Dict[str, tuple[Path, int]]: + return { + f"bin/{CLI_FILENAME}": (self.cli_file, 0o755), + f"bin/{UI_FILENAME}": (self.local_bin_dir / UI_FILENAME, 0o755), + f"lib/{LIB_FILENAME}": (self.lib_file, 0o644), + f"lib/{LIB_X86_FILENAME}": (self.lib_x86_file, 0o644), + f"share/vulkan/implicit_layer.d/{JSON_FILENAME}": (self.json_file, 0o644), + f"share/vulkan/implicit_layer.d/{JSON_X86_FILENAME}": (self.json_x86_file, 0o644), + f"share/applications/{UI_DESKTOP_FILENAME}": ( + self.user_home / LOCAL_SHARE / "applications" / UI_DESKTOP_FILENAME, + 0o644, + ), + f"share/icons/hicolor/256x256/apps/{UI_ICON_FILENAME}": ( + self.user_home / LOCAL_SHARE / "icons/hicolor/256x256/apps" / UI_ICON_FILENAME, + 0o644, + ), + } - Returns: - True if running on ARM (aarch64), False otherwise - """ - if platform.machine().lower() in ('aarch64', 'arm64'): - return True + def _install_archive(self, archive_path: Path) -> None: + destinations = self._payload_destinations() + found = set() + with tarfile.open(archive_path, "r:*") as archive: + members = { + member.name.removeprefix("./"): member + for member in archive.getmembers() + if member.isfile() + } + for source_path, (destination, mode) in destinations.items(): + member = members.get(source_path) + if member is None: + continue + source = archive.extractfile(member) + if source is None: + continue + destination.parent.mkdir(parents=True, exist_ok=True) + temporary_path = None + try: + with tempfile.NamedTemporaryFile( + mode="wb", + dir=destination.parent, + prefix=f".{destination.name}.", + delete=False, + ) as temporary_file: + temporary_path = Path(temporary_file.name) + with source: + shutil.copyfileobj(source, temporary_file) + temporary_file.flush() + os.fsync(temporary_file.fileno()) + temporary_path.chmod(mode) + os.replace(temporary_path, destination) + found.add(source_path) + except Exception: + if temporary_path is not None: + temporary_path.unlink(missing_ok=True) + raise + missing = sorted(set(destinations) - found) + if missing: + raise OSError("Archive is missing required files: " + ", ".join(missing)) - # Decky runs through FEX on Armada, so Python reports x86_64 even - # though the host is AArch64. Armada exposes this native helper only - # on its ARM image, including inside Decky's FEX rootfs. - if ARMADA_DEVICE_ENV.is_file(): - self.log.info("Detected native AArch64 Armada host through device-env") - return True + def _default_config(self) -> ProfileData: + defaults = ConfigurationManager.get_defaults() + return ProfileData( + profiles={}, + global_config={"dll": defaults["dll"], "no_fp16": defaults["no_fp16"]}, + ) - # Fall back to the native PID 1 ELF header. e_machine 183 is AArch64. + def _prepare_config(self) -> ProfileData: try: - with Path('/proc/1/exe').open('rb') as host_executable: - elf_header = host_executable.read(20) - if elf_header[:4] == b'\x7fELF' and elf_header[5] in (1, 2): - byte_order = 'little' if elf_header[5] == 1 else 'big' - if int.from_bytes(elf_header[18:20], byte_order) == 183: - self.log.info("Detected native AArch64 host through PID 1") - return True - except OSError as e: - self.log.debug(f"Could not inspect native host architecture: {e}") + profile_data = ( + ConfigurationManager.parse_toml_content_multi_profile( + self.config_file_path.read_text(encoding="utf-8") + ) + if self.config_file_path.exists() + else self._default_config() + ) + except ValueError: + profile_data = self._default_config() + self._resolve_dll_path(profile_data) + defaults = ConfigurationManager.get_defaults() + for name, profile in profile_data["profiles"].items(): + profile_data["profiles"][name] = ConfigurationManager.validate_config( + {**defaults, **profile, **profile_data["global_config"]} + ) + return profile_data + def _resolve_dll_path(self, profile_data: ProfileData) -> bool: + current_path = str(profile_data["global_config"].get("dll") or "") + if current_path and Path(current_path).is_file(): + return False + dll_path = self.steam_service.find_lsfg_vk_dll() + if dll_path and current_path != dll_path: + profile_data["global_config"]["dll"] = dll_path + return True return False - @staticmethod - def _copy_plugin_file(src_file: Path, dst_file: Path) -> None: - """Copy plugin content without preserving FEX-incompatible metadata.""" - shutil.copyfile(src_file, dst_file) - dst_file.chmod(0o644) + def _remove_legacy_layer_files(self) -> None: + for path in (self.legacy_lib_file, self.legacy_json_file): + self._remove_if_exists(path) - def _extract_and_install_files(self, zip_path: Path) -> None: - """Extract zip file and install files to appropriate locations - - Args: - zip_path: Path to the zip file to extract - - Raises: - zipfile.BadZipFile: If zip file is corrupted - OSError: If file operations fail - """ - # Destination mapping for file types - dest_map = { - SO_EXT: self.local_lib_dir, - JSON_EXT: self.local_share_dir - } - - with zipfile.ZipFile(zip_path, 'r') as zip_ref: - with tempfile.TemporaryDirectory() as temp_dir: - temp_path = Path(temp_dir) - zip_ref.extractall(temp_path) - - # Process extracted files - for root, dirs, files in os.walk(temp_path): - root_path = Path(root) - for file in files: - src_file = root_path / file - file_path = Path(file) - - # Check if we know where this file type should go - dst_dir = dest_map.get(file_path.suffix) - if dst_dir: - dst_file = dst_dir / file - - # Special handling for JSON files - need to modify library_path - if file_path.suffix == JSON_EXT and file == JSON_FILENAME: - self._copy_and_fix_json_file(src_file, dst_file) - else: - self._copy_plugin_file(src_file, dst_file) - - self.log.info(f"Copied {file} to {dst_file}") - - def _copy_and_fix_json_file(self, src_file: Path, dst_file: Path) -> None: - """Copy JSON file and fix the library_path to use relative path - - Args: - src_file: Source JSON file path - dst_file: Destination JSON file path - """ - try: - # Read the JSON file - with open(src_file, 'r') as f: - json_data = json.load(f) - - # Fix the library_path from "liblsfg-vk.so" to "../../../lib/liblsfg-vk.so" - if 'layer' in json_data and 'library_path' in json_data['layer']: - current_path = json_data['layer']['library_path'] - if current_path == "liblsfg-vk.so": - json_data['layer']['library_path'] = "../../../lib/liblsfg-vk.so" - self.log.info(f"Fixed library_path from '{current_path}' to '../../../lib/liblsfg-vk.so'") - - # Write the modified JSON file - with open(dst_file, 'w') as f: - json.dump(json_data, f, indent=2) - - except (json.JSONDecodeError, KeyError, OSError) as e: - self.log.error(f"Error fixing JSON file {src_file}: {e}") - # Fallback to simple copy if JSON modification fails - self._copy_plugin_file(src_file, dst_file) - - def _create_config_file(self) -> None: - """Create or update the TOML config file in ~/.config/lsfg-vk with default configuration and detected DLL path - - If a config file already exists, preserve existing profiles and only update global settings like DLL path. - """ - # Import here to avoid circular imports - from .dll_detection import DllDetectionService - - # Try to detect DLL path - dll_service = DllDetectionService(self.log) - - # Check if config file already exists - if self.config_file_path.exists(): + def _prune_empty_directories(self) -> None: + # Only prune directories created by this plugin. Never remove a + # non-empty directory because the user's other tools may use it. + candidates = ( + self.config_dir, + self.local_bin_dir, + self.local_lib_dir, + self.local_share_dir, + self.user_home / LOCAL_SHARE / "applications", + self.user_home / LOCAL_SHARE / "icons/hicolor/256x256/apps", + ) + for directory in candidates: try: - # Read existing config to preserve user profiles - content = self.config_file_path.read_text(encoding='utf-8') - existing_profile_data = ConfigurationManager.parse_toml_content_multi_profile(content) - self.log.info(f"Found existing config file, preserving user profiles") - - # Create merged profile data that preserves user settings but adds any new fields - merged_profile_data = self._merge_config_with_defaults(existing_profile_data, dll_service) - - # Generate TOML content with merged profiles - toml_content = ConfigurationManager.generate_toml_content_multi_profile(merged_profile_data) - - except Exception as e: - self.log.warning(f"Failed to parse existing config file: {str(e)}, creating new one") - # Fall back to creating a new config file - config = ConfigurationManager.get_defaults_with_dll_detection(dll_service) - toml_content = ConfigurationManager.generate_toml_content(config) - else: - # No existing config file, create a new one with defaults - config = ConfigurationManager.get_defaults_with_dll_detection(dll_service) - toml_content = ConfigurationManager.generate_toml_content(config) - self.log.info(f"Creating new config file") - - # Write config file - self._write_file(self.config_file_path, toml_content, 0o644) - self.log.info(f"Created config file at {self.config_file_path}") - - # Log detected DLL path if found - USE GENERATED CONSTANTS - from .config_schema_generated import DLL - try: - # Try to parse the written content to get the DLL path - final_content = self.config_file_path.read_text(encoding='utf-8') - final_config = ConfigurationManager.parse_toml_content(final_content) - if final_config.get(DLL): - self.log.info(f"Configured DLL path: {final_config[DLL]}") - except (OSError, IOError, ValueError, KeyError) as e: - # Don't fail installation if we can't log the DLL path - self.log.debug(f"Could not log DLL path: {e}") - - def _create_lsfg_launch_script(self) -> None: - """Create the ~/lsfg launch script for easier game setup""" - # Use the default configuration for the initial script - from .config_schema import ConfigurationManager - default_config = ConfigurationManager.get_defaults() - - # Create configuration service to generate the script - from .configuration import ConfigurationService - config_service = ConfigurationService(logger=self.log) - config_service.user_home = self.user_home - config_service.lsfg_script_path = self.lsfg_launch_script_path - - # Generate script content with default configuration - script_content = config_service._generate_script_content(default_config) - - # Write the script file - self._write_file(self.lsfg_launch_script_path, script_content, 0o755) - self.log.info(f"Created lsfg launch script at {self.lsfg_launch_script_path}") - - def get_launch_script_path(self) -> str: - """Get the path to the lsfg launch script - - Returns: - String path to the launch script file - """ - return str(self.lsfg_launch_script_path) + if directory.is_dir() and not directory.is_symlink(): + directory.rmdir() + self.log.info(f"Removed empty directory {directory}") + except OSError: + continue def check_installation(self) -> InstallationCheckResponse: - """Check if lsfg-vk is already installed - - Returns: - InstallationCheckResponse with installation status and file paths - """ try: - lib_exists = self.lib_file.exists() - json_exists = self.json_file.exists() - config_exists = self.config_file_path.exists() - - self.log.info(f"Installation check: lib={lib_exists}, json={json_exists}, config={config_exists}") - + installation_error = None + try: + installed = self.runtime_service.is_healthy() + except Exception as error: + installed = False + installation_error = str(error) + lossless_scaling = self.runtime_service.check_lossless_scaling() return { - "installed": lib_exists and json_exists, - "lib_exists": lib_exists, - "json_exists": json_exists, - "script_exists": config_exists, # Keep script_exists for backward compatibility - "lib_path": str(self.lib_file), - "json_path": str(self.json_file), - "script_path": str(self.config_file_path), # Keep script_path for backward compatibility - "error": None + "installed": installed, + "lossless_scaling_installed": bool(lossless_scaling["installed"]), + "lossless_scaling_status": str(lossless_scaling["status"]), + "error": installation_error, } - - except Exception as e: - error_msg = f"Error checking lsfg-vk installation: {str(e)}" - self.log.error(error_msg) + except Exception as error: return { "installed": False, - "lib_exists": False, - "json_exists": False, - "script_exists": False, - "lib_path": str(self.lib_file), - "json_path": str(self.json_file), - "script_path": str(self.config_file_path), - "error": str(e) + "lossless_scaling_installed": False, + "lossless_scaling_status": str(error), + "error": str(error), } - + def uninstall(self) -> UninstallationResponse: - """Uninstall lsfg-vk by removing the installed files - - Note: The config file (conf.toml) is preserved to maintain user's custom profiles - - Returns: - UninstallationResponse with success status and removed files list - """ - try: - removed_files = [] - # Remove core lsfg-vk files, but preserve config file to maintain user's custom profiles - files_to_remove = [self.lib_file, self.json_file, self.lsfg_launch_script_path] - - for file_path in files_to_remove: - if self._remove_if_exists(file_path): - removed_files.append(str(file_path)) - - # Also try to remove the old script file if it exists (for backward compatibility) - if self._remove_if_exists(self.lsfg_script_path): - removed_files.append(str(self.lsfg_script_path)) - - # Don't remove config directory since we're preserving the config file - - if not removed_files: - return self._success_response(UninstallationResponse, - "No lsfg-vk files found to remove", - removed_files=None) - - self.log.info("lsfg-vk uninstalled successfully") - 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 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 - - Note: The config file (conf.toml) is preserved to maintain user's custom profiles - """ try: - self.log.info("Checking for lsfg-vk files to clean up:") - self.log.info(f" Library file: {self.lib_file}") - self.log.info(f" JSON file: {self.json_file}") - self.log.info(f" Config file: {self.config_file_path} (preserved)") - self.log.info(f" Launch script: {self.lsfg_launch_script_path}") - self.log.info(f" Old script file: {self.lsfg_script_path}") - - removed_files = [] - # Remove core lsfg-vk files, but preserve config file to maintain user's custom profiles - files_to_remove = [self.lib_file, self.json_file, self.lsfg_launch_script_path, self.lsfg_script_path] - - for file_path in files_to_remove: - try: - if self._remove_if_exists(file_path): - removed_files.append(str(file_path)) - except OSError as e: - self.log.error(f"Failed to remove {file_path}: {e}") - - # Don't remove config directory since we're preserving the config file - - if removed_files: - self.log.info(f"Cleaned up {len(removed_files)} lsfg-vk files during plugin uninstall: {removed_files}") - else: - self.log.info("No lsfg-vk files found to clean up during plugin uninstall") - - except Exception as e: - self.log.error(f"Error cleaning up lsfg-vk files during uninstall: {str(e)}") - self.log.error(f"Traceback: {traceback.format_exc()}") + removed = [ + str(path) + for path in ( + self.lib_file, + self.lib_x86_file, + self.json_file, + self.json_x86_file, + self.cli_file, + self.local_bin_dir / UI_FILENAME, + self.user_home / LOCAL_SHARE / "applications" / UI_DESKTOP_FILENAME, + self.user_home / LOCAL_SHARE / "icons/hicolor/256x256/apps" / UI_ICON_FILENAME, + self.legacy_lib_file, + self.legacy_json_file, + self.legacy_script_path, + self.config_file_path, + ) + if self._remove_if_exists(path) + ] + self._prune_empty_directories() + if not removed: + return self._success_response( + UninstallationResponse, + "No lsfg-vk files found to remove", + removed_files=None, + ) + return self._success_response( + UninstallationResponse, + f"lsfg-vk uninstalled successfully. Removed {len(removed)} files.", + removed_files=removed, + ) + except Exception as error: + return self._error_response( + UninstallationResponse, + str(error), + removed_files=None, + ) - def _merge_config_with_defaults(self, existing_profile_data, dll_service): - """Merge existing user config with current schema defaults - - This ensures that: - 1. User's custom profiles and values are preserved - 2. Any new fields added to the schema get their default values - 3. Global settings like DLL path are updated as needed - - Args: - existing_profile_data: The user's existing ProfileData - dll_service: DLL detection service for updating DLL path - - Returns: - ProfileData with merged configuration - """ - from .config_schema import ProfileData - - # Get current schema defaults - default_config = ConfigurationManager.get_defaults_with_dll_detection(dll_service) - default_global_config = { - "dll": default_config.get("dll", ""), - "no_fp16": False - } - - # Start with existing data - merged_data: ProfileData = { - "current_profile": existing_profile_data.get("current_profile", "decky-lsfg-vk"), - "global_config": existing_profile_data.get("global_config", {}).copy(), - "profiles": {} - } - - # Merge global config: preserve user values, add missing fields, update DLL - for key, default_value in default_global_config.items(): - if key not in merged_data["global_config"]: - merged_data["global_config"][key] = default_value - self.log.info(f"Added missing global field '{key}' with default value: {default_value}") - - # Update DLL path if detected - dll_result = dll_service.check_lossless_scaling_dll() - if dll_result.get("detected") and dll_result.get("path"): - old_dll = merged_data["global_config"].get("dll") - merged_data["global_config"]["dll"] = dll_result["path"] - if old_dll != dll_result["path"]: - self.log.info(f"Updated DLL path from '{old_dll}' to: {dll_result['path']}") - - # Merge each profile: preserve user values, add missing fields - existing_profiles = existing_profile_data.get("profiles", {}) - - for profile_name, existing_profile_config in existing_profiles.items(): - merged_profile_config = existing_profile_config.copy() - - # Add any missing fields from current schema with default values - added_fields = [] - for key, default_value in default_config.items(): - if key not in merged_profile_config and key not in ["dll", "no_fp16"]: # Skip global fields - merged_profile_config[key] = default_value - added_fields.append(key) - - if added_fields: - self.log.info(f"Profile '{profile_name}': Added missing fields {added_fields}") - - merged_data["profiles"][profile_name] = merged_profile_config - - # If no profiles exist, create the default one - if not merged_data["profiles"]: - merged_data["profiles"]["decky-lsfg-vk"] = { - k: v for k, v in default_config.items() - if k not in ["dll", "no_fp16"] # Exclude global fields - } - merged_data["current_profile"] = "decky-lsfg-vk" - self.log.info("No existing profiles found, created default profile") - - return merged_data + def cleanup_on_uninstall(self) -> bool: + try: + result = self.uninstall() + if not result.get("success"): + self.log.error(f"Error cleaning up lsfg-vk files during uninstall: {result.get('error')}") + return False + return True + except Exception as error: + self.log.error(f"Error cleaning up lsfg-vk files during uninstall: {error}") + self.log.error(traceback.format_exc()) + return False diff --git a/py_modules/lsfg_vk/plugin.py b/py_modules/lsfg_vk/plugin.py index cb59b4f..c63e3a9 100644 --- a/py_modules/lsfg_vk/plugin.py +++ b/py_modules/lsfg_vk/plugin.py @@ -1,495 +1,222 @@ -""" -Main plugin class for the lsfg-vk Decky Loader plugin. - -This plugin provides services for installing and managing the lsfg-vk -Vulkan layer for frame generation on Steam Deck. -""" - import os -import subprocess -import hashlib -from typing import Dict, Any -from pathlib import Path +from typing import Any, Dict import decky -from .installation import InstallationService -from .dll_detection import DllDetectionService from .configuration import ConfigurationService -from .config_schema import ConfigurationManager +from .flatpak_profile_service import FlatpakProfileService from .flatpak_service import FlatpakService +from .installation import InstallationService +from .runtime_service import RuntimeService +from .steam_service import SteamService +from .wrapper_service import WrapperService class Plugin: - """ - Main plugin class for lsfg-vk management. - - This class provides a unified interface for installation, configuration, - and DLL detection services. It implements the Decky Loader plugin lifecycle - methods (_main, _unload, _uninstall, _migration). - """ - def __init__(self): - """Initialize the plugin with all necessary services""" - self.installation_service = InstallationService() - self.dll_detection_service = DllDetectionService() - self.configuration_service = ConfigurationService() + self.runtime_service = RuntimeService() + self.steam_service = SteamService() + self.installation_service = InstallationService( + runtime_service=self.runtime_service, + steam_service=self.steam_service, + ) + self.configuration_service = ConfigurationService(runtime_service=self.runtime_service) self.flatpak_service = FlatpakService() + self.flatpak_profile_service = FlatpakProfileService( + self.flatpak_service, + self.configuration_service, + ) + self.wrapper_service = WrapperService() - async def install_lsfg_vk(self) -> Dict[str, Any]: - """Install lsfg-vk by extracting the zip file to ~/.local - - Returns: - InstallationResponse dict with success status and message/error - """ + async def install_lsfg_vk(self): return self.installation_service.install() - async def check_lsfg_vk_installed(self) -> Dict[str, Any]: - """Check if lsfg-vk is already installed - - Returns: - InstallationCheckResponse dict with installation status and paths - """ + async def check_lsfg_vk_installed(self): return self.installation_service.check_installation() - async def uninstall_lsfg_vk(self) -> Dict[str, Any]: - """Uninstall lsfg-vk by removing the installed files - - Returns: - UninstallationResponse dict with success status and removed files - """ - return self.installation_service.uninstall() - - async def check_lossless_scaling_dll(self) -> Dict[str, Any]: - """Check if Lossless Scaling DLL is available at the expected paths - - Returns: - DllDetectionResponse dict with detection status and path info - """ - return self.dll_detection_service.check_lossless_scaling_dll() - - async def get_dll_stats(self) -> Dict[str, Any]: - """Get detailed statistics about the detected DLL - - Returns: - Dict containing DLL path, SHA256 hash, and other stats - """ - try: - dll_result = self.dll_detection_service.check_lossless_scaling_dll() - - if not dll_result.get("detected") or not dll_result.get("path"): - return { - "success": False, - "error": "DLL not detected", - "dll_path": None, - "dll_sha256": None - } - - dll_path = dll_result["path"] - if dll_path is None: - return { - "success": False, - "error": "DLL path is None", - "dll_path": None, - "dll_sha256": None - } - - dll_path_obj = Path(dll_path) - - sha256_hash = hashlib.sha256() - try: - with open(dll_path_obj, "rb") as f: - for chunk in iter(lambda: f.read(4096), b""): - sha256_hash.update(chunk) - dll_sha256 = sha256_hash.hexdigest() - except Exception as e: - return { - "success": False, - "error": f"Failed to calculate SHA256: {str(e)}", - "dll_path": dll_path, - "dll_sha256": None - } - - return { - "success": True, - "dll_path": dll_path, - "dll_sha256": dll_sha256, - "dll_source": dll_result.get("source"), - "error": None - } - - except Exception as e: + def _cleanup_runtime_state(self, preserve_wrapper: bool = False): + flatpak = self.flatpak_service.remove_plugin_owned_environment() + if not flatpak.get("success"): + return flatpak.get("error") or "Could not clean up Flatpak support" + profiles = self.configuration_service.reset_all_flatpak_configs() + if not profiles.get("success"): + return profiles.get("error") or "Could not remove Flatpak profiles" + wrapper = self.wrapper_service.neutralize() if preserve_wrapper else self.wrapper_service.purge() + if not wrapper.get("success"): + return wrapper.get("error") or "Could not remove workaround state" + return None + + async def uninstall_lsfg_vk(self): + error = self._cleanup_runtime_state() + if error: return { "success": False, - "error": f"Failed to get DLL stats: {str(e)}", - "dll_path": None, - "dll_sha256": None + "message": "", + "error": error, + "removed_files": None, } + return self.installation_service.uninstall() - async def get_lsfg_config(self) -> Dict[str, Any]: - """Read current lsfg script configuration - - Returns: - ConfigurationResponse dict with current configuration or error - """ - return self.configuration_service.get_config() - - async def get_config_schema(self) -> Dict[str, Any]: - """Get configuration schema information for frontend - - Returns: - Dict with field names, types, defaults, and profile information - """ - try: - profiles_response = self.configuration_service.get_profiles() - - schema_data = { - "field_names": ConfigurationManager.get_field_names(), - "field_types": {name: field_type.value for name, field_type in ConfigurationManager.get_field_types().items()}, - "defaults": ConfigurationManager.get_defaults() - } - - if profiles_response.get("success"): - schema_data["profiles"] = profiles_response.get("profiles", []) - schema_data["current_profile"] = profiles_response.get("current_profile") - else: - schema_data["profiles"] = ["decky-lsfg-vk"] - schema_data["current_profile"] = "decky-lsfg-vk" - - return schema_data - - except (ValueError, KeyError, AttributeError) as e: - self.configuration_service.log.warning(f"Failed to get full schema, using fallback: {e}") - return { - "field_names": ConfigurationManager.get_field_names(), - "field_types": {name: field_type.value for name, field_type in ConfigurationManager.get_field_types().items()}, - "defaults": ConfigurationManager.get_defaults(), - "profiles": ["decky-lsfg-vk"], - "current_profile": "decky-lsfg-vk" - } + async def get_game_configs(self): + return self.configuration_service.get_game_configs() - async def update_lsfg_config(self, config: Dict[str, Any]) -> Dict[str, Any]: - """Update lsfg TOML configuration using object-based API (single source of truth) - - Args: - config: Configuration data dictionary containing all settings - - Returns: - ConfigurationResponse dict with success status - """ - validated_config = ConfigurationManager.validate_config(config) - - return self.configuration_service.update_config_from_dict(validated_config) - - async def get_profiles(self) -> Dict[str, Any]: - """Get list of all profiles and current profile - - Returns: - ProfilesResponse dict with profile list and current profile - """ - return self.configuration_service.get_profiles() - - async def create_profile(self, profile_name: str, source_profile: str = None) -> Dict[str, Any]: - """Create a new profile - - Args: - profile_name: Name for the new profile - source_profile: Optional source profile to copy from (default: current profile) - - Returns: - ProfileResponse dict with success status - """ - return self.configuration_service.create_profile(profile_name, source_profile) - - async def delete_profile(self, profile_name: str) -> Dict[str, Any]: - """Delete a profile - - Args: - profile_name: Name of the profile to delete - - Returns: - ProfileResponse dict with success status - """ - return self.configuration_service.delete_profile(profile_name) - - async def rename_profile(self, old_name: str, new_name: str) -> Dict[str, Any]: - """Rename a profile - - Args: - old_name: Current profile name - new_name: New profile name - - Returns: - ProfileResponse dict with success status - """ - return self.configuration_service.rename_profile(old_name, new_name) - - async def set_current_profile(self, profile_name: str) -> Dict[str, Any]: - """Set the current active profile - - Args: - profile_name: Name of the profile to set as current - - Returns: - ProfileResponse dict with success status - """ - return self.configuration_service.set_current_profile(profile_name) - - async def update_profile_config(self, profile_name: str, config: Dict[str, Any]) -> Dict[str, Any]: - """Update configuration for a specific profile - - Args: - profile_name: Name of the profile to update - config: Configuration data dictionary containing settings - - Returns: - ConfigurationResponse dict with success status - """ - validated_config = ConfigurationManager.validate_config(config) - - return self.configuration_service.update_profile_config(profile_name, validated_config) - - async def get_launch_option(self) -> Dict[str, Any]: - """Get the launch option that users need to set for their games - - Returns: - Dict containing the launch option string and instructions - """ - return { - "launch_option": "~/lsfg %command%", - "instructions": "Add this to your game's launch options in Steam Properties", - "explanation": "The lsfg script is created during installation and sets up the environment for the plugin" - } + async def update_global_config(self, config: Dict[str, Any]): + return self.configuration_service.update_global_config(config) + + async def get_installed_games(self): + return self.steam_service.get_installed_games() + + async def update_game_config(self, appid: str, game_name: str, config: Dict[str, Any]): + return self.configuration_service.update_game_config(appid, game_name, config) + + async def reset_game_config(self, appid: str): + return self.configuration_service.reset_game_config(appid) + + async def reset_game_configs(self, appids): + return self.configuration_service.reset_game_configs(appids) + + async def reset_all_game_configs(self): + return self.configuration_service.reset_all_game_configs() + + async def get_workaround_state(self, appid: str): + return self.wrapper_service.get(appid) + + async def get_workaround_apps(self): + return self.wrapper_service.list_apps() - async def get_config_file_content(self) -> Dict[str, Any]: - """Get the current config file content - - Returns: - Dict containing the config file content or error message - """ + async def set_workaround_state( + self, + appid: str, + state: Dict[str, Any], + command_token_added: bool = False, + non_steam: bool = False, + ): + return self.wrapper_service.set(appid, state, command_token_added, non_steam) + + async def remove_workaround_state(self, appid: str): + return self.wrapper_service.remove(appid) + + async def get_config_file_content(self): + path = self.configuration_service.config_file_path try: - config_path = self.configuration_service.config_file_path - if not config_path.exists(): + if not path.exists(): return { "success": False, "content": None, - "path": str(config_path), - "error": "Config file does not exist" + "path": str(path), + "error": "Config file does not exist", } - - content = config_path.read_text(encoding='utf-8') return { "success": True, - "content": content, - "path": str(config_path), - "error": None + "content": path.read_text(encoding="utf-8"), + "path": str(path), + "error": None, } - except Exception as e: + except Exception as error: return { "success": False, "content": None, - "path": str(config_path) if 'config_path' in locals() else "unknown", - "error": f"Error reading config file: {str(e)}" + "path": str(path), + "error": f"Error reading config file: {error}", } - async def get_launch_script_content(self) -> Dict[str, Any]: - """Get the content of the launch script file - - Returns: - FileContentResponse dict with file content or error information - """ - try: - script_path = self.installation_service.get_launch_script_path() - - if not os.path.exists(script_path): - return { - "success": False, - "error": f"Launch script not found at {script_path}", - "path": str(script_path) - } - - with open(script_path, 'r') as file: - content = file.read() - - return { - "success": True, - "content": content, - "path": str(script_path) - } - - except Exception as e: - decky.logger.error(f"Error reading launch script: {e}") - return { - "success": False, - "error": str(e) - } - - async def check_fgmod_directory(self) -> Dict[str, Any]: - """Check if the fgmod directory exists in the home directory - - Returns: - Dict with exists status and directory path - """ - try: - home_path = Path(decky.DECKY_USER_HOME) - fgmod_path = home_path / "fgmod" - - exists = fgmod_path.exists() and fgmod_path.is_dir() - - return { - "success": True, - "exists": exists, - "path": str(fgmod_path) - } - - except Exception as e: - decky.logger.error(f"Error checking fgmod directory: {e}") - return { - "success": False, + async def get_debug_file_contents(self): + files = ( + ("config", "LSFG-VK configuration", self.configuration_service.config_file_path), + ("workarounds", "Per-app workarounds", self.wrapper_service.sidecar_path), + ("wrapper", "Generated launch wrapper", self.wrapper_service.wrapper_path), + ("flatpak", "Flatpak ownership state", self.flatpak_service.ownership_path), + ) + contents = [] + for file_id, label, path in files: + item = { + "id": file_id, + "label": label, + "path": str(path), "exists": False, - "error": str(e) + "content": None, + "error": None, } + try: + if path.is_symlink(): + item["error"] = "Path is a symlink; refusing to read it" + elif not path.exists(): + item["error"] = "File does not exist" + elif not path.is_file(): + item["error"] = "Path is not a regular file" + else: + item["exists"] = True + item["content"] = path.read_text(encoding="utf-8") + except Exception as error: + item["error"] = f"Error reading file: {error}" + contents.append(item) + return { + "success": True, + "message": "Debug file contents retrieved", + "error": None, + "files": contents, + } + + async def get_lossless_scaling_branch_status(self): + return self.steam_service.get_branch_status() + + async def get_flatpak_apps(self): + return self.flatpak_profile_service.get_apps() + + async def enable_flatpak_app(self, flatpak_app_id: str): + return self.flatpak_profile_service.enable_app(flatpak_app_id) + + async def update_flatpak_config(self, flatpak_app_id: str, config: Dict[str, Any]): + return self.flatpak_profile_service.update_config(flatpak_app_id, config) + + async def get_flatpak_workaround_state(self, flatpak_app_id: str): + return self.flatpak_profile_service.get_workaround_state(flatpak_app_id) + + async def set_flatpak_workaround_state(self, flatpak_app_id: str, state: Dict[str, Any]): + return self.flatpak_profile_service.set_workaround_state(flatpak_app_id, state) + + async def remove_flatpak_app(self, flatpak_app_id: str): + return self.flatpak_profile_service.remove_app(flatpak_app_id) + + async def get_running_flatpak_apps(self): + return self.flatpak_profile_service.get_running_apps() - async def check_flatpak_extension_status(self) -> Dict[str, Any]: - """Check status of lsfg-vk Flatpak runtime extensions - - Returns: - FlatpakExtensionStatus dict with installation status for both runtime versions - """ - return self.flatpak_service.get_extension_status() - - async def install_flatpak_extension(self, version: str) -> Dict[str, Any]: - """Install lsfg-vk Flatpak runtime extension - - Args: - version: Runtime version to install ("23.08" or "24.08") - - Returns: - BaseResponse dict with success status and message/error - """ - return self.flatpak_service.install_extension(version) - - async def uninstall_flatpak_extension(self, version: str) -> Dict[str, Any]: - """Uninstall lsfg-vk Flatpak runtime extension - - Args: - version: Runtime version to uninstall ("23.08" or "24.08") - - Returns: - BaseResponse dict with success status and message/error - """ - return self.flatpak_service.uninstall_extension(version) - - async def get_flatpak_apps(self) -> Dict[str, Any]: - """Get list of installed Flatpak apps and their lsfg-vk override status - - Returns: - FlatpakAppInfo dict with apps list and override status - """ - return self.flatpak_service.get_flatpak_apps() - - async def set_flatpak_app_override(self, app_id: str) -> Dict[str, Any]: - """Set lsfg-vk overrides for a Flatpak app - - Args: - app_id: Flatpak application ID - - Returns: - FlatpakOverrideResponse dict with operation result - """ - return self.flatpak_service.set_app_override(app_id) - - async def remove_flatpak_app_override(self, app_id: str) -> Dict[str, Any]: - """Remove lsfg-vk overrides for a Flatpak app - - Args: - app_id: Flatpak application ID - - Returns: - FlatpakOverrideResponse dict with operation result - """ - return self.flatpak_service.remove_app_override(app_id) - async def _main(self): - """ - Main entry point for the plugin. - - This method is called by Decky Loader when the plugin is loaded. - Any initialization code should go here. - """ + repair = self.wrapper_service.repair() + if not repair.get("success"): + decky.logger.error(f"Could not repair lsfg workaround wrapper: {repair.get('error')}") decky.logger.info("decky-lsfg-vk plugin loaded") async def _unload(self): - """ - Cleanup tasks when the plugin is unloaded. - - This method is called by Decky Loader when the plugin is being unloaded. - Any cleanup code should go here. - """ decky.logger.info("decky-lsfg-vk plugin unloaded") async def _uninstall(self): - """ - Called when the plugin is uninstalled. - - This method is called by Decky Loader when the plugin is being uninstalled. - Performs cleanup of plugin files and flatpak extensions. - """ decky.logger.info("decky-lsfg-vk plugin being uninstalled") - - # Clean up lsfg-vk files when the plugin is uninstalled - self.installation_service.cleanup_on_uninstall() - - # Also clean up flatpak extensions if they are installed try: - decky.logger.info("Checking for flatpak extensions to uninstall") - - extension_status = self.flatpak_service.get_extension_status() - - if extension_status.get("success"): - if extension_status.get("installed_23_08"): - decky.logger.info("Uninstalling lsfg-vk flatpak runtime 23.08") - result = self.flatpak_service.uninstall_extension("23.08") - if result.get("success"): - decky.logger.info("Successfully uninstalled flatpak runtime 23.08") - else: - decky.logger.warning(f"Failed to uninstall flatpak runtime 23.08: {result.get('error')}") - - if extension_status.get("installed_24_08"): - decky.logger.info("Uninstalling lsfg-vk flatpak runtime 24.08") - result = self.flatpak_service.uninstall_extension("24.08") - if result.get("success"): - decky.logger.info("Successfully uninstalled flatpak runtime 24.08") - else: - decky.logger.warning(f"Failed to uninstall flatpak runtime 24.08: {result.get('error')}") - - decky.logger.info("Flatpak extension cleanup completed") - else: - decky.logger.info(f"Could not check flatpak status for cleanup: {extension_status.get('error')}") - - except Exception as e: - decky.logger.error(f"Error during flatpak cleanup: {e}") - + error = self._cleanup_runtime_state(preserve_wrapper=True) + if error: + decky.logger.warning(f"Preserving lsfg-vk files because uninstall cleanup failed: {error}") + return + except Exception as error: + decky.logger.error(f"Error during lsfg-vk cleanup: {error}") + return + self.installation_service.cleanup_on_uninstall() decky.logger.info("decky-lsfg-vk plugin uninstall cleanup completed") async def _migration(self): - """ - Migrations that should be performed before entering `_main()`. - - This method is called by Decky Loader for plugin migrations. - Currently migrates logs, settings, and runtime data from old locations. - """ decky.logger.info("Running decky-lsfg-vk plugin migrations") - - decky.migrate_logs(os.path.join(decky.DECKY_USER_HOME, - ".config", "decky-lossless-scaling-vk", "lossless-scaling-vk.log")) - + decky.migrate_logs(os.path.join( + decky.DECKY_USER_HOME, + ".config", + "decky-lossless-scaling-vk", + "lossless-scaling-vk.log", + )) decky.migrate_settings( os.path.join(decky.DECKY_HOME, "settings", "lossless-scaling-vk.json"), - os.path.join(decky.DECKY_USER_HOME, ".config", "decky-lossless-scaling-vk")) - + os.path.join(decky.DECKY_USER_HOME, ".config", "decky-lossless-scaling-vk"), + ) decky.migrate_runtime( os.path.join(decky.DECKY_HOME, "lossless-scaling-vk"), - os.path.join(decky.DECKY_USER_HOME, ".local", "share", "decky-lossless-scaling-vk")) - + os.path.join(decky.DECKY_USER_HOME, ".local", "share", "decky-lossless-scaling-vk"), + ) decky.logger.info("decky-lsfg-vk plugin migrations completed") diff --git a/py_modules/lsfg_vk/runtime_service.py b/py_modules/lsfg_vk/runtime_service.py new file mode 100644 index 0000000..8785522 --- /dev/null +++ b/py_modules/lsfg_vk/runtime_service.py @@ -0,0 +1,120 @@ +import os +import subprocess +import tempfile +from pathlib import Path +from typing import Any, Sequence + +from .base_service import BaseService +from .constants import CLI_FILENAME + + +class RuntimeService(BaseService): + COMMAND_TIMEOUT_SECONDS = 10 + MAX_OUTPUT_LENGTH = 12_000 + DLL_MISSING_MARKER = "! The DLL file does not exist:" + DLL_NONE_MARKER = "DLL override: (none)" + + def __init__(self, logger=None): + super().__init__(logger) + self.cli_path = self.local_bin_dir / CLI_FILENAME + + def _environment(self) -> dict[str, str]: + environment = os.environ.copy() + environment.update( + HOME=str(self.user_home), + XDG_CONFIG_HOME=str(self.user_home / ".config"), + ) + for name in ("LSFGVK_CONFIG", "LSFGVK_ENV"): + environment.pop(name, None) + return environment + + @classmethod + def _output(cls, stdout: Any, stderr: Any) -> str: + values = [] + for value in (stdout, stderr): + if isinstance(value, bytes): + value = value.decode("utf-8", errors="replace") + if value: + values.append(str(value).strip()) + output = "\n".join(value for value in values if value) + return output if len(output) <= cls.MAX_OUTPUT_LENGTH else output[: cls.MAX_OUTPUT_LENGTH] + + def _run(self, arguments: Sequence[str]) -> tuple[int, str]: + if not self.cli_path.is_file() or not os.access(self.cli_path, os.X_OK): + raise FileNotFoundError(f"{CLI_FILENAME} is not installed at {self.cli_path}") + result = subprocess.run( + [str(self.cli_path), *arguments], + cwd=str(self.user_home), + env=self._environment(), + capture_output=True, + text=True, + timeout=self.COMMAND_TIMEOUT_SECONDS, + check=False, + ) + return result.returncode, self._output(result.stdout, result.stderr) + + def validate_config_content(self, content: str) -> None: + self.config_dir.mkdir(parents=True, exist_ok=True) + temporary_path: Path | None = None + try: + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=self.config_dir, + prefix=".conf.toml.", + suffix=".tmp", + delete=False, + ) as temporary_file: + temporary_path = Path(temporary_file.name) + temporary_file.write(content) + temporary_file.flush() + os.fsync(temporary_file.fileno()) + + returncode, output = self._run(("validate", "--config", str(temporary_path))) + if returncode != 0: + raise ValueError(output or "lsfg-vk rejected the generated configuration") + finally: + if temporary_path is not None: + temporary_path.unlink(missing_ok=True) + + def is_healthy(self) -> bool: + returncode, output = self._run(("healthcheck",)) + return returncode == 0 and "Healthcheck found issues" not in output + + def check_lossless_scaling(self) -> dict[str, Any]: + """Report Lossless Scaling state from lsfg-vk's own validator.""" + if not self.config_file_path.is_file(): + return { + "installed": False, + "status": "lsfg-vk configuration is not installed", + } + + try: + returncode, output = self._run( + ("validate", "--config", str(self.config_file_path), "--print") + ) + except Exception as error: + return {"installed": False, "status": str(error)} + + if returncode != 0: + return { + "installed": False, + "status": output or "lsfg-vk could not validate its configuration", + } + + if self.DLL_MISSING_MARKER in output: + return { + "installed": False, + "status": "Lossless Scaling's lsfg-vk.dll was not found", + } + + if self.DLL_NONE_MARKER in output: + return { + "installed": False, + "status": "Lossless Scaling's lsfg-vk.dll is not configured", + } + + return { + "installed": True, + "status": "Lossless Scaling detected by lsfg-vk", + } diff --git a/py_modules/lsfg_vk/steam_service.py b/py_modules/lsfg_vk/steam_service.py new file mode 100644 index 0000000..93f2593 --- /dev/null +++ b/py_modules/lsfg_vk/steam_service.py @@ -0,0 +1,290 @@ +import re +from pathlib import Path +from typing import Dict, Optional, Tuple + +from .base_service import BaseService +from .constants import ( + STEAM_LOSSLESS_SCALING_APP_ID, + STEAM_LOSSLESS_SCALING_BRANCH, +) + + +class SteamService(BaseService): + DEFAULT_BRANCH = "public" + MANIFEST_FILENAME = f"appmanifest_{STEAM_LOSSLESS_SCALING_APP_ID}.acf" + # Valve compatibility tools, runtimes, Steamworks redistributables, and LSFG. + GAME_SELECTOR_EXCLUDED_APPIDS = { + "858280", # Proton 3.7 + "961940", # Proton 3.16 + "1054830", # Proton 4.2 + "1113280", # Proton 4.11 + "1245040", # Proton 5.0 + "1420170", # Proton 5.13 + "1493710", # Proton Experimental + "1580130", # Proton 6.3 + "1887720", # Proton 7 + "2180100", # Proton Hotfix + "228980", # Steamworks Common Redistributables + "2348590", # Proton 8 + "2805730", # Proton 9 + "3029110", # Lepton + "3127680", # fex + "3658110", # Proton 10 + "4183110", # Steam Linux Runtime 4.0 + "4185400", # Steam Linux Runtime 4.0 for arm64 + "4427310", # Proton Experimental (ARM64) + "4628710", # Proton 11 / Proton Next + "4628740", # Proton 11 (ARM64) + "4690330", # Legacy Steam Runtime + "993090", # Lossless Scaling + "1070560", # Steam Linux Runtime 1.0 + "1391110", # Steam Linux Runtime 2.0 + "1628350", # Steam Linux Runtime 3.0 + } + + def _steam_roots(self): + seen = set() + for candidate in ( + self.user_home / ".local/share/Steam", + self.user_home / ".steam/steam", + self.user_home / ".steam/root", + self.user_home / ".var/app/com.valvesoftware.Steam/.local/share/Steam", + ): + yield from self._unique_existing_root(candidate, seen) + + def _steam_library_roots(self): + seen = set() + for root in self._steam_roots(): + yield from self._unique_existing_root(root, seen) + for library_file in ( + root / "steamapps/libraryfolders.vdf", + root / "config/libraryfolders.vdf", + ): + try: + content = library_file.read_text(encoding="utf-8") + except OSError: + continue + for raw_path in re.findall(r'(?m)^\s*"path"\s+"((?:\\.|[^"])*)"', content): + path = raw_path.replace(r'\"', '"').replace(r'\\', '\\') + yield from self._unique_existing_root(Path(path), seen) + + @staticmethod + def _unique_existing_root(path: Path, seen: set[str]): + if not path.exists(): + return + try: + resolved = str(path.resolve()) + except OSError: + resolved = str(path) + if resolved not in seen: + seen.add(resolved) + yield path + + @staticmethod + def _read_shortcuts(data: bytes) -> Dict[str, object]: + def read_string(offset: int) -> Tuple[str, int]: + end = data.index(b"\0", offset) + return data[offset:end].decode("utf-8", errors="replace"), end + 1 + + def read_object(offset: int = 0) -> Tuple[Dict[str, object], int]: + values = {} + while offset < len(data): + value_type, offset = data[offset], offset + 1 + if value_type == 8: + return values, offset + key, offset = read_string(offset) + if value_type == 0: + value, offset = read_object(offset) + elif value_type == 1: + value, offset = read_string(offset) + elif value_type in (2, 7): + width = 4 if value_type == 2 else 8 + if offset + width > len(data): + raise ValueError("truncated binary VDF integer") + value = int.from_bytes(data[offset:offset + width], "little", signed=True) + offset += width + else: + raise ValueError(f"unsupported binary VDF type {value_type}") + values[key] = value + raise ValueError("unterminated binary VDF object") + + values, offset = read_object() + if offset != len(data): + raise ValueError("trailing binary VDF data") + return values + + @staticmethod + def _shortcut_game(shortcut: object) -> Optional[Dict[str, object]]: + if not isinstance(shortcut, dict): + return None + appid = shortcut.get("appid") + name = shortcut.get("AppName") or shortcut.get("appname") + if not isinstance(appid, int) or appid == 0 or not isinstance(name, str) or not name: + return None + game = { + "appid": str(appid & 0xFFFFFFFF), + "name": name, + "nonSteam": True, + } + executable = shortcut.get("Exe") or shortcut.get("exe") + if isinstance(executable, str) and executable.strip().strip('"') in {"flatpak", "/usr/bin/flatpak"}: + game["isFlatpakShortcut"] = True + return game + + def _shortcut_games(self): + games = {} + for root in self._steam_roots(): + for path in sorted((root / "userdata").glob("*/config/shortcuts.vdf")): + try: + shortcuts = self._read_shortcuts(path.read_bytes()).get("shortcuts", {}) + except (OSError, ValueError): + continue + if not isinstance(shortcuts, dict): + continue + for shortcut in shortcuts.values(): + game = self._shortcut_game(shortcut) + if game and game["appid"] not in self.GAME_SELECTOR_EXCLUDED_APPIDS: + games.setdefault(game["appid"], game) + return list(games.values()) + + def _manifest_path(self) -> Optional[Path]: + return next(( + path + for root in self._steam_library_roots() + if (path := root / "steamapps" / self.MANIFEST_FILENAME).is_file() + ), None) + + @staticmethod + def _section_bounds(content: str, section_name: str) -> Optional[Tuple[int, int, str]]: + section = re.search( + rf'(?m)^(?P<indent>[ \t]*)"{re.escape(section_name)}"[ \t\r\n]*\{{', + content, + ) + if section is None: + return None + depth = 1 + in_string = False + escaped = False + for index in range(section.end(), len(content)): + character = content[index] + if in_string: + if escaped: + escaped = False + elif character == "\\": + escaped = True + elif character == '"': + in_string = False + elif character == '"': + in_string = True + elif character == "{": + depth += 1 + elif character == "}": + depth -= 1 + if depth == 0: + return section.end(), index, section.group("indent") + return None + + @classmethod + def _section_value(cls, content: str, section_name: str, key: str) -> Optional[str]: + bounds = cls._section_bounds(content, section_name) + if bounds is None: + return None + start, end, _ = bounds + pattern = re.compile(r'(?m)^[ \t]*"(?P<key>[^"]+)"[ \t]+"(?P<value>(?:\\.|[^"\\])*)"') + return next(( + match.group("value") + for match in pattern.finditer(content, start, end) + if match.group("key") == key + ), None) + + @classmethod + def _branch_or_default(cls, branch: Optional[str]) -> str: + return branch or cls.DEFAULT_BRANCH + + def _status_fields(self, manifest_path: Path, content: str) -> Dict[str, object]: + selected = self._branch_or_default(self._section_value(content, "UserConfig", "BetaKey")) + current = self._branch_or_default( + self._section_value(content, "MountedConfig", "BetaKey") + or self._section_value(content, "UserConfig", "BetaKey") + ) + needs_switch = selected != STEAM_LOSSLESS_SCALING_BRANCH or current != STEAM_LOSSLESS_SCALING_BRANCH + return { + "installed": True, + "manifest_path": str(manifest_path), + "selected_branch": selected, + "current_branch": current, + "target_branch": STEAM_LOSSLESS_SCALING_BRANCH, + "needs_switch": needs_switch, + "restart_required": selected == STEAM_LOSSLESS_SCALING_BRANCH and current != STEAM_LOSSLESS_SCALING_BRANCH, + } + + @staticmethod + def _missing_branch_fields() -> Dict[str, object]: + return { + "installed": False, + "manifest_path": None, + "selected_branch": None, + "current_branch": None, + "target_branch": STEAM_LOSSLESS_SCALING_BRANCH, + "needs_switch": False, + "restart_required": False, + } + + def find_lsfg_vk_dll(self) -> Optional[str]: + if self.get_branch_status().get("needs_switch"): + return None + return next(( + str(path) + for root in self._steam_library_roots() + if (path := root / "steamapps/common/Lossless Scaling/lsfg-vk.dll").is_file() + ), None) + + def get_branch_status(self) -> Dict[str, object]: + try: + manifest = self._manifest_path() + if manifest is None: + return self._success_response( + dict, + "Lossless Scaling is not installed through Steam", + **self._missing_branch_fields(), + ) + fields = self._status_fields(manifest, manifest.read_text(encoding="utf-8")) + message = ( + "Lossless Scaling is using the lsfg-vk Steam branch" + if not fields["needs_switch"] + else "lsfg-vk is selected; restart Steam to finish the branch switch" + if fields["restart_required"] + else "Select lsfg-vk in Lossless Scaling's Steam Properties > Betas" + ) + return self._success_response(dict, message, **fields) + except Exception as error: + return self._error_response(dict, str(error), **self._missing_branch_fields()) + + def get_installed_games(self) -> Dict[str, object]: + try: + games: Dict[str, Dict[str, object]] = {} + for root in self._steam_library_roots(): + for manifest in (root / "steamapps").glob("appmanifest_*.acf"): + match = re.fullmatch(r"appmanifest_(\d+)\.acf", manifest.name) + if not match: + continue + try: + content = manifest.read_text(encoding="utf-8") + except OSError: + continue + appid = match.group(1) + if appid in self.GAME_SELECTOR_EXCLUDED_APPIDS: + continue + games[appid] = { + "appid": appid, + "name": self._section_value(content, "AppState", "name") or f"App {appid}", + "nonSteam": False, + } + for game in self._shortcut_games(): + games.setdefault(str(game["appid"]), game) + return self._success_response( + dict, + games=sorted(games.values(), key=lambda game: str(game["name"]).lower()), + ) + except Exception as error: + return self._error_response(dict, str(error), games=[]) diff --git a/py_modules/lsfg_vk/types.py b/py_modules/lsfg_vk/types.py index 7b7ca2b..ce541ec 100644 --- a/py_modules/lsfg_vk/types.py +++ b/py_modules/lsfg_vk/types.py @@ -1,83 +1,31 @@ -""" -Type definitions for the lsfg-vk plugin responses. -""" +from typing import List, Optional, TypedDict -from typing import TypedDict, Optional, List, Dict, Any -from .config_schema import ConfigurationData - -class BaseResponse(TypedDict): - """Base response structure""" +class InstallationResponse(TypedDict): success: bool - - -class ErrorResponse(BaseResponse): - """Response structure for errors""" - error: str - - -class MessageResponse(BaseResponse): - """Response structure with message""" - message: str - - -class InstallationResponse(BaseResponse): - """Response for installation operations""" message: str error: Optional[str] -class UninstallationResponse(BaseResponse): - """Response for uninstallation operations""" - message: str +class UninstallationResponse(InstallationResponse): removed_files: Optional[List[str]] - error: Optional[str] class InstallationCheckResponse(TypedDict): - """Response for installation check""" installed: bool - lib_exists: bool - json_exists: bool - script_exists: bool - lib_path: str - json_path: str - script_path: str - error: Optional[str] - - -class DllDetectionResponse(TypedDict): - """Response for DLL detection""" - detected: bool - path: Optional[str] - source: Optional[str] - message: Optional[str] - error: Optional[str] - - -class ConfigurationResponse(BaseResponse): - """Response for configuration operations""" - config: Optional[ConfigurationData] - message: Optional[str] - error: Optional[str] - - -class ProfileConfig(TypedDict): - """Configuration for a single profile""" - exe: str - config: ConfigurationData - - -class ProfilesResponse(BaseResponse): - """Response for profile operations""" - profiles: Optional[List[str]] - current_profile: Optional[str] - message: Optional[str] + lossless_scaling_installed: bool + lossless_scaling_status: str error: Optional[str] -class ProfileResponse(BaseResponse): - """Response for single profile operations""" - profile_name: Optional[str] - message: Optional[str] +class SteamBranchStatusResponse(TypedDict): + success: bool + message: str error: Optional[str] + installed: bool + manifest_path: Optional[str] + selected_branch: Optional[str] + current_branch: Optional[str] + target_branch: str + needs_switch: bool + restart_required: bool diff --git a/py_modules/lsfg_vk/wrapper_service.py b/py_modules/lsfg_vk/wrapper_service.py new file mode 100644 index 0000000..30c2323 --- /dev/null +++ b/py_modules/lsfg_vk/wrapper_service.py @@ -0,0 +1,464 @@ +from __future__ import annotations + +import json +import re +import shlex +import threading +from typing import Any, Dict, Optional, Tuple + +from .base_service import BaseService +from .constants import WRAPPER_FILENAME + + +class WrapperService(BaseService): + LEGACY_FORMAT_VERSION = 1 + FORMAT_VERSION = 2 + LEGACY_MARKER = "# lsfg-vk-wrapper-format: 1" + MARKER = "# lsfg-vk-wrapper-format: 2" + WRAPPER_TOKEN = "~/.lsfg" + STATE_FIELDS = ( + "dxvkFrameRate", + "disableGamescopeWsi", + "disableHdr", + "disableSteamdeckMode", + "disableVkbasalt", + "enableZink", + ) + BOOLEAN_FIELDS = STATE_FIELDS[1:] + MANAGED_ENV_KEYS = ( + "ENABLE_GAMESCOPE_WSI", + "DISABLE_GAMESCOPE_WSI", + "DXVK_HDR", + "SteamDeck", + "DISABLE_LSFGVK", + "DISABLE_LSFG", + "DISABLE_VKBASALT", + "ENABLE_VKBASALT", + "MESA_LOADER_DRIVER_OVERRIDE", + "__GLX_VENDOR_LIBRARY_NAME", + "GALLIUM_DRIVER", + "DXVK_FRAME_RATE", + ) + + def __init__(self, logger=None): + super().__init__(logger) + self.sidecar_path = self.config_dir / "workarounds.json" + self.wrapper_path = self.user_home / WRAPPER_FILENAME + self._lock = threading.RLock() + + @classmethod + def default_state(cls) -> Dict[str, Any]: + return { + "dxvkFrameRate": 0, + "disableGamescopeWsi": True, + "disableHdr": True, + "disableSteamdeckMode": False, + "disableVkbasalt": False, + "enableZink": False, + } + + @staticmethod + def _valid_appid(appid: Any) -> str: + value = str(appid) + if not re.fullmatch(r"[1-9][0-9]*", value): + raise ValueError("Invalid Steam App ID") + return value + + @classmethod + def _validate_state(cls, raw: Any) -> Dict[str, Any]: + if not isinstance(raw, dict): + raise ValueError("Workaround state must be an object") + missing = [field for field in cls.STATE_FIELDS if field not in raw] + if missing: + raise ValueError("Workaround state is missing: " + ", ".join(missing)) + state = {field: raw[field] for field in cls.STATE_FIELDS} + frame_rate = state["dxvkFrameRate"] + if isinstance(frame_rate, bool) or not isinstance(frame_rate, int) or not 0 <= frame_rate <= 60: + raise ValueError("Base FPS Cap must be an integer from 0 to 60") + for field in cls.BOOLEAN_FIELDS: + if type(state[field]) is not bool: + raise ValueError(f"{field} must be a boolean") + return state + + @classmethod + def _validate_entry(cls, raw: Any) -> Dict[str, Any]: + if not isinstance(raw, dict): + raise ValueError("Workaround AppID entry must be an object") + entry = { + "state": cls._validate_state(raw.get("state")), + "command_token_added": raw.get("command_token_added", False), + "non_steam": raw.get("non_steam", False), + } + if type(entry["command_token_added"]) is not bool: + raise ValueError("command_token_added must be a boolean") + if type(entry["non_steam"]) is not bool: + raise ValueError("non_steam must be a boolean") + return entry + + @classmethod + def _validate_document(cls, raw: Any) -> Dict[str, Any]: + if not isinstance(raw, dict) or raw.get("version") not in ( + cls.LEGACY_FORMAT_VERSION, + cls.FORMAT_VERSION, + ): + raise ValueError("Unsupported lsfg-vk workaround state version") + apps = raw.get("apps") + if not isinstance(apps, dict): + raise ValueError("Workaround state apps must be an object") + validated_apps: Dict[str, Any] = {} + for appid, entry in apps.items(): + normalized = cls._valid_appid(appid) + if normalized != str(appid): + raise ValueError("Workaround AppIDs must not contain leading zeroes") + validated_apps[normalized] = cls._validate_entry(entry) + return {"version": cls.FORMAT_VERSION, "apps": validated_apps} + + def _empty_document(self) -> Dict[str, Any]: + return {"version": self.FORMAT_VERSION, "apps": {}} + + def _read_document(self) -> Tuple[Dict[str, Any], bool, Optional[str]]: + if not self.sidecar_path.exists(): + return self._empty_document(), False, None + if self.sidecar_path.is_symlink() or not self.sidecar_path.is_file(): + raise RuntimeError("Workaround state path is not a regular file") + try: + content = self.sidecar_path.read_text(encoding="utf-8") + raw = json.loads(content) + except (OSError, json.JSONDecodeError) as error: + raise RuntimeError(f"Could not read workaround state: {error}") from error + return self._validate_document(raw), True, content + + def _wrapper_marker(self) -> bool: + if self.wrapper_path.is_symlink() or not self.wrapper_path.exists(): + return False + if not self.wrapper_path.is_file(): + raise RuntimeError("lsfg wrapper path is not a regular file") + try: + prefix = "\n".join(self.wrapper_path.read_text(encoding="utf-8").splitlines()[:8]) + except OSError as error: + raise RuntimeError(f"Could not read lsfg wrapper: {error}") from error + return self.MARKER in prefix or self.LEGACY_MARKER in prefix + + def _assert_wrapper_owned_or_absent(self) -> bool: + if not self.wrapper_path.exists() and not self.wrapper_path.is_symlink(): + return False + if self.wrapper_path.is_symlink() or not self._wrapper_marker(): + raise RuntimeError(f"Refusing to replace unowned wrapper at {self.wrapper_path}") + return True + + @staticmethod + def _shell(value: str) -> str: + return shlex.quote(value) + + def _state_lines(self, state: Dict[str, Any]) -> list[str]: + lines = [" unset " + " ".join(self.MANAGED_ENV_KEYS)] + lines.extend([ + ' SteamAppId="$appid"', + " export SteamAppId", + f" LSFGVK_CONFIG={self._shell(str(self.config_file_path))}", + " export LSFGVK_CONFIG", + ]) + if state["disableGamescopeWsi"]: + lines.extend([" ENABLE_GAMESCOPE_WSI=0", " export ENABLE_GAMESCOPE_WSI"]) + if state["disableHdr"]: + lines.extend([" DXVK_HDR=0", " export DXVK_HDR"]) + if state["disableSteamdeckMode"]: + lines.extend([" SteamDeck=0", " export SteamDeck"]) + if state["disableVkbasalt"]: + lines.extend([" DISABLE_VKBASALT=1", " export DISABLE_VKBASALT"]) + if state["enableZink"]: + lines.extend([ + " __GLX_VENDOR_LIBRARY_NAME=mesa", + " export __GLX_VENDOR_LIBRARY_NAME", + " MESA_LOADER_DRIVER_OVERRIDE=zink", + " export MESA_LOADER_DRIVER_OVERRIDE", + " GALLIUM_DRIVER=zink", + " export GALLIUM_DRIVER", + ]) + frame_rate = state["dxvkFrameRate"] + if frame_rate > 0: + lines.extend([ + ' if [ -n "${DXVK_CONFIG+x}" ]; then', + ' if [ -n "${DXVK_CONFIG}" ]; then', + f' DXVK_CONFIG="${{DXVK_CONFIG}}; dxvk.maxFrameRate = {frame_rate}"', + " else", + f' DXVK_CONFIG="dxvk.maxFrameRate = {frame_rate}"', + " fi", + " else", + f' DXVK_CONFIG="dxvk.maxFrameRate = {frame_rate}"', + " fi", + " export DXVK_CONFIG", + ]) + return lines + + def _render_wrapper(self, document: Dict[str, Any]) -> str: + lines = [ + "#!/bin/sh", + self.MARKER, + "", + "appid=", + 'case "${SteamAppId-}" in', + " ''|*[!0-9]*) ;;", + ' *) appid="${SteamAppId}" ;;', + "esac", + 'if [ -z "$appid" ]; then', + ' case "${SteamGameId-}" in', + " ''|*[!0-9]*) ;;", + ' *) appid="${SteamGameId}" ;;', + " esac", + "fi", + 'if [ -z "$appid" ]; then', + ' case "${STEAM_COMPAT_APP_ID-}" in', + " ''|*[!0-9]*) ;;", + ' *) appid="${STEAM_COMPAT_APP_ID}" ;;', + " esac", + "fi", + 'case "$appid" in', + ] + for appid in sorted(document["apps"], key=lambda value: int(value)): + lines.append(f" {appid})") + lines.extend(self._state_lines(document["apps"][appid]["state"])) + lines.append(" ;;") + lines.extend([ + "esac", + 'exec "$@"', + "", + ]) + return "\n".join(lines) + + def _write_document(self, document: Dict[str, Any]) -> None: + self._write_file( + self.sidecar_path, + json.dumps(document, indent=2, sort_keys=True) + "\n", + 0o644, + ) + + def _write_pair(self, document: Dict[str, Any]) -> None: + old_sidecar_exists = self.sidecar_path.exists() + old_sidecar = self.sidecar_path.read_text(encoding="utf-8") if old_sidecar_exists else None + old_wrapper_exists = self.wrapper_path.exists() or self.wrapper_path.is_symlink() + old_wrapper = self.wrapper_path.read_text(encoding="utf-8") if old_wrapper_exists and not self.wrapper_path.is_symlink() else None + try: + self._write_document(document) + self._write_file(self.wrapper_path, self._render_wrapper(document), 0o755) + except Exception: + try: + if old_sidecar_exists and old_sidecar is not None: + self._write_file(self.sidecar_path, old_sidecar, 0o644) + elif self.sidecar_path.exists(): + self.sidecar_path.unlink() + if old_wrapper_exists and old_wrapper is not None: + self._write_file(self.wrapper_path, old_wrapper, 0o755) + elif not old_wrapper_exists and self.wrapper_path.exists(): + self.wrapper_path.unlink() + except Exception as rollback_error: + self.log.error(f"Could not roll back workaround wrapper update: {rollback_error}") + raise + + def _response(self, document: Dict[str, Any], appid: str = "") -> Dict[str, Any]: + entry = document["apps"].get(appid) + return { + "success": True, + "message": "", + "error": None, + "appid": appid or None, + "state": dict(entry["state"]) if entry else None, + "wrapper_path": self.WRAPPER_TOKEN, + "wrapper_owned": self._wrapper_marker() if document["apps"] else False, + "command_token_added": entry.get("command_token_added", False) if entry else False, + "non_steam": entry.get("non_steam", False) if entry else False, + } + + def get(self, appid: str) -> Dict[str, Any]: + try: + normalized = self._valid_appid(appid) + with self._lock: + document, _, _ = self._read_document() + self._assert_wrapper_owned_or_absent() + return self._response(document, normalized) + except Exception as error: + return { + "success": False, + "message": "", + "error": str(error), + "appid": str(appid), + "state": None, + "wrapper_path": self.WRAPPER_TOKEN, + "wrapper_owned": False, + "non_steam": False, + } + + def set( + self, + appid: str, + state: Dict[str, Any], + command_token_added: bool = False, + non_steam: bool = False, + ) -> Dict[str, Any]: + try: + normalized = self._valid_appid(appid) + validated_state = self._validate_state(state) + if type(command_token_added) is not bool: + raise ValueError("command_token_added must be a boolean") + if type(non_steam) is not bool: + raise ValueError("non_steam must be a boolean") + with self._lock: + self._assert_wrapper_owned_or_absent() + document, _, _ = self._read_document() + document["apps"][normalized] = { + "state": validated_state, + "command_token_added": command_token_added, + "non_steam": non_steam, + } + self._write_pair(document) + return self._response(document, normalized) + except Exception as error: + return { + "success": False, + "message": "", + "error": str(error), + "appid": str(appid), + "state": None, + "wrapper_path": self.WRAPPER_TOKEN, + "wrapper_owned": False, + "non_steam": False, + } + + def remove(self, appid: str) -> Dict[str, Any]: + try: + normalized = self._valid_appid(appid) + with self._lock: + document, _, _ = self._read_document() + self._assert_wrapper_owned_or_absent() + if normalized not in document["apps"]: + return self._response(document, normalized) + document["apps"].pop(normalized, None) + self._write_pair(document) + return self._response(document, normalized) + except Exception as error: + return { + "success": False, + "message": "", + "error": str(error), + "appid": str(appid), + "state": None, + "wrapper_path": self.WRAPPER_TOKEN, + "wrapper_owned": False, + "non_steam": False, + } + + def repair(self) -> Dict[str, Any]: + try: + with self._lock: + document, _, _ = self._read_document() + self._assert_wrapper_owned_or_absent() + if not document["apps"]: + return self._response(document) + self._write_file(self.wrapper_path, self._render_wrapper(document), 0o755) + return self._response(document) + except Exception as error: + return { + "success": False, + "message": "", + "error": str(error), + "wrapper_path": self.WRAPPER_TOKEN, + "wrapper_owned": False, + } + + def list_apps(self) -> Dict[str, Any]: + try: + with self._lock: + document, _, _ = self._read_document() + self._assert_wrapper_owned_or_absent() + apps = [ + { + "appid": appid, + "non_steam": entry.get("non_steam", False), + "command_token_added": entry.get("command_token_added", False), + } + for appid, entry in document["apps"].items() + ] + return { + "success": True, + "message": "", + "error": None, + "apps": apps, + "wrapper_path": self.WRAPPER_TOKEN, + } + except Exception as error: + return { + "success": False, + "message": "", + "error": str(error), + "apps": [], + "wrapper_path": self.WRAPPER_TOKEN, + } + + def purge(self) -> Dict[str, Any]: + """Remove the plugin-owned wrapper and its sidecar during uninstall. + + This is deliberately separate from ``remove``: normal profile removal + leaves a safe passthrough wrapper for the remaining profiles, while an + uninstall should remove the wrapper entirely. Both files are + validated before anything is removed so a user's replacement wrapper + or damaged state is left untouched. + """ + removed = [] + try: + with self._lock: + _document, sidecar_exists, _ = self._read_document() + wrapper_owned = self._assert_wrapper_owned_or_absent() + if wrapper_owned: + self.wrapper_path.unlink() + removed.append(str(self.wrapper_path)) + if sidecar_exists: + self.sidecar_path.unlink() + removed.append(str(self.sidecar_path)) + return { + "success": True, + "message": "Removed lsfg-vk workaround state", + "error": None, + "removed_files": removed, + } + except Exception as error: + return { + "success": False, + "message": "", + "error": str(error), + "removed_files": removed or None, + } + + def neutralize(self) -> Dict[str, Any]: + """Leave an owned passthrough wrapper for Decky-level uninstall. + + Decky can remove this plugin without giving the frontend a chance to + clean Steam launch options first. Keeping a dependency-free wrapper + prevents those options from turning into a broken executable path. + """ + try: + with self._lock: + _document, sidecar_exists, _ = self._read_document() + self._assert_wrapper_owned_or_absent() + self._write_file( + self.wrapper_path, + "#!/bin/sh\n" + f"{self.MARKER}\n" + "# Safe passthrough retained for existing Steam launch options.\n" + "exec \"$@\"\n", + 0o755, + ) + if sidecar_exists: + self.sidecar_path.unlink() + return { + "success": True, + "message": "Replaced lsfg-vk wrapper with a safe passthrough", + "error": None, + "removed_files": [str(self.sidecar_path)] if sidecar_exists else [], + } + except Exception as error: + return { + "success": False, + "message": "", + "error": str(error), + "removed_files": None, + } diff --git a/scripts/deploy-to-deck.sh b/scripts/deploy-to-deck.sh new file mode 100755 index 0000000..f27fe4e --- /dev/null +++ b/scripts/deploy-to-deck.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +set -euo pipefail + +deck_host="deck@192.168.0.241" +plugin_root="Decky LSFG-VK" +install_path="/home/deck/homebrew/plugins/Decky LSFG-VK" + +if [[ -z ${DEPLOY_EXPECT:-} && -f .env ]]; then + set -a + source .env + set +a +fi + +if [[ -n ${DECK_PASSWORD:-} && -z ${DEPLOY_EXPECT:-} ]]; then + export DEPLOY_EXPECT=1 + export DEPLOY_SCRIPT="$0" + exec expect <<'EXPECT' +set timeout -1 +spawn bash $env(DEPLOY_SCRIPT) + +expect { + -re {(?i)yes/no} { + send -- "yes\r" + exp_continue + } + -re {(?i)(password|passphrase).*:} { + send -- "$env(DECK_PASSWORD)\r" + exp_continue + } + eof +} + +catch wait result +exit [lindex $result 3] +EXPECT +fi + +package_dir="$(mktemp -d "${TMPDIR:-/tmp}/decky-plugin-package.XXXXXX")" +run_id="$(basename "$package_dir")" +remote_archive="/tmp/decky-lsfg-vk-${run_id}.zip" +remote_stage="/tmp/decky-lsfg-vk-stage-${run_id}" +trap 'rm -rf -- "$package_dir"' EXIT INT TERM + +pnpm build +./cli/decky plugin build . \ + --output-path "$package_dir" \ + --tmp-output-path "$package_dir/tmp" + +package_zip="$(find "$package_dir" -maxdepth 1 -type f -name '*.zip' -print -quit)" +test -n "$package_zip" + +scp "$package_zip" "$deck_host:$remote_archive" + +remote_script="$(cat <<'REMOTE' +set -euo pipefail + +archive="$1" +stage="$2" +install="$3" +plugin_root="$4" + +cleanup() { + rm -rf -- "$archive" "$stage" +} +trap cleanup EXIT INT TERM + +mkdir -p -- "$stage" +unzip -q "$archive" -d "$stage" +test -f "$stage/$plugin_root/plugin.json" +test -f "$stage/$plugin_root/dist/index.js" + +sudo -v +sudo rm -rf -- "$install" +sudo mv -- "$stage/$plugin_root" "$install" +sudo chown -R deck:deck -- "$install" +sudo systemctl restart plugin_loader.service +sleep 2 +sudo chown -R deck:deck -- "$install" + +test "$(systemctl is-active plugin_loader.service)" = active +echo "Deck is ready to test" +REMOTE +)" + +printf -v remote_command 'bash -c %q -- %q %q %q %q' \ + "$remote_script" "$remote_archive" "$remote_stage" "$install_path" "$plugin_root" + +ssh -tt "$deck_host" "$remote_command" diff --git a/scripts/generate_python_boilerplate.py b/scripts/generate_python_boilerplate.py deleted file mode 100644 index a03aa2b..0000000 --- a/scripts/generate_python_boilerplate.py +++ /dev/null @@ -1,252 +0,0 @@ -#!/usr/bin/env python3 -""" -Generate Python boilerplate from shared_config.py - -This script generates repetitive Python code patterns from the canonical schema, -reducing manual maintenance when adding/removing configuration fields. -""" - -import sys -from pathlib import Path - -# Add project root to path to import shared_config -project_root = Path(__file__).parent.parent -sys.path.insert(0, str(project_root)) - -from shared_config import CONFIG_SCHEMA_DEF, ConfigFieldType - - -def get_python_type(field_type: ConfigFieldType) -> str: - """Convert ConfigFieldType to Python type annotation""" - type_map = { - ConfigFieldType.BOOLEAN: "bool", - ConfigFieldType.INTEGER: "int", - ConfigFieldType.FLOAT: "float", - ConfigFieldType.STRING: "str" - } - return type_map.get(field_type, "Any") - - -def get_env_var_name(field_name: str) -> str: - """Convert field name to environment variable name""" - env_map = { - "dxvk_frame_rate": "DXVK_FRAME_RATE", - "enable_wow64": "PROTON_USE_WOW64", - "disable_steamdeck_mode": "SteamDeck", - "mangohud_workaround": "MANGOHUD", - "disable_vkbasalt": "DISABLE_VKBASALT", - "force_enable_vkbasalt": "ENABLE_VKBASALT", - "enable_wsi": "ENABLE_GAMESCOPE_WSI", - "enable_zink": "ZINK_ENABLE" - } - return env_map.get(field_name, field_name.upper()) - - -def generate_typed_dict() -> str: - """Generate ConfigurationData TypedDict""" - lines = [ - "class ConfigurationData(TypedDict):", - " \"\"\"Type-safe configuration data structure - AUTO-GENERATED\"\"\"" - ] - - for field_name, field_def in CONFIG_SCHEMA_DEF.items(): - python_type = get_python_type(ConfigFieldType(field_def["fieldType"])) - lines.append(f" {field_name}: {python_type}") - - return "\n".join(lines) - - -def generate_script_parsing() -> str: - """Generate script content parsing logic""" - lines = [] - - script_fields = [ - (field_name, field_def) - for field_name, field_def in CONFIG_SCHEMA_DEF.items() - if field_def.get("location") == "script" - ] - - for field_name, field_def in script_fields: - env_var = get_env_var_name(field_name) - field_type = ConfigFieldType(field_def["fieldType"]) - - if field_type == ConfigFieldType.BOOLEAN: - if field_name == "disable_steamdeck_mode": - # Special case: SteamDeck=0 means disable_steamdeck_mode=True - lines.append(f' elif key == "{env_var}":') - lines.append(f' script_values["{field_name}"] = value == "0"') - elif field_name == "enable_wsi": - # Special case: ENABLE_GAMESCOPE_WSI=0 or DXVK_HDR=0 means enable_wsi=False - lines.append(f' elif key == "{env_var}":') - lines.append(f' script_values["{field_name}"] = value != "0"') - lines.append(f' elif key == "DXVK_HDR":') - lines.append(f' script_values["{field_name}"] = value != "0"') - elif field_name == "enable_zink": - # Special case: Zink uses multiple environment variables - lines.append(f' elif key == "__GLX_VENDOR_LIBRARY_NAME" and value == "mesa":') - lines.append(f' script_values["{field_name}"] = True') - lines.append(f' elif key == "MESA_LOADER_DRIVER_OVERRIDE" and value == "zink":') - lines.append(f' script_values["{field_name}"] = True') - lines.append(f' elif key == "GALLIUM_DRIVER" and value == "zink":') - lines.append(f' script_values["{field_name}"] = True') - else: - lines.append(f' elif key == "{env_var}":') - lines.append(f' script_values["{field_name}"] = value == "1"') - elif field_type == ConfigFieldType.INTEGER: - lines.append(f' elif key == "{env_var}":') - lines.append(' try:') - lines.append(f' script_values["{field_name}"] = int(value)') - lines.append(' except ValueError:') - lines.append(' pass') - elif field_type == ConfigFieldType.FLOAT: - lines.append(f' elif key == "{env_var}":') - lines.append(' try:') - lines.append(f' script_values["{field_name}"] = float(value)') - lines.append(' except ValueError:') - lines.append(' pass') - elif field_type == ConfigFieldType.STRING: - lines.append(f' elif key == "{env_var}":') - lines.append(f' script_values["{field_name}"] = value') - - return "\n".join(lines) - - -def generate_script_generation() -> str: - """Generate script content generation logic""" - lines = [] - - script_fields = [ - (field_name, field_def) - for field_name, field_def in CONFIG_SCHEMA_DEF.items() - if field_def.get("location") == "script" - ] - - for field_name, field_def in script_fields: - env_var = get_env_var_name(field_name) - field_type = ConfigFieldType(field_def["fieldType"]) - - if field_type == ConfigFieldType.BOOLEAN: - if field_name == "disable_steamdeck_mode": - # Special case: disable_steamdeck_mode=True should export SteamDeck=0 - lines.append(f' if config.get("{field_name}", False):') - lines.append(f' lines.append("export {env_var}=0")') - elif field_name == "enable_wsi": - # Special case: enable_wsi=False should export ENABLE_GAMESCOPE_WSI=0 and DXVK_HDR=0 - lines.append(f' if not config.get("{field_name}", False):') - lines.append(f' lines.append("export {env_var}=0")') - lines.append(f' lines.append("export DXVK_HDR=0")') - elif field_name == "enable_zink": - # Special case: enable_zink=True should export multiple Zink environment variables - lines.append(f' if config.get("{field_name}", False):') - lines.append(f' lines.append("export __GLX_VENDOR_LIBRARY_NAME=mesa")') - lines.append(f' lines.append("export MESA_LOADER_DRIVER_OVERRIDE=zink")') - lines.append(f' lines.append("export GALLIUM_DRIVER=zink")') - else: - lines.append(f' if config.get("{field_name}", False):') - lines.append(f' lines.append("export {env_var}=1")') - elif field_type in [ConfigFieldType.INTEGER, ConfigFieldType.FLOAT]: - default = field_def["default"] - if field_name == "dxvk_frame_rate": - # Special handling for DXVK_FRAME_RATE (only export if > 0) - lines.append(f' {field_name} = config.get("{field_name}", {default})') - lines.append(f' if {field_name} > 0:') - lines.append(f' lines.append(f"export {env_var}={{{field_name}}}")') - else: - lines.append(f' {field_name} = config.get("{field_name}", {default})') - lines.append(f' if {field_name} != {default}:') - lines.append(f' lines.append(f"export {env_var}={{{field_name}}}")') - elif field_type == ConfigFieldType.STRING: - lines.append(f' {field_name} = config.get("{field_name}", "")') - lines.append(f' if {field_name}:') - lines.append(f' lines.append(f"export {env_var}={{{field_name}}}")') - - return "\n".join(lines) - - -def generate_complete_schema_file() -> str: - """Generate complete config_schema_generated.py file""" - - # Generate field name constants - field_constants = [] - for field_name in CONFIG_SCHEMA_DEF.keys(): - const_name = field_name.upper() - field_constants.append(f'{const_name} = "{field_name}"') - - lines = [ - '"""', - 'Auto-generated configuration schema components from shared_config.py', - 'DO NOT EDIT THIS FILE MANUALLY - it will be overwritten on build', - '"""', - '', - 'from typing import TypedDict, Dict, Any, Union', - 'from enum import Enum', - 'import sys', - 'from pathlib import Path', - '', - '# Import shared configuration constants', - 'sys.path.insert(0, str(Path(__file__).parent.parent.parent))', - 'from shared_config import CONFIG_SCHEMA_DEF, ConfigFieldType', - '', - '# Field name constants for type-safe access', - ] + field_constants + [ - '', - '', - generate_typed_dict(), - '', - '', - 'def get_script_parsing_logic():', - ' """Return the script parsing logic as a callable"""', - ' def parse_script_values(lines):', - ' script_values = {}', - ' for line in lines:', - ' line = line.strip()', - ' if not line or line.startswith("#") or not line.startswith("export "):', - ' continue', - ' if "=" in line:', - ' export_line = line[len("export "):]', - ' key, value = export_line.split("=", 1)', - ' key = key.strip()', - ' value = value.strip()', - '', - ' # Auto-generated parsing logic:', - f'{generate_script_parsing().replace(" elif", " if")}', - '', - ' return script_values', - ' return parse_script_values', - '', - '', - 'def get_script_generation_logic():', - ' """Return the script generation logic as a callable"""', - ' def generate_script_lines(config):', - ' lines = []', - f'{generate_script_generation()}', - ' return lines', - ' return generate_script_lines', - '', - '', - f'ALL_FIELDS = {list(CONFIG_SCHEMA_DEF.keys())}', - '' - ] - - return '\n'.join(lines) - - -def main(): - """Generate complete Python configuration files""" - try: - # Create generated files in py_modules/lsfg_vk/ - target_dir = project_root / "py_modules" / "lsfg_vk" - - # Generate the complete schema file - schema_content = generate_complete_schema_file() - schema_file = target_dir / "config_schema_generated.py" - schema_file.write_text(schema_content) - print(f"Generated {schema_file.relative_to(project_root)}") - - except Exception as e: - print(f"❌ Error generating Python files: {e}") - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/scripts/generate_ts_schema.py b/scripts/generate_ts_schema.py deleted file mode 100644 index c4c0e8a..0000000 --- a/scripts/generate_ts_schema.py +++ /dev/null @@ -1,158 +0,0 @@ -#!/usr/bin/env python3 -""" -Generate TypeScript schema from Python shared_config.py - -This script reads the canonical schema from shared_config.py and generates -the corresponding TypeScript files, ensuring single source of truth. -""" - -import sys -from pathlib import Path - -# Add project root to path to import shared_config -project_root = Path(__file__).parent.parent -sys.path.insert(0, str(project_root)) - -from shared_config import CONFIG_SCHEMA_DEF, ConfigFieldType - - -def generate_typescript_schema(): - """Generate generatedConfigSchema.ts from Python schema""" - - # Generate field name constants - field_constants = [] - for field_name in CONFIG_SCHEMA_DEF.keys(): - const_name = field_name.upper() - field_constants.append(f'export const {const_name} = "{field_name}" as const;') - - # Generate enum - enum_lines = [ - "// src/config/generatedConfigSchema.ts", - "// Configuration field type enum - matches Python", - "export enum ConfigFieldType {", - " BOOLEAN = \"boolean\",", - " INTEGER = \"integer\",", - " FLOAT = \"float\",", - " STRING = \"string\"", - "}", - "", - "// Field name constants for type-safe access", - ] + field_constants + [ - "", - "// 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 schema entries - schema_entries = [] - interface_fields = [] - defaults_fields = [] - field_types = [] - - for field_name, field_def in CONFIG_SCHEMA_DEF.items(): - # Schema entry - default_value = field_def["default"] - if isinstance(default_value, str): - default_str = f'"{default_value}"' - elif isinstance(default_value, bool): - default_str = "true" if default_value else "false" - else: - default_str = str(default_value) - - schema_entries.append(f' {field_name}: {{') - schema_entries.append(f' name: "{field_def["name"]}",') - schema_entries.append(f' fieldType: ConfigFieldType.{field_def["fieldType"].upper()},') - schema_entries.append(f' default: {default_str},') - schema_entries.append(f' description: "{field_def["description"]}"') - schema_entries.append(' },') - - # Interface field - if field_def["fieldType"] == ConfigFieldType.BOOLEAN: - ts_type = "boolean" - elif field_def["fieldType"] == ConfigFieldType.INTEGER: - ts_type = "number" - elif field_def["fieldType"] == ConfigFieldType.FLOAT: - ts_type = "number" - elif field_def["fieldType"] == ConfigFieldType.STRING: - ts_type = "string" - else: - ts_type = "any" - - interface_fields.append(f' {field_name}: {ts_type};') - defaults_fields.append(f' {field_name}: {default_str},') - field_types.append(f' {field_name}: ConfigFieldType.{field_def["fieldType"].upper()},') - - # Complete the file - all_lines = enum_lines + schema_entries + [ - "};", - "", - "// Type-safe configuration data structure", - "export interface ConfigurationData {", - ] + interface_fields + [ - "}", - "", - "// Helper functions", - "export function getFieldNames(): string[] {", - " return Object.keys(CONFIG_SCHEMA);", - "}", - "", - "export function getDefaults(): ConfigurationData {", - " return {", - ] + defaults_fields + [ - " };", - "}", - "", - "export function getFieldTypes(): Record<string, ConfigFieldType> {", - " return {", - ] + field_types + [ - " };", - "}", - "", - "" - ] - - return "\n".join(all_lines) - - -def main(): - """Main function to generate TypeScript schema and Python boilerplate""" - try: - # Generate the TypeScript content - ts_content = generate_typescript_schema() - - # Write to the target file - target_file = project_root / "src" / "config" / "generatedConfigSchema.ts" - target_file.write_text(ts_content) - - print(f"✅ Generated {target_file} from shared_config.py") - print(f" Fields: {len(CONFIG_SCHEMA_DEF)}") - - # Also generate Python boilerplate - print("\n🔄 Generating Python boilerplate...") - from pathlib import Path - import subprocess - - boilerplate_script = project_root / "scripts" / "generate_python_boilerplate.py" - result = subprocess.run([sys.executable, str(boilerplate_script)], - capture_output=True, text=True) - - if result.returncode == 0: - print(result.stdout) - else: - print(f"⚠️ Python boilerplate generation had issues:\n{result.stderr}") - - except Exception as e: - print(f"❌ Error generating schema: {e}") - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/shared_config.py b/shared_config.py deleted file mode 100644 index 997717b..0000000 --- a/shared_config.py +++ /dev/null @@ -1,162 +0,0 @@ -""" -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" - - -CONFIG_SCHEMA_DEF = { - "dll": { - "name": "dll", - "fieldType": ConfigFieldType.STRING, - "default": "/games/Lossless Scaling/Lossless.dll", - "description": "specify where Lossless.dll is stored", - "location": "toml" - }, - - "no_fp16": { - "name": "no_fp16", - "fieldType": ConfigFieldType.BOOLEAN, - "default": False, - "description": "force-disable fp16 (use on older nvidia cards)", - "location": "toml" - }, - - "multiplier": { - "name": "multiplier", - "fieldType": ConfigFieldType.INTEGER, - "default": 1, - "description": "change the fps multiplier", - "location": "toml" - }, - - "flow_scale": { - "name": "flow_scale", - "fieldType": ConfigFieldType.FLOAT, - "default": 0.8, - "description": "change the flow scale", - "location": "toml" - }, - - "performance_mode": { - "name": "performance_mode", - "fieldType": ConfigFieldType.BOOLEAN, - "default": False, - "description": "use a lighter model for FG (recommended for most games)", - "location": "toml" - }, - - "hdr_mode": { - "name": "hdr_mode", - "fieldType": ConfigFieldType.BOOLEAN, - "default": False, - "description": "enable HDR mode (only for games that support HDR)", - "location": "toml" - }, - - "experimental_present_mode": { - "name": "experimental_present_mode", - "fieldType": ConfigFieldType.STRING, - "default": "fifo", - "description": "override Vulkan present mode (may cause crashes)", - "location": "toml" - }, - - "dxvk_frame_rate": { - "name": "dxvk_frame_rate", - "fieldType": ConfigFieldType.INTEGER, - "default": 0, - "description": "base framerate cap for DirectX games before frame multiplier", - "location": "script" - }, - - "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)", - "location": "script" - }, - - "disable_steamdeck_mode": { - "name": "disable_steamdeck_mode", - "fieldType": ConfigFieldType.BOOLEAN, - "default": False, - "description": "disable Steam Deck mode (unlocks hidden settings in some games)", - "location": "script" - }, - - "mangohud_workaround": { - "name": "mangohud_workaround", - "fieldType": ConfigFieldType.BOOLEAN, - "default": False, - "description": "Enables a transparent mangohud overlay, sometimes fixes issues with 2X multiplier in game mode", - "location": "script" - }, - - "disable_vkbasalt": { - "name": "disable_vkbasalt", - "fieldType": ConfigFieldType.BOOLEAN, - "default": False, - "description": "Disables vkBasalt layer which can conflict with LSFG (Reshade, some Decky plugins)", - "location": "script" - }, - - "force_enable_vkbasalt": { - "name": "force_enable_vkbasalt", - "fieldType": ConfigFieldType.BOOLEAN, - "default": False, - "description": "Force vkBasalt to engage to fix framepacing issues in gamemode", - "location": "script" - }, - - "enable_wsi": { - "name": "enable_wsi", - "fieldType": ConfigFieldType.BOOLEAN, - "default": False, - "description": "Enable Gamescope WSI Layer, disable if frame generation isn't applying or isn't feeling smooth (use with HDR off)", - "location": "script" - }, - - "enable_zink": { - "name": "enable_zink", - "fieldType": ConfigFieldType.BOOLEAN, - "default": False, - "description": "Enable Zink (Vulkan-based OpenGL implementation) for OpenGL games", - "location": "script" - } -} - - -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/api/lsfgApi.ts b/src/api/lsfgApi.ts index 82f37c8..44b72b5 100644 --- a/src/api/lsfgApi.ts +++ b/src/api/lsfgApi.ts @@ -1,170 +1,180 @@ import { callable } from "@decky/api"; import { ConfigurationData } from "../config/configSchema"; -// Type definitions for API responses -export interface InstallationResult { +interface ApiResult { success: boolean; - error?: string; message?: string; + error?: string | null; +} + +export interface InstallationResult extends ApiResult { removed_files?: string[]; } export interface InstallationStatus { installed: boolean; - lib_exists: boolean; - json_exists: boolean; - script_exists: boolean; - lib_path: string; - json_path: string; - script_path: string; + lossless_scaling_installed: boolean; + lossless_scaling_status: string; error?: string; } -export interface DllDetectionResult { - detected: boolean; - path?: string; - source?: string; - message?: string; - error?: string; +export interface SteamBranchStatus extends ApiResult { + message: string; + installed: boolean; + manifest_path?: string; + selected_branch?: string; + current_branch?: string; + target_branch: string; + needs_switch: boolean; + restart_required: boolean; } -export interface DllStatsResult { - success: boolean; - dll_path?: string; - dll_sha256?: string; - dll_source?: string; - error?: string; +export type LsfgConfig = ConfigurationData; + +export interface GameConfigEntry { + appid: string; + profile: string; + config: LsfgConfig; } -// Use centralized configuration data type -export type LsfgConfig = ConfigurationData; +export interface InstalledGame { + appid: string; + name: string; + nonSteam: boolean; + isFlatpakShortcut?: boolean; +} -export interface ConfigResult { - success: boolean; - config?: LsfgConfig; - error?: string; +export interface GlobalConfig { + dll: string; + no_fp16: boolean; } -export interface ConfigUpdateResult { - success: boolean; - message?: string; - error?: string; +export interface WorkaroundState { + dxvkFrameRate: number; + disableGamescopeWsi: boolean; + disableHdr: boolean; + disableSteamdeckMode: boolean; + disableVkbasalt: boolean; + enableZink: boolean; } -export interface ConfigSchemaResult { - field_names: string[]; - field_types: Record<string, string>; - defaults: ConfigurationData; - profiles?: string[]; - current_profile?: string; +export interface WorkaroundStateResult extends ApiResult { + appid?: string; + app_id?: string; + state?: WorkaroundState | null; + wrapper_path?: string; + wrapper_owned?: boolean; + command_token_added?: boolean; + non_steam?: boolean; } -export interface LaunchOptionResult { - launch_option: string; - instructions: string; - explanation: string; +export interface WorkaroundApp { + appid: string; + non_steam: boolean; + command_token_added: boolean; } -export interface FileContentResult { - success: boolean; +export interface WorkaroundAppsResult extends ApiResult { + apps?: WorkaroundApp[]; + wrapper_path?: string; +} + +export interface GameConfigsResult extends ApiResult { + global_config?: GlobalConfig; + games?: GameConfigEntry[]; +} + +export interface GlobalConfigResult extends ApiResult { + global_config?: GlobalConfig; +} + +export interface GameConfigResult extends ApiResult { + appid?: string; + exists?: boolean; + config?: LsfgConfig; +} + +export interface InstalledGamesResult extends ApiResult { + games?: InstalledGame[]; +} + +export interface FileContentResult extends ApiResult { content?: string; path?: string; - error?: string; } -export interface FgmodCheckResult { - success: boolean; +export interface DebugFileContent { + id: string; + label: string; + path: string; exists: boolean; - path?: string; - error?: string; + content?: string | null; + error?: string | null; } -// Flatpak management interfaces -export interface FlatpakExtensionStatus { - success: boolean; - message: string; - error?: string; - installed_23_08: boolean; - installed_24_08: boolean; - installed_25_08: boolean; +export interface DebugFileContentsResult extends ApiResult { + files?: DebugFileContent[]; } export interface FlatpakApp { app_id: string; app_name: string; - has_filesystem_override: boolean; - has_env_override: boolean; -} - -export interface FlatpakAppInfo { - success: boolean; - message: string; - error?: string; - apps: FlatpakApp[]; - total_apps: number; + runtime?: string | null; + runtime_branch?: string | null; + runtime_ready: boolean; + prepared: boolean; + owned: boolean; + enabled: boolean; + profile: string; + config?: LsfgConfig | null; + workarounds: WorkaroundState; + error?: string | null; +} + +export interface RunningFlatpakApp { + app_id: string; + active: boolean; + pid?: string; + start_time?: number | null; } -export interface FlatpakOperationResult { - success: boolean; - message: string; - error?: string; - app_id?: string; - operation?: string; +export interface FlatpakAppsResult extends ApiResult { + apps?: FlatpakApp[]; } -// Profile management interfaces -export interface ProfilesResult { - success: boolean; - profiles?: string[]; - current_profile?: string; - message?: string; - error?: string; +export interface RunningFlatpakAppsResult extends ApiResult { + apps?: RunningFlatpakApp[]; } -export interface ProfileResult { - success: boolean; - profile_name?: string; - message?: string; - error?: string; +export interface FlatpakAppResult extends ApiResult, Partial<FlatpakApp> { + app_id: string; } -// API functions export const installLsfgVk = callable<[], InstallationResult>("install_lsfg_vk"); export const uninstallLsfgVk = callable<[], InstallationResult>("uninstall_lsfg_vk"); export const checkLsfgVkInstalled = callable<[], InstallationStatus>("check_lsfg_vk_installed"); -export const checkLosslessScalingDll = callable<[], DllDetectionResult>("check_lossless_scaling_dll"); -export const getDllStats = callable<[], DllStatsResult>("get_dll_stats"); -export const getLsfgConfig = callable<[], ConfigResult>("get_lsfg_config"); -export const getConfigSchema = callable<[], ConfigSchemaResult>("get_config_schema"); -export const getLaunchOption = callable<[], LaunchOptionResult>("get_launch_option"); +export const getLosslessScalingBranchStatus = callable<[], SteamBranchStatus>("get_lossless_scaling_branch_status"); export const getConfigFileContent = callable<[], FileContentResult>("get_config_file_content"); -export const getLaunchScriptContent = callable<[], FileContentResult>("get_launch_script_content"); -export const checkFgmodDirectory = callable<[], FgmodCheckResult>("check_fgmod_directory"); - -// Flatpak management API functions -export const checkFlatpakExtensionStatus = callable<[], FlatpakExtensionStatus>("check_flatpak_extension_status"); -export const installFlatpakExtension = callable<[string], FlatpakOperationResult>("install_flatpak_extension"); -export const uninstallFlatpakExtension = callable<[string], FlatpakOperationResult>("uninstall_flatpak_extension"); -export const getFlatpakApps = callable<[], FlatpakAppInfo>("get_flatpak_apps"); -export const setFlatpakAppOverride = callable<[string], FlatpakOperationResult>("set_flatpak_app_override"); -export const removeFlatpakAppOverride = callable<[string], FlatpakOperationResult>("remove_flatpak_app_override"); - -// Updated config function using object-based configuration (single source of truth) -export const updateLsfgConfig = callable< - [ConfigurationData], - ConfigUpdateResult ->("update_lsfg_config"); - -// Legacy helper function for backward compatibility -export const updateLsfgConfigFromObject = async (config: ConfigurationData): Promise<ConfigUpdateResult> => { - return updateLsfgConfig(config); -}; - -// Self-updater API functions -// Profile management API functions -export const getProfiles = callable<[], ProfilesResult>("get_profiles"); -export const createProfile = callable<[string, string?], ProfileResult>("create_profile"); -export const deleteProfile = callable<[string], ProfileResult>("delete_profile"); -export const renameProfile = callable<[string, string], ProfileResult>("rename_profile"); -export const setCurrentProfile = callable<[string], ProfileResult>("set_current_profile"); -export const updateProfileConfig = callable<[string, ConfigurationData], ConfigUpdateResult>("update_profile_config"); +export const getFlatpakApps = callable<[], FlatpakAppsResult>("get_flatpak_apps"); +export const enableFlatpakApp = callable<[string], FlatpakAppResult>("enable_flatpak_app"); +export const updateFlatpakConfig = callable<[string, LsfgConfig], FlatpakAppResult>("update_flatpak_config"); +export const setFlatpakWorkaroundState = callable<[string, WorkaroundState], WorkaroundStateResult>("set_flatpak_workaround_state"); +export const removeFlatpakApp = callable<[string], FlatpakAppResult>("remove_flatpak_app"); +export const getRunningFlatpakApps = callable<[], RunningFlatpakAppsResult>("get_running_flatpak_apps"); +export const getGameConfigs = callable<[], GameConfigsResult>("get_game_configs"); +export const getInstalledGames = callable<[], InstalledGamesResult>("get_installed_games"); +export const updateGameConfig = callable<[string, string, LsfgConfig], GameConfigResult>("update_game_config"); +export const resetGameConfig = callable<[string], GameConfigResult>("reset_game_config"); +export const resetGameConfigs = callable<[string[]], GameConfigsResult>("reset_game_configs"); +export const resetAllGameConfigs = callable<[], GameConfigsResult>("reset_all_game_configs"); +export const updateGlobalConfig = callable<[GlobalConfig], GlobalConfigResult>("update_global_config"); +export const getWorkaroundState = callable<[string], WorkaroundStateResult>("get_workaround_state"); +export const setWorkaroundState = callable<[ + string, + WorkaroundState, + boolean, + boolean, +], WorkaroundStateResult>("set_workaround_state"); +export const removeWorkaroundState = callable<[string], WorkaroundStateResult>("remove_workaround_state"); +export const getWorkaroundApps = callable<[], WorkaroundAppsResult>("get_workaround_apps"); +export const getDebugFileContents = callable<[], DebugFileContentsResult>("get_debug_file_contents"); diff --git a/src/components/CollapsibleItemGroup.tsx b/src/components/CollapsibleItemGroup.tsx new file mode 100644 index 0000000..a4e4092 --- /dev/null +++ b/src/components/CollapsibleItemGroup.tsx @@ -0,0 +1,103 @@ +import { ButtonItem, Field, PanelSectionRow } from "@decky/ui"; +import { useEffect, useState, type RefObject } from "react"; +import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri"; + +export interface CollapsibleItem { + id: string; + label: string; + description: string; + disabled?: boolean; +} + +export const collapsibleItemGroupStyles = ` + .LSFG_GameGroupCollapseButton_Container { + margin-top: -2px; + margin-bottom: 4px; + } + + .LSFG_GameGroupCollapseButton_Container > div > div > div > button, + .LSFG_GameGroupCollapseButton_Container > div > div > div > div > button { + height: 24px !important; + min-height: 24px !important; + padding: 0 !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + } + + .LSFG_GameGroupCollapseButton_Container svg { + display: block; + margin: 0; + } +`; + +export function usePersistentCollapsed(key: string) { + const [collapsed, setCollapsed] = useState(() => { + try { + return localStorage.getItem(key) !== "false"; + } catch { + return true; + } + }); + + useEffect(() => { + try { + localStorage.setItem(key, String(collapsed)); + } catch {} + }, [collapsed, key]); + + return [collapsed, () => setCollapsed((value) => !value)] as const; +} + +interface Props { + title: string; + items: CollapsibleItem[]; + collapsed: boolean; + onToggle: () => void; + onSelect: (id: string) => void; + toggleRef?: RefObject<HTMLDivElement>; +} + +export function CollapsibleItemGroup({ + title, + items, + collapsed, + onToggle, + onSelect, + toggleRef, +}: Props) { + if (items.length === 0) return null; + + return ( + <> + <PanelSectionRow> + <Field label={`${title} (${items.length})`} bottomSeparator="none" /> + </PanelSectionRow> + <PanelSectionRow> + <div + ref={toggleRef} + className="LSFG_GameGroupCollapseButton_Container" + > + <ButtonItem + layout="below" + bottomSeparator={collapsed ? "standard" : "none"} + onClick={onToggle} + > + {collapsed ? <RiArrowDownSFill /> : <RiArrowUpSFill />} + </ButtonItem> + </div> + </PanelSectionRow> + {!collapsed && items.map((item) => ( + <PanelSectionRow key={item.id}> + <Field + label={item.label} + description={item.description} + disabled={item.disabled} + onActivate={item.disabled ? undefined : () => onSelect(item.id)} + highlightOnFocus + /> + </PanelSectionRow> + ))} + </> + ); +} diff --git a/src/components/ConfigFileTab.tsx b/src/components/ConfigFileTab.tsx new file mode 100644 index 0000000..fd6d716 --- /dev/null +++ b/src/components/ConfigFileTab.tsx @@ -0,0 +1,123 @@ +import { ButtonItem, Field, PanelSection, PanelSectionRow, Spinner } from "@decky/ui"; +import { useEffect, useState } from "react"; +import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri"; +import { getDebugFileContents, type DebugFileContent, type DebugFileContentsResult } from "../api/lsfgApi"; +import t from "../i18n/i18n"; + +function usePersistentCollapsed(key: string) { + const [collapsed, setCollapsed] = useState(() => { + try { + return localStorage.getItem(key) !== "false"; + } catch { + return true; + } + }); + + useEffect(() => { + try { + localStorage.setItem(key, String(collapsed)); + } catch { + // Persisting the view preference is optional. + } + }, [collapsed, key]); + + return [collapsed, () => setCollapsed((value) => !value)] as const; +} + +function DebugFileSection({ file }: { file: DebugFileContent }) { + const [collapsed, toggleCollapsed] = usePersistentCollapsed(`lsfg-debug-file-${file.id}-collapsed-v1`); + const status = file.exists ? "Present" : "Not present"; + + return ( + <> + <PanelSectionRow> + <Field + label={file.label} + description={`${file.path} · ${status}`} + bottomSeparator="none" + /> + </PanelSectionRow> + <PanelSectionRow> + <div + className="LSFG_DebugFileCollapseButton_Container" + style={{ marginTop: "-2px", marginBottom: "4px" }} + > + <ButtonItem + layout="below" + bottomSeparator={collapsed ? "standard" : "none"} + onClick={toggleCollapsed} + > + {collapsed ? <RiArrowDownSFill /> : <RiArrowUpSFill />} + </ButtonItem> + </div> + </PanelSectionRow> + {!collapsed && ( + <PanelSectionRow> + {file.exists && file.content !== null && file.content !== undefined ? ( + <pre style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}> + {file.content} + </pre> + ) : ( + <Field + label="File unavailable" + description={file.error || "The file has not been created yet."} + /> + )} + </PanelSectionRow> + )} + </> + ); +} + +export function ConfigFileTab() { + const [result, setResult] = useState<DebugFileContentsResult | null>(null); + + useEffect(() => { + getDebugFileContents().then(setResult).catch((error) => { + setResult({ success: false, error: String(error) }); + }); + }, []); + + if (!result) { + return ( + <PanelSection title={t("NERD_CONFIG_FILE", "Config / Debug")}> + <PanelSectionRow> + <Spinner /> + </PanelSectionRow> + </PanelSection> + ); + } + + return ( + <> + <style> + {` + .LSFG_DebugFileCollapseButton_Container > div > div > div > button, + .LSFG_DebugFileCollapseButton_Container > div > div > div > div > button { + height: 24px !important; + min-height: 24px !important; + padding: 0 !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + } + + .LSFG_DebugFileCollapseButton_Container svg { + display: block; + margin: 0; + } + `} + </style> + <PanelSection title={t("NERD_CONFIG_FILE", "Config / Debug")}> + {result.error && ( + <PanelSectionRow> + <Field label="Error" description={result.error} /> + </PanelSectionRow> + )} + {result.success && result.files?.map((file) => ( + <DebugFileSection key={file.id} file={file} /> + ))} + </PanelSection> + </> + ); +} diff --git a/src/components/ConfigurationSection.tsx b/src/components/ConfigurationSection.tsx index a10aab2..6996bcd 100644 --- a/src/components/ConfigurationSection.tsx +++ b/src/components/ConfigurationSection.tsx @@ -1,301 +1,25 @@ -import { PanelSectionRow, ToggleField, SliderField, ButtonItem } from "@decky/ui"; -import { useState, useEffect } from "react"; -import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri"; +import { PanelSectionRow, ToggleField, SliderField } from "@decky/ui"; import { ConfigurationData } from "../config/configSchema"; -import { - FLOW_SCALE, NO_FP16, PERFORMANCE_MODE, HDR_MODE, - EXPERIMENTAL_PRESENT_MODE, DXVK_FRAME_RATE, DISABLE_STEAMDECK_MODE, - MANGOHUD_WORKAROUND, DISABLE_VKBASALT, FORCE_ENABLE_VKBASALT, ENABLE_WSI, ENABLE_ZINK -} from "../config/generatedConfigSchema"; -import t from '../i18n/i18n'; +import { FLOW_SCALE, PERFORMANCE_MODE, OVERRIDE_PRESENT_MODE, PRESERVE_SWAPCHAIN_IMAGE_COUNT } from "../config/configSchema"; interface ConfigurationSectionProps { config: ConfigurationData; - onConfigChange: (fieldName: keyof ConfigurationData, value: boolean | number | string) => Promise<void>; + onConfigChange: (fieldName: keyof ConfigurationData, value: boolean | number | string | string[]) => Promise<void>; } -const WORKAROUNDS_COLLAPSED_KEY = "lsfg-workarounds-collapsed"; -const CONFIG_COLLAPSED_KEY = "lsfg-config-collapsed"; - -export function ConfigurationSection({ - config, - onConfigChange -}: ConfigurationSectionProps) { - // Initialize with localStorage value, fallback to true if not found - const [configCollapsed, setConfigCollapsed] = useState(() => { - try { - const saved = localStorage.getItem(CONFIG_COLLAPSED_KEY); - return saved !== null ? JSON.parse(saved) : false; - } catch { - return false; - } - }); - - const [workaroundsCollapsed, setWorkaroundsCollapsed] = useState(() => { - try { - const saved = localStorage.getItem(WORKAROUNDS_COLLAPSED_KEY); - return saved !== null ? JSON.parse(saved) : true; - } catch { - return true; - } - }); - - // Persist workarounds collapse state to localStorage - useEffect(() => { - try { - localStorage.setItem(CONFIG_COLLAPSED_KEY, JSON.stringify(configCollapsed)); - } catch (error) { - console.warn("Failed to save config collapse state:", error); - } - }, [configCollapsed]); - - useEffect(() => { - try { - localStorage.setItem(WORKAROUNDS_COLLAPSED_KEY, JSON.stringify(workaroundsCollapsed)); - } catch (error) { - console.warn("Failed to save workarounds collapse state:", error); - } - }, [workaroundsCollapsed]); - - return ( - <> - <style> - {` - .LSFG_ConfigCollapseButton_Container > div > div > div > button, - .LSFG_ConfigCollapseButton_Container > div > div > div > div > button, - .LSFG_WorkaroundsCollapseButton_Container > div > div > div > button { - height: 10px !important; - } - .LSFG_WorkaroundsCollapseButton_Container > div > div > div > div > button { - height: 10px !important; - } - `} - </style> - - {/* Config Section */} - <PanelSectionRow> - <div - style={{ - fontSize: "14px", - fontWeight: "bold", - marginTop: "8px", - marginBottom: "6px", - borderBottom: "1px solid rgba(255, 255, 255, 0.2)", - paddingBottom: "3px", - color: "white" - }} - > - {t('CONFIG_SECTION_TITLE', 'Config')} - </div> - </PanelSectionRow> - - <PanelSectionRow> - <div - className="LSFG_ConfigCollapseButton_Container" - style={{ marginTop: "-2px", marginBottom: "4px" }} - > - <ButtonItem - layout="below" - bottomSeparator={configCollapsed ? "standard" : "none"} - onClick={() => setConfigCollapsed(!configCollapsed)} - > - {configCollapsed ? ( - <RiArrowDownSFill - style={{ transform: "translate(0, -13px)", fontSize: "1.5em" }} - /> - ) : ( - <RiArrowUpSFill - style={{ transform: "translate(0, -12px)", fontSize: "1.5em" }} - /> - )} - </ButtonItem> - </div> - </PanelSectionRow> - - {!configCollapsed && ( - <> - <PanelSectionRow> - <SliderField - label={`${t('CONFIG_FLOW_SCALE', 'Flow Scale')} (${Math.round(config.flow_scale * 100)}%)`} - description={t('CONFIG_FLOW_SCALE_DESC', 'Lowers internal motion estimation resolution, improving performance slightly')} - value={config.flow_scale} - min={0.25} - max={1.0} - step={0.01} - onChange={(value) => onConfigChange(FLOW_SCALE, value)} - /> - </PanelSectionRow> - - <PanelSectionRow> - <ToggleField - label="FP16 Acceleration" - description="Use FP16 shaders when supported" - checked={!config.no_fp16} - onChange={(value) => onConfigChange(NO_FP16, !value)} - /> - </PanelSectionRow> - - <PanelSectionRow> - <SliderField - label={`${t('CONFIG_BASE_FPS_CAP', 'Base FPS Cap')}${config.dxvk_frame_rate > 0 ? ` (${config.dxvk_frame_rate} FPS)` : ` (${t('CONFIG_BASE_FPS_CAP_OFF', 'Off')})`}`} - description={t('CONFIG_BASE_FPS_CAP_DESC', 'Base framerate cap for DirectX games, before frame multiplier. (Requires game restart to apply)')} - value={config.dxvk_frame_rate} - min={0} - max={60} - step={1} - onChange={(value) => onConfigChange(DXVK_FRAME_RATE, value)} - /> - </PanelSectionRow> - - <PanelSectionRow> - <ToggleField - label={`${t('CONFIG_PRESENT_MODE', 'Present Mode')} (${(config.experimental_present_mode || "fifo") === "fifo" ? t('CONFIG_PRESENT_MODE_FIFO', 'FIFO - VSync') : t('CONFIG_PRESENT_MODE_MAILBOX', 'Mailbox')})`} - description={t('CONFIG_PRESENT_MODE_DESC', 'Toggle between FIFO - VSync (default) and Mailbox presentation modes for better performance or compatibility')} - checked={(config.experimental_present_mode || "fifo") === "fifo"} - onChange={(value) => onConfigChange(EXPERIMENTAL_PRESENT_MODE, value ? "fifo" : "mailbox")} - /> - </PanelSectionRow> - - <PanelSectionRow> - <ToggleField - label={t('CONFIG_PERFORMANCE_MODE', 'Performance Mode')} - description={t('CONFIG_PERFORMANCE_MODE_DESC', 'Uses a lighter model for FG (Recommended for most games)')} - checked={config.performance_mode} - onChange={(value) => onConfigChange(PERFORMANCE_MODE, value)} - /> - </PanelSectionRow> - - <PanelSectionRow> - <ToggleField - label={t('CONFIG_HDR_MODE', 'HDR Mode')} - description={t('CONFIG_HDR_MODE_DESC', 'Enables HDR mode (only for games that support HDR)')} - checked={config.hdr_mode} - onChange={(value) => onConfigChange(HDR_MODE, value)} - /> - </PanelSectionRow> - </> - )} - - {/* Workarounds Section */} - <PanelSectionRow> - <div - style={{ - fontSize: "14px", - fontWeight: "bold", - marginTop: "8px", - marginBottom: "6px", - borderBottom: "1px solid rgba(255, 255, 255, 0.2)", - paddingBottom: "3px", - color: "white" - }} - > - {t('CONFIG_WORKAROUNDS_TITLE', 'Workarounds')} - </div> - </PanelSectionRow> - - <PanelSectionRow> - <div - className="LSFG_WorkaroundsCollapseButton_Container" - style={{ marginTop: "-2px", marginBottom: "4px" }} - > - <ButtonItem - layout="below" - bottomSeparator={workaroundsCollapsed ? "standard" : "none"} - onClick={() => setWorkaroundsCollapsed(!workaroundsCollapsed)} - > - {workaroundsCollapsed ? ( - <RiArrowDownSFill - style={{ transform: "translate(0, -13px)", fontSize: "1.5em" }} - /> - ) : ( - <RiArrowUpSFill - style={{ transform: "translate(0, -12px)", fontSize: "1.5em" }} - /> - )} - </ButtonItem> - </div> - </PanelSectionRow> - - {!workaroundsCollapsed && ( - <> - <PanelSectionRow> - <ToggleField - label={t('CONFIG_ENABLE_WSI', 'Enable WSI')} - description={t('CONFIG_ENABLE_WSI_DESC', 'Re-Enable Gamescope WSI Layer. Requires game restart to apply.')} - checked={config.enable_wsi} - onChange={(value) => onConfigChange(ENABLE_WSI, value)} - /> - </PanelSectionRow> - - <PanelSectionRow> - <ToggleField - label={t('CONFIG_ENABLE_WOW64', 'Enable WOW64 for 32-bit games')} - description={t('CONFIG_ENABLE_WOW64_DESC', 'Enables PROTON_USE_WOW64=1 for 32-bit games (Use with ProtonGE to fix crashing)')} - checked={config.enable_wow64} - onChange={(value) => onConfigChange('enable_wow64', value)} - /> - </PanelSectionRow> - - <PanelSectionRow> - <ToggleField - label={t('CONFIG_DISABLE_STEAMDECK_MODE', 'Disable Steam Deck Mode')} - description={t('CONFIG_DISABLE_STEAMDECK_MODE_DESC', 'Disables Steam Deck mode (Unlocks hidden settings in some games)')} - checked={config.disable_steamdeck_mode} - onChange={(value) => onConfigChange(DISABLE_STEAMDECK_MODE, value)} - /> - </PanelSectionRow> - - <PanelSectionRow> - <ToggleField - label={t('CONFIG_MANGOHUD_WORKAROUND', 'MangoHud Workaround')} - description={t('CONFIG_MANGOHUD_WORKAROUND_DESC', 'Enables a transparent mangohud overlay, sometimes fixes issues with 2X multiplier in game mode')} - checked={config.mangohud_workaround} - onChange={(value) => onConfigChange(MANGOHUD_WORKAROUND, value)} - /> - </PanelSectionRow> - - <PanelSectionRow> - <ToggleField - label={t('CONFIG_DISABLE_VKBASALT', 'Disable vkBasalt')} - description={t('CONFIG_DISABLE_VKBASALT_DESC', 'Disables vkBasalt layer which can conflict with LSFG (Reshade, some Decky plugins)')} - checked={config.disable_vkbasalt} - disabled={config.force_enable_vkbasalt} - onChange={(value) => { - if (value && config.force_enable_vkbasalt) { - // Turn off force enable when enabling disable - onConfigChange(FORCE_ENABLE_VKBASALT, false); - } - onConfigChange(DISABLE_VKBASALT, value); - }} - /> - </PanelSectionRow> - - <PanelSectionRow> - <ToggleField - label={t('CONFIG_FORCE_ENABLE_VKBASALT', 'Force Enable vkBasalt')} - description={t('CONFIG_FORCE_ENABLE_VKBASALT_DESC', 'Force vkBasalt to engage to fix framepacing issues in gamemode')} - checked={config.force_enable_vkbasalt} - disabled={config.disable_vkbasalt} - onChange={(value) => { - if (value && config.disable_vkbasalt) { - // Turn off disable when enabling force enable - onConfigChange(DISABLE_VKBASALT, false); - } - onConfigChange(FORCE_ENABLE_VKBASALT, value); - }} - /> - </PanelSectionRow> - - <PanelSectionRow> - <ToggleField - label={t('CONFIG_ENABLE_ZINK', 'Enable Zink for OpenGL Games')} - description={t('CONFIG_ENABLE_ZINK_DESC', 'Use Vulkan-based OpenGL implementation for OpenGL games (may cause crashes or freezes with some games)')} - checked={config.enable_zink} - onChange={(value) => onConfigChange(ENABLE_ZINK, value)} - /> - </PanelSectionRow> - </> - )} - </> - ); +export function ConfigurationSection({ config, onConfigChange }: ConfigurationSectionProps) { + return <> + <PanelSectionRow> + <SliderField label={`Flow Scale (${Math.round(config.flow_scale * 100)}%)`} value={config.flow_scale} min={0.25} max={1} step={0.01} onChange={(value) => onConfigChange(FLOW_SCALE, value)} /> + </PanelSectionRow> + <PanelSectionRow> + <ToggleField label="Performance Mode" checked={config.performance_mode} onChange={(value) => onConfigChange(PERFORMANCE_MODE, value)} /> + </PanelSectionRow> + <PanelSectionRow> + <ToggleField label="Present Mode Override" checked={config.override_present_mode} onChange={(value) => onConfigChange(OVERRIDE_PRESENT_MODE, value)} /> + </PanelSectionRow> + <PanelSectionRow> + <ToggleField label="Preserve Swapchain Image Count" checked={config.preserve_swapchain_image_count} onChange={(value) => onConfigChange(PRESERVE_SWAPCHAIN_IMAGE_COUNT, value)} /> + </PanelSectionRow> + </>; } diff --git a/src/components/ConfigurationTab.tsx b/src/components/ConfigurationTab.tsx new file mode 100644 index 0000000..37555e0 --- /dev/null +++ b/src/components/ConfigurationTab.tsx @@ -0,0 +1,204 @@ +import { ButtonItem, ConfirmModal, DialogButton, Field, Focusable, PanelSection, PanelSectionRow, gamepadDialogClasses, showModal } from "@decky/ui"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { FaArrowLeft } from "react-icons/fa"; +import { ConfigurationData } from "../config/configSchema"; +import type { GameTarget, KnownGameSource } from "../utils/gameTargets"; +import { sourceLabel } from "../utils/gameTargets"; +import { GameConfigurationControls } from "./GameConfigurationControls"; +import { GameConfigurationSelector } from "./GameConfigurationSelector"; +import { ProfileDetails } from "./ProfileDetails"; + +interface ConfigurationTabProps { + title: string; + source: KnownGameSource; + config: ConfigurationData; + targets: GameTarget[]; + runningGame: GameTarget | null; + onSelect: (appid: string) => void; + onConfigChange: ( + fieldName: keyof ConfigurationData, + value: boolean | number | string | string[], + cleanupLaunchOptions?: boolean, + ) => Promise<void>; + onEnable: (appid: string) => Promise<boolean>; + onEnableAll: (source: KnownGameSource) => Promise<void>; + bulkOperationBusy: boolean; + onRepair: (appid: string) => Promise<boolean>; + onReset: () => Promise<void>; + onResetAll: (source: KnownGameSource) => Promise<void>; +} + +export function ConfigurationTab({ + title, + source, + config, + targets, + runningGame, + onSelect, + onConfigChange, + onEnable, + onEnableAll, + bulkOperationBusy, + onRepair, + onReset, + onResetAll, +}: ConfigurationTabProps) { + 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 closeDetails = useCallback(() => { + setFocusFpsMultiplier(false); + setFocusDetailAction(null); + setDetailAppId(null); + }, []); + const clearFpsFocusRequest = useCallback(() => setFocusFpsMultiplier(false), []); + const clearConfiguredToggleFocusRequest = useCallback(() => setFocusConfiguredToggle(false), []); + + useEffect(() => { + if (!focusDetailAction) return; + if (focusDetailAction === "fps") { + setFocusFpsMultiplier(true); + setFocusDetailAction(null); + return; + } + const frame = requestAnimationFrame(() => { + enableRef.current?.querySelector<HTMLElement>('[role="button"]')?.focus(); + setFocusDetailAction(null); + }); + return () => cancelAnimationFrame(frame); + }, [focusDetailAction]); + + const selectedTarget = detailAppId ? targets.find((target) => target.appid === detailAppId) : null; + + if (detailAppId === null) { + return ( + <> + <PanelSection title={title}> + <GameConfigurationSelector + targets={targets} + runningGame={runningGame} + source={source} + bulkOperationBusy={bulkOperationBusy} + 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> + </> + ); + } + + const profileLabel = selectedTarget?.name || "Game profile"; + const profileTransport = selectedTarget ? sourceLabel(selectedTarget.source) : sourceLabel(source); + const profileDescription = selectedTarget + ? `${profileTransport} · App ID ${selectedTarget.appid} · ${selectedTarget.configured ? "LSFG-VK Enabled" : "LSFG-VK not enabled"}${selectedTarget.source === "unknown" ? " · Bulk actions exclude this profile" : ""}` + : "Game is no longer available"; + const enableProfile = async (appid: string, quitRunningGame = false) => { + if (!(await onEnable(appid))) return; + if (quitRunningGame) SteamClient.Apps.TerminateApp(appid, false); + setFocusFpsMultiplier(true); + }; + const handleProfileAction = async () => { + if (selectedTarget?.configured) { + await onReset(); + setFocusConfiguredToggle(true); + closeDetails(); + } else if (detailAppId) { + const isRunningUnconfigured = runningGame?.appid === detailAppId + && runningGame.source === "steam" + && selectedTarget?.source === "steam" + && !runningGame.configured; + if (isRunningUnconfigured) { + showModal( + <ConfirmModal + strTitle="Game is running" + strDescription="Quit the game now so LSFG-VK is used on its next launch?" + strOKButtonText="Quit and enable" + strCancelButtonText="Enable without quitting" + onOK={() => void enableProfile(detailAppId, true)} + onCancel={() => void enableProfile(detailAppId)} + />, + ); + } else { + await enableProfile(detailAppId); + } + } + }; + + return ( + <Focusable onCancelButton={closeDetails}> + <PanelSection> + <PanelSectionRow> + <div style={{ display: "flex", alignItems: "center", width: "100%" }}> + <Focusable noFocusRing style={{ flex: "none" }}> + <DialogButton + aria-label={`Back to ${title}`} + onClick={closeDetails} + style={{ + width: "48px", + minWidth: "48px", + height: "24px", + minHeight: "24px", + padding: 0, + display: "flex", + alignItems: "center", + justifyContent: "center", + }} + > + <FaArrowLeft /> + </DialogButton> + </Focusable> + <div + className={gamepadDialogClasses.FieldLabel} + style={{ flex: 1, minWidth: 0, marginLeft: "8px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} + > + {profileLabel} + </div> + </div> + </PanelSectionRow> + </PanelSection> + <PanelSection> + {!selectedTarget?.configured && selectedTarget && ( + <PanelSectionRow> + {selectedTarget.source === "unknown" ? ( + <Field + label="Target source unavailable" + description="Refresh Steam and try again before enabling this target." + /> + ) : ( + <Focusable ref={enableRef} noFocusRing> + <ButtonItem layout="below" onClick={handleProfileAction}>Enable for next launch</ButtonItem> + </Focusable> + )} + </PanelSectionRow> + )} + </PanelSection> + {selectedTarget?.configured && ( + <GameConfigurationControls + config={config} + onConfigChange={(field, value) => onConfigChange(field, value, selectedTarget?.source !== "unknown")} + autoFocusFpsMultiplier={focusFpsMultiplier} + onFpsMultiplierFocused={clearFpsFocusRequest} + showWorkarounds={selectedTarget.source !== "unknown"} + workaroundTarget={selectedTarget.source !== "unknown" ? selectedTarget : undefined} + onRepairWorkaround={selectedTarget.source !== "unknown" ? () => onRepair(selectedTarget.appid) : undefined} + /> + )} + {selectedTarget?.configured && ( + <PanelSectionRow> + <ButtonItem layout="below" onClick={handleProfileAction}>Remove profile</ButtonItem> + </PanelSectionRow> + )} + <ProfileDetails description={profileDescription} /> + </Focusable> + ); +} diff --git a/src/components/Content.tsx b/src/components/Content.tsx index 5988e79..470db95 100644 --- a/src/components/Content.tsx +++ b/src/components/Content.tsx @@ -1,187 +1,279 @@ -import { useEffect } from "react"; -import { PanelSection, showModal, ButtonItem, PanelSectionRow } from "@decky/ui"; -import { useInstallationStatus, useDllDetection, useLsfgConfig } from "../hooks/useLsfgHooks"; -import { useProfileManagement } from "../hooks/useProfileManagement"; -import { useInstallationActions } from "../hooks/useInstallationActions"; -import { StatusDisplay } from "./StatusDisplay"; -import { InstallationButton } from "./InstallationButton"; -import { ConfigurationSection } from "./ConfigurationSection"; -import { ProfileManagement } from "./ProfileManagement"; -import { UsageInstructions } from "./UsageInstructions"; -import { SmartClipboardButton } from "./SmartClipboardButton"; -import { FgmodClipboardButton } from "./FgmodClipboardButton"; -import { FpsMultiplierControl } from "./FpsMultiplierControl"; -import { NerdStuffModal } from "./NerdStuffModal"; -import { FlatpaksModal } from "./FlatpaksModal"; +import { Tabs } from "@decky/ui"; +import { useEffect, useRef, useState, type FocusEvent, type ReactNode } from "react"; +import { FaCube, FaExternalLinkAlt, FaFileAlt, FaGamepad, FaSteam, FaTools } from "react-icons/fa"; import { ConfigurationData } from "../config/configSchema"; -import t from '../i18n/i18n'; +import { useFlatpakConfiguration } from "../hooks/useFlatpakConfiguration"; +import { useGameConfiguration } from "../hooks/useGameConfiguration"; +import { useInstallation } from "../hooks/useLsfgHooks"; +import { tabStyles } from "../styles"; +import { targetsForSource } from "../utils/gameTargets"; +import { resolveNowPlayingTarget, type NowPlayingTarget } from "../utils/nowPlaying"; +import { ConfigFileTab } from "./ConfigFileTab"; +import { ConfigurationTab } from "./ConfigurationTab"; +import { FlatpakNowPlayingTab } from "./FlatpakNowPlayingTab"; +import { FlatpakTab } from "./FlatpakTab"; +import { NowPlayingTab } from "./NowPlayingTab"; +import { SettingsTab } from "./SettingsTab"; -export function Content() { - const { - isInstalled, - installationStatus, - setIsInstalled, - setInstallationStatus - } = useInstallationStatus(); +const tabIcons = { + nowPlaying: <FaGamepad size={18} />, + steam: <FaSteam size={18} />, + nonSteam: <FaExternalLinkAlt size={18} />, + flatpak: <FaCube size={18} />, + configFile: <FaFileAlt size={18} />, + settings: <FaTools size={18} />, +}; + +const DEBUG_TAB_VISIBILITY_KEY = "lsfg-debug-tab-visible-v1"; +type GameTabId = "Steam" | "NonSteam" | "Flatpak"; + +function tabForNowPlaying(target: NowPlayingTarget | null): GameTabId { + if (!target) return "Steam"; + if (target.kind === "flatpak") return target.launcher?.source === "nonSteam" ? "NonSteam" : "Flatpak"; + return target.game.source === "nonSteam" ? "NonSteam" : "Steam"; +} + +function usePersistentBoolean(key: string, defaultValue: boolean) { + const [value, setValue] = useState(() => { + try { + const stored = localStorage.getItem(key); + return stored === null ? defaultValue : stored === "true"; + } catch { + return defaultValue; + } + }); + + useEffect(() => { + try { + localStorage.setItem(key, String(value)); + } catch {} + }, [key, value]); - const { dllDetected, dllDetectionStatus } = useDllDetection(); + return [value, setValue] as const; +} +export function Content() { const { config, - loadLsfgConfig, - updateField - } = useLsfgConfig(); - + runningConfig, + globalConfig, + targets, + runningGame, + setSelectedAppId, + save, + saveFor, + updateGlobal, + enable, + enableAll, + bulkOperationBusy, + repair, + resetSelected, + resetAll, + cleanupAllWorkarounds, + reload, + } = useGameConfiguration(); const { - currentProfile, - updateProfileConfig, - loadProfiles - } = useProfileManagement(); + isInstalled, + installationStatus, + losslessScalingInstalled, + losslessScalingStatus, + steamBranchStatus, + isInstalling, + isUninstalling, + install, + uninstall, + } = useInstallation(reload, cleanupAllWorkarounds); + const setupComplete = + isInstalled && + losslessScalingInstalled && + steamBranchStatus?.success === true && + steamBranchStatus.installed && + !steamBranchStatus.needs_switch; + const flatpak = useFlatpakConfiguration(setupComplete); + const [tab, setTab] = useState("Settings"); + const [showDebugTab, setShowDebugTab] = usePersistentBoolean(DEBUG_TAB_VISIBILITY_KEY, false); + const [contentFocused, setContentFocused] = useState(false); + const previousRunningWorkload = useRef<string | null>(null); + const previousNowPlayingTab = useRef<GameTabId>("Steam"); + const runningFlatpak = flatpak.runningApp; + const nowPlayingTarget = resolveNowPlayingTarget(runningGame, runningFlatpak); + const hasNowPlaying = Boolean(nowPlayingTarget); + const runningWorkload = nowPlayingTarget + ? nowPlayingTarget.kind === "flatpak" + ? `flatpak:${nowPlayingTarget.app.app_id}:${nowPlayingTarget.launcher?.appid ?? ""}` + : `${nowPlayingTarget.game.source}:${nowPlayingTarget.game.appid}` + : null; + const steamTargets = targetsForSource(targets, "steam"); + const nonSteamTargets = targetsForSource(targets, "nonSteam"); - const { isInstalling, isUninstalling, handleInstall, handleUninstall } = useInstallationActions(); + useEffect(() => { + if (!setupComplete) { + setTab("Settings"); + return; + } + setTab((current) => current === "Settings" ? (hasNowPlaying ? "NowPlaying" : "Steam") : current); + }, [hasNowPlaying, setupComplete]); useEffect(() => { - if (isInstalled) { - loadLsfgConfig(); + if (!setupComplete) return; + const previous = previousRunningWorkload.current; + previousRunningWorkload.current = runningWorkload; + if (runningWorkload && runningWorkload !== previous) { + previousNowPlayingTab.current = tabForNowPlaying(nowPlayingTarget); + setTab("NowPlaying"); } - }, [isInstalled, loadLsfgConfig]); - - const handleConfigChange = async (fieldName: keyof ConfigurationData, value: boolean | number | string) => { - if (currentProfile) { - const newConfig = { ...config, [fieldName]: value }; - const result = await updateProfileConfig(currentProfile, newConfig); - if (result.success) { - await loadLsfgConfig(); - } - } else { - await updateField(fieldName, value); + else if (!runningWorkload && previous) { + setTab((current) => current === "NowPlaying" ? previousNowPlayingTab.current : current); } - }; + }, [runningWorkload, setupComplete]); - const onInstall = () => { - handleInstall(setIsInstalled, setInstallationStatus, loadLsfgConfig); - }; + useEffect(() => { + if (isInstalled) { + void reload(); + void flatpak.reload(); + } + }, [isInstalled, reload, flatpak.reload]); - const onUninstall = () => { - handleUninstall(setIsInstalled, setInstallationStatus); - }; + useEffect(() => { + if (!showDebugTab && tab === "ConfigFile") setTab(setupComplete ? "Steam" : "Settings"); + }, [setupComplete, showDebugTab, tab]); - const handleShowNerdStuff = () => { - showModal(<NerdStuffModal />); + const handleConfigChange = async ( + fieldName: keyof ConfigurationData, + value: boolean | number | string | string[], + cleanupLaunchOptions = false, + ) => { + await save({ ...config, [fieldName]: value }, cleanupLaunchOptions); }; - const handleShowFlatpaks = () => { - showModal(<FlatpaksModal />); + const settings = ( + <SettingsTab + isInstalled={isInstalled} + installationStatus={installationStatus} + losslessScalingInstalled={losslessScalingInstalled} + losslessScalingStatus={losslessScalingStatus} + steamBranchStatus={steamBranchStatus} + isInstalling={isInstalling} + isUninstalling={isUninstalling} + globalConfig={globalConfig} + showDebugTab={showDebugTab} + onGlobalConfigChange={updateGlobal} + onShowDebugTabChange={setShowDebugTab} + onInstall={() => void install()} + onUninstall={() => void uninstall()} + /> + ); + + const tabContent = (content: ReactNode) => ( + <div className="lsfg-vk-tab-content">{content}</div> + ); + + const nowPlaying = nowPlayingTarget?.kind === "flatpak" ? ( + <FlatpakNowPlayingTab + app={nowPlayingTarget.app} + launcher={nowPlayingTarget.launcher} + onConfigChange={flatpak.updateConfig} + /> + ) : nowPlayingTarget ? ( + <NowPlayingTab + game={nowPlayingTarget.game} + config={runningConfig} + onConfigChange={async (field, value) => { + await saveFor(nowPlayingTarget.game.appid, { ...runningConfig, [field]: value }, true); + }} + /> + ) : null; + + const tabs = setupComplete + ? [ + ...(nowPlaying ? [{ id: "NowPlaying", title: tabIcons.nowPlaying, content: tabContent(nowPlaying) }] : []), + { + id: "Steam", + title: tabIcons.steam, + content: tabContent( + <ConfigurationTab + title="Steam games" + source="steam" + config={config} + targets={steamTargets} + runningGame={runningGame} + onSelect={setSelectedAppId} + onConfigChange={handleConfigChange} + onEnable={enable} + onEnableAll={enableAll} + bulkOperationBusy={bulkOperationBusy} + onRepair={repair} + onReset={resetSelected} + onResetAll={resetAll} + />, + ), + }, + { + id: "NonSteam", + title: tabIcons.nonSteam, + content: tabContent( + <ConfigurationTab + title="Non-Steam games" + source="nonSteam" + config={config} + targets={nonSteamTargets} + runningGame={runningGame} + onSelect={setSelectedAppId} + onConfigChange={handleConfigChange} + onEnable={enable} + onEnableAll={enableAll} + bulkOperationBusy={bulkOperationBusy} + onRepair={repair} + onReset={resetSelected} + onResetAll={resetAll} + /> + ), + }, + { + id: "Flatpak", + title: tabIcons.flatpak, + content: tabContent( + <FlatpakTab + apps={flatpak.apps} + runningApp={runningFlatpak} + loading={flatpak.loading} + busyAppId={flatpak.busyAppId} + onRefresh={flatpak.reload} + onEnable={flatpak.enableApp} + onEnableAll={flatpak.enableAll} + onRemove={flatpak.removeApp} + onRemoveAll={flatpak.removeAll} + onConfigChange={flatpak.updateConfig} + onWorkaroundChange={flatpak.updateWorkarounds} + />, + ), + }, + ...(showDebugTab ? [{ id: "ConfigFile", title: tabIcons.configFile, content: tabContent(<ConfigFileTab />) }] : []), + { id: "Settings", title: tabIcons.settings, content: tabContent(settings) }, + ] + : [{ id: "Settings", title: tabIcons.settings, content: tabContent(settings) }]; + + const availableTabIds = new Set(tabs.map(({ id }) => id)); + const activeTab = availableTabIds.has(tab) ? tab : setupComplete ? "Steam" : "Settings"; + const handleFocusCapture = (event: FocusEvent<HTMLDivElement>) => { + const focusedElement = event.target as HTMLElement | null; + setContentFocused(!focusedElement?.closest?.('[role="tab"]')); }; return ( - <PanelSection> - {!isInstalled && ( - <> - <InstallationButton - isInstalled={isInstalled} - isInstalling={isInstalling} - isUninstalling={isUninstalling} - onInstall={onInstall} - onUninstall={onUninstall} - /> - - <StatusDisplay - dllDetected={dllDetected} - dllDetectionStatus={dllDetectionStatus} - isInstalled={isInstalled} - installationStatus={installationStatus} - /> - </> - )} - - {isInstalled && ( - <> - <PanelSectionRow> - <div - style={{ - fontSize: "14px", - fontWeight: "bold", - marginTop: "8px", - marginBottom: "6px", - borderBottom: "1px solid rgba(255, 255, 255, 0.2)", - paddingBottom: "3px", - color: "white" - }} - > - {t('CONTENT_FPS_MULTIPLIER', 'FPS Multiplier')} - </div> - </PanelSectionRow> - - <FpsMultiplierControl - config={config} - onConfigChange={handleConfigChange} - /> - </> - )} - - {isInstalled && ( - <ProfileManagement - currentProfile={currentProfile} - onProfileChange={async () => { - await loadProfiles(); - await loadLsfgConfig(); - }} - /> - )} - - {isInstalled && ( - <ConfigurationSection - config={config} - onConfigChange={handleConfigChange} - /> - )} - - {isInstalled && ( - <> - <SmartClipboardButton /> - <FgmodClipboardButton /> - </> - )} - - <UsageInstructions /> - - <PanelSectionRow> - <ButtonItem - layout="below" - onClick={handleShowNerdStuff} - > - {t('CONTENT_NERD_STUFF', 'Nerd Stuff')} - </ButtonItem> - </PanelSectionRow> - - <PanelSectionRow> - <ButtonItem - layout="below" - onClick={handleShowFlatpaks} - > - {t('CONTENT_FLATPAK_SETUP', 'Flatpak Setup')} - </ButtonItem> - </PanelSectionRow> - - {isInstalled && ( - <> - <StatusDisplay - dllDetected={dllDetected} - dllDetectionStatus={dllDetectionStatus} - isInstalled={isInstalled} - installationStatus={installationStatus} - /> - - <InstallationButton - isInstalled={isInstalled} - isInstalling={isInstalling} - isUninstalling={isUninstalling} - onInstall={onInstall} - onUninstall={onUninstall} - /> - </> - )} - </PanelSection> + <div + className={`lsfg-vk-tabs${contentFocused ? " lsfg-vk-tabs--content-focused" : ""}`} + style={{ height: "95%", width: "300px", position: "fixed", marginTop: "-12px", overflow: "hidden" }} + onFocusCapture={handleFocusCapture} + > + <style>{tabStyles}</style> + <Tabs + activeTab={activeTab} + onShowTab={(nextTab: string) => { + if (availableTabIds.has(nextTab)) setTab(nextTab); + }} + tabs={tabs} + /> + </div> ); } diff --git a/src/components/FgmodClipboardButton.tsx b/src/components/FgmodClipboardButton.tsx deleted file mode 100644 index efc5490..0000000 --- a/src/components/FgmodClipboardButton.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { useState, useEffect } from "react"; -import { PanelSectionRow, ButtonItem } from "@decky/ui"; -import { FaClipboard, FaCheck } from "react-icons/fa"; -import { checkFgmodDirectory } from "../api/lsfgApi"; -import { showClipboardErrorToast } from "../utils/toastUtils"; -import { copyWithVerification } from "../utils/clipboardUtils"; -import t from '../i18n/i18n'; - -export function FgmodClipboardButton() { - const [isLoading, setIsLoading] = useState(false); - const [showSuccess, setShowSuccess] = useState(false); - const [fgmodExists, setFgmodExists] = useState(false); - const [checkingFgmod, setCheckingFgmod] = useState(true); - - // Check for fgmod directory on component mount - useEffect(() => { - const checkFgmod = async () => { - try { - const result = await checkFgmodDirectory(); - setFgmodExists(result.exists); - } catch (error) { - console.error("Error checking fgmod directory:", error); - setFgmodExists(false); - } finally { - setCheckingFgmod(false); - } - }; - - checkFgmod(); - }, []); - - // Reset success state after 3 seconds - useEffect(() => { - if (showSuccess) { - const timer = setTimeout(() => { - setShowSuccess(false); - }, 3000); - return () => clearTimeout(timer); - } - return undefined; - }, [showSuccess]); - - const copyToClipboard = async () => { - if (isLoading || showSuccess) return; - - setIsLoading(true); - try { - const text = "~/fgmod/fgmod ~/lsfg %command%"; - const { success, verified } = await copyWithVerification(text); - - if (success) { - // Show success feedback in the button instead of toast - setShowSuccess(true); - if (!verified) { - // Copy worked but verification failed - still show success - console.log('Copy verification failed but copy likely worked'); - } - } else { - showClipboardErrorToast(); - } - } catch (error) { - showClipboardErrorToast(); - } finally { - setIsLoading(false); - } - }; - - // Don't render if fgmod directory doesn't exist or we're still checking - if (checkingFgmod || !fgmodExists) { - return null; - } - - return ( - <PanelSectionRow> - <ButtonItem - layout="below" - onClick={copyToClipboard} - disabled={isLoading || showSuccess} - > - <div style={{ display: "flex", alignItems: "center", gap: "8px" }}> - {showSuccess ? ( - <FaCheck style={{ - color: "#4CAF50" // Green color for success - }} /> - ) : isLoading ? ( - <FaClipboard style={{ - animation: "pulse 1s ease-in-out infinite", - opacity: 0.7 - }} /> - ) : ( - <FaClipboard /> - )} - <div style={{ - color: showSuccess ? "#4CAF50" : "inherit", - fontWeight: showSuccess ? "bold" : "normal" - }}> - {showSuccess ? t('CLIPBOARD_COPIED', 'Copied to clipboard') : isLoading ? t('CLIPBOARD_COPYING', 'Copying...') : t('CLIPBOARD_LSFG_FGMOD', 'LSFG + DeckyFG')} - </div> - </div> - </ButtonItem> - <style>{` - @keyframes pulse { - 0% { opacity: 0.7; } - 50% { opacity: 1; } - 100% { opacity: 0.7; } - } - `}</style> - </PanelSectionRow> - ); -} diff --git a/src/components/FlatpakNowPlayingTab.tsx b/src/components/FlatpakNowPlayingTab.tsx new file mode 100644 index 0000000..449e4b5 --- /dev/null +++ b/src/components/FlatpakNowPlayingTab.tsx @@ -0,0 +1,38 @@ +import { Focusable } from "@decky/ui"; +import type { FlatpakApp, LsfgConfig } from "../api/lsfgApi"; +import type { GameTarget } from "../utils/gameTargets"; +import { ConfigurationSection } from "./ConfigurationSection"; +import { FpsMultiplierControl } from "./FpsMultiplierControl"; +import { NowPlayingSummary } from "./NowPlayingSummary"; + +interface Props { + app: FlatpakApp; + launcher: GameTarget | null; + onConfigChange: (appId: string, config: LsfgConfig) => Promise<boolean>; +} + +export function FlatpakNowPlayingTab({ app, launcher, onConfigChange }: Props) { + if (!app.config) return null; + const changeConfig = async ( + field: keyof LsfgConfig, + value: boolean | number | string | string[], + ) => { + await onConfigChange(app.app_id, { ...app.config!, [field]: value }); + }; + + return ( + <Focusable> + <NowPlayingSummary + title={launcher?.name || app.app_name} + details={[ + launcher ? (launcher.source === "nonSteam" ? "Steam shortcut" : "Steam") : "Flatpak", + launcher && launcher.name !== app.app_name ? `Running in ${app.app_name}` : null, + launcher ? "Flatpak" : null, + `Controls: ${app.app_name} profile`, + ].filter((detail): detail is string => detail !== null)} + /> + <FpsMultiplierControl config={app.config} onConfigChange={changeConfig} /> + <ConfigurationSection config={app.config} onConfigChange={changeConfig} /> + </Focusable> + ); +} diff --git a/src/components/FlatpakTab.tsx b/src/components/FlatpakTab.tsx new file mode 100644 index 0000000..35721f6 --- /dev/null +++ b/src/components/FlatpakTab.tsx @@ -0,0 +1,247 @@ +import { ButtonItem, ConfirmModal, DialogButton, Field, Focusable, PanelSection, PanelSectionRow, gamepadDialogClasses, showModal } from "@decky/ui"; +import { useCallback, useMemo, useState } from "react"; +import { FaArrowLeft } from "react-icons/fa"; +import type { FlatpakApp, LsfgConfig, WorkaroundState } from "../api/lsfgApi"; +import { CollapsibleItemGroup, collapsibleItemGroupStyles, usePersistentCollapsed } from "./CollapsibleItemGroup"; +import { ConfigurationSection } from "./ConfigurationSection"; +import { FlatpakWorkaroundsSection } from "./FlatpakWorkaroundsSection"; +import { FpsMultiplierControl } from "./FpsMultiplierControl"; +import { ProfileDetails } from "./ProfileDetails"; + +interface Props { + apps: FlatpakApp[]; + runningApp: FlatpakApp | null; + loading: boolean; + busyAppId: string; + onRefresh: () => Promise<void>; + onEnable: (appId: string) => Promise<boolean>; + onEnableAll: () => Promise<void>; + onRemove: (appId: string) => Promise<boolean>; + onRemoveAll: () => Promise<void>; + onConfigChange: (appId: string, config: LsfgConfig) => Promise<boolean>; + onWorkaroundChange: (appId: string, state: WorkaroundState) => Promise<boolean>; +} + +const ENABLED_COLLAPSED_KEY = "lsfg-flatpak-enabled-collapsed-v2"; +const AVAILABLE_COLLAPSED_KEY = "lsfg-flatpak-available-collapsed-v2"; + +export function FlatpakTab({ + apps, + runningApp, + loading, + busyAppId, + onRefresh, + onEnable, + onEnableAll, + onRemove, + onRemoveAll, + onConfigChange, + onWorkaroundChange, +}: Props) { + const [selectedAppId, setSelectedAppId] = useState<string | null>(null); + const selected = useMemo( + () => selectedAppId ? apps.find((app) => app.app_id === selectedAppId) || null : null, + [apps, selectedAppId], + ); + const close = useCallback(() => setSelectedAppId(null), []); + const [enabledCollapsed, toggleEnabled] = usePersistentCollapsed(ENABLED_COLLAPSED_KEY); + const [availableCollapsed, toggleAvailable] = usePersistentCollapsed(AVAILABLE_COLLAPSED_KEY); + + const enabledApps = useMemo( + () => apps.filter((app) => app.enabled).sort((a, b) => a.app_name.localeCompare(b.app_name)), + [apps], + ); + const availableApps = useMemo( + () => apps.filter((app) => !app.enabled).sort((a, b) => a.app_name.localeCompare(b.app_name)), + [apps], + ); + const enableableApps = useMemo( + () => availableApps.filter((app) => !(app.prepared && !app.owned) && !app.error), + [availableApps], + ); + const confirmEnableAll = () => { + showModal( + <ConfirmModal + strTitle="Enable all available Flatpaks?" + strDescription="Create individual LSFG-VK profiles for every Flatpak app this plugin can manage. Apps prepared externally or unavailable will be skipped." + strOKButtonText="Enable all" + strCancelButtonText="Cancel" + onOK={() => void onEnableAll()} + onCancel={() => {}} + />, + ); + }; + const confirmRemoveAll = () => { + showModal( + <ConfirmModal + strTitle="Remove all Flatpak profiles?" + strOKButtonText="Remove all" + strCancelButtonText="Cancel" + onOK={() => void onRemoveAll()} + onCancel={() => {}} + />, + ); + }; + const itemFor = (app: FlatpakApp) => ({ + id: app.app_id, + label: app.app_name, + description: `${app.app_id} · ${app.prepared && !app.owned ? "Prepared externally" : "Available"}`, + }); + + if (!selectedAppId) { + return ( + <PanelSection title="Flatpak"> + <style>{collapsibleItemGroupStyles}</style> + <CollapsibleItemGroup + title="Enabled" + items={enabledApps.map((app) => ({ + id: app.app_id, + label: app.app_name, + description: `${app.app_id}${app.app_id === runningApp?.app_id ? " · Running" : ""}`, + }))} + collapsed={enabledCollapsed} + onToggle={toggleEnabled} + onSelect={setSelectedAppId} + /> + <CollapsibleItemGroup + title="Available" + items={availableApps.map(itemFor)} + collapsed={availableCollapsed} + onToggle={toggleAvailable} + onSelect={setSelectedAppId} + /> + {enableableApps.length > 0 && ( + <PanelSectionRow> + <ButtonItem + layout="below" + disabled={loading || Boolean(busyAppId)} + onClick={confirmEnableAll} + > + Enable all available Flatpaks + </ButtonItem> + </PanelSectionRow> + )} + <PanelSectionRow> + <ButtonItem + layout="below" + disabled={loading || Boolean(busyAppId) || enabledApps.length === 0} + onClick={confirmRemoveAll} + > + Remove all profiles + </ButtonItem> + </PanelSectionRow> + {apps.length === 0 && !loading && ( + <PanelSectionRow> + <Field label="No Flatpak applications found" /> + </PanelSectionRow> + )} + <PanelSectionRow> + <ButtonItem layout="below" disabled={loading || Boolean(busyAppId)} onClick={() => void onRefresh()}> + {loading ? "Refreshing..." : "Refresh Flatpaks"} + </ButtonItem> + </PanelSectionRow> + </PanelSection> + ); + } + + if (!selected) { + return ( + <PanelSection title="Flatpak"> + <PanelSectionRow> + <ButtonItem layout="below" onClick={close}>Back</ButtonItem> + </PanelSectionRow> + <PanelSectionRow> + <Field label="Flatpak application is no longer installed" /> + </PanelSectionRow> + </PanelSection> + ); + } + + const busy = busyAppId === selected.app_id; + const config = selected.config; + const external = selected.prepared && !selected.owned; + const profileDescription = [ + selected.app_id, + selected.runtime_branch ? `runtime ${selected.runtime_branch}` : null, + selected.enabled ? `profile ${selected.profile}` : null, + selected.app_id === runningApp?.app_id ? "Running" : null, + ].filter(Boolean).join(" · "); + + const changeConfig = async ( + field: keyof LsfgConfig, + value: boolean | number | string | string[], + ) => { + if (!config) return; + await onConfigChange(selected.app_id, { ...config, [field]: value }); + }; + + return ( + <Focusable onCancelButton={close}> + <PanelSection> + <PanelSectionRow> + <div style={{ display: "flex", alignItems: "center", width: "100%" }}> + <Focusable noFocusRing style={{ flex: "none" }}> + <DialogButton + aria-label="Back to Flatpaks" + onClick={close} + style={{ + width: "48px", + minWidth: "48px", + height: "24px", + minHeight: "24px", + padding: 0, + display: "flex", + alignItems: "center", + justifyContent: "center", + }} + > + <FaArrowLeft /> + </DialogButton> + </Focusable> + <div + className={gamepadDialogClasses.FieldLabel} + style={{ flex: 1, minWidth: 0, marginLeft: "8px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} + > + {selected.app_name} + </div> + </div> + </PanelSectionRow> + </PanelSection> + {!selected.enabled && ( + <PanelSection> + <PanelSectionRow> + <ButtonItem + layout="below" + disabled={busy || external || Boolean(selected.error)} + onClick={() => void onEnable(selected.app_id)} + > + {busy ? "Enabling..." : external ? "Prepared externally" : "Enable LSFG-VK"} + </ButtonItem> + </PanelSectionRow> + {selected.error && ( + <PanelSectionRow> + <Field label="Unavailable" description={selected.error} /> + </PanelSectionRow> + )} + </PanelSection> + )} + {selected.enabled && config && ( + <> + <FpsMultiplierControl config={config} onConfigChange={changeConfig} /> + <ConfigurationSection config={config} onConfigChange={changeConfig} /> + <FlatpakWorkaroundsSection + state={selected.workarounds} + disabled={busy} + onChange={(state) => onWorkaroundChange(selected.app_id, state)} + /> + <PanelSectionRow> + <ButtonItem layout="below" disabled={busy} onClick={() => void onRemove(selected.app_id)}> + {busy ? "Removing..." : "Remove Flatpak profile"} + </ButtonItem> + </PanelSectionRow> + </> + )} + <ProfileDetails description={profileDescription} /> + </Focusable> + ); +} diff --git a/src/components/FlatpakWorkaroundsSection.tsx b/src/components/FlatpakWorkaroundsSection.tsx new file mode 100644 index 0000000..7d9d880 --- /dev/null +++ b/src/components/FlatpakWorkaroundsSection.tsx @@ -0,0 +1,144 @@ +import { ButtonItem, PanelSectionRow, SliderField, ToggleField } from "@decky/ui"; +import { useEffect, useRef, useState } from "react"; +import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri"; +import type { WorkaroundState } from "../api/lsfgApi"; +import t from "../i18n/i18n"; + +interface Props { + state: WorkaroundState; + disabled?: boolean; + onChange: (state: WorkaroundState) => Promise<boolean>; +} + +const WORKAROUNDS_COLLAPSED_KEY = "lsfg-flatpak-workarounds-collapsed-v1"; + +export function FlatpakWorkaroundsSection({ state, disabled = false, onChange }: Props) { + const [collapsed, setCollapsed] = useState(() => { + try { + return localStorage.getItem(WORKAROUNDS_COLLAPSED_KEY) !== "false"; + } catch { + return true; + } + }); + const [fpsValue, setFpsValue] = useState(state.dxvkFrameRate); + const timer = useRef<number | null>(null); + + useEffect(() => { + try { + localStorage.setItem(WORKAROUNDS_COLLAPSED_KEY, String(collapsed)); + } catch {} + }, [collapsed]); + + useEffect(() => { + setFpsValue(state.dxvkFrameRate); + }, [state.dxvkFrameRate]); + + useEffect(() => () => { + if (timer.current !== null) window.clearTimeout(timer.current); + }, []); + + const update = (field: keyof WorkaroundState, value: boolean | number) => { + void onChange({ ...state, [field]: value }); + }; + + const updateFps = (value: number) => { + setFpsValue(value); + if (timer.current !== null) window.clearTimeout(timer.current); + timer.current = window.setTimeout(() => { + timer.current = null; + void onChange({ ...state, dxvkFrameRate: value }); + }, 250); + }; + + const fpsLabel = fpsValue > 0 ? `${fpsValue} FPS` : t("CONFIG_BASE_FPS_CAP_OFF", "Off"); + + return ( + <> + <PanelSectionRow> + <div + style={{ + fontSize: "14px", + fontWeight: "bold", + marginTop: "8px", + marginBottom: "6px", + borderBottom: "1px solid rgba(255, 255, 255, 0.2)", + paddingBottom: "3px", + color: "white", + }} + > + {t("CONFIG_WORKAROUNDS_TITLE", "Workarounds")} + </div> + </PanelSectionRow> + <PanelSectionRow> + <ButtonItem + layout="below" + bottomSeparator={collapsed ? "standard" : "none"} + onClick={() => setCollapsed((value) => !value)} + > + {collapsed ? <RiArrowDownSFill /> : <RiArrowUpSFill />} + </ButtonItem> + </PanelSectionRow> + {!collapsed && ( + <> + <PanelSectionRow> + <SliderField + label={`${t("CONFIG_BASE_FPS_CAP", "Base FPS Cap")} (${fpsLabel})`} + description={t("CONFIG_BASE_FPS_CAP_DESC", "Base cap for DXVK-backed games before frame generation; 0 disables. Requires app restart to apply.")} + value={fpsValue} + min={0} + max={60} + step={1} + onChange={updateFps} + disabled={disabled} + /> + </PanelSectionRow> + <PanelSectionRow> + <ToggleField + label={t("CONFIG_DISABLE_STEAMDECK_MODE", "Disable Steam Deck Mode")} + description={t("CONFIG_DISABLE_STEAMDECK_MODE_DESC", "Disables a game-specific Steam Deck compatibility switch. Requires app restart to apply.")} + checked={state.disableSteamdeckMode} + onChange={(value) => update("disableSteamdeckMode", value)} + disabled={disabled} + /> + </PanelSectionRow> + <PanelSectionRow> + <ToggleField + label={t("CONFIG_DISABLE_GAMESCOPE_WSI", "Disable Gamescope WSI")} + description={t("CONFIG_DISABLE_GAMESCOPE_WSI_DESC", "Adds ENABLE_GAMESCOPE_WSI=0. Requires app restart to apply.")} + checked={state.disableGamescopeWsi} + onChange={(value) => update("disableGamescopeWsi", value)} + disabled={disabled} + /> + </PanelSectionRow> + <PanelSectionRow> + <ToggleField + label={t("CONFIG_DISABLE_HDR", "Disable HDR")} + description={t("CONFIG_DISABLE_HDR_DESC", "Prevents DXVK from exposing HDR to the app. Requires app restart to apply.")} + checked={state.disableHdr} + onChange={(value) => update("disableHdr", value)} + disabled={disabled} + /> + </PanelSectionRow> + <PanelSectionRow> + <ToggleField + label={t("CONFIG_DISABLE_VKBASALT", "Disable vkBasalt")} + description={t("CONFIG_DISABLE_VKBASALT_DESC", "Disables vkBasalt which can conflict with LSFG-VK.")} + checked={state.disableVkbasalt} + onChange={(value) => update("disableVkbasalt", value)} + disabled={disabled} + /> + </PanelSectionRow> + <PanelSectionRow> + <ToggleField + label={t("CONFIG_ENABLE_ZINK", "Force Zink for OpenGL Games")} + description={t("CONFIG_ENABLE_ZINK_DESC", "Uses Mesa's Zink OpenGL-to-Vulkan driver. May cause crashes or freezes. Requires app restart to apply.")} + checked={state.enableZink} + onChange={(value) => update("enableZink", value)} + disabled={disabled} + /> + </PanelSectionRow> + </> + )} + </> + ); +} diff --git a/src/components/FlatpaksModal.tsx b/src/components/FlatpaksModal.tsx deleted file mode 100644 index 16d0369..0000000 --- a/src/components/FlatpaksModal.tsx +++ /dev/null @@ -1,433 +0,0 @@ -import { FC, useState, useEffect, CSSProperties } from 'react'; -import { - ModalRoot, - DialogBody, - DialogHeader, - DialogControlsSection, - DialogControlsSectionHeader, - ButtonItem, - PanelSectionRow, - Field, - Toggle, - Spinner, - Focusable, - showModal, - ConfirmModal -} from '@decky/ui'; -import { FaCheck, FaTimes, FaDownload, FaTrash, FaCog } from 'react-icons/fa'; -import flatpakTargetImage from '../../assets/flatpak-target.png'; -import { - checkFlatpakExtensionStatus, - installFlatpakExtension, - uninstallFlatpakExtension, - getFlatpakApps, - setFlatpakAppOverride, - removeFlatpakAppOverride, - FlatpakExtensionStatus, - FlatpakApp, - FlatpakAppInfo -} from '../api/lsfgApi'; -import t from '../i18n/i18n'; - -interface FlatpaksModalProps { - closeModal?: () => void; -} - -export const FlatpaksModal: FC<FlatpaksModalProps> = ({ closeModal }) => { - const [extensionStatus, setExtensionStatus] = useState<FlatpakExtensionStatus | null>(null); - const [flatpakApps, setFlatpakApps] = useState<FlatpakAppInfo | null>(null); - const [loading, setLoading] = useState(true); - const [operationInProgress, setOperationInProgress] = useState<string | null>(null); - - const loadData = async () => { - setLoading(true); - try { - const [statusResult, appsResult] = await Promise.all([ - checkFlatpakExtensionStatus(), - getFlatpakApps() - ]); - - setExtensionStatus(statusResult); - setFlatpakApps(appsResult); - } catch (error) { - console.error('Error loading Flatpak data:', error); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - loadData(); - }, []); - - const handleExtensionOperation = async (operation: 'install' | 'uninstall', version: string) => { - const operationId = `${operation}-${version}`; - setOperationInProgress(operationId); - - try { - const result = operation === 'install' - ? await installFlatpakExtension(version) - : await uninstallFlatpakExtension(version); - - if (result.success) { - // Reload status after operation - const newStatus = await checkFlatpakExtensionStatus(); - setExtensionStatus(newStatus); - } - } catch (error) { - console.error(`Error ${operation}ing extension:`, error); - } finally { - setOperationInProgress(null); - } - }; - - const handleAppOverrideToggle = async (app: FlatpakApp) => { - const hasOverrides = app.has_filesystem_override && app.has_env_override; - const operationId = `app-${app.app_id}`; - setOperationInProgress(operationId); - - try { - const result = hasOverrides - ? await removeFlatpakAppOverride(app.app_id) - : await setFlatpakAppOverride(app.app_id); - - if (result.success) { - // Reload apps data after operation - const newApps = await getFlatpakApps(); - setFlatpakApps(newApps); - } - } catch (error) { - console.error('Error toggling app override:', error); - } finally { - setOperationInProgress(null); - } - }; - - const confirmOperation = (operation: () => void, title: string, description: string) => { - showModal( - <ConfirmModal - strTitle={title} - strDescription={description} - onOK={operation} - onCancel={() => {}} - /> - ); - }; - - if (loading) { - return ( - <ModalRoot closeModal={closeModal}> - <DialogHeader>{t('FLATPAK_MODAL_TITLE', 'Flatpak Extensions')}</DialogHeader> - <DialogBody> - <div style={{ display: 'flex', justifyContent: 'center', padding: '20px' }}> - <Spinner /> - </div> - </DialogBody> - </ModalRoot> - ); - } - - const instructionSteps = [ - { - id: 'try-first', - title: t('FLATPAK_STEP_TRY_FIRST', 'Try first:'), - command: '~/lsfg' - }, - { - id: 'try-full-path', - title: t('FLATPAK_STEP_TRY_FULL_PATH', "If that doesn't work, try full path:"), - command: '/home/(username)/lsfg' - }, - { - id: 'final-result', - title: t('FLATPAK_STEP_FINAL', 'Final result should look like:'), - command: '~/lsfg "usr/bin/flatpak"' - } - ]; - - const focusableInstructionStyle: CSSProperties = { - padding: '10px', - background: 'rgba(0, 0, 0, 0.3)', - borderRadius: '6px', - marginBottom: '12px' - }; - - const commandStyle: CSSProperties = { - fontFamily: 'monospace', - fontSize: '0.85em', - background: 'rgba(0, 0, 0, 0.45)', - padding: '8px', - borderRadius: '4px', - marginTop: '6px' - }; - - return ( - <ModalRoot closeModal={closeModal}> - <DialogHeader>{t('FLATPAK_MODAL_TITLE', 'Flatpak Extensions')}</DialogHeader> - <DialogBody> - <Focusable> - {/* Extension Status Section */} - <DialogControlsSection> - <DialogControlsSectionHeader>{t('FLATPAK_RUNTIME_INSTALLER', 'Runtime Extension Installer')}</DialogControlsSectionHeader> - - {extensionStatus && extensionStatus.success ? ( - <> - {/* 23.08 Runtime */} - <PanelSectionRow> - <Field - label={t('FLATPAK_RUNTIME_23', 'Runtime 23.08')} - description={extensionStatus.installed_23_08 ? t('FLATPAK_INSTALLED', 'Installed') : t('FLATPAK_NOT_INSTALLED', 'Not installed')} - icon={extensionStatus.installed_23_08 ? <FaCheck style={{color: 'green'}} /> : <FaTimes style={{color: 'red'}} />} - > - <ButtonItem - layout="below" - onClick={() => { - const operation = extensionStatus.installed_23_08 ? 'uninstall' : 'install'; - const action = () => handleExtensionOperation(operation, '23.08'); - - if (operation === 'uninstall') { - confirmOperation( - action, - t('FLATPAK_UNINSTALL_TITLE', 'Uninstall Runtime Extension'), - `${t('FLATPAK_UNINSTALL_CONFIRM_PREFIX', 'Are you sure you want to uninstall the')} 23.08 ${t('FLATPAK_UNINSTALL_CONFIRM_SUFFIX', 'runtime extension?')}` - ); - } else { - action(); - } - }} - disabled={operationInProgress === 'install-23.08' || operationInProgress === 'uninstall-23.08'} - > - {operationInProgress === 'install-23.08' || operationInProgress === 'uninstall-23.08' ? ( - <Spinner /> - ) : extensionStatus.installed_23_08 ? ( - <> - <FaTrash /> {t('FLATPAK_UNINSTALL_BTN', 'Uninstall')} - </> - ) : ( - <> - <FaDownload /> {t('FLATPAK_INSTALL_BTN', 'Install')} - </> - )} - </ButtonItem> - </Field> - </PanelSectionRow> - - {/* 24.08 Runtime */} - <PanelSectionRow> - <Field - label={t('FLATPAK_RUNTIME_24', 'Runtime 24.08')} - description={extensionStatus.installed_24_08 ? t('FLATPAK_INSTALLED', 'Installed') : t('FLATPAK_NOT_INSTALLED', 'Not installed')} - icon={extensionStatus.installed_24_08 ? <FaCheck style={{color: 'green'}} /> : <FaTimes style={{color: 'red'}} />} - > - <ButtonItem - layout="below" - onClick={() => { - const operation = extensionStatus.installed_24_08 ? 'uninstall' : 'install'; - const action = () => handleExtensionOperation(operation, '24.08'); - - if (operation === 'uninstall') { - confirmOperation( - action, - t('FLATPAK_UNINSTALL_TITLE', 'Uninstall Runtime Extension'), - `${t('FLATPAK_UNINSTALL_CONFIRM_PREFIX', 'Are you sure you want to uninstall the')} 24.08 ${t('FLATPAK_UNINSTALL_CONFIRM_SUFFIX', 'runtime extension?')}` - ); - } else { - action(); - } - }} - disabled={operationInProgress === 'install-24.08' || operationInProgress === 'uninstall-24.08'} - > - {operationInProgress === 'install-24.08' || operationInProgress === 'uninstall-24.08' ? ( - <Spinner /> - ) : extensionStatus.installed_24_08 ? ( - <> - <FaTrash /> {t('FLATPAK_UNINSTALL_BTN', 'Uninstall')} - </> - ) : ( - <> - <FaDownload /> {t('FLATPAK_INSTALL_BTN', 'Install')} - </> - )} - </ButtonItem> - </Field> - </PanelSectionRow> - - {/* 25.08 Runtime */} - <PanelSectionRow> - <Field - label={t('FLATPAK_RUNTIME_25', 'Runtime 25.08')} - description={extensionStatus.installed_25_08 ? t('FLATPAK_INSTALLED', 'Installed') : t('FLATPAK_NOT_INSTALLED', 'Not installed')} - icon={extensionStatus.installed_25_08 ? <FaCheck style={{color: 'green'}} /> : <FaTimes style={{color: 'red'}} />} - > - <ButtonItem - layout="below" - onClick={() => { - const operation = extensionStatus.installed_25_08 ? 'uninstall' : 'install'; - const action = () => handleExtensionOperation(operation, '25.08'); - - if (operation === 'uninstall') { - confirmOperation( - action, - t('FLATPAK_UNINSTALL_TITLE', 'Uninstall Runtime Extension'), - `${t('FLATPAK_UNINSTALL_CONFIRM_PREFIX', 'Are you sure you want to uninstall the')} 25.08 ${t('FLATPAK_UNINSTALL_CONFIRM_SUFFIX', 'runtime extension?')}` - ); - } else { - action(); - } - }} - disabled={operationInProgress === 'install-25.08' || operationInProgress === 'uninstall-25.08'} - > - {operationInProgress === 'install-25.08' || operationInProgress === 'uninstall-25.08' ? ( - <Spinner /> - ) : extensionStatus.installed_25_08 ? ( - <> - <FaTrash /> {t('FLATPAK_UNINSTALL_BTN', 'Uninstall')} - </> - ) : ( - <> - <FaDownload /> {t('FLATPAK_INSTALL_BTN', 'Install')} - </> - )} - </ButtonItem> - </Field> - </PanelSectionRow> - </> - ) : ( - <PanelSectionRow> - <Field - label={t('FLATPAK_ERROR', 'Error')} - description={extensionStatus?.error || t('FLATPAK_ERROR_STATUS', 'Failed to check extension status')} - icon={<FaTimes style={{color: 'red'}} />} - /> - </PanelSectionRow> - )} - </DialogControlsSection> - - {/* Flatpak Apps Section */} - <DialogControlsSection> - <DialogControlsSectionHeader>{t('FLATPAK_APPS_TITLE', 'Flatpak Applications')}</DialogControlsSectionHeader> - - {flatpakApps && flatpakApps.success ? ( - flatpakApps.apps.length > 0 ? ( - flatpakApps.apps.map((app) => { - const hasOverrides = app.has_filesystem_override && app.has_env_override; - const partialOverrides = app.has_filesystem_override || app.has_env_override; - - let statusColor = 'red'; - let statusText = t('FLATPAK_STATUS_NO_OVERRIDES', 'No overrides'); - - if (hasOverrides) { - statusColor = 'green'; - statusText = t('FLATPAK_STATUS_CONFIGURED', 'Configured'); - } else if (partialOverrides) { - statusColor = 'orange'; - statusText = t('FLATPAK_STATUS_PARTIAL', 'Partial'); - } - - return ( - <PanelSectionRow key={app.app_id}> - <Field - label={app.app_name || app.app_id} - description={`${app.app_id} - ${statusText}`} - icon={<FaCog style={{color: statusColor}} />} - > - <Toggle - value={hasOverrides} - onChange={() => handleAppOverrideToggle(app)} - disabled={operationInProgress === `app-${app.app_id}`} - /> - </Field> - </PanelSectionRow> - ); - }) - ) : ( - <PanelSectionRow> - <Field - label={t('FLATPAK_NO_APPS', 'No Flatpak Apps Found')} - description={t('FLATPAK_NO_APPS_DESC', 'No Flatpak applications are currently installed')} - /> - </PanelSectionRow> - ) - ) : ( - <PanelSectionRow> - <Field - label={t('FLATPAK_ERROR', 'Error')} - description={flatpakApps?.error || t('FLATPAK_ERROR_APPS', 'Failed to load Flatpak applications')} - icon={<FaTimes style={{color: 'red'}} />} - /> - </PanelSectionRow> - )} - </DialogControlsSection> - - {/* Steam Configuration Instructions */} - <DialogControlsSection> - <DialogControlsSectionHeader>{t('FLATPAK_STEAM_CONFIG_TITLE', 'Steam Configuration')}</DialogControlsSectionHeader> - <div - style={{ - padding: '12px', - background: 'rgba(255, 255, 255, 0.1)', - borderRadius: '8px', - margin: '8px 0', - display: 'flex', - flexDirection: 'column' - }} - > - <div style={{ fontWeight: 'bold', marginBottom: '8px', color: '#fff' }}> - {t('FLATPAK_STEAM_CONFIG_HEADER', 'Configure Steam Flatpak Shortcuts')} - </div> - <div style={{ fontSize: '0.9em', lineHeight: '1.4', marginBottom: '8px' }}> - {t('FLATPAK_STEAM_CONFIG_DESC', 'In Steam, open your flatpak game and click the cog wheel.')} - </div> - <div style={{ fontSize: '0.9em', lineHeight: '1.4', marginBottom: '12px', color: '#ffa500' }}> - <strong>IMPORTANT:</strong> {t('FLATPAK_STEAM_CONFIG_IMPORTANT', 'Set this in TARGET (NOT LAUNCH OPTIONS)')} - </div> - - {instructionSteps.map((step) => ( - <Focusable - key={step.id} - focusWithinClassName="gpfocuswithin" - onActivate={() => {}} - style={focusableInstructionStyle} - > - <div style={{ fontWeight: 'bold' }}>{step.title}</div> - <div style={commandStyle}>{step.command}</div> - </Focusable> - ))} - - <Focusable - focusWithinClassName="gpfocuswithin" - onActivate={() => {}} - style={{ marginTop: '4px' }} - > - <div style={{ textAlign: 'center' }}> - <img - src={flatpakTargetImage.replace(/ /g, '%20')} - alt="Steam Properties Target Field Example" - style={{ - maxWidth: '100%', - height: 'auto', - border: '1px solid rgba(255, 255, 255, 0.2)', - borderRadius: '4px' - }} - /> - </div> - </Focusable> - </div> - </DialogControlsSection> - - {/* Close Button */} - <DialogControlsSection> - <PanelSectionRow> - <ButtonItem - layout="below" - onClick={closeModal} - > - {t('FLATPAK_CLOSE', 'Close')} - </ButtonItem> - </PanelSectionRow> - </DialogControlsSection> - </Focusable> - </DialogBody> - </ModalRoot> - ); -}; diff --git a/src/components/FpsMultiplierControl.tsx b/src/components/FpsMultiplierControl.tsx index 206643e..523e8b3 100644 --- a/src/components/FpsMultiplierControl.tsx +++ b/src/components/FpsMultiplierControl.tsx @@ -1,72 +1,62 @@ -import { PanelSectionRow, DialogButton, Focusable } from "@decky/ui"; +import { Focusable, PanelSectionRow, SliderField } from "@decky/ui"; +import { useEffect, useRef } from "react"; import { ConfigurationData } from "../config/configSchema"; import { MULTIPLIER } from "../config/generatedConfigSchema"; -import t from '../i18n/i18n'; +import t from "../i18n/i18n"; interface FpsMultiplierControlProps { config: ConfigurationData; - onConfigChange: (fieldName: keyof ConfigurationData, value: boolean | number | string) => Promise<void>; + onConfigChange: (fieldName: keyof ConfigurationData, value: boolean | number | string | string[]) => Promise<void>; + autoFocus?: boolean; + onAutoFocus?: () => void; } export function FpsMultiplierControl({ config, - onConfigChange + onConfigChange, + autoFocus = false, + onAutoFocus, }: FpsMultiplierControlProps) { + const focusableRef = useRef<HTMLDivElement>(null); + + useEffect(() => { + if (!autoFocus) return; + const frame = requestAnimationFrame(() => { + const target = focusableRef.current?.querySelector<HTMLElement>( + '[role="button"], [role="slider"]', + ); + target?.focus(); + onAutoFocus?.(); + }); + return () => cancelAnimationFrame(frame); + }, [autoFocus, onAutoFocus]); + + const multiplierLabel = config.multiplier === 1 + ? t("MULTIPLIER_OFF", "Off") + : `${config.multiplier}x`; + return ( <PanelSectionRow> - <Focusable - style={{ - marginTop: "6px", - marginBottom: "6px", - display: "flex", - justifyContent: "center", - alignItems: "center" - }} - flow-children="horizontal" - > - <DialogButton - style={{ - marginLeft: "0px", - height: "30px", - display: "flex", - alignItems: "center", - justifyContent: "center", - padding: "5px 0px 0px 0px", - minWidth: "40px", - }} - onClick={() => onConfigChange(MULTIPLIER, Math.max(1, config.multiplier - 1))} - disabled={config.multiplier <= 1} - > - − - </DialogButton> - <div - style={{ - marginLeft: "20px", - marginRight: "20px", - fontSize: "16px", - fontWeight: "bold", - color: config.multiplier > 4 ? "red" : "white", - minWidth: "60px", - textAlign: "center" - }} - > - {config.multiplier < 2 ? t('MULTIPLIER_OFF', 'OFF') : `${config.multiplier}X`} - </div> - <DialogButton - style={{ - marginLeft: "0px", - height: "30px", - display: "flex", - alignItems: "center", - justifyContent: "center", - padding: "5px 0px 0px 0px", - minWidth: "40px", - }} - onClick={() => onConfigChange(MULTIPLIER, Math.min(4, config.multiplier + 1))} - disabled={config.multiplier >= 4} - > - + - </DialogButton> + <Focusable ref={focusableRef} noFocusRing> + <SliderField + label={`FPS multiplier · ${multiplierLabel}`} + value={config.multiplier} + min={1} + max={6} + step={1} + notchCount={6} + notchLabels={[ + { notchIndex: 0, label: "OFF", value: 1 }, + { notchIndex: 1, label: "2X", value: 2 }, + { notchIndex: 2, label: "3X", value: 3 }, + { notchIndex: 3, label: "4X", value: 4 }, + { notchIndex: 4, label: "5X", value: 5 }, + { notchIndex: 5, label: "6X", value: 6 }, + ]} + notchTicksVisible={true} + showValue={false} + onChange={(value) => void onConfigChange(MULTIPLIER, value)} + /> </Focusable> </PanelSectionRow> ); diff --git a/src/components/GameConfigurationControls.tsx b/src/components/GameConfigurationControls.tsx new file mode 100644 index 0000000..7025f78 --- /dev/null +++ b/src/components/GameConfigurationControls.tsx @@ -0,0 +1,44 @@ +import { ConfigurationData } from "../config/configSchema"; +import type { GameTarget } from "../hooks/useGameConfiguration"; +import { ConfigurationSection } from "./ConfigurationSection"; +import { FpsMultiplierControl } from "./FpsMultiplierControl"; +import { WorkaroundsSection } from "./WorkaroundsSection"; + +interface Props { + config: ConfigurationData; + onConfigChange: (fieldName: keyof ConfigurationData, value: boolean | number | string | string[]) => Promise<void>; + autoFocusFpsMultiplier?: boolean; + onFpsMultiplierFocused?: () => void; + showWorkarounds?: boolean; + workaroundTarget?: Pick<GameTarget, "appid" | "nonSteam">; + onRepairWorkaround?: () => Promise<boolean>; +} + +export function GameConfigurationControls({ + config, + onConfigChange, + autoFocusFpsMultiplier, + onFpsMultiplierFocused, + showWorkarounds = false, + workaroundTarget, + onRepairWorkaround, +}: Props) { + return ( + <> + <FpsMultiplierControl + config={config} + onConfigChange={onConfigChange} + autoFocus={autoFocusFpsMultiplier} + onAutoFocus={onFpsMultiplierFocused} + /> + <ConfigurationSection config={config} onConfigChange={onConfigChange} /> + {showWorkarounds && workaroundTarget && ( + <WorkaroundsSection + appId={workaroundTarget.appid} + nonSteam={workaroundTarget.nonSteam} + onRepair={onRepairWorkaround} + /> + )} + </> + ); +} diff --git a/src/components/GameConfigurationSelector.tsx b/src/components/GameConfigurationSelector.tsx new file mode 100644 index 0000000..91dc3df --- /dev/null +++ b/src/components/GameConfigurationSelector.tsx @@ -0,0 +1,140 @@ +import { ButtonItem, ConfirmModal, Field, PanelSectionRow, showModal } from "@decky/ui"; +import { useEffect, useRef } from "react"; +import type { GameTarget, KnownGameSource } from "../utils/gameTargets"; +import { sourceLabel } from "../utils/gameTargets"; +import { CollapsibleItemGroup, collapsibleItemGroupStyles, usePersistentCollapsed } from "./CollapsibleItemGroup"; + +interface Props { + targets: GameTarget[]; + runningGame: GameTarget | null; + source: KnownGameSource; + bulkOperationBusy: boolean; + onSelect: (appid: string) => void; + onEnableAll: (source: KnownGameSource) => Promise<void>; + onResetAll: (source: KnownGameSource) => Promise<void>; + focusConfiguredToggle?: boolean; + onConfiguredToggleFocused?: () => void; +} + +const ENABLED_COLLAPSED_KEY = "lsfg-enabled-games-collapsed-v4"; +const AVAILABLE_COLLAPSED_KEY = "lsfg-available-games-collapsed-v3"; + +function targetDescription(game: GameTarget): string { + if (game.isFlatpakShortcut) return "Non-Steam | Use Flatpak Tab"; + return game.source === "unknown" + ? "Unknown source · excluded from bulk actions" + : sourceLabel(game.source); +} + +export function GameConfigurationSelector({ + targets, + runningGame, + source, + bulkOperationBusy, + 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; + return a.name.localeCompare(b.name); + }); + const enabledGames = sortGames(targets.filter((game) => game.configured)); + const availableGames = sortGames(targets.filter((game) => !game.configured)); + const enableableGames = availableGames.filter((game) => game.source === source && !game.isFlatpakShortcut); + const removableGames = enabledGames.filter((game) => game.source === source && !game.isFlatpakShortcut); + const sourceName = source === "nonSteam" ? "non-Steam shortcuts" : "Steam games"; + const emptyDescription = source === "nonSteam" + ? "Steam has not reported any eligible non-Steam shortcuts" + : "Steam has not reported any eligible installed games"; + const toItem = (game: GameTarget) => ({ + id: game.appid, + label: game.name, + description: targetDescription(game), + disabled: game.isFlatpakShortcut, + }); + const [enabledCollapsed, toggleEnabled] = usePersistentCollapsed(`${ENABLED_COLLAPSED_KEY}-${source}`); + const [availableCollapsed, toggleAvailable] = usePersistentCollapsed(`${AVAILABLE_COLLAPSED_KEY}-${source}`); + const enabledToggleRef = useRef<HTMLDivElement>(null); + + useEffect(() => { + if (!focusConfiguredToggle) return; + const frame = requestAnimationFrame(() => { + enabledToggleRef.current?.querySelector<HTMLElement>('[role="button"], button')?.focus(); + onConfiguredToggleFocused?.(); + }); + return () => cancelAnimationFrame(frame); + }, [enabledGames.length, focusConfiguredToggle, onConfiguredToggleFocused]); + + const confirmResetAll = () => { + showModal( + <ConfirmModal + strTitle={`Remove all ${sourceName} profiles?`} + strOKButtonText="Remove all" + strCancelButtonText="Cancel" + onOK={() => void onResetAll(source)} + onCancel={() => {}} + />, + ); + }; + + const confirmEnableAll = () => { + showModal( + <ConfirmModal + strTitle={`Enable all available ${sourceName}?`} + strDescription={`Create individual LSFG-VK profiles for every available ${sourceName}. Unknown-source profiles are excluded. Flatpak profiles are managed separately in the Flatpak tab.`} + strOKButtonText="Enable all" + strCancelButtonText="Cancel" + onOK={() => void onEnableAll(source)} + onCancel={() => {}} + />, + ); + }; + + return ( + <> + <style> + {collapsibleItemGroupStyles} + </style> + {targets.length === 0 && ( + <PanelSectionRow> + <Field label={`No ${sourceName} found`} description={emptyDescription} /> + </PanelSectionRow> + )} + <CollapsibleItemGroup + title="Enabled" + items={enabledGames.map(toItem)} + collapsed={enabledCollapsed} + onToggle={toggleEnabled} + onSelect={onSelect} + toggleRef={enabledToggleRef} + /> + <CollapsibleItemGroup + title="Available" + items={availableGames.map(toItem)} + collapsed={availableCollapsed} + onToggle={toggleAvailable} + onSelect={onSelect} + /> + {enableableGames.length > 0 && ( + <PanelSectionRow> + <ButtonItem layout="below" onClick={confirmEnableAll} disabled={bulkOperationBusy}> + {`Enable all ${sourceName}`} + </ButtonItem> + </PanelSectionRow> + )} + <PanelSectionRow> + <ButtonItem + layout="below" + onClick={confirmResetAll} + disabled={bulkOperationBusy || removableGames.length === 0} + > + {`Remove all ${sourceLabel(source)} profiles`} + </ButtonItem> + </PanelSectionRow> + </> + ); +} diff --git a/src/components/InstallationButton.tsx b/src/components/InstallationButton.tsx deleted file mode 100644 index 7f0ac96..0000000 --- a/src/components/InstallationButton.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { ButtonItem, PanelSectionRow } from "@decky/ui"; -import { FaDownload, FaTrash } from "react-icons/fa"; -import t from '../i18n/i18n'; - -interface InstallationButtonProps { - isInstalled: boolean; - isInstalling: boolean; - isUninstalling: boolean; - onInstall: () => void; - onUninstall: () => void; -} - -export function InstallationButton({ - isInstalled, - isInstalling, - isUninstalling, - onInstall, - onUninstall -}: InstallationButtonProps) { - const renderButtonContent = () => { - if (isInstalling) { - return ( - <div style={{ display: "flex", alignItems: "center", gap: "8px" }}> - <div>{t('INSTALL_INSTALLING', 'Installing...')}</div> - </div> - ); - } - - if (isUninstalling) { - return ( - <div style={{ display: "flex", alignItems: "center", gap: "8px" }}> - <div>{t('INSTALL_UNINSTALLING', 'Uninstalling...')}</div> - </div> - ); - } - - if (isInstalled) { - return ( - <div style={{ display: "flex", alignItems: "center", gap: "8px" }}> - <FaTrash /> - <div>{t('INSTALL_UNINSTALL_BTN', 'Uninstall LSFG-VK')}</div> - </div> - ); - } - - return ( - <div style={{ display: "flex", alignItems: "center", gap: "8px" }}> - <FaDownload /> - <div>{t('INSTALL_INSTALL_BTN', 'Install LSFG-VK')}</div> - </div> - ); - }; - - return ( - <PanelSectionRow> - <ButtonItem - layout="below" - onClick={isInstalled ? onUninstall : onInstall} - disabled={isInstalling || isUninstalling} - > - {renderButtonContent()} - </ButtonItem> - </PanelSectionRow> - ); -} diff --git a/src/components/NerdStuffModal.tsx b/src/components/NerdStuffModal.tsx deleted file mode 100644 index 8a79b67..0000000 --- a/src/components/NerdStuffModal.tsx +++ /dev/null @@ -1,187 +0,0 @@ -import { useState, useEffect } from "react"; -import { - ModalRoot, - Field, - Focusable, - DialogControlsSection, - PanelSectionRow, - ButtonItem -} from "@decky/ui"; -import { getDllStats, DllStatsResult, getConfigFileContent, getLaunchScriptContent, FileContentResult } from "../api/lsfgApi"; -import t from '../i18n/i18n'; - -interface NerdStuffModalProps { - closeModal?: () => void; -} - -export function NerdStuffModal({ closeModal }: NerdStuffModalProps) { - const [dllStats, setDllStats] = useState<DllStatsResult | null>(null); - const [configContent, setConfigContent] = useState<FileContentResult | null>(null); - const [scriptContent, setScriptContent] = useState<FileContentResult | null>(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState<string | null>(null); - - useEffect(() => { - const loadData = async () => { - try { - setLoading(true); - setError(null); - - // Load all data in parallel - const [dllResult, configResult, scriptResult] = await Promise.all([ - getDllStats(), - getConfigFileContent(), - getLaunchScriptContent() - ]); - - setDllStats(dllResult); - setConfigContent(configResult); - setScriptContent(scriptResult); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to load data"); - } finally { - setLoading(false); - } - }; - - loadData(); - }, []); - - const formatSHA256 = (hash: string) => { - // Format SHA256 hash for better readability (add spaces every 8 characters) - return hash.replace(/(.{8})/g, '$1 ').trim(); - }; - - const copyToClipboard = async (text: string) => { - try { - await navigator.clipboard.writeText(text); - // Could add a toast notification here if desired - } catch (err) { - console.error("Failed to copy to clipboard:", err); - } - }; - - return ( - <ModalRoot onCancel={closeModal} onOK={closeModal}> - {loading && ( - <div>{t('NERD_LOADING', 'Loading information...')}</div> - )} - - {error && ( - <div>Error: {error}</div> - )} - - {!loading && !error && ( - <> - {/* DLL Stats Section */} - {dllStats && ( - <> - {!dllStats.success ? ( - <div>{dllStats.error || "Failed to get DLL stats"}</div> - ) : ( - <div> - <Field label={t('NERD_DLL_PATH', 'DLL Path')}> - <Focusable - onClick={() => dllStats.dll_path && copyToClipboard(dllStats.dll_path)} - onActivate={() => dllStats.dll_path && copyToClipboard(dllStats.dll_path)} - > - {dllStats.dll_path || t('NERD_NOT_AVAILABLE', 'Not available')} - </Focusable> - </Field> - - <Field label={t('NERD_DLL_HASH', 'DLL SHA256 Hash')}> - <Focusable - onClick={() => dllStats.dll_sha256 && copyToClipboard(dllStats.dll_sha256)} - onActivate={() => dllStats.dll_sha256 && copyToClipboard(dllStats.dll_sha256)} - > - {dllStats.dll_sha256 ? formatSHA256(dllStats.dll_sha256) : t('NERD_NOT_AVAILABLE', 'Not available')} - </Focusable> - </Field> - - {dllStats.dll_source && ( - <Field label={t('NERD_DETECTION_SOURCE', 'Detection Source')}> - <div>{dllStats.dll_source}</div> - </Field> - )} - </div> - )} - </> - )} - - {/* Launch Script Section */} - {scriptContent && ( - <Field label={t('NERD_LAUNCH_SCRIPT', 'Launch Script')}> - {!scriptContent.success ? ( - <div>{t('NERD_SCRIPT_NOT_FOUND_PREFIX', 'Script not found:')} {scriptContent.error}</div> - ) : ( - <div> - <div style={{ marginBottom: "8px", fontSize: "0.9em", opacity: 0.8 }}> - {t('NERD_PATH_PREFIX', 'Path:')} {scriptContent.path} - </div> - <Focusable - onClick={() => scriptContent.content && copyToClipboard(scriptContent.content)} - onActivate={() => scriptContent.content && copyToClipboard(scriptContent.content)} - > - <pre style={{ - background: "rgba(255, 255, 255, 0.1)", - padding: "8px", - borderRadius: "4px", - fontSize: "0.8em", - whiteSpace: "pre-wrap", - overflow: "auto", - maxHeight: "150px" - }}> - {scriptContent.content || t('NERD_NO_CONTENT', 'No content')} - </pre> - </Focusable> - </div> - )} - </Field> - )} - - {/* Config File Section */} - {configContent && ( - <Field label={t('NERD_CONFIG_FILE', 'Configuration File')}> - {!configContent.success ? ( - <div>{t('NERD_CONFIG_NOT_FOUND_PREFIX', 'Config not found:')} {configContent.error}</div> - ) : ( - <div> - <div style={{ marginBottom: "8px", fontSize: "0.9em", opacity: 0.8 }}> - {t('NERD_PATH_PREFIX', 'Path:')} {configContent.path} - </div> - <Focusable - onClick={() => configContent.content && copyToClipboard(configContent.content)} - onActivate={() => configContent.content && copyToClipboard(configContent.content)} - > - <pre style={{ - background: "rgba(255, 255, 255, 0.1)", - padding: "8px", - borderRadius: "4px", - fontSize: "0.8em", - whiteSpace: "pre-wrap", - overflow: "auto" - }}> - {configContent.content || t('NERD_NO_CONTENT', 'No content')} - </pre> - </Focusable> - </div> - )} - </Field> - )} - - {/* Close Button */} - <DialogControlsSection> - <PanelSectionRow> - <ButtonItem - layout="below" - onClick={closeModal} - > - {t('NERD_CLOSE', 'Close')} - </ButtonItem> - </PanelSectionRow> - </DialogControlsSection> - </> - )} - </ModalRoot> - ); -} diff --git a/src/components/NowPlayingSummary.tsx b/src/components/NowPlayingSummary.tsx new file mode 100644 index 0000000..adc5d10 --- /dev/null +++ b/src/components/NowPlayingSummary.tsx @@ -0,0 +1,16 @@ +import { Field, PanelSection, PanelSectionRow } from "@decky/ui"; + +interface Props { + title: string; + details: string[]; +} + +export function NowPlayingSummary({ title, details }: Props) { + return ( + <PanelSection> + <PanelSectionRow> + <Field label={title} description={details.filter(Boolean).join(" | ")} /> + </PanelSectionRow> + </PanelSection> + ); +} diff --git a/src/components/NowPlayingTab.tsx b/src/components/NowPlayingTab.tsx new file mode 100644 index 0000000..69e72d7 --- /dev/null +++ b/src/components/NowPlayingTab.tsx @@ -0,0 +1,39 @@ +import { Focusable } from "@decky/ui"; +import { ConfigurationData } from "../config/configSchema"; +import type { GameTarget } from "../utils/gameTargets"; +import { sourceLabel } from "../utils/gameTargets"; +import { GameConfigurationControls } from "./GameConfigurationControls"; +import { NowPlayingSummary } from "./NowPlayingSummary"; + +interface Props { + game: GameTarget; + config: ConfigurationData; + onConfigChange: ( + fieldName: keyof ConfigurationData, + value: boolean | number | string | string[], + ) => Promise<void>; +} + +function targetDescription(game: GameTarget): string { + return sourceLabel(game.source); +} + +export function NowPlayingTab({ + game, + config, + onConfigChange, +}: Props) { + return ( + <Focusable> + <NowPlayingSummary + title={game.name} + details={[targetDescription(game), `Controls: ${game.name} profile`]} + /> + <GameConfigurationControls + config={config} + onConfigChange={onConfigChange} + showWorkarounds={false} + /> + </Focusable> + ); +} diff --git a/src/components/ProfileDetails.tsx b/src/components/ProfileDetails.tsx new file mode 100644 index 0000000..4dd8891 --- /dev/null +++ b/src/components/ProfileDetails.tsx @@ -0,0 +1,37 @@ +import { ButtonItem, Field, Focusable, PanelSectionRow } from "@decky/ui"; +import { useEffect, useRef, useState } from "react"; +import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri"; + +interface ProfileDetailsProps { + description: string; +} + +export function ProfileDetails({ description }: ProfileDetailsProps) { + const [expanded, setExpanded] = useState(false); + const detailsRef = useRef<HTMLDivElement>(null); + + useEffect(() => { + if (!expanded) return; + const frame = requestAnimationFrame(() => detailsRef.current?.scrollIntoView({ block: "nearest" })); + return () => cancelAnimationFrame(frame); + }, [expanded]); + + return ( + <Focusable ref={detailsRef} noFocusRing> + <PanelSectionRow> + <ButtonItem + layout="below" + bottomSeparator={expanded ? "none" : "standard"} + onClick={() => setExpanded((value) => !value)} + > + {expanded ? <RiArrowUpSFill /> : <RiArrowDownSFill />} Game Details + </ButtonItem> + </PanelSectionRow> + {expanded && ( + <PanelSectionRow> + <Field label="Game Details" description={description} /> + </PanelSectionRow> + )} + </Focusable> + ); +} diff --git a/src/components/ProfileManagement.tsx b/src/components/ProfileManagement.tsx deleted file mode 100644 index 626d54c..0000000 --- a/src/components/ProfileManagement.tsx +++ /dev/null @@ -1,477 +0,0 @@ -import { useState, useEffect } from "react"; -import { - PanelSectionRow, - Dropdown, - DropdownOption, - showModal, - ConfirmModal, - Field, - DialogButton, - ButtonItem, - ModalRoot, - TextField, - Focusable, - AppOverview, - Router -} from "@decky/ui"; -import { RiArrowDownSFill, RiArrowUpSFill, RiEditLine, RiDeleteBinLine } from "react-icons/ri"; -import { - getProfiles, - createProfile, - deleteProfile, - renameProfile, - setCurrentProfile, - ProfilesResult, - ProfileResult -} from "../api/lsfgApi"; -import { showSuccessToast, showErrorToast } from "../utils/toastUtils"; -import t from '../i18n/i18n'; - -const PROFILES_COLLAPSED_KEY = 'lsfg-profiles-collapsed'; - -interface TextInputModalProps { - title: string; - description: string; - defaultValue?: string; - okText?: string; - cancelText?: string; - onOK: (value: string) => void; - closeModal?: () => void; -} - -function TextInputModal({ - title, - description, - defaultValue = "", - okText = "OK", - cancelText = "Cancel", - onOK, - closeModal -}: TextInputModalProps) { - const [value, setValue] = useState(defaultValue); - - const handleOK = () => { - if (value.trim()) { - onOK(value); - closeModal?.(); - } - }; - - return ( - <ModalRoot> - <div style={{ padding: "16px", minWidth: "400px" }}> - <h2 style={{ marginBottom: "16px" }}>{title}</h2> - <p style={{ marginBottom: "24px" }}>{description}</p> - - <div style={{ marginBottom: "24px" }}> - <Field - label={t('PROFILE_NAME_LABEL', 'Name')} - childrenLayout="below" - childrenContainerWidth="max" - > - <TextField - value={value} - onChange={(e) => setValue(e?.target?.value || "")} - style={{ width: "100%" }} - /> - </Field> - </div> - - <Focusable - style={{ - display: "flex", - justifyContent: "flex-end", - gap: "8px", - marginTop: "16px" - }} - flow-children="horizontal" - > - <DialogButton onClick={closeModal}> - {cancelText} - </DialogButton> - <DialogButton - onClick={handleOK} - disabled={!value.trim()} - > - {okText} - </DialogButton> - </Focusable> - </div> - </ModalRoot> - ); -} - -interface ProfileManagementProps { - currentProfile?: string; - onProfileChange?: (profileName: string) => void; -} - -export function ProfileManagement({ currentProfile, onProfileChange }: ProfileManagementProps) { - const [profiles, setProfiles] = useState<string[]>([]); - const [selectedProfile, setSelectedProfile] = useState<string>(currentProfile || "decky-lsfg-vk"); - const [isLoading, setIsLoading] = useState(false); - const [mainRunningApp, setMainRunningApp] = useState<AppOverview | undefined>(undefined); - - // Initialize with localStorage value, fallback to false (expanded) if not found - const [profilesCollapsed, setProfilesCollapsed] = useState(() => { - try { - const saved = localStorage.getItem(PROFILES_COLLAPSED_KEY); - return saved !== null ? JSON.parse(saved) : false; - } catch { - return false; - } - }); - - // Persist profiles collapse state to localStorage - useEffect(() => { - try { - localStorage.setItem(PROFILES_COLLAPSED_KEY, JSON.stringify(profilesCollapsed)); - } catch (error) { - console.warn('Failed to save profiles collapse state:', error); - } - }, [profilesCollapsed]); - - // Load profiles on component mount - useEffect(() => { - loadProfiles(); - }, []); - - // Update selected profile when prop changes - useEffect(() => { - if (currentProfile) { - setSelectedProfile(currentProfile); - } - }, [currentProfile]); - - // Poll for running app every 2 seconds - useEffect(() => { - const checkRunningApp = () => { - setMainRunningApp(Router.MainRunningApp); - }; - - // Check immediately - checkRunningApp(); - - // Set up polling interval - const interval = setInterval(checkRunningApp, 2000); - - // Cleanup interval on unmount - return () => clearInterval(interval); - }, []); - - const loadProfiles = async () => { - try { - const result: ProfilesResult = await getProfiles(); - if (result.success && result.profiles) { - setProfiles(result.profiles); - if (result.current_profile) { - setSelectedProfile(result.current_profile); - } - } else { - console.error("Failed to load profiles:", result.error); - showErrorToast("Failed to load profiles", result.error || "Unknown error"); - } - } catch (error) { - console.error("Error loading profiles:", error); - showErrorToast("Error loading profiles", String(error)); - } - }; - - const handleProfileChange = async (profileName: string) => { - setIsLoading(true); - try { - const result: ProfileResult = await setCurrentProfile(profileName); - if (result.success) { - setSelectedProfile(profileName); - showSuccessToast("Profile switched", `Switched to profile: ${profileName}`); - onProfileChange?.(profileName); - } else { - console.error("Failed to switch profile:", result.error); - showErrorToast("Failed to switch profile", result.error || "Unknown error"); - } - } catch (error) { - console.error("Error switching profile:", error); - showErrorToast("Error switching profile", String(error)); - } finally { - setIsLoading(false); - } - }; - - const handleCreateProfile = () => { - showModal( - <TextInputModal - title={t('PROFILE_CREATE_TITLE', 'Create New Profile')} - description={t('PROFILE_CREATE_DESC', "Enter a name for the new profile. The current profile's settings will be copied.")} - okText={t('PROFILE_CREATE_BTN', 'Create')} - cancelText={t('PROFILE_CANCEL_BTN', 'Cancel')} - onOK={(name: string) => { - if (name.trim()) { - createNewProfile(name.trim()); - } - }} - /> - ); - }; - - const createNewProfile = async (profileName: string) => { - setIsLoading(true); - try { - const result: ProfileResult = await createProfile(profileName, selectedProfile); - if (result.success) { - // Use the normalized name returned from backend (spaces converted to dashes) - const actualProfileName = result.profile_name || profileName; - showSuccessToast("Profile created", `Created profile: ${actualProfileName}`); - await loadProfiles(); - // Automatically switch to the newly created profile using the normalized name - await handleProfileChange(actualProfileName); - } else { - console.error("Failed to create profile:", result.error); - showErrorToast("Failed to create profile", result.error || "Unknown error"); - } - } catch (error) { - console.error("Error creating profile:", error); - showErrorToast("Error creating profile", String(error)); - } finally { - setIsLoading(false); - } - }; - - const handleDeleteProfile = () => { - if (selectedProfile === "decky-lsfg-vk") { - showErrorToast(t('PROFILE_CANNOT_DELETE_TITLE', 'Cannot delete default profile'), t('PROFILE_CANNOT_DELETE_MSG', 'The default profile cannot be deleted')); - return; - } - - showModal( - <ConfirmModal - strTitle={t('PROFILE_DELETE_TITLE', 'Delete Profile')} - strDescription={`${t('PROFILE_DELETE_DESC_PREFIX', 'Are you sure you want to delete the profile')} "${selectedProfile}"${t('PROFILE_DELETE_DESC_SUFFIX', '? This action cannot be undone.')}`} - strOKButtonText={t('PROFILE_DELETE_BTN', 'Delete')} - strCancelButtonText={t('PROFILE_CANCEL_BTN', 'Cancel')} - onOK={() => deleteSelectedProfile()} - /> - ); - }; - - const deleteSelectedProfile = async () => { - setIsLoading(true); - try { - const result: ProfileResult = await deleteProfile(selectedProfile); - if (result.success) { - showSuccessToast("Profile deleted", `Deleted profile: ${selectedProfile}`); - await loadProfiles(); - // If we deleted the current profile, it should have switched to default - setSelectedProfile("decky-lsfg-vk"); - onProfileChange?.("decky-lsfg-vk"); - } else { - console.error("Failed to delete profile:", result.error); - showErrorToast("Failed to delete profile", result.error || "Unknown error"); - } - } catch (error) { - console.error("Error deleting profile:", error); - showErrorToast("Error deleting profile", String(error)); - } finally { - setIsLoading(false); - } - }; - - const handleDropdownChange = (option: DropdownOption) => { - if (option.data === "__NEW_PROFILE__") { - handleCreateProfile(); - } else { - handleProfileChange(option.data); - } - }; - - const handleRenameProfile = () => { - if (selectedProfile === "decky-lsfg-vk") { - showErrorToast(t('PROFILE_CANNOT_RENAME_TITLE', 'Cannot rename default profile'), t('PROFILE_CANNOT_RENAME_MSG', 'The default profile cannot be renamed')); - return; - } - - showModal( - <TextInputModal - title={t('PROFILE_RENAME_TITLE', 'Rename Profile')} - description={`${t('PROFILE_RENAME_DESC_PREFIX', 'Enter a new name for the profile')} "${selectedProfile}".`} - defaultValue={selectedProfile} - okText={t('PROFILE_RENAME_BTN', 'Rename')} - cancelText={t('PROFILE_CANCEL_BTN', 'Cancel')} - onOK={(newName: string) => { - if (newName.trim() && newName.trim() !== selectedProfile) { - renameSelectedProfile(newName.trim()); - } - }} - /> - ); - }; - - const renameSelectedProfile = async (newName: string) => { - setIsLoading(true); - try { - const result: ProfileResult = await renameProfile(selectedProfile, newName); - if (result.success) { - // Use the normalized name returned from backend (spaces converted to dashes) - const actualNewName = result.profile_name || newName; - showSuccessToast("Profile renamed", `Renamed profile to: ${actualNewName}`); - await loadProfiles(); - setSelectedProfile(actualNewName); - onProfileChange?.(actualNewName); - } else { - console.error("Failed to rename profile:", result.error); - showErrorToast("Failed to rename profile", result.error || "Unknown error"); - } - } catch (error) { - console.error("Error renaming profile:", error); - showErrorToast("Error renaming profile", String(error)); - } finally { - setIsLoading(false); - } - }; - - const profileOptions: DropdownOption[] = [ - ...profiles.map((profile: string) => ({ - data: profile, - label: profile === "decky-lsfg-vk" ? t('PROFILE_DEFAULT', 'Default') : profile - })), - { - data: "__NEW_PROFILE__", - label: t('PROFILE_NEW', 'New Profile') - } - ]; - - return ( - <> - <style> - {` - .LSFG_ProfilesCollapseButton_Container > div > div > div > button { - height: 10px !important; - } - .LSFG_ProfilesCollapseButton_Container > div > div > div > div > button { - height: 10px !important; - } - `} - </style> - - {/* Display currently running game info - always visible */} - {mainRunningApp && ( - <PanelSectionRow> - <div style={{ - padding: "8px 12px", - backgroundColor: "rgba(0, 255, 0, 0.1)", - borderRadius: "4px", - border: "1px solid rgba(0, 255, 0, 0.3)", - fontSize: "13px" - }}> - <strong>{mainRunningApp.display_name}</strong> running. {t('PROFILE_CLOSE_GAME', 'Close game to change profile.')} - </div> - </PanelSectionRow> - )} - - <PanelSectionRow> - <div - style={{ - fontSize: "14px", - fontWeight: "bold", - marginTop: "8px", - marginBottom: "6px", - borderBottom: "1px solid rgba(255, 255, 255, 0.2)", - paddingBottom: "3px", - color: "white" - }} - > - {t('PROFILE_SECTION_TITLE', 'Profile:')} {selectedProfile === "decky-lsfg-vk" ? t('PROFILE_DEFAULT', 'Default') : selectedProfile} - </div> - </PanelSectionRow> - - <PanelSectionRow> - <div - className="LSFG_ProfilesCollapseButton_Container" - style={{ marginTop: "-2px", marginBottom: "4px" }} - > - <ButtonItem - layout="below" - bottomSeparator={profilesCollapsed ? "standard" : "none"} - onClick={() => setProfilesCollapsed(!profilesCollapsed)} - > - {profilesCollapsed ? ( - <RiArrowDownSFill - style={{ transform: "translate(0, -13px)", fontSize: "1.5em" }} - /> - ) : ( - <RiArrowUpSFill - style={{ transform: "translate(0, -12px)", fontSize: "1.5em" }} - /> - )} - </ButtonItem> - </div> - </PanelSectionRow> - - {!profilesCollapsed && ( - <> - <PanelSectionRow> - <Field - label="" - childrenLayout="below" - childrenContainerWidth="max" - bottomSeparator="none" - > - <Dropdown - rgOptions={profileOptions} - selectedOption={selectedProfile} - onChange={handleDropdownChange} - disabled={isLoading || !!mainRunningApp} - /> - </Field> - </PanelSectionRow> - - <PanelSectionRow> - <Focusable - style={{ - display: "flex", - alignItems: "center", - gap: "8px", - width: "100%", - padding: "0", - margin: "0", - marginTop: "8px" - }} - flow-children="horizontal" - > - <DialogButton - style={{ - height: "40px", - flex: 1, - display: "flex", - alignItems: "center", - justifyContent: "center", - padding: "10px", - minWidth: "0", - }} - onClick={handleRenameProfile} - disabled={isLoading || selectedProfile === "decky-lsfg-vk" || !!mainRunningApp} - > - <RiEditLine size={20} /> - </DialogButton> - - <DialogButton - style={{ - height: "40px", - flex: 1, - display: "flex", - alignItems: "center", - justifyContent: "center", - padding: "10px", - minWidth: "0", - }} - onClick={handleDeleteProfile} - disabled={isLoading || selectedProfile === "decky-lsfg-vk" || !!mainRunningApp} - > - <RiDeleteBinLine size={20} /> - </DialogButton> - </Focusable> - </PanelSectionRow> - </> - )} - </> - ); -} diff --git a/src/components/SettingsTab.tsx b/src/components/SettingsTab.tsx new file mode 100644 index 0000000..35a74cc --- /dev/null +++ b/src/components/SettingsTab.tsx @@ -0,0 +1,100 @@ +import { ButtonItem, Field, PanelSection, PanelSectionRow, ToggleField } from "@decky/ui"; +import { type GlobalConfig, type SteamBranchStatus } from "../api/lsfgApi"; +import t from "../i18n/i18n"; + +interface SettingsTabProps { + isInstalled: boolean; + installationStatus: string; + losslessScalingInstalled: boolean; + losslessScalingStatus: string; + steamBranchStatus: SteamBranchStatus | null; + isInstalling: boolean; + isUninstalling: boolean; + globalConfig: GlobalConfig; + showDebugTab: boolean; + onGlobalConfigChange: (config: GlobalConfig) => Promise<boolean>; + onShowDebugTabChange: (value: boolean) => void; + onInstall: () => void; + onUninstall: () => void; +} + +export function SettingsTab(props: SettingsTabProps) { + const { + isInstalled, + installationStatus, + losslessScalingInstalled, + losslessScalingStatus, + steamBranchStatus, + isInstalling, + isUninstalling, + globalConfig, + showDebugTab, + onGlobalConfigChange, + onShowDebugTabChange, + onInstall, + onUninstall, + } = props; + const losslessScalingAppInstalled = losslessScalingInstalled || steamBranchStatus?.installed === true; + const buttonLabel = isInstalling + ? t("INSTALL_INSTALLING", "Installing...") + : isUninstalling + ? t("INSTALL_UNINSTALLING", "Uninstalling...") + : isInstalled + ? t("INSTALL_UNINSTALL_BTN", "Uninstall LSFG-VK") + : t("INSTALL_INSTALL_BTN", "Install LSFG-VK"); + + return ( + <> + <PanelSection title="Settings"> + <PanelSectionRow> + <Field + label="Lossless Scaling" + description={losslessScalingAppInstalled ? "Installed" : losslessScalingStatus || "Not installed"} + /> + </PanelSectionRow> + <PanelSectionRow> + <Field label="LSFG-VK" description={installationStatus} /> + </PanelSectionRow> + {steamBranchStatus?.installed && ( + <PanelSectionRow> + <Field + label="Steam branch" + description={`${steamBranchStatus.current_branch || "public"}${steamBranchStatus.needs_switch ? ` - ${steamBranchStatus.message}` : ""}`} + /> + </PanelSectionRow> + )} + <PanelSectionRow> + <ButtonItem + layout="below" + onClick={isInstalled ? onUninstall : onInstall} + disabled={isInstalling || isUninstalling} + > + {buttonLabel} + </ButtonItem> + </PanelSectionRow> + </PanelSection> + {isInstalled && ( + <> + <PanelSection title="Global settings"> + <PanelSectionRow> + <ToggleField + label="FP16 Acceleration" + checked={!globalConfig.no_fp16} + onChange={(value) => void onGlobalConfigChange({ ...globalConfig, no_fp16: !value })} + /> + </PanelSectionRow> + </PanelSection> + <PanelSection title="Advanced"> + <PanelSectionRow> + <ToggleField + label="Show config file tab" + checked={showDebugTab} + onChange={onShowDebugTabChange} + /> + </PanelSectionRow> + </PanelSection> + </> + )} + </> + ); +} diff --git a/src/components/SmartClipboardButton.tsx b/src/components/SmartClipboardButton.tsx deleted file mode 100644 index 8da239a..0000000 --- a/src/components/SmartClipboardButton.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { useState, useEffect } from "react"; -import { PanelSectionRow, ButtonItem } from "@decky/ui"; -import { FaClipboard, FaCheck } from "react-icons/fa"; -import { getLaunchOption } from "../api/lsfgApi"; -import { showClipboardErrorToast } from "../utils/toastUtils"; -import { copyWithVerification } from "../utils/clipboardUtils"; -import t from '../i18n/i18n'; - -export function SmartClipboardButton() { - const [isLoading, setIsLoading] = useState(false); - const [showSuccess, setShowSuccess] = useState(false); - - useEffect(() => { - if (showSuccess) { - const timer = setTimeout(() => { - setShowSuccess(false); - }, 3000); - return () => clearTimeout(timer); - } - return undefined; - }, [showSuccess]); - - const getLaunchOptionText = async (): Promise<string> => { - try { - const result = await getLaunchOption(); - return result.launch_option || "~/lsfg %command%"; - } catch (error) { - return "~/lsfg %command%"; - } - }; - - const copyToClipboard = async () => { - if (isLoading || showSuccess) return; - - setIsLoading(true); - try { - const text = await getLaunchOptionText(); - const { success, verified } = await copyWithVerification(text); - - if (success) { - setShowSuccess(true); - if (!verified) { - console.log('Copy verification failed but copy likely worked'); - } - } else { - showClipboardErrorToast(); - } - - } catch (error) { - showClipboardErrorToast(); - } finally { - setIsLoading(false); - } - }; - - return ( - <PanelSectionRow> - <ButtonItem - layout="below" - onClick={copyToClipboard} - disabled={isLoading || showSuccess} - > - <div style={{ display: "flex", alignItems: "center", gap: "8px" }}> - {showSuccess ? ( - <FaCheck style={{ color: "#4CAF50" }} /> - ) : isLoading ? ( - <FaClipboard style={{ - animation: "pulse 1s ease-in-out infinite", - opacity: 0.7 - }} /> - ) : ( - <FaClipboard /> - )} - <div style={{ - color: showSuccess ? "#4CAF50" : "inherit", - fontWeight: showSuccess ? "bold" : "normal" - }}> - {showSuccess ? t('CLIPBOARD_COPIED', 'Copied to clipboard') : isLoading ? t('CLIPBOARD_COPYING', 'Copying...') : t('CLIPBOARD_COPY_LAUNCH', 'Copy Launch Option')} - </div> - </div> - </ButtonItem> - <style>{` - @keyframes pulse { - 0% { opacity: 0.7; } - 50% { opacity: 1; } - 100% { opacity: 0.7; } - } - `}</style> - </PanelSectionRow> - ); -} diff --git a/src/components/StatusDisplay.tsx b/src/components/StatusDisplay.tsx deleted file mode 100644 index 3a48a15..0000000 --- a/src/components/StatusDisplay.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { PanelSectionRow } from "@decky/ui"; - -interface StatusDisplayProps { - dllDetected: boolean; - dllDetectionStatus: string; - isInstalled: boolean; - installationStatus: string; -} - -export function StatusDisplay({ - dllDetected, - dllDetectionStatus, - isInstalled, - installationStatus -}: StatusDisplayProps) { - return ( - <PanelSectionRow> - <div style={{ marginBottom: "8px", fontSize: "14px" }}> - <div - style={{ - color: dllDetected ? "#4CAF50" : "#F44336", - fontWeight: "600", - marginBottom: "6px", - display: "flex", - alignItems: "center", - gap: "6px" - }} - > - <span style={{ fontSize: "16px" }}> - {dllDetected ? "✅" : "❌"} - </span> - {dllDetectionStatus} - </div> - <div - style={{ - color: isInstalled ? "#4CAF50" : "#FF9800", - fontWeight: "600", - display: "flex", - alignItems: "center", - gap: "6px" - }} - > - <span style={{ fontSize: "16px" }}> - {isInstalled ? "✅" : "❌"} - </span> - {installationStatus} - </div> - </div> - </PanelSectionRow> - ); -} diff --git a/src/components/UsageInstructions.tsx b/src/components/UsageInstructions.tsx deleted file mode 100644 index 36e31cf..0000000 --- a/src/components/UsageInstructions.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { PanelSectionRow } from "@decky/ui"; -import t from '../i18n/i18n'; - -export function UsageInstructions() { - return ( - <> - <PanelSectionRow> - <div - style={{ - fontSize: "14px", - fontWeight: "bold", - marginTop: "16px", - marginBottom: "8px", - borderBottom: "1px solid rgba(255, 255, 255, 0.2)", - paddingBottom: "4px", - color: "white" - }} - > - {t('USAGE_TITLE', 'Usage Instructions')} - </div> - </PanelSectionRow> - - <PanelSectionRow> - <div - style={{ - fontSize: "12px", - lineHeight: "1.4", - opacity: "0.8", - whiteSpace: "pre-wrap" - }} - > - {t('USAGE_DESC', 'Click "Copy Launch Option" button, then paste it into your Steam game\'s launch options to enable frame generation.')} - </div> - </PanelSectionRow> - - <PanelSectionRow> - <div - style={{ - fontSize: "12px", - lineHeight: "1.4", - opacity: "0.8", - backgroundColor: "rgba(255, 255, 255, 0.1)", - padding: "8px", - borderRadius: "4px", - fontFamily: "monospace", - marginTop: "8px", - marginBottom: "8px", - textAlign: "center" - }} - > - <strong>~/lsfg %command%</strong> - </div> - </PanelSectionRow> - - <PanelSectionRow> - <div - style={{ - fontSize: "11px", - lineHeight: "1.3", - opacity: "0.6", - marginTop: "8px" - }} - > - {t('USAGE_CONFIG_NOTE', 'The configuration is stored in ~/.config/lsfg-vk/conf.toml and hot-reloads while games are running.')} - </div> - </PanelSectionRow> - </> - ); -} diff --git a/src/components/WorkaroundsSection.tsx b/src/components/WorkaroundsSection.tsx new file mode 100644 index 0000000..3de5ec1 --- /dev/null +++ b/src/components/WorkaroundsSection.tsx @@ -0,0 +1,212 @@ +import { ButtonItem, Field, PanelSectionRow, SliderField, ToggleField } from "@decky/ui"; +import { useEffect, useState } from "react"; +import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri"; +import { usePerAppWorkarounds } from "../hooks/usePerAppWorkarounds"; +import t from "../i18n/i18n"; +import type { WorkaroundField } from "../hooks/usePerAppWorkarounds"; + +interface WorkaroundsSectionProps { + appId: string; + nonSteam: boolean; + onRepair?: () => Promise<boolean>; +} + +const WORKAROUNDS_COLLAPSED_KEY = "lsfg-workarounds-collapsed-v2"; +type ToggleWorkaroundField = Exclude<WorkaroundField, "dxvkFrameRate">; + +const TOGGLE_ROWS: readonly { + field: ToggleWorkaroundField; + labelKey: string; + label: string; + descriptionKey: string; + 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. 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", + labelKey: "CONFIG_DISABLE_VKBASALT", + label: "Disable vkBasalt", + descriptionKey: "CONFIG_DISABLE_VKBASALT_DESC", + description: "Disables vkBasalt layer which can conflict with LSFG (Reshade, some Decky plugins)", + }, + { + field: "enableZink", + labelKey: "CONFIG_ENABLE_ZINK", + label: "Force Zink for OpenGL Games", + descriptionKey: "CONFIG_ENABLE_ZINK_DESC", + description: "Uses Mesa's Zink OpenGL-to-Vulkan driver. May cause crashes or freezes with some games. Requires game restart to apply.", + }, +]; + +function usePersistentCollapsed() { + const [collapsed, setCollapsed] = useState(() => { + try { + const saved = localStorage.getItem(WORKAROUNDS_COLLAPSED_KEY); + return saved !== null ? JSON.parse(saved) === true : true; + } catch { + return true; + } + }); + + useEffect(() => { + try { + localStorage.setItem(WORKAROUNDS_COLLAPSED_KEY, JSON.stringify(collapsed)); + } catch {} + }, [collapsed]); + + return [collapsed, () => setCollapsed((value) => !value)] as const; +} + +export function WorkaroundsSection({ appId, nonSteam, onRepair }: WorkaroundsSectionProps) { + const [collapsed, toggleCollapsed] = usePersistentCollapsed(); + const { status, snapshot, refresh, update, error } = usePerAppWorkarounds(appId, nonSteam); + const [repairing, setRepairing] = useState(false); + const state = snapshot?.state; + const controlsDisabled = status !== "ready" || state === undefined || snapshot?.wrapperOwned !== true || snapshot.integrationInstalled !== true; + const [fpsValue, setFpsValue] = useState<number | null>(null); + const effectiveFpsValue = fpsValue ?? state?.dxvkFrameRate ?? 0; + const fpsLabel = effectiveFpsValue > 0 + ? `${effectiveFpsValue} FPS` + : t("CONFIG_BASE_FPS_CAP_OFF", "Off"); + + const handleRepair = async () => { + if (!onRepair || repairing) return; + setRepairing(true); + try { + if (await onRepair()) await refresh(); + } finally { + setRepairing(false); + } + }; + + useEffect(() => { + setFpsValue(state?.dxvkFrameRate ?? null); + }, [state?.dxvkFrameRate, status]); + + return ( + <> + <style> + {` + .LSFG_WorkaroundsCollapseButton_Container > div > div > div > button, + .LSFG_WorkaroundsCollapseButton_Container > div > div > div > div > button { + height: 24px !important; + padding: 0 !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + } + `} + </style> + <PanelSectionRow> + <div + style={{ + fontSize: "14px", + fontWeight: "bold", + marginTop: "8px", + marginBottom: "6px", + borderBottom: "1px solid rgba(255, 255, 255, 0.2)", + paddingBottom: "3px", + color: "white", + }} + > + {t("CONFIG_WORKAROUNDS_TITLE", "Workarounds")} + </div> + </PanelSectionRow> + <PanelSectionRow> + <div className="LSFG_WorkaroundsCollapseButton_Container" style={{ marginTop: "-2px", marginBottom: "4px" }}> + <ButtonItem + layout="below" + bottomSeparator={collapsed ? "standard" : "none"} + onClick={toggleCollapsed} + > + {collapsed ? <RiArrowDownSFill /> : <RiArrowUpSFill />} + </ButtonItem> + </div> + </PanelSectionRow> + + {!collapsed && ( + <> + {status === "loading" && ( + <PanelSectionRow> + <Field label="Reading launch options..." /> + </PanelSectionRow> + )} + {status === "error" && ( + <> + <PanelSectionRow> + <Field + label="Launch options unavailable" + description={error || "Steam did not provide readable launch options."} + /> + </PanelSectionRow> + <PanelSectionRow> + <ButtonItem layout="below" onClick={() => void refresh()}>Retry</ButtonItem> + </PanelSectionRow> + </> + )} + {status === "ready" && snapshot && (!snapshot.wrapperOwned || !snapshot.integrationInstalled) && ( + <> + <PanelSectionRow> + <Field label="Wrapper needs to be reinstalled" /> + </PanelSectionRow> + {onRepair && ( + <PanelSectionRow> + <ButtonItem layout="below" disabled={repairing} onClick={() => void handleRepair()}> + {repairing ? "Reinstalling..." : "Reinstall wrapper"} + </ButtonItem> + </PanelSectionRow> + )} + </> + )} + + <PanelSectionRow> + <SliderField + label={`${t("CONFIG_BASE_FPS_CAP", "Base FPS Cap")} (${fpsLabel})`} + description={t("CONFIG_BASE_FPS_CAP_DESC", "Base cap for DXVK-backed games before frame generation; 0 disables. Requires game restart to apply.")} + value={effectiveFpsValue} + min={0} + max={60} + step={1} + onChange={(value) => { + setFpsValue(value); + void update("dxvkFrameRate", value); + }} + disabled={controlsDisabled} + /> + </PanelSectionRow> + {TOGGLE_ROWS.map((row) => ( + <PanelSectionRow key={row.field}> + <ToggleField + label={t(row.labelKey, row.label)} + description={t(row.descriptionKey, row.description)} + checked={Boolean(state?.[row.field])} + onChange={(value) => void update(row.field, value)} + disabled={controlsDisabled} + /> + </PanelSectionRow> + ))} + </> + )} + </> + ); +} diff --git a/src/components/index.ts b/src/components/index.ts index bec45ae..5459974 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,11 +1,10 @@ export { Content } from "./Content"; -export { StatusDisplay } from "./StatusDisplay"; -export { InstallationButton } from "./InstallationButton"; export { ConfigurationSection } from "./ConfigurationSection"; export { FpsMultiplierControl } from "./FpsMultiplierControl"; -export { UsageInstructions } from "./UsageInstructions"; -export { SmartClipboardButton } from "./SmartClipboardButton"; -export { FgmodClipboardButton } from "./FgmodClipboardButton"; -export { NerdStuffModal } from "./NerdStuffModal"; -export { FlatpaksModal } from "./FlatpaksModal"; -export { ProfileManagement } from "./ProfileManagement"; +export { ConfigurationTab } from "./ConfigurationTab"; +export { ConfigFileTab } from "./ConfigFileTab"; +export { SettingsTab } from "./SettingsTab"; +export { GameConfigurationSelector } from "./GameConfigurationSelector"; +export { GameConfigurationControls } from "./GameConfigurationControls"; +export { NowPlayingTab } from "./NowPlayingTab"; +export { WorkaroundsSection } from "./WorkaroundsSection"; diff --git a/src/config/configSchema.ts b/src/config/configSchema.ts index 8b4fc1e..bd72e8c 100644 --- a/src/config/configSchema.ts +++ b/src/config/configSchema.ts @@ -6,8 +6,6 @@ export { getFieldNames, getDefaults, getFieldTypes, - DLL, NO_FP16, MULTIPLIER, FLOW_SCALE, PERFORMANCE_MODE, HDR_MODE, - EXPERIMENTAL_PRESENT_MODE, DXVK_FRAME_RATE, ENABLE_WOW64, - DISABLE_STEAMDECK_MODE, MANGOHUD_WORKAROUND, DISABLE_VKBASALT, - FORCE_ENABLE_VKBASALT, ENABLE_WSI, ENABLE_ZINK + DLL, NO_FP16, ACTIVE_IN, PACING_MODE, MULTIPLIER, FLOW_SCALE, + PERFORMANCE_MODE, OVERRIDE_PRESENT_MODE, PRESERVE_SWAPCHAIN_IMAGE_COUNT } from './generatedConfigSchema'; diff --git a/src/config/generatedConfigSchema.ts b/src/config/generatedConfigSchema.ts index 3c5d34e..3668b5b 100644 --- a/src/config/generatedConfigSchema.ts +++ b/src/config/generatedConfigSchema.ts @@ -1,192 +1,31 @@ -// src/config/generatedConfigSchema.ts -// Configuration field type enum - matches Python -export enum ConfigFieldType { - BOOLEAN = "boolean", - INTEGER = "integer", - FLOAT = "float", - STRING = "string" -} +export enum ConfigFieldType { BOOLEAN = "boolean", INTEGER = "integer", FLOAT = "float", STRING = "string", ARRAY = "array" } -// Field name constants for type-safe access export const DLL = "dll" as const; export const NO_FP16 = "no_fp16" as const; +export const ACTIVE_IN = "active_in" as const; +export const PACING_MODE = "pacing_mode" as const; export const MULTIPLIER = "multiplier" as const; export const FLOW_SCALE = "flow_scale" as const; export const PERFORMANCE_MODE = "performance_mode" as const; -export const HDR_MODE = "hdr_mode" as const; -export const EXPERIMENTAL_PRESENT_MODE = "experimental_present_mode" as const; -export const DXVK_FRAME_RATE = "dxvk_frame_rate" as const; -export const ENABLE_WOW64 = "enable_wow64" as const; -export const DISABLE_STEAMDECK_MODE = "disable_steamdeck_mode" as const; -export const MANGOHUD_WORKAROUND = "mangohud_workaround" as const; -export const DISABLE_VKBASALT = "disable_vkbasalt" as const; -export const FORCE_ENABLE_VKBASALT = "force_enable_vkbasalt" as const; -export const ENABLE_WSI = "enable_wsi" as const; -export const ENABLE_ZINK = "enable_zink" as const; +export const OVERRIDE_PRESENT_MODE = "override_present_mode" as const; +export const PRESERVE_SWAPCHAIN_IMAGE_COUNT = "preserve_swapchain_image_count" as const; -// Configuration field definition -export interface ConfigField { - name: string; - fieldType: ConfigFieldType; - default: boolean | number | string; - description: string; +export interface ConfigField { name: string; fieldType: ConfigFieldType; default: boolean | number | string | string[]; description: string; } +export interface ConfigurationData { + dll: string; no_fp16: boolean; active_in: string[]; pacing_mode: string; multiplier: number; + flow_scale: number; performance_mode: boolean; override_present_mode: boolean; preserve_swapchain_image_count: boolean; } - -// 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" - }, - no_fp16: { - name: "no_fp16", - fieldType: ConfigFieldType.BOOLEAN, - default: false, - description: "force-disable fp16 (use on older nvidia cards)" - }, - 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: false, - 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)" - }, - mangohud_workaround: { - name: "mangohud_workaround", - fieldType: ConfigFieldType.BOOLEAN, - default: false, - description: "Enables a transparent mangohud overlay, sometimes fixes issues with 2X multiplier in game mode" - }, - disable_vkbasalt: { - name: "disable_vkbasalt", - fieldType: ConfigFieldType.BOOLEAN, - default: false, - description: "Disables vkBasalt layer which can conflict with LSFG (Reshade, some Decky plugins)" - }, - force_enable_vkbasalt: { - name: "force_enable_vkbasalt", - fieldType: ConfigFieldType.BOOLEAN, - default: false, - description: "Force vkBasalt to engage to fix framepacing issues in gamemode" - }, - enable_wsi: { - name: "enable_wsi", - fieldType: ConfigFieldType.BOOLEAN, - default: false, - description: "Enable Gamescope WSI Layer, disable if frame generation isn't applying or isn't feeling smooth (use with HDR off)" - }, - enable_zink: { - name: "enable_zink", - fieldType: ConfigFieldType.BOOLEAN, - default: false, - description: "Enable Zink (Vulkan-based OpenGL implementation) for OpenGL games" - }, + dll: { name: "dll", fieldType: ConfigFieldType.STRING, default: "", description: "Override the lsfg-vk.dll path" }, + no_fp16: { name: "no_fp16", fieldType: ConfigFieldType.BOOLEAN, default: false, description: "Disable FP16 acceleration" }, + active_in: { name: "active_in", fieldType: ConfigFieldType.ARRAY, default: [], description: "Steam AppID or executable identifiers" }, + pacing_mode: { name: "pacing_mode", fieldType: ConfigFieldType.STRING, default: "vsync", description: "Frame pacing mode" }, + multiplier: { name: "multiplier", fieldType: ConfigFieldType.INTEGER, default: 2, description: "Frame generation multiplier" }, + flow_scale: { name: "flow_scale", fieldType: ConfigFieldType.FLOAT, default: 0.8, description: "Motion estimation resolution scale" }, + performance_mode: { name: "performance_mode", fieldType: ConfigFieldType.BOOLEAN, default: false, description: "Use the lighter frame generation model" }, + override_present_mode: { name: "override_present_mode", fieldType: ConfigFieldType.BOOLEAN, default: true, description: "Override present mode" }, + preserve_swapchain_image_count: { name: "preserve_swapchain_image_count", fieldType: ConfigFieldType.BOOLEAN, default: false, description: "Preserve the swapchain image count" }, }; - -// Type-safe configuration data structure -export interface ConfigurationData { - dll: string; - no_fp16: boolean; - 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; - mangohud_workaround: boolean; - disable_vkbasalt: boolean; - force_enable_vkbasalt: boolean; - enable_wsi: boolean; - enable_zink: boolean; -} - -// Helper functions -export function getFieldNames(): string[] { - return Object.keys(CONFIG_SCHEMA); -} - -export function getDefaults(): ConfigurationData { - return { - dll: "/games/Lossless Scaling/Lossless.dll", - no_fp16: false, - multiplier: 1, - flow_scale: 0.8, - performance_mode: false, - hdr_mode: false, - experimental_present_mode: "fifo", - dxvk_frame_rate: 0, - enable_wow64: false, - disable_steamdeck_mode: false, - mangohud_workaround: false, - disable_vkbasalt: false, - force_enable_vkbasalt: false, - enable_wsi: false, - enable_zink: false, - }; -} - -export function getFieldTypes(): Record<string, ConfigFieldType> { - return { - dll: ConfigFieldType.STRING, - no_fp16: ConfigFieldType.BOOLEAN, - 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, - mangohud_workaround: ConfigFieldType.BOOLEAN, - disable_vkbasalt: ConfigFieldType.BOOLEAN, - force_enable_vkbasalt: ConfigFieldType.BOOLEAN, - enable_wsi: ConfigFieldType.BOOLEAN, - enable_zink: ConfigFieldType.BOOLEAN, - }; -} - +export function getFieldNames(): string[] { return Object.keys(CONFIG_SCHEMA); } +export function getDefaults(): ConfigurationData { return { dll: "", no_fp16: false, active_in: [], pacing_mode: "vsync", multiplier: 2, flow_scale: 0.8, performance_mode: false, override_present_mode: true, preserve_swapchain_image_count: false }; } +export function getFieldTypes(): Record<string, ConfigFieldType> { return Object.fromEntries(Object.entries(CONFIG_SCHEMA).map(([key, value]) => [key, value.fieldType])); } diff --git a/src/hooks/useFlatpakConfiguration.ts b/src/hooks/useFlatpakConfiguration.ts new file mode 100644 index 0000000..f936271 --- /dev/null +++ b/src/hooks/useFlatpakConfiguration.ts @@ -0,0 +1,160 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + enableFlatpakApp, + getFlatpakApps, + getRunningFlatpakApps, + removeFlatpakApp, + setFlatpakWorkaroundState, + updateFlatpakConfig, + type FlatpakApp, + type LsfgConfig, + type RunningFlatpakApp, + type WorkaroundState, +} from "../api/lsfgApi"; +import { selectMostRecentRunningFlatpak } from "../utils/nowPlaying"; +import { showErrorToast } from "../utils/toastUtils"; + +type FlatpakOperationResult = { + success: boolean; + error?: string | null; + config?: LsfgConfig | null; + state?: WorkaroundState | null; +}; + +export function useFlatpakConfiguration(enabled: boolean) { + const [apps, setApps] = useState<FlatpakApp[]>([]); + const [runningApps, setRunningApps] = useState<RunningFlatpakApp[]>([]); + const [loading, setLoading] = useState(false); + const [busyAppId, setBusyAppId] = useState(""); + + const reload = useCallback(async () => { + if (!enabled) { + setApps([]); + return; + } + setLoading(true); + try { + const result = await getFlatpakApps(); + if (!result.success) throw new Error(result.error || "Could not list Flatpak applications"); + setApps(result.apps || []); + } catch (error) { + showErrorToast("Flatpak unavailable", error instanceof Error ? error.message : String(error)); + } finally { + setLoading(false); + } + }, [enabled]); + + const pollRunning = useCallback(async () => { + if (!enabled) { + setRunningApps([]); + return; + } + try { + const result = await getRunningFlatpakApps(); + if (result.success) setRunningApps(result.apps || []); + } catch {} + }, [enabled]); + + useEffect(() => { + void reload(); + }, [reload]); + + useEffect(() => { + void pollRunning(); + if (!enabled) return; + const interval = window.setInterval(() => void pollRunning(), 2000); + return () => window.clearInterval(interval); + }, [enabled, pollRunning]); + + const operate = useCallback(async ( + appId: string, + operation: () => Promise<FlatpakOperationResult>, + refresh = true, + ): Promise<FlatpakOperationResult> => { + if (busyAppId) return { success: false }; + setBusyAppId(appId); + try { + const result = await operation(); + if (!result.success) throw new Error(result.error || "Flatpak operation failed"); + if (refresh) { + await reload(); + await pollRunning(); + } + return result; + } catch (error) { + showErrorToast("Flatpak operation failed", error instanceof Error ? error.message : String(error)); + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } finally { + setBusyAppId(""); + } + }, [busyAppId, pollRunning, reload]); + + const enableApp = useCallback(async (appId: string) => ( + await operate(appId, () => enableFlatpakApp(appId)) + ).success, [operate]); + const removeApp = useCallback(async (appId: string) => ( + await operate(appId, () => removeFlatpakApp(appId)) + ).success, [operate]); + const enableAll = useCallback(async (): Promise<void> => { + if (busyAppId) return; + const available = apps.filter((app) => ( + !app.enabled && !(app.prepared && !app.owned) && !app.error + )); + for (const app of available) { + const result = await operate(app.app_id, () => enableFlatpakApp(app.app_id), false); + if (!result.success) break; + } + await reload(); + await pollRunning(); + }, [apps, busyAppId, operate, pollRunning, reload]); + const removeAll = useCallback(async (): Promise<void> => { + if (busyAppId) return; + for (const app of apps.filter((item) => item.enabled)) { + const result = await operate(app.app_id, () => removeFlatpakApp(app.app_id), false); + if (!result.success) break; + } + await reload(); + await pollRunning(); + }, [apps, busyAppId, operate, pollRunning, reload]); + const updateConfig = useCallback( + async (appId: string, config: LsfgConfig) => { + const result = await operate(appId, () => updateFlatpakConfig(appId, config), false); + if (result.success) { + setApps((current) => current.map((app) => ( + app.app_id === appId ? { ...app, config: result.config || config } : app + ))); + } + return result.success; + }, + [operate], + ); + const updateWorkarounds = useCallback( + async (appId: string, state: WorkaroundState) => { + const result = await operate(appId, () => setFlatpakWorkaroundState(appId, state), false); + if (result.success) { + setApps((current) => current.map((app) => ( + app.app_id === appId ? { ...app, workarounds: result.state || state } : app + ))); + } + return result.success; + }, + [operate], + ); + + const runningApp = useMemo(() => selectMostRecentRunningFlatpak(apps, runningApps), [apps, runningApps]); + + return { + apps, + runningApps, + runningApp, + loading, + busyAppId, + reload, + enableApp, + enableAll, + removeApp, + removeAll, + updateConfig, + updateWorkarounds, + }; +} diff --git a/src/hooks/useGameConfiguration.ts b/src/hooks/useGameConfiguration.ts new file mode 100644 index 0000000..deb68ff --- /dev/null +++ b/src/hooks/useGameConfiguration.ts @@ -0,0 +1,378 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useQuickAccessVisible } from "@decky/api"; +import { Router } from "@decky/ui"; +import { getGameConfigs, getInstalledGames, getWorkaroundApps, getWorkaroundState, removeWorkaroundState, resetGameConfig, resetGameConfigs, setWorkaroundState, updateGameConfig, updateGlobalConfig as saveGlobalConfig, type GameConfigEntry, type GlobalConfig, type InstalledGame, type WorkaroundApp, type WorkaroundState } from "../api/lsfgApi"; +import { ConfigurationData, getDefaults } from "../config/configSchema"; +import { getDefaultWrapperPath, installWrapperIntegration, removeWrapperIntegration } from "../utils/steamLaunchOptions"; +import { getTargetSource, mergeGameTargets, type GameTarget, type KnownGameSource } from "../utils/gameTargets"; +import { showErrorToast } from "../utils/toastUtils"; + +export type { GameSource, GameTarget, KnownGameSource } from "../utils/gameTargets"; + +async function getSteamShortcuts(): Promise<InstalledGame[]> { + const apps = (globalThis as any).SteamClient?.Apps; + if (typeof apps?.GetAllShortcuts !== "function") return []; + + try { + const shortcuts = await apps.GetAllShortcuts(); + if (!Array.isArray(shortcuts)) return []; + return shortcuts.flatMap((shortcut: any) => { + const appid = Number(shortcut?.appid); + const name = shortcut?.data?.strAppName; + if (!Number.isInteger(appid) || appid === 0 || typeof name !== "string" || !name) return []; + return [{ + appid: String(appid >>> 0), + name, + nonSteam: true, + }]; + }); + } catch { + return []; + } +} + +function mergeInstalledGames(backendGames: InstalledGame[], shortcutGames: InstalledGame[]) { + const games = new Map(backendGames.map((game) => [game.appid, game])); + for (const game of shortcutGames) { + const existing = games.get(game.appid); + games.set(game.appid, existing ? { ...existing, name: game.name, nonSteam: true } : game); + } + return Array.from(games.values()); +} + +const DEFAULT_WORKAROUND_STATE: WorkaroundState = { + dxvkFrameRate: 0, + disableGamescopeWsi: true, + disableHdr: true, + disableSteamdeckMode: false, + disableVkbasalt: false, + enableZink: false, +}; + +function asError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} + +export function useGameConfiguration() { + const [games, setGames] = useState<GameConfigEntry[]>([]); + const [globalConfig, setGlobalConfig] = useState<GlobalConfig>({ dll: "", no_fp16: false }); + const [installedGames, setInstalledGames] = useState<InstalledGame[]>([]); + const [workaroundApps, setWorkaroundApps] = useState<WorkaroundApp[]>([]); + const [configsLoaded, setConfigsLoaded] = useState(false); + const [selectedAppId, setSelectedAppId] = useState(""); + const [runningGame, setRunningGame] = useState<GameTarget | null>(null); + const [bulkOperationBusy, setBulkOperationBusy] = useState(false); + const bulkOperationLock = useRef(false); + const previousRunningAppId = useRef<string | null>(null); + const previousQuickAccessVisible = useRef<boolean | null>(null); + const quickAccessVisible = useQuickAccessVisible(); + + const load = useCallback(async () => { + const [result, installed, shortcuts, workaroundResult] = await Promise.all([ + getGameConfigs(), + getInstalledGames(), + getSteamShortcuts(), + getWorkaroundApps(), + ]); + if (result.success) { + setGlobalConfig(result.global_config || { dll: "", no_fp16: false }); + setGames(result.games || []); + } + setInstalledGames(mergeInstalledGames(installed.success ? installed.games || [] : [], shortcuts)); + setWorkaroundApps(workaroundResult.success ? workaroundResult.apps || [] : []); + setConfigsLoaded(true); + }, []); + + useEffect(() => { + const initialLoad = previousQuickAccessVisible.current === null; + const becameVisible = quickAccessVisible && previousQuickAccessVisible.current === false; + previousQuickAccessVisible.current = quickAccessVisible; + if (initialLoad || becameVisible) void load(); + }, [load, quickAccessVisible]); + + useEffect(() => { + const poll = () => { + if (!configsLoaded) return; + const app = Router.MainRunningApp as any; + if (!app?.appid) return setRunningGame(null); + const appid = String(app.appid); + const installed = installedGames.find((game) => game.appid === appid); + const name = app.display_name || installed?.name; + if (!name) return setRunningGame(null); + const source = getTargetSource(appid, installedGames, workaroundApps); + const next: GameTarget = { + ...(installed || { appid, name, nonSteam: source === "nonSteam" }), + name, + nonSteam: source === "nonSteam", + source, + configured: games.some((game) => game.appid === appid), + }; + setRunningGame((current) => ( + current?.appid === next.appid + && current.name === next.name + && current.nonSteam === next.nonSteam + && current.source === next.source + && current.configured === next.configured + ? current + : next + )); + }; + poll(); + const interval = window.setInterval(poll, 2000); + return () => window.clearInterval(interval); + }, [configsLoaded, games, installedGames, workaroundApps]); + + useEffect(() => { + const appid = runningGame?.appid || null; + if (appid !== previousRunningAppId.current) { + previousRunningAppId.current = appid; + setSelectedAppId(appid || ""); + } + }, [runningGame?.appid]); + + const targets = useMemo<GameTarget[]>(() => { + return mergeGameTargets(games, installedGames, workaroundApps, runningGame); + }, [games, installedGames, runningGame, workaroundApps]); + const template = useMemo(() => ({ ...getDefaults(), ...globalConfig }), [globalConfig]); + const config = games.find((game) => game.appid === selectedAppId)?.config || template; + const runningConfig = runningGame + ? games.find((game) => game.appid === runningGame.appid)?.config || template + : template; + + const ensureTargetWorkarounds = useCallback(async (target: GameTarget): Promise<boolean> => { + if (target.source === "unknown") { + showErrorToast("Could not initialize workarounds", "The target source is unknown; re-discover the game before enabling it"); + return false; + } + if (!installedGames.some((game) => game.appid === target.appid)) return true; + const appId = Number(target.appid); + let integration: Awaited<ReturnType<typeof installWrapperIntegration>> | null = null; + let newState = false; + let stateWriteAttempted = false; + let wrapperPath = getDefaultWrapperPath(); + try { + const existing = await getWorkaroundState(target.appid); + if (!existing.success) throw new Error(existing.error || "Could not read workaround state"); + wrapperPath = existing.wrapper_path || getDefaultWrapperPath(); + const state = existing.state || { ...DEFAULT_WORKAROUND_STATE }; + const commandTokenAdded = existing.command_token_added === true; + newState = !existing.state; + integration = await installWrapperIntegration( + appId, + target.nonSteam, + wrapperPath, + commandTokenAdded, + ); + stateWriteAttempted = true; + const saved = await setWorkaroundState( + target.appid, + state, + integration.commandTokenAdded, + target.nonSteam, + ); + if (!saved.success) throw new Error(saved.error || "Could not save workaround state"); + return true; + } catch (error) { + let rollbackSucceeded = true; + if (integration?.changed) { + try { + await removeWrapperIntegration( + appId, + target.nonSteam, + wrapperPath, + integration.commandTokenAdded, + ); + } catch (rollbackError) { + showErrorToast("Workaround rollback failed", asError(rollbackError).message); + rollbackSucceeded = false; + } + } + if (rollbackSucceeded && newState && stateWriteAttempted) { + const restored = await removeWorkaroundState(target.appid); + if (!restored.success) { + showErrorToast("Workaround rollback failed", restored.error || "Could not roll back workaround state"); + rollbackSucceeded = false; + } + } + showErrorToast("Could not initialize workarounds", asError(error).message); + return false; + } + }, [installedGames]); + + const removeTargetWorkarounds = useCallback(async (target: GameTarget): Promise<boolean> => { + const installed = installedGames.some((game) => game.appid === target.appid); + if (target.source === "unknown" && installed) return true; + const appId = Number(target.appid); + try { + const existing = await getWorkaroundState(target.appid); + if (!existing.success) throw new Error(existing.error || "Could not read workaround state"); + const wrapperPath = existing.wrapper_path || getDefaultWrapperPath(); + if (installed) { + await removeWrapperIntegration( + appId, + target.nonSteam, + wrapperPath, + existing.command_token_added === true, + ); + } + const removed = await removeWorkaroundState(target.appid); + if (!removed.success) throw new Error(removed.error || "Could not remove workaround state"); + return true; + } catch (error) { + showErrorToast("Could not clean up game workarounds", asError(error).message); + return false; + } + }, [installedGames]); + + const acquireBulkOperation = useCallback(() => { + if (bulkOperationLock.current) return false; + bulkOperationLock.current = true; + setBulkOperationBusy(true); + return true; + }, []); + + const releaseBulkOperation = useCallback(() => { + bulkOperationLock.current = false; + setBulkOperationBusy(false); + }, []); + + const cleanupAllWorkarounds = useCallback(async (): Promise<boolean> => { + try { + const result = await getWorkaroundApps(); + if (!result.success) throw new Error(result.error || "Could not read workaround state"); + const cleaned = new Set<string>(); + const wrapperPath = result.wrapper_path || getDefaultWrapperPath(); + + for (const entry of result.apps || []) { + await removeWrapperIntegration( + Number(entry.appid), + entry.non_steam, + wrapperPath, + entry.command_token_added, + ); + const removed = await removeWorkaroundState(entry.appid); + if (!removed.success) throw new Error(removed.error || "Could not remove workaround state"); + cleaned.add(entry.appid); + } + + // Also clean configured targets whose sidecar entry was lost. This + // removes an old wrapper and only the plugin-managed launch pieces. + for (const target of targets.filter((item) => item.configured && installedGames.some((game) => game.appid === item.appid))) { + if (!cleaned.has(target.appid) && !(await removeTargetWorkarounds(target))) return false; + } + return true; + } catch (error) { + showErrorToast("Could not clean up game launch options", asError(error).message); + return false; + } + }, [installedGames, removeTargetWorkarounds, targets]); + + const saveFor = useCallback(async (appid: string, next: ConfigurationData, cleanupLaunchOptions = false) => { + const target = targets.find((item) => item.appid === appid); + if (!target?.name) return false; + if (cleanupLaunchOptions && !(await ensureTargetWorkarounds(target))) return false; + const result = await updateGameConfig(appid, target.name, next); + if (result.success) await load(); + return result.success; + }, [ensureTargetWorkarounds, load, targets]); + + const save = useCallback( + async (next: ConfigurationData, cleanupLaunchOptions = false) => { + if (!selectedAppId) return false; + return saveFor(selectedAppId, next, cleanupLaunchOptions); + }, + [saveFor, selectedAppId], + ); + + const updateGlobal = useCallback(async (next: GlobalConfig): Promise<boolean> => { + const result = await saveGlobalConfig(next); + if (!result.success) return false; + setGlobalConfig(result.global_config || next); + return true; + }, []); + + const enable = useCallback(async (appid: string) => { + const target = targets.find((item) => item.appid === appid); + if (!target?.name) return false; + if (!(await ensureTargetWorkarounds(target))) return false; + const result = await updateGameConfig(appid, target.name, template); + if (result.success) await load(); + else await removeTargetWorkarounds(target); + return result.success; + }, [ensureTargetWorkarounds, load, removeTargetWorkarounds, targets, template]); + + const enableAll = useCallback(async (source: KnownGameSource): Promise<void> => { + if (!acquireBulkOperation()) return; + try { + const available = targets.filter((target) => target.source === source && !target.configured && target.name); + if (available.length === 0) return; + for (const target of available) { + if (!(await ensureTargetWorkarounds(target))) { + await load(); + return; + } + const result = await updateGameConfig(target.appid, target.name, template); + if (!result.success) { + await removeTargetWorkarounds(target); + showErrorToast( + "Could not enable all games", + result.error || `Could not create a profile for ${target.name}`, + ); + await load(); + return; + } + } + await load(); + } finally { + releaseBulkOperation(); + } + }, [acquireBulkOperation, ensureTargetWorkarounds, load, removeTargetWorkarounds, releaseBulkOperation, targets, template]); + + const repair = useCallback(async (appid: string): Promise<boolean> => { + const target = targets.find((item) => item.appid === appid); + if (!target) return false; + const success = await ensureTargetWorkarounds(target); + if (success) await load(); + return success; + }, [ensureTargetWorkarounds, load, targets]); + + const resetSelected = useCallback(async () => { + if (selectedAppId) { + const selectedTarget = targets.find((target) => target.appid === selectedAppId); + if (selectedTarget && !(await removeTargetWorkarounds(selectedTarget))) return; + const result = await resetGameConfig(selectedAppId); + if (result.success) { + setRunningGame((current) => current?.appid === selectedAppId ? { ...current, configured: false } : current); + setSelectedAppId(""); + await load(); + } + } + }, [load, removeTargetWorkarounds, selectedAppId, targets]); + + const resetAll = useCallback(async (source: KnownGameSource) => { + if (!acquireBulkOperation()) return; + try { + const selectedTargets = targets.filter((item) => item.configured && item.source === source); + if (selectedTargets.length === 0) return; + for (const target of selectedTargets) { + if (!(await removeTargetWorkarounds(target))) { + await load(); + return; + } + } + const result = await resetGameConfigs(selectedTargets.map((target) => target.appid)); + if (!result.success) { + showErrorToast("Could not remove all profiles", result.error || "Could not remove the selected profiles"); + await load(); + return; + } + setRunningGame((current) => current?.source === source ? { ...current, configured: false } : current); + setSelectedAppId(""); + await load(); + } finally { + releaseBulkOperation(); + } + }, [acquireBulkOperation, load, removeTargetWorkarounds, releaseBulkOperation, targets]); + + return { config, runningConfig, globalConfig, targets, runningGame, selectedAppId, setSelectedAppId, save, saveFor, updateGlobal, enable, enableAll, repair, resetSelected, resetAll, bulkOperationBusy, cleanupAllWorkarounds, reload: load }; +} diff --git a/src/hooks/useInstallationActions.ts b/src/hooks/useInstallationActions.ts deleted file mode 100644 index f184145..0000000 --- a/src/hooks/useInstallationActions.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { useState } from "react"; -import { installLsfgVk, uninstallLsfgVk } from "../api/lsfgApi"; -import { - showInstallSuccessToast, - showInstallErrorToast, - showUninstallSuccessToast, - showUninstallErrorToast -} from "../utils/toastUtils"; - -export function useInstallationActions() { - const [isInstalling, setIsInstalling] = useState<boolean>(false); - const [isUninstalling, setIsUninstalling] = useState<boolean>(false); - - const handleInstall = async ( - setIsInstalled: (value: boolean) => void, - setInstallationStatus: (value: string) => void, - reloadConfig?: () => Promise<void> - ) => { - setIsInstalling(true); - setInstallationStatus("Installing lsfg-vk..."); - - try { - const result = await installLsfgVk(); - if (result.success) { - setIsInstalled(true); - setInstallationStatus("lsfg-vk installed"); - showInstallSuccessToast(); - - // Reload lsfg config after installation - if (reloadConfig) { - await reloadConfig(); - } - } else { - setInstallationStatus(`Installation failed: ${result.error}`); - showInstallErrorToast(result.error); - } - } catch (error) { - setInstallationStatus(`Installation failed: ${error}`); - showInstallErrorToast(String(error)); - } finally { - setIsInstalling(false); - } - }; - - const handleUninstall = async ( - setIsInstalled: (value: boolean) => void, - setInstallationStatus: (value: string) => void - ) => { - setIsUninstalling(true); - setInstallationStatus("Uninstalling lsfg-vk..."); - - try { - const result = await uninstallLsfgVk(); - if (result.success) { - setIsInstalled(false); - setInstallationStatus("lsfg-vk uninstalled successfully!"); - showUninstallSuccessToast(); - } else { - setInstallationStatus(`Uninstallation failed: ${result.error}`); - showUninstallErrorToast(result.error); - } - } catch (error) { - setInstallationStatus(`Uninstallation failed: ${error}`); - showUninstallErrorToast(String(error)); - } finally { - setIsUninstalling(false); - } - }; - - return { - isInstalling, - isUninstalling, - handleInstall, - handleUninstall - }; -} diff --git a/src/hooks/useLsfgHooks.ts b/src/hooks/useLsfgHooks.ts index d9bbe3e..0f51e90 100644 --- a/src/hooks/useLsfgHooks.ts +++ b/src/hooks/useLsfgHooks.ts @@ -1,125 +1,117 @@ -import { useState, useEffect, useCallback } from "react"; +import { useEffect, useState } from "react"; import { checkLsfgVkInstalled, - checkLosslessScalingDll, - getLsfgConfig, - updateLsfgConfigFromObject, - type ConfigUpdateResult + getLosslessScalingBranchStatus, + installLsfgVk, + uninstallLsfgVk, + type SteamBranchStatus, } from "../api/lsfgApi"; -import { ConfigurationData, getDefaults } from "../config/configSchema"; -import { showErrorToast, ToastMessages } from "../utils/toastUtils"; +import { + showInstallErrorToast, + showInstallSuccessToast, + showUninstallErrorToast, + showUninstallSuccessToast, +} from "../utils/toastUtils"; -export function useInstallationStatus() { - const [isInstalled, setIsInstalled] = useState<boolean>(false); - const [installationStatus, setInstallationStatus] = useState<string>(""); +export function useInstallation( + reloadConfig?: () => Promise<void>, + beforeUninstall?: () => Promise<boolean>, +) { + const [isInstalled, setIsInstalled] = useState(false); + const [installationStatus, setInstallationStatus] = useState(""); + const [losslessScalingInstalled, setLosslessScalingInstalled] = useState(false); + const [losslessScalingStatus, setLosslessScalingStatus] = useState(""); + const [steamBranchStatus, setSteamBranchStatus] = useState<SteamBranchStatus | null>(null); + const [isInstalling, setIsInstalling] = useState(false); + const [isUninstalling, setIsUninstalling] = useState(false); const checkInstallation = async () => { try { + setSteamBranchStatus(await getLosslessScalingBranchStatus()); + } catch (error) { + console.error("Error checking Lossless Scaling Steam branch:", error); + setSteamBranchStatus(null); + } + + try { const status = await checkLsfgVkInstalled(); setIsInstalled(status.installed); - if (status.installed) { - setInstallationStatus("lsfg-vk Installed"); - } else { - setInstallationStatus("lsfg-vk Not Installed"); - } + setLosslessScalingInstalled(status.lossless_scaling_installed); + setLosslessScalingStatus(status.lossless_scaling_status || "Lossless Scaling Not Installed"); + setInstallationStatus(status.installed ? "lsfg-vk Installed" : "lsfg-vk Not Installed"); return status.installed; - } catch (error) { + } catch { + setSteamBranchStatus(null); + setLosslessScalingInstalled(false); + setLosslessScalingStatus("Lossless Scaling Not Installed"); setInstallationStatus("lsfg-vk Not Installed"); return false; } }; useEffect(() => { - checkInstallation(); + void checkInstallation(); }, []); - return { - isInstalled, - installationStatus, - setIsInstalled, - setInstallationStatus, - checkInstallation - }; -} - -export function useDllDetection() { - const [dllDetected, setDllDetected] = useState<boolean>(false); - const [dllDetectionStatus, setDllDetectionStatus] = useState<string>(""); - - const checkDllDetection = async () => { + const install = async () => { + setIsInstalling(true); + setInstallationStatus("Installing lsfg-vk..."); try { - const result = await checkLosslessScalingDll(); - setDllDetected(result.detected); - if (result.detected) { - setDllDetectionStatus("Lossless Scaling Installed"); - } else { - setDllDetectionStatus("Lossless Scaling Not Installed"); + const result = await installLsfgVk(); + if (!result.success) { + setInstallationStatus(`Installation failed: ${result.error}`); + showInstallErrorToast(result.error ?? undefined); + return; } + setIsInstalled(true); + setInstallationStatus("lsfg-vk installed"); + showInstallSuccessToast(); + await reloadConfig?.(); + await checkInstallation(); } catch (error) { - setDllDetectionStatus("Lossless Scaling Not Installed"); + setInstallationStatus(`Installation failed: ${error}`); + showInstallErrorToast(String(error)); + } finally { + setIsInstalling(false); } }; - useEffect(() => { - checkDllDetection(); - }, []); - - return { - dllDetected, - dllDetectionStatus - }; -} - -export function useLsfgConfig() { - const [config, setConfig] = useState<ConfigurationData>(() => getDefaults()); - - const loadLsfgConfig = useCallback(async () => { + const uninstall = async () => { + setIsUninstalling(true); + setInstallationStatus("Uninstalling lsfg-vk..."); try { - const result = await getLsfgConfig(); - if (result.success && result.config) { - setConfig(result.config); - } else { - console.log("lsfg config not available, using defaults:", result.error); - setConfig(getDefaults()); + if (beforeUninstall && !(await beforeUninstall())) { + setInstallationStatus("Uninstallation cancelled: could not clean up launch options"); + return; } - } catch (error) { - console.error("Error loading lsfg config:", error); - setConfig(getDefaults()); - } - }, []); - - const updateConfig = useCallback(async (newConfig: ConfigurationData): Promise<ConfigUpdateResult> => { - try { - const result = await updateLsfgConfigFromObject(newConfig); - if (result.success) { - setConfig(newConfig); - } else { - showErrorToast( - ToastMessages.CONFIG_UPDATE_ERROR.title, - result.error || ToastMessages.CONFIG_UPDATE_ERROR.body - ); + const result = await uninstallLsfgVk(); + if (!result.success) { + setInstallationStatus(`Uninstallation failed: ${result.error}`); + showUninstallErrorToast(result.error ?? undefined); + return; } - return result; + setIsInstalled(false); + setInstallationStatus("lsfg-vk uninstalled successfully!"); + await checkInstallation(); + showUninstallSuccessToast(); } catch (error) { - showErrorToast(ToastMessages.CONFIG_UPDATE_ERROR.title, String(error)); - return { success: false, error: String(error) }; + setInstallationStatus(`Uninstallation failed: ${error}`); + showUninstallErrorToast(String(error)); + } finally { + setIsUninstalling(false); } - }, []); - - const updateField = useCallback(async (fieldName: keyof ConfigurationData, value: boolean | number | string): Promise<ConfigUpdateResult> => { - const newConfig = { ...config, [fieldName]: value }; - return updateConfig(newConfig); - }, [config, updateConfig]); - - useEffect(() => { - loadLsfgConfig(); - }, []); + }; return { - config, - setConfig, - loadLsfgConfig, - updateConfig, - updateField + isInstalled, + installationStatus, + losslessScalingInstalled, + losslessScalingStatus, + steamBranchStatus, + isInstalling, + isUninstalling, + install, + uninstall, + checkInstallation, }; } diff --git a/src/hooks/usePerAppWorkarounds.ts b/src/hooks/usePerAppWorkarounds.ts new file mode 100644 index 0000000..ab33bb2 --- /dev/null +++ b/src/hooks/usePerAppWorkarounds.ts @@ -0,0 +1,266 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + getWorkaroundState, + removeWorkaroundState, + setWorkaroundState, + type WorkaroundState, +} from "../api/lsfgApi"; +import { + getDefaultWrapperPath, + installWrapperIntegration, + isWrapperIntegrationInstalled, + readSteamLaunchOptions, + removeWrapperIntegration, + subscribeSteamLaunchOptions, + type SteamLaunchOptionsSnapshot, +} from "../utils/steamLaunchOptions"; +import { showErrorToast } from "../utils/toastUtils"; + +export type WorkaroundField = keyof WorkaroundState; +export type WorkaroundLoadStatus = "loading" | "ready" | "error"; + +const SLIDER_DEBOUNCE_MS = 250; + +const DEFAULT_WORKAROUND_STATE: WorkaroundState = { + dxvkFrameRate: 0, + disableGamescopeWsi: true, + disableHdr: true, + disableSteamdeckMode: false, + disableVkbasalt: false, + enableZink: false, +}; + +interface PendingSliderUpdate { + timer: number; + value: number; + waiters: Array<(success: boolean) => void>; +} + +export interface WorkaroundSnapshot { + steam: SteamLaunchOptionsSnapshot; + state: WorkaroundState; + wrapperPath: string; + wrapperOwned: boolean; + integrationInstalled: boolean; + commandTokenAdded: boolean; +} + +interface PerAppWorkarounds { + status: WorkaroundLoadStatus; + snapshot: WorkaroundSnapshot | null; + refresh: () => Promise<void>; + update: (field: WorkaroundField, value: boolean | number) => Promise<boolean>; + error: string | null; +} + +function asError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} + +function makeSnapshot( + steam: SteamLaunchOptionsSnapshot, + result: Awaited<ReturnType<typeof getWorkaroundState>>, + nonSteam: boolean, +): WorkaroundSnapshot { + if (!result.state) throw new Error("Workaround state is not initialized for this profile"); + const wrapperPath = result.wrapper_path || getDefaultWrapperPath(); + return { + steam, + state: result.state, + wrapperPath, + wrapperOwned: result.wrapper_owned === true, + integrationInstalled: isWrapperIntegrationInstalled(steam, nonSteam, wrapperPath), + commandTokenAdded: result.command_token_added === true, + }; +} + +async function adoptWorkaroundState( + appId: string, + nonSteam: boolean, + wrapperPath: string, +): Promise<WorkaroundSnapshot> { + let integration: Awaited<ReturnType<typeof installWrapperIntegration>> | null = null; + try { + integration = await installWrapperIntegration(Number(appId), nonSteam, wrapperPath, false); + const finalized = await setWorkaroundState( + appId, + DEFAULT_WORKAROUND_STATE, + integration.commandTokenAdded, + nonSteam, + ); + if (!finalized.success) throw new Error(finalized.error || "Could not finalize workaround state"); + return makeSnapshot(integration.snapshot, finalized, nonSteam); + } catch (error) { + let rollbackSucceeded = true; + if (integration?.changed) { + try { + await removeWrapperIntegration( + Number(appId), + nonSteam, + wrapperPath, + integration.commandTokenAdded, + ); + } catch { + rollbackSucceeded = false; + } + } + if (rollbackSucceeded) { + const removed = await removeWorkaroundState(appId); + if (!removed.success) throw new Error(removed.error || "Could not roll back workaround state"); + } + throw error; + } +} + +export function usePerAppWorkarounds(appId: string, nonSteam: boolean): PerAppWorkarounds { + const [status, setStatus] = useState<WorkaroundLoadStatus>("loading"); + const [snapshot, setSnapshot] = useState<WorkaroundSnapshot | null>(null); + const [error, setError] = useState<string | null>(null); + const pendingSliderUpdate = useRef<PendingSliderUpdate | null>(null); + const numericAppId = Number(appId); + + const loadSnapshot = useCallback(async () => { + const [result, steam] = await Promise.all([ + getWorkaroundState(appId), + readSteamLaunchOptions(numericAppId, nonSteam), + ]); + if (!result.success) throw new Error(result.error || "Could not read workaround state"); + if (!result.state) { + return adoptWorkaroundState( + appId, + nonSteam, + result.wrapper_path || getDefaultWrapperPath(), + ); + } + return makeSnapshot(steam, result, nonSteam); + }, [appId, nonSteam, numericAppId]); + + const applySnapshot = useCallback((next: WorkaroundSnapshot) => { + setSnapshot(next); + setStatus("ready"); + setError(null); + }, []); + + const refresh = useCallback(async () => { + setStatus("loading"); + setError(null); + try { + applySnapshot(await loadSnapshot()); + } catch (refreshError) { + const nextError = asError(refreshError); + setStatus("error"); + setError(nextError.message); + } + }, [applySnapshot, loadSnapshot]); + + useEffect(() => { + let active = true; + setStatus("loading"); + setSnapshot(null); + setError(null); + let unsubscribe = () => {}; + try { + unsubscribe = subscribeSteamLaunchOptions( + numericAppId, + nonSteam, + (steam) => { + if (!active) return; + setSnapshot((current) => current ? { + ...current, + steam, + integrationInstalled: isWrapperIntegrationInstalled(steam, nonSteam, current.wrapperPath), + } : current); + }, + (subscriptionError) => { + if (!active) return; + setStatus("error"); + setError(subscriptionError.message); + }, + ); + } catch (subscriptionError) { + if (active) { + setStatus("error"); + setError(asError(subscriptionError).message); + } + } + void loadSnapshot() + .then((next) => { if (active) applySnapshot(next); }) + .catch((readError) => { + if (active) { + setStatus("error"); + setError(asError(readError).message); + } + }); + return () => { + active = false; + unsubscribe(); + }; + }, [applySnapshot, loadSnapshot, nonSteam, numericAppId]); + + const persistUpdate = useCallback(async (field: WorkaroundField, value: boolean | number): Promise<boolean> => { + const current = snapshot; + if (!current) return false; + setError(null); + const nextState = { ...current.state, [field]: value } as WorkaroundState; + try { + const result = await setWorkaroundState( + appId, + nextState, + current.commandTokenAdded, + nonSteam, + ); + if (!result.success || !result.state) throw new Error(result.error || "Could not save workaround state"); + applySnapshot({ + ...current, + state: result.state, + wrapperPath: result.wrapper_path || current.wrapperPath, + wrapperOwned: result.wrapper_owned === true, + commandTokenAdded: result.command_token_added === true, + }); + return true; + } catch (updateError) { + const nextError = asError(updateError); + setStatus("error"); + setError(nextError.message); + showErrorToast("Workaround update failed", nextError.message); + return false; + } + }, [appId, applySnapshot, snapshot]); + + const flushSliderUpdate = useCallback(async (): Promise<boolean> => { + const pending = pendingSliderUpdate.current; + if (!pending) return true; + pendingSliderUpdate.current = null; + clearTimeout(pending.timer); + const success = await persistUpdate("dxvkFrameRate", pending.value); + pending.waiters.forEach((resolve) => resolve(success)); + return success; + }, [persistUpdate]); + + const update = useCallback(async (field: WorkaroundField, value: boolean | number): Promise<boolean> => { + if (field === "dxvkFrameRate") { + setError(null); + return new Promise<boolean>((resolve) => { + const pending = pendingSliderUpdate.current || { timer: 0, value: 0, waiters: [] }; + window.clearTimeout(pending.timer); + pending.value = Number(value); + pending.waiters.push(resolve); + pending.timer = window.setTimeout(() => { void flushSliderUpdate(); }, SLIDER_DEBOUNCE_MS); + pendingSliderUpdate.current = pending; + }); + } + const sliderSuccess = await flushSliderUpdate(); + if (!sliderSuccess) return false; + return persistUpdate(field, value); + }, [flushSliderUpdate, persistUpdate]); + + useEffect(() => () => { + const pending = pendingSliderUpdate.current; + if (!pending) return; + window.clearTimeout(pending.timer); + pendingSliderUpdate.current = null; + pending.waiters.forEach((resolve) => resolve(false)); + }, [numericAppId, nonSteam]); + + return useMemo(() => ({ status, snapshot, refresh, update, error }), [error, refresh, snapshot, status, update]); +} diff --git a/src/hooks/useProfileManagement.ts b/src/hooks/useProfileManagement.ts deleted file mode 100644 index a5f2a07..0000000 --- a/src/hooks/useProfileManagement.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { useState, useEffect, useCallback } from "react"; -import { - getProfiles, - createProfile, - deleteProfile, - renameProfile, - setCurrentProfile, - updateProfileConfig, - type ProfilesResult, - type ProfileResult, - type ConfigUpdateResult -} from "../api/lsfgApi"; -import { ConfigurationData } from "../config/configSchema"; -import { showSuccessToast, showErrorToast } from "../utils/toastUtils"; - -export function useProfileManagement() { - const [profiles, setProfiles] = useState<string[]>([]); - const [currentProfile, setCurrentProfileState] = useState<string>("decky-lsfg-vk"); - const [isLoading, setIsLoading] = useState(false); - - // Load profiles on hook initialization - const loadProfiles = useCallback(async () => { - try { - const result: ProfilesResult = await getProfiles(); - if (result.success && result.profiles) { - setProfiles(result.profiles); - if (result.current_profile) { - setCurrentProfileState(result.current_profile); - } - return result; - } else { - console.error("Failed to load profiles:", result.error); - showErrorToast("Failed to load profiles", result.error || "Unknown error"); - return result; - } - } catch (error) { - console.error("Error loading profiles:", error); - showErrorToast("Error loading profiles", String(error)); - return { success: false, error: String(error) }; - } - }, []); - - // Create a new profile - const handleCreateProfile = useCallback(async (profileName: string, sourceProfile?: string) => { - setIsLoading(true); - try { - const result: ProfileResult = await createProfile(profileName, sourceProfile || currentProfile); - if (result.success) { - // Use the normalized name returned from backend (spaces converted to dashes) - const actualProfileName = result.profile_name || profileName; - showSuccessToast("Profile created", `Created profile: ${actualProfileName}`); - await loadProfiles(); - return result; - } else { - console.error("Failed to create profile:", result.error); - showErrorToast("Failed to create profile", result.error || "Unknown error"); - return result; - } - } catch (error) { - console.error("Error creating profile:", error); - showErrorToast("Error creating profile", String(error)); - return { success: false, error: String(error) }; - } finally { - setIsLoading(false); - } - }, [currentProfile, loadProfiles]); - - // Delete a profile - const handleDeleteProfile = useCallback(async (profileName: string) => { - if (profileName === "decky-lsfg-vk") { - showErrorToast("Cannot delete default profile", "The default profile cannot be deleted"); - return { success: false, error: "Cannot delete default profile" }; - } - - setIsLoading(true); - try { - const result: ProfileResult = await deleteProfile(profileName); - if (result.success) { - showSuccessToast("Profile deleted", `Deleted profile: ${profileName}`); - await loadProfiles(); - // If we deleted the current profile, it should have switched to default - if (currentProfile === profileName) { - setCurrentProfileState("decky-lsfg-vk"); - } - return result; - } else { - console.error("Failed to delete profile:", result.error); - showErrorToast("Failed to delete profile", result.error || "Unknown error"); - return result; - } - } catch (error) { - console.error("Error deleting profile:", error); - showErrorToast("Error deleting profile", String(error)); - return { success: false, error: String(error) }; - } finally { - setIsLoading(false); - } - }, [currentProfile, loadProfiles]); - - // Rename a profile - const handleRenameProfile = useCallback(async (oldName: string, newName: string) => { - if (oldName === "decky-lsfg-vk") { - showErrorToast("Cannot rename default profile", "The default profile cannot be renamed"); - return { success: false, error: "Cannot rename default profile" }; - } - - setIsLoading(true); - try { - const result: ProfileResult = await renameProfile(oldName, newName); - if (result.success) { - // Use the normalized name returned from backend (spaces converted to dashes) - const actualNewName = result.profile_name || newName; - showSuccessToast("Profile renamed", `Renamed profile to: ${actualNewName}`); - await loadProfiles(); - // Update current profile if it was renamed - if (currentProfile === oldName) { - setCurrentProfileState(actualNewName); - } - return result; - } else { - console.error("Failed to rename profile:", result.error); - showErrorToast("Failed to rename profile", result.error || "Unknown error"); - return result; - } - } catch (error) { - console.error("Error renaming profile:", error); - showErrorToast("Error renaming profile", String(error)); - return { success: false, error: String(error) }; - } finally { - setIsLoading(false); - } - }, [currentProfile, loadProfiles]); - - // Set the current active profile - const handleSetCurrentProfile = useCallback(async (profileName: string) => { - setIsLoading(true); - try { - const result: ProfileResult = await setCurrentProfile(profileName); - if (result.success) { - setCurrentProfileState(profileName); - showSuccessToast("Profile switched", `Switched to profile: ${profileName}`); - return result; - } else { - console.error("Failed to switch profile:", result.error); - showErrorToast("Failed to switch profile", result.error || "Unknown error"); - return result; - } - } catch (error) { - console.error("Error switching profile:", error); - showErrorToast("Error switching profile", String(error)); - return { success: false, error: String(error) }; - } finally { - setIsLoading(false); - } - }, []); - - // Update configuration for a specific profile - const handleUpdateProfileConfig = useCallback(async (profileName: string, config: ConfigurationData) => { - setIsLoading(true); - try { - const result: ConfigUpdateResult = await updateProfileConfig(profileName, config); - if (result.success) { - return result; - } else { - console.error("Failed to update profile config:", result.error); - showErrorToast("Failed to update profile config", result.error || "Unknown error"); - return result; - } - } catch (error) { - console.error("Error updating profile config:", error); - showErrorToast("Error updating profile config", String(error)); - return { success: false, error: String(error) }; - } finally { - setIsLoading(false); - } - }, [currentProfile]); - - // Initialize profiles on mount - useEffect(() => { - loadProfiles(); - }, [loadProfiles]); - - return { - profiles, - currentProfile, - isLoading, - loadProfiles, - createProfile: handleCreateProfile, - deleteProfile: handleDeleteProfile, - renameProfile: handleRenameProfile, - setCurrentProfile: handleSetCurrentProfile, - updateProfileConfig: handleUpdateProfileConfig - }; -} diff --git a/src/i18n/i18n.ts b/src/i18n/i18n.ts index dc8de8b..31a55cb 100644 --- a/src/i18n/i18n.ts +++ b/src/i18n/i18n.ts @@ -2,8 +2,16 @@ // to generate for localhost/dev, run `build_i18n_json.sh` script import * as languages from "./languages.json"; -const steamLanguageMap: Record<string, string> = - languages.steam_language_map as Record<string, string>; +type LanguageStrings = Record<string, string>; +type Language = { name: string; strings?: LanguageStrings }; +type LanguageData = { + language_metadata: Record<string, Language>; + steam_language_map: Record<string, string>; + [language: string]: LanguageStrings | Record<string, Language> | Record<string, string>; +}; + +const languageData = languages as unknown as LanguageData; +const steamLanguageMap = languageData.steam_language_map; const normalizeLanguage = (language: string): string => { const normalized = language.trim().toLowerCase(); @@ -11,13 +19,13 @@ const normalizeLanguage = (language: string): string => { }; function getLangs() { - const langs = languages.language_metadata; + const langs = languageData.language_metadata; - Object.keys(languages).map((lang) => { + Object.keys(languageData).forEach((lang) => { if (lang === "language_metadata" || lang == "steam_language_map") { return; } - const strs = languages[lang]; + const strs = languageData[lang] as LanguageStrings; if (lang && strs && langs[lang]?.name) { langs[lang].strings = strs; } @@ -27,12 +35,7 @@ function getLangs() { } export const LANGS: { - [key: string]: { - name: string; - strings: { - [key: string]: string; - }; - }; + [key: string]: Language; } = getLangs(); let cachedLang: string | undefined; diff --git a/src/i18n/languages.json b/src/i18n/languages.json index c1a49b8..7e5676b 100644 --- a/src/i18n/languages.json +++ b/src/i18n/languages.json @@ -10,7 +10,7 @@ "CONFIG_FLOW_SCALE_DESC": "内部モーション推定解像度を下げて、パフォーマンスをわずかに向上させます", "CONFIG_BASE_FPS_CAP": "基本FPS上限", "CONFIG_BASE_FPS_CAP_OFF": "オフ", - "CONFIG_BASE_FPS_CAP_DESC": "フレーム倍率適用前のDirectXゲームの基本フレームレート上限。(ゲームの再起動が必要)", + "CONFIG_BASE_FPS_CAP_DESC": "フレーム生成前のDXVKゲームの基本上限。0で無効。ゲームの再起動が必要です。", "CONFIG_PRESENT_MODE": "プレゼンテーションモード", "CONFIG_PRESENT_MODE_FIFO": "FIFO - VSync", "CONFIG_PRESENT_MODE_MAILBOX": "Mailbox", @@ -21,18 +21,16 @@ "CONFIG_HDR_MODE_DESC": "HDRモードを有効化します(HDRをサポートするゲームのみ)", "CONFIG_ENABLE_WSI": "WSIを有効化", "CONFIG_ENABLE_WSI_DESC": "Gamescope WSIレイヤーを再有効化します。ゲームの再起動が必要。", - "CONFIG_ENABLE_WOW64": "32ビットゲーム用WOW64を有効化", - "CONFIG_ENABLE_WOW64_DESC": "32ビットゲームにPROTON_USE_WOW64=1を有効化します(ProtonGEと併用してクラッシュを修正)", + "CONFIG_DISABLE_GAMESCOPE_WSI": "Gamescope WSIを無効化", + "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_MANGOHUD_WORKAROUND": "MangoHudワークアラウンド", - "CONFIG_MANGOHUD_WORKAROUND_DESC": "透明なMangoHudオーバーレイを有効化します。ゲームモードでの2X倍率問題を修正することがあります", + "CONFIG_DISABLE_STEAMDECK_MODE_DESC": "ゲーム固有のSteam Deck互換スイッチを無効化します。ゲームの再起動が必要です。", "CONFIG_DISABLE_VKBASALT": "vkBasaltを無効化", "CONFIG_DISABLE_VKBASALT_DESC": "LSFGと競合する可能性のあるvkBasaltレイヤーを無効化します(Reshade、一部のDeckyプラグイン)", - "CONFIG_FORCE_ENABLE_VKBASALT": "vkBasaltを強制有効化", - "CONFIG_FORCE_ENABLE_VKBASALT_DESC": "ゲームモードのフレームペーシング問題を修正するためにvkBasaltを強制有効化します", - "CONFIG_ENABLE_ZINK": "OpenGLゲーム用Zinkを有効化", - "CONFIG_ENABLE_ZINK_DESC": "OpenGLゲームにVulkanベースのOpenGL実装を使用します(一部のゲームでクラッシュやフリーズが発生する場合があります)", + "CONFIG_ENABLE_ZINK": "OpenGLゲームでZinkを強制", + "CONFIG_ENABLE_ZINK_DESC": "MesaのZink OpenGL-to-Vulkanドライバーを使用します。一部のゲームでクラッシュやフリーズが発生する可能性があります。ゲームの再起動が必要です。", "INSTALL_INSTALLING": "インストール中...", "INSTALL_UNINSTALLING": "アンインストール中...", "INSTALL_UNINSTALL_BTN": "LSFG-VKをアンインストール", @@ -58,21 +56,12 @@ "FLATPAK_ERROR": "エラー", "FLATPAK_ERROR_STATUS": "拡張ステータスの確認に失敗しました", "FLATPAK_ERROR_APPS": "Flatpakアプリケーションの読み込みに失敗しました", - "FLATPAK_STEAM_CONFIG_TITLE": "Steam設定", - "FLATPAK_STEAM_CONFIG_HEADER": "Steam Flatpakショートカットの設定", - "FLATPAK_STEAM_CONFIG_DESC": "Steamでflatpakゲームを開き、歯車アイコンをクリックしてください。", - "FLATPAK_STEAM_CONFIG_IMPORTANT": "重要: 起動オプションではなくターゲット(TARGET)に設定してください", - "FLATPAK_STEP_TRY_FIRST": "まず試す:", - "FLATPAK_STEP_TRY_FULL_PATH": "うまくいかない場合、フルパスを試す:", - "FLATPAK_STEP_FINAL": "最終的な結果はこのようになります:", "FLATPAK_CLOSE": "閉じる", "NERD_LOADING": "情報を読み込み中...", "NERD_DLL_PATH": "DLLパス", "NERD_NOT_AVAILABLE": "利用不可", "NERD_DLL_HASH": "DLL SHA256ハッシュ", "NERD_DETECTION_SOURCE": "検出ソース", - "NERD_LAUNCH_SCRIPT": "起動スクリプト", - "NERD_SCRIPT_NOT_FOUND_PREFIX": "スクリプトが見つかりません:", "NERD_PATH_PREFIX": "パス:", "NERD_NO_CONTENT": "コンテンツなし", "NERD_CONFIG_FILE": "設定ファイル", @@ -97,14 +86,7 @@ "PROFILE_DELETE_DESC_SUFFIX": "この操作は取り消せません。", "PROFILE_DELETE_BTN": "削除", "PROFILE_CANNOT_RENAME_TITLE": "デフォルトプロファイルの名前は変更できません", - "PROFILE_CANNOT_RENAME_MSG": "デフォルトプロファイルの名前は変更できません", - "USAGE_TITLE": "使用方法", - "USAGE_DESC": "「起動オプションをコピー」ボタンをクリックし、Steamゲームの起動オプションに貼り付けてフレーム生成を有効化してください。", - "USAGE_CONFIG_NOTE": "設定は~/.config/lsfg-vk/conf.tomlに保存され、ゲーム実行中もホットリロードされます。", - "CLIPBOARD_COPIED": "クリップボードにコピーしました", - "CLIPBOARD_COPYING": "コピー中...", - "CLIPBOARD_COPY_LAUNCH": "起動オプションをコピー", - "CLIPBOARD_LSFG_FGMOD": "LSFG + DeckyFG" + "PROFILE_CANNOT_RENAME_MSG": "デフォルトプロファイルの名前は変更できません" }, "ko": { "CONTENT_FPS_MULTIPLIER": "FPS 배율", @@ -117,7 +99,7 @@ "CONFIG_FLOW_SCALE_DESC": "내부 모션 추정 해상도를 낮춰 성능을 약간 향상시킵니다", "CONFIG_BASE_FPS_CAP": "기본 FPS 상한", "CONFIG_BASE_FPS_CAP_OFF": "끄기", - "CONFIG_BASE_FPS_CAP_DESC": "프레임 배율 적용 전 DirectX 게임의 기본 프레임 상한. (게임 재시작 필요)", + "CONFIG_BASE_FPS_CAP_DESC": "프레임 생성 전 DXVK 게임의 기본 제한입니다. 0은 비활성화합니다. 게임 재시작 필요.", "CONFIG_PRESENT_MODE": "프레젠테이션 모드", "CONFIG_PRESENT_MODE_FIFO": "FIFO - VSync", "CONFIG_PRESENT_MODE_MAILBOX": "Mailbox", @@ -128,18 +110,16 @@ "CONFIG_HDR_MODE_DESC": "HDR 모드를 활성화합니다 (HDR을 지원하는 게임에만 해당)", "CONFIG_ENABLE_WSI": "WSI 활성화", "CONFIG_ENABLE_WSI_DESC": "Gamescope WSI 레이어를 다시 활성화합니다. 게임 재시작 필요.", - "CONFIG_ENABLE_WOW64": "32비트 게임용 WOW64 활성화", - "CONFIG_ENABLE_WOW64_DESC": "32비트 게임에 PROTON_USE_WOW64=1을 활성화합니다 (크래시 수정을 위해 ProtonGE와 함께 사용)", + "CONFIG_DISABLE_GAMESCOPE_WSI": "Gamescope WSI 비활성화", + "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_MANGOHUD_WORKAROUND": "MangoHud 우회", - "CONFIG_MANGOHUD_WORKAROUND_DESC": "투명한 MangoHud 오버레이를 활성화합니다. 게임 모드에서 2X 배율 문제를 수정하는 데 도움이 될 수 있습니다", + "CONFIG_DISABLE_STEAMDECK_MODE_DESC": "게임별 Steam Deck 호환 스위치를 비활성화합니다. 게임 재시작 필요.", "CONFIG_DISABLE_VKBASALT": "vkBasalt 비활성화", "CONFIG_DISABLE_VKBASALT_DESC": "LSFG와 충돌할 수 있는 vkBasalt 레이어를 비활성화합니다 (Reshade, 일부 Decky 플러그인)", - "CONFIG_FORCE_ENABLE_VKBASALT": "vkBasalt 강제 활성화", - "CONFIG_FORCE_ENABLE_VKBASALT_DESC": "게임 모드에서 프레임 페이싱 문제 수정을 위해 vkBasalt를 강제 활성화합니다", - "CONFIG_ENABLE_ZINK": "OpenGL 게임에 Zink 활성화", - "CONFIG_ENABLE_ZINK_DESC": "OpenGL 게임에 Vulkan 기반 OpenGL 구현을 사용합니다 (일부 게임에서 크래시나 멈춤이 발생할 수 있습니다)", + "CONFIG_ENABLE_ZINK": "OpenGL 게임에 Zink 강제", + "CONFIG_ENABLE_ZINK_DESC": "Mesa의 Zink OpenGL-to-Vulkan 드라이버를 사용합니다. 일부 게임에서 충돌 또는 멈춤이 발생할 수 있으며 게임 재시작이 필요합니다.", "INSTALL_INSTALLING": "설치 중...", "INSTALL_UNINSTALLING": "제거 중...", "INSTALL_UNINSTALL_BTN": "LSFG-VK 제거", @@ -165,21 +145,12 @@ "FLATPAK_ERROR": "오류", "FLATPAK_ERROR_STATUS": "확장 상태 확인 실패", "FLATPAK_ERROR_APPS": "Flatpak 애플리케이션 로드 실패", - "FLATPAK_STEAM_CONFIG_TITLE": "Steam 설정", - "FLATPAK_STEAM_CONFIG_HEADER": "Steam Flatpak 단축키 설정", - "FLATPAK_STEAM_CONFIG_DESC": "Steam에서 Flatpak 게임을 열고 톱니바퀴를 클릭하세요.", - "FLATPAK_STEAM_CONFIG_IMPORTANT": "중요: 실행 옵션이 아닌 대상(TARGET)에 설정하세요", - "FLATPAK_STEP_TRY_FIRST": "먼저 시도:", - "FLATPAK_STEP_TRY_FULL_PATH": "작동하지 않으면 전체 경로 시도:", - "FLATPAK_STEP_FINAL": "최종 결과는 다음과 같아야 합니다:", "FLATPAK_CLOSE": "닫기", "NERD_LOADING": "정보 불러오는 중...", "NERD_DLL_PATH": "DLL 경로", "NERD_NOT_AVAILABLE": "사용 불가", "NERD_DLL_HASH": "DLL SHA256 해시", "NERD_DETECTION_SOURCE": "감지 소스", - "NERD_LAUNCH_SCRIPT": "실행 스크립트", - "NERD_SCRIPT_NOT_FOUND_PREFIX": "스크립트 없음:", "NERD_PATH_PREFIX": "경로:", "NERD_NO_CONTENT": "내용 없음", "NERD_CONFIG_FILE": "설정 파일", @@ -204,14 +175,7 @@ "PROFILE_DELETE_DESC_SUFFIX": "이 작업은 취소할 수 없습니다.", "PROFILE_DELETE_BTN": "삭제", "PROFILE_CANNOT_RENAME_TITLE": "기본 프로필 이름 변경 불가", - "PROFILE_CANNOT_RENAME_MSG": "기본 프로필의 이름은 변경할 수 없습니다", - "USAGE_TITLE": "사용 방법", - "USAGE_DESC": "\"실행 옵션 복사\" 버튼을 클릭한 후, Steam 게임의 실행 옵션에 붙여넣어 프레임 생성을 활성화하세요.", - "USAGE_CONFIG_NOTE": "설정은 ~/.config/lsfg-vk/conf.toml에 저장되며 게임 실행 중에도 즉시 반영됩니다.", - "CLIPBOARD_COPIED": "클립보드에 복사됨", - "CLIPBOARD_COPYING": "복사 중...", - "CLIPBOARD_COPY_LAUNCH": "실행 옵션 복사", - "CLIPBOARD_LSFG_FGMOD": "LSFG + DeckyFG" + "PROFILE_CANNOT_RENAME_MSG": "기본 프로필의 이름은 변경할 수 없습니다" }, "language_metadata": { "ko": { @@ -252,7 +216,7 @@ "CONFIG_FLOW_SCALE_DESC": "Lowers internal motion estimation resolution, improving performance slightly", "CONFIG_BASE_FPS_CAP": "Base FPS Cap", "CONFIG_BASE_FPS_CAP_OFF": "Off", - "CONFIG_BASE_FPS_CAP_DESC": "Base framerate cap for DirectX games, before frame multiplier. (Requires game restart to apply)", + "CONFIG_BASE_FPS_CAP_DESC": "Base cap for DXVK-backed games before frame generation; 0 disables. Requires game restart to apply.", "CONFIG_PRESENT_MODE": "Present Mode", "CONFIG_PRESENT_MODE_FIFO": "FIFO - VSync", "CONFIG_PRESENT_MODE_MAILBOX": "Mailbox", @@ -263,18 +227,16 @@ "CONFIG_HDR_MODE_DESC": "Enables HDR mode (only for games that support HDR)", "CONFIG_ENABLE_WSI": "Enable WSI", "CONFIG_ENABLE_WSI_DESC": "Re-Enable Gamescope WSI Layer. Requires game restart to apply.", - "CONFIG_ENABLE_WOW64": "Enable WOW64 for 32-bit games", - "CONFIG_ENABLE_WOW64_DESC": "Enables PROTON_USE_WOW64=1 for 32-bit games (Use with ProtonGE to fix crashing)", + "CONFIG_DISABLE_GAMESCOPE_WSI": "Disable Gamescope WSI", + "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 Steam Deck mode (Unlocks hidden settings in some games)", - "CONFIG_MANGOHUD_WORKAROUND": "MangoHud Workaround", - "CONFIG_MANGOHUD_WORKAROUND_DESC": "Enables a transparent mangohud overlay, sometimes fixes issues with 2X multiplier in game mode", + "CONFIG_DISABLE_STEAMDECK_MODE_DESC": "Disables a game-specific Steam Deck compatibility switch. Requires game restart to apply.", "CONFIG_DISABLE_VKBASALT": "Disable vkBasalt", "CONFIG_DISABLE_VKBASALT_DESC": "Disables vkBasalt layer which can conflict with LSFG (Reshade, some Decky plugins)", - "CONFIG_FORCE_ENABLE_VKBASALT": "Force Enable vkBasalt", - "CONFIG_FORCE_ENABLE_VKBASALT_DESC": "Force vkBasalt to engage to fix framepacing issues in gamemode", - "CONFIG_ENABLE_ZINK": "Enable Zink for OpenGL Games", - "CONFIG_ENABLE_ZINK_DESC": "Use Vulkan-based OpenGL implementation for OpenGL games (may cause crashes or freezes with some games)", + "CONFIG_ENABLE_ZINK": "Force Zink for OpenGL Games", + "CONFIG_ENABLE_ZINK_DESC": "Uses Mesa's Zink OpenGL-to-Vulkan driver. May cause crashes or freezes with some games. Requires game restart to apply.", "INSTALL_INSTALLING": "Installing...", "INSTALL_UNINSTALLING": "Uninstalling...", "INSTALL_UNINSTALL_BTN": "Uninstall LSFG-VK", @@ -300,21 +262,12 @@ "FLATPAK_ERROR": "Error", "FLATPAK_ERROR_STATUS": "Failed to check extension status", "FLATPAK_ERROR_APPS": "Failed to load Flatpak applications", - "FLATPAK_STEAM_CONFIG_TITLE": "Steam Configuration", - "FLATPAK_STEAM_CONFIG_HEADER": "Configure Steam Flatpak Shortcuts", - "FLATPAK_STEAM_CONFIG_DESC": "In Steam, open your flatpak game and click the cog wheel.", - "FLATPAK_STEAM_CONFIG_IMPORTANT": "IMPORTANT: Set this in TARGET (NOT LAUNCH OPTIONS)", - "FLATPAK_STEP_TRY_FIRST": "Try first:", - "FLATPAK_STEP_TRY_FULL_PATH": "If that doesn't work, try full path:", - "FLATPAK_STEP_FINAL": "Final result should look like:", "FLATPAK_CLOSE": "Close", "NERD_LOADING": "Loading information...", "NERD_DLL_PATH": "DLL Path", "NERD_NOT_AVAILABLE": "Not available", "NERD_DLL_HASH": "DLL SHA256 Hash", "NERD_DETECTION_SOURCE": "Detection Source", - "NERD_LAUNCH_SCRIPT": "Launch Script", - "NERD_SCRIPT_NOT_FOUND_PREFIX": "Script not found:", "NERD_PATH_PREFIX": "Path:", "NERD_NO_CONTENT": "No content", "NERD_CONFIG_FILE": "Configuration File", @@ -339,13 +292,6 @@ "PROFILE_DELETE_DESC_SUFFIX": "? This action cannot be undone.", "PROFILE_DELETE_BTN": "Delete", "PROFILE_CANNOT_RENAME_TITLE": "Cannot rename default profile", - "PROFILE_CANNOT_RENAME_MSG": "The default profile cannot be renamed", - "USAGE_TITLE": "Usage Instructions", - "USAGE_DESC": "Click \"Copy Launch Option\" button, then paste it into your Steam game's launch options to enable frame generation.", - "USAGE_CONFIG_NOTE": "The configuration is stored in ~/.config/lsfg-vk/conf.toml and hot-reloads while games are running.", - "CLIPBOARD_COPIED": "Copied to clipboard", - "CLIPBOARD_COPYING": "Copying...", - "CLIPBOARD_COPY_LAUNCH": "Copy Launch Option", - "CLIPBOARD_LSFG_FGMOD": "LSFG + DeckyFG" + "PROFILE_CANNOT_RENAME_MSG": "The default profile cannot be renamed" } } diff --git a/src/styles.ts b/src/styles.ts new file mode 100644 index 0000000..58c6b01 --- /dev/null +++ b/src/styles.ts @@ -0,0 +1,51 @@ +export const tabStyles = ` + .lsfg-vk-tabs > div > div:first-child { + background: #0D141C; + box-shadow: none; + backdrop-filter: none; + } + + .lsfg-vk-tabs [role="tabpanel"] { + padding-left: 8px !important; + padding-right: 8px !important; + } + + .lsfg-vk-tabs .lsfg-vk-tab-content { + padding-bottom: 96px; // workaround for in-game bottom bar padding behaving differently than in launcher, remove later? + } + + .lsfg-vk-tabs [role="tablist"] { + display: flex; + flex-wrap: nowrap; + justify-content: center; + } + + .lsfg-vk-tabs [role="tab"] { + flex: 0 1 auto; + min-width: 0; + box-sizing: border-box; + padding-left: 6px !important; + padding-right: 6px !important; + display: flex !important; + align-items: center; + justify-content: center; + } + + .lsfg-vk-tabs [role="tab"] svg { + display: block; + margin: 0; + } + + .lsfg-vk-tabs--content-focused [role="tablist"][aria-orientation="horizontal"], + .lsfg-vk-tabs--content-focused [role="tablist"][aria-orientation="horizontal"] > div, + .lsfg-vk-tabs--content-focused [role="tablist"][aria-orientation="horizontal"] > div > div, + .lsfg-vk-tabs--content-focused [role="tablist"][aria-orientation="horizontal"] [role="tab"] { + animation: none !important; + transition: none !important; + } + + .lsfg-vk-tabs--content-focused [role="tablist"][aria-orientation="horizontal"] > div > div { + scroll-behavior: auto !important; + scroll-snap-type: none !important; + } +`; diff --git a/src/types.d.ts b/src/types.d.ts index dfc0472..4adad61 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -12,3 +12,27 @@ declare module "*.jpg" { const content: string; export default content; } + +interface SteamAppDetails { + strLaunchOptions?: string; + strShortcutLaunchOptions?: string; +} + +interface SteamAppDetailsRegistration { + unregister: () => void; +} + +interface SteamApps { + RegisterForAppDetails( + appId: number, + callback: (details: SteamAppDetails) => void, + ): SteamAppDetailsRegistration; + SetAppLaunchOptions(appId: number, options: string): void | Promise<void>; + SetShortcutLaunchOptions(appId: number, options: string): void | Promise<void>; + TerminateApp(appId: string, param1: boolean): void; + GetAllShortcuts?(): Promise<unknown[]>; +} + +declare const SteamClient: { + Apps: SteamApps; +}; diff --git a/src/utils/clipboardUtils.ts b/src/utils/clipboardUtils.ts deleted file mode 100644 index 8a04caa..0000000 --- a/src/utils/clipboardUtils.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * 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> { - const tempInput = document.createElement('input'); - tempInput.value = text; - tempInput.style.position = 'absolute'; - tempInput.style.left = '-9999px'; - document.body.appendChild(tempInput); - - try { - tempInput.focus(); - tempInput.select(); - - let copySuccess = false; - try { - if (document.execCommand('copy')) { - copySuccess = true; - } - } catch (e) { - try { - await navigator.clipboard.writeText(text); - copySuccess = true; - } catch (clipboardError) { - console.error('Both copy methods failed:', e, clipboardError); - } - } - - return copySuccess; - } finally { - 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) { - 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/gameTargets.ts b/src/utils/gameTargets.ts new file mode 100644 index 0000000..8922e98 --- /dev/null +++ b/src/utils/gameTargets.ts @@ -0,0 +1,75 @@ +import type { GameConfigEntry, InstalledGame, WorkaroundApp } from "../api/lsfgApi"; + +export type GameSource = "steam" | "nonSteam" | "unknown"; +export type KnownGameSource = Exclude<GameSource, "unknown">; + +export interface GameTarget extends InstalledGame { + configured: boolean; + source: GameSource; +} + +export function sourceFromNonSteam(nonSteam: boolean): KnownGameSource { + return nonSteam ? "nonSteam" : "steam"; +} + +export function getTargetSource( + appid: string, + installedGames: InstalledGame[], + workaroundApps: WorkaroundApp[], +): GameSource { + const workaround = workaroundApps.find((item) => item.appid === appid); + if (workaround) return sourceFromNonSteam(workaround.non_steam); + + const installed = installedGames.find((game) => game.appid === appid); + return installed ? sourceFromNonSteam(installed.nonSteam) : "unknown"; +} + +export function mergeGameTargets( + configs: GameConfigEntry[], + installedGames: InstalledGame[], + workaroundApps: WorkaroundApp[], + runningGame: GameTarget | null = null, +): GameTarget[] { + const configuredIds = new Set(configs.map((game) => game.appid)); + const targets = installedGames.map((game) => { + const source = getTargetSource(game.appid, installedGames, workaroundApps); + return { + ...game, + nonSteam: source === "nonSteam", + source, + configured: configuredIds.has(game.appid), + }; + }); + + for (const game of configs) { + if (targets.some((target) => target.appid === game.appid)) continue; + const source = getTargetSource(game.appid, installedGames, workaroundApps); + targets.push({ + appid: game.appid, + name: game.profile || `App ${game.appid}`, + nonSteam: source === "nonSteam", + source, + configured: true, + }); + } + + if ( + runningGame + && !targets.some((target) => target.appid === runningGame.appid) + && (runningGame.configured || runningGame.source !== "unknown") + ) { + targets.unshift(runningGame); + } + + return targets; +} + +export function targetsForSource(targets: GameTarget[], source: KnownGameSource): GameTarget[] { + return targets.filter((target) => target.source === source || (target.source === "unknown" && target.configured)); +} + +export function sourceLabel(source: GameSource): string { + if (source === "nonSteam") return "Non-Steam"; + if (source === "steam") return "Steam"; + return "Unknown source"; +} diff --git a/src/utils/nowPlaying.ts b/src/utils/nowPlaying.ts new file mode 100644 index 0000000..a307f5c --- /dev/null +++ b/src/utils/nowPlaying.ts @@ -0,0 +1,86 @@ +import type { FlatpakApp, RunningFlatpakApp } from "../api/lsfgApi"; +import type { GameTarget } from "./gameTargets"; + +export type NowPlayingTarget = + | { + kind: "flatpak"; + app: FlatpakApp; + launcher: GameTarget | null; + } + | { + kind: "steam"; + game: GameTarget; + } + | { + kind: "nonSteam"; + game: GameTarget; + }; + +function numericValue(value: number | null | undefined): number { + return typeof value === "number" && Number.isFinite(value) ? value : -1; +} + +function numericPid(value: string | undefined): number { + return value && /^\d+$/.test(value) ? Number(value) : -1; +} + +function compareRunningProcesses(a: RunningFlatpakApp, b: RunningFlatpakApp): number { + if (a.active !== b.active) return a.active ? -1 : 1; + const startDifference = numericValue(b.start_time) - numericValue(a.start_time); + if (startDifference !== 0) return startDifference; + return numericPid(b.pid) - numericPid(a.pid); +} + +export function selectMostRecentRunningFlatpak( + apps: FlatpakApp[], + runningApps: RunningFlatpakApp[], +): FlatpakApp | null { + const newestProcessByApp = new Map<string, RunningFlatpakApp>(); + for (const running of runningApps) { + const current = newestProcessByApp.get(running.app_id); + if (!current || compareRunningProcesses(running, current) < 0) { + newestProcessByApp.set(running.app_id, running); + } + } + + const candidates = Array.from(newestProcessByApp.values()) + .map((running) => ({ + running, + app: apps.find((app) => app.app_id === running.app_id) || null, + })) + .filter((candidate): candidate is { running: RunningFlatpakApp; app: FlatpakApp } => candidate.app !== null); + const activeCandidates = candidates.filter(({ running }) => running.active); + const eligibleCandidates = activeCandidates.length > 0 + ? activeCandidates + : candidates.length === 1 + ? candidates + : []; + + eligibleCandidates.sort((a, b) => { + const processDifference = compareRunningProcesses(a.running, b.running); + if (processDifference !== 0) return processDifference; + return a.running.app_id.localeCompare(b.running.app_id); + }); + + return eligibleCandidates[0]?.app || null; +} + +export function resolveNowPlayingTarget( + runningGame: GameTarget | null, + runningFlatpak: FlatpakApp | null, +): NowPlayingTarget | null { + if (runningGame?.source === "steam") { + return runningGame.configured ? { kind: "steam", game: runningGame } : null; + } + if (runningFlatpak) { + return { + kind: "flatpak", + app: runningFlatpak, + launcher: runningGame?.source === "nonSteam" ? runningGame : null, + }; + } + if (runningGame?.source === "nonSteam" && runningGame.configured) { + return { kind: "nonSteam", game: runningGame }; + } + return null; +} diff --git a/src/utils/steamLaunchOptions.ts b/src/utils/steamLaunchOptions.ts new file mode 100644 index 0000000..83c46f2 --- /dev/null +++ b/src/utils/steamLaunchOptions.ts @@ -0,0 +1,409 @@ +const DEFAULT_WRAPPER_PATH = "~/.lsfg"; +const COMMAND_TOKEN = "%command%"; + +export const LEGACY_WRAPPER_TOKENS = new Set([ + "~/lsfg", + "~/.local/bin/lsfg", + "~/.local/bin/lsfg-vk-experimental", + "~/.local/bin/mako-run", + "mako-run", + "~/.local/bin/mako-launch", + "mako-launch", +]); + +const LEGACY_ABSOLUTE_WRAPPER = /^\/(?:home|Users)\/[^/]+\/(?:lsfg|\.local\/bin\/(?:lsfg|lsfg-vk-experimental|mako-run|mako-launch))$/; +const MANAGED_ENV_KEYS = new Set([ + "ENABLE_GAMESCOPE_WSI", "DISABLE_GAMESCOPE_WSI", "DXVK_HDR", "SteamDeck", + "DISABLE_VKBASALT", "ENABLE_VKBASALT", "MESA_LOADER_DRIVER_OVERRIDE", + "__GLX_VENDOR_LIBRARY_NAME", "GALLIUM_DRIVER", "DXVK_FRAME_RATE", +]); +const DXVK_FRAME_RATE_SEGMENT = /^(?:dxvk\.maxFrameRate|dxgi\.maxFrameRate|d3d9\.maxFrameRate)\s*=/i; + +interface LaunchToken { raw: string; value: string; } +export interface SteamLaunchOptionsSnapshot { + appId: number; + nonSteam: boolean; + options: string; + details: SteamAppDetails; +} +export interface WrapperIntegrationResult { + snapshot: SteamLaunchOptionsSnapshot; + commandTokenAdded: boolean; + changed: boolean; +} + +function asError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} + +function apps(): Partial<SteamApps> | undefined { + return (globalThis as typeof globalThis & { SteamClient?: { Apps?: Partial<SteamApps> } }).SteamClient?.Apps; +} + +function validateAppId(appId: number): void { + if (!Number.isSafeInteger(appId) || appId <= 0) throw new Error("Invalid Steam App ID"); +} + +function timer() { + const host = typeof window !== "undefined" ? window : globalThis; + return { + set: (handler: () => void, ms: number) => host.setTimeout(handler, ms) as unknown as number, + clear: (id: number) => host.clearTimeout(id), + }; +} + +function snapshot(appId: number, nonSteam: boolean, details: SteamAppDetails): SteamLaunchOptionsSnapshot { + return { + appId, + nonSteam, + options: nonSteam ? details.strShortcutLaunchOptions || "" : details.strLaunchOptions || "", + details, + }; +} + +function registerDetails(appId: number, onDetails: (details: SteamAppDetails) => boolean | void): () => void { + validateAppId(appId); + const register = apps()?.RegisterForAppDetails; + if (!register) throw new Error("Steam app-details API is unavailable"); + let active = true; + let registration: SteamAppDetailsRegistration | undefined; + const unsubscribe = () => { + active = false; + try { registration?.unregister(); } catch {} + }; + registration = register.call(apps(), appId, (details) => { + if (active && onDetails(details || {}) === false) unsubscribe(); + }); + if (!active) unsubscribe(); + return unsubscribe; +} + +export async function readSteamLaunchOptions(appId: number, nonSteam: boolean): Promise<SteamLaunchOptionsSnapshot> { + return new Promise((resolve, reject) => { + let done = false; + let unsubscribe = () => {}; + const clock = timer(); + const timeout = clock.set(() => finish(new Error("Timed out reading Steam app details")), 5000); + const finish = (error?: unknown, details?: SteamAppDetails) => { + if (done) return; + done = true; + clock.clear(timeout); + unsubscribe(); + if (error) reject(asError(error)); + else resolve(snapshot(appId, nonSteam, details || {})); + }; + try { + unsubscribe = registerDetails(appId, (details) => { + finish(undefined, details); + return false; + }); + } catch (error) { + finish(error); + } + }); +} + +export function subscribeSteamLaunchOptions( + appId: number, + nonSteam: boolean, + onSnapshot: (value: SteamLaunchOptionsSnapshot) => void, + onError: (error: Error) => void, +): () => void { + return registerDetails(appId, (details) => { + try { onSnapshot(snapshot(appId, nonSteam, details)); } + catch (error) { onError(asError(error)); } + }); +} + +function decodeToken(raw: string): string { + let value = ""; + let quote: "'" | '"' | null = null; + for (let i = 0; i < raw.length; i++) { + const c = raw[i]; + if (c === "\\" && quote !== "'" && i + 1 < raw.length) value += raw[++i]; + else if (quote) { if (c === quote) quote = null; else value += c; } + else if (c === "'" || c === '"') quote = c; + else value += c; + } + return value; +} + +function tokenize(options: string): LaunchToken[] { + const tokens: LaunchToken[] = []; + let start = -1; + let quote: "'" | '"' | null = null; + let escaped = false; + const push = (end: number) => { + if (start < 0) return; + const raw = options.slice(start, end); + tokens.push({ raw, value: decodeToken(raw) }); + start = -1; + }; + for (let i = 0; i < options.length; i++) { + const c = options[i]; + if (start < 0) { if (/\s/.test(c)) continue; start = i; } + if (escaped) escaped = false; + else if (c === "\\" && quote !== "'") escaped = true; + else if (quote) { if (c === quote) quote = null; } + else if (c === "'" || c === '"') quote = c; + else if (/\s/.test(c)) push(i); + } + push(options.length); + return tokens; +} + +const serialize = (tokens: readonly LaunchToken[]) => tokens.map(({ raw }) => raw).join(" "); +function isCommandToken(token: LaunchToken): boolean { + return token.value.toLowerCase() === COMMAND_TOKEN; +} + +function isMalformedCommandToken(token: LaunchToken): boolean { + const value = token.value.toLowerCase(); + return value === "%command" || value === "command%"; +} + +function normalizeCommandTokens(tokens: LaunchToken[]): void { + for (const token of tokens) { + if (isCommandToken(token) || isMalformedCommandToken(token)) { + token.raw = COMMAND_TOKEN; + token.value = COMMAND_TOKEN; + } + } +} + +const commandIndex = (tokens: readonly LaunchToken[]) => tokens.findIndex(isCommandToken); +const isAssignment = (token: LaunchToken) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(token.value); +const isLegacyToken = (value: string) => LEGACY_WRAPPER_TOKENS.has(value) || LEGACY_ABSOLUTE_WRAPPER.test(value); +const isWrapperToken = (value: string, wrapperPath: string) => decodeToken(value) === wrapperPath || isLegacyWrapperToken(value); + +export const normalizeLaunchOptions = (options: string) => serialize(tokenize(options)); +export const isLegacyWrapperToken = (value: string) => isLegacyToken(decodeToken(value)); + +function removeMatchingWrappers(tokens: LaunchToken[], predicate: (value: string) => boolean): boolean { + const command = commandIndex(tokens); + const prefixEnd = command >= 0 ? command : tokens.length; + const kept = tokens.filter((token, i) => i >= prefixEnd || !predicate(token.value)); + if (kept.length === tokens.length) return false; + tokens.splice(0, tokens.length, ...kept); + return true; +} + +function installLaunchOption( + options: string, + wrapperPath = DEFAULT_WRAPPER_PATH, +) { + const tokens = tokenize(options); + normalizeCommandTokens(tokens); + removeMatchingWrappers(tokens, isLegacyToken); + let command = commandIndex(tokens); + if (command >= 0) { + if (tokens[command - 1]?.value === wrapperPath) return { options: serialize(tokens), commandTokenAdded: false }; + removeMatchingWrappers(tokens, (value) => decodeToken(value) === wrapperPath); + command = commandIndex(tokens); + tokens.splice(command, 0, { raw: wrapperPath, value: wrapperPath }); + return { options: serialize(tokens), commandTokenAdded: false }; + } + + const existingWrapper = tokens.findIndex((token) => decodeToken(token.value) === wrapperPath); + if (existingWrapper >= 0) { + tokens.splice(existingWrapper + 1, 0, { raw: COMMAND_TOKEN, value: COMMAND_TOKEN }); + return { options: serialize(tokens), commandTokenAdded: true }; + } + + let insertion = 0; + while (insertion < tokens.length && isAssignment(tokens[insertion])) insertion++; + tokens.splice(insertion, 0, + { raw: wrapperPath, value: wrapperPath }, + { raw: COMMAND_TOKEN, value: COMMAND_TOKEN }, + ); + return { options: serialize(tokens), commandTokenAdded: true }; +} + +export function installWrapperLaunchOption(options: string, wrapperPath = DEFAULT_WRAPPER_PATH) { + return installLaunchOption(options, wrapperPath); +} + +export function removeWrapperLaunchOption( + options: string, + wrapperPath = DEFAULT_WRAPPER_PATH, + commandTokenAdded = false, +): string { + const tokens = tokenize(options); + normalizeCommandTokens(tokens); + if (removeMatchingWrappers(tokens, (value) => isWrapperToken(value, wrapperPath)) && commandTokenAdded) { + const command = commandIndex(tokens); + if (command >= 0) tokens.splice(command, 1); + } + return serialize(tokens); +} + +function encodeAssignmentValue(value: string): string { + return /^[A-Za-z0-9_./:+,%=-]+$/.test(value) + ? value + : `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; +} + +export function cleanupPluginAssignments(options: string): string { + const tokens = tokenize(options); + const command = commandIndex(tokens); + const prefixEnd = command >= 0 ? command : tokens.length; + return serialize(tokens.flatMap((token, i) => { + if (i >= prefixEnd || !isAssignment(token)) return [token]; + const split = token.value.indexOf("="); + const key = token.value.slice(0, split); + if (key === "DXVK_CONFIG") { + const value = token.value.slice(split + 1).split(";").map((part) => part.trim()) + .filter((part) => part && !DXVK_FRAME_RATE_SEGMENT.test(part)).join("; "); + return value ? [{ raw: `DXVK_CONFIG=${encodeAssignmentValue(value)}`, value: `DXVK_CONFIG=${value}` }] : []; + } + return MANAGED_ENV_KEYS.has(key) ? [] : [token]; + })); +} + +export function cleanupLegacyLaunchOptions(options: string): string { + const tokens = tokenize(options); + removeMatchingWrappers(tokens, isLegacyToken); + return serialize(tokens); +} +export const cleanupPluginLaunchOptions = (options: string, wrapperPath = DEFAULT_WRAPPER_PATH) => + cleanupPluginAssignments(removeWrapperLaunchOption(options, wrapperPath)); +export const cleanupLegacyWrapper = cleanupPluginLaunchOptions; +export function hasWrapperLaunchIntegration(options: string, wrapperPath = DEFAULT_WRAPPER_PATH): boolean { + const tokens = tokenize(options); + const command = commandIndex(tokens); + return command > 0 && tokens[command - 1].value === wrapperPath; +} + +export function isWrapperIntegrationInstalled( + steam: SteamLaunchOptionsSnapshot, + _nonSteam: boolean, + wrapperPath = DEFAULT_WRAPPER_PATH, +): boolean { + return hasWrapperLaunchIntegration(steam.options, wrapperPath); +} + +const queues = new Map<string, Promise<unknown>>(); +function queued<T>(appId: number, nonSteam: boolean, operation: () => Promise<T>): Promise<T> { + const key = `${nonSteam ? "shortcut" : "app"}:${appId}`; + const previous = queues.get(key) || Promise.resolve(); + const current = previous.catch(() => undefined).then(operation); + const cleanup = current.then( + () => { if (queues.get(key) === cleanup) queues.delete(key); }, + () => { if (queues.get(key) === cleanup) queues.delete(key); }, + ); + queues.set(key, cleanup); + return current; +} + +async function waitFor( + appId: number, + nonSteam: boolean, + matches: (value: SteamLaunchOptionsSnapshot) => boolean, + message: string, +): Promise<SteamLaunchOptionsSnapshot> { + const deadline = Date.now() + 5000; + let lastError: Error | null = null; + while (Date.now() <= deadline) { + try { + const value = await readSteamLaunchOptions(appId, nonSteam); + if (matches(value)) return value; + } catch (error) { lastError = asError(error); } + if (Date.now() < deadline) await new Promise((resolve) => timer().set(resolve as () => void, 100)); + } + throw lastError ? new Error(`${message}: ${lastError.message}`) : new Error(`${message} before the readback timeout`); +} + +async function writeVerified( + appId: number, + nonSteam: boolean, + previous: string, + next: string, + write: (value: string) => Promise<void>, + message: string, +): Promise<SteamLaunchOptionsSnapshot> { + try { + await write(next); + return await waitFor(appId, nonSteam, (value) => normalizeLaunchOptions(value.options) === normalizeLaunchOptions(next), message); + } catch (error) { + const failure = asError(error); + try { + await write(previous); + await waitFor(appId, nonSteam, (value) => normalizeLaunchOptions(value.options) === normalizeLaunchOptions(previous), "Steam did not restore the previous launch options"); + } catch (rollback) { + throw new Error(`${failure.message}; rollback also failed: ${asError(rollback).message}`); + } + throw failure; + } +} + +function writeOptions(appId: number, nonSteam: boolean, value: string): Promise<void> { + const setter = nonSteam ? apps()?.SetShortcutLaunchOptions : apps()?.SetAppLaunchOptions; + if (!setter) return Promise.reject(new Error(`Steam ${nonSteam ? "shortcut " : ""}launch options API is unavailable`)); + return Promise.resolve(setter.call(apps(), appId, value)); +} + +export function updateSteamLaunchOptions( + appId: number, + nonSteam: boolean, + transform: (options: string) => string, +): Promise<SteamLaunchOptionsSnapshot> { + return queued(appId, nonSteam, async () => { + const current = await readSteamLaunchOptions(appId, nonSteam); + const next = transform(current.options); + return next === current.options ? current : writeVerified( + appId, nonSteam, current.options, next, + (value) => writeOptions(appId, nonSteam, value), + "Steam did not accept the launch options", + ); + }); +} + +export function installWrapperIntegration( + appId: number, + nonSteam: boolean, + wrapperPath: string, + commandTokenAdded = false, +): Promise<WrapperIntegrationResult> { + return queued(appId, nonSteam, async () => { + const current = await readSteamLaunchOptions(appId, nonSteam); + const cleaned = cleanupPluginAssignments(cleanupLegacyLaunchOptions(current.options)); + const alreadyInstalled = hasWrapperLaunchIntegration(current.options, wrapperPath); + const rewrite = installLaunchOption(cleaned, wrapperPath); + if (rewrite.options === current.options) return { snapshot: current, commandTokenAdded, changed: false }; + const value = await writeVerified( + appId, nonSteam, current.options, rewrite.options, + (options) => writeOptions(appId, nonSteam, options), + "Steam did not accept the launch options", + ); + return { + snapshot: value, + commandTokenAdded: alreadyInstalled ? commandTokenAdded : commandTokenAdded || rewrite.commandTokenAdded, + changed: true, + }; + }); +} + +export function removeWrapperIntegration( + appId: number, + nonSteam: boolean, + wrapperPath: string, + commandTokenAdded = false, +): Promise<SteamLaunchOptionsSnapshot> { + return queued(appId, nonSteam, async () => { + const current = await readSteamLaunchOptions(appId, nonSteam); + const next = cleanupPluginAssignments(removeWrapperLaunchOption(current.options, wrapperPath, commandTokenAdded)); + return next === current.options ? current : writeVerified( + appId, nonSteam, current.options, next, + (options) => writeOptions(appId, nonSteam, options), + "Steam did not clean the launch options", + ); + }); +} + +export const cleanupLegacySteamLaunchOptions = ( + appId: number, + nonSteam: boolean, + wrapperPath = DEFAULT_WRAPPER_PATH, +) => updateSteamLaunchOptions(appId, nonSteam, (options) => cleanupPluginLaunchOptions(options, wrapperPath)); + +export const getDefaultWrapperPath = () => DEFAULT_WRAPPER_PATH; diff --git a/src/utils/toastUtils.ts b/src/utils/toastUtils.ts index dce0a59..c41f4c0 100644 --- a/src/utils/toastUtils.ts +++ b/src/utils/toastUtils.ts @@ -1,8 +1,3 @@ -/** - * Centralized toast notification utilities - * Provides consistent success/error messaging patterns - */ - import { toaster } from "@decky/api"; export interface ToastOptions { @@ -10,106 +5,46 @@ export interface ToastOptions { 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 - }); -} +const showToast = (title: string, body: string): void => { + toaster.toast({ title, body }); +}; +export const showSuccessToast = showToast; +export const showErrorToast = showToast; -/** - * Standard success messages for common operations - */ export const ToastMessages = { INSTALL_SUCCESS: { title: "Installation Complete", - body: "lsfg-vk has been installed successfully" + body: "lsfg-vk has been installed successfully", }, INSTALL_ERROR: { title: "Installation Failed", - body: "Unknown error occurred" + body: "Unknown error occurred", }, UNINSTALL_SUCCESS: { - title: "Uninstallation Complete", - body: "lsfg-vk has been uninstalled successfully" + title: "Uninstallation Complete", + body: "lsfg-vk has been uninstalled successfully", }, UNINSTALL_ERROR: { title: "Uninstallation Failed", - body: "Unknown error occurred" + body: "Unknown error occurred", }, CONFIG_UPDATE_ERROR: { title: "Update Failed", - body: "Failed to update configuration" + 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); -} +export const showErrorToastWithMessage = (title: string, error: unknown): void => + showErrorToast(title, error instanceof Error ? error.message : String(error)); -/** - * Show installation success toast - */ -export function showInstallSuccessToast(): void { +export const showInstallSuccessToast = (): void => showSuccessToast(ToastMessages.INSTALL_SUCCESS.title, ToastMessages.INSTALL_SUCCESS.body); -} -/** - * Show installation error toast - */ -export function showInstallErrorToast(error?: string): void { +export const showInstallErrorToast = (error?: string): void => showErrorToast(ToastMessages.INSTALL_ERROR.title, error || ToastMessages.INSTALL_ERROR.body); -} -/** - * Show uninstallation success toast - */ -export function showUninstallSuccessToast(): void { +export const showUninstallSuccessToast = (): void => showSuccessToast(ToastMessages.UNINSTALL_SUCCESS.title, ToastMessages.UNINSTALL_SUCCESS.body); -} -/** - * Show uninstallation error toast - */ -export function showUninstallErrorToast(error?: string): void { +export const 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); -} diff --git a/tests/gameTargets.test.ts b/tests/gameTargets.test.ts new file mode 100644 index 0000000..2eb8b7e --- /dev/null +++ b/tests/gameTargets.test.ts @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { getTargetSource, mergeGameTargets, targetsForSource } from "../src/utils/gameTargets.ts"; + +const config = (appid: string, profile: string) => ({ appid, profile, config: {} }); + +test("workaround sidecar source wins over current discovery metadata", () => { + const installed = [{ appid: "123", name: "Shortcut", nonSteam: false }]; + const workarounds = [{ appid: "123", non_steam: true, command_token_added: true }]; + + assert.equal(getTargetSource("123", installed, workarounds), "nonSteam"); + assert.equal(mergeGameTargets([config("123", "Shortcut")], installed, workarounds)[0].source, "nonSteam"); +}); + +test("configured profiles without reliable source are unknown", () => { + const targets = mergeGameTargets([config("456", "Missing Game")], [], []); + + assert.deepEqual(targets[0], { + appid: "456", + name: "Missing Game", + nonSteam: false, + source: "unknown", + configured: true, + }); +}); + +test("unknown configured profiles are visible in both source tabs", () => { + const targets = mergeGameTargets([ + config("123", "Steam Game"), + config("456", "Missing Game"), + ], [ + { appid: "123", name: "Steam Game", nonSteam: false }, + { appid: "789", name: "Shortcut", nonSteam: true }, + ], []); + + const steamTargets = targetsForSource(targets, "steam"); + const nonSteamTargets = targetsForSource(targets, "nonSteam"); + + assert.deepEqual(steamTargets.map((target) => target.appid).sort(), ["123", "456"]); + assert.deepEqual(nonSteamTargets.map((target) => target.appid).sort(), ["456", "789"]); +}); + +test("direct Flatpak shortcuts remain non-Steam targets", () => { + const targets = mergeGameTargets([], [ + { appid: "123", name: "Flatpak shortcut", nonSteam: true, isFlatpakShortcut: true }, + ], []); + + assert.equal(targets[0].source, "nonSteam"); + assert.equal(targets[0].isFlatpakShortcut, true); +}); diff --git a/tests/nowPlaying.test.ts b/tests/nowPlaying.test.ts new file mode 100644 index 0000000..a6b116a --- /dev/null +++ b/tests/nowPlaying.test.ts @@ -0,0 +1,114 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { resolveNowPlayingTarget, selectMostRecentRunningFlatpak } from "../src/utils/nowPlaying.ts"; + +const flatpak = (app_id: string, app_name = app_id) => ({ + app_id, + app_name, + runtime_ready: true, + prepared: true, + owned: true, + enabled: true, + profile: `flatpak:${app_id}`, + workarounds: { + dxvkFrameRate: 0, + disableGamescopeWsi: true, + disableHdr: true, + disableSteamdeckMode: false, + disableVkbasalt: false, + enableZink: false, + }, +}); + +const game = (nonSteam = true, configured = true) => ({ + appid: "123456", + name: nonSteam ? "1080 Snowboarding" : "Native Game", + nonSteam, + source: nonSteam ? "nonSteam" : "steam", + configured, +}); + +test("selects the newest active managed Flatpak", () => { + const apps = [flatpak("org.example.old"), flatpak("org.example.new")]; + const running = [ + { app_id: "org.example.old", active: true, pid: "100", start_time: 500 }, + { app_id: "org.example.new", active: true, pid: "200", start_time: 600 }, + ]; + + assert.equal(selectMostRecentRunningFlatpak(apps, running)?.app_id, "org.example.new"); +}); + +test("prefers active Flatpak status before process age", () => { + const apps = [flatpak("org.example.running"), flatpak("org.example.active")]; + const running = [ + { app_id: "org.example.running", active: false, pid: "900", start_time: 900 }, + { app_id: "org.example.active", active: true, pid: "100", start_time: 100 }, + ]; + + assert.equal(selectMostRecentRunningFlatpak(apps, running)?.app_id, "org.example.active"); +}); + +test("deduplicates multiple process rows for one managed Flatpak", () => { + const apps = [flatpak("com.heroicgameslauncher.hgl")]; + const running = [ + { app_id: "com.heroicgameslauncher.hgl", active: false, pid: "228081", start_time: null }, + { app_id: "com.heroicgameslauncher.hgl", active: false, pid: "228116", start_time: null }, + ]; + + assert.equal(selectMostRecentRunningFlatpak(apps, running)?.app_id, "com.heroicgameslauncher.hgl"); +}); + +test("Flatpak runtime wins while a Steam shortcut is running", () => { + const target = resolveNowPlayingTarget(game(true), flatpak("org.libretro.RetroArch", "RetroArch")); + + assert.equal(target?.kind, "flatpak"); + assert.equal(target?.kind === "flatpak" ? target.launcher?.name : null, "1080 Snowboarding"); +}); + +test("native Steam game wins over an unrelated Flatpak", () => { + assert.equal(resolveNowPlayingTarget(game(false), flatpak("org.example.Game"))?.kind, "steam"); +}); + +test("unconfigured native Steam game blocks unrelated Flatpak Now Playing", () => { + assert.equal(resolveNowPlayingTarget(game(false, false), flatpak("org.example.Game")), null); +}); + +test("direct Flatpak launch creates a Flatpak Now Playing target", () => { + const target = resolveNowPlayingTarget(null, flatpak("org.example.Game")); + + assert.equal(target?.kind, "flatpak"); + assert.equal(target?.kind === "flatpak" ? target.launcher : null, null); +}); + +test("multiple inactive Flatpaks do not create an arbitrary Now Playing target", () => { + const apps = [flatpak("org.example.one"), flatpak("org.example.two")]; + const running = [ + { app_id: "org.example.one", active: false, pid: "100", start_time: 500 }, + { app_id: "org.example.two", active: false, pid: "200", start_time: 600 }, + ]; + + assert.equal(selectMostRecentRunningFlatpak(apps, running), null); +}); + +test("one inactive Flatpak remains a usable fallback", () => { + const apps = [flatpak("org.example.one")]; + const running = [{ app_id: "org.example.one", active: false, pid: "100", start_time: 500 }]; + + assert.equal(selectMostRecentRunningFlatpak(apps, running)?.app_id, "org.example.one"); +}); + +test("configured Steam target remains the fallback", () => { + const target = resolveNowPlayingTarget(game(false), null); + + assert.equal(target?.kind, "steam"); +}); + +test("configured non-Steam target remains the fallback", () => { + const target = resolveNowPlayingTarget(game(true), null); + + assert.equal(target?.kind, "nonSteam"); +}); + +test("unconfigured Steam target has no Now Playing controls", () => { + assert.equal(resolveNowPlayingTarget(game(false, false), null), null); +}); diff --git a/tests/steamLaunchOptions.test.ts b/tests/steamLaunchOptions.test.ts new file mode 100644 index 0000000..1d2f762 --- /dev/null +++ b/tests/steamLaunchOptions.test.ts @@ -0,0 +1,219 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + cleanupPluginAssignments, + cleanupPluginLaunchOptions, + cleanupLegacyWrapper, + hasWrapperLaunchIntegration, + installWrapperIntegration, + installWrapperLaunchOption, + isLegacyWrapperToken, + normalizeLaunchOptions, + readSteamLaunchOptions, + removeWrapperIntegration, + removeWrapperLaunchOption, +} from "../src/utils/steamLaunchOptions.ts"; + +const wrapper = "~/.lsfg"; + +test("inserts one wrapper immediately before an existing command macro", () => { + assert.deepEqual(installWrapperLaunchOption('gamemoderun %command% --profile "high quality"', wrapper), { + options: 'gamemoderun ~/.lsfg %command% --profile "high quality"', + commandTokenAdded: false, + }); + assert.equal(hasWrapperLaunchIntegration(`gamemoderun ${wrapper} %command%`, wrapper), true); + assert.deepEqual(installWrapperLaunchOption(`gamemoderun ${wrapper} %command%`, wrapper), { + options: `gamemoderun ${wrapper} %command%`, + commandTokenAdded: false, + }); +}); + +test("normalizes blank, malformed, and argument-only launch fields", () => { + assert.deepEqual(installWrapperLaunchOption("", wrapper), { + options: `${wrapper} %command%`, + commandTokenAdded: true, + }); + assert.deepEqual(installWrapperLaunchOption("FOO=bar --windowed", wrapper), { + options: `FOO=bar ${wrapper} %command% --windowed`, + commandTokenAdded: true, + }); + assert.deepEqual(installWrapperLaunchOption("gamemoderun --windowed", wrapper), { + options: `${wrapper} %command% gamemoderun --windowed`, + commandTokenAdded: true, + }); + assert.deepEqual(installWrapperLaunchOption('"%command%"', wrapper), { + options: `${wrapper} %command%`, + commandTokenAdded: false, + }); + assert.deepEqual(installWrapperLaunchOption(`${wrapper} %command`, wrapper), { + options: `${wrapper} %command%`, + commandTokenAdded: false, + }); + assert.deepEqual(installWrapperLaunchOption(`${wrapper} --windowed`, wrapper), { + options: `${wrapper} %command% --windowed`, + commandTokenAdded: true, + }); +}); + +test("preserves assignments quoting suffixes and released wrapper cleanup", () => { + const options = 'FOO="hello world" VK_INSTANCE_LAYERS="one:two" gamemoderun %command% --flag "two words"'; + assert.equal( + installWrapperLaunchOption(options, wrapper).options, + 'FOO="hello world" VK_INSTANCE_LAYERS="one:two" gamemoderun ~/.lsfg %command% --flag "two words"', + ); + assert.equal(removeWrapperLaunchOption(`${wrapper} %command% --arg "${wrapper}"`, wrapper, true), `--arg "${wrapper}"`); + assert.equal(normalizeLaunchOptions(" FOO=bar %COMMAND% --flag "), "FOO=bar %COMMAND% --flag"); + for (const token of ["~/lsfg", "/home/deck/lsfg", "mako-run", "mako-launch"]) { + assert.equal(cleanupLegacyWrapper(`FOO=bar ${token} %command% --arg "${token}"`), `FOO=bar %command% --arg "${token}"`); + } + assert.equal(isLegacyWrapperToken("/home/kurt/lsfg"), true); + assert.equal(isLegacyWrapperToken("/opt/tools/lsfg"), false); +}); + +test("removes only managed assignments and preserves unrelated values", () => { + assert.equal( + cleanupPluginAssignments( + '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" %command%', + ), + 'FOO="keep this" DXVK_CONFIG="dxgi.syncInterval = 0" %command%', + ); + assert.equal(cleanupPluginLaunchOptions(`DXVK_FRAME_RATE=30 ${wrapper} %command%`, wrapper), "%command%"); + assert.equal( + cleanupPluginAssignments("PROTON_USE_WOW64=1 MANGOHUD=1 MANGOHUD_CONFIG=alpha %command%"), + "PROTON_USE_WOW64=1 MANGOHUD=1 MANGOHUD_CONFIG=alpha %command%", + ); +}); + +test("uses launch options for Steam and non-Steam shortcuts without a Target API", async () => { + const previousWindow = (globalThis as Record<string, unknown>).window; + const previousSteamClient = (globalThis as Record<string, unknown>).SteamClient; + let appOptions = "FOO=bar %command%"; + let shortcutOptions = "--windowed"; + const appWrites: string[] = []; + const shortcutWrites: string[] = []; + const unregisters: number[] = []; + const apps = { + RegisterForAppDetails(appId: number, callback: (details: SteamAppDetails) => void) { + callback(appId === 42 + ? { strLaunchOptions: appOptions, strShortcutLaunchOptions: "wrong-field" } + : { strShortcutLaunchOptions: shortcutOptions, strLaunchOptions: "wrong-field" }); + return { unregister: () => unregisters.push(appId) }; + }, + SetAppLaunchOptions(appId: number, options: string) { + assert.equal(appId, 42); + appWrites.push(options); + appOptions = options.replaceAll(" ", " "); + }, + SetShortcutLaunchOptions(appId: number, options: string) { + assert.equal(appId, 43); + shortcutWrites.push(options); + shortcutOptions = options; + }, + }; + (globalThis as Record<string, unknown>).window = { setTimeout, clearTimeout }; + (globalThis as Record<string, unknown>).SteamClient = { Apps: apps }; + try { + const normal = await readSteamLaunchOptions(42, false); + assert.equal(normal.options, "FOO=bar %command%"); + const installed = await installWrapperIntegration(42, false, wrapper); + assert.equal(installed.snapshot.options, `FOO=bar ${wrapper} %command%`.replaceAll(" ", " ")); + assert.equal(installed.commandTokenAdded, false); + assert.equal(appWrites.length, 1); + + const shortcut = await installWrapperIntegration(43, true, wrapper); + assert.equal(shortcut.snapshot.options, `~/.lsfg %command% --windowed`); + assert.deepEqual(shortcutWrites, [`~/.lsfg %command% --windowed`]); + + const restored = await removeWrapperIntegration(43, true, wrapper, shortcut.commandTokenAdded); + assert.equal(restored.options, "--windowed"); + + const cleaned = await removeWrapperIntegration(42, false, wrapper, installed.commandTokenAdded); + assert.equal(cleaned.options, "FOO=bar %command%".replaceAll(" ", " ")); + assert.ok(unregisters.includes(42)); + assert.ok(unregisters.includes(43)); + } finally { + if (previousWindow === undefined) delete (globalThis as Record<string, unknown>).window; + else (globalThis as Record<string, unknown>).window = previousWindow; + if (previousSteamClient === undefined) delete (globalThis as Record<string, unknown>).SteamClient; + else (globalThis as Record<string, unknown>).SteamClient = previousSteamClient; + } +}); + +test("AppImage EmuDeck and direct Flatpak shortcuts all stay launch-option based", async () => { + const previousWindow = (globalThis as Record<string, unknown>).window; + const previousSteamClient = (globalThis as Record<string, unknown>).SteamClient; + const cases = [ + { + options: 'DESKTOPINTEGRATION=1 "/home/deck/AppImages/dusk.appimage"', + expected: 'DESKTOPINTEGRATION=1 ~/.lsfg %command% "/home/deck/AppImages/dusk.appimage"', + }, + { + options: "", + expected: "~/.lsfg %command%", + }, + { + options: "run org.example.Game", + expected: "~/.lsfg %command% run org.example.Game", + }, + ]; + (globalThis as Record<string, unknown>).window = { setTimeout, clearTimeout }; + try { + for (const [index, item] of cases.entries()) { + let shortcutOptions = item.options; + const shortcutWrites: string[] = []; + (globalThis as Record<string, unknown>).SteamClient = { + Apps: { + RegisterForAppDetails(_appId: number, callback: (details: SteamAppDetails) => void) { + callback({ strShortcutLaunchOptions: shortcutOptions }); + return { unregister() {} }; + }, + SetShortcutLaunchOptions(_appId: number, options: string) { + shortcutWrites.push(options); + shortcutOptions = options; + }, + }, + }; + const installed = await installWrapperIntegration(100 + index, true, wrapper); + assert.equal(installed.snapshot.options, item.expected); + assert.deepEqual(shortcutWrites, [item.expected]); + const restored = await removeWrapperIntegration(100 + index, true, wrapper, installed.commandTokenAdded); + assert.equal(restored.options, item.options); + } + } finally { + if (previousWindow === undefined) delete (globalThis as Record<string, unknown>).window; + else (globalThis as Record<string, unknown>).window = previousWindow; + if (previousSteamClient === undefined) delete (globalThis as Record<string, unknown>).SteamClient; + else (globalThis as Record<string, unknown>).SteamClient = previousSteamClient; + } +}); + +test("launch option write failure rolls back the original value", async () => { + const previousWindow = (globalThis as Record<string, unknown>).window; + const previousSteamClient = (globalThis as Record<string, unknown>).SteamClient; + let appOptions = "FOO=bar %command%"; + const writes: string[] = []; + (globalThis as Record<string, unknown>).window = { setTimeout, clearTimeout }; + (globalThis as Record<string, unknown>).SteamClient = { + Apps: { + RegisterForAppDetails(_appId: number, callback: (details: SteamAppDetails) => void) { + callback({ strLaunchOptions: appOptions }); + return { unregister() {} }; + }, + SetAppLaunchOptions(_appId: number, options: string) { + writes.push(options); + appOptions = options; + if (options.includes(wrapper)) throw new Error("simulated launch option failure"); + }, + }, + }; + try { + await assert.rejects(installWrapperIntegration(42, false, wrapper), /simulated launch option failure/); + assert.equal(appOptions, "FOO=bar %command%"); + assert.deepEqual(writes, [`FOO=bar ${wrapper} %command%`, "FOO=bar %command%"]); + } finally { + if (previousWindow === undefined) delete (globalThis as Record<string, unknown>).window; + else (globalThis as Record<string, unknown>).window = previousWindow; + if (previousSteamClient === undefined) delete (globalThis as Record<string, unknown>).SteamClient; + else (globalThis as Record<string, unknown>).SteamClient = previousSteamClient; + } +}); diff --git a/tests/test_configuration_profiles.py b/tests/test_configuration_profiles.py new file mode 100644 index 0000000..898a9e7 --- /dev/null +++ b/tests/test_configuration_profiles.py @@ -0,0 +1,114 @@ +import sys +import tempfile +import types +import unittest +from pathlib import Path +from unittest.mock import Mock, patch + + +sys.modules.setdefault( + "decky", + types.SimpleNamespace(DECKY_USER_HOME="/home/deck", logger=Mock()), +) +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "py_modules")) + +from lsfg_vk.config_schema import ConfigurationManager +from lsfg_vk.configuration import ConfigurationService + + +class ConfigurationProfileTests(unittest.TestCase): + def setUp(self): + self.tempdir = tempfile.TemporaryDirectory() + self.home = Path(self.tempdir.name) / "home" / "deck" + self.home.mkdir(parents=True) + self.runtime = Mock() + self.service = ConfigurationService(runtime_service=self.runtime) + self.service.user_home = self.home + self.service.config_dir = self.home / ".config/lsfg-vk" + self.service.config_file_path = self.service.config_dir / "conf.toml" + + def tearDown(self): + self.tempdir.cleanup() + + def test_selector_only_profile_survives_round_trip(self): + content = """version = 2 + +[global] +allow_fp16 = true + +[[profile]] +name = "flatpak:org.example.Game" +pacing_mode = "vsync" +multiplier = 3 +flow_scale = 0.8 +performance_mode = false +override_present_mode = true +preserve_swapchain_image_count = false +""" + parsed = ConfigurationManager.parse_toml_content_multi_profile(content) + self.assertIn("flatpak:org.example.Game", parsed["profiles"]) + self.assertEqual(parsed["profiles"]["flatpak:org.example.Game"]["active_in"], []) + rendered = ConfigurationManager.generate_toml_content_multi_profile(parsed) + reparsed = ConfigurationManager.parse_toml_content_multi_profile(rendered) + self.assertEqual(reparsed["profiles"]["flatpak:org.example.Game"]["multiplier"], 3) + + def test_game_reset_all_preserves_flatpak_profiles(self): + self.service.update_game_config("123", "Steam Game", {"multiplier": 2}) + self.service.update_flatpak_config("org.example.Game", {"multiplier": 3}) + + result = self.service.reset_all_game_configs() + data = self.service._get_profile_data() + + self.assertTrue(result["success"]) + self.assertNotIn("Steam Game", data["profiles"]) + self.assertIn("flatpak:org.example.Game", data["profiles"]) + self.assertEqual(data["profiles"]["flatpak:org.example.Game"]["multiplier"], 3) + + def test_scoped_game_reset_is_one_write_and_preserves_other_profiles(self): + self.service.update_game_config("123", "Steam Game", {"multiplier": 2}) + self.service.update_game_config("456", "Non-Steam Game", {"multiplier": 3}) + self.service.update_flatpak_config("org.example.Game", {"multiplier": 4}) + + with patch.object(self.service, "_save_profile_data", wraps=self.service._save_profile_data) as save: + result = self.service.reset_game_configs(["123"]) + + data = self.service._get_profile_data() + self.assertTrue(result["success"]) + self.assertEqual(save.call_count, 1) + self.assertNotIn("Steam Game", data["profiles"]) + self.assertIn("Non-Steam Game", data["profiles"]) + self.assertIn("flatpak:org.example.Game", data["profiles"]) + + def test_flatpak_reset_all_preserves_steam_profiles(self): + self.service.update_game_config("123", "Steam Game", {"multiplier": 2}) + self.service.update_flatpak_config("org.example.Game", {"multiplier": 3}) + + result = self.service.reset_all_flatpak_configs() + data = self.service._get_profile_data() + + self.assertTrue(result["success"]) + self.assertIn("Steam Game", data["profiles"]) + self.assertNotIn("flatpak:org.example.Game", data["profiles"]) + + def test_global_config_update_does_not_change_profile_values(self): + self.service.update_game_config("123", "Steam Game", {"multiplier": 2}) + + result = self.service.update_global_config({"no_fp16": True}) + data = self.service._get_profile_data() + + self.assertTrue(result["success"]) + self.assertTrue(result["global_config"]["no_fp16"]) + self.assertTrue(data["global_config"]["no_fp16"]) + self.assertEqual(data["profiles"]["Steam Game"]["multiplier"], 2) + + def test_profile_update_cannot_overwrite_global_fp16_setting(self): + self.service.update_global_config({"no_fp16": True}) + + self.service.update_game_config("123", "Steam Game", {"multiplier": 3, "no_fp16": False}) + + data = self.service._get_profile_data() + self.assertTrue(data["global_config"]["no_fp16"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_flatpak_profile_service.py b/tests/test_flatpak_profile_service.py new file mode 100644 index 0000000..2b9933b --- /dev/null +++ b/tests/test_flatpak_profile_service.py @@ -0,0 +1,233 @@ +import hashlib +import sys +import tempfile +import types +import unittest +from pathlib import Path +from unittest.mock import Mock + + +sys.modules.setdefault( + "decky", + types.SimpleNamespace(DECKY_USER_HOME="/home/deck", logger=Mock()), +) +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "py_modules")) + +from lsfg_vk.configuration import ConfigurationService +from lsfg_vk.flatpak_service import FlatpakService +from lsfg_vk.flatpak_profile_service import FlatpakProfileService + + +class FakeFlatpakService: + def __init__(self, home: Path): + self.user_home = home + self.config_dir = home / ".config/lsfg-vk" + self.config_file_path = self.config_dir / "conf.toml" + self.backup_dir = self.config_dir / "flatpak-overrides" + self.state = {"version": 2, "plugin_owned_branches": [], "prepared_apps": {}} + self.commands = [] + self.running = "" + self.start_times = {} + + def _read_state(self): + return self.state + + def _write_state(self, state): + self.state = state + + def _override_path(self, app_id): + return self.user_home / ".local/share/flatpak/overrides" / app_id + + def _backup_path(self, app_id): + return self.backup_dir / f"{app_id}.ini" + + @staticmethod + def _sha256(content): + return hashlib.sha256(content).hexdigest() + + def _snapshot_override(self, app_id): + path = self._override_path(app_id) + if not path.exists(): + return False, b"" + return True, path.read_bytes() + + def _process_start_time(self, pid): + return self.start_times.get(pid) + + @staticmethod + def _write_file(path, content, mode=0o644): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + path.chmod(mode) + + def prepare_app(self, app_id): + apps = self.state["prepared_apps"] + if app_id not in apps: + existed, original = self._snapshot_override(app_id) + if existed: + self._write_file(self._backup_path(app_id), original.decode("utf-8")) + apps[app_id] = {"override_existed": existed, "managed_sha256": ""} + entry = apps[app_id] + baseline = "" + if entry["override_existed"]: + baseline = self._backup_path(app_id).read_text(encoding="utf-8") + managed = baseline + "\n[Context]\nfilesystems=/config:ro;/dll:ro;\n[Environment]\nLSFGVK_CONFIG=/config/conf.toml\nLSFGVK_FLATPAK=1\n" + self._write_file(self._override_path(app_id), managed) + entry["managed_sha256"] = self._sha256(managed.encode()) + return {"success": True, "owned": True, "prepared": True, "runtime": "org.freedesktop.Platform/x86_64/24.08", "runtime_branch": "24.08"} + + def remove_app_override(self, app_id): + entry = self.state["prepared_apps"].get(app_id) + if entry is None: + return {"success": True, "prepared": False, "owned": False} + existed, current = self._snapshot_override(app_id) + current_hash = self._sha256(current) if existed else self._sha256(b"") + if current_hash != entry["managed_sha256"]: + return {"success": False, "error": "Flatpak override changed after preparation"} + path = self._override_path(app_id) + backup = self._backup_path(app_id) + if entry["override_existed"]: + self._write_file(path, backup.read_text(encoding="utf-8")) + else: + path.unlink(missing_ok=True) + backup.unlink(missing_ok=True) + self.state["prepared_apps"].pop(app_id) + return {"success": True, "prepared": False, "owned": False} + + def get_flatpak_apps(self): + app_id = "org.example.Game" + return { + "success": True, + "apps": [{ + "app_id": app_id, + "app_name": "Example Game", + "runtime": "org.freedesktop.Platform/x86_64/24.08", + "runtime_branch": "24.08", + "runtime_ready": True, + "prepared": app_id in self.state["prepared_apps"], + "owned": app_id in self.state["prepared_apps"], + "error": None, + }], + } + + def _run_flatpak_command(self, args, **_kwargs): + self.commands.append(args) + if args[:3] == ["override", "--user", "--show"]: + path = self._override_path(args[3]) + return types.SimpleNamespace(returncode=0, stdout=path.read_text(encoding="utf-8") if path.exists() else "", stderr="") + if args[0] == "override": + app_id = args[-1] + path = self._override_path(app_id) + content = path.read_text(encoding="utf-8") if path.exists() else "" + env = [item.removeprefix("--env=") for item in args if item.startswith("--env=")] + unset = [item.removeprefix("--unset-env=") for item in args if item.startswith("--unset-env=")] + if unset: + content += "\n[Context]\nunset-environment=" + ";".join(unset) + ";\n" + if env: + content += "\n[Environment]\n" + "\n".join(env) + "\n" + self._write_file(path, content) + return types.SimpleNamespace(returncode=0, stdout="", stderr="") + if args[0] == "ps": + return types.SimpleNamespace(returncode=0, stdout=self.running, stderr="") + raise AssertionError(args) + + +class FlatpakProfileServiceTests(unittest.TestCase): + def setUp(self): + self.tempdir = tempfile.TemporaryDirectory() + self.home = Path(self.tempdir.name) / "home" / "deck" + self.home.mkdir(parents=True) + self.flatpak = FakeFlatpakService(self.home) + self.runtime = Mock() + self.configuration = ConfigurationService(runtime_service=self.runtime) + self.configuration.user_home = self.home + self.configuration.config_dir = self.flatpak.config_dir + self.configuration.config_file_path = self.flatpak.config_file_path + self.service = FlatpakProfileService(self.flatpak, self.configuration) + self.app_id = "org.example.Game" + + def tearDown(self): + self.tempdir.cleanup() + + def test_enable_creates_selector_profile_and_default_workarounds(self): + result = self.service.enable_app(self.app_id) + config = self.configuration.get_flatpak_config(self.app_id) + content = self.flatpak._override_path(self.app_id).read_text(encoding="utf-8") + + self.assertTrue(result["success"]) + self.assertTrue(config["exists"]) + self.assertEqual(config["profile"], "flatpak:org.example.Game") + self.assertEqual(config["config"]["active_in"], []) + self.assertIn("LSFGVK_PROFILE=flatpak:org.example.Game", content) + self.assertIn("ENABLE_GAMESCOPE_WSI=0", content) + self.assertIn("DXVK_HDR=0", content) + + def test_workaround_update_rebuilds_from_original_override(self): + baseline = "[Environment]\nDXVK_CONFIG=dxgi.syncInterval = 0\nKEEP=yes\n" + self.flatpak._write_file(self.flatpak._override_path(self.app_id), baseline) + self.assertTrue(self.service.enable_app(self.app_id)["success"]) + + state = self.service.default_state() + state.update({"dxvkFrameRate": 30, "disableHdr": False, "enableZink": True}) + result = self.service.set_workaround_state(self.app_id, state) + command = next( + args for args in reversed(self.flatpak.commands) + if args[0] == "override" and any(item.startswith("--env=LSFGVK_PROFILE=") for item in args) + ) + + self.assertTrue(result["success"]) + self.assertIn("--env=DXVK_CONFIG=dxgi.syncInterval = 0; dxvk.maxFrameRate = 30", command) + self.assertNotIn("--env=DXVK_HDR=0", command) + self.assertIn("--env=MESA_LOADER_DRIVER_OVERRIDE=zink", command) + + def test_config_update_returns_without_relisting_flatpaks(self): + self.assertTrue(self.service.enable_app(self.app_id)["success"]) + self.service.get_app = Mock(side_effect=AssertionError("config updates must not relist Flatpaks")) + + result = self.service.update_config(self.app_id, {"multiplier": 4}) + + self.assertTrue(result["success"]) + self.assertEqual(result["app_id"], self.app_id) + self.assertEqual(result["config"]["multiplier"], 4) + self.service.get_app.assert_not_called() + + def test_remove_restores_exact_original_override_and_profile(self): + baseline = "[Environment]\nKEEP=yes\n" + self.flatpak._write_file(self.flatpak._override_path(self.app_id), baseline) + self.assertTrue(self.service.enable_app(self.app_id)["success"]) + + removed = self.service.remove_app(self.app_id) + + self.assertTrue(removed["success"]) + self.assertEqual(self.flatpak._override_path(self.app_id).read_text(encoding="utf-8"), baseline) + self.assertFalse(self.configuration.get_flatpak_config(self.app_id)["exists"]) + + def test_external_override_change_fails_closed(self): + self.assertTrue(self.service.enable_app(self.app_id)["success"]) + path = self.flatpak._override_path(self.app_id) + path.write_text(path.read_text(encoding="utf-8") + "EXTERNAL=yes\n", encoding="utf-8") + + result = self.service.set_workaround_state(self.app_id, self.service.default_state()) + + self.assertFalse(result["success"]) + self.assertIn("changed after preparation", result["error"]) + + def test_running_detection_uses_owned_selector_state(self): + self.assertTrue(self.service.enable_app(self.app_id)["success"]) + self.flatpak.running = "org.example.Game\ttrue\t1234\norg.other.App\ttrue\t9999\n" + self.flatpak.start_times["1234"] = 200 + + result = self.service.get_running_apps() + + self.assertTrue(result["success"]) + self.assertEqual(result["apps"], [{"app_id": self.app_id, "active": True, "pid": "1234", "start_time": 200}]) + + def test_process_start_time_parser_handles_parentheses_in_command_name(self): + fields = ["S"] + ["0"] * 18 + ["4242"] + stat = "1234 (retro)arch) " + " ".join(fields) + + self.assertEqual(FlatpakService._parse_process_start_time(stat), 4242) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_flatpak_service.py b/tests/test_flatpak_service.py new file mode 100644 index 0000000..17d0016 --- /dev/null +++ b/tests/test_flatpak_service.py @@ -0,0 +1,320 @@ +import json +import sys +import tempfile +import types +import unittest +from pathlib import Path +from unittest.mock import Mock + + +sys.modules.setdefault( + "decky", + types.SimpleNamespace(DECKY_USER_HOME="/home/deck", logger=Mock()), +) +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "py_modules")) + +from lsfg_vk.flatpak_service import FlatpakService + + +class FlatpakServiceTests(unittest.TestCase): + def setUp(self): + self.tempdir = tempfile.TemporaryDirectory() + self.home = Path(self.tempdir.name) / "home" / "deck" + self.home.mkdir(parents=True) + self.service = FlatpakService() + self.service.user_home = self.home + self.service.config_dir = self.home / ".config/lsfg-vk" + self.service.config_file_path = self.service.config_dir / "conf.toml" + self.service.ownership_path.parent.mkdir(parents=True, exist_ok=True) + self.service.check_flatpak_available = Mock(return_value=True) + self.service._run_flatpak_command = Mock(side_effect=self._run_flatpak_command) + self.runtime_ref = "org.freedesktop.Platform/x86_64/24.08" + self.runtime_metadata = "" + self.user_branches = set() + self.system_branches = set() + self.user_extension_origin = "flathub" + self.apps = {"com.example.Game": "Example Game"} + self.dll_dir = self.home / ".local/share/Steam/steamapps/common/Lossless Scaling" + self.service._dll_directory = Mock(return_value=self.dll_dir) + + def tearDown(self): + self.tempdir.cleanup() + + @staticmethod + def _result(stdout="", returncode=0, stderr=""): + return types.SimpleNamespace(stdout=stdout, returncode=returncode, stderr=stderr) + + @staticmethod + def _extension_line(branch): + return f"org.freedesktop.Platform.VulkanLayer.lsfgvk\tx86_64\t{branch}\n" + + @staticmethod + def _parse_override(content): + section = None + filesystems = [] + unset_environment = [] + environment = {} + other = [] + for raw in content.splitlines(): + line = raw.strip() + if line.startswith("[") and line.endswith("]"): + section = line[1:-1] + continue + key, separator, value = line.partition("=") + if not separator: + continue + if section == "Context" and key == "filesystems": + filesystems.extend(item for item in value.split(";") if item) + elif section == "Context" and key == "unset-environment": + unset_environment.extend(item for item in value.split(";") if item) + elif section == "Environment": + environment[key] = value + else: + other.append((section, key, value)) + return filesystems, unset_environment, environment, other + + @staticmethod + def _serialize_override(filesystems, unset_environment, environment): + lines = ["[Context]"] + if filesystems: + lines.append("filesystems=" + ";".join(filesystems) + ";") + if unset_environment: + lines.append("unset-environment=" + ";".join(unset_environment) + ";") + if environment: + lines.append("") + lines.append("[Environment]") + lines.extend(f"{key}={value}" for key, value in environment.items()) + return "\n".join(lines) + "\n" + + def _apply_override(self, args): + app_id = args[-1] + path = self.service._override_path(app_id) + content = path.read_text(encoding="utf-8") if path.exists() else "" + filesystems, unset_environment, environment, _ = self._parse_override(content) + for arg in args[2:-1]: + if arg.startswith("--filesystem="): + value = arg.split("=", 1)[1] + if value not in filesystems: + filesystems.append(value) + elif arg.startswith("--env="): + key, value = arg.split("=", 1)[1].split("=", 1) + environment[key] = value + if key in unset_environment: + unset_environment.remove(key) + elif arg.startswith("--unset-env="): + key = arg.split("=", 1)[1] + environment.pop(key, None) + if key not in unset_environment: + unset_environment.append(key) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + self._serialize_override(filesystems, unset_environment, environment), + encoding="utf-8", + ) + return self._result() + + def _run_flatpak_command(self, args, **_kwargs): + if args[:2] == ["info", "--show-runtime"]: + return self._result(self.runtime_ref + "\n") + if args[:2] == ["info", "--show-metadata"]: + return self._result(self.runtime_metadata) + if args[:3] == ["info", "--user", "--show-origin"]: + return self._result(self.user_extension_origin) + if args[:2] == ["list", "--app"]: + return self._result("".join(f"{name}\t{app_id}\n" for app_id, name in self.apps.items())) + if args[0] == "list": + branches = self.user_branches if "--user" in args else self.system_branches + return self._result("".join(self._extension_line(branch) for branch in sorted(branches))) + if args[0] == "install": + self.user_branches.add(self.runtime_ref.rsplit("/", 1)[-1]) + return self._result() + if args[0] == "uninstall": + self.user_branches.discard(args[-1].rsplit("/", 1)[-1]) + return self._result() + if args[:3] == ["override", "--user", "--show"]: + path = self.service._override_path(args[-1]) + return self._result(path.read_text(encoding="utf-8") if path.exists() else "") + if args[:2] == ["override", "--user"]: + return self._apply_override(args) + raise AssertionError(f"Unexpected Flatpak command: {args}") + + def test_resolves_freedesktop_and_derived_runtimes(self): + runtime, branch = self.service._resolve_runtime("com.example.Game") + self.assertEqual(runtime, self.runtime_ref) + self.assertEqual(branch, "24.08") + + self.runtime_ref = "org.kde.Platform/x86_64/6.10" + self.runtime_metadata = "[Extension org.freedesktop.Platform.GL]\nversions=25.08;25.08-extra;1.4\n" + runtime, branch = self.service._resolve_runtime("com.example.Game") + self.assertEqual(runtime, self.runtime_ref) + self.assertEqual(branch, "25.08") + + def test_clean_env_targets_deck_user_session_bus(self): + env = self.service._clean_env() + user_id = self.home.stat().st_uid + self.assertEqual(env["XDG_RUNTIME_DIR"], f"/run/user/{user_id}") + self.assertEqual(env["DBUS_SESSION_BUS_ADDRESS"], f"unix:path=/run/user/{user_id}/bus") + + def test_prepare_app_installs_runtime_and_persists_narrow_override(self): + response = self.service.prepare_app("com.example.Game") + + self.assertTrue(response["success"]) + self.assertTrue(response["prepared"]) + self.assertTrue(response["owned"]) + self.assertEqual(response["runtime_branch"], "24.08") + self.assertEqual(self.user_branches, {"24.08"}) + install_calls = [ + call.args[0] + for call in self.service._run_flatpak_command.call_args_list + if call.args[0][0] == "install" + ] + self.assertEqual( + install_calls, + [[ + "install", + "--user", + "--noninteractive", + "--or-update", + "flathub", + "org.freedesktop.Platform.VulkanLayer.lsfgvk//24.08", + ]], + ) + status = self.service._app_override_status("com.example.Game") + self.assertTrue(status["prepared"]) + content = self.service._override_path("com.example.Game").read_text(encoding="utf-8") + self.assertIn(str(self.service.config_dir) + ":ro", content) + self.assertIn(str(self.dll_dir) + ":ro", content) + self.assertIn("LSFGVK_CONFIG=" + str(self.service.config_file_path), content) + self.assertIn("LSFGVK_FLATPAK=1", content) + self.assertNotIn("ENABLE_GAMESCOPE_WSI", content) + state = json.loads(self.service.ownership_path.read_text(encoding="utf-8")) + self.assertEqual(state["plugin_owned_branches"], ["24.08"]) + self.assertIn("com.example.Game", state["prepared_apps"]) + + def test_prepare_is_idempotent(self): + first = self.service.prepare_app("com.example.Game") + first_content = self.service._override_path("com.example.Game").read_bytes() + second = self.service.prepare_app("com.example.Game") + + self.assertTrue(first["success"]) + self.assertTrue(second["success"]) + self.assertEqual(first_content, self.service._override_path("com.example.Game").read_bytes()) + install_calls = [call for call in self.service._run_flatpak_command.call_args_list if call.args[0][0] == "install"] + self.assertEqual(len(install_calls), 1) + + def test_replaces_extension_from_another_remote(self): + self.user_branches = {"24.08"} + self.user_extension_origin = "lsfgvk-origin" + + response = self.service.install_extension("24.08") + + self.assertTrue(response["success"]) + commands = [call.args[0] for call in self.service._run_flatpak_command.call_args_list] + self.assertIn( + [ + "uninstall", + "--user", + "--noninteractive", + "org.freedesktop.Platform.VulkanLayer.lsfgvk/x86_64/24.08", + ], + commands, + ) + self.assertEqual(self.user_branches, {"24.08"}) + + def test_preinstalled_runtime_is_not_owned(self): + self.system_branches = {"24.08"} + response = self.service.prepare_app("com.example.Game") + + self.assertTrue(response["success"]) + state = json.loads(self.service.ownership_path.read_text(encoding="utf-8")) + self.assertEqual(state["plugin_owned_branches"], []) + self.assertIn("com.example.Game", state["prepared_apps"]) + + def test_external_preparation_is_preserved(self): + path = self.service._override_path("com.example.Game") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + self._serialize_override( + [str(self.service.config_dir) + ":ro", str(self.dll_dir) + ":ro"], + ["DISABLE_LSFGVK", "DISABLE_LSFG"], + { + "LSFGVK_CONFIG": str(self.service.config_file_path), + "LSFGVK_FLATPAK": "1", + }, + ), + encoding="utf-8", + ) + self.system_branches = {"24.08"} + + response = self.service.prepare_app("com.example.Game") + + self.assertTrue(response["success"]) + self.assertTrue(response["prepared"]) + self.assertFalse(response["owned"]) + self.assertFalse(self.service.ownership_path.exists()) + + def test_remove_restores_exact_previous_override(self): + self.system_branches = {"24.08"} + original = "[Context]\nfilesystems=~/Documents;\n\n[Environment]\nFOO=bar\n" + path = self.service._override_path("com.example.Game") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(original, encoding="utf-8") + self.assertTrue(self.service.prepare_app("com.example.Game")["success"]) + + response = self.service.remove_app_override("com.example.Game") + + self.assertTrue(response["success"]) + self.assertEqual(path.read_text(encoding="utf-8"), original) + self.assertFalse(self.service.ownership_path.exists()) + + def test_remove_deletes_override_created_by_plugin(self): + self.system_branches = {"24.08"} + self.assertTrue(self.service.prepare_app("com.example.Game")["success"]) + path = self.service._override_path("com.example.Game") + self.assertTrue(path.exists()) + + response = self.service.remove_app_override("com.example.Game") + + self.assertTrue(response["success"]) + self.assertFalse(path.exists()) + self.assertFalse(self.service.ownership_path.exists()) + + def test_remove_fails_closed_after_external_change(self): + self.system_branches = {"24.08"} + self.assertTrue(self.service.prepare_app("com.example.Game")["success"]) + path = self.service._override_path("com.example.Game") + with path.open("a", encoding="utf-8") as handle: + handle.write("EXTERNAL=1\n") + + response = self.service.remove_app_override("com.example.Game") + + self.assertFalse(response["success"]) + self.assertIn("changed after preparation", response["error"]) + self.assertTrue(path.exists()) + self.assertTrue(self.service.ownership_path.exists()) + + def test_full_cleanup_removes_only_owned_state(self): + self.system_branches = {"23.08"} + self.assertTrue(self.service.prepare_app("com.example.Game")["success"]) + self.assertEqual(self.user_branches, {"24.08"}) + + response = self.service.remove_plugin_owned_environment() + + self.assertTrue(response["success"]) + self.assertEqual(response["removed_apps"], ["com.example.Game"]) + self.assertEqual(response["removed_branches"], ["24.08"]) + self.assertEqual(self.user_branches, set()) + self.assertEqual(self.system_branches, {"23.08"}) + self.assertFalse(self.service.ownership_path.exists()) + + def test_corrupt_ownership_metadata_fails_closed(self): + self.service.ownership_path.write_text("{not-json", encoding="utf-8") + + response = self.service.remove_plugin_owned_environment() + + self.assertFalse(response["success"]) + self.assertEqual(self.service._run_flatpak_command.call_count, 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_installation_cleanup.py b/tests/test_installation_cleanup.py new file mode 100644 index 0000000..3336505 --- /dev/null +++ b/tests/test_installation_cleanup.py @@ -0,0 +1,63 @@ +import sys +import tempfile +import types +import unittest +from pathlib import Path +from unittest.mock import Mock + + +sys.modules.setdefault( + "decky", + types.SimpleNamespace(DECKY_USER_HOME="/home/deck", logger=Mock()), +) +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "py_modules")) + +from lsfg_vk.base_service import BaseService +from lsfg_vk.installation import InstallationService + + +class InstallationCleanupTests(unittest.TestCase): + def test_uninstall_removes_legacy_files_and_prunes_only_empty_directories(self): + with tempfile.TemporaryDirectory() as temporary: + home = Path(temporary) / "home" / "deck" + service = InstallationService.__new__(InstallationService) + BaseService.__init__(service) + service.log = Mock() + service.user_home = home + service.local_bin_dir = home / ".local/bin" + service.local_lib_dir = home / ".local/lib" + service.local_share_dir = home / ".local/share/vulkan/implicit_layer.d" + service.config_dir = home / ".config/lsfg-vk" + service.config_file_path = service.config_dir / "conf.toml" + service.legacy_script_path = home / "lsfg" + service.lib_file = service.local_lib_dir / "liblsfg-vk-layer.so" + service.lib_x86_file = service.local_lib_dir / "liblsfg-vk-layer.x86.so" + service.json_file = service.local_share_dir / "VkLayer_LSFGVK_frame_generation.json" + service.json_x86_file = service.local_share_dir / "VkLayer_LSFGVK_frame_generation.x86.json" + service.cli_file = service.local_bin_dir / "lsfg-vk-cli" + service.legacy_lib_file = service.local_lib_dir / "liblsfg-vk.so" + service.legacy_json_file = service.local_share_dir / "VkLayer_LS_frame_generation.json" + + for path in ( + service.lib_file, + service.config_file_path, + service.legacy_script_path, + ): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("owned", encoding="utf-8") + unrelated = home / ".local/bin/keep-me" + unrelated.parent.mkdir(parents=True, exist_ok=True) + unrelated.write_text("user file", encoding="utf-8") + + result = service.uninstall() + + self.assertTrue(result["success"]) + self.assertFalse(service.lib_file.exists()) + self.assertFalse(service.config_file_path.exists()) + self.assertFalse(service.legacy_script_path.exists()) + self.assertTrue(unrelated.exists()) + self.assertTrue(service.local_bin_dir.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_plugin_migration.py b/tests/test_plugin_migration.py new file mode 100644 index 0000000..fc51470 --- /dev/null +++ b/tests/test_plugin_migration.py @@ -0,0 +1,101 @@ +import asyncio +import sys +import types +import unittest +from unittest.mock import Mock + + +class PluginMigrationTests(unittest.TestCase): + def _load_plugin(self): + decky = types.SimpleNamespace( + DECKY_HOME="/decky", + DECKY_USER_HOME="/home/deck", + migrate_logs=Mock(), + migrate_settings=Mock(), + migrate_runtime=Mock(), + logger=Mock(), + ) + previous_decky = sys.modules.get("decky") + previous_tomllib = sys.modules.get("tomllib") + previous_plugin = sys.modules.pop("lsfg_vk.plugin", None) + sys.modules["decky"] = decky + sys.modules["tomllib"] = types.SimpleNamespace(loads=Mock()) + sys.path.insert(0, "py_modules") + from lsfg_vk.plugin import Plugin + return Plugin, decky, previous_decky, previous_tomllib, previous_plugin + + def _restore(self, previous_decky, previous_tomllib, previous_plugin): + sys.path.remove("py_modules") + if previous_decky is None: + sys.modules.pop("decky", None) + else: + sys.modules["decky"] = previous_decky + if previous_tomllib is None: + sys.modules.pop("tomllib", None) + else: + sys.modules["tomllib"] = previous_tomllib + if previous_plugin is None: + sys.modules.pop("lsfg_vk.plugin", None) + else: + sys.modules["lsfg_vk.plugin"] = previous_plugin + + def test_migration_only_runs_decky_path_migrations(self): + Plugin, decky, previous_decky, previous_tomllib, previous_plugin = self._load_plugin() + try: + plugin = Plugin.__new__(Plugin) + plugin.installation_service = Mock() + plugin.flatpak_service = Mock() + + asyncio.run(plugin._migration()) + + decky.migrate_logs.assert_called_once() + decky.migrate_settings.assert_called_once() + decky.migrate_runtime.assert_called_once() + plugin.installation_service.install.assert_not_called() + plugin.flatpak_service.prepare_app.assert_not_called() + finally: + self._restore(previous_decky, previous_tomllib, previous_plugin) + + def test_uninstall_cleans_owned_flatpak_state_and_profiles(self): + Plugin, _decky, previous_decky, previous_tomllib, previous_plugin = self._load_plugin() + try: + plugin = Plugin.__new__(Plugin) + plugin.installation_service = Mock() + plugin.flatpak_service = Mock() + plugin.configuration_service = Mock() + plugin.wrapper_service = Mock() + plugin.flatpak_service.remove_plugin_owned_environment.return_value = {"success": True} + plugin.configuration_service.reset_all_flatpak_configs.return_value = {"success": True} + plugin.wrapper_service.neutralize.return_value = {"success": True} + + asyncio.run(plugin._uninstall()) + + plugin.flatpak_service.remove_plugin_owned_environment.assert_called_once_with() + plugin.configuration_service.reset_all_flatpak_configs.assert_called_once_with() + plugin.wrapper_service.neutralize.assert_called_once_with() + plugin.wrapper_service.purge.assert_not_called() + plugin.installation_service.cleanup_on_uninstall.assert_called_once_with() + finally: + self._restore(previous_decky, previous_tomllib, previous_plugin) + + def test_uninstall_preserves_profiles_when_flatpak_cleanup_fails(self): + Plugin, _decky, previous_decky, previous_tomllib, previous_plugin = self._load_plugin() + try: + plugin = Plugin.__new__(Plugin) + plugin.installation_service = Mock() + plugin.flatpak_service = Mock() + plugin.configuration_service = Mock() + plugin.wrapper_service = Mock() + plugin.flatpak_service.remove_plugin_owned_environment.return_value = {"success": False, "error": "changed"} + + asyncio.run(plugin._uninstall()) + + plugin.configuration_service.reset_all_flatpak_configs.assert_not_called() + plugin.wrapper_service.purge.assert_not_called() + plugin.installation_service.cleanup_on_uninstall.assert_not_called() + finally: + self._restore(previous_decky, previous_tomllib, previous_plugin) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_steam_service.py b/tests/test_steam_service.py new file mode 100644 index 0000000..75cdabf --- /dev/null +++ b/tests/test_steam_service.py @@ -0,0 +1,76 @@ +import sys +import types +import unittest +from pathlib import Path +from unittest.mock import Mock + + +sys.modules.setdefault( + "decky", + types.SimpleNamespace(DECKY_USER_HOME="/home/deck", logger=Mock()), +) +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "py_modules")) + +from lsfg_vk.steam_service import SteamService + + +class SteamShortcutTests(unittest.TestCase): + def test_direct_flatpak_shortcut_is_marked(self): + game = SteamService._shortcut_game( + { + "appid": 123456, + "AppName": "PCSX2 shortcut", + "Exe": "/usr/bin/flatpak", + "LaunchOptions": "run net.pcsx2.PCSX2 --fullscreen", + "StartDir": "/home/deck/Games", + } + ) + + self.assertEqual(game, { + "appid": "123456", + "name": "PCSX2 shortcut", + "nonSteam": True, + "isFlatpakShortcut": True, + }) + + def test_bare_flatpak_shortcut_is_marked(self): + game = SteamService._shortcut_game( + { + "appid": 654321, + "AppName": "Faugus shortcut", + "Exe": '"flatpak"', + "LaunchOptions": "run io.github.Faugus.faugus-launcher --game elliot", + } + ) + + self.assertEqual(game, { + "appid": "654321", + "name": "Faugus shortcut", + "nonSteam": True, + "isFlatpakShortcut": True, + }) + + def test_emudeck_launcher_is_ordinary_non_steam_metadata(self): + game = SteamService._shortcut_game( + { + "appid": 987654, + "AppName": "1080 Snowboarding", + "Exe": '"/home/deck/Emulation/tools/launchers/retroarch.sh" -L core rom.z64', + "LaunchOptions": "", + } + ) + + self.assertEqual(game, { + "appid": "987654", + "name": "1080 Snowboarding", + "nonSteam": True, + }) + + def test_shortcut_rejects_invalid_identity(self): + self.assertIsNone(SteamService._shortcut_game({"appid": 0, "AppName": "Bad"})) + self.assertIsNone(SteamService._shortcut_game({"appid": 1, "AppName": ""})) + self.assertIsNone(SteamService._shortcut_game("bad")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_wrapper_service.py b/tests/test_wrapper_service.py new file mode 100644 index 0000000..46e029b --- /dev/null +++ b/tests/test_wrapper_service.py @@ -0,0 +1,210 @@ +import subprocess +import sys +import tempfile +import types +import unittest +from pathlib import Path +from unittest.mock import Mock + + +sys.modules.setdefault( + "decky", + types.SimpleNamespace(DECKY_USER_HOME="/home/deck", logger=Mock()), +) +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "py_modules")) + +from lsfg_vk.wrapper_service import WrapperService + + +class WrapperServiceTests(unittest.TestCase): + def setUp(self): + self.tempdir = tempfile.TemporaryDirectory() + self.home = Path(self.tempdir.name) / "home" / "deck" + self.home.mkdir(parents=True) + self.service = WrapperService() + self.service.user_home = self.home + self.service.config_dir = self.home / ".config/lsfg-vk" + self.service.config_file_path = self.service.config_dir / "conf.toml" + self.service.sidecar_path = self.service.config_dir / "workarounds.json" + self.service.wrapper_path = self.home / ".lsfg" + + def tearDown(self): + self.tempdir.cleanup() + + def _state(self, **changes): + state = self.service.default_state() + state.update(changes) + return state + + def _run(self, appid, *args, env=None): + process_env = {"PATH": "/usr/bin:/bin", "SteamAppId": str(appid)} + if env: + process_env.update(env) + return subprocess.run( + [str(self.service.wrapper_path), *args], + env=process_env, + capture_output=True, + text=True, + check=True, + ) + + def test_writes_owned_dispatcher_and_validates_shell(self): + response = self.service.set("123", self._state(dxvkFrameRate=60, enableZink=True)) + self.assertTrue(response["success"]) + self.assertEqual(response["wrapper_path"], "~/.lsfg") + self.assertTrue(response["wrapper_owned"]) + self.assertEqual(response["state"]["dxvkFrameRate"], 60) + self.assertEqual(subprocess.run(["/bin/sh", "-n", str(self.service.wrapper_path)]).returncode, 0) + self.assertIn(self.service.MARKER, self.service.wrapper_path.read_text(encoding="utf-8")) + self.assertEqual(self.service.get("123")["state"], self._state(dxvkFrameRate=60, enableZink=True)) + + def test_dispatch_exports_appid_config_and_workarounds(self): + self.service.set( + "123", + self._state(dxvkFrameRate=30, disableSteamdeckMode=True, disableVkbasalt=True, enableZink=True), + ) + result = self._run( + 123, + "/usr/bin/env", + env={ + "DXVK_CONFIG": "dxgi.syncInterval = 0", + "DXVK_FRAME_RATE": "5", + "ENABLE_GAMESCOPE_WSI": "1", + "DISABLE_LSFGVK": "1", + "DISABLE_LSFG": "1", + "DISABLE_VKBASALT": "0", + "MESA_LOADER_DRIVER_OVERRIDE": "llvmpipe", + "MANGOHUD": "1", + }, + ) + values = dict(line.split("=", 1) for line in result.stdout.splitlines() if "=" in line) + self.assertEqual(values["SteamAppId"], "123") + self.assertEqual(values["LSFGVK_CONFIG"], str(self.service.config_file_path)) + self.assertEqual(values["ENABLE_GAMESCOPE_WSI"], "0") + self.assertEqual(values["DXVK_HDR"], "0") + self.assertEqual(values["SteamDeck"], "0") + self.assertEqual(values["DISABLE_VKBASALT"], "1") + self.assertEqual(values["__GLX_VENDOR_LIBRARY_NAME"], "mesa") + self.assertEqual(values["MESA_LOADER_DRIVER_OVERRIDE"], "zink") + self.assertEqual(values["GALLIUM_DRIVER"], "zink") + self.assertEqual(values["DXVK_CONFIG"], "dxgi.syncInterval = 0; dxvk.maxFrameRate = 30") + self.assertEqual(values["MANGOHUD"], "1") + self.assertNotIn("DXVK_FRAME_RATE", values) + self.assertNotIn("ENABLE_VKBASALT", values) + self.assertNotIn("DISABLE_LSFGVK", values) + self.assertNotIn("DISABLE_LSFG", values) + + def test_wrapper_is_transport_agnostic(self): + self.service.set("123", self._state()) + fake = self.home / "target" + fake.write_text("#!/bin/sh\nprintf '%s\\n' \"$@\"\n", encoding="utf-8") + fake.chmod(0o755) + result = self._run(123, str(fake), "run", "org.example.Game") + self.assertEqual(result.stdout.splitlines(), ["run", "org.example.Game"]) + content = self.service.wrapper_path.read_text(encoding="utf-8") + self.assertNotIn("flatpakAppId", content) + self.assertNotIn("shortcut_exe", content) + self.assertNotIn("--filesystem", content) + + def test_appid_fallback_and_unmatched_passthrough(self): + self.service.set("123", self._state(disableGamescopeWsi=False, disableHdr=False)) + self.service.set("456", self._state(disableSteamdeckMode=True)) + fallback = subprocess.run( + [str(self.service.wrapper_path), "/usr/bin/env"], + env={"PATH": "/usr/bin:/bin", "SteamAppId": "bad", "SteamGameId": "456"}, + capture_output=True, + text=True, + check=True, + ) + fallback_values = dict(line.split("=", 1) for line in fallback.stdout.splitlines() if "=" in line) + self.assertEqual(fallback_values["SteamDeck"], "0") + self.assertEqual(fallback_values["SteamAppId"], "456") + + passthrough = subprocess.run( + [str(self.service.wrapper_path), "/usr/bin/env"], + env={"PATH": "/usr/bin:/bin", "SteamAppId": "999", "KEEP": "yes", "DXVK_HDR": "1"}, + capture_output=True, + text=True, + check=True, + ) + passthrough_values = dict(line.split("=", 1) for line in passthrough.stdout.splitlines() if "=" in line) + self.assertEqual(passthrough_values["KEEP"], "yes") + self.assertEqual(passthrough_values["DXVK_HDR"], "1") + + def test_invalid_state_and_foreign_wrapper_fail_closed(self): + invalid = self.service.set("0", self.service.default_state()) + self.assertFalse(invalid["success"]) + invalid = self.service.set("123", {**self.service.default_state(), "dxvkFrameRate": 61}) + self.assertFalse(invalid["success"]) + + self.service.wrapper_path.write_text("#!/bin/sh\necho foreign\n", encoding="utf-8") + response = self.service.set("123", self.service.default_state()) + self.assertFalse(response["success"]) + self.assertIn("unowned", response["error"]) + self.assertEqual(self.service.wrapper_path.read_text(encoding="utf-8"), "#!/bin/sh\necho foreign\n") + + def test_remove_keeps_safe_passthrough_wrapper(self): + self.service.set("123", self.service.default_state()) + response = self.service.remove("123") + self.assertTrue(response["success"]) + self.assertIsNone(self.service.get("123")["state"]) + self.assertTrue(self.service.wrapper_path.exists()) + result = subprocess.run( + [str(self.service.wrapper_path), "/usr/bin/printf", "ok"], + env={"PATH": "/usr/bin:/bin", "SteamAppId": "123"}, + capture_output=True, + text=True, + check=True, + ) + self.assertEqual(result.stdout, "ok") + + def test_purge_removes_owned_wrapper_and_state(self): + self.service.set("123", self._state(), non_steam=True) + self.assertTrue(self.service.get("123")["non_steam"]) + self.assertEqual(self.service.list_apps()["apps"][0]["non_steam"], True) + response = self.service.purge() + self.assertTrue(response["success"]) + self.assertEqual(response["removed_files"], [str(self.service.wrapper_path), str(self.service.sidecar_path)]) + self.assertFalse(self.service.wrapper_path.exists()) + self.assertFalse(self.service.sidecar_path.exists()) + + def test_purge_refuses_foreign_wrapper(self): + self.service.wrapper_path.write_text("#!/bin/sh\necho foreign\n", encoding="utf-8") + response = self.service.purge() + self.assertFalse(response["success"]) + self.assertIn("unowned", response["error"]) + self.assertTrue(self.service.wrapper_path.exists()) + + def test_purge_refuses_invalid_state(self): + self.service.config_dir.mkdir(parents=True, exist_ok=True) + self.service.sidecar_path.write_text("not json", encoding="utf-8") + self.service.wrapper_path.write_text( + f"#!/bin/sh\n{self.service.MARKER}\nexec \"$@\"\n", + encoding="utf-8", + ) + response = self.service.purge() + self.assertFalse(response["success"]) + self.assertTrue(self.service.wrapper_path.exists()) + self.assertTrue(self.service.sidecar_path.exists()) + + def test_neutralize_leaves_dependency_free_passthrough_wrapper(self): + self.service.set("123", self._state()) + response = self.service.neutralize() + self.assertTrue(response["success"]) + self.assertFalse(self.service.sidecar_path.exists()) + self.assertTrue(self.service.wrapper_path.exists()) + content = self.service.wrapper_path.read_text(encoding="utf-8") + self.assertIn(self.service.MARKER, content) + self.assertNotIn("LSFGVK_CONFIG", content) + self.assertEqual(self._run(123, "/usr/bin/printf", "ok").stdout, "ok") + + def test_neutralize_refuses_foreign_wrapper(self): + self.service.wrapper_path.write_text("#!/bin/sh\necho foreign\n", encoding="utf-8") + response = self.service.neutralize() + self.assertFalse(response["success"]) + self.assertIn("unowned", response["error"]) + self.assertEqual(self.service.wrapper_path.read_text(encoding="utf-8"), "#!/bin/sh\necho foreign\n") + + +if __name__ == "__main__": + unittest.main() diff --git a/tsconfig.json b/tsconfig.json index 626c60f..f4b795f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,6 +8,7 @@ "jsxFragmentFactory": "window.SP_REACT.Fragment", "declaration": false, "moduleResolution": "node", + "resolveJsonModule": true, "noUnusedLocals": true, "noUnusedParameters": true, "esModuleInterop": true, |
