summaryrefslogtreecommitdiff
path: root/src/utils
diff options
context:
space:
mode:
Diffstat (limited to 'src/utils')
-rw-r--r--src/utils/gameTargets.ts75
-rw-r--r--src/utils/nowPlaying.ts86
-rw-r--r--src/utils/steamLaunchOptions.ts133
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",
);
});