Merge branch 'feat/displaying-new-game-update' of https://github.com/hydralauncher/hydra into feat/displaying-new-game-update

This commit is contained in:
Moyasee
2025-11-11 16:32:37 +02:00
68 changed files with 4103 additions and 303 deletions

View File

@@ -41,8 +41,12 @@ export const appVersion = app.getVersion() + (isStaging ? "-staging" : "");
export const ASSETS_PATH = path.join(SystemPath.getPath("userData"), "Assets");
export const THEMES_PATH = path.join(SystemPath.getPath("userData"), "themes");
export const MAIN_LOOP_INTERVAL = 2000;
export const DEFAULT_ACHIEVEMENT_SOUND_VOLUME = 0.15;
export const DECKY_PLUGINS_LOCATION = path.join(
SystemPath.getPath("home"),
"homebrew",

View File

@@ -18,6 +18,7 @@ import "./library/close-game";
import "./library/delete-game-folder";
import "./library/get-game-by-object-id";
import "./library/get-library";
import "./library/refresh-library-assets";
import "./library/extract-game-download";
import "./library/clear-new-download-options";
import "./library/open-game";
@@ -94,6 +95,11 @@ import "./themes/get-custom-theme-by-id";
import "./themes/get-active-custom-theme";
import "./themes/close-editor-window";
import "./themes/toggle-custom-theme";
import "./themes/copy-theme-achievement-sound";
import "./themes/remove-theme-achievement-sound";
import "./themes/get-theme-sound-path";
import "./themes/get-theme-sound-data-url";
import "./themes/import-theme-sound-from-store";
import "./download-sources/remove-download-source";
import "./download-sources/get-download-sources";
import { isPortableVersion } from "@main/helpers";

View File

@@ -4,6 +4,7 @@ import {
downloadsSublevel,
gamesShopAssetsSublevel,
gamesSublevel,
gameAchievementsSublevel,
} from "@main/level";
const getLibrary = async (): Promise<LibraryGame[]> => {
@@ -18,14 +19,32 @@ const getLibrary = async (): Promise<LibraryGame[]> => {
const download = await downloadsSublevel.get(key);
const gameAssets = await gamesShopAssetsSublevel.get(key);
let unlockedAchievementCount = 0;
let achievementCount = 0;
try {
const achievements = await gameAchievementsSublevel.get(key);
if (achievements) {
achievementCount = achievements.achievements.length;
unlockedAchievementCount =
achievements.unlockedAchievements.length;
}
} catch {
// No achievements data for this game
}
return {
id: key,
...game,
download: download ?? null,
unlockedAchievementCount,
achievementCount,
// Spread gameAssets last to ensure all image URLs are properly set
...gameAssets,
// Ensure compatibility with LibraryGame type
libraryHeroImageUrl:
game.libraryHeroImageUrl ?? gameAssets?.libraryHeroImageUrl,
// Preserve custom image URLs from game if they exist
customIconUrl: game.customIconUrl,
customLogoImageUrl: game.customLogoImageUrl,
customHeroImageUrl: game.customHeroImageUrl,
} as LibraryGame;
})
);

View File

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

View File

@@ -0,0 +1,40 @@
import { registerEvent } from "../register-event";
import fs from "node:fs";
import path from "node:path";
import { getThemePath } from "@main/helpers";
import { themesSublevel } from "@main/level";
const copyThemeAchievementSound = async (
_event: Electron.IpcMainInvokeEvent,
themeId: string,
sourcePath: string
): Promise<void> => {
if (!sourcePath || !fs.existsSync(sourcePath)) {
throw new Error("Source file does not exist");
}
const theme = await themesSublevel.get(themeId);
if (!theme) {
throw new Error("Theme not found");
}
const themeDir = getThemePath(themeId, theme.name);
if (!fs.existsSync(themeDir)) {
fs.mkdirSync(themeDir, { recursive: true });
}
const fileExtension = path.extname(sourcePath);
const destinationPath = path.join(themeDir, `achievement${fileExtension}`);
await fs.promises.copyFile(sourcePath, destinationPath);
await themesSublevel.put(themeId, {
...theme,
hasCustomSound: true,
originalSoundPath: sourcePath,
updatedAt: new Date(),
});
};
registerEvent("copyThemeAchievementSound", copyThemeAchievementSound);

View File

