summaryrefslogtreecommitdiff
path: root/src/utils
diff options
context:
space:
mode:
authorKurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com>2026-09-12 21:29:08 -0400
committerGitHub <noreply@github.com>2026-09-12 21:29:08 -0400
commite580c6bd60c92af36f92d4c29b5d9a44f73d47d7 (patch)
tree7fc6799626519e5b01fb137f5440c99fda5faca7 /src/utils
parente997a3fb74fa70f5e60b60807fb0897120e98313 (diff)
parent81feb03288166755545401b9df2ff2c9bc3ae7d7 (diff)
downloaddecky-lsfg-vk-e580c6bd60c92af36f92d4c29b5d9a44f73d47d7.tar.gz
decky-lsfg-vk-e580c6bd60c92af36f92d4c29b5d9a44f73d47d7.zip
Merge pull request #264 from xXJSONDeruloXx/chore/migrate
the big one
Diffstat (limited to 'src/utils')
-rw-r--r--src/utils/clipboardUtils.ts64
-rw-r--r--src/utils/gameTargets.ts75
-rw-r--r--src/utils/nowPlaying.ts86
-rw-r--r--src/utils/steamLaunchOptions.ts409
-rw-r--r--src/utils/toastUtils.ts99
5 files changed, 587 insertions, 146 deletions
diff --git a/src/utils/clipboardUtils.ts b/src/utils/clipboardUtils.ts
deleted file mode 100644
index 8a04caa..0000000
--- a/src/utils/clipboardUtils.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-/**
- * Clipboard utilities for reliable copy operations across different environments
- */
-
-/**
- * Reliably copy text to clipboard using multiple fallback methods
- * This is especially important in gaming mode where clipboard APIs may behave differently
- */
-export async function copyToClipboard(text: string): Promise<boolean> {
- const tempInput = document.createElement('input');
- tempInput.value = text;
- tempInput.style.position = 'absolute';
- tempInput.style.left = '-9999px';
- document.body.appendChild(tempInput);
-
- try {
- tempInput.focus();
- tempInput.select();
-
- let copySuccess = false;
- try {
- if (document.execCommand('copy')) {
- copySuccess = true;
- }
- } catch (e) {
- try {
- await navigator.clipboard.writeText(text);
- copySuccess = true;
- } catch (clipboardError) {
- console.error('Both copy methods failed:', e, clipboardError);
- }
- }
-
- return copySuccess;
- } finally {
- document.body.removeChild(tempInput);
- }
-}
-
-/**
- * Verify that text was successfully copied to clipboard
- */
-export async function verifyCopy(expectedText: string): Promise<boolean> {
- try {
- const readBack = await navigator.clipboard.readText();
- return readBack === expectedText;
- } catch (e) {
- return true;
- }
-}
-
-/**
- * Copy text with verification and return success status
- */
-export async function copyWithVerification(text: string): Promise<{ success: boolean; verified: boolean }> {
- const copySuccess = await copyToClipboard(text);
-
- if (!copySuccess) {
- return { success: false, verified: false };
- }
-
- const verified = await verifyCopy(text);
- return { success: true, verified };
-}
diff --git a/src/utils/gameTargets.ts b/src/utils/gameTargets.ts
new file mode 100644
index 0000000..8922e98
--- /dev/null
+++ b/src/utils/gameTargets.ts
@@ -0,0 +1,75 @@
+import type { GameConfigEntry, InstalledGame, WorkaroundApp } from "../api/lsfgApi";
+
+export type GameSource = "steam" | "nonSteam" | "unknown";
+export type KnownGameSource = Exclude<GameSource, "unknown">;
+
+export interface GameTarget extends InstalledGame {
+ configured: boolean;
+ source: GameSource;
+}
+
+export function sourceFromNonSteam(nonSteam: boolean): KnownGameSource {
+ return nonSteam ? "nonSteam" : "steam";
+}
+
+export function getTargetSource(
+ appid: string,
+ installedGames: InstalledGame[],
+ workaroundApps: WorkaroundApp[],
+): GameSource {
+ const workaround = workaroundApps.find((item) => item.appid === appid);
+ if (workaround) return sourceFromNonSteam(workaround.non_steam);
+
+ const installed = installedGames.find((game) => game.appid === appid);
+ return installed ? sourceFromNonSteam(installed.nonSteam) : "unknown";
+}
+
+export function mergeGameTargets(
+ configs: GameConfigEntry[],
+ installedGames: InstalledGame[],
+ workaroundApps: WorkaroundApp[],
+ runningGame: GameTarget | null = null,
+): GameTarget[] {
+ const configuredIds = new Set(configs.map((game) => game.appid));
+ const targets = installedGames.map((game) => {
+ const source = getTargetSource(game.appid, installedGames, workaroundApps);
+ return {
+ ...game,
+ nonSteam: source === "nonSteam",
+ source,
+ configured: configuredIds.has(game.appid),
+ };
+ });
+
+ for (const game of configs) {
+ if (targets.some((target) => target.appid === game.appid)) continue;
+ const source = getTargetSource(game.appid, installedGames, workaroundApps);
+ targets.push({
+ appid: game.appid,
+ name: game.profile || `App ${game.appid}`,
+ nonSteam: source === "nonSteam",
+ source,
+ configured: true,
+ });
+ }
+
+ if (
+ runningGame
+ && !targets.some((target) => target.appid === runningGame.appid)
+ && (runningGame.configured || runningGame.source !== "unknown")
+ ) {
+ targets.unshift(runningGame);
+ }
+
+ return targets;
+}
+
+export function targetsForSource(targets: GameTarget[], source: KnownGameSource): GameTarget[] {
+ return targets.filter((target) => target.source === source || (target.source === "unknown" && target.configured));
+}
+
+export function sourceLabel(source: GameSource): string {
+ if (source === "nonSteam") return "Non-Steam";
+ if (source === "steam") return "Steam";
+ return "Unknown source";
+}
diff --git a/src/utils/nowPlaying.ts b/src/utils/nowPlaying.ts
new file mode 100644
index 0000000..a307f5c
--- /dev/null
+++ b/src/utils/nowPlaying.ts
@@ -0,0 +1,86 @@
+import type { FlatpakApp, RunningFlatpakApp } from "../api/lsfgApi";
+import type { GameTarget } from "./gameTargets";
+
+export type NowPlayingTarget =
+ | {
+ kind: "flatpak";
+ app: FlatpakApp;
+ launcher: GameTarget | null;
+ }
+ | {
+ kind: "steam";
+ game: GameTarget;
+ }
+ | {
+ kind: "nonSteam";
+ game: GameTarget;
+ };
+
+function numericValue(value: number | null | undefined): number {
+ return typeof value === "number" && Number.isFinite(value) ? value : -1;
+}
+
+function numericPid(value: string | undefined): number {
+ return value && /^\d+$/.test(value) ? Number(value) : -1;
+}
+
+function compareRunningProcesses(a: RunningFlatpakApp, b: RunningFlatpakApp): number {
+ if (a.active !== b.active) return a.active ? -1 : 1;
+ const startDifference = numericValue(b.start_time) - numericValue(a.start_time);
+ if (startDifference !== 0) return startDifference;
+ return numericPid(b.pid) - numericPid(a.pid);
+}
+
+export function selectMostRecentRunningFlatpak(
+ apps: FlatpakApp[],
+ runningApps: RunningFlatpakApp[],
+): FlatpakApp | null {
+ const newestProcessByApp = new Map<string, RunningFlatpakApp>();
+ for (const running of runningApps) {
+ const current = newestProcessByApp.get(running.app_id);
+ if (!current || compareRunningProcesses(running, current) < 0) {
+ newestProcessByApp.set(running.app_id, running);
+ }
+ }
+
+ const candidates = Array.from(newestProcessByApp.values())
+ .map((running) => ({
+ running,
+ app: apps.find((app) => app.app_id === running.app_id) || null,
+ }))
+ .filter((candidate): candidate is { running: RunningFlatpakApp; app: FlatpakApp } => candidate.app !== null);
+ const activeCandidates = candidates.filter(({ running }) => running.active);
+ const eligibleCandidates = activeCandidates.length > 0
+ ? activeCandidates
+ : candidates.length === 1
+ ? candidates
+ : [];
+
+ eligibleCandidates.sort((a, b) => {
+ const processDifference = compareRunningProcesses(a.running, b.running);
+ if (processDifference !== 0) return processDifference;
+ return a.running.app_id.localeCompare(b.running.app_id);
+ });
+
+ return eligibleCandidates[0]?.app || null;
+}
+
+export function resolveNowPlayingTarget(
+ runningGame: GameTarget | null,
+ runningFlatpak: FlatpakApp | null,
+): NowPlayingTarget | null {
+ if (runningGame?.source === "steam") {
+ return runningGame.configured ? { kind: "steam", game: runningGame } : null;
+ }
+ if (runningFlatpak) {
+ return {
+ kind: "flatpak",
+ app: runningFlatpak,
+ launcher: runningGame?.source === "nonSteam" ? runningGame : null,
+ };
+ }
+ if (runningGame?.source === "nonSteam" && runningGame.configured) {
+ return { kind: "nonSteam", game: runningGame };
+ }
+ return null;
+}
diff --git a/src/utils/steamLaunchOptions.ts b/src/utils/steamLaunchOptions.ts
new file mode 100644
index 0000000..83c46f2
--- /dev/null
+++ b/src/utils/steamLaunchOptions.ts
@@ -0,0 +1,409 @@
+const DEFAULT_WRAPPER_PATH = "~/.lsfg";
+const COMMAND_TOKEN = "%command%";
+
+export const LEGACY_WRAPPER_TOKENS = new Set([
+ "~/lsfg",
+ "~/.local/bin/lsfg",
+ "~/.local/bin/lsfg-vk-experimental",
+ "~/.local/bin/mako-run",
+ "mako-run",
+ "~/.local/bin/mako-launch",
+ "mako-launch",
+]);
+
+const LEGACY_ABSOLUTE_WRAPPER = /^\/(?:home|Users)\/[^/]+\/(?:lsfg|\.local\/bin\/(?:lsfg|lsfg-vk-experimental|mako-run|mako-launch))$/;
+const MANAGED_ENV_KEYS = new Set([
+ "ENABLE_GAMESCOPE_WSI", "DISABLE_GAMESCOPE_WSI", "DXVK_HDR", "SteamDeck",
+ "DISABLE_VKBASALT", "ENABLE_VKBASALT", "MESA_LOADER_DRIVER_OVERRIDE",
+ "__GLX_VENDOR_LIBRARY_NAME", "GALLIUM_DRIVER", "DXVK_FRAME_RATE",
+]);
+const DXVK_FRAME_RATE_SEGMENT = /^(?:dxvk\.maxFrameRate|dxgi\.maxFrameRate|d3d9\.maxFrameRate)\s*=/i;
+
+interface LaunchToken { raw: string; value: string; }
+export interface SteamLaunchOptionsSnapshot {
+ appId: number;
+ nonSteam: boolean;
+ options: string;
+ details: SteamAppDetails;
+}
+export interface WrapperIntegrationResult {
+ snapshot: SteamLaunchOptionsSnapshot;
+ commandTokenAdded: boolean;
+ changed: boolean;
+}
+
+function asError(error: unknown): Error {
+ return error instanceof Error ? error : new Error(String(error));
+}
+
+function apps(): Partial<SteamApps> | undefined {
+ return (globalThis as typeof globalThis & { SteamClient?: { Apps?: Partial<SteamApps> } }).SteamClient?.Apps;
+}
+
+function validateAppId(appId: number): void {
+ if (!Number.isSafeInteger(appId) || appId <= 0) throw new Error("Invalid Steam App ID");
+}
+
+function timer() {
+ const host = typeof window !== "undefined" ? window : globalThis;
+ return {
+ set: (handler: () => void, ms: number) => host.setTimeout(handler, ms) as unknown as number,
+ clear: (id: number) => host.clearTimeout(id),
+ };
+}
+
+function snapshot(appId: number, nonSteam: boolean, details: SteamAppDetails): SteamLaunchOptionsSnapshot {
+ return {
+ appId,
+ nonSteam,
+ options: nonSteam ? details.strShortcutLaunchOptions || "" : details.strLaunchOptions || "",
+ details,
+ };
+}
+
+function registerDetails(appId: number, onDetails: (details: SteamAppDetails) => boolean | void): () => void {
+ validateAppId(appId);
+ const register = apps()?.RegisterForAppDetails;
+ if (!register) throw new Error("Steam app-details API is unavailable");
+ let active = true;
+ let registration: SteamAppDetailsRegistration | undefined;
+ const unsubscribe = () => {
+ active = false;
+ try { registration?.unregister(); } catch {}
+ };
+ registration = register.call(apps(), appId, (details) => {
+ if (active && onDetails(details || {}) === false) unsubscribe();
+ });
+ if (!active) unsubscribe();
+ return unsubscribe;
+}
+
+export async function readSteamLaunchOptions(appId: number, nonSteam: boolean): Promise<SteamLaunchOptionsSnapshot> {
+ return new Promise((resolve, reject) => {
+ let done = false;
+ let unsubscribe = () => {};
+ const clock = timer();
+ const timeout = clock.set(() => finish(new Error("Timed out reading Steam app details")), 5000);
+ const finish = (error?: unknown, details?: SteamAppDetails) => {
+ if (done) return;
+ done = true;
+ clock.clear(timeout);
+ unsubscribe();
+ if (error) reject(asError(error));
+ else resolve(snapshot(appId, nonSteam, details || {}));
+ };
+ try {
+ unsubscribe = registerDetails(appId, (details) => {
+ finish(undefined, details);
+ return false;
+ });
+ } catch (error) {
+ finish(error);
+ }
+ });
+}
+
+export function subscribeSteamLaunchOptions(
+ appId: number,
+ nonSteam: boolean,
+ onSnapshot: (value: SteamLaunchOptionsSnapshot) => void,
+ onError: (error: Error) => void,
+): () => void {
+ return registerDetails(appId, (details) => {
+ try { onSnapshot(snapshot(appId, nonSteam, details)); }
+ catch (error) { onError(asError(error)); }
+ });
+}
+
+function decodeToken(raw: string): string {
+ let value = "";
+ let quote: "'" | '"' | null = null;
+ for (let i = 0; i < raw.length; i++) {
+ const c = raw[i];
+ if (c === "\\" && quote !== "'" && i + 1 < raw.length) value += raw[++i];
+ else if (quote) { if (c === quote) quote = null; else value += c; }
+ else if (c === "'" || c === '"') quote = c;
+ else value += c;
+ }
+ return value;
+}
+
+function tokenize(options: string): LaunchToken[] {
+ const tokens: LaunchToken[] = [];
+ let start = -1;
+ let quote: "'" | '"' | null = null;
+ let escaped = false;
+ const push = (end: number) => {
+ if (start < 0) return;
+ const raw = options.slice(start, end);
+ tokens.push({ raw, value: decodeToken(raw) });
+ start = -1;
+ };
+ for (let i = 0; i < options.length; i++) {
+ const c = options[i];
+ if (start < 0) { if (/\s/.test(c)) continue; start = i; }
+ if (escaped) escaped = false;
+ else if (c === "\\" && quote !== "'") escaped = true;
+ else if (quote) { if (c === quote) quote = null; }
+ else if (c === "'" || c === '"') quote = c;
+ else if (/\s/.test(c)) push(i);
+ }
+ push(options.length);
+ return tokens;
+}
+
+const serialize = (tokens: readonly LaunchToken[]) => tokens.map(({ raw }) => raw).join(" ");
+function isCommandToken(token: LaunchToken): boolean {
+ return token.value.toLowerCase() === COMMAND_TOKEN;
+}
+
+function isMalformedCommandToken(token: LaunchToken): boolean {
+ const value = token.value.toLowerCase();
+ return value === "%command" || value === "command%";
+}
+
+function normalizeCommandTokens(tokens: LaunchToken[]): void {
+ for (const token of tokens) {
+ if (isCommandToken(token) || isMalformedCommandToken(token)) {
+ token.raw = COMMAND_TOKEN;
+ token.value = COMMAND_TOKEN;
+ }
+ }
+}
+
+const commandIndex = (tokens: readonly LaunchToken[]) => tokens.findIndex(isCommandToken);
+const isAssignment = (token: LaunchToken) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(token.value);
+const isLegacyToken = (value: string) => LEGACY_WRAPPER_TOKENS.has(value) || LEGACY_ABSOLUTE_WRAPPER.test(value);
+const isWrapperToken = (value: string, wrapperPath: string) => decodeToken(value) === wrapperPath || isLegacyWrapperToken(value);
+
+export const normalizeLaunchOptions = (options: string) => serialize(tokenize(options));
+export const isLegacyWrapperToken = (value: string) => isLegacyToken(decodeToken(value));
+
+function removeMatchingWrappers(tokens: LaunchToken[], predicate: (value: string) => boolean): boolean {
+ const command = commandIndex(tokens);
+ const prefixEnd = command >= 0 ? command : tokens.length;
+ const kept = tokens.filter((token, i) => i >= prefixEnd || !predicate(token.value));
+ if (kept.length === tokens.length) return false;
+ tokens.splice(0, tokens.length, ...kept);
+ return true;
+}
+
+function installLaunchOption(
+ options: string,
+ wrapperPath = DEFAULT_WRAPPER_PATH,
+) {
+ const tokens = tokenize(options);
+ normalizeCommandTokens(tokens);
+ removeMatchingWrappers(tokens, isLegacyToken);
+ let command = commandIndex(tokens);
+ if (command >= 0) {
+ if (tokens[command - 1]?.value === wrapperPath) return { options: serialize(tokens), commandTokenAdded: false };
+ removeMatchingWrappers(tokens, (value) => decodeToken(value) === wrapperPath);
+ command = commandIndex(tokens);
+ tokens.splice(command, 0, { raw: wrapperPath, value: wrapperPath });
+ return { options: serialize(tokens), commandTokenAdded: false };
+ }
+
+ const existingWrapper = tokens.findIndex((token) => decodeToken(token.value) === wrapperPath);
+ if (existingWrapper >= 0) {
+ tokens.splice(existingWrapper + 1, 0, { raw: COMMAND_TOKEN, value: COMMAND_TOKEN });
+ return { options: serialize(tokens), commandTokenAdded: true };
+ }
+
+ let insertion = 0;
+ while (insertion < tokens.length && isAssignment(tokens[insertion])) insertion++;
+ tokens.splice(insertion, 0,
+ { raw: wrapperPath, value: wrapperPath },
+ { raw: COMMAND_TOKEN, value: COMMAND_TOKEN },
+ );
+ return { options: serialize(tokens), commandTokenAdded: true };
+}
+
+export function installWrapperLaunchOption(options: string, wrapperPath = DEFAULT_WRAPPER_PATH) {
+ return installLaunchOption(options, wrapperPath);
+}
+
+export function removeWrapperLaunchOption(
+ options: string,
+ wrapperPath = DEFAULT_WRAPPER_PATH,
+ commandTokenAdded = false,
+): string {
+ const tokens = tokenize(options);
+ normalizeCommandTokens(tokens);
+ if (removeMatchingWrappers(tokens, (value) => isWrapperToken(value, wrapperPath)) && commandTokenAdded) {
+ const command = commandIndex(tokens);
+ if (command >= 0) tokens.splice(command, 1);
+ }
+ return serialize(tokens);
+}
+
+function encodeAssignmentValue(value: string): string {
+ return /^[A-Za-z0-9_./:+,%=-]+$/.test(value)
+ ? value
+ : `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
+}
+
+export function cleanupPluginAssignments(options: string): string {
+ const tokens = tokenize(options);
+ const command = commandIndex(tokens);
+ const prefixEnd = command >= 0 ? command : tokens.length;
+ return serialize(tokens.flatMap((token, i) => {
+ if (i >= prefixEnd || !isAssignment(token)) return [token];
+ const split = token.value.indexOf("=");
+ const key = token.value.slice(0, split);
+ if (key === "DXVK_CONFIG") {
+ const value = token.value.slice(split + 1).split(";").map((part) => part.trim())
+ .filter((part) => part && !DXVK_FRAME_RATE_SEGMENT.test(part)).join("; ");
+ return value ? [{ raw: `DXVK_CONFIG=${encodeAssignmentValue(value)}`, value: `DXVK_CONFIG=${value}` }] : [];
+ }
+ return MANAGED_ENV_KEYS.has(key) ? [] : [token];
+ }));
+}
+
+export function cleanupLegacyLaunchOptions(options: string): string {
+ const tokens = tokenize(options);
+ removeMatchingWrappers(tokens, isLegacyToken);
+ return serialize(tokens);
+}
+export const cleanupPluginLaunchOptions = (options: string, wrapperPath = DEFAULT_WRAPPER_PATH) =>
+ cleanupPluginAssignments(removeWrapperLaunchOption(options, wrapperPath));
+export const cleanupLegacyWrapper = cleanupPluginLaunchOptions;
+export function hasWrapperLaunchIntegration(options: string, wrapperPath = DEFAULT_WRAPPER_PATH): boolean {
+ const tokens = tokenize(options);
+ const command = commandIndex(tokens);
+ return command > 0 && tokens[command - 1].value === wrapperPath;
+}
+
+export function isWrapperIntegrationInstalled(
+ steam: SteamLaunchOptionsSnapshot,
+ _nonSteam: boolean,
+ wrapperPath = DEFAULT_WRAPPER_PATH,
+): boolean {
+ return hasWrapperLaunchIntegration(steam.options, wrapperPath);
+}
+
+const queues = new Map<string, Promise<unknown>>();
+function queued<T>(appId: number, nonSteam: boolean, operation: () => Promise<T>): Promise<T> {
+ const key = `${nonSteam ? "shortcut" : "app"}:${appId}`;
+ const previous = queues.get(key) || Promise.resolve();
+ const current = previous.catch(() => undefined).then(operation);
+ const cleanup = current.then(
+ () => { if (queues.get(key) === cleanup) queues.delete(key); },
+ () => { if (queues.get(key) === cleanup) queues.delete(key); },
+ );
+ queues.set(key, cleanup);
+ return current;
+}
+
+async function waitFor(
+ appId: number,
+ nonSteam: boolean,
+ matches: (value: SteamLaunchOptionsSnapshot) => boolean,
+ message: string,
+): Promise<SteamLaunchOptionsSnapshot> {
+ const deadline = Date.now() + 5000;
+ let lastError: Error | null = null;
+ while (Date.now() <= deadline) {
+ try {
+ const value = await readSteamLaunchOptions(appId, nonSteam);
+ if (matches(value)) return value;
+ } catch (error) { lastError = asError(error); }
+ if (Date.now() < deadline) await new Promise((resolve) => timer().set(resolve as () => void, 100));
+ }
+ throw lastError ? new Error(`${message}: ${lastError.message}`) : new Error(`${message} before the readback timeout`);
+}
+
+async function writeVerified(
+ appId: number,
+ nonSteam: boolean,
+ previous: string,
+ next: string,
+ write: (value: string) => Promise<void>,
+ message: string,
+): Promise<SteamLaunchOptionsSnapshot> {
+ try {
+ await write(next);
+ return await waitFor(appId, nonSteam, (value) => normalizeLaunchOptions(value.options) === normalizeLaunchOptions(next), message);
+ } catch (error) {
+ const failure = asError(error);
+ try {
+ await write(previous);
+ await waitFor(appId, nonSteam, (value) => normalizeLaunchOptions(value.options) === normalizeLaunchOptions(previous), "Steam did not restore the previous launch options");
+ } catch (rollback) {
+ throw new Error(`${failure.message}; rollback also failed: ${asError(rollback).message}`);
+ }
+ throw failure;
+ }
+}
+
+function writeOptions(appId: number, nonSteam: boolean, value: string): Promise<void> {
+ const setter = nonSteam ? apps()?.SetShortcutLaunchOptions : apps()?.SetAppLaunchOptions;
+ if (!setter) return Promise.reject(new Error(`Steam ${nonSteam ? "shortcut " : ""}launch options API is unavailable`));
+ return Promise.resolve(setter.call(apps(), appId, value));
+}
+
+export function updateSteamLaunchOptions(
+ appId: number,
+ nonSteam: boolean,
+ transform: (options: string) => string,
+): Promise<SteamLaunchOptionsSnapshot> {
+ return queued(appId, nonSteam, async () => {
+ const current = await readSteamLaunchOptions(appId, nonSteam);
+ const next = transform(current.options);
+ return next === current.options ? current : writeVerified(
+ appId, nonSteam, current.options, next,
+ (value) => writeOptions(appId, nonSteam, value),
+ "Steam did not accept the launch options",
+ );
+ });
+}
+
+export function installWrapperIntegration(
+ appId: number,
+ nonSteam: boolean,
+ wrapperPath: string,
+ commandTokenAdded = false,
+): Promise<WrapperIntegrationResult> {
+ return queued(appId, nonSteam, async () => {
+ const current = await readSteamLaunchOptions(appId, nonSteam);
+ const cleaned = cleanupPluginAssignments(cleanupLegacyLaunchOptions(current.options));
+ const alreadyInstalled = hasWrapperLaunchIntegration(current.options, wrapperPath);
+ const rewrite = installLaunchOption(cleaned, wrapperPath);
+ if (rewrite.options === current.options) return { snapshot: current, commandTokenAdded, changed: false };
+ const value = await writeVerified(
+ appId, nonSteam, current.options, rewrite.options,
+ (options) => writeOptions(appId, nonSteam, options),
+ "Steam did not accept the launch options",
+ );
+ return {
+ snapshot: value,
+ commandTokenAdded: alreadyInstalled ? commandTokenAdded : commandTokenAdded || rewrite.commandTokenAdded,
+ changed: true,
+ };
+ });
+}
+
+export function removeWrapperIntegration(
+ appId: number,
+ nonSteam: boolean,
+ wrapperPath: string,
+ commandTokenAdded = false,
+): Promise<SteamLaunchOptionsSnapshot> {
+ return queued(appId, nonSteam, async () => {
+ const current = await readSteamLaunchOptions(appId, nonSteam);
+ const next = cleanupPluginAssignments(removeWrapperLaunchOption(current.options, wrapperPath, commandTokenAdded));
+ return next === current.options ? current : writeVerified(
+ appId, nonSteam, current.options, next,
+ (options) => writeOptions(appId, nonSteam, options),
+ "Steam did not clean the launch options",
+ );
+ });
+}
+
+export const cleanupLegacySteamLaunchOptions = (
+ appId: number,
+ nonSteam: boolean,
+ wrapperPath = DEFAULT_WRAPPER_PATH,
+) => updateSteamLaunchOptions(appId, nonSteam, (options) => cleanupPluginLaunchOptions(options, wrapperPath));
+
+export const getDefaultWrapperPath = () => DEFAULT_WRAPPER_PATH;
diff --git a/src/utils/toastUtils.ts b/src/utils/toastUtils.ts
index dce0a59..c41f4c0 100644
--- a/src/utils/toastUtils.ts
+++ b/src/utils/toastUtils.ts
@@ -1,8 +1,3 @@
-/**
- * Centralized toast notification utilities
- * Provides consistent success/error messaging patterns
- */
-
import { toaster } from "@decky/api";
export interface ToastOptions {
@@ -10,106 +5,46 @@ export interface ToastOptions {
body: string;
}
-/**
- * Show a success toast notification
- */
-export function showSuccessToast(title: string, body: string): void {
- toaster.toast({
- title,
- body
- });
-}
-
-/**
- * Show an error toast notification
- */
-export function showErrorToast(title: string, body: string): void {
- toaster.toast({
- title,
- body
- });
-}
+const showToast = (title: string, body: string): void => {
+ toaster.toast({ title, body });
+};
+export const showSuccessToast = showToast;
+export const showErrorToast = showToast;
-/**
- * Standard success messages for common operations
- */
export const ToastMessages = {
INSTALL_SUCCESS: {
title: "Installation Complete",
- body: "lsfg-vk has been installed successfully"
+ body: "lsfg-vk has been installed successfully",
},
INSTALL_ERROR: {
title: "Installation Failed",
- body: "Unknown error occurred"
+ body: "Unknown error occurred",
},
UNINSTALL_SUCCESS: {
- title: "Uninstallation Complete",
- body: "lsfg-vk has been uninstalled successfully"
+ title: "Uninstallation Complete",
+ body: "lsfg-vk has been uninstalled successfully",
},
UNINSTALL_ERROR: {
title: "Uninstallation Failed",
- body: "Unknown error occurred"
+ body: "Unknown error occurred",
},
CONFIG_UPDATE_ERROR: {
title: "Update Failed",
- body: "Failed to update configuration"
+ body: "Failed to update configuration",
},
- CLIPBOARD_SUCCESS: {
- title: "Copied to Clipboard!",
- body: "Launch option ready to paste"
- },
- CLIPBOARD_ERROR: {
- title: "Copy Failed",
- body: "Unable to copy to clipboard"
- }
} as const;
-/**
- * Show a toast with dynamic error message
- */
-export function showErrorToastWithMessage(title: string, error: unknown): void {
- const errorMessage = error instanceof Error ? error.message : String(error);
- showErrorToast(title, errorMessage);
-}
+export const showErrorToastWithMessage = (title: string, error: unknown): void =>
+ showErrorToast(title, error instanceof Error ? error.message : String(error));
-/**
- * Show installation success toast
- */
-export function showInstallSuccessToast(): void {
+export const showInstallSuccessToast = (): void =>
showSuccessToast(ToastMessages.INSTALL_SUCCESS.title, ToastMessages.INSTALL_SUCCESS.body);
-}
-/**
- * Show installation error toast
- */
-export function showInstallErrorToast(error?: string): void {
+export const showInstallErrorToast = (error?: string): void =>
showErrorToast(ToastMessages.INSTALL_ERROR.title, error || ToastMessages.INSTALL_ERROR.body);
-}
-/**
- * Show uninstallation success toast
- */
-export function showUninstallSuccessToast(): void {
+export const showUninstallSuccessToast = (): void =>
showSuccessToast(ToastMessages.UNINSTALL_SUCCESS.title, ToastMessages.UNINSTALL_SUCCESS.body);
-}
-/**
- * Show uninstallation error toast
- */
-export function showUninstallErrorToast(error?: string): void {
+export const showUninstallErrorToast = (error?: string): void =>
showErrorToast(ToastMessages.UNINSTALL_ERROR.title, error || ToastMessages.UNINSTALL_ERROR.body);
-}
-
-/**
- * Show clipboard success toast
- */
-export function showClipboardSuccessToast(): void {
- showSuccessToast(ToastMessages.CLIPBOARD_SUCCESS.title, ToastMessages.CLIPBOARD_SUCCESS.body);
-}
-
-/**
- * Show clipboard error toast
- */
-export function showClipboardErrorToast(): void {
- showErrorToast(ToastMessages.CLIPBOARD_ERROR.title, ToastMessages.CLIPBOARD_ERROR.body);
-}