diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 2411f70..300916a 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -100,6 +100,8 @@ jobs: run: yarn install --frozen-lockfile - name: Build + env: + VITE_DISCORD_CLIENT_ID: ${{ secrets.DISCORD_CLIENT_ID }} run: yarn run build - name: Package Application diff --git a/package.json b/package.json index 532ed8a..36929be 100644 --- a/package.json +++ b/package.json @@ -98,6 +98,7 @@ "@electron-toolkit/utils": "^4.0.0", "@iconify/iconify": "^3.1.1", "@ryelite/core": "0.1.1", + "discord-rpc": "^4.0.1", "electron-log": "^5.4.2", "electron-updater": "^6.6.7", "idb": "^8.0.3", @@ -105,6 +106,7 @@ }, "devDependencies": { "@electron-toolkit/tsconfig": "^1.0.1", + "@types/discord-rpc": "^4.0.10", "@types/node": "^22.17.1", "electron": "^37.2.6", "electron-builder": "^26.1.0", diff --git a/src/main/env.d.ts b/src/main/env.d.ts new file mode 100644 index 0000000..5a5d70d --- /dev/null +++ b/src/main/env.d.ts @@ -0,0 +1,11 @@ +// This file provides type definitions for Vite environment variables +// specifically for the Electron main process, which is compiled +// separately from the renderer process via tsconfig.node.json. + +interface ImportMetaEnv { + readonly VITE_DISCORD_CLIENT_ID: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/src/main/index.ts b/src/main/index.ts index 40a48dd..454ae41 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -20,6 +20,7 @@ import { createConsoleWindow } from './windows/console'; import { createClientWindow } from './windows/client'; import log from 'electron-log'; import registerScreenshotIPC from './modules/screenshotManagement/index'; +import discordModule from './modules/discord/index'; log.initialize({ spyRendererConsole: true }); log.transports.console.level = 'info'; @@ -44,6 +45,8 @@ app.whenReady().then(async () => { }); registerScreenshotIPC(); + discordModule.initialize(); + ipcMain.once('delay-update', async () => { await createClientWindow(); updateWindow.close(); diff --git a/src/main/modules/discord/index.ts b/src/main/modules/discord/index.ts new file mode 100644 index 0000000..6ef8af8 --- /dev/null +++ b/src/main/modules/discord/index.ts @@ -0,0 +1,104 @@ +import DiscordRPC from 'discord-rpc'; +import { settingsService } from '../settingsManagement/index'; +import log from 'electron-log'; +import { ipcMain } from 'electron'; + +const clientId = import.meta.env.VITE_DISCORD_CLIENT_ID || ''; +const rpc = new DiscordRPC.Client({ transport: 'ipc' }); + +class DiscordModule { + private isConnected: boolean = false; + private isEnabled: boolean = false; + private isLoggedIn: boolean = false; + private activityStartTimestamp: Date = new Date(); + + constructor() { + // Handle when RPC successfully connects + rpc.on('ready', () => { + this.isConnected = true; + log.info('Discord RPC connected successfully.'); + this.setActivity(); + }); + + // Listen for setting changes + settingsService.on('setting-changed', (section, key, value) => { + if (section === 'Integrations' && key === 'Enable Discord Rich Presence') { + this.updateEnabledState(value as boolean); + } + }); + + settingsService.on('settings-applied', () => { + const enabled = settingsService.getByName('Enable Discord Rich Presence'); + this.updateEnabledState(enabled as boolean); + }); + + // Listen for login state changes from the renderer + ipcMain.on('discord:update-state', (_event, isLoggedIn: boolean) => { + if (this.isLoggedIn !== isLoggedIn) { + this.isLoggedIn = isLoggedIn; + log.info(`[Discord RPC] State updated. Logged in: ${isLoggedIn}`); + this.setActivity(); + } + }); + } + + private updateEnabledState(enabled: boolean) { + if (this.isEnabled === enabled) return; + this.isEnabled = enabled; + if (this.isEnabled) { + this.start(); + } else { + this.stop(); + } + } + + public async initialize() { + // Wait for settings to load if they haven't already + await settingsService.load(); + const enabled = settingsService.getByName('Enable Discord Rich Presence'); + this.isEnabled = enabled === true; // Default to false if not set + + if (this.isEnabled) { + this.start(); + } + } + + public start() { + if (!this.isEnabled) return; + + rpc.login({ clientId }).catch((err) => { + // It's normal for login to fail if Discord isn't running + log.warn('Discord RPC failed to login (Discord might not be running).', err.message); + }); + } + + public stop() { + if (this.isConnected) { + rpc.clearActivity().catch(console.error); + rpc.destroy().catch(console.error); + this.isConnected = false; + } + log.info('Discord RPC stopped.'); + } + + public setActivity() { + if (!this.isConnected || !this.isEnabled) return; + + const detailText = this.isLoggedIn ? 'Exploring the world' : 'XP wasting'; + const stateText = this.isLoggedIn ? 'In Game' : 'At Login Screen'; + + rpc.setActivity({ + details: detailText, + state: stateText, + startTimestamp: this.activityStartTimestamp, + largeImageKey: 'logo', + largeImageText: 'HighSpell', + instance: false, + }).catch(err => { + log.error('Failed to set Discord activity', err); + }); + } +} + +export const discordModule = new DiscordModule(); +export default discordModule; diff --git a/src/main/modules/settingsManagement/index.ts b/src/main/modules/settingsManagement/index.ts index 1069706..95bfc78 100644 --- a/src/main/modules/settingsManagement/index.ts +++ b/src/main/modules/settingsManagement/index.ts @@ -24,13 +24,15 @@ import { createSettingsModal } from "../../windows/settings/index"; import { BrowserWindow, ipcMain, dialog, app } from "electron"; import fs from 'fs'; import path from 'path'; +import { EventEmitter } from 'events'; // Centralized Settings Service -class SettingsService { +class SettingsService extends EventEmitter { private static instance: SettingsService; private settingsPath: string; private constructor() { + super(); this.settingsPath = path.join(app.getPath('userData'), 'settings.json'); } @@ -83,11 +85,12 @@ class SettingsService { settingsSchema.loadFromJSON(schemaJSONString); await fs.promises.mkdir(path.dirname(this.settingsPath), { recursive: true }); await fs.promises.writeFile(this.settingsPath, schemaJSONString, 'utf-8'); + this.emit('settings-applied'); } getAll(): any { // Return current settings as plain object - this.ensureDynamicDefaults(); + this.ensureDynamicDefaults(); const out: Record = {}; Object.entries(settingsSchema.settings).forEach(([sectionKey, section]: any) => { out[sectionKey] = {}; @@ -112,6 +115,7 @@ class SettingsService { if (field) { field.value = value; await this.saveCurrent(); + this.emit('setting-changed', section, key, value); } } @@ -133,7 +137,7 @@ export const settingsService = SettingsService.getInstance(); // IPC Handlers for settings API -let settingsWindowRef : BrowserWindow | null = null; +let settingsWindowRef: BrowserWindow | null = null; ipcMain.on('settings:open', async (event) => { console.warn("Here"); const parent = BrowserWindow.fromWebContents(event.sender) || BrowserWindow.getFocusedWindow(); @@ -173,8 +177,8 @@ ipcMain.handle('settings:load', async () => { ipcMain.handle('settings:apply', async (_event, newSettings) => { try { - // Expect newSettings as a full schema JSON string - await settingsService.saveFromSchemaJSON(String(newSettings)); + // Expect newSettings as a full schema JSON string + await settingsService.saveFromSchemaJSON(String(newSettings)); return true; } catch (e) { console.error('Failed to save settings:', e); diff --git a/src/preload/index.ts b/src/preload/index.ts index 1f2e5f1..80e9906 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -30,11 +30,16 @@ const screenshotAPI = { capture: async () => ipcRenderer.invoke('screenshot:capture') as Promise<{ ok: boolean; path?: string; error?: string }>, }; +const discordAPI = { + updateState: (isLoggedIn: boolean) => ipcRenderer.send('discord:update-state', isLoggedIn), +}; + if (process.contextIsolated) { try { contextBridge.exposeInMainWorld('electron', electronAPI); contextBridge.exposeInMainWorld('settings', settingsAPI); contextBridge.exposeInMainWorld('screenshot', screenshotAPI); + contextBridge.exposeInMainWorld('discord', discordAPI); } catch (error) { console.error(error); } @@ -45,4 +50,6 @@ if (process.contextIsolated) { window.settings = settingsAPI; // @ts-ignore window.screenshot = screenshotAPI; + // @ts-ignore + window.discord = discordAPI; } diff --git a/src/preload/settings.ts b/src/preload/settings.ts index 16e69fb..79a4636 100644 --- a/src/preload/settings.ts +++ b/src/preload/settings.ts @@ -140,6 +140,17 @@ export class settingsSchema extends SettingsSchema { } } as DirectoryField ] + }, + Integrations: { + heading: "Integrations", + fields: [ + { + label: "Enable Discord Rich Presence", + type: SettingTypes.BOOLEAN, + description: "Show your game status on Discord.", + default: false + } as Field + ] } }; } \ No newline at end of file diff --git a/src/renderer/client/client.ts b/src/renderer/client/client.ts index 6e989c4..628385f 100644 --- a/src/renderer/client/client.ts +++ b/src/renderer/client/client.ts @@ -23,6 +23,7 @@ import '@static/css/item-tooltip.css'; import './helpers/titlebarHelpers.js'; import { setupWorldSelectorObserver } from './helpers/worldSelectHelper'; +import { setupAuthObserver } from './helpers/authHelper'; // Load settings via centralized API (values are available via window.settings) await window.settings.getAll(); @@ -229,6 +230,7 @@ toElements.forEach(element => { // Inject World Selector into Login Screen setupWorldSelectorObserver(); +setupAuthObserver(); // Page Setup Completed, Add Game Client Script const clientScript = document.createElement('script'); diff --git a/src/renderer/client/helpers/authHelper.ts b/src/renderer/client/helpers/authHelper.ts new file mode 100644 index 0000000..ec07ea1 --- /dev/null +++ b/src/renderer/client/helpers/authHelper.ts @@ -0,0 +1,46 @@ +// Copyright (C) 2025 HighLite + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +export function setupAuthObserver() { + let lastLoggedInState: boolean | null = null; + + const checkState = () => { + const gameContainer = document.querySelector('#game-container'); + const loginContainer = document.querySelector('#login-screen-container'); + + // Use presence and visibility to determine login status + const isLoggedIn = !!gameContainer && (!loginContainer || (loginContainer as HTMLElement).style.display === 'none'); + + if (lastLoggedInState !== isLoggedIn) { + lastLoggedInState = isLoggedIn; + console.log(`[AuthHelper] Auth state changed. isLoggedIn: ${isLoggedIn}`); + + // @ts-ignore - The preload api might not be perfectly typed for TS here + if (window.discord && typeof window.discord.updateState === 'function') { + // @ts-ignore + window.discord.updateState(isLoggedIn); + } + } + }; + + // Initial check + checkState(); + + const observer = new MutationObserver(() => { + checkState(); + }); + + // We observe body for childList (add/remove) and attributes (style display none) + observer.observe(document.body, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['style', 'class'] + }); + + console.log('[AuthHelper] Setup Auth Observer Complete.'); +} diff --git a/src/types/vite-env.d.ts b/src/types/vite-env.d.ts index d3f96fc..b71c209 100644 --- a/src/types/vite-env.d.ts +++ b/src/types/vite-env.d.ts @@ -14,3 +14,11 @@ // along with this program. If not, see . /// + +interface ImportMetaEnv { + readonly VITE_DISCORD_CLIENT_ID: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/yarn.lock b/yarn.lock index 83a80df..7e938bb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -903,6 +903,15 @@ __metadata: languageName: node linkType: hard +"@types/discord-rpc@npm:^4.0.10": + version: 4.0.10 + resolution: "@types/discord-rpc@npm:4.0.10" + dependencies: + "@types/events": "npm:*" + checksum: 10c0/f8ce9b426a3e86dd33e122877ffdcd7f1984e4c90710933a733fd4e8b58dd4f00fdefda5be07b13969d593452f5da645cae8c111ca6a48a61d935851399a5952 + languageName: node + linkType: hard + "@types/estree@npm:1.0.8": version: 1.0.8 resolution: "@types/estree@npm:1.0.8" @@ -910,6 +919,13 @@ __metadata: languageName: node linkType: hard +"@types/events@npm:*": + version: 3.0.3 + resolution: "@types/events@npm:3.0.3" + checksum: 10c0/3a56f8c51eb4ebc0d05dcadca0c6636c816acc10216ce36c976fad11e54a01f4bb979a07211355686015884753b37f17d74bfdc7aaf4ebb027c1e8a501c7b21d + languageName: node + linkType: hard + "@types/fs-extra@npm:9.0.13, @types/fs-extra@npm:^9.0.11": version: 9.0.13 resolution: "@types/fs-extra@npm:9.0.13" @@ -1200,6 +1216,15 @@ __metadata: languageName: node linkType: hard +"bindings@npm:^1.3.0": + version: 1.5.0 + resolution: "bindings@npm:1.5.0" + dependencies: + file-uri-to-path: "npm:1.0.0" + checksum: 10c0/3dab2491b4bb24124252a91e656803eac24292473e56554e35bbfe3cc1875332cfa77600c3bac7564049dc95075bf6fcc63a4609920ff2d64d0fe405fcf0d4ba + languageName: node + linkType: hard + "bl@npm:^4.1.0": version: 4.1.0 resolution: "bl@npm:4.1.0" @@ -1638,6 +1663,20 @@ __metadata: languageName: node linkType: hard +"discord-rpc@npm:^4.0.1": + version: 4.0.1 + resolution: "discord-rpc@npm:4.0.1" + dependencies: + node-fetch: "npm:^2.6.1" + register-scheme: "github:devsnek/node-register-scheme" + ws: "npm:^7.3.1" + dependenciesMeta: + register-scheme: + optional: true + checksum: 10c0/9a055efcbd252b5ba91cbfc2c1a0130eba75e389c2b7ede5b0db2d7c7499b36d1a36c3503dad50370df184cfabd7e4e5c4689a7e043222fc4baf37a020fa3957 + languageName: node + linkType: hard + "dmg-builder@npm:26.1.0": version: 26.1.0 resolution: "dmg-builder@npm:26.1.0" @@ -2077,6 +2116,13 @@ __metadata: languageName: node linkType: hard +"file-uri-to-path@npm:1.0.0": + version: 1.0.0 + resolution: "file-uri-to-path@npm:1.0.0" + checksum: 10c0/3b545e3a341d322d368e880e1c204ef55f1d45cdea65f7efc6c6ce9e0c4d22d802d5629320eb779d006fe59624ac17b0e848d83cc5af7cd101f206cb704f5519 + languageName: node + linkType: hard + "filelist@npm:^1.0.4": version: 1.0.4 resolution: "filelist@npm:1.0.4" @@ -3015,7 +3061,7 @@ __metadata: languageName: node linkType: hard -"node-addon-api@npm:^1.6.3": +"node-addon-api@npm:^1.3.0, node-addon-api@npm:^1.6.3": version: 1.7.2 resolution: "node-addon-api@npm:1.7.2" dependencies: @@ -3033,6 +3079,20 @@ __metadata: languageName: node linkType: hard +"node-fetch@npm:^2.6.1": + version: 2.7.0 + resolution: "node-fetch@npm:2.7.0" + dependencies: + whatwg-url: "npm:^5.0.0" + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + checksum: 10c0/b55786b6028208e6fbe594ccccc213cab67a72899c9234eb59dba51062a299ea853210fcf526998eaa2867b0963ad72338824450905679ff0fa304b8c5093ae8 + languageName: node + linkType: hard + "node-gyp@npm:^11.2.0, node-gyp@npm:latest": version: 11.5.0 resolution: "node-gyp@npm:11.5.0" @@ -3310,6 +3370,16 @@ __metadata: languageName: node linkType: hard +"register-scheme@github:devsnek/node-register-scheme": + version: 0.0.2 + resolution: "register-scheme@https://github.com/devsnek/node-register-scheme.git#commit=e7cc9a63a1f512565da44cb57316d9fb10750e17" + dependencies: + bindings: "npm:^1.3.0" + node-addon-api: "npm:^1.3.0" + checksum: 10c0/a73a5ea3f07fc9a916329b7c304ee891eff7bd3e24d2f00be3e775743423c47a1281de12874308f39fa05ebefee825c99467f465817872f4e09d35634fc50104 + languageName: node + linkType: hard + "require-directory@npm:^2.1.1": version: 2.1.1 resolution: "require-directory@npm:2.1.1" @@ -3464,7 +3534,9 @@ __metadata: "@electron-toolkit/utils": "npm:^4.0.0" "@iconify/iconify": "npm:^3.1.1" "@ryelite/core": "npm:0.1.1" + "@types/discord-rpc": "npm:^4.0.10" "@types/node": "npm:^22.17.1" + discord-rpc: "npm:^4.0.1" electron: "npm:^37.2.6" electron-builder: "npm:^26.1.0" electron-log: "npm:^5.4.2" @@ -3833,6 +3905,13 @@ __metadata: languageName: node linkType: hard +"tr46@npm:~0.0.3": + version: 0.0.3 + resolution: "tr46@npm:0.0.3" + checksum: 10c0/047cb209a6b60c742f05c9d3ace8fa510bff609995c129a37ace03476a9b12db4dbf975e74600830ef0796e18882b2381fb5fb1f6b4f96b832c374de3ab91a11 + languageName: node + linkType: hard + "truncate-utf8-bytes@npm:^1.0.0": version: 1.0.2 resolution: "truncate-utf8-bytes@npm:1.0.2" @@ -4048,6 +4127,23 @@ __metadata: languageName: node linkType: hard +"webidl-conversions@npm:^3.0.0": + version: 3.0.1 + resolution: "webidl-conversions@npm:3.0.1" + checksum: 10c0/5612d5f3e54760a797052eb4927f0ddc01383550f542ccd33d5238cfd65aeed392a45ad38364970d0a0f4fea32e1f4d231b3d8dac4a3bdd385e5cf802ae097db + languageName: node + linkType: hard + +"whatwg-url@npm:^5.0.0": + version: 5.0.0 + resolution: "whatwg-url@npm:5.0.0" + dependencies: + tr46: "npm:~0.0.3" + webidl-conversions: "npm:^3.0.0" + checksum: 10c0/1588bed84d10b72d5eec1d0faa0722ba1962f1821e7539c535558fb5398d223b0c50d8acab950b8c488b4ba69043fd833cc2697056b167d8ad46fac3995a55d5 + languageName: node + linkType: hard + "which@npm:^2.0.1": version: 2.0.2 resolution: "which@npm:2.0.2" @@ -4099,6 +4195,21 @@ __metadata: languageName: node linkType: hard +"ws@npm:^7.3.1": + version: 7.5.10 + resolution: "ws@npm:7.5.10" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: 10c0/bd7d5f4aaf04fae7960c23dcb6c6375d525e00f795dd20b9385902bd008c40a94d3db3ce97d878acc7573df852056ca546328b27b39f47609f80fb22a0a9b61d + languageName: node + linkType: hard + "xmlbuilder@npm:>=11.0.1, xmlbuilder@npm:^15.1.1": version: 15.1.1 resolution: "xmlbuilder@npm:15.1.1"