@@ -0,0 +1,40 @@
import { registerEvent } from "../register-event";
import { getThemeSoundPath } from "@main/helpers";
import { themesSublevel } from "@main/level";
import fs from "node:fs";
import path from "node:path";
import { logger } from "@main/services";
const getThemeSoundDataUrl = async (
_event: Electron.IpcMainInvokeEvent,
themeId: string
): Promise<string | null> => {
try {
const theme = await themesSublevel.get(themeId);
const soundPath = getThemeSoundPath(themeId, theme?.name);
if (!soundPath || !fs.existsSync(soundPath)) {
return null;
}
const buffer = await fs.promises.readFile(soundPath);
const ext = path.extname(soundPath).toLowerCase().slice(1);
const mimeTypes: Record<string, string> = {
mp3: "audio/mpeg",
wav: "audio/wav",
ogg: "audio/ogg",
m4a: "audio/mp4",
};
const mimeType = mimeTypes[ext] || "audio/mpeg";
const base64 = buffer.toString("base64");
return `data:${mimeType};base64,${base64}`;
} catch (error) {
logger.error("Failed to get theme sound data URL", error);
return null;
}
};
registerEvent("getThemeSoundDataUrl", getThemeSoundDataUrl);

View File

@@ -0,0 +1,13 @@
import { registerEvent } from "../register-event";
import { getThemeSoundPath } from "@main/helpers";
import { themesSublevel } from "@main/level";
const getThemeSoundPathEvent = async (
_event: Electron.IpcMainInvokeEvent,
themeId: string
): Promise<string | null> => {
const theme = await themesSublevel.get(themeId);
return getThemeSoundPath(themeId, theme?.name);
};
registerEvent("getThemeSoundPath", getThemeSoundPathEvent);

View File

@@ -0,0 +1,60 @@
import { registerEvent } from "../register-event";
import fs from "node:fs";
import path from "node:path";
import axios from "axios";
import { getThemePath } from "@main/helpers";
import { themesSublevel } from "@main/level";
import { logger } from "@main/services";
const importThemeSoundFromStore = async (
_event: Electron.IpcMainInvokeEvent,
themeId: string,
themeName: string,
storeUrl: string
): Promise<void> => {
const theme = await themesSublevel.get(themeId);
if (!theme) {
throw new Error("Theme not found");
}
const formats = ["wav", "mp3", "ogg", "m4a"];
for (const format of formats) {
try {
const soundUrl = `${storeUrl}/themes/${themeName.toLowerCase()}/achievement.${format}`;
const response = await axios.get(soundUrl, {
responseType: "arraybuffer",
timeout: 10000,
});
const themeDir = getThemePath(themeId, theme.name);
if (!fs.existsSync(themeDir)) {
fs.mkdirSync(themeDir, { recursive: true });
}
const destinationPath = path.join(themeDir, `achievement.${format}`);
await fs.promises.writeFile(destinationPath, response.data);
await themesSublevel.put(themeId, {
...theme,
hasCustomSound: true,
updatedAt: new Date(),
});
logger.log(`Successfully imported sound for theme ${themeName}`);
return;
} catch (error) {
logger.error(
`Failed to import ${format} sound for theme ${themeName}`,
error
);
continue;
}
}
logger.log(`No sound file found for theme ${themeName} in store`);
};
registerEvent("importThemeSoundFromStore", importThemeSoundFromStore);

View File

@@ -0,0 +1,48 @@
import { registerEvent } from "../register-event";
import fs from "node:fs";
import { getThemePath } from "@main/helpers";
import { themesSublevel } from "@main/level";
import { THEMES_PATH } from "@main/constants";
import path from "node:path";
const removeThemeAchievementSound = async (
_event: Electron.IpcMainInvokeEvent,
themeId: string
): Promise<void> => {
const theme = await themesSublevel.get(themeId);
if (!theme) {
throw new Error("Theme not found");
}
const themeDir = getThemePath(themeId, theme.name);
const legacyThemeDir = path.join(THEMES_PATH, themeId);
const removeFromDir = async (dir: string) => {
if (!fs.existsSync(dir)) {
return;
}
const formats = ["wav", "mp3", "ogg", "m4a"];
for (const format of formats) {
const soundPath = path.join(dir, `achievement.${format}`);
if (fs.existsSync(soundPath)) {
await fs.promises.unlink(soundPath);
}
}
};
await removeFromDir(themeDir);
if (themeDir !== legacyThemeDir) {
await removeFromDir(legacyThemeDir);
}
await themesSublevel.put(themeId, {
...theme,
hasCustomSound: false,
originalSoundPath: undefined,
updatedAt: new Date(),
});
};
registerEvent("removeThemeAchievementSound", removeThemeAchievementSound);

