diff --git a/.env.example b/.env.example index e3b58f9e..86a515f6 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,5 @@ MAIN_VITE_API_URL=API_URL MAIN_VITE_AUTH_URL=AUTH_URL +MAIN_VITE_WS_URL= RENDERER_VITE_REAL_DEBRID_REFERRAL_ID= RENDERER_VITE_TORBOX_REFERRAL_CODE= diff --git a/.eslintignore b/.eslintignore index a4c40302..fc068890 100644 --- a/.eslintignore +++ b/.eslintignore @@ -4,3 +4,4 @@ out .gitignore migration.stub hydra-python-rpc/ +src/main/generated/ diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 45ed8aaf..1845ffd5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -48,6 +48,7 @@ jobs: MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_STAGING_API_URL }} MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_STAGING_AUTH_URL }} MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_STAGING_CHECKOUT_URL }} + MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_STAGING_URL }} RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }} MAIN_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -63,6 +64,7 @@ jobs: MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_STAGING_API_URL }} MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_STAGING_AUTH_URL }} MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_STAGING_CHECKOUT_URL }} + MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_STAGING_URL }} RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }} MAIN_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d7098e44..e2536c33 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -51,6 +51,7 @@ jobs: MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_CHECKOUT_URL }} RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }} MAIN_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }} + MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_URL }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} RENDERER_VITE_SENTRY_DSN: ${{ vars.SENTRY_DSN }} @@ -66,6 +67,7 @@ jobs: MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_CHECKOUT_URL }} RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }} MAIN_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }} + MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_URL }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} RENDERER_VITE_SENTRY_DSN: ${{ vars.SENTRY_DSN }} diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..ec2e002d --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "proto"] + path = proto + url = https://github.com/hydralauncher/hydra-protos.git diff --git a/package.json b/package.json index 9fd49f91..9984c0df 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,8 @@ "build:win": "electron-vite build && electron-builder --win", "build:mac": "electron-vite build && electron-builder --mac", "build:linux": "electron-vite build && electron-builder --linux", - "prepare": "husky" + "prepare": "husky", + "protoc": "npx protoc --ts_out src/main/generated --proto_path proto proto/*.proto" }, "dependencies": { "@electron-toolkit/preload": "^3.0.0", @@ -74,6 +75,7 @@ "tar": "^7.4.3", "tough-cookie": "^5.1.1", "user-agents": "^1.1.387", + "ws": "^8.18.1", "yaml": "^2.6.1", "yup": "^1.5.0", "zod": "^3.24.1" @@ -85,6 +87,7 @@ "@electron-toolkit/eslint-config-prettier": "^2.0.0", "@electron-toolkit/eslint-config-ts": "^2.0.0", "@electron-toolkit/tsconfig": "^1.0.1", + "@protobuf-ts/plugin": "^2.10.0", "@swc/core": "^1.4.16", "@types/auto-launch": "^5.0.5", "@types/color": "^3.0.6", @@ -97,6 +100,7 @@ "@types/react-dom": "^18.2.18", "@types/sound-play": "^1.1.3", "@types/user-agents": "^1.0.4", + "@types/ws": "^8.18.1", "@vitejs/plugin-react": "^4.2.1", "electron": "^31.7.7", "electron-builder": "^26.0.12", diff --git a/proto b/proto new file mode 160000 index 00000000..7a23620f --- /dev/null +++ b/proto @@ -0,0 +1 @@ +Subproject commit 7a23620f930f6fbb84c0abcaab5149a34ab4b4eb diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 00000000..b2b70bb5 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1 @@ +sonar.exclusions=src/main/generated/** diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 30a3aa4c..5b5579ae 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -370,10 +370,11 @@ "restart_to_install_update": "Restart Hydra to install the update", "notification_achievement_unlocked_title": "Achievement unlocked for {{game}}", "notification_achievement_unlocked_body": "{{achievement}} and other {{count}} were unlocked", - "new_friend_request_description": "You have received a new friend request", + "new_friend_request_description": "{{displayName}} sent you a friend request", "new_friend_request_title": "New friend request", "extraction_complete": "Extraction complete", - "game_extracted": "{{title}} extracted successfully" + "game_extracted": "{{title}} extracted successfully", + "friend_started_playing_game": "{{displayName}} started playing a game" }, "system_tray": { "open": "Open Hydra", diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index ef816d9f..f960b70e 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -371,7 +371,8 @@ "notification_achievement_unlocked_title": "Logro desbloqueado de {{game}}", "notification_achievement_unlocked_body": "{{achievement}} y otros {{count}} fueron desbloqueados", "new_friend_request_title": "Nueva solicitud de amistad", - "new_friend_request_description": "Has recibido una nueva solicitud de amistad" + "new_friend_request_description": "{{displayName}} te envió una solicitud de amistad", + "friend_started_playing_game": "{{displayName}} está jugando" }, "system_tray": { "open": "Abrir Hydra", diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index db6af6ed..bf9c6e46 100644 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -356,9 +356,10 @@ "new_update_available": "Versão {{version}} disponível", "restart_to_install_update": "Reinicie o Hydra para instalar a nova versão", "new_friend_request_title": "Novo pedido de amizade", - "new_friend_request_description": "Você recebeu um novo pedido de amizade", + "new_friend_request_description": "{{displayName}} te enviou um pedido de amizade", "extraction_complete": "Extração concluída", - "game_extracted": "{{title}} extraído com sucesso" + "game_extracted": "{{title}} extraído com sucesso", + "friend_started_playing_game": "{{displayName}} começou a jogar" }, "system_tray": { "open": "Abrir Hydra", diff --git a/src/locales/pt-PT/translation.json b/src/locales/pt-PT/translation.json index 01cb7d1b..6c32b35b 100644 --- a/src/locales/pt-PT/translation.json +++ b/src/locales/pt-PT/translation.json @@ -341,7 +341,8 @@ "new_update_available": "Versão {{version}} disponível", "restart_to_install_update": "Reinicia o Hydra para instalar a nova versão", "new_friend_request_title": "Novo pedido de amizade", - "new_friend_request_description": "Recebeste um novo pedido de amizade" + "new_friend_request_description": "{{displayName}} te enviou um pedido de amizade", + "friend_started_playing_game": "{{displayName}} começou a jogar" }, "system_tray": { "open": "Abrir o Hydra", diff --git a/src/main/events/auth/sign-out.ts b/src/main/events/auth/sign-out.ts index 2ab5e458..35a15d8b 100644 --- a/src/main/events/auth/sign-out.ts +++ b/src/main/events/auth/sign-out.ts @@ -1,5 +1,10 @@ import { registerEvent } from "../register-event"; -import { DownloadManager, HydraApi, gamesPlaytime } from "@main/services"; +import { + DownloadManager, + HydraApi, + WSClient, + gamesPlaytime, +} from "@main/services"; import { db, downloadsSublevel, gamesSublevel, levelKeys } from "@main/level"; const signOut = async (_event: Electron.IpcMainInvokeEvent) => { @@ -30,6 +35,8 @@ const signOut = async (_event: Electron.IpcMainInvokeEvent) => { databaseOperations, HydraApi.post("/auth/logout").catch(() => {}), ]); + + WSClient.close(); }; registerEvent("signOut", signOut); diff --git a/src/main/events/profile/sync-friend-requests.ts b/src/main/events/profile/sync-friend-requests.ts index 72fdceee..478c337f 100644 --- a/src/main/events/profile/sync-friend-requests.ts +++ b/src/main/events/profile/sync-friend-requests.ts @@ -1,34 +1,11 @@ -import { MAIN_LOOP_INTERVAL } from "@main/constants"; import { registerEvent } from "../register-event"; import { HydraApi, WindowManager } from "@main/services"; -import { publishNewFriendRequestNotification } from "@main/services/notifications"; import { UserNotLoggedInError } from "@shared"; import type { FriendRequestSync } from "@types"; -interface SyncState { - friendRequestCount: number | null; - tick: number; -} - -const ticksToUpdate = (2 * 60 * 1000) / MAIN_LOOP_INTERVAL; // 2 minutes - -const syncState: SyncState = { - friendRequestCount: null, - tick: 0, -}; - -const syncFriendRequests = async () => { +export const syncFriendRequests = async () => { return HydraApi.get(`/profile/friend-requests/sync`) .then((res) => { - if ( - syncState.friendRequestCount != null && - syncState.friendRequestCount < res.friendRequestCount - ) { - publishNewFriendRequestNotification(); - } - - syncState.friendRequestCount = res.friendRequestCount; - WindowManager.mainWindow?.webContents.send( "on-sync-friend-requests", res @@ -44,16 +21,4 @@ const syncFriendRequests = async () => { }); }; -const syncFriendRequestsEvent = async (_event: Electron.IpcMainInvokeEvent) => { - return syncFriendRequests(); -}; - -export const watchFriendRequests = async () => { - if (syncState.tick % ticksToUpdate === 0) { - await syncFriendRequests(); - } - - syncState.tick++; -}; - -registerEvent("syncFriendRequests", syncFriendRequestsEvent); +registerEvent("syncFriendRequests", syncFriendRequests); diff --git a/src/main/generated/envelope.ts b/src/main/generated/envelope.ts new file mode 100644 index 00000000..0a17a2af --- /dev/null +++ b/src/main/generated/envelope.ts @@ -0,0 +1,352 @@ +// @generated by protobuf-ts 2.10.0 +// @generated from protobuf file "envelope.proto" (syntax proto3) +// tslint:disable +import type { BinaryWriteOptions } from "@protobuf-ts/runtime"; +import type { IBinaryWriter } from "@protobuf-ts/runtime"; +import { WireType } from "@protobuf-ts/runtime"; +import type { BinaryReadOptions } from "@protobuf-ts/runtime"; +import type { IBinaryReader } from "@protobuf-ts/runtime"; +import { UnknownFieldHandler } from "@protobuf-ts/runtime"; +import type { PartialMessage } from "@protobuf-ts/runtime"; +import { reflectionMergePartial } from "@protobuf-ts/runtime"; +import { MessageType } from "@protobuf-ts/runtime"; +/** + * @generated from protobuf message FriendRequest + */ +export interface FriendRequest { + /** + * @generated from protobuf field: int32 friend_request_count = 1; + */ + friendRequestCount: number; + /** + * @generated from protobuf field: optional string sender_id = 2; + */ + senderId?: string; +} +/** + * @generated from protobuf message FriendGameSession + */ +export interface FriendGameSession { + /** + * @generated from protobuf field: string object_id = 1; + */ + objectId: string; + /** + * @generated from protobuf field: string shop = 2; + */ + shop: string; + /** + * @generated from protobuf field: string friend_id = 3; + */ + friendId: string; +} +/** + * @generated from protobuf message Envelope + */ +export interface Envelope { + /** + * @generated from protobuf oneof: payload + */ + payload: + | { + oneofKind: "friendRequest"; + /** + * @generated from protobuf field: FriendRequest friend_request = 1; + */ + friendRequest: FriendRequest; + } + | { + oneofKind: "friendGameSession"; + /** + * @generated from protobuf field: FriendGameSession friend_game_session = 2; + */ + friendGameSession: FriendGameSession; + } + | { + oneofKind: undefined; + }; +} +// @generated message type with reflection information, may provide speed optimized methods +class FriendRequest$Type extends MessageType { + constructor() { + super("FriendRequest", [ + { + no: 1, + name: "friend_request_count", + kind: "scalar", + T: 5 /*ScalarType.INT32*/, + }, + { + no: 2, + name: "sender_id", + kind: "scalar", + opt: true, + T: 9 /*ScalarType.STRING*/, + }, + ]); + } + create(value?: PartialMessage): FriendRequest { + const message = globalThis.Object.create(this.messagePrototype!); + message.friendRequestCount = 0; + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead( + reader: IBinaryReader, + length: number, + options: BinaryReadOptions, + target?: FriendRequest + ): FriendRequest { + let message = target ?? this.create(), + end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* int32 friend_request_count */ 1: + message.friendRequestCount = reader.int32(); + break; + case /* optional string sender_id */ 2: + message.senderId = reader.string(); + 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: FriendRequest, + writer: IBinaryWriter, + options: BinaryWriteOptions + ): IBinaryWriter { + /* int32 friend_request_count = 1; */ + if (message.friendRequestCount !== 0) + writer.tag(1, WireType.Varint).int32(message.friendRequestCount); + /* optional string sender_id = 2; */ + if (message.senderId !== undefined) + writer.tag(2, WireType.LengthDelimited).string(message.senderId); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)( + this.typeName, + message, + writer + ); + return writer; + } +} +/** + * @generated MessageType for protobuf message FriendRequest + */ +export const FriendRequest = new FriendRequest$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class FriendGameSession$Type extends MessageType { + constructor() { + super("FriendGameSession", [ + { no: 1, name: "object_id", kind: "scalar", T: 9 /*ScalarType.STRING*/ }, + { no: 2, name: "shop", kind: "scalar", T: 9 /*ScalarType.STRING*/ }, + { no: 3, name: "friend_id", kind: "scalar", T: 9 /*ScalarType.STRING*/ }, + ]); + } + create(value?: PartialMessage): FriendGameSession { + const message = globalThis.Object.create(this.messagePrototype!); + message.objectId = ""; + message.shop = ""; + message.friendId = ""; + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead( + reader: IBinaryReader, + length: number, + options: BinaryReadOptions, + target?: FriendGameSession + ): FriendGameSession { + let message = target ?? this.create(), + end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* string object_id */ 1: + message.objectId = reader.string(); + break; + case /* string shop */ 2: + message.shop = reader.string(); + break; + case /* string friend_id */ 3: + message.friendId = reader.string(); + 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: FriendGameSession, + writer: IBinaryWriter, + options: BinaryWriteOptions + ): IBinaryWriter { + /* string object_id = 1; */ + if (message.objectId !== "") + writer.tag(1, WireType.LengthDelimited).string(message.objectId); + /* string shop = 2; */ + if (message.shop !== "") + writer.tag(2, WireType.LengthDelimited).string(message.shop); + /* string friend_id = 3; */ + if (message.friendId !== "") + writer.tag(3, WireType.LengthDelimited).string(message.friendId); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)( + this.typeName, + message, + writer + ); + return writer; + } +} +/** + * @generated MessageType for protobuf message FriendGameSession + */ +export const FriendGameSession = new FriendGameSession$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class Envelope$Type extends MessageType { + constructor() { + super("Envelope", [ + { + no: 1, + name: "friend_request", + kind: "message", + oneof: "payload", + T: () => FriendRequest, + }, + { + no: 2, + name: "friend_game_session", + kind: "message", + oneof: "payload", + T: () => FriendGameSession, + }, + ]); + } + create(value?: PartialMessage): Envelope { + const message = globalThis.Object.create(this.messagePrototype!); + message.payload = { oneofKind: undefined }; + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead( + reader: IBinaryReader, + length: number, + options: BinaryReadOptions, + target?: Envelope + ): Envelope { + let message = target ?? this.create(), + end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* FriendRequest friend_request */ 1: + message.payload = { + oneofKind: "friendRequest", + friendRequest: FriendRequest.internalBinaryRead( + reader, + reader.uint32(), + options, + (message.payload as any).friendRequest + ), + }; + break; + case /* FriendGameSession friend_game_session */ 2: + message.payload = { + oneofKind: "friendGameSession", + friendGameSession: FriendGameSession.internalBinaryRead( + reader, + reader.uint32(), + options, + (message.payload as any).friendGameSession + ), + }; + 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: Envelope, + writer: IBinaryWriter, + options: BinaryWriteOptions + ): IBinaryWriter { + /* FriendRequest friend_request = 1; */ + if (message.payload.oneofKind === "friendRequest") + FriendRequest.internalBinaryWrite( + message.payload.friendRequest, + writer.tag(1, WireType.LengthDelimited).fork(), + options + ).join(); + /* FriendGameSession friend_game_session = 2; */ + if (message.payload.oneofKind === "friendGameSession") + FriendGameSession.internalBinaryWrite( + message.payload.friendGameSession, + writer.tag(2, WireType.LengthDelimited).fork(), + options + ).join(); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)( + this.typeName, + message, + writer + ); + return writer; + } +} +/** + * @generated MessageType for protobuf message Envelope + */ +export const Envelope = new Envelope$Type(); diff --git a/src/main/main.ts b/src/main/main.ts index fa20979a..0669d6f2 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -1,15 +1,21 @@ -import { Aria2, DownloadManager, Ludusavi, startMainLoop } from "./services"; -import { RealDebridClient } from "./services/download/real-debrid"; -import { HydraApi } from "./services/hydra-api"; -import { uploadGamesBatch } from "./services/library-sync"; import { downloadsSublevel } from "./level/sublevels/downloads"; import { sortBy } from "lodash-es"; import { Downloader } from "@shared"; import { levelKeys, db } from "./level"; import type { UserPreferences } from "@types"; -import { TorBoxClient } from "./services/download/torbox"; -import { CommonRedistManager } from "./services/common-redist-manager"; -import { SystemPath } from "./services/system-path"; +import { + WSClient, + SystemPath, + CommonRedistManager, + TorBoxClient, + RealDebridClient, + Aria2, + DownloadManager, + Ludusavi, + HydraApi, + uploadGamesBatch, + startMainLoop, +} from "@main/services"; export const loadState = async () => { SystemPath.checkIfPathsAreAvailable(); @@ -37,6 +43,7 @@ export const loadState = async () => { await HydraApi.setupApi().then(() => { uploadGamesBatch(); + WSClient.connect(); }); const downloads = await downloadsSublevel diff --git a/src/main/services/download/index.ts b/src/main/services/download/index.ts index b8396b66..f4e2eddc 100644 --- a/src/main/services/download/index.ts +++ b/src/main/services/download/index.ts @@ -1 +1,3 @@ export * from "./download-manager"; +export * from "./real-debrid"; +export * from "./torbox"; diff --git a/src/main/services/hydra-api.ts b/src/main/services/hydra-api.ts index 32beff38..122960c7 100644 --- a/src/main/services/hydra-api.ts +++ b/src/main/services/hydra-api.ts @@ -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/ws-client"; interface HydraApiOptions { needsAuth?: boolean; @@ -101,6 +102,8 @@ export class HydraApi { WindowManager.mainWindow.webContents.send("on-signin"); await clearGamesRemoteIds(); uploadGamesBatch(); + WSClient.close(); + WSClient.connect(); } } diff --git a/src/main/services/index.ts b/src/main/services/index.ts index 164b7b8d..3d1ab69e 100644 --- a/src/main/services/index.ts +++ b/src/main/services/index.ts @@ -12,3 +12,6 @@ export * from "./7zip"; export * from "./game-files-manager"; export * from "./common-redist-manager"; export * from "./aria2"; +export * from "./ws"; +export * from "./system-path"; +export * from "./library-sync"; diff --git a/src/main/services/main-loop.ts b/src/main/services/main-loop.ts index 64eee16d..795c471a 100644 --- a/src/main/services/main-loop.ts +++ b/src/main/services/main-loop.ts @@ -3,7 +3,6 @@ import { DownloadManager } from "./download"; import { watchProcesses } from "./process-watcher"; import { AchievementWatcherManager } from "./achievements/achievement-watcher-manager"; import { UpdateManager } from "./update-manager"; -import { watchFriendRequests } from "@main/events/profile/sync-friend-requests"; import { MAIN_LOOP_INTERVAL } from "@main/constants"; export const startMainLoop = async () => { @@ -11,7 +10,6 @@ export const startMainLoop = async () => { while (true) { await Promise.allSettled([ watchProcesses(), - watchFriendRequests(), DownloadManager.watchDownloads(), AchievementWatcherManager.watchAchievements(), DownloadManager.getSeedStatus(), diff --git a/src/main/services/notifications/index.ts b/src/main/services/notifications/index.ts index 673c6374..77866a47 100644 --- a/src/main/services/notifications/index.ts +++ b/src/main/services/notifications/index.ts @@ -10,7 +10,7 @@ import icon from "@resources/icon.png?asset"; import { NotificationOptions, toXmlString } from "./xml"; import { logger } from "../logger"; import { WindowManager } from "../window-manager"; -import type { Game, UserPreferences } from "@types"; +import type { Game, GameStats, UserPreferences, UserProfile } from "@types"; import { db, levelKeys } from "@main/level"; import { restartAndInstallUpdate } from "@main/events/autoupdater/restart-and-install-update"; import { SystemPath } from "../system-path"; @@ -81,7 +81,9 @@ export const publishNotificationUpdateReadyToInstall = async ( .show(); }; -export const publishNewFriendRequestNotification = async () => { +export const publishNewFriendRequestNotification = async ( + user: UserProfile +) => { const userPreferences = await db.get( levelKeys.userPreferences, { @@ -97,8 +99,27 @@ export const publishNewFriendRequestNotification = async () => { }), body: t("new_friend_request_description", { ns: "notifications", + displayName: user.displayName, }), - icon: trayIcon, + icon: user?.profileImageUrl + ? await downloadImage(user.profileImageUrl) + : trayIcon, + }).show(); +}; + +export const publishFriendStartedPlayingGameNotification = async ( + friend: UserProfile, + game: GameStats +) => { + new Notification({ + title: t("friend_started_playing_game", { + ns: "notifications", + displayName: friend.displayName, + }), + body: game.assets?.title, + icon: friend?.profileImageUrl + ? await downloadImage(friend.profileImageUrl) + : trayIcon, }).show(); }; diff --git a/src/main/services/ws/events/friend-game-session.ts b/src/main/services/ws/events/friend-game-session.ts new file mode 100644 index 00000000..930b4885 --- /dev/null +++ b/src/main/services/ws/events/friend-game-session.ts @@ -0,0 +1,17 @@ +import type { FriendGameSession } from "@main/generated/envelope"; +import { HydraApi } from "@main/services/hydra-api"; +import { publishFriendStartedPlayingGameNotification } from "@main/services/notifications"; +import { GameStats } from "@types"; + +export const friendGameSessionEvent = async (payload: FriendGameSession) => { + const [friend, gameStats] = await Promise.all([ + HydraApi.get(`/users/${payload.friendId}`), + HydraApi.get( + `/games/stats?objectId=${payload.objectId}&shop=steam` + ), + ]); + + if (friend && gameStats) { + publishFriendStartedPlayingGameNotification(friend, gameStats); + } +}; diff --git a/src/main/services/ws/events/friend-request.ts b/src/main/services/ws/events/friend-request.ts new file mode 100644 index 00000000..8faa38a5 --- /dev/null +++ b/src/main/services/ws/events/friend-request.ts @@ -0,0 +1,16 @@ +import type { FriendRequest } from "@main/generated/envelope"; +import { HydraApi } from "@main/services/hydra-api"; +import { publishNewFriendRequestNotification } from "@main/services/notifications"; +import { WindowManager } from "@main/services/window-manager"; + +export const friendRequestEvent = async (payload: FriendRequest) => { + WindowManager.mainWindow?.webContents.send("on-sync-friend-requests", { + friendRequestCount: payload.friendRequestCount, + }); + + const user = await HydraApi.get(`/users/${payload.senderId}`); + + if (user) { + publishNewFriendRequestNotification(user); + } +}; diff --git a/src/main/services/ws/index.ts b/src/main/services/ws/index.ts new file mode 100644 index 00000000..39bb25dc --- /dev/null +++ b/src/main/services/ws/index.ts @@ -0,0 +1 @@ +export * from "./ws-client"; diff --git a/src/main/services/ws/ws-client.ts b/src/main/services/ws/ws-client.ts new file mode 100644 index 00000000..632a3107 --- /dev/null +++ b/src/main/services/ws/ws-client.ts @@ -0,0 +1,119 @@ +import { WebSocket } from "ws"; +import { HydraApi } from "../hydra-api"; +import { Envelope } from "@main/generated/envelope"; +import { logger } from "../logger"; +import { friendRequestEvent } from "./events/friend-request"; +import { friendGameSessionEvent } from "./events/friend-game-session"; + +export class WSClient { + private static ws: WebSocket | null = null; + private static reconnectInterval = 1_000; + private static readonly maxReconnectInterval = 30_000; + private static shouldReconnect = true; + private static reconnecting = false; + private static heartbeatInterval: NodeJS.Timeout | null = null; + + static async connect() { + this.shouldReconnect = true; + + try { + const { token } = await HydraApi.post<{ token: string }>("/auth/ws"); + + this.ws = new WebSocket(import.meta.env.MAIN_VITE_WS_URL, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + this.ws.on("open", () => { + logger.info("WS connected"); + this.reconnectInterval = 1000; + this.reconnecting = false; + + this.startHeartbeat(); + }); + + this.ws.on("message", (message) => { + const envelope = Envelope.fromBinary( + new Uint8Array(Buffer.from(message.toString())) + ); + + logger.info("Received WS envelope:", envelope); + + if (envelope.payload.oneofKind === "friendRequest") { + friendRequestEvent(envelope.payload.friendRequest); + } + + if (envelope.payload.oneofKind === "friendGameSession") { + friendGameSessionEvent(envelope.payload.friendGameSession); + } + }); + + this.ws.on("close", () => this.handleDisconnect("close")); + this.ws.on("error", (err) => { + logger.error("WS error:", err); + this.handleDisconnect("error"); + }); + } catch (err) { + logger.error("Failed to connect WS:", err); + this.handleDisconnect("auth-failed"); + } + } + + private static handleDisconnect(reason: string) { + logger.warn(`WS disconnected due to ${reason}`); + + if (this.shouldReconnect) { + this.cleanupSocket(); + this.tryReconnect(); + } + } + + private static async tryReconnect() { + if (this.reconnecting) return; + this.reconnecting = true; + + logger.info(`Reconnecting in ${this.reconnectInterval / 1000}s...`); + + setTimeout(async () => { + try { + await this.connect(); + } catch (err) { + logger.error("Reconnect failed:", err); + this.reconnectInterval = Math.min( + this.reconnectInterval * 2, + this.maxReconnectInterval + ); + this.reconnecting = false; + this.tryReconnect(); + } + }, this.reconnectInterval); + } + + private static cleanupSocket() { + if (this.ws) { + this.ws.removeAllListeners(); + this.ws.close(); + this.ws = null; + } + + if (this.heartbeatInterval) { + clearInterval(this.heartbeatInterval); + this.heartbeatInterval = null; + } + } + + public static close() { + this.shouldReconnect = false; + this.reconnecting = false; + this.cleanupSocket(); + } + + private static startHeartbeat() { + this.heartbeatInterval = setInterval(() => { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.ping(); + } + }, 15_000); + } +} diff --git a/src/main/vite-env.d.ts b/src/main/vite-env.d.ts index e6a7d8cd..69ba99fb 100644 --- a/src/main/vite-env.d.ts +++ b/src/main/vite-env.d.ts @@ -6,6 +6,7 @@ interface ImportMetaEnv { readonly MAIN_VITE_AUTH_URL: string; readonly MAIN_VITE_CHECKOUT_URL: string; readonly MAIN_VITE_EXTERNAL_RESOURCES_URL: string; + readonly MAIN_VITE_WS_URL: string; } interface ImportMeta { diff --git a/src/renderer/src/app.tsx b/src/renderer/src/app.tsx index 8184b15d..f22e0361 100644 --- a/src/renderer/src/app.tsx +++ b/src/renderer/src/app.tsx @@ -20,7 +20,6 @@ import { setUserDetails, setProfileBackground, setGameRunning, - setFriendRequestCount, } from "@renderer/features"; import { useTranslation } from "react-i18next"; import { UserFriendModal } from "./pages/shared-modals/user-friend-modal"; @@ -155,16 +154,6 @@ export function App() { }); }, [fetchUserDetails, t, showSuccessToast, updateUserDetails]); - useEffect(() => { - const unsubscribe = window.electron.onSyncFriendRequests((result) => { - dispatch(setFriendRequestCount(result.friendRequestCount)); - }); - - return () => { - unsubscribe(); - }; - }, [dispatch]); - useEffect(() => { const unsubscribe = window.electron.onGamesRunning((gamesRunning) => { if (gamesRunning.length) { diff --git a/src/renderer/src/components/sidebar/sidebar.tsx b/src/renderer/src/components/sidebar/sidebar.tsx index 7584775a..f9c8c47a 100644 --- a/src/renderer/src/components/sidebar/sidebar.tsx +++ b/src/renderer/src/components/sidebar/sidebar.tsx @@ -23,6 +23,8 @@ import { sortBy } from "lodash-es"; import cn from "classnames"; import { CommentDiscussionIcon } from "@primer/octicons-react"; import { SidebarGameItem } from "./sidebar-game-item"; +import { setFriendRequestCount } from "@renderer/features/user-details-slice"; +import { useDispatch } from "react-redux"; const SIDEBAR_MIN_WIDTH = 200; const SIDEBAR_INITIAL_WIDTH = 250; @@ -33,6 +35,8 @@ const initialSidebarWidth = window.localStorage.getItem("sidebarWidth"); export function Sidebar() { const filterRef = useRef(null); + const dispatch = useDispatch(); + const { t } = useTranslation("sidebar"); const { library, updateLibrary } = useLibrary(); const navigate = useNavigate(); @@ -60,6 +64,16 @@ export function Sidebar() { updateLibrary(); }, [lastPacket?.gameId, updateLibrary]); + useEffect(() => { + const unsubscribe = window.electron.onSyncFriendRequests((result) => { + dispatch(setFriendRequestCount(result.friendRequestCount)); + }); + + return () => { + unsubscribe(); + }; + }, [dispatch]); + const sidebarRef = useRef(null); const cursorPos = useRef({ x: 0 }); diff --git a/src/renderer/src/pages/catalogue/game-item.scss b/src/renderer/src/pages/catalogue/game-item.scss index 83d182f4..f49bcbf8 100644 --- a/src/renderer/src/pages/catalogue/game-item.scss +++ b/src/renderer/src/pages/catalogue/game-item.scss @@ -25,6 +25,21 @@ border-right: 1px solid globals.$border-color; } + &__cover-placeholder { + display: flex; + align-items: center; + justify-content: center; + color: globals.$body-color; + width: 200px; + height: 103px; + background: linear-gradient( + 90deg, + rgba(255, 255, 255, 0.1) 0%, + rgba(255, 255, 255, 0.05) 50%, + rgba(255, 255, 255, 0.1) 100% + ); + } + &__details { display: flex; flex-direction: column; diff --git a/src/renderer/src/pages/catalogue/game-item.tsx b/src/renderer/src/pages/catalogue/game-item.tsx index 85fa2115..69915f20 100644 --- a/src/renderer/src/pages/catalogue/game-item.tsx +++ b/src/renderer/src/pages/catalogue/game-item.tsx @@ -7,6 +7,7 @@ import { useNavigate } from "react-router-dom"; import "./game-item.scss"; import { useTranslation } from "react-i18next"; import { CatalogueSearchResult } from "@types"; +import { QuestionIcon } from "@primer/octicons-react"; export interface GameItemProps { game: CatalogueSearchResult; @@ -43,18 +44,32 @@ export function GameItem({ game }: GameItemProps) { }); }, [game.genres, language, steamGenres]); + const libraryImage = useMemo(() => { + if (game.libraryImageUrl) { + return ( + {game.title} + ); + } + + return ( +
+ +
+ ); + }, [game.libraryImageUrl, game.title]); + return (