0%

Electron 開發框架結合Angular與TypeScript

簡介 Intro

來介紹一下如何使用Electron開發框架,並且結合TypeScript與Angular設定

並且最終使用Electron-Forge打包成桌面應用程式。

  1. Type-Safe IPC: Eliminates runtime errors by enforcing a strict contract for IPC channels, payloads, and return types between the main process and renderer processes.
  2. Simplified Utility Process Management: Provides a straightforward wrapper for creating, communicating with, and managing Electron’s utilityProcess.
  3. Angular-Friendly Service: Includes an example of an injectable Angular service (IPCRendererService) that wraps IPC calls in an RxJS-friendly way.

如果你懶得看文章,也可以直接參考範例專案

前置準備

在真正開始之前,需要先確保相關的開發環境準備

  1. TypeScript
  2. Node.js
  3. Electron
  4. Electron-Forge
  5. ESLint

如果你是在windows環境下,則建議還要多準備以下幾個

  1. Wix Toolset
  2. Visual Studio Build Tools

章節介紹

  1. 資料結構宣告
    1. 文件結構
    2. 共用參考結構
    3. Electron結構宣告
    4. Angular結構宣告
    5. Angular Service封裝
  2. 不同的設定方法差別
    1. 使用git submodule維護前端與後端共同結構參考 (推薦)
    2. 使前端後端其中一方,屬於對方的文件結構之下 (稍微不推薦)
    3. 使用tsconfig.jsonpathsreferences來維護前端與後端的共同結構參考 (不推薦)
  3. 使用Electron-Forge打包成桌面應用程式

資料結構的宣告

文件結構

根據不同的做法,會有不同的文件結構

  1. 使用git submodule維護前端與後端共同結構參考 (推薦)
  2. 使前端後端其中一方,屬於對方的文件結構之下 (稍微不推薦)
  3. 使用tsconfig.jsonpathsreferences來維護前端與後端的共同結構參考 (不推薦)

我自己就只先示範第一種

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
[獨立的共享宣告結構專案]
shared-declare-types/ (git repository)
├── ipc.ts
├── ipc-valid-channel.ts
├── ipc-routes.declare.ts
└── data.ts

=========================================================
[主專案,引用共享宣告結構專案]
project-root/ (git repository)
├── electron-src/
| ├── declares/
| | ├── ipc-main-overwrite.declare.ts
| | ├── app-router.ts
│ | └── electron-ipc-route.declare.ts
│ ├── app.ts
│ ├── ipc-routes.ts
│ └── preload.ts
│ └── shared-declare-types/ (git submodule -> shared-declare-types)
│ ├── ipc.ts
| ├── ipc-routes.declare.ts
│ ├── ipc-valid-channel.ts
│ └── data.ts
├── angular-app/
│ ├── app/
| ├── @services/
│ │ ├── ipc-renderer.service.ts
│ │ └── startup_test.service.ts
│ └── shared-declare-types/ (git submodule -> shared-declare-types)
│ ├── ipc.ts
│ ├── ipc-valid-channel.ts
| ├── ipc-routes.declare.ts
│ └── data.ts
├── forge.config.ts
├── tsconfig.json
└── package.json

共同參考結構

首先我們需要先宣告一個結構,這個結構是假裝前後端的TS都需要共同參考的結構

Example: shared-declare-types/data.ts

1
2
3
4
5
6
export interface Data<T extends string> {
key: string;
value: string;
timestamp: number;
description?: T; // 可選屬性
}

接著需要定義IPC共用通道宣告

Example: shared-declare-types/ipc-valid-channel.ts

1
2
3
4
5
6
export interface IPCValidChannelMap {
startup_test: "startup_test";
}

export type IPCValidChannel = keyof IPCValidChannelMap;

定義Electron IPC接口的宣告

Example: shared-declare-types/ipc.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { IpcMain, IpcMainEvent, IpcMainInvokeEvent } from "electron";

export type IPCValidChannel<T> = T extends string ? T : never;

export interface IPCRenderer<T> {
send<R = never | undefined>(channel: IPCValidChannel<T>): void;
send<R extends any[]>(channel: IPCValidChannel<T>, ...args: R): void;
receive<K>(channel: IPCValidChannel<T>, func: (args: K) => void): void;
invoke<P extends any[], R>(channel: IPCValidChannel<T>, ...args: P): Promise<R>;
removeAllListeners(channel: IPCValidChannel<T>): void;
}

export interface IPCRendererReceiveMessageHandler {
(...args: any[]): void;
}

export interface IPCMessageHandler<T extends IpcMainEvent = IpcMainEvent> {
(event: T, ...args: any[]): void;
}

