diff options
Diffstat (limited to 'src/utils')
| -rw-r--r-- | src/utils/gameTargets.ts | 75 | ||||
| -rw-r--r-- | src/utils/nowPlaying.ts | 86 | ||||
| -rw-r--r-- | src/utils/steamLaunchOptions.ts | 133 |
3 files changed, 217 insertions, 77 deletions
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 index 65541d8..83c46f2 100644 --- a/src/utils/steamLaunchOptions.ts +++ b/src/utils/steamLaunchOptions.ts @@ -24,13 +24,12 @@ export interface SteamLaunchOptionsSnapshot { appId: number; nonSteam: boolean; options: string; - target: string; details: SteamAppDetails; } export interface WrapperIntegrationResult { snapshot: SteamLaunchOptionsSnapshot; - originalExecutable?: string; commandTokenAdded: boolean; + changed: boolean; } function asError(error: unknown): Error { @@ -58,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, }; } @@ -155,7 +153,25 @@ function tokenize(options: string): LaunchToken[] { } const serialize = (tokens: readonly LaunchToken[]) => tokens.map(({ raw }) => raw).join(" "); -const commandIndex = (tokens: readonly LaunchToken[]) => tokens.findIndex((token) => token.raw.toLowerCase() === COMMAND_TOKEN); +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); @@ -172,12 +188,12 @@ function removeMatchingWrappers(tokens: LaunchToken[], predicate: (value: string return true; } -export function installWrapperLaunchOption( +function installLaunchOption( options: string, wrapperPath = DEFAULT_WRAPPER_PATH, - allowCommandArgs = false, ) { const tokens = tokenize(options); + normalizeCommandTokens(tokens); removeMatchingWrappers(tokens, isLegacyToken); let command = commandIndex(tokens); if (command >= 0) { @@ -187,11 +203,15 @@ export function installWrapperLaunchOption( 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++; - if (!allowCommandArgs && insertion < tokens.length && !tokens[insertion].value.startsWith("-")) { - throw new Error("Launch options do not contain %command%; refusing to guess a launcher command"); - } tokens.splice(insertion, 0, { raw: wrapperPath, value: wrapperPath }, { raw: COMMAND_TOKEN, value: COMMAND_TOKEN }, @@ -199,12 +219,17 @@ export function installWrapperLaunchOption( 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); @@ -249,6 +274,14 @@ export function hasWrapperLaunchIntegration(options: string, wrapperPath = DEFAU 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}`; @@ -286,18 +319,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}`); } @@ -305,19 +336,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, @@ -329,7 +352,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", ); }); @@ -340,43 +363,23 @@ export function installWrapperIntegration( nonSteam: boolean, wrapperPath: string, commandTokenAdded = false, - transport: "host" | "flatpak" = "host", ): Promise<WrapperIntegrationResult> { return queued(appId, nonSteam, async () => { - let current = await readSteamLaunchOptions(appId, nonSteam); - if (nonSteam && transport === "flatpak") { - 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); - if (cleaned !== current.options) { - current = await writeVerified( - appId, true, current.options, cleaned, - (value) => writeOptions(appId, true, value), readOptions, - "Steam did not accept shortcut launch options", - ); - } - if (current.target === wrapperPath) return { snapshot: current, commandTokenAdded: false }; - const originalExecutable = current.target; - const value = await writeVerified( - appId, true, originalExecutable, wrapperPath, - (target) => writeTarget(appId, target), readTarget, - "Steam did not accept the shortcut Target", - ); - return { snapshot: value, originalExecutable, commandTokenAdded: false }; - } - + const current = await readSteamLaunchOptions(appId, nonSteam); const cleaned = cleanupPluginAssignments(cleanupLegacyLaunchOptions(current.options)); const alreadyInstalled = hasWrapperLaunchIntegration(current.options, wrapperPath); - const rewrite = installWrapperLaunchOption(cleaned, wrapperPath, nonSteam); - if (rewrite.options === current.options) return { snapshot: current, commandTokenAdded }; + 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), readOptions, + (options) => writeOptions(appId, nonSteam, options), "Steam did not accept the launch options", ); - return { snapshot: value, commandTokenAdded: alreadyInstalled ? commandTokenAdded : rewrite.commandTokenAdded }; + return { + snapshot: value, + commandTokenAdded: alreadyInstalled ? commandTokenAdded : commandTokenAdded || rewrite.commandTokenAdded, + changed: true, + }; }); } @@ -384,38 +387,14 @@ export function removeWrapperIntegration( appId: number, nonSteam: boolean, wrapperPath: string, - originalExecutable?: string, commandTokenAdded = false, - transport: "host" | "flatpak" = "host", ): Promise<SteamLaunchOptionsSnapshot> { return queued(appId, nonSteam, async () => { - let current = await readSteamLaunchOptions(appId, nonSteam); - if (nonSteam && transport === "flatpak") { - if (!originalExecutable || isWrapperToken(originalExecutable, wrapperPath)) { - throw new Error("Original shortcut Target is unavailable; refusing to overwrite the current Target"); - } - if (current.target !== wrapperPath && current.target !== originalExecutable) { - throw new Error("Shortcut Target changed externally; refusing to restore it"); - } - const cleaned = cleanupPluginLaunchOptions(current.options, wrapperPath); - if (cleaned !== current.options) { - current = await writeVerified( - appId, true, current.options, cleaned, - (value) => writeOptions(appId, true, value), readOptions, - "Steam did not clean shortcut launch options", - ); - } - if (current.target === originalExecutable) return current; - 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", ); }); |