View File

@@ -2,6 +2,8 @@ import axios from "axios";
import { JSDOM } from "jsdom";
import UserAgent from "user-agents";
import path from "node:path";
import fs from "node:fs";
import { THEMES_PATH } from "@main/constants";
export const getFileBuffer = async (url: string) =>
fetch(url, { method: "GET" }).then((response) =>
@@ -31,9 +33,64 @@ export const isPortableVersion = () => {
};
export const normalizePath = (str: string) =>
path.posix.normalize(str).replace(/\\/g, "/");
path.posix.normalize(str).replaceAll("\\", "/");
export const addTrailingSlash = (str: string) =>
str.endsWith("/") ? str : `${str}/`;
const sanitizeFolderName = (name: string): string => {
return name
.toLowerCase()
.replaceAll(/[^a-z0-9-_\s]/g, "")
.replaceAll(/\s+/g, "-")
.replaceAll(/-+/g, "-")
.replaceAll(/(^-|-$)/g, "");
};
export const getThemePath = (themeId: string, themeName?: string): string => {
if (themeName) {
const sanitizedName = sanitizeFolderName(themeName);
if (sanitizedName) {
return path.join(THEMES_PATH, sanitizedName);
}
}
return path.join(THEMES_PATH, themeId);
};
export const getThemeSoundPath = (
themeId: string,
themeName?: string
): string | null => {
const themeDir = getThemePath(themeId, themeName);
const legacyThemeDir = themeName ? path.join(THEMES_PATH, themeId) : null;
const checkDir = (dir: string): string | null => {
if (!fs.existsSync(dir)) {
return null;
}
const formats = ["wav", "mp3", "ogg", "m4a"];
for (const format of formats) {
const soundPath = path.join(dir, `achievement.${format}`);
if (fs.existsSync(soundPath)) {
return soundPath;
}
}
return null;
};
const soundPath = checkDir(themeDir);
if (soundPath) {
return soundPath;
}
if (legacyThemeDir) {
return checkDir(legacyThemeDir);
}
return null;
};
export * from "./reg-parser";

View File

@@ -17,6 +17,7 @@ import {
Lock,
DeckyPlugin,
DownloadSourcesChecker,
WSClient,
} from "@main/services";
import { migrateDownloadSources } from "./helpers/migrate-download-sources";
@@ -60,6 +61,7 @@ export const loadState = async () => {
// Check for new download options on startup
DownloadSourcesChecker.checkForChanges();
WSClient.connect();
});
const downloads = await downloadsSublevel

View File

@@ -1,6 +1,7 @@
import axios, { AxiosResponse } from "axios";
import { wrapper } from "axios-cookiejar-support";
import { CookieJar } from "tough-cookie";
import { logger } from "@main/services";
export class DatanodesApi {
private static readonly jar = new CookieJar();
@@ -20,51 +21,42 @@ export class DatanodesApi {
await this.jar.setCookie("lang=english;", "https://datanodes.to");
const payload = new URLSearchParams({
op: "download2",
id: fileCode,
method_free: "Free Download >>",
dl: "1",
});
const formData = new FormData();
formData.append("op", "download2");
formData.append("id", fileCode);
formData.append("rand", "");
formData.append("referer", "https://datanodes.to/download");
formData.append("method_free", "Free Download >>");
formData.append("method_premium", "");
formData.append("__dl", "1");
const response: AxiosResponse = await this.session.post(
"https://datanodes.to/download",
payload,
formData,
{
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:135.0) Gecko/20100101 Firefox/135.0",
accept: "*/*",
"accept-language": "en-US,en;q=0.9",
priority: "u=1, i",
"sec-ch-ua":
'"Google Chrome";v="141", "Not?A_Brand";v="8", "Chromium";v="141"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"Windows"',
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-origin",
Referer: "https://datanodes.to/download",
Origin: "https://datanodes.to",
"Content-Type": "application/x-www-form-urlencoded",
},
maxRedirects: 0,
validateStatus: (status: number) => status === 302 || status < 400,
}
);
if (response.status === 302) {
return response.headers["location"];
}
if (typeof response.data === "object" && response.data.url) {
return decodeURIComponent(response.data.url);
}
const htmlContent = String(response.data);
if (!htmlContent) {
throw new Error("Empty response received");
}
const downloadLinkRegex = /href=["'](https:\/\/[^"']+)["']/;
const downloadLinkMatch = downloadLinkRegex.exec(htmlContent);
if (downloadLinkMatch) {
return downloadLinkMatch[1];
}
throw new Error("Failed to get the download link");
} catch (error) {
console.error("Error fetching download URL:", error);
logger.error("Error fetching download URL:", error);
throw error;
}
}

