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 {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<MainEvents>;
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 = <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
// 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

View file

@ -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};
}
};

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({
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');
}
});
};

View file

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

View file

@ -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,

View file

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

View file

@ -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<RendererEvents>;
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 = <U extends keyof RendererEvents>(
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) {
this.emitter.on(ev, fn);
}
once(ev: string, fn: (...args: any[]) => void) {
once = <U extends keyof RendererEvents>(ev: U, fn: (arg0: RendererEvents[U]) => void) => {
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) {
throw new Error('Not ready');
}
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);
}
return this;
};
removeAllListeners() {
removeAllListeners = () => {
this.emitter.removeAllListeners();
}
return this;
};
destroy() {
destroy = () => {
this.removeAllListeners();
this.ipc.removeAllListeners(this.id);
}
};
}