summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorxXJSONDeruloXx <danielhimebauch@gmail.com>2026-09-11 09:05:14 -0400
committerxXJSONDeruloXx <danielhimebauch@gmail.com>2026-09-11 09:05:14 -0400
commitd2bfdafa92f3d31cc5392bf8a5a1f1df5c2358ce (patch)
tree102c12dc843ce64dd21599c2cf8ae3d5a3bd0104
parent904e2e6131071c3b132d3148947b613c2830b1bb (diff)
downloaddecky-lsfg-vk-d2bfdafa92f3d31cc5392bf8a5a1f1df5c2358ce.tar.gz
decky-lsfg-vk-d2bfdafa92f3d31cc5392bf8a5a1f1df5c2358ce.zip
flatpak correctness, ui alignment, tests
-rw-r--r--justfile2
-rw-r--r--package.json2
-rw-r--r--py_modules/lsfg_vk/flatpak_profile_service.py12
-rw-r--r--py_modules/lsfg_vk/flatpak_service.py30
-rw-r--r--src/api/lsfgApi.ts1
-rw-r--r--src/components/CollapsibleItemGroup.tsx79
-rw-r--r--src/components/Content.tsx56
-rw-r--r--src/components/FlatpakNowPlayingTab.tsx26
-rw-r--r--src/components/FlatpakTab.tsx79
-rw-r--r--src/components/GameConfigurationSelector.tsx85
-rw-r--r--src/hooks/useFlatpakConfiguration.ts59
-rw-r--r--src/utils/nowPlaying.ts54
-rw-r--r--tests/nowPlaying.test.ts76
-rw-r--r--tests/test_flatpak_profile_service.py25
-rw-r--r--tests/test_flatpak_service.py6
15 files changed, 452 insertions, 140 deletions
diff --git a/justfile b/justfile
index b608987..c935b76 100644
--- a/justfile
+++ b/justfile
@@ -8,7 +8,7 @@ deploy:
./scripts/deploy-to-deck.sh
test:
- node --experimental-strip-types --test tests/steamLaunchOptions.test.ts
+ node --experimental-strip-types --test tests/steamLaunchOptions.test.ts tests/nowPlaying.test.ts
python3.12 -m unittest discover -s tests -p 'test_*.py'
watch:
diff --git a/package.json b/package.json
index 4a1780a..f1bcda0 100644
--- a/package.json
+++ b/package.json
@@ -6,7 +6,7 @@
"scripts": {
"build": "rollup -c",
"watch": "rollup -c -w",
- "test": "node --experimental-strip-types --test tests/steamLaunchOptions.test.ts && python3 -m unittest discover -s tests -p 'test_*.py'"
+ "test": "node --experimental-strip-types --test tests/steamLaunchOptions.test.ts tests/nowPlaying.test.ts && python3 -m unittest discover -s tests -p 'test_*.py'"
},
"repository": {
"type": "git",
diff --git a/py_modules/lsfg_vk/flatpak_profile_service.py b/py_modules/lsfg_vk/flatpak_profile_service.py
index ddec116..9eaf9c7 100644
--- a/py_modules/lsfg_vk/flatpak_profile_service.py
+++ b/py_modules/lsfg_vk/flatpak_profile_service.py
@@ -202,7 +202,7 @@ class FlatpakProfileService:
result = self.configuration_service.update_flatpak_config(app_id, config)
if not result.get("success"):
raise RuntimeError(result.get("error") or "Could not update Flatpak profile")
- return self.get_app(app_id)
+ return result
except Exception as error:
return {
"success": False,
@@ -374,8 +374,16 @@ class FlatpakProfileService:
"app_id": fields[0],
"active": active,
"pid": fields[2].strip() if len(fields) > 2 else "",
+ "start_time": self.flatpak_service._process_start_time(
+ fields[2].strip() if len(fields) > 2 else ""
+ ),
})
- running.sort(key=lambda item: (not item["active"], item["app_id"]))
+ running.sort(key=lambda item: (
+ not item["active"],
+ -(item["start_time"] if isinstance(item["start_time"], int) else -1),
+ -int(item["pid"]) if str(item["pid"]).isdigit() else 1,
+ item["app_id"],
+ ))
return {"success": True, "message": "", "error": None, "apps": running}
except Exception as error:
return {"success": False, "message": "", "error": str(error), "apps": []}
diff --git a/py_modules/lsfg_vk/flatpak_service.py b/py_modules/lsfg_vk/flatpak_service.py
index 4f04382..f2ed90c 100644
--- a/py_modules/lsfg_vk/flatpak_service.py
+++ b/py_modules/lsfg_vk/flatpak_service.py
@@ -44,6 +44,13 @@ class FlatpakService(BaseService):
env = os.environ.copy()
env.pop("LD_LIBRARY_PATH", None)
env["HOME"] = str(self.user_home)
+ try:
+ user_id = self.user_home.stat().st_uid
+ except OSError:
+ user_id = None
+ if user_id is not None:
+ env["XDG_RUNTIME_DIR"] = f"/run/user/{user_id}"
+ env["DBUS_SESSION_BUS_ADDRESS"] = f"unix:path=/run/user/{user_id}/bus"
path = [entry for entry in env.get("PATH", "").split(":") if entry]
for entry in ("/usr/bin", "/usr/local/bin", "/bin"):
if entry not in path:
@@ -196,6 +203,29 @@ class FlatpakService(BaseService):
def _sha256(content: bytes) -> str:
return hashlib.sha256(content).hexdigest()
+ @staticmethod
+ def _parse_process_start_time(stat_content: str) -> Optional[int]:
+ closing_command = stat_content.rfind(")")
+ if closing_command < 0:
+ return None
+ fields = stat_content[closing_command + 2:].split()
+ if len(fields) <= 19:
+ return None
+ try:
+ return int(fields[19])
+ except (TypeError, ValueError):
+ return None
+
+ @classmethod
+ def _process_start_time(cls, pid: str) -> Optional[int]:
+ if not isinstance(pid, str) or re.fullmatch(r"[0-9]+", pid) is None:
+ return None
+ try:
+ stat_content = (Path("/proc") / pid / "stat").read_text(encoding="utf-8")
+ except OSError:
+ return None
+ return cls._parse_process_start_time(stat_content)
+
def _snapshot_override(self, app_id: str) -> tuple[bool, bytes]:
path = self._override_path(app_id)
if path.is_symlink():
diff --git a/src/api/lsfgApi.ts b/src/api/lsfgApi.ts
index 3d4890e..f3b7fa1 100644
--- a/src/api/lsfgApi.ts
+++ b/src/api/lsfgApi.ts
@@ -118,6 +118,7 @@ export interface RunningFlatpakApp {
app_id: string;
active: boolean;
pid?: string;
+ start_time?: number | null;
}
export interface FlatpakAppsResult extends ApiResult {
diff --git a/src/components/CollapsibleItemGroup.tsx b/src/components/CollapsibleItemGroup.tsx
new file mode 100644
index 0000000..a66a8ba
--- /dev/null
+++ b/src/components/CollapsibleItemGroup.tsx
@@ -0,0 +1,79 @@
+import { ButtonItem, Field, PanelSectionRow } from "@decky/ui";
+import { type RefObject } from "react";
+import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri";
+
+export interface CollapsibleItem {
+ id: string;
+ label: string;
+ description: string;
+}
+
+export const collapsibleItemGroupStyles = `
+ .LSFG_GameGroupCollapseButton_Container > div > div > div > button,
+ .LSFG_GameGroupCollapseButton_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_GameGroupCollapseButton_Container svg {
+ display: block;
+ margin: 0;
+ }
+`;
+
+interface Props {
+ title: string;
+ items: CollapsibleItem[];
+ collapsed: boolean;
+ onToggle: () => void;
+ onSelect: (id: string) => void;
+ toggleRef?: RefObject<HTMLDivElement>;
+}
+
+export function CollapsibleItemGroup({
+ title,
+ items,
+ collapsed,
+ onToggle,
+ onSelect,
+ toggleRef,
+}: Props) {
+ if (items.length === 0) return null;
+
+ return (
+ <>
+ <PanelSectionRow>
+ <Field label={`${title} (${items.length})`} bottomSeparator="none" />
+ </PanelSectionRow>
+ <PanelSectionRow>
+ <div
+ ref={toggleRef}
+ className="LSFG_GameGroupCollapseButton_Container"
+ style={{ marginTop: "-2px", marginBottom: "4px" }}
+ >
+ <ButtonItem
+ layout="below"
+ bottomSeparator={collapsed ? "standard" : "none"}
+ onClick={onToggle}
+ >
+ {collapsed ? <RiArrowDownSFill /> : <RiArrowUpSFill />}
+ </ButtonItem>
+ </div>
+ </PanelSectionRow>
+ {!collapsed && items.map((item) => (
+ <PanelSectionRow key={item.id}>
+ <Field
+ label={item.label}
+ description={item.description}
+ onActivate={() => onSelect(item.id)}
+ highlightOnFocus
+ />
+ </PanelSectionRow>
+ ))}
+ </>
+ );
+}
diff --git a/src/components/Content.tsx b/src/components/Content.tsx
index ce3c018..0d49f89 100644
--- a/src/components/Content.tsx
+++ b/src/components/Content.tsx
@@ -1,4 +1,4 @@
-import { Tabs } from "@decky/ui";
+import { Field, PanelSection, PanelSectionRow, Tabs } from "@decky/ui";
import { useEffect, useRef, useState } from "react";
import { FaCube, FaFileAlt, FaGamepad, FaList, FaTools } from "react-icons/fa";
import { ConfigurationData } from "../config/configSchema";
@@ -6,6 +6,7 @@ import { useFlatpakConfiguration } from "../hooks/useFlatpakConfiguration";
import { useGameConfiguration } from "../hooks/useGameConfiguration";
import { useInstallation } from "../hooks/useLsfgHooks";
import { tabStyles } from "../styles";
+import { resolveNowPlayingTarget } from "../utils/nowPlaying";
import { ConfigFileTab } from "./ConfigFileTab";
import { ConfigurationTab } from "./ConfigurationTab";
import { FlatpakNowPlayingTab } from "./FlatpakNowPlayingTab";
@@ -80,10 +81,13 @@ export function Content() {
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;
+ const nowPlayingTarget = resolveNowPlayingTarget(runningGame, runningFlatpak);
+ const hasNowPlaying = Boolean(nowPlayingTarget);
+ const runningWorkload = nowPlayingTarget
+ ? nowPlayingTarget.kind === "flatpak"
+ ? `flatpak:${nowPlayingTarget.app.app_id}`
+ : `steam:${nowPlayingTarget.game.appid}`
+ : null;
useEffect(() => {
if (!setupComplete) {
@@ -136,26 +140,27 @@ export function Content() {
/>
);
- const nowPlaying = runningGame?.configured ? (
+ const nowPlaying = nowPlayingTarget?.kind === "steam" ? (
<NowPlayingTab
- game={runningGame}
+ game={nowPlayingTarget.game}
config={runningConfig}
onConfigChange={async (field, value) => {
- await saveFor(runningGame.appid, { ...runningConfig, [field]: value }, true);
+ await saveFor(nowPlayingTarget.game.appid, { ...runningConfig, [field]: value }, true);
}}
/>
- ) : runningFlatpak ? (
+ ) : nowPlayingTarget?.kind === "flatpak" ? (
<FlatpakNowPlayingTab
- app={runningFlatpak}
- busy={flatpak.busyAppId === runningFlatpak.app_id}
+ app={nowPlayingTarget.app}
+ launcher={nowPlayingTarget.launcher}
onConfigChange={flatpak.updateConfig}
- onWorkaroundChange={flatpak.updateWorkarounds}
/>
- ) : null;
+ ) : (
+ <NowPlayingTabPlaceholder />
+ );
const tabs = setupComplete
? [
- ...(nowPlaying ? [{ id: "NowPlaying", title: tabIcons.nowPlaying, content: nowPlaying }] : []),
+ { id: "NowPlaying", title: tabIcons.nowPlaying, content: nowPlaying },
{
id: "Games",
title: tabIcons.games,
@@ -198,13 +203,34 @@ export function Content() {
]
: [{ id: "Setup", title: tabIcons.setup, content: setup }];
+ const availableTabIds = new Set(tabs.map(({ id }) => id));
+ const activeTab = availableTabIds.has(tab) ? tab : setupComplete ? "Games" : "Setup";
+
return (
<div
className="lsfg-vk-tabs"
style={{ height: "95%", width: "300px", position: "fixed", marginTop: "-12px", overflow: "hidden" }}
>
<style>{tabStyles}</style>
- <Tabs activeTab={!showDebugTab && tab === "ConfigFile" ? "Games" : tab} onShowTab={setTab} tabs={tabs} />
+ <Tabs
+ activeTab={activeTab}
+ onShowTab={(nextTab: string) => {
+ if (availableTabIds.has(nextTab)) setTab(nextTab);
+ }}
+ tabs={tabs}
+ />
+ </div>
+ );
+}
+
+function NowPlayingTabPlaceholder() {
+ return (
+ <div>
+ <PanelSection title="Now Playing">
+ <PanelSectionRow>
+ <Field label="Nothing running" description="Start an enabled Steam, non-Steam, or Flatpak target to configure it here." />
+ </PanelSectionRow>
+ </PanelSection>
</div>
);
}
diff --git a/src/components/FlatpakNowPlayingTab.tsx b/src/components/FlatpakNowPlayingTab.tsx
index 9e5a0db..9623b11 100644
--- a/src/components/FlatpakNowPlayingTab.tsx
+++ b/src/components/FlatpakNowPlayingTab.tsx
@@ -1,17 +1,16 @@
import { Field, Focusable, PanelSection, PanelSectionRow } from "@decky/ui";
-import type { FlatpakApp, LsfgConfig, WorkaroundState } from "../api/lsfgApi";
+import type { FlatpakApp, LsfgConfig } from "../api/lsfgApi";
+import type { GameTarget } from "../hooks/useGameConfiguration";
import { ConfigurationSection } from "./ConfigurationSection";
-import { FlatpakWorkaroundsSection } from "./FlatpakWorkaroundsSection";
import { FpsMultiplierControl } from "./FpsMultiplierControl";
interface Props {
app: FlatpakApp;
- busy: boolean;
+ launcher: GameTarget | null;
onConfigChange: (appId: string, config: LsfgConfig) => Promise<boolean>;
- onWorkaroundChange: (appId: string, state: WorkaroundState) => Promise<boolean>;
}
-export function FlatpakNowPlayingTab({ app, busy, onConfigChange, onWorkaroundChange }: Props) {
+export function FlatpakNowPlayingTab({ app, launcher, onConfigChange }: Props) {
if (!app.config) return null;
const changeConfig = async (
field: keyof LsfgConfig,
@@ -24,16 +23,21 @@ export function FlatpakNowPlayingTab({ app, busy, onConfigChange, onWorkaroundCh
<Focusable>
<PanelSection title="Now Playing">
<PanelSectionRow>
- <Field label={app.app_name} description={`Flatpak · ${app.app_id}`} />
+ <Field
+ label={launcher?.name || app.app_name}
+ description={launcher
+ ? `${launcher.nonSteam ? "Steam shortcut" : "Steam"} · Running in ${app.app_name} · Flatpak`
+ : `Flatpak · ${app.app_id}`}
+ />
</PanelSectionRow>
+ {launcher && (
+ <PanelSectionRow>
+ <Field label="Controls" description={`${app.app_name} profile · ${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
index c5e27f5..31bfc2d 100644
--- a/src/components/FlatpakTab.tsx
+++ b/src/components/FlatpakTab.tsx
@@ -1,7 +1,8 @@
import { ButtonItem, DialogButton, Field, Focusable, PanelSection, PanelSectionRow, gamepadDialogClasses } from "@decky/ui";
-import { useCallback, useMemo, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { FaArrowLeft } from "react-icons/fa";
import type { FlatpakApp, LsfgConfig, WorkaroundState } from "../api/lsfgApi";
+import { CollapsibleItemGroup, collapsibleItemGroupStyles } from "./CollapsibleItemGroup";
import { ConfigurationSection } from "./ConfigurationSection";
import { FlatpakWorkaroundsSection } from "./FlatpakWorkaroundsSection";
import { FpsMultiplierControl } from "./FpsMultiplierControl";
@@ -19,6 +20,24 @@ interface Props {
onWorkaroundChange: (appId: string, state: WorkaroundState) => Promise<boolean>;
}
+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 {}
+ }, [collapsed, key]);
+
+ return [collapsed, () => setCollapsed((value) => !value)] as const;
+}
+
export function FlatpakTab({
apps,
runningApp,
@@ -36,31 +55,47 @@ export function FlatpakTab({
[apps, selectedAppId],
);
const close = useCallback(() => setSelectedAppId(null), []);
+ const [enabledCollapsed, toggleEnabled] = usePersistentCollapsed("lsfg-flatpak-enabled-collapsed-v1");
+ const [availableCollapsed, toggleAvailable] = usePersistentCollapsed("lsfg-flatpak-available-collapsed-v1");
+ const enabledToggleRef = useRef<HTMLDivElement>(null);
+
+ const enabledApps = useMemo(
+ () => apps.filter((app) => app.enabled).sort((a, b) => a.app_name.localeCompare(b.app_name)),
+ [apps],
+ );
+ const availableApps = useMemo(
+ () => apps.filter((app) => !app.enabled).sort((a, b) => a.app_name.localeCompare(b.app_name)),
+ [apps],
+ );
+ const itemFor = (app: FlatpakApp) => ({
+ id: app.app_id,
+ label: app.app_name,
+ description: `${app.app_id} · ${app.prepared && !app.owned ? "Prepared externally" : "Available"}`,
+ });
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>
- );
- })}
+ <style>{collapsibleItemGroupStyles}</style>
+ <CollapsibleItemGroup
+ title="Enabled"
+ items={enabledApps.map((app) => ({
+ id: app.app_id,
+ label: app.app_name,
+ description: `${app.app_id}${app.app_id === runningApp?.app_id ? " · Running" : ""}`,
+ }))}
+ collapsed={enabledCollapsed}
+ onToggle={toggleEnabled}
+ onSelect={setSelectedAppId}
+ toggleRef={enabledToggleRef}
+ />
+ <CollapsibleItemGroup
+ title="Available"
+ items={availableApps.map(itemFor)}
+ collapsed={availableCollapsed}
+ onToggle={toggleAvailable}
+ onSelect={setSelectedAppId}
+ />
{apps.length === 0 && !loading && (
<PanelSectionRow>
<Field label="No Flatpak applications found" />
diff --git a/src/components/GameConfigurationSelector.tsx b/src/components/GameConfigurationSelector.tsx
index 1f0bc73..ee87423 100644
--- a/src/components/GameConfigurationSelector.tsx
+++ b/src/components/GameConfigurationSelector.tsx
@@ -1,7 +1,7 @@
import { ButtonItem, ConfirmModal, Field, PanelSectionRow, showModal } from "@decky/ui";
-import { useEffect, useRef, useState, type RefObject } from "react";
-import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri";
+import { useEffect, useRef, useState } from "react";
import { GameTarget } from "../hooks/useGameConfiguration";
+import { CollapsibleItemGroup, collapsibleItemGroupStyles } from "./CollapsibleItemGroup";
interface Props {
targets: GameTarget[];
@@ -38,57 +38,6 @@ function targetDescription(game: GameTarget): string {
return game.nonSteam ? "Non-Steam" : "Steam";
}
-function GameGroup({
- title,
- games,
- collapsed,
- onToggle,
- onSelect,
- toggleRef,
-}: {
- title: string;
- games: GameTarget[];
- collapsed: boolean;
- onToggle: () => void;
- onSelect: (appid: string) => void;
- toggleRef?: RefObject<HTMLDivElement>;
-}) {
- if (games.length === 0) return null;
-
- return (
- <>
- <PanelSectionRow>
- <Field label={title + " (" + games.length + ")"} bottomSeparator="none" />
- </PanelSectionRow>
- <PanelSectionRow>
- <div
- ref={toggleRef}
- className="LSFG_GameGroupCollapseButton_Container"
- style={{ marginTop: "-2px", marginBottom: "4px" }}
- >
- <ButtonItem
- layout="below"
- bottomSeparator={collapsed ? "standard" : "none"}
- onClick={onToggle}
- >
- {collapsed ? <RiArrowDownSFill /> : <RiArrowUpSFill />}
- </ButtonItem>
- </div>
- </PanelSectionRow>
- {!collapsed && games.map((game) => (
- <PanelSectionRow key={game.appid}>
- <Field
- label={game.name}
- description={targetDescription(game)}
- onActivate={() => onSelect(game.appid)}
- highlightOnFocus
- />
- </PanelSectionRow>
- ))}
- </>
- );
-}
-
export function GameConfigurationSelector({
targets,
runningGame,
@@ -105,6 +54,11 @@ export function GameConfigurationSelector({
});
const enabledGames = sortGames(targets.filter((game) => game.configured));
const availableGames = sortGames(targets.filter((game) => !game.configured));
+ const toItem = (game: GameTarget) => ({
+ id: game.appid,
+ label: game.name,
+ description: targetDescription(game),
+ });
const [enabledCollapsed, toggleEnabled] = usePersistentCollapsed(ENABLED_COLLAPSED_KEY);
const [availableCollapsed, toggleAvailable] = usePersistentCollapsed(AVAILABLE_COLLAPSED_KEY);
const enabledToggleRef = useRef<HTMLDivElement>(null);
@@ -146,39 +100,24 @@ export function GameConfigurationSelector({
return (
<>
<style>
- {`
- .LSFG_GameGroupCollapseButton_Container > div > div > div > button,
- .LSFG_GameGroupCollapseButton_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_GameGroupCollapseButton_Container svg {
- display: block;
- margin: 0;
- }
- `}
+ {collapsibleItemGroupStyles}
</style>
{targets.length === 0 && (
<PanelSectionRow>
<Field label="No installed games" description="Steam has not reported any eligible games" />
</PanelSectionRow>
)}
- <GameGroup
+ <CollapsibleItemGroup
title="Enabled"
- games={enabledGames}
+ items={enabledGames.map(toItem)}
collapsed={enabledCollapsed}
onToggle={toggleEnabled}
onSelect={onSelect}
toggleRef={enabledToggleRef}
/>
- <GameGroup
+ <CollapsibleItemGroup
title="Available"
- games={availableGames}
+ items={availableGames.map(toItem)}
collapsed={availableCollapsed}
onToggle={toggleAvailable}
onSelect={onSelect}
diff --git a/src/hooks/useFlatpakConfiguration.ts b/src/hooks/useFlatpakConfiguration.ts
index d5e6f7e..ce881f0 100644
--- a/src/hooks/useFlatpakConfiguration.ts
+++ b/src/hooks/useFlatpakConfiguration.ts
@@ -11,8 +11,16 @@ import {
type RunningFlatpakApp,
type WorkaroundState,
} from "../api/lsfgApi";
+import { selectMostRecentRunningFlatpak } from "../utils/nowPlaying";
import { showErrorToast } from "../utils/toastUtils";
+type FlatpakOperationResult = {
+ success: boolean;
+ error?: string | null;
+ config?: LsfgConfig | null;
+ state?: WorkaroundState | null;
+};
+
export function useFlatpakConfiguration(enabled: boolean) {
const [apps, setApps] = useState<FlatpakApp[]>([]);
const [runningApps, setRunningApps] = useState<RunningFlatpakApp[]>([]);
@@ -58,39 +66,62 @@ export function useFlatpakConfiguration(enabled: boolean) {
return () => window.clearInterval(interval);
}, [enabled, pollRunning]);
- const operate = useCallback(async (appId: string, operation: () => Promise<{ success: boolean; error?: string | null }>) => {
- if (busyAppId) return false;
+ const operate = useCallback(async (
+ appId: string,
+ operation: () => Promise<FlatpakOperationResult>,
+ refresh = true,
+ ): Promise<FlatpakOperationResult> => {
+ if (busyAppId) return { success: 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;
+ if (refresh) {
+ await reload();
+ await pollRunning();
+ }
+ return result;
} catch (error) {
showErrorToast("Flatpak operation failed", error instanceof Error ? error.message : String(error));
- return false;
+ return { success: false, error: error instanceof Error ? error.message : String(error) };
} 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 enableApp = useCallback(async (appId: string) => (
+ await operate(appId, () => enableFlatpakApp(appId))
+ ).success, [operate]);
+ const removeApp = useCallback(async (appId: string) => (
+ await operate(appId, () => removeFlatpakApp(appId))
+ ).success, [operate]);
const updateConfig = useCallback(
- (appId: string, config: LsfgConfig) => operate(appId, () => updateFlatpakConfig(appId, config)),
+ async (appId: string, config: LsfgConfig) => {
+ const result = await operate(appId, () => updateFlatpakConfig(appId, config), false);
+ if (result.success) {
+ setApps((current) => current.map((app) => (
+ app.app_id === appId ? { ...app, config: result.config || config } : app
+ )));
+ }
+ return result.success;
+ },
[operate],
);
const updateWorkarounds = useCallback(
- (appId: string, state: WorkaroundState) => operate(appId, () => setFlatpakWorkaroundState(appId, state)),
+ async (appId: string, state: WorkaroundState) => {
+ const result = await operate(appId, () => setFlatpakWorkaroundState(appId, state), false);
+ if (result.success) {
+ setApps((current) => current.map((app) => (
+ app.app_id === appId ? { ...app, workarounds: result.state || state } : app
+ )));
+ }
+ return result.success;
+ },
[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;
+ return selectMostRecentRunningFlatpak(apps, runningApps);
}, [apps, runningApps]);
return {
diff --git a/src/utils/nowPlaying.ts b/src/utils/nowPlaying.ts
new file mode 100644
index 0000000..a207321
--- /dev/null
+++ b/src/utils/nowPlaying.ts
@@ -0,0 +1,54 @@
+import type { FlatpakApp, RunningFlatpakApp } from "../api/lsfgApi";
+import type { GameTarget } from "../hooks/useGameConfiguration";
+
+export type NowPlayingTarget =
+ | {
+ kind: "flatpak";
+ app: FlatpakApp;
+ launcher: GameTarget | null;
+ }
+ | {
+ kind: "steam";
+ 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;
+}
+
+export function selectMostRecentRunningFlatpak(
+ apps: FlatpakApp[],
+ runningApps: RunningFlatpakApp[],
+): FlatpakApp | null {
+ const candidates = runningApps
+ .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)
+ .sort((a, b) => {
+ if (a.running.active !== b.running.active) return a.running.active ? -1 : 1;
+ const startDifference = numericValue(b.running.start_time) - numericValue(a.running.start_time);
+ if (startDifference !== 0) return startDifference;
+ const pidDifference = numericPid(b.running.pid) - numericPid(a.running.pid);
+ if (pidDifference !== 0) return pidDifference;
+ return a.running.app_id.localeCompare(b.running.app_id);
+ });
+
+ return candidates[0]?.app || null;
+}
+
+export function resolveNowPlayingTarget(
+ runningGame: GameTarget | null,
+ runningFlatpak: FlatpakApp | null,
+): NowPlayingTarget | null {
+ if (runningFlatpak) {
+ return { kind: "flatpak", app: runningFlatpak, launcher: runningGame };
+ }
+ if (runningGame?.configured) return { kind: "steam", game: runningGame };
+ return null;
+}
diff --git a/tests/nowPlaying.test.ts b/tests/nowPlaying.test.ts
new file mode 100644
index 0000000..3f0d891
--- /dev/null
+++ b/tests/nowPlaying.test.ts
@@ -0,0 +1,76 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { resolveNowPlayingTarget, selectMostRecentRunningFlatpak } from "../src/utils/nowPlaying.ts";
+
+const flatpak = (app_id: string, app_name = app_id) => ({
+ app_id,
+ app_name,
+ runtime_ready: true,
+ prepared: true,
+ owned: true,
+ enabled: true,
+ profile: `flatpak:${app_id}`,
+ workarounds: {
+ dxvkFrameRate: 0,
+ disableGamescopeWsi: true,
+ disableHdr: true,
+ disableSteamdeckMode: false,
+ disableVkbasalt: false,
+ enableZink: false,
+ },
+});
+
+const game = (nonSteam = true, configured = true) => ({
+ appid: "123456",
+ name: nonSteam ? "1080 Snowboarding" : "Native Game",
+ nonSteam,
+ configured,
+});
+
+test("selects the newest active managed Flatpak", () => {
+ const apps = [flatpak("org.example.old"), flatpak("org.example.new")];
+ const running = [
+ { app_id: "org.example.old", active: true, pid: "100", start_time: 500 },
+ { app_id: "org.example.new", active: true, pid: "200", start_time: 600 },
+ ];
+
+ assert.equal(selectMostRecentRunningFlatpak(apps, running)?.app_id, "org.example.new");
+});
+
+test("prefers active Flatpak status before process age", () => {
+ const apps = [flatpak("org.example.running"), flatpak("org.example.active")];
+ const running = [
+ { app_id: "org.example.running", active: false, pid: "900", start_time: 900 },
+ { app_id: "org.example.active", active: true, pid: "100", start_time: 100 },
+ ];
+
+ assert.equal(selectMostRecentRunningFlatpak(apps, running)?.app_id, "org.example.active");
+});
+
+test("Flatpak runtime wins while a Steam shortcut is running", () => {
+ const target = resolveNowPlayingTarget(game(true), flatpak("org.libretro.RetroArch", "RetroArch"));
+
+ assert.equal(target?.kind, "flatpak");
+ assert.equal(target?.kind === "flatpak" ? target.launcher?.name : null, "1080 Snowboarding");
+});
+
+test("Flatpak runtime wins over a native Steam game", () => {
+ assert.equal(resolveNowPlayingTarget(game(false), flatpak("org.example.Game"))?.kind, "flatpak");
+});
+
+test("direct Flatpak launch creates a Flatpak Now Playing target", () => {
+ const target = resolveNowPlayingTarget(null, flatpak("org.example.Game"));
+
+ assert.equal(target?.kind, "flatpak");
+ assert.equal(target?.kind === "flatpak" ? target.launcher : null, null);
+});
+
+test("configured Steam target remains the fallback", () => {
+ const target = resolveNowPlayingTarget(game(false), null);
+
+ assert.equal(target?.kind, "steam");
+});
+
+test("unconfigured Steam target has no Now Playing controls", () => {
+ assert.equal(resolveNowPlayingTarget(game(false, false), null), null);
+});
diff --git a/tests/test_flatpak_profile_service.py b/tests/test_flatpak_profile_service.py
index 8d58413..2b9933b 100644
--- a/tests/test_flatpak_profile_service.py
+++ b/tests/test_flatpak_profile_service.py
@@ -14,6 +14,7 @@ sys.modules.setdefault(
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "py_modules"))
from lsfg_vk.configuration import ConfigurationService
+from lsfg_vk.flatpak_service import FlatpakService
from lsfg_vk.flatpak_profile_service import FlatpakProfileService
@@ -26,6 +27,7 @@ class FakeFlatpakService:
self.state = {"version": 2, "plugin_owned_branches": [], "prepared_apps": {}}
self.commands = []
self.running = ""
+ self.start_times = {}
def _read_state(self):
return self.state
@@ -49,6 +51,9 @@ class FakeFlatpakService:
return False, b""
return True, path.read_bytes()
+ def _process_start_time(self, pid):
+ return self.start_times.get(pid)
+
@staticmethod
def _write_file(path, content, mode=0o644):
path.parent.mkdir(parents=True, exist_ok=True)
@@ -175,6 +180,17 @@ class FlatpakProfileServiceTests(unittest.TestCase):
self.assertNotIn("--env=DXVK_HDR=0", command)
self.assertIn("--env=MESA_LOADER_DRIVER_OVERRIDE=zink", command)
+ def test_config_update_returns_without_relisting_flatpaks(self):
+ self.assertTrue(self.service.enable_app(self.app_id)["success"])
+ self.service.get_app = Mock(side_effect=AssertionError("config updates must not relist Flatpaks"))
+
+ result = self.service.update_config(self.app_id, {"multiplier": 4})
+
+ self.assertTrue(result["success"])
+ self.assertEqual(result["app_id"], self.app_id)
+ self.assertEqual(result["config"]["multiplier"], 4)
+ self.service.get_app.assert_not_called()
+
def test_remove_restores_exact_original_override_and_profile(self):
baseline = "[Environment]\nKEEP=yes\n"
self.flatpak._write_file(self.flatpak._override_path(self.app_id), baseline)
@@ -199,11 +215,18 @@ class FlatpakProfileServiceTests(unittest.TestCase):
def test_running_detection_uses_owned_selector_state(self):
self.assertTrue(self.service.enable_app(self.app_id)["success"])
self.flatpak.running = "org.example.Game\ttrue\t1234\norg.other.App\ttrue\t9999\n"
+ self.flatpak.start_times["1234"] = 200
result = self.service.get_running_apps()
self.assertTrue(result["success"])
- self.assertEqual(result["apps"], [{"app_id": self.app_id, "active": True, "pid": "1234"}])
+ self.assertEqual(result["apps"], [{"app_id": self.app_id, "active": True, "pid": "1234", "start_time": 200}])
+
+ def test_process_start_time_parser_handles_parentheses_in_command_name(self):
+ fields = ["S"] + ["0"] * 18 + ["4242"]
+ stat = "1234 (retro)arch) " + " ".join(fields)
+
+ self.assertEqual(FlatpakService._parse_process_start_time(stat), 4242)
if __name__ == "__main__":
diff --git a/tests/test_flatpak_service.py b/tests/test_flatpak_service.py
index 4d804c7..17d0016 100644
--- a/tests/test_flatpak_service.py
+++ b/tests/test_flatpak_service.py
@@ -149,6 +149,12 @@ class FlatpakServiceTests(unittest.TestCase):
self.assertEqual(runtime, self.runtime_ref)
self.assertEqual(branch, "25.08")
+ def test_clean_env_targets_deck_user_session_bus(self):
+ env = self.service._clean_env()
+ user_id = self.home.stat().st_uid
+ self.assertEqual(env["XDG_RUNTIME_DIR"], f"/run/user/{user_id}")
+ self.assertEqual(env["DBUS_SESSION_BUS_ADDRESS"], f"unix:path=/run/user/{user_id}/bus")
+
def test_prepare_app_installs_runtime_and_persists_narrow_override(self):
response = self.service.prepare_app("com.example.Game")