Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Tech] Make IPC typing simpler & more expandable #3819

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
Open
Next Next commit
Create new IPC type system
"new" here is a bit loose, as this inherits most of its type definition from the
.d.ts file, just changing how TS views it.

This replaces our old approach (overwriting electron's types) with a layer on
top of the existing IPC. This should play nicer with IDE intellisense, and
allows us to make changes to IPC very easily in the future (we can now for
example move the `event` parameter to the end of the parameter list passed to
the backend, or pass an abort controller to every handler)

In the process of moving the actual type definitions, I've also corrected a few
mistakes (which weren't caught before, as TS doesn't check .d.ts files)
  • Loading branch information
CommandMC committed Jun 13, 2024
commit 2f4e623b3328694b9e0058c9a986deb233634032
26 changes: 26 additions & 0 deletions src/common/ipc/backend.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// eslint-disable-next-line no-restricted-imports
import { ipcMain, type IpcMainEvent } from 'electron'

import type { AsyncIPCFunctions, SyncIPCFunctions } from './types'

function addListener<ChannelName extends keyof SyncIPCFunctions>(
channel: ChannelName,
listener: (
e: IpcMainEvent,
...args: Parameters<SyncIPCFunctions[ChannelName]>
) => void
) {
ipcMain.on(channel, listener as never)
}

function addHandler<ChannelName extends keyof AsyncIPCFunctions>(
channel: ChannelName,
handler: (
e: IpcMainEvent,
...args: Parameters<AsyncIPCFunctions[ChannelName]>
) => ReturnType<AsyncIPCFunctions[ChannelName]>
) {
ipcMain.handle(channel, handler as never)
}

export { addListener, addHandler }
35 changes: 35 additions & 0 deletions src/common/ipc/frontend.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// eslint-disable-next-line no-restricted-imports
import { ipcRenderer, type IpcRendererEvent } from 'electron'

import type { AsyncIPCFunctions, SyncIPCFunctions } from './types'

// Creates a Promise<T> only if T isn't already a promise
type PromiseOnce<T> = T extends Promise<unknown> ? T : Promise<T>

// Returns a function calling an IPC listener created by the backend, accepting
// that listeners parameters
function makeListenerCaller<ChannelName extends keyof SyncIPCFunctions>(
channel: ChannelName
) {
return (...args: Parameters<SyncIPCFunctions[ChannelName]>) =>
ipcRenderer.send(channel, ...args)
}

// Like `makeListenerCaller`, but for IPC handlers instead
function makeHandlerInvoker<ChannelName extends keyof AsyncIPCFunctions>(
channel: ChannelName
) {
return (...args: Parameters<AsyncIPCFunctions[ChannelName]>) =>
ipcRenderer.invoke(channel, ...args) as PromiseOnce<
ReturnType<AsyncIPCFunctions[ChannelName]>
>
}

// Returns a function the Frontend can call to add a listener to this channel
// The listener has to accept the Parameters specified with the Params type
function frontendListenerSlot<Params extends unknown[]>(channel: string) {
return (listener: (e: IpcRendererEvent, ...args: Params) => void) =>
ipcRenderer.on(channel, listener as never)
}

export { makeListenerCaller, makeHandlerInvoker, frontendListenerSlot }
92 changes: 17 additions & 75 deletions src/common/typedefs/ipcBridge.d.ts → src/common/ipc/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { EventEmitter } from 'node:events'
import { IpcMainEvent, OpenDialogOptions, TitleBarOverlay } from 'electron'
import { OpenDialogOptions, TitleBarOverlay } from 'electron'

import {
Runner,
Expand All @@ -21,7 +20,6 @@ import {
StatusPromise,
SaveSyncArgs,
RunWineCommandArgs,
SideloadGame,
WineVersionInfo,
AntiCheatInfo,
RuntimeName,
Expand All @@ -31,10 +29,12 @@ import {
ExtraInfo,
LaunchOption,
DownloadManagerState,
InstallInfo
InstallInfo,
ExecResult,
WikiInfo
} from 'common/types'
import { SelectiveDownload } from 'common/types/legendary'
import { GOGCloudSavesLocation } from 'common/types/gog'
import { GameOverride, SelectiveDownload } from 'common/types/legendary'
import { GOGCloudSavesLocation, UserData } from 'common/types/gog'
import {
NileLoginData,
NileRegisterData,
Expand All @@ -50,8 +50,7 @@ import type { SystemInformation } from 'backend/utils/systeminfo'
* I've decided against that to keep it in line with the `AsyncIPCFunctions`
* interface
*/
// ts-prune-ignore-next
interface SyncIPCFunctions {
export interface SyncIPCFunctions {
setZoomFactor: (zoomFactor: string) => void
changeLanguage: (language: string) => void
notify: (args: { title: string; body: string }) => void
Expand Down Expand Up @@ -85,7 +84,7 @@ interface SyncIPCFunctions {
showItemInFolder: (item: string) => void
clipboardWriteText: (text: string) => void
processShortcut: (combination: string) => void
addNewApp: (args: SideloadGame) => void
addNewApp: (args: GameInfo) => void
showLogFileInFolder: (appNameOrRunner: string) => void
addShortcut: (appName: string, runner: Runner, fromMenu: boolean) => void
removeShortcut: (appName: string, runner: Runner) => void
Expand All @@ -105,9 +104,9 @@ interface SyncIPCFunctions {
unmaximizeWindow: () => void
closeWindow: () => void
setTitleBarOverlay: (options: TitleBarOverlay) => void
winetricksInstall: ({
runner: Runner,
appName: string,
winetricksInstall: (args: {
runner: Runner
appName: string
component: string
}) => void
changeGameVersionPinnedStatus: (
Expand All @@ -117,21 +116,20 @@ interface SyncIPCFunctions {
) => void
}

// ts-prune-ignore-next
interface AsyncIPCFunctions {
export interface AsyncIPCFunctions {
addToDMQueue: (element: DMQueueElement) => Promise<void>
kill: (appName: string, runner: Runner) => Promise<void>
checkDiskSpace: (folder: string) => Promise<DiskSpaceData>
callTool: (args: Tools) => Promise<void>
runWineCommand: (
args: WineCommandArgs
) => Promise<{ stdout: string; stderr: string }>
winetricksInstalled: ({
runner: Runner,
winetricksInstalled: (args: {
runner: Runner
appName: string
}) => Promise<string[]>
winetricksAvailable: ({
runner: Runner,
winetricksAvailable: (args: {
runner: Runner
appName: string
}) => Promise<string[]>
checkGameUpdates: () => Promise<string[]>
Expand Down Expand Up @@ -267,7 +265,7 @@ interface AsyncIPCFunctions {
getDefaultSavePath: (
appName: string,
runner: Runner,
alreadyDefinedGogSaves: GOGCloudSavesLocation[]
alreadyDefinedGogSaves?: GOGCloudSavesLocation[]
) => Promise<string | GOGCloudSavesLocation[]>
isGameAvailable: (args: {
appName: string
Expand Down Expand Up @@ -296,59 +294,3 @@ interface AsyncIPCFunctions {
modsToLoad: string[]
}) => Promise<void>
}

// This is quite ugly & throws a lot of errors in a regular .ts file
// TODO: Find a TS magician who can improve this further
// ts-prune-ignore-next
declare namespace Electron {
class IpcMain extends EventEmitter {
public on: <
Name extends keyof SyncIPCFunctions,
Definition extends SyncIPCFunctions[Name]
>(
name: Name,
callback: (e: IpcMainEvent, ...args: Parameters<Definition>) => void
) => void

public handle: <
Name extends keyof AsyncIPCFunctions,
Definition extends AsyncIPCFunctions[Name]
>(
name: Name,
callback: (
e: IpcMainEvent,
...args: Parameters<Definition>
) => ReturnType<Definition>
) => void
}

class IpcRenderer extends EventEmitter {
public send: <
Name extends keyof SyncIPCFunctions,
Definition extends SyncIPCFunctions[Name]
>(
name: Name,
...args: Parameters<Definition>
) => void

public invoke: <
Name extends keyof AsyncIPCFunctions,
Definition extends AsyncIPCFunctions[Name],
Ret extends ReturnType<Definition>
>(
name: Name,
...args: Parameters<Definition>
) => Ret extends Promise<unknown> ? Ret : Promise<Ret>
}

namespace CrossProcessExports {
const ipcMain: IpcMain
type IpcMain = Electron.IpcMain
const ipcRenderer: IpcRenderer
type IpcRenderer = Electron.IpcRenderer
}
}

declare module 'electron' {
export = Electron.CrossProcessExports
}