Merge branch 'main' into feat/disabling-update-badges

This commit is contained in:
Chubby Granny Chaser
2025-12-26 21:53:45 +00:00
committed by GitHub
71 changed files with 2994 additions and 1150 deletions

View File

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

View File

@@ -0,0 +1,11 @@
import { registerEvent } from "../register-event";
import { LocalNotificationManager } from "@main/services";
const deleteLocalNotification = async (
_event: Electron.IpcMainInvokeEvent,
id: string
) => {
await LocalNotificationManager.deleteNotification(id);
};
registerEvent("deleteLocalNotification", deleteLocalNotification);

View File

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

View File

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

View File

@@ -1,3 +1,9 @@
import "./publish-new-repacks-notification";
import "./show-achievement-test-notification";
import "./update-achievement-notification-window";
import "./get-local-notifications";
import "./get-local-notifications-count";
import "./mark-local-notification-read";
import "./mark-all-local-notifications-read";
import "./delete-local-notification";
import "./clear-all-local-notifications";

View File

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

View File

@@ -0,0 +1,11 @@
import { registerEvent } from "../register-event";
import { LocalNotificationManager } from "@main/services";
const markLocalNotificationRead = async (
_event: Electron.IpcMainInvokeEvent,
id: string
) => {
await LocalNotificationManager.markAsRead(id);
};
registerEvent("markLocalNotificationRead", markLocalNotificationRead);

View File

@@ -1,4 +1,3 @@
import "./get-me";
import "./process-profile-image";
import "./sync-friend-requests";
import "./update-profile";

View File

@@ -1,24 +0,0 @@
import { registerEvent } from "../register-event";
import { HydraApi, WindowManager } from "@main/services";
import { UserNotLoggedInError } from "@shared";
import type { FriendRequestSync } from "@types";
export const syncFriendRequests = async () => {
return HydraApi.get<FriendRequestSync>(`/profile/friend-requests/sync`)
.then((res) => {
WindowManager.mainWindow?.webContents.send(
"on-sync-friend-requests",
res
);
return res;
})
.catch((err) => {
if (err instanceof UserNotLoggedInError) {
return { friendRequestCount: 0 } as FriendRequestSync;
}
throw err;
});
};
registerEvent("syncFriendRequests", syncFriendRequests);

View File

