summaryrefslogtreecommitdiff
path: root/src
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 /src
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
Diffstat (limited to 'src')
-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
43 files changed, 3520 insertions, 2913 deletions
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);
-}