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.
Files changed (117) hide show
  1. package/README.md +50 -30
  2. package/README.zh-CN.md +130 -0
  3. package/dist/cjs/exports/QueueComponent.d.ts +1 -0
  4. package/dist/cjs/exports/QueueComponent.js +20 -0
  5. package/dist/esm/exports/QueueComponent.js +1 -0
  6. package/dist/types/exports/QueueComponent.d.ts +1 -0
  7. package/doc/en/Application.md +107 -0
  8. package/doc/en/CLI.md +274 -0
  9. package/doc/en/Cache.md +379 -0
  10. package/doc/en/Command.md +110 -0
  11. package/doc/en/ContainerdIntegration.md +17 -16
  12. package/doc/en/Controllers.md +256 -0
  13. package/doc/en/DBus.md +291 -0
  14. package/doc/en/DTOValidation.md +72 -43
  15. package/doc/en/Database.md +188 -0
  16. package/doc/en/DatabaseDrivers.md +287 -0
  17. package/doc/en/Decorator.md +52 -40
  18. package/doc/en/DockerIntegration.md +7 -6
  19. package/doc/en/Entities.md +240 -0
  20. package/doc/en/Entrypoint.md +216 -0
  21. package/doc/en/Exceptions.md +174 -0
  22. package/doc/en/GRPC.md +251 -0
  23. package/doc/en/GettingStarted.md +165 -0
  24. package/doc/en/HTTP.md +507 -0
  25. package/doc/en/HelperFunctions.md +11 -11
  26. package/doc/en/I18n.md +119 -115
  27. package/doc/en/Injection.md +144 -0
  28. package/doc/en/Installers.md +280 -0
  29. package/doc/en/Logger.md +206 -0
  30. package/doc/en/MCP.md +293 -0
  31. package/doc/en/Middlewares.md +213 -0
  32. package/doc/en/Migrations.md +202 -0
  33. package/doc/en/MongoEmbedded.md +149 -0
  34. package/doc/en/Monitor.md +164 -0
  35. package/doc/en/Mutex.md +27 -25
  36. package/doc/en/NATS.md +246 -0
  37. package/doc/en/NativeAddons.md +212 -0
  38. package/doc/en/Objects.md +143 -0
  39. package/doc/en/Overview.md +62 -85
  40. package/doc/en/Packaging.md +193 -0
  41. package/doc/en/ProjectStructure.md +124 -0
  42. package/doc/en/Queue.md +269 -0
  43. package/doc/en/Schedules.md +217 -0
  44. package/doc/en/Security.md +10 -9
  45. package/doc/en/ServiceCalls.md +504 -0
  46. package/doc/en/Time.md +19 -20
  47. package/doc/en/UpgradingFrom1.md +435 -0
  48. package/doc/en/UpgradingFrom2.md +315 -0
  49. package/doc/en/Workspaces.md +189 -0
  50. package/doc/en/examples/BackgroundJobs.md +677 -0
  51. package/doc/en/examples/CommandLineTool.md +664 -0
  52. package/doc/en/examples/DesktopApp.md +809 -0
  53. package/doc/en/examples/DeviceService.md +607 -0
  54. package/doc/en/examples/MCPServer.md +653 -0
  55. package/doc/en/examples/README.md +16 -0
  56. package/doc/en/examples/RestAPI.md +490 -0
  57. package/doc/en/examples/ServiceCalls.md +829 -0
  58. package/doc/en/examples/WebApp.md +568 -0
  59. package/doc/zh/Application.md +75 -0
  60. package/doc/zh/CLI.md +250 -0
  61. package/doc/zh/Cache.md +328 -0
  62. package/doc/zh/Command.md +78 -0
  63. package/doc/zh/ContainerdIntegration.md +9 -9
  64. package/doc/zh/Controllers.md +228 -0
  65. package/doc/zh/DBus.md +257 -0
  66. package/doc/zh/DTOValidation.md +49 -19
  67. package/doc/zh/Database.md +175 -0
  68. package/doc/zh/DatabaseDrivers.md +261 -0
  69. package/doc/zh/Decorator.md +25 -18
  70. package/doc/zh/DockerIntegration.md +6 -6
  71. package/doc/zh/Entities.md +224 -0
  72. package/doc/zh/Entrypoint.md +193 -0
  73. package/doc/zh/Exceptions.md +158 -0
  74. package/doc/zh/GRPC.md +226 -0
  75. package/doc/zh/GettingStarted.md +148 -0
  76. package/doc/zh/HTTP.md +445 -0
  77. package/doc/zh/HelperFunctions.md +2 -2
  78. package/doc/zh/I18n.md +26 -26
  79. package/doc/zh/Injection.md +129 -0
  80. package/doc/zh/Installers.md +232 -0
  81. package/doc/zh/Logger.md +188 -0
  82. package/doc/zh/MCP.md +265 -0
  83. package/doc/zh/Middlewares.md +198 -0
  84. package/doc/zh/Migrations.md +178 -0
  85. package/doc/zh/MongoEmbedded.md +118 -0
  86. package/doc/zh/Monitor.md +145 -0
  87. package/doc/zh/Mutex.md +10 -10
  88. package/doc/zh/NATS.md +207 -0
  89. package/doc/zh/NativeAddons.md +180 -0
  90. package/doc/zh/Objects.md +119 -0
  91. package/doc/zh/Overview.md +46 -71
  92. package/doc/zh/Packaging.md +154 -0
  93. package/doc/zh/ProjectStructure.md +102 -0
  94. package/doc/zh/Queue.md +214 -0
  95. package/doc/zh/Schedules.md +193 -0
  96. package/doc/zh/Security.md +6 -6
  97. package/doc/zh/ServiceCalls.md +459 -0
  98. package/doc/zh/Time.md +2 -2
  99. package/doc/zh/UpgradingFrom1.md +347 -0
  100. package/doc/zh/UpgradingFrom2.md +230 -0
  101. package/doc/zh/Workspaces.md +150 -0
  102. package/doc/zh/examples/BackgroundJobs.md +618 -0
  103. package/doc/zh/examples/CommandLineTool.md +618 -0
  104. package/doc/zh/examples/DesktopApp.md +748 -0
  105. package/doc/zh/examples/DeviceService.md +546 -0
  106. package/doc/zh/examples/MCPServer.md +604 -0
  107. package/doc/zh/examples/README.md +14 -0
  108. package/doc/zh/examples/RestAPI.md +458 -0
  109. package/doc/zh/examples/ServiceCalls.md +765 -0
  110. package/doc/zh/examples/WebApp.md +520 -0
  111. package/package.json +46 -17
  112. package/doc/en/ActionPattern.md +0 -408
  113. package/doc/en/Core.md +0 -312
  114. package/doc/en/ORMIntegration.md +0 -341
  115. package/doc/zh/ActionPattern.md +0 -334
  116. package/doc/zh/Core.md +0 -256
  117. package/doc/zh/ORMIntegration.md +0 -319
