diff --git a/.env.example b/.env.example index 991a06ff..b12e9517 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,3 @@ MAIN_VITE_API_URL=API_URL MAIN_VITE_AUTH_URL=AUTH_URL MAIN_VITE_STEAMGRIDDB_API_KEY=YOUR_API_KEY -RENDERER_VITE_INTERCOM_APP_ID=YOUR_APP_ID diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 89392011..0811967b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,16 +22,6 @@ jobs: with: node-version: 20.18.0 - - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v2 - with: - aws-access-key-id: ${{ secrets.R2_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.R2_SECRET_ACCESS_KEY }} - aws-region: ${{ env.AWS_REGION }} - - - name: Push build to R2 - run: aws s3 sync ./docs s3://${{ vars.BUILDS_BUCKET_NAME }} - - name: Install dependencies run: yarn @@ -58,6 +48,7 @@ jobs: MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_STAGING_CHECKOUT_URL }} MAIN_VITE_ANALYTICS_API_URL: ${{ vars.MAIN_VITE_ANALYTICS_API_URL }} RENDERER_VITE_INTERCOM_APP_ID: ${{ vars.RENDERER_VITE_INTERCOM_APP_ID }} + RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.RENDERER_VITE_EXTERNAL_RESOURCES_URL }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Build Windows @@ -69,6 +60,7 @@ jobs: MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_STAGING_CHECKOUT_URL }} MAIN_VITE_ANALYTICS_API_URL: ${{ vars.MAIN_VITE_ANALYTICS_API_URL }} RENDERER_VITE_INTERCOM_APP_ID: ${{ vars.RENDERER_VITE_INTERCOM_APP_ID }} + RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.RENDERER_VITE_EXTERNAL_RESOURCES_URL }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Create artifact diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ff97c937..b91cc743 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -47,6 +47,7 @@ jobs: MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_CHECKOUT_URL }} MAIN_VITE_ANALYTICS_API_URL: ${{ vars.MAIN_VITE_ANALYTICS_API_URL }} RENDERER_VITE_INTERCOM_APP_ID: ${{ vars.RENDERER_VITE_INTERCOM_APP_ID }} + RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.RENDERER_VITE_EXTERNAL_RESOURCES_URL }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Build Windows if: matrix.os == 'windows-latest' @@ -57,6 +58,7 @@ jobs: MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_CHECKOUT_URL }} MAIN_VITE_ANALYTICS_API_URL: ${{ vars.MAIN_VITE_ANALYTICS_API_URL }} RENDERER_VITE_INTERCOM_APP_ID: ${{ vars.RENDERER_VITE_INTERCOM_APP_ID }} + RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.RENDERER_VITE_EXTERNAL_RESOURCES_URL }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Create artifact uses: actions/upload-artifact@v4 diff --git a/src/main/events/catalogue/get-how-long-to-beat.ts b/src/main/events/catalogue/get-how-long-to-beat.ts index 01966afc..2a1492ef 100644 --- a/src/main/events/catalogue/get-how-long-to-beat.ts +++ b/src/main/events/catalogue/get-how-long-to-beat.ts @@ -1,23 +1,21 @@ -import type { HowLongToBeatCategory } from "@types"; -import { getHowLongToBeatGame, searchHowLongToBeat } from "@main/services"; +import type { GameShop, HowLongToBeatCategory } from "@types"; import { registerEvent } from "../register-event"; -import { formatName } from "@shared"; +import { HydraApi } from "@main/services"; const getHowLongToBeat = async ( _event: Electron.IpcMainInvokeEvent, - title: string + objectId: string, + shop: GameShop ): Promise => { - const response = await searchHowLongToBeat(title); - - const game = response.data.find((game) => { - return formatName(game.game_name) === formatName(title); + const params = new URLSearchParams({ + objectId, + shop, }); - if (!game) return null; - const howLongToBeat = await getHowLongToBeatGame(String(game.game_id)); - - return howLongToBeat; + return HydraApi.get(`/games/how-long-to-beat?${params.toString()}`, null, { + needsAuth: false, + }); }; registerEvent("getHowLongToBeat", getHowLongToBeat); diff --git a/src/main/events/index.ts b/src/main/events/index.ts index 932b80e4..eff62531 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -1,4 +1,4 @@ -import { appVersion, defaultDownloadsPath } from "@main/constants"; +import { appVersion, defaultDownloadsPath, isStaging } from "@main/constants"; import { ipcMain } from "electron"; import "./catalogue/get-catalogue"; @@ -72,5 +72,6 @@ import "./misc/show-item-in-folder"; ipcMain.handle("ping", () => "pong"); ipcMain.handle("getVersion", () => appVersion); +ipcMain.handle("isStaging", () => isStaging); ipcMain.handle("isPortableVersion", () => isPortableVersion()); ipcMain.handle("getDefaultDownloadsPath", () => defaultDownloadsPath); diff --git a/src/main/events/torrenting/start-game-download.ts b/src/main/events/torrenting/start-game-download.ts index 17099450..ce16e97b 100644 --- a/src/main/events/torrenting/start-game-download.ts +++ b/src/main/events/torrenting/start-game-download.ts @@ -1,5 +1,4 @@ import { registerEvent } from "../register-event"; -import parseTorrent from "parse-torrent"; import type { StartGameDownloadPayload } from "@types"; import { DownloadManager, HydraApi, logger } from "@main/services"; @@ -9,7 +8,6 @@ import { createGame } from "@main/services/library-sync"; import { steamUrlBuilder } from "@shared"; import { dataSource } from "@main/data-source"; import { DownloadQueue, Game } from "@main/entity"; -import { HydraAnalytics } from "@main/services/hydra-analytics"; const startGameDownload = async ( _event: Electron.IpcMainInvokeEvent, @@ -91,17 +89,6 @@ const startGameDownload = async ( logger.error("Failed to create game download", err); }); - if (uri.startsWith("magnet:")) { - try { - const { infoHash } = await parseTorrent(payload.uri); - if (infoHash) { - HydraAnalytics.postDownload(infoHash).catch(() => {}); - } - } catch (err) { - logger.error("Failed to parse torrent", err); - } - } - await DownloadManager.cancelDownload(updatedGame!.id); await DownloadManager.startDownload(updatedGame!); diff --git a/src/main/services/how-long-to-beat.ts b/src/main/services/how-long-to-beat.ts deleted file mode 100644 index 1e5f3279..00000000 --- a/src/main/services/how-long-to-beat.ts +++ /dev/null @@ -1,108 +0,0 @@ -import axios from "axios"; -import { requestWebPage } from "@main/helpers"; -import type { - HowLongToBeatCategory, - HowLongToBeatSearchResponse, -} from "@types"; -import { formatName } from "@shared"; -import { logger } from "./logger"; -import UserAgent from "user-agents"; - -const state = { - apiKey: null as string | null, -}; - -const getHowLongToBeatSearchApiKey = async () => { - const userAgent = new UserAgent(); - - const document = await requestWebPage("https://howlongtobeat.com/"); - const scripts = Array.from(document.querySelectorAll("script")); - - const appScript = scripts.find((script) => - script.src.startsWith("/_next/static/chunks/pages/_app") - ); - - if (!appScript) return null; - - const response = await axios.get( - `https://howlongtobeat.com${appScript.src}`, - { - headers: { - "User-Agent": userAgent.toString(), - }, - } - ); - - const results = /fetch\("\/api\/search\/"\.concat\("(.*?)"\)/gm.exec( - response.data - ); - - if (!results) return null; - - return results[1]; -}; - -export const searchHowLongToBeat = async (gameName: string) => { - state.apiKey = state.apiKey ?? (await getHowLongToBeatSearchApiKey()); - if (!state.apiKey) return { data: [] }; - - const userAgent = new UserAgent(); - - const response = await axios - .post( - `https://howlongtobeat.com/api/search/${state.apiKey}`, - { - searchType: "games", - searchTerms: formatName(gameName).split(" "), - searchPage: 1, - size: 20, - }, - { - headers: { - "User-Agent": userAgent.toString(), - Referer: "https://howlongtobeat.com/", - }, - } - ) - .catch((error) => { - logger.error("Error searching HowLongToBeat:", error?.response?.status); - return { data: { data: [] } }; - }); - - return response.data as HowLongToBeatSearchResponse; -}; - -const parseListItems = ($lis: Element[]) => { - return $lis.map(($li) => { - const title = $li.querySelector("h4")?.textContent; - const [, accuracyClassName] = Array.from(($li as HTMLElement).classList); - - const accuracy = accuracyClassName.split("time_").at(1); - - return { - title: title ?? "", - duration: $li.querySelector("h5")?.textContent ?? "", - accuracy: accuracy ?? "", - }; - }); -}; - -export const getHowLongToBeatGame = async ( - id: string -): Promise => { - const document = await requestWebPage(`https://howlongtobeat.com/game/${id}`); - - const $ul = document.querySelector(".shadow_shadow ul"); - if (!$ul) return []; - - const $lis = Array.from($ul.children); - - const [$firstLi] = $lis; - - if ($firstLi.tagName === "DIV") { - const $pcData = $lis.find(($li) => $li.textContent?.includes("PC")); - return parseListItems(Array.from($pcData?.querySelectorAll("li") ?? [])); - } - - return parseListItems($lis); -}; diff --git a/src/main/services/hydra-analytics.ts b/src/main/services/hydra-analytics.ts deleted file mode 100644 index f4a6b24c..00000000 --- a/src/main/services/hydra-analytics.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { userSubscriptionRepository } from "@main/repository"; -import axios from "axios"; -import { appVersion } from "@main/constants"; - -export class HydraAnalytics { - private static instance = axios.create({ - baseURL: import.meta.env.MAIN_VITE_ANALYTICS_API_URL, - headers: { "User-Agent": `Hydra Launcher v${appVersion}` }, - }); - - private static async hasActiveSubscription() { - const userSubscription = await userSubscriptionRepository.findOne({ - where: { id: 1 }, - }); - - return ( - userSubscription?.expiresAt && userSubscription.expiresAt > new Date() - ); - } - - static async postDownload(hash: string) { - const hasSubscription = await this.hasActiveSubscription(); - - return this.instance - .post("/track", { - event: "download", - attributes: { - hash, - hasSubscription, - }, - }) - .then((response) => response.data); - } -} diff --git a/src/main/services/hydra-api.ts b/src/main/services/hydra-api.ts index b2125ac4..f642f43b 100644 --- a/src/main/services/hydra-api.ts +++ b/src/main/services/hydra-api.ts @@ -12,6 +12,7 @@ import { UserNotLoggedInError, SubscriptionRequiredError } from "@shared"; import { omit } from "lodash-es"; import { appVersion } from "@main/constants"; import { getUserData } from "./user/get-user-data"; +import { isFuture, isToday } from "date-fns"; interface HydraApiOptions { needsAuth?: boolean; @@ -45,10 +46,8 @@ export class HydraApi { } private static hasActiveSubscription() { - return ( - this.userAuth.subscription?.expiresAt && - this.userAuth.subscription.expiresAt > new Date() - ); + const expiresAt = this.userAuth.subscription?.expiresAt; + return expiresAt && (isFuture(expiresAt) || isToday(expiresAt)); } static async handleExternalAuth(uri: string) { diff --git a/src/main/services/index.ts b/src/main/services/index.ts index 27abf579..498159c9 100644 --- a/src/main/services/index.ts +++ b/src/main/services/index.ts @@ -4,7 +4,6 @@ export * from "./steam-250"; export * from "./steam-grid"; export * from "./window-manager"; export * from "./download"; -export * from "./how-long-to-beat"; export * from "./process-watcher"; export * from "./main-loop"; export * from "./hydra-api"; diff --git a/src/preload/index.ts b/src/preload/index.ts index d8d142ca..3f2f677a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -42,8 +42,8 @@ contextBridge.exposeInMainWorld("electron", { getGameShopDetails: (objectId: string, shop: GameShop, language: string) => ipcRenderer.invoke("getGameShopDetails", objectId, shop, language), getRandomGame: () => ipcRenderer.invoke("getRandomGame"), - getHowLongToBeat: (title: string) => - ipcRenderer.invoke("getHowLongToBeat", title), + getHowLongToBeat: (objectId: string, shop: GameShop) => + ipcRenderer.invoke("getHowLongToBeat", objectId, shop), getGames: (take?: number, skip?: number) => ipcRenderer.invoke("getGames", take, skip), searchGameRepacks: (query: string) => @@ -198,6 +198,7 @@ contextBridge.exposeInMainWorld("electron", { ping: () => ipcRenderer.invoke("ping"), getVersion: () => ipcRenderer.invoke("getVersion"), getDefaultDownloadsPath: () => ipcRenderer.invoke("getDefaultDownloadsPath"), + isStaging: () => ipcRenderer.invoke("isStaging"), isPortableVersion: () => ipcRenderer.invoke("isPortableVersion"), openExternal: (src: string) => ipcRenderer.invoke("openExternal", src), openCheckout: () => ipcRenderer.invoke("openCheckout"), diff --git a/src/renderer/index.html b/src/renderer/index.html index bfc3a206..6290dfa4 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 5a479879..bb2f096a 100644 --- a/src/renderer/src/app.tsx +++ b/src/renderer/src/app.tsx @@ -2,8 +2,6 @@ import { useCallback, useContext, useEffect, useRef } from "react"; import { Sidebar, BottomPanel, Header, Toast } from "@renderer/components"; -import Intercom from "@intercom/messenger-js-sdk"; - import { useAppDispatch, useAppSelector, @@ -36,10 +34,6 @@ export interface AppProps { children: React.ReactNode; } -Intercom({ - app_id: import.meta.env.RENDERER_VITE_INTERCOM_APP_ID, -}); - export function App() { const contentRef = useRef(null); const { updateLibrary, library } = useLibrary(); @@ -120,12 +114,33 @@ export function App() { dispatch(setProfileBackground(profileBackground)); } - fetchUserDetails().then((response) => { - if (response) { - updateUserDetails(response); - syncFriendRequests(); - } - }); + fetchUserDetails() + .then((response) => { + if (response) { + updateUserDetails(response); + syncFriendRequests(); + + const $existingScript = document.getElementById("user-details"); + + const content = `window.userDetails = ${JSON.stringify(response)};`; + + if ($existingScript) { + $existingScript.textContent = content; + } else { + const $script = document.createElement("script"); + $script.id = "user-details"; + $script.type = "text/javascript"; + $script.textContent = content; + + document.head.appendChild($script); + } + } + }) + .finally(() => { + const $script = document.createElement("script"); + $script.src = `${import.meta.env.RENDERER_VITE_EXTERNAL_RESOURCES_URL}?t=${Date.now()}`; + document.head.appendChild($script); + }); }, [fetchUserDetails, syncFriendRequests, updateUserDetails, dispatch]); const onSignIn = useCallback(() => { @@ -215,9 +230,7 @@ export function App() { useEffect(() => { new MutationObserver(() => { - const modal = document.body.querySelector( - "[role=dialog]:not([data-intercom-frame='true'])" - ); + const modal = document.body.querySelector("[data-hydra-dialog]"); dispatch(toggleDraggingDisabled(Boolean(modal))); }).observe(document.body, { diff --git a/src/renderer/src/components/modal/modal.tsx b/src/renderer/src/components/modal/modal.tsx index eb2894de..af15feb5 100644 --- a/src/renderer/src/components/modal/modal.tsx +++ b/src/renderer/src/components/modal/modal.tsx @@ -107,6 +107,7 @@ export function Modal({ aria-labelledby={title} aria-describedby={description} ref={modalContentRef} + data-hydra-dialog >
diff --git a/src/renderer/src/components/sidebar/sidebar.tsx b/src/renderer/src/components/sidebar/sidebar.tsx index 75bd1b78..f487681c 100644 --- a/src/renderer/src/components/sidebar/sidebar.tsx +++ b/src/renderer/src/components/sidebar/sidebar.tsx @@ -22,8 +22,6 @@ import { SidebarProfile } from "./sidebar-profile"; import { sortBy } from "lodash-es"; import { CommentDiscussionIcon } from "@primer/octicons-react"; -import { show, update } from "@intercom/messenger-js-sdk"; - const SIDEBAR_MIN_WIDTH = 200; const SIDEBAR_INITIAL_WIDTH = 250; const SIDEBAR_MAX_WIDTH = 450; @@ -50,20 +48,7 @@ export function Sidebar() { return sortBy(library, (game) => game.title); }, [library]); - const { userDetails, hasActiveSubscription } = useUserDetails(); - - useEffect(() => { - if (userDetails) { - update({ - name: userDetails.displayName, - Username: userDetails.username, - email: userDetails.email ?? undefined, - Email: userDetails.email, - "Subscription expiration date": userDetails?.subscription?.expiresAt, - "Payment status": userDetails?.subscription?.status, - }); - } - }, [userDetails, hasActiveSubscription]); + const { hasActiveSubscription } = useUserDetails(); const { lastPacket, progress } = useDownload(); @@ -266,7 +251,11 @@ export function Sidebar() {
{hasActiveSubscription && ( -