diff --git a/package.json b/package.json index 75f541d3..fd7c2855 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hydralauncher", - "version": "3.1.5", + "version": "3.2.0", "description": "Hydra", "main": "./out/main/index.js", "author": "Los Broxas", @@ -36,6 +36,7 @@ "@electron-toolkit/utils": "^3.0.0", "@fontsource/noto-sans": "^5.1.0", "@hookform/resolvers": "^3.9.1", + "@monaco-editor/react": "^4.6.0", "@primer/octicons-react": "^19.9.0", "@radix-ui/react-dropdown-menu": "^2.1.2", "@reduxjs/toolkit": "^2.2.3", @@ -59,6 +60,7 @@ "i18next-browser-languagedetector": "^7.2.1", "jsdom": "^24.0.0", "jsonwebtoken": "^9.0.2", + "kill-port": "^2.0.1", "knex": "^3.1.0", "lodash-es": "^4.17.21", "parse-torrent": "^11.0.17", diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 94b52c75..f1e85019 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -189,9 +189,10 @@ "download_error_gofile_quota_exceeded": "You have exceeded your Gofile monthly quota. Please await the quota to reset.", "download_error_real_debrid_account_not_authorized": "Your Real-Debrid account is not authorized to make new downloads. Please check your account settings and try again.", "download_error_not_cached_in_real_debrid": "This download is not available on Real-Debrid and polling download status from Real-Debrid is not yet available.", - "download_error_not_cached_in_torbox": "This download is not available on Torbox and polling download status from Torbox is not yet available." + "download_error_not_cached_in_torbox": "This download is not available on Torbox and polling download status from Torbox is not yet available.", + "game_removed_from_favorites": "Game removed from favorites", + "game_added_to_favorites": "Game added to favorites" }, - "activation": { "title": "Activate Hydra", "installation_id": "Installation ID:", @@ -303,10 +304,35 @@ "subscription_renew_cancelled": "Automatic renewal is disabled", "subscription_renews_on": "Your subscription renews on {{date}}", "bill_sent_until": "Your next bill will be sent until this day", + "no_themes": "Seems like you don't have any themes yet, but no worries, click here to create your first masterpiece.", + "editor_tab_code": "Code", + "editor_tab_info": "Info", + "editor_tab_save": "Save", + "web_store": "Web store", + "clear_themes": "Clear", + "create_theme": "Create", + "create_theme_modal_title": "Create custom theme", + "create_theme_modal_description": "Create a new theme to customize Hydra's appearance", + "theme_name": "Name", + "insert_theme_name": "Insert theme name", + "set_theme": "Set theme", + "unset_theme": "Unset theme", + "delete_theme": "Delete theme", + "edit_theme": "Edit theme", + "delete_all_themes": "Delete all themes", + "delete_all_themes_description": "This will delete all your custom themes", + "delete_theme_description": "This will delete the theme {{theme}}", + "cancel": "Cancel", + "appearance": "Appearance", "enable_torbox": "Enable Torbox", "torbox_description": "TorBox is your premium seedbox service rivaling even the best servers on the market.", "torbox_account_linked": "TorBox account linked", - "real_debrid_account_linked": "Real-Debrid account linked" + "real_debrid_account_linked": "Real-Debrid account linked", + "name_min_length": "Theme name must be at least 3 characters long", + "import_theme": "Import theme", + "import_theme_description": "You will import {{theme}} from the theme store", + "error_importing_theme": "Error importing theme", + "theme_imported": "Theme imported successfully" }, "notifications": { "download_complete": "Download complete", diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index 6394ed95..0cefd188 100644 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -179,9 +179,10 @@ "download_error_gofile_quota_exceeded": "Você excedeu sua cota mensal do Gofile. Por favor, aguarde a cota resetar.", "download_error_real_debrid_account_not_authorized": "Sua conta do Real-Debrid não está autorizada a fazer novos downloads. Por favor, verifique sua assinatura e tente novamente.", "download_error_not_cached_in_real_debrid": "Este download não está disponível no Real-Debrid e a verificação do status do download não está disponível.", - "download_error_not_cached_in_torbox": "Este download não está disponível no Torbox e a verificação do status do download não está disponível." + "download_error_not_cached_in_torbox": "Este download não está disponível no Torbox e a verificação do status do download não está disponível.", + "game_removed_from_favorites": "Jogo removido dos favoritos", + "game_added_to_favorites": "Jogo adicionado aos favoritos" }, - "activation": { "title": "Ativação", "installation_id": "ID da instalação:", @@ -293,10 +294,33 @@ "subscription_renew_cancelled": "A renovação automática está desativada", "subscription_renews_on": "Sua assinatura renova dia {{date}}", "bill_sent_until": "Sua próxima cobrança será enviada até esse dia", + "no_themes": "Parece que você ainda não tem nenhum tema. Não se preocupe, clique aqui para criar sua primeira obra de arte.", + "editor_tab_save": "Salvar", + "web_store": "Loja de temas", + "clear_themes": "Limpar", + "create_theme": "Criar", + "create_theme_modal_title": "Criar tema customizado", + "create_theme_modal_description": "Criar novo tema para customizar a aparência do Hydra", + "theme_name": "Nome", + "insert_theme_name": "Insira o nome do tema", + "set_theme": "Habilitar tema", + "unset_theme": "Desabilitar tema", + "delete_theme": "Deletar tema", + "edit_theme": "Editar tema", + "delete_all_themes": "Deletar todos os temas", + "delete_all_themes_description": "Isso irá deletar todos os seus temas", + "delete_theme_description": "Isso irá deletar o tema {{theme}}", + "cancel": "Cancelar", + "appearance": "Aparência", "enable_torbox": "Habilitar Torbox", "torbox_description": "TorBox é o seu serviço de seedbox premium que rivaliza até com os melhores servidores do mercado.", "torbox_account_linked": "Conta do TorBox vinculada", - "real_debrid_account_linked": "Conta Real-Debrid associada" + "real_debrid_account_linked": "Conta Real-Debrid associada", + "name_min_length": "O nome do tema deve ter pelo menos 3 caracteres", + "import_theme": "Importar tema", + "import_theme_description": "Você irá importar {{theme}} da loja de temas", + "error_importing_theme": "Erro ao importar tema", + "theme_imported": "Tema importado com sucesso" }, "notifications": { "download_complete": "Download concluído", diff --git a/src/main/events/auth/get-session-hash.ts b/src/main/events/auth/get-session-hash.ts index c81e0965..02edebdc 100644 --- a/src/main/events/auth/get-session-hash.ts +++ b/src/main/events/auth/get-session-hash.ts @@ -3,7 +3,6 @@ import jwt from "jsonwebtoken"; import { registerEvent } from "../register-event"; import { db, levelKeys } from "@main/level"; import type { Auth } from "@types"; -import { Crypto } from "@main/services"; const getSessionHash = async (_event: Electron.IpcMainInvokeEvent) => { const auth = await db.get(levelKeys.auth, { @@ -11,9 +10,7 @@ const getSessionHash = async (_event: Electron.IpcMainInvokeEvent) => { }); if (!auth) return null; - const payload = jwt.decode( - Crypto.decrypt(auth.accessToken) - ) as jwt.JwtPayload; + const payload = jwt.decode(auth.accessToken) as jwt.JwtPayload; if (!payload) return null; diff --git a/src/main/events/index.ts b/src/main/events/index.ts index dc64b40e..572cba0f 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -77,6 +77,16 @@ import "./cloud-save/upload-save-game"; import "./cloud-save/delete-game-artifact"; import "./cloud-save/select-game-backup-path"; import "./notifications/publish-new-repacks-notification"; +import "./themes/add-custom-theme"; +import "./themes/delete-custom-theme"; +import "./themes/get-all-custom-themes"; +import "./themes/delete-all-custom-themes"; +import "./themes/update-custom-theme"; +import "./themes/open-editor-window"; +import "./themes/get-custom-theme-by-id"; +import "./themes/get-active-custom-theme"; +import "./themes/close-editor-window"; +import "./themes/toggle-custom-theme"; import { isPortableVersion } from "@main/helpers"; ipcMain.handle("ping", () => "pong"); diff --git a/src/main/events/misc/open-checkout.ts b/src/main/events/misc/open-checkout.ts index 76316a6e..75b93f9d 100644 --- a/src/main/events/misc/open-checkout.ts +++ b/src/main/events/misc/open-checkout.ts @@ -1,6 +1,6 @@ import { shell } from "electron"; import { registerEvent } from "../register-event"; -import { Crypto, HydraApi } from "@main/services"; +import { HydraApi } from "@main/services"; import { db, levelKeys } from "@main/level"; import type { Auth } from "@types"; @@ -14,7 +14,7 @@ const openCheckout = async (_event: Electron.IpcMainInvokeEvent) => { } const paymentToken = await HydraApi.post("/auth/payment", { - refreshToken: Crypto.decrypt(auth.refreshToken), + refreshToken: auth.refreshToken, }).then((response) => response.accessToken); const params = new URLSearchParams({ diff --git a/src/main/events/themes/add-custom-theme.ts b/src/main/events/themes/add-custom-theme.ts new file mode 100644 index 00000000..95f526d9 --- /dev/null +++ b/src/main/events/themes/add-custom-theme.ts @@ -0,0 +1,12 @@ +import { Theme } from "@types"; +import { registerEvent } from "../register-event"; +import { themesSublevel } from "@main/level"; + +const addCustomTheme = async ( + _event: Electron.IpcMainInvokeEvent, + theme: Theme +) => { + await themesSublevel.put(theme.id, theme); +}; + +registerEvent("addCustomTheme", addCustomTheme); diff --git a/src/main/events/themes/close-editor-window.ts b/src/main/events/themes/close-editor-window.ts new file mode 100644 index 00000000..6ebc012c --- /dev/null +++ b/src/main/events/themes/close-editor-window.ts @@ -0,0 +1,11 @@ +import { WindowManager } from "@main/services"; +import { registerEvent } from "../register-event"; + +const closeEditorWindow = async ( + _event: Electron.IpcMainInvokeEvent, + themeId?: string +) => { + WindowManager.closeEditorWindow(themeId); +}; + +registerEvent("closeEditorWindow", closeEditorWindow); diff --git a/src/main/events/themes/delete-all-custom-themes.ts b/src/main/events/themes/delete-all-custom-themes.ts new file mode 100644 index 00000000..d7a42d39 --- /dev/null +++ b/src/main/events/themes/delete-all-custom-themes.ts @@ -0,0 +1,8 @@ +import { themesSublevel } from "@main/level"; +import { registerEvent } from "../register-event"; + +const deleteAllCustomThemes = async (_event: Electron.IpcMainInvokeEvent) => { + await themesSublevel.clear(); +}; + +registerEvent("deleteAllCustomThemes", deleteAllCustomThemes); diff --git a/src/main/events/themes/delete-custom-theme.ts b/src/main/events/themes/delete-custom-theme.ts new file mode 100644 index 00000000..d47c43fb --- /dev/null +++ b/src/main/events/themes/delete-custom-theme.ts @@ -0,0 +1,11 @@ +import { themesSublevel } from "@main/level"; +import { registerEvent } from "../register-event"; + +const deleteCustomTheme = async ( + _event: Electron.IpcMainInvokeEvent, + themeId: string +) => { + await themesSublevel.del(themeId); +}; + +registerEvent("deleteCustomTheme", deleteCustomTheme); diff --git a/src/main/events/themes/get-active-custom-theme.ts b/src/main/events/themes/get-active-custom-theme.ts new file mode 100644 index 00000000..b117f758 --- /dev/null +++ b/src/main/events/themes/get-active-custom-theme.ts @@ -0,0 +1,9 @@ +import { themesSublevel } from "@main/level"; +import { registerEvent } from "../register-event"; + +const getActiveCustomTheme = async () => { + const allThemes = await themesSublevel.values().all(); + return allThemes.find((theme) => theme.isActive); +}; + +registerEvent("getActiveCustomTheme", getActiveCustomTheme); diff --git a/src/main/events/themes/get-all-custom-themes.ts b/src/main/events/themes/get-all-custom-themes.ts new file mode 100644 index 00000000..f59a87cd --- /dev/null +++ b/src/main/events/themes/get-all-custom-themes.ts @@ -0,0 +1,8 @@ +import { themesSublevel } from "@main/level"; +import { registerEvent } from "../register-event"; + +const getAllCustomThemes = async (_event: Electron.IpcMainInvokeEvent) => { + return themesSublevel.values().all(); +}; + +registerEvent("getAllCustomThemes", getAllCustomThemes); diff --git a/src/main/events/themes/get-custom-theme-by-id.ts b/src/main/events/themes/get-custom-theme-by-id.ts new file mode 100644 index 00000000..4ec5dc03 --- /dev/null +++ b/src/main/events/themes/get-custom-theme-by-id.ts @@ -0,0 +1,11 @@ +import { themesSublevel } from "@main/level"; +import { registerEvent } from "../register-event"; + +const getCustomThemeById = async ( + _event: Electron.IpcMainInvokeEvent, + themeId: string +) => { + return themesSublevel.get(themeId); +}; + +registerEvent("getCustomThemeById", getCustomThemeById); diff --git a/src/main/events/themes/open-editor-window.ts b/src/main/events/themes/open-editor-window.ts new file mode 100644 index 00000000..59838ed4 --- /dev/null +++ b/src/main/events/themes/open-editor-window.ts @@ -0,0 +1,11 @@ +import { WindowManager } from "@main/services"; +import { registerEvent } from "../register-event"; + +const openEditorWindow = async ( + _event: Electron.IpcMainInvokeEvent, + themeId: string +) => { + WindowManager.openEditorWindow(themeId); +}; + +registerEvent("openEditorWindow", openEditorWindow); diff --git a/src/main/events/themes/toggle-custom-theme.ts b/src/main/events/themes/toggle-custom-theme.ts new file mode 100644 index 00000000..50440551 --- /dev/null +++ b/src/main/events/themes/toggle-custom-theme.ts @@ -0,0 +1,22 @@ +import { themesSublevel } from "@main/level"; +import { registerEvent } from "../register-event"; + +const toggleCustomTheme = async ( + _event: Electron.IpcMainInvokeEvent, + themeId: string, + isActive: boolean +) => { + const theme = await themesSublevel.get(themeId); + + if (!theme) { + throw new Error("Theme not found"); + } + + await themesSublevel.put(themeId, { + ...theme, + isActive, + updatedAt: new Date(), + }); +}; + +registerEvent("toggleCustomTheme", toggleCustomTheme); diff --git a/src/main/events/themes/update-custom-theme.ts b/src/main/events/themes/update-custom-theme.ts new file mode 100644 index 00000000..b9a8e048 --- /dev/null +++ b/src/main/events/themes/update-custom-theme.ts @@ -0,0 +1,27 @@ +import { themesSublevel } from "@main/level"; +import { registerEvent } from "../register-event"; +import { WindowManager } from "@main/services"; + +const updateCustomTheme = async ( + _event: Electron.IpcMainInvokeEvent, + themeId: string, + code: string +) => { + const theme = await themesSublevel.get(themeId); + + if (!theme) { + throw new Error("Theme not found"); + } + + await themesSublevel.put(themeId, { + ...theme, + code, + updatedAt: new Date(), + }); + + if (theme.isActive) { + WindowManager.mainWindow?.webContents.send("css-injected", code); + } +}; + +registerEvent("updateCustomTheme", updateCustomTheme); diff --git a/src/main/events/user-preferences/get-user-preferences.ts b/src/main/events/user-preferences/get-user-preferences.ts index c67f72b9..ba01d077 100644 --- a/src/main/events/user-preferences/get-user-preferences.ts +++ b/src/main/events/user-preferences/get-user-preferences.ts @@ -1,27 +1,10 @@ import { registerEvent } from "../register-event"; import { db, levelKeys } from "@main/level"; -import { Crypto } from "@main/services"; import type { UserPreferences } from "@types"; const getUserPreferences = async () => - db - .get(levelKeys.userPreferences, { - valueEncoding: "json", - }) - .then((userPreferences) => { - if (userPreferences?.realDebridApiToken) { - userPreferences.realDebridApiToken = Crypto.decrypt( - userPreferences.realDebridApiToken - ); - } - - if (userPreferences?.torBoxApiToken) { - userPreferences.torBoxApiToken = Crypto.decrypt( - userPreferences.torBoxApiToken - ); - } - - return userPreferences; - }); + db.get(levelKeys.userPreferences, { + valueEncoding: "json", + }); registerEvent("getUserPreferences", getUserPreferences); diff --git a/src/main/events/user-preferences/update-user-preferences.ts b/src/main/events/user-preferences/update-user-preferences.ts index 275a6f27..09f39d2d 100644 --- a/src/main/events/user-preferences/update-user-preferences.ts +++ b/src/main/events/user-preferences/update-user-preferences.ts @@ -3,7 +3,6 @@ import { registerEvent } from "../register-event"; import type { UserPreferences } from "@types"; import i18next from "i18next"; import { db, levelKeys } from "@main/level"; -import { Crypto } from "@main/services"; import { patchUserProfile } from "../profile/update-profile"; const updateUserPreferences = async ( @@ -24,16 +23,6 @@ const updateUserPreferences = async ( patchUserProfile({ language: preferences.language }).catch(() => {}); } - if (preferences.realDebridApiToken) { - preferences.realDebridApiToken = Crypto.encrypt( - preferences.realDebridApiToken - ); - } - - if (preferences.torBoxApiToken) { - preferences.torBoxApiToken = Crypto.encrypt(preferences.torBoxApiToken); - } - if (!preferences.downloadsPath) { preferences.downloadsPath = null; } diff --git a/src/main/index.ts b/src/main/index.ts index 2a18fa31..01818b3d 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -3,6 +3,7 @@ import updater from "electron-updater"; import i18n from "i18next"; import path from "node:path"; import url from "node:url"; +import kill from "kill-port"; import { electronApp, optimizer } from "@electron-toolkit/utils"; import { logger, WindowManager } from "@main/services"; import resources from "@locales"; @@ -58,7 +59,7 @@ app.whenReady().then(async () => { return net.fetch(url.pathToFileURL(decodeURI(filePath)).toString()); }); - await loadState(); + await kill(PythonRPC.RPC_PORT).finally(() => loadState()); const language = await db.get(levelKeys.language, { valueEncoding: "utf-8", @@ -85,6 +86,29 @@ const handleDeepLinkPath = (uri?: string) => { if (url.host === "install-source") { WindowManager.redirect(`settings${url.search}`); + return; + } + + if (url.host === "profile") { + const userId = url.searchParams.get("userId"); + + if (userId) { + WindowManager.redirect(`profile/${userId}`); + } + + return; + } + + if (url.host === "install-theme") { + const themeName = url.searchParams.get("theme"); + const authorId = url.searchParams.get("authorId"); + const authorName = url.searchParams.get("authorName"); + + if (themeName && authorId && authorName) { + WindowManager.redirect( + `settings?theme=${themeName}&authorId=${authorId}&authorName=${authorName}` + ); + } } } catch (error) { logger.error("Error handling deep link", uri, error); diff --git a/src/main/level/sublevels/index.ts b/src/main/level/sublevels/index.ts index 3f0e840e..e63f0a3b 100644 --- a/src/main/level/sublevels/index.ts +++ b/src/main/level/sublevels/index.ts @@ -3,3 +3,4 @@ export * from "./games"; export * from "./game-shop-cache"; export * from "./game-achievements"; export * from "./keys"; +export * from "./themes"; diff --git a/src/main/level/sublevels/keys.ts b/src/main/level/sublevels/keys.ts index 53eae44b..3f0a09ea 100644 --- a/src/main/level/sublevels/keys.ts +++ b/src/main/level/sublevels/keys.ts @@ -5,6 +5,7 @@ export const levelKeys = { game: (shop: GameShop, objectId: string) => `${shop}:${objectId}`, user: "user", auth: "auth", + themes: "themes", gameShopCache: "gameShopCache", gameShopCacheItem: (shop: GameShop, objectId: string, language: string) => `${shop}:${objectId}:${language}`, diff --git a/src/main/level/sublevels/themes.ts b/src/main/level/sublevels/themes.ts new file mode 100644 index 00000000..5e23468f --- /dev/null +++ b/src/main/level/sublevels/themes.ts @@ -0,0 +1,7 @@ +import type { Theme } from "@types"; +import { db } from "../level"; +import { levelKeys } from "./keys"; + +export const themesSublevel = db.sublevel(levelKeys.themes, { + valueEncoding: "json", +}); diff --git a/src/main/main.ts b/src/main/main.ts index 68d4684b..6bcd0ff6 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -1,10 +1,4 @@ -import { - Crypto, - DownloadManager, - logger, - Ludusavi, - startMainLoop, -} from "./services"; +import { DownloadManager, logger, Ludusavi, startMainLoop } from "./services"; import { RealDebridClient } from "./services/download/real-debrid"; import { HydraApi } from "./services/hydra-api"; import { uploadGamesBatch } from "./services/library-sync"; @@ -38,13 +32,11 @@ export const loadState = async () => { Aria2.spawn(); if (userPreferences?.realDebridApiToken) { - RealDebridClient.authorize( - Crypto.decrypt(userPreferences.realDebridApiToken) - ); + RealDebridClient.authorize(userPreferences.realDebridApiToken); } if (userPreferences?.torBoxApiToken) { - TorBoxClient.authorize(Crypto.decrypt(userPreferences.torBoxApiToken)); + TorBoxClient.authorize(userPreferences.torBoxApiToken); } Ludusavi.addManifestToLudusaviConfig(); @@ -121,9 +113,7 @@ const migrateFromSqlite = async () => { levelKeys.userPreferences, { ...rest, - realDebridApiToken: realDebridApiToken - ? Crypto.encrypt(realDebridApiToken) - : null, + realDebridApiToken, preferQuitInsteadOfHiding: rest.preferQuitInsteadOfHiding === 1, runAtStartup: rest.runAtStartup === 1, startMinimized: rest.startMinimized === 1, @@ -189,8 +179,8 @@ const migrateFromSqlite = async () => { await db.put( levelKeys.auth, { - accessToken: Crypto.encrypt(users[0].accessToken), - refreshToken: Crypto.encrypt(users[0].refreshToken), + accessToken: users[0].accessToken, + refreshToken: users[0].refreshToken, tokenExpirationTimestamp: users[0].tokenExpirationTimestamp, }, { diff --git a/src/main/services/crypto.ts b/src/main/services/crypto.ts deleted file mode 100644 index 63a50668..00000000 --- a/src/main/services/crypto.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { safeStorage } from "electron"; -import { logger } from "./logger"; - -export class Crypto { - public static encrypt(str: string) { - if (safeStorage.isEncryptionAvailable()) { - return safeStorage.encryptString(str).toString("base64"); - } else { - logger.warn( - "Encrypt method returned raw string because encryption is not available" - ); - - return str; - } - } - - public static decrypt(b64: string) { - if (safeStorage.isEncryptionAvailable()) { - return safeStorage.decryptString(Buffer.from(b64, "base64")); - } else { - logger.warn( - "Decrypt method returned raw string because encryption is not available" - ); - - return b64; - } - } -} diff --git a/src/main/services/download/download-manager.ts b/src/main/services/download/download-manager.ts index 789a3010..d3cab967 100644 --- a/src/main/services/download/download-manager.ts +++ b/src/main/services/download/download-manager.ts @@ -230,14 +230,17 @@ export class DownloadManager { } static async cancelDownload(downloadKey = this.downloadingGameId) { - await PythonRPC.rpc.post("/action", { - action: "cancel", - game_id: downloadKey, - }); - - WindowManager.mainWindow?.setProgressBar(-1); + await PythonRPC.rpc + .post("/action", { + action: "cancel", + game_id: downloadKey, + }) + .catch((err) => { + logger.error("Failed to cancel game download", err); + }); if (downloadKey === this.downloadingGameId) { + WindowManager.mainWindow?.setProgressBar(-1); WindowManager.mainWindow?.webContents.send("on-download-progress", null); this.downloadingGameId = null; } diff --git a/src/main/services/hydra-api.ts b/src/main/services/hydra-api.ts index ba972b44..9c71d57d 100644 --- a/src/main/services/hydra-api.ts +++ b/src/main/services/hydra-api.ts @@ -12,7 +12,6 @@ import { isFuture, isToday } from "date-fns"; import { db } from "@main/level"; import { levelKeys } from "@main/level/sublevels"; import type { Auth, User } from "@types"; -import { Crypto } from "./crypto"; interface HydraApiOptions { needsAuth?: boolean; @@ -32,8 +31,9 @@ export class HydraApi { private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5; // 5 minutes private static readonly ADD_LOG_INTERCEPTOR = true; - private static readonly secondsToMilliseconds = (seconds: number) => - seconds * 1000; + private static secondsToMilliseconds(seconds: number) { + return seconds * 1000; + } private static userAuth: HydraApiUserAuth = { authToken: "", @@ -81,8 +81,8 @@ export class HydraApi { db.put( levelKeys.auth, { - accessToken: Crypto.encrypt(accessToken), - refreshToken: Crypto.encrypt(refreshToken), + accessToken, + refreshToken, tokenExpirationTimestamp, }, { valueEncoding: "json" } @@ -204,12 +204,8 @@ export class HydraApi { const user = result.at(1) as User | undefined; this.userAuth = { - authToken: userAuth?.accessToken - ? Crypto.decrypt(userAuth.accessToken) - : "", - refreshToken: userAuth?.refreshToken - ? Crypto.decrypt(userAuth.refreshToken) - : "", + authToken: userAuth?.accessToken ?? "", + refreshToken: userAuth?.refreshToken ?? "", expirationTimestamp: userAuth?.tokenExpirationTimestamp ?? 0, subscription: user?.subscription ? { expiresAt: user.subscription?.expiresAt } @@ -258,7 +254,7 @@ export class HydraApi { levelKeys.auth, { ...auth, - accessToken: Crypto.encrypt(accessToken), + accessToken, tokenExpirationTimestamp, }, { valueEncoding: "json" } diff --git a/src/main/services/index.ts b/src/main/services/index.ts index d2034f15..5aaf5322 100644 --- a/src/main/services/index.ts +++ b/src/main/services/index.ts @@ -1,4 +1,3 @@ -export * from "./crypto"; export * from "./logger"; export * from "./steam"; export * from "./steam-250"; diff --git a/src/main/services/window-manager.ts b/src/main/services/window-manager.ts index 2d0bf24d..f51d0e39 100644 --- a/src/main/services/window-manager.ts +++ b/src/main/services/window-manager.ts @@ -24,6 +24,8 @@ import { isStaging } from "@main/constants"; export class WindowManager { public static mainWindow: Electron.BrowserWindow | null = null; + private static readonly editorWindows: Map = new Map(); + private static loadMainWindowURL(hash = "") { // HMR for renderer base on electron-vite cli. // Load the remote URL for development or the local html file for production. @@ -201,6 +203,87 @@ export class WindowManager { } } + public static openEditorWindow(themeId: string) { + if (this.mainWindow) { + const existingWindow = this.editorWindows.get(themeId); + if (existingWindow) { + if (existingWindow.isMinimized()) { + existingWindow.restore(); + } + existingWindow.focus(); + return; + } + + const editorWindow = new BrowserWindow({ + width: 600, + height: 720, + minWidth: 600, + minHeight: 540, + backgroundColor: "#1c1c1c", + titleBarStyle: process.platform === "linux" ? "default" : "hidden", + ...(process.platform === "linux" ? { icon } : {}), + trafficLightPosition: { x: 16, y: 16 }, + titleBarOverlay: { + symbolColor: "#DADBE1", + color: "#151515", + height: 34, + }, + webPreferences: { + preload: path.join(__dirname, "../preload/index.mjs"), + sandbox: false, + }, + show: false, + }); + + this.editorWindows.set(themeId, editorWindow); + + editorWindow.removeMenu(); + + if (is.dev && process.env["ELECTRON_RENDERER_URL"]) { + editorWindow.loadURL( + `${process.env["ELECTRON_RENDERER_URL"]}#/theme-editor?themeId=${themeId}` + ); + } else { + editorWindow.loadFile(path.join(__dirname, "../renderer/index.html"), { + hash: `theme-editor?themeId=${themeId}`, + }); + } + + editorWindow.once("ready-to-show", () => { + editorWindow.show(); + this.mainWindow?.webContents.openDevTools(); + if (isStaging) { + editorWindow.webContents.openDevTools(); + } + }); + + editorWindow.webContents.on("before-input-event", (event, input) => { + if (input.key === "F12") { + event.preventDefault(); + this.mainWindow?.webContents.toggleDevTools(); + } + }); + + editorWindow.on("close", () => { + this.mainWindow?.webContents.closeDevTools(); + this.editorWindows.delete(themeId); + }); + } + } + + public static closeEditorWindow(themeId?: string) { + if (themeId) { + const editorWindow = this.editorWindows.get(themeId); + if (editorWindow) { + editorWindow.close(); + } + } else { + this.editorWindows.forEach((editorWindow) => { + editorWindow.close(); + }); + } + } + public static redirect(hash: string) { if (!this.mainWindow) this.createMainWindow(); this.loadMainWindowURL(hash); diff --git a/src/preload/index.ts b/src/preload/index.ts index ef61cbb9..5b3498ac 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -14,6 +14,7 @@ import type { CatalogueSearchPayload, SeedingStatus, GameAchievement, + Theme, } from "@types"; import type { AuthPage, CatalogueCategory } from "@shared"; import type { AxiosProgressEvent } from "axios"; @@ -347,4 +348,30 @@ contextBridge.exposeInMainWorld("electron", { /* Notifications */ publishNewRepacksNotification: (newRepacksCount: number) => ipcRenderer.invoke("publishNewRepacksNotification", newRepacksCount), + + /* Themes */ + addCustomTheme: (theme: Theme) => ipcRenderer.invoke("addCustomTheme", theme), + getAllCustomThemes: () => ipcRenderer.invoke("getAllCustomThemes"), + deleteAllCustomThemes: () => ipcRenderer.invoke("deleteAllCustomThemes"), + deleteCustomTheme: (themeId: string) => + ipcRenderer.invoke("deleteCustomTheme", themeId), + updateCustomTheme: (themeId: string, code: string) => + ipcRenderer.invoke("updateCustomTheme", themeId, code), + getCustomThemeById: (themeId: string) => + ipcRenderer.invoke("getCustomThemeById", themeId), + getActiveCustomTheme: () => ipcRenderer.invoke("getActiveCustomTheme"), + toggleCustomTheme: (themeId: string, isActive: boolean) => + ipcRenderer.invoke("toggleCustomTheme", themeId, isActive), + + /* Editor */ + openEditorWindow: (themeId: string) => + ipcRenderer.invoke("openEditorWindow", themeId), + onCssInjected: (cb: (cssString: string) => void) => { + const listener = (_event: Electron.IpcRendererEvent, cssString: string) => + cb(cssString); + ipcRenderer.on("css-injected", listener); + return () => ipcRenderer.removeListener("css-injected", listener); + }, + closeEditorWindow: (themeId?: string) => + ipcRenderer.invoke("closeEditorWindow", themeId), }); diff --git a/src/renderer/src/app.tsx b/src/renderer/src/app.tsx index 650d4ca0..daa93a6b 100644 --- a/src/renderer/src/app.tsx +++ b/src/renderer/src/app.tsx @@ -28,6 +28,7 @@ import { downloadSourcesTable } from "./dexie"; import { useSubscription } from "./hooks/use-subscription"; import { HydraCloudModal } from "./pages/shared-modals/hydra-cloud/hydra-cloud-modal"; +import { injectCustomCss } from "./helpers"; import "./app.scss"; export interface AppProps { @@ -233,6 +234,17 @@ export function App() { downloadSourcesWorker.postMessage(["SYNC_DOWNLOAD_SOURCES", id]); }, [updateRepacks]); + useEffect(() => { + const loadAndApplyTheme = async () => { + const activeTheme = await window.electron.getActiveCustomTheme(); + + if (activeTheme?.code) { + injectCustomCss(activeTheme.code); + } + }; + loadAndApplyTheme(); + }, []); + const playAudio = useCallback(() => { const audio = new Audio(achievementSound); audio.volume = 0.2; @@ -249,6 +261,16 @@ export function App() { }; }, [playAudio]); + useEffect(() => { + const unsubscribe = window.electron.onCssInjected((cssString) => { + if (cssString) { + injectCustomCss(cssString); + } + }); + + return () => unsubscribe(); + }, []); + const handleToastClose = useCallback(() => { dispatch(closeToast()); }, [dispatch]); diff --git a/src/renderer/src/components/sidebar/sidebar.tsx b/src/renderer/src/components/sidebar/sidebar.tsx index a9139fdb..7584775a 100644 --- a/src/renderer/src/components/sidebar/sidebar.tsx +++ b/src/renderer/src/components/sidebar/sidebar.tsx @@ -167,6 +167,10 @@ export function Sidebar() { } }; + const favoriteGames = useMemo(() => { + return sortedLibrary.filter((game) => game.favorite); + }, [sortedLibrary]); + return (