diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index b13a5da0..d46cafdb 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -224,5 +224,15 @@ }, "forms": { "toggle_password_visibility": "Toggle password visibility" + }, + "user_profile": { + "amount_hours": "{{amount}} hours", + "amount_minutes": "{{amount}} minutes", + "play_time": "Played for {{amount}}", + "last_time_played": "Last played {{period}}", + "sign_out": "Sign out", + "activity": "Recent Activity", + "library": "Library", + "total_play_time": "Total playtime: {{amount}}" } } diff --git a/src/locales/pt/translation.json b/src/locales/pt/translation.json index 67732144..8dd937d2 100644 --- a/src/locales/pt/translation.json +++ b/src/locales/pt/translation.json @@ -225,5 +225,15 @@ }, "forms": { "toggle_password_visibility": "Alternar visibilidade da senha" + }, + "user_profile": { + "amount_hours": "{{amount}} horas", + "amount_minutes": "{{amount}} minutos", + "play_time": "Jogado por {{amount}}", + "last_time_played": "Jogou {{period}}", + "sign_out": "Sair da conta", + "activity": "Atividade Recente", + "library": "Biblioteca", + "total_play_time": "Tempo total de jogo: {{amount}}" } } diff --git a/src/main/entity/game.entity.ts b/src/main/entity/game.entity.ts index 49ad2716..b8607b73 100644 --- a/src/main/entity/game.entity.ts +++ b/src/main/entity/game.entity.ts @@ -22,6 +22,9 @@ export class Game { @Column("text", { unique: true }) objectID: string; + @Column("text", { unique: true, nullable: true }) + remoteId: string | null; + @Column("text") title: string; diff --git a/src/main/entity/user-auth.ts b/src/main/entity/user-auth.ts index 61ca6738..6bfd6ad7 100644 --- a/src/main/entity/user-auth.ts +++ b/src/main/entity/user-auth.ts @@ -11,6 +11,15 @@ export class UserAuth { @PrimaryGeneratedColumn() id: number; + @Column("text", { default: "" }) + userId: string; + + @Column("text", { default: "" }) + displayName: string; + + @Column("text", { nullable: true }) + profileImageUrl: string | null; + @Column("text", { default: "" }) accessToken: string; diff --git a/src/main/events/auth/signout.ts b/src/main/events/auth/signout.ts new file mode 100644 index 00000000..4525fdb2 --- /dev/null +++ b/src/main/events/auth/signout.ts @@ -0,0 +1,12 @@ +import { userAuthRepository } from "@main/repository"; +import { registerEvent } from "../register-event"; +import { HydraApi } from "@main/services/hydra-api"; + +const signOut = async (_event: Electron.IpcMainInvokeEvent): Promise => { + await Promise.all([ + userAuthRepository.delete({ id: 1 }), + HydraApi.post("/auth/logout"), + ]); +}; + +registerEvent("signOut", signOut); diff --git a/src/main/events/index.ts b/src/main/events/index.ts index 86e74fcb..096b14a1 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -39,6 +39,9 @@ import "./download-sources/validate-download-source"; import "./download-sources/add-download-source"; import "./download-sources/remove-download-source"; import "./download-sources/sync-download-sources"; +import "./auth/signout"; +import "./user/get-user"; +import "./profile/get-me"; ipcMain.handle("ping", () => "pong"); ipcMain.handle("getVersion", () => app.getVersion()); diff --git a/src/main/events/library/add-game-to-library.ts b/src/main/events/library/add-game-to-library.ts index 1c7447e5..8187d41e 100644 --- a/src/main/events/library/add-game-to-library.ts +++ b/src/main/events/library/add-game-to-library.ts @@ -6,6 +6,7 @@ import type { GameShop } from "@types"; import { getFileBase64, getSteamAppAsset } from "@main/helpers"; import { steamGamesWorker } from "@main/workers"; +import { createGame } from "@main/services/library-sync"; const addGameToLibrary = async ( _event: Electron.IpcMainInvokeEvent, @@ -49,6 +50,21 @@ const addGameToLibrary = async ( } }); } + + const game = await gameRepository.findOne({ where: { objectID } }); + + createGame(game!).then((response) => { + const { + id: remoteId, + playTimeInMilliseconds, + lastTimePlayed, + } = response.data; + + gameRepository.update( + { objectID }, + { remoteId, playTimeInMilliseconds, lastTimePlayed } + ); + }); }); }; diff --git a/src/main/events/library/remove-game-from-library.ts b/src/main/events/library/remove-game-from-library.ts index 29a7a635..5d154aae 100644 --- a/src/main/events/library/remove-game-from-library.ts +++ b/src/main/events/library/remove-game-from-library.ts @@ -1,5 +1,7 @@ import { registerEvent } from "../register-event"; import { gameRepository } from "../../repository"; +import { HydraApi } from "@main/services/hydra-api"; +import { logger } from "@main/services"; const removeGameFromLibrary = async ( _event: Electron.IpcMainInvokeEvent, @@ -9,6 +11,18 @@ const removeGameFromLibrary = async ( { id: gameId }, { isDeleted: true, executablePath: null } ); + + removeRemoveGameFromLibrary(gameId).catch((err) => { + logger.error("removeRemoveGameFromLibrary", err); + }); +}; + +const removeRemoveGameFromLibrary = async (gameId: number) => { + const game = await gameRepository.findOne({ where: { id: gameId } }); + + if (game?.remoteId) { + HydraApi.delete(`/games/${game.remoteId}`); + } }; registerEvent("removeGameFromLibrary", removeGameFromLibrary); diff --git a/src/main/events/profile/get-me.ts b/src/main/events/profile/get-me.ts new file mode 100644 index 00000000..d68e5c81 --- /dev/null +++ b/src/main/events/profile/get-me.ts @@ -0,0 +1,32 @@ +import { registerEvent } from "../register-event"; +import { HydraApi } from "@main/services/hydra-api"; +import { UserProfile } from "@types"; +import { userAuthRepository } from "@main/repository"; +import { logger } from "@main/services"; + +const getMe = async ( + _event: Electron.IpcMainInvokeEvent +): Promise => { + return HydraApi.get(`/profile/me`) + .then((response) => { + const me = response.data; + + userAuthRepository.upsert( + { + id: 1, + displayName: me.displayName, + profileImageUrl: me.profileImageUrl, + userId: me.id, + }, + ["id"] + ); + + return me; + }) + .catch((err) => { + logger.error("getMe", err); + return userAuthRepository.findOne({ where: { id: 1 } }); + }); +}; + +registerEvent("getMe", getMe); diff --git a/src/main/events/torrenting/start-game-download.ts b/src/main/events/torrenting/start-game-download.ts index e2115349..0aa01fc9 100644 --- a/src/main/events/torrenting/start-game-download.ts +++ b/src/main/events/torrenting/start-game-download.ts @@ -12,6 +12,7 @@ import { DownloadManager } from "@main/services"; import { Not } from "typeorm"; import { steamGamesWorker } from "@main/workers"; +import { createGame } from "@main/services/library-sync"; const startGameDownload = async ( _event: Electron.IpcMainInvokeEvent, @@ -94,6 +95,19 @@ const startGameDownload = async ( }, }); + createGame(updatedGame!).then((response) => { + const { + id: remoteId, + playTimeInMilliseconds, + lastTimePlayed, + } = response.data; + + gameRepository.update( + { objectID }, + { remoteId, playTimeInMilliseconds, lastTimePlayed } + ); + }); + await downloadQueueRepository.delete({ game: { id: updatedGame!.id } }); await downloadQueueRepository.insert({ game: { id: updatedGame!.id } }); diff --git a/src/main/events/user/get-user.ts b/src/main/events/user/get-user.ts new file mode 100644 index 00000000..65d3e9e0 --- /dev/null +++ b/src/main/events/user/get-user.ts @@ -0,0 +1,56 @@ +import { registerEvent } from "../register-event"; +import { HydraApi } from "@main/services/hydra-api"; +import { steamGamesWorker } from "@main/workers"; +import { UserProfile } from "@types"; +import { convertSteamGameToCatalogueEntry } from "../helpers/search-games"; +import { getSteamAppAsset } from "@main/helpers"; + +const getUser = async ( + _event: Electron.IpcMainInvokeEvent, + userId: string +): Promise => { + try { + const response = await HydraApi.get(`/user/${userId}`); + const profile = response.data; + + const recentGames = await Promise.all( + profile.recentGames.map(async (game) => { + const steamGame = await steamGamesWorker.run(Number(game.objectId), { + name: "getById", + }); + const iconUrl = steamGame?.clientIcon + ? getSteamAppAsset("icon", game.objectId, steamGame.clientIcon) + : null; + + return { + ...game, + ...convertSteamGameToCatalogueEntry(steamGame), + iconUrl, + }; + }) + ); + + const libraryGames = await Promise.all( + profile.libraryGames.map(async (game) => { + const steamGame = await steamGamesWorker.run(Number(game.objectId), { + name: "getById", + }); + const iconUrl = steamGame?.clientIcon + ? getSteamAppAsset("icon", game.objectId, steamGame.clientIcon) + : null; + + return { + ...game, + ...convertSteamGameToCatalogueEntry(steamGame), + iconUrl, + }; + }) + ); + + return { ...profile, libraryGames, recentGames }; + } catch (err) { + return null; + } +}; + +registerEvent("getUser", getUser); diff --git a/src/main/index.ts b/src/main/index.ts index e2333789..53b68bba 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -7,6 +7,7 @@ import { DownloadManager, logger, WindowManager } from "@main/services"; import { dataSource } from "@main/data-source"; import * as resources from "@locales"; import { userPreferencesRepository } from "@main/repository"; +import { HydraApi } from "./services/hydra-api"; const { autoUpdater } = updater; @@ -83,7 +84,13 @@ app.on("second-instance", (_event, commandLine) => { } const [, path] = commandLine.pop()?.split("://") ?? []; - if (path) WindowManager.redirect(path); + if (path) { + if (path.startsWith("auth")) { + HydraApi.handleExternalAuth(path); + } else { + WindowManager.redirect(path); + } + } }); app.on("open-url", (_event, url) => { diff --git a/src/main/main.ts b/src/main/main.ts index 7e5692d5..1abd8c2f 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -10,6 +10,7 @@ import { fetchDownloadSourcesAndUpdate } from "./helpers"; import { publishNewRepacksNotifications } from "./services/notifications"; import { MoreThan } from "typeorm"; import { HydraApi } from "./services/hydra-api"; +import { uploadGamesBatch } from "./services/library-sync"; startMainLoop(); @@ -21,7 +22,9 @@ const loadState = async (userPreferences: UserPreferences | null) => { if (userPreferences?.realDebridApiToken) RealDebridClient.authorize(userPreferences?.realDebridApiToken); - HydraApi.setupApi(); + HydraApi.setupApi().then(async () => { + if (HydraApi.isLoggedIn()) uploadGamesBatch(); + }); const [nextQueueItem] = await downloadQueueRepository.find({ order: { diff --git a/src/main/services/hydra-api.ts b/src/main/services/hydra-api.ts index 8ae05bcf..87bb4077 100644 --- a/src/main/services/hydra-api.ts +++ b/src/main/services/hydra-api.ts @@ -1,5 +1,10 @@ import { userAuthRepository } from "@main/repository"; -import axios, { AxiosInstance } from "axios"; +import axios, { AxiosError, AxiosInstance } from "axios"; +import { WindowManager } from "./window-manager"; +import url from "url"; +import { uploadGamesBatch } from "./library-sync"; +import { clearGamesRemoteIds } from "./library-sync/clear-games-remote-id"; +import { logger } from "./logger"; export class HydraApi { private static instance: AxiosInstance; @@ -12,11 +17,80 @@ export class HydraApi { expirationTimestamp: 0, }; + static isLoggedIn() { + return this.userAuth.authToken !== ""; + } + + static async handleExternalAuth(auth: string) { + const { payload } = url.parse(auth, true).query; + + const decodedBase64 = atob(payload as string); + const jsonData = JSON.parse(decodedBase64); + + const { accessToken, expiresIn, refreshToken } = jsonData; + + const now = new Date(); + + const tokenExpirationTimestamp = + now.getTime() + expiresIn - this.EXPIRATION_OFFSET_IN_MS; + + this.userAuth = { + authToken: accessToken, + refreshToken: refreshToken, + expirationTimestamp: tokenExpirationTimestamp, + }; + + await userAuthRepository.upsert( + { + id: 1, + accessToken, + tokenExpirationTimestamp, + refreshToken, + }, + ["id"] + ); + + if (WindowManager.mainWindow) { + WindowManager.mainWindow.webContents.send("on-signin"); + await clearGamesRemoteIds(); + uploadGamesBatch(); + } + } + static async setupApi() { this.instance = axios.create({ baseURL: import.meta.env.MAIN_VITE_API_URL, }); + this.instance.interceptors.request.use( + (request) => { + logger.log(" ---- REQUEST -----"); + logger.log(request.method, request.url, request.data); + return request; + }, + (error) => { + logger.log("request error", error); + return Promise.reject(error); + } + ); + + this.instance.interceptors.response.use( + (response) => { + logger.log(" ---- RESPONSE -----"); + logger.log( + response.status, + response.config.method, + response.config.url, + response.data + ); + return response; + }, + (error) => { + logger.error("response error", error); + return Promise.reject(error); + } + ); + const userAuth = await userAuthRepository.findOne({ where: { id: 1 }, }); @@ -29,28 +103,57 @@ export class HydraApi { } private static async revalidateAccessTokenIfExpired() { + if (!this.userAuth.authToken) { + userAuthRepository.delete({ id: 1 }); + logger.error("user is not logged in"); + throw new Error("user is not logged in"); + } + const now = new Date(); if (this.userAuth.expirationTimestamp < now.getTime()) { - const response = await this.instance.post(`/auth/refresh`, { - refreshToken: this.userAuth.refreshToken, - }); + try { + const response = await this.instance.post(`/auth/refresh`, { + refreshToken: this.userAuth.refreshToken, + }); - const { accessToken, expiresIn } = response.data; + const { accessToken, expiresIn } = response.data; - const tokenExpirationTimestamp = - now.getTime() + expiresIn - this.EXPIRATION_OFFSET_IN_MS; + const tokenExpirationTimestamp = + now.getTime() + expiresIn - this.EXPIRATION_OFFSET_IN_MS; - this.userAuth.authToken = accessToken; - this.userAuth.expirationTimestamp = tokenExpirationTimestamp; + this.userAuth.authToken = accessToken; + this.userAuth.expirationTimestamp = tokenExpirationTimestamp; - userAuthRepository.upsert( - { - id: 1, - accessToken, - tokenExpirationTimestamp, - }, - ["id"] - ); + userAuthRepository.upsert( + { + id: 1, + accessToken, + tokenExpirationTimestamp, + }, + ["id"] + ); + } catch (err) { + if ( + err instanceof AxiosError && + (err?.response?.status === 401 || err?.response?.status === 403) + ) { + this.userAuth = { + authToken: "", + expirationTimestamp: 0, + refreshToken: "", + }; + + userAuthRepository.delete({ id: 1 }); + + if (WindowManager.mainWindow) { + WindowManager.mainWindow.webContents.send("on-signout"); + } + + logger.log("user refresh token expired"); + } + + throw err; + } } } @@ -72,13 +175,18 @@ export class HydraApi { return this.instance.post(url, data, this.getAxiosConfig()); } - static async put(url, data?: any) { + static async put(url: string, data?: any) { await this.revalidateAccessTokenIfExpired(); return this.instance.put(url, data, this.getAxiosConfig()); } - static async patch(url, data?: any) { + static async patch(url: string, data?: any) { await this.revalidateAccessTokenIfExpired(); return this.instance.patch(url, data, this.getAxiosConfig()); } + + static async delete(url: string) { + await this.revalidateAccessTokenIfExpired(); + return this.instance.delete(url, this.getAxiosConfig()); + } } diff --git a/src/main/services/library-sync/clear-games-remote-id.ts b/src/main/services/library-sync/clear-games-remote-id.ts new file mode 100644 index 00000000..f26d65f1 --- /dev/null +++ b/src/main/services/library-sync/clear-games-remote-id.ts @@ -0,0 +1,5 @@ +import { gameRepository } from "@main/repository"; + +export const clearGamesRemoteIds = () => { + return gameRepository.update({}, { remoteId: null }); +}; diff --git a/src/main/services/library-sync/create-game.ts b/src/main/services/library-sync/create-game.ts new file mode 100644 index 00000000..6e56a3de --- /dev/null +++ b/src/main/services/library-sync/create-game.ts @@ -0,0 +1,11 @@ +import { Game } from "@main/entity"; +import { HydraApi } from "../hydra-api"; + +export const createGame = async (game: Game) => { + return HydraApi.post(`/games`, { + objectId: game.objectID, + playTimeInMilliseconds: Math.trunc(game.playTimeInMilliseconds), + shop: game.shop, + lastTimePlayed: game.lastTimePlayed, + }); +}; diff --git a/src/main/services/library-sync/index.ts b/src/main/services/library-sync/index.ts new file mode 100644 index 00000000..24fafc03 --- /dev/null +++ b/src/main/services/library-sync/index.ts @@ -0,0 +1,4 @@ +export * from "./merge-with-remote-games"; +export * from "./upload-games-batch"; +export * from "./update-game-playtime"; +export * from "./create-game"; diff --git a/src/main/services/library-sync/merge-with-remote-games.ts b/src/main/services/library-sync/merge-with-remote-games.ts new file mode 100644 index 00000000..e31310fc --- /dev/null +++ b/src/main/services/library-sync/merge-with-remote-games.ts @@ -0,0 +1,69 @@ +import { gameRepository } from "@main/repository"; +import { HydraApi } from "../hydra-api"; +import { steamGamesWorker } from "@main/workers"; +import { getSteamAppAsset } from "@main/helpers"; +import { logger } from "../logger"; +import { AxiosError } from "axios"; + +export const mergeWithRemoteGames = async () => { + try { + const games = await HydraApi.get("/games"); + + for (const game of games.data) { + const localGame = await gameRepository.findOne({ + where: { + objectID: game.objectId, + }, + }); + + if (localGame) { + const updatedLastTimePlayed = + localGame.lastTimePlayed == null || + new Date(game.lastTimePlayed) > localGame.lastTimePlayed + ? new Date(game.lastTimePlayed) + : localGame.lastTimePlayed; + + const updatedPlayTime = + localGame.playTimeInMilliseconds < game.playTimeInMilliseconds + ? game.playTimeInMilliseconds + : localGame.playTimeInMilliseconds; + + gameRepository.update( + { + objectID: game.objectId, + shop: "steam", + lastTimePlayed: updatedLastTimePlayed, + playTimeInMilliseconds: updatedPlayTime, + }, + { remoteId: game.id } + ); + } else { + const steamGame = await steamGamesWorker.run(Number(game.objectId), { + name: "getById", + }); + + if (steamGame) { + const iconUrl = steamGame?.clientIcon + ? getSteamAppAsset("icon", game.objectId, steamGame.clientIcon) + : null; + + gameRepository.insert({ + objectID: game.objectId, + title: steamGame?.name, + remoteId: game.id, + shop: game.shop, + iconUrl, + lastTimePlayed: game.lastTimePlayed, + playTimeInMilliseconds: game.playTimeInMilliseconds, + }); + } + } + } + } catch (err) { + if (err instanceof AxiosError) { + logger.error("getRemoteGames", err.response, err.message); + } else { + logger.error("getRemoteGames", err); + } + } +}; diff --git a/src/main/services/library-sync/update-game-playtime.ts b/src/main/services/library-sync/update-game-playtime.ts new file mode 100644 index 00000000..efedf47c --- /dev/null +++ b/src/main/services/library-sync/update-game-playtime.ts @@ -0,0 +1,13 @@ +import { Game } from "@main/entity"; +import { HydraApi } from "../hydra-api"; + +export const updateGamePlaytime = async ( + game: Game, + delta: number, + lastTimePlayed: Date +) => { + return HydraApi.put(`/games/${game.remoteId}`, { + playTimeDeltaInSeconds: Math.trunc(delta), + lastTimePlayed, + }); +}; diff --git a/src/main/services/library-sync/upload-games-batch.ts b/src/main/services/library-sync/upload-games-batch.ts new file mode 100644 index 00000000..63eee82a --- /dev/null +++ b/src/main/services/library-sync/upload-games-batch.ts @@ -0,0 +1,44 @@ +import { gameRepository } from "@main/repository"; +import { chunk } from "lodash-es"; +import { IsNull } from "typeorm"; +import { HydraApi } from "../hydra-api"; +import { logger } from "../logger"; +import { AxiosError } from "axios"; + +import { mergeWithRemoteGames } from "./merge-with-remote-games"; +import { WindowManager } from "../window-manager"; + +export const uploadGamesBatch = async () => { + try { + const games = await gameRepository.find({ + where: { remoteId: IsNull(), isDeleted: false }, + }); + + const gamesChunks = chunk(games, 200); + + for (const chunk of gamesChunks) { + await HydraApi.post( + "/games/batch", + chunk.map((game) => { + return { + objectId: game.objectID, + playTimeInMilliseconds: Math.trunc(game.playTimeInMilliseconds), + shop: game.shop, + lastTimePlayed: game.lastTimePlayed, + }; + }) + ); + } + + await mergeWithRemoteGames(); + + if (WindowManager.mainWindow) + WindowManager.mainWindow.webContents.send("on-library-batch-complete"); + } catch (err) { + if (err instanceof AxiosError) { + logger.error("uploadGamesBatch", err.response, err.message); + } else { + logger.error("uploadGamesBatch", err); + } + } +}; diff --git a/src/main/services/process-watcher.ts b/src/main/services/process-watcher.ts index ea1b6355..de3af727 100644 --- a/src/main/services/process-watcher.ts +++ b/src/main/services/process-watcher.ts @@ -4,8 +4,12 @@ import { IsNull, Not } from "typeorm"; import { gameRepository } from "@main/repository"; import { getProcesses } from "@main/helpers"; import { WindowManager } from "./window-manager"; +import { createGame, updateGamePlaytime } from "./library-sync"; -const gamesPlaytime = new Map(); +const gamesPlaytime = new Map< + number, + { lastTick: number; firstTick: number } +>(); export const watchProcesses = async () => { const games = await gameRepository.find({ @@ -37,7 +41,9 @@ export const watchProcesses = async () => { if (gameProcess) { if (gamesPlaytime.has(game.id)) { - const zero = gamesPlaytime.get(game.id) ?? 0; + const gamePlaytime = gamesPlaytime.get(game.id)!; + + const zero = gamePlaytime.lastTick; const delta = performance.now() - zero; if (WindowManager.mainWindow) { @@ -48,12 +54,45 @@ export const watchProcesses = async () => { playTimeInMilliseconds: game.playTimeInMilliseconds + delta, lastTimePlayed: new Date(), }); - } - gamesPlaytime.set(game.id, performance.now()); + gamesPlaytime.set(game.id, { + ...gamePlaytime, + lastTick: performance.now(), + }); + } else { + if (game.remoteId) { + updateGamePlaytime(game, 0, new Date()); + } else { + createGame({ ...game, lastTimePlayed: new Date() }).then( + (response) => { + const { id: remoteId } = response.data; + gameRepository.update({ objectID: game.objectID }, { remoteId }); + } + ); + } + + gamesPlaytime.set(game.id, { + lastTick: performance.now(), + firstTick: performance.now(), + }); + } } else if (gamesPlaytime.has(game.id)) { + const gamePlaytime = gamesPlaytime.get(game.id)!; gamesPlaytime.delete(game.id); + if (game.remoteId) { + updateGamePlaytime( + game, + performance.now() - gamePlaytime.firstTick, + game.lastTimePlayed! + ); + } else { + createGame(game).then((response) => { + const { id: remoteId } = response.data; + gameRepository.update({ objectID: game.objectID }, { remoteId }); + }); + } + if (WindowManager.mainWindow) { WindowManager.mainWindow.webContents.send("on-game-close", game.id); } diff --git a/src/preload/index.ts b/src/preload/index.ts index 3a47ba82..883091f6 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -96,6 +96,12 @@ contextBridge.exposeInMainWorld("electron", { ipcRenderer.on("on-game-close", listener); return () => ipcRenderer.removeListener("on-game-close", listener); }, + onLibraryBatchComplete: (cb: () => void) => { + const listener = (_event: Electron.IpcRendererEvent) => cb(); + ipcRenderer.on("on-library-batch-complete", listener); + return () => + ipcRenderer.removeListener("on-library-batch-complete", listener); + }, /* Hardware */ getDiskFreeSpace: (path: string) => @@ -125,4 +131,23 @@ contextBridge.exposeInMainWorld("electron", { }, checkForUpdates: () => ipcRenderer.invoke("checkForUpdates"), restartAndInstallUpdate: () => ipcRenderer.invoke("restartAndInstallUpdate"), + + /* Profile */ + getMe: () => ipcRenderer.invoke("getMe"), + + /* User */ + getUser: (userId: string) => ipcRenderer.invoke("getUser", userId), + + /* Auth */ + signOut: () => ipcRenderer.invoke("signOut"), + onSignIn: (cb: () => void) => { + const listener = (_event: Electron.IpcRendererEvent) => cb(); + ipcRenderer.on("on-signin", listener); + return () => ipcRenderer.removeListener("on-signin", listener); + }, + onSignOut: (cb: () => void) => { + const listener = (_event: Electron.IpcRendererEvent) => cb(); + ipcRenderer.on("on-signout", listener); + return () => ipcRenderer.removeListener("on-signout", listener); + }, }); diff --git a/src/renderer/index.html b/src/renderer/index.html index 6a37ec53..85a75bdc 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -6,7 +6,7 @@ Hydra diff --git a/src/renderer/src/app.tsx b/src/renderer/src/app.tsx index 67df3084..916105e7 100644 --- a/src/renderer/src/app.tsx +++ b/src/renderer/src/app.tsx @@ -7,6 +7,7 @@ import { useAppSelector, useDownload, useLibrary, + useUserDetails, } from "@renderer/hooks"; import * as styles from "./app.css"; @@ -30,15 +31,19 @@ export function App() { const { clearDownload, setLastPacket } = useDownload(); + const { updateUser, clearUser } = useUserDetails(); + const dispatch = useAppDispatch(); const navigate = useNavigate(); const location = useLocation(); const search = useAppSelector((state) => state.search.value); + const draggingDisabled = useAppSelector( (state) => state.window.draggingDisabled ); + const toast = useAppSelector((state) => state.toast); useEffect(() => { @@ -67,6 +72,28 @@ export function App() { }; }, [clearDownload, setLastPacket, updateLibrary]); + useEffect(() => { + updateUser(); + }, [updateUser]); + + useEffect(() => { + const listeners = [ + window.electron.onSignIn(() => { + updateUser(); + }), + window.electron.onLibraryBatchComplete(() => { + updateLibrary(); + }), + window.electron.onSignOut(() => { + clearUser(); + }), + ]; + + return () => { + listeners.forEach((unsubscribe) => unsubscribe()); + }; + }, [updateUser, updateLibrary, clearUser]); + const handleSearch = useCallback( (query: string) => { dispatch(setSearch(query)); diff --git a/src/renderer/src/components/header/header.tsx b/src/renderer/src/components/header/header.tsx index 046a6a4f..8b3ac2fa 100644 --- a/src/renderer/src/components/header/header.tsx +++ b/src/renderer/src/components/header/header.tsx @@ -39,6 +39,7 @@ export function Header({ onSearch, onClear, search }: HeaderProps) { const title = useMemo(() => { if (location.pathname.startsWith("/game")) return headerTitle; + if (location.pathname.startsWith("/profile")) return headerTitle; if (location.pathname.startsWith("/search")) return t("search_results"); return t(pathTitle[location.pathname]); diff --git a/src/renderer/src/components/sidebar/sidebar-profile.tsx b/src/renderer/src/components/sidebar/sidebar-profile.tsx new file mode 100644 index 00000000..c81b24fb --- /dev/null +++ b/src/renderer/src/components/sidebar/sidebar-profile.tsx @@ -0,0 +1,71 @@ +import { useNavigate } from "react-router-dom"; +import { PersonIcon } from "@primer/octicons-react"; +import * as styles from "./sidebar.css"; +import { useUserDetails } from "@renderer/hooks"; +import { useMemo } from "react"; + +export function SidebarProfile() { + const navigate = useNavigate(); + + const { userDetails, profileBackground } = useUserDetails(); + + const handleClickProfile = () => { + navigate(`/user/${userDetails!.id}`); + }; + + const handleClickLogin = () => { + window.electron.openExternal("https://auth.hydra.losbroxas.org"); + }; + + const profileButtonBackground = useMemo(() => { + if (profileBackground) return profileBackground; + return undefined; + }, [profileBackground]); + + if (userDetails == null) { + return ( + <> + + + ); + } + + return ( + <> + + + ); +} diff --git a/src/renderer/src/components/sidebar/sidebar.css.ts b/src/renderer/src/components/sidebar/sidebar.css.ts index 6677bf63..5a96e87a 100644 --- a/src/renderer/src/components/sidebar/sidebar.css.ts +++ b/src/renderer/src/components/sidebar/sidebar.css.ts @@ -149,7 +149,9 @@ export const profileAvatar = style({ justifyContent: "center", alignItems: "center", backgroundColor: vars.color.background, + border: `solid 1px ${vars.color.border}`, position: "relative", + objectFit: "cover", }); export const profileButtonInformation = style({ diff --git a/src/renderer/src/components/sidebar/sidebar.tsx b/src/renderer/src/components/sidebar/sidebar.tsx index 897d6b18..5fb20577 100644 --- a/src/renderer/src/components/sidebar/sidebar.tsx +++ b/src/renderer/src/components/sidebar/sidebar.tsx @@ -13,7 +13,7 @@ import * as styles from "./sidebar.css"; import { buildGameDetailsPath } from "@renderer/helpers"; import SteamLogo from "@renderer/assets/steam-logo.svg?react"; -import { PersonIcon } from "@primer/octicons-react"; +import { SidebarProfile } from "./sidebar-profile"; const SIDEBAR_MIN_WIDTH = 200; const SIDEBAR_INITIAL_WIDTH = 250; @@ -154,18 +154,7 @@ export function Sidebar() { maxWidth: sidebarWidth, }} > - +
Promise; onPlaytime: (cb: (gameId: number) => void) => () => Electron.IpcRenderer; onGameClose: (cb: (gameId: number) => void) => () => Electron.IpcRenderer; + onLibraryBatchComplete: (cb: () => void) => () => Electron.IpcRenderer; /* User preferences */ getUserPreferences: () => Promise; @@ -109,6 +111,17 @@ declare global { ) => () => Electron.IpcRenderer; checkForUpdates: () => Promise; restartAndInstallUpdate: () => Promise; + + /* Auth */ + signOut: () => Promise; + onSignIn: (cb: () => void) => () => Electron.IpcRenderer; + onSignOut: (cb: () => void) => () => Electron.IpcRenderer; + + /* User */ + getUser: (userId: string) => Promise; + + /* Profile */ + getMe: () => Promise; } interface Window { diff --git a/src/renderer/src/features/index.ts b/src/renderer/src/features/index.ts index 12853619..f3132520 100644 --- a/src/renderer/src/features/index.ts +++ b/src/renderer/src/features/index.ts @@ -4,3 +4,4 @@ export * from "./use-preferences-slice"; export * from "./download-slice"; export * from "./window-slice"; export * from "./toast-slice"; +export * from "./user-details-slice"; diff --git a/src/renderer/src/features/user-details-slice.ts b/src/renderer/src/features/user-details-slice.ts new file mode 100644 index 00000000..af14ce56 --- /dev/null +++ b/src/renderer/src/features/user-details-slice.ts @@ -0,0 +1,32 @@ +import { PayloadAction, createSlice } from "@reduxjs/toolkit"; +import type { UserDetails } from "@types"; + +export interface UserDetailsState { + userDetails: UserDetails | null; + profileBackground: null | string; +} + +const initialState: UserDetailsState = { + userDetails: null, + profileBackground: null, +}; + +export const userDetailsSlice = createSlice({ + name: "user-details", + initialState, + reducers: { + setUserDetails: (state, action: PayloadAction) => { + state.userDetails = action.payload; + }, + setProfileBackground: (state, action: PayloadAction) => { + state.profileBackground = action.payload; + }, + clearUserDetails: (state) => { + state.userDetails = null; + state.profileBackground = null; + }, + }, +}); + +export const { setUserDetails, setProfileBackground, clearUserDetails } = + userDetailsSlice.actions; diff --git a/src/renderer/src/helpers.ts b/src/renderer/src/helpers.ts index 9d5a4700..19d1969c 100644 --- a/src/renderer/src/helpers.ts +++ b/src/renderer/src/helpers.ts @@ -1,5 +1,7 @@ import type { GameShop } from "@types"; +import Color from "color"; + export const steamUrlBuilder = { library: (objectID: string) => `https://steamcdn-a.akamaihd.net/steam/apps/${objectID}/header.jpg`, @@ -40,3 +42,6 @@ export const buildGameDetailsPath = ( const searchParams = new URLSearchParams({ title: game.title, ...params }); return `/game/${game.shop}/${game.objectID}?${searchParams.toString()}`; }; + +export const darkenColor = (color: string, amount: number) => + new Color(color).darken(amount).toString(); diff --git a/src/renderer/src/hooks/index.ts b/src/renderer/src/hooks/index.ts index 563e3ff1..5bc287b8 100644 --- a/src/renderer/src/hooks/index.ts +++ b/src/renderer/src/hooks/index.ts @@ -3,3 +3,4 @@ export * from "./use-library"; export * from "./use-date"; export * from "./use-toast"; export * from "./redux"; +export * from "./use-user-details"; diff --git a/src/renderer/src/hooks/use-user-details.ts b/src/renderer/src/hooks/use-user-details.ts new file mode 100644 index 00000000..fe4c0505 --- /dev/null +++ b/src/renderer/src/hooks/use-user-details.ts @@ -0,0 +1,57 @@ +import { useCallback } from "react"; +import { average } from "color.js"; + +import { useAppDispatch, useAppSelector } from "./redux"; +import { + clearUserDetails, + setProfileBackground, + setUserDetails, +} from "@renderer/features"; +import { darkenColor } from "@renderer/helpers"; + +export function useUserDetails() { + const dispatch = useAppDispatch(); + + const { userDetails, profileBackground } = useAppSelector( + (state) => state.userDetails + ); + + const clearUser = useCallback(async () => { + dispatch(clearUserDetails()); + }, [dispatch]); + + const signOut = useCallback(async () => { + clearUser(); + + return window.electron.signOut(); + }, [clearUser]); + + const updateUser = useCallback(async () => { + return window.electron.getMe().then(async (userDetails) => { + if (userDetails) { + dispatch(setUserDetails(userDetails)); + + if (userDetails.profileImageUrl) { + const output = await average(userDetails.profileImageUrl, { + amount: 1, + format: "hex", + }); + + dispatch( + setProfileBackground( + `linear-gradient(135deg, ${darkenColor(output as string, 0.6)}, ${darkenColor(output as string, 0.7)})` + ) + ); + } + } + }); + }, [dispatch]); + + return { + userDetails, + updateUser, + signOut, + clearUser, + profileBackground, + }; +} diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index a457592e..e8fa203b 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -27,6 +27,7 @@ import { import { store } from "./store"; import * as resources from "@locales"; +import { User } from "./pages/user/user"; i18n .use(LanguageDetector) @@ -54,6 +55,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render( + diff --git a/src/renderer/src/pages/user/user-content.tsx b/src/renderer/src/pages/user/user-content.tsx new file mode 100644 index 00000000..600f9128 --- /dev/null +++ b/src/renderer/src/pages/user/user-content.tsx @@ -0,0 +1,205 @@ +import { UserGame, UserProfile } from "@types"; +import cn from "classnames"; + +import * as styles from "./user.css"; +import { SPACING_UNIT, vars } from "@renderer/theme.css"; +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import SteamLogo from "@renderer/assets/steam-logo.svg?react"; +import { useDate, useUserDetails } from "@renderer/hooks"; +import { useNavigate } from "react-router-dom"; +import { buildGameDetailsPath } from "@renderer/helpers"; +import { PersonIcon } from "@primer/octicons-react"; +import { Button } from "@renderer/components"; + +const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120; + +export interface ProfileContentProps { + userProfile: UserProfile; +} + +export function UserContent({ userProfile }: ProfileContentProps) { + const { t, i18n } = useTranslation("user_profile"); + + const { userDetails, profileBackground, signOut } = useUserDetails(); + + const navigate = useNavigate(); + + const numberFormatter = useMemo(() => { + return new Intl.NumberFormat(i18n.language, { + maximumFractionDigits: 0, + }); + }, [i18n.language]); + + const { formatDistance } = useDate(); + + const formatPlayTime = () => { + const seconds = userProfile.libraryGames.reduce( + (acc, game) => acc + game.playTimeInSeconds, + 0 + ); + const minutes = seconds / 60; + + if (minutes < MAX_MINUTES_TO_SHOW_IN_PLAYTIME) { + return t("amount_minutes", { + amount: minutes.toFixed(0), + }); + } + + const hours = minutes / 60; + return t("amount_hours", { amount: numberFormatter.format(hours) }); + }; + + const handleGameClick = (game: UserGame) => { + navigate(buildGameDetailsPath(game)); + }; + + const handleSignout = async () => { + await signOut(); + navigate("/"); + }; + + const isMe = userDetails?.id == userProfile.id; + + const profileContentBoxBackground = useMemo(() => { + if (profileBackground) return profileBackground; + /* TODO: Render background colors for other users */ + return undefined; + }, [profileBackground]); + + return ( + <> +
+
+ {userProfile.profileImageUrl ? ( + {userProfile.displayName} + ) : ( + + )} +
+ +
+

{userProfile.displayName}

+
+ + {isMe && ( +
+ +
+ )} +
+ +
+
+
+

{t("activity")}

+
+
+ {userProfile.recentGames.map((game) => { + return ( + + ); + })} +
+
+ +
+
+

{t("library")}

+ +
+

+ {userProfile.libraryGames.length} +

+
+ {t("total_play_time", { amount: formatPlayTime() })} +
+ {userProfile.libraryGames.map((game) => { + return ( + + ); + })} +
+
+
+ + ); +} diff --git a/src/renderer/src/pages/user/user-skeleton.tsx b/src/renderer/src/pages/user/user-skeleton.tsx new file mode 100644 index 00000000..442322cc --- /dev/null +++ b/src/renderer/src/pages/user/user-skeleton.tsx @@ -0,0 +1,14 @@ +import Skeleton from "react-loading-skeleton"; +import * as styles from "./user.css"; + +export const UserSkeleton = () => { + return ( + <> + +
+ + +
+ + ); +}; diff --git a/src/renderer/src/pages/user/user.css.ts b/src/renderer/src/pages/user/user.css.ts new file mode 100644 index 00000000..6ec6b820 --- /dev/null +++ b/src/renderer/src/pages/user/user.css.ts @@ -0,0 +1,138 @@ +import { SPACING_UNIT, vars } from "../../theme.css"; +import { style } from "@vanilla-extract/css"; + +export const wrapper = style({ + padding: "24px", + width: "100%", + display: "flex", + flexDirection: "column", + gap: `${SPACING_UNIT * 3}px`, +}); + +export const profileContentBox = style({ + display: "flex", + gap: `${SPACING_UNIT * 3}px`, + padding: `${SPACING_UNIT * 4}px ${SPACING_UNIT * 2}px`, + alignItems: "center", + borderRadius: "4px", + border: `solid 1px ${vars.color.border}`, + width: "100%", + overflow: "hidden", + boxShadow: "0px 0px 15px 0px rgba(0, 0, 0, 0.7)", + transition: "all ease 0.3s", +}); + +export const profileAvatarContainer = style({ + width: "96px", + height: "96px", + borderRadius: "50%", + display: "flex", + justifyContent: "center", + alignItems: "center", + backgroundColor: vars.color.background, + position: "relative", + overflow: "hidden", + border: `solid 1px ${vars.color.border}`, + boxShadow: "0px 0px 5px 0px rgba(0, 0, 0, 0.7)", +}); + +export const profileAvatar = style({ + width: "96px", + height: "96px", + objectFit: "cover", +}); + +export const profileInformation = style({ + display: "flex", + flexDirection: "column", + alignItems: "flex-start", + color: "#c0c1c7", +}); + +export const profileContent = style({ + display: "flex", + flexDirection: "row", + gap: `${SPACING_UNIT * 4}px`, +}); + +export const profileGameSection = style({ + width: "100%", + display: "flex", + flexDirection: "column", + gap: `${SPACING_UNIT * 2}px`, +}); + +export const contentSidebar = style({ + width: "100%", + "@media": { + "(min-width: 768px)": { + width: "100%", + maxWidth: "150px", + }, + "(min-width: 1024px)": { + maxWidth: "250px", + width: "100%", + }, + "(min-width: 1280px)": { + width: "100%", + maxWidth: "350px", + }, + }, +}); + +export const feedGameIcon = style({ + height: "100%", + display: "flex", + justifyContent: "center", + alignItems: "center", + position: "relative", +}); + +export const libraryGameIcon = style({ + height: "100%", + borderRadius: "4px", + display: "flex", + justifyContent: "center", + alignItems: "center", + position: "relative", +}); + +export const feedItem = style({ + color: vars.color.body, + display: "flex", + flexDirection: "row", + gap: `${SPACING_UNIT * 2}px`, + width: "100%", + height: "72px", + transition: "all ease 0.2s", + cursor: "pointer", + zIndex: "1", + ":hover": { + backgroundColor: "rgba(255, 255, 255, 0.15)", + }, +}); + +export const gameListItem = style({ + color: vars.color.body, + display: "flex", + flexDirection: "row", + gap: `${SPACING_UNIT}px`, + aspectRatio: "1", + transition: "all ease 0.2s", + cursor: "pointer", + zIndex: "1", + ":hover": { + backgroundColor: "rgba(255, 255, 255, 0.15)", + }, +}); + +export const gameInformation = style({ + display: "flex", + flexDirection: "column", + alignItems: "flex-start", + gap: `${SPACING_UNIT / 2}px`, +}); + +export const profileHeaderSkeleton = style({ + height: "200px", +}); diff --git a/src/renderer/src/pages/user/user.tsx b/src/renderer/src/pages/user/user.tsx new file mode 100644 index 00000000..e50ea1e2 --- /dev/null +++ b/src/renderer/src/pages/user/user.tsx @@ -0,0 +1,38 @@ +import { UserProfile } from "@types"; +import { useEffect, useState } from "react"; +import { useParams } from "react-router-dom"; +import { setHeaderTitle } from "@renderer/features"; +import { useAppDispatch } from "@renderer/hooks"; +import { UserSkeleton } from "./user-skeleton"; +import { UserContent } from "./user-content"; +import { SkeletonTheme } from "react-loading-skeleton"; +import { vars } from "@renderer/theme.css"; +import * as styles from "./user.css"; + +export const User = () => { + const { userId } = useParams(); + const [userProfile, setUserProfile] = useState(); + + const dispatch = useAppDispatch(); + + useEffect(() => { + window.electron.getUser(userId!).then((userProfile) => { + if (userProfile) { + dispatch(setHeaderTitle(userProfile.displayName)); + setUserProfile(userProfile); + } + }); + }, [dispatch, userId]); + + return ( + +
+ {userProfile ? ( + + ) : ( + + )} +
+
+ ); +}; diff --git a/src/renderer/src/store.ts b/src/renderer/src/store.ts index a321ee4f..9bc0c950 100644 --- a/src/renderer/src/store.ts +++ b/src/renderer/src/store.ts @@ -6,6 +6,7 @@ import { searchSlice, userPreferencesSlice, toastSlice, + userDetailsSlice, } from "@renderer/features"; export const store = configureStore({ @@ -16,6 +17,7 @@ export const store = configureStore({ userPreferences: userPreferencesSlice.reducer, download: downloadSlice.reducer, toast: toastSlice.reducer, + userDetails: userDetailsSlice.reducer, }, }); diff --git a/src/types/index.ts b/src/types/index.ts index d07e40a1..153fdc9e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -85,6 +85,16 @@ export interface CatalogueEntry { repacks: GameRepack[]; } +export interface UserGame { + objectID: string; + shop: GameShop; + title: string; + iconUrl: string | null; + cover: string; + playTimeInSeconds: number; + lastTimePlayed: Date | null; +} + export interface DownloadQueue { id: number; createdAt: Date; @@ -234,6 +244,20 @@ export interface RealDebridUser { expiration: string; } +export interface UserDetails { + id: string; + displayName: string; + profileImageUrl: string | null; +} + +export interface UserProfile { + id: string; + displayName: string; + profileImageUrl: string | null; + libraryGames: UserGame[]; + recentGames: UserGame[]; +} + export interface DownloadSource { id: number; name: string;