@@ -0,0 +1,748 @@
1
+ # 桌面应用
2
+
3
+ 这个示例构建一个桌面笔记应用:笔记是用户所选文件夹中的 Markdown 文件,应用窗口可以列出、打开、保存和删除笔记。它从 `electron-react` 模板开始,展示用 lakutata 开发的 Electron 应用的两部分:一是主进程,它的动作负责读写文件、打开系统对话框,并在文件夹变化时推送事件;二是 React 渲染进程,它通过 IPC 调用这些动作。示例还展示了主进程的测试,测试不需要 Electron 就能运行。
4
+
5
+ | 消息 | 作用 |
6
+ |---|---|
7
+ | `{cmd: 'folder.get'}` | 返回笔记文件夹,还没选择时为 `null` |
8
+ | `{cmd: 'folder.choose'}` | 用系统对话框让用户选择文件夹,并记住它 |
9
+ | `{cmd: 'notes.list'}` | 列出笔记,最近修改的排在前面 |
10
+ | `{cmd: 'notes.read', name: 'Shopping'}` | 返回一条笔记的文本 |
11
+ | `{cmd: 'notes.save', name: 'Shopping', text: '- bread'}` | 把笔记写入文件 `Shopping.md` |
12
+ | `{cmd: 'notes.delete', name: 'Shopping'}` | 删除笔记 |
13
+ | `{cmd: 'notes.reveal', name: 'Shopping'}` | 在文件管理器(Finder、资源管理器等)中显示笔记文件 |
14
+
15
+ 文件夹中的文件有变化时,主进程会向窗口推送 `notes.changed` 事件。
16
+
17
+ ## 1. 创建项目
18
+
19
+ ```bash
20
+ npx @lakutata/cli create --name notes --template electron-react --yes
21
+ cd notes
22
+ rm packages/app/src/controllers/ExampleController.ts packages/app/src/dtos/GreetDTO.ts
23
+ ```
24
+
25
+ 项目是一个工作区(见 [ProjectStructure](../ProjectStructure.md)):`packages/app` 是主进程,也是一个 lakutata 应用;`packages/ui` 是渲染进程,即由 Vite 构建的 React 页面;`packages/resources` 存放图标。应用不需要安装其他包:文件用 `node:fs` 读写,对话框由 Electron 提供。这里删掉了模板中的示例控制器。
26
+
27
+ ## 2. 输入
28
+
29
+ **`packages/app/src/dtos/NoteDTO.ts`**
30
+
31
+ ```typescript
32
+ import {DTO} from 'lakutata'
33
+ import {Expect} from 'lakutata/decorator/dto'
34
+
35
+ /**
36
+ * 按名称指定的笔记,名称就是文件名去掉 .md:不能有斜杠,也不能有文件系统不接受的字符
37
+ */
38
+ export class NoteNameDTO extends DTO {
39
+ @Expect(DTO.String().trim().min(1).max(100).pattern(/^[^\\/:*?"<>|]+$/, 'file name').required())
40
+ public name: string
41
+ }
42
+
43
+ /**
44
+ * 要写入的笔记
45
+ */
46
+ export class SaveNoteDTO extends NoteNameDTO {
47
+ @Expect(DTO.String().allow('').max(1000000).required())
48
+ public text: string
49
+ }
50
+ ```
51
+
52
+ 每个动作在运行前都会用 DTO 校验输入(见 [DTOValidation](../DTOValidation.md)):
53
+
54
+ - 笔记就是文件夹中的 `<name>.md` 文件:这个模式不允许斜杠,也不允许 Windows 文件名中禁用的字符,所以名称无法指向文件夹之外。`.pattern()` 的第二个参数是这个模式在错误消息中显示的名字:`"name" with value "a/b" fails to match the file name pattern`;
55
+ - `.allow('')` 允许空笔记;
56
+ - 消息是 JSON,值会保留各自的类型,所以不需要像 HTTP 请求的查询字符串那样使用 `.strict(false)`(见 [ServiceCalls](../ServiceCalls.md) 第 2 节)。
57
+
58
+ ## 3. 异常
59
+
60
+ **`packages/app/src/exceptions/NoteExceptions.ts`**
61
+
62
+ ```typescript
63
+ import {Exception} from 'lakutata'
64
+
65
+ /**
66
+ * 还没有选择笔记文件夹
67
+ */
68
+ export class NoFolderException extends Exception {
69
+ public errno: string = 'E_NO_FOLDER'
70
+ }
71
+
72
+ /**
73
+ * 请求的笔记不在文件夹中
74
+ */
75
+ export class NoteNotFoundException extends Exception {
76
+ public errno: string = 'E_NOTE_NOT_FOUND'
77
+ }
78
+ ```
79
+
80
+ 异常有一个 `errno`,渲染进程用它判断是哪种错误(见 [Exceptions](../Exceptions.md))。通过 IPC 发送错误时,会带上它的全部属性(见 [ServiceCalls](../ServiceCalls.md) 第 3 节),渲染进程收到的就是抛出时的错误:`error.errno` 为 `'E_NOTE_NOT_FOUND'`。服务调用的传输方式总是以封套回复,不管错误的状态码是多少,所以这些异常不需要 `statusCode`。
81
+
82
+ ## 4. 笔记
83
+
84
+ **`packages/app/src/components/Notes.ts`**
85
+
86
+ ```typescript
87
+ import {Component, DTO} from 'lakutata'
88
+ import {type Entrypoint} from 'lakutata/com/entrypoint'
89
+ import {Configurable, Inject} from 'lakutata/decorator/di'
90
+ import {existsSync, watch, type FSWatcher} from 'node:fs'
91
+ import {mkdir, readdir, readFile, rm, stat, writeFile} from 'node:fs/promises'
92
+ import path from 'node:path'
93
+ import {NoFolderException, NoteNotFoundException} from '../exceptions/NoteExceptions.js'
94
+
95
+ /**
96
+ * 文件夹中的一条笔记:名称,以及文件的修改时间
97
+ */
98
+ export type NoteInfo = { name: string, modifiedAt: string }
99
+
100
+ /**
101
+ * 笔记:用户所选文件夹中的 Markdown 文件,这个文件夹记录在应用的设置中。文件夹中的文件有变化时,不论是应用自己
102
+ * 还是其他程序改的,都会通知窗口(notes.changed)
103
+ */
104
+ export class Notes extends Component {
105
+
106
+ /**
107
+ * 向窗口推送事件
108
+ */
109
+ @Inject('entrypoint')
110
+ protected readonly entrypoint: Entrypoint
111
+
112
+ /**
113
+ * 设置文件,记录所选的文件夹
114
+ */
115
+ @Configurable(DTO.String().required())
116
+ protected readonly settings: string
117
+
118
+ #folder: string | null = null
119
+
120
+ #watcher: FSWatcher | undefined
121
+
122
+ #timer: NodeJS.Timeout | undefined
123
+
124
+ /**
125
+ * 笔记文件夹,选择之前为 null
126
+ */
127
+ public get folder(): string | null {
128
+ return this.#folder
129
+ }
130
+
131
+ /**
132
+ * 使用设置中记录的文件夹(如果它还存在)
133
+ */
134
+ protected async init(): Promise<void> {
135
+ const settings: { folder?: string } = await readFile(this.settings, 'utf8').then((text: string) => JSON.parse(text), () => ({}))
136
+ if (settings.folder && existsSync(settings.folder)) this.watch(settings.folder)
137
+ }
138
+
139
+ protected async destroy(): Promise<void> {
140
+ clearTimeout(this.#timer)
141
+ this.#watcher?.close()
142
+ }
143
+
144
+ /**
145
+ * 监视文件夹中的文件:等变化稳定下来,再推送给窗口
146
+ * @param folder
147
+ */
148
+ protected watch(folder: string): void {
149
+ this.#watcher?.close()
150
+ const watcher: FSWatcher = watch(folder, (): void => {
151
+ clearTimeout(this.#timer)
152
+ this.#timer = setTimeout((): number => this.entrypoint.broadcast('notes.changed'), 100)
153
+ })
154
+ //文件夹被删除了,或者无法再读取
155
+ watcher.on('error', (): void => watcher.close())
156
+ this.#watcher = watcher
157
+ this.#folder = folder
158
+ }
159
+
160
+ /**
161
+ * 改用另一个文件夹,并记住它
162
+ * @param folder
163
+ */
164
+ public async use(folder: string): Promise<void> {
165
+ this.watch(folder)
166
+ await mkdir(path.dirname(this.settings), {recursive: true})
167
+ await writeFile(this.settings, JSON.stringify({folder: folder}))
168
+ }
169
+
170
+ /**
171
+ * 一条笔记的文件
172
+ * @param name
173
+ */
174
+ protected file(name: string): string {
175
+ if (!this.#folder) throw new NoFolderException('No folder of notes is chosen')
176
+ return path.join(this.#folder, `${name}.md`)
177
+ }
178
+
179
+ /**
180
+ * 一条已有笔记的文件
181
+ * @param name
182
+ */
183
+ public find(name: string): string {
184
+ const file: string = this.file(name)
185
+ if (!existsSync(file)) throw new NoteNotFoundException(`The note ${name} does not exist`)
186
+ return file
187
+ }
188
+
189
+ /**
190
+ * 所有笔记,最近修改的排在前面
191
+ */
192
+ public async list(): Promise<NoteInfo[]> {
193
+ if (!this.#folder) return []
194
+ const notes: NoteInfo[] = []
195
+ for (const entry of await readdir(this.#folder, {withFileTypes: true})) {
196
+ if (!entry.isFile() || !entry.name.endsWith('.md')) continue
197
+ const modified: Date = (await stat(path.join(this.#folder, entry.name))).mtime
198
+ notes.push({name: entry.name.slice(0, -'.md'.length), modifiedAt: modified.toISOString()})
199
+ }
200
+ return notes.sort((a: NoteInfo, b: NoteInfo): number => b.modifiedAt.localeCompare(a.modifiedAt))
201
+ }
202
+
203
+ /**
204
+ * 一条笔记的文本
205
+ * @param name
206
+ */
207
+ public async read(name: string): Promise<string> {
208
+ return await readFile(this.find(name), 'utf8')
209
+ }
210
+
211
+ /**
212
+ * 写入一条笔记,新建或者覆盖
213
+ * @param name
214
+ * @param text
215
+ */
216
+ public async write(name: string, text: string): Promise<NoteInfo> {
217
+ const file: string = this.file(name)
218
+ await writeFile(file, text)
219
+ return {name: name, modifiedAt: (await stat(file)).mtime.toISOString()}
220
+ }
221
+
222
+ /**
223
+ * 删除一条笔记
224
+ * @param name
225
+ */
226
+ public async remove(name: string): Promise<void> {
227
+ await rm(this.find(name))
228
+ }
229
+ }
230
+ ```
231
+
232
+ - 组件只创建一次,由整个应用共享(见 [Objects](../Objects.md))。它保存着文件夹,而文件夹在请求结束之后仍然要用:控制器只存在于一次请求之内(见 [Controllers](../Controllers.md) 第 2 节)。
233
+ - `settings` 是组件的选项,也就是记录文件夹的那个文件:它用 `@Configurable` 声明,值由应用的选项传入(见 [Injection](../Injection.md))。应用启动时,`init()` 会读取这个文件。
234
+ - `node:fs` 的 `watch()` 监视文件夹。文件夹中的文件有变化时,不论是应用保存的还是其他程序(编辑器、同步工具)改的,`this.entrypoint.broadcast()` 都会向每个窗口推送 `notes.changed`(见 [ServiceCalls](../ServiceCalls.md) 第 5 节)。保存一次文件往往会产生好几次变化,所以用定时器等变化稳定下来再推送。
235
+ - 应用停止时,`destroy()` 停止监视。
236
+
237
+ ## 5. 控制器
238
+
239
+ **`packages/app/src/controllers/NoteController.ts`**
240
+
241
+ ```typescript
242
+ import {Controller, type ServiceContext} from 'lakutata/com/entrypoint'
243
+ import {ipcEventOfContext} from 'lakutata/adapter/electron'
244
+ import {ServiceAction} from 'lakutata/decorator/ctrl'
245
+ import {Inject} from 'lakutata/decorator/di'
246
+ import type {BrowserWindow, IpcMainInvokeEvent, OpenDialogOptions, OpenDialogReturnValue} from 'electron'
247
+ import {type NoteInfo, Notes} from '../components/Notes.js'
248
+ import {NoteNameDTO, SaveNoteDTO} from '../dtos/NoteDTO.js'
249
+
250
+ /**
251
+ * 笔记的动作,由窗口调用:window.ipc.invoke({cmd: 'notes.list'})
252
+ */
253
+ export class NoteController extends Controller {
254
+
255
+ @Inject('notes')
256
+ protected readonly notes: Notes
257
+
258
+ /**
259
+ * 笔记文件夹,选择之前为 null
260
+ */
261
+ @ServiceAction({cmd: 'folder.get'})
262
+ public async folder(): Promise<string | null> {
263
+ return this.notes.folder
264
+ }
265
+
266
+ /**
267
+ * 弹出系统对话框(属于发出请求的窗口),让用户选择文件夹;取消对话框时文件夹不变
268
+ */
269
+ @ServiceAction({cmd: 'folder.choose'})
270
+ public async choose(): Promise<string | null> {
271
+ //Electron 由用到它的动作自己加载:测试用 Node.js 运行,不会调用这些动作
272
+ const {BrowserWindow, dialog} = (await import('electron')).default
273
+ const event: IpcMainInvokeEvent | undefined = ipcEventOfContext<IpcMainInvokeEvent>(this.context as ServiceContext)
274
+ const window: BrowserWindow | null = event ? BrowserWindow.fromWebContents(event.sender) : null
275
+ const options: OpenDialogOptions = {title: 'The folder of the notes', properties: ['openDirectory', 'createDirectory']}
276
+ const result: OpenDialogReturnValue = window ? await dialog.showOpenDialog(window, options) : await dialog.showOpenDialog(options)
277
+ if (!result.canceled && result.filePaths.length) await this.notes.use(result.filePaths[0])
278
+ return this.notes.folder
279
+ }
280
+
281
+ /**
282
+ * 所有笔记,最近修改的排在前面
283
+ */
284
+ @ServiceAction({cmd: 'notes.list'})
285
+ public async list(): Promise<NoteInfo[]> {
286
+ return await this.notes.list()
287
+ }
288
+
289
+ /**
290
+ * 一条笔记及其文本
291
+ * @param input
292
+ */
293
+ @ServiceAction({cmd: 'notes.read'}, NoteNameDTO)
294
+ public async read(input: NoteNameDTO): Promise<{ name: string, text: string }> {
295
+ return {name: input.name, text: await this.notes.read(input.name)}
296
+ }
297
+
298
+ /**
299
+ * 写入一条笔记:文件夹中的 <name>.md 文件
300
+ * @param input
301
+ */
302
+ @ServiceAction({cmd: 'notes.save'}, SaveNoteDTO)
303
+ public async save(input: SaveNoteDTO): Promise<NoteInfo> {
304
+ return await this.notes.write(input.name, input.text)
305
+ }
306
+
307
+ /**
308
+ * 删除一条笔记
309
+ * @param input
310
+ */
311
+ @ServiceAction({cmd: 'notes.delete'}, NoteNameDTO)
312
+ public async remove(input: NoteNameDTO): Promise<void> {
313
+ await this.notes.remove(input.name)
314
+ }
315
+
316
+ /**
317
+ * 在系统的文件管理器(Finder、资源管理器等)中显示笔记文件
318
+ * @param input
319
+ */
320
+ @ServiceAction({cmd: 'notes.reveal'}, NoteNameDTO)
321
+ public async reveal(input: NoteNameDTO): Promise<void> {
322
+ const {shell} = (await import('electron')).default
323
+ shell.showItemInFolder(this.notes.find(input.name))
324
+ }
325
+ }
326
+ ```
327
+
328
+ - 每个 `@ServiceAction` 都由消息调用:`{cmd: 'notes.save', name: 'Shopping', text: '- bread'}` 会把 `{name: 'Shopping', text: '- bread'}` 作为输入调用 `save()`,输入由 `SaveNoteDTO` 校验(见 [ServiceCalls](../ServiceCalls.md) 第 2 节)。动作都很短:实际工作由控制器注入的 `notes` 组件完成。
329
+ - `folder.choose` 和 `notes.reveal` 用到只有主进程才有的 API:`dialog` 和 `shell`。和模板中的 `MainWindow` 一样,它们在运行时才导入 Electron,所以用 Node.js 运行的测试从不加载 Electron。
330
+ - `ipcEventOfContext()` 返回请求的 IPC 事件。它的 `sender` 是发出调用的页面,`BrowserWindow.fromWebContents()` 由此找到页面所在的窗口,对话框就属于这个窗口(在 macOS 上,对话框附着在窗口上显示)。通过开发用的 HTTP 入口调用时(见第 9 节),动作没有 IPC 事件,对话框也就不属于任何窗口。
331
+ - 没有返回值的动作(例如 `remove()`)回复 `{success: true, error: null}`。
332
+
333
+ ## 6. 应用的选项
334
+
335
+ **`packages/app/src/config/Config.ts`**
336
+
337
+ ```typescript
338
+ import type {ApplicationOptions} from 'lakutata'
339
+ import {buildEntrypoints, httpServiceEntrypoint} from 'lakutata/com/entrypoint'
340
+ import {electronIPCServiceEntrypoint, type IPCMain} from 'lakutata/adapter/electron'
341
+ import {MainWindow} from '../components/MainWindow.js'
342
+ import {Notes} from '../components/Notes.js'
343
+ import {NoteController} from '../controllers/NoteController.js'
344
+
345
+ export type ConfigOptions = {
346
+ /**
347
+ * 设置文件,记录笔记文件夹
348
+ */
349
+ settings: string
350
+ /**
351
+ * 主进程所在的目录,预加载脚本和构建好的渲染进程都在这里
352
+ */
353
+ directory?: string
354
+ /**
355
+ * 开发时渲染进程的 URL(Vite 的开发服务器),不提供时使用构建好的渲染进程
356
+ */
357
+ renderer?: string
358
+ mode?: 'development' | 'production'
359
+ /**
360
+ * 是否打开窗口,默认为 true(测试时只提供动作,不打开窗口)
361
+ */
362
+ window?: boolean
363
+ /**
364
+ * 提供动作的 ipcMain,默认为 Electron 的(测试传入自己的)
365
+ */
366
+ ipcMain?: IPCMain
367
+ /**
368
+ * 开发用 HTTP 入口的端口,默认不启用:供工具(Postman、curl)调用动作(POST /,带上模式和输入),并接收推送给
369
+ * 它们的事件(GET /events,Server-Sent Events);客户端 id 放在请求头 id 中(也可以用查询参数 id)
370
+ */
371
+ devPort?: number
372
+ }
373
+
374
+ /**
375
+ * 应用的选项
376
+ * @param options
377
+ */
378
+ export function config(options: ConfigOptions): ApplicationOptions {
379
+ return {
380
+ id: 'notes',
381
+ name: 'notes',
382
+ mode: options.mode ?? 'development',
383
+ components: {
384
+ entrypoint: buildEntrypoints({
385
+ controllers: [NoteController],
386
+ //渲染进程通过 IPC 调用动作:window.ipc.invoke({cmd: 'notes.list'})
387
+ service: [
388
+ electronIPCServiceEntrypoint({ipcMain: options.ipcMain}),
389
+ ...options.devPort === undefined ? [] : [httpServiceEntrypoint({port: options.devPort})]
390
+ ]
391
+ }),
392
+ notes: {
393
+ class: Notes,
394
+ settings: options.settings
395
+ },
396
+ ...(options.window === false ? {} : {
397
+ window: {
398
+ class: MainWindow,
399
+ directory: options.directory,
400
+ renderer: options.renderer
401
+ }
402
+ })
403
+ },
404
+ //先提供动作、开始监视文件夹,再让窗口加载渲染进程
405
+ bootstrap: options.window === false ? ['entrypoint', 'notes'] : ['entrypoint', 'notes', 'window']
406
+ }
407
+ }
408
+ ```
409
+
410
+ 这是模板中的 `Config.ts`,改动如下:
411
+
412
+ - 新增必填的 `settings` 选项,即设置文件;
413
+ - 新增 `notes` 组件,把设置文件传给它;用 `NoteController` 代替示例控制器;
414
+ - `bootstrap: ['entrypoint', 'notes', 'window']`:先提供动作、开始监视文件夹,窗口再加载渲染进程。测试启动应用时不打开窗口(`window: false`),并传入自己的 `ipcMain`。
415
+
416
+ ## 7. 主进程
417
+
418
+ **`packages/app/src/App.ts`**
419
+
420
+ ```typescript
421
+ import {Application, resourcesPath, type ILogger} from 'lakutata'
422
+ import electron from 'electron'
423
+ import path from 'node:path'
424
+ import {fileURLToPath} from 'node:url'
425
+ import {config} from './config/Config.js'
426
+
427
+ /**
428
+ * 预加载脚本和构建好的渲染进程所在目录:打包后是应用的资源目录(lakutata.resources),开发时是主进程所在目录
429
+ * (build/,渲染进程取自 ui 包)
430
+ */
431
+ const directory: string = resourcesPath() ?? path.dirname(fileURLToPath(import.meta.url))
432
+
433
+ /**
434
+ * 开发时渲染进程的 URL(--renderer=<url>,Vite 的开发服务器)
435
+ */
436
+ const renderer: string | undefined = process.argv.find((argument: string): boolean => argument.startsWith('--renderer='))?.slice('--renderer='.length)
437
+
438
+ //应用只运行一个实例:再启动一个实例时,新实例直接退出,已在运行的实例把窗口切到前台
439
+ if (!electron.app.requestSingleInstanceLock()) {
440
+ electron.app.exit(0)
441
+ } else {
442
+ Application
443
+ .run(config({
444
+ //笔记文件夹记录在应用的数据目录中
445
+ settings: path.join(electron.app.getPath('userData'), 'settings.json'),
446
+ directory: directory,
447
+ renderer: renderer,
448
+ mode: electron.app.isPackaged ? 'production' : 'development',
449
+ //开发时也向工具(Postman、curl)提供动作,打包后的应用不提供
450
+ devPort: electron.app.isPackaged ? undefined : Number(process.env.DEV_PORT ?? 9000)
451
+ }))
452
+ .onLaunched((app: Application, log: ILogger): void => log.info('%s started in %s mode', app.appName, app.mode()))
453
+ .onFatalException((error: Error, log: ILogger): void => {
454
+ log.error('The application cannot start: %s', error.message)
455
+ electron.app.exit(1)
456
+ })
457
+ }
458
+ ```
459
+
460
+ 和模板中的 `App.ts` 相比,只改了一处:`settings`,即记录笔记文件夹的文件,放在应用的数据目录(`app.getPath('userData')`)中。这个目录以 `packages/app/package.json` 的 `productName`(`notes`)命名,开发时和打包后都一样:在 macOS 上是 `~/Library/Application Support/notes`,在 Windows 上是 `%APPDATA%\notes`,在 Linux 上是 `~/.config/notes`。
461
+
462
+ 模板的预加载脚本(`packages/app/src/preload.cts`)保持不变:它只把动作和事件的 IPC 通道交给渲染进程,Electron 和 Node.js 的其他能力一概不暴露。
463
+
464
+ ## 8. 窗口
465
+
466
+ **`packages/ui/src/App.tsx`**
467
+
468
+ ```tsx
469
+ import {type FormEvent, type KeyboardEvent, useEffect, useState} from 'react'
470
+
471
+ /**
472
+ * 主进程列出的一条笔记
473
+ */
474
+ type NoteInfo = { name: string, modifiedAt: string }
475
+
476
+ export function App() {
477
+ const [folder, setFolder] = useState<string | null>(null)
478
+ const [notes, setNotes] = useState<NoteInfo[]>([])
479
+ const [current, setCurrent] = useState<string | null>(null)
480
+ const [text, setText] = useState<string>('')
481
+ const [newName, setNewName] = useState<string>('')
482
+ const [status, setStatus] = useState<string>('')
483
+
484
+ //调用主进程的一个动作;出错时(笔记不存在、名称不合法)把错误显示在笔记下方
485
+ const run = (action: () => Promise<void>): void => {
486
+ action().catch((error: Error): void => setStatus(error.message))
487
+ }
488
+ const list = (): void => run(async (): Promise<void> => setNotes(await window.ipc.invoke<NoteInfo[]>({cmd: 'notes.list'})))
489
+
490
+ useEffect((): () => void => {
491
+ run(async (): Promise<void> => setFolder(await window.ipc.invoke<string | null>({cmd: 'folder.get'})))
492
+ //文件夹中的文件变化时由主进程推送:不论是在这里保存的,还是其他程序改的
493
+ window.ipc.on('notes.changed', list)
494
+ return (): void => {
495
+ window.ipc.off('notes.changed', list)
496
+ }
497
+ }, [])
498
+
499
+ useEffect((): void => {
500
+ if (folder) list()
501
+ }, [folder])
502
+
503
+ const choose = (): void => run(async (): Promise<void> => setFolder(await window.ipc.invoke<string | null>({cmd: 'folder.choose'})))
504
+
505
+ const open = (name: string): void => run(async (): Promise<void> => {
506
+ const note = await window.ipc.invoke<{ name: string, text: string }>({cmd: 'notes.read', name: name})
507
+ setCurrent(note.name)
508
+ setText(note.text)
509
+ setStatus('')
510
+ })
511
+
512
+ const create = (event: FormEvent): void => {
513
+ event.preventDefault()
514
+ run(async (): Promise<void> => {
515
+ await window.ipc.invoke({cmd: 'notes.save', name: newName, text: ''})
516
+ setCurrent(newName.trim())
517
+ setText('')
518
+ setNewName('')
519
+ setStatus('')
520
+ })
521
+ }
522
+
523
+ const save = (): void => run(async (): Promise<void> => {
524
+ await window.ipc.invoke({cmd: 'notes.save', name: current, text: text})
525
+ setStatus(`Saved at ${new Date().toLocaleTimeString()}`)
526
+ })
527
+
528
+ const remove = (): void => run(async (): Promise<void> => {
529
+ await window.ipc.invoke({cmd: 'notes.delete', name: current})
530
+ setCurrent(null)
531
+ setStatus('')
532
+ })
533
+
534
+ //Ctrl+S,在 macOS 上是 Cmd+S
535
+ const shortcut = (event: KeyboardEvent): void => {
536
+ if ((event.ctrlKey || event.metaKey) && event.key === 's') {
537
+ event.preventDefault()
538
+ save()
539
+ }
540
+ }
541
+
542
+ if (!folder) {
543
+ return (
544
+ <main style={{fontFamily: 'system-ui', padding: 32}}>
545
+ <p>The notes are Markdown files, in a folder of your choice.</p>
546
+ <button onClick={choose}>Choose a folder…</button>
547
+ <p>{status}</p>
548
+ </main>
549
+ )
550
+ }
551
+
552
+ return (
553
+ <main style={{fontFamily: 'system-ui', position: 'fixed', inset: 0, display: 'grid', gridTemplateColumns: '240px 1fr'}}>
554
+ <aside style={{borderRight: '1px solid #ddd', padding: 12, overflow: 'auto'}}>
555
+ <button onClick={choose} title={folder} style={{width: '100%'}}>{folder.split(/[\\/]/).pop()}</button>
556
+ <form onSubmit={create}>
557
+ <input value={newName} onChange={(event): void => setNewName(event.target.value)} placeholder="New note" style={{width: '100%', margin: '12px 0', boxSizing: 'border-box'}}/>
558
+ </form>
559
+ {notes.map((note: NoteInfo) => (
560
+ <div key={note.name} onClick={(): void => open(note.name)} style={{padding: 6, cursor: 'pointer', fontWeight: note.name === current ? 'bold' : 'normal'}}>
561
+ {note.name}
562
+ <div style={{fontSize: 11, color: '#888'}}>{new Date(note.modifiedAt).toLocaleString()}</div>
563
+ </div>
564
+ ))}
565
+ </aside>
566
+ <section style={{display: 'flex', flexDirection: 'column', padding: 12, gap: 8}}>
567
+ {current === null ? <p>Open a note, or create one.</p> : (
568
+ <>
569
+ <div style={{display: 'flex', gap: 8, alignItems: 'center'}}>
570
+ <strong style={{flex: 1}}>{current}</strong>
571
+ <button onClick={save}>Save</button>
572
+ <button onClick={(): void => run(() => window.ipc.invoke({cmd: 'notes.reveal', name: current}))}>Show in folder</button>
573
+ <button onClick={remove}>Delete</button>
574
+ </div>
575
+ <textarea value={text} onChange={(event): void => setText(event.target.value)} onKeyDown={shortcut} style={{flex: 1, fontFamily: 'ui-monospace, monospace', fontSize: 14}}/>
576
+ </>
577
+ )}
578
+ <small style={{color: '#888'}}>{status}</small>
579
+ </section>
580
+ </main>
581
+ )
582
+ }
583
+ ```
584
+
585
+ - `window.ipc` 来自模板(`packages/ui/src/ipc.ts`),通过预加载脚本工作。`invoke()` 发送消息,返回动作的结果,或者抛出动作的错误(带有 `errno`);`on()` 和 `off()` 监听主进程的事件(见 [ServiceCalls](../ServiceCalls.md) 第 4 节)。
586
+ - 渲染进程与 Node.js 和 Electron 隔离:它自己不读文件,也不打开对话框,而是调用负责这些事情的动作。
587
+ - 收到 `notes.changed` 时,它重新列出笔记:用编辑器保存的笔记也会出现在窗口中。
588
+ - 错误(例如名称不合法)显示在笔记下方。
589
+
590
+ ## 9. 运行
591
+
592
+ ```bash
593
+ npm run dev
594
+ ```
595
+
596
+ 这条命令启动 Vite 的开发服务器(它提供渲染进程,`packages/ui` 有改动时自动重新加载),同时启动 Electron,它的窗口加载渲染进程并打开 DevTools。主进程会输出 `notes started in development mode`。第一次运行时,窗口会请你选择一个文件夹;以后再启动,它会打开设置中记录的文件夹。
597
+
598
+ 开发时,动作还会通过 HTTP 在 9000 端口(`DEV_PORT`)上提供,方便用 curl 或 Postman 试用:`POST /` 发送消息,`GET /events` 以 Server-Sent Events 的形式输出推送给某个客户端的事件(见 [ServiceCalls](../ServiceCalls.md) 第 4 节)。在窗口中选好文件夹(里面已经有一条笔记 `Ideas`)之后,在另一个终端中执行:
599
+
600
+ ```bash
601
+ curl -N 'localhost:9000/events?id=me' &
602
+ # event: client
603
+ # data: ["me"]
604
+
605
+ curl -X POST localhost:9000/ -H 'Content-Type: application/json' -d '{"cmd": "notes.save", "name": "Shopping", "text": "- bread\n- tea\n"}'
606
+ # {"success":true,"error":null,"payload":{"name":"Shopping","modifiedAt":"2026-10-17T14:57:41.391Z"}}
607
+ # event: notes.changed
608
+ # data: []
609
+
610
+ curl -X POST localhost:9000/ -H 'Content-Type: application/json' -d '{"cmd": "notes.list"}'
611
+ # {"success":true,"error":null,"payload":[{"name":"Shopping","modifiedAt":"2026-10-17T14:57:41.391Z"},{"name":"Ideas","modifiedAt":"2026-10-17T14:57:23.287Z"}]}
612
+
613
+ curl -X POST localhost:9000/ -H 'Content-Type: application/json' -d '{"cmd": "notes.read", "name": "Lunch"}'
614
+ # {"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}
615
+ ```
616
+
617
+ 窗口会立刻显示新笔记,因为事件也推送给了它。错误带有异常的全部属性,包括调用栈(这里省略了)。
618
+
619
+ ## 10. 测试
620
+
621
+ 模板中的测试换成了针对笔记的测试。和模板中的测试一样,它用 Node.js 运行主进程,不需要 Electron,也不打开窗口:测试用自己的 `ipcMain` 代替 Electron 的,并通过它像渲染进程那样调用动作。测试在设置中记录了临时目录下的一个文件夹。
622
+
623
+ **`packages/app/src/tests/App.spec.ts`**
624
+
625
+ ```typescript
626
+ import {after, before, describe, it} from 'node:test'
627
+ import assert from 'node:assert'
628
+ import {existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs'
629
+ import {tmpdir} from 'node:os'
630
+ import path from 'node:path'
631
+ import {Application} from 'lakutata'
632
+ import type {IPCMain} from 'lakutata/adapter/electron'
633
+ import {config} from '../config/Config.js'
634
+
635
+ /**
636
+ * 测试用的 ipcMain:通过它像渲染进程那样调用动作,不需要 Electron
637
+ */
638
+ const handlers: Map<string, (event: unknown, data: unknown) => Promise<unknown>> = new Map()
639
+ const listeners: Map<string, (event: unknown, clientId: string) => void> = new Map()
640
+ const ipcMain: IPCMain = {
641
+ handle: (channel: string, listener: (event: unknown, data: unknown) => Promise<unknown>): unknown => handlers.set(channel, listener),
642
+ removeHandler: (channel: string): unknown => handlers.delete(channel),
643
+ on: (channel: string, listener: (event: unknown, clientId: string) => void): unknown => listeners.set(channel, listener),
644
+ removeAllListeners: (channel: string): unknown => listeners.delete(channel)
645
+ }
646
+ //渲染进程的 web contents:收集推送给它的事件
647
+ const received: unknown[][] = []
648
+ const sender = {once: (): void => undefined, off: (): void => undefined, send: (...args: unknown[]): unknown => received.push(args)}
649
+
650
+ /**
651
+ * 像渲染进程那样调用动作(window.ipc.invoke)
652
+ * @param data
653
+ */
654
+ function invoke(data: Record<string, unknown>): Promise<any> {
655
+ return (handlers.get('lakutata:service') as (event: unknown, data: unknown, clientId: string) => Promise<unknown>)({sender: sender}, data, 'page')
656
+ }
657
+
658
+ /**
659
+ * 等待条件成立,最多等 5 秒
660
+ * @param condition
661
+ */
662
+ async function until(condition: () => boolean): Promise<void> {
663
+ for (let waited: number = 0; !condition(); waited += 50) {
664
+ if (waited >= 5000) throw new Error('Waited too long')
665
+ await new Promise((resolve: (value: unknown) => void): unknown => setTimeout(resolve, 50))
666
+ }
667
+ }
668
+
669
+ describe('notes', (): void => {
670
+
671
+ //应用的数据目录,以及设置中记录的笔记文件夹
672
+ const data: string = mkdtempSync(path.join(tmpdir(), 'notes-'))
673
+ const folder: string = path.join(data, 'My notes')
674
+ let app: Application
675
+
676
+ before(async (): Promise<void> => {
677
+ mkdirSync(folder)
678
+ writeFileSync(path.join(data, 'settings.json'), JSON.stringify({folder: folder}))
679
+ app = await new Promise<Application>((resolve: (app: Application) => void): void => {
680
+ Application.run(config({settings: path.join(data, 'settings.json'), window: false, ipcMain: ipcMain})).onLaunched((launched: Application): void => resolve(launched))
681
+ })
682
+ })
683
+
684
+ after(async (): Promise<void> => {
685
+ await app?.dispose()
686
+ rmSync(data, {recursive: true, force: true})
687
+ })
688
+
689
+ it('gives the folder remembered by the settings', async (): Promise<void> => {
690
+ assert.deepStrictEqual(await invoke({cmd: 'folder.get'}), {success: true, error: null, payload: folder})
691
+ })
692
+
693
+ it('pushes notes.changed to the renderer when the files of the folder change', async (): Promise<void> => {
694
+ //页面订阅事件
695
+ listeners.get('lakutata:service:subscribe')!({sender: sender}, 'page')
696
+ //其他程序写入一条笔记
697
+ writeFileSync(path.join(folder, 'Ideas.md'), '# Ideas\n')
698
+ await until((): boolean => received.length > 0)
699
+ assert.deepStrictEqual(received[0], ['lakutata:service:event', 'notes.changed', []])
700
+ })
701
+
702
+ it('saves, lists, reads and deletes the notes', async (): Promise<void> => {
703
+ const saved = await invoke({cmd: 'notes.save', name: 'Shopping', text: '- bread\n- tea\n'})
704
+ assert.strictEqual(saved.payload.name, 'Shopping')
705
+ assert.strictEqual(readFileSync(path.join(folder, 'Shopping.md'), 'utf8'), '- bread\n- tea\n')
706
+ const listed = await invoke({cmd: 'notes.list'})
707
+ assert.deepStrictEqual(listed.payload.map((note: { name: string }): string => note.name), ['Shopping', 'Ideas'])
708
+ assert.deepStrictEqual((await invoke({cmd: 'notes.read', name: 'Shopping'})).payload, {name: 'Shopping', text: '- bread\n- tea\n'})
709
+ assert.strictEqual((await invoke({cmd: 'notes.delete', name: 'Shopping'})).success, true)
710
+ assert.strictEqual(existsSync(path.join(folder, 'Shopping.md')), false)
711
+ })
712
+
713
+ it('refuses a note that does not exist', async (): Promise<void> => {
714
+ const response = await invoke({cmd: 'notes.read', name: 'Missing'})
715
+ assert.strictEqual(response.success, false)
716
+ assert.strictEqual(response.error.errno, 'E_NOTE_NOT_FOUND')
717
+ })
718
+
719
+ it('refuses a name that is not a file name', async (): Promise<void> => {
720
+ const response = await invoke({cmd: 'notes.save', name: '../outside', text: ''})
721
+ assert.strictEqual(response.success, false)
722
+ assert.match(response.error.message, /file name/)
723
+ assert.strictEqual(existsSync(path.join(data, 'outside.md')), false)
724
+ })
725
+ })
726
+ ```
727
+
728
+ ```bash
729
+ npm test
730
+ ```
731
+
732
+ ```
733
+ app │ ✔ gives the folder remembered by the settings
734
+ app │ ✔ pushes notes.changed to the renderer when the files of the folder change
735
+ app │ ✔ saves, lists, reads and deletes the notes
736
+ app │ ✔ refuses a note that does not exist
737
+ app │ ✔ refuses a name that is not a file name
738
+ app │ ℹ pass 5
739
+ ✔ test ran in app
740
+ ```
741
+
742
+ `npm test` 先构建所有包(包括渲染进程),再运行测试。用到 Electron 的两个动作(对话框和文件管理器)要在窗口中试用。
743
+
744
+ ## 11. 打包
745
+
746
+ - `npm run package:host` 构建所有包,然后在 `dist/installer` 中生成当前平台的安装包:macOS 上是 `dmg`,Linux 上是 `deb`,Windows 上是 `nsis` 安装程序和一个便携版可执行文件。`npm run package:linux` 在 Docker 中生成 Linux 安装包,在任何平台上都可以运行。安装包由 electron-builder 根据 `packages/app/package.json` 中的 `lakutata.installer` 制作(见 [Installers](../Installers.md) 第 8 节)。
747
+ - 主进程会连同它的依赖打包在一起。构建好的渲染进程和预加载脚本放进 Electron 的资源目录(由 `packages/app/package.json` 中的 `lakutata.resources` 指定),`resourcesPath()` 在那里找到它们(见 [Packaging](../Packaging.md) 第 6 节)。
748
+ - 打包后的应用没有 HTTP 入口(只有 `app.isPackaged` 为 false 时才传入 `devPort`),设置仍然保存在同一个数据目录中。