From 2b644e1fbb922d4a72e1744ed08be8f1b7de92f8 Mon Sep 17 00:00:00 2001 From: Labhansh Agrawal Date: Sun, 25 Jun 2023 18:56:16 +0530 Subject: [PATCH] strongly typed rpc --- app/rpc.ts | 33 +++++++++---- app/ui/window.ts | 12 ++--- common.d.ts | 96 ++++++++++++++++++++++++++++++++++++++ lib/actions/header.ts | 8 ++-- lib/actions/sessions.ts | 10 +++- lib/actions/term-groups.ts | 4 +- lib/actions/updater.ts | 2 +- lib/utils/rpc.ts | 44 ++++++++++------- 8 files changed, 169 insertions(+), 40 deletions(-) create mode 100644 common.d.ts diff --git a/app/rpc.ts b/app/rpc.ts index dd8661ed..c6831c33 100644 --- a/app/rpc.ts +++ b/app/rpc.ts @@ -1,15 +1,18 @@ import {EventEmitter} from 'events'; import {ipcMain, BrowserWindow} from 'electron'; import {v4 as uuidv4} from 'uuid'; +import {TypedEmitter, MainEvents, RendererEvents, FilterNever} from '../common'; -export class Server extends EventEmitter { +export class Server { + emitter: TypedEmitter; destroyed = false; win: BrowserWindow; id!: string; + constructor(win: BrowserWindow) { - super(); + this.emitter = new EventEmitter(); this.win = win; - this.ipcListener = this.ipcListener.bind(this); + this.emit = this.emit.bind(this); if (this.destroyed) { return; @@ -18,7 +21,6 @@ export class Server extends EventEmitter { const uid = uuidv4(); this.id = uid; - // eslint-disable-next-line @typescript-eslint/unbound-method ipcMain.on(uid, this.ipcListener); // we intentionally subscribe to `on` instead of `once` @@ -33,23 +35,34 @@ export class Server extends EventEmitter { return this.win.webContents; } - ipcListener(event: any, {ev, data}: {ev: string; data: any}) { - super.emit(ev, data); - } + ipcListener = (event: any, {ev, data}: {ev: keyof MainEvents; data: any}) => this.emitter.emit(ev, data); - emit(ch: string, data: any = {}): any { + on = (ev: U, fn: (arg0: MainEvents[U]) => void) => { + this.emitter.on(ev, fn); + return this; + }; + + once = (ev: U, fn: (arg0: MainEvents[U]) => void) => { + this.emitter.once(ev, fn); + return this; + }; + + emit>>(ch: U): boolean; + emit>(ch: U, data: RendererEvents[U]): boolean; + emit(ch: U, data?: RendererEvents[U]) { // This check is needed because data-batching can cause extra data to be // emitted after the window has already closed if (!this.win.isDestroyed()) { this.wc.send(this.id, {ch, data}); + return true; } + return false; } destroy() { - this.removeAllListeners(); + this.emitter.removeAllListeners(); this.wc.removeAllListeners(); if (this.id) { - // eslint-disable-next-line @typescript-eslint/unbound-method ipcMain.removeListener(this.id, this.ipcListener); } else { // mark for `genUid` in constructor diff --git a/app/ui/window.ts b/app/ui/window.ts index 6d3dc639..b263bc80 100644 --- a/app/ui/window.ts +++ b/app/ui/window.ts @@ -206,8 +206,8 @@ export function newWindow( session.resize({cols, rows}); } }); - rpc.on('data', ({uid, data, escaped}: {uid: string; data: string; escaped: boolean}) => { - const session = sessions.get(uid); + rpc.on('data', ({uid, data, escaped}) => { + const session = uid && sessions.get(uid); if (session) { if (escaped) { const escapedData = session.shell?.endsWith('cmd.exe') @@ -255,10 +255,10 @@ export function newWindow( }); // pass on the full screen events from the window to react rpc.win.on('enter-full-screen', () => { - rpc.emit('enter full screen', {}); + rpc.emit('enter full screen'); }); rpc.win.on('leave-full-screen', () => { - rpc.emit('leave full screen', {}); + rpc.emit('leave full screen'); }); const deleteSessions = () => { sessions.forEach((session, key) => { @@ -280,9 +280,9 @@ export function newWindow( const protocol = typeof url === 'string' && new URL(url).protocol; if (protocol === 'file:') { const path = fileURLToPath(url); - return {data: path, escaped: true}; + return {uid: null, data: path, escaped: true}; } else if (protocol === 'http:' || protocol === 'https:') { - return {data: url}; + return {uid: null, data: url}; } }; diff --git a/common.d.ts b/common.d.ts new file mode 100644 index 00000000..5f69d9ba --- /dev/null +++ b/common.d.ts @@ -0,0 +1,96 @@ +import type parseUrl from 'parse-url'; + +export type Session = { + uid: string; + rows: number | null; + cols: number | null; + splitDirection?: 'HORIZONTAL' | 'VERTICAL'; + shell: string | null; + pid: number | null; + activeUid?: string; +}; + +export type sessionExtraOptions = { + cwd: string | undefined; + splitDirection?: 'HORIZONTAL' | 'VERTICAL'; + activeUid?: string | null; + isNewGroup?: boolean; +}; + +export type MainEvents = { + close: never; + command: string; + data: {uid: string | null; data: string; escaped?: boolean | null}; + exit: {uid: string}; + 'info renderer': {uid: string; type: string}; + init: null; + maximize: never; + minimize: never; + new: sessionExtraOptions; + 'open context menu': string; + 'open external': {url: string}; + 'open hamburger menu': {x: number; y: number}; + 'quit and install': never; + resize: {uid: string; cols: number; rows: number}; + unmaximize: never; +}; + +export type RendererEvents = { + ready: never; + 'add notification': {text: string; url: string; dismissable: boolean}; + 'update available': {releaseNotes: string; releaseName: string; releaseUrl: string; canInstall: boolean}; + 'open ssh': ReturnType; + 'open file': {path: string}; + 'move jump req': number | 'last'; + 'reset fontSize req': never; + 'move left req': never; + 'move right req': never; + 'prev pane req': never; + 'decrease fontSize req': never; + 'increase fontSize req': never; + 'next pane req': never; + 'session break req': never; + 'session quit req': never; + 'session search close': never; + 'session search': never; + 'session stop req': never; + 'session tmux req': never; + 'session del line beginning req': never; + 'session del line end req': never; + 'session del word left req': never; + 'session del word right req': never; + 'session move line beginning req': never; + 'session move line end req': never; + 'session move word left req': never; + 'session move word right req': never; + 'term selectAll': never; + reload: never; + 'session clear req': never; + 'split request horizontal': {activeUid?: string}; + 'split request vertical': {activeUid?: string}; + 'termgroup add req': {activeUid?: string}; + 'termgroup close req': never; + 'session add': Session; + 'session data': string; + 'session exit': {uid: string}; + 'windowGeometry change': {isMaximized: boolean}; + move: {bounds: {x: number; y: number}}; + 'enter full screen': never; + 'leave full screen': never; + 'session data send': {uid: string | null; data: string; escaped?: boolean}; +}; + +/** + * Get keys of T where the value is not never + */ +export type FilterNever = {[K in keyof T]: T[K] extends never ? never : K}[keyof T]; + +export interface TypedEmitter { + on(event: E, listener: (args: Events[E]) => void): this; + once(event: E, listener: (args: Events[E]) => void): this; + emit>>(event: E): boolean; + emit>(event: E, data: Events[E]): boolean; + emit(event: E, data?: Events[E]): boolean; + removeListener(event: E, listener: (args: Events[E]) => void): this; + removeAllListeners(event?: E): this; +} diff --git a/lib/actions/header.ts b/lib/actions/header.ts index 9cc52cef..a13eb50b 100644 --- a/lib/actions/header.ts +++ b/lib/actions/header.ts @@ -39,7 +39,7 @@ export function maximize() { dispatch({ type: UI_WINDOW_MAXIMIZE, effect() { - rpc.emit('maximize', null); + rpc.emit('maximize'); } }); }; @@ -50,7 +50,7 @@ export function unmaximize() { dispatch({ type: UI_WINDOW_UNMAXIMIZE, effect() { - rpc.emit('unmaximize', null); + rpc.emit('unmaximize'); } }); }; @@ -72,7 +72,7 @@ export function minimize() { dispatch({ type: UI_WINDOW_MINIMIZE, effect() { - rpc.emit('minimize', null); + rpc.emit('minimize'); } }); }; @@ -83,7 +83,7 @@ export function close() { dispatch({ type: UI_WINDOW_CLOSE, effect() { - rpc.emit('close', null); + rpc.emit('close'); } }); }; diff --git a/lib/actions/sessions.ts b/lib/actions/sessions.ts index 92b07eb1..149e6ed7 100644 --- a/lib/actions/sessions.ts +++ b/lib/actions/sessions.ts @@ -17,7 +17,15 @@ import { } from '../constants/sessions'; import {HyperState, session, HyperDispatch, HyperActions} from '../hyper'; -export function addSession({uid, shell, pid, cols, rows, splitDirection, activeUid}: session) { +export function addSession({ + uid, + shell, + pid, + cols, + rows, + splitDirection, + activeUid +}: Pick) { return (dispatch: HyperDispatch, getState: () => HyperState) => { const {sessions} = getState(); const now = Date.now(); diff --git a/lib/actions/term-groups.ts b/lib/actions/term-groups.ts index e8b1ca67..a519b4a3 100644 --- a/lib/actions/term-groups.ts +++ b/lib/actions/term-groups.ts @@ -13,7 +13,7 @@ import {setActiveSession, ptyExitSession, userExitSession} from './sessions'; import {ITermState, ITermGroup, HyperState, HyperDispatch, HyperActions} from '../hyper'; function requestSplit(direction: 'VERTICAL' | 'HORIZONTAL') { - return (activeUid: string) => + return (activeUid: string | undefined) => (dispatch: HyperDispatch, getState: () => HyperState): void => { dispatch({ type: SESSION_REQUEST, @@ -40,7 +40,7 @@ export function resizeTermGroup(uid: string, sizes: number[]): HyperActions { }; } -export function requestTermGroup(activeUid: string) { +export function requestTermGroup(activeUid: string | undefined) { return (dispatch: HyperDispatch, getState: () => HyperState) => { dispatch({ type: TERM_GROUP_REQUEST, diff --git a/lib/actions/updater.ts b/lib/actions/updater.ts index 17eef45b..a69da693 100644 --- a/lib/actions/updater.ts +++ b/lib/actions/updater.ts @@ -6,7 +6,7 @@ export function installUpdate(): HyperActions { return { type: UPDATE_INSTALL, effect: () => { - rpc.emit('quit and install', null); + rpc.emit('quit and install'); } }; } diff --git a/lib/utils/rpc.ts b/lib/utils/rpc.ts index a1a44a86..40de6958 100644 --- a/lib/utils/rpc.ts +++ b/lib/utils/rpc.ts @@ -1,13 +1,17 @@ import {EventEmitter} from 'events'; import {IpcRenderer, IpcRendererEvent} from 'electron'; import electron from 'electron'; +import {FilterNever, MainEvents, RendererEvents, TypedEmitter} from '../../common'; + export default class Client { - emitter: EventEmitter; + emitter: TypedEmitter; ipc: IpcRenderer; id!: string; + constructor() { this.emitter = new EventEmitter(); this.ipc = electron.ipcRenderer; + this.emit = this.emit.bind(this); if (window.__rpcId) { setTimeout(() => { this.id = window.__rpcId; @@ -27,35 +31,43 @@ export default class Client { } } - ipcListener = (event: any, {ch, data}: {ch: string; data: any}) => { - this.emitter.emit(ch, data); + ipcListener = ( + event: IpcRendererEvent, + {ch, data}: {ch: U; data: RendererEvents[U]} + ) => this.emitter.emit(ch, data); + + on = (ev: U, fn: (arg0: RendererEvents[U]) => void) => { + this.emitter.on(ev, fn); + return this; }; - on(ev: string, fn: (...args: any[]) => void) { - this.emitter.on(ev, fn); - } - - once(ev: string, fn: (...args: any[]) => void) { + once = (ev: U, fn: (arg0: RendererEvents[U]) => void) => { this.emitter.once(ev, fn); - } + return this; + }; - emit(ev: string, data: any) { + emit>>(ch: U): boolean; + emit>(ch: U, data: MainEvents[U]): boolean; + emit(ev: U, data?: MainEvents[U]) { if (!this.id) { throw new Error('Not ready'); } this.ipc.send(this.id, {ev, data}); + return true; } - removeListener(ev: string, fn: (...args: any[]) => void) { + removeListener = (ev: U, fn: (arg0: RendererEvents[U]) => void) => { this.emitter.removeListener(ev, fn); - } + return this; + }; - removeAllListeners() { + removeAllListeners = () => { this.emitter.removeAllListeners(); - } + return this; + }; - destroy() { + destroy = () => { this.removeAllListeners(); this.ipc.removeAllListeners(this.id); - } + }; }