@@ -1,4 +1,4 @@
// @generated by protobuf-ts 2.10.0
// @generated by protobuf-ts 2.11.1
// @generated from protobuf file "envelope.proto" (syntax proto3)
// tslint:disable
import type { BinaryWriteOptions } from "@protobuf-ts/runtime";
@@ -15,11 +15,11 @@ import { MessageType } from "@protobuf-ts/runtime";
*/
export interface FriendRequest {
/**
* @generated from protobuf field: int32 friend_request_count = 1;
* @generated from protobuf field: int32 friend_request_count = 1
*/
friendRequestCount: number;
/**
* @generated from protobuf field: optional string sender_id = 2;
* @generated from protobuf field: optional string sender_id = 2
*/
senderId?: string;
}
@@ -28,18 +28,27 @@ export interface FriendRequest {
*/
export interface FriendGameSession {
/**
* @generated from protobuf field: string object_id = 1;
* @generated from protobuf field: string object_id = 1
*/
objectId: string;
/**
* @generated from protobuf field: string shop = 2;
* @generated from protobuf field: string shop = 2
*/
shop: string;
/**
* @generated from protobuf field: string friend_id = 3;
* @generated from protobuf field: string friend_id = 3
*/
friendId: string;
}
/**
* @generated from protobuf message Notification
*/
export interface Notification {
/**
* @generated from protobuf field: int32 notification_count = 1
*/
notificationCount: number;
}
/**
* @generated from protobuf message Envelope
*/
@@ -51,17 +60,24 @@ export interface Envelope {
| {
oneofKind: "friendRequest";
/**
* @generated from protobuf field: FriendRequest friend_request = 1;
* @generated from protobuf field: FriendRequest friend_request = 1
*/
friendRequest: FriendRequest;
}
| {
oneofKind: "friendGameSession";
/**
* @generated from protobuf field: FriendGameSession friend_game_session = 2;
* @generated from protobuf field: FriendGameSession friend_game_session = 2
*/
friendGameSession: FriendGameSession;
}
| {
oneofKind: "notification";
/**
* @generated from protobuf field: Notification notification = 3
*/
notification: Notification;
}
| {
oneofKind: undefined;
};
@@ -239,6 +255,80 @@ class FriendGameSession$Type extends MessageType<FriendGameSession> {
*/
export const FriendGameSession = new FriendGameSession$Type();
// @generated message type with reflection information, may provide speed optimized methods
class Notification$Type extends MessageType<Notification> {
constructor() {
super("Notification", [
{
no: 1,
name: "notification_count",
kind: "scalar",
T: 5 /*ScalarType.INT32*/,
},
]);
}
create(value?: PartialMessage<Notification>): Notification {
const message = globalThis.Object.create(this.messagePrototype!);
message.notificationCount = 0;
if (value !== undefined)
reflectionMergePartial<Notification>(this, message, value);
return message;
}
internalBinaryRead(
reader: IBinaryReader,
length: number,
options: BinaryReadOptions,
target?: Notification
): Notification {
let message = target ?? this.create(),
end = reader.pos + length;
while (reader.pos < end) {
let [fieldNo, wireType] = reader.tag();
switch (fieldNo) {
case /* int32 notification_count */ 1:
message.notificationCount = reader.int32();
break;
default:
let u = options.readUnknownField;
if (u === "throw")
throw new globalThis.Error(
`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`
);
let d = reader.skip(wireType);
if (u !== false)
(u === true ? UnknownFieldHandler.onRead : u)(
this.typeName,
message,
fieldNo,
wireType,
d
);
}
}
return message;
}
internalBinaryWrite(
message: Notification,
writer: IBinaryWriter,
options: BinaryWriteOptions
): IBinaryWriter {
/* int32 notification_count = 1; */
if (message.notificationCount !== 0)
writer.tag(1, WireType.Varint).int32(message.notificationCount);
let u = options.writeUnknownFields;
if (u !== false)
(u == true ? UnknownFieldHandler.onWrite : u)(
this.typeName,
message,
writer
);
return writer;
}
}
/**
* @generated MessageType for protobuf message Notification
*/
export const Notification = new Notification$Type();
// @generated message type with reflection information, may provide speed optimized methods
class Envelope$Type extends MessageType<Envelope> {
constructor() {
super("Envelope", [
@@ -256,6 +346,13 @@ class Envelope$Type extends MessageType<Envelope> {
oneof: "payload",
T: () => FriendGameSession,
},
{
no: 3,
name: "notification",
kind: "message",
oneof: "payload",
T: () => Notification,
},
]);
}
create(value?: PartialMessage<Envelope>): Envelope {
@@ -298,6 +395,17 @@ class Envelope$Type extends MessageType<Envelope> {
),
};
break;
case /* Notification notification */ 3:
message.payload = {
oneofKind: "notification",
notification: Notification.internalBinaryRead(
reader,
reader.uint32(),
options,
(message.payload as any).notification
),
};
break;
default:
let u = options.readUnknownField;
if (u === "throw")
@@ -336,6 +444,13 @@ class Envelope$Type extends MessageType<Envelope> {
writer.tag(2, WireType.LengthDelimited).fork(),
options
).join();
/* Notification notification = 3; */
if (message.payload.oneofKind === "notification")
Notification.internalBinaryWrite(
message.payload.notification,
writer.tag(3, WireType.LengthDelimited).fork(),
options
).join();
let u = options.writeUnknownFields;
if (u !== false)
(u == true ? UnknownFieldHandler.onWrite : u)(

View File

@@ -8,3 +8,4 @@ export * from "./keys";
export * from "./themes";
export * from "./download-sources";
export * from "./downloadSourcesCheckTimestamp";
export * from "./local-notifications";

View File

@@ -20,4 +20,5 @@ export const levelKeys = {
downloadSources: "downloadSources",
downloadSourcesCheckBaseline: "downloadSourcesCheckBaseline", // When we last started the app
downloadSourcesSinceValue: "downloadSourcesSinceValue", // The 'since' value API used (for modal comparison)
localNotifications: "localNotifications",
};

View File

@@ -0,0 +1,11 @@
import type { LocalNotification } from "@types";
import { db } from "../level";
import { levelKeys } from "./keys";
export const localNotificationsSublevel = db.sublevel<
string,
LocalNotification
>(levelKeys.localNotifications, {
valueEncoding: "json",
});

View File

@@ -29,20 +29,23 @@ import { BuzzheavierApi, FuckingFastApi } from "@main/services/hosters";
export class DownloadManager {
private static downloadingGameId: string | null = null;
private static extractFilename(url: string, originalUrl?: string): string | undefined {
if (originalUrl?.includes('#')) {
const hashPart = originalUrl.split('#')[1];
if (hashPart && !hashPart.startsWith('http')) return hashPart;
private static extractFilename(
url: string,
originalUrl?: string
): string | undefined {
if (originalUrl?.includes("#")) {
const hashPart = originalUrl.split("#")[1];
if (hashPart && !hashPart.startsWith("http")) return hashPart;
}
if (url.includes('#')) {
const hashPart = url.split('#')[1];
if (hashPart && !hashPart.startsWith('http')) return hashPart;
if (url.includes("#")) {
const hashPart = url.split("#")[1];
if (hashPart && !hashPart.startsWith("http")) return hashPart;
}
try {
const urlObj = new URL(url);
const filename = urlObj.pathname.split('/').pop();
const filename = urlObj.pathname.split("/").pop();
if (filename?.length) return filename;
} catch {
// Invalid URL
@@ -52,13 +55,20 @@ export class DownloadManager {
}
private static sanitizeFilename(filename: string): string {
return filename.replace(/[<>:"/\\|?*]/g, '_');
return filename.replace(/[<>:"/\\|?*]/g, "_");
}
private static createDownloadPayload(directUrl: string, originalUrl: string, downloadId: string, savePath: string) {
private static createDownloadPayload(
directUrl: string,
originalUrl: string,
downloadId: string,
savePath: string
) {
const filename = this.extractFilename(directUrl, originalUrl);
const sanitizedFilename = filename ? this.sanitizeFilename(filename) : undefined;
const sanitizedFilename = filename
? this.sanitizeFilename(filename)
: undefined;
if (sanitizedFilename) {
logger.log(`[DownloadManager] Using filename: ${sanitizedFilename}`);
}
@@ -98,7 +108,9 @@ export class DownloadManager {
}
private static async getDownloadStatus() {
const response = await PythonRPC.rpc.get<LibtorrentPayload | null>("/status");
const response = await PythonRPC.rpc.get<LibtorrentPayload | null>(
"/status"
);
if (response.data === null || !this.downloadingGameId) return null;
const downloadId = this.downloadingGameId;
@@ -114,7 +126,8 @@ export class DownloadManager {
status,
} = response.data;
const isDownloadingMetadata = status === LibtorrentStatus.DownloadingMetadata;
const isDownloadingMetadata =
status === LibtorrentStatus.DownloadingMetadata;
const isCheckingFiles = status === LibtorrentStatus.CheckingFiles;
const download = await downloadsSublevel.get(downloadId);
@@ -179,7 +192,10 @@ export class DownloadManager {
if (progress === 1 && download) {
publishDownloadCompleteNotification(game);
if (userPreferences?.seedAfterDownloadComplete && download.downloader === Downloader.Torrent) {
if (
userPreferences?.seedAfterDownloadComplete &&
download.downloader === Downloader.Torrent
) {
await downloadsSublevel.put(gameId, {
...download,
status: "seeding",
@@ -200,13 +216,22 @@ export class DownloadManager {
}
if (shouldExtract) {
const gameFilesManager = new GameFilesManager(game.shop, game.objectId);
const gameFilesManager = new GameFilesManager(
game.shop,
game.objectId
);
if (FILE_EXTENSIONS_TO_EXTRACT.some((ext) => download.folderName?.endsWith(ext))) {
if (
FILE_EXTENSIONS_TO_EXTRACT.some((ext) =>
download.folderName?.endsWith(ext)
)
) {
gameFilesManager.extractDownloadedFile();
} else {
gameFilesManager
.extractFilesInDirectory(path.join(download.downloadPath, download.folderName!))
.extractFilesInDirectory(
path.join(download.downloadPath, download.folderName!)
)
.then(() => gameFilesManager.setExtractionComplete());
}
}
@@ -214,11 +239,13 @@ export class DownloadManager {
const downloads = await downloadsSublevel
.values()
.all()
.then((games) => sortBy(
games.filter((game) => game.status === "paused" && game.queued),
"timestamp",
"DESC"
));
.then((games) =>
sortBy(
games.filter((game) => game.status === "paused" && game.queued),
"timestamp",
"DESC"
)
);
const [nextItemOnQueue] = downloads;
@@ -245,7 +272,9 @@ export class DownloadManager {
if (!download) return;
const totalSize = await getDirSize(path.join(download.downloadPath, status.folderName));
const totalSize = await getDirSize(
path.join(download.downloadPath, status.folderName)
);
if (totalSize < status.fileSize) {
await this.cancelDownload(status.gameId);
@@ -266,7 +295,10 @@ export class DownloadManager {
static async pauseDownload(downloadKey = this.downloadingGameId) {
await PythonRPC.rpc
.post("/action", { action: "pause", game_id: downloadKey } as PauseDownloadPayload)
.post("/action", {
action: "pause",
game_id: downloadKey,
} as PauseDownloadPayload)
.catch(() => {});
if (downloadKey === this.downloadingGameId) {
@@ -357,24 +389,44 @@ export class DownloadManager {
};
}
case Downloader.Buzzheavier: {
logger.log(`[DownloadManager] Processing Buzzheavier download for URI: ${download.uri}`);
logger.log(
`[DownloadManager] Processing Buzzheavier download for URI: ${download.uri}`
);
try {
const directUrl = await BuzzheavierApi.getDirectLink(download.uri);
logger.log(`[DownloadManager] Buzzheavier direct URL obtained`);
return this.createDownloadPayload(directUrl, download.uri, downloadId, download.downloadPath);
return this.createDownloadPayload(
directUrl,
download.uri,
downloadId,
download.downloadPath
);
} catch (error) {
logger.error(`[DownloadManager] Error processing Buzzheavier download:`, error);
logger.error(
`[DownloadManager] Error processing Buzzheavier download:`,
error
);
throw error;
}
}
case Downloader.FuckingFast: {
logger.log(`[DownloadManager] Processing FuckingFast download for URI: ${download.uri}`);
logger.log(
`[DownloadManager] Processing FuckingFast download for URI: ${download.uri}`
);
try {
const directUrl = await FuckingFastApi.getDirectLink(download.uri);
logger.log(`[DownloadManager] FuckingFast direct URL obtained`);
return this.createDownloadPayload(directUrl, download.uri, downloadId, download.downloadPath);
return this.createDownloadPayload(
directUrl,
download.uri,
downloadId,
download.downloadPath
);
} catch (error) {
logger.error(`[DownloadManager] Error processing FuckingFast download:`, error);
logger.error(
`[DownloadManager] Error processing FuckingFast download:`,
error
);
throw error;
}
}
@@ -419,7 +471,9 @@ export class DownloadManager {
};
}
case Downloader.Hydra: {
const downloadUrl = await HydraDebridClient.getDownloadUrl(download.uri);
const downloadUrl = await HydraDebridClient.getDownloadUrl(
download.uri
);
if (!downloadUrl) throw new Error(DownloadError.NotCachedOnHydra);
return {

View File

@@ -21,7 +21,9 @@ export class BuzzheavierApi {
private static async getBuzzheavierDirectLink(url: string): Promise<string> {
try {
const baseUrl = url.split("#")[0];
logger.log(`[Buzzheavier] Starting download link extraction for: ${baseUrl}`);
logger.log(
`[Buzzheavier] Starting download link extraction for: ${baseUrl}`
);
await axios.get(baseUrl, {
headers: { "User-Agent": HOSTER_USER_AGENT },
@@ -46,7 +48,9 @@ export class BuzzheavierApi {
const hxRedirect = headResponse.headers["hx-redirect"];
logger.log(`[Buzzheavier] Received hx-redirect header: ${hxRedirect}`);
if (!hxRedirect) {
logger.error(`[Buzzheavier] No hx-redirect header found. Status: ${headResponse.status}`);
logger.error(
`[Buzzheavier] No hx-redirect header found. Status: ${headResponse.status}`
);
throw new Error(
"Could not extract download link. File may be deleted or is a directory."
);

View File

@@ -30,7 +30,7 @@ export class HydraApi {
private static instance: AxiosInstance;
private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5; // 5 minutes
private static readonly ADD_LOG_INTERCEPTOR = false;
private static readonly ADD_LOG_INTERCEPTOR = true;
private static secondsToMilliseconds(seconds: number) {
return seconds * 1000;

View File

@@ -20,3 +20,4 @@ export * from "./lock";
export * from "./decky-plugin";
export * from "./user";
export * from "./download-sources-checker";
export * from "./notifications/local-notifications";

View File

@@ -16,6 +16,7 @@ import { restartAndInstallUpdate } from "@main/events/autoupdater/restart-and-in
import { SystemPath } from "../system-path";
import { getThemeSoundPath } from "@main/helpers";
import { processProfileImage } from "@main/events/profile/process-profile-image";
import { LocalNotificationManager } from "./local-notifications";
const getStaticImage = async (path: string) => {
return processProfileImage(path, "jpg")
@@ -78,37 +79,59 @@ export const publishDownloadCompleteNotification = async (game: Game) => {
}
);
const title = t("download_complete", { ns: "notifications" });
const body = t("game_ready_to_install", {
ns: "notifications",
title: game.title,
});
if (userPreferences?.downloadNotificationsEnabled) {
new Notification({
title: t("download_complete", {
ns: "notifications",
}),
body: t("game_ready_to_install", {
ns: "notifications",
title: game.title,
}),
title,
body,
icon: await downloadImage(game.iconUrl),
}).show();
}
// Create local notification
await LocalNotificationManager.createNotification(
"DOWNLOAD_COMPLETE",
title,
body,
{
pictureUrl: game.iconUrl,
url: `/game/${game.shop}/${game.objectId}`,
}
);
};
export const publishNotificationUpdateReadyToInstall = async (
version: string
) => {
const title = t("new_update_available", {
ns: "notifications",
version,
});
const body = t("restart_to_install_update", {
ns: "notifications",
});
new Notification({
title: t("new_update_available", {
ns: "notifications",
version,
}),
body: t("restart_to_install_update", {
ns: "notifications",
}),
title,
body,
icon: trayIcon,
})
.on("click", () => {
restartAndInstallUpdate();
})
.show();
// Create local notification
await LocalNotificationManager.createNotification(
"UPDATE_AVAILABLE",
title,
body
);
};
export const publishNewFriendRequestNotification = async (
@@ -181,14 +204,27 @@ export const publishCombinedNewAchievementNotification = async (
};
export const publishExtractionCompleteNotification = async (game: Game) => {
const title = t("extraction_complete", { ns: "notifications" });
const body = t("game_extracted", {
ns: "notifications",
title: game.title,
});
new Notification({
title: t("extraction_complete", { ns: "notifications" }),
body: t("game_extracted", {
ns: "notifications",
title: game.title,
}),
title,
body,
icon: trayIcon,
}).show();
// Create local notification
await LocalNotificationManager.createNotification(
"EXTRACTION_COMPLETE",
title,
body,
{
url: `/game/${game.shop}/${game.objectId}`,
}
);
};
export const publishNewAchievementNotification = async (info: {

View File

@@ -0,0 +1,99 @@
import { localNotificationsSublevel } from "@main/level";
import { WindowManager } from "../window-manager";
import type { LocalNotification, LocalNotificationType } from "@types";
import crypto from "node:crypto";
export class LocalNotificationManager {
private static generateId(): string {
return crypto.randomBytes(8).toString("hex");
}
static async createNotification(
type: LocalNotificationType,
title: string,
description: string,
options?: {
pictureUrl?: string | null;
url?: string | null;
}
): Promise<LocalNotification> {
const id = this.generateId();
const notification: LocalNotification = {
id,
type,
title,
description,
pictureUrl: options?.pictureUrl ?? null,
url: options?.url ?? null,
isRead: false,
createdAt: new Date().toISOString(),
};
await localNotificationsSublevel.put(id, notification);
// Notify renderer about new notification
if (WindowManager.mainWindow) {
WindowManager.mainWindow.webContents.send(
"on-local-notification-created",
notification
);
}
return notification;
}
static async getNotifications(): Promise<LocalNotification[]> {
const notifications: LocalNotification[] = [];
for await (const [, value] of localNotificationsSublevel.iterator()) {
notifications.push(value);
}
// Sort by createdAt descending
return notifications.sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
}
static async getUnreadCount(): Promise<number> {
let count = 0;
for await (const [, value] of localNotificationsSublevel.iterator()) {
if (!value.isRead) {
count++;
}
}
return count;
}
static async markAsRead(id: string): Promise<void> {
const notification = await localNotificationsSublevel.get(id);
if (notification) {
notification.isRead = true;
await localNotificationsSublevel.put(id, notification);
}
}
static async markAllAsRead(): Promise<void> {
const batch = localNotificationsSublevel.batch();
for await (const [key, value] of localNotificationsSublevel.iterator()) {
if (!value.isRead) {
value.isRead = true;
batch.put(key, value);
}
}
await batch.write();
}
static async deleteNotification(id: string): Promise<void> {
await localNotificationsSublevel.del(id);
}
static async clearAll(): Promise<void> {
await localNotificationsSublevel.clear();
}
}

View File

@@ -0,0 +1,8 @@
import type { Notification } from "@main/generated/envelope";
import { WindowManager } from "@main/services/window-manager";
export const notificationEvent = (payload: Notification) => {
WindowManager.mainWindow?.webContents.send("on-sync-notification-count", {
notificationCount: payload.notificationCount,
});
};

View File

@@ -4,6 +4,7 @@ import { Envelope } from "@main/generated/envelope";
import { logger } from "../logger";
import { friendRequestEvent } from "./events/friend-request";
import { friendGameSessionEvent } from "./events/friend-game-session";
import { notificationEvent } from "./events/notification";
export class WSClient {
private static ws: WebSocket | null = null;
@@ -51,6 +52,10 @@ export class WSClient {
if (envelope.payload.oneofKind === "friendGameSession") {
friendGameSessionEvent(envelope.payload.friendGameSession);
}
if (envelope.payload.oneofKind === "notification") {
notificationEvent(envelope.payload.notification);
}
});
this.ws.on("close", () => this.handleDisconnect("close"));