diff options
Diffstat (limited to 'src/components/CustomPathOverride.tsx')
| -rw-r--r-- | src/components/CustomPathOverride.tsx | 303 |
1 files changed, 303 insertions, 0 deletions
diff --git a/src/components/CustomPathOverride.tsx b/src/components/CustomPathOverride.tsx new file mode 100644 index 0000000..ffc4b1f --- /dev/null +++ b/src/components/CustomPathOverride.tsx @@ -0,0 +1,303 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { ButtonItem, Field, PanelSectionRow, ToggleField } from "@decky/ui"; +import { FileSelectionType, openFilePicker } from "@decky/api"; +import { getPathDefaults, runManualPatch, runManualUnpatch } from "../api"; +import type { ApiResponse } from "../types/index"; +import { SmartClipboardButton } from "./SmartClipboardButton"; + +interface PathDefaults { + home: string; + steamCommon: string; +} + +const DEFAULT_HOME = "/home"; +const DEFAULT_STEAM_COMMON = "/home/deck/.local/share/Steam/steamapps/common"; + +const INITIAL_DEFAULTS: PathDefaults = { + home: DEFAULT_HOME, + steamCommon: DEFAULT_STEAM_COMMON, +}; + +const normalizePath = (value: string) => value.replace(/\\/g, "/"); + +const stripTrailingSlash = (value: string) => + value.length > 1 && value.endsWith("/") ? value.slice(0, -1) : value; + +const ensureDirectory = (value: string) => { + const normalized = normalizePath(value); + const lastSegment = normalized.substring(normalized.lastIndexOf("/") + 1); + if (!lastSegment || !lastSegment.includes(".")) { + return stripTrailingSlash(normalized); + } + const parent = normalized.slice(0, normalized.lastIndexOf("/")); + return parent || "/"; +}; + +interface ManualPatchControlsProps { + isAvailable: boolean; + onManualModeChange?: (enabled: boolean) => void; +} + +interface PickerState { + selectedPath: string | null; + lastError: string | null; +} + +const INITIAL_PICKER_STATE: PickerState = { + selectedPath: null, + lastError: null, +}; + +const formatResultMessage = (result: ApiResponse | null) => { + if (!result) return null; + if (result.status === "success") { + return result.message || result.output || "Operation completed successfully."; + } + return result.message || result.output || "Operation failed."; +}; + +export const ManualPatchControls = ({ isAvailable, onManualModeChange }: ManualPatchControlsProps) => { + const [isEnabled, setEnabled] = useState(false); + const [defaults, setDefaults] = useState<PathDefaults>(INITIAL_DEFAULTS); + const [pickerState, setPickerState] = useState<PickerState>(INITIAL_PICKER_STATE); + const [isPatching, setIsPatching] = useState(false); + const [isUnpatching, setIsUnpatching] = useState(false); + const [operationResult, setOperationResult] = useState<ApiResponse | null>(null); + const [lastOperation, setLastOperation] = useState<"patch" | "unpatch" | null>(null); + + useEffect(() => { + let cancelled = false; + + (async () => { + try { + const response = await getPathDefaults(); + if (!response || cancelled) return; + + const home = response.home ? normalizePath(response.home) : DEFAULT_HOME; + const steamCommon = response.steam_common + ? normalizePath(response.steam_common) + : normalizePath(`${stripTrailingSlash(home)}/.local/share/Steam/steamapps/common`); + + setDefaults({ + home, + steamCommon: steamCommon || DEFAULT_STEAM_COMMON, + }); + } catch (err) { + console.error("ManualPatchControls -> getPathDefaults", err); + } + })(); + + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + if (!isAvailable) { + setEnabled(false); + setPickerState(INITIAL_PICKER_STATE); + setOperationResult(null); + setLastOperation(null); + onManualModeChange?.(false); + } + }, [isAvailable, onManualModeChange]); + + const canInteract = isAvailable && isEnabled; + const selectedPath = pickerState.selectedPath; + const statusMessage = useMemo(() => formatResultMessage(operationResult), [operationResult]); + const wasSuccessful = operationResult?.status === "success"; + const statusLabel = useMemo(() => { + if (!operationResult || !lastOperation) return null; + if (operationResult.status === "success") { + return lastOperation === "patch" ? "Game patched" : "Game unpatched"; + } + return lastOperation === "patch" ? "Patch failed" : "Unpatch failed"; + }, [lastOperation, operationResult]); + + const openDirectoryPicker = useCallback(async () => { + const candidates = [ + selectedPath, + defaults.steamCommon, + defaults.home, + ]; + + let lastError: string | null = null; + + for (const candidate of candidates) { + if (!candidate) continue; + + const startPath = ensureDirectory(candidate); + + try { + const result = await openFilePicker( + FileSelectionType.FOLDER, + startPath, + true, + true, + undefined, + undefined, + true + ); + + if (result?.path) { + setPickerState({ selectedPath: normalizePath(result.path), lastError: null }); + setOperationResult(null); + return; + } + } catch (err) { + console.error("ManualPatchControls -> openDirectoryPicker", err); + lastError = err instanceof Error ? err.message : String(err); + } + } + + setPickerState((prev) => ({ ...prev, lastError })); + }, [defaults.home, defaults.steamCommon, selectedPath]); + + const runOperation = useCallback( + async (action: "patch" | "unpatch") => { + if (!selectedPath) return; + + const setBusy = action === "patch" ? setIsPatching : setIsUnpatching; + setLastOperation(action); + setBusy(true); + setOperationResult(null); + + try { + const response = + action === "patch" + ? await runManualPatch(selectedPath) + : await runManualUnpatch(selectedPath); + setOperationResult(response ?? { status: "error", message: "No response from backend." }); + } catch (err) { + setOperationResult({ + status: "error", + message: err instanceof Error ? err.message : String(err), + }); + } finally { + setBusy(false); + } + }, + [selectedPath] + ); + + const handleToggle = (value: boolean) => { + if (!isAvailable) { + setEnabled(false); + return; + } + + setEnabled(value); + onManualModeChange?.(value); + if (!value) { + setPickerState(INITIAL_PICKER_STATE); + setOperationResult(null); + setLastOperation(null); + } + }; + + const busy = isPatching || isUnpatching; + + return ( + <> + <PanelSectionRow> + <ToggleField + label="Advanced Mode" + description={ + isAvailable + ? "Manually apply OptiScaler to a specific game directory." + : "Install OptiScaler first to enable manual patching." + } + checked={isEnabled && isAvailable} + disabled={!isAvailable} + onChange={handleToggle} + /> + </PanelSectionRow> + + {canInteract && ( + <> + <SmartClipboardButton + command='WINEDLLOVERRIDES="dxgi=n,b" SteamDeck=0 %command%' + buttonText="Manual launch cmd" + /> + <PanelSectionRow> + <ButtonItem + layout="below" + onClick={openDirectoryPicker} + description="Choose the game's installation directory (where the EXE lives)." + > + Select directory + </ButtonItem> + </PanelSectionRow> + + {pickerState.lastError && ( + <PanelSectionRow> + <Field + label="Picker error" + description={pickerState.lastError} + > + ⚠️ + </Field> + </PanelSectionRow> + )} + + {selectedPath && ( + <> + <PanelSectionRow> + <Field + label="Target directory" + description="OptiScaler files will be copied here." + > + <div + style={{ + fontFamily: "monospace", + backgroundColor: "rgba(255, 255, 255, 0.05)", + border: "1px solid rgba(255, 255, 255, 0.1)", + borderRadius: "4px", + padding: "6px 8px", + width: "100%", + boxSizing: "border-box", + whiteSpace: "pre-wrap", + wordBreak: "break-word", + }} + > + {selectedPath} + </div> + </Field> + </PanelSectionRow> + + <PanelSectionRow> + <ButtonItem + layout="below" + disabled={busy} + onClick={() => runOperation("patch")} + > + {isPatching ? "Patching..." : "Patch directory"} + </ButtonItem> + </PanelSectionRow> + + <PanelSectionRow> + <ButtonItem + layout="below" + disabled={busy} + onClick={() => runOperation("unpatch")} + > + {isUnpatching ? "Reverting..." : "Unpatch directory"} + </ButtonItem> + </PanelSectionRow> + </> + )} + + {operationResult && ( + <PanelSectionRow> + <Field + label={statusLabel ?? (wasSuccessful ? "Last action succeeded" : "Last action failed")} + > + {!wasSuccessful && statusMessage ? statusMessage : null} + </Field> + </PanelSectionRow> + )} + </> + )} + </> + ); +};
\ No newline at end of file |
