lakutata 3.1.4 → 3.1.6

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 (67) hide show
  1. package/README.md +11 -11
  2. package/README.zh-CN.md +5 -5
  3. package/dist/cjs/exports/NwjsAdapter.d.ts +1 -0
  4. package/dist/cjs/exports/NwjsAdapter.js +20 -0
  5. package/dist/cjs/exports/ReactNativeAdapter.d.ts +1 -0
  6. package/dist/cjs/exports/ReactNativeAdapter.js +20 -0
  7. package/dist/esm/exports/NwjsAdapter.js +1 -0
  8. package/dist/esm/exports/ReactNativeAdapter.js +1 -0
  9. package/dist/types/exports/NwjsAdapter.d.ts +1 -0
  10. package/dist/types/exports/ReactNativeAdapter.d.ts +1 -0
  11. package/doc/en/Application.md +6 -1
  12. package/doc/en/Barcode.md +386 -0
  13. package/doc/en/Cache.md +2 -1
  14. package/doc/en/Command.md +20 -4
  15. package/doc/en/ContainerdIntegration.md +1 -1
  16. package/doc/en/Database.md +4 -0
  17. package/doc/en/DatabaseDrivers.md +75 -4
  18. package/doc/en/Entrypoint.md +4 -2
  19. package/doc/en/Exceptions.md +1 -1
  20. package/doc/en/FFI.md +1 -1
  21. package/doc/en/GRPC.md +1 -1
  22. package/doc/en/GettingStarted.md +16 -2
  23. package/doc/en/HTTP.md +1 -0
  24. package/doc/en/I18n.md +1 -1
  25. package/doc/en/Installers.md +34 -6
  26. package/doc/en/Math.md +1 -1
  27. package/doc/en/Migrations.md +2 -2
  28. package/doc/en/NativeAddons.md +12 -2
  29. package/doc/en/Overview.md +14 -11
  30. package/doc/en/Packaging.md +8 -1
  31. package/doc/en/ProjectStructure.md +18 -3
  32. package/doc/en/ServiceCalls.md +85 -5
  33. package/doc/en/UpgradingFrom1.md +2 -2
  34. package/doc/en/UpgradingFrom2.md +4 -4
  35. package/doc/en/WebRTC.md +4 -4
  36. package/doc/en/examples/MobileApp.md +730 -0
  37. package/doc/en/examples/README.md +2 -0
  38. package/doc/en/examples/TrayApp.md +1061 -0
  39. package/doc/zh/Application.md +3 -1
  40. package/doc/zh/Barcode.md +313 -0
  41. package/doc/zh/Cache.md +1 -1
  42. package/doc/zh/Command.md +12 -3
  43. package/doc/zh/ContainerdIntegration.md +1 -1
  44. package/doc/zh/Database.md +2 -0
  45. package/doc/zh/DatabaseDrivers.md +52 -2
  46. package/doc/zh/Entrypoint.md +3 -1
  47. package/doc/zh/Exceptions.md +1 -1
  48. package/doc/zh/FFI.md +1 -1
  49. package/doc/zh/GRPC.md +1 -1
  50. package/doc/zh/GettingStarted.md +12 -2
  51. package/doc/zh/HTTP.md +1 -0
  52. package/doc/zh/I18n.md +1 -1
  53. package/doc/zh/Installers.md +16 -5
  54. package/doc/zh/Math.md +1 -1
  55. package/doc/zh/Migrations.md +1 -1
  56. package/doc/zh/NativeAddons.md +4 -2
  57. package/doc/zh/Overview.md +5 -4
  58. package/doc/zh/Packaging.md +6 -1
  59. package/doc/zh/ProjectStructure.md +5 -2
  60. package/doc/zh/ServiceCalls.md +65 -2
  61. package/doc/zh/UpgradingFrom1.md +2 -2
  62. package/doc/zh/UpgradingFrom2.md +3 -3
  63. package/doc/zh/WebRTC.md +1 -1
  64. package/doc/zh/examples/MobileApp.md +671 -0
  65. package/doc/zh/examples/README.md +2 -0
  66. package/doc/zh/examples/TrayApp.md +980 -0
  67. package/package.json +36 -16
