diff options
Diffstat (limited to 'src')
28 files changed, 1705 insertions, 1498 deletions
diff --git a/src/api/lsfgApi.ts b/src/api/lsfgApi.ts index 16b6eab..e050f62 100644 --- a/src/api/lsfgApi.ts +++ b/src/api/lsfgApi.ts @@ -1,11 +1,13 @@ 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[]; } @@ -16,10 +18,8 @@ export interface InstallationStatus { error?: string; } -export interface SteamBranchStatus { - success: boolean; +export interface SteamBranchStatus extends ApiResult { message: string; - error?: string; installed: boolean; manifest_path?: string; selected_branch?: string; @@ -29,62 +29,23 @@ export interface SteamBranchStatus { restart_required: boolean; } -// Use centralized configuration data type export type LsfgConfig = ConfigurationData; -export interface ConfigUpdateResult { - success: boolean; - message?: string; - error?: string; -} - export interface GameConfigEntry { appid: string; profile: string; config: LsfgConfig; } -export type TargetTransport = - | { kind: "host" } - | { kind: "flatpak"; flatpakAppId: string }; - -export type FlatpakTargetSupportStatus = "ready" | "needs-runtime" | "unsupported" | "error"; - -export interface FlatpakTargetSupport { - success: boolean; - message?: string; - error?: string | null; - flatpak_app_id?: string; - runtime?: string | null; - runtime_branch?: string | null; - support_status: FlatpakTargetSupportStatus; - extension_installed: boolean; - installed_branches: string[]; -} export interface InstalledGame { appid: string; name: string; nonSteam: boolean; - transport: TargetTransport; - executable?: string; - arguments?: string; - startDir?: string; - flatpakSupport?: FlatpakTargetSupport; } -export interface InstalledGamesResult { success: boolean; games?: InstalledGame[]; error?: string; } -export interface GlobalConfig { dll: string; no_fp16: boolean; } -export interface GameConfigsResult { - success: boolean; - global_config?: GlobalConfig; - games?: GameConfigEntry[]; - error?: string; -} - -export interface GameConfigResult extends ConfigUpdateResult { - appid?: string; - exists?: boolean; - config?: LsfgConfig; +export interface GlobalConfig { + dll: string; + no_fp16: boolean; } export interface WorkaroundState { @@ -96,88 +57,122 @@ export interface WorkaroundState { enableZink: boolean; } -export interface WorkaroundStateResult { - success: boolean; - message?: string; - error?: string; +export interface WorkaroundStateResult extends ApiResult { appid?: string; + app_id?: string; state?: WorkaroundState | null; wrapper_path?: string; wrapper_owned?: boolean; - shortcut_exe?: string | null; command_token_added?: boolean; - transport?: TargetTransport | null; + non_steam?: boolean; } -export interface FileContentResult { - success: boolean; +export interface WorkaroundApp { + appid: string; + non_steam: boolean; + command_token_added: 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 FlatpakExtensionStatus { - success: boolean; - message: string; +export interface DebugFileContent { + id: string; + label: string; + path: string; + exists: boolean; + content?: string | null; error?: string | null; - available: boolean; - extension_id: string; - supported_branches: string[]; - installed_branches: string[]; - owned_branches: string[]; - ownership_uncertain: boolean; } -export interface FlatpakCleanupResult { - success: boolean; - message: string; - error?: string | null; - removed_branches: string[]; - preserved_branches: string[]; - ownership_uncertain: boolean; +export interface DebugFileContentsResult extends ApiResult { + files?: DebugFileContent[]; } -export interface FlatpakExtensionToggleResult { - success: boolean; - message: string; - error?: string | null; - runtime_branch: string; +export interface FlatpakApp { + app_id: string; + app_name: string; + runtime?: string | null; + runtime_branch?: string | null; + runtime_ready: boolean; + prepared: boolean; + owned: boolean; enabled: boolean; - installed: boolean; - owned_by_plugin: boolean; - preserved: 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 FlatpakAppsResult extends ApiResult { + apps?: FlatpakApp[]; +} + +export interface RunningFlatpakAppsResult extends ApiResult { + apps?: RunningFlatpakApp[]; +} + +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 getLosslessScalingBranchStatus = callable<[], SteamBranchStatus>("get_lossless_scaling_branch_status"); export const getConfigFileContent = callable<[], FileContentResult>("get_config_file_content"); - -export const getFlatpakSupportStatus = callable<[], FlatpakExtensionStatus>("get_flatpak_support_status"); -export const ensureFlatpakSupport = callable<[string], FlatpakTargetSupport>("ensure_flatpak_support"); -export const repairFlatpakSupport = callable<[string], FlatpakTargetSupport>("repair_flatpak_support"); -export const setFlatpakExtensionEnabled = callable< - [string, boolean], - FlatpakExtensionToggleResult ->("set_flatpak_extension_enabled"); -export const removePluginOwnedFlatpakExtensions = callable< - [], - FlatpakCleanupResult ->("remove_plugin_owned_flatpak_extensions"); - +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 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, - string | null | undefined, boolean, - TargetTransport | null | undefined, + 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..29fe945 --- /dev/null +++ b/src/components/CollapsibleItemGroup.tsx @@ -0,0 +1,101 @@ +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; +} + +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} + onActivate={() => onSelect(item.id)} + highlightOnFocus + /> + </PanelSectionRow> + ))} + </> + ); +} diff --git a/src/components/ConfigFileTab.tsx b/src/components/ConfigFileTab.tsx index e3cc09d..07408d7 100644 --- a/src/components/ConfigFileTab.tsx +++ b/src/components/ConfigFileTab.tsx @@ -1,13 +1,79 @@ +import { ButtonItem, Field, PanelSection, PanelSectionRow, Spinner } from "@decky/ui"; import { useEffect, useState } from "react"; -import { Field, PanelSection, PanelSectionRow, Spinner } from "@decky/ui"; -import { getConfigFileContent, FileContentResult } from "../api/lsfgApi"; +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<FileContentResult | null>(null); + const [result, setResult] = useState<DebugFileContentsResult | null>(null); useEffect(() => { - getConfigFileContent().then(setResult).catch((error) => { + getDebugFileContents().then(setResult).catch((error) => { setResult({ success: false, error: String(error) }); }); }, []); @@ -23,24 +89,35 @@ export function ConfigFileTab() { } return ( - <PanelSection title={t("NERD_CONFIG_FILE", "Configuration File")}> - {result.error && ( - <PanelSectionRow> - <Field label="Error" description={result.error} /> - </PanelSectionRow> - )} - {result.success && result.content && ( - <> - <PanelSectionRow> - <Field label="Config file" description={result.path} /> - </PanelSectionRow> + <> + <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", "Configuration File")}> + {result.error && ( <PanelSectionRow> - <pre style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}> - {result.content} - </pre> + <Field label="Error" description={result.error} /> </PanelSectionRow> - </> - )} - </PanelSection> + )} + {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 1f17264..6996bcd 100644 --- a/src/components/ConfigurationSection.tsx +++ b/src/components/ConfigurationSection.tsx @@ -1,6 +1,6 @@ import { PanelSectionRow, ToggleField, SliderField } from "@decky/ui"; import { ConfigurationData } from "../config/configSchema"; -import { FLOW_SCALE, PERFORMANCE_MODE, OVERRIDE_PRESENT_MODE, PRESERVE_SWAPCHAIN_IMAGE_COUNT, NO_FP16 } from "../config/configSchema"; +import { FLOW_SCALE, PERFORMANCE_MODE, OVERRIDE_PRESENT_MODE, PRESERVE_SWAPCHAIN_IMAGE_COUNT } from "../config/configSchema"; interface ConfigurationSectionProps { config: ConfigurationData; @@ -13,9 +13,6 @@ export function ConfigurationSection({ config, onConfigChange }: ConfigurationSe <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="FP16 Acceleration" checked={!config.no_fp16} onChange={(value) => onConfigChange(NO_FP16, !value)} /> - </PanelSectionRow> - <PanelSectionRow> <ToggleField label="Performance Mode" checked={config.performance_mode} onChange={(value) => onConfigChange(PERFORMANCE_MODE, value)} /> </PanelSectionRow> <PanelSectionRow> diff --git a/src/components/ConfigurationTab.tsx b/src/components/ConfigurationTab.tsx index 8f8690c..37a7867 100644 --- a/src/components/ConfigurationTab.tsx +++ b/src/components/ConfigurationTab.tsx @@ -1,4 +1,4 @@ -import { ButtonItem, DialogButton, Focusable, PanelSection, PanelSectionRow, gamepadDialogClasses } from "@decky/ui"; +import { ButtonItem, ConfirmModal, DialogButton, 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"; @@ -37,7 +37,6 @@ export function ConfigurationTab({ const [focusDetailAction, setFocusDetailAction] = useState<"enable" | "fps" | null>(null); const [focusConfiguredToggle, setFocusConfiguredToggle] = useState(false); const enableRef = useRef<HTMLDivElement>(null); - const promptedRunningAppId = useRef<string | null>(null); const closeDetails = useCallback(() => { setFocusFpsMultiplier(false); setFocusDetailAction(null); @@ -60,58 +59,65 @@ export function ConfigurationTab({ return () => cancelAnimationFrame(frame); }, [focusDetailAction]); - useEffect(() => { - if (!runningGame || runningGame.configured) { - promptedRunningAppId.current = null; - return; - } - if (promptedRunningAppId.current !== runningGame.appid && detailAppId === null) { - promptedRunningAppId.current = runningGame.appid; - setFocusDetailAction("enable"); - setDetailAppId(runningGame.appid); - } - }, [detailAppId, runningGame?.appid, runningGame?.configured]); - const selectedTarget = detailAppId ? targets.find((target) => target.appid === detailAppId) : null; if (detailAppId === null) { return ( - <PanelSection title="Games"> - <GameConfigurationSelector - targets={targets} - runningGame={runningGame} - onSelect={(appid) => { - setFocusConfiguredToggle(false); - setFocusDetailAction(targets.find((target) => target.appid === appid)?.configured ? "fps" : "enable"); - onSelect(appid); - setDetailAppId(appid); - }} - onEnableAll={onEnableAll} - onResetAll={onResetAll} - focusConfiguredToggle={focusConfiguredToggle} - onConfiguredToggleFocused={clearConfiguredToggleFocusRequest} - /> - </PanelSection> + <> + <PanelSection title="Games"> + <GameConfigurationSelector + targets={targets} + runningGame={runningGame} + onSelect={(appid) => { + setFocusConfiguredToggle(false); + setFocusDetailAction(targets.find((target) => target.appid === appid)?.configured ? "fps" : "enable"); + onSelect(appid); + setDetailAppId(appid); + }} + onEnableAll={onEnableAll} + onResetAll={onResetAll} + focusConfiguredToggle={focusConfiguredToggle} + onConfiguredToggleFocused={clearConfiguredToggleFocusRequest} + /> + </PanelSection> + </> ); } const profileLabel = selectedTarget?.name || "Game profile"; - const profileTransport = selectedTarget - ? selectedTarget.transport.kind === "flatpak" - ? "Non-Steam · Flatpak" - : selectedTarget.nonSteam ? "Non-Steam" : "Steam" - : "Game"; + const profileTransport = selectedTarget?.nonSteam ? "Non-Steam" : "Steam"; const profileDescription = selectedTarget ? `${profileTransport} · App ID ${selectedTarget.appid} · ${selectedTarget.configured ? "LSFG-VK Enabled" : "LSFG-VK not enabled"}` : "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) { - promptedRunningAppId.current = detailAppId; await onReset(); setFocusConfiguredToggle(true); closeDetails(); - } else if (detailAppId && await onEnable(detailAppId)) { - setFocusFpsMultiplier(true); + } else if (detailAppId) { + const isRunningUnconfigured = runningGame?.appid === detailAppId + && runningGame.nonSteam === false + && selectedTarget?.nonSteam === false + && !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); + } } }; @@ -121,22 +127,22 @@ export function ConfigurationTab({ <PanelSectionRow> <div style={{ display: "flex", alignItems: "center", width: "100%" }}> <Focusable noFocusRing style={{ flex: "none" }}> - <DialogButton - aria-label="Back to games" - onClick={closeDetails} - style={{ - width: "48px", - minWidth: "48px", - height: "24px", - minHeight: "24px", - padding: 0, - display: "flex", - alignItems: "center", - justifyContent: "center", - }} - > - <FaArrowLeft /> - </DialogButton> + <DialogButton + aria-label="Back to games" + 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} @@ -156,18 +162,6 @@ export function ConfigurationTab({ </PanelSectionRow> )} </PanelSection> - {selectedTarget?.configured && selectedTarget.transport.kind === "flatpak" && selectedTarget.flatpakSupport?.support_status !== "ready" && ( - <PanelSection> - <PanelSectionRow> - <ButtonItem - layout="below" - onClick={() => void onRepair(selectedTarget.appid)} - > - Repair Flatpak support - </ButtonItem> - </PanelSectionRow> - </PanelSection> - )} {selectedTarget?.configured && ( <GameConfigurationControls config={config} diff --git a/src/components/Content.tsx b/src/components/Content.tsx index 58512d5..3f25320 100644 --- a/src/components/Content.tsx +++ b/src/components/Content.tsx @@ -1,80 +1,126 @@ import { Tabs } from "@decky/ui"; -import { useEffect, useRef, useState } from "react"; -import { FaFileAlt, FaGamepad, FaList, FaTools } from "react-icons/fa"; +import { useEffect, useRef, useState, type FocusEvent, type ReactNode } from "react"; +import { FaCube, FaFileAlt, FaGamepad, FaList, FaTools } from "react-icons/fa"; import { ConfigurationData } from "../config/configSchema"; -import { tabStyles } from "../styles"; +import { useFlatpakConfiguration } from "../hooks/useFlatpakConfiguration"; import { useGameConfiguration } from "../hooks/useGameConfiguration"; -import { useInstallationActions } from "../hooks/useInstallationActions"; -import { useInstallationStatus } from "../hooks/useLsfgHooks"; -import { ConfigurationTab } from "./ConfigurationTab"; +import { useInstallation } from "../hooks/useLsfgHooks"; +import { tabStyles } from "../styles"; +import { resolveNowPlayingTarget } from "../utils/nowPlaying"; import { ConfigFileTab } from "./ConfigFileTab"; +import { ConfigurationTab } from "./ConfigurationTab"; +import { FlatpakNowPlayingTab } from "./FlatpakNowPlayingTab"; +import { FlatpakTab } from "./FlatpakTab"; import { NowPlayingTab } from "./NowPlayingTab"; import { SetupTab } from "./SetupTab"; const tabIcons = { nowPlaying: <FaGamepad size={18} />, games: <FaList size={18} />, + flatpak: <FaCube size={18} />, configFile: <FaFileAlt size={18} />, setup: <FaTools size={18} />, }; +const DEBUG_TAB_VISIBILITY_KEY = "lsfg-debug-tab-visible-v1"; + +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]); + + return [value, setValue] as const; +} + export function Content() { const { - isInstalled, - installationStatus, - setIsInstalled, - setInstallationStatus, - losslessScalingInstalled, - losslessScalingStatus, - steamBranchStatus, - checkInstallation, - } = useInstallationStatus(); - const { config, + runningConfig, + globalConfig, targets, runningGame, setSelectedAppId, save, + saveFor, + updateGlobal, enable, enableAll, repair, resetSelected, resetAll, + cleanupAllWorkarounds, reload, } = useGameConfiguration(); - const { isInstalling, isUninstalling, handleInstall, handleUninstall } = useInstallationActions(); - const [tab, setTab] = useState("Setup"); + const { + 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 previousRunningAppId = useRef<string | null>(null); + const flatpak = useFlatpakConfiguration(setupComplete); + const [tab, setTab] = useState("Setup"); + const [showDebugTab, setShowDebugTab] = usePersistentBoolean(DEBUG_TAB_VISIBILITY_KEY, false); + const [contentFocused, setContentFocused] = useState(false); + const previousRunningWorkload = useRef<string | null>(null); + 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 ?? ""}` + : `steam:${nowPlayingTarget.game.appid}` + : null; useEffect(() => { if (!setupComplete) { setTab("Setup"); return; } - setTab((current) => current === "Setup" ? (runningGame ? "NowPlaying" : "Games") : current); - }, [runningGame?.appid, setupComplete]); + setTab((current) => current === "Setup" ? (hasNowPlaying ? "NowPlaying" : "Games") : current); + }, [hasNowPlaying, setupComplete]); useEffect(() => { if (!setupComplete) return; - const appid = runningGame?.appid || null; - const previous = previousRunningAppId.current; - previousRunningAppId.current = appid; - if (appid && appid !== previous) { - setTab("NowPlaying"); - } else if (!appid && previous) { - setTab((currentTab) => currentTab === "NowPlaying" ? "Games" : currentTab); + const previous = previousRunningWorkload.current; + previousRunningWorkload.current = runningWorkload; + if (runningWorkload && runningWorkload !== previous) setTab("NowPlaying"); + else if (!runningWorkload && previous) { + setTab((current) => current === "NowPlaying" ? "Games" : current); + } + }, [runningWorkload, setupComplete]); + + useEffect(() => { + if (isInstalled) { + void reload(); + void flatpak.reload(); } - }, [runningGame?.appid, runningGame?.configured, setupComplete]); + }, [isInstalled, reload, flatpak.reload]); useEffect(() => { - if (isInstalled) void reload(); - }, [isInstalled, reload]); + if (!showDebugTab && tab === "ConfigFile") setTab("Games"); + }, [showDebugTab, tab]); const handleConfigChange = async ( fieldName: keyof ConfigurationData, @@ -84,15 +130,7 @@ export function Content() { await save({ ...config, [fieldName]: value }, cleanupLaunchOptions); }; - const onInstall = () => { - void handleInstall(setIsInstalled, setInstallationStatus, reload, checkInstallation); - }; - - const onUninstall = () => { - void handleUninstall(setIsInstalled, setInstallationStatus, checkInstallation); - }; - - const setupContent = ( + const setup = ( <SetupTab isInstalled={isInstalled} installationStatus={installationStatus} @@ -101,63 +139,101 @@ export function Content() { steamBranchStatus={steamBranchStatus} isInstalling={isInstalling} isUninstalling={isUninstalling} - onInstall={onInstall} - onUninstall={onUninstall} - flatpakRelevant={targets.some((target) => target.transport.kind === "flatpak")} + 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 === "steam" ? ( + <NowPlayingTab + game={nowPlayingTarget.game} + config={runningConfig} + onConfigChange={async (field, value) => { + await saveFor(nowPlayingTarget.game.appid, { ...runningConfig, [field]: value }, true); + }} + /> + ) : nowPlayingTarget?.kind === "flatpak" ? ( + <FlatpakNowPlayingTab + app={nowPlayingTarget.app} + launcher={nowPlayingTarget.launcher} + onConfigChange={flatpak.updateConfig} + /> + ) : null; + const tabs = setupComplete ? [ - ...(runningGame ? [{ - id: "NowPlaying", - title: tabIcons.nowPlaying, - content: ( - <NowPlayingTab - game={runningGame} - config={config} - onConfigChange={(fieldName, value) => handleConfigChange(fieldName, value)} - onEnable={enable} - onRepair={repair} - /> - ), - }] : []), + ...(nowPlaying ? [{ id: "NowPlaying", title: tabIcons.nowPlaying, content: tabContent(nowPlaying) }] : []), { id: "Games", title: tabIcons.games, - content: ( + content: tabContent( <ConfigurationTab config={config} targets={targets} runningGame={runningGame} onSelect={setSelectedAppId} - onConfigChange={(fieldName, value) => handleConfigChange(fieldName, value, true)} + onConfigChange={(field, value) => handleConfigChange(field, value, true)} onEnable={enable} onEnableAll={enableAll} onRepair={repair} onReset={resetSelected} onResetAll={resetAll} - /> + />, ), }, { - id: "ConfigFile", - title: tabIcons.configFile, - content: <ConfigFileTab />, + 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} + />, + ), }, - { id: "Setup", title: tabIcons.setup, content: setupContent }, + ...(showDebugTab ? [{ id: "ConfigFile", title: tabIcons.configFile, content: tabContent(<ConfigFileTab />) }] : []), + { id: "Setup", title: tabIcons.setup, content: tabContent(setup) }, ] - : [ - { id: "Setup", title: tabIcons.setup, content: setupContent }, - ]; + : [{ id: "Setup", title: tabIcons.setup, content: tabContent(setup) }]; + + const availableTabIds = new Set(tabs.map(({ id }) => id)); + const activeTab = availableTabIds.has(tab) ? tab : setupComplete ? "Games" : "Setup"; + const handleFocusCapture = (event: FocusEvent<HTMLDivElement>) => { + const focusedElement = event.target as HTMLElement | null; + setContentFocused(!focusedElement?.closest?.('[role="tab"]')); + }; return ( <div - className="lsfg-vk-tabs" + 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={tab} onShowTab={setTab} tabs={tabs} /> + <Tabs + activeTab={activeTab} + onShowTab={(nextTab: string) => { + if (availableTabIds.has(nextTab)) setTab(nextTab); + }} + tabs={tabs} + /> </div> ); } diff --git a/src/components/FlatpakNowPlayingTab.tsx b/src/components/FlatpakNowPlayingTab.tsx new file mode 100644 index 0000000..89f7f49 --- /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 "../hooks/useGameConfiguration"; +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.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/GameConfigurationControls.tsx b/src/components/GameConfigurationControls.tsx index 6bea2e0..7025f78 100644 --- a/src/components/GameConfigurationControls.tsx +++ b/src/components/GameConfigurationControls.tsx @@ -10,7 +10,7 @@ interface Props { autoFocusFpsMultiplier?: boolean; onFpsMultiplierFocused?: () => void; showWorkarounds?: boolean; - workaroundTarget?: Pick<GameTarget, "appid" | "nonSteam" | "transport">; + workaroundTarget?: Pick<GameTarget, "appid" | "nonSteam">; onRepairWorkaround?: () => Promise<boolean>; } @@ -36,7 +36,6 @@ export function GameConfigurationControls({ <WorkaroundsSection appId={workaroundTarget.appid} nonSteam={workaroundTarget.nonSteam} - transport={workaroundTarget.transport} onRepair={onRepairWorkaround} /> )} diff --git a/src/components/GameConfigurationSelector.tsx b/src/components/GameConfigurationSelector.tsx index 92eacba..2c683ae 100644 --- a/src/components/GameConfigurationSelector.tsx +++ b/src/components/GameConfigurationSelector.tsx @@ -1,7 +1,7 @@ import { ButtonItem, ConfirmModal, Field, PanelSectionRow, showModal } from "@decky/ui"; -import { useEffect, useRef, useState, type RefObject } from "react"; -import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri"; +import { useEffect, useRef } from "react"; import { GameTarget } from "../hooks/useGameConfiguration"; +import { CollapsibleItemGroup, collapsibleItemGroupStyles, usePersistentCollapsed } from "./CollapsibleItemGroup"; interface Props { targets: GameTarget[]; @@ -16,82 +16,10 @@ interface Props { const ENABLED_COLLAPSED_KEY = "lsfg-enabled-games-collapsed-v4"; const AVAILABLE_COLLAPSED_KEY = "lsfg-available-games-collapsed-v3"; -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 targetDescription(game: GameTarget): string { - if (game.transport.kind === "flatpak") return "Non-Steam · Flatpak"; return game.nonSteam ? "Non-Steam" : "Steam"; } -function GameGroup({ - title, - games, - collapsed, - onToggle, - onSelect, - toggleRef, -}: { - title: string; - games: GameTarget[]; - collapsed: boolean; - onToggle: () => void; - onSelect: (appid: string) => void; - toggleRef?: RefObject<HTMLDivElement>; -}) { - if (games.length === 0) return null; - - return ( - <> - <PanelSectionRow> - <Field label={title + " (" + games.length + ")"} bottomSeparator="none" /> - </PanelSectionRow> - <PanelSectionRow> - <div - ref={toggleRef} - className="LSFG_GameGroupCollapseButton_Container" - style={{ marginTop: "-2px", marginBottom: "4px" }} - > - <ButtonItem - layout="below" - bottomSeparator={collapsed ? "standard" : "none"} - onClick={onToggle} - > - {collapsed ? <RiArrowDownSFill /> : <RiArrowUpSFill />} - </ButtonItem> - </div> - </PanelSectionRow> - {!collapsed && games.map((game) => ( - <PanelSectionRow key={game.appid}> - <Field - label={game.name} - description={targetDescription(game)} - onActivate={() => onSelect(game.appid)} - highlightOnFocus - /> - </PanelSectionRow> - ))} - </> - ); -} - export function GameConfigurationSelector({ targets, runningGame, @@ -108,6 +36,11 @@ export function GameConfigurationSelector({ }); const enabledGames = sortGames(targets.filter((game) => game.configured)); const availableGames = sortGames(targets.filter((game) => !game.configured)); + const toItem = (game: GameTarget) => ({ + id: game.appid, + label: game.name, + description: targetDescription(game), + }); const [enabledCollapsed, toggleEnabled] = usePersistentCollapsed(ENABLED_COLLAPSED_KEY); const [availableCollapsed, toggleAvailable] = usePersistentCollapsed(AVAILABLE_COLLAPSED_KEY); const enabledToggleRef = useRef<HTMLDivElement>(null); @@ -137,7 +70,7 @@ export function GameConfigurationSelector({ showModal( <ConfirmModal strTitle="Enable all available games?" - strDescription="Create individual LSFG-VK profiles for every available game using the plugin defaults. Flatpak targets will be provisioned as needed." + strDescription="Create individual LSFG-VK profiles for every available Steam game and non-Steam shortcut. Flatpak profiles are managed separately in the Flatpak tab." strOKButtonText="Enable all" strCancelButtonText="Cancel" onOK={() => void onEnableAll()} @@ -149,39 +82,24 @@ export function GameConfigurationSelector({ return ( <> <style> - {` - .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; - } - `} + {collapsibleItemGroupStyles} </style> {targets.length === 0 && ( <PanelSectionRow> <Field label="No installed games" description="Steam has not reported any eligible games" /> </PanelSectionRow> )} - <GameGroup + <CollapsibleItemGroup title="Enabled" - games={enabledGames} + items={enabledGames.map(toItem)} collapsed={enabledCollapsed} onToggle={toggleEnabled} onSelect={onSelect} toggleRef={enabledToggleRef} /> - <GameGroup + <CollapsibleItemGroup title="Available" - games={availableGames} + items={availableGames.map(toItem)} collapsed={availableCollapsed} onToggle={toggleAvailable} onSelect={onSelect} diff --git a/src/components/InstallationButton.tsx b/src/components/InstallationButton.tsx deleted file mode 100644 index 1bf10ac..0000000 --- a/src/components/InstallationButton.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { ButtonItem, PanelSectionRow } from "@decky/ui"; -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 label = 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 ( - <PanelSectionRow> - <ButtonItem - layout="below" - onClick={isInstalled ? onUninstall : onInstall} - disabled={isInstalling || isUninstalling} - > - {label} - </ButtonItem> - </PanelSectionRow> - ); -} 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 index 5bce5c4..4c22fb7 100644 --- a/src/components/NowPlayingTab.tsx +++ b/src/components/NowPlayingTab.tsx @@ -1,8 +1,8 @@ -import { ButtonItem, Field, Focusable, PanelSection, PanelSectionRow } from "@decky/ui"; -import { useState } from "react"; +import { Focusable } from "@decky/ui"; import { ConfigurationData } from "../config/configSchema"; import { GameTarget } from "../hooks/useGameConfiguration"; import { GameConfigurationControls } from "./GameConfigurationControls"; +import { NowPlayingSummary } from "./NowPlayingSummary"; interface Props { game: GameTarget; @@ -11,12 +11,9 @@ interface Props { fieldName: keyof ConfigurationData, value: boolean | number | string | string[], ) => Promise<void>; - onEnable: (appid: string) => Promise<boolean>; - onRepair: (appid: string) => Promise<boolean>; } function targetDescription(game: GameTarget): string { - if (game.transport.kind === "flatpak") return "Non-Steam · Flatpak"; return game.nonSteam ? "Non-Steam" : "Steam"; } @@ -24,79 +21,18 @@ export function NowPlayingTab({ game, config, onConfigChange, - onEnable, - onRepair, }: Props) { - const [busy, setBusy] = useState(false); - const supportNeedsRepair = - game.configured && - game.transport.kind === "flatpak" && - game.flatpakSupport?.support_status !== "ready"; - - const handleEnable = async () => { - if (busy) return; - setBusy(true); - try { - await onEnable(game.appid); - } finally { - setBusy(false); - } - }; - - const handleRepair = async () => { - if (busy) return; - setBusy(true); - try { - await onRepair(game.appid); - } finally { - setBusy(false); - } - }; - return ( <Focusable> - <PanelSection title="Now Playing"> - <PanelSectionRow> - <Field label={game.name} description={targetDescription(game)} /> - </PanelSectionRow> - </PanelSection> - {!game.configured && ( - <PanelSection> - <PanelSectionRow> - <Field - label="LSFG-VK is available" - description="This target is not enabled yet. Create its AppID profile before the next launch." - /> - </PanelSectionRow> - <PanelSectionRow> - <ButtonItem layout="below" disabled={busy} onClick={() => void handleEnable()}> - {busy ? "Enabling..." : "Enable LSFG-VK"} - </ButtonItem> - </PanelSectionRow> - </PanelSection> - )} - {game.configured && supportNeedsRepair && ( - <PanelSection> - <PanelSectionRow> - <Field - label="Flatpak support needs repair" - description={game.flatpakSupport?.error || "The target runtime extension is not ready."} - /> - </PanelSectionRow> - <PanelSectionRow> - <ButtonItem layout="below" disabled={busy} onClick={() => void handleRepair()}> - {busy ? "Repairing..." : "Repair Flatpak support"} - </ButtonItem> - </PanelSectionRow> - </PanelSection> - )} - {game.configured && ( - <GameConfigurationControls - config={config} - onConfigChange={onConfigChange} - showWorkarounds={false} - /> - )} + <NowPlayingSummary + title={game.name} + details={[targetDescription(game), `Controls: ${game.name} profile`]} + /> + <GameConfigurationControls + config={config} + onConfigChange={onConfigChange} + showWorkarounds={false} + /> </Focusable> ); } diff --git a/src/components/SetupTab.tsx b/src/components/SetupTab.tsx index df43c5e..ff072cb 100644 --- a/src/components/SetupTab.tsx +++ b/src/components/SetupTab.tsx @@ -1,15 +1,6 @@ -import { ButtonItem, ConfirmModal, Field, PanelSection, PanelSectionRow, ToggleField, showModal } from "@decky/ui"; -import { useEffect, useState } from "react"; -import { - getFlatpakSupportStatus, - removePluginOwnedFlatpakExtensions, - setFlatpakExtensionEnabled, - type FlatpakExtensionStatus, - type SteamBranchStatus, -} from "../api/lsfgApi"; -import { InstallationButton } from "./InstallationButton"; -import { StatusDisplay } from "./StatusDisplay"; -import { showErrorToast } from "../utils/toastUtils"; +import { ButtonItem, Field, PanelSection, PanelSectionRow, ToggleField } from "@decky/ui"; +import { type GlobalConfig, type SteamBranchStatus } from "../api/lsfgApi"; +import t from "../i18n/i18n"; interface SetupTabProps { isInstalled: boolean; @@ -19,195 +10,91 @@ interface SetupTabProps { steamBranchStatus: SteamBranchStatus | null; isInstalling: boolean; isUninstalling: boolean; + globalConfig: GlobalConfig; + showDebugTab: boolean; + onGlobalConfigChange: (config: GlobalConfig) => Promise<boolean>; + onShowDebugTabChange: (value: boolean) => void; onInstall: () => void; onUninstall: () => void; - flatpakRelevant: boolean; } -function FlatpakSupportDiagnostics({ relevant }: { relevant: boolean }) { - const [status, setStatus] = useState<FlatpakExtensionStatus | null>(null); - const [advanced, setAdvanced] = useState(false); - const [operation, setOperation] = useState<string | null>(null); - - const refresh = async () => { - try { - setStatus(await getFlatpakSupportStatus()); - } catch (error) { - setStatus({ - success: false, - message: "", - error: String(error), - available: false, - extension_id: "", - supported_branches: [], - installed_branches: [], - owned_branches: [], - ownership_uncertain: false, - }); - } - }; - - useEffect(() => { - if (relevant) void refresh(); - }, [relevant]); - - if (!relevant || !status?.available) return null; - - const runExtensionOperation = async (version: string, enabled: boolean) => { - const operationKey = `${enabled ? "enable" : "disable"}-${version}`; - setOperation(operationKey); - try { - const result = await setFlatpakExtensionEnabled(version, enabled); - if (!result.success) throw new Error(result.error || result.message || "Flatpak runtime update failed"); - await refresh(); - } catch (error) { - showErrorToast("Flatpak runtime update failed", String(error)); - } finally { - setOperation(null); - } - }; - - const confirmDisable = (version: string) => { - showModal( - <ConfirmModal - strTitle={`Disable Flatpak runtime ${version}?`} - strDescription="Only runtime extensions installed by this plugin can be removed. Pre-existing extensions are preserved." - strOKButtonText="Disable" - strCancelButtonText="Cancel" - onOK={() => void runExtensionOperation(version, false)} - onCancel={() => {}} - />, - ); - }; - - const handleExtensionToggle = (version: string, enabled: boolean) => { - const installed = status.installed_branches.includes(version); - const owned = status.owned_branches.includes(version); - if (!enabled && installed && !owned) { - showErrorToast( - "Flatpak runtime preserved", - `${version} was not installed by this plugin, so it will remain installed.`, - ); - void refresh(); - return; - } - if (!enabled && installed && owned) { - confirmDisable(version); - return; - } - void runExtensionOperation(version, enabled); - }; - - const confirmCleanup = () => { - showModal( - <ConfirmModal - strTitle="Remove plugin-installed Flatpak extensions?" - strDescription="Shared runtime branches recorded as installed by this plugin will be removed. Existing unowned branches are preserved." - strOKButtonText="Remove extensions" - strCancelButtonText="Cancel" - onOK={async () => { - setOperation("cleanup"); - try { - const result = await removePluginOwnedFlatpakExtensions(); - if (!result.success) throw new Error(result.error || result.message || "Flatpak cleanup failed"); - await refresh(); - } catch (error) { - showErrorToast("Flatpak cleanup failed", String(error)); - } finally { - setOperation(null); - } - }} - onCancel={() => {}} - />, - ); - }; +export function SetupTab(props: SetupTabProps) { + 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="Flatpak support"> - <PanelSectionRow> - <Field - label="Runtime extension support" - description={status.message || "Flatpak is available for classified targets."} - /> - </PanelSectionRow> - <PanelSectionRow> - <ButtonItem layout="below" onClick={() => setAdvanced((value) => !value)}> - {advanced ? "Hide runtime details" : "Show runtime details"} - </ButtonItem> - </PanelSectionRow> - {advanced && ( + <> + <PanelSection title="Setup"> + <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 && ( <> - {status.supported_branches.map((branch) => ( - <PanelSectionRow key={branch}> + <PanelSection title="Global settings"> + <PanelSectionRow> <ToggleField - label={branch} - description={ - operation === `enable-${branch}` - ? "Installing..." - : operation === `disable-${branch}` - ? "Uninstalling..." - : status.installed_branches.includes(branch) - ? status.owned_branches.includes(branch) - ? "Installed · plugin-owned" - : "Installed · pre-existing (preserved)" - : "Not installed" - } - checked={status.installed_branches.includes(branch)} - onChange={(enabled) => handleExtensionToggle(branch, enabled)} - disabled={operation !== null || status.ownership_uncertain} + label="FP16 Acceleration" + checked={!globalConfig.no_fp16} + onChange={(value) => void onGlobalConfigChange({ ...globalConfig, no_fp16: !value })} /> </PanelSectionRow> - ))} - {status.ownership_uncertain && ( + </PanelSection> + <PanelSection title="Advanced"> <PanelSectionRow> - <Field label="Ownership metadata is uncertain" description="Cleanup is disabled until the metadata is repaired." /> + <ToggleField + label="Show config file tab" + checked={showDebugTab} + onChange={onShowDebugTabChange} + /> </PanelSectionRow> - )} - <PanelSectionRow> - <ButtonItem - layout="below" - disabled={operation !== null || status.ownership_uncertain || status.owned_branches.length === 0} - onClick={confirmCleanup} - > - {operation === "cleanup" ? "Removing..." : "Remove plugin-installed extensions"} - </ButtonItem> - </PanelSectionRow> + </PanelSection> </> )} - </PanelSection> - ); -} - -export function SetupTab({ - isInstalled, - installationStatus, - losslessScalingInstalled, - losslessScalingStatus, - steamBranchStatus, - isInstalling, - isUninstalling, - onInstall, - onUninstall, - flatpakRelevant, -}: SetupTabProps) { - return ( - <> - <PanelSection title="Setup"> - <StatusDisplay - installationStatus={installationStatus} - losslessScalingInstalled={losslessScalingInstalled} - losslessScalingStatus={losslessScalingStatus} - steamBranchStatus={steamBranchStatus} - /> - <InstallationButton - isInstalled={isInstalled} - isInstalling={isInstalling} - isUninstalling={isUninstalling} - onInstall={onInstall} - onUninstall={onUninstall} - /> - </PanelSection> - <FlatpakSupportDiagnostics relevant={flatpakRelevant} /> </> ); } diff --git a/src/components/StatusDisplay.tsx b/src/components/StatusDisplay.tsx deleted file mode 100644 index b1a98e5..0000000 --- a/src/components/StatusDisplay.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { Field, PanelSectionRow } from "@decky/ui"; -import type { SteamBranchStatus } from "../api/lsfgApi"; - -interface StatusDisplayProps { - installationStatus: string; - losslessScalingInstalled: boolean; - losslessScalingStatus: string; - steamBranchStatus: SteamBranchStatus | null; -} - -export function StatusDisplay({ - installationStatus, - losslessScalingInstalled, - losslessScalingStatus, - steamBranchStatus -}: StatusDisplayProps) { - const losslessScalingAppInstalled = losslessScalingInstalled || steamBranchStatus?.installed === true; - - return ( - <> - <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> - )} - </> - ); -} diff --git a/src/components/WorkaroundsSection.tsx b/src/components/WorkaroundsSection.tsx index 5587392..3de5ec1 100644 --- a/src/components/WorkaroundsSection.tsx +++ b/src/components/WorkaroundsSection.tsx @@ -1,7 +1,6 @@ import { ButtonItem, Field, PanelSectionRow, SliderField, ToggleField } from "@decky/ui"; import { useEffect, useState } from "react"; import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri"; -import type { TargetTransport } from "../api/lsfgApi"; import { usePerAppWorkarounds } from "../hooks/usePerAppWorkarounds"; import t from "../i18n/i18n"; import type { WorkaroundField } from "../hooks/usePerAppWorkarounds"; @@ -9,7 +8,6 @@ import type { WorkaroundField } from "../hooks/usePerAppWorkarounds"; interface WorkaroundsSectionProps { appId: string; nonSteam: boolean; - transport: TargetTransport; onRepair?: () => Promise<boolean>; } @@ -73,17 +71,15 @@ function usePersistentCollapsed() { useEffect(() => { try { localStorage.setItem(WORKAROUNDS_COLLAPSED_KEY, JSON.stringify(collapsed)); - } catch { - // Persisting the view preference is optional. - } + } catch {} }, [collapsed]); return [collapsed, () => setCollapsed((value) => !value)] as const; } -export function WorkaroundsSection({ appId, nonSteam, transport, onRepair }: WorkaroundsSectionProps) { +export function WorkaroundsSection({ appId, nonSteam, onRepair }: WorkaroundsSectionProps) { const [collapsed, toggleCollapsed] = usePersistentCollapsed(); - const { status, snapshot, refresh, update, error } = usePerAppWorkarounds(appId, nonSteam, transport); + 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; diff --git a/src/components/index.ts b/src/components/index.ts index 6856e76..bca6f6f 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,6 +1,4 @@ export { Content } from "./Content"; -export { StatusDisplay } from "./StatusDisplay"; -export { InstallationButton } from "./InstallationButton"; export { ConfigurationSection } from "./ConfigurationSection"; export { FpsMultiplierControl } from "./FpsMultiplierControl"; export { ConfigurationTab } from "./ConfigurationTab"; 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 index c70d589..fd8dfb1 100644 --- a/src/hooks/useGameConfiguration.ts +++ b/src/hooks/useGameConfiguration.ts @@ -1,9 +1,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useQuickAccessVisible } from "@decky/api"; import { Router } from "@decky/ui"; -import { ensureFlatpakSupport, getGameConfigs, getInstalledGames, getWorkaroundState, removeWorkaroundState, repairFlatpakSupport, resetGameConfig, resetAllGameConfigs, setWorkaroundState, updateGameConfig, type GameConfigEntry, type GlobalConfig, type InstalledGame, type WorkaroundState } from "../api/lsfgApi"; +import { getGameConfigs, getInstalledGames, getWorkaroundApps, getWorkaroundState, removeWorkaroundState, resetGameConfig, resetAllGameConfigs, setWorkaroundState, updateGameConfig, updateGlobalConfig as saveGlobalConfig, type GameConfigEntry, type GlobalConfig, type InstalledGame, type WorkaroundState } from "../api/lsfgApi"; import { ConfigurationData, getDefaults } from "../config/configSchema"; -import { cleanupLegacySteamLaunchOptions, getDefaultWrapperPath, hasWrapperLaunchIntegration, installWrapperIntegration, isLegacyWrapperToken, readSteamLaunchOptions, removeWrapperIntegration } from "../utils/steamLaunchOptions"; +import { getDefaultWrapperPath, installWrapperIntegration, removeWrapperIntegration } from "../utils/steamLaunchOptions"; import { showErrorToast } from "../utils/toastUtils"; export interface GameTarget extends InstalledGame { configured: boolean; } @@ -23,7 +23,6 @@ async function getSteamShortcuts(): Promise<InstalledGame[]> { appid: String(appid >>> 0), name, nonSteam: true, - transport: { kind: "host" }, }]; }); } catch { @@ -40,23 +39,6 @@ function mergeInstalledGames(backendGames: InstalledGame[], shortcutGames: Insta return Array.from(games.values()); } -function selectShortcutExecutable( - target: GameTarget, - ...candidates: Array<string | null | undefined> -): string | undefined { - const absolute = candidates - .map((candidate) => candidate?.trim()) - .find((candidate) => candidate && candidate.startsWith("/")); - if (absolute) return absolute; - - // Steam's app-details API can report a Flatpak Target as just "flatpak" - // even when the shortcut's canonical VDF executable is /usr/bin/flatpak. - // Keep the stored original executable absolute so SetShortcutExe and the - // generated dispatcher agree on the same direct transport. - if (target.transport.kind === "flatpak") return "/usr/bin/flatpak"; - return candidates.map((candidate) => candidate?.trim()).find(Boolean); -} - const DEFAULT_WORKAROUND_STATE: WorkaroundState = { dxvkFrameRate: 0, disableGamescopeWsi: true, @@ -97,6 +79,7 @@ export function useGameConfiguration() { previousQuickAccessVisible.current = quickAccessVisible; if (initialLoad || becameVisible) void load(); }, [load, quickAccessVisible]); + useEffect(() => { const poll = () => { if (!configsLoaded) return; @@ -106,16 +89,25 @@ export function useGameConfiguration() { const installed = installedGames.find((game) => game.appid === appid); const name = app.display_name || installed?.name; if (!name) return setRunningGame(null); - setRunningGame((current) => current?.appid === appid ? current : { - ...(installed || { appid, name, nonSteam: false, transport: { kind: "host" } }), + const next: GameTarget = { + ...(installed || { appid, name, nonSteam: false }), name, configured: games.some((game) => game.appid === appid), - }); + }; + setRunningGame((current) => ( + current?.appid === next.appid + && current.name === next.name + && current.nonSteam === next.nonSteam + && current.configured === next.configured + ? current + : next + )); }; poll(); const interval = window.setInterval(poll, 2000); return () => window.clearInterval(interval); }, [configsLoaded, games, installedGames]); + useEffect(() => { const appid = runningGame?.appid || null; if (appid !== previousRunningAppId.current) { @@ -126,125 +118,67 @@ export function useGameConfiguration() { const targets = useMemo<GameTarget[]>(() => { const configured = installedGames.map((game) => ({ ...game, configured: games.some((item) => item.appid === game.appid) })); - for (const game of games) if (!configured.some((item) => item.appid === game.appid)) configured.push({ appid: game.appid, name: game.profile, nonSteam: false, transport: { kind: "host" }, configured: true }); + for (const game of games) if (!configured.some((item) => item.appid === game.appid)) configured.push({ appid: game.appid, name: game.profile, nonSteam: false, configured: true }); if (runningGame && !configured.some((game) => game.appid === runningGame.appid)) configured.unshift(runningGame); return configured; }, [games, installedGames, runningGame]); const template = useMemo(() => ({ ...getDefaults(), ...globalConfig }), [globalConfig]); const config = games.find((game) => game.appid === selectedAppId)?.config || template; - - const ensureTargetFlatpakSupport = useCallback(async (target: GameTarget): Promise<boolean> => { - if (target.transport.kind !== "flatpak") return true; - const result = await ensureFlatpakSupport(target.transport.flatpakAppId); - if (!result.success || result.support_status !== "ready") { - showErrorToast( - "Flatpak support unavailable", - result.error || result.message || "The required Flatpak runtime extension is not ready", - ); - return false; - } - return true; - }, []); + const runningConfig = runningGame + ? games.find((game) => game.appid === runningGame.appid)?.config || template + : template; const ensureTargetWorkarounds = useCallback(async (target: GameTarget): Promise<boolean> => { 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"); - const current = await readSteamLaunchOptions(appId, target.nonSteam); - const wrapperPath = existing.wrapper_path || getDefaultWrapperPath(); - const oldState = existing.state; - const oldShortcutExe = existing.shortcut_exe || undefined; - const oldCommandTokenAdded = existing.command_token_added === true; - const oldTransport = existing.transport || target.transport; - if (target.nonSteam && oldState && current.target === wrapperPath && !oldShortcutExe) { - throw new Error("Managed shortcut Target has no saved original executable"); - } - if (target.nonSteam && oldState && current.target !== wrapperPath && current.target !== oldShortcutExe) { - throw new Error("Shortcut Target changed externally; refusing to replace it"); - } - if (target.nonSteam && !oldState && current.target === wrapperPath) { - throw new Error("Shortcut Target is already the managed wrapper but its original Target is unknown"); - } - const state = oldState || { ...DEFAULT_WORKAROUND_STATE }; - const originalExecutable = target.nonSteam - ? selectShortcutExecutable( - target, - oldShortcutExe, - target.transport.kind === "flatpak" ? target.executable : undefined, - current.target, - ) - : undefined; - const initialIntegration = target.nonSteam - ? current.target === wrapperPath - : hasWrapperLaunchIntegration(current.options, wrapperPath); - const initialStateResult = await setWorkaroundState( + 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, - originalExecutable || null, - oldCommandTokenAdded, - target.transport, + integration.commandTokenAdded, + target.nonSteam, ); - if (!initialStateResult.success) throw new Error(initialStateResult.error || "Could not create workaround state"); - - let integration: Awaited<ReturnType<typeof installWrapperIntegration>> | null = null; - try { - integration = await installWrapperIntegration(appId, target.nonSteam, wrapperPath, oldCommandTokenAdded); - const finalStateResult = await setWorkaroundState( - target.appid, - state, - target.nonSteam - ? (selectShortcutExecutable( - target, - integration.originalExecutable, - originalExecutable, - target.transport.kind === "flatpak" ? target.executable : undefined, - ) || null) - : null, - integration.commandTokenAdded, - target.transport, - ); - if (!finalStateResult.success) throw new Error(finalStateResult.error || "Could not finalize workaround state"); - return true; - } catch (error) { - let rollbackSucceeded = true; - if (!initialIntegration && integration) { - try { - await removeWrapperIntegration( - appId, - target.nonSteam, - wrapperPath, - target.nonSteam - ? (selectShortcutExecutable( - target, - integration?.originalExecutable, - originalExecutable, - target.transport.kind === "flatpak" ? target.executable : undefined, - ) || undefined) - : undefined, - integration?.commandTokenAdded ?? oldCommandTokenAdded, - ); - } catch (rollbackError) { - showErrorToast("Workaround rollback failed", asError(rollbackError).message); - rollbackSucceeded = false; - } + 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) { - const restored = oldState - ? await setWorkaroundState( - target.appid, - oldState, - oldShortcutExe || null, - oldCommandTokenAdded, - oldTransport, - ) - : await removeWorkaroundState(target.appid); - if (!restored.success) throw new Error(restored.error || "Could not roll back workaround state"); + } + 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; } - throw error; } - } catch (error) { showErrorToast("Could not initialize workarounds", asError(error).message); return false; } @@ -257,21 +191,12 @@ export function useGameConfiguration() { 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 (existing.state) { - await removeWrapperIntegration( - appId, - target.nonSteam, - wrapperPath, - existing.shortcut_exe || undefined, - existing.command_token_added === true, - ); - } else { - const current = await readSteamLaunchOptions(appId, target.nonSteam); - if (target.nonSteam && (current.target === wrapperPath || isLegacyWrapperToken(current.target))) { - throw new Error("Shortcut Target is a frame-generation wrapper but its original Target is unknown"); - } - await cleanupLegacySteamLaunchOptions(appId, target.nonSteam, wrapperPath); - } + 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; @@ -281,33 +206,78 @@ export function useGameConfiguration() { } }, [installedGames]); - const save = useCallback(async (next: ConfigurationData, cleanupLaunchOptions = false) => { - const selectedTarget = targets.find((target) => target.appid === selectedAppId); - if (!selectedTarget?.name) return; - // The profile owns its wrapper integration. Keep this check on every - // configuration save so an external edit is detected before the profile - // is changed; toggles update the sidecar only. - if (cleanupLaunchOptions && !(await ensureTargetWorkarounds(selectedTarget))) return; - const result = await updateGameConfig(selectedAppId, selectedTarget.name, next); + 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 targetsByAppId = new Map(targets.map((target) => [target.appid, target])); + const cleaned = new Set<string>(); + const wrapperPath = result.wrapper_path || getDefaultWrapperPath(); + + for (const entry of result.apps || []) { + const target = targetsByAppId.get(entry.appid); + const nonSteam = target?.nonSteam ?? entry.non_steam; + await removeWrapperIntegration( + Number(entry.appid), + nonSteam, + 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(); - }, [ensureTargetWorkarounds, load, selectedAppId, targets]); + 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 ensureTargetFlatpakSupport(target))) 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; - }, [ensureTargetFlatpakSupport, ensureTargetWorkarounds, load, removeTargetWorkarounds, targets, template]); + }, [ensureTargetWorkarounds, load, removeTargetWorkarounds, targets, template]); + const enableAll = useCallback(async (): Promise<void> => { const available = targets.filter((target) => !target.configured && target.name); if (available.length === 0) return; - for (const target of available) { - if (!(await ensureTargetFlatpakSupport(target))) return; if (!(await ensureTargetWorkarounds(target))) return; const result = await updateGameConfig(target.appid, target.name, template); if (!result.success) { @@ -320,20 +290,11 @@ export function useGameConfiguration() { } } await load(); - }, [ensureTargetFlatpakSupport, ensureTargetWorkarounds, load, removeTargetWorkarounds, targets, template]); + }, [ensureTargetWorkarounds, load, removeTargetWorkarounds, targets, template]); + const repair = useCallback(async (appid: string): Promise<boolean> => { const target = targets.find((item) => item.appid === appid); if (!target) return false; - if (target.transport.kind === "flatpak") { - const support = await repairFlatpakSupport(target.transport.flatpakAppId); - if (!support.success || support.support_status !== "ready") { - showErrorToast( - "Flatpak support unavailable", - support.error || support.message || "The required Flatpak runtime extension is not ready", - ); - return false; - } - } const success = await ensureTargetWorkarounds(target); if (success) await load(); return success; @@ -351,6 +312,7 @@ export function useGameConfiguration() { } } }, [load, removeTargetWorkarounds, selectedAppId, targets]); + const resetAll = useCallback(async () => { for (const target of targets.filter((item) => item.configured)) { if (!(await removeTargetWorkarounds(target))) return; @@ -363,5 +325,5 @@ export function useGameConfiguration() { } }, [load, removeTargetWorkarounds, targets]); - return { config, games, targets, runningGame, selectedAppId, setSelectedAppId, save, enable, enableAll, repair, resetSelected, resetAll, reload: load }; + return { config, runningConfig, globalConfig, targets, runningGame, selectedAppId, setSelectedAppId, save, saveFor, updateGlobal, enable, enableAll, repair, resetSelected, resetAll, cleanupAllWorkarounds, reload: load }; } diff --git a/src/hooks/useInstallationActions.ts b/src/hooks/useInstallationActions.ts deleted file mode 100644 index 41189bd..0000000 --- a/src/hooks/useInstallationActions.ts +++ /dev/null @@ -1,84 +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>, - reloadStatus?: () => Promise<boolean> - ) => { - 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(); - } - if (reloadStatus) { - await reloadStatus(); - } - } 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, - reloadStatus?: () => Promise<boolean> - ) => { - setIsUninstalling(true); - setInstallationStatus("Uninstalling lsfg-vk..."); - - try { - const result = await uninstallLsfgVk(); - if (result.success) { - setIsInstalled(false); - setInstallationStatus("lsfg-vk uninstalled successfully!"); - if (reloadStatus) { - await reloadStatus(); - } - 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 0b71ee9..0f51e90 100644 --- a/src/hooks/useLsfgHooks.ts +++ b/src/hooks/useLsfgHooks.ts @@ -1,16 +1,29 @@ -import { useState, useEffect } from "react"; +import { useEffect, useState } from "react"; import { checkLsfgVkInstalled, getLosslessScalingBranchStatus, - type SteamBranchStatus + installLsfgVk, + uninstallLsfgVk, + type SteamBranchStatus, } from "../api/lsfgApi"; +import { + showInstallErrorToast, + showInstallSuccessToast, + showUninstallErrorToast, + showUninstallSuccessToast, +} from "../utils/toastUtils"; -export function useInstallationStatus() { - const [isInstalled, setIsInstalled] = useState<boolean>(false); - const [installationStatus, setInstallationStatus] = useState<string>(""); - const [losslessScalingInstalled, setLosslessScalingInstalled] = useState<boolean>(false); - const [losslessScalingStatus, setLosslessScalingStatus] = 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 { @@ -25,13 +38,9 @@ export function useInstallationStatus() { setIsInstalled(status.installed); setLosslessScalingInstalled(status.lossless_scaling_installed); setLosslessScalingStatus(status.lossless_scaling_status || "Lossless Scaling Not Installed"); - if (status.installed) { - setInstallationStatus("lsfg-vk Installed"); - } else { - setInstallationStatus("lsfg-vk 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"); @@ -41,17 +50,68 @@ export function useInstallationStatus() { }; useEffect(() => { - checkInstallation(); + void checkInstallation(); }, []); + const install = async () => { + setIsInstalling(true); + setInstallationStatus("Installing lsfg-vk..."); + try { + 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) { + setInstallationStatus(`Installation failed: ${error}`); + showInstallErrorToast(String(error)); + } finally { + setIsInstalling(false); + } + }; + + const uninstall = async () => { + setIsUninstalling(true); + setInstallationStatus("Uninstalling lsfg-vk..."); + try { + if (beforeUninstall && !(await beforeUninstall())) { + setInstallationStatus("Uninstallation cancelled: could not clean up launch options"); + return; + } + const result = await uninstallLsfgVk(); + if (!result.success) { + setInstallationStatus(`Uninstallation failed: ${result.error}`); + showUninstallErrorToast(result.error ?? undefined); + return; + } + setIsInstalled(false); + setInstallationStatus("lsfg-vk uninstalled successfully!"); + await checkInstallation(); + showUninstallSuccessToast(); + } catch (error) { + setInstallationStatus(`Uninstallation failed: ${error}`); + showUninstallErrorToast(String(error)); + } finally { + setIsUninstalling(false); + } + }; + return { isInstalled, installationStatus, - setIsInstalled, - setInstallationStatus, losslessScalingInstalled, losslessScalingStatus, steamBranchStatus, - checkInstallation + isInstalling, + isUninstalling, + install, + uninstall, + checkInstallation, }; } diff --git a/src/hooks/usePerAppWorkarounds.ts b/src/hooks/usePerAppWorkarounds.ts index e9e44e1..ab33bb2 100644 --- a/src/hooks/usePerAppWorkarounds.ts +++ b/src/hooks/usePerAppWorkarounds.ts @@ -3,14 +3,12 @@ import { getWorkaroundState, removeWorkaroundState, setWorkaroundState, - type TargetTransport, type WorkaroundState, } from "../api/lsfgApi"; import { getDefaultWrapperPath, - hasWrapperLaunchIntegration, installWrapperIntegration, - isLegacyWrapperToken, + isWrapperIntegrationInstalled, readSteamLaunchOptions, removeWrapperIntegration, subscribeSteamLaunchOptions, @@ -45,8 +43,6 @@ export interface WorkaroundSnapshot { wrapperOwned: boolean; integrationInstalled: boolean; commandTokenAdded: boolean; - shortcutExe?: string | null; - transport: TargetTransport; } interface PerAppWorkarounds { @@ -61,26 +57,6 @@ function asError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)); } -function selectShortcutExecutable( - transport: TargetTransport, - ...candidates: Array<string | null | undefined> -): string | undefined { - const absolute = candidates - .map((candidate) => candidate?.trim()) - .find((candidate) => candidate && candidate.startsWith("/")); - if (absolute) return absolute; - if (transport.kind === "flatpak") return "/usr/bin/flatpak"; - return candidates.map((candidate) => candidate?.trim()).find(Boolean); -} - -function integrationIsInstalled( - steam: SteamLaunchOptionsSnapshot, - nonSteam: boolean, - wrapperPath: string, -): boolean { - return nonSteam ? steam.target === wrapperPath : hasWrapperLaunchIntegration(steam.options, wrapperPath); -} - function makeSnapshot( steam: SteamLaunchOptionsSnapshot, result: Awaited<ReturnType<typeof getWorkaroundState>>, @@ -88,75 +64,43 @@ function makeSnapshot( ): WorkaroundSnapshot { if (!result.state) throw new Error("Workaround state is not initialized for this profile"); const wrapperPath = result.wrapper_path || getDefaultWrapperPath(); - if (nonSteam && steam.target === wrapperPath && !result.shortcut_exe) { - throw new Error("Managed shortcut Target has no saved original executable"); - } return { steam, state: result.state, wrapperPath, wrapperOwned: result.wrapper_owned === true, - integrationInstalled: integrationIsInstalled(steam, nonSteam, wrapperPath), + integrationInstalled: isWrapperIntegrationInstalled(steam, nonSteam, wrapperPath), commandTokenAdded: result.command_token_added === true, - shortcutExe: result.shortcut_exe, - transport: result.transport || { kind: "host" }, }; } async function adoptWorkaroundState( appId: string, nonSteam: boolean, - transport: TargetTransport, - steam: SteamLaunchOptionsSnapshot, wrapperPath: string, ): Promise<WorkaroundSnapshot> { - if (nonSteam && (!steam.target || steam.target === wrapperPath || isLegacyWrapperToken(steam.target))) { - throw new Error("Shortcut Target is a wrapper but its original Target is unknown"); - } - const originalExecutable = nonSteam - ? selectShortcutExecutable(transport, steam.target) - : null; - const initial = await setWorkaroundState( - appId, - DEFAULT_WORKAROUND_STATE, - originalExecutable, - false, - transport, - ); - if (!initial.success) throw new Error(initial.error || "Could not create workaround state"); let integration: Awaited<ReturnType<typeof installWrapperIntegration>> | null = null; try { - integration = await installWrapperIntegration( - Number(appId), - nonSteam, - wrapperPath, - ); + integration = await installWrapperIntegration(Number(appId), nonSteam, wrapperPath, false); const finalized = await setWorkaroundState( appId, DEFAULT_WORKAROUND_STATE, - nonSteam - ? (selectShortcutExecutable(transport, integration.originalExecutable, originalExecutable) || null) - : null, integration.commandTokenAdded, - transport, + 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) { + if (integration?.changed) { try { await removeWrapperIntegration( Number(appId), nonSteam, wrapperPath, - nonSteam - ? (selectShortcutExecutable(transport, integration?.originalExecutable, originalExecutable) || undefined) - : undefined, - integration?.commandTokenAdded ?? false, + integration.commandTokenAdded, ); } catch { - // Leave the owned integration in place rather than guessing at cleanup. rollbackSucceeded = false; } } @@ -168,11 +112,7 @@ async function adoptWorkaroundState( } } -export function usePerAppWorkarounds( - appId: string, - nonSteam: boolean, - transport: TargetTransport = { kind: "host" }, -): PerAppWorkarounds { +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); @@ -189,13 +129,11 @@ export function usePerAppWorkarounds( return adoptWorkaroundState( appId, nonSteam, - transport, - steam, result.wrapper_path || getDefaultWrapperPath(), ); } return makeSnapshot(steam, result, nonSteam); - }, [appId, nonSteam, numericAppId, transport]); + }, [appId, nonSteam, numericAppId]); const applySnapshot = useCallback((next: WorkaroundSnapshot) => { setSnapshot(next); @@ -230,7 +168,7 @@ export function usePerAppWorkarounds( setSnapshot((current) => current ? { ...current, steam, - integrationInstalled: integrationIsInstalled(steam, nonSteam, current.wrapperPath), + integrationInstalled: isWrapperIntegrationInstalled(steam, nonSteam, current.wrapperPath), } : current); }, (subscriptionError) => { @@ -268,9 +206,8 @@ export function usePerAppWorkarounds( const result = await setWorkaroundState( appId, nextState, - current.shortcutExe ?? null, current.commandTokenAdded, - current.transport, + nonSteam, ); if (!result.success || !result.state) throw new Error(result.error || "Could not save workaround state"); applySnapshot({ @@ -278,9 +215,7 @@ export function usePerAppWorkarounds( state: result.state, wrapperPath: result.wrapper_path || current.wrapperPath, wrapperOwned: result.wrapper_owned === true, - shortcutExe: result.shortcut_exe, commandTokenAdded: result.command_token_added === true, - transport: result.transport || current.transport, }); return true; } catch (updateError) { diff --git a/src/styles.ts b/src/styles.ts index 1bc089b..58c6b01 100644 --- a/src/styles.ts +++ b/src/styles.ts @@ -10,6 +10,10 @@ export const tabStyles = ` 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; @@ -31,4 +35,17 @@ export const tabStyles = ` 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 df433e0..4adad61 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -16,8 +16,6 @@ declare module "*.jpg" { interface SteamAppDetails { strLaunchOptions?: string; strShortcutLaunchOptions?: string; - strShortcutExe?: string; - strShortcutStartDir?: string; } interface SteamAppDetailsRegistration { @@ -31,7 +29,7 @@ interface SteamApps { ): SteamAppDetailsRegistration; SetAppLaunchOptions(appId: number, options: string): void | Promise<void>; SetShortcutLaunchOptions(appId: number, options: string): void | Promise<void>; - SetShortcutExe(appId: number, executable: string): void | Promise<void>; + TerminateApp(appId: string, param1: boolean): void; GetAllShortcuts?(): Promise<unknown[]>; } diff --git a/src/utils/nowPlaying.ts b/src/utils/nowPlaying.ts new file mode 100644 index 0000000..380c9f5 --- /dev/null +++ b/src/utils/nowPlaying.ts @@ -0,0 +1,76 @@ +import type { FlatpakApp, RunningFlatpakApp } from "../api/lsfgApi"; +import type { GameTarget } from "../hooks/useGameConfiguration"; + +export type NowPlayingTarget = + | { + kind: "flatpak"; + app: FlatpakApp; + launcher: GameTarget | null; + } + | { + kind: "steam"; + 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 && !runningGame.nonSteam) { + return runningGame.configured ? { kind: "steam", game: runningGame } : null; + } + if (runningFlatpak) { + return { kind: "flatpak", app: runningFlatpak, launcher: runningGame?.nonSteam ? runningGame : null }; + } + if (runningGame?.configured) return { kind: "steam", game: runningGame }; + return null; +} diff --git a/src/utils/steamLaunchOptions.ts b/src/utils/steamLaunchOptions.ts index e00b32d..f03eeab 100644 --- a/src/utils/steamLaunchOptions.ts +++ b/src/utils/steamLaunchOptions.ts @@ -12,146 +12,88 @@ export const LEGACY_WRAPPER_TOKENS = new Set([ ]); 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", + "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; -} - +interface LaunchToken { raw: string; value: string; } export interface SteamLaunchOptionsSnapshot { appId: number; nonSteam: boolean; options: string; - target: string; details: SteamAppDetails; } - export interface WrapperIntegrationResult { snapshot: SteamLaunchOptionsSnapshot; - originalExecutable?: string; commandTokenAdded: boolean; + changed: boolean; } -function validateAppId(appId: number): void { - if (!Number.isSafeInteger(appId) || appId <= 0) throw new Error("Invalid Steam App ID"); +function asError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); } -function getSteamApps(): Partial<SteamApps> | undefined { - return (globalThis as typeof globalThis & { - SteamClient?: { Apps?: Partial<SteamApps> }; - }).SteamClient?.Apps; +function apps(): Partial<SteamApps> | undefined { + return (globalThis as typeof globalThis & { SteamClient?: { Apps?: Partial<SteamApps> } }).SteamClient?.Apps; } -interface TimerHost { - setTimeout(handler: () => void, timeout: number): number; - clearTimeout(timeout: number): void; +function validateAppId(appId: number): void { + if (!Number.isSafeInteger(appId) || appId <= 0) throw new Error("Invalid Steam App ID"); } -function timerHost(): TimerHost { - if (typeof window !== "undefined") { - return { - setTimeout: (handler, timeout) => window.setTimeout(handler, timeout), - clearTimeout: (timeout) => window.clearTimeout(timeout), - }; - } +function timer() { + const host = typeof window !== "undefined" ? window : globalThis; return { - setTimeout: (handler, timeout) => globalThis.setTimeout(handler, timeout) as unknown as number, - clearTimeout: (timeout) => globalThis.clearTimeout(timeout), + set: (handler: () => void, ms: number) => host.setTimeout(handler, ms) as unknown as number, + clear: (id: number) => host.clearTimeout(id), }; } -function snapshotFromDetails(appId: number, nonSteam: boolean, details: SteamAppDetails): SteamLaunchOptionsSnapshot { +function snapshot(appId: number, nonSteam: boolean, details: SteamAppDetails): SteamLaunchOptionsSnapshot { return { appId, nonSteam, options: nonSteam ? details.strShortcutLaunchOptions || "" : details.strLaunchOptions || "", - target: nonSteam ? details.strShortcutExe || "" : "", details, }; } -function asError(error: unknown): Error { - return error instanceof Error ? error : new Error(String(error)); -} - -function registerSteamAppDetails( - appId: number, - onDetails: (details: SteamAppDetails) => boolean | void, -): () => void { +function registerDetails(appId: number, onDetails: (details: SteamAppDetails) => boolean | void): () => void { validateAppId(appId); - const apps = getSteamApps(); - const registerForAppDetails = apps?.RegisterForAppDetails; - if (!registerForAppDetails) throw new Error("Steam app-details API is unavailable"); - + const register = apps()?.RegisterForAppDetails; + if (!register) throw new Error("Steam app-details API is unavailable"); let active = true; - let unregisterPending = false; let registration: SteamAppDetailsRegistration | undefined; const unsubscribe = () => { active = false; - if (!registration) { - unregisterPending = true; - return; - } - try { - registration.unregister(); - } catch { - // Steam can invalidate a registration while details are refreshing. - } + try { registration?.unregister(); } catch {} }; - - try { - registration = registerForAppDetails.call(apps, appId, (details) => { - if (!active) return; - if (onDetails(details || {}) === false && active) unsubscribe(); - }); - if (unregisterPending) { - try { - registration.unregister(); - } catch { - // A synchronous callback can invalidate the registration before return. - } - } - } catch (error) { - throw asError(error); - } + 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 settled = false; - let timeout: number | undefined; + 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 (settled) return; - settled = true; - if (timeout !== undefined) timerHost().clearTimeout(timeout); + if (done) return; + done = true; + clock.clear(timeout); unsubscribe(); - if (error) { - reject(asError(error)); - return; - } - resolve(snapshotFromDetails(appId, nonSteam, details || {})); + if (error) reject(asError(error)); + else resolve(snapshot(appId, nonSteam, details || {})); }; - - timeout = timerHost().setTimeout(() => finish(new Error("Timed out reading Steam app details")), 5000); try { - unsubscribe = registerSteamAppDetails(appId, (details) => { + unsubscribe = registerDetails(appId, (details) => { finish(undefined, details); return false; }); @@ -164,34 +106,24 @@ export async function readSteamLaunchOptions(appId: number, nonSteam: boolean): export function subscribeSteamLaunchOptions( appId: number, nonSteam: boolean, - onSnapshot: (snapshot: SteamLaunchOptionsSnapshot) => void, + onSnapshot: (value: SteamLaunchOptionsSnapshot) => void, onError: (error: Error) => void, ): () => void { - return registerSteamAppDetails(appId, (details) => { - try { - onSnapshot(snapshotFromDetails(appId, nonSteam, details)); - } catch (error) { - onError(asError(error)); - } + 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 index = 0; index < raw.length; index += 1) { - const character = raw[index]; - if (character === "\\" && quote !== "'" && index + 1 < raw.length) { - value += raw[index + 1]; - index += 1; - } else if (quote !== null) { - if (character === quote) quote = null; - else value += character; - } else if (character === "'" || character === '"') { - quote = character; - } else { - value += character; - } + 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; } @@ -201,299 +133,190 @@ function tokenize(options: string): LaunchToken[] { let start = -1; let quote: "'" | '"' | null = null; let escaped = false; - for (let index = 0; index < options.length; index += 1) { - const character = options[index]; - if (start < 0) { - if (/\s/.test(character)) continue; - start = index; - } - if (escaped) escaped = false; - else if (character === "\\" && quote !== "'") escaped = true; - else if (quote !== null) { - if (character === quote) quote = null; - } else if (character === "'" || character === '"') quote = character; - else if (/\s/.test(character)) { - const raw = options.slice(start, index); - tokens.push({ raw, value: decodeToken(raw) }); - start = -1; - } - } - if (start >= 0) { - const raw = options.slice(start); + 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; } -function serialize(tokens: readonly LaunchToken[]): string { - return tokens.map((token) => token.raw).join(" "); -} - -export function normalizeLaunchOptions(options: string): string { - return serialize(tokenize(options)); -} - -function isCommandToken(token: LaunchToken): boolean { - return token.raw.toLowerCase() === COMMAND_TOKEN; -} - -function commandIndex(tokens: readonly LaunchToken[]): number { - return tokens.findIndex(isCommandToken); -} - -function isAssignment(token: LaunchToken): boolean { - return /^[A-Za-z_][A-Za-z0-9_]*=/.test(token.value); -} - -function isLegacyToken(value: string): boolean { - return LEGACY_WRAPPER_TOKENS.has(value) || LEGACY_ABSOLUTE_WRAPPER.test(value); -} +const serialize = (tokens: readonly LaunchToken[]) => tokens.map(({ raw }) => raw).join(" "); +const commandIndex = (tokens: readonly LaunchToken[]) => tokens.findIndex((token) => token.raw.toLowerCase() === COMMAND_TOKEN); +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 function isLegacyWrapperToken(value: string): boolean { - return isLegacyToken(decodeToken(value)); -} +export const normalizeLaunchOptions = (options: string) => serialize(tokenize(options)); +export const isLegacyWrapperToken = (value: string) => isLegacyToken(decodeToken(value)); -function isWrapperToken(value: string, wrapperPath: string): boolean { - return decodeToken(value) === wrapperPath || isLegacyWrapperToken(value); -} - -function removeWrapperTokens(tokens: LaunchToken[], wrapperPath: string): boolean { - const index = commandIndex(tokens); - const prefixEnd = index >= 0 ? index : tokens.length; - const retained = tokens.filter((token, tokenIndex) => tokenIndex >= prefixEnd || !isWrapperToken(token.value, wrapperPath)); - if (retained.length === tokens.length) return false; - tokens.splice(0, tokens.length, ...retained); - return true; -} - -function removeLegacyTokens(tokens: LaunchToken[]): boolean { - const index = commandIndex(tokens); - const prefixEnd = index >= 0 ? index : tokens.length; - const retained = tokens.filter((token, tokenIndex) => tokenIndex >= prefixEnd || !isLegacyToken(token.value)); - if (retained.length === tokens.length) return false; - tokens.splice(0, tokens.length, ...retained); +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 leadingAssignments(tokens: readonly LaunchToken[]): number { - let count = 0; - while (count < tokens.length && isAssignment(tokens[count])) count += 1; - return count; -} - -function wrapperToken(wrapperPath: string): LaunchToken { - return { raw: wrapperPath, value: wrapperPath }; -} - -export interface LaunchOptionRewrite { - options: string; - commandTokenAdded: boolean; -} - -/** Add one exact wrapper token immediately before Steam's command macro. */ -export function installWrapperLaunchOption(options: string, wrapperPath = DEFAULT_WRAPPER_PATH): LaunchOptionRewrite { +function installLaunchOption( + options: string, + wrapperPath = DEFAULT_WRAPPER_PATH, + shortcutLaunchOptions = false, +) { const tokens = tokenize(options); - removeLegacyTokens(tokens); - let index = commandIndex(tokens); - if (index >= 0) { - const currentWrapper = tokens[index - 1]; - if (currentWrapper && currentWrapper.value === wrapperPath) { - return { options: serialize(tokens), commandTokenAdded: false }; - } - const retained = tokens.filter((token, tokenIndex) => tokenIndex >= index || token.value !== wrapperPath); - tokens.splice(0, tokens.length, ...retained); - index = commandIndex(tokens); - tokens.splice(index, 0, wrapperToken(wrapperPath)); + 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 insertion = leadingAssignments(tokens); - const argumentsOnly = insertion === tokens.length || tokens[insertion]?.value.startsWith("-"); - if (tokens.length !== insertion && !argumentsOnly) { + let insertion = 0; + while (insertion < tokens.length && isAssignment(tokens[insertion])) insertion++; + if (!shortcutLaunchOptions && insertion < tokens.length && !tokens[insertion].value.startsWith("-")) { throw new Error("Launch options do not contain %command%; refusing to guess a launcher command"); } - tokens.splice(insertion, 0, wrapperToken(wrapperPath), { raw: COMMAND_TOKEN, value: COMMAND_TOKEN }); + tokens.splice(insertion, 0, + { raw: wrapperPath, value: wrapperPath }, + { raw: COMMAND_TOKEN, value: COMMAND_TOKEN }, + ); return { options: serialize(tokens), commandTokenAdded: true }; } -/** Remove the wrapper and known legacy tokens, preserving the user's arguments. */ +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); - const removed = removeWrapperTokens(tokens, wrapperPath); - if (removed && commandTokenAdded) { - const index = commandIndex(tokens); - if (index >= 0) tokens.splice(index, 1); + 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 { - if (/^[A-Za-z0-9_./:+,%=-]+$/.test(value)) return value; - return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; + return /^[A-Za-z0-9_./:+,%=-]+$/.test(value) + ? value + : `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; } -function cleanDxvkConfigValue(value: string): string | null { - const retained = value - .split(";") - .map((segment) => segment.trim()) - .filter((segment) => segment && !DXVK_FRAME_RATE_SEGMENT.test(segment)); - return retained.length > 0 ? retained.join("; ") : null; -} - -/** Remove only the old plugin's direct assignments; unrelated prefixes remain. */ export function cleanupPluginAssignments(options: string): string { const tokens = tokenize(options); - const index = commandIndex(tokens); - const prefixEnd = index >= 0 ? index : tokens.length; - const retained: LaunchToken[] = []; - for (let tokenIndex = 0; tokenIndex < tokens.length; tokenIndex += 1) { - const token = tokens[tokenIndex]; - if (tokenIndex >= prefixEnd || !isAssignment(token)) { - retained.push(token); - continue; - } - const separator = token.value.indexOf("="); - const key = token.value.slice(0, separator); + 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 cleaned = cleanDxvkConfigValue(token.value.slice(separator + 1)); - if (cleaned) retained.push({ raw: `DXVK_CONFIG=${encodeAssignmentValue(cleaned)}`, value: `DXVK_CONFIG=${cleaned}` }); - continue; + 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}` }] : []; } - if (!MANAGED_ENV_KEYS.has(key)) retained.push(token); - } - return serialize(retained); + return MANAGED_ENV_KEYS.has(key) ? [] : [token]; + })); } export function cleanupLegacyLaunchOptions(options: string): string { const tokens = tokenize(options); - removeLegacyTokens(tokens); + removeMatchingWrappers(tokens, isLegacyToken); return serialize(tokens); } - -export function cleanupPluginLaunchOptions(options: string, wrapperPath = DEFAULT_WRAPPER_PATH): string { - const tokens = tokenize(options); - removeWrapperTokens(tokens, wrapperPath); - return cleanupPluginAssignments(serialize(tokens)); -} - -export function cleanupLegacyWrapper(options: string, wrapperPath = DEFAULT_WRAPPER_PATH): string { - return cleanupPluginLaunchOptions(options, wrapperPath); -} - +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 index = commandIndex(tokens); - return index > 0 && tokens[index - 1].value === wrapperPath; + const command = commandIndex(tokens); + return command > 0 && tokens[command - 1].value === wrapperPath; } -function delay(milliseconds: number): Promise<void> { - return new Promise((resolve) => timerHost().setTimeout(resolve, milliseconds)); -} - -async function setSteamLaunchOptions(appId: number, nonSteam: boolean, options: string): Promise<void> { - const apps = getSteamApps(); - const setter = nonSteam ? apps?.SetShortcutLaunchOptions : apps?.SetAppLaunchOptions; - if (!setter) throw new Error(`Steam ${nonSteam ? "shortcut " : ""}launch options API is unavailable`); - await Promise.resolve(setter.call(apps, appId, options)); +export function isWrapperIntegrationInstalled( + steam: SteamLaunchOptionsSnapshot, + _nonSteam: boolean, + wrapperPath = DEFAULT_WRAPPER_PATH, +): boolean { + return hasWrapperLaunchIntegration(steam.options, wrapperPath); } -async function setShortcutExecutable(appId: number, executable: string): Promise<void> { - const apps = getSteamApps(); - if (!apps?.SetShortcutExe) throw new Error("Steam shortcut Target API is unavailable"); - await Promise.resolve(apps.SetShortcutExe.call(apps, appId, executable)); +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 waitForSnapshot( +async function waitFor( appId: number, nonSteam: boolean, - matches: (snapshot: SteamLaunchOptionsSnapshot) => 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 snapshot = await readSteamLaunchOptions(appId, nonSteam); - if (matches(snapshot)) return snapshot; - } catch (error) { - lastError = asError(error); - } - if (Date.now() >= deadline) break; - await delay(100); + 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)); } - if (lastError) throw new Error(`${message}: ${lastError.message}`); - throw new Error(`${message} before the readback timeout`); + throw lastError ? new Error(`${message}: ${lastError.message}`) : new Error(`${message} before the readback timeout`); } -async function writeLaunchOptionsAndVerify( +async function writeVerified( appId: number, nonSteam: boolean, previous: string, next: string, + write: (value: string) => Promise<void>, message: string, ): Promise<SteamLaunchOptionsSnapshot> { try { - await setSteamLaunchOptions(appId, nonSteam, next); - return await waitForSnapshot( - appId, - nonSteam, - (snapshot) => normalizeLaunchOptions(snapshot.options) === normalizeLaunchOptions(next), - message, - ); - } catch (error) { - const failure = asError(error); - try { - await setSteamLaunchOptions(appId, nonSteam, previous); - await waitForSnapshot( - appId, - nonSteam, - (snapshot) => normalizeLaunchOptions(snapshot.options) === normalizeLaunchOptions(previous), - "Steam did not restore the previous launch options", - ); - } catch (rollbackError) { - throw new Error(`${failure.message}; rollback also failed: ${asError(rollbackError).message}`); - } - throw failure; - } -} - -async function writeShortcutExecutableAndVerify( - appId: number, - previous: string, - next: string, - message: string, -): Promise<SteamLaunchOptionsSnapshot> { - try { - await setShortcutExecutable(appId, next); - return await waitForSnapshot(appId, true, (snapshot) => snapshot.target === next, message); + await write(next); + return await waitFor(appId, nonSteam, (value) => normalizeLaunchOptions(value.options) === normalizeLaunchOptions(next), message); } catch (error) { const failure = asError(error); try { - await setShortcutExecutable(appId, previous); - await waitForSnapshot(appId, true, (snapshot) => snapshot.target === previous, "Steam did not restore the previous shortcut Target"); - } catch (rollbackError) { - throw new Error(`${failure.message}; rollback also failed: ${asError(rollbackError).message}`); + 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; } } -const operationQueues = new Map<string, Promise<unknown>>(); - -function queueSteamOperation<T>(appId: number, nonSteam: boolean, operation: () => Promise<T>): Promise<T> { - const key = `${nonSteam ? "shortcut" : "app"}:${appId}`; - const previous = operationQueues.get(key) || Promise.resolve(); - const queued = previous.catch(() => undefined).then(operation); - const cleanup = queued.then( - () => { if (operationQueues.get(key) === cleanup) operationQueues.delete(key); }, - () => { if (operationQueues.get(key) === cleanup) operationQueues.delete(key); }, - ); - operationQueues.set(key, cleanup); - return queued; +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( @@ -501,11 +324,14 @@ export function updateSteamLaunchOptions( nonSteam: boolean, transform: (options: string) => string, ): Promise<SteamLaunchOptionsSnapshot> { - return queueSteamOperation(appId, nonSteam, async () => { + return queued(appId, nonSteam, async () => { const current = await readSteamLaunchOptions(appId, nonSteam); const next = transform(current.options); - if (next === current.options) return current; - return writeLaunchOptionsAndVerify(appId, nonSteam, current.options, next, "Steam did not accept the launch options"); + return next === current.options ? current : writeVerified( + appId, nonSteam, current.options, next, + (value) => writeOptions(appId, nonSteam, value), + "Steam did not accept the launch options", + ); }); } @@ -515,33 +341,18 @@ export function installWrapperIntegration( wrapperPath: string, commandTokenAdded = false, ): Promise<WrapperIntegrationResult> { - return queueSteamOperation(appId, nonSteam, async () => { + return queued(appId, nonSteam, async () => { const current = await readSteamLaunchOptions(appId, nonSteam); - if (nonSteam) { - if (!current.target) throw new Error("Steam shortcut Target is empty; refusing to replace it"); - if (current.target !== wrapperPath && isWrapperToken(current.target, wrapperPath)) { - throw new Error("The shortcut Target points to a legacy frame-generation wrapper; restore it first"); - } - const cleanedOptions = cleanupPluginLaunchOptions(current.options, wrapperPath); - if (cleanedOptions !== current.options) { - await writeLaunchOptionsAndVerify(appId, true, current.options, cleanedOptions, "Steam did not accept shortcut launch options"); - } - if (current.target === wrapperPath) { - return { snapshot: await readSteamLaunchOptions(appId, true), originalExecutable: undefined, commandTokenAdded: false }; - } - const originalExecutable = current.target; - const snapshot = await writeShortcutExecutableAndVerify(appId, originalExecutable, wrapperPath, "Steam did not accept the shortcut Target"); - return { snapshot, originalExecutable, commandTokenAdded: false }; - } - const cleaned = cleanupPluginAssignments(cleanupLegacyLaunchOptions(current.options)); const alreadyInstalled = hasWrapperLaunchIntegration(current.options, wrapperPath); - const rewrite = installWrapperLaunchOption(cleaned, wrapperPath); - if (rewrite.options === current.options) { - return { snapshot: current, commandTokenAdded }; - } - const snapshot = await writeLaunchOptionsAndVerify(appId, false, current.options, rewrite.options, "Steam did not accept the launch options"); - return { snapshot, commandTokenAdded: alreadyInstalled ? commandTokenAdded : rewrite.commandTokenAdded }; + const rewrite = installLaunchOption(cleaned, wrapperPath, nonSteam); + 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 : rewrite.commandTokenAdded, changed: true }; }); } @@ -549,48 +360,23 @@ export function removeWrapperIntegration( appId: number, nonSteam: boolean, wrapperPath: string, - originalExecutable?: string, commandTokenAdded = false, ): Promise<SteamLaunchOptionsSnapshot> { - return queueSteamOperation(appId, nonSteam, async () => { + return queued(appId, nonSteam, async () => { const current = await readSteamLaunchOptions(appId, nonSteam); - if (nonSteam) { - if (!originalExecutable || isWrapperToken(originalExecutable, wrapperPath)) { - throw new Error("Original shortcut Target is unavailable; refusing to overwrite the current Target"); - } - if (current.target !== wrapperPath && current.target !== originalExecutable) { - throw new Error("Shortcut Target changed externally; refusing to restore it"); - } - const cleaned = cleanupPluginLaunchOptions(current.options, wrapperPath); - if (cleaned !== current.options) { - await writeLaunchOptionsAndVerify(appId, true, current.options, cleaned, "Steam did not clean shortcut launch options"); - } - if (current.target === originalExecutable) { - return readSteamLaunchOptions(appId, true); - } - return writeShortcutExecutableAndVerify(appId, wrapperPath, originalExecutable, "Steam did not restore the shortcut Target"); - } - - const withoutWrapper = removeWrapperLaunchOption(current.options, wrapperPath, commandTokenAdded); - const next = cleanupPluginAssignments(withoutWrapper); - if (next === current.options) return current; - return writeLaunchOptionsAndVerify(appId, false, current.options, next, "Steam did not clean the launch options"); + 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 function cleanupLegacySteamLaunchOptions( +export const cleanupLegacySteamLaunchOptions = ( appId: number, nonSteam: boolean, wrapperPath = DEFAULT_WRAPPER_PATH, -): Promise<SteamLaunchOptionsSnapshot> { - return queueSteamOperation(appId, nonSteam, async () => { - const current = await readSteamLaunchOptions(appId, nonSteam); - const next = cleanupPluginLaunchOptions(current.options, wrapperPath); - if (next === current.options) return current; - return writeLaunchOptionsAndVerify(appId, nonSteam, current.options, next, "Steam did not clean legacy launch options"); - }); -} +) => updateSteamLaunchOptions(appId, nonSteam, (options) => cleanupPluginLaunchOptions(options, wrapperPath)); -export function getDefaultWrapperPath(): string { - return DEFAULT_WRAPPER_PATH; -} +export const getDefaultWrapperPath = () => DEFAULT_WRAPPER_PATH; diff --git a/src/utils/toastUtils.ts b/src/utils/toastUtils.ts index cbbbc55..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,84 +5,46 @@ export interface ToastOptions { body: string; } -/** - * Show a success toast notification - */ -export function showSuccessToast(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; -/** - * Show an error toast notification - */ -export function showErrorToast(title: string, body: string): void { - toaster.toast({ - title, - body - }); -} - -/** - * Standard success messages for common operations - */ export const ToastMessages = { INSTALL_SUCCESS: { title: "Installation Complete", - body: "lsfg-vk has been installed successfully" + 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", + }, } 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); -} |