export type Params<H, G extends IpcMainEvent = IpcMainEvent> = H extends (ev: G, ...args: infer P) => any ? P : never;

定義Electron IPC路由簽章

Example: shared-declare-types/ipc-routes.declare.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import {
IpcMainEvent, // 如果這個交互是單向的 (也就是event.reply不是一對一回應,可能不回應,或是像stream一樣多次回應),則使用 IpcMainEvent
IpcMainInvokeEvent // 如果這個交互是雙向的 (也就是event.reply是有回應的,一問一答),則使用 IpcMainInvokeEvent
} from "electron";
import { Data } from "./shared-declare-types/data";

// A simple fire-and-forget style handler
export interface startup_test<T extends IpcMainInvokeEvent> {
(_ev: T, ...args: any[]): string;
}

// A handler that takes a typed payload and returns a Promise
export interface ipc_message_wtih_args<T extends IpcMainEvent, U extends Data<string>> {
(_ev: T, desc: U): Promise<boolean>;
}

這些參考分別會被Electron的Preload與Angular的Service共同參考


Electron結構宣告

在Electron的主進程中宣告相關定義

定義Electron IPC 被複寫

Example: electron-src/decalres/ipc-main-overwrite.declare.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
/**
* @description
*
* The main purpose of multi-layer encapsulation here is to allow the front-end and back-end IPC communication to use the same interface definition at the same time.
*
* 這邊經過多層封裝最主要的目的是為了可以同時讓前後端的 IPC 通訊可以使用相同的 interface 定義
*/
export class IPCMainOverwrite {

private _ipc: IpcMain;

constructor(ipc: IpcMain) {
this._ipc = ipc;
}

private ipcSocket<T, R = IpcMainEvent>(channel: IPCValidChannel, callback: (channel: string, _ev: R, ...args: any[]) => T, _ev: R, ...args: any[]): T {
return callback(channel, _ev, ...args);
}

public handle<R>(channel: IPCValidChannel, callback: <T extends IpcMainInvokeEvent>(_ev: T, ...args: any[]) => R | Promise<R>): void {
this._ipc.handle(channel, callback);
}

public on<R>(channel: IPCValidChannel, callback: <T extends IpcMainEvent>(channel: string, _ev: T, ...args: any[]) => R): void {
this._ipc.on(channel, (event, ...args) => {
const result = this.ipcSocket<ReturnType<typeof callback>>(channel, callback, event, ...args);
Promise.resolve(result).then((result) => {
event.reply(channel, result);
}).catch((error) => {
console.log(error);
});
});
}
}

宣告IPC路由的簽章,並組合路由

為每個 IPC 通道定義特定的輸入和輸出。

這為每個通道的功能創建了明確的宣告。

將所有路由簽章合併到一個主介面。此介面代表了 IPC 系統的整個 API 介面。

Example: electron-src/decalres/electron-ipc-route.declare.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import {
IpcMainEvent, // 如果這個交互是單向的 (也就是event.reply不是一對一回應,可能不回應,或是像stream一樣多次回應),則使用 IpcMainEvent
IpcMainInvokeEvent // 如果這個交互是雙向的 (也就是event.reply是有回應的,一問一答),則使用 IpcMainInvokeEvent
} from "electron";

import {
startup_test,
ipc_message_wtih_args
} from "electron-src/shared-declare-types/ipc-routes.declare"

import { Data } from "./shared-declare-types/data";

export interface ElectronIpcMainRouter {
startup_test: startup_test<IpcMainInvokeEvent>;
ipc_message_wtih_args: ipc_message_wtih_args<IpcMainEvent, Data<string>>;
}

實作AppMainRouter

使用 ElectronIpcMainRouter 介面來實作你的路由器。
這個路由器將會處理 IPC 的請求。

Example: electron-src/declares/app-router.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { ElectronIpcMainRouter } from "electron-src/declares/electron-ipc-route.declare"; // Adjust path as needed

export class AppRouter implements ElectronIpcMainRouter {

public static create_router(): AppRouter {
return new AppRouter();
}

private constructor() {

}

startup_test(_ev: IpcMainInvokeEvent): string {
return "Pong";
}

ipc_message_wtih_args(_ev: IpcMainEvent, desc: Data<string>): Promise<boolean> {
return Promise.resolve(true);
}
}

在Electron主程式中的宣告

在 Electron 主行程檔案中,引用 AppRouter 並實作 IPC 路由。

並且使用 IPCMainOverwrite 來處理 IPC 的請求。