@@ -0,0 +1,1061 @@
1
+ # A tray application with NW.js
2
+
3
+ This example builds a clipboard history that lives in the tray of the system: it keeps the texts copied in any program,
4
+ and its window lists them, searches them, copies one to the clipboard again and pins the ones to keep. It starts from
5
+ the `nwjs-react` template and shows what an NW.js application made with lakutata is made of: its background script, a
6
+ lakutata application that runs as long as the application does, with or without a window (here it reads the clipboard
7
+ and keeps the icon in the tray with its menu); its window, whose React user interface calls the actions of the
8
+ application and receives its events; and the tests of the application, which run without NW.js.
9
+
10
+ | Message | What it does |
11
+ |---|---|
12
+ | `{cmd: 'clips.list'}` | Lists the clips, the pinned ones first, then the latest |
13
+ | `{cmd: 'clips.list', query: 'git'}` | Lists only the clips whose text holds `git`, whatever its case |
14
+ | `{cmd: 'clips.copy', id: '…'}` | Copies a clip to the clipboard again, which moves it to the top |
15
+ | `{cmd: 'clips.pin', id: '…', pinned: true}` | Pins a clip: it stays, however many texts are copied after it |
16
+ | `{cmd: 'clips.delete', id: '…'}` | Removes a clip |
17
+ | `{cmd: 'clips.clear'}` | Removes the clips, except the pinned ones |
18
+
19
+ When the history changes, the application pushes the `clips.changed` event to the window.
20
+
21
+ ## 1. Create the project
22
+
23
+ ```bash
24
+ npx @lakutata/cli create --name clips --template nwjs-react --yes
25
+ cd clips
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 background script of
30
+ NW.js, a lakutata application; `packages/ui` is the user interface, a React page built by Vite; `packages/resources`
31
+ holds the icon. The application needs no other package: the clipboard and the tray come from NW.js, and the history is a
32
+ JSON file written with `node:fs`. The example controller of the template is removed.
33
+
34
+ ## 2. The input
35
+
36
+ **`packages/app/src/dtos/ClipDTO.ts`**
37
+
38
+ ```typescript
39
+ import {DTO} from 'lakutata'
40
+ import {Expect} from 'lakutata/decorator/dto'
41
+
42
+ /**
43
+ * The clips whose text holds a query, all of them without one
44
+ */
45
+ export class SearchClipsDTO extends DTO {
46
+ @Expect(DTO.String().trim().max(200).optional())
47
+ public query?: string
48
+ }
49
+
50
+ /**
51
+ * A clip of the history, by its id
52
+ */
53
+ export class ClipIdDTO extends DTO {
54
+ @Expect(DTO.String().uuid().required())
55
+ public id: string
56
+ }
57
+
58
+ /**
59
+ * A clip to pin, or to unpin
60
+ */
61
+ export class PinClipDTO extends ClipIdDTO {
62
+ @Expect(DTO.Boolean().required())
63
+ public pinned: boolean
64
+ }
65
+ ```
66
+
67
+ Each action validates its input with a DTO before it runs ([DTOValidation](../DTOValidation.md)). A clip is named by its
68
+ id, a UUID given by the history: `.uuid()` refuses any other string, `"id" must be a valid GUID`. A message is JSON, so
69
+ its values keep their types: `pinned` must be a boolean.
70
+
71
+ ## 3. The exception
72
+
73
+ **`packages/app/src/exceptions/ClipExceptions.ts`**
74
+
75
+ ```typescript
76
+ import {Exception} from 'lakutata'
77
+
78
+ /**
79
+ * The clip is not in the history (any more)
80
+ */
81
+ export class ClipNotFoundException extends Exception {
82
+ public errno: string = 'E_CLIP_NOT_FOUND'
83
+ }
84
+ ```
85
+
86
+ An exception has an `errno`, the code that the user interface checks ([Exceptions](../Exceptions.md)). The window
87
+ receives the error as it was thrown, with all its properties ([ServiceCalls](../ServiceCalls.md), section 3):
88
+ `error.errno` is `'E_CLIP_NOT_FOUND'`. The service transports always answer with an envelope, whatever the status of the
89
+ error, so the exception needs no `statusCode`.
90
+
91
+ ## 4. The history
92
+
93
+ **`packages/app/src/components/History.ts`**
94
+
95
+ ```typescript
96
+ import {Component, DTO} from 'lakutata'
97
+ import {type Entrypoint} from 'lakutata/com/entrypoint'
98
+ import {Configurable, Inject} from 'lakutata/decorator/di'
99
+ import {randomUUID} from 'node:crypto'
100
+ import {mkdir, readFile, rename, writeFile} from 'node:fs/promises'
101
+ import path from 'node:path'
102
+ import {ClipNotFoundException} from '../exceptions/ClipExceptions.js'
103
+
104
+ /**
105
+ * A text copied to the clipboard
106
+ */
107
+ export type Clip = { id: string, text: string, copiedAt: string, pinned: boolean }
108
+
109
+ /**
110
+ * The text of the clipboard, read and written: the clipboard of NW.js in the application, a stand-in in the tests
111
+ */
112
+ export type ClipboardAccess = { read(): string, write(text: string): void }
113
+
114
+ /**
115
+ * The history of the clipboard: the texts copied, the latest first, kept in a file. NW.js tells no change of the
116
+ * clipboard, so the component reads it at an interval. Its changes are pushed to the windows (clips.changed) and
117
+ * emitted to the other components (changed)
118
+ */
119
+ export class History extends Component {
120
+
121
+ /**
122
+ * Pushes the events to the windows
123
+ */
124
+ @Inject('entrypoint')
125
+ protected readonly entrypoint: Entrypoint
126
+
127
+ /**
128
+ * The clipboard read and written
129
+ */
130
+ @Configurable(DTO.Object({read: DTO.Function().required(), write: DTO.Function().required()}).required())
131
+ protected readonly clipboard: ClipboardAccess
132
+
133
+ /**
134
+ * The file of the history
135
+ */
136
+ @Configurable(DTO.String().required())
137
+ protected readonly file: string
138
+
139
+ /**
140
+ * How often the clipboard is read, in milliseconds
141
+ */
142
+ @Configurable(DTO.Number().integer().min(10).optional().default(500))
143
+ protected readonly interval: number
144
+
145
+ /**
146
+ * The clips kept, the pinned ones aside
147
+ */
148
+ @Configurable(DTO.Number().integer().min(1).optional().default(50))
149
+ protected readonly limit: number
150
+
151
+ #clips: Clip[] = []
152
+
153
+ //The text read last from the clipboard: a copy is a change of it
154
+ #last: string = ''
155
+
156
+ #timer: NodeJS.Timeout | undefined
157
+
158
+ //The writes of the file, one after the other
159
+ #saved: Promise<void> = Promise.resolve()
160
+
161
+ /**
162
+ * Read the history from its file, then the clipboard at each interval: what it holds when the application starts
163
+ * is not a copy
164
+ */
165
+ protected async init(): Promise<void> {
166
+ this.#clips = await readFile(this.file, 'utf8').then((text: string): Clip[] => JSON.parse(text), (): Clip[] => [])
167
+ this.#last = this.clipboard.read()
168
+ this.#timer = setInterval((): void => this.read(), this.interval)
169
+ }
170
+
171
+ protected async destroy(): Promise<void> {
172
+ clearInterval(this.#timer)
173
+ await this.#saved
174
+ }
175
+
176
+ /**
177
+ * Read the clipboard: a text other than the last one read is a copy
178
+ */
179
+ protected read(): void {
180
+ const text: string = this.clipboard.read()
181
+ if (!text.trim() || text === this.#last) return
182
+ this.#last = text
183
+ this.add(text)
184
+ }
185
+
186
+ /**
187
+ * Put a text at the top of the history: a text copied again moves up, and the oldest clips not pinned beyond the
188
+ * limit are dropped
189
+ * @param text
190
+ */
191
+ protected add(text: string): Clip {
192
+ const clip: Clip = this.#clips.find((item: Clip): boolean => item.text === text) ?? {id: randomUUID(), text: text, copiedAt: '', pinned: false}
193
+ clip.copiedAt = new Date().toISOString()
194
+ let kept: number = 0
195
+ this.#clips = [clip, ...this.#clips.filter((item: Clip): boolean => item !== clip)].filter((item: Clip): boolean => item.pinned || ++kept <= this.limit)
196
+ this.changed()
197
+ return clip
198
+ }
199
+
200
+ /**
201
+ * Save the history, and tell it changed: the file written aside, then renamed, so that it is never left half written
202
+ */
203
+ protected changed(): void {
204
+ const json: string = JSON.stringify(this.#clips)
205
+ this.#saved = this.#saved.then(async (): Promise<void> => {
206
+ await mkdir(path.dirname(this.file), {recursive: true})
207
+ await writeFile(`${this.file}.tmp`, json)
208
+ await rename(`${this.file}.tmp`, this.file)
209
+ })
210
+ this.entrypoint.broadcast('clips.changed')
211
+ this.emit('changed')
212
+ }
213
+
214
+ /**
215
+ * The clips, the pinned ones first, then the latest; those whose text holds the query only (whatever its case)
216
+ * @param query
217
+ */
218
+ public list(query?: string): Clip[] {
219
+ const lowered: string | undefined = query?.toLowerCase()
220
+ return this.#clips
221
+ .filter((clip: Clip): boolean => !lowered || clip.text.toLowerCase().includes(lowered))
222
+ .sort((a: Clip, b: Clip): number => Number(b.pinned) - Number(a.pinned) || b.copiedAt.localeCompare(a.copiedAt))
223
+ .map((clip: Clip): Clip => ({...clip}))
224
+ }
225
+
226
+ /**
227
+ * A clip of the history
228
+ * @param id
229
+ */
230
+ protected find(id: string): Clip {
231
+ const clip: Clip | undefined = this.#clips.find((item: Clip): boolean => item.id === id)
232
+ if (!clip) throw new ClipNotFoundException('The clip {0} is not in the history', id)
233
+ return clip
234
+ }
235
+
236
+ /**
237
+ * Copy a clip to the clipboard again: it moves to the top
238
+ * @param id
239
+ */
240
+ public copy(id: string): Clip {
241
+ const {text} = this.find(id)
242
+ this.#last = text
243
+ this.clipboard.write(text)
244
+ return {...this.add(text)}
245
+ }
246
+
247
+ /**
248
+ * Pin a clip, or unpin it: a pinned clip stays, whatever is copied after it
249
+ * @param id
250
+ * @param pinned
251
+ */
252
+ public pin(id: string, pinned: boolean): Clip {
253
+ const clip: Clip = this.find(id)
254
+ clip.pinned = pinned
255
+ this.changed()
256
+ return {...clip}
257
+ }
258
+
259
+ /**
260
+ * Remove a clip
261
+ * @param id
262
+ */
263
+ public remove(id: string): void {
264
+ const clip: Clip = this.find(id)
265
+ this.#clips = this.#clips.filter((item: Clip): boolean => item !== clip)
266
+ this.changed()
267
+ }
268
+
269
+ /**
270
+ * Remove the clips, but the pinned ones
271
+ */
272
+ public clear(): void {
273
+ this.#clips = this.#clips.filter((clip: Clip): boolean => clip.pinned)
274
+ this.changed()
275
+ }
276
+ }
277
+ ```
278
+
279
+ - A component is created once and shared by the whole application ([Objects](../Objects.md)): it keeps the clips, which
280
+ outlive the requests, while a controller lives for one request only ([Controllers](../Controllers.md), section 2).
281
+ - The component knows nothing of NW.js. Its options give it what it uses ([Injection](../Injection.md)): `clipboard`,
282
+ the functions that read and write the text of the clipboard (the clipboard of NW.js in the application, section 8; a
283
+ variable in the tests, section 11), and `file`, where the history is kept.
284
+ - NW.js does not tell when the clipboard changes, so `init()` reads it every `interval` milliseconds. A text other than
285
+ the last one read is a copy; the text that the clipboard holds when the application starts is not one, and neither is
286
+ the text that `copy()` writes itself.
287
+ - A text copied again moves to the top instead of being added twice. Beyond `limit` clips, the oldest ones are dropped,
288
+ except the pinned ones.
289
+ - Each change saves the file and tells it: `this.entrypoint.broadcast()` pushes `clips.changed` to the window
290
+ ([ServiceCalls](../ServiceCalls.md), section 5), and `this.emit('changed')` tells the other components, such as the
291
+ tray (section 6). The file is written to a file beside it (`history.json.tmp`), then renamed over it, so that a crash
292
+ never leaves it half written; the writes wait for each other, and `destroy()` waits for the last one when the
293
+ application stops.
294
+
295
+ ## 5. The controller
296
+
297
+ **`packages/app/src/controllers/ClipController.ts`**
298
+
299
+ ```typescript
300
+ import {Controller} from 'lakutata/com/entrypoint'
301
+ import {ServiceAction} from 'lakutata/decorator/ctrl'
302
+ import {Inject} from 'lakutata/decorator/di'
303
+ import {type Clip, History} from '../components/History.js'
304
+ import {ClipIdDTO, PinClipDTO, SearchClipsDTO} from '../dtos/ClipDTO.js'
305
+
306
+ /**
307
+ * The actions of the history, invoked by the window: window.ipc.invoke({cmd: 'clips.list'})
308
+ */
309
+ export class ClipController extends Controller {
310
+
311
+ @Inject('history')
312
+ protected readonly history: History
313
+
314
+ @ServiceAction({cmd: 'clips.list'}, SearchClipsDTO)
315
+ public async list(input: SearchClipsDTO): Promise<Clip[]> {
316
+ return this.history.list(input.query)
317
+ }
318
+
319
+ @ServiceAction({cmd: 'clips.copy'}, ClipIdDTO)
320
+ public async copy(input: ClipIdDTO): Promise<Clip> {
321
+ return this.history.copy(input.id)
322
+ }
323
+
324
+ @ServiceAction({cmd: 'clips.pin'}, PinClipDTO)
325
+ public async pin(input: PinClipDTO): Promise<Clip> {
326
+ return this.history.pin(input.id, input.pinned)
327
+ }
328
+
329
+ @ServiceAction({cmd: 'clips.delete'}, ClipIdDTO)
330
+ public async remove(input: ClipIdDTO): Promise<void> {
331
+ this.history.remove(input.id)
332
+ }
333
+
334
+ @ServiceAction({cmd: 'clips.clear'})
335
+ public async clear(): Promise<void> {
336
+ this.history.clear()
337
+ }
338
+ }
339
+ ```
340
+
341
+ Each `@ServiceAction` is called by a message: `{cmd: 'clips.pin', id: '…', pinned: true}` calls `pin()` with
342
+ `{id: '…', pinned: true}`, validated by `PinClipDTO` ([ServiceCalls](../ServiceCalls.md), section 2). The actions are
343
+ short: the work is done by the `history` component, which the controller injects. An action that returns nothing, such
344
+ as `remove()`, answers `{success: true, error: null}`.
345
+
346
+ ## 6. The window and the tray
347
+
348
+ The application stays in the tray: closing the window hides it, and the menu of the tray shows it again or quits the
349
+ application. Two files of the template change, the component of the window and the types of the API of NW.js, and the
350
+ tray is a new component.
351
+
352
+ **`packages/app/src/components/MainWindow.ts`**
353
+
354
+ ```typescript
355
+ import {Application, Component, DTO} from 'lakutata'
356
+ import {Configurable, Inject} from 'lakutata/decorator/di'
357
+
358
+ /**
359
+ * The window of the application: its host page (packages/app/host) holds the user interface in a webview, isolated from
360
+ * Node.js and from NW.js (the user interface invokes the actions of the application through the messages of the host
361
+ * page only). Closing it hides it: the application keeps reading the clipboard, in the tray
362
+ */
363
+ export class MainWindow extends Component {
364
+
365
+ @Inject(Application)
366
+ protected readonly app: Application
367
+
368
+ @Configurable(DTO.Number().optional().default(420))
369
+ protected readonly width: number
370
+
371
+ @Configurable(DTO.Number().optional().default(640))
372
+ protected readonly height: number
373
+
374
+ /**
375
+ * The URL of the user interface in development, its built files otherwise
376
+ */
377
+ @Configurable(DTO.String().optional())
378
+ protected readonly ui?: string
379
+
380
+ #window: nw.Window | undefined
381
+
382
+ protected async init(): Promise<void> {
383
+ //The pages are files of the application: its resources (lakutata.resources) are in its directory resources, the
384
+ //user interface is given relative to the host page
385
+ const query: URLSearchParams = new URLSearchParams({ui: this.ui ?? '../ui/index.html'})
386
+ //The DevTools of the user interface in development (the user interface served by Vite)
387
+ if (this.ui) query.set('devtools', 'true')
388
+ const window: nw.Window = await new Promise((resolve: (window: nw.Window) => void): void => nw.Window.open(`resources/host/index.html?${query}`, {
389
+ title: this.app.appName,
390
+ width: this.width,
391
+ height: this.height,
392
+ //The icon of the window (Windows, Linux: the windows of macOS show the one of the application)
393
+ icon: 'resources/logo.png'
394
+ }, resolve))
395
+ this.#window = window
396
+ //Closed, the window is hidden: the application quits from its tray, or when it is asked to (Cmd+Q on macOS)
397
+ window.on('close', (reason?: 'quit'): unknown => reason === 'quit' ? this.app.exit(0) : window.hide())
398
+ //A second instance of the application gives its window back
399
+ nw.App.on('open', (): void => this.show())
400
+ }
401
+
402
+ /**
403
+ * Show the window again, in front of the others
404
+ */
405
+ public show(): void {
406
+ this.#window?.show()
407
+ this.#window?.restore()
408
+ this.#window?.focus()
409
+ }
410
+
411
+ protected async destroy(): Promise<void> {
412
+ this.#window?.removeAllListeners('close').close(true)
413
+ }
414
+ }
415
+ ```
416
+
417
+ - The window opens the host page of the template (`packages/app/host`), which holds the user interface in a webview. The
418
+ user interface runs isolated from Node.js and from NW.js: it reaches the application through the messages of the host
419
+ page only, the role that the preload script plays in Electron.
420
+ - NW.js asks a window to close with its `close` event; once the event is listened to, the window closes only through
421
+ `close(true)`. Here the listener hides it, unless the application is quitting (Cmd+Q on macOS, which passes `'quit'`
422
+ to the listener). `destroy()` removes the listener and closes the window when the application stops.
423
+ - `show()` brings the window back, from the menu of the tray or when the application is started a second time
424
+ (`nw.App.on('open')`: NW.js runs a single instance of the application).
425
+
426
+ **`packages/app/src/components/Tray.ts`**
427
+
428
+ ```typescript
429
+ import {Application, Component} from 'lakutata'
430
+ import {Inject} from 'lakutata/decorator/di'
431
+ import {type Clip, History} from './History.js'
432
+ import {MainWindow} from './MainWindow.js'
433
+
434
+ /**
435
+ * The longest label of a clip in the menu of the tray
436
+ */
437
+ const LABEL: number = 40
438
+
439
+ /**
440
+ * The icon of the application in the tray of the system (the menu bar of macOS, the notification area of Windows):
441
+ * its menu copies one of the latest clips again, shows the window or quits the application
442
+ */
443
+ export class Tray extends Component {
444
+
445
+ @Inject(Application)
446
+ protected readonly app: Application
447
+
448
+ @Inject('history')
449
+ protected readonly history: History
450
+
451
+ @Inject('window')
452
+ protected readonly window: MainWindow
453
+
454
+ #tray: nw.Tray | undefined
455
+
456
+ protected async init(): Promise<void> {
457
+ //The menu bar of macOS shows an icon at its size, without making it smaller: the name of the application stands
458
+ //there instead of the icon (a template image of 22 pixels, black and transparent, would fit)
459
+ this.#tray = new nw.Tray(process.platform === 'darwin' ? {title: this.app.appName} : {icon: 'resources/logo.png', tooltip: this.app.appName})
460
+ this.update()
461
+ this.history.on('changed', (): void => this.update())
462
+ }
463
+
464
+ /**
465
+ * Make the menu again, from the latest clips
466
+ */
467
+ protected update(): void {
468
+ const menu: nw.Menu = new nw.Menu()
469
+ for (const clip of this.history.list().slice(0, 5)) {
470
+ menu.append(new nw.MenuItem({label: this.label(clip), click: (): unknown => this.history.copy(clip.id)}))
471
+ }
472
+ menu.append(new nw.MenuItem({type: 'separator'}))
473
+ menu.append(new nw.MenuItem({label: 'Show the history', click: (): void => this.window.show()}))
474
+ menu.append(new nw.MenuItem({label: 'Quit', click: (): unknown => this.app.exit(0)}))
475
+ this.#tray!.menu = menu
476
+ }
477
+
478
+ /**
479
+ * The label of a clip: its first line, shortened
480
+ * @param clip
481
+ */
482
+ protected label(clip: Clip): string {
483
+ const line: string = clip.text.trim().split('\n')[0]
484
+ return `${clip.pinned ? '★ ' : ''}${line.length > LABEL ? `${line.slice(0, LABEL - 1)}…` : line}`
485
+ }
486
+
487
+ protected async destroy(): Promise<void> {
488
+ this.#tray?.remove()
489
+ }
490
+ }
491
+ ```
492
+
493
+ - The menu of the tray lists the five latest clips; choosing one copies it to the clipboard again. The component makes
494
+ the menu again whenever the history emits `changed`.
495
+ - **Quit** calls `this.app.exit(0)`: the application stops gracefully, its components destroyed in turn
496
+ ([Application](../Application.md), section 3), the icon removed from the tray and the window closed.
497
+ - On Windows, the notification area shows the icon of the application made smaller. The menu bar of macOS shows an icon
498
+ at its size (the 512 pixels of `logo.png`), so the name of the application stands there instead. NW.js recommends
499
+ creating the tray in the background script, which lasts as long as the application: a tray created by a page stops
500
+ working when the page navigates.
501
+
502
+ The types of the template describe what it uses of the API of NW.js. They gain the `close` event and `hide()` of a
503
+ window, `nw.App.dataPath`, the clipboard, the tray and its menu:
504
+
505
+ **`packages/app/src/nw.d.ts`**
506
+
507
+ ```typescript
508
+ /**
509
+ * What the application uses of the API of NW.js (the global nw of its background script): https://docs.nwjs.io
510
+ */
511
+ declare namespace nw {
512
+
513
+ /**
514
+ * A window of NW.js
515
+ */
516
+ interface Window {
517
+ on(event: 'closed' | 'loaded' | 'focus' | 'blur' | 'minimize' | 'restore', listener: () => void): this
518
+ /**
519
+ * The window is asked to close: by the user, or with quit when the application quits (Cmd+Q on macOS). Listened
520
+ * to, it closes only through close(true)
521
+ * @param event
522
+ * @param listener
523
+ */
524
+ on(event: 'close', listener: (reason?: 'quit') => void): this
525
+ removeAllListeners(event?: string): this
526
+ show(): void
527
+ hide(): void
528
+ focus(): void
529
+ restore(): void
530
+ /**
531
+ * Close the window: with true, without its close event
532
+ * @param force
533
+ */
534
+ close(force?: boolean): void
535
+ }
536
+
537
+ /**
538
+ * The options of a window: https://docs.nwjs.io/References/Manifest%20Format/#window-subfields
539
+ */
540
+ interface WindowOptions {
541
+ title?: string
542
+ width?: number
543
+ height?: number
544
+ icon?: string
545
+ show?: boolean
546
+ [option: string]: unknown
547
+ }
548
+
549
+ /**
550
+ * The clipboard of the system: https://docs.nwjs.io/References/Clipboard/
551
+ */
552
+ interface Clipboard {
553
+ get(type?: 'text' | 'html' | 'rtf' | 'png' | 'jpeg'): string
554
+ set(data: string, type?: 'text' | 'html' | 'rtf' | 'png' | 'jpeg'): void
555
+ }
556
+
557
+ /**
558
+ * An item of a menu: https://docs.nwjs.io/References/MenuItem/
559
+ */
560
+ class MenuItem {
561
+ constructor(options: {label?: string, type?: 'normal' | 'separator', enabled?: boolean, click?: () => void})
562
+ }
563
+
564
+ /**
565
+ * A menu: https://docs.nwjs.io/References/Menu/
566
+ */
567
+ class Menu {
568
+ append(item: MenuItem): void
569
+ }
570
+
571
+ /**
572
+ * An icon in the tray of the system (the menu bar of macOS, the notification area of Windows):
573
+ * https://docs.nwjs.io/References/Tray/
574
+ */
575
+ class Tray {
576
+ constructor(options: {title?: string, tooltip?: string, icon?: string})
577
+ menu: Menu
578
+ remove(): void
579
+ }
580
+
581
+ const App: {
582
+ /**
583
+ * The arguments of the application, after the directory of the application
584
+ */
585
+ readonly argv: string[]
586
+ /**
587
+ * The directory of the data of the application, in the one of the user
588
+ */
589
+ readonly dataPath: string
590
+ /**
591
+ * Quit the application
592
+ */
593
+ quit(): void
594
+ /**
595
+ * A second instance of the application started: its command line given to the first one
596
+ * @param event
597
+ * @param listener
598
+ */
599
+ on(event: 'open', listener: (commandLine: string) => void): void
600
+ }
601
+
602
+ const Window: {
603
+ /**
604
+ * Open a window: its URL relative to the directory of the application
605
+ * @param url
606
+ * @param options
607
+ * @param callback
608
+ */
609
+ open(url: string, options: WindowOptions, callback: (window: nw.Window) => void): void
610
+ }
611
+
612
+ const Clipboard: {
613
+ /**
614
+ * The clipboard
615
+ */
616
+ get(): Clipboard
617
+ }
618
+ }
619
+ ```
620
+
621
+ ## 7. The options of the application
622
+
623
+ **`packages/app/src/config/Config.ts`**
624
+
625
+ ```typescript
626
+ import type {ApplicationOptions} from 'lakutata'
627
+ import {buildEntrypoints, httpServiceEntrypoint} from 'lakutata/com/entrypoint'
628
+ import {nwjsServiceEntrypoint} from 'lakutata/adapter/nwjs'
629
+ import path from 'node:path'
630
+ import {type ClipboardAccess, History} from '../components/History.js'
631
+ import {MainWindow} from '../components/MainWindow.js'
632
+ import {Tray} from '../components/Tray.js'
633
+ import {ClipController} from '../controllers/ClipController.js'
634
+
635
+ export type ConfigOptions = {
636
+ /**
637
+ * The clipboard read and written, the one of NW.js in the application (the tests give theirs)
638
+ */
639
+ clipboard: ClipboardAccess
640
+ /**
641
+ * The directory of the data of the application, where the history is kept
642
+ */
643
+ dataPath: string
644
+ /**
645
+ * How often the clipboard is read, in milliseconds (500 by default)
646
+ */
647
+ interval?: number
648
+ /**
649
+ * The clips kept, the pinned ones aside (50 by default)
650
+ */
651
+ limit?: number
652
+ /**
653
+ * The URL of the user interface in development (the dev server of Vite), its built files otherwise
654
+ */
655
+ ui?: string
656
+ mode?: 'development' | 'production'
657
+ /**
658
+ * Open the window and the icon in the tray, true by default (the tests serve the actions without them)
659
+ */
660
+ window?: boolean
661
+ /**
662
+ * The global object the bridge of the actions is put on, the one of NW.js by default (the tests give theirs)
663
+ */
664
+ global?: object
665
+ /**
666
+ * The port of an HTTP entrypoint for development, none by default. Tools (Postman, curl) invoke the actions with
667
+ * POST / and the message (the pattern and the input), receive the events pushed to them with GET /events
668
+ * (Server-Sent Events), and give their client id in the id header (or the id query parameter)
669
+ */
670
+ devPort?: number
671
+ }
672
+
673
+ /**
674
+ * The options of the application
675
+ * @param options
676
+ */
677
+ export function config(options: ConfigOptions): ApplicationOptions {
678
+ return {
679
+ id: 'clips',
680
+ name: 'Clips',
681
+ mode: options.mode ?? 'development',
682
+ components: {
683
+ entrypoint: buildEntrypoints({
684
+ controllers: [ClipController],
685
+ //The user interface invokes the actions through the host page of the window: window.ipc.invoke({cmd: 'clips.list'})
686
+ service: [
687
+ nwjsServiceEntrypoint({global: options.global}),
688
+ ...options.devPort === undefined ? [] : [httpServiceEntrypoint({port: options.devPort})]
689
+ ]
690
+ }),
691
+ history: {
692
+ class: History,
693
+ clipboard: options.clipboard,
694
+ file: path.join(options.dataPath, 'history.json'),
695
+ interval: options.interval,
696
+ limit: options.limit
697
+ },
698
+ ...(options.window === false ? {} : {
699
+ window: {
700
+ class: MainWindow,
701
+ ui: options.ui
702
+ },
703
+ tray: {
704
+ class: Tray
705
+ }
706
+ })
707
+ },
708
+ //The actions are served before the window loads the user interface; the history reads the clipboard from the
709
+ //start, the window hidden too
710
+ bootstrap: options.window === false ? ['entrypoint', 'history'] : ['entrypoint', 'history', 'window', 'tray']
711
+ }
712
+ }
713
+ ```
714
+
715
+ The options of `history` are those of the component: the clipboard, the file (`history.json` in the data directory) and
716
+ the interval and the limit, which take their default values when they are `undefined`. Without a window
717
+ (`window: false`, the tests), there is no tray either: the components that need NW.js are left out, and the actions are
718
+ served through the global object that the tests give.
719
+
720
+ ## 8. The background script
721
+
722
+ **`packages/app/src/App.ts`**
723
+
724
+ ```typescript
725
+ import {Application, type ILogger} from 'lakutata'
726
+ import {config} from './config/Config.js'
727
+
728
+ /**
729
+ * The URL of the user interface in development (--ui=<url>, the dev server of Vite), given to lakutata nwjs run
730
+ */
731
+ const ui: string | undefined = nw.App.argv.find((argument: string): boolean => argument.startsWith('--ui='))?.slice('--ui='.length)
732
+
733
+ /**
734
+ * The application runs in the NW.js of the development (lakutata nwjs run: the sdk flavor, with the DevTools), in the
735
+ * normal one once installed
736
+ */
737
+ const development: boolean = process.versions['nw-flavor'] === 'sdk'
738
+
739
+ /**
740
+ * The clipboard of the system, through NW.js
741
+ */
742
+ const clipboard: nw.Clipboard = nw.Clipboard.get()
743
+
744
+ //NW.js runs a single instance of the application: a second one exits, and the first one brings its window to the front
745
+ Application
746
+ .run(config({
747
+ clipboard: {read: (): string => clipboard.get('text'), write: (text: string): void => clipboard.set(text, 'text')},
748
+ //The directory of the data of NW.js for the application, in the one of the user
749
+ dataPath: nw.App.dataPath,
750
+ ui: ui,
751
+ mode: development ? 'development' : 'production',
752
+ //The actions served to the tools in development (Postman, curl), not by the installed application
753
+ devPort: development ? Number(process.env.DEV_PORT ?? 9000) : undefined
754
+ }))
755
+ .onLaunched((app: Application, log: ILogger): void => log.info('%s started in %s mode', app.appName, app.mode()))
756
+ .onFatalException((error: Error, log: ILogger): void => {
757
+ log.error('The application cannot start: %s', error.message)
758
+ process.exit(1)
759
+ })
760
+ ```
761
+
762
+ Two options are added to the `App.ts` of the template: `clipboard`, the clipboard of NW.js (`nw.Clipboard.get()`) read
763
+ and written as text, and `dataPath`, the data directory that NW.js gives the application (`nw.App.dataPath`). That
764
+ directory is named after the `appId` of `lakutata.installer` in `packages/app/package.json` (`clips`), in development as
765
+ in the installed application: `~/Library/Application Support/clips/Default` on macOS, `%LOCALAPPDATA%\clips` on Windows
766
+ and `~/.config/clips` on Linux.
767
+
768
+ The host page and the bridge of the template (`packages/app/host`, `packages/ui/src/ipc.ts`) stay as they are: they give
769
+ the user interface the actions and the events of the application, and nothing else from NW.js or Node.js.
770
+
771
+ ## 9. The user interface
772
+
773
+ **`packages/ui/src/App.tsx`**
774
+
775
+ ```tsx
776
+ import {useCallback, useEffect, useState} from 'react'
777
+
778
+ /**
779
+ * A clip of the history, as the actions give it
780
+ */
781
+ type Clip = { id: string, text: string, copiedAt: string, pinned: boolean }
782
+
783
+ export function App() {
784
+ const [query, setQuery] = useState<string>('')
785
+ const [clips, setClips] = useState<Clip[]>([])
786
+ const [failure, setFailure] = useState<string>('')
787
+
788
+ const load = useCallback((): void => {
789
+ window.ipc.invoke<Clip[]>({cmd: 'clips.list', query: query || undefined})
790
+ .then((loaded: Clip[]): void => setClips(loaded))
791
+ .catch((error: Error): void => setFailure(error.message))
792
+ }, [query])
793
+
794
+ useEffect((): () => void => {
795
+ load()
796
+ //The history changed: a copy, here or from the tray
797
+ window.ipc.on('clips.changed', load)
798
+ return (): void => {
799
+ window.ipc.off('clips.changed', load)
800
+ }
801
+ }, [load])
802
+
803
+ /**
804
+ * Invoke an action on the history, which pushes its change
805
+ * @param data
806
+ */
807
+ const act = (data: Record<string, unknown>): void => {
808
+ window.ipc.invoke(data).catch((error: Error): void => setFailure(error.message))
809
+ }
810
+
811
+ return (
812
+ <main>
813
+ <input type="search" placeholder="Search the history" value={query} onChange={(event): void => setQuery(event.target.value)}/>
814
+ <button onClick={(): void => act({cmd: 'clips.clear'})}>Clear</button>
815
+ {failure && <p role="alert">{failure}</p>}
816
+ <ul>
817
+ {clips.map((clip: Clip) => (
818
+ <li key={clip.id}>
819
+ <pre title={new Date(clip.copiedAt).toLocaleString()}>{clip.text}</pre>
820
+ <button onClick={(): void => act({cmd: 'clips.copy', id: clip.id})}>Copy</button>
821
+ <button onClick={(): void => act({cmd: 'clips.pin', id: clip.id, pinned: !clip.pinned})}>{clip.pinned ? 'Unpin' : 'Pin'}</button>
822
+ <button onClick={(): void => act({cmd: 'clips.delete', id: clip.id})}>Delete</button>
823
+ </li>
824
+ ))}
825
+ </ul>
826
+ </main>
827
+ )
828
+ }
829
+ ```
830
+
831
+ - `window.ipc` is the bridge of the template (`packages/ui/src/ipc.ts`): `invoke()` calls an action and gives its
832
+ `payload`, or throws its error; `on()` and `off()` listen to the events pushed by the application.
833
+ - The list is loaded again on each `clips.changed`, whatever caused it: a copy in another program, an action of the
834
+ window, or a clip chosen in the menu of the tray.
835
+ - The buttons do not change the list themselves: the history pushes its change, and the list follows.
836
+
837
+ ## 10. Run it
838
+
839
+ ```bash
840
+ npm run dev
841
+ ```
842
+
843
+ The command starts the dev server of Vite, which serves the user interface and reloads it whenever `packages/ui`
844
+ changes, and NW.js, whose window loads the user interface with its DevTools open. The application logs
845
+ `Clips started in development mode`. Copy a few texts in any program: they appear at the top of the window, and in the
846
+ menu of the tray (the name `Clips` in the menu bar of macOS).
847
+
848
+ In development, the actions are also served over HTTP on port 9000 (`DEV_PORT`), so you can try them with curl or
849
+ Postman: `POST /` sends a message, and `GET /events` streams the events of a client as Server-Sent Events
850
+ ([ServiceCalls](../ServiceCalls.md), section 4). After copying `git log --oneline -5`, then
851
+ `https://docs.nwjs.io/References/Tray/`, run in another terminal:
852
+
853
+ ```bash
854
+ curl -N 'localhost:9000/events?id=me' &
855
+ # event: client
856
+ # data: ["me"]
857
+
858
+ curl -X POST localhost:9000/ -H 'Content-Type: application/json' -d '{"cmd": "clips.list"}'
859
+ # {"success":true,"error":null,"payload":[{"id":"89994a65-872a-44f1-bdf2-d44d2ead2748","text":"https://docs.nwjs.io/References/Tray/","copiedAt":"2026-11-03T15:29:26.787Z","pinned":false},{"id":"7de7167d-cfaa-4861-807c-f65ea10b97fc","text":"git log --oneline -5","copiedAt":"2026-11-03T15:29:25.786Z","pinned":false}]}
860
+
861
+ curl -X POST localhost:9000/ -H 'Content-Type: application/json' -d '{"cmd": "clips.pin", "id": "7de7167d-cfaa-4861-807c-f65ea10b97fc", "pinned": true}'
862
+ # {"success":true,"error":null,"payload":{"id":"7de7167d-cfaa-4861-807c-f65ea10b97fc","text":"git log --oneline -5","copiedAt":"2026-11-03T15:29:25.786Z","pinned":true}}
863
+ # event: clips.changed
864
+ # data: []
865
+
866
+ curl -X POST localhost:9000/ -H 'Content-Type: application/json' -d '{"cmd": "clips.list", "query": "TRAY"}'
867
+ # {"success":true,"error":null,"payload":[{"id":"89994a65-872a-44f1-bdf2-d44d2ead2748","text":"https://docs.nwjs.io/References/Tray/","copiedAt":"2026-11-03T15:29:26.787Z","pinned":false}]}
868
+
869
+ curl -X POST localhost:9000/ -H 'Content-Type: application/json' -d '{"cmd": "clips.copy", "id": "00000000-0000-4000-8000-000000000000"}'
870
+ # {"success":false,"error":{…,"message":"The clip 00000000-0000-4000-8000-000000000000 is not in the history","errMsg":"The clip 00000000-0000-4000-8000-000000000000 is not in the history","err":"ClipNotFoundException","errno":"E_CLIP_NOT_FOUND"},"payload":null}
871
+ ```
872
+
873
+ The window shows the pin at once, as the event reached it too. The error carries all the properties of the exception,
874
+ including its stack (left out here).
875
+
876
+ ## 11. Test it
877
+
878
+ **`packages/app/src/tests/App.spec.ts`**
879
+
880
+ ```typescript
881
+ import {after, before, describe, it} from 'node:test'
882
+ import assert from 'node:assert'
883
+ import {mkdtempSync, readFileSync, rmSync} from 'node:fs'
884
+ import os from 'node:os'
885
+ import path from 'node:path'
886
+ import {Application} from 'lakutata'
887
+ import {NWJS_SERVICE_BRIDGE, type NwjsServiceBridge} from 'lakutata/adapter/nwjs'
888
+ import type {Clip} from '../components/History.js'
889
+ import {config} from '../config/Config.js'
890
+
891
+ /**
892
+ * The text of the clipboard of the tests, which the application reads and writes without NW.js
893
+ */
894
+ let copied: string = ''
895
+
896
+ /**
897
+ * The global object of the tests: the actions are invoked through the bridge put on it, as the host page of the window
898
+ * invokes them
899
+ */
900
+ const shared: Record<symbol, unknown> = {}
901
+
902
+ /**
903
+ * Invoke an action as the user interface does (window.ipc.invoke)
904
+ * @param data
905
+ */
906
+ function invoke(data: Record<string, unknown>): Promise<any> {
907
+ return (shared[Symbol.for(NWJS_SERVICE_BRIDGE)] as NwjsServiceBridge).invoke(data, 'page')
908
+ }
909
+
910
+ /**
911
+ * The texts of the history, in its order
912
+ */
913
+ async function texts(): Promise<string[]> {
914
+ return (await invoke({cmd: 'clips.list'})).payload.map((clip: Clip): string => clip.text)
915
+ }
916
+
917
+ /**
918
+ * Copy a text to the clipboard, as another program does, and wait for the history to read it: the latest clip that is
919
+ * not pinned
920
+ * @param text
921
+ */
922
+ async function copy(text: string): Promise<void> {
923
+ copied = text
924
+ const latest = async (): Promise<string | undefined> => (await invoke({cmd: 'clips.list'})).payload.find((clip: Clip): boolean => !clip.pinned)?.text
925
+ for (let attempt: number = 0; await latest() !== text; attempt++) {
926
+ if (attempt === 100) throw new Error(`${text} was not read from the clipboard`)
927
+ await new Promise((resolve): unknown => setTimeout(resolve, 10))
928
+ }
929
+ }
930
+
931
+ describe('Clips', (): void => {
932
+
933
+ let app: Application
934
+ let data: string
935
+ const pushed: string[] = []
936
+
937
+ before(async (): Promise<void> => {
938
+ data = mkdtempSync(path.join(os.tmpdir(), 'clips-'))
939
+ copied = 'On the clipboard before the application starts'
940
+ app = await new Promise<Application>((resolve: (app: Application) => void): void => {
941
+ Application.run(config({
942
+ clipboard: {read: (): string => copied, write: (text: string): void => void (copied = text)},
943
+ dataPath: data,
944
+ interval: 10,
945
+ limit: 3,
946
+ window: false,
947
+ global: shared
948
+ })).onLaunched((launched: Application): void => resolve(launched))
949
+ })
950
+ //The page subscribed to the events of the application
951
+ const bridge: NwjsServiceBridge = shared[Symbol.for(NWJS_SERVICE_BRIDGE)] as NwjsServiceBridge
952
+ bridge.subscribe('page', (event: string): unknown => pushed.push(event))
953
+ })
954
+
955
+ after(async (): Promise<void> => {
956
+ await app?.dispose()
957
+ rmSync(data, {recursive: true, force: true})
958
+ })
959
+
960
+ it('keeps the texts copied, the latest first, but not what the clipboard held at the start', async (): Promise<void> => {
961
+ await copy('git status')
962
+ await copy('npm test')
963
+ assert.deepStrictEqual(await texts(), ['npm test', 'git status'])
964
+ //The window was told
965
+ assert.ok(pushed.includes('clips.changed'))
966
+ })
967
+
968
+ it('moves a text copied again to the top, and drops the oldest clips beyond the limit', async (): Promise<void> => {
969
+ await copy('git status')
970
+ await copy('ls -la')
971
+ await copy('pwd')
972
+ assert.deepStrictEqual(await texts(), ['pwd', 'ls -la', 'git status'])
973
+ })
974
+
975
+ it('keeps the pinned clips first, beyond the limit', async (): Promise<void> => {
976
+ const [, , oldest] = (await invoke({cmd: 'clips.list'})).payload
977
+ assert.strictEqual((await invoke({cmd: 'clips.pin', id: oldest.id, pinned: true})).payload.pinned, true)
978
+ await copy('make')
979
+ await copy('make install')
980
+ await copy('make clean')
981
+ assert.deepStrictEqual(await texts(), ['git status', 'make clean', 'make install', 'make'])
982
+ })
983
+
984
+ it('copies a clip to the clipboard again', async (): Promise<void> => {
985
+ const clips: Clip[] = (await invoke({cmd: 'clips.list', query: 'INSTALL'})).payload
986
+ assert.deepStrictEqual(clips.map((clip: Clip): string => clip.text), ['make install'])
987
+ await invoke({cmd: 'clips.copy', id: clips[0].id})
988
+ assert.strictEqual(copied, 'make install')
989
+ assert.deepStrictEqual(await texts(), ['git status', 'make install', 'make clean', 'make'])
990
+ })
991
+
992
+ it('removes a clip, then all of them but the pinned ones', async (): Promise<void> => {
993
+ const [, second] = (await invoke({cmd: 'clips.list'})).payload
994
+ assert.strictEqual((await invoke({cmd: 'clips.delete', id: second.id})).success, true)
995
+ assert.deepStrictEqual(await texts(), ['git status', 'make clean', 'make'])
996
+ await invoke({cmd: 'clips.clear'})
997
+ assert.deepStrictEqual(await texts(), ['git status'])
998
+ })
999
+
1000
+ it('keeps the history in its file', async (): Promise<void> => {
1001
+ await new Promise((resolve): unknown => setTimeout(resolve, 50))
1002
+ const saved: Clip[] = JSON.parse(readFileSync(path.join(data, 'history.json'), 'utf8'))
1003
+ assert.deepStrictEqual(saved.map((clip: Clip): string => clip.text), ['git status'])
1004
+ })
1005
+
1006
+ it('refuses a clip that is not in the history, and an invalid input', async (): Promise<void> => {
1007
+ const missing = await invoke({cmd: 'clips.copy', id: '00000000-0000-4000-8000-000000000000'})
1008
+ assert.strictEqual(missing.success, false)
1009
+ assert.strictEqual(missing.error.errno, 'E_CLIP_NOT_FOUND')
1010
+ const invalid = await invoke({cmd: 'clips.pin', id: 'first'})
1011
+ assert.strictEqual(invalid.success, false)
1012
+ assert.match(invalid.error.message, /id/)
1013
+ })
1014
+ })
1015
+ ```
1016
+
1017
+ The tests run the application with Node.js, without NW.js: `window: false` leaves out the window and the tray, the
1018
+ clipboard is a variable, and the data directory a temporary one. `nwjsServiceEntrypoint` puts its bridge on the global
1019
+ object it is given (`global: shared`), where the host page of a window finds it in NW.js; the tests call the actions
1020
+ through it, as the window does, and subscribe to the events of a client. The interval of 10 milliseconds and the limit
1021
+ of 3 clips keep the tests short.
1022
+
1023
+ ```bash
1024
+ npm test
1025
+ ```
1026
+
1027
+ ```
1028
+ app │ ✔ keeps the texts copied, the latest first, but not what the clipboard held at the start
1029
+ app │ ✔ moves a text copied again to the top, and drops the oldest clips beyond the limit
1030
+ app │ ✔ keeps the pinned clips first, beyond the limit
1031
+ app │ ✔ copies a clip to the clipboard again
1032
+ app │ ✔ removes a clip, then all of them but the pinned ones
1033
+ app │ ✔ keeps the history in its file
1034
+ app │ ✔ refuses a clip that is not in the history, and an invalid input
1035
+ app │ ℹ pass 7
1036
+ ✔ test ran in app
1037
+ ```
1038
+
1039
+ `npm test` builds the packages, the user interface included, then runs the tests. What needs NW.js, the clipboard of the
1040
+ system, the tray and the window, is tried out with `npm run dev`.
1041
+
1042
+ ## 12. Package it
1043
+
1044
+ - `npm run package:host` builds the packages, then makes the installers for the platform it runs on, in
1045
+ `dist/installer`: a `dmg` on macOS, a `deb` on Linux, an `nsis` installer and a `zip` on Windows.
1046
+ `npm run package:linux` makes the Linux ones in Docker, from any platform. They are made by
1047
+ `lakutata package installer`, from `lakutata.installer` in `packages/app/package.json`
1048
+ ([Installers](../Installers.md)).
1049
+ - The installed application runs in the normal flavor of NW.js, without the DevTools: its mode is `production` and it
1050
+ has no HTTP entrypoint (`devPort` is given only in the sdk flavor of `lakutata nwjs run`).
1051
+ - The application runs in the version of NW.js given by `lakutata.nwjs` in `packages/app/package.json`;
1052
+ `npm run nwjs:use -- <version>` gives it another one, such as `win7`, NW.js 0.72, the last release that runs on
1053
+ Windows 7, 8 and 8.1 ([ProjectStructure](../ProjectStructure.md)).
1054
+
1055
+ <!--
1056
+ check:
1057
+ create: clips nwjs-react
1058
+ remove: packages/app/src/controllers/ExampleController.ts packages/app/src/dtos/GreetDTO.ts
1059
+ run: npm test
1060
+ run: npm run typecheck -w ui
1061
+ -->