lakutata 3.0.0 → 3.1.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/README.md +50 -30
- package/README.zh-CN.md +130 -0
- package/dist/cjs/exports/QueueComponent.d.ts +1 -0
- package/dist/cjs/exports/QueueComponent.js +20 -0
- package/dist/esm/exports/QueueComponent.js +1 -0
- package/dist/types/exports/QueueComponent.d.ts +1 -0
- package/doc/en/Application.md +107 -0
- package/doc/en/CLI.md +274 -0
- package/doc/en/Cache.md +379 -0
- package/doc/en/Command.md +110 -0
- package/doc/en/ContainerdIntegration.md +17 -16
- package/doc/en/Controllers.md +256 -0
- package/doc/en/DBus.md +291 -0
- package/doc/en/DTOValidation.md +72 -43
- package/doc/en/Database.md +188 -0
- package/doc/en/DatabaseDrivers.md +287 -0
- package/doc/en/Decorator.md +52 -40
- package/doc/en/DockerIntegration.md +7 -6
- package/doc/en/Entities.md +240 -0
- package/doc/en/Entrypoint.md +216 -0
- package/doc/en/Exceptions.md +174 -0
- package/doc/en/GRPC.md +251 -0
- package/doc/en/GettingStarted.md +165 -0
- package/doc/en/HTTP.md +507 -0
- package/doc/en/HelperFunctions.md +11 -11
- package/doc/en/I18n.md +119 -115
- package/doc/en/Injection.md +144 -0
- package/doc/en/Installers.md +280 -0
- package/doc/en/Logger.md +206 -0
- package/doc/en/MCP.md +293 -0
- package/doc/en/Middlewares.md +213 -0
- package/doc/en/Migrations.md +202 -0
- package/doc/en/MongoEmbedded.md +149 -0
- package/doc/en/Monitor.md +164 -0
- package/doc/en/Mutex.md +27 -25
- package/doc/en/NATS.md +246 -0
- package/doc/en/NativeAddons.md +212 -0
- package/doc/en/Objects.md +143 -0
- package/doc/en/Overview.md +62 -85
- package/doc/en/Packaging.md +193 -0
- package/doc/en/ProjectStructure.md +124 -0
- package/doc/en/Queue.md +269 -0
- package/doc/en/Schedules.md +217 -0
- package/doc/en/Security.md +10 -9
- package/doc/en/ServiceCalls.md +504 -0
- package/doc/en/Time.md +19 -20
- package/doc/en/UpgradingFrom1.md +435 -0
- package/doc/en/UpgradingFrom2.md +315 -0
- package/doc/en/Workspaces.md +189 -0
- package/doc/en/examples/BackgroundJobs.md +677 -0
- package/doc/en/examples/CommandLineTool.md +664 -0
- package/doc/en/examples/DesktopApp.md +809 -0
- package/doc/en/examples/DeviceService.md +607 -0
- package/doc/en/examples/MCPServer.md +653 -0
- package/doc/en/examples/README.md +16 -0
- package/doc/en/examples/RestAPI.md +490 -0
- package/doc/en/examples/ServiceCalls.md +829 -0
- package/doc/en/examples/WebApp.md +568 -0
- package/doc/zh/Application.md +75 -0
- package/doc/zh/CLI.md +250 -0
- package/doc/zh/Cache.md +328 -0
- package/doc/zh/Command.md +78 -0
- package/doc/zh/ContainerdIntegration.md +9 -9
- package/doc/zh/Controllers.md +228 -0
- package/doc/zh/DBus.md +257 -0
- package/doc/zh/DTOValidation.md +49 -19
- package/doc/zh/Database.md +175 -0
- package/doc/zh/DatabaseDrivers.md +261 -0
- package/doc/zh/Decorator.md +25 -18
- package/doc/zh/DockerIntegration.md +6 -6
- package/doc/zh/Entities.md +224 -0
- package/doc/zh/Entrypoint.md +193 -0
- package/doc/zh/Exceptions.md +158 -0
- package/doc/zh/GRPC.md +226 -0
- package/doc/zh/GettingStarted.md +148 -0
- package/doc/zh/HTTP.md +445 -0
- package/doc/zh/HelperFunctions.md +2 -2
- package/doc/zh/I18n.md +26 -26
- package/doc/zh/Injection.md +129 -0
- package/doc/zh/Installers.md +232 -0
- package/doc/zh/Logger.md +188 -0
- package/doc/zh/MCP.md +265 -0
- package/doc/zh/Middlewares.md +198 -0
- package/doc/zh/Migrations.md +178 -0
- package/doc/zh/MongoEmbedded.md +118 -0
- package/doc/zh/Monitor.md +145 -0
- package/doc/zh/Mutex.md +10 -10
- package/doc/zh/NATS.md +207 -0
- package/doc/zh/NativeAddons.md +180 -0
- package/doc/zh/Objects.md +119 -0
- package/doc/zh/Overview.md +46 -71
- package/doc/zh/Packaging.md +154 -0
- package/doc/zh/ProjectStructure.md +102 -0
- package/doc/zh/Queue.md +214 -0
- package/doc/zh/Schedules.md +193 -0
- package/doc/zh/Security.md +6 -6
- package/doc/zh/ServiceCalls.md +459 -0
- package/doc/zh/Time.md +2 -2
- package/doc/zh/UpgradingFrom1.md +347 -0
- package/doc/zh/UpgradingFrom2.md +230 -0
- package/doc/zh/Workspaces.md +150 -0
- package/doc/zh/examples/BackgroundJobs.md +618 -0
- package/doc/zh/examples/CommandLineTool.md +618 -0
- package/doc/zh/examples/DesktopApp.md +748 -0
- package/doc/zh/examples/DeviceService.md +546 -0
- package/doc/zh/examples/MCPServer.md +604 -0
- package/doc/zh/examples/README.md +14 -0
- package/doc/zh/examples/RestAPI.md +458 -0
- package/doc/zh/examples/ServiceCalls.md +765 -0
- package/doc/zh/examples/WebApp.md +520 -0
- package/package.json +46 -17
- package/doc/en/ActionPattern.md +0 -408
- package/doc/en/Core.md +0 -312
- package/doc/en/ORMIntegration.md +0 -341
- package/doc/zh/ActionPattern.md +0 -334
- package/doc/zh/Core.md +0 -256
- package/doc/zh/ORMIntegration.md +0 -319
|
@@ -0,0 +1,809 @@
|
|
|
1
|
+
# A desktop application
|
|
2
|
+
|
|
3
|
+
This example builds a desktop notes application: the notes are Markdown files in a folder that the user chooses, and
|
|
4
|
+
the window lists, opens, saves and deletes them. It starts from the `electron-react` template and shows both sides of
|
|
5
|
+
an Electron application made with lakutata: the main process, whose actions read and write the files, open a system
|
|
6
|
+
dialog and push an event when the folder changes; and the React renderer, which calls these actions over IPC. It also
|
|
7
|
+
shows tests of the main process that run without Electron.
|
|
8
|
+
|
|
9
|
+
| Message | What it does |
|
|
10
|
+
|---|---|
|
|
11
|
+
| `{cmd: 'folder.get'}` | Gives the notes folder, `null` before one is chosen |
|
|
12
|
+
| `{cmd: 'folder.choose'}` | Asks the user for a folder in a system dialog, and remembers it |
|
|
13
|
+
| `{cmd: 'notes.list'}` | Lists the notes, the most recently modified first |
|
|
14
|
+
| `{cmd: 'notes.read', name: 'Shopping'}` | Gives the text of a note |
|
|
15
|
+
| `{cmd: 'notes.save', name: 'Shopping', text: '- bread'}` | Writes a note to the file `Shopping.md` |
|
|
16
|
+
| `{cmd: 'notes.delete', name: 'Shopping'}` | Deletes it |
|
|
17
|
+
| `{cmd: 'notes.reveal', name: 'Shopping'}` | Shows its file in the file manager (Finder, Explorer…) |
|
|
18
|
+
|
|
19
|
+
When files in the folder change, the main process pushes the `notes.changed` event to the windows.
|
|
20
|
+
|
|
21
|
+
## 1. Create the project
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npx @lakutata/cli create --name notes --template electron-react --yes
|
|
25
|
+
cd notes
|
|
26
|
+
rm packages/app/src/controllers/ExampleController.ts packages/app/src/dtos/GreetDTO.ts
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The project is a workspace ([ProjectStructure](../ProjectStructure.md)): `packages/app` is the main process, a lakutata
|
|
30
|
+
application; `packages/ui` is the renderer, a React page built by Vite; `packages/resources` holds the icon. The
|
|
31
|
+
application needs no other package: it reads the files with `node:fs`, and its dialogs come from Electron. The example
|
|
32
|
+
controller of the template is removed.
|
|
33
|
+
|
|
34
|
+
## 2. The input
|
|
35
|
+
|
|
36
|
+
**`packages/app/src/dtos/NoteDTO.ts`**
|
|
37
|
+
|
|
38
|
+
```typescript
|
|
39
|
+
import {DTO} from 'lakutata'
|
|
40
|
+
import {Expect} from 'lakutata/decorator/dto'
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A note by its name, which is its file name without .md: no slash, and no character that a file system refuses
|
|
44
|
+
*/
|
|
45
|
+
export class NoteNameDTO extends DTO {
|
|
46
|
+
@Expect(DTO.String().trim().min(1).max(100).pattern(/^[^\\/:*?"<>|]+$/, 'file name').required())
|
|
47
|
+
public name: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A note to write
|
|
52
|
+
*/
|
|
53
|
+
export class SaveNoteDTO extends NoteNameDTO {
|
|
54
|
+
@Expect(DTO.String().allow('').max(1000000).required())
|
|
55
|
+
public text: string
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Each action validates its input with a DTO before it runs ([DTOValidation](../DTOValidation.md)):
|
|
60
|
+
|
|
61
|
+
- a note is a file `<name>.md` in the folder: the pattern refuses slashes and the characters that Windows does not
|
|
62
|
+
allow in file names, so a name cannot lead out of the folder. The second argument of `.pattern()` names the pattern
|
|
63
|
+
in the error message: `"name" with value "a/b" fails to match the file name pattern`;
|
|
64
|
+
- `.allow('')` accepts an empty note;
|
|
65
|
+
- a message is JSON, so its values keep their types: no `.strict(false)` is needed, unlike the query string of an HTTP
|
|
66
|
+
request ([ServiceCalls](../ServiceCalls.md), section 2).
|
|
67
|
+
|
|
68
|
+
## 3. The exceptions
|
|
69
|
+
|
|
70
|
+
**`packages/app/src/exceptions/NoteExceptions.ts`**
|
|
71
|
+
|
|
72
|
+
```typescript
|
|
73
|
+
import {Exception} from 'lakutata'
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* No notes folder has been chosen yet
|
|
77
|
+
*/
|
|
78
|
+
export class NoFolderException extends Exception {
|
|
79
|
+
public errno: string = 'E_NO_FOLDER'
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The requested note is not in the folder
|
|
84
|
+
*/
|
|
85
|
+
export class NoteNotFoundException extends Exception {
|
|
86
|
+
public errno: string = 'E_NOTE_NOT_FOUND'
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
An exception has an `errno`, the code that the renderer checks ([Exceptions](../Exceptions.md)). Over IPC, the error is
|
|
91
|
+
sent with all its properties ([ServiceCalls](../ServiceCalls.md), section 3), so the renderer receives it as it was
|
|
92
|
+
thrown: `error.errno` is `'E_NOTE_NOT_FOUND'`. The service transports always answer with an envelope, whatever the
|
|
93
|
+
status of the error, so these exceptions need no `statusCode`.
|
|
94
|
+
|
|
95
|
+
## 4. The notes
|
|
96
|
+
|
|
97
|
+
**`packages/app/src/components/Notes.ts`**
|
|
98
|
+
|
|
99
|
+
```typescript
|
|
100
|
+
import {Component, DTO} from 'lakutata'
|
|
101
|
+
import {type Entrypoint} from 'lakutata/com/entrypoint'
|
|
102
|
+
import {Configurable, Inject} from 'lakutata/decorator/di'
|
|
103
|
+
import {existsSync, watch, type FSWatcher} from 'node:fs'
|
|
104
|
+
import {mkdir, readdir, readFile, rm, stat, writeFile} from 'node:fs/promises'
|
|
105
|
+
import path from 'node:path'
|
|
106
|
+
import {NoFolderException, NoteNotFoundException} from '../exceptions/NoteExceptions.js'
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* A note of the folder: its name, and when its file was modified
|
|
110
|
+
*/
|
|
111
|
+
export type NoteInfo = { name: string, modifiedAt: string }
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The notes: the Markdown files in the folder that the user chose, which the settings of the application remember.
|
|
115
|
+
* Changes to its files, made by the application or by another program, are pushed to the windows (notes.changed)
|
|
116
|
+
*/
|
|
117
|
+
export class Notes extends Component {
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Pushes the events to the windows
|
|
121
|
+
*/
|
|
122
|
+
@Inject('entrypoint')
|
|
123
|
+
protected readonly entrypoint: Entrypoint
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The settings file, where the folder is remembered
|
|
127
|
+
*/
|
|
128
|
+
@Configurable(DTO.String().required())
|
|
129
|
+
protected readonly settings: string
|
|
130
|
+
|
|
131
|
+
#folder: string | null = null
|
|
132
|
+
|
|
133
|
+
#watcher: FSWatcher | undefined
|
|
134
|
+
|
|
135
|
+
#timer: NodeJS.Timeout | undefined
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The notes folder, null before one is chosen
|
|
139
|
+
*/
|
|
140
|
+
public get folder(): string | null {
|
|
141
|
+
return this.#folder
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Use the folder remembered in the settings, if it still exists
|
|
146
|
+
*/
|
|
147
|
+
protected async init(): Promise<void> {
|
|
148
|
+
const settings: { folder?: string } = await readFile(this.settings, 'utf8').then((text: string) => JSON.parse(text), () => ({}))
|
|
149
|
+
if (settings.folder && existsSync(settings.folder)) this.watch(settings.folder)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
protected async destroy(): Promise<void> {
|
|
153
|
+
clearTimeout(this.#timer)
|
|
154
|
+
this.#watcher?.close()
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Watch the files of a folder: their changes are pushed to the windows once they settle
|
|
159
|
+
* @param folder
|
|
160
|
+
*/
|
|
161
|
+
protected watch(folder: string): void {
|
|
162
|
+
this.#watcher?.close()
|
|
163
|
+
const watcher: FSWatcher = watch(folder, (): void => {
|
|
164
|
+
clearTimeout(this.#timer)
|
|
165
|
+
this.#timer = setTimeout((): number => this.entrypoint.broadcast('notes.changed'), 100)
|
|
166
|
+
})
|
|
167
|
+
//The folder was removed, or can no longer be read
|
|
168
|
+
watcher.on('error', (): void => watcher.close())
|
|
169
|
+
this.#watcher = watcher
|
|
170
|
+
this.#folder = folder
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Use another folder, and remember it
|
|
175
|
+
* @param folder
|
|
176
|
+
*/
|
|
177
|
+
public async use(folder: string): Promise<void> {
|
|
178
|
+
this.watch(folder)
|
|
179
|
+
await mkdir(path.dirname(this.settings), {recursive: true})
|
|
180
|
+
await writeFile(this.settings, JSON.stringify({folder: folder}))
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The file of a note
|
|
185
|
+
* @param name
|
|
186
|
+
*/
|
|
187
|
+
protected file(name: string): string {
|
|
188
|
+
if (!this.#folder) throw new NoFolderException('No folder of notes is chosen')
|
|
189
|
+
return path.join(this.#folder, `${name}.md`)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The file of an existing note
|
|
194
|
+
* @param name
|
|
195
|
+
*/
|
|
196
|
+
public find(name: string): string {
|
|
197
|
+
const file: string = this.file(name)
|
|
198
|
+
if (!existsSync(file)) throw new NoteNotFoundException(`The note ${name} does not exist`)
|
|
199
|
+
return file
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The notes, the most recently modified first
|
|
204
|
+
*/
|
|
205
|
+
public async list(): Promise<NoteInfo[]> {
|
|
206
|
+
if (!this.#folder) return []
|
|
207
|
+
const notes: NoteInfo[] = []
|
|
208
|
+
for (const entry of await readdir(this.#folder, {withFileTypes: true})) {
|
|
209
|
+
if (!entry.isFile() || !entry.name.endsWith('.md')) continue
|
|
210
|
+
const modified: Date = (await stat(path.join(this.#folder, entry.name))).mtime
|
|
211
|
+
notes.push({name: entry.name.slice(0, -'.md'.length), modifiedAt: modified.toISOString()})
|
|
212
|
+
}
|
|
213
|
+
return notes.sort((a: NoteInfo, b: NoteInfo): number => b.modifiedAt.localeCompare(a.modifiedAt))
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The text of a note
|
|
218
|
+
* @param name
|
|
219
|
+
*/
|
|
220
|
+
public async read(name: string): Promise<string> {
|
|
221
|
+
return await readFile(this.find(name), 'utf8')
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Write a note, new or existing
|
|
226
|
+
* @param name
|
|
227
|
+
* @param text
|
|
228
|
+
*/
|
|
229
|
+
public async write(name: string, text: string): Promise<NoteInfo> {
|
|
230
|
+
const file: string = this.file(name)
|
|
231
|
+
await writeFile(file, text)
|
|
232
|
+
return {name: name, modifiedAt: (await stat(file)).mtime.toISOString()}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Delete a note
|
|
237
|
+
* @param name
|
|
238
|
+
*/
|
|
239
|
+
public async remove(name: string): Promise<void> {
|
|
240
|
+
await rm(this.find(name))
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
- A component is created once and shared by the whole application ([Objects](../Objects.md)). It keeps the folder,
|
|
246
|
+
which outlives the requests: a controller lives for one request only ([Controllers](../Controllers.md), section 2).
|
|
247
|
+
- `settings`, the file where the folder is remembered, is an option of the component: `@Configurable` declares it, and
|
|
248
|
+
the options of the application give its value ([Injection](../Injection.md)). `init()` reads the file when the
|
|
249
|
+
application starts.
|
|
250
|
+
- `watch()` from `node:fs` watches the folder. When its files change, whether the application or another program (an
|
|
251
|
+
editor, a sync tool) saved them, `this.entrypoint.broadcast()` pushes `notes.changed` to every window
|
|
252
|
+
([ServiceCalls](../ServiceCalls.md), section 5). The timer waits for the changes to settle, as one save often causes
|
|
253
|
+
several of them.
|
|
254
|
+
- `destroy()` stops watching when the application stops.
|
|
255
|
+
|
|
256
|
+
## 5. The controller
|
|
257
|
+
|
|
258
|
+
**`packages/app/src/controllers/NoteController.ts`**
|
|
259
|
+
|
|
260
|
+
```typescript
|
|
261
|
+
import {Controller, type ServiceContext} from 'lakutata/com/entrypoint'
|
|
262
|
+
import {ipcEventOfContext} from 'lakutata/adapter/electron'
|
|
263
|
+
import {ServiceAction} from 'lakutata/decorator/ctrl'
|
|
264
|
+
import {Inject} from 'lakutata/decorator/di'
|
|
265
|
+
import type {BrowserWindow, IpcMainInvokeEvent, OpenDialogOptions, OpenDialogReturnValue} from 'electron'
|
|
266
|
+
import {type NoteInfo, Notes} from '../components/Notes.js'
|
|
267
|
+
import {NoteNameDTO, SaveNoteDTO} from '../dtos/NoteDTO.js'
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The note actions, invoked by the window: window.ipc.invoke({cmd: 'notes.list'})
|
|
271
|
+
*/
|
|
272
|
+
export class NoteController extends Controller {
|
|
273
|
+
|
|
274
|
+
@Inject('notes')
|
|
275
|
+
protected readonly notes: Notes
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* The notes folder, null before one is chosen
|
|
279
|
+
*/
|
|
280
|
+
@ServiceAction({cmd: 'folder.get'})
|
|
281
|
+
public async folder(): Promise<string | null> {
|
|
282
|
+
return this.notes.folder
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Ask the user for a folder in a system dialog, attached to the window that called; the folder stays the same when
|
|
287
|
+
* the dialog is canceled
|
|
288
|
+
*/
|
|
289
|
+
@ServiceAction({cmd: 'folder.choose'})
|
|
290
|
+
public async choose(): Promise<string | null> {
|
|
291
|
+
//Electron is loaded by the actions that use it: the tests, run by Node.js, do not invoke them
|
|
292
|
+
const {BrowserWindow, dialog} = (await import('electron')).default
|
|
293
|
+
const event: IpcMainInvokeEvent | undefined = ipcEventOfContext<IpcMainInvokeEvent>(this.context as ServiceContext)
|
|
294
|
+
const window: BrowserWindow | null = event ? BrowserWindow.fromWebContents(event.sender) : null
|
|
295
|
+
const options: OpenDialogOptions = {title: 'The folder of the notes', properties: ['openDirectory', 'createDirectory']}
|
|
296
|
+
const result: OpenDialogReturnValue = window ? await dialog.showOpenDialog(window, options) : await dialog.showOpenDialog(options)
|
|
297
|
+
if (!result.canceled && result.filePaths.length) await this.notes.use(result.filePaths[0])
|
|
298
|
+
return this.notes.folder
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* The notes, the most recently modified first
|
|
303
|
+
*/
|
|
304
|
+
@ServiceAction({cmd: 'notes.list'})
|
|
305
|
+
public async list(): Promise<NoteInfo[]> {
|
|
306
|
+
return await this.notes.list()
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* A note and its text
|
|
311
|
+
* @param input
|
|
312
|
+
*/
|
|
313
|
+
@ServiceAction({cmd: 'notes.read'}, NoteNameDTO)
|
|
314
|
+
public async read(input: NoteNameDTO): Promise<{ name: string, text: string }> {
|
|
315
|
+
return {name: input.name, text: await this.notes.read(input.name)}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Write a note: the file <name>.md in the folder
|
|
320
|
+
* @param input
|
|
321
|
+
*/
|
|
322
|
+
@ServiceAction({cmd: 'notes.save'}, SaveNoteDTO)
|
|
323
|
+
public async save(input: SaveNoteDTO): Promise<NoteInfo> {
|
|
324
|
+
return await this.notes.write(input.name, input.text)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Delete a note
|
|
329
|
+
* @param input
|
|
330
|
+
*/
|
|
331
|
+
@ServiceAction({cmd: 'notes.delete'}, NoteNameDTO)
|
|
332
|
+
public async remove(input: NoteNameDTO): Promise<void> {
|
|
333
|
+
await this.notes.remove(input.name)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Show the file of a note in the system file manager (Finder, Explorer…)
|
|
338
|
+
* @param input
|
|
339
|
+
*/
|
|
340
|
+
@ServiceAction({cmd: 'notes.reveal'}, NoteNameDTO)
|
|
341
|
+
public async reveal(input: NoteNameDTO): Promise<void> {
|
|
342
|
+
const {shell} = (await import('electron')).default
|
|
343
|
+
shell.showItemInFolder(this.notes.find(input.name))
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
- Each `@ServiceAction` is called by a message: `{cmd: 'notes.save', name: 'Shopping', text: '- bread'}` calls `save()`
|
|
349
|
+
with `{name: 'Shopping', text: '- bread'}`, validated by `SaveNoteDTO` ([ServiceCalls](../ServiceCalls.md),
|
|
350
|
+
section 2). The actions are short: the work is done by the `notes` component, which the controller injects.
|
|
351
|
+
- `folder.choose` and `notes.reveal` use APIs that only the main process has: `dialog` and `shell`. Like the
|
|
352
|
+
`MainWindow` of the template, they import Electron only when they run, so the tests, which run with Node.js, never
|
|
353
|
+
load it.
|
|
354
|
+
- `ipcEventOfContext()` gives the IPC event of the request. Its `sender` is the page that called, and
|
|
355
|
+
`BrowserWindow.fromWebContents()` gives the window of that page, which the dialog belongs to (on macOS, a sheet
|
|
356
|
+
attached to the window). When the action is called over the HTTP entrypoint for development (section 9), there is no
|
|
357
|
+
IPC event, and the dialog belongs to no window.
|
|
358
|
+
- An action that returns nothing, such as `remove()`, answers `{success: true, error: null}`.
|
|
359
|
+
|
|
360
|
+
## 6. The options of the application
|
|
361
|
+
|
|
362
|
+
**`packages/app/src/config/Config.ts`**
|
|
363
|
+
|
|
364
|
+
```typescript
|
|
365
|
+
import type {ApplicationOptions} from 'lakutata'
|
|
366
|
+
import {buildEntrypoints, httpServiceEntrypoint} from 'lakutata/com/entrypoint'
|
|
367
|
+
import {electronIPCServiceEntrypoint, type IPCMain} from 'lakutata/adapter/electron'
|
|
368
|
+
import {MainWindow} from '../components/MainWindow.js'
|
|
369
|
+
import {Notes} from '../components/Notes.js'
|
|
370
|
+
import {NoteController} from '../controllers/NoteController.js'
|
|
371
|
+
|
|
372
|
+
export type ConfigOptions = {
|
|
373
|
+
/**
|
|
374
|
+
* The settings file, where the notes folder is remembered
|
|
375
|
+
*/
|
|
376
|
+
settings: string
|
|
377
|
+
/**
|
|
378
|
+
* The directory of the main process, where the preload script and the built renderer are
|
|
379
|
+
*/
|
|
380
|
+
directory?: string
|
|
381
|
+
/**
|
|
382
|
+
* The URL of the renderer in development (the Vite dev server); without it, the built renderer is loaded
|
|
383
|
+
*/
|
|
384
|
+
renderer?: string
|
|
385
|
+
mode?: 'development' | 'production'
|
|
386
|
+
/**
|
|
387
|
+
* Whether to open the window, true by default (the tests serve the actions without a window)
|
|
388
|
+
*/
|
|
389
|
+
window?: boolean
|
|
390
|
+
/**
|
|
391
|
+
* The ipcMain that serves the actions, Electron's by default (the tests pass their own)
|
|
392
|
+
*/
|
|
393
|
+
ipcMain?: IPCMain
|
|
394
|
+
/**
|
|
395
|
+
* The port of an HTTP entrypoint for development, none by default. Tools (Postman, curl) invoke the actions with
|
|
396
|
+
* POST / and the message (the pattern and the input), receive the events pushed to them with GET /events
|
|
397
|
+
* (Server-Sent Events), and give their client id in the id header (or the id query parameter)
|
|
398
|
+
*/
|
|
399
|
+
devPort?: number
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* The options of the application
|
|
404
|
+
* @param options
|
|
405
|
+
*/
|
|
406
|
+
export function config(options: ConfigOptions): ApplicationOptions {
|
|
407
|
+
return {
|
|
408
|
+
id: 'notes',
|
|
409
|
+
name: 'notes',
|
|
410
|
+
mode: options.mode ?? 'development',
|
|
411
|
+
components: {
|
|
412
|
+
entrypoint: buildEntrypoints({
|
|
413
|
+
controllers: [NoteController],
|
|
414
|
+
//The renderer invokes the actions over IPC: window.ipc.invoke({cmd: 'notes.list'})
|
|
415
|
+
service: [
|
|
416
|
+
electronIPCServiceEntrypoint({ipcMain: options.ipcMain}),
|
|
417
|
+
...options.devPort === undefined ? [] : [httpServiceEntrypoint({port: options.devPort})]
|
|
418
|
+
]
|
|
419
|
+
}),
|
|
420
|
+
notes: {
|
|
421
|
+
class: Notes,
|
|
422
|
+
settings: options.settings
|
|
423
|
+
},
|
|
424
|
+
...(options.window === false ? {} : {
|
|
425
|
+
window: {
|
|
426
|
+
class: MainWindow,
|
|
427
|
+
directory: options.directory,
|
|
428
|
+
renderer: options.renderer
|
|
429
|
+
}
|
|
430
|
+
})
|
|
431
|
+
},
|
|
432
|
+
//The actions are served, and the folder watched, before the window loads the renderer
|
|
433
|
+
bootstrap: options.window === false ? ['entrypoint', 'notes'] : ['entrypoint', 'notes', 'window']
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
This is the `Config.ts` of the template, with:
|
|
439
|
+
|
|
440
|
+
- the required `settings` option, the settings file;
|
|
441
|
+
- the `notes` component, which receives that file, and the `NoteController` instead of the example controller;
|
|
442
|
+
- `bootstrap: ['entrypoint', 'notes', 'window']`: the actions are served and the folder is watched before the window
|
|
443
|
+
loads the renderer. The tests start the application without its window (`window: false`), with their own `ipcMain`.
|
|
444
|
+
|
|
445
|
+
## 7. The main process
|
|
446
|
+
|
|
447
|
+
**`packages/app/src/App.ts`**
|
|
448
|
+
|
|
449
|
+
```typescript
|
|
450
|
+
import {Application, resourcesPath, type ILogger} from 'lakutata'
|
|
451
|
+
import electron from 'electron'
|
|
452
|
+
import path from 'node:path'
|
|
453
|
+
import {fileURLToPath} from 'node:url'
|
|
454
|
+
import {config} from './config/Config.js'
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* The directory of the preload script and of the built renderer: the resources of the packaged application
|
|
458
|
+
* (lakutata.resources), or in development the directory of the main process (build/, with the renderer from the ui
|
|
459
|
+
* package)
|
|
460
|
+
*/
|
|
461
|
+
const directory: string = resourcesPath() ?? path.dirname(fileURLToPath(import.meta.url))
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* The URL of the renderer in development (--renderer=<url>, the Vite dev server)
|
|
465
|
+
*/
|
|
466
|
+
const renderer: string | undefined = process.argv.find((argument: string): boolean => argument.startsWith('--renderer='))?.slice('--renderer='.length)
|
|
467
|
+
|
|
468
|
+
//A single instance of the application: a second one exits, and the first one brings its window to the front
|
|
469
|
+
if (!electron.app.requestSingleInstanceLock()) {
|
|
470
|
+
electron.app.exit(0)
|
|
471
|
+
} else {
|
|
472
|
+
Application
|
|
473
|
+
.run(config({
|
|
474
|
+
//The notes folder is remembered in the data directory of the application
|
|
475
|
+
settings: path.join(electron.app.getPath('userData'), 'settings.json'),
|
|
476
|
+
directory: directory,
|
|
477
|
+
renderer: renderer,
|
|
478
|
+
mode: electron.app.isPackaged ? 'production' : 'development',
|
|
479
|
+
//In development, the actions are also served to tools (Postman, curl); the packaged application does not serve them
|
|
480
|
+
devPort: electron.app.isPackaged ? undefined : Number(process.env.DEV_PORT ?? 9000)
|
|
481
|
+
}))
|
|
482
|
+
.onLaunched((app: Application, log: ILogger): void => log.info('%s started in %s mode', app.appName, app.mode()))
|
|
483
|
+
.onFatalException((error: Error, log: ILogger): void => {
|
|
484
|
+
log.error('The application cannot start: %s', error.message)
|
|
485
|
+
electron.app.exit(1)
|
|
486
|
+
})
|
|
487
|
+
}
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
One option is added to the `App.ts` of the template: `settings`, the file that remembers the notes folder, in the data
|
|
491
|
+
directory of the application (`app.getPath('userData')`). That directory is named after the `productName` of
|
|
492
|
+
`packages/app/package.json` (`notes`), in development as in the packaged application:
|
|
493
|
+
`~/Library/Application Support/notes` on macOS, `%APPDATA%\notes` on Windows and `~/.config/notes` on Linux.
|
|
494
|
+
|
|
495
|
+
The preload script of the template (`packages/app/src/preload.cts`) stays as it is: it gives the renderer the IPC
|
|
496
|
+
channel for the actions and the events, and nothing else from Electron or Node.js.
|
|
497
|
+
|
|
498
|
+
## 8. The window
|
|
499
|
+
|
|
500
|
+
**`packages/ui/src/App.tsx`**
|
|
501
|
+
|
|
502
|
+
```tsx
|
|
503
|
+
import {type FormEvent, type KeyboardEvent, useEffect, useState} from 'react'
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* A note of the folder, as the main process lists it
|
|
507
|
+
*/
|
|
508
|
+
type NoteInfo = { name: string, modifiedAt: string }
|
|
509
|
+
|
|
510
|
+
export function App() {
|
|
511
|
+
const [folder, setFolder] = useState<string | null>(null)
|
|
512
|
+
const [notes, setNotes] = useState<NoteInfo[]>([])
|
|
513
|
+
const [current, setCurrent] = useState<string | null>(null)
|
|
514
|
+
const [text, setText] = useState<string>('')
|
|
515
|
+
const [newName, setNewName] = useState<string>('')
|
|
516
|
+
const [status, setStatus] = useState<string>('')
|
|
517
|
+
|
|
518
|
+
//Run an action of the main process; its error (a note not found, an invalid name) is shown under the note
|
|
519
|
+
const run = (action: () => Promise<void>): void => {
|
|
520
|
+
action().catch((error: Error): void => setStatus(error.message))
|
|
521
|
+
}
|
|
522
|
+
const list = (): void => run(async (): Promise<void> => setNotes(await window.ipc.invoke<NoteInfo[]>({cmd: 'notes.list'})))
|
|
523
|
+
|
|
524
|
+
useEffect((): () => void => {
|
|
525
|
+
run(async (): Promise<void> => setFolder(await window.ipc.invoke<string | null>({cmd: 'folder.get'})))
|
|
526
|
+
//Pushed by the main process when files in the folder change, whether saved here or by another program
|
|
527
|
+
window.ipc.on('notes.changed', list)
|
|
528
|
+
return (): void => {
|
|
529
|
+
window.ipc.off('notes.changed', list)
|
|
530
|
+
}
|
|
531
|
+
}, [])
|
|
532
|
+
|
|
533
|
+
useEffect((): void => {
|
|
534
|
+
if (folder) list()
|
|
535
|
+
}, [folder])
|
|
536
|
+
|
|
537
|
+
const choose = (): void => run(async (): Promise<void> => setFolder(await window.ipc.invoke<string | null>({cmd: 'folder.choose'})))
|
|
538
|
+
|
|
539
|
+
const open = (name: string): void => run(async (): Promise<void> => {
|
|
540
|
+
const note = await window.ipc.invoke<{ name: string, text: string }>({cmd: 'notes.read', name: name})
|
|
541
|
+
setCurrent(note.name)
|
|
542
|
+
setText(note.text)
|
|
543
|
+
setStatus('')
|
|
544
|
+
})
|
|
545
|
+
|
|
546
|
+
const create = (event: FormEvent): void => {
|
|
547
|
+
event.preventDefault()
|
|
548
|
+
run(async (): Promise<void> => {
|
|
549
|
+
await window.ipc.invoke({cmd: 'notes.save', name: newName, text: ''})
|
|
550
|
+
setCurrent(newName.trim())
|
|
551
|
+
setText('')
|
|
552
|
+
setNewName('')
|
|
553
|
+
setStatus('')
|
|
554
|
+
})
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const save = (): void => run(async (): Promise<void> => {
|
|
558
|
+
await window.ipc.invoke({cmd: 'notes.save', name: current, text: text})
|
|
559
|
+
setStatus(`Saved at ${new Date().toLocaleTimeString()}`)
|
|
560
|
+
})
|
|
561
|
+
|
|
562
|
+
const remove = (): void => run(async (): Promise<void> => {
|
|
563
|
+
await window.ipc.invoke({cmd: 'notes.delete', name: current})
|
|
564
|
+
setCurrent(null)
|
|
565
|
+
setStatus('')
|
|
566
|
+
})
|
|
567
|
+
|
|
568
|
+
//Ctrl+S, or Cmd+S on macOS
|
|
569
|
+
const shortcut = (event: KeyboardEvent): void => {
|
|
570
|
+
if ((event.ctrlKey || event.metaKey) && event.key === 's') {
|
|
571
|
+
event.preventDefault()
|
|
572
|
+
save()
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
if (!folder) {
|
|
577
|
+
return (
|
|
578
|
+
<main style={{fontFamily: 'system-ui', padding: 32}}>
|
|
579
|
+
<p>The notes are Markdown files, in a folder of your choice.</p>
|
|
580
|
+
<button onClick={choose}>Choose a folder…</button>
|
|
581
|
+
<p>{status}</p>
|
|
582
|
+
</main>
|
|
583
|
+
)
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
return (
|
|
587
|
+
<main style={{fontFamily: 'system-ui', position: 'fixed', inset: 0, display: 'grid', gridTemplateColumns: '240px 1fr'}}>
|
|
588
|
+
<aside style={{borderRight: '1px solid #ddd', padding: 12, overflow: 'auto'}}>
|
|
589
|
+
<button onClick={choose} title={folder} style={{width: '100%'}}>{folder.split(/[\\/]/).pop()}</button>
|
|
590
|
+
<form onSubmit={create}>
|
|
591
|
+
<input value={newName} onChange={(event): void => setNewName(event.target.value)} placeholder="New note" style={{width: '100%', margin: '12px 0', boxSizing: 'border-box'}}/>
|
|
592
|
+
</form>
|
|
593
|
+
{notes.map((note: NoteInfo) => (
|
|
594
|
+
<div key={note.name} onClick={(): void => open(note.name)} style={{padding: 6, cursor: 'pointer', fontWeight: note.name === current ? 'bold' : 'normal'}}>
|
|
595
|
+
{note.name}
|
|
596
|
+
<div style={{fontSize: 11, color: '#888'}}>{new Date(note.modifiedAt).toLocaleString()}</div>
|
|
597
|
+
</div>
|
|
598
|
+
))}
|
|
599
|
+
</aside>
|
|
600
|
+
<section style={{display: 'flex', flexDirection: 'column', padding: 12, gap: 8}}>
|
|
601
|
+
{current === null ? <p>Open a note, or create one.</p> : (
|
|
602
|
+
<>
|
|
603
|
+
<div style={{display: 'flex', gap: 8, alignItems: 'center'}}>
|
|
604
|
+
<strong style={{flex: 1}}>{current}</strong>
|
|
605
|
+
<button onClick={save}>Save</button>
|
|
606
|
+
<button onClick={(): void => run(() => window.ipc.invoke({cmd: 'notes.reveal', name: current}))}>Show in folder</button>
|
|
607
|
+
<button onClick={remove}>Delete</button>
|
|
608
|
+
</div>
|
|
609
|
+
<textarea value={text} onChange={(event): void => setText(event.target.value)} onKeyDown={shortcut} style={{flex: 1, fontFamily: 'ui-monospace, monospace', fontSize: 14}}/>
|
|
610
|
+
</>
|
|
611
|
+
)}
|
|
612
|
+
<small style={{color: '#888'}}>{status}</small>
|
|
613
|
+
</section>
|
|
614
|
+
</main>
|
|
615
|
+
)
|
|
616
|
+
}
|
|
617
|
+
```
|
|
618
|
+
|
|
619
|
+
- `window.ipc` comes from the template (`packages/ui/src/ipc.ts`), through the preload script. `invoke()` sends a
|
|
620
|
+
message and returns the result of the action, or throws its error, `errno` included; `on()` and `off()` listen to
|
|
621
|
+
the events of the main process ([ServiceCalls](../ServiceCalls.md), section 4).
|
|
622
|
+
- The renderer is isolated from Node.js and Electron: it reads no files and opens no dialogs itself, but calls the
|
|
623
|
+
actions that do.
|
|
624
|
+
- On `notes.changed`, it lists the notes again: a note saved by an editor appears in the window.
|
|
625
|
+
- Errors, such as an invalid name, are shown under the note.
|
|
626
|
+
|
|
627
|
+
## 9. Run it
|
|
628
|
+
|
|
629
|
+
```bash
|
|
630
|
+
npm run dev
|
|
631
|
+
```
|
|
632
|
+
|
|
633
|
+
The command starts the Vite dev server, which serves the renderer and reloads it whenever `packages/ui` changes, and
|
|
634
|
+
Electron, whose window loads the renderer with its DevTools open. The main process logs
|
|
635
|
+
`notes started in development mode`. The first time, the window asks you to choose a folder; after that, it opens the
|
|
636
|
+
folder remembered in the settings.
|
|
637
|
+
|
|
638
|
+
In development, the actions are also served over HTTP on port 9000 (`DEV_PORT`), so you can try them with curl or
|
|
639
|
+
Postman: `POST /` sends a message, and `GET /events` streams the events of a client as Server-Sent Events
|
|
640
|
+
([ServiceCalls](../ServiceCalls.md), section 4). Once a folder is chosen in the window (here, one that already holds a
|
|
641
|
+
note `Ideas`), run in another terminal:
|
|
642
|
+
|
|
643
|
+
```bash
|
|
644
|
+
curl -N 'localhost:9000/events?id=me' &
|
|
645
|
+
# event: client
|
|
646
|
+
# data: ["me"]
|
|
647
|
+
|
|
648
|
+
curl -X POST localhost:9000/ -H 'Content-Type: application/json' -d '{"cmd": "notes.save", "name": "Shopping", "text": "- bread\n- tea\n"}'
|
|
649
|
+
# {"success":true,"error":null,"payload":{"name":"Shopping","modifiedAt":"2026-10-17T14:57:41.391Z"}}
|
|
650
|
+
# event: notes.changed
|
|
651
|
+
# data: []
|
|
652
|
+
|
|
653
|
+
curl -X POST localhost:9000/ -H 'Content-Type: application/json' -d '{"cmd": "notes.list"}'
|
|
654
|
+
# {"success":true,"error":null,"payload":[{"name":"Shopping","modifiedAt":"2026-10-17T14:57:41.391Z"},{"name":"Ideas","modifiedAt":"2026-10-17T14:57:23.287Z"}]}
|
|
655
|
+
|
|
656
|
+
curl -X POST localhost:9000/ -H 'Content-Type: application/json' -d '{"cmd": "notes.read", "name": "Lunch"}'
|
|
657
|
+
# {"success":false,"error":{…,"message":"The note Lunch does not exist","errMsg":"The note Lunch does not exist","err":"NoteNotFoundException","errno":"E_NOTE_NOT_FOUND"},"payload":null}
|
|
658
|
+
```
|
|
659
|
+
|
|
660
|
+
The window shows the new note at once, as the event reached it too. The error carries all the properties of the
|
|
661
|
+
exception, including its stack (left out here).
|
|
662
|
+
|
|
663
|
+
## 10. Test it
|
|
664
|
+
|
|
665
|
+
The test of the template is replaced by a test of the notes. Like the test of the template, it runs the main process
|
|
666
|
+
with Node.js, without Electron or a window: its own `ipcMain` stands in for Electron's, and the test calls the actions
|
|
667
|
+
through it as the renderer does. The settings of the test remember a folder in a temporary directory.
|
|
668
|
+
|
|
669
|
+
**`packages/app/src/tests/App.spec.ts`**
|
|
670
|
+
|
|
671
|
+
```typescript
|
|
672
|
+
import {after, before, describe, it} from 'node:test'
|
|
673
|
+
import assert from 'node:assert'
|
|
674
|
+
import {existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs'
|
|
675
|
+
import {tmpdir} from 'node:os'
|
|
676
|
+
import path from 'node:path'
|
|
677
|
+
import {Application} from 'lakutata'
|
|
678
|
+
import type {IPCMain} from 'lakutata/adapter/electron'
|
|
679
|
+
import {config} from '../config/Config.js'
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* The ipcMain of the tests: through it, the actions are invoked as the renderer invokes them, without Electron
|
|
683
|
+
*/
|
|
684
|
+
const handlers: Map<string, (event: unknown, data: unknown) => Promise<unknown>> = new Map()
|
|
685
|
+
const listeners: Map<string, (event: unknown, clientId: string) => void> = new Map()
|
|
686
|
+
const ipcMain: IPCMain = {
|
|
687
|
+
handle: (channel: string, listener: (event: unknown, data: unknown) => Promise<unknown>): unknown => handlers.set(channel, listener),
|
|
688
|
+
removeHandler: (channel: string): unknown => handlers.delete(channel),
|
|
689
|
+
on: (channel: string, listener: (event: unknown, clientId: string) => void): unknown => listeners.set(channel, listener),
|
|
690
|
+
removeAllListeners: (channel: string): unknown => listeners.delete(channel)
|
|
691
|
+
}
|
|
692
|
+
//The web contents of the renderer, which collects the events pushed to it
|
|
693
|
+
const received: unknown[][] = []
|
|
694
|
+
const sender = {once: (): void => undefined, off: (): void => undefined, send: (...args: unknown[]): unknown => received.push(args)}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Invoke an action as the renderer does (window.ipc.invoke)
|
|
698
|
+
* @param data
|
|
699
|
+
*/
|
|
700
|
+
function invoke(data: Record<string, unknown>): Promise<any> {
|
|
701
|
+
return (handlers.get('lakutata:service') as (event: unknown, data: unknown, clientId: string) => Promise<unknown>)({sender: sender}, data, 'page')
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* Wait until a condition holds, 5 seconds at most
|
|
706
|
+
* @param condition
|
|
707
|
+
*/
|
|
708
|
+
async function until(condition: () => boolean): Promise<void> {
|
|
709
|
+
for (let waited: number = 0; !condition(); waited += 50) {
|
|
710
|
+
if (waited >= 5000) throw new Error('Waited too long')
|
|
711
|
+
await new Promise((resolve: (value: unknown) => void): unknown => setTimeout(resolve, 50))
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
describe('notes', (): void => {
|
|
716
|
+
|
|
717
|
+
//The data directory of the application, and the notes folder that its settings remember
|
|
718
|
+
const data: string = mkdtempSync(path.join(tmpdir(), 'notes-'))
|
|
719
|
+
const folder: string = path.join(data, 'My notes')
|
|
720
|
+
let app: Application
|
|
721
|
+
|
|
722
|
+
before(async (): Promise<void> => {
|
|
723
|
+
mkdirSync(folder)
|
|
724
|
+
writeFileSync(path.join(data, 'settings.json'), JSON.stringify({folder: folder}))
|
|
725
|
+
app = await new Promise<Application>((resolve: (app: Application) => void): void => {
|
|
726
|
+
Application.run(config({settings: path.join(data, 'settings.json'), window: false, ipcMain: ipcMain})).onLaunched((launched: Application): void => resolve(launched))
|
|
727
|
+
})
|
|
728
|
+
})
|
|
729
|
+
|
|
730
|
+
after(async (): Promise<void> => {
|
|
731
|
+
await app?.dispose()
|
|
732
|
+
rmSync(data, {recursive: true, force: true})
|
|
733
|
+
})
|
|
734
|
+
|
|
735
|
+
it('gives the folder remembered by the settings', async (): Promise<void> => {
|
|
736
|
+
assert.deepStrictEqual(await invoke({cmd: 'folder.get'}), {success: true, error: null, payload: folder})
|
|
737
|
+
})
|
|
738
|
+
|
|
739
|
+
it('pushes notes.changed to the renderer when the files of the folder change', async (): Promise<void> => {
|
|
740
|
+
//The page subscribes to the events
|
|
741
|
+
listeners.get('lakutata:service:subscribe')!({sender: sender}, 'page')
|
|
742
|
+
//Another program writes a note
|
|
743
|
+
writeFileSync(path.join(folder, 'Ideas.md'), '# Ideas\n')
|
|
744
|
+
await until((): boolean => received.length > 0)
|
|
745
|
+
assert.deepStrictEqual(received[0], ['lakutata:service:event', 'notes.changed', []])
|
|
746
|
+
})
|
|
747
|
+
|
|
748
|
+
it('saves, lists, reads and deletes the notes', async (): Promise<void> => {
|
|
749
|
+
const saved = await invoke({cmd: 'notes.save', name: 'Shopping', text: '- bread\n- tea\n'})
|
|
750
|
+
assert.strictEqual(saved.payload.name, 'Shopping')
|
|
751
|
+
assert.strictEqual(readFileSync(path.join(folder, 'Shopping.md'), 'utf8'), '- bread\n- tea\n')
|
|
752
|
+
const listed = await invoke({cmd: 'notes.list'})
|
|
753
|
+
assert.deepStrictEqual(listed.payload.map((note: { name: string }): string => note.name), ['Shopping', 'Ideas'])
|
|
754
|
+
assert.deepStrictEqual((await invoke({cmd: 'notes.read', name: 'Shopping'})).payload, {name: 'Shopping', text: '- bread\n- tea\n'})
|
|
755
|
+
assert.strictEqual((await invoke({cmd: 'notes.delete', name: 'Shopping'})).success, true)
|
|
756
|
+
assert.strictEqual(existsSync(path.join(folder, 'Shopping.md')), false)
|
|
757
|
+
})
|
|
758
|
+
|
|
759
|
+
it('refuses a note that does not exist', async (): Promise<void> => {
|
|
760
|
+
const response = await invoke({cmd: 'notes.read', name: 'Missing'})
|
|
761
|
+
assert.strictEqual(response.success, false)
|
|
762
|
+
assert.strictEqual(response.error.errno, 'E_NOTE_NOT_FOUND')
|
|
763
|
+
})
|
|
764
|
+
|
|
765
|
+
it('refuses a name that is not a file name', async (): Promise<void> => {
|
|
766
|
+
const response = await invoke({cmd: 'notes.save', name: '../outside', text: ''})
|
|
767
|
+
assert.strictEqual(response.success, false)
|
|
768
|
+
assert.match(response.error.message, /file name/)
|
|
769
|
+
assert.strictEqual(existsSync(path.join(data, 'outside.md')), false)
|
|
770
|
+
})
|
|
771
|
+
})
|
|
772
|
+
```
|
|
773
|
+
|
|
774
|
+
```bash
|
|
775
|
+
npm test
|
|
776
|
+
```
|
|
777
|
+
|
|
778
|
+
```
|
|
779
|
+
app │ ✔ gives the folder remembered by the settings
|
|
780
|
+
app │ ✔ pushes notes.changed to the renderer when the files of the folder change
|
|
781
|
+
app │ ✔ saves, lists, reads and deletes the notes
|
|
782
|
+
app │ ✔ refuses a note that does not exist
|
|
783
|
+
app │ ✔ refuses a name that is not a file name
|
|
784
|
+
app │ ℹ pass 5
|
|
785
|
+
✔ test ran in app
|
|
786
|
+
```
|
|
787
|
+
|
|
788
|
+
`npm test` builds the packages, the renderer included, then runs the tests. The two actions that use Electron, the
|
|
789
|
+
dialog and the file manager, are tried out in the window.
|
|
790
|
+
|
|
791
|
+
## 11. Package it
|
|
792
|
+
|
|
793
|
+
- `npm run package:host` builds the packages, then makes the installers for the platform it runs on, in
|
|
794
|
+
`dist/installer`: a `dmg` on macOS, a `deb` on Linux, an `nsis` installer and a portable executable on Windows.
|
|
795
|
+
`npm run package:linux` makes the Linux ones in Docker, from any platform. They are made by electron-builder, from
|
|
796
|
+
`lakutata.installer` in `packages/app/package.json` ([Installers](../Installers.md), section 8).
|
|
797
|
+
- The main process is bundled with its dependencies. The built renderer and the preload script go into the resources of
|
|
798
|
+
Electron (`lakutata.resources` in `packages/app/package.json`), where `resourcesPath()` finds them
|
|
799
|
+
([Packaging](../Packaging.md), section 6).
|
|
800
|
+
- The packaged application has no HTTP entrypoint (`devPort` is given only when `app.isPackaged` is false), and keeps
|
|
801
|
+
its settings in the same data directory.
|
|
802
|
+
|
|
803
|
+
<!--
|
|
804
|
+
check:
|
|
805
|
+
create: notes electron-react
|
|
806
|
+
remove: packages/app/src/controllers/ExampleController.ts packages/app/src/dtos/GreetDTO.ts
|
|
807
|
+
run: npm test
|
|
808
|
+
run: npm run typecheck -w ui
|
|
809
|
+
-->
|