feat: implement game launcher window functionality and enhance deep link handling

This commit is contained in:
Moyasee
2026-01-21 21:04:22 +02:00
parent 335f4d33b9
commit 9824f7a905
14 changed files with 554 additions and 14 deletions

View File

@@ -819,6 +819,16 @@
"learn_more": "Learn More",
"debrid_description": "Download up to 4x faster with Nimbus"
},
"game_launcher": {
"launching": "Launching...",
"launching_base": "Launching",
"open_hydra": "Open Hydra",
"playtime": "Playtime",
"amount_hours": "{{amount}} hours",
"amount_minutes": "{{amount}} minutes",
"amount_hours_short": "{{amount}}h",
"amount_minutes_short": "{{amount}}m"
},
"notifications_page": {
"title": "Notifications",
"mark_all_as_read": "Mark all as read",

View File

@@ -1,10 +1,11 @@
import { registerEvent } from "../register-event";
import { shell } from "electron";
import { spawn } from "child_process";
import { spawn } from "node:child_process";
import { parseExecutablePath } from "../helpers/parse-executable-path";
import { gamesSublevel, levelKeys } from "@main/level";
import { GameShop } from "@types";
import { parseLaunchOptions } from "../helpers/parse-launch-options";
import { WindowManager } from "@main/services";
const openGame = async (
_event: Electron.IpcMainInvokeEvent,
@@ -28,6 +29,9 @@ const openGame = async (
launchOptions,
});
// Always show the launcher window when launching a game
WindowManager.createGameLauncherWindow(shop, objectId);
if (parsedParams.length === 0) {
shell.openPath(parsedPath);
return;

View File

@@ -0,0 +1,8 @@
import { registerEvent } from "../register-event";
import { WindowManager } from "@main/services";
const closeGameLauncherWindow = async () => {
WindowManager.closeGameLauncherWindow();
};
registerEvent("closeGameLauncherWindow", closeGameLauncherWindow);

View File

@@ -1,12 +1,15 @@
import "./can-install-common-redist";
import "./check-homebrew-folder-exists";
import "./close-game-launcher-window";
import "./delete-temp-file";
import "./show-game-launcher-window";
import "./get-hydra-decky-plugin-info";
import "./hydra-api-call";
import "./install-common-redist";
import "./install-hydra-decky-plugin";
import "./open-checkout";
import "./open-external";
import "./open-main-window";
import "./save-temp-file";
import "./show-item-in-folder";
import "./show-open-dialog";

View File

@@ -0,0 +1,8 @@
import { registerEvent } from "../register-event";
import { WindowManager } from "@main/services";
const openMainWindow = async () => {
WindowManager.openMainWindow();
};
registerEvent("openMainWindow", openMainWindow);

View File

@@ -0,0 +1,8 @@
import { registerEvent } from "../register-event";
import { WindowManager } from "@main/services";
const showGameLauncherWindow = async () => {
WindowManager.showGameLauncherWindow();
};
registerEvent("showGameLauncherWindow", showGameLauncherWindow);

View File

@@ -15,7 +15,7 @@ import {
import resources from "@locales";
import { PythonRPC } from "./services/python-rpc";
import { db, gamesSublevel, levelKeys } from "./level";
import { GameShop } from "@types";
import { GameShop, UserPreferences } from "@types";
import { parseExecutablePath } from "./events/helpers/parse-executable-path";
import { parseLaunchOptions } from "./events/helpers/parse-launch-options";
import { loadState } from "./main";
@@ -144,16 +144,19 @@ app.whenReady().then(async () => {
if (language) i18n.changeLanguage(language);
if (!process.argv.includes("--hidden")) {
// Check if starting from a "run" deep link - don't show main window in that case
const deepLinkArg = process.argv.find((arg) =>
arg.startsWith("hydralauncher://")
);
const isRunDeepLink = deepLinkArg?.startsWith("hydralauncher://run");
if (!process.argv.includes("--hidden") && !isRunDeepLink) {
WindowManager.createMainWindow();
}
WindowManager.createNotificationWindow();
WindowManager.createSystemTray(language || "en");
const deepLinkArg = process.argv.find((arg) =>
arg.startsWith("hydralauncher://")
);
if (deepLinkArg) {
handleDeepLinkPath(deepLinkArg);
}
@@ -172,6 +175,19 @@ const handleRunGame = async (shop: GameShop, objectId: string) => {
return;
}
const userPreferences = await db.get<string, UserPreferences | null>(
levelKeys.userPreferences,
{ valueEncoding: "json" }
);
// Always show the launcher window
await WindowManager.createGameLauncherWindow(shop, objectId);
// Only open main window if setting is disabled
if (!userPreferences?.hideToTrayOnGameStart) {
WindowManager.createMainWindow();
}
const parsedPath = parseExecutablePath(game.executablePath);
const parsedParams = parseLaunchOptions(game.launchOptions);
@@ -237,17 +253,23 @@ const handleDeepLinkPath = (uri?: string) => {
};
app.on("second-instance", (_event, commandLine) => {
// Someone tried to run a second instance, we should focus our window.
if (WindowManager.mainWindow) {
if (WindowManager.mainWindow.isMinimized())
WindowManager.mainWindow.restore();
const deepLink = commandLine.pop();
WindowManager.mainWindow.focus();
} else {
WindowManager.createMainWindow();
// Check if this is a "run" deep link - don't show main window in that case
const isRunDeepLink = deepLink?.startsWith("hydralauncher://run");
if (!isRunDeepLink) {
if (WindowManager.mainWindow) {
if (WindowManager.mainWindow.isMinimized())
WindowManager.mainWindow.restore();
WindowManager.mainWindow.focus();
} else {
WindowManager.createMainWindow();
}
}
handleDeepLinkPath(commandLine.pop());
handleDeepLinkPath(deepLink);
});
app.on("open-url", (_event, url) => {

View File

@@ -204,6 +204,9 @@ function onOpenGame(game: Game) {
lastSyncTick: now,
});
// Close the launcher window when game starts
WindowManager.closeGameLauncherWindow();
// Hide Hydra to tray on game startup if enabled
db.get<string, UserPreferences | null>(levelKeys.userPreferences, {
valueEncoding: "json",

View File

@@ -30,6 +30,7 @@ import { logger } from "./logger";
export class WindowManager {
public static mainWindow: Electron.BrowserWindow | null = null;
public static notificationWindow: Electron.BrowserWindow | null = null;
public static gameLauncherWindow: Electron.BrowserWindow | null = null;
private static readonly editorWindows: Map<string, BrowserWindow> = new Map();
@@ -516,6 +517,84 @@ export class WindowManager {
}
}
private static readonly GAME_LAUNCHER_WINDOW_WIDTH = 550;
private static readonly GAME_LAUNCHER_WINDOW_HEIGHT = 320;
public static async createGameLauncherWindow(shop: string, objectId: string) {
if (this.gameLauncherWindow) {
this.gameLauncherWindow.close();
this.gameLauncherWindow = null;
}
const display = screen.getPrimaryDisplay();
const { width: displayWidth, height: displayHeight } = display.bounds;
const x = Math.round((displayWidth - this.GAME_LAUNCHER_WINDOW_WIDTH) / 2);
const y = Math.round(
(displayHeight - this.GAME_LAUNCHER_WINDOW_HEIGHT) / 2
);
this.gameLauncherWindow = new BrowserWindow({
width: this.GAME_LAUNCHER_WINDOW_WIDTH,
height: this.GAME_LAUNCHER_WINDOW_HEIGHT,
x,
y,
resizable: false,
maximizable: false,
minimizable: false,
fullscreenable: false,
frame: false,
backgroundColor: "#1c1c1c",
icon,
skipTaskbar: false,
webPreferences: {
preload: path.join(__dirname, "../preload/index.mjs"),
sandbox: false,
},
show: false,
});
this.gameLauncherWindow.removeMenu();
this.loadWindowURL(
this.gameLauncherWindow,
`game-launcher?shop=${shop}&objectId=${objectId}`
);
this.gameLauncherWindow.on("closed", () => {
this.gameLauncherWindow = null;
});
if (!app.isPackaged || isStaging) {
this.gameLauncherWindow.webContents.openDevTools();
}
}
public static showGameLauncherWindow() {
if (this.gameLauncherWindow && !this.gameLauncherWindow.isDestroyed()) {
this.gameLauncherWindow.show();
}
}
public static closeGameLauncherWindow() {
if (this.gameLauncherWindow) {
this.gameLauncherWindow.close();
this.gameLauncherWindow = null;
}
}
public static openMainWindow() {
if (this.mainWindow) {
this.mainWindow.show();
if (this.mainWindow.isMinimized()) {
this.mainWindow.restore();
}
this.mainWindow.focus();
} else {
this.createMainWindow();
}
}
public static redirect(hash: string) {
if (!this.mainWindow) this.createMainWindow();
this.loadMainWindowURL(hash);

View File

@@ -675,6 +675,11 @@ contextBridge.exposeInMainWorld("electron", {
closeEditorWindow: (themeId?: string) =>
ipcRenderer.invoke("closeEditorWindow", themeId),
/* Game Launcher Window */
showGameLauncherWindow: () => ipcRenderer.invoke("showGameLauncherWindow"),
closeGameLauncherWindow: () => ipcRenderer.invoke("closeGameLauncherWindow"),
openMainWindow: () => ipcRenderer.invoke("openMainWindow"),
/* LevelDB Generic CRUD */
leveldb: {
get: (

View File

@@ -462,6 +462,11 @@ declare global {
onCustomThemeUpdated: (cb: () => void) => () => Electron.IpcRenderer;
closeEditorWindow: (themeId?: string) => Promise<void>;
/* Game Launcher Window */
showGameLauncherWindow: () => Promise<void>;
closeGameLauncherWindow: () => Promise<void>;
openMainWindow: () => Promise<void>;
/* Download Options */
onNewDownloadOptions: (
cb: (gamesWithNewOptions: { gameId: string; count: number }[]) => void

View File

@@ -34,6 +34,7 @@ import ThemeEditor from "./pages/theme-editor/theme-editor";
import Library from "./pages/library/library";
import Notifications from "./pages/notifications/notifications";
import { AchievementNotification } from "./pages/achievements/notification/achievement-notification";
import GameLauncher from "./pages/game-launcher/game-launcher";
console.log = logger.log;
@@ -98,6 +99,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
path="/achievement-notification"
element={<AchievementNotification />}
/>
<Route path="/game-launcher" element={<GameLauncher />} />
</Routes>
</HashRouter>
</Provider>

View File

@@ -0,0 +1,180 @@
@use "../../scss/globals.scss";
.game-launcher {
display: flex;
flex-direction: column;
height: 100vh;
width: 100vw;
background: linear-gradient(135deg, #0d0d0d 0%, #1a1a2e 50%, #16213e 100%);
-webkit-app-region: drag;
padding: calc(globals.$spacing-unit * 3);
box-sizing: border-box;
position: relative;
overflow: hidden;
&__glow {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: radial-gradient(
ellipse at top right,
rgba(22, 177, 149, 0.15) 0%,
transparent 50%
);
pointer-events: none;
z-index: 0;
}
&__logo-badge {
position: absolute;
top: calc(globals.$spacing-unit * 2);
right: calc(globals.$spacing-unit * 2);
z-index: 3;
svg {
width: 28px;
height: 28px;
fill: globals.$body-color;
opacity: 0.6;
}
}
&__content {
display: flex;
flex: 1;
gap: calc(globals.$spacing-unit * 2);
-webkit-app-region: no-drag;
min-height: 0;
position: relative;
z-index: 1;
}
&__cover {
height: 100%;
width: auto;
max-width: 180px;
border-radius: 8px;
object-fit: contain;
flex-shrink: 0;
background-color: globals.$dark-background-color;
}
&__cover-placeholder {
height: 100%;
width: 180px;
border-radius: 8px;
background-color: globals.$dark-background-color;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: globals.$muted-color;
}
&__info {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
&__center {
display: flex;
flex-direction: column;
justify-content: center;
flex: 1;
gap: calc(globals.$spacing-unit / 2);
}
&__title {
font-size: 22px;
font-weight: 700;
color: globals.$body-color;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
padding-right: 40px;
}
&__status {
font-size: 14px;
color: globals.$muted-color;
margin: 0;
display: flex;
align-items: center;
}
&__dots {
display: inline-block;
width: 20px;
&::after {
content: "";
animation: dots 1.5s steps(4, end) infinite;
}
}
@keyframes dots {
0% {
content: "";
}
25% {
content: ".";
}
50% {
content: "..";
}
75% {
content: "...";
}
100% {
content: "";
}
}
&__stats {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 2);
padding-top: calc(globals.$spacing-unit * 2);
}
&__stat {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit / 2);
font-size: 13px;
color: rgba(255, 255, 255, 0.5);
}
&__button {
width: 100%;
background-color: globals.$muted-color;
border: solid 1px transparent;
color: #0d0d0d;
border-radius: 8px;
padding: globals.$spacing-unit calc(globals.$spacing-unit * 2);
min-height: 40px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all ease 0.2s;
font-family: inherit;
display: flex;
align-items: center;
justify-content: center;
margin-top: calc(globals.$spacing-unit);
&:hover {
background-color: #dadbe1;
}
&:active {
opacity: globals.$active-opacity;
}
}
}

View File

@@ -0,0 +1,203 @@
import { useCallback, useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { ImageIcon, ClockIcon, TrophyIcon } from "@primer/octicons-react";
import HydraIcon from "@renderer/assets/icons/hydra.svg?react";
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
import { darkenColor } from "@renderer/helpers";
import { logger } from "@renderer/logger";
import { average } from "color.js";
import type { Game, GameShop, ShopAssets } from "@types";
import "./game-launcher.scss";
export default function GameLauncher() {
const { t } = useTranslation("game_launcher");
const [searchParams] = useSearchParams();
const shop = searchParams.get("shop") as GameShop;
const objectId = searchParams.get("objectId");
const [game, setGame] = useState<Game | null>(null);
const [gameAssets, setGameAssets] = useState<ShopAssets | null>(null);
const [imageError, setImageError] = useState(false);
const [imageLoaded, setImageLoaded] = useState(false);
const [accentColor, setAccentColor] = useState<string | null>(null);
const [colorExtracted, setColorExtracted] = useState(false);
const [colorError, setColorError] = useState(false);
const [windowShown, setWindowShown] = useState(false);
const formatPlayTime = useCallback(
(playTimeInMilliseconds = 0) => {
const minutes = playTimeInMilliseconds / 60000;
if (minutes < MAX_MINUTES_TO_SHOW_IN_PLAYTIME) {
return t("amount_minutes_short", { amount: minutes.toFixed(0) });
}
const hours = minutes / 60;
return t("amount_hours_short", { amount: hours.toFixed(1) });
},
[t]
);
useEffect(() => {
if (shop && objectId) {
window.electron.getGameByObjectId(shop, objectId).then((gameData) => {
setGame(gameData);
});
window.electron.getGameAssets(objectId, shop).then((assets) => {
setGameAssets(assets);
});
}
}, [shop, objectId]);
// Fallback auto-close timer - game detection will usually close it sooner
useEffect(() => {
if (!windowShown) return;
const timer = setTimeout(() => {
window.electron.closeGameLauncherWindow();
}, 5000);
return () => clearTimeout(timer);
}, [windowShown]);
const handleOpenHydra = () => {
window.electron.openMainWindow();
window.electron.closeGameLauncherWindow();
};
const coverImage =
gameAssets?.coverImageUrl?.replaceAll("\\", "/") ||
game?.iconUrl?.replaceAll("\\", "/") ||
game?.libraryHeroImageUrl?.replaceAll("\\", "/") ||
"";
const gameTitle = game?.title ?? gameAssets?.title ?? "";
const playTime = game?.playTimeInMilliseconds ?? 0;
const achievementCount = game?.achievementCount ?? 0;
const unlockedAchievements = game?.unlockedAchievementCount ?? 0;
const extractAccentColor = useCallback(async (imageUrl: string) => {
try {
const color = await average(imageUrl, { amount: 1, format: "hex" });
const colorString = typeof color === "string" ? color : color.toString();
setAccentColor(colorString);
} catch (error) {
logger.error("Failed to extract accent color:", error);
setColorError(true);
} finally {
setColorExtracted(true);
}
}, []);
// Extract color as soon as we have the image URL
useEffect(() => {
if (coverImage && !colorExtracted) {
extractAccentColor(coverImage);
}
}, [coverImage, colorExtracted, extractAccentColor]);
// Only show content when both image is loaded and color is extracted successfully
const isReady = imageLoaded && colorExtracted && !colorError;
const hasFailed =
imageError || colorError || (!coverImage && gameAssets !== null);
// Show or close window based on loading state
useEffect(() => {
if (windowShown) return;
if (hasFailed) {
window.electron.closeGameLauncherWindow();
return;
}
if (isReady) {
window.electron.showGameLauncherWindow();
setWindowShown(true);
}
}, [isReady, hasFailed, windowShown]);
const backgroundStyle = accentColor
? {
background: `linear-gradient(135deg, ${darkenColor(accentColor, 0.7)} 0%, ${darkenColor(accentColor, 0.8, 0.9)} 50%, ${darkenColor(accentColor, 0.85, 0.8)} 100%)`,
}
: undefined;
const glowStyle = accentColor
? {
background: `radial-gradient(ellipse at top right, ${darkenColor(accentColor, 0.3, 0.15)} 0%, transparent 50%)`,
}
: undefined;
return (
<div className="game-launcher" style={backgroundStyle}>
<div className="game-launcher__glow" style={glowStyle} />
<div className="game-launcher__logo-badge">
<HydraIcon />
</div>
<div className="game-launcher__content">
{imageError || !coverImage ? (
<div className="game-launcher__cover-placeholder">
<ImageIcon size={32} />
</div>
) : (
<>
{!isReady && (
<div className="game-launcher__cover-placeholder">
<ImageIcon size={32} />
</div>
)}
<img
src={coverImage}
alt={gameTitle}
className="game-launcher__cover"
style={{ display: isReady ? "block" : "none" }}
onLoad={() => setImageLoaded(true)}
onError={() => setImageError(true)}
/>
</>
)}
<div className="game-launcher__info">
<div className="game-launcher__center">
<h1 className="game-launcher__title">{gameTitle}</h1>
<p className="game-launcher__status">
{t("launching_base")}
<span className="game-launcher__dots" />
</p>
<button
type="button"
className="game-launcher__button"
onClick={handleOpenHydra}
>
{t("open_hydra")}
</button>
</div>
{(playTime > 0 || achievementCount > 0) && (
<div className="game-launcher__stats">
{playTime > 0 && (
<span className="game-launcher__stat">
<ClockIcon size={14} />
{formatPlayTime(playTime)}
</span>
)}
{achievementCount > 0 && (
<span className="game-launcher__stat">
<TrophyIcon size={14} />
{unlockedAchievements}/{achievementCount}
</span>
)}
</div>
)}
</div>
</div>
</div>
);
}