這樣可以確保你的 IPC 路由是類型安全的,並且符合你在 ElectronIpcMainRouter 中定義的介面。

TypeScript 會強制你的實作(函數體)與路由器介面的簽章相符。

Example: electron-src/ipc-routes.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import { IpcMain, IpcMainEvent, IpcMainInvokeEvent } from "electron";
import { IPCMainOverwrite } from "electron-src/declares/ipc-main-overwrite.declare.ts"; // Adjust path as needed
import { AppRouter } from "electron-src/declares/app-router"; // Adjust path as needed

export class IPCRoute {
private ipc: IPCMainOverwrite;

private router: AppRouter;

public static create_ipc_router(ipc: IpcMain): IPCRoute {
const router = AppRouter.create_router();
return new IPCRoute(ipc, router);
}

private constructor(_parent_ipc: IpcMain, router: AppRouter) {
this.ipc = new IPCMainOverwrite(_parent_ipc);
this.router = router;

// 註冊IPC路由
this.ipc.handle("startup_test", router.startup_test);
}

public handle(channel: IPCValidChannel, callback: <T extends IpcMainInvokeEvent>(_ev: T, ...args: any[]) => any): void {
this.ipc.handle(channel, callback);
}

public on(channel: IPCValidChannel, callback: <T extends IpcMainEvent>(channel: string, _ev: T, ...args: any[]) => any): void {
this.ipc.on(channel, callback);
}

}

在Electron Entry Point中使用IPCRoute

Example: electron-src/app.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import {
app,
BrowserWindow,
ipcMain,
Menu,
dialog,
nativeImage,
NativeImage,
screen
} from "electron";

import {
IPCRoute
} from "./ipc-routes";

// 必須在這裡建立IPC路由器,因為這是Electron的主進程入口點
// 你可能會呼叫`ipc_route.on`或`ipc_route.handle`來註冊額外的IPC通道,如果都沒有,也仍然必須建立一個實例
const ipc_route = IPCRoute.create_ipc_router(ipcMain);

// 以下省略


在Electron主程式中使用Preload腳本

Example: electron-src/preload.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
// See the Electron documentation for details on how to use preload scripts:
// https://www.electronjs.org/docs/latest/tutorial/process-model#preload-scripts

import { contextBridge, ipcRenderer } from "electron";

import {
IPCRenderer
} from "electron-src/shared-declare-types/ipc";

import {
IPCValidChannel
} from "electron-src/shared-declare-types/ipc-valid-channel";

ipcRenderer.setMaxListeners(Infinity);

/**
* 這裡必須要手動一個一個定義,因exposeInMainWorld在預設情況下,如果有參考外部的資料,會被視為不安全,進而被阻擋。
*/
const valid_IPC_channels: Array<IPCValidChannel> = [
"startup_test"
];

contextBridge.exposeInMainWorld(
"ipcRenderer",
<IPCRenderer<IPCValidChannel>>{
send: <R extends any[]>(channel: IPCValidChannel, ...args: R): void => {
if (valid_IPC_channels.includes(channel)) {
ipcRenderer.send(channel, ...args);
} else {
console.error(`[ipcRenderer.send]Invalid IPC channel: ${channel}`);
}
},
// eslint-disable-next-line @typescript-eslint/ban-types
receive: <U>(channel: IPCValidChannel, func: (args: U) => void): void => {
if (valid_IPC_channels.includes(channel)) {
ipcRenderer.on(channel, (event, args: U) => func(args));
} else {
console.error(`[ipcRenderer.receive]Invalid IPC channel: ${channel}`);
}
},
// eslint-disable-next-line arrow-body-style
invoke: <P extends any[], R>(channel: IPCValidChannel, ...args: P): Promise<R> => {
if (valid_IPC_channels.includes(channel)) {
return ipcRenderer.invoke(channel, ...args) as Promise<R>;
}
console.error(`[ipcRenderer.invoke]Invalid IPC channel: ${channel}`);
return Promise.reject(new Error(`[ipcRenderer.invoke]Invalid IPC channel: ${channel}`));
},
removeAllListeners: (channel: IPCValidChannel): void => {
if (valid_IPC_channels.includes(channel)) {
ipcRenderer.removeAllListeners(channel);
} else {
console.error(`[ipcRenderer.removeAllListeners]Invalid IPC channel: ${channel}`);
}
}
}
);

Angular結構宣告

接著輪到Angular的部分,會簡單的多

將Electron的IPC Renderer封裝成Angular Service即可

Angular IpcRenderer Service

