@lakutata/template-electron-react 3.0.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,23 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023-present Lakutata
4
+
5
+ All rights reserved
6
+
7
+ Permission is hereby granted, free of charge, to any person obtaining a copy
8
+ of this software and associated documentation files (the "Software"), to deal
9
+ in the Software without restriction, including without limitation the rights
10
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
+ copies of the Software, and to permit persons to whom the Software is
12
+ furnished to do so, subject to the following conditions:
13
+
14
+ The above copyright notice and this permission notice shall be included in all
15
+ copies or substantial portions of the Software.
16
+
17
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,12 @@
1
+ # @lakutata/template-electron-react
2
+
3
+ The Electron template of the [lakutata](https://github.com/lakutata/lakutata) projects with React: the main process is a
4
+ lakutata application serving the actions of its controllers over IPC to the renderer (React, Vite), isolated from Node.js;
5
+ the application is packaged by `lakutata package installer` (electron-builder), for Linux in Docker (`npm run package:linux`).
6
+
7
+ ```bash
8
+ npx @lakutata/cli create --name my-app --template electron-react
9
+ ```
10
+
11
+ The files of the project are in `template/`; `lakutata create` fills `{$APP_NAME}`, `{$APP_ID}`, `{$APP_DESC}`,
12
+ `{$APP_AUTHOR}` and `{$APP_LICENSE}`, and renames `_gitignore` and `_dockerignore`.
@@ -0,0 +1,6 @@
1
+ {
2
+ "scripts": {
3
+ "i18n:check": "lakutata i18n check --dir src/locales",
4
+ "i18n:types": "lakutata i18n types --dir src/locales"
5
+ }
6
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "greeting": {
3
+ "hello": "Hello, {name}!"
4
+ }
5
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "greeting": {
3
+ "hello": "你好,{name}!"
4
+ }
5
+ }
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@lakutata/template-electron-react",
3
+ "version": "3.0.0-beta.0",
4
+ "description": "The Electron template of the lakutata projects with React: the main process is a lakutata application serving the actions of its controllers to the React renderer (Vite) over IPC, packaged by electron-builder.",
5
+ "keywords": [
6
+ "lakutata",
7
+ "lakutata-template"
8
+ ],
9
+ "license": "MIT",
10
+ "files": [
11
+ "template",
12
+ "features",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "lakutata": {
17
+ "template": "electron-react",
18
+ "features": {
19
+ "i18n": "The messages in several languages (lakutata/com/i18n): src/locales, the language of each request"
20
+ }
21
+ }
22
+ }
@@ -0,0 +1,17 @@
1
+ # The installers of Linux (npm run package:linux), made on Linux by electron-builder: the packages of the configuration
2
+ # (lakutata.installer of packages/app/package.json) in dist/installer. The ones of another architecture: docker buildx
3
+ # build --platform linux/amd64 ... The packages of fpm (deb, rpm) are made by the fpm of the system (the one electron-builder
4
+ # downloads is the one of x64); add the system libraries your native addons need
5
+ FROM node:22-bookworm AS build
6
+ RUN apt-get update \
7
+ && apt-get install -y --no-install-recommends build-essential python3 ruby ruby-dev rpm \
8
+ && gem install fpm --no-document \
9
+ && rm -rf /var/lib/apt/lists/*
10
+ ENV USE_SYSTEM_FPM=true
11
+ WORKDIR /project
12
+ COPY . .
13
+ RUN (npm ci --no-audit --no-fund || npm install --no-audit --no-fund) && npm run build && npm run package -w app -- --outdir /installer \
14
+ && mkdir /packages && find /installer -maxdepth 1 -type f ! -name '*.yml' ! -name '*.blockmap' -exec cp {} /packages \;
15
+
16
+ FROM scratch
17
+ COPY --from=build /packages /
@@ -0,0 +1,73 @@
1
+ # {$APP_NAME}
2
+
3
+ {$APP_DESC}
4
+
5
+ A [lakutata](https://github.com/lakutata/lakutata) Electron application. The project is a workspace:
6
+
7
+ - `packages/app`: the main process, a lakutata application. Its controllers serve their `@ServiceAction` actions to the
8
+ renderer over IPC (`electronIPCServiceEntrypoint`); `MainWindow` is its window;
9
+ - `packages/ui`: the renderer, React built by Vite. It is isolated from Node.js and from Electron: `window.ipc`
10
+ (`packages/ui/src/ipc.ts`, through the preload script `packages/app/src/preload.cts`) invokes the actions of the main
11
+ process, `window.ipc.invoke({cmd: 'greet', name: 'you'})`, and listens to the events it pushes,
12
+ `window.ipc.on('greeted', listener)` (`once`, `off`);
13
+ - `packages/resources`: the resources of the application, its icon (`logo.png`: the icons of its installers and of its
14
+ window).
15
+
16
+ ```bash
17
+ npm run dev # the renderer served by Vite (hot reload) in the window of the application, with its DevTools
18
+ npm test # the tests of the packages (the actions of the main process, without Electron)
19
+ npm run build # build the packages, in the order of their dependencies (ui, then app)
20
+ npm start # start the built application
21
+ npm run package:host # the installers of this platform: dist/installer (deb, dmg, nsis…)
22
+ npm run package:linux # the ones of Linux, in Docker (Dockerfile.installer): dist/installer
23
+ npm run version:patch # give the packages their next version
24
+ ```
25
+
26
+ A new action: a method of a controller of `packages/app/src/controllers` decorated by `@ServiceAction({cmd: 'name'}, DTO)`,
27
+ invoked by the renderer with `window.ipc.invoke({cmd: 'name', ...input})`: its input is validated by its DTO, its result is
28
+ given back, its errors are thrown in the renderer (with their `errno`). Each page is a client of the main process: its id
29
+ is the `clientId` of the context of its requests, which the entrypoint pushes events to, `this.entrypoint.notify(clientId,
30
+ 'event', ...args)` (the id may be kept to push them later), or to every client, `this.entrypoint.broadcast('event',
31
+ ...args)`.
32
+
33
+ In development (not packaged), the actions are also served over HTTP on the port `DEV_PORT` (9000 by default), for the
34
+ tools such as Postman or curl: `POST /` with `{"cmd": "greet", "name": "you"}` answers the response of the action; the id
35
+ of the client in the `id` header. `GET /events?id=<client>` streams the events pushed to the client (Server-Sent Events):
36
+
37
+ ```bash
38
+ curl -N 'http://localhost:9000/events?id=me' &
39
+ curl -X POST http://localhost:9000/ -H 'id: me' -H 'Content-Type: application/json' -d '{"cmd": "greet", "name": "you"}'
40
+ ```
41
+
42
+ ## The packaging
43
+
44
+ `npm run package:host` builds the packages and makes the installers of the platform it runs on with `lakutata package installer`
45
+ (electron-builder, configured by the `lakutata.installer` of `packages/app/package.json`): the main process bundled with
46
+ its dependencies in the archive of the application, its native addons, the preload script and the built renderer (its
47
+ `lakutata.resources`) next to it, in the resources of Electron. The installers of Windows are made on Windows, the ones of
48
+ macOS on macOS, the ones of Linux on Linux: `npm run package:linux` makes them in Docker on any platform
49
+ (`Dockerfile.installer`, whose system libraries are the ones the native addons need: add yours; `docker buildx build
50
+ --platform linux/amd64` makes the ones of another architecture).
51
+
52
+ ```json
53
+ "installer": {
54
+ "runtime": "electron",
55
+ "linux": {"targets": ["deb"], "scripts": {"postinstall": "build/linux/postinstall.sh"}},
56
+ "mac": {"targets": ["dmg"]},
57
+ "win": {"targets": ["nsis", "portable"], "scripts": {"postinstall": "build/win/postinstall.ps1"}},
58
+ "electron": {"linux": {"category": "Utility"}}
59
+ }
60
+ ```
61
+
62
+ `electron` is the configuration of electron-builder (its icons, its file associations, its signature, its updates…),
63
+ merged over the one made from `lakutata.installer`. The scripts of the installers (`scripts`, run by the deb and rpm
64
+ packages, the pkg package of macOS and the installer of NSIS) are the ones of `lakutata package installer` (its documentation in
65
+ `@lakutata/cli`).
66
+
67
+ The deb packages need the email of their maintainer: the `author` of `packages/app/package.json` is `"Name <email>"` (and
68
+ its `homepage` is the one of the packages).
69
+
70
+ The native addons built for Node.js (not with N-API) are rebuilt for Electron before packaging: `npx @electron/rebuild`
71
+ (and `npm rebuild` to run the tests with Node.js again).
72
+
73
+ Electron downloads its binary the first time it runs (`npx install-electron` downloads it ahead, for a CI caching it).
@@ -0,0 +1,8 @@
1
+ .git
2
+ node_modules
3
+ **/node_modules
4
+ **/build
5
+ **/dist
6
+ *.log
7
+ .env
8
+ .env.*
@@ -0,0 +1,13 @@
1
+ node_modules
2
+ build
3
+ *.log
4
+ .env
5
+ .env.*
6
+ .DS_Store
7
+ .idea
8
+ .vscode
9
+ .next
10
+ next-env.d.ts
11
+ .nuxt
12
+ .output
13
+ dist
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "{$APP_ID}",
3
+ "productName": "{$APP_NAME}",
4
+ "version": "0.0.1",
5
+ "description": "{$APP_DESC}",
6
+ "author": "{$APP_AUTHOR}",
7
+ "license": "{$APP_LICENSE}",
8
+ "private": true,
9
+ "workspaces": [
10
+ "packages/*"
11
+ ],
12
+ "scripts": {
13
+ "build": "lakutata run rebuild",
14
+ "test": "lakutata run test",
15
+ "dev": "lakutata run dev --parallel",
16
+ "start": "npm start -w app",
17
+ "package:host": "npm run build && npm run package -w app -- --outdir ../../dist/installer",
18
+ "package:linux": "docker buildx build --file Dockerfile.installer --output type=local,dest=dist/installer .",
19
+ "version:patch": "lakutata version patch --yes"
20
+ },
21
+ "devDependencies": {
22
+ "@lakutata/builder": "^{$LAKUTATA_VERSION}",
23
+ "@lakutata/cli": "^{$LAKUTATA_VERSION}",
24
+ "@types/node": "^22.0.0",
25
+ "electron": "^44.5.1",
26
+ "electron-builder": "^26.15.3",
27
+ "typescript": "^6.0.3"
28
+ },
29
+ "engines": {
30
+ "node": ">=22"
31
+ }
32
+ }
@@ -0,0 +1,83 @@
1
+ {
2
+ "name": "app",
3
+ "productName": "{$APP_NAME}",
4
+ "version": "0.0.1",
5
+ "description": "{$APP_DESC}",
6
+ "author": "{$APP_AUTHOR}",
7
+ "license": "{$APP_LICENSE}",
8
+ "private": true,
9
+ "type": "module",
10
+ "main": "build/App.js",
11
+ "scripts": {
12
+ "clean": "lakutata clean",
13
+ "build": "tsc",
14
+ "rebuild": "npm run clean && npm run build",
15
+ "dev": "npm run rebuild && electron build/App.js --renderer=http://localhost:5173",
16
+ "start": "electron build/App.js",
17
+ "test": "node --test \"build/tests/**/*.spec.js\"",
18
+ "package": "lakutata package installer"
19
+ },
20
+ "dependencies": {
21
+ "lakutata": "^{$LAKUTATA_VERSION}",
22
+ "ui": "^0.0.1",
23
+ "resources": "^0.0.1"
24
+ },
25
+ "lakutata": {
26
+ "icon": "../resources/logo.png",
27
+ "resources": [
28
+ {
29
+ "from": "../ui/dist",
30
+ "to": "renderer"
31
+ },
32
+ {
33
+ "from": "build/preload.cjs",
34
+ "to": "preload.cjs"
35
+ },
36
+ {
37
+ "from": "../resources/logo.png",
38
+ "to": "logo.png"
39
+ }
40
+ ],
41
+ "installer": {
42
+ "runtime": "electron",
43
+ "name": "{$APP_ID}",
44
+ "appId": "{$APP_ID}",
45
+ "linux": {
46
+ "targets": [
47
+ "deb"
48
+ ]
49
+ },
50
+ "mac": {
51
+ "targets": [
52
+ "dmg"
53
+ ]
54
+ },
55
+ "win": {
56
+ "targets": [
57
+ "nsis",
58
+ "portable"
59
+ ]
60
+ },
61
+ "electron": {
62
+ "linux": {
63
+ "category": "Utility",
64
+ "syncDesktopName": true
65
+ },
66
+ "deb": {
67
+ "depends": [
68
+ "libgtk-3-0",
69
+ "libnotify4",
70
+ "libnss3",
71
+ "libxss1",
72
+ "libxtst6",
73
+ "xdg-utils",
74
+ "libatspi2.0-0",
75
+ "libuuid1",
76
+ "libsecret-1-0",
77
+ "libasound2"
78
+ ]
79
+ }
80
+ }
81
+ }
82
+ }
83
+ }
@@ -0,0 +1,35 @@
1
+ import {Application, resourcesPath, type ILogger} from 'lakutata'
2
+ import electron from 'electron'
3
+ import path from 'node:path'
4
+ import {fileURLToPath} from 'node:url'
5
+ import {installerConfig} from './config/Config.js'
6
+
7
+ /**
8
+ * The directory of the preload script and of the built renderer: the resources of the packaged application
9
+ * (lakutata.resources), the directory of the main process in development (build/, the renderer of the ui package)
10
+ */
11
+ const directory: string = resourcesPath() ?? path.dirname(fileURLToPath(import.meta.url))
12
+
13
+ /**
14
+ * The URL of the renderer in development (--renderer=<url>, the dev server of Vite)
15
+ */
16
+ const renderer: string | undefined = process.argv.find((argument: string): boolean => argument.startsWith('--renderer='))?.slice('--renderer='.length)
17
+
18
+ //One instance of the application: a second one gives its window back to the first one
19
+ if (!electron.app.requestSingleInstanceLock()) {
20
+ electron.app.exit(0)
21
+ } else {
22
+ Application
23
+ .run(installerConfig({
24
+ directory: directory,
25
+ renderer: renderer,
26
+ mode: electron.app.isPackaged ? 'production' : 'development',
27
+ //The actions served to the tools in development (Postman, curl), not by the packaged application
28
+ devPort: electron.app.isPackaged ? undefined : Number(process.env.DEV_PORT ?? 9000)
29
+ }))
30
+ .onLaunched((app: Application, log: ILogger): void => log.info('%s started in %s mode', app.appName, app.mode()))
31
+ .onFatalException((error: Error, log: ILogger): void => {
32
+ log.error('The application cannot start: %s', error.message)
33
+ electron.app.exit(1)
34
+ })
35
+ }
@@ -0,0 +1,110 @@
1
+ import {Application, Component, DTO} from 'lakutata'
2
+ import {Configurable, Inject} from 'lakutata/decorator/di'
3
+ import type {BrowserWindow} from 'electron'
4
+ import {existsSync} from 'node:fs'
5
+ import {createRequire} from 'node:module'
6
+ import path from 'node:path'
7
+
8
+ /**
9
+ * The window of the application: the renderer, isolated from Node.js (it invokes the actions of the main process through
10
+ * the preload script only)
11
+ */
12
+ export class MainWindow extends Component {
13
+
14
+ @Inject(Application)
15
+ protected readonly app: Application
16
+
17
+ @Configurable(DTO.Number().optional().default(1024))
18
+ protected readonly width: number
19
+
20
+ @Configurable(DTO.Number().optional().default(720))
21
+ protected readonly height: number
22
+
23
+ /**
24
+ * The directory of the preload script and of the built renderer (the resources of the packaged application)
25
+ */
26
+ @Configurable(DTO.String().optional())
27
+ protected readonly directory?: string
28
+
29
+ /**
30
+ * The URL of the renderer in development, the built renderer otherwise
31
+ */
32
+ @Configurable(DTO.String().optional())
33
+ protected readonly renderer?: string
34
+
35
+ #window: BrowserWindow | undefined
36
+
37
+ /**
38
+ * The index.html of the built renderer: in the resources of the packaged application, in the ui package otherwise
39
+ */
40
+ protected rendererFile(): string {
41
+ const packaged: string = path.join(this.directory ?? '', 'renderer', 'index.html')
42
+ if (existsSync(packaged)) return packaged
43
+ return path.join(path.dirname(createRequire(import.meta.url).resolve('ui/package.json')), 'dist', 'index.html')
44
+ }
45
+
46
+ /**
47
+ * The icon of the window (Windows, Linux: the windows of macOS show the one of the application): in the resources of
48
+ * the packaged application, in the resources package otherwise
49
+ */
50
+ protected iconFile(): string {
51
+ const packaged: string = path.join(this.directory ?? '', 'logo.png')
52
+ if (existsSync(packaged)) return packaged
53
+ return path.join(path.dirname(createRequire(import.meta.url).resolve('resources/package.json')), 'logo.png')
54
+ }
55
+
56
+ /**
57
+ * Load the URL of the renderer in development: the dev server of Vite may start after the application
58
+ * @param window
59
+ * @param url
60
+ */
61
+ protected async loadURL(window: BrowserWindow, url: string): Promise<void> {
62
+ for (let attempt: number = 1; ; attempt++) {
63
+ try {
64
+ return await window.loadURL(url)
65
+ } catch (error) {
66
+ if (attempt >= 30 || window.isDestroyed()) throw error
67
+ await new Promise((resolve: (value: unknown) => void): unknown => setTimeout(resolve, 500))
68
+ }
69
+ }
70
+ }
71
+
72
+ protected async init(): Promise<void> {
73
+ //Electron is loaded with the window: the tests, run by Node.js, serve the actions without it (the package of
74
+ //Electron downloads its binary when it is loaded)
75
+ const {app, BrowserWindow} = (await import('electron')).default
76
+ await app.whenReady()
77
+ const window: BrowserWindow = new BrowserWindow({
78
+ width: this.width,
79
+ height: this.height,
80
+ title: this.app.appName,
81
+ icon: this.iconFile(),
82
+ show: false,
83
+ webPreferences: {
84
+ preload: path.join(this.directory ?? '', 'preload.cjs'),
85
+ contextIsolation: true,
86
+ nodeIntegration: false,
87
+ sandbox: true
88
+ }
89
+ })
90
+ this.#window = window
91
+ window.once('ready-to-show', (): void => window.show())
92
+ //The application ends with its window
93
+ window.once('closed', (): void => this.app.exit(0))
94
+ if (this.renderer) {
95
+ await this.loadURL(window, this.renderer)
96
+ window.webContents.openDevTools({mode: 'detach'})
97
+ } else {
98
+ await window.loadFile(this.rendererFile())
99
+ }
100
+ //A second instance of the application gives its window back
101
+ app.on('second-instance', (): void => {
102
+ if (window.isMinimized()) window.restore()
103
+ window.focus()
104
+ })
105
+ }
106
+
107
+ protected async destroy(): Promise<void> {
108
+ if (this.#window && !this.#window.isDestroyed()) this.#window.destroy()
109
+ }
110
+ }
@@ -0,0 +1,75 @@
1
+ import type {ApplicationOptions} from 'lakutata'
2
+ import {buildEntrypoints, httpServiceEntrypoint} from 'lakutata/com/entrypoint'
3
+ import {electronIPCServiceEntrypoint, type IPCMain} from 'lakutata/adapter/electron'
4
+ import {MainWindow} from '../components/MainWindow.js'
5
+ import {ExampleController} from '../controllers/ExampleController.js'
6
+ //@if i18n
7
+ import {buildI18nOptions, I18nMiddleware} from 'lakutata/com/i18n'
8
+ import en from '../locales/en.json' with {type: 'json'}
9
+ import zhCN from '../locales/zh-CN.json' with {type: 'json'}
10
+ //@end
11
+
12
+ export type ConfigOptions = {
13
+ /**
14
+ * The directory of the main process, where the preload script and the built renderer are
15
+ */
16
+ directory?: string
17
+ /**
18
+ * The URL of the renderer in development (the dev server of Vite), the built renderer otherwise
19
+ */
20
+ renderer?: string
21
+ mode?: 'development' | 'production'
22
+ /**
23
+ * Open the window, true by default (the tests serve the actions without window)
24
+ */
25
+ window?: boolean
26
+ /**
27
+ * The ipcMain serving the actions, the one of Electron by default (the tests give theirs)
28
+ */
29
+ ipcMain?: IPCMain
30
+ /**
31
+ * The port of the HTTP entrypoint of the development, none by default: the actions invoked by the tools (Postman,
32
+ * curl: POST / with their pattern and their input), the events pushed to them received (GET /events, Server-Sent
33
+ * Events), the id of their client in the header id (or the query id of the events)
34
+ */
35
+ devPort?: number
36
+ }
37
+
38
+ /**
39
+ * The options of the application
40
+ * @param options
41
+ */
42
+ export function installerConfig(options: ConfigOptions = {}): ApplicationOptions {
43
+ return {
44
+ id: '{$APP_ID}',
45
+ name: '{$APP_NAME}',
46
+ mode: options.mode ?? 'development',
47
+ components: {
48
+ //@if i18n
49
+ //The messages of the languages (src/locales), bundled with the code; created by the first request
50
+ i18n: buildI18nOptions({messages: {'en': en, 'zh-CN': zhCN}}),
51
+ //@end
52
+ entrypoint: buildEntrypoints({
53
+ controllers: [ExampleController],
54
+ //@if i18n
55
+ //The messages in the language of its environment (LANG)
56
+ middlewares: [I18nMiddleware],
57
+ //@end
58
+ //The renderer invokes the actions over IPC: window.ipc.invoke({cmd: 'greet', name: 'you'})
59
+ service: [
60
+ electronIPCServiceEntrypoint({ipcMain: options.ipcMain}),
61
+ ...options.devPort === undefined ? [] : [httpServiceEntrypoint({port: options.devPort})]
62
+ ]
63
+ }),
64
+ ...(options.window === false ? {} : {
65
+ window: {
66
+ class: MainWindow,
67
+ directory: options.directory,
68
+ renderer: options.renderer
69
+ }
70
+ })
71
+ },
72
+ //The actions are served before the window loads the renderer
73
+ bootstrap: options.window === false ? ['entrypoint'] : ['entrypoint', 'window']
74
+ }
75
+ }
@@ -0,0 +1,41 @@
1
+ import {Controller, Entrypoint, type ServiceContext} from 'lakutata/com/entrypoint'
2
+ import {ServiceAction} from 'lakutata/decorator/ctrl'
3
+ import {Inject} from 'lakutata/decorator/di'
4
+ import {GreetDTO} from '../dtos/GreetDTO.js'
5
+ //@if i18n
6
+ import {Translator} from 'lakutata/com/i18n'
7
+ //@end
8
+
9
+ export class ExampleController extends Controller {
10
+
11
+ /**
12
+ * Pushes the events to the clients: the windows (window.ipc.on), the tools in development (GET /events)
13
+ */
14
+ @Inject('entrypoint')
15
+ protected readonly entrypoint: Entrypoint
16
+ //@if i18n
17
+
18
+ /**
19
+ * The messages of the language of the request
20
+ */
21
+ @Inject(Translator)
22
+ protected readonly i18n: Translator
23
+ //@end
24
+
25
+ /**
26
+ * Invoked by the renderer: window.ipc.invoke({cmd: 'greet', name: 'you'})
27
+ * @param input
28
+ */
29
+ @ServiceAction({cmd: 'greet'}, GreetDTO)
30
+ public async greet(input: GreetDTO): Promise<{ message: string }> {
31
+ //An event pushed to the client of the request (window.ipc.on('greeted', listener)), whose id may be kept to push
32
+ //it events later; this.entrypoint.broadcast pushes one to every client
33
+ const clientId: string | undefined = (this.context as ServiceContext).clientId
34
+ if (clientId) this.entrypoint.notify(clientId, 'greeted', input.name)
35
+ //@if i18n
36
+ return {message: this.i18n.t('greeting.hello', {name: input.name})}
37
+ //@else
38
+ return {message: `Hello, ${input.name}!`}
39
+ //@end
40
+ }
41
+ }
@@ -0,0 +1,7 @@
1
+ import {DTO} from 'lakutata'
2
+ import {Expect} from 'lakutata/decorator/dto'
3
+
4
+ export class GreetDTO extends DTO {
5
+ @Expect(DTO.String().required().max(64).description('Who is greeted'))
6
+ public name: string
7
+ }
@@ -0,0 +1,15 @@
1
+ import {contextBridge, ipcRenderer} from 'electron'
2
+
3
+ /**
4
+ * The preload script of the window: the transport of window.ipc (packages/ui/src/ipc.ts), the renderer invokes the actions
5
+ * of the main process and receives the events it pushes, nothing else of Electron or of Node.js
6
+ */
7
+ contextBridge.exposeInMainWorld('ipcBridge', {
8
+ //The page is a client of the main process: its id is the clientId of the contexts of its requests
9
+ invoke: (data: Record<string, unknown>, clientId: string): Promise<unknown> => ipcRenderer.invoke('lakutata:service', data, clientId),
10
+ //The events of the main process (Entrypoint.notify, broadcast), once the page is subscribed to them
11
+ subscribe: (clientId: string, listener: (event: string, args: unknown[]) => void): void => {
12
+ ipcRenderer.on('lakutata:service:event', (_event: unknown, name: string, args: unknown[]): void => listener(name, args))
13
+ ipcRenderer.send('lakutata:service:subscribe', clientId)
14
+ }
15
+ })
@@ -0,0 +1,109 @@
1
+ import {after, before, describe, it} from 'node:test'
2
+ import assert from 'node:assert'
3
+ import {createServer, type Server} from 'node:net'
4
+ import {Application} from 'lakutata'
5
+ import type {IPCMain} from 'lakutata/adapter/electron'
6
+ import {installerConfig} from '../config/Config.js'
7
+
8
+ /**
9
+ * The ipcMain of the tests: the actions are invoked as the renderer invokes them, without Electron
10
+ */
11
+ const handlers: Map<string, (event: unknown, data: unknown) => Promise<unknown>> = new Map()
12
+ const listeners: Map<string, (event: unknown, clientId: string) => void> = new Map()
13
+ const ipcMain: IPCMain = {
14
+ handle: (channel: string, listener: (event: unknown, data: unknown) => Promise<unknown>): unknown => handlers.set(channel, listener),
15
+ removeHandler: (channel: string): unknown => handlers.delete(channel),
16
+ on: (channel: string, listener: (event: unknown, clientId: string) => void): unknown => listeners.set(channel, listener),
17
+ removeAllListeners: (channel: string): unknown => listeners.delete(channel)
18
+ }
19
+ //The web contents of the renderer: the events pushed to it
20
+ const received: unknown[][] = []
21
+ const sender = {once: (): void => undefined, off: (): void => undefined, send: (...args: unknown[]): unknown => received.push(args)}
22
+
23
+ /**
24
+ * Invoke an action as the renderer does (window.ipc.invoke)
25
+ * @param data
26
+ */
27
+ function invoke(data: Record<string, unknown>): Promise<any> {
28
+ return (handlers.get('lakutata:service') as (event: unknown, data: unknown, clientId: string) => Promise<unknown>)({sender: sender}, data, 'page')
29
+ }
30
+
31
+ /**
32
+ * A free port, the one of the HTTP entrypoint of the development
33
+ */
34
+ async function freePort(): Promise<number> {
35
+ const server: Server = createServer()
36
+ await new Promise<void>((resolve: () => void): unknown => server.listen(0, '127.0.0.1', resolve))
37
+ const port: number = (server.address() as { port: number }).port
38
+ await new Promise<void>((resolve: () => void): unknown => server.close((): void => resolve()))
39
+ return port
40
+ }
41
+
42
+ describe('{$APP_NAME}', (): void => {
43
+
44
+ let app: Application
45
+ let devPort: number
46
+
47
+ before(async (): Promise<void> => {
48
+ //@if i18n
49
+ //The actions answer in the language of the environment: English for the tests
50
+ process.env.LC_ALL = 'en_US.UTF-8'
51
+ //@end
52
+ devPort = await freePort()
53
+ app = await new Promise<Application>((resolve: (app: Application) => void): void => {
54
+ Application.run(installerConfig({window: false, ipcMain: ipcMain, devPort: devPort})).onLaunched((launched: Application): void => resolve(launched))
55
+ })
56
+ })
57
+
58
+ after(async (): Promise<void> => {
59
+ await app?.dispose()
60
+ })
61
+
62
+ it('serves the actions to the renderer', async (): Promise<void> => {
63
+ assert.deepStrictEqual(await invoke({cmd: 'greet', name: 'you'}), {success: true, error: null, payload: {message: 'Hello, you!'}})
64
+ })
65
+
66
+ //@if i18n
67
+ it('serves the actions in the language of the environment', async (): Promise<void> => {
68
+ process.env.LC_ALL = 'zh_CN.UTF-8'
69
+ try {
70
+ assert.deepStrictEqual(await invoke({cmd: 'greet', name: 'you'}), {success: true, error: null, payload: {message: '你好,you!'}})
71
+ } finally {
72
+ process.env.LC_ALL = 'en_US.UTF-8'
73
+ }
74
+ })
75
+
76
+ //@end
77
+ it('pushes its events to the renderer', async (): Promise<void> => {
78
+ //The page subscribed to the events
79
+ listeners.get('lakutata:service:subscribe')!({sender: sender}, 'page')
80
+ received.length = 0
81
+ await invoke({cmd: 'greet', name: 'you'})
82
+ assert.deepStrictEqual(received, [['lakutata:service:event', 'greeted', ['you']]])
83
+ })
84
+
85
+ it('serves the actions and their events to the tools of the development over HTTP', async (): Promise<void> => {
86
+ //The events of the client (Server-Sent Events): its id first
87
+ const events: Response = await fetch(`http://127.0.0.1:${devPort}/events?id=tool`)
88
+ const reader = events.body!.getReader()
89
+ let received: string = ''
90
+ const until = async (pattern: RegExp): Promise<void> => {
91
+ while (!pattern.test(received)) received += new TextDecoder().decode((await reader.read()).value)
92
+ }
93
+ await until(/event: client/)
94
+ const response: Response = await fetch(`http://127.0.0.1:${devPort}/`, {method: 'POST', headers: {'Content-Type': 'application/json', 'id': 'tool'}, body: JSON.stringify({cmd: 'greet', name: 'you'})})
95
+ assert.deepStrictEqual(await response.json(), {success: true, error: null, payload: {message: 'Hello, you!'}})
96
+ await until(/event: greeted\ndata: \["you"\]/)
97
+ await reader.cancel()
98
+ })
99
+
100
+ it('refuses an invalid input', async (): Promise<void> => {
101
+ const response = await invoke({cmd: 'greet'})
102
+ assert.strictEqual(response.success, false)
103
+ assert.match(response.error.message, /name/)
104
+ })
105
+
106
+ it('refuses an unknown action', async (): Promise<void> => {
107
+ assert.strictEqual((await invoke({cmd: 'unknown'})).success, false)
108
+ })
109
+ })
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "lakutata/tsconfig.json",
3
+ "compilerOptions": {
4
+ "rootDir": "src",
5
+ "outDir": "build",
6
+ "sourceMap": true
7
+ },
8
+ "include": ["src"]
9
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "resources",
3
+ "version": "0.0.1",
4
+ "private": true,
5
+ "description": "The resources of the application: its icon (logo.png, lakutata.icon of the app package), made into the icons of each platform"
6
+ }
@@ -0,0 +1,13 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'" />
7
+ <title>{$APP_NAME}</title>
8
+ </head>
9
+ <body>
10
+ <div id="root"></div>
11
+ <script type="module" src="/src/main.tsx"></script>
12
+ </body>
13
+ </html>
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "ui",
3
+ "version": "0.0.1",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "clean": "lakutata clean dist",
8
+ "build": "vite build",
9
+ "rebuild": "npm run clean && npm run build",
10
+ "dev": "vite --port 5173 --strictPort",
11
+ "typecheck": "tsc --noEmit"
12
+ },
13
+ "dependencies": {
14
+ "react": "^19.3.0",
15
+ "react-dom": "^19.3.0"
16
+ },
17
+ "devDependencies": {
18
+ "@types/react": "^19.3.0",
19
+ "@types/react-dom": "^19.3.0",
20
+ "@vitejs/plugin-react": "^6.1.1",
21
+ "vite": "^8.3.1"
22
+ }
23
+ }
@@ -0,0 +1,26 @@
1
+ import {useEffect, useState} from 'react'
2
+
3
+ export function App() {
4
+ const [message, setMessage] = useState<string>('…')
5
+ const [pushed, setPushed] = useState<string>('')
6
+
7
+ useEffect((): () => void => {
8
+ //An event the main process pushes
9
+ const greeted = (name: string): void => setPushed(`Pushed by the main process: ${name}`)
10
+ window.ipc.on('greeted', greeted)
11
+ window.ipc.invoke<{ message: string }>({cmd: 'greet', name: '{$APP_NAME}'})
12
+ .then((result: { message: string }): void => setMessage(result.message))
13
+ .catch((error: Error): void => setMessage(error.message))
14
+ return (): void => {
15
+ window.ipc.off('greeted', greeted)
16
+ }
17
+ }, [])
18
+
19
+ return (
20
+ <main>
21
+ <h1>{$APP_NAME}</h1>
22
+ <p>{message}</p>
23
+ <p>{pushed}</p>
24
+ </main>
25
+ )
26
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * The response of an action of the main process
3
+ */
4
+ type ServiceResponse<T> = {
5
+ success: boolean
6
+ error: { message?: string, errno?: string } | null
7
+ payload: T
8
+ }
9
+
10
+ /**
11
+ * A listener of an event of the main process, given its arguments
12
+ */
13
+ type Listener = (...args: any[]) => void
14
+
15
+ declare global {
16
+ interface Window {
17
+ /**
18
+ * The transport of the IPC, given by the preload script of the window of the application
19
+ */
20
+ ipcBridge?: {
21
+ invoke<T>(data: Record<string, unknown>, clientId: string): Promise<ServiceResponse<T>>
22
+ subscribe(clientId: string, listener: (event: string, args: unknown[]) => void): void
23
+ }
24
+ /**
25
+ * The IPC of the main process
26
+ */
27
+ ipc: IPC
28
+ }
29
+ }
30
+
31
+ /**
32
+ * The IPC of the main process (window.ipc): its actions invoked, their results given and their errors thrown (with their
33
+ * errno), its events listened to. Defined in the page: the errors and the listeners given across the preload script lose
34
+ * their properties and their identity
35
+ */
36
+ export class IPC {
37
+
38
+ /**
39
+ * The id of the page, a client of the main process (the clientId of the contexts of its requests, which the events
40
+ * are pushed to): a new one each time the page is loaded
41
+ */
42
+ public readonly id: string = crypto.randomUUID()
43
+
44
+ readonly #listeners: Map<string, Listener[]> = new Map()
45
+
46
+ constructor() {
47
+ window.ipcBridge?.subscribe(this.id, (event: string, args: unknown[]): void => {
48
+ for (const listener of [...this.#listeners.get(event) ?? []]) listener(...args)
49
+ })
50
+ }
51
+
52
+ /**
53
+ * Invoke an action of the main process (a @ServiceAction of its controllers): window.ipc.invoke({cmd: 'greet', name: 'you'})
54
+ * @param data its pattern and its input
55
+ */
56
+ public async invoke<T = any>(data: Record<string, unknown>): Promise<T> {
57
+ if (!window.ipcBridge) throw new Error('The actions are invoked in the window of the application (npm run dev)')
58
+ const response: ServiceResponse<T> = await window.ipcBridge.invoke<T>(data, this.id)
59
+ if (!response.success) throw Object.assign(new Error(response.error?.message ?? 'The action failed'), response.error)
60
+ return response.payload
61
+ }
62
+
63
+ /**
64
+ * Listen to an event of the main process
65
+ * @param event
66
+ * @param listener
67
+ */
68
+ public on(event: string, listener: Listener): this {
69
+ this.#listeners.set(event, [...this.#listeners.get(event) ?? [], listener])
70
+ return this
71
+ }
72
+
73
+ /**
74
+ * Listen to the next event of the main process
75
+ * @param event
76
+ * @param listener
77
+ */
78
+ public once(event: string, listener: Listener): this {
79
+ const once: Listener & { listener?: Listener } = (...args: any[]): void => {
80
+ this.off(event, once)
81
+ listener(...args)
82
+ }
83
+ once.listener = listener
84
+ return this.on(event, once)
85
+ }
86
+
87
+ /**
88
+ * Stop listening to an event of the main process
89
+ * @param event
90
+ * @param listener
91
+ */
92
+ public off(event: string, listener: Listener): this {
93
+ this.#listeners.set(event, (this.#listeners.get(event) ?? []).filter((existing: Listener & { listener?: Listener }): boolean => existing !== listener && existing.listener !== listener))
94
+ return this
95
+ }
96
+
97
+ /**
98
+ * Stop listening to an event of the main process (off)
99
+ * @param event
100
+ * @param listener
101
+ */
102
+ public removeListener(event: string, listener: Listener): this {
103
+ return this.off(event, listener)
104
+ }
105
+
106
+ /**
107
+ * Stop listening to an event of the main process, or to every event
108
+ * @param event
109
+ */
110
+ public removeAllListeners(event?: string): this {
111
+ if (event === undefined) this.#listeners.clear()
112
+ else this.#listeners.delete(event)
113
+ return this
114
+ }
115
+
116
+ /**
117
+ * Kept for the renderers of the version 2 (the listeners of an event are not limited)
118
+ * @param _count
119
+ */
120
+ public setMaxListeners(_count: number): this {
121
+ return this
122
+ }
123
+ }
124
+
125
+ window.ipc = new IPC()
@@ -0,0 +1,11 @@
1
+ //window.ipc, before the components using it
2
+ import './ipc'
3
+ import {StrictMode} from 'react'
4
+ import {createRoot} from 'react-dom/client'
5
+ import {App} from './App'
6
+
7
+ createRoot(document.getElementById('root')!).render(
8
+ <StrictMode>
9
+ <App/>
10
+ </StrictMode>
11
+ )
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
5
+ "module": "ESNext",
6
+ "moduleResolution": "Bundler",
7
+ "jsx": "react-jsx",
8
+ "strict": true,
9
+ "noEmit": true,
10
+ "skipLibCheck": true,
11
+ "isolatedModules": true,
12
+ "types": ["vite/client"]
13
+ },
14
+ "include": ["src", "vite.config.ts"]
15
+ }
@@ -0,0 +1,8 @@
1
+ import {defineConfig} from 'vite'
2
+ import react from '@vitejs/plugin-react'
3
+
4
+ //The pages are loaded from the files of the packaged application: their paths are relative
5
+ export default defineConfig({
6
+ base: './',
7
+ plugins: [react()]
8
+ })