View File

@@ -11,6 +11,7 @@ import { getUserData } from "./user/get-user-data";
import { db } from "@main/level";
import { levelKeys } from "@main/level/sublevels";
import type { Auth, User } from "@types";
import { WSClient } from "./ws";
export interface HydraApiOptions {
needsAuth?: boolean;
@@ -103,8 +104,8 @@ export class HydraApi {
await clearGamesRemoteIds();
uploadGamesBatch();
// WSClient.close();
// WSClient.connect();
WSClient.close();
WSClient.connect();
const { syncDownloadSourcesFromApi } = await import("./user");
syncDownloadSourcesFromApi();

View File

@@ -60,13 +60,20 @@ export const mergeWithRemoteGames = async () => {
const localGameShopAsset = await gamesShopAssetsSublevel.get(gameKey);
// Construct coverImageUrl if not provided by backend (Steam games use predictable pattern)
const coverImageUrl =
game.coverImageUrl ||
(game.shop === "steam"
? `https://shared.steamstatic.com/store_item_assets/steam/apps/${game.objectId}/library_600x900_2x.jpg`
: null);
await gamesShopAssetsSublevel.put(gameKey, {
updatedAt: Date.now(),
...localGameShopAsset,
shop: game.shop,
objectId: game.objectId,
title: localGame?.title || game.title, // Preserve local title if it exists
coverImageUrl: game.coverImageUrl,
coverImageUrl,
libraryHeroImageUrl: game.libraryHeroImageUrl,
libraryImageUrl: game.libraryImageUrl,
logoImageUrl: game.logoImageUrl,

View File

@@ -11,9 +11,10 @@ import { NotificationOptions, toXmlString } from "./xml";
import { logger } from "../logger";
import { WindowManager } from "../window-manager";
import type { Game, UserPreferences, UserProfile } from "@types";
import { db, levelKeys } from "@main/level";
import { db, levelKeys, themesSublevel } from "@main/level";
import { restartAndInstallUpdate } from "@main/events/autoupdater/restart-and-install-update";
import { SystemPath } from "../system-path";
import { getThemeSoundPath } from "@main/helpers";
async function downloadImage(url: string | null) {
if (!url) return undefined;
@@ -40,6 +41,27 @@ async function downloadImage(url: string | null) {
});
}
async function getAchievementSoundPath(): Promise<string> {
try {
const allThemes = await themesSublevel.values().all();
const activeTheme = allThemes.find((theme) => theme.isActive);
if (activeTheme?.hasCustomSound) {
const themeSoundPath = getThemeSoundPath(
activeTheme.id,
activeTheme.name
);
if (themeSoundPath) {
return themeSoundPath;
}
}
} catch (error) {
logger.error("Failed to get theme sound path", error);
}
return achievementSoundPath;
}
export const publishDownloadCompleteNotification = async (game: Game) => {
const userPreferences = await db.get<string, UserPreferences>(
levelKeys.userPreferences,
@@ -145,7 +167,8 @@ export const publishCombinedNewAchievementNotification = async (
if (WindowManager.mainWindow) {
WindowManager.mainWindow.webContents.send("on-achievement-unlocked");
} else if (process.platform !== "linux") {
sound.play(achievementSoundPath);
const soundPath = await getAchievementSoundPath();
sound.play(soundPath);
}
};
@@ -205,6 +228,7 @@ export const publishNewAchievementNotification = async (info: {
if (WindowManager.mainWindow) {
WindowManager.mainWindow.webContents.send("on-achievement-unlocked");
} else if (process.platform !== "linux") {
sound.play(achievementSoundPath);
const soundPath = await getAchievementSoundPath();
sound.play(soundPath);
}
};

View File

@@ -16,7 +16,7 @@ export const requestSteam250 = async (path: string) => {
if (!steamGameUrl) return null;
return {
title: $title.textContent,
title: $title.getAttribute("data-title") || "",
objectId: steamGameUrl.split("/").pop(),
} as Steam250Game;
})