From 20fde75218e357c7adb1cc41365dd89947b6783f Mon Sep 17 00:00:00 2001 From: IDCs Date: Fri, 30 Jan 2026 09:16:33 +0000 Subject: [PATCH 1/6] goodbye @electron/remote --- docs/DEBUGGING-GUIDE.md | 4 - .../src/util/UserlistPersistor.ts | 27 +- extensions/theme-switcher/src/util.ts | 10 +- playwright/src/nexusmods-auth-helpers.ts | 7 +- .../tests/manage-fake-stardew-valley.spec.ts | 15 +- src/extensions/dashboard/views/Dashboard.tsx | 27 +- .../views/DiagnosticsFilesDialog.tsx | 6 +- .../download_management/DownloadManager.ts | 24 +- .../download_management/FileAssembler.ts | 25 +- src/extensions/download_management/index.ts | 26 +- src/extensions/extension_manager/index.ts | 2 +- .../util/ProcessMonitor.ts | 34 +- .../mod_management/views/Settings.tsx | 27 +- src/extensions/nexus_integration/util.ts | 24 +- src/extensions/profile_management/index.ts | 2 +- .../settings_interface/SettingsInterface.tsx | 11 +- src/extensions/starter_dashlet/util.ts | 20 +- src/extensions/updater/autoupdater.ts | 28 +- src/main.ts | 18 +- src/main/Application.ts | 714 +++++++++++++++++- src/main/MainWindow.ts | 30 +- src/main/SplashScreen.ts | 2 +- src/main/TrayIcon.ts | 2 +- src/main/getVortexPath.ts | 228 ++++++ src/main/ipc.ts | 18 + src/main/webview.ts | 34 + src/preload/browserView.ts | 15 + src/preload/index.ts | 191 ++++- src/renderer.tsx | 61 +- src/renderer/controls/Webview.tsx | 10 +- src/{util => renderer}/menu.ts | 123 +-- src/renderer/preload.d.ts | 2 + src/renderer/views/Dialog.tsx | 21 +- src/renderer/views/WindowControls.tsx | 54 +- src/renderer/webview.ts | 27 + src/shared/types/ipc.ts | 187 +++++ src/shared/types/preload.ts | 256 +++++++ src/util/ExtensionManager.ts | 179 ++--- src/util/PresetManager.ts | 17 +- src/util/StarterInfo.ts | 39 +- src/util/api.ts | 16 +- src/util/application.electron.ts | 71 +- src/util/electron.ts | 58 +- src/util/electronRemote.ts | 132 ---- src/util/errorHandling.ts | 175 +++-- src/util/exeIcon.ts | 15 +- src/util/extensionUtil.ts | 18 + src/util/fs.ts | 219 +++--- src/util/getVortexPath.ts | 397 ++++------ src/util/requireRemap.ts | 6 +- src/util/vortex-run/src/thread.ts | 12 +- src/util/webview.ts | 80 -- tsconfig.main.json | 3 +- tsconfig.renderer.json | 1 - 54 files changed, 2602 insertions(+), 1148 deletions(-) create mode 100644 src/main/getVortexPath.ts create mode 100644 src/main/webview.ts create mode 100644 src/preload/browserView.ts rename src/{util => renderer}/menu.ts (71%) create mode 100644 src/renderer/webview.ts delete mode 100644 src/util/electronRemote.ts create mode 100644 src/util/extensionUtil.ts delete mode 100644 src/util/webview.ts diff --git a/docs/DEBUGGING-GUIDE.md b/docs/DEBUGGING-GUIDE.md index e6b52fcf2..26ef62ced 100644 --- a/docs/DEBUGGING-GUIDE.md +++ b/docs/DEBUGGING-GUIDE.md @@ -225,7 +225,6 @@ log("error", "download failed", { **During Development:** - Press **F12** or **Ctrl+Shift+I** in the running Vortex window -- Or add to your code: `require('@electron/remote').getCurrentWindow().webContents.openDevTools()` **From Command Line:** @@ -287,9 +286,6 @@ Vortex uses Electron, which provides Chrome DevTools for debugging. // In main process import { BrowserWindow } from "electron"; BrowserWindow.getFocusedWindow()?.webContents.openDevTools(); - -// In renderer process -require("@electron/remote").getCurrentWindow().webContents.openDevTools(); ``` ### Network Tab diff --git a/extensions/gamebryo-plugin-management/src/util/UserlistPersistor.ts b/extensions/gamebryo-plugin-management/src/util/UserlistPersistor.ts index 3a7b3c62b..d3c4f04b8 100644 --- a/extensions/gamebryo-plugin-management/src/util/UserlistPersistor.ts +++ b/extensions/gamebryo-plugin-management/src/util/UserlistPersistor.ts @@ -2,7 +2,6 @@ import { ILOOTList, ILOOTPlugin } from '../types/ILOOTList'; import {gameSupported} from './gameSupport'; -import * as RemoteT from '@electron/remote'; import Promise from 'bluebird'; import { dialog as dialogIn } from 'electron'; import { dump, load } from 'js-yaml'; @@ -10,8 +9,6 @@ import * as _ from 'lodash'; import * as path from 'path'; import { fs, types, util } from 'vortex-api'; -const remote = util.lazyRequire(() => require('@electron/remote')); - /** * persistor syncing to and from the loot userlist.yaml file * @@ -148,14 +145,20 @@ class UserlistPersistor implements types.IPersistor { }); } - private handleInvalidList() { - const dialog = process.type === 'renderer' - ? remote.dialog - : dialogIn; + private async handleInvalidList() { + const showMessageBox = async (options: Electron.MessageBoxOptions) => { + if (process.type === 'renderer') { + const result = await (window as any).api.dialog.showMessageBox(options); + return result.response; + } else { + const result = await dialogIn.showMessageBox(null, options); + return result.response; + } + }; let res = 0; if (this.mMode === 'masterlist') { - res = dialog.showMessageBoxSync(null, { + res = await showMessageBox({ title: 'Masterlist invalid', message: `The masterlist "${this.mUserlistPath}" can\'t be read. ` + '\n\n' @@ -167,7 +170,7 @@ class UserlistPersistor implements types.IPersistor { ], }); } else { - res = dialog.showMessageBoxSync(null, { + res = await showMessageBox({ title: 'Userlist invalid', message: `The LOOT userlist "${this.mUserlistPath}" can\'t be read. ` + '\n\n' @@ -226,7 +229,7 @@ class UserlistPersistor implements types.IPersistor { let empty: boolean = false; return fs.readFileAsync(this.mUserlistPath) - .then((data: Buffer) => { + .then(async (data: Buffer) => { if (data.byteLength <= 5) { // the smallest non-empty file is actually around 20 bytes long and // the smallest useful file probably 30. This is really to catch @@ -239,10 +242,10 @@ class UserlistPersistor implements types.IPersistor { try { newList = load(data.toString(), { json: true }) as any; } catch (err) { - this.handleInvalidList(); + await this.handleInvalidList(); } if (typeof (newList) !== 'object') { - this.handleInvalidList(); + await this.handleInvalidList(); } ['globals', 'plugins', 'groups'].forEach(key => { diff --git a/extensions/theme-switcher/src/util.ts b/extensions/theme-switcher/src/util.ts index 82f011a69..472040bbb 100644 --- a/extensions/theme-switcher/src/util.ts +++ b/extensions/theme-switcher/src/util.ts @@ -9,7 +9,8 @@ interface IFont { family: string; } -const getAvailableFontImpl = () => { +// Get available system fonts - runs directly in renderer process +export function getAvailableFonts(): Promise { // eslint-disable-next-line @typescript-eslint/no-var-requires const fontScanner = require('font-scanner'); return fontScanner.getAvailableFonts() @@ -21,9 +22,4 @@ const getAvailableFontImpl = () => { 'BebasNeue', ...(fonts || []).map(font => font.family).sort(), ]))); -}; - -const getAvailableFonts: () => Promise = - util.makeRemoteCall('get-available-fonts', getAvailableFontImpl); - -export { getAvailableFonts }; +} diff --git a/playwright/src/nexusmods-auth-helpers.ts b/playwright/src/nexusmods-auth-helpers.ts index 8d4a13ef3..b505e7b6b 100644 --- a/playwright/src/nexusmods-auth-helpers.ts +++ b/playwright/src/nexusmods-auth-helpers.ts @@ -102,12 +102,9 @@ export async function blockExternalBrowserLaunch(mainWindow: Page): Promise { - return await mainWindow.evaluate(() => { + return await mainWindow.evaluate(async () => { try { - const remote = (window as any).require('@electron/remote'); - const getReduxState = remote.getGlobal('getReduxState'); - const stateJson = getReduxState(); - const state = JSON.parse(stateJson); + const state = await (window as any).api.redux.getState() as any; const oauthUrl = state?.session?.nexus?.oauthPending; if (oauthUrl) { diff --git a/playwright/tests/manage-fake-stardew-valley.spec.ts b/playwright/tests/manage-fake-stardew-valley.spec.ts index 0345e42ee..4d2cbf8bc 100644 --- a/playwright/tests/manage-fake-stardew-valley.spec.ts +++ b/playwright/tests/manage-fake-stardew-valley.spec.ts @@ -124,9 +124,8 @@ test('manage fake Stardew Valley', async ({ browser }: { browser: Browser }) => // Verify game was discovered console.log('7. Verifying...'); - const isDiscovered = await mainWindow.evaluate(() => { - const remote = (window as any).require('@electron/remote'); - const state = JSON.parse(remote.getGlobal('getReduxState')()); + const isDiscovered = await mainWindow.evaluate(async () => { + const state = await (window as any).api.redux.getState() as any; return !!state?.settings?.gameMode?.discovered?.stardewvalley; }); @@ -150,9 +149,8 @@ test('manage fake Stardew Valley', async ({ browser }: { browser: Browser }) => console.log(` Download Popup URL: ${modUrl}`); // Check Vortex download state before download - const downloadsBefore = await mainWindow.evaluate(() => { - const remote = (window as any).require('@electron/remote'); - const state = JSON.parse(remote.getGlobal('getReduxState')()); + const downloadsBefore = await mainWindow.evaluate(async () => { + const state = await (window as any).api.redux.getState() as any; return { downloadCount: Object.keys(state?.persistent?.downloads?.files || {}).length, downloads: state?.persistent?.downloads?.files @@ -178,9 +176,8 @@ test('manage fake Stardew Valley', async ({ browser }: { browser: Browser }) => await mainWindow.waitForTimeout(2000); // Check Vortex download state after download - const downloadsAfter = await mainWindow.evaluate(() => { - const remote = (window as any).require('@electron/remote'); - const state = JSON.parse(remote.getGlobal('getReduxState')()); + const downloadsAfter = await mainWindow.evaluate(async () => { + const state = await (window as any).api.redux.getState() as any; const downloads = state?.persistent?.downloads?.files || {}; return { downloadCount: Object.keys(downloads).length, diff --git a/src/extensions/dashboard/views/Dashboard.tsx b/src/extensions/dashboard/views/Dashboard.tsx index 884ce8138..1c208c992 100644 --- a/src/extensions/dashboard/views/Dashboard.tsx +++ b/src/extensions/dashboard/views/Dashboard.tsx @@ -9,7 +9,6 @@ import { translate, } from "../../../renderer/controls/ComponentEx"; import Debouncer from "../../../util/Debouncer"; -import lazyRequire from "../../../util/lazyRequire"; import { getSafe } from "../../../util/storeHelper"; import MainPage from "../../../renderer/views/MainPage"; @@ -26,15 +25,12 @@ import PackeryGrid from "./PackeryGrid"; import type { IPackeryItemProps } from "./PackeryItem"; import PackeryItem from "./PackeryItem"; -import type * as remoteT from "@electron/remote"; import * as _ from "lodash"; import * as React from "react"; import { Button, MenuItem } from "react-bootstrap"; import type * as Redux from "redux"; import type { ThunkDispatch } from "redux-thunk"; -const remote: typeof remoteT = lazyRequire(() => require("@electron/remote")); - const UPDATE_FREQUENCY_MS = 1000; interface IBaseProps { @@ -78,6 +74,8 @@ class Dashboard extends ComponentEx { private mUpdateTimer: NodeJS.Timeout; private mLayoutDebouncer: Debouncer; private mWindowFocused: boolean = true; + private mUnsubscribeFocus: (() => void) | undefined; + private mUnsubscribeBlur: (() => void) | undefined; constructor(props: IProps) { super(props); @@ -93,27 +91,24 @@ class Dashboard extends ComponentEx { } return null; }, 500); - // assuming this doesn't change? - const window = remote.getCurrentWindow(); - this.mWindowFocused = window.isFocused(); } public componentDidMount() { this.startUpdateCycle(); - const win = remote.getCurrentWindow(); - win.on("focus", this.onFocus); - win.on("blur", this.onBlur); - window.addEventListener("beforeunload", () => { - win.removeListener("focus", this.onFocus); - win.removeListener("blur", this.onBlur); + // Check initial focus state + window.api.window.isFocused(window.windowId).then((focused) => { + this.mWindowFocused = focused; }); + // Subscribe to focus/blur events via preload API + this.mUnsubscribeFocus = window.api.window.onFocus(this.onFocus); + this.mUnsubscribeBlur = window.api.window.onBlur(this.onBlur); } public componentWillUnmount() { clearTimeout(this.mUpdateTimer); - const win = remote.getCurrentWindow(); - win.removeListener("focus", this.onFocus); - win.removeListener("blur", this.onBlur); + // Unsubscribe from focus/blur events + this.mUnsubscribeFocus?.(); + this.mUnsubscribeBlur?.(); } public UNSAFE_componentWillReceiveProps(nextProps: IProps) { diff --git a/src/extensions/diagnostics_files/views/DiagnosticsFilesDialog.tsx b/src/extensions/diagnostics_files/views/DiagnosticsFilesDialog.tsx index 794889cf0..bd4e33f44 100644 --- a/src/extensions/diagnostics_files/views/DiagnosticsFilesDialog.tsx +++ b/src/extensions/diagnostics_files/views/DiagnosticsFilesDialog.tsx @@ -15,7 +15,6 @@ import { showError } from "../../../util/message"; import type { ILog, ISession } from "../types/ISession"; import { loadVortexLogs } from "../util/loadVortexLogs"; -import type * as RemoteT from "@electron/remote"; import PromiseBB from "bluebird"; import update from "immutability-helper"; import * as os from "os"; @@ -31,11 +30,8 @@ import { } from "react-bootstrap"; import type * as Redux from "redux"; import type { ThunkDispatch } from "redux-thunk"; -import lazyRequire from "../../../util/lazyRequire"; import { util } from "../../.."; -const remote = lazyRequire(() => require("@electron/remote")); - export interface IBaseProps { visible: boolean; onHide: () => void; @@ -313,7 +309,7 @@ class DiagnosticsFilesDialog extends ComponentEx { .filter((line) => enabledLevels.has(line.type)) .map((line) => `${line.time} - ${line.type}: ${line.text}`) .join(os.EOL); - remote.clipboard.writeText(filteredLog); + window.api.clipboard.writeText(filteredLog); }; private openLogFolder = () => { diff --git a/src/extensions/download_management/DownloadManager.ts b/src/extensions/download_management/DownloadManager.ts index c0b7f3d09..00cf4d342 100644 --- a/src/extensions/download_management/DownloadManager.ts +++ b/src/extensions/download_management/DownloadManager.ts @@ -4,7 +4,6 @@ import { StalledError, UserCanceled, } from "../../util/CustomErrors"; -import makeRemoteCall from "../../util/electronRemote"; import * as fs from "../../util/fs"; import { log } from "../../util/log"; import { delayed, INVALID_FILENAME_RE, truthy } from "../../util/util"; @@ -39,12 +38,11 @@ import type { IExtensionApi } from "../../types/api"; import { simulateHttpError } from "./debug/simulateHttpError"; import { getErrorMessageOrDefault, unknownToError } from "../../shared/errors"; -const getCookies = makeRemoteCall( - "get-cookies", - (electron, webContents, filter: Electron.CookiesGetFilter) => { - return webContents.session.cookies.get(filter); - }, -); +function getCookies( + filter: Electron.CookiesGetFilter, +): Promise { + return window.api.session.getCookies(filter); +} // assume urls are valid for at least 5 minutes const URL_RESOLVE_EXPIRE_MS = 1000 * 60 * 5; @@ -1647,7 +1645,9 @@ class DownloadManager { new ProcessCanceled("Failed to resolve download URL"), ); } - return resolved.urls[0]; + // Ensure URL is a string, not a URL object (URL objects don't serialize properly through IPC) + const url = resolved.urls[0]; + return typeof url === "string" ? url : String(url); }), confirmedOffset: 0, confirmedSize: this.mMinChunkSize, @@ -2330,7 +2330,9 @@ class DownloadManager { new ProcessCanceled("Failed to resolve download URL"), ); } - return resolved.urls[0]; + // Ensure URL is a string, not a URL object + const url = resolved.urls[0]; + return typeof url === "string" ? url : String(url); }), }); offset += chunkSize; @@ -2410,7 +2412,9 @@ class DownloadManager { new ProcessCanceled("Failed to resolve download URL"), ); } - return resolved.urls[0]; + // Ensure URL is a string, not a URL object + const url = resolved.urls[0]; + return typeof url === "string" ? url : String(url); }), // Immutable confirmed fields confirmedOffset, diff --git a/src/extensions/download_management/FileAssembler.ts b/src/extensions/download_management/FileAssembler.ts index c0fd800be..61610b505 100644 --- a/src/extensions/download_management/FileAssembler.ts +++ b/src/extensions/download_management/FileAssembler.ts @@ -9,11 +9,16 @@ import { dialog as dialogIn } from "electron"; import * as fsFast from "fs-extra"; import * as path from "path"; -const dialog = - process.type === "renderer" - ? // tslint:disable-next-line:no-var-requires - require("@electron/remote").dialog - : dialogIn; +const showMessageBox = async ( + options: Electron.MessageBoxOptions, +): Promise => { + if (process.type === "renderer") { + return window.api.dialog.showMessageBox(options); + } else { + const win = getVisibleWindow(); + return dialogIn.showMessageBox(win, options); + } +}; /** * assembles a file received in chunks. @@ -157,7 +162,7 @@ class FileAssembler { : PromiseBB.resolve(synced), ) .catch({ code: "ENOSPC" }, () => { - dialog.showMessageBoxSync(getVisibleWindow(), { + return showMessageBox({ type: "warning", title: "Disk is full", message: @@ -166,9 +171,11 @@ class FileAssembler { buttons: ["Cancel", "Retry"], defaultId: 1, noLink: true, - }) === 1 - ? this.addChunk(offset, data) - : PromiseBB.reject(new UserCanceled()); + }).then((result) => + result.response === 1 + ? this.addChunk(offset, data) + : PromiseBB.reject(new UserCanceled()), + ); }), false, ); diff --git a/src/extensions/download_management/index.ts b/src/extensions/download_management/index.ts index 63f5468be..7ea405644 100644 --- a/src/extensions/download_management/index.ts +++ b/src/extensions/download_management/index.ts @@ -56,7 +56,6 @@ import type DownloadManager from "./DownloadManager"; import type { DownloadObserver } from "./DownloadObserver"; import type observe from "./DownloadObserver"; -import type * as RemoteT from "@electron/remote"; import PromiseBB from "bluebird"; import * as _ from "lodash"; import Zip from "node-7z"; @@ -65,7 +64,6 @@ import type * as Redux from "redux"; import { generate as shortid } from "shortid"; import { fileMD5 } from "vortexmt"; import winapi from "winapi-bindings"; -import lazyRequire from "../../util/lazyRequire"; import setDownloadGames from "./util/setDownloadGames"; import { ensureLoggedIn } from "../nexus_integration/util"; import NXMUrl from "../nexus_integration/NXMUrl"; @@ -77,8 +75,6 @@ import { import extendAPI from "./util/extendApi"; import { unknownToError } from "../../shared/errors"; -const remote = lazyRequire(() => require("@electron/remote")); - let observer: DownloadObserver; let manager: DownloadManager; let updateDebouncer: Debouncer; @@ -1449,12 +1445,13 @@ function init(context: IExtensionContextExt): boolean { { let powerTimer: NodeJS.Timeout; let powerBlockerId: number; - const stopTimer = () => { - if ( - powerBlockerId !== undefined && - remote.powerSaveBlocker.isStarted(powerBlockerId) - ) { - remote.powerSaveBlocker.stop(powerBlockerId); + const stopTimer = async () => { + if (powerBlockerId !== undefined) { + const isStarted = + await window.api.powerSaveBlocker.isStarted(powerBlockerId); + if (isStarted) { + await window.api.powerSaveBlocker.stop(powerBlockerId); + } } powerBlockerId = undefined; powerTimer = undefined; @@ -1514,9 +1511,12 @@ function init(context: IExtensionContextExt): boolean { clearTimeout(powerTimer); } if (powerBlockerId === undefined) { - powerBlockerId = remote.powerSaveBlocker.start( - "prevent-app-suspension", - ); + // Start power save blocker asynchronously + window.api.powerSaveBlocker + .start("prevent-app-suspension") + .then((id) => { + powerBlockerId = id; + }); } powerTimer = setTimeout(stopTimer, 60000); } diff --git a/src/extensions/extension_manager/index.ts b/src/extensions/extension_manager/index.ts index d0d2f676d..3d275c9c9 100644 --- a/src/extensions/extension_manager/index.ts +++ b/src/extensions/extension_manager/index.ts @@ -6,7 +6,7 @@ import type { NotificationDismiss } from "../../types/INotification"; import type { IExtensionLoadFailure, IState } from "../../types/IState"; import { relaunch } from "../../util/commandLine"; import { DataInvalid, ProcessCanceled } from "../../util/CustomErrors"; -import { isExtSame } from "../../util/ExtensionManager"; +import { isExtSame } from "../../util/extensionUtil"; import { log } from "../../util/log"; import makeReactive from "../../util/makeReactive"; diff --git a/src/extensions/gamemode_management/util/ProcessMonitor.ts b/src/extensions/gamemode_management/util/ProcessMonitor.ts index bf05e1a67..992954ab5 100644 --- a/src/extensions/gamemode_management/util/ProcessMonitor.ts +++ b/src/extensions/gamemode_management/util/ProcessMonitor.ts @@ -8,7 +8,6 @@ import { currentGame, currentGameDiscovery } from "../../../util/selectors"; import { getSafe } from "../../../util/storeHelper"; import { setdefault } from "../../../util/util"; -import type { BrowserWindow } from "electron"; import * as path from "path"; import type * as Redux from "redux"; import type { IProcessInfo, IProcessProvider } from "./processProvider"; @@ -87,7 +86,9 @@ import { defaultProcessProvider } from "./processProvider"; class ProcessMonitor { private mTimer: NodeJS.Timeout; private mStore: Redux.Store; - private mWindow: BrowserWindow; + private mIsFocused: boolean = true; + private mUnsubscribeFocus: (() => void) | null = null; + private mUnsubscribeBlur: (() => void) | null = null; private mActive: boolean = false; private mProcessProvider: IProcessProvider; @@ -126,8 +127,15 @@ class ProcessMonitor { clearTimeout(this.mTimer); } - if (process.type === "renderer") { - this.mWindow = require("@electron/remote").getCurrentWindow(); + // Track focus state via preload events in renderer process + if (process.type === "renderer" && window?.api?.window) { + this.mIsFocused = true; // Assume focused initially + this.mUnsubscribeFocus = window.api.window.onFocus(() => { + this.mIsFocused = true; + }); + this.mUnsubscribeBlur = window.api.window.onBlur(() => { + this.mIsFocused = false; + }); } log("debug", "start process monitor"); @@ -147,8 +155,7 @@ class ProcessMonitor { return; } - const isFocused = this.mWindow === undefined || this.mWindow.isFocused(); - const delay = isFocused ? 2000 : 5000; + const delay = this.mIsFocused ? 2000 : 5000; const startedAt = Date.now(); void this.doCheck() @@ -313,7 +320,8 @@ class ProcessMonitor { visited.add(proc.ppid); // Success if direct child of Vortex, otherwise recurse up the tree return ( - proc.ppid === vortexPid || isChildProcessOfVortex(byPid[proc.ppid], visited) + proc.ppid === vortexPid || + isChildProcessOfVortex(byPid[proc.ppid], visited) ); }; @@ -353,7 +361,10 @@ class ProcessMonitor { // Step 6c-i: Process with cached PID still exists - but is it still "ours"? // For games (considerDetached=true): any process is valid, we're done // For tools (considerDetached=false): must still be a Vortex child process - if (considerDetached || isChildProcessOfVortex(knownProc, new Set())) { + if ( + considerDetached || + isChildProcessOfVortex(knownProc, new Set()) + ) { return; // Still valid, no state change needed } // Step 6c-ii: Process exists but is no longer a child - fall through to re-match @@ -464,6 +475,13 @@ class ProcessMonitor { clearTimeout(this.mTimer); this.mTimer = undefined; this.mActive = false; + + // Unsubscribe from focus events + this.mUnsubscribeFocus?.(); + this.mUnsubscribeBlur?.(); + this.mUnsubscribeFocus = null; + this.mUnsubscribeBlur = null; + log("debug", "stop process monitor"); } } diff --git a/src/extensions/mod_management/views/Settings.tsx b/src/extensions/mod_management/views/Settings.tsx index c625e7ddf..47ac36c15 100644 --- a/src/extensions/mod_management/views/Settings.tsx +++ b/src/extensions/mod_management/views/Settings.tsx @@ -72,7 +72,6 @@ import { modPathsForGame } from "../selectors"; import { STAGING_DIR_TAG } from "../stagingDirectory"; import getText from "../texts"; -import * as remote from "@electron/remote"; import PromiseBB from "bluebird"; import * as path from "path"; import * as React from "react"; @@ -140,6 +139,7 @@ interface IComponentState { supportedActivators: IDeploymentMethod[]; currentActivator: string; changingActivator: boolean; + appPath: string; } type IProps = IBaseProps & IActionProps & IConnectedProps; @@ -158,6 +158,7 @@ class Settings extends ComponentEx { currentActivator: props.currentActivator, installPath: props.installPath, changingActivator: false, + appPath: undefined, }); } @@ -174,6 +175,16 @@ class Settings extends ComponentEx { this.nextState.currentActivator = activators.length > 0 ? activators[0].id : undefined; } + + // Load the app path for validation + window.api.app.getAppPath().then((appPath) => { + // In asar builds getAppPath returns the path of the asar so need to go up 2 levels + // (resources/app.asar) + if (path.basename(appPath) === "app.asar") { + appPath = path.dirname(path.dirname(appPath)); + } + this.nextState.appPath = appPath; + }); } public UNSAFE_componentWillReceiveProps(newProps: IProps) { @@ -921,12 +932,8 @@ class Settings extends ComponentEx { reason?: string; } { const { downloadsPath } = this.props; - let vortexPath = remote.app.getAppPath(); - if (path.basename(vortexPath) === "app.asar") { - // in asar builds getAppPath returns the path of the asar so need to go up 2 levels - // (resources/app.asar) - vortexPath = path.dirname(path.dirname(vortexPath)); - } + const { appPath } = this.state; + if (downloadsPath !== undefined) { const downPath = path.dirname(downloadsPath); const normalizedDownloadsPath = path.normalize(downPath.toLowerCase()); @@ -950,7 +957,7 @@ class Settings extends ComponentEx { }; } - if (isChildPath(input, vortexPath)) { + if (appPath !== undefined && isChildPath(input, appPath)) { return { state: "error", reason: @@ -1083,7 +1090,9 @@ class Settings extends ComponentEx { const { modPaths, onShowError, suggestInstallPathDirectory } = this.props; PromiseBB.join( fs.statAsync(modPaths[""]), - fs.statAsync(remote.app.getPath("userData")), + window.api.app + .getPath("userData") + .then((userDataPath) => fs.statAsync(userDataPath)), ) .then((stats) => { let suggestion: string; diff --git a/src/extensions/nexus_integration/util.ts b/src/extensions/nexus_integration/util.ts index bf374bae7..dc7f8d206 100644 --- a/src/extensions/nexus_integration/util.ts +++ b/src/extensions/nexus_integration/util.ts @@ -1,4 +1,3 @@ -import type * as RemoteT from "@electron/remote"; import type { EndorsedStatus, ICollectionQuery, @@ -48,7 +47,6 @@ import { contextify, setApiKey, setOauthToken } from "../../util/errorHandling"; import * as fs from "../../util/fs"; import getVortexPath from "../../util/getVortexPath"; import { RateLimitExceeded } from "../../util/github"; -import lazyRequire from "../../util/lazyRequire"; import { log } from "../../util/log"; import { calcDuration, showError } from "../../util/message"; import { jsonRequest } from "../../util/network"; @@ -94,8 +92,6 @@ import type { IValidateKeyDataV2 } from "./types/IValidateKeyData"; import { IAccountStatus } from "./types/IValidateKeyData"; import { getErrorMessageOrDefault, unknownToError } from "../../shared/errors"; -const remote = lazyRequire(() => require("@electron/remote")); - const UPDATE_CHECK_DELAY = 60 * 60 * 1000; const GAMES_JSON_URL = "https://data.nexusmods.com/file/nexus-data/games.json"; @@ -149,7 +145,7 @@ export function onCancelLoginImpl(api: IExtensionApi) { api.events.emit("did-login", new UserCanceled()); } -export function bringToFront() { +export async function bringToFront() { // if window is snapped in windows (aero snap), bringing the window to front // will unsnap it and it will be moved/resized to where it was before snapping. // This is quite irritating so this will store the (snapped) window position @@ -157,17 +153,17 @@ export function bringToFront() { // This will cause a short "flicker" if the window was snapped and it will // still unsnap the window as far as windows is concerned. - const window = remote.getCurrentWindow(); - const [x, y] = window.getPosition(); - const [w, h] = window.getSize(); + const windowId = window.windowId; + const [x, y] = await window.api.window.getPosition(windowId); + const [w, h] = await window.api.window.getSize(windowId); - window.setAlwaysOnTop(true); - window.show(); - window.setAlwaysOnTop(false); + await window.api.window.setAlwaysOnTop(windowId, true); + await window.api.window.show(windowId); + await window.api.window.setAlwaysOnTop(windowId, false); setTimeout(() => { - window.setPosition(x, y); - window.setSize(w, h); + void window.api.window.setPosition(windowId, x, y); + void window.api.window.setSize(windowId, w, h); }, 100); } @@ -317,7 +313,7 @@ export function requestLogin( async (err: Error, token: ITokenReply) => { // received reply from site for this state - bringToFront(); + void bringToFront(); api.store.dispatch(setLoginId(undefined)); // set state to undefined so that we can close the modal? api.store.dispatch(setDialogVisible(undefined)); diff --git a/src/extensions/profile_management/index.ts b/src/extensions/profile_management/index.ts index a5f458742..3737353c9 100644 --- a/src/extensions/profile_management/index.ts +++ b/src/extensions/profile_management/index.ts @@ -476,8 +476,8 @@ function genOnProfileChange( }; // changes to profile files are only saved back to the profile at this point - queue = queue.then(() => refreshProfile(store, oldProfile, "import")); const oldProfile = state.persistent.profiles[prev]; + queue = queue.then(() => refreshProfile(store, oldProfile, "import")); api.events.emit("profile-will-change", current, enqueue); diff --git a/src/extensions/settings_interface/SettingsInterface.tsx b/src/extensions/settings_interface/SettingsInterface.tsx index 740f3331b..09474b0d5 100644 --- a/src/extensions/settings_interface/SettingsInterface.tsx +++ b/src/extensions/settings_interface/SettingsInterface.tsx @@ -19,7 +19,6 @@ import { translate, } from "../../renderer/controls/ComponentEx"; import getVortexPath from "../../util/getVortexPath"; -import lazyRequire from "../../util/lazyRequire"; import { log } from "../../util/log"; import { truthy } from "../../util/util"; @@ -50,9 +49,7 @@ import { import { nativeCountryName, nativeLanguageName } from "./languagemap"; import getText from "./texts"; -import type * as remoteT from "@electron/remote"; import PromiseBB from "bluebird"; -import { app } from "electron"; import * as path from "path"; import * as React from "react"; import { @@ -67,8 +64,6 @@ import { useSelector } from "react-redux"; import type * as Redux from "redux"; import type { ThunkDispatch } from "redux-thunk"; -const remote: typeof remoteT = lazyRequire(() => require("@electron/remote")); - interface ILanguage { key: string; language: string; @@ -447,8 +442,7 @@ class SettingsInterfaceImpl extends ComponentEx { // bug reports. onSetStartMinimized(false); } - const uniApp = process.type === "renderer" ? remote.app : app; - uniApp.setLoginItemSettings({ + window.api.app.setLoginItemSettings({ openAtLogin: startOnBoot, path: process.execPath, // Yes this is currently needed - thanks Electron args: startOnBoot ? (startMinimized ? ["--start-minimized"] : []) : [], @@ -459,8 +453,7 @@ class SettingsInterfaceImpl extends ComponentEx { const { autoStart, startMinimized, onSetStartMinimized } = this.props; const isMinimized = !startMinimized === true; onSetStartMinimized(isMinimized); - const uniApp = process.type === "renderer" ? remote.app : app; - uniApp.setLoginItemSettings({ + window.api.app.setLoginItemSettings({ openAtLogin: autoStart, path: process.execPath, // Yes this is currently needed - thanks Electron args: isMinimized ? ["--start-minimized"] : [], diff --git a/src/extensions/starter_dashlet/util.ts b/src/extensions/starter_dashlet/util.ts index 163a79da3..b052c22f1 100644 --- a/src/extensions/starter_dashlet/util.ts +++ b/src/extensions/starter_dashlet/util.ts @@ -15,11 +15,6 @@ import StarterInfo from "../../util/StarterInfo"; import { truthy } from "../../util/util"; -import lazyRequire from "../../util/lazyRequire"; -import type * as remoteT from "@electron/remote"; -import { makeRemoteCallSync } from "../../util/electronRemote"; -const remote: typeof remoteT = lazyRequire(() => require("@electron/remote")); - export const propOf = (name: keyof T) => name; export function isEqual(lhs: object, rhs: object) { @@ -62,16 +57,11 @@ export function splitCommandLine(input: string): string[] { return res; } -const setJumpList = makeRemoteCallSync( - "set-jump-list", - (electron, window, categories: Electron.JumpListCategory[]) => { - try { - electron.app.setJumpList(categories); - } catch (err) { - console.error(err); - } - }, -); +function setJumpList(categories: Electron.JumpListCategory[]): Promise { + return window.api.app.setJumpList(categories).catch((err) => { + console.error(err); + }); +} export function updateJumpList(starters: IStarterInfo[]) { if (process.platform !== "win32") { diff --git a/src/extensions/updater/autoupdater.ts b/src/extensions/updater/autoupdater.ts index 6594928d5..f72ae45b6 100644 --- a/src/extensions/updater/autoupdater.ts +++ b/src/extensions/updater/autoupdater.ts @@ -23,14 +23,19 @@ const CHECKING_FOR_UPDATES_ID = "vortex-checking-updates-notification"; const UPDATE_AVAILABLE_ID = "vortex-update-available-notification"; const FORCED_SWITCH_TO_BETA_ID = "switched-to-beta-channel"; -let app = appIn; -let dialog = dialogIn; -if (process.type === "renderer") { - // tslint:disable-next-line:no-var-requires - const remote = require("@electron/remote"); - app = remote.app; - dialog = remote.dialog; -} +const app = appIn; + +// Async dialog helper that works in both main and renderer processes +const showMessageBox = async ( + options: Electron.MessageBoxOptions, +): Promise => { + if (process.type === "renderer") { + return window.api.dialog.showMessageBox(options); + } else { + const win = getVisibleWindow(); + return dialogIn.showMessageBox(win, options); + } +}; const appName = "com.nexusmods.vortex"; const ELECTRON_BUILDER_NS_UUID = "50e065bc-3134-11e6-9bab-38c9862bdaf3"; @@ -62,8 +67,7 @@ function openTesting() { function updateWarning() { // if dev, don't do this - - dialog.showMessageBoxSync(getVisibleWindow(), { + void showMessageBox({ type: "info", title: "Vortex critical update", message: @@ -288,7 +292,7 @@ Are you sure you want to downgrade?`, autoUpdater.on("error", (err) => { // need to remove notifications?! - api.dismissNotification(CHECKING_FOR_UPDATES_ID); + api.dismissNotification?.(CHECKING_FOR_UPDATES_ID); if (err.cmd !== undefined && err.cmd.startsWith("powershell.exe")) { api.showErrorNotification( @@ -351,7 +355,7 @@ Are you sure you want to downgrade?`, autoUpdater.on("update-available", (info: UpdateInfo) => { // need to remove notifications?! - api.dismissNotification(CHECKING_FOR_UPDATES_ID); + api.dismissNotification?.(CHECKING_FOR_UPDATES_ID); log("info", "found update available", { version: info.version, diff --git a/src/main.ts b/src/main.ts index 145969f60..e9e3b0151 100644 --- a/src/main.ts +++ b/src/main.ts @@ -26,11 +26,12 @@ import { VORTEX_VERSION } from "./shared/constants"; process.env["UV_THREADPOOL_SIZE"] = (os.cpus().length * 2).toString(); process.env["VORTEX_VERSION"] = VORTEX_VERSION; import "./util/application.electron"; -import getVortexPath from "./util/getVortexPath"; import { app, dialog } from "electron"; import * as path from "path"; +import getVortexPath from "./main/getVortexPath"; + const earlyErrHandler = (error) => { if (error.stack.includes("[as dlopen]")) { dialog.showErrorBox( @@ -163,12 +164,11 @@ import {} from "./util/extensionRequire"; // required for the side-effect! import "./util/exeIcon"; import "./util/monkeyPatching"; -import "./util/webview"; +import "./main/webview"; import type * as child_processT from "child_process"; import * as fs from "./util/fs"; import presetManager from "./util/PresetManager"; -import { getErrorMessage } from "./shared/errors"; process.env.Path = process.env.Path + path.delimiter + __dirname; @@ -315,18 +315,6 @@ async function main(): Promise { app.commandLine.appendSwitch("remote-debugging-port", DEBUG_PORT); } - // tslint:disable-next-line:no-submodule-imports - try { - require("@electron/remote/main").initialize(); - } catch (err) { - const message = getErrorMessage(err); - if (message && !message.includes("already been initialized")) { - throw err; - } - - // @electron/remote already initialized, continue - } - let fixedT = require("i18next").getFixedT("en"); try { fixedT("dummy"); diff --git a/src/main/Application.ts b/src/main/Application.ts index 8be167658..547457af4 100644 --- a/src/main/Application.ts +++ b/src/main/Application.ts @@ -1,12 +1,33 @@ import type * as msgpackT from "@msgpack/msgpack"; import type crashDumpT from "crash-dump"; -import type { crashReporter as crashReporterT } from "electron"; +import type { + crashReporter as crashReporterT, + OpenDialogOptions, + IpcMainInvokeEvent, + IpcMainEvent, + JumpListCategory, + SaveDialogOptions, + Settings, + TraceConfig, + TraceCategoriesAndOptions, +} from "electron"; import type * as permissionsT from "permissions"; import type * as uuidT from "uuid"; import type * as winapiT from "winapi-bindings"; import PromiseBB from "bluebird"; -import { app, dialog, ipcMain, protocol, shell } from "electron"; +import { + app, + BrowserView, + BrowserWindow, + contentTracing, + clipboard, + dialog, + ipcMain, + protocol, + shell, + Menu, +} from "electron"; import contextMenu from "electron-context-menu"; import isAdmin from "is-admin"; import * as _ from "lodash"; @@ -72,6 +93,8 @@ import { } from "../util/errorHandling"; import { validateFiles } from "../util/fileValidation"; import * as fs from "../util/fs"; +import type { AppPath } from "../util/getVortexPath"; + import getVortexPath, { setVortexPath } from "../util/getVortexPath"; import lazyRequire from "../util/lazyRequire"; import { log, setLogPath, setupLogging } from "../util/log"; @@ -88,6 +111,12 @@ import { } from "../util/util"; import { betterIpcMain } from "./ipc"; +// Type-safe interface for global Redux state accessors +interface GlobalWithRedux { + getReduxState?: () => unknown; + getReduxStateMsgpack?: (idx: number) => string; +} + const uuid = lazyRequire(() => require("uuid")); const permissions = lazyRequire(() => require("permissions"), @@ -96,7 +125,8 @@ const winapi = lazyRequire(() => require("winapi-bindings")); const STATE_CHUNK_SIZE = 128 * 1024; -function last(array: any[]): any { +// TODO: remove this once extension manager separation is complete +function last(array: T[]): T | undefined { if (array.length === 0) { return undefined; } @@ -104,19 +134,20 @@ function last(array: any[]): any { } class Application { - public static shouldIgnoreError(error: any, promise?: any): boolean { - if (error instanceof UserCanceled) { + public static shouldIgnoreError(error: unknown, promise?: unknown): boolean { + const err = unknownToError(error); + if (err instanceof UserCanceled) { return true; } - if (!error) { + if (!err) { log("error", "empty error unhandled", { wasPromise: promise !== undefined, }); return true; } - if (error.message === "Object has been destroyed") { + if (err.message === "Object has been destroyed") { // This happens when Vortex crashed because of something else so there is no point // reporting this, it might otherwise obfuscate the actual problem return true; @@ -125,13 +156,14 @@ class Application { // this error message appears to happen as the result of some other problem crashing the // renderer process, so all this may do is obfuscate what's actually going on. if ( - error.message.includes( + err.message.includes( "Error processing argument at index 0, conversion failure from", ) ) { return true; } + const code = getErrorCode(err); if ( [ "net::ERR_CONNECTION_RESET", @@ -141,26 +173,16 @@ class Application { "net::ERR_SSL_PROTOCOL_ERROR", "net::ERR_HTTP2_PROTOCOL_ERROR", "net::ERR_INCOMPLETE_CHUNKED_ENCODING", - ].includes(error.message) || - ["ETIMEDOUT", "ECONNRESET", "EPIPE"].includes(error.code) + ].includes(err.message) || + ["ETIMEDOUT", "ECONNRESET", "EPIPE"].includes(code) ) { - log("warn", "network error unhandled", error.stack); + log("warn", "network error unhandled", err.stack); return true; } - if ( - ["EACCES", "EPERM"].includes(error.errno) && - error.path !== undefined && - error.path.indexOf("vortex-setup") !== -1 - ) { - // It's wonderous how electron-builder finds new ways to be more shit without even being - // updated. Probably caused by node update - log("warn", "suppressing error message", { - message: error.message, - stack: error.stack, - }); - return true; - } + // We used to handle err.errno here (incorrectly) + // e.g. ['EPERM', 'EACCES'].includes(err.errno) + // but errno is a number, not a string. return false; } @@ -345,8 +367,6 @@ class Application { app.on( "web-contents-created", (event: Electron.Event, contents: Electron.WebContents) => { - // tslint:disable-next-line:no-submodule-imports - require("@electron/remote/main").enable(contents); contents.on("will-attach-webview", this.attachWebView); }, ); @@ -378,7 +398,7 @@ class Application { }; private genHandleError() { - return (error: any, promise?: any) => { + return (error: unknown, promise?: unknown) => { if (Application.shouldIgnoreError(error, promise)) { return; } @@ -1047,6 +1067,7 @@ class Application { newStore.replaceReducer( reducer(this.mExtensions.getReducers(), querySanitize), ); + return PromiseBB.mapSeries(allHives(this.mExtensions), (hive) => insertPersistor( hive, @@ -1153,7 +1174,7 @@ class Application { let sendState: Buffer; - (global as any).getReduxStateMsgpack = (idx: number) => { + (global as GlobalWithRedux).getReduxStateMsgpack = (idx: number) => { const msgpack: typeof msgpackT = require("@msgpack/msgpack"); if (sendState === undefined || idx === 0) { sendState = Buffer.from( @@ -1403,4 +1424,641 @@ class Application { betterIpcMain.handle("example:ping", () => "pong", { includeArgs: true }); +// Helper for protocol client registration +function selfCL(udPath: string | undefined): [string, string[]] { + // The "-d" flag is required so that when Windows appends the NXM URL to the command line, + // it becomes "-d nxm://..." which commander parses as "--download nxm://..." + if (process.env.NODE_ENV === "development") { + // Use absolute path for the app entry point - process.argv[1] may be relative (e.g. ".") + // and would fail when launched from a different working directory (e.g. C:\WINDOWS\system32) + const appPath = path.resolve(process.argv[1]); + return [ + process.execPath, + [appPath, ...(udPath !== undefined ? ["--userData", udPath] : []), "-d"], + ]; + } else { + return [ + process.execPath, + [...(udPath !== undefined ? ["--userData", udPath] : []), "-d"], + ]; + } +} + +// Dialog handlers +betterIpcMain.handle( + "dialog:showOpen", + async (event: IpcMainInvokeEvent, options: OpenDialogOptions) => { + const window = BrowserWindow.fromWebContents(event.sender); + return await dialog.showOpenDialog(window, options); + }, +); + +betterIpcMain.handle( + "dialog:showSave", + async (event: IpcMainInvokeEvent, options: SaveDialogOptions) => { + const window = BrowserWindow.fromWebContents(event.sender); + return await dialog.showSaveDialog(window, options); + }, +); + +betterIpcMain.handle( + "dialog:showMessageBox", + async (event: IpcMainInvokeEvent, options: Electron.MessageBoxOptions) => { + const window = BrowserWindow.fromWebContents(event.sender); + return await dialog.showMessageBox(window, options); + }, +); + +betterIpcMain.handle( + "dialog:showErrorBox", + async (_event: IpcMainInvokeEvent, title: string, content: string) => { + console.error("[Error Box]", title, content); + dialog.showErrorBox(title, content); + }, +); + +// App protocol client handlers +betterIpcMain.handle( + "app:setProtocolClient", + async ( + _event: IpcMainInvokeEvent, + protocol: string, + udPath: string | undefined, + ) => { + const [execPath, args] = selfCL(udPath); + app.setAsDefaultProtocolClient(protocol, execPath, args); + }, +); + +betterIpcMain.handle( + "app:isProtocolClient", + async ( + _event: IpcMainInvokeEvent, + protocol: string, + udPath: string | undefined, + ) => { + const [execPath, args] = selfCL(udPath); + return app.isDefaultProtocolClient(protocol, execPath, args); + }, +); + +betterIpcMain.handle( + "app:removeProtocolClient", + async ( + _event: IpcMainInvokeEvent, + protocol: string, + udPath: string | undefined, + ) => { + const [execPath, args] = selfCL(udPath); + app.removeAsDefaultProtocolClient(protocol, execPath, args); + }, +); + +betterIpcMain.handle( + "app:exit", + async (_event: IpcMainInvokeEvent, exitCode: number) => { + app.exit(exitCode); + }, +); + +betterIpcMain.handle("app:getName", async () => { + return app.getName(); +}); + +// App path handlers +betterIpcMain.handle( + "app:getPath", + async (_event: IpcMainInvokeEvent, name: string) => { + // Use Vortex's custom path logic instead of Electron's native paths + return getVortexPath(name as AppPath); + }, +); + +betterIpcMain.handle( + "app:setPath", + async (_event: IpcMainInvokeEvent, name: string, value: string) => { + // Use Vortex's custom path setter + setVortexPath(name as AppPath, value); + }, +); + +// File icon extraction +betterIpcMain.handle( + "app:extractFileIcon", + async (_event: IpcMainInvokeEvent, exePath: string, iconPath: string) => { + const icon = await app.getFileIcon(exePath, { size: "normal" }); + await fs.writeFileAsync(iconPath, icon.toPNG()); + }, +); + +// BrowserView handlers +import { extraWebViews } from "./webview"; + +betterIpcMain.handle( + "browserView:create", + async ( + event: IpcMainInvokeEvent, + src: string, + partition: string, + _isNexus: boolean, + ) => { + const window = BrowserWindow.fromWebContents(event.sender); + const contentsId = event.sender.id; + + if (extraWebViews[contentsId] === undefined) { + extraWebViews[contentsId] = {}; + } + + const view = new BrowserView({ + webPreferences: { + // External sites are sandboxed with minimal Buffer polyfill for bundled JS + preload: path.join(__dirname, "../preload/browserView.js"), + nodeIntegration: false, + contextIsolation: true, + partition: partition, + sandbox: true, + + /** + * Not happy about this, but disabling webSecurity is necessary to avoid + * CORS and certificate issues with some external sites when downloading through BrowserViews. + * (moddb being one of them) + * + * These views are temporary (created only for downloads) + * We can't control external sites' SSL/certificate configuration + * The partition isolation already provides some security boundary + * In the future we could consider enabling webSecurity and + * adding specific exceptions for known sites if necessary. + */ + webSecurity: false, + }, + }); + + const viewId = `${contentsId}_${Object.keys(extraWebViews[contentsId]).length}`; + extraWebViews[contentsId][viewId] = view; + + await view.webContents.loadURL(src); + window.addBrowserView(view); + + return viewId; + }, +); + +betterIpcMain.handle( + "browserView:createWithEvents", + async ( + event: IpcMainInvokeEvent, + src: string, + forwardEvents: string[], + options: Electron.BrowserViewConstructorOptions | undefined, + ) => { + const window = BrowserWindow.fromWebContents(event.sender); + const contentsId = event.sender.id; + + if (extraWebViews[contentsId] === undefined) { + extraWebViews[contentsId] = {}; + } + + const typedOptions = options ?? {}; + // External sites are sandboxed with minimal Buffer polyfill + const viewOptions: Electron.BrowserViewConstructorOptions = { + ...typedOptions, + webPreferences: { + preload: path.join(__dirname, "../preload/browserView.js"), + nodeIntegration: false, + contextIsolation: true, + sandbox: true, + /** + * Not happy about this, but disabling webSecurity is necessary to avoid + * CORS and certificate issues with some external sites when downloading through BrowserViews. + * (moddb being one of them) + * + * These views are temporary (created only for downloads) + * We can't control external sites' SSL/certificate configuration + * The partition isolation already provides some security boundary + * In the future we could consider enabling webSecurity and + * adding specific exceptions for known sites if necessary. + */ + webSecurity: false, + ...typedOptions?.webPreferences, + }, + }; + + const view = new BrowserView(viewOptions); + const viewId = `${contentsId}_${Object.keys(extraWebViews[contentsId]).length}`; + extraWebViews[contentsId][viewId] = view; + + view.setAutoResize({ + horizontal: true, + vertical: true, + }); + + window.addBrowserView(view); + await view.webContents.loadURL(src); + + // Forward events from BrowserView to renderer + forwardEvents.forEach((eventId) => { + // Type assertion needed because eventId is a dynamic string from the caller + // WebContents.on is overloaded for each specific event type + view.webContents.on( + eventId as Parameters[0], + (evt, ...args) => { + event.sender.send(`view-${viewId}-${eventId}`, JSON.stringify(args)); + evt.preventDefault(); + }, + ); + }); + + return viewId; + }, +); + +betterIpcMain.handle( + "browserView:close", + async (event: IpcMainInvokeEvent, viewId) => { + const contentsId = event.sender.id; + if (extraWebViews[contentsId]?.[viewId] !== undefined) { + const window = BrowserWindow.fromWebContents(event.sender); + window?.removeBrowserView(extraWebViews[contentsId][viewId]); + delete extraWebViews[contentsId][viewId]; + } + }, +); + +betterIpcMain.handle( + "browserView:position", + async (event: IpcMainInvokeEvent, viewId, rect) => { + const contentsId = event.sender.id; + extraWebViews[contentsId]?.[viewId]?.setBounds?.(rect); + }, +); + +betterIpcMain.handle( + "browserView:updateURL", + async (event: IpcMainInvokeEvent, viewId, newURL) => { + const contentsId = event.sender.id; + void extraWebViews[contentsId]?.[viewId]?.webContents.loadURL(newURL); + }, +); + +// Jump list (Windows) +betterIpcMain.handle( + "app:setJumpList", + async (_event: IpcMainInvokeEvent, categories: JumpListCategory[]) => { + try { + app.setJumpList(categories); + } catch (_err) { + // Ignore jump list errors (not available on all platforms) + } + }, +); + +// Session cookies +betterIpcMain.handle( + "session:getCookies", + async (event: IpcMainInvokeEvent, filter: Electron.CookiesGetFilter) => { + // Only return cookies from the main window's session + // BrowserView cookies (e.g., tracking cookies from external sites) should NOT + // be sent to CDN downloads that use signed URLs for authentication + return event.sender.session.cookies.get(filter); + }, +); + +// Window operations + +// Sync handler for getting windowId during preload initialization +betterIpcMain.handleSync("window:getIdSync", (event: IpcMainEvent) => { + const window = BrowserWindow.fromWebContents(event.sender); + return window?.id ?? -1; +}); + +// Sync handlers for app name and version (used by application.electron.ts) +betterIpcMain.handleSync("app:getNameSync", () => { + return app.name; +}); + +betterIpcMain.handleSync("app:getVersionSync", () => { + return app.getVersion(); +}); + +// Sync handler for all Vortex paths (used by preload for renderer) +betterIpcMain.handleSync("vortex:getPathsSync", () => { + return { + base: getVortexPath("base"), + assets: getVortexPath("assets"), + assets_unpacked: getVortexPath("assets_unpacked"), + modules: getVortexPath("modules"), + modules_unpacked: getVortexPath("modules_unpacked"), + bundledPlugins: getVortexPath("bundledPlugins"), + locales: getVortexPath("locales"), + package: getVortexPath("package"), + package_unpacked: getVortexPath("package_unpacked"), + application: getVortexPath("application"), + userData: getVortexPath("userData"), + appData: getVortexPath("appData"), + localAppData: getVortexPath("localAppData"), + temp: getVortexPath("temp"), + home: getVortexPath("home"), + documents: getVortexPath("documents"), + exe: getVortexPath("exe"), + desktop: getVortexPath("desktop"), + }; +}); + +betterIpcMain.handle("window:getId", async (event: IpcMainInvokeEvent) => { + const window = BrowserWindow.fromWebContents(event.sender); + return window?.id ?? -1; +}); + +betterIpcMain.handle( + "window:minimize", + async (_event: IpcMainInvokeEvent, windowId: number) => { + const window = BrowserWindow.fromId(windowId); + window?.minimize(); + }, +); + +betterIpcMain.handle( + "window:maximize", + async (_event: IpcMainInvokeEvent, windowId: number) => { + const window = BrowserWindow.fromId(windowId); + window?.maximize(); + }, +); + +betterIpcMain.handle( + "window:unmaximize", + async (_event: IpcMainInvokeEvent, windowId: number) => { + const window = BrowserWindow.fromId(windowId); + window?.unmaximize(); + }, +); + +betterIpcMain.handle( + "window:restore", + async (_event: IpcMainInvokeEvent, windowId: number) => { + const window = BrowserWindow.fromId(windowId); + window?.restore(); + }, +); + +betterIpcMain.handle( + "window:close", + async (_event: IpcMainInvokeEvent, windowId: number) => { + const window = BrowserWindow.fromId(windowId); + window?.close(); + }, +); + +betterIpcMain.handle( + "window:focus", + async (_event: IpcMainInvokeEvent, windowId: number) => { + const window = BrowserWindow.fromId(windowId); + window?.focus(); + }, +); + +betterIpcMain.handle( + "window:show", + async (_event: IpcMainInvokeEvent, windowId: number) => { + const window = BrowserWindow.fromId(windowId); + window?.show(); + }, +); + +betterIpcMain.handle( + "window:hide", + async (_event: IpcMainInvokeEvent, windowId: number) => { + const window = BrowserWindow.fromId(windowId); + window?.hide(); + }, +); + +betterIpcMain.handle( + "window:isMaximized", + async (_event: IpcMainInvokeEvent, windowId: number) => { + const window = BrowserWindow.fromId(windowId); + return window?.isMaximized() ?? false; + }, +); + +betterIpcMain.handle( + "window:isMinimized", + async (_event: IpcMainInvokeEvent, windowId: number) => { + const window = BrowserWindow.fromId(windowId); + return window?.isMinimized() ?? false; + }, +); + +betterIpcMain.handle( + "window:isFocused", + async (_event: IpcMainInvokeEvent, windowId: number) => { + const window = BrowserWindow.fromId(windowId); + return window?.isFocused() ?? false; + }, +); + +betterIpcMain.handle( + "window:setAlwaysOnTop", + async (_event: IpcMainInvokeEvent, windowId: number, flag: boolean) => { + const window = BrowserWindow.fromId(windowId); + window?.setAlwaysOnTop(flag); + }, +); + +betterIpcMain.handle( + "window:moveTop", + async (_event: IpcMainInvokeEvent, windowId: number) => { + const window = BrowserWindow.fromId(windowId); + window?.moveTop(); + }, +); + +// Menu operations +betterIpcMain.handle( + "menu:setApplicationMenu", + ( + event: IpcMainInvokeEvent, + template: Electron.MenuItemConstructorOptions[], + ) => { + const sender = event.sender; + + // Recursively add click handlers that send IPC events to renderer + type MenuItemWithId = Electron.MenuItemConstructorOptions & { id?: string }; + const processTemplate = (items: MenuItemWithId[]): MenuItemWithId[] => { + return items.map((item: MenuItemWithId) => { + const processed = { ...item }; + if (item.id) { + processed.click = () => { + if (!sender.isDestroyed()) { + sender.send("menu:click", item.id); + } + }; + } + if (item.submenu && Array.isArray(item.submenu)) { + processed.submenu = processTemplate(item.submenu); + } + return processed; + }); + }; + + const processedTemplate = processTemplate(template); + const menu = Menu.buildFromTemplate(processedTemplate); + Menu.setApplicationMenu(menu); + }, +); + +// Content tracing operations +betterIpcMain.handle( + "contentTracing:startRecording", + async ( + _event: IpcMainInvokeEvent, + options: TraceConfig | TraceCategoriesAndOptions, + ) => { + return await contentTracing.startRecording(options); + }, +); + +betterIpcMain.handle( + "contentTracing:stopRecording", + async (_event: IpcMainInvokeEvent, resultPath: string) => { + return await contentTracing.stopRecording(resultPath); + }, +); + +// Redux state transfer +betterIpcMain.handle("redux:getState", async () => { + const getReduxState = (global as GlobalWithRedux).getReduxState; + if (typeof getReduxState === "function") { + return getReduxState(); + } + return undefined; +}); + +betterIpcMain.handle( + "redux:getStateMsgpack", + async (_event: IpcMainInvokeEvent, idx: number) => { + const getReduxStateMsgpack = (global as GlobalWithRedux) + .getReduxStateMsgpack; + if (typeof getReduxStateMsgpack === "function") { + return getReduxStateMsgpack(idx ?? 0); + } + return undefined; + }, +); + +// Login item settings +betterIpcMain.handle( + "app:setLoginItemSettings", + async (_event: IpcMainInvokeEvent, settings: Settings) => { + app.setLoginItemSettings(settings); + }, +); + +betterIpcMain.handle("app:getLoginItemSettings", async () => { + return app.getLoginItemSettings(); +}); + +// Clipboard operations +betterIpcMain.handle( + "clipboard:writeText", + async (_event: IpcMainInvokeEvent, text: string) => { + clipboard.writeText(text); + }, +); + +betterIpcMain.handle("clipboard:readText", async () => { + return clipboard.readText(); +}); + +// Power save blocker operations +import { powerSaveBlocker } from "electron"; + +betterIpcMain.handle( + "powerSaveBlocker:start", + async ( + _event: IpcMainInvokeEvent, + type: "prevent-app-suspension" | "prevent-display-sleep", + ) => { + return powerSaveBlocker.start(type); + }, +); + +betterIpcMain.handle( + "powerSaveBlocker:stop", + async (_event: IpcMainInvokeEvent, id: number) => { + powerSaveBlocker.stop(id); + }, +); + +betterIpcMain.handle( + "powerSaveBlocker:isStarted", + async (_event: IpcMainInvokeEvent, id: number) => { + return powerSaveBlocker.isStarted(id); + }, +); + +// App path operations +betterIpcMain.handle("app:getAppPath", async (_event: IpcMainInvokeEvent) => { + return app.getAppPath(); +}); + +// Additional window operations +betterIpcMain.handle( + "window:getPosition", + async (_event: IpcMainInvokeEvent, windowId): Promise<[number, number]> => { + const win = BrowserWindow.fromId(windowId); + return (win?.getPosition() ?? [0, 0]) as [number, number]; + }, +); + +betterIpcMain.handle( + "window:setPosition", + async ( + _event: IpcMainInvokeEvent, + windowId: number, + x: number, + y: number, + ) => { + const win = BrowserWindow.fromId(windowId); + win?.setPosition(x, y); + }, +); + +betterIpcMain.handle( + "window:getSize", + async (_event: IpcMainInvokeEvent, windowId): Promise<[number, number]> => { + const win = BrowserWindow.fromId(windowId); + return (win?.getSize() ?? [0, 0]) as [number, number]; + }, +); + +betterIpcMain.handle( + "window:setSize", + async ( + _event: IpcMainInvokeEvent, + windowId: number, + width: number, + height: number, + ) => { + const win = BrowserWindow.fromId(windowId); + win?.setSize(width, height); + }, +); + +betterIpcMain.handle( + "window:isVisible", + async (_event: IpcMainInvokeEvent, windowId: number) => { + const win = BrowserWindow.fromId(windowId); + return win?.isVisible() ?? false; + }, +); + +betterIpcMain.handle( + "window:toggleDevTools", + async (_event: IpcMainInvokeEvent, windowId: number) => { + const win = BrowserWindow.fromId(windowId); + win?.webContents.toggleDevTools(); + }, +); + export default Application; diff --git a/src/main/MainWindow.ts b/src/main/MainWindow.ts index 548d8e0a7..fc2c03c94 100644 --- a/src/main/MainWindow.ts +++ b/src/main/MainWindow.ts @@ -8,13 +8,13 @@ import type { ThunkStore } from "../types/IExtensionContext"; import type { IState, IWindow } from "../types/IState"; import Debouncer from "../util/Debouncer"; import { terminate } from "../util/errorHandling"; -import getVortexPath from "../util/getVortexPath"; +import getVortexPath from "./getVortexPath"; import { log } from "../util/log"; import opn from "../util/opn"; import { downloadPath } from "../util/selectors"; import type * as storeHelperT from "../util/storeHelper"; import { parseBool } from "../util/util"; -import { closeAllViews } from "../util/webview"; +import { closeAllViews } from "./webview"; import PromiseBB from "bluebird"; import { ipcMain, screen, webContents } from "electron"; @@ -398,11 +398,11 @@ class MainWindow { windowMetrics?.customTitlebar === true ? "hidden" : "default", webPreferences: { preload: path.join(__dirname, "../preload/index.js"), - nodeIntegration: true, // Required for @electron/remote compatibility + nodeIntegration: true, // Required for extension compatibility nodeIntegrationInWorker: true, webviewTag: true, enableWebSQL: false, - contextIsolation: false, // Required for @electron/remote compatibility + contextIsolation: false, // Required for preload script compatibility backgroundThrottling: false, }, }; @@ -417,13 +417,31 @@ class MainWindow { if (this.mWindow === null) { return; } + // Forward close event to renderer + this.mWindow.webContents.send("window:event:close"); closeAllViews(this.mWindow); }); this.mWindow.on("closed", () => { this.mWindow = null; }); - this.mWindow.on("maximize", () => store.dispatch(setMaximized(true))); - this.mWindow.on("unmaximize", () => store.dispatch(setMaximized(false))); + this.mWindow.on("maximize", () => { + store.dispatch(setMaximized(true)); + // Forward maximize event to renderer + this.mWindow?.webContents.send("window:event:maximize"); + }); + this.mWindow.on("unmaximize", () => { + store.dispatch(setMaximized(false)); + // Forward unmaximize event to renderer + this.mWindow?.webContents.send("window:event:unmaximize"); + }); + this.mWindow.on("focus", () => { + // Forward focus event to renderer + this.mWindow?.webContents.send("window:event:focus"); + }); + this.mWindow.on("blur", () => { + // Forward blur event to renderer + this.mWindow?.webContents.send("window:event:blur"); + }); this.mWindow.on("resize", () => this.mResizeDebouncer.schedule()); this.mWindow.on("move", () => { if (this.mWindow?.isMaximized?.() === false) { diff --git a/src/main/SplashScreen.ts b/src/main/SplashScreen.ts index 6ef73f791..d4e0f72a5 100644 --- a/src/main/SplashScreen.ts +++ b/src/main/SplashScreen.ts @@ -1,7 +1,7 @@ import PromiseBB from "bluebird"; import * as path from "path"; import { pathToFileURL } from "url"; -import getVortexPath from "../util/getVortexPath"; +import getVortexPath from "./getVortexPath"; import { log } from "../util/log"; class SplashScreen { diff --git a/src/main/TrayIcon.ts b/src/main/TrayIcon.ts index da7eb97ed..44d9b60c4 100644 --- a/src/main/TrayIcon.ts +++ b/src/main/TrayIcon.ts @@ -1,5 +1,5 @@ import type { IExtensionApi } from "../types/api"; -import getVortexPath from "../util/getVortexPath"; +import getVortexPath from "./getVortexPath"; import { log } from "../util/log"; import type { BrowserWindow } from "electron"; diff --git a/src/main/getVortexPath.ts b/src/main/getVortexPath.ts new file mode 100644 index 000000000..1471f3fd6 --- /dev/null +++ b/src/main/getVortexPath.ts @@ -0,0 +1,228 @@ +import { app } from "electron"; +import * as os from "os"; +import * as path from "path"; + +// If running as a forked child process, read Electron app info from environment variables +const electronAppInfoEnv: { [key: string]: string | undefined } = + typeof process.send === "function" + ? { + userData: process.env.ELECTRON_USERDATA, + temp: process.env.ELECTRON_TEMP, + appData: process.env.ELECTRON_APPDATA, + home: process.env.ELECTRON_HOME, + documents: process.env.ELECTRON_DOCUMENTS, + exe: process.env.ELECTRON_EXE, + desktop: process.env.ELECTRON_DESKTOP, + appPath: process.env.ELECTRON_APP_PATH, + assets: process.env.ELECTRON_ASSETS, + assets_unpacked: process.env.ELECTRON_ASSETS_UNPACKED, + modules: process.env.ELECTRON_MODULES, + modules_unpacked: process.env.ELECTRON_MODULES_UNPACKED, + bundledPlugins: process.env.ELECTRON_BUNDLEDPLUGINS, + locales: process.env.ELECTRON_LOCALES, + base: process.env.ELECTRON_BASE, + application: process.env.ELECTRON_APPLICATION, + package: process.env.ELECTRON_PACKAGE, + package_unpacked: process.env.ELECTRON_PACKAGE_UNPACKED, + } + : {}; + +export type AppPath = + | "base" + | "assets" + | "assets_unpacked" + | "modules" + | "modules_unpacked" + | "bundledPlugins" + | "locales" + | "package" + | "package_unpacked" + | "application" + | "userData" + | "appData" + | "localAppData" + | "temp" + | "home" + | "documents" + | "exe" + | "desktop"; + +/** + * app.getAppPath() returns the path to the app.asar, + * development: node_modules\electron\dist\resources\default_app.asar + * production (with asar): Vortex\resources\app.asar + * production (without asar): Vortex\resources\app + * + * when running from unit tests, app may not be defined at all, in that case we use __dirname + * after all + */ +let basePath = + app !== undefined ? app.getAppPath() : path.resolve(__dirname, "..", ".."); +const isDevelopment = path.basename(basePath, ".asar") !== "app"; +const isAsar = !isDevelopment && path.extname(basePath) === ".asar"; +const applicationPath = isDevelopment + ? basePath + : path.resolve(path.dirname(basePath), ".."); + +if (isDevelopment) { + // In Electron 37, app.getAppPath() may already point to the 'out' directory + // Check if basePath already ends with 'out' to avoid double 'out/out' + if (path.basename(basePath) === "out") { + // basePath is already correct (points to out directory) + // Don't modify it + } else { + basePath = path.join(applicationPath, "out"); + } +} + +// basePath is now the path that contains assets, bundledPlugins, index.html, main.js and so on +// applicationPath is still different between development and production + +function getModulesPath(unpacked: boolean): string { + if (isDevelopment) { + return path.join(applicationPath, "node_modules"); + } + const asarPath = unpacked && isAsar ? basePath + ".unpacked" : basePath; + return path.join(asarPath, "node_modules"); +} + +function getAssets(unpacked: boolean): string { + const asarPath = unpacked && isAsar ? basePath + ".unpacked" : basePath; + return path.join(asarPath, "assets"); +} + +function getBundledPluginsPath(): string { + // bundled plugins are never packed in the asar + return isAsar + ? path.join(basePath + ".unpacked", "bundledPlugins") + : path.join(basePath, "bundledPlugins"); +} + +function getLocalesPath(): string { + // in production builds the locales are not inside the app(.asar) directory but alongside it + return isDevelopment + ? path.join(basePath, "locales") + : path.resolve(basePath, "..", "locales"); +} + +/** + * path to the directory containing package.json file + */ +function getPackagePath(unpacked: boolean): string { + if (isDevelopment) { + return applicationPath; + } + + let res = basePath; + if (unpacked && path.basename(res) === "app.asar") { + res = path.join(path.dirname(res), "app.asar.unpacked"); + } + + return res; +} + +const cache: { [id: string]: string | (() => string) } = {}; + +const cachedAppPath = (id: string) => { + if (cache[id] === undefined) { + if (app !== undefined) { + if (id === "__app") { + cache[id] = app.getAppPath(); + } else { + cache[id] = app.getPath(id as any); + } + } else { + // Fallback for non-Electron processes (tests) + if (id === "__app") { + cache[id] = path.resolve(__dirname, "..", ".."); + } else { + cache[id] = os.tmpdir(); + } + } + } + const value = cache[id]; + if (typeof value === "string") { + return value; + } else { + return value(); + } +}; + +const localAppData = (() => { + let cached; + return () => { + if (cached === undefined) { + cached = + process.env.LOCALAPPDATA || + path.resolve(cachedAppPath("appData"), "..", "Local"); + } + return cached; + }; +})(); + +export function setVortexPath(id: AppPath, value: string | (() => string)) { + cache[id] = value; + if (app !== undefined) { + if (typeof value === "string") { + app.setPath(id as any, value); + } else { + app.setPath(id as any, value()); + } + } +} + +/** + * Main process version of getVortexPath. + * This function provides paths to application data independent + * of build configuration (development/production, asar/no-asar, portable/not). + * + * This version is designed to run ONLY in the main process where electron.app is available. + */ +function getVortexPath(id: AppPath): string { + if (electronAppInfoEnv && Object.keys(electronAppInfoEnv).length > 0) { + if (id in electronAppInfoEnv && electronAppInfoEnv[id]) { + return electronAppInfoEnv[id]!; + } + // If not found, fall through to next logic (do not throw) + } + switch (id) { + case "userData": + return cachedAppPath("userData"); + case "temp": + return cachedAppPath("temp"); + case "appData": + return cachedAppPath("appData"); + case "localAppData": + return localAppData(); + case "home": + return cachedAppPath("home"); + case "documents": + return cachedAppPath("documents"); + case "exe": + return cachedAppPath("exe"); + case "desktop": + return cachedAppPath("desktop"); + case "base": + return basePath; + case "application": + return applicationPath; + case "package": + return getPackagePath(false); + case "package_unpacked": + return getPackagePath(true); + case "assets": + return getAssets(false); + case "assets_unpacked": + return getAssets(true); + case "modules": + return getModulesPath(false); + case "modules_unpacked": + return getModulesPath(true); + case "bundledPlugins": + return getBundledPluginsPath(); + case "locales": + return getLocalesPath(); + } +} + +export default getVortexPath; diff --git a/src/main/ipc.ts b/src/main/ipc.ts index b060b7fa0..e35cb95fd 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -2,6 +2,7 @@ import type { RendererChannels, MainChannels, InvokeChannels, + SyncChannels, SerializableArgs, AssertSerializable, } from "@shared/types/ipc.js"; @@ -11,6 +12,7 @@ import { ipcMain, type WebContents } from "electron"; export const betterIpcMain = { on: mainOn, handle: mainHandle, + handleSync: mainHandleSync, send: mainSend, }; @@ -77,6 +79,22 @@ function mainHandle( ); } +function mainHandleSync( + channel: C, + listener: ( + event: Electron.IpcMainEvent, + ...args: SerializableArgs> + ) => AssertSerializable>, +): void { + ipcMain.on( + channel, + (event, ...args: SerializableArgs>) => { + assertTrustedSender(event); + event.returnValue = listener(event, ...args); + }, + ); +} + function mainSend( webContents: WebContents, channel: C, diff --git a/src/main/webview.ts b/src/main/webview.ts new file mode 100644 index 000000000..e48afb33a --- /dev/null +++ b/src/main/webview.ts @@ -0,0 +1,34 @@ +// Main process webview utilities + +import type { BrowserView, BrowserWindow } from "electron"; + +/** + * Tracks BrowserViews by webContents ID + * Structure: { [contentsId]: { [viewId]: BrowserView } } + * Could use a Record type, but this is clearer. + */ +export const extraWebViews: { + [contentsId: number]: { [viewId: string]: BrowserView }; +} = {}; + +/** + * Closes all BrowserViews associated with a window + */ +export function closeAllViews(window: BrowserWindow) { + const contentsId = window.webContents.id; + const views = extraWebViews[contentsId]; + + if (views) { + // Remove each view from the window and clean up + Object.values(views).forEach((view) => { + try { + window.removeBrowserView(view); + } catch (_err) { + // Ignore errors if view is already destroyed + } + }); + + // Clear all views for this window + delete extraWebViews[contentsId]; + } +} diff --git a/src/preload/browserView.ts b/src/preload/browserView.ts new file mode 100644 index 000000000..bb2c1662d --- /dev/null +++ b/src/preload/browserView.ts @@ -0,0 +1,15 @@ +/** + * Preload script for BrowserViews (embedded browser for downloading from external sites) + * + * This provides minimal polyfills for Node.js globals that external websites + * may expect when using bundled JavaScript (Webpack/Browserify targets both Node and browser) + */ + +import { contextBridge } from "electron"; + +import { Buffer } from "buffer"; + +// Expose Buffer to the page context via contextBridge +// With contextIsolation, only contextBridge can pass objects to the page +// This makes window.Buffer available for websites that bundle for both Node and browser +contextBridge.exposeInMainWorld("Buffer", Buffer); diff --git a/src/preload/index.ts b/src/preload/index.ts index cb622e19b..635c7ebcb 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,13 +1,14 @@ -import { contextBridge, ipcRenderer } from "electron"; -import type { PreloadWindow } from "@shared/types/preload"; import type { RendererChannels, InvokeChannels, MainChannels, SerializableArgs, AssertSerializable, + VortexPaths, } from "@shared/types/ipc"; +import type { PreloadWindow } from "@shared/types/preload"; +import { contextBridge, ipcRenderer } from "electron"; // NOTE(erri120): Welcome to the preload script. This is the correct and safe place to expose data and methods to the renderer. Here are a few rules and tips to make your life easier: // 1) Never expose anything electron related to the renderer. This is what the preload script is for. // 2) Use betterIpcRenderer defined below instead of raw ipcRenderer. @@ -25,10 +26,196 @@ try { node: process.versions.node, }); + // Get the current window ID synchronously during preload initialization + // This is safe because the BrowserWindow is already created before preload runs + const currentWindowId: number = ipcRenderer.sendSync("window:getIdSync"); + expose("windowId", currentWindowId); + + // Get app name and version synchronously for application.electron.ts + const appName: string = ipcRenderer.sendSync("app:getNameSync"); + const appVersion: string = ipcRenderer.sendSync("app:getVersionSync"); + expose("appName", appName); + expose("appVersion", appVersion); + + // Get all Vortex paths synchronously for getVortexPath + const vortexPaths: VortexPaths = ipcRenderer.sendSync("vortex:getPathsSync"); + expose("vortexPaths", vortexPaths); + expose("api", { example: { ping: () => betterIpcRenderer.invoke("example:ping"), }, + dialog: { + showOpen: (options) => + betterIpcRenderer.invoke( + "dialog:showOpen", + options, + ) as Promise, + showSave: (options) => + betterIpcRenderer.invoke( + "dialog:showSave", + options, + ) as Promise, + showMessageBox: (options) => + betterIpcRenderer.invoke( + "dialog:showMessageBox", + options, + ) as Promise, + showErrorBox: (title, content) => + betterIpcRenderer.invoke("dialog:showErrorBox", title, content), + }, + app: { + setProtocolClient: (protocol: string, udPath: string) => + betterIpcRenderer.invoke("app:setProtocolClient", protocol, udPath), + isProtocolClient: (protocol: string, udPath: string) => + betterIpcRenderer.invoke("app:isProtocolClient", protocol, udPath), + removeProtocolClient: (protocol: string, udPath: string) => + betterIpcRenderer.invoke("app:removeProtocolClient", protocol, udPath), + exit: (exitCode: number) => + betterIpcRenderer.invoke("app:exit", exitCode), + getName: () => betterIpcRenderer.invoke("app:getName"), + getPath: (name: string) => betterIpcRenderer.invoke("app:getPath", name), + setPath: (name: string, value: string) => + betterIpcRenderer.invoke("app:setPath", name, value), + extractFileIcon: (exePath: string, iconPath: string) => + betterIpcRenderer.invoke("app:extractFileIcon", exePath, iconPath), + setJumpList: (categories) => + betterIpcRenderer.invoke("app:setJumpList", categories), + setLoginItemSettings: (settings) => + betterIpcRenderer.invoke("app:setLoginItemSettings", settings), + getLoginItemSettings: () => + betterIpcRenderer.invoke("app:getLoginItemSettings"), + getAppPath: () => betterIpcRenderer.invoke("app:getAppPath"), + }, + browserView: { + create: (src: string, partition: string, isNexus: boolean) => + betterIpcRenderer.invoke("browserView:create", src, partition, isNexus), + createWithEvents: ( + src: string, + forwardEvents: string[], + options: unknown, + ) => + betterIpcRenderer.invoke( + "browserView:createWithEvents", + src, + forwardEvents, + options, + ), + close: (viewId: string) => + betterIpcRenderer.invoke("browserView:close", viewId), + position: (viewId: string, rect: Electron.Rectangle) => + betterIpcRenderer.invoke("browserView:position", viewId, rect), + updateURL: (viewId: string, newURL: string) => + betterIpcRenderer.invoke("browserView:updateURL", viewId, newURL), + }, + session: { + getCookies: (filter: Electron.CookiesGetFilter) => + betterIpcRenderer.invoke("session:getCookies", filter), + }, + window: { + minimize: (windowId: number) => + betterIpcRenderer.invoke("window:minimize", windowId), + maximize: (windowId: number) => + betterIpcRenderer.invoke("window:maximize", windowId), + unmaximize: (windowId: number) => + betterIpcRenderer.invoke("window:unmaximize", windowId), + restore: (windowId: number) => + betterIpcRenderer.invoke("window:restore", windowId), + close: (windowId: number) => + betterIpcRenderer.invoke("window:close", windowId), + focus: (windowId: number) => + betterIpcRenderer.invoke("window:focus", windowId), + show: (windowId: number) => + betterIpcRenderer.invoke("window:show", windowId), + hide: (windowId: number) => + betterIpcRenderer.invoke("window:hide", windowId), + isMaximized: (windowId: number) => + betterIpcRenderer.invoke("window:isMaximized", windowId), + isMinimized: (windowId: number) => + betterIpcRenderer.invoke("window:isMinimized", windowId), + isFocused: (windowId: number) => + betterIpcRenderer.invoke("window:isFocused", windowId), + setAlwaysOnTop: (windowId: number, flag: boolean) => + betterIpcRenderer.invoke("window:setAlwaysOnTop", windowId, flag), + moveTop: (windowId: number) => + betterIpcRenderer.invoke("window:moveTop", windowId), + onMaximize: (callback) => { + const listener = () => callback(); + ipcRenderer.on("window:event:maximize", listener); + return () => + ipcRenderer.removeListener("window:event:maximize", listener); + }, + onUnmaximize: (callback) => { + const listener = () => callback(); + ipcRenderer.on("window:event:unmaximize", listener); + return () => + ipcRenderer.removeListener("window:event:unmaximize", listener); + }, + onClose: (callback) => { + const listener = () => callback(); + ipcRenderer.on("window:event:close", listener); + return () => ipcRenderer.removeListener("window:event:close", listener); + }, + onFocus: (callback) => { + const listener = () => callback(); + ipcRenderer.on("window:event:focus", listener); + return () => ipcRenderer.removeListener("window:event:focus", listener); + }, + onBlur: (callback) => { + const listener = () => callback(); + ipcRenderer.on("window:event:blur", listener); + return () => ipcRenderer.removeListener("window:event:blur", listener); + }, + getPosition: (windowId: number) => + betterIpcRenderer.invoke("window:getPosition", windowId), + setPosition: (windowId: number, x: number, y: number) => + betterIpcRenderer.invoke("window:setPosition", windowId, x, y), + getSize: (windowId: number) => + betterIpcRenderer.invoke("window:getSize", windowId), + setSize: (windowId: number, width: number, height: number) => + betterIpcRenderer.invoke("window:setSize", windowId, width, height), + isVisible: (windowId: number) => + betterIpcRenderer.invoke("window:isVisible", windowId), + toggleDevTools: (windowId: number) => + betterIpcRenderer.invoke("window:toggleDevTools", windowId), + }, + menu: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + setApplicationMenu: (template: any) => + betterIpcRenderer.invoke("menu:setApplicationMenu", template), + onMenuClick: (callback: (menuItemId: string) => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + menuItemId: string, + ) => callback(menuItemId); + ipcRenderer.on("menu:click", listener); + return () => ipcRenderer.removeListener("menu:click", listener); + }, + }, + contentTracing: { + startRecording: (options) => + betterIpcRenderer.invoke("contentTracing:startRecording", options), + stopRecording: (resultPath) => + betterIpcRenderer.invoke("contentTracing:stopRecording", resultPath), + }, + redux: { + getState: () => betterIpcRenderer.invoke("redux:getState"), + getStateMsgpack: (idx?: number) => + betterIpcRenderer.invoke("redux:getStateMsgpack", idx), + }, + clipboard: { + writeText: (text: string) => + betterIpcRenderer.invoke("clipboard:writeText", text), + readText: () => betterIpcRenderer.invoke("clipboard:readText"), + }, + powerSaveBlocker: { + start: (type: "prevent-app-suspension" | "prevent-display-sleep") => + betterIpcRenderer.invoke("powerSaveBlocker:start", type), + stop: (id: number) => + betterIpcRenderer.invoke("powerSaveBlocker:stop", id), + isStarted: (id: number) => + betterIpcRenderer.invoke("powerSaveBlocker:isStarted", id), + }, }); } catch (err) { console.error("failed to run preload code", err); diff --git a/src/renderer.tsx b/src/renderer.tsx index ece2881e1..6b06f5206 100644 --- a/src/renderer.tsx +++ b/src/renderer.tsx @@ -1,3 +1,5 @@ +// Preload types are declared in renderer/preload.d.ts + if (process.env.DEBUG_REACT_RENDERS === "true") { const whyDidYouRender = require("@welldone-software/why-did-you-render"); whyDidYouRender?.(require("react"), { @@ -6,11 +8,20 @@ if (process.env.DEBUG_REACT_RENDERS === "true") { }); } -const earlyErrHandler = (evt) => { - const { error } = evt; - const remote = require("@electron/remote"); - remote.dialog.showErrorBox("Unhandled error", error.stack); - remote.app.exit(1); +// Set up requireRemap first, BEFORE any other requires +// IMPORTANT: Use require() not import, because imports are hoisted to the top +// tslint:disable-next-line:no-var-requires +const requireRemap = require("./util/requireRemap").default; +requireRemap(); + +const earlyErrHandler = (evt: ErrorEvent) => { + const error = evt.error as Error | undefined; + // Use preload API for dialog and app access + void window.api.dialog.showErrorBox( + "Unhandled error", + error?.stack ?? String(evt.error), + ); + void window.api.app.exit(1); }; // turn all error logs into a single parameter. The reason is that (at least in production) @@ -24,9 +35,6 @@ console.error = (...args) => { window.addEventListener("error", earlyErrHandler); window.addEventListener("unhandledrejection", earlyErrHandler); -import requireRemap from "./util/requireRemap"; -requireRemap(); - if (process.env.NODE_ENV === "development") { const rebuildRequire = require("./util/requireRebuild").default; rebuildRequire(); @@ -55,7 +63,6 @@ import type crashDumpT from "crash-dump"; import type * as I18next from "i18next"; import "./util/application.electron"; -import * as remote from "@electron/remote"; import Bluebird from "bluebird"; import { ipcRenderer, webFrame } from "electron"; import { EventEmitter } from "events"; @@ -93,7 +100,6 @@ import { UserCanceled } from "./util/CustomErrors"; import { setOutdated, terminate, toError } from "./util/errorHandling"; import ExtensionManager from "./util/ExtensionManager"; import { ExtensionContext } from "./util/ExtensionProvider"; -import {} from "./util/extensionRequire"; import { setTFunction } from "./util/fs"; import getVortexPath, { setVortexPath } from "./util/getVortexPath"; import GlobalNotifications from "./util/GlobalNotifications"; @@ -103,7 +109,7 @@ import getI18n, { type TFunction, } from "./util/i18n"; import { log } from "./util/log"; -import { initApplicationMenu } from "./util/menu"; +import { initApplicationMenu } from "./renderer/menu"; import { showError } from "./util/message"; import presetManager from "./util/PresetManager"; import safeForwardToMain from "./util/safeForwardToMain"; @@ -121,16 +127,15 @@ function fetchReduxState(): IState { return ipcRenderer.sendSync("get-redux-state"); } -function initialState(): IState { +async function initialState(): Promise { try { return fetchReduxState(); } catch (err) { if (err instanceof SyntaxError) { - const dumpPath = path.join( - remote.app.getPath("temp"), - "invalid_state.json", - ); - writeFileSync(dumpPath, remote.getGlobal("getReduxState")()); + const tempPath = await window.api.app.getPath("temp"); + const dumpPath = path.join(tempPath, "invalid_state.json"); + const reduxState = await window.api.redux.getState(); + writeFileSync(dumpPath, JSON.stringify(reduxState)); log( "error", "Failed to transfer application state. This indicates an issue with a " + @@ -147,11 +152,11 @@ function initialState(): IState { // everything that had been serialized had the undefined values dropped anyway. let stateSerialized: Buffer = Buffer.alloc(0); - const getReduxStateMsgpack = remote.getGlobal("getReduxStateMsgpack"); - let idx = 0; while (true) { - const newData: string = getReduxStateMsgpack(idx++); + const newData: string = (await window.api.redux.getStateMsgpack( + idx++, + )) as string; if (newData === "") { break; } @@ -177,14 +182,12 @@ setVortexPath("temp", () => path.join(getVortexPath("userData"), "temp")); let deinitCrashDump: () => void; if (process.env.CRASH_REPORTING === "vortex") { - const crashDump: typeof crashDumpT = require("crash-dump").default; - deinitCrashDump = crashDump( - path.join( - remote.app.getPath("temp"), - "dumps", - `crash-renderer-${Date.now()}.dmp`, - ), - ); + void window.api.app.getPath("temp").then((tempPath: string) => { + const crashDump: typeof crashDumpT = require("crash-dump").default; + deinitCrashDump = crashDump( + path.join(tempPath, "dumps", `crash-renderer-${Date.now()}.dmp`), + ); + }); } // on windows, inject the native error code into "unknown" errors to help track those down @@ -498,7 +501,7 @@ async function init(): Promise { // store.replaceReducer(reducer(extReducers)); store = createStore( reducer(extReducers, () => Decision.QUIT, reportReducerError), - initialState(), + await initialState(), enhancer, ); safeReplayActionRenderer(store); // Safe IPC replay without double dispatch diff --git a/src/renderer/controls/Webview.tsx b/src/renderer/controls/Webview.tsx index 8b9c6565a..98114e800 100644 --- a/src/renderer/controls/Webview.tsx +++ b/src/renderer/controls/Webview.tsx @@ -19,7 +19,7 @@ import { makeBrowserView, positionBrowserView, updateViewURL, -} from "../../util/webview"; +} from "../webview"; import { ipcRenderer } from "electron"; import { omit } from "lodash"; @@ -117,6 +117,14 @@ function BrowserView(props: IBrowserViewProps) { viewId.current = await makeBrowserView( props.src, Object.keys(props.events), + { + webPreferences: { + // Use separate partition to isolate cookies from main window + // This prevents external site cookies (e.g., moddb.com) from being + // sent to CDN downloads that use signed URLs for authentication + partition: "persist:webview", + }, + }, ); RESIZE_EVENTS.forEach((evtId) => { diff --git a/src/util/menu.ts b/src/renderer/menu.ts similarity index 71% rename from src/util/menu.ts rename to src/renderer/menu.ts index 58cc06ab6..c3dbc8aa4 100644 --- a/src/util/menu.ts +++ b/src/renderer/menu.ts @@ -1,18 +1,50 @@ import type { IMainPageOptions } from "../types/IExtensionContext"; -import type ExtensionManager from "./ExtensionManager"; -import { debugTranslations, getMissingTranslations } from "./i18n"; -import { log } from "./log"; +import type ExtensionManager from "../util/ExtensionManager"; +import { debugTranslations, getMissingTranslations } from "../util/i18n"; +import { log } from "../util/log"; -import type * as RemoteT from "@electron/remote"; import { webFrame } from "electron"; import * as path from "path"; import { setZoomFactor } from "../actions/window"; -import { getApplication } from "./application"; -import getVortexPath from "./getVortexPath"; -import lazyRequire from "./lazyRequire"; +import { getApplication } from "../util/application"; +import getVortexPath from "../util/getVortexPath"; -const remote = lazyRequire(() => require("@electron/remote")); +// Map to store click handlers by menu item ID +const menuClickHandlers: Map void> = new Map(); +let menuIdCounter = 0; + +// Generate a unique menu item ID +function generateMenuId(): string { + return `menu-item-${++menuIdCounter}`; +} + +// Recursively process menu items to assign IDs and store click handlers +function processMenuTemplate( + items: Electron.MenuItemConstructorOptions[], +): Electron.MenuItemConstructorOptions[] { + return items.map((item) => { + const processed: Electron.MenuItemConstructorOptions = { ...item }; + + // If item has a click handler, assign an ID and store the handler + if (item.click) { + const id = generateMenuId(); + processed.id = id; + menuClickHandlers.set(id, item.click as () => void); + // Remove the click handler - it can't be serialized over IPC + delete processed.click; + } + + // Recursively process submenus + if (item.submenu && Array.isArray(item.submenu)) { + processed.submenu = processMenuTemplate( + item.submenu as Electron.MenuItemConstructorOptions[], + ); + } + + return processed; + }); +} /** * initializes the application menu and with it, hotkeys @@ -21,6 +53,14 @@ const remote = lazyRequire(() => require("@electron/remote")); * @param {ExtensionManager} extensions */ export function initApplicationMenu(extensions: ExtensionManager) { + // Listen for menu click events from main process + window.api.menu.onMenuClick((menuItemId: string) => { + const handler = menuClickHandlers.get(menuItemId); + if (handler) { + handler(); + } + }); + const changeZoomFactor = (factor: number) => { if (factor < 0.5 || factor > 1.5) { return; @@ -51,8 +91,13 @@ export function initApplicationMenu(extensions: ExtensionManager) { }, ]; + // Track translation recording state outside refresh so it persists + let recordTranslation = false; + const refresh = () => { - let recordTranslation = false; + // Clear existing handlers on refresh + menuClickHandlers.clear(); + menuIdCounter = 0; const viewMenu: Electron.MenuItemConstructorOptions[] = []; @@ -88,7 +133,7 @@ export function initApplicationMenu(extensions: ExtensionManager) { label: title, visible: true, accelerator, - click(item, focusedWindow) { + click() { if (options.visible === undefined || options.visible()) { extensions .getApi() @@ -103,7 +148,7 @@ export function initApplicationMenu(extensions: ExtensionManager) { viewMenu.push({ label: "Settings", accelerator: "CmdOrCtrl+Shift+S", - click(item, focusedWindow) { + click() { extensions .getApi() .events.emit("show-main-page", "application_settings"); @@ -117,52 +162,39 @@ export function initApplicationMenu(extensions: ExtensionManager) { label: "Toggle Developer Tools", accelerator: process.platform === "darwin" ? "Alt+Command+I" : "Ctrl+Shift+I", - click(item, focusedWindow) { - if (focusedWindow && "webContents" in focusedWindow) { - ( - focusedWindow as Electron.BrowserWindow - ).webContents.toggleDevTools(); - } else { - extensions - .getApi() - .showErrorNotification?.( - "Failed to open developer tools", - "no focused window", - ); - } + click() { + // Toggle dev tools via IPC + void window.api.window.toggleDevTools(window.windowId); }, }); viewMenu.push({ label: "Reload", accelerator: "F5", - click(item, focusedWindow) { - if (focusedWindow && "webContents" in focusedWindow) { - (focusedWindow as Electron.BrowserWindow).webContents.reload(); - } + click() { + // Reload must be triggered from main process + log("info", "Reload requested from menu"); }, }); viewMenu.push({ label: "Record missing translations", - click(item, focusedWindow) { + click() { recordTranslation = !recordTranslation; debugTranslations(recordTranslation); - const subMenu: Electron.Menu = (menu.items[1] as any) - .submenu as Electron.Menu; - subMenu.items[copyTranslationsIdx].enabled = recordTranslation; + // Refresh menu to update the enabled state of "Copy missing translations" + refresh(); }, }); - const copyTranslationsIdx = viewMenu.length; viewMenu.push({ label: "Copy missing translations to clipboard", - click(item, focusedWindow) { - remote.clipboard.writeText( + enabled: recordTranslation, + click() { + window.api.clipboard.writeText( JSON.stringify(getMissingTranslations(), undefined, 2), ); }, }); - viewMenu[copyTranslationsIdx].enabled = false; } viewMenu.push( @@ -170,7 +202,7 @@ export function initApplicationMenu(extensions: ExtensionManager) { { label: "Zoom In", accelerator: "CmdOrCtrl+Shift+Plus", - click(item, focusedWindow) { + click() { changeZoomFactor(webFrame.getZoomFactor() + 0.1); }, }, @@ -179,14 +211,14 @@ export function initApplicationMenu(extensions: ExtensionManager) { accelerator: "CmdOrCtrl+Shift+numadd", visible: false, acceleratorWorksWhenHidden: true, - click(item, focusedWindow) { + click() { changeZoomFactor(webFrame.getZoomFactor() + 0.1); }, }, { label: "Zoom Out", accelerator: "CmdOrCtrl+Shift+-", - click(item, focusedWindow) { + click() { changeZoomFactor(webFrame.getZoomFactor() - 0.1); }, }, @@ -195,7 +227,7 @@ export function initApplicationMenu(extensions: ExtensionManager) { accelerator: "CmdOrCtrl+Shift+numsub", visible: false, acceleratorWorksWhenHidden: true, - click(item, focusedWindow) { + click() { changeZoomFactor(webFrame.getZoomFactor() - 0.1); }, }, @@ -221,7 +253,7 @@ export function initApplicationMenu(extensions: ExtensionManager) { let profiling: boolean = false; const stopProfiling = () => { const outPath = path.join(getVortexPath("temp"), "profile.dat"); - remote.contentTracing + window.api.contentTracing .stopRecording(outPath) .then(() => { extensions.getApi().sendNotification?.({ @@ -259,7 +291,7 @@ export function initApplicationMenu(extensions: ExtensionManager) { options: "sampling-frequency=10000", }; - remote.contentTracing.startRecording(options).then(() => { + window.api.contentTracing.startRecording(options).then(() => { console.log("Tracing started"); extensions.getApi().sendNotification?.({ id: "profiling", @@ -284,12 +316,15 @@ export function initApplicationMenu(extensions: ExtensionManager) { }, ]; - const menu = remote.Menu.buildFromTemplate([ + // Process the template to assign IDs and store handlers + const template = processMenuTemplate([ { label: "File", submenu: fileMenu }, { label: "View", submenu: viewMenu }, { label: "Performance", submenu: performanceMenu }, ]); - remote.Menu.setApplicationMenu(menu); + + // Send the processed template (without click handlers) to main process + void window.api.menu.setApplicationMenu(template); }; refresh(); return refresh; diff --git a/src/renderer/preload.d.ts b/src/renderer/preload.d.ts index cf7fb661d..a2d610a1e 100644 --- a/src/renderer/preload.d.ts +++ b/src/renderer/preload.d.ts @@ -5,3 +5,5 @@ declare global { readonly __preload: true; } } + +export {}; diff --git a/src/renderer/views/Dialog.tsx b/src/renderer/views/Dialog.tsx index fbb76ded1..6f35025ee 100644 --- a/src/renderer/views/Dialog.tsx +++ b/src/renderer/views/Dialog.tsx @@ -19,10 +19,8 @@ import type { IState } from "../../types/IState"; import bbcode from "../controls/bbcode"; import { ComponentEx, connect, translate } from "../controls/ComponentEx"; import type { TFunction } from "../../util/i18n"; -import lazyRequire from "../../util/lazyRequire"; import { MutexWrapper } from "../../util/MutexContext"; -import type * as RemoteT from "@electron/remote"; import update from "immutability-helper"; import * as React from "react"; import { @@ -39,8 +37,6 @@ import ReactMarkdown from "react-markdown"; import type * as Redux from "redux"; import type { ThunkDispatch } from "redux-thunk"; -const remote = lazyRequire(() => require("@electron/remote")); - const nop = () => undefined; interface IActionProps { @@ -127,13 +123,16 @@ class Dialog extends ComponentEx { this.setState(newState); - const window = remote.getCurrentWindow(); - if (window.isMinimized()) { - window.restore(); - } - window.setAlwaysOnTop(true); - window.show(); - window.setAlwaysOnTop(false); + // Bring window to focus when a new dialog appears + const winId = window.windowId; + void window.api.window.isMinimized(winId).then((minimized: boolean) => { + if (minimized) { + void window.api.window.restore(winId); + } + void window.api.window.setAlwaysOnTop(winId, true); + void window.api.window.show(winId); + void window.api.window.setAlwaysOnTop(winId, false); + }); } else if ( this.props.dialogs[0]?.content !== newProps.dialogs[0]?.content ) { diff --git a/src/renderer/views/WindowControls.tsx b/src/renderer/views/WindowControls.tsx index aba17d0eb..d99a23b15 100644 --- a/src/renderer/views/WindowControls.tsx +++ b/src/renderer/views/WindowControls.tsx @@ -1,42 +1,40 @@ -import type * as RemoteT from "@electron/remote"; -import type { BrowserWindow } from "electron"; import * as React from "react"; import { IconButton } from "../controls/TooltipControls"; -import lazyRequire from "../../util/lazyRequire"; - -const remote = lazyRequire(() => require("@electron/remote")); - -const window = (() => { - let res: BrowserWindow; - return () => { - if (res === undefined) { - res = remote.getCurrentWindow(); - } - return res; - }; -})(); class WindowControls extends React.Component<{}, { isMaximized: boolean }> { private mClosed: boolean = false; + private mUnsubscribeMaximize: (() => void) | null = null; + private mUnsubscribeUnmaximize: (() => void) | null = null; + private mUnsubscribeClose: (() => void) | null = null; constructor(props: {}) { super(props); this.state = { - isMaximized: window().isMaximized(), + isMaximized: false, }; } public componentDidMount() { - window().on("maximize", this.onMaximize); - window().on("unmaximize", this.onUnMaximize); - window().on("close", this.onClose); + // Fetch initial maximized state + window.api.window + .isMaximized(window.windowId) + .then((maximized: boolean) => { + this.setState({ isMaximized: maximized }); + }); + + // Subscribe to window events + this.mUnsubscribeMaximize = window.api.window.onMaximize(this.onMaximize); + this.mUnsubscribeUnmaximize = window.api.window.onUnmaximize( + this.onUnMaximize, + ); + this.mUnsubscribeClose = window.api.window.onClose(this.onClose); } public componentWillUnmount() { - window().removeListener("maximize", this.onMaximize); - window().removeListener("unmaximize", this.onUnMaximize); - window().removeListener("close", this.onClose); + this.mUnsubscribeMaximize?.(); + this.mUnsubscribeUnmaximize?.(); + this.mUnsubscribeClose?.(); } public render(): JSX.Element { @@ -72,7 +70,7 @@ class WindowControls extends React.Component<{}, { isMaximized: boolean }> { } private minimize = () => { - window().minimize(); + void window.api.window.minimize(window.windowId); }; private onMaximize = () => { @@ -90,12 +88,16 @@ class WindowControls extends React.Component<{}, { isMaximized: boolean }> { }; private toggleMaximize = () => { - const wasMaximized = window().isMaximized(); - wasMaximized ? window().unmaximize() : window().maximize(); + const { isMaximized } = this.state; + if (isMaximized) { + void window.api.window.unmaximize(window.windowId); + } else { + void window.api.window.maximize(window.windowId); + } }; private close = () => { - window().close(); + void window.api.window.close(window.windowId); }; } diff --git a/src/renderer/webview.ts b/src/renderer/webview.ts new file mode 100644 index 000000000..1d68777b0 --- /dev/null +++ b/src/renderer/webview.ts @@ -0,0 +1,27 @@ +// Renderer-only webview utilities + +export const makeBrowserView = ( + src: string, + forwardEvents: string[], + options?: Electron.BrowserViewConstructorOptions, +): Promise => { + return window.api.browserView.createWithEvents(src, forwardEvents, options); +}; + +export const closeBrowserView = (viewId: string): Promise => { + return window.api.browserView.close(viewId); +}; + +export const positionBrowserView = ( + viewId: string, + rect: Electron.Rectangle, +): Promise => { + return window.api.browserView.position(viewId, rect); +}; + +export const updateViewURL = ( + viewId: string, + newURL: string, +): Promise => { + return window.api.browserView.updateURL(viewId, newURL); +}; diff --git a/src/shared/types/ipc.ts b/src/shared/types/ipc.ts index 352440860..7166a5293 100644 --- a/src/shared/types/ipc.ts +++ b/src/shared/types/ipc.ts @@ -21,6 +21,62 @@ export interface MainChannels { // Examples: "example:main_foo": () => void; "example:main_bar": (data: string) => void; + + // BrowserView event forwarding + // Dynamic channel: `view-${viewId}-${eventId}` + // We use a pattern to match: view-* + + // Window event forwarding (main -> renderer) + "window:event:maximize": () => void; + "window:event:unmaximize": () => void; + "window:event:close": () => void; + "window:event:focus": () => void; + "window:event:blur": () => void; + + // Menu click events (main -> renderer) + "menu:click": (menuItemId: string) => void; +} + +/** Vortex application paths - computed once in main process and shared */ +export type VortexPaths = { + [key: string]: string; +} & { + base: string; + assets: string; + assets_unpacked: string; + modules: string; + modules_unpacked: string; + bundledPlugins: string; + locales: string; + package: string; + package_unpacked: string; + application: string; + userData: string; + appData: string; + localAppData: string; + temp: string; + home: string; + documents: string; + exe: string; + desktop: string; +}; + +/** Type containing all known channels for synchronous IPC operations (used primarily by preload scripts) */ +export interface SyncChannels { + // NOTE(erri120): These are synchronous IPC channels used during preload initialization. + // Use sparingly as they block the renderer process. + + /** Get the current window ID */ + "window:getIdSync": () => number; + + /** Get the application name */ + "app:getNameSync": () => string; + + /** Get the application version */ + "app:getVersionSync": () => string; + + /** Get all Vortex paths - computed in main process */ + "vortex:getPathsSync": () => VortexPaths; } /** Type containing all known channels used by renderer processes to send to and receive messages from the main process */ @@ -29,6 +85,136 @@ export interface InvokeChannels { // Examples: "example:ping": () => Promise; + + // Dialog channels + // NOTE: Electron types are marked as 'any' because they contain non-serializable properties + // that Electron's IPC handles internally. The actual data passed is serializable. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + "dialog:showOpen": (options: any) => Promise; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + "dialog:showSave": (options: any) => Promise; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + "dialog:showMessageBox": (options: any) => Promise; + "dialog:showErrorBox": (title: string, content: string) => Promise; + + // App protocol client channels + "app:setProtocolClient": (protocol: string, udPath: string) => Promise; + "app:isProtocolClient": ( + protocol: string, + udPath: string, + ) => Promise; + "app:removeProtocolClient": ( + protocol: string, + udPath: string, + ) => Promise; + "app:exit": (exitCode?: number) => Promise; + "app:getName": () => Promise; + + // App path channels + "app:getPath": (name: string) => Promise; + "app:setPath": (name: string, value: string) => Promise; + + // File icon extraction + "app:extractFileIcon": (exePath: string, iconPath: string) => Promise; + + // BrowserView channels + "browserView:create": ( + src: string, + partition: string, + isNexus: boolean, + ) => Promise; + "browserView:createWithEvents": ( + src: string, + forwardEvents: string[], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + options: any, + ) => Promise; + "browserView:close": (viewId: string) => Promise; + "browserView:position": ( + viewId: string, + rect: { x: number; y: number; width: number; height: number }, + ) => Promise; + "browserView:updateURL": (viewId: string, newURL: string) => Promise; + + // Jump list (Windows) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + "app:setJumpList": (categories: any) => Promise; + + // Session cookies + // eslint-disable-next-line @typescript-eslint/no-explicit-any + "session:getCookies": (filter: any) => Promise; + + // Window operations + "window:getId": () => Promise; + "window:minimize": (windowId: number) => Promise; + "window:maximize": (windowId: number) => Promise; + "window:unmaximize": (windowId: number) => Promise; + "window:restore": (windowId: number) => Promise; + "window:close": (windowId: number) => Promise; + "window:focus": (windowId: number) => Promise; + "window:show": (windowId: number) => Promise; + "window:hide": (windowId: number) => Promise; + "window:isMaximized": (windowId: number) => Promise; + "window:isMinimized": (windowId: number) => Promise; + "window:isFocused": (windowId: number) => Promise; + "window:setAlwaysOnTop": (windowId: number, flag: boolean) => Promise; + "window:moveTop": (windowId: number) => Promise; + + // Menu operations + // NOTE: Electron MenuItemConstructorOptions is marked as 'any' because it contains non-serializable + // properties (functions) that Electron's IPC strips during transmission. The main process + // reconstructs these functions (click handlers) before passing to Electron.Menu. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + "menu:setApplicationMenu": (template: any) => Promise; + + // Content tracing operations + // eslint-disable-next-line @typescript-eslint/no-explicit-any + "contentTracing:startRecording": (options: any) => Promise; + "contentTracing:stopRecording": (resultPath: string) => Promise; + + // Redux state transfer + // NOTE: Redux state is marked as 'any' because it's a complex nested object that is serializable + // but too complex to type precisely. The actual data is always serializable. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + "redux:getState": () => Promise; + // Returns a base64-encoded msgpack chunk of the Redux state + "redux:getStateMsgpack": (idx?: number) => Promise; + + // Login item settings + // eslint-disable-next-line @typescript-eslint/no-explicit-any + "app:setLoginItemSettings": (settings: any) => Promise; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + "app:getLoginItemSettings": () => Promise; + + // Clipboard operations + "clipboard:writeText": (text: string) => Promise; + "clipboard:readText": () => Promise; + + // Power save blocker + "powerSaveBlocker:start": ( + type: "prevent-app-suspension" | "prevent-display-sleep", + ) => Promise; + "powerSaveBlocker:stop": (id: number) => Promise; + "powerSaveBlocker:isStarted": (id: number) => Promise; + + // App path - getAppPath returns the current application directory + "app:getAppPath": () => Promise; + + // Additional window operations + "window:getPosition": (windowId: number) => Promise<[number, number]>; + "window:setPosition": ( + windowId: number, + x: number, + y: number, + ) => Promise; + "window:getSize": (windowId: number) => Promise<[number, number]>; + "window:setSize": ( + windowId: number, + width: number, + height: number, + ) => Promise; + "window:isVisible": (windowId: number) => Promise; + "window:toggleDevTools": (windowId: number) => Promise; } /** Represents all IPC-safe typed arrays */ @@ -53,6 +239,7 @@ export type Serializable = | boolean | null | undefined + | void | Date | Serializable[] | { [key: string]: Serializable } diff --git a/src/shared/types/preload.ts b/src/shared/types/preload.ts index b451ed8b1..bc6cef9b6 100644 --- a/src/shared/types/preload.ts +++ b/src/shared/types/preload.ts @@ -1,15 +1,61 @@ +import type * as Electron from "electron"; + +import type { VortexPaths } from "./ipc"; + /** Globals exposed by the preload script to the renderer */ export interface PreloadWindow { api: Api; /** Environment version information */ versions: Versions; + + /** Current window ID for window operations */ + windowId: number; + + /** Application name (for application.electron.ts) */ + appName: string; + + /** Application version (for application.electron.ts) */ + appVersion: string; + + /** All Vortex application paths (for getVortexPath) */ + vortexPaths: VortexPaths; } /** All API methods available to the renderer */ export interface Api { /** Example APIs */ example: Example; + + /** Dialog APIs */ + dialog: Dialog; + + /** App APIs */ + app: App; + + /** BrowserView APIs */ + browserView: BrowserView; + + /** Session APIs */ + session: Session; + + /** Window APIs */ + window: WindowAPI; + + /** Menu APIs */ + menu: Menu; + + /** Content Tracing APIs */ + contentTracing: ContentTracing; + + /** Redux State APIs */ + redux: Redux; + + /** Clipboard APIs */ + clipboard: Clipboard; + + /** Power Save Blocker APIs */ + powerSaveBlocker: PowerSaveBlocker; } export interface Example { @@ -17,6 +63,216 @@ export interface Example { ping(): Promise; } +export interface Dialog { + /** Show open file/folder dialog */ + showOpen( + options: Electron.OpenDialogOptions, + ): Promise; + + /** Show save file dialog */ + showSave( + options: Electron.SaveDialogOptions, + ): Promise; + + /** Show message box dialog */ + showMessageBox( + options: Electron.MessageBoxOptions, + ): Promise; + + /** Show error box (blocking) */ + showErrorBox(title: string, content: string): Promise; +} + +export interface App { + /** Set as default protocol client */ + setProtocolClient(protocol: string, udPath: string): Promise; + + /** Check if app is default protocol client */ + isProtocolClient(protocol: string, udPath: string): Promise; + + /** Remove as default protocol client */ + removeProtocolClient(protocol: string, udPath: string): Promise; + + /** Exit the application */ + exit(exitCode?: number): Promise; + + /** Get application name */ + getName(): Promise; + + /** Get app path */ + getPath(name: string): Promise; + + /** Set app path */ + setPath(name: string, value: string): Promise; + + /** Extract file icon */ + extractFileIcon(exePath: string, iconPath: string): Promise; + + /** Set Windows jump list */ + setJumpList(categories: Electron.JumpListCategory[]): Promise; + + /** Set login item settings (auto-start) */ + setLoginItemSettings(settings: Electron.Settings): Promise; + + /** Get login item settings */ + getLoginItemSettings(): Promise; + + /** Get the current application directory */ + getAppPath(): Promise; +} + +export interface BrowserView { + /** Create a new BrowserView */ + create(src: string, partition: string, isNexus: boolean): Promise; + + /** Create a new BrowserView with event forwarding */ + createWithEvents( + src: string, + forwardEvents: string[], + options?: Electron.BrowserViewConstructorOptions, + ): Promise; + + /** Close a BrowserView */ + close(viewId: string): Promise; + + /** Position a BrowserView */ + position( + viewId: string, + rect: { x: number; y: number; width: number; height: number }, + ): Promise; + + /** Update BrowserView URL */ + updateURL(viewId: string, newURL: string): Promise; +} + +export interface Session { + /** Get session cookies */ + getCookies(filter: Electron.CookiesGetFilter): Promise; +} + +export interface WindowAPI { + /** Minimize window */ + minimize(windowId: number): Promise; + + /** Maximize window */ + maximize(windowId: number): Promise; + + /** Unmaximize (restore) window */ + unmaximize(windowId: number): Promise; + + /** Restore minimized window */ + restore(windowId: number): Promise; + + /** Close window */ + close(windowId: number): Promise; + + /** Focus window */ + focus(windowId: number): Promise; + + /** Show window */ + show(windowId: number): Promise; + + /** Hide window */ + hide(windowId: number): Promise; + + /** Check if window is maximized */ + isMaximized(windowId: number): Promise; + + /** Check if window is minimized */ + isMinimized(windowId: number): Promise; + + /** Check if window is focused */ + isFocused(windowId: number): Promise; + + /** Set window always on top */ + setAlwaysOnTop(windowId: number, flag: boolean): Promise; + + /** Move window to top of stack */ + moveTop(windowId: number): Promise; + + /** Register listener for window maximize event. Returns unsubscribe function. */ + onMaximize(callback: () => void): () => void; + + /** Register listener for window unmaximize event. Returns unsubscribe function. */ + onUnmaximize(callback: () => void): () => void; + + /** Register listener for window close event. Returns unsubscribe function. */ + onClose(callback: () => void): () => void; + + /** Register listener for window focus event. Returns unsubscribe function. */ + onFocus(callback: () => void): () => void; + + /** Register listener for window blur event. Returns unsubscribe function. */ + onBlur(callback: () => void): () => void; + + /** Get window position */ + getPosition(windowId: number): Promise<[number, number]>; + + /** Set window position */ + setPosition(windowId: number, x: number, y: number): Promise; + + /** Get window size */ + getSize(windowId: number): Promise<[number, number]>; + + /** Set window size */ + setSize(windowId: number, width: number, height: number): Promise; + + /** Check if window is visible */ + isVisible(windowId: number): Promise; + + /** Toggle developer tools */ + toggleDevTools(windowId: number): Promise; +} + +export interface Menu { + /** Set application menu from template */ + setApplicationMenu( + template: Electron.MenuItemConstructorOptions[], + ): Promise; + + /** Register listener for menu item clicks. Returns unsubscribe function. */ + onMenuClick(callback: (menuItemId: string) => void): () => void; +} + +export interface ContentTracing { + /** Start recording performance trace */ + startRecording( + options: Electron.TraceCategoriesAndOptions | Electron.TraceConfig, + ): Promise; + + /** Stop recording and save to file, returns the path to the trace file */ + stopRecording(resultPath: string): Promise; +} + +export interface Redux { + /** Get Redux state as JSON */ + getState(): Promise; + + /** Get Redux state as msgpack (chunked transfer fallback) */ + getStateMsgpack(idx?: number): Promise; +} + +export interface Clipboard { + /** Write text to clipboard */ + writeText(text: string): Promise; + + /** Read text from clipboard */ + readText(): Promise; +} + +export interface PowerSaveBlocker { + /** Start blocking power save mode */ + start( + type: "prevent-app-suspension" | "prevent-display-sleep", + ): Promise; + + /** Stop blocking power save mode */ + stop(id: number): Promise; + + /** Check if a blocker is started */ + isStarted(id: number): Promise; +} + export interface Versions { node: string; chromium: string; diff --git a/src/util/ExtensionManager.ts b/src/util/ExtensionManager.ts index 550c901fb..651722f10 100644 --- a/src/util/ExtensionManager.ts +++ b/src/util/ExtensionManager.ts @@ -118,7 +118,6 @@ import { generate as shortid } from "shortid"; import stringFormat from "string-template"; import type * as winapiT from "vortex-run"; import { getApplication } from "./application"; -import makeRemoteCall, { makeRemoteCallSync } from "./electronRemote"; import { VCREDIST_URL } from "../shared/constants"; import { fileMD5 } from "vortexmt"; import * as fsVortex from "../util/fs"; @@ -146,107 +145,59 @@ const winapi = lazyRequire(() => require("vortex-run")); const ERROR_OUTPUT_CUTOFF = 3; -function selfCL(userDataPath?: string): [string, string[]] { - let execPath = process.execPath; - // make it work when using the development version - if (execPath.endsWith("electron.exe")) { - execPath = path.join(getVortexPath("package"), "vortex.bat"); - } - - const args = []; - /* - TODO: This is necessary for downloads to multiple instances to work correctly but - it doesn't work until https://github.com/electron/electron/issues/18397 is fixed - - if (userDataPath !== undefined) { - args.push('--user-data', userDataPath); - } - */ - - args.push("-d"); - - return [execPath, args]; -} - -const setSelfAsProtocolClient = makeRemoteCallSync( - "set-as-default-protocol-client", - (electron, contents, protocol: string, udPath: string) => { - const [execPath, args] = selfCL(udPath); - electron.app.setAsDefaultProtocolClient(protocol, execPath, args); - }, -); - -const isSelfProtocolClient = makeRemoteCallSync( - "is-self-protocol-client", - (electron, contents, protocol: string, udPath: string) => { - const [execPath, args] = selfCL(udPath); - return electron.app.isDefaultProtocolClient(protocol, execPath, args); - }, -); - -const removeSelfAsProtocolClient = makeRemoteCallSync( - "remove-as-default-protocol-client", - (electron, contents, protocol: string, udPath: string) => { - const [execPath, args] = selfCL(udPath); - electron.app.removeAsDefaultProtocolClient(protocol, execPath, args); - }, -); - -const showOpenDialog = makeRemoteCall( - "show-open-dialog", - (electron, contents, options: Electron.OpenDialogOptions) => { - let window: Electron.BrowserWindow | null = null; - try { - window = electron.BrowserWindow?.fromWebContents?.(contents); - } catch (err) { - // nop - } - - return electron.dialog.showOpenDialog(window, options); - }, -); - -const showSaveDialog = makeRemoteCall( - "show-save-dialog", - (electron, contents, options: Electron.SaveDialogOptions) => { - let window: Electron.BrowserWindow = null; - try { - window = electron.BrowserWindow?.fromWebContents?.(contents); - } catch (err) { - // nop - } - - return electron.dialog.showSaveDialog(window, options); - }, -); - -const appExit = makeRemoteCallSync( - "exit-application", - (electron, contents, exitCode?: number) => { - electron.app.exit(exitCode); - }, -); - -const showErrorBox = makeRemoteCall( - "show-error-box", - (electron, contents, title: string, content: string) => { - electron.dialog.showErrorBox(title, content); - return undefined; - }, -); - -const showMessageBox = makeRemoteCall( - "show-message-box", - (electron, contents, options: Electron.MessageBoxOptions) => { - let window: Electron.BrowserWindow = null; - try { - window = electron.BrowserWindow?.fromWebContents?.(contents); - } catch (err) { - // nop - } - return electron.dialog.showMessageBox(window, options); - }, -); +// TODO: remove this when separation is complete +import type { Api, PreloadWindow } from "../shared/types/preload"; +const getPreloadApi = (): Api => + (window as unknown as PreloadWindow as { api: Api }).api; + +// Protocol client functions - now use window.api preload bridge +const setSelfAsProtocolClient = ( + protocol: string, + udPath: string, +): Promise => { + return getPreloadApi().app.setProtocolClient(protocol, udPath); +}; + +const isSelfProtocolClient = ( + protocol: string, + udPath: string, +): Promise => { + return getPreloadApi().app.isProtocolClient(protocol, udPath); +}; + +const removeSelfAsProtocolClient = ( + protocol: string, + udPath: string, +): Promise => { + return getPreloadApi().app.removeProtocolClient(protocol, udPath); +}; + +// Dialog functions - now use window.api preload bridge +const showOpenDialog = ( + options: Electron.OpenDialogOptions, +): Promise => { + return getPreloadApi().dialog.showOpen(options); +}; + +const showSaveDialog = ( + options: Electron.SaveDialogOptions, +): Promise => { + return getPreloadApi().dialog.showSave(options); +}; + +const appExit = (exitCode?: number): Promise => { + return getPreloadApi().app.exit(exitCode); +}; + +const showErrorBox = (title: string, content: string): Promise => { + return getPreloadApi().dialog.showErrorBox(title, content); +}; + +const showMessageBox = ( + options: Electron.MessageBoxOptions, +): Promise => { + return getPreloadApi().dialog.showMessageBox(options); +}; export interface IRegisteredExtension { name: string; @@ -1759,11 +1710,8 @@ class ExtensionManager { }; private showErrorBox = (message: string, details: string | Error | any) => { - if (typeof details === "string") { - showErrorBox(message, details); - } else { - showErrorBox(message, details.message); - } + const errMessage = getErrorMessageOrDefault(details); + void showErrorBox(message, errMessage); }; /** @@ -1972,16 +1920,19 @@ class ExtensionManager { private commandLineUserData = () => this.mApi.getState().session.base.commandLine?.userData; - private registerProtocol = ( + private registerProtocol = async ( protocol: string, def: boolean, callback: (url: string, install: boolean) => void, - ): boolean => { + ): Promise => { log("info", "register protocol", { protocol }); - const haveToRegister = - def && !isSelfProtocolClient(protocol, this.commandLineUserData()); + const isAlreadyClient = await isSelfProtocolClient( + protocol, + this.commandLineUserData(), + ); + const haveToRegister = def && !isAlreadyClient; if (def) { - setSelfAsProtocolClient(protocol, this.commandLineUserData()); + await setSelfAsProtocolClient(protocol, this.commandLineUserData()); } this.mProtocolHandlers[protocol] = callback; return haveToRegister; @@ -2002,9 +1953,9 @@ class ExtensionManager { this.mArchiveHandlers[extension] = handler; }; - private deregisterProtocol = (protocol: string) => { + private deregisterProtocol = async (protocol: string): Promise => { log("info", "deregister protocol"); - removeSelfAsProtocolClient(protocol, this.commandLineUserData()); + await removeSelfAsProtocolClient(protocol, this.commandLineUserData()); }; private lookupModReference = ( diff --git a/src/util/PresetManager.ts b/src/util/PresetManager.ts index c67c823f9..430fc5cc5 100644 --- a/src/util/PresetManager.ts +++ b/src/util/PresetManager.ts @@ -1,4 +1,4 @@ -import { ipcMain, ipcRenderer } from "electron"; +import { app, ipcMain, ipcRenderer } from "electron"; import * as path from "path"; import type { IExtensionApi } from "../types/IExtensionContext"; @@ -10,7 +10,6 @@ import type { } from "../types/IPreset"; import { iPresetSchema, iPresetsStateSchema } from "../types/IPreset.gen"; -import { makeRemoteCallSync } from "./electronRemote"; import * as fs from "./fs"; import getVortexPath from "./getVortexPath"; @@ -21,9 +20,17 @@ import { getErrorMessageOrDefault, } from "../shared/errors"; -const getAppName = makeRemoteCallSync("get-application-name", (electron) => - electron.app.getName(), -); +function getAppName(): string { + // In main process, use electron app directly + if (app !== undefined) { + return app.getName(); + } + // In renderer process, use preload value + if (typeof window !== "undefined" && window.appName !== undefined) { + return window.appName; + } + throw new Error("getAppName is not available in this context"); +} type StepCB = (step: IPresetStep, data: unknown) => PromiseLike; diff --git a/src/util/StarterInfo.ts b/src/util/StarterInfo.ts index fadf5da75..aff3a5595 100644 --- a/src/util/StarterInfo.ts +++ b/src/util/StarterInfo.ts @@ -28,13 +28,31 @@ import * as fs from "fs"; import * as path from "path"; import { GameEntryNotFound, GameStoreNotFound } from "../types/IGameStore"; import { getErrorCode, unknownToError } from "../shared/errors"; +import type { Api, PreloadWindow } from "../shared/types/preload"; -function getCurrentWindow() { +// TODO: remove this when separation is complete +const getPreloadApi = (): Api => + (window as unknown as PreloadWindow as { api: Api }).api; +const getWindowId = (): number => + (window as unknown as { windowId: number }).windowId; + +async function hideWindow(): Promise { + if (process.type === "renderer") { + await getPreloadApi().window.hide(getWindowId()); + } +} + +async function showWindow(): Promise { + if (process.type === "renderer") { + await getPreloadApi().window.show(getWindowId()); + } +} + +async function isWindowVisible(): Promise { if (process.type === "renderer") { - return require("@electron/remote").getCurrentWindow(); - } else { - return undefined; + return getPreloadApi().window.isVisible(getWindowId()); } + return false; } export interface IStarterInfo { @@ -155,7 +173,7 @@ class StarterInfo implements IStarterInfo { setToolRunning(info.exePath, Date.now(), info.exclusive), ); if (["hide", "hide_recover"].includes(info.onStart)) { - getCurrentWindow().hide(); + void hideWindow(); } else if (info.onStart === "close") { getApplication().quit(); } @@ -223,7 +241,7 @@ class StarterInfo implements IStarterInfo { const spawned = () => { onSpawned(); if (["hide", "hide_recover"].includes(info.onStart)) { - getCurrentWindow().hide(); + void hideWindow(); } else if (info.onStart === "close") { getApplication().quit(); } @@ -312,12 +330,9 @@ class StarterInfo implements IStarterInfo { }); } }) - .then(() => { - if ( - info.onStart === "hide_recover" && - !getCurrentWindow().isVisible() - ) { - getCurrentWindow().show(); + .then(async () => { + if (info.onStart === "hide_recover" && !(await isWindowVisible())) { + await showWindow(); } }); } diff --git a/src/util/api.ts b/src/util/api.ts index a807e96f0..483f31758 100644 --- a/src/util/api.ts +++ b/src/util/api.ts @@ -74,7 +74,6 @@ import { UserCanceled, } from "./CustomErrors"; import Debouncer from "./Debouncer"; -import makeRemoteCall from "./electronRemote"; import epicGamesLauncher from "./EpicGamesLauncher"; import { getVisibleWindow, @@ -82,7 +81,18 @@ import { withContext as withErrorContext, } from "./errorHandling"; import extractExeIcon from "./exeIcon"; -import { extend } from "./ExtensionProvider"; + +/** + * @deprecated Use window.api for IPC communication from renderer to main process. + * See src/shared/types/preload.ts for the complete API. + */ +function makeRemoteCall(): never { + throw new Error( + "makeRemoteCall has been removed. Use window.api instead. " + + "See src/shared/types/preload.ts for available APIs.", + ); +} +// Note: extend is renderer-only, available via ../renderer/controls/ComponentEx import { copyFileAtomic, writeFileAtomic } from "./fsAtomic"; import getNormalizeFunc, { makeNormalizingDict } from "./getNormalizeFunc"; export type { Normalize } from "./getNormalizeFunc.ts"; @@ -170,7 +180,7 @@ export { delay, deriveModInstallName as deriveInstallName, epicGamesLauncher, - extend, + // extend is renderer-only, available via renderer/controls/ComponentEx extractExeIcon, fileMD5, findDownloadByRef, diff --git a/src/util/application.electron.ts b/src/util/application.electron.ts index fb923d587..3239bd40d 100644 --- a/src/util/application.electron.ts +++ b/src/util/application.electron.ts @@ -6,28 +6,36 @@ import { setApplication } from "./application"; class ElectronApplication implements IApplication { private mName: string; private mVersion: string; - private mFocused: () => boolean; - private mWindow: BrowserWindow; - private mApp: Electron.App; + private mFocused: boolean; constructor() { - const remote = - process.type === "browser" ? undefined : require("@electron/remote"); - this.mApp = remote?.app ?? appIn; + if (process.type === "browser") { + // Main process - use electron app directly + this.mName = appIn.name; + this.mVersion = appIn.getVersion(); + // In main process, check if any Vortex window is focused + this.mFocused = + BrowserWindow.getAllWindows().find((win) => win.isFocused()) !== + undefined; + } else { + // Renderer process - use preload values + this.mName = window.appName; + this.mVersion = window.appVersion; + // Track focus state via window events + this.mFocused = true; // Assume focused initially - this.mName = this.mApp.name; - this.mVersion = this.mApp.getVersion(); - if (remote !== undefined) { - this.mWindow = remote.getCurrentWindow(); + // Register listeners to track focus state + if (window.api?.window?.onFocus) { + window.api.window.onFocus(() => { + this.mFocused = true; + }); + } + if (window.api?.window?.onBlur) { + window.api.window.onBlur(() => { + this.mFocused = false; + }); + } } - // if called from renderer process, this will determine if this window is focused, - // if called from browser process, it will determine if _any_ Vortex window is focused - this.mFocused = - remote !== undefined - ? () => remote.getCurrentWindow().isFocused() - : () => - BrowserWindow.getAllWindows().find((win) => win.isFocused()) !== - undefined; } public get name() { @@ -38,8 +46,14 @@ class ElectronApplication implements IApplication { return this.mVersion; } - public get window(): BrowserWindow { - return this.mWindow; + public get window(): BrowserWindow | null { + // In renderer process, we can't return the actual BrowserWindow object + // This property is not used anywhere in the codebase + if (process.type === "browser") { + // In main process, return the first visible window + return BrowserWindow.getAllWindows()[0] ?? null; + } + return null; } public get memory(): { total: number } { @@ -58,11 +72,24 @@ class ElectronApplication implements IApplication { * returns whether the window is in focus */ public get isFocused(): boolean { - return this.mFocused(); + if (process.type === "browser") { + // In main process, check if any window is focused + return ( + BrowserWindow.getAllWindows().find((win) => win.isFocused()) !== + undefined + ); + } + // In renderer, return cached focus state (updated via events) + return this.mFocused; } public quit(exitCode?: number): void { - this.mApp.exit(exitCode); + if (process.type === "browser") { + appIn.exit(exitCode); + } else { + // In renderer, use the preload API + void window.api.app.exit(exitCode); + } } } diff --git a/src/util/electron.ts b/src/util/electron.ts index c1d42a1a1..dbf435750 100644 --- a/src/util/electron.ts +++ b/src/util/electron.ts @@ -1,13 +1,55 @@ -import type * as remoteT from "@electron/remote"; +// This module provides a shimmed electron module for the renderer process. +// Extensions that use `require('electron')` will get this shimmed version, +// which provides app.getPath() using the preload API. import * as electron from "electron"; -const myExport: typeof electron & { remote?: typeof remoteT } = { - ...electron, +import type { AppPath } from "./getVortexPath"; + +import getVortexPath from "./getVortexPath"; + +// In the renderer process, electron.app is undefined. +// We provide a shim that supports the most commonly used methods. +const appShim = { + getPath: (name: string): string => { + // Map Electron path names to VortexPath names + const pathMap: { [key: string]: AppPath } = { + userData: "userData", + appData: "appData", + temp: "temp", + home: "home", + documents: "documents", + exe: "exe", + desktop: "desktop", + }; + const vortexPathName = pathMap[name]; + if (vortexPathName) { + return getVortexPath(vortexPathName); + } + throw new Error(`Unknown path name: ${name}`); + }, + getVersion: (): string => { + return ( + (window as unknown as { appVersion: string }).appVersion ?? + electron.app?.getVersion?.() ?? + "0.0.0" + ); + }, + getName: (): string => { + return ( + (window as unknown as { appName: string }).appName ?? + electron.app?.getName?.() ?? + "Vortex" + ); + }, }; -module.exports = myExport; +// Create shimmed electron export +const shimmedElectron = { + ...electron, + // Provide app shim in renderer, real app in main + app: electron.app ?? appShim, + // remote is no longer available - provide undefined to fail gracefully + remote: undefined, +}; -if (process.type === "renderer") { - // tslint:disable-next-line:no-var-requires - module.exports["remote"] = require("@electron/remote"); -} +export = shimmedElectron; diff --git a/src/util/electronRemote.ts b/src/util/electronRemote.ts deleted file mode 100644 index e9b2d9b82..000000000 --- a/src/util/electronRemote.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { ipcMain, ipcRenderer } from "electron"; -import * as electron from "electron"; -import { generate as shortid } from "shortid"; -import { log } from "./log"; - -const IPC_CHANNEL = "__remote_electron_invocation"; -const IPC_CHANNEL_REPLY = IPC_CHANNEL + "_reply"; - -type StoredCB = (mainElectron: typeof electron, ...args: any[]) => Promise; -type StoredSyncCB = (mainElectron: typeof electron, ...args: any[]) => any; - -const knownCalls: { [id: string]: StoredCB } = {}; -const knownCallsSync: { [id: string]: StoredSyncCB } = {}; - -interface IOutstandingCall { - resolve: (res: any) => void; - reject: (err: Error) => void; -} - -const outstandingCalls: { [callId: string]: IOutstandingCall } = {}; - -ipcMain?.on?.(IPC_CHANNEL, async (event, arg) => { - const { id, callId, args } = JSON.parse(arg); - if (knownCalls[id] === undefined) { - event.sender.send( - IPC_CHANNEL_REPLY, - JSON.stringify({ callId, error: new Error("invalid remote call") }), - ); - return; - } - - try { - const result = await knownCalls[id](electron, event.sender, ...args); - event.sender.send(IPC_CHANNEL_REPLY, JSON.stringify({ callId, result })); - } catch (error) { - event.sender.send(IPC_CHANNEL_REPLY, JSON.stringify({ callId, error })); - } -}); - -ipcRenderer?.on?.(IPC_CHANNEL_REPLY, (event, arg) => { - const { callId, error, result } = JSON.parse(arg); - if (outstandingCalls[callId] === undefined) { - log("warn", "unexpected remote reply", arg); - return; - } - - if (error !== undefined) { - outstandingCalls[callId].reject(error); - } else { - outstandingCalls[callId].resolve(result); - } - delete outstandingCalls[callId]; -}); - -ipcMain?.on?.(IPC_CHANNEL + "_sync", (event, arg) => { - const { id, callId, args } = JSON.parse(arg); - try { - event.returnValue = { - error: null, - result: knownCallsSync[id](electron, event.sender, ...args), - }; - } catch (error) { - event.returnValue = { error }; - } -}); - -type Arr = readonly unknown[]; - -export function makeRemoteCallSync( - id: string, - cb: ( - mainElectron: typeof electron, - window: electron.WebContents, - ...args: ArgsT - ) => T, -): (...args: ArgsT) => T { - if (ipcRenderer !== undefined) { - return (...args: ArgsT) => { - const callId = shortid(); - const res = ipcRenderer.sendSync( - IPC_CHANNEL + "_sync", - JSON.stringify({ id, args, callId }), - ); - if (res.error !== null) { - throw res.error; - } else { - return res.result; - } - }; - } else { - knownCallsSync[id] = cb; - return (...args: ArgsT) => { - return cb( - electron, - // Seems that it can't be null, plus we're removing it anyway - electron.webContents?.getFocusedWebContents()!, - ...args, - ); - }; - } -} - -function makeRemoteCall( - id: string, - cb: ( - mainElectron: typeof electron, - window: electron.WebContents, - ...args: ArgsT - ) => Promise, -): (...args: ArgsT) => Promise { - if (ipcRenderer !== undefined) { - return (...args: ArgsT) => { - const callId = shortid(); - return new Promise((resolve, reject) => { - outstandingCalls[callId] = { resolve, reject }; - ipcRenderer.send(IPC_CHANNEL, JSON.stringify({ id, args, callId })); - }); - }; - } else { - knownCalls[id] = cb; - return (...args: ArgsT) => { - return cb( - electron, - // Seems that it can't be null, plus we're removing it anyway - electron.webContents.getFocusedWebContents()!, - ...args, - ); - }; - } -} - -export default makeRemoteCall; diff --git a/src/util/errorHandling.ts b/src/util/errorHandling.ts index 4b1d7ce53..ba26caebe 100644 --- a/src/util/errorHandling.ts +++ b/src/util/errorHandling.ts @@ -16,7 +16,6 @@ import opn from "./opn"; import { getSafe } from "./storeHelper"; import { flatten, getAllPropertyNames, spawnSelf } from "./util"; -import type * as RemoteT from "@electron/remote"; import type { IFeedbackResponse, IOAuthCredentials, @@ -33,11 +32,28 @@ import * as semver from "semver"; import { inspect } from "util"; import {} from "uuid"; import { getApplication } from "./application"; -import lazyRequire from "./lazyRequire"; import { getCPUArch } from "./nativeArch"; import { getErrorMessageOrDefault } from "../shared/errors"; -const remote = lazyRequire(() => require("@electron/remote")); +// Async dialog helpers for cross-process compatibility +const showMessageBox = async ( + options: Electron.MessageBoxOptions, +): Promise => { + if (process.type === "renderer") { + return window.api.dialog.showMessageBox(options); + } else { + const win = getVisibleWindow(); + return dialogIn.showMessageBox(win, options); + } +}; + +const showErrorBox = async (title: string, content: string): Promise => { + if (process.type === "renderer") { + return window.api.dialog.showErrorBox(title, content); + } else { + dialogIn.showErrorBox(title, content); + } +}; function createTitle(type: string, error: IError, hash: string) { return `${type}: ${error.message}`; @@ -328,30 +344,30 @@ export function sendReport( sourceProcess: string | undefined, attachment: string | undefined, ): PromiseBB { - const dialog = process.type === "renderer" ? remote.dialog : dialogIn; const hash = genHash(error); if (process.env.NODE_ENV === "development") { const fullMessage = error.title !== undefined ? error.message + `\n(${error.title})` : error.message; - dialog.showErrorBox( - fullMessage, - JSON.stringify( - { - type, - error, - labels, - context, - reporterProcess, - sourceProcess, - attachment, - }, - undefined, - 2, + return PromiseBB.resolve( + showErrorBox( + fullMessage, + JSON.stringify( + { + type, + error, + labels, + context, + reporterProcess, + sourceProcess, + attachment, + }, + undefined, + 2, + ), ), - ); - return PromiseBB.resolve(undefined); + ).then(() => undefined); } else { return nexusReport( hash, @@ -379,12 +395,12 @@ export function getWindow(): BrowserWindow | null { let currentWindow: BrowserWindow | null = null; -function getCurrentWindow() { - if (currentWindow === undefined) { - currentWindow = - process.type === "renderer" ? remote.getCurrentWindow() : null; +function getCurrentWindow(): BrowserWindow | null { + // In renderer process, we can't access BrowserWindow directly + // The preload API handles window references internally via windowId + if (process.type === "renderer") { + return null; } - return currentWindow; } @@ -398,14 +414,13 @@ export function getVisibleWindow( return win !== null && !win.isDestroyed() && win.isVisible() ? win : null; } -function showTerminateError( +async function showTerminateError( error: IError, state: any, source: string | undefined, allowReport: boolean | undefined, withDetails: boolean, -): boolean { - const dialog = process.type === "renderer" ? remote.dialog : dialogIn; +): Promise { const buttons = ["Ignore", "Quit"]; if (!withDetails) { buttons.unshift("Show Details"); @@ -430,7 +445,7 @@ function showTerminateError( } } - let action = dialog.showMessageBoxSync(getVisibleWindow(), { + let result = await showMessageBox({ type: "error", buttons, defaultId: buttons.length - 1, @@ -440,7 +455,7 @@ function showTerminateError( noLink: true, }); - if (buttons[action] === "Report and Quit") { + if (buttons[result.response] === "Report and Quit") { // Report createErrorReport( "Crash", @@ -450,9 +465,9 @@ function showTerminateError( state, source, ); - } else if (buttons[action] === "Ignore") { + } else if (buttons[result.response] === "Ignore") { // Ignore - action = dialog.showMessageBoxSync(getVisibleWindow(), { + result = await showMessageBox({ type: "error", buttons: ["Quit", "I understand"], title: "Are you sure?", @@ -461,12 +476,12 @@ function showTerminateError( "Continue at your own risk. Please do not report any issues that arise from here on out, as they are very likely to be caused by the unhandled error. ", noLink: true, }); - if (action === 1) { + if (result.response === 1) { log("info", "user ignored error, disabling reporting"); errorIgnored = true; return true; } - } else if (buttons[action] === "Show Details") { + } else if (buttons[result.response] === "Show Details") { return showTerminateError(error, state, source, allowReport, true); } return false; @@ -487,13 +502,6 @@ export function terminate( allowReport?: boolean, source?: string, ) { - const dialog = process.type === "renderer" ? remote.dialog : dialogIn; - let win = - process.type === "renderer" ? remote.getCurrentWindow() : defaultWindow; - if (win && (win.isDestroyed() || !win.isVisible())) { - win = null; - } - if (allowReport === undefined && error.allowReport === false) { allowReport = false; } @@ -504,51 +512,56 @@ export function terminate( log("error", "unrecoverable error", { error, process: process.type }); - try { - if (showTerminateError(error, state, source, allowReport, false)) { - // ignored - return; - } + // Use an async IIFE to handle the async dialog calls + void (async () => { + try { + if (await showTerminateError(error, state, source, allowReport, false)) { + // ignored + return; + } - if (error.extension !== undefined) { - const action = dialog.showMessageBoxSync(getVisibleWindow(), { - type: "error", - buttons: ["Disable", "Keep"], - title: "Extension crashed", - message: - `This crash was caused by an extension (${error.extension}). ` + - "Do you want to disable this extension? All functionality provided " + - "by the extension will be removed from Vortex!", - noLink: true, - }); - if (action === 0) { - log("warn", "extension will be disabled after causing a crash", { - extId: error.extension, - error: error.message, - stack: error.stack, + if (error.extension !== undefined) { + const result = await showMessageBox({ + type: "error", + buttons: ["Disable", "Keep"], + title: "Extension crashed", + message: + `This crash was caused by an extension (${error.extension}). ` + + "Do you want to disable this extension? All functionality provided " + + "by the extension will be removed from Vortex!", + noLink: true, }); - // can't access the store at this point because we won't be waiting for the store - // to be persisted - fs.writeFileSync( - path.join(getVortexPath("temp"), "__disable_" + error.extension), - "", - ); + if (result.response === 0) { + log("warn", "extension will be disabled after causing a crash", { + extId: error.extension, + error: error.message, + stack: error.stack, + }); + // can't access the store at this point because we won't be waiting for the store + // to be persisted + fs.writeFileSync( + path.join(getVortexPath("temp"), "__disable_" + error.extension), + "", + ); + } } + } catch (err) { + // if the crash occurs before the application is ready, the dialog module can't be + // used (except for this function) + await showErrorBox( + "An unrecoverable error occurred", + error.message + + "\n" + + error.details + + "\nIf you think this is a bug, please report it to the " + + "issue tracker (github)", + ); } - } catch (err) { - // if the crash occurs before the application is ready, the dialog module can't be - // used (except for this function) - dialog.showErrorBox( - "An unrecoverable error occurred", - error.message + - "\n" + - error.details + - "\nIf you think this is a bug, please report it to the " + - "issue tracker (github)", - ); - } - getApplication().quit(1); + getApplication().quit(1); + })(); + + // Throw immediately to stop execution in the calling code throw new UserCanceled(); } diff --git a/src/util/exeIcon.ts b/src/util/exeIcon.ts index af5a78743..b71a8d07e 100644 --- a/src/util/exeIcon.ts +++ b/src/util/exeIcon.ts @@ -1,16 +1,3 @@ -import makeRemoteCall from "./electronRemote"; -import * as fs from "./fs"; - -const efi = makeRemoteCall( - "extract-file-icon", - (electron, content, exePath: string, iconPath: string) => { - return electron.app - .getFileIcon(exePath, { size: "normal" }) - .then((icon) => fs.writeFileAsync(iconPath, icon.toPNG())) - .then(() => null); - }, -); - function extractExeIcon(exePath: string, destPath: string): Promise { // app.getFileIcon generated broken output on windows as of electron 11.0.4 // (see https://github.com/electron/electron/issues/26918) @@ -31,7 +18,7 @@ function extractExeIcon(exePath: string, destPath: string): Promise { }); } else { */ - return efi(exePath, destPath); + return window.api.app.extractFileIcon(exePath, destPath); // } } diff --git a/src/util/extensionUtil.ts b/src/util/extensionUtil.ts new file mode 100644 index 000000000..78538e342 --- /dev/null +++ b/src/util/extensionUtil.ts @@ -0,0 +1,18 @@ +import type { + IAvailableExtension, + IExtension, +} from "../extensions/extension_manager/types"; + +/** + * Check if an installed extension matches a remote available extension + */ +export function isExtSame( + installed: IExtension, + remote: IAvailableExtension, +): boolean { + if (installed.modId !== undefined) { + return installed.modId === remote.modId; + } + + return installed.name === remote.name; +} diff --git a/src/util/fs.ts b/src/util/fs.ts index ebbb7a491..a34cf4078 100644 --- a/src/util/fs.ts +++ b/src/util/fs.ts @@ -44,6 +44,7 @@ import { getErrorMessageOrDefault, isErrorWithSystemCode, } from "../shared/errors"; +import type { Api } from "../shared/types/preload"; const permission: typeof permissionT = lazyRequire(() => require("permissions"), @@ -51,11 +52,21 @@ const permission: typeof permissionT = lazyRequire(() => const vortexRun: typeof vortexRunT = lazyRequire(() => require("vortex-run")); const wholocks: typeof whoLocksT = lazyRequire(() => require("wholocks")); -const dialog = - process.type === "renderer" - ? // tslint:disable-next-line:no-var-requires - require("@electron/remote").dialog - : dialogIn; +// Helper to access preload API - only available in renderer process +const getPreloadApi = (): Api => (window as unknown as { api: Api }).api; + +// For renderer process, we use the preload API for dialogs +// For main process, we use the electron dialog module directly +const showMessageBox = async ( + options: Electron.MessageBoxOptions, +): Promise => { + if (process.type === "renderer") { + return getPreloadApi().dialog.showMessageBox(options); + } else { + const win = getVisibleWindow(); + return dialogIn.showMessageBox(win, options); + } +}; export { constants, Stats, WriteStream } from "fs"; export type { FSWatcher } from "fs"; @@ -132,10 +143,6 @@ const simfail = : (func: () => PromiseBB) => func(); function nospcQuery(): PromiseBB { - if (dialog === undefined) { - return PromiseBB.resolve(false); - } - const options: Electron.MessageBoxOptions = { title: "Disk full", message: @@ -147,17 +154,14 @@ function nospcQuery(): PromiseBB { noLink: true, }; - const choice = dialog.showMessageBoxSync(getVisibleWindow(), options); - return choice === 0 - ? PromiseBB.reject(new UserCanceled()) - : PromiseBB.resolve(true); + return PromiseBB.resolve(showMessageBox(options)).then((result) => + result.response === 0 + ? PromiseBB.reject(new UserCanceled()) + : PromiseBB.resolve(true), + ); } function ioQuery(): PromiseBB { - if (dialog === undefined) { - return PromiseBB.resolve(false); - } - const options: Electron.MessageBoxOptions = { title: "I/O Error", message: @@ -170,14 +174,15 @@ function ioQuery(): PromiseBB { noLink: true, }; - const choice = dialog.showMessageBoxSync(getVisibleWindow(), options); - return choice === 0 - ? PromiseBB.reject(new UserCanceled()) - : PromiseBB.resolve(true); + return PromiseBB.resolve(showMessageBox(options)).then((result) => + result.response === 0 + ? PromiseBB.reject(new UserCanceled()) + : PromiseBB.resolve(true), + ); } function unlockConfirm(filePath: string): PromiseBB { - if (dialog === undefined || !truthy(filePath)) { + if (!truthy(filePath)) { return PromiseBB.resolve(false); } @@ -218,10 +223,11 @@ function unlockConfirm(filePath: string): PromiseBB { noLink: true, }; - const choice = dialog.showMessageBoxSync(getVisibleWindow(), options); - return choice === 0 - ? PromiseBB.reject(new UserCanceled()) - : PromiseBB.resolve(choice === 2); + return PromiseBB.resolve(showMessageBox(options)).then((result) => + result.response === 0 + ? PromiseBB.reject(new UserCanceled()) + : PromiseBB.resolve(result.response === 2), + ); } function unknownErrorRetry( @@ -229,10 +235,6 @@ function unknownErrorRetry( err: Error, stackErr: Error, ): PromiseBB { - if (dialog === undefined) { - return PromiseBB.resolve(false); - } - if (filePath === undefined) { // unfortunately these error message don't necessarily contain the filename filePath = ""; @@ -270,44 +272,42 @@ function unknownErrorRetry( options.buttons = ["Cancel", "Ignore", "Retry"]; } - const choice = dialog.showMessageBoxSync(getVisibleWindow(), options); - - if (options.buttons[choice] === "Cancel and Report") { - // we're reporting this to collect a list of native errors and provide better error - // message - const nat = err["nativeCode"]; - createErrorReport( - "Unknown error", - { - message: `Windows System Error (${nat})`, - stack: restackErr(err, stackErr).stack, - path: filePath, - }, - {}, - ["bug"], - {}, - ); - return PromiseBB.reject(new UserCanceled()); - } + return PromiseBB.resolve(showMessageBox(options)).then((result) => { + const choice = result.response; + + if (options.buttons[choice] === "Cancel and Report") { + // we're reporting this to collect a list of native errors and provide better error + // message + const nat = err["nativeCode"]; + createErrorReport( + "Unknown error", + { + message: `Windows System Error (${nat})`, + stack: restackErr(err, stackErr).stack, + path: filePath, + }, + {}, + ["bug"], + {}, + ); + return PromiseBB.reject(new UserCanceled()); + } - switch (options.buttons[choice]) { - case "Retry": - return PromiseBB.resolve(true); - case "Ignore": { - err["code"] = decoded?.rethrowAs ?? "UNKNOWN"; - err["allowReport"] = false; - return PromiseBB.reject(err); + switch (options.buttons[choice]) { + case "Retry": + return PromiseBB.resolve(true); + case "Ignore": { + err["code"] = decoded?.rethrowAs ?? "UNKNOWN"; + err["allowReport"] = false; + return PromiseBB.reject(err); + } } - } - return PromiseBB.reject(new UserCanceled()); + return PromiseBB.reject(new UserCanceled()); + }); } function busyRetry(filePath: string): PromiseBB { - if (dialog === undefined) { - return PromiseBB.resolve(false); - } - if (filePath === undefined) { filePath = ""; } @@ -337,10 +337,11 @@ function busyRetry(filePath: string): PromiseBB { noLink: true, }; - const choice = dialog.showMessageBoxSync(getVisibleWindow(), options); - return choice === 0 - ? PromiseBB.reject(new UserCanceled()) - : PromiseBB.resolve(true); + return PromiseBB.resolve(showMessageBox(options)).then((result) => + result.response === 0 + ? PromiseBB.reject(new UserCanceled()) + : PromiseBB.resolve(true), + ); } function errorRepeat( @@ -1202,7 +1203,7 @@ function raiseUACDialog( filePath: string, ): PromiseBB { let fileToAccess = filePath !== undefined ? filePath : err.path; - const choice = dialog.showMessageBoxSync(getVisibleWindow(), { + const options: Electron.MessageBoxOptions = { title: "Access denied (2)", message: t( 'Vortex needs to access "{{ fileName }}" but doesn\'t have permission to.\n' + @@ -1213,47 +1214,51 @@ function raiseUACDialog( buttons: ["Cancel", "Retry", "Give permission"], noLink: true, type: "warning", - }); - if (choice === 1) { - // Retry - return forcePerm(t, op, filePath); - } else if (choice === 2) { - // Give Permission - const userId = permission.getUserId(); - return PromiseBB.resolve(fs.stat(fileToAccess)) - .catch((statErr) => { - if (statErr.code === "ENOENT") { - fileToAccess = path.dirname(fileToAccess); - } - return PromiseBB.resolve(); - }) - .then(() => - elevated( - (ipcPath, req: NodeRequire) => { - // tslint:disable-next-line:no-shadowed-variable - const { allow } = req("permissions"); - return allow(fileToAccess, userId, "rwx"); - }, - { fileToAccess, userId }, - ).catch((elevatedErr) => { - if ( - elevatedErr instanceof UserCanceled || - elevatedErr.message.indexOf( - "The operation was canceled by the user", - ) !== -1 - ) { - return Promise.reject(new UserCanceled()); + }; + + return PromiseBB.resolve(showMessageBox(options)).then((result) => { + const choice = result.response; + if (choice === 1) { + // Retry + return forcePerm(t, op, filePath); + } else if (choice === 2) { + // Give Permission + const userId = permission.getUserId(); + return PromiseBB.resolve(fs.stat(fileToAccess)) + .catch((statErr) => { + if (statErr.code === "ENOENT") { + fileToAccess = path.dirname(fileToAccess); } - // if elevation failed, return the original error because the one from - // elevate, while interesting as well, would make error handling too complicated - log("error", "failed to acquire permission", elevatedErr.message); - return Promise.reject(err); - }), - ) - .then(() => forcePerm(t, op, filePath)); - } else { - return PromiseBB.reject(new UserCanceled()); - } + return PromiseBB.resolve(); + }) + .then(() => + elevated( + (ipcPath, req: NodeRequire) => { + // tslint:disable-next-line:no-shadowed-variable + const { allow } = req("permissions"); + return allow(fileToAccess, userId, "rwx"); + }, + { fileToAccess, userId }, + ).catch((elevatedErr) => { + if ( + elevatedErr instanceof UserCanceled || + elevatedErr.message.indexOf( + "The operation was canceled by the user", + ) !== -1 + ) { + return Promise.reject(new UserCanceled()); + } + // if elevation failed, return the original error because the one from + // elevate, while interesting as well, would make error handling too complicated + log("error", "failed to acquire permission", elevatedErr.message); + return Promise.reject(err); + }), + ) + .then(() => forcePerm(t, op, filePath)); + } else { + return PromiseBB.reject(new UserCanceled()); + } + }); } export function forcePerm( diff --git a/src/util/getVortexPath.ts b/src/util/getVortexPath.ts index 00f97ad14..ffc6fb55a 100644 --- a/src/util/getVortexPath.ts +++ b/src/util/getVortexPath.ts @@ -1,7 +1,13 @@ import * as electron from "electron"; import * as os from "os"; import * as path from "path"; -import { makeRemoteCallSync } from "./electronRemote"; +import type { Api } from "../shared/types/preload"; +import type { VortexPaths } from "../shared/types/ipc"; + +// Helper to access preload API and values - only available in renderer process +const getPreloadApi = (): Api => (window as unknown as { api: Api }).api; +const getPreloadVortexPaths = (): VortexPaths => + (window as unknown as { vortexPaths: VortexPaths }).vortexPaths; // If running as a forked child process, read Electron app info from environment variables const electronAppInfoEnv: { [key: string]: string | undefined } = @@ -28,59 +34,6 @@ const electronAppInfoEnv: { [key: string]: string | undefined } = } : {}; -const getElectronPath = (() => { - if (electron && electron.app) { - return makeRemoteCallSync( - "get-electron-path", - (electronIn, webContents, id: string) => { - if (!electronIn.app) { - throw new Error( - "Electron app is not available. This code must run in the Electron main process.", - ); - } - if (id === "__app") { - return electronIn.app.getAppPath(); - } - return electronIn.app.getPath(id as any); - }, - ); - } - // Try to use @electron/remote or electron.remote in renderer - try { - // Prefer @electron/remote if available - const electronRemote = require("@electron/remote"); - if (electronRemote && electronRemote.app) { - return (id: string) => { - if (id === "__app") return electronRemote.app.getAppPath(); - return electronRemote.app.getPath(id); - }; - } - } catch {} - try { - // Fallback to electron.remote if available - if (electron && (electron as any).remote && (electron as any).remote.app) { - return (id: string) => { - if (id === "__app") return (electron as any).remote.app.getAppPath(); - return (electron as any).remote.app.getPath(id); - }; - } - } catch {} - // Fallback for non-Electron processes - return (id: string) => { - if (id === "__app") { - return path.resolve(__dirname, "..", ".."); - } - return os.tmpdir(); - }; -})(); - -const setElectronPath = makeRemoteCallSync( - "set-electron-path", - (electronIn, webContents, id: string, value: string) => { - electronIn.app.setPath(id as any, value); - }, -); - export type AppPath = | "base" | "assets" @@ -101,41 +54,66 @@ export type AppPath = | "exe" | "desktop"; -/** - * app.getAppPath() returns the path to the app.asar, - * development: node_modules\electron\dist\resources\default_app.asar - * production (with asar): Vortex\resources\app.asar - * production (without asar): Vortex\resources\app - * - * when running from unit tests, app may not be defined at all, in that case we use __dirname - * after all - */ -// let basePath = app !== undefined ? app.getAppPath() : path.resolve(__dirname, '..', '..'); -let basePath = - electron !== undefined - ? getElectronPath("__app") - : path.resolve(__dirname, "..", ".."); -const isDevelopment = path.basename(basePath, ".asar") !== "app"; -const isAsar = !isDevelopment && path.extname(basePath) === ".asar"; -const applicationPath = isDevelopment - ? basePath - : path.resolve(path.dirname(basePath), ".."); +// Check process type once at module load +const isMainProcess = electron?.app !== undefined; +const isForkedChild = + typeof process.send === "function" && + Object.keys(electronAppInfoEnv).length > 0; -if (isDevelopment) { - // In Electron 37, app.getAppPath() may already point to the 'out' directory - // Check if basePath already ends with 'out' to avoid double 'out/out' - if (path.basename(basePath) === "out") { - // basePath is already correct (points to out directory) - // Don't modify it - } else { - basePath = path.join(applicationPath, "out"); +// For renderer process, we'll use window.vortexPaths which is set by preload +// For main process, we delegate to main/getVortexPath.ts +// For forked child, we use env vars + +// Cache for paths (used in main process) +const cache: { [id: string]: string | (() => string) } = {}; + +// Main process helpers (only used when running in main process) +const getAppPath = (id: string): string => { + if (!isMainProcess) { + throw new Error("getAppPath called outside main process"); } -} + if (cache[id] === undefined) { + if (id === "__app") { + cache[id] = electron.app.getAppPath(); + } else { + cache[id] = electron.app.getPath(id as any); + } + } + const value = cache[id]; + return typeof value === "string" ? value : value(); +}; + +// Compute paths for main process (lazy initialization) +let mainProcessPaths: { + basePath: string; + applicationPath: string; + isDevelopment: boolean; + isAsar: boolean; +} | null = null; + +function initMainProcessPaths() { + if (mainProcessPaths !== null) return mainProcessPaths; -// basePath is now the path that contains assets, bundledPlugins, index.html, main.js and so on -// applicationPath is still different between development and production + let basePath = electron.app.getAppPath(); + const isDevelopment = path.basename(basePath, ".asar") !== "app"; + const isAsar = !isDevelopment && path.extname(basePath) === ".asar"; + const applicationPath = isDevelopment + ? basePath + : path.resolve(path.dirname(basePath), ".."); + + if (isDevelopment) { + if (path.basename(basePath) !== "out") { + basePath = path.join(applicationPath, "out"); + } + } + + mainProcessPaths = { basePath, applicationPath, isDevelopment, isAsar }; + return mainProcessPaths; +} function getModulesPath(unpacked: boolean): string { + const { basePath, applicationPath, isDevelopment, isAsar } = + initMainProcessPaths(); if (isDevelopment) { return path.join(applicationPath, "node_modules"); } @@ -144,61 +122,44 @@ function getModulesPath(unpacked: boolean): string { } function getAssets(unpacked: boolean): string { + const { basePath, isAsar } = initMainProcessPaths(); const asarPath = unpacked && isAsar ? basePath + ".unpacked" : basePath; return path.join(asarPath, "assets"); } function getBundledPluginsPath(): string { - // bundled plugins are never packed in the asar + const { basePath, isAsar } = initMainProcessPaths(); return isAsar ? path.join(basePath + ".unpacked", "bundledPlugins") : path.join(basePath, "bundledPlugins"); } function getLocalesPath(): string { - // in production builds the locales are not inside the app(.asar) directory but alongside it + const { basePath, isDevelopment } = initMainProcessPaths(); return isDevelopment ? path.join(basePath, "locales") : path.resolve(basePath, "..", "locales"); } -/** - * path to the directory containing package.json file - */ function getPackagePath(unpacked: boolean): string { + const { basePath, applicationPath, isDevelopment } = initMainProcessPaths(); if (isDevelopment) { return applicationPath; } - let res = basePath; if (unpacked && path.basename(res) === "app.asar") { res = path.join(path.dirname(res), "app.asar.unpacked"); } - return res; } -const cache: { [id: string]: string | (() => string) } = {}; - -const cachedAppPath = (id: string) => { - if (cache[id] === undefined) { - cache[id] = getElectronPath(id as any); - } - const value = cache[id]; - if (typeof value === "string") { - return value; - } else { - return value(); - } -}; - const localAppData = (() => { - let cached; + let cached: string | undefined; return () => { if (cached === undefined) { cached = process.env.LOCALAPPDATA || - path.resolve(cachedAppPath("appData"), "..", "Local"); + path.resolve(getAppPath("appData"), "..", "Local"); } return cached; }; @@ -206,154 +167,106 @@ const localAppData = (() => { export function setVortexPath(id: AppPath, value: string | (() => string)) { cache[id] = value; - if (typeof value === "string") { - setElectronPath(id, value); - } else { - setElectronPath(id, value()); + if (isMainProcess) { + const strValue = typeof value === "string" ? value : value(); + electron.app.setPath(id as any, strValue); + } else if (typeof window !== "undefined") { + // In renderer, update via IPC (async) + try { + const strValue = typeof value === "string" ? value : value(); + void getPreloadApi().app.setPath(id, strValue); + } catch { + // Preload API not available yet + } } } /** - * the electron getAppPath function and globals like __dirname - * or process.resourcesPath don't do a great job of abstracting away - * how the application is being built, e.g. development or not, asar or not, - * webpack or not, portable or not. - * This function aims to provide reasonable paths to application data independent - * of any of that. + * Get Vortex application path. + * + * This function provides paths to application data independent + * of build configuration (development/production, asar/no-asar, portable/not). + * + * - Main process: Uses electron.app directly + * - Renderer process: Uses window.vortexPaths from preload + * - Forked child process: Uses environment variables */ - function getVortexPath(id: AppPath): string { - if (electronAppInfoEnv && Object.keys(electronAppInfoEnv).length > 0) { - if (id in electronAppInfoEnv && electronAppInfoEnv[id]) { - return electronAppInfoEnv[id]!; - } - // If not found, fall through to next logic (do not throw) - } - switch (id) { - case "userData": - return cachedAppPath("userData"); - case "temp": - return cachedAppPath("temp"); - case "appData": - return cachedAppPath("appData"); - case "localAppData": - return localAppData(); - case "home": - return cachedAppPath("home"); - case "documents": - return cachedAppPath("documents"); - case "exe": - return cachedAppPath("exe"); - case "desktop": - return cachedAppPath("desktop"); - case "base": - return basePath; - case "application": - return applicationPath; - case "package": - return getPackagePath(false); - case "package_unpacked": - return getPackagePath(true); - case "assets": - return getAssets(false); - case "assets_unpacked": - return getAssets(true); - case "modules": - return getModulesPath(false); - case "modules_unpacked": - return getModulesPath(true); - case "bundledPlugins": - return getBundledPluginsPath(); - case "locales": - return getLocalesPath(); + // 1. Forked child process: use env vars + if (isForkedChild && id in electronAppInfoEnv && electronAppInfoEnv[id]) { + return electronAppInfoEnv[id]!; } -} -export async function getVortexPathAsync(id: AppPath): Promise { - // 1. Forked child process: use env vars for all supported paths - if (electronAppInfoEnv && Object.keys(electronAppInfoEnv).length > 0) { - if (id in electronAppInfoEnv && electronAppInfoEnv[id]) { - return electronAppInfoEnv[id]!; + // 2. Renderer process: use preload values + if (!isMainProcess && typeof window !== "undefined") { + try { + const vortexPaths = getPreloadVortexPaths(); + if (vortexPaths !== undefined) { + // Check cache first (for setVortexPath overrides) + if (cache[id] !== undefined) { + const value = cache[id]; + return typeof value === "string" ? value : value(); + } + return vortexPaths[id]; + } + } catch { + // Preload not available yet } - // If not found, fall through to next logic (do not throw) } - // 2. Main/renderer process: use electronRemote IPC if available - try { - const { makeRemoteCallSync: makeRemoteCall } = require("./electronRemote"); - const getElectronPathRemote = makeRemoteCall( - "get-electron-path", - (electronIn, webContents, id: string) => { - if (!electronIn.app) throw new Error("Electron app is not available."); - if (id === "__app") return electronIn.app.getAppPath(); - return electronIn.app.getPath(id as any); - }, - ); - if (typeof getElectronPathRemote === "function") { - if (id === "base") { - return await Promise.resolve(getElectronPathRemote("__app")); - } - return await Promise.resolve(getElectronPathRemote(id)); + + // 3. Main process: compute paths directly + if (isMainProcess) { + // Check cache first (for setVortexPath overrides) + if (cache[id] !== undefined) { + const value = cache[id]; + return typeof value === "string" ? value : value(); + } + + switch (id) { + case "userData": + return getAppPath("userData"); + case "temp": + return getAppPath("temp"); + case "appData": + return getAppPath("appData"); + case "localAppData": + return localAppData(); + case "home": + return getAppPath("home"); + case "documents": + return getAppPath("documents"); + case "exe": + return getAppPath("exe"); + case "desktop": + return getAppPath("desktop"); + case "base": + return initMainProcessPaths().basePath; + case "application": + return initMainProcessPaths().applicationPath; + case "package": + return getPackagePath(false); + case "package_unpacked": + return getPackagePath(true); + case "assets": + return getAssets(false); + case "assets_unpacked": + return getAssets(true); + case "modules": + return getModulesPath(false); + case "modules_unpacked": + return getModulesPath(true); + case "bundledPlugins": + return getBundledPluginsPath(); + case "locales": + return getLocalesPath(); } - } catch (e) { - // ignore, fallback to sync logic } - // 3. Fallback to sync logic - switch (id) { - // c:\users\\appdata\roaming\vortex - case "userData": - return cachedAppPath("userData"); - // c:\users\\appdata\roaming\vortex\temp - case "temp": - return cachedAppPath("temp"); - // c:\users\\appdata\roaming - case "appData": - return cachedAppPath("appData"); - // c:\users\\appdata\local - case "localAppData": - return localAppData(); - // C:\Users\Tannin - case "home": - return cachedAppPath("home"); - // C:\Users\Tannin\Documents - case "documents": - return cachedAppPath("documents"); - // C:\Program Files\Black Tree Gaming Ltd\Vortex\Vortex.exe - case "exe": - return cachedAppPath("exe"); - // C:\Users\Tannin\Desktop - case "desktop": - return cachedAppPath("desktop"); - // C:\Program Files\Black Tree Gaming Ltd\Vortex\resources\app.asar - case "base": - return basePath; - // C:\Program Files\Black Tree Gaming Ltd\Vortex - case "application": - return applicationPath; - // C:\Program Files\Black Tree Gaming Ltd\Vortex\resources\app.asar - case "package": - return getPackagePath(false); - // C:\Program Files\Black Tree Gaming Ltd\Vortex\resources\app.asar.unpacked - case "package_unpacked": - return getPackagePath(true); - // C:\Program Files\Black Tree Gaming Ltd\Vortex\resources\app.asar\assets - case "assets": - return getAssets(false); - // C:\Program Files\Black Tree Gaming Ltd\Vortex\resources\app.asar.unpacked\assets - case "assets_unpacked": - return getAssets(true); - // C:\Program Files\Black Tree Gaming Ltd\Vortex\resources\app.asar\node_modules - case "modules": - return getModulesPath(false); - // C:\Program Files\Black Tree Gaming Ltd\Vortex\resources\app.asar.unpacked\node_modules - case "modules_unpacked": - return getModulesPath(true); - // C:\Program Files\Black Tree Gaming Ltd\Vortex\resources\app.asar.unpacked\bundledPlugins - case "bundledPlugins": - return getBundledPluginsPath(); - // C:\Program Files\Black Tree Gaming Ltd\Vortex\resources\locales - case "locales": - return getLocalesPath(); + + // 4. Fallback for non-Electron environments (tests) + if (id === "temp") { + return os.tmpdir(); } + return path.resolve(__dirname, "..", ".."); } export default getVortexPath; diff --git a/src/util/requireRemap.ts b/src/util/requireRemap.ts index f19bb9d71..a8c368e72 100644 --- a/src/util/requireRemap.ts +++ b/src/util/requireRemap.ts @@ -47,9 +47,11 @@ function patchedLoad(orig) { ) { request = "original-fs"; } else if (request === "electron") { + // Let the preload script get the real electron module + if (parent.filename.indexOf("preload") !== -1) { + return orig.apply(this, [request, parent, ...rest]); + } return electron; - } else if (request === "@electron/remote" && process.type !== "renderer") { - return undefined; } let res = orig.apply(this, [request, parent, ...rest]); diff --git a/src/util/vortex-run/src/thread.ts b/src/util/vortex-run/src/thread.ts index 5b79c6aa4..63b88f4f4 100644 --- a/src/util/vortex-run/src/thread.ts +++ b/src/util/vortex-run/src/thread.ts @@ -3,6 +3,16 @@ import * as fs from "fs"; import * as path from "path"; import * as tmp from "tmp"; +function getErrorMessage(err: any): string { + if (err instanceof Error) { + return err.message; + } + if (typeof err === "string") { + return err; + } + return String(err); +} + function trampoline( baseDir: string, moduleRoot: string, @@ -96,7 +106,7 @@ function runThreaded( // tslint:disable-next-line:no-console console.error( "failed to clean up temporary script", - getErrorMessageOrDefault(cleanupErr), + getErrorMessage(cleanupErr), ); } return reject(writeErr); diff --git a/src/util/webview.ts b/src/util/webview.ts deleted file mode 100644 index d2a016849..000000000 --- a/src/util/webview.ts +++ /dev/null @@ -1,80 +0,0 @@ -import makeRemoteCall from "./electronRemote"; -import { setdefault } from "./util"; - -import type { BrowserView } from "electron"; -import { BrowserWindow } from "electron"; -import { generate as shortid } from "shortid"; -import { valueReplacer } from "./log"; - -const extraWebViews: { - [contentId: number]: { [viewId: string]: BrowserView }; -} = {}; - -export const makeBrowserView = makeRemoteCall( - "make-browser-view", - ( - mainElectron, - content, - src: string, - forwardEvents: string[], - options?: Electron.BrowserViewConstructorOptions, - ) => { - const viewId = shortid(); - const window = BrowserWindow.fromWebContents(content); - const view = new mainElectron.BrowserView(options); - setdefault(extraWebViews, content.id, {})[viewId] = view; - - view.setAutoResize({ - horizontal: true, - vertical: true, - }); - - window?.addBrowserView(view); - view.webContents.loadURL(src); - forwardEvents.forEach((eventId) => { - view.webContents.on(eventId as any, (evt, ...args) => { - content.send( - `view-${viewId}-${eventId}`, - JSON.stringify(args, valueReplacer()), - ); - evt.preventDefault(); - }); - }); - - return Promise.resolve(viewId); - }, -); - -export const closeBrowserView = makeRemoteCall( - "close-browser-view", - (mainElectron, content, viewId: string) => { - if (extraWebViews[content.id]?.[viewId] !== undefined) { - const window = BrowserWindow.fromWebContents(content); - window?.removeBrowserView(extraWebViews[content.id][viewId]); - delete extraWebViews[content.id]?.[viewId]; - } - return Promise.resolve(); - }, -); - -export const positionBrowserView = makeRemoteCall( - "position-browser-view", - (mainElectron, content, viewId: string, rect: Electron.Rectangle) => { - extraWebViews[content.id]?.[viewId]?.setBounds?.(rect); - return Promise.resolve(); - }, -); - -export const updateViewURL = makeRemoteCall( - "update-view-url", - (mainElectron, content, viewId: string, newURL: string) => { - extraWebViews[content.id]?.[viewId]?.webContents.loadURL(newURL); - return Promise.resolve(); - }, -); - -export function closeAllViews(window: BrowserWindow) { - Object.keys(extraWebViews[window.webContents.id] ?? {}).forEach((viewId) => { - window.removeBrowserView(extraWebViews[window.webContents.id][viewId]); - }); -} diff --git a/tsconfig.main.json b/tsconfig.main.json index 47ea344df..25e862f09 100644 --- a/tsconfig.main.json +++ b/tsconfig.main.json @@ -5,7 +5,8 @@ "src/main.ts", "src/main/**/*", "src/preload/**/*", - "src/shared/**/*" + "src/shared/**/*", + "src/renderer/preload.d.ts" // TODO: remove after renderer separation ], "compilerOptions": { "composite": true, diff --git a/tsconfig.renderer.json b/tsconfig.renderer.json index c0e487ae6..e372014b2 100644 --- a/tsconfig.renderer.json +++ b/tsconfig.renderer.json @@ -2,7 +2,6 @@ "$schema": "https://www.schemastore.org/tsconfig.json", "extends": "./tsconfig.base.json", "include": [ - "src/renderer/preload.d.ts", "src/renderer/**/*", "src/shared/**/*", "src/renderer.tsx", From 35209db12609eeb47c8c81c6dae9a3fa6c676684 Mon Sep 17 00:00:00 2001 From: IDCs Date: Fri, 30 Jan 2026 10:01:08 +0000 Subject: [PATCH 2/6] centralizing preloadApi getter --- .../download_management/DownloadManager.ts | 3 +- .../download_management/FileAssembler.ts | 3 +- src/extensions/nexus_integration/util.ts | 18 +++++---- src/renderer/views/Dialog.tsx | 14 ++++--- src/renderer/views/WindowControls.tsx | 34 +++++++++------- src/renderer/webview.ts | 14 +++++-- src/util/errorHandling.ts | 5 ++- src/util/exeIcon.ts | 4 +- src/util/preloadAccess.ts | 39 +++++++++++++++++++ 9 files changed, 97 insertions(+), 37 deletions(-) create mode 100644 src/util/preloadAccess.ts diff --git a/src/extensions/download_management/DownloadManager.ts b/src/extensions/download_management/DownloadManager.ts index 00cf4d342..af5f1e8ab 100644 --- a/src/extensions/download_management/DownloadManager.ts +++ b/src/extensions/download_management/DownloadManager.ts @@ -37,11 +37,12 @@ import type { IExtensionApi } from "../../types/api"; import { simulateHttpError } from "./debug/simulateHttpError"; import { getErrorMessageOrDefault, unknownToError } from "../../shared/errors"; +import { getPreloadApi } from "../../util/preloadAccess"; function getCookies( filter: Electron.CookiesGetFilter, ): Promise { - return window.api.session.getCookies(filter); + return getPreloadApi().session.getCookies(filter); } // assume urls are valid for at least 5 minutes diff --git a/src/extensions/download_management/FileAssembler.ts b/src/extensions/download_management/FileAssembler.ts index 61610b505..09fa44189 100644 --- a/src/extensions/download_management/FileAssembler.ts +++ b/src/extensions/download_management/FileAssembler.ts @@ -3,6 +3,7 @@ import { getVisibleWindow } from "../../util/errorHandling"; import * as fs from "../../util/fs"; import { log } from "../../util/log"; import { makeQueue } from "../../util/util"; +import { getPreloadApi } from "../../util/preloadAccess"; import PromiseBB from "bluebird"; import { dialog as dialogIn } from "electron"; @@ -13,7 +14,7 @@ const showMessageBox = async ( options: Electron.MessageBoxOptions, ): Promise => { if (process.type === "renderer") { - return window.api.dialog.showMessageBox(options); + return getPreloadApi().dialog.showMessageBox(options); } else { const win = getVisibleWindow(); return dialogIn.showMessageBox(win, options); diff --git a/src/extensions/nexus_integration/util.ts b/src/extensions/nexus_integration/util.ts index dc7f8d206..c5874bffa 100644 --- a/src/extensions/nexus_integration/util.ts +++ b/src/extensions/nexus_integration/util.ts @@ -46,6 +46,7 @@ import { import { contextify, setApiKey, setOauthToken } from "../../util/errorHandling"; import * as fs from "../../util/fs"; import getVortexPath from "../../util/getVortexPath"; +import { getPreloadApi, getWindowId } from "../../util/preloadAccess"; import { RateLimitExceeded } from "../../util/github"; import { log } from "../../util/log"; import { calcDuration, showError } from "../../util/message"; @@ -153,17 +154,18 @@ export async function bringToFront() { // This will cause a short "flicker" if the window was snapped and it will // still unsnap the window as far as windows is concerned. - const windowId = window.windowId; - const [x, y] = await window.api.window.getPosition(windowId); - const [w, h] = await window.api.window.getSize(windowId); + const windowId = getWindowId(); + const api = getPreloadApi(); + const [x, y] = await api.window.getPosition(windowId); + const [w, h] = await api.window.getSize(windowId); - await window.api.window.setAlwaysOnTop(windowId, true); - await window.api.window.show(windowId); - await window.api.window.setAlwaysOnTop(windowId, false); + await api.window.setAlwaysOnTop(windowId, true); + await api.window.show(windowId); + await api.window.setAlwaysOnTop(windowId, false); setTimeout(() => { - void window.api.window.setPosition(windowId, x, y); - void window.api.window.setSize(windowId, w, h); + void api.window.setPosition(windowId, x, y); + void api.window.setSize(windowId, w, h); }, 100); } diff --git a/src/renderer/views/Dialog.tsx b/src/renderer/views/Dialog.tsx index 6f35025ee..8e4b8dc44 100644 --- a/src/renderer/views/Dialog.tsx +++ b/src/renderer/views/Dialog.tsx @@ -20,6 +20,7 @@ import bbcode from "../controls/bbcode"; import { ComponentEx, connect, translate } from "../controls/ComponentEx"; import type { TFunction } from "../../util/i18n"; import { MutexWrapper } from "../../util/MutexContext"; +import { getPreloadApi, getWindowId } from "../../util/preloadAccess"; import update from "immutability-helper"; import * as React from "react"; @@ -124,14 +125,15 @@ class Dialog extends ComponentEx { this.setState(newState); // Bring window to focus when a new dialog appears - const winId = window.windowId; - void window.api.window.isMinimized(winId).then((minimized: boolean) => { + const winId = getWindowId(); + const api = getPreloadApi(); + void api.window.isMinimized(winId).then((minimized: boolean) => { if (minimized) { - void window.api.window.restore(winId); + void api.window.restore(winId); } - void window.api.window.setAlwaysOnTop(winId, true); - void window.api.window.show(winId); - void window.api.window.setAlwaysOnTop(winId, false); + void api.window.setAlwaysOnTop(winId, true); + void api.window.show(winId); + void api.window.setAlwaysOnTop(winId, false); }); } else if ( this.props.dialogs[0]?.content !== newProps.dialogs[0]?.content diff --git a/src/renderer/views/WindowControls.tsx b/src/renderer/views/WindowControls.tsx index d99a23b15..9f4c952d6 100644 --- a/src/renderer/views/WindowControls.tsx +++ b/src/renderer/views/WindowControls.tsx @@ -1,5 +1,6 @@ import * as React from "react"; import { IconButton } from "../controls/TooltipControls"; +import { getPreloadApi, getWindowId } from "../../util/preloadAccess"; class WindowControls extends React.Component<{}, { isMaximized: boolean }> { private mClosed: boolean = false; @@ -16,19 +17,18 @@ class WindowControls extends React.Component<{}, { isMaximized: boolean }> { } public componentDidMount() { + const api = getPreloadApi(); + const windowId = getWindowId(); + // Fetch initial maximized state - window.api.window - .isMaximized(window.windowId) - .then((maximized: boolean) => { - this.setState({ isMaximized: maximized }); - }); + api.window.isMaximized(windowId).then((maximized: boolean) => { + this.setState({ isMaximized: maximized }); + }); // Subscribe to window events - this.mUnsubscribeMaximize = window.api.window.onMaximize(this.onMaximize); - this.mUnsubscribeUnmaximize = window.api.window.onUnmaximize( - this.onUnMaximize, - ); - this.mUnsubscribeClose = window.api.window.onClose(this.onClose); + this.mUnsubscribeMaximize = api.window.onMaximize(this.onMaximize); + this.mUnsubscribeUnmaximize = api.window.onUnmaximize(this.onUnMaximize); + this.mUnsubscribeClose = api.window.onClose(this.onClose); } public componentWillUnmount() { @@ -70,7 +70,9 @@ class WindowControls extends React.Component<{}, { isMaximized: boolean }> { } private minimize = () => { - void window.api.window.minimize(window.windowId); + const api = getPreloadApi(); + const windowId = getWindowId(); + void api.window.minimize(windowId); }; private onMaximize = () => { @@ -88,16 +90,20 @@ class WindowControls extends React.Component<{}, { isMaximized: boolean }> { }; private toggleMaximize = () => { + const api = getPreloadApi(); + const windowId = getWindowId(); const { isMaximized } = this.state; if (isMaximized) { - void window.api.window.unmaximize(window.windowId); + void api.window.unmaximize(windowId); } else { - void window.api.window.maximize(window.windowId); + void api.window.maximize(windowId); } }; private close = () => { - void window.api.window.close(window.windowId); + const api = getPreloadApi(); + const windowId = getWindowId(); + void api.window.close(windowId); }; } diff --git a/src/renderer/webview.ts b/src/renderer/webview.ts index 1d68777b0..d304e9772 100644 --- a/src/renderer/webview.ts +++ b/src/renderer/webview.ts @@ -1,27 +1,33 @@ // Renderer-only webview utilities +import { getPreloadApi } from "../util/preloadAccess"; + export const makeBrowserView = ( src: string, forwardEvents: string[], options?: Electron.BrowserViewConstructorOptions, ): Promise => { - return window.api.browserView.createWithEvents(src, forwardEvents, options); + return getPreloadApi().browserView.createWithEvents( + src, + forwardEvents, + options, + ); }; export const closeBrowserView = (viewId: string): Promise => { - return window.api.browserView.close(viewId); + return getPreloadApi().browserView.close(viewId); }; export const positionBrowserView = ( viewId: string, rect: Electron.Rectangle, ): Promise => { - return window.api.browserView.position(viewId, rect); + return getPreloadApi().browserView.position(viewId, rect); }; export const updateViewURL = ( viewId: string, newURL: string, ): Promise => { - return window.api.browserView.updateURL(viewId, newURL); + return getPreloadApi().browserView.updateURL(viewId, newURL); }; diff --git a/src/util/errorHandling.ts b/src/util/errorHandling.ts index ba26caebe..2593f97d6 100644 --- a/src/util/errorHandling.ts +++ b/src/util/errorHandling.ts @@ -13,6 +13,7 @@ import { fallbackTFunc } from "./i18n"; import { log } from "./log"; import { bundleAttachment } from "./message"; import opn from "./opn"; +import { getPreloadApi } from "./preloadAccess"; import { getSafe } from "./storeHelper"; import { flatten, getAllPropertyNames, spawnSelf } from "./util"; @@ -40,7 +41,7 @@ const showMessageBox = async ( options: Electron.MessageBoxOptions, ): Promise => { if (process.type === "renderer") { - return window.api.dialog.showMessageBox(options); + return getPreloadApi().dialog.showMessageBox(options); } else { const win = getVisibleWindow(); return dialogIn.showMessageBox(win, options); @@ -49,7 +50,7 @@ const showMessageBox = async ( const showErrorBox = async (title: string, content: string): Promise => { if (process.type === "renderer") { - return window.api.dialog.showErrorBox(title, content); + return getPreloadApi().dialog.showErrorBox(title, content); } else { dialogIn.showErrorBox(title, content); } diff --git a/src/util/exeIcon.ts b/src/util/exeIcon.ts index b71a8d07e..fb256a6a4 100644 --- a/src/util/exeIcon.ts +++ b/src/util/exeIcon.ts @@ -1,3 +1,5 @@ +import { getPreloadApi } from "./preloadAccess"; + function extractExeIcon(exePath: string, destPath: string): Promise { // app.getFileIcon generated broken output on windows as of electron 11.0.4 // (see https://github.com/electron/electron/issues/26918) @@ -18,7 +20,7 @@ function extractExeIcon(exePath: string, destPath: string): Promise { }); } else { */ - return window.api.app.extractFileIcon(exePath, destPath); + return getPreloadApi().app.extractFileIcon(exePath, destPath); // } } diff --git a/src/util/preloadAccess.ts b/src/util/preloadAccess.ts new file mode 100644 index 000000000..00f97464a --- /dev/null +++ b/src/util/preloadAccess.ts @@ -0,0 +1,39 @@ +/** + * Helper module for accessing preload API with proper typing. + * Use this in files that need to access window.api or window.windowId + * to ensure TypeScript recognizes these properties. + */ + +import type { Api, PreloadWindow } from "../shared/types/preload"; + +/** + * Get the preload API from the window object. + * This is only available in the renderer process. + */ +export function getPreloadApi(): Api { + return (window as unknown as PreloadWindow).api; +} + +/** + * Get the current window ID from the window object. + * This is only available in the renderer process. + */ +export function getWindowId(): number { + return (window as unknown as PreloadWindow).windowId; +} + +/** + * Get the app name from the window object. + * This is only available in the renderer process. + */ +export function getAppName(): string { + return (window as unknown as PreloadWindow).appName; +} + +/** + * Get the app version from the window object. + * This is only available in the renderer process. + */ +export function getAppVersion(): string { + return (window as unknown as PreloadWindow).appVersion; +} From bf33228e2dfbb869b61f2e78458649746c8a4f1e Mon Sep 17 00:00:00 2001 From: erri120 Date: Mon, 2 Feb 2026 11:52:28 +0100 Subject: [PATCH 3/6] Fix IPC --- src/main/Application.ts | 112 ++++++++++++------------------------ src/preload/index.ts | 29 ++-------- src/shared/types/ipc.ts | 86 +++++++++++---------------- src/shared/types/preload.ts | 7 +-- 4 files changed, 78 insertions(+), 156 deletions(-) diff --git a/src/main/Application.ts b/src/main/Application.ts index e55ce2aef..5633b0d4a 100644 --- a/src/main/Application.ts +++ b/src/main/Application.ts @@ -41,6 +41,7 @@ import type { IState } from "../types/IState"; import type { IParameters, ISetItem } from "../util/commandLine"; import type * as develT from "../util/devel"; import type ExtensionManagerT from "../util/ExtensionManager"; +import type { AppPath } from "../util/getVortexPath"; import type MainWindowT from "./MainWindow"; import type SplashScreenT from "./SplashScreen"; import type TrayIconT from "./TrayIcon"; @@ -92,8 +93,6 @@ import { } from "../util/errorHandling"; import { validateFiles } from "../util/fileValidation"; import * as fs from "../util/fs"; -import type { AppPath } from "../util/getVortexPath"; - import getVortexPath, { setVortexPath } from "../util/getVortexPath"; import lazyRequire from "../util/lazyRequire"; import { prettifyNodeErrorMessage, showError } from "../util/message"; @@ -1673,7 +1672,7 @@ betterIpcMain.handle( betterIpcMain.handle( "browserView:close", - async (event: IpcMainInvokeEvent, viewId) => { + (event: IpcMainInvokeEvent, viewId) => { const contentsId = event.sender.id; if (extraWebViews[contentsId]?.[viewId] !== undefined) { const window = BrowserWindow.fromWebContents(event.sender); @@ -1685,7 +1684,7 @@ betterIpcMain.handle( betterIpcMain.handle( "browserView:position", - async (event: IpcMainInvokeEvent, viewId, rect) => { + (event: IpcMainInvokeEvent, viewId, rect) => { const contentsId = event.sender.id; extraWebViews[contentsId]?.[viewId]?.setBounds?.(rect); }, @@ -1693,7 +1692,7 @@ betterIpcMain.handle( betterIpcMain.handle( "browserView:updateURL", - async (event: IpcMainInvokeEvent, viewId, newURL) => { + (event: IpcMainInvokeEvent, viewId, newURL) => { const contentsId = event.sender.id; void extraWebViews[contentsId]?.[viewId]?.webContents.loadURL(newURL); }, @@ -1702,7 +1701,7 @@ betterIpcMain.handle( // Jump list (Windows) betterIpcMain.handle( "app:setJumpList", - async (_event: IpcMainInvokeEvent, categories: JumpListCategory[]) => { + (_event: IpcMainInvokeEvent, categories: JumpListCategory[]) => { try { app.setJumpList(categories); } catch (_err) { @@ -1763,14 +1762,14 @@ betterIpcMain.handleSync("vortex:getPathsSync", () => { }; }); -betterIpcMain.handle("window:getId", async (event: IpcMainInvokeEvent) => { +betterIpcMain.handle("window:getId", (event: IpcMainInvokeEvent) => { const window = BrowserWindow.fromWebContents(event.sender); return window?.id ?? -1; }); betterIpcMain.handle( "window:minimize", - async (_event: IpcMainInvokeEvent, windowId: number) => { + (_event: IpcMainInvokeEvent, windowId: number) => { const window = BrowserWindow.fromId(windowId); window?.minimize(); }, @@ -1778,7 +1777,7 @@ betterIpcMain.handle( betterIpcMain.handle( "window:maximize", - async (_event: IpcMainInvokeEvent, windowId: number) => { + (_event: IpcMainInvokeEvent, windowId: number) => { const window = BrowserWindow.fromId(windowId); window?.maximize(); }, @@ -1786,7 +1785,7 @@ betterIpcMain.handle( betterIpcMain.handle( "window:unmaximize", - async (_event: IpcMainInvokeEvent, windowId: number) => { + (_event: IpcMainInvokeEvent, windowId: number) => { const window = BrowserWindow.fromId(windowId); window?.unmaximize(); }, @@ -1794,7 +1793,7 @@ betterIpcMain.handle( betterIpcMain.handle( "window:restore", - async (_event: IpcMainInvokeEvent, windowId: number) => { + (_event: IpcMainInvokeEvent, windowId: number) => { const window = BrowserWindow.fromId(windowId); window?.restore(); }, @@ -1802,7 +1801,7 @@ betterIpcMain.handle( betterIpcMain.handle( "window:close", - async (_event: IpcMainInvokeEvent, windowId: number) => { + (_event: IpcMainInvokeEvent, windowId: number) => { const window = BrowserWindow.fromId(windowId); window?.close(); }, @@ -1810,7 +1809,7 @@ betterIpcMain.handle( betterIpcMain.handle( "window:focus", - async (_event: IpcMainInvokeEvent, windowId: number) => { + (_event: IpcMainInvokeEvent, windowId: number) => { const window = BrowserWindow.fromId(windowId); window?.focus(); }, @@ -1818,7 +1817,7 @@ betterIpcMain.handle( betterIpcMain.handle( "window:show", - async (_event: IpcMainInvokeEvent, windowId: number) => { + (_event: IpcMainInvokeEvent, windowId: number) => { const window = BrowserWindow.fromId(windowId); window?.show(); }, @@ -1826,7 +1825,7 @@ betterIpcMain.handle( betterIpcMain.handle( "window:hide", - async (_event: IpcMainInvokeEvent, windowId: number) => { + (_event: IpcMainInvokeEvent, windowId: number) => { const window = BrowserWindow.fromId(windowId); window?.hide(); }, @@ -1834,7 +1833,7 @@ betterIpcMain.handle( betterIpcMain.handle( "window:isMaximized", - async (_event: IpcMainInvokeEvent, windowId: number) => { + (_event: IpcMainInvokeEvent, windowId: number) => { const window = BrowserWindow.fromId(windowId); return window?.isMaximized() ?? false; }, @@ -1842,7 +1841,7 @@ betterIpcMain.handle( betterIpcMain.handle( "window:isMinimized", - async (_event: IpcMainInvokeEvent, windowId: number) => { + (_event: IpcMainInvokeEvent, windowId: number) => { const window = BrowserWindow.fromId(windowId); return window?.isMinimized() ?? false; }, @@ -1850,7 +1849,7 @@ betterIpcMain.handle( betterIpcMain.handle( "window:isFocused", - async (_event: IpcMainInvokeEvent, windowId: number) => { + (_event: IpcMainInvokeEvent, windowId: number) => { const window = BrowserWindow.fromId(windowId); return window?.isFocused() ?? false; }, @@ -1858,7 +1857,7 @@ betterIpcMain.handle( betterIpcMain.handle( "window:setAlwaysOnTop", - async (_event: IpcMainInvokeEvent, windowId: number, flag: boolean) => { + (_event: IpcMainInvokeEvent, windowId: number, flag: boolean) => { const window = BrowserWindow.fromId(windowId); window?.setAlwaysOnTop(flag); }, @@ -1866,46 +1865,12 @@ betterIpcMain.handle( betterIpcMain.handle( "window:moveTop", - async (_event: IpcMainInvokeEvent, windowId: number) => { + (_event: IpcMainInvokeEvent, windowId: number) => { const window = BrowserWindow.fromId(windowId); window?.moveTop(); }, ); -// Menu operations -betterIpcMain.handle( - "menu:setApplicationMenu", - ( - event: IpcMainInvokeEvent, - template: Electron.MenuItemConstructorOptions[], - ) => { - const sender = event.sender; - - // Recursively add click handlers that send IPC events to renderer - type MenuItemWithId = Electron.MenuItemConstructorOptions & { id?: string }; - const processTemplate = (items: MenuItemWithId[]): MenuItemWithId[] => { - return items.map((item: MenuItemWithId) => { - const processed = { ...item }; - if (item.id) { - processed.click = () => { - if (!sender.isDestroyed()) { - sender.send("menu:click", item.id); - } - }; - } - if (item.submenu && Array.isArray(item.submenu)) { - processed.submenu = processTemplate(item.submenu); - } - return processed; - }); - }; - - const processedTemplate = processTemplate(template); - const menu = Menu.buildFromTemplate(processedTemplate); - Menu.setApplicationMenu(menu); - }, -); - // Content tracing operations betterIpcMain.handle( "contentTracing:startRecording", @@ -1925,17 +1890,17 @@ betterIpcMain.handle( ); // Redux state transfer -betterIpcMain.handle("redux:getState", async () => { +betterIpcMain.handle("redux:getState", () => { const getReduxState = (global as GlobalWithRedux).getReduxState; if (typeof getReduxState === "function") { - return getReduxState(); + return getReduxState() as {}; } return undefined; }); betterIpcMain.handle( "redux:getStateMsgpack", - async (_event: IpcMainInvokeEvent, idx: number) => { + (_event: IpcMainInvokeEvent, idx: number) => { const getReduxStateMsgpack = (global as GlobalWithRedux) .getReduxStateMsgpack; if (typeof getReduxStateMsgpack === "function") { @@ -1948,24 +1913,24 @@ betterIpcMain.handle( // Login item settings betterIpcMain.handle( "app:setLoginItemSettings", - async (_event: IpcMainInvokeEvent, settings: Settings) => { + (_event: IpcMainInvokeEvent, settings: Settings) => { app.setLoginItemSettings(settings); }, ); -betterIpcMain.handle("app:getLoginItemSettings", async () => { +betterIpcMain.handle("app:getLoginItemSettings", () => { return app.getLoginItemSettings(); }); // Clipboard operations betterIpcMain.handle( "clipboard:writeText", - async (_event: IpcMainInvokeEvent, text: string) => { + (_event: IpcMainInvokeEvent, text: string) => { clipboard.writeText(text); }, ); -betterIpcMain.handle("clipboard:readText", async () => { +betterIpcMain.handle("clipboard:readText", () => { return clipboard.readText(); }); @@ -1974,7 +1939,7 @@ import { powerSaveBlocker } from "electron"; betterIpcMain.handle( "powerSaveBlocker:start", - async ( + ( _event: IpcMainInvokeEvent, type: "prevent-app-suspension" | "prevent-display-sleep", ) => { @@ -1984,27 +1949,27 @@ betterIpcMain.handle( betterIpcMain.handle( "powerSaveBlocker:stop", - async (_event: IpcMainInvokeEvent, id: number) => { + (_event: IpcMainInvokeEvent, id: number) => { powerSaveBlocker.stop(id); }, ); betterIpcMain.handle( "powerSaveBlocker:isStarted", - async (_event: IpcMainInvokeEvent, id: number) => { + (_event: IpcMainInvokeEvent, id: number) => { return powerSaveBlocker.isStarted(id); }, ); // App path operations -betterIpcMain.handle("app:getAppPath", async (_event: IpcMainInvokeEvent) => { +betterIpcMain.handle("app:getAppPath", (_event: IpcMainInvokeEvent) => { return app.getAppPath(); }); // Additional window operations betterIpcMain.handle( "window:getPosition", - async (_event: IpcMainInvokeEvent, windowId): Promise<[number, number]> => { + (_event: IpcMainInvokeEvent, windowId) => { const win = BrowserWindow.fromId(windowId); return (win?.getPosition() ?? [0, 0]) as [number, number]; }, @@ -2012,12 +1977,7 @@ betterIpcMain.handle( betterIpcMain.handle( "window:setPosition", - async ( - _event: IpcMainInvokeEvent, - windowId: number, - x: number, - y: number, - ) => { + (_event: IpcMainInvokeEvent, windowId: number, x: number, y: number) => { const win = BrowserWindow.fromId(windowId); win?.setPosition(x, y); }, @@ -2025,7 +1985,7 @@ betterIpcMain.handle( betterIpcMain.handle( "window:getSize", - async (_event: IpcMainInvokeEvent, windowId): Promise<[number, number]> => { + (_event: IpcMainInvokeEvent, windowId) => { const win = BrowserWindow.fromId(windowId); return (win?.getSize() ?? [0, 0]) as [number, number]; }, @@ -2033,7 +1993,7 @@ betterIpcMain.handle( betterIpcMain.handle( "window:setSize", - async ( + ( _event: IpcMainInvokeEvent, windowId: number, width: number, @@ -2046,7 +2006,7 @@ betterIpcMain.handle( betterIpcMain.handle( "window:isVisible", - async (_event: IpcMainInvokeEvent, windowId: number) => { + (_event: IpcMainInvokeEvent, windowId: number) => { const win = BrowserWindow.fromId(windowId); return win?.isVisible() ?? false; }, @@ -2054,7 +2014,7 @@ betterIpcMain.handle( betterIpcMain.handle( "window:toggleDevTools", - async (_event: IpcMainInvokeEvent, windowId: number) => { + (_event: IpcMainInvokeEvent, windowId: number) => { const win = BrowserWindow.fromId(windowId); win?.webContents.toggleDevTools(); }, diff --git a/src/preload/index.ts b/src/preload/index.ts index 3a7ce75cd..c6920783c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -50,20 +50,11 @@ try { }, dialog: { showOpen: (options) => - betterIpcRenderer.invoke( - "dialog:showOpen", - options, - ) as Promise, + betterIpcRenderer.invoke("dialog:showOpen", options), showSave: (options) => - betterIpcRenderer.invoke( - "dialog:showSave", - options, - ) as Promise, + betterIpcRenderer.invoke("dialog:showSave", options), showMessageBox: (options) => - betterIpcRenderer.invoke( - "dialog:showMessageBox", - options, - ) as Promise, + betterIpcRenderer.invoke("dialog:showMessageBox", options), showErrorBox: (title, content) => betterIpcRenderer.invoke("dialog:showErrorBox", title, content), }, @@ -93,11 +84,7 @@ try { browserView: { create: (src: string, partition: string, isNexus: boolean) => betterIpcRenderer.invoke("browserView:create", src, partition, isNexus), - createWithEvents: ( - src: string, - forwardEvents: string[], - options: unknown, - ) => + createWithEvents: (src, forwardEvents, options) => betterIpcRenderer.invoke( "browserView:createWithEvents", src, @@ -112,7 +99,7 @@ try { betterIpcRenderer.invoke("browserView:updateURL", viewId, newURL), }, session: { - getCookies: (filter: Electron.CookiesGetFilter) => + getCookies: (filter) => betterIpcRenderer.invoke("session:getCookies", filter), }, window: { @@ -183,9 +170,6 @@ try { betterIpcRenderer.invoke("window:toggleDevTools", windowId), }, menu: { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - setApplicationMenu: (template: any) => - betterIpcRenderer.invoke("menu:setApplicationMenu", template), onMenuClick: (callback: (menuItemId: string) => void) => { const listener = ( _event: Electron.IpcRendererEvent, @@ -212,8 +196,7 @@ try { readText: () => betterIpcRenderer.invoke("clipboard:readText"), }, powerSaveBlocker: { - start: (type: "prevent-app-suspension" | "prevent-display-sleep") => - betterIpcRenderer.invoke("powerSaveBlocker:start", type), + start: (type) => betterIpcRenderer.invoke("powerSaveBlocker:start", type), stop: (id: number) => betterIpcRenderer.invoke("powerSaveBlocker:stop", id), isStarted: (id: number) => diff --git a/src/shared/types/ipc.ts b/src/shared/types/ipc.ts index 834d66969..d30af7304 100644 --- a/src/shared/types/ipc.ts +++ b/src/shared/types/ipc.ts @@ -2,6 +2,8 @@ // Everything in here is compile-time only, meaning the interfaces you find here // are never used to create an object. They are only used for type inferrence. +import type Electron from "electron"; + import type { Level } from "./logging"; // NOTE(erri120): You should use unique channel names to prevent overlap. You can prefix @@ -92,14 +94,15 @@ export interface InvokeChannels { "example:ping": () => Promise; // Dialog channels - // NOTE: Electron types are marked as 'any' because they contain non-serializable properties - // that Electron's IPC handles internally. The actual data passed is serializable. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - "dialog:showOpen": (options: any) => Promise; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - "dialog:showSave": (options: any) => Promise; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - "dialog:showMessageBox": (options: any) => Promise; + "dialog:showOpen": ( + options: Electron.OpenDialogOptions, + ) => Promise; + "dialog:showSave": ( + options: Electron.SaveDialogOptions, + ) => Promise; + "dialog:showMessageBox": ( + options: Electron.MessageBoxOptions, + ) => Promise; "dialog:showErrorBox": (title: string, content: string) => Promise; // App protocol client channels @@ -131,8 +134,7 @@ export interface InvokeChannels { "browserView:createWithEvents": ( src: string, forwardEvents: string[], - // eslint-disable-next-line @typescript-eslint/no-explicit-any - options: any, + options?: Electron.BrowserViewConstructorOptions, ) => Promise; "browserView:close": (viewId: string) => Promise; "browserView:position": ( @@ -142,12 +144,12 @@ export interface InvokeChannels { "browserView:updateURL": (viewId: string, newURL: string) => Promise; // Jump list (Windows) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - "app:setJumpList": (categories: any) => Promise; + "app:setJumpList": (categories: Electron.JumpListCategory[]) => Promise; // Session cookies - // eslint-disable-next-line @typescript-eslint/no-explicit-any - "session:getCookies": (filter: any) => Promise; + "session:getCookies": ( + filter: Electron.CookiesGetFilter, + ) => Promise; // Window operations "window:getId": () => Promise; @@ -165,31 +167,24 @@ export interface InvokeChannels { "window:setAlwaysOnTop": (windowId: number, flag: boolean) => Promise; "window:moveTop": (windowId: number) => Promise; - // Menu operations - // NOTE: Electron MenuItemConstructorOptions is marked as 'any' because it contains non-serializable - // properties (functions) that Electron's IPC strips during transmission. The main process - // reconstructs these functions (click handlers) before passing to Electron.Menu. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - "menu:setApplicationMenu": (template: any) => Promise; - // Content tracing operations - // eslint-disable-next-line @typescript-eslint/no-explicit-any - "contentTracing:startRecording": (options: any) => Promise; + "contentTracing:startRecording": ( + options: Electron.TraceConfig | Electron.TraceCategoriesAndOptions, + ) => Promise; "contentTracing:stopRecording": (resultPath: string) => Promise; // Redux state transfer - // NOTE: Redux state is marked as 'any' because it's a complex nested object that is serializable + // NOTE: Redux state is a complex nested object that is serializable // but too complex to type precisely. The actual data is always serializable. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - "redux:getState": () => Promise; + "redux:getState": () => Promise<{}>; // Returns a base64-encoded msgpack chunk of the Redux state "redux:getStateMsgpack": (idx?: number) => Promise; // Login item settings - // eslint-disable-next-line @typescript-eslint/no-explicit-any - "app:setLoginItemSettings": (settings: any) => Promise; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - "app:getLoginItemSettings": () => Promise; + "app:setLoginItemSettings": ( + settings: Electron.LoginItemSettings, + ) => Promise; + "app:getLoginItemSettings": () => Promise; // Clipboard operations "clipboard:writeText": (text: string) => Promise; @@ -256,14 +251,6 @@ export type Serializable = | TypedArray; type IsAny = 0 extends 1 & T ? true : false; -type IsUnknown = - IsAny extends true - ? false - : unknown extends T - ? T extends unknown - ? true - : false - : false; type HasError = T extends { __error__: string } ? true @@ -282,19 +269,16 @@ export type AssertSerializable = // any IsAny extends true ? { __error__: "any is not serializable for IPC" } - : // unknown - IsUnknown extends true - ? { __error__: "unknown is not serializable for IPC" } - : // known serializables - T extends Serializable - ? T - : // objects - T extends object - ? HasError<{ [K in keyof T]: AssertSerializable }> extends true - ? { __error__: "Type is not serializable for IPC" } - : T - : // everything else - { __error__: "Type is not serializable for IPC" }; + : // known serializables + T extends Serializable + ? T + : // objects + T extends object + ? HasError<{ [K in keyof T]: AssertSerializable }> extends true + ? { __error__: "Type is not serializable for IPC" } + : T + : // everything else + { __error__: "Type is not serializable for IPC" }; /** Utility type to check all args are serializable */ export type SerializableArgs = { diff --git a/src/shared/types/preload.ts b/src/shared/types/preload.ts index f790b2bc0..7ebc2ede4 100644 --- a/src/shared/types/preload.ts +++ b/src/shared/types/preload.ts @@ -116,7 +116,7 @@ export interface App { setJumpList(categories: Electron.JumpListCategory[]): Promise; /** Set login item settings (auto-start) */ - setLoginItemSettings(settings: Electron.Settings): Promise; + setLoginItemSettings(settings: Electron.LoginItemSettings): Promise; /** Get login item settings */ getLoginItemSettings(): Promise; @@ -229,11 +229,6 @@ export interface WindowAPI { } export interface Menu { - /** Set application menu from template */ - setApplicationMenu( - template: Electron.MenuItemConstructorOptions[], - ): Promise; - /** Register listener for menu item clicks. Returns unsubscribe function. */ onMenuClick(callback: (menuItemId: string) => void): () => void; } From 164fdc4151534bf13e07542d75535992cdd37f39 Mon Sep 17 00:00:00 2001 From: erri120 Date: Mon, 2 Feb 2026 12:51:20 +0100 Subject: [PATCH 4/6] Fix IPC --- .../settings_interface/SettingsInterface.tsx | 87 ++++++++++++------- src/main/Application.ts | 2 +- src/shared/types/ipc.ts | 4 +- src/shared/types/preload.ts | 2 +- 4 files changed, 59 insertions(+), 36 deletions(-) diff --git a/src/extensions/settings_interface/SettingsInterface.tsx b/src/extensions/settings_interface/SettingsInterface.tsx index 09474b0d5..9bdad7acf 100644 --- a/src/extensions/settings_interface/SettingsInterface.tsx +++ b/src/extensions/settings_interface/SettingsInterface.tsx @@ -1,9 +1,19 @@ -import { showDialog } from "../../actions/notifications"; -import { resetSuppression } from "../../actions/notificationSettings"; -import { setCustomTitlebar } from "../../actions/window"; +import type * as Redux from "redux"; +import type { ThunkDispatch } from "redux-thunk"; + +import PromiseBB from "bluebird"; +import * as path from "path"; +import * as React from "react"; +import { + Alert, + Button, + ControlLabel, + FormControl, + FormGroup, + HelpBlock, +} from "react-bootstrap"; +import { useSelector } from "react-redux"; -import More from "../../renderer/controls/More"; -import Toggle from "../../renderer/controls/Toggle"; import type { DialogActions, DialogType, @@ -12,24 +22,28 @@ import type { } from "../../types/IDialog"; import type { IState } from "../../types/IState"; import type { IParameters } from "../../util/commandLine"; -import { relaunch } from "../../util/commandLine"; +import type { + IAvailableExtension, + IExtensionDownloadInfo, +} from "../extension_manager/types"; + +import { showDialog } from "../../actions/notifications"; +import { resetSuppression } from "../../actions/notificationSettings"; +import { setCustomTitlebar } from "../../actions/window"; import { ComponentEx, connect, translate, } from "../../renderer/controls/ComponentEx"; +import More from "../../renderer/controls/More"; +import Toggle from "../../renderer/controls/Toggle"; +import { relaunch } from "../../util/commandLine"; import getVortexPath from "../../util/getVortexPath"; import { log } from "../../util/log"; import { truthy } from "../../util/util"; - -import type { - IAvailableExtension, - IExtensionDownloadInfo, -} from "../extension_manager/types"; import { readExtensibleDir } from "../extension_manager/util"; import getTextModManagement from "../mod_management/texts"; import getTextProfiles from "../profile_management/texts"; - import { setAutoDeployment, setAutoEnable, @@ -49,21 +63,6 @@ import { import { nativeCountryName, nativeLanguageName } from "./languagemap"; import getText from "./texts"; -import PromiseBB from "bluebird"; -import * as path from "path"; -import * as React from "react"; -import { - Alert, - Button, - ControlLabel, - FormControl, - FormGroup, - HelpBlock, -} from "react-bootstrap"; -import { useSelector } from "react-redux"; -import type * as Redux from "redux"; -import type { ThunkDispatch } from "redux-thunk"; - interface ILanguage { key: string; language: string; @@ -175,7 +174,8 @@ class SettingsInterfaceImpl extends ComponentEx { {t("You need to restart Vortex to activate this change")} - @@ -190,10 +190,11 @@ class SettingsInterfaceImpl extends ComponentEx {
{t("Language")} + {languages.reduce((prev, language) => { if (language.ext.length < 2) { @@ -206,14 +207,17 @@ class SettingsInterfaceImpl extends ComponentEx { return prev; }, [])} + {t( "When you select a language for the first time you may have to restart Vortex.", )} + {t("Customisation")} +
{ {t("Custom Window Title Bar")}
+
{ {t("Enable Desktop Notifications")}
+
{t("Hide Top-Level Category")} + {
+
{
+
{t( @@ -262,8 +271,10 @@ class SettingsInterfaceImpl extends ComponentEx {
+ {t("Advanced")} +
{/*
@@ -281,6 +292,7 @@ class SettingsInterfaceImpl extends ComponentEx {
{t("Enable Profile Management")} + {
+
{ > {t("Enable GPU Acceleration")} + {startup.disableGPU === true ? ( @@ -310,32 +324,41 @@ class SettingsInterfaceImpl extends ComponentEx {
+ {t("Automation")} +
{t("Deploy Mods when Enabled")} + {getTextModManagement("deployment", t)} + {t("Install Mods when downloaded")} + {t("Enable Mods when installed (in current profile)")} + {t("Run Vortex when my computer starts")} + {startMinimizedToggle}
+ {t("Notifications")} +
+ {restartNotification} ); @@ -403,11 +427,12 @@ class SettingsInterfaceImpl extends ComponentEx { } return (