Example: angular-app/app/@services/ipc-renderer.service.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
/* eslint-disable arrow-body-style */
import { Injectable, OnInit, Inject, Optional } from "@angular/core";
import { switchMap, distinctUntilChanged, catchError, map } from "rxjs/operators";
import { forkJoin, of, takeUntil, ReplaySubject, Observable, lastValueFrom, from, mergeMap, share } from "rxjs";

import { IPCRenderer, Params } from "angular-app/app/shared-declare-types/ipc";
import { IPCValidChannel } from "angular-app/app/shared-declare-types/ipc-valid-channel";


// 定義全域的Window介面,讓TypeScript知道有ipcRenderer這個屬性
declare global {
interface Window {
ipcRenderer: IPCRenderer<IPCValidChannel>;
}
}

@Injectable({
providedIn: "root"
})

export class IPCRendererService {

private _destroyed$: ReplaySubject<boolean> = new ReplaySubject(1);

private readonly receive_map: Map<IPCValidChannel, Observable<any>> = new Map<IPCValidChannel, Observable<any>>();

constructor() {
console.info("[IPCRendererService.constructor]IPCRendererService constructed.");
}

public send<T=never|undefined>(channel: IPCValidChannel): void;
public send<T extends any[]>(channel: IPCValidChannel, ...data: T): void;
public send<T extends any[]>(...args: T): void {
// eslint-disable-next-line no-undef
if (window.ipcRenderer && window.ipcRenderer.send) {
if (args.length === 1) {
// eslint-disable-next-line no-undef
window.ipcRenderer.send<T>(args[0]);
} else if (args.length > 1) {
// eslint-disable-next-line no-undef
window.ipcRenderer.send<Array<any>>(args[0], ...args.slice(1));
} else {
console.error(`[IPCRendererService.send]Invalid IPC channel: ${args[0]}`);
}
} else {
console.error(`[IPCRendererService.send]Invalid IPC channel: ${args[0]}`);
}
}

public invoke<P extends any[], R>(channel: IPCValidChannel, ...args: P): Promise<R> {
return new Promise((resolve, reject) => {
// eslint-disable-next-line no-undef
if (window.ipcRenderer && window.ipcRenderer.invoke) {
// eslint-disable-next-line no-undef
window.ipcRenderer.invoke<P, R>(channel, ...args).then((data: R) => {
resolve(data);
}).catch((error: Error) => {
reject(error);
});
} else {
console.error(`[IPCRendererService.invoke]Invalid IPC channel: ${channel}`);
return reject(new Error(`[IPCRendererService.invoke]Invalid IPC channel: ${channel}`));
}
});
}

public receive<T>(channel: IPCValidChannel): Observable<T> {
if (this.receive_map.has(channel)) {
return this.receive_map.get(channel) as Observable<T>;
}
const observable = (new Observable((subscriber) => {
// eslint-disable-next-line no-undef
if (window.ipcRenderer && window.ipcRenderer.receive) {
// eslint-disable-next-line no-undef
window.ipcRenderer.receive<T>(channel, (data) => {
subscriber.next(data);
});
} else {
console.error(`[IPCRendererService.receive]Invalid IPC channel: ${channel}`);
subscriber.error(new Error(`[IPCRendererService.receive]Invalid IPC channel: ${channel}`));
}
})).pipe<T>(share<any>());



this.receive_map.set(channel, observable);

observable.subscribe((data) => {
});

return observable;
}
}

可能有的人會發現,為什麼並沒有自動推論型別,這是因為需要在範型中加入參數,進行推論,比如你封裝startup_test這個通道

封裝startup_test通道

Example: angular-app/app/@services/startup_test.service.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/* eslint-disable arrow-body-style */
import { Injectable, OnInit, Inject, Optional } from "@angular/core";
import { switchMap, distinctUntilChanged, catchError, map } from "rxjs/operators";
import { forkJoin, of, takeUntil, ReplaySubject, Observable, lastValueFrom, from, mergeMap, share } from "rxjs";

import { IPCRenderer, Params } from "angular-app/app/shared-declare-types/ipc.model";
import { IPCValidChannel } from "angular-app/app/shared-declare-types/ipc-valid-channel";

import { IPCRendererService } from "./ipc-renderer.service";

import {
startup_test
} from "angular-app/shared-declare-types/ipc-routes.declare";

@Injectable({
providedIn: "root"
})

export class SomeService {

private _destroyed$: ReplaySubject<boolean> = new ReplaySubject(1);


constructor(private ipcRenderer: IPCRendererService) {
}

public ping() {
return from(this.ipcRenderer.invoke<Params<startup_test<any>>, ReturnType<startup_test<any>>>("startup_test"));
}
}