diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/api/lsfgApi.ts | 85 | ||||
| -rw-r--r-- | src/components/ConfigFileTab.tsx | 119 | ||||
| -rw-r--r-- | src/components/ConfigurationTab.tsx | 102 | ||||
| -rw-r--r-- | src/components/Content.tsx | 123 | ||||
| -rw-r--r-- | src/components/FlatpakNowPlayingTab.tsx | 39 | ||||
| -rw-r--r-- | src/components/FlatpakTab.tsx | 178 | ||||
| -rw-r--r-- | src/components/FlatpakWorkaroundsSection.tsx | 144 | ||||
| -rw-r--r-- | src/components/GameConfigurationControls.tsx | 3 | ||||
| -rw-r--r-- | src/components/GameConfigurationSelector.tsx | 7 | ||||
| -rw-r--r-- | src/components/NowPlayingTab.tsx | 36 | ||||
| -rw-r--r-- | src/components/WorkaroundsSection.tsx | 10 | ||||
| -rw-r--r-- | src/hooks/useFlatpakConfiguration.ts | 108 | ||||
| -rw-r--r-- | src/hooks/useGameConfiguration.ts | 91 | ||||
| -rw-r--r-- | src/hooks/usePerAppWorkarounds.ts | 47 | ||||
| -rw-r--r-- | src/types.d.ts | 3 | ||||
| -rw-r--r-- | src/utils/steamLaunchOptions.ts | 122 |
16 files changed, 823 insertions, 394 deletions
diff --git a/src/api/lsfgApi.ts b/src/api/lsfgApi.ts index b96acec..3d4890e 100644 --- a/src/api/lsfgApi.ts +++ b/src/api/lsfgApi.ts @@ -30,10 +30,6 @@ export interface SteamBranchStatus extends ApiResult { } export type LsfgConfig = ConfigurationData; -export type TargetTransport = - | { kind: "host" } - | { kind: "flatpak"; flatpakAppId: string }; -export type FlatpakTargetSupportStatus = "ready" | "needs-runtime" | "unsupported" | "error"; export interface GameConfigEntry { appid: string; @@ -45,11 +41,6 @@ export interface InstalledGame { appid: string; name: string; nonSteam: boolean; - transport: TargetTransport; - executable?: string; - arguments?: string; - startDir?: string; - flatpakSupport?: FlatpakTargetSupport; } export interface GlobalConfig { @@ -57,15 +48,6 @@ export interface GlobalConfig { no_fp16: boolean; } -export interface FlatpakTargetSupport extends ApiResult { - flatpak_app_id?: string; - runtime?: string | null; - runtime_branch?: string | null; - support_status: FlatpakTargetSupportStatus; - extension_installed: boolean; - installed_branches: string[]; -} - export interface WorkaroundState { dxvkFrameRate: number; disableGamescopeWsi: boolean; @@ -77,12 +59,11 @@ export interface WorkaroundState { 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; } export interface GameConfigsResult extends ApiResult { @@ -105,19 +86,50 @@ export interface FileContentResult extends ApiResult { path?: string; } -export interface FlatpakExtensionStatus extends ApiResult { - message: string; - available: boolean; - extension_id: string; - supported_branches: string[]; - installed_branches: string[]; +export interface DebugFileContent { + id: string; + label: string; + path: string; + exists: boolean; + content?: string | null; + error?: string | null; } -export interface FlatpakExtensionToggleResult extends ApiResult { - message: string; - runtime_branch: string; +export interface DebugFileContentsResult extends ApiResult { + files?: DebugFileContent[]; +} + +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; + profile: string; + config?: LsfgConfig | null; + workarounds: WorkaroundState; + error?: string | null; +} + +export interface RunningFlatpakApp { + app_id: string; + active: boolean; + pid?: string; +} + +export interface FlatpakAppsResult extends ApiResult { + apps?: FlatpakApp[]; +} + +export interface RunningFlatpakAppsResult extends ApiResult { + apps?: RunningFlatpakApp[]; +} + +export interface FlatpakAppResult extends ApiResult, Partial<FlatpakApp> { + app_id: string; } export const installLsfgVk = callable<[], InstallationResult>("install_lsfg_vk"); @@ -125,10 +137,12 @@ export const uninstallLsfgVk = callable<[], InstallationResult>("uninstall_lsfg_ 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 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"); @@ -138,8 +152,7 @@ export const getWorkaroundState = callable<[string], WorkaroundStateResult>("get export const setWorkaroundState = callable<[ string, WorkaroundState, - string | null | undefined, boolean, - TargetTransport | null | undefined, ], WorkaroundStateResult>("set_workaround_state"); export const removeWorkaroundState = callable<[string], WorkaroundStateResult>("remove_workaround_state"); +export const getDebugFileContents = callable<[], DebugFileContentsResult>("get_debug_file_contents"); 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/ConfigurationTab.tsx b/src/components/ConfigurationTab.tsx index 5ae4a87..a6d4c2a 100644 --- a/src/components/ConfigurationTab.tsx +++ b/src/components/ConfigurationTab.tsx @@ -1,4 +1,4 @@ -import { ButtonItem, ConfirmModal, DialogButton, Focusable, PanelSection, PanelSectionRow, gamepadDialogClasses, showModal } from "@decky/ui"; +import { ButtonItem, ConfirmModal, DialogButton, Focusable, PanelSection, PanelSectionRow, ToggleField, gamepadDialogClasses, showModal } from "@decky/ui"; import { useCallback, useEffect, useRef, useState } from "react"; import { FaArrowLeft } from "react-icons/fa"; import { ConfigurationData } from "../config/configSchema"; @@ -11,6 +11,8 @@ interface ConfigurationTabProps { config: ConfigurationData; targets: GameTarget[]; runningGame: GameTarget | null; + showDebugTab: boolean; + onShowDebugTabChange: (value: boolean) => void; onSelect: (appid: string) => void; onConfigChange: (fieldName: keyof ConfigurationData, value: boolean | number | string | string[]) => Promise<void>; onEnable: (appid: string) => Promise<boolean>; @@ -24,6 +26,8 @@ export function ConfigurationTab({ config, targets, runningGame, + showDebugTab, + onShowDebugTabChange, onSelect, onConfigChange, onEnable, @@ -63,31 +67,39 @@ export function ConfigurationTab({ 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> + <PanelSection title="Settings"> + <PanelSectionRow> + <ToggleField + label="Show debug tab" + description="Show the raw configuration and generated files tab for troubleshooting." + checked={showDebugTab} + onChange={onShowDebugTabChange} + /> + </PanelSectionRow> + </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"; @@ -104,9 +116,7 @@ export function ConfigurationTab({ } else if (detailAppId) { const isRunningUnconfigured = runningGame?.appid === detailAppId && runningGame.nonSteam === false - && runningGame.transport.kind === "host" && selectedTarget?.nonSteam === false - && selectedTarget?.transport.kind === "host" && !runningGame.configured; if (isRunningUnconfigured) { showModal( @@ -131,22 +141,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} @@ -166,18 +176,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 578f984..ce3c018 100644 --- a/src/components/Content.tsx +++ b/src/components/Content.tsx @@ -1,29 +1,56 @@ import { Tabs } from "@decky/ui"; import { useEffect, useRef, useState } from "react"; -import { FaFileAlt, FaGamepad, FaList, FaTools } from "react-icons/fa"; +import { FaCube, FaFileAlt, FaGamepad, FaList, FaTools } from "react-icons/fa"; import { ConfigurationData } from "../config/configSchema"; +import { useFlatpakConfiguration } from "../hooks/useFlatpakConfiguration"; import { useGameConfiguration } from "../hooks/useGameConfiguration"; import { useInstallation } from "../hooks/useLsfgHooks"; import { tabStyles } from "../styles"; 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 { config, + runningConfig, targets, runningGame, setSelectedAppId, save, + saveFor, enable, enableAll, repair, @@ -42,43 +69,58 @@ export function Content() { install, uninstall, } = useInstallation(reload); - const [tab, setTab] = useState("Setup"); - const previousRunningAppId = useRef<string | null>(null); const setupComplete = isInstalled && losslessScalingInstalled && steamBranchStatus?.success === true && steamBranchStatus.installed && !steamBranchStatus.needs_switch; + const flatpak = useFlatpakConfiguration(setupComplete); + const [tab, setTab] = useState("Setup"); + const [showDebugTab, setShowDebugTab] = usePersistentBoolean(DEBUG_TAB_VISIBILITY_KEY, true); + const previousRunningWorkload = useRef<string | null>(null); + const runningFlatpak = flatpak.runningApp; + const hasNowPlaying = Boolean(runningGame?.configured || runningFlatpak); + const runningWorkload = runningGame?.configured + ? `steam:${runningGame.appid}` + : runningFlatpak ? `flatpak:${runningFlatpak.app_id}` : null; useEffect(() => { if (!setupComplete) { setTab("Setup"); return; } - setTab((current) => current === "Setup" ? (runningGame?.configured ? "NowPlaying" : "Games") : current); - }, [runningGame?.appid, runningGame?.configured, 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(runningGame?.configured ? "NowPlaying" : "Games"); - else if (!appid && previous) { + const previous = previousRunningWorkload.current; + previousRunningWorkload.current = runningWorkload; + if (runningWorkload && runningWorkload !== previous) setTab("NowPlaying"); + else if (!runningWorkload && previous) { setTab((current) => current === "NowPlaying" ? "Games" : current); } - }, [runningGame?.appid, runningGame?.configured, setupComplete]); + }, [runningWorkload, setupComplete]); + + useEffect(() => { + if (isInstalled) { + void reload(); + void flatpak.reload(); + } + }, [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, value: boolean | number | string | string[], cleanupLaunchOptions = false, - ) => save({ ...config, [fieldName]: value }, cleanupLaunchOptions); + ) => { + await save({ ...config, [fieldName]: value }, cleanupLaunchOptions); + }; const setup = ( <SetupTab @@ -94,20 +136,26 @@ export function Content() { /> ); + const nowPlaying = runningGame?.configured ? ( + <NowPlayingTab + game={runningGame} + config={runningConfig} + onConfigChange={async (field, value) => { + await saveFor(runningGame.appid, { ...runningConfig, [field]: value }, true); + }} + /> + ) : runningFlatpak ? ( + <FlatpakNowPlayingTab + app={runningFlatpak} + busy={flatpak.busyAppId === runningFlatpak.app_id} + onConfigChange={flatpak.updateConfig} + onWorkaroundChange={flatpak.updateWorkarounds} + /> + ) : null; + const tabs = setupComplete ? [ - ...(runningGame?.configured ? [{ - id: "NowPlaying", - title: tabIcons.nowPlaying, - content: ( - <NowPlayingTab - game={runningGame} - config={config} - onConfigChange={(field, value) => handleConfigChange(field, value)} - onRepair={repair} - /> - ), - }] : []), + ...(nowPlaying ? [{ id: "NowPlaying", title: tabIcons.nowPlaying, content: nowPlaying }] : []), { id: "Games", title: tabIcons.games, @@ -116,6 +164,8 @@ export function Content() { config={config} targets={targets} runningGame={runningGame} + showDebugTab={showDebugTab} + onShowDebugTabChange={setShowDebugTab} onSelect={setSelectedAppId} onConfigChange={(field, value) => handleConfigChange(field, value, true)} onEnable={enable} @@ -126,7 +176,24 @@ export function Content() { /> ), }, - { id: "ConfigFile", title: tabIcons.configFile, content: <ConfigFileTab /> }, + { + id: "Flatpak", + title: tabIcons.flatpak, + content: ( + <FlatpakTab + apps={flatpak.apps} + runningApp={runningFlatpak} + loading={flatpak.loading} + busyAppId={flatpak.busyAppId} + onRefresh={flatpak.reload} + onEnable={flatpak.enableApp} + onRemove={flatpak.removeApp} + onConfigChange={flatpak.updateConfig} + onWorkaroundChange={flatpak.updateWorkarounds} + /> + ), + }, + ...(showDebugTab ? [{ id: "ConfigFile", title: tabIcons.configFile, content: <ConfigFileTab /> }] : []), { id: "Setup", title: tabIcons.setup, content: setup }, ] : [{ id: "Setup", title: tabIcons.setup, content: setup }]; @@ -137,7 +204,7 @@ export function Content() { style={{ height: "95%", width: "300px", position: "fixed", marginTop: "-12px", overflow: "hidden" }} > <style>{tabStyles}</style> - <Tabs activeTab={tab} onShowTab={setTab} tabs={tabs} /> + <Tabs activeTab={!showDebugTab && tab === "ConfigFile" ? "Games" : tab} onShowTab={setTab} tabs={tabs} /> </div> ); } diff --git a/src/components/FlatpakNowPlayingTab.tsx b/src/components/FlatpakNowPlayingTab.tsx new file mode 100644 index 0000000..9e5a0db --- /dev/null +++ b/src/components/FlatpakNowPlayingTab.tsx @@ -0,0 +1,39 @@ +import { Field, Focusable, PanelSection, PanelSectionRow } from "@decky/ui"; +import type { FlatpakApp, LsfgConfig, WorkaroundState } from "../api/lsfgApi"; +import { ConfigurationSection } from "./ConfigurationSection"; +import { FlatpakWorkaroundsSection } from "./FlatpakWorkaroundsSection"; +import { FpsMultiplierControl } from "./FpsMultiplierControl"; + +interface Props { + app: FlatpakApp; + busy: boolean; + onConfigChange: (appId: string, config: LsfgConfig) => Promise<boolean>; + onWorkaroundChange: (appId: string, state: WorkaroundState) => Promise<boolean>; +} + +export function FlatpakNowPlayingTab({ app, busy, onConfigChange, onWorkaroundChange }: 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> + <PanelSection title="Now Playing"> + <PanelSectionRow> + <Field label={app.app_name} description={`Flatpak · ${app.app_id}`} /> + </PanelSectionRow> + </PanelSection> + <FpsMultiplierControl config={app.config} onConfigChange={changeConfig} /> + <ConfigurationSection config={app.config} onConfigChange={changeConfig} /> + <FlatpakWorkaroundsSection + state={app.workarounds} + disabled={busy} + onChange={(state) => onWorkaroundChange(app.app_id, state)} + /> + </Focusable> + ); +} diff --git a/src/components/FlatpakTab.tsx b/src/components/FlatpakTab.tsx new file mode 100644 index 0000000..c5e27f5 --- /dev/null +++ b/src/components/FlatpakTab.tsx @@ -0,0 +1,178 @@ +import { ButtonItem, DialogButton, Field, Focusable, PanelSection, PanelSectionRow, gamepadDialogClasses } from "@decky/ui"; +import { useCallback, useMemo, useState } from "react"; +import { FaArrowLeft } from "react-icons/fa"; +import type { FlatpakApp, LsfgConfig, WorkaroundState } from "../api/lsfgApi"; +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>; + onRemove: (appId: string) => Promise<boolean>; + onConfigChange: (appId: string, config: LsfgConfig) => Promise<boolean>; + onWorkaroundChange: (appId: string, state: WorkaroundState) => Promise<boolean>; +} + +export function FlatpakTab({ + apps, + runningApp, + loading, + busyAppId, + onRefresh, + onEnable, + onRemove, + 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), []); + + if (!selectedAppId) { + return ( + <PanelSection title="Flatpak"> + {/* <PanelSectionRow> + <Field + label="Flatpak applications" + description="Enable LSFG-VK directly for a Flatpak. Steam shortcuts and launcher scripts are not modified." + /> + </PanelSectionRow> */} + {apps.map((app) => { + const status = app.enabled + ? app.app_id === runningApp?.app_id ? "Enabled · Running" : "Enabled" + : app.prepared && !app.owned ? "Prepared externally" : "Available"; + return ( + <PanelSectionRow key={app.app_id}> + <Field + label={app.app_name} + description={`${app.app_id} · ${status}`} + onActivate={() => setSelectedAppId(app.app_id)} + highlightOnFocus + /> + </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..1f0bc73 100644 --- a/src/components/GameConfigurationSelector.tsx +++ b/src/components/GameConfigurationSelector.tsx @@ -28,16 +28,13 @@ function usePersistentCollapsed(key: string) { useEffect(() => { try { localStorage.setItem(key, String(collapsed)); - } catch { - // Persisting the view preference is optional. - } + } catch {} }, [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"; } @@ -137,7 +134,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()} diff --git a/src/components/NowPlayingTab.tsx b/src/components/NowPlayingTab.tsx index 57858db..c067dc0 100644 --- a/src/components/NowPlayingTab.tsx +++ b/src/components/NowPlayingTab.tsx @@ -1,5 +1,4 @@ -import { ButtonItem, Field, Focusable, PanelSection, PanelSectionRow } from "@decky/ui"; -import { useState } from "react"; +import { Field, Focusable, PanelSection, PanelSectionRow } from "@decky/ui"; import { ConfigurationData } from "../config/configSchema"; import { GameTarget } from "../hooks/useGameConfiguration"; import { GameConfigurationControls } from "./GameConfigurationControls"; @@ -11,11 +10,9 @@ interface Props { fieldName: keyof ConfigurationData, value: boolean | number | string | string[], ) => Promise<void>; - 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"; } @@ -23,23 +20,7 @@ export function NowPlayingTab({ game, config, onConfigChange, - onRepair, }: Props) { - const [busy, setBusy] = useState(false); - const supportNeedsRepair = - game.transport.kind === "flatpak" && - game.flatpakSupport?.support_status !== "ready"; - - const handleRepair = async () => { - if (busy) return; - setBusy(true); - try { - await onRepair(game.appid); - } finally { - setBusy(false); - } - }; - return ( <Focusable> <PanelSection title="Now Playing"> @@ -47,21 +28,6 @@ export function NowPlayingTab({ <Field label={game.name} description={targetDescription(game)} /> </PanelSectionRow> </PanelSection> - {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> - )} <GameConfigurationControls config={config} onConfigChange={onConfigChange} 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/hooks/useFlatpakConfiguration.ts b/src/hooks/useFlatpakConfiguration.ts new file mode 100644 index 0000000..d5e6f7e --- /dev/null +++ b/src/hooks/useFlatpakConfiguration.ts @@ -0,0 +1,108 @@ +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 { showErrorToast } from "../utils/toastUtils"; + +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<{ success: boolean; error?: string | null }>) => { + if (busyAppId) return false; + setBusyAppId(appId); + try { + const result = await operation(); + if (!result.success) throw new Error(result.error || "Flatpak operation failed"); + await reload(); + await pollRunning(); + return true; + } catch (error) { + showErrorToast("Flatpak operation failed", error instanceof Error ? error.message : String(error)); + return false; + } finally { + setBusyAppId(""); + } + }, [busyAppId, pollRunning, reload]); + + const enableApp = useCallback((appId: string) => operate(appId, () => enableFlatpakApp(appId)), [operate]); + const removeApp = useCallback((appId: string) => operate(appId, () => removeFlatpakApp(appId)), [operate]); + const updateConfig = useCallback( + (appId: string, config: LsfgConfig) => operate(appId, () => updateFlatpakConfig(appId, config)), + [operate], + ); + const updateWorkarounds = useCallback( + (appId: string, state: WorkaroundState) => operate(appId, () => setFlatpakWorkaroundState(appId, state)), + [operate], + ); + + const runningApp = useMemo(() => { + if (runningApps.length === 0) return null; + const running = runningApps.find((app) => app.active) || (runningApps.length === 1 ? runningApps[0] : null); + if (!running) return null; + return apps.find((app) => app.app_id === running.app_id) || null; + }, [apps, runningApps]); + + return { + apps, + runningApps, + runningApp, + loading, + busyAppId, + reload, + enableApp, + removeApp, + updateConfig, + updateWorkarounds, + }; +} diff --git a/src/hooks/useGameConfiguration.ts b/src/hooks/useGameConfiguration.ts index 3260019..2e39f5e 100644 --- a/src/hooks/useGameConfiguration.ts +++ b/src/hooks/useGameConfiguration.ts @@ -1,7 +1,7 @@ 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, getWorkaroundState, removeWorkaroundState, resetGameConfig, resetAllGameConfigs, setWorkaroundState, updateGameConfig, type GameConfigEntry, type GlobalConfig, type InstalledGame, type WorkaroundState } from "../api/lsfgApi"; import { ConfigurationData, getDefaults } from "../config/configSchema"; import { getDefaultWrapperPath, installWrapperIntegration, removeWrapperIntegration } from "../utils/steamLaunchOptions"; import { showErrorToast } from "../utils/toastUtils"; @@ -23,7 +23,6 @@ async function getSteamShortcuts(): Promise<InstalledGame[]> { appid: String(appid >>> 0), name, nonSteam: true, - transport: { kind: "host" }, }]; }); } catch { @@ -80,6 +79,7 @@ export function useGameConfiguration() { previousQuickAccessVisible.current = quickAccessVisible; if (initialLoad || becameVisible) void load(); }, [load, quickAccessVisible]); + useEffect(() => { const poll = () => { if (!configsLoaded) return; @@ -89,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) { @@ -109,25 +118,15 @@ 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; @@ -148,16 +147,12 @@ export function useGameConfiguration() { target.nonSteam, wrapperPath, commandTokenAdded, - target.transport, - target.transport.kind === "flatpak" ? existing.shortcut_exe || undefined : undefined, ); stateWriteAttempted = true; const saved = await setWorkaroundState( target.appid, state, - integration.originalExecutable ?? null, integration.commandTokenAdded, - target.transport, ); if (!saved.success) throw new Error(saved.error || "Could not save workaround state"); return true; @@ -169,9 +164,7 @@ export function useGameConfiguration() { appId, target.nonSteam, wrapperPath, - integration.originalExecutable, integration.commandTokenAdded, - target.transport, ); } catch (rollbackError) { showErrorToast("Workaround rollback failed", asError(rollbackError).message); @@ -201,9 +194,7 @@ export function useGameConfiguration() { appId, target.nonSteam, wrapperPath, - target.transport.kind === "flatpak" ? existing.shortcut_exe || undefined : undefined, existing.command_token_added === true, - target.transport, ); const removed = await removeWorkaroundState(target.appid); if (!removed.success) throw new Error(removed.error || "Could not remove workaround state"); @@ -214,33 +205,37 @@ 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 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 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) { @@ -253,20 +248,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; @@ -284,6 +270,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; @@ -296,5 +283,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, games, targets, runningGame, selectedAppId, setSelectedAppId, save, saveFor, enable, enableAll, repair, resetSelected, resetAll, reload: load }; } diff --git a/src/hooks/usePerAppWorkarounds.ts b/src/hooks/usePerAppWorkarounds.ts index 29e1181..c2b8904 100644 --- a/src/hooks/usePerAppWorkarounds.ts +++ b/src/hooks/usePerAppWorkarounds.ts @@ -3,11 +3,9 @@ import { getWorkaroundState, removeWorkaroundState, setWorkaroundState, - type TargetTransport, type WorkaroundState, } from "../api/lsfgApi"; import { - assertKnownShortcutTarget, getDefaultWrapperPath, installWrapperIntegration, isWrapperIntegrationInstalled, @@ -45,8 +43,6 @@ export interface WorkaroundSnapshot { wrapperOwned: boolean; integrationInstalled: boolean; commandTokenAdded: boolean; - shortcutExe?: string | null; - transport: TargetTransport; } interface PerAppWorkarounds { @@ -65,49 +61,34 @@ function makeSnapshot( steam: SteamLaunchOptionsSnapshot, result: Awaited<ReturnType<typeof getWorkaroundState>>, nonSteam: boolean, - transport: TargetTransport, ): WorkaroundSnapshot { if (!result.state) throw new Error("Workaround state is not initialized for this profile"); const wrapperPath = result.wrapper_path || getDefaultWrapperPath(); - const selectedTransport = result.transport || transport; - const shortcutExe = selectedTransport.kind === "flatpak" ? result.shortcut_exe : undefined; - assertKnownShortcutTarget(steam, nonSteam, selectedTransport, wrapperPath, shortcutExe); return { steam, state: result.state, wrapperPath, wrapperOwned: result.wrapper_owned === true, - integrationInstalled: isWrapperIntegrationInstalled(steam, nonSteam, selectedTransport, wrapperPath), + integrationInstalled: isWrapperIntegrationInstalled(steam, nonSteam, wrapperPath), commandTokenAdded: result.command_token_added === true, - shortcutExe, - transport: selectedTransport, }; } async function adoptWorkaroundState( appId: string, nonSteam: boolean, - transport: TargetTransport, wrapperPath: string, ): Promise<WorkaroundSnapshot> { let integration: Awaited<ReturnType<typeof installWrapperIntegration>> | null = null; try { - integration = await installWrapperIntegration( - Number(appId), - nonSteam, - wrapperPath, - false, - transport, - ); + integration = await installWrapperIntegration(Number(appId), nonSteam, wrapperPath, false); const finalized = await setWorkaroundState( appId, DEFAULT_WORKAROUND_STATE, - integration.originalExecutable ?? null, integration.commandTokenAdded, - transport, ); if (!finalized.success) throw new Error(finalized.error || "Could not finalize workaround state"); - return makeSnapshot(integration.snapshot, finalized, nonSteam, transport); + return makeSnapshot(integration.snapshot, finalized, nonSteam); } catch (error) { let rollbackSucceeded = true; if (integration?.changed) { @@ -116,12 +97,9 @@ async function adoptWorkaroundState( Number(appId), nonSteam, wrapperPath, - integration.originalExecutable, integration.commandTokenAdded, - transport, ); } catch { - // Leave the owned integration in place rather than guessing at cleanup. rollbackSucceeded = false; } } @@ -133,11 +111,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); @@ -154,12 +128,11 @@ export function usePerAppWorkarounds( return adoptWorkaroundState( appId, nonSteam, - transport, result.wrapper_path || getDefaultWrapperPath(), ); } - return makeSnapshot(steam, result, nonSteam, transport); - }, [appId, nonSteam, numericAppId, transport]); + return makeSnapshot(steam, result, nonSteam); + }, [appId, nonSteam, numericAppId]); const applySnapshot = useCallback((next: WorkaroundSnapshot) => { setSnapshot(next); @@ -194,7 +167,7 @@ export function usePerAppWorkarounds( setSnapshot((current) => current ? { ...current, steam, - integrationInstalled: isWrapperIntegrationInstalled(steam, nonSteam, current.transport, current.wrapperPath), + integrationInstalled: isWrapperIntegrationInstalled(steam, nonSteam, current.wrapperPath), } : current); }, (subscriptionError) => { @@ -229,24 +202,18 @@ export function usePerAppWorkarounds( setError(null); const nextState = { ...current.state, [field]: value } as WorkaroundState; try { - const shortcutExe = current.transport.kind === "flatpak" ? current.shortcutExe ?? null : null; const result = await setWorkaroundState( appId, nextState, - shortcutExe, current.commandTokenAdded, - current.transport, ); if (!result.success || !result.state) throw new Error(result.error || "Could not save workaround state"); - const selectedTransport = result.transport || current.transport; applySnapshot({ ...current, state: result.state, wrapperPath: result.wrapper_path || current.wrapperPath, wrapperOwned: result.wrapper_owned === true, - shortcutExe: selectedTransport.kind === "flatpak" ? result.shortcut_exe : undefined, commandTokenAdded: result.command_token_added === true, - transport: selectedTransport, }); return true; } catch (updateError) { diff --git a/src/types.d.ts b/src/types.d.ts index e5db40d..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,6 @@ 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/steamLaunchOptions.ts b/src/utils/steamLaunchOptions.ts index 82cf116..f03eeab 100644 --- a/src/utils/steamLaunchOptions.ts +++ b/src/utils/steamLaunchOptions.ts @@ -1,5 +1,3 @@ -import type { TargetTransport } from "../api/lsfgApi"; - const DEFAULT_WRAPPER_PATH = "~/.lsfg"; const COMMAND_TOKEN = "%command%"; @@ -26,12 +24,10 @@ export interface SteamLaunchOptionsSnapshot { appId: number; nonSteam: boolean; options: string; - target: string; details: SteamAppDetails; } export interface WrapperIntegrationResult { snapshot: SteamLaunchOptionsSnapshot; - originalExecutable?: string; commandTokenAdded: boolean; changed: boolean; } @@ -61,7 +57,6 @@ function snapshot(appId: number, nonSteam: boolean, details: SteamAppDetails): S appId, nonSteam, options: nonSteam ? details.strShortcutLaunchOptions || "" : details.strLaunchOptions || "", - target: nonSteam ? details.strShortcutExe || "" : "", details, }; } @@ -162,15 +157,6 @@ const commandIndex = (tokens: readonly LaunchToken[]) => tokens.findIndex((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); -const usesShortcutTarget = (nonSteam: boolean, transport: TargetTransport) => nonSteam && transport.kind === "flatpak"; - -function selectFlatpakExecutable(transport: TargetTransport, candidate?: string | null): string | undefined { - if (transport.kind !== "flatpak") return undefined; - const value = candidate?.trim() ? decodeToken(candidate.trim()) : ""; - if (value === "flatpak") return "/usr/bin/flatpak"; - if (value === "/usr/bin/flatpak") return value; - return undefined; -} export const normalizeLaunchOptions = (options: string) => serialize(tokenize(options)); export const isLegacyWrapperToken = (value: string) => isLegacyToken(decodeToken(value)); @@ -267,25 +253,10 @@ export function hasWrapperLaunchIntegration(options: string, wrapperPath = DEFAU export function isWrapperIntegrationInstalled( steam: SteamLaunchOptionsSnapshot, - nonSteam: boolean, - transport: TargetTransport, + _nonSteam: boolean, wrapperPath = DEFAULT_WRAPPER_PATH, ): boolean { - return usesShortcutTarget(nonSteam, transport) - ? steam.target === wrapperPath - : hasWrapperLaunchIntegration(steam.options, wrapperPath); -} - -export function assertKnownShortcutTarget( - steam: SteamLaunchOptionsSnapshot, - nonSteam: boolean, - transport: TargetTransport, - wrapperPath: string, - originalExecutable?: string | null, -): void { - if (usesShortcutTarget(nonSteam, transport) && steam.target === wrapperPath && !originalExecutable) { - throw new Error("Managed shortcut Target has no saved original executable"); - } + return hasWrapperLaunchIntegration(steam.options, wrapperPath); } const queues = new Map<string, Promise<unknown>>(); @@ -325,18 +296,16 @@ async function writeVerified( previous: string, next: string, write: (value: string) => Promise<void>, - read: (value: SteamLaunchOptionsSnapshot) => string, message: string, ): Promise<SteamLaunchOptionsSnapshot> { - const normalized = read === readOptions ? normalizeLaunchOptions : (value: string) => value; try { await write(next); - return await waitFor(appId, nonSteam, (value) => normalized(read(value)) === normalized(next), message); + return await waitFor(appId, nonSteam, (value) => normalizeLaunchOptions(value.options) === normalizeLaunchOptions(next), message); } catch (error) { const failure = asError(error); try { await write(previous); - await waitFor(appId, nonSteam, (value) => normalized(read(value)) === normalized(previous), `Steam did not restore the previous ${read === readOptions ? "launch options" : "shortcut Target"}`); + 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}`); } @@ -344,19 +313,11 @@ async function writeVerified( } } -const readOptions = (value: SteamLaunchOptionsSnapshot) => value.options; -const readTarget = (value: SteamLaunchOptionsSnapshot) => value.target; - 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)); } -function writeTarget(appId: number, value: string): Promise<void> { - const setter = apps()?.SetShortcutExe; - if (!setter) return Promise.reject(new Error("Steam shortcut Target API is unavailable")); - return Promise.resolve(setter.call(apps(), appId, value)); -} export function updateSteamLaunchOptions( appId: number, @@ -368,7 +329,7 @@ export function updateSteamLaunchOptions( const next = transform(current.options); return next === current.options ? current : writeVerified( appId, nonSteam, current.options, next, - (value) => writeOptions(appId, nonSteam, value), readOptions, + (value) => writeOptions(appId, nonSteam, value), "Steam did not accept the launch options", ); }); @@ -379,52 +340,16 @@ export function installWrapperIntegration( nonSteam: boolean, wrapperPath: string, commandTokenAdded = false, - transport: TargetTransport = { kind: "host" }, - originalExecutable?: string, ): Promise<WrapperIntegrationResult> { return queued(appId, nonSteam, async () => { - let current = await readSteamLaunchOptions(appId, nonSteam); - if (usesShortcutTarget(nonSteam, transport)) { - 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 cleaned = cleanupPluginLaunchOptions(current.options, wrapperPath); - const launchOptionsChanged = cleaned !== current.options; - if (launchOptionsChanged) { - current = await writeVerified( - appId, true, current.options, cleaned, - (value) => writeOptions(appId, true, value), readOptions, - "Steam did not accept shortcut launch options", - ); - } - const savedOriginal = selectFlatpakExecutable(transport, originalExecutable); - if (current.target === wrapperPath) { - if (!savedOriginal) throw new Error("Managed shortcut Target has no saved original executable"); - return { snapshot: current, originalExecutable: savedOriginal, commandTokenAdded: false, changed: launchOptionsChanged }; - } - if (savedOriginal && selectFlatpakExecutable(transport, current.target) !== savedOriginal) { - throw new Error("Shortcut Target changed externally; refusing to replace it"); - } - const currentOriginal = selectFlatpakExecutable(transport, current.target); - if (!currentOriginal || (originalExecutable && !savedOriginal)) { - throw new Error("Flatpak shortcut Target is not a supported executable"); - } - const value = await writeVerified( - appId, true, current.target, wrapperPath, - (target) => writeTarget(appId, target), readTarget, - "Steam did not accept the shortcut Target", - ); - return { snapshot: value, originalExecutable: currentOriginal, commandTokenAdded: false, changed: true }; - } - + const current = await readSteamLaunchOptions(appId, nonSteam); const cleaned = cleanupPluginAssignments(cleanupLegacyLaunchOptions(current.options)); const alreadyInstalled = hasWrapperLaunchIntegration(current.options, wrapperPath); 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), readOptions, + (options) => writeOptions(appId, nonSteam, options), "Steam did not accept the launch options", ); return { snapshot: value, commandTokenAdded: alreadyInstalled ? commandTokenAdded : rewrite.commandTokenAdded, changed: true }; @@ -435,43 +360,14 @@ export function removeWrapperIntegration( appId: number, nonSteam: boolean, wrapperPath: string, - originalExecutable?: string, commandTokenAdded = false, - transport: TargetTransport = { kind: "host" }, ): Promise<SteamLaunchOptionsSnapshot> { return queued(appId, nonSteam, async () => { - let current = await readSteamLaunchOptions(appId, nonSteam); - if (usesShortcutTarget(nonSteam, transport)) { - const cleaned = cleanupPluginLaunchOptions(current.options, wrapperPath); - if (cleaned !== current.options) { - current = await writeVerified( - appId, true, current.options, cleaned, - (value) => writeOptions(appId, true, value), readOptions, - "Steam did not clean shortcut launch options", - ); - } - if (current.target !== wrapperPath) { - if (isWrapperToken(current.target, wrapperPath)) { - throw new Error("Original shortcut Target is unavailable; refusing to overwrite the current Target"); - } - if (originalExecutable && selectFlatpakExecutable(transport, current.target) !== selectFlatpakExecutable(transport, originalExecutable)) { - throw new Error("Shortcut Target changed externally; refusing to restore it"); - } - return current; - } - if (!originalExecutable || isWrapperToken(originalExecutable, wrapperPath)) { - throw new Error("Original shortcut Target is unavailable; refusing to overwrite the current Target"); - } - return writeVerified( - appId, true, wrapperPath, originalExecutable, - (target) => writeTarget(appId, target), readTarget, - "Steam did not restore the shortcut Target", - ); - } + const current = await readSteamLaunchOptions(appId, nonSteam); const next = cleanupPluginAssignments(removeWrapperLaunchOption(current.options, wrapperPath, commandTokenAdded)); return next === current.options ? current : writeVerified( appId, nonSteam, current.options, next, - (options) => writeOptions(appId, nonSteam, options), readOptions, + (options) => writeOptions(appId, nonSteam, options), "Steam did not clean the launch options", ); }); |
