strongly typed rpc

This commit is contained in:
Labhansh Agrawal 2023-06-25 18:56:16 +05:30
parent e31d72cc31
commit 2b644e1fbb
8 changed files with 169 additions and 40 deletions

View file

@ -1,15 +1,18 @@
import {EventEmitter} from 'events'; import {EventEmitter} from 'events';
import {ipcMain, BrowserWindow} from 'electron'; import {ipcMain, BrowserWindow} from 'electron';
import {v4 as uuidv4} from 'uuid'; import {v4 as uuidv4} from 'uuid';
import {TypedEmitter, MainEvents, RendererEvents, FilterNever} from '../common';
export class Server extends EventEmitter { export class Server {
emitter: TypedEmitter<MainEvents>;
destroyed = false; destroyed = false;
win: BrowserWindow; win: BrowserWindow;
id!: string; id!: string;
constructor(win: BrowserWindow) { constructor(win: BrowserWindow) {
super(); this.emitter = new EventEmitter();
this.win = win; this.win = win;
this.ipcListener = this.ipcListener.bind(this); this.emit = this.emit.bind(this);
if (this.destroyed) { if (this.destroyed) {
return; return;
@ -18,7 +21,6 @@ export class Server extends EventEmitter {
const uid = uuidv4(); const uid = uuidv4();
this.id = uid; this.id = uid;
// eslint-disable-next-line @typescript-eslint/unbound-method
ipcMain.on(uid, this.ipcListener); ipcMain.on(uid, this.ipcListener);
// we intentionally subscribe to `on` instead of `once` // we intentionally subscribe to `on` instead of `once`
@ -33,23 +35,34 @@ export class Server extends EventEmitter {
return this.win.webContents; return this.win.webContents;
} }
ipcListener(event: any, {ev, data}: {ev: string; data: any}) { ipcListener = (event: any, {ev, data}: {ev: keyof MainEvents; data: any}) => this.emitter.emit(ev, data);
super.emit(ev, data);
}
emit(ch: string, data: any = {}): any { on = <U extends keyof MainEvents>(ev: U, fn: (arg0: MainEvents[U]) => void) => {
this.emitter.on(ev, fn);
return this;
};
once = <U extends keyof MainEvents>(ev: U, fn: (arg0: MainEvents[U]) => void) => {
this.emitter.once(ev, fn);
return this;
};
emit<U extends Exclude<keyof RendererEvents, FilterNever<RendererEvents>>>(ch: U): boolean;
emit<U extends FilterNever<RendererEvents>>(ch: U, data: RendererEvents[U]): boolean;
emit<U extends keyof RendererEvents>(ch: U, data?: RendererEvents[U]) {
// This check is needed because data-batching can cause extra data to be // This check is needed because data-batching can cause extra data to be
// emitted after the window has already closed // emitted after the window has already closed
if (!this.win.isDestroyed()) { if (!this.win.isDestroyed()) {
this.wc.send(this.id, {ch, data}); this.wc.send(this.id, {ch, data});
return true;
} }
return false;
} }
destroy() { destroy() {
this.removeAllListeners(); this.emitter.removeAllListeners();
this.wc.removeAllListeners(); this.wc.removeAllListeners();
if (this.id) { if (this.id) {
// eslint-disable-next-line @typescript-eslint/unbound-method
ipcMain.removeListener(this.id, this.ipcListener); ipcMain.removeListener(this.id, this.ipcListener);
} else { } else {
// mark for `genUid` in constructor // mark for `genUid` in constructor

View file

@ -206,8 +206,8 @@ export function newWindow(
session.resize({cols, rows}); session.resize({cols, rows});
} }
}); });
rpc.on('data', ({uid, data, escaped}: {uid: string; data: string; escaped: boolean}) => { rpc.on('data', ({uid, data, escaped}) => {
const session = sessions.get(uid); const session = uid && sessions.get(uid);
if (session) { if (session) {
if (escaped) { if (escaped) {
const escapedData = session.shell?.endsWith('cmd.exe') 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 // pass on the full screen events from the window to react
rpc.win.on('enter-full-screen', () => { rpc.win.on('enter-full-screen', () => {
rpc.emit('enter full screen', {}); rpc.emit('enter full screen');
}); });
rpc.win.on('leave-full-screen', () => { rpc.win.on('leave-full-screen', () => {
rpc.emit('leave full screen', {}); rpc.emit('leave full screen');
}); });
const deleteSessions = () => { const deleteSessions = () => {
sessions.forEach((session, key) => { sessions.forEach((session, key) => {
@ -280,9 +280,9 @@ export function newWindow(
const protocol = typeof url === 'string' && new URL(url).protocol; const protocol = typeof url === 'string' && new URL(url).protocol;
if (protocol === 'file:') { if (protocol === 'file:') {
const path = fileURLToPath(url); const path = fileURLToPath(url);
return {data: path, escaped: true}; return {uid: null, data: path, escaped: true};
} else if (protocol === 'http:' || protocol === 'https:') { } else if (protocol === 'http:' || protocol === 'https:') {
return {data: url}; return {uid: null, data: url};
} }
}; };

96
common.d.ts vendored Normal file
View file

@ -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<typeof parseUrl>;
'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<T> = {[K in keyof T]: T[K] extends never ? never : K}[keyof T];
export interface TypedEmitter<Events> {
on<E extends keyof Events>(event: E, listener: (args: Events[E]) => void): this;
once<E extends keyof Events>(event: E, listener: (args: Events[E]) => void): this;
emit<E extends Exclude<keyof Events, FilterNever<Events>>>(event: E): boolean;
emit<E extends FilterNever<Events>>(event: E, data: Events[E]): boolean;
emit<E extends keyof Events>(event: E, data?: Events[E]): boolean;
removeListener<E extends keyof Events>(event: E, listener: (args: Events[E]) => void): this;
removeAllListeners<E extends keyof Events>(event?: E): this;
}

View file

@ -39,7 +39,7 @@ export function maximize() {
dispatch({ dispatch({
type: UI_WINDOW_MAXIMIZE, type: UI_WINDOW_MAXIMIZE,
effect() { effect() {
rpc.emit('maximize', null); rpc.emit('maximize');
} }
}); });
}; };
@ -50,7 +50,7 @@ export function unmaximize() {
dispatch({ dispatch({
type: UI_WINDOW_UNMAXIMIZE, type: UI_WINDOW_UNMAXIMIZE,
effect() { effect() {
rpc.emit('unmaximize', null); rpc.emit('unmaximize');
} }
}); });
}; };
@ -72,7 +72,7 @@ export function minimize() {
dispatch({ dispatch({
type: UI_WINDOW_MINIMIZE, type: UI_WINDOW_MINIMIZE,
effect() { effect() {
rpc.emit('minimize', null); rpc.emit('minimize');
} }
}); });
}; };
@ -83,7 +83,7 @@ export function close() {
dispatch({ dispatch({
type: UI_WINDOW_CLOSE, type: UI_WINDOW_CLOSE,
effect() { effect() {
rpc.emit('close', null); rpc.emit('close');
} }
}); });
}; };

View file

@ -17,7 +17,15 @@ import {
} from '../constants/sessions'; } from '../constants/sessions';
import {HyperState, session, HyperDispatch, HyperActions} from '../hyper'; 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<session, 'uid' | 'shell' | 'pid' | 'cols' | 'rows' | 'splitDirection' | 'activeUid'>) {
return (dispatch: HyperDispatch, getState: () => HyperState) => { return (dispatch: HyperDispatch, getState: () => HyperState) => {
const {sessions} = getState(); const {sessions} = getState();
const now = Date.now(); const now = Date.now();

View file

@ -13,7 +13,7 @@ import {setActiveSession, ptyExitSession, userExitSession} from './sessions';
import {ITermState, ITermGroup, HyperState, HyperDispatch, HyperActions} from '../hyper'; import {ITermState, ITermGroup, HyperState, HyperDispatch, HyperActions} from '../hyper';
function requestSplit(direction: 'VERTICAL' | 'HORIZONTAL') { function requestSplit(direction: 'VERTICAL' | 'HORIZONTAL') {
return (activeUid: string) => return (activeUid: string | undefined) =>
(dispatch: HyperDispatch, getState: () => HyperState): void => { (dispatch: HyperDispatch, getState: () => HyperState): void => {
dispatch({ dispatch({
type: SESSION_REQUEST, 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) => { return (dispatch: HyperDispatch, getState: () => HyperState) => {
dispatch({ dispatch({
type: TERM_GROUP_REQUEST, type: TERM_GROUP_REQUEST,

View file

@ -6,7 +6,7 @@ export function installUpdate(): HyperActions {
return { return {
type: UPDATE_INSTALL, type: UPDATE_INSTALL,
effect: () => { effect: () => {
rpc.emit('quit and install', null); rpc.emit('quit and install');
} }
}; };
} }

View file

@ -1,13 +1,17 @@
import {EventEmitter} from 'events'; import {EventEmitter} from 'events';
import {IpcRenderer, IpcRendererEvent} from 'electron'; import {IpcRenderer, IpcRendererEvent} from 'electron';
import electron from 'electron'; import electron from 'electron';
import {FilterNever, MainEvents, RendererEvents, TypedEmitter} from '../../common';
export default class Client { export default class Client {
emitter: EventEmitter; emitter: TypedEmitter<RendererEvents>;
ipc: IpcRenderer; ipc: IpcRenderer;
id!: string; id!: string;
constructor() { constructor() {
this.emitter = new EventEmitter(); this.emitter = new EventEmitter();
this.ipc = electron.ipcRenderer; this.ipc = electron.ipcRenderer;
this.emit = this.emit.bind(this);
if (window.__rpcId) { if (window.__rpcId) {
setTimeout(() => { setTimeout(() => {
this.id = window.__rpcId; this.id = window.__rpcId;
@ -27,35 +31,43 @@ export default class Client {
} }
} }
ipcListener = (event: any, {ch, data}: {ch: string; data: any}) => { ipcListener = <U extends keyof RendererEvents>(
this.emitter.emit(ch, data); event: IpcRendererEvent,
{ch, data}: {ch: U; data: RendererEvents[U]}
) => this.emitter.emit(ch, data);
on = <U extends keyof RendererEvents>(ev: U, fn: (arg0: RendererEvents[U]) => void) => {
this.emitter.on(ev, fn);
return this;
}; };
on(ev: string, fn: (...args: any[]) => void) { once = <U extends keyof RendererEvents>(ev: U, fn: (arg0: RendererEvents[U]) => void) => {
this.emitter.on(ev, fn);
}
once(ev: string, fn: (...args: any[]) => void) {
this.emitter.once(ev, fn); this.emitter.once(ev, fn);
} return this;
};
emit(ev: string, data: any) { emit<U extends Exclude<keyof MainEvents, FilterNever<MainEvents>>>(ch: U): boolean;
emit<U extends FilterNever<MainEvents>>(ch: U, data: MainEvents[U]): boolean;
emit<U extends keyof MainEvents>(ev: U, data?: MainEvents[U]) {
if (!this.id) { if (!this.id) {
throw new Error('Not ready'); throw new Error('Not ready');
} }
this.ipc.send(this.id, {ev, data}); this.ipc.send(this.id, {ev, data});
return true;
} }
removeListener(ev: string, fn: (...args: any[]) => void) { removeListener = <U extends keyof RendererEvents>(ev: U, fn: (arg0: RendererEvents[U]) => void) => {
this.emitter.removeListener(ev, fn); this.emitter.removeListener(ev, fn);
} return this;
};
removeAllListeners() { removeAllListeners = () => {
this.emitter.removeAllListeners(); this.emitter.removeAllListeners();
} return this;
};
destroy() { destroy = () => {
this.removeAllListeners(); this.removeAllListeners();
this.ipc.removeAllListeners(this.id); this.ipc.removeAllListeners(this.id);
} };
} }