summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorKurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com>2026-09-12 21:29:08 -0400
committerGitHub <noreply@github.com>2026-09-12 21:29:08 -0400
commite580c6bd60c92af36f92d4c29b5d9a44f73d47d7 (patch)
tree7fc6799626519e5b01fb137f5440c99fda5faca7
parente997a3fb74fa70f5e60b60807fb0897120e98313 (diff)
parent81feb03288166755545401b9df2ff2c9bc3ae7d7 (diff)
downloaddecky-lsfg-vk-e580c6bd60c92af36f92d4c29b5d9a44f73d47d7.tar.gz
decky-lsfg-vk-e580c6bd60c92af36f92d4c29b5d9a44f73d47d7.zip
Merge pull request #264 from xXJSONDeruloXx/chore/migrate
the big one
-rw-r--r--README.md61
-rw-r--r--assets/flatpak-target.pngbin78195 -> 0 bytes
-rw-r--r--defaults/i18n/ja.json34
-rw-r--r--defaults/i18n/ko.json34
-rw-r--r--defaults/i18n/template.json34
-rw-r--r--justfile14
-rw-r--r--package.json30
-rw-r--r--py_modules/lsfg_vk/base_service.py144
-rw-r--r--py_modules/lsfg_vk/config_schema.py687
-rw-r--r--py_modules/lsfg_vk/config_schema_generated.py125
-rw-r--r--py_modules/lsfg_vk/configuration.py627
-rw-r--r--py_modules/lsfg_vk/constants.py46
-rw-r--r--py_modules/lsfg_vk/dll_detection.py223
-rw-r--r--py_modules/lsfg_vk/flatpak_profile_service.py383
-rw-r--r--py_modules/lsfg_vk/flatpak_service.py1037
-rw-r--r--py_modules/lsfg_vk/installation.py623
-rw-r--r--py_modules/lsfg_vk/plugin.py605
-rw-r--r--py_modules/lsfg_vk/runtime_service.py120
-rw-r--r--py_modules/lsfg_vk/steam_service.py290
-rw-r--r--py_modules/lsfg_vk/types.py82
-rw-r--r--py_modules/lsfg_vk/wrapper_service.py464
-rwxr-xr-xscripts/deploy-to-deck.sh88
-rw-r--r--scripts/generate_python_boilerplate.py252
-rw-r--r--scripts/generate_ts_schema.py158
-rw-r--r--shared_config.py162
-rw-r--r--src/api/lsfgApi.ts250
-rw-r--r--src/components/CollapsibleItemGroup.tsx103
-rw-r--r--src/components/ConfigFileTab.tsx123
-rw-r--r--src/components/ConfigurationSection.tsx312
-rw-r--r--src/components/ConfigurationTab.tsx204
-rw-r--r--src/components/Content.tsx422
-rw-r--r--src/components/FgmodClipboardButton.tsx110
-rw-r--r--src/components/FlatpakNowPlayingTab.tsx38
-rw-r--r--src/components/FlatpakTab.tsx247
-rw-r--r--src/components/FlatpakWorkaroundsSection.tsx144
-rw-r--r--src/components/FlatpaksModal.tsx433
-rw-r--r--src/components/FpsMultiplierControl.tsx104
-rw-r--r--src/components/GameConfigurationControls.tsx44
-rw-r--r--src/components/GameConfigurationSelector.tsx140
-rw-r--r--src/components/InstallationButton.tsx65
-rw-r--r--src/components/NerdStuffModal.tsx187
-rw-r--r--src/components/NowPlayingSummary.tsx16
-rw-r--r--src/components/NowPlayingTab.tsx39
-rw-r--r--src/components/ProfileDetails.tsx37
-rw-r--r--src/components/ProfileManagement.tsx477
-rw-r--r--src/components/SettingsTab.tsx100
-rw-r--r--src/components/SmartClipboardButton.tsx91
-rw-r--r--src/components/StatusDisplay.tsx51
-rw-r--r--src/components/UsageInstructions.tsx69
-rw-r--r--src/components/WorkaroundsSection.tsx212
-rw-r--r--src/components/index.ts15
-rw-r--r--src/config/configSchema.ts6
-rw-r--r--src/config/generatedConfigSchema.ts203
-rw-r--r--src/hooks/useFlatpakConfiguration.ts160
-rw-r--r--src/hooks/useGameConfiguration.ts378
-rw-r--r--src/hooks/useInstallationActions.ts76
-rw-r--r--src/hooks/useLsfgHooks.ts176
-rw-r--r--src/hooks/usePerAppWorkarounds.ts266
-rw-r--r--src/hooks/useProfileManagement.ts194
-rw-r--r--src/i18n/i18n.ts25
-rw-r--r--src/i18n/languages.json108
-rw-r--r--src/styles.ts51
-rw-r--r--src/types.d.ts24
-rw-r--r--src/utils/clipboardUtils.ts64
-rw-r--r--src/utils/gameTargets.ts75
-rw-r--r--src/utils/nowPlaying.ts86
-rw-r--r--src/utils/steamLaunchOptions.ts409
-rw-r--r--src/utils/toastUtils.ts99
-rw-r--r--tests/gameTargets.test.ts50
-rw-r--r--tests/nowPlaying.test.ts114
-rw-r--r--tests/steamLaunchOptions.test.ts219
-rw-r--r--tests/test_configuration_profiles.py114
-rw-r--r--tests/test_flatpak_profile_service.py233
-rw-r--r--tests/test_flatpak_service.py320
-rw-r--r--tests/test_installation_cleanup.py63
-rw-r--r--tests/test_plugin_migration.py101
-rw-r--r--tests/test_steam_service.py76
-rw-r--r--tests/test_wrapper_service.py210
-rw-r--r--tsconfig.json1
79 files changed, 7783 insertions, 6474 deletions
diff --git a/README.md b/README.md
index e8bf1fa..31e2ad8 100644
--- a/README.md
+++ b/README.md
@@ -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
deleted file mode 100644
index 773e567..0000000
--- a/assets/flatpak-target.png
+++ /dev/null
Binary files differ
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"
}
diff --git a/justfile b/justfile
index 54148ad..57636a8 100644
--- a/justfile
+++ b/justfile
@@ -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,