A desktop application
This example builds a desktop notes application: the notes are Markdown files in a folder that the user chooses, and the window lists, opens, saves and deletes them. It starts from the electron-react template and shows both sides of an Electron application made with lakutata: the main process, whose actions read and write the files, open a system dialog and push an event when the folder changes; and the React renderer, which calls these actions over IPC. It also shows tests of the main process that run without Electron.
| Message | What it does |
|---|---|
{cmd: 'folder.get'} | Gives the notes folder, null before one is chosen |
{cmd: 'folder.choose'} | Asks the user for a folder in a system dialog, and remembers it |
{cmd: 'notes.list'} | Lists the notes, the most recently modified first |
{cmd: 'notes.read', name: 'Shopping'} | Gives the text of a note |
{cmd: 'notes.save', name: 'Shopping', text: '- bread'} | Writes a note to the file Shopping.md |
{cmd: 'notes.delete', name: 'Shopping'} | Deletes it |
{cmd: 'notes.reveal', name: 'Shopping'} | Shows its file in the file manager (Finder, Explorer…) |
When files in the folder change, the main process pushes the notes.changed event to the windows.
1. Create the project
npx @lakutata/cli create --name notes --template electron-react --yes
cd notes
rm packages/app/src/controllers/ExampleController.ts packages/app/src/dtos/GreetDTO.tsThe project is a workspace (ProjectStructure): packages/app is the main process, a lakutata application; packages/ui is the renderer, a React page built by Vite; packages/resources holds the icon. The application needs no other package: it reads the files with node:fs, and its dialogs come from Electron. The example controller of the template is removed.
2. The input
packages/app/src/dtos/NoteDTO.ts
import {DTO} from 'lakutata'
import {Expect} from 'lakutata/decorator/dto'
/**
* A note by its name, which is its file name without .md: no slash, and no character that a file system refuses
*/
export class NoteNameDTO extends DTO {
@Expect(DTO.String().trim().min(1).max(100).pattern(/^[^\\/:*?"<>|]+$/, 'file name').required())
public name: string
}
/**
* A note to write
*/
export class SaveNoteDTO extends NoteNameDTO {
@Expect(DTO.String().allow('').max(1000000).required())
public text: string
}Each action validates its input with a DTO before it runs (DTOValidation):
- a note is a file
<name>.mdin the folder: the pattern refuses slashes and the characters that Windows does not allow in file names, so a name cannot lead out of the folder. The second argument of.pattern()names the pattern in the error message:"name" with value "a/b" fails to match the file name pattern; .allow('')accepts an empty note;- a message is JSON, so its values keep their types: no
.strict(false)is needed, unlike the query string of an HTTP request (ServiceCalls, section 2).
3. The exceptions
packages/app/src/exceptions/NoteExceptions.ts
import {Exception} from 'lakutata'
/**
* No notes folder has been chosen yet
*/
export class NoFolderException extends Exception {
public errno: string = 'E_NO_FOLDER'
}
/**
* The requested note is not in the folder
*/
export class NoteNotFoundException extends Exception {
public errno: string = 'E_NOTE_NOT_FOUND'
}An exception has an errno, the code that the renderer checks (Exceptions). Over IPC, the error is sent with all its properties (ServiceCalls, section 3), so the renderer receives it as it was thrown: error.errno is 'E_NOTE_NOT_FOUND'. The service transports always answer with an envelope, whatever the status of the error, so these exceptions need no statusCode.
4. The notes
packages/app/src/components/Notes.ts
import {Component, DTO} from 'lakutata'
import {type Entrypoint} from 'lakutata/com/entrypoint'
import {Configurable, Inject} from 'lakutata/decorator/di'
import {existsSync, watch, type FSWatcher} from 'node:fs'
import {mkdir, readdir, readFile, rm, stat, writeFile} from 'node:fs/promises'
import path from 'node:path'
import {NoFolderException, NoteNotFoundException} from '../exceptions/NoteExceptions.js'
/**
* A note of the folder: its name, and when its file was modified
*/
export type NoteInfo = { name: string, modifiedAt: string }
/**
* The notes: the Markdown files in the folder that the user chose, which the settings of the application remember.
* Changes to its files, made by the application or by another program, are pushed to the windows (notes.changed)
*/
export class Notes extends Component {
/**
* Pushes the events to the windows
*/
@Inject('entrypoint')
protected readonly entrypoint: Entrypoint
/**
* The settings file, where the folder is remembered
*/
@Configurable(DTO.String().required())
protected readonly settings: string
#folder: string | null = null
#watcher: FSWatcher | undefined
#timer: NodeJS.Timeout | undefined
/**
* The notes folder, null before one is chosen
*/
public get folder(): string | null {
return this.#folder
}
/**
* Use the folder remembered in the settings, if it still exists
*/
protected async init(): Promise<void> {
const settings: { folder?: string } = await readFile(this.settings, 'utf8').then((text: string) => JSON.parse(text), () => ({}))
if (settings.folder && existsSync(settings.folder)) this.watch(settings.folder)
}
protected async destroy(): Promise<void> {
clearTimeout(this.#timer)
this.#watcher?.close()
}
/**
* Watch the files of a folder: their changes are pushed to the windows once they settle
* @param folder
*/
protected watch(folder: string): void {
this.#watcher?.close()
const watcher: FSWatcher = watch(folder, (): void => {
clearTimeout(this.#timer)
this.#timer = setTimeout((): number => this.entrypoint.broadcast('notes.changed'), 100)
})
//The folder was removed, or can no longer be read
watcher.on('error', (): void => watcher.close())
this.#watcher = watcher
this.#folder = folder
}
/**
* Use another folder, and remember it
* @param folder
*/
public async use(folder: string): Promise<void> {
this.watch(folder)
await mkdir(path.dirname(this.settings), {recursive: true})
await writeFile(this.settings, JSON.stringify({folder: folder}))
}
/**
* The file of a note
* @param name
*/
protected file(name: string): string {
if (!this.#folder) throw new NoFolderException('No folder of notes is chosen')
return path.join(this.#folder, `${name}.md`)
}
/**
* The file of an existing note
* @param name
*/
public find(name: string): string {
const file: string = this.file(name)
if (!existsSync(file)) throw new NoteNotFoundException(`The note ${name} does not exist`)
return file
}
/**
* The notes, the most recently modified first
*/
public async list(): Promise<NoteInfo[]> {
if (!this.#folder) return []
const notes: NoteInfo[] = []
for (const entry of await readdir(this.#folder, {withFileTypes: true})) {
if (!entry.isFile() || !entry.name.endsWith('.md')) continue
const modified: Date = (await stat(path.join(this.#folder, entry.name))).mtime
notes.push({name: entry.name.slice(0, -'.md'.length), modifiedAt: modified.toISOString()})
}
return notes.sort((a: NoteInfo, b: NoteInfo): number => b.modifiedAt.localeCompare(a.modifiedAt))
}
/**
* The text of a note
* @param name
*/
public async read(name: string): Promise<string> {
return await readFile(this.find(name), 'utf8')
}
/**
* Write a note, new or existing
* @param name
* @param text
*/
public async write(name: string, text: string): Promise<NoteInfo> {
const file: string = this.file(name)
await writeFile(file, text)
return {name: name, modifiedAt: (await stat(file)).mtime.toISOString()}
}
/**
* Delete a note
* @param name
*/
public async remove(name: string): Promise<void> {
await rm(this.find(name))
}
}- A component is created once and shared by the whole application (Objects). It keeps the folder, which outlives the requests: a controller lives for one request only (Controllers, section 2).
settings, the file where the folder is remembered, is an option of the component:@Configurabledeclares it, and the options of the application give its value (Injection).init()reads the file when the application starts.watch()fromnode:fswatches the folder. When its files change, whether the application or another program (an editor, a sync tool) saved them,this.entrypoint.broadcast()pushesnotes.changedto every window (ServiceCalls, section 5). The timer waits for the changes to settle, as one save often causes several of them.destroy()stops watching when the application stops.
5. The controller
packages/app/src/controllers/NoteController.ts
import {Controller, type ServiceContext} from 'lakutata/com/entrypoint'
import {ipcEventOfContext} from 'lakutata/adapter/electron'
import {ServiceAction} from 'lakutata/decorator/ctrl'
import {Inject} from 'lakutata/decorator/di'
import type {BrowserWindow, IpcMainInvokeEvent, OpenDialogOptions, OpenDialogReturnValue} from 'electron'
import {type NoteInfo, Notes} from '../components/Notes.js'
import {NoteNameDTO, SaveNoteDTO} from '../dtos/NoteDTO.js'
/**
* The note actions, invoked by the window: window.ipc.invoke({cmd: 'notes.list'})
*/
export class NoteController extends Controller {
@Inject('notes')
protected readonly notes: Notes
/**
* The notes folder, null before one is chosen
*/
@ServiceAction({cmd: 'folder.get'})
public async folder(): Promise<string | null> {
return this.notes.folder
}
/**
* Ask the user for a folder in a system dialog, attached to the window that called; the folder stays the same when
* the dialog is canceled
*/
@ServiceAction({cmd: 'folder.choose'})
public async choose(): Promise<string | null> {
//Electron is loaded by the actions that use it: the tests, run by Node.js, do not invoke them
const {BrowserWindow, dialog} = (await import('electron')).default
const event: IpcMainInvokeEvent | undefined = ipcEventOfContext<IpcMainInvokeEvent>(this.context as ServiceContext)
const window: BrowserWindow | null = event ? BrowserWindow.fromWebContents(event.sender) : null
const options: OpenDialogOptions = {title: 'The folder of the notes', properties: ['openDirectory', 'createDirectory']}
const result: OpenDialogReturnValue = window ? await dialog.showOpenDialog(window, options) : await dialog.showOpenDialog(options)
if (!result.canceled && result.filePaths.length) await this.notes.use(result.filePaths[0])
return this.notes.folder
}
/**
* The notes, the most recently modified first
*/
@ServiceAction({cmd: 'notes.list'})
public async list(): Promise<NoteInfo[]> {
return await this.notes.list()
}
/**
* A note and its text
* @param input
*/
@ServiceAction({cmd: 'notes.read'}, NoteNameDTO)
public async read(input: NoteNameDTO): Promise<{ name: string, text: string }> {
return {name: input.name, text: await this.notes.read(input.name)}
}
/**
* Write a note: the file <name>.md in the folder
* @param input
*/
@ServiceAction({cmd: 'notes.save'}, SaveNoteDTO)
public async save(input: SaveNoteDTO): Promise<NoteInfo> {
return await this.notes.write(input.name, input.text)
}
/**
* Delete a note
* @param input
*/
@ServiceAction({cmd: 'notes.delete'}, NoteNameDTO)
public async remove(input: NoteNameDTO): Promise<void> {
await this.notes.remove(input.name)
}
/**
* Show the file of a note in the system file manager (Finder, Explorer…)
* @param input
*/
@ServiceAction({cmd: 'notes.reveal'}, NoteNameDTO)
public async reveal(input: NoteNameDTO): Promise<void> {
const {shell} = (await import('electron')).default
shell.showItemInFolder(this.notes.find(input.name))
}
}- Each
@ServiceActionis called by a message:{cmd: 'notes.save', name: 'Shopping', text: '- bread'}callssave()with{name: 'Shopping', text: '- bread'}, validated bySaveNoteDTO(ServiceCalls, section 2). The actions are short: the work is done by thenotescomponent, which the controller injects. folder.chooseandnotes.revealuse APIs that only the main process has:dialogandshell. Like theMainWindowof the template, they import Electron only when they run, so the tests, which run with Node.js, never load it.ipcEventOfContext()gives the IPC event of the request. Itssenderis the page that called, andBrowserWindow.fromWebContents()gives the window of that page, which the dialog belongs to (on macOS, a sheet attached to the window). When the action is called over the HTTP entrypoint for development (section 9), there is no IPC event, and the dialog belongs to no window.- An action that returns nothing, such as
remove(), answers{success: true, error: null}.
6. The options of the application
packages/app/src/config/Config.ts
import type {ApplicationOptions} from 'lakutata'
import {buildEntrypoints, httpServiceEntrypoint} from 'lakutata/com/entrypoint'
import {electronIPCServiceEntrypoint, type IPCMain} from 'lakutata/adapter/electron'
import {MainWindow} from '../components/MainWindow.js'
import {Notes} from '../components/Notes.js'
import {NoteController} from '../controllers/NoteController.js'
export type ConfigOptions = {
/**
* The settings file, where the notes folder is remembered
*/
settings: string
/**
* The directory of the main process, where the preload script and the built renderer are
*/
directory?: string
/**
* The URL of the renderer in development (the Vite dev server); without it, the built renderer is loaded
*/
renderer?: string
mode?: 'development' | 'production'
/**
* Whether to open the window, true by default (the tests serve the actions without a window)
*/
window?: boolean
/**
* The ipcMain that serves the actions, Electron's by default (the tests pass their own)
*/
ipcMain?: IPCMain
/**
* The port of an HTTP entrypoint for development, none by default. Tools (Postman, curl) invoke the actions with
* POST / and the message (the pattern and the input), receive the events pushed to them with GET /events
* (Server-Sent Events), and give their client id in the id header (or the id query parameter)
*/
devPort?: number
}
/**
* The options of the application
* @param options
*/
export function config(options: ConfigOptions): ApplicationOptions {
return {
id: 'notes',
name: 'notes',
mode: options.mode ?? 'development',
components: {
entrypoint: buildEntrypoints({
controllers: [NoteController],
//The renderer invokes the actions over IPC: window.ipc.invoke({cmd: 'notes.list'})
service: [
electronIPCServiceEntrypoint({ipcMain: options.ipcMain}),
...options.devPort === undefined ? [] : [httpServiceEntrypoint({port: options.devPort})]
]
}),
notes: {
class: Notes,
settings: options.settings
},
...(options.window === false ? {} : {
window: {
class: MainWindow,
directory: options.directory,
renderer: options.renderer
}
})
},
//The actions are served, and the folder watched, before the window loads the renderer
bootstrap: options.window === false ? ['entrypoint', 'notes'] : ['entrypoint', 'notes', 'window']
}
}This is the Config.ts of the template, with:
- the required
settingsoption, the settings file; - the
notescomponent, which receives that file, and theNoteControllerinstead of the example controller; bootstrap: ['entrypoint', 'notes', 'window']: the actions are served and the folder is watched before the window loads the renderer. The tests start the application without its window (window: false), with their ownipcMain.
7. The main process
packages/app/src/App.ts
import {Application, resourcesPath, type ILogger} from 'lakutata'
import electron from 'electron'
import path from 'node:path'
import {fileURLToPath} from 'node:url'
import {config} from './config/Config.js'
/**
* The directory of the preload script and of the built renderer: the resources of the packaged application
* (lakutata.resources), or in development the directory of the main process (build/, with the renderer from the ui
* package)
*/
const directory: string = resourcesPath() ?? path.dirname(fileURLToPath(import.meta.url))
/**
* The URL of the renderer in development (--renderer=<url>, the Vite dev server)
*/
const renderer: string | undefined = process.argv.find((argument: string): boolean => argument.startsWith('--renderer='))?.slice('--renderer='.length)
//A single instance of the application: a second one exits, and the first one brings its window to the front
if (!electron.app.requestSingleInstanceLock()) {
electron.app.exit(0)
} else {
Application
.run(config({
//The notes folder is remembered in the data directory of the application
settings: path.join(electron.app.getPath('userData'), 'settings.json'),
directory: directory,
renderer: renderer,
mode: electron.app.isPackaged ? 'production' : 'development',
//In development, the actions are also served to tools (Postman, curl); the packaged application does not serve them
devPort: electron.app.isPackaged ? undefined : Number(process.env.DEV_PORT ?? 9000)
}))
.onLaunched((app: Application, log: ILogger): void => log.info('%s started in %s mode', app.appName, app.mode()))
.onFatalException((error: Error, log: ILogger): void => {
log.error('The application cannot start: %s', error.message)
electron.app.exit(1)
})
}One option is added to the App.ts of the template: settings, the file that remembers the notes folder, in the data directory of the application (app.getPath('userData')). That directory is named after the productName of packages/app/package.json (notes), in development as in the packaged application: ~/Library/Application Support/notes on macOS, %APPDATA%\notes on Windows and ~/.config/notes on Linux.
The preload script of the template (packages/app/src/preload.cts) stays as it is: it gives the renderer the IPC channel for the actions and the events, and nothing else from Electron or Node.js.
8. The window
packages/ui/src/App.tsx
import {type FormEvent, type KeyboardEvent, useEffect, useState} from 'react'
/**
* A note of the folder, as the main process lists it
*/
type NoteInfo = { name: string, modifiedAt: string }
export function App() {
const [folder, setFolder] = useState<string | null>(null)
const [notes, setNotes] = useState<NoteInfo[]>([])
const [current, setCurrent] = useState<string | null>(null)
const [text, setText] = useState<string>('')
const [newName, setNewName] = useState<string>('')
const [status, setStatus] = useState<string>('')
//Run an action of the main process; its error (a note not found, an invalid name) is shown under the note
const run = (action: () => Promise<void>): void => {
action().catch((error: Error): void => setStatus(error.message))
}
const list = (): void => run(async (): Promise<void> => setNotes(await window.ipc.invoke<NoteInfo[]>({cmd: 'notes.list'})))
useEffect((): () => void => {
run(async (): Promise<void> => setFolder(await window.ipc.invoke<string | null>({cmd: 'folder.get'})))
//Pushed by the main process when files in the folder change, whether saved here or by another program
window.ipc.on('notes.changed', list)
return (): void => {
window.ipc.off('notes.changed', list)
}
}, [])
useEffect((): void => {
if (folder) list()
}, [folder])
const choose = (): void => run(async (): Promise<void> => setFolder(await window.ipc.invoke<string | null>({cmd: 'folder.choose'})))
const open = (name: string): void => run(async (): Promise<void> => {
const note = await window.ipc.invoke<{ name: string, text: string }>({cmd: 'notes.read', name: name})
setCurrent(note.name)
setText(note.text)
setStatus('')
})
const create = (event: FormEvent): void => {
event.preventDefault()
run(async (): Promise<void> => {
await window.ipc.invoke({cmd: 'notes.save', name: newName, text: ''})
setCurrent(newName.trim())
setText('')
setNewName('')
setStatus('')
})
}
const save = (): void => run(async (): Promise<void> => {
await window.ipc.invoke({cmd: 'notes.save', name: current, text: text})
setStatus(`Saved at ${new Date().toLocaleTimeString()}`)
})
const remove = (): void => run(async (): Promise<void> => {
await window.ipc.invoke({cmd: 'notes.delete', name: current})
setCurrent(null)
setStatus('')
})
//Ctrl+S, or Cmd+S on macOS
const shortcut = (event: KeyboardEvent): void => {
if ((event.ctrlKey || event.metaKey) && event.key === 's') {
event.preventDefault()
save()
}
}
if (!folder) {
return (
<main style={{fontFamily: 'system-ui', padding: 32}}>
<p>The notes are Markdown files, in a folder of your choice.</p>
<button onClick={choose}>Choose a folder…</button>
<p>{status}</p>
</main>
)
}
return (
<main style={{fontFamily: 'system-ui', position: 'fixed', inset: 0, display: 'grid', gridTemplateColumns: '240px 1fr'}}>
<aside style={{borderRight: '1px solid #ddd', padding: 12, overflow: 'auto'}}>
<button onClick={choose} title={folder} style={{width: '100%'}}>{folder.split(/[\\/]/).pop()}</button>
<form onSubmit={create}>
<input value={newName} onChange={(event): void => setNewName(event.target.value)} placeholder="New note" style={{width: '100%', margin: '12px 0', boxSizing: 'border-box'}}/>
</form>
{notes.map((note: NoteInfo) => (
<div key={note.name} onClick={(): void => open(note.name)} style={{padding: 6, cursor: 'pointer', fontWeight: note.name === current ? 'bold' : 'normal'}}>
{note.name}
<div style={{fontSize: 11, color: '#888'}}>{new Date(note.modifiedAt).toLocaleString()}</div>
</div>
))}
</aside>
<section style={{display: 'flex', flexDirection: 'column', padding: 12, gap: 8}}>
{current === null ? <p>Open a note, or create one.</p> : (
<>
<div style={{display: 'flex', gap: 8, alignItems: 'center'}}>
<strong style={{flex: 1}}>{current}</strong>
<button onClick={save}>Save</button>
<button onClick={(): void => run(() => window.ipc.invoke({cmd: 'notes.reveal', name: current}))}>Show in folder</button>
<button onClick={remove}>Delete</button>
</div>
<textarea value={text} onChange={(event): void => setText(event.target.value)} onKeyDown={shortcut} style={{flex: 1, fontFamily: 'ui-monospace, monospace', fontSize: 14}}/>
</>
)}
<small style={{color: '#888'}}>{status}</small>
</section>
</main>
)
}window.ipccomes from the template (packages/ui/src/ipc.ts), through the preload script.invoke()sends a message and returns the result of the action, or throws its error,errnoincluded;on()andoff()listen to the events of the main process (ServiceCalls, section 4).- The renderer is isolated from Node.js and Electron: it reads no files and opens no dialogs itself, but calls the actions that do.
- On
notes.changed, it lists the notes again: a note saved by an editor appears in the window. - Errors, such as an invalid name, are shown under the note.
9. Run it
npm run devThe command starts the Vite dev server, which serves the renderer and reloads it whenever packages/ui changes, and Electron, whose window loads the renderer with its DevTools open. The main process logs notes started in development mode. The first time, the window asks you to choose a folder; after that, it opens the folder remembered in the settings.
In development, the actions are also served over HTTP on port 9000 (DEV_PORT), so you can try them with curl or Postman: POST / sends a message, and GET /events streams the events of a client as Server-Sent Events (ServiceCalls, section 4). Once a folder is chosen in the window (here, one that already holds a note Ideas), run in another terminal:
curl -N 'localhost:9000/events?id=me' &
# event: client
# data: ["me"]
curl -X POST localhost:9000/ -H 'Content-Type: application/json' -d '{"cmd": "notes.save", "name": "Shopping", "text": "- bread\n- tea\n"}'
# {"success":true,"error":null,"payload":{"name":"Shopping","modifiedAt":"2026-10-17T14:57:41.391Z"}}
# event: notes.changed
# data: []
curl -X POST localhost:9000/ -H 'Content-Type: application/json' -d '{"cmd": "notes.list"}'
# {"success":true,"error":null,"payload":[{"name":"Shopping","modifiedAt":"2026-10-17T14:57:41.391Z"},{"name":"Ideas","modifiedAt":"2026-10-17T14:57:23.287Z"}]}
curl -X POST localhost:9000/ -H 'Content-Type: application/json' -d '{"cmd": "notes.read", "name": "Lunch"}'
# {"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}The window shows the new note at once, as the event reached it too. The error carries all the properties of the exception, including its stack (left out here).
10. Test it
The test of the template is replaced by a test of the notes. Like the test of the template, it runs the main process with Node.js, without Electron or a window: its own ipcMain stands in for Electron's, and the test calls the actions through it as the renderer does. The settings of the test remember a folder in a temporary directory.
packages/app/src/tests/App.spec.ts
import {after, before, describe, it} from 'node:test'
import assert from 'node:assert'
import {existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs'
import {tmpdir} from 'node:os'
import path from 'node:path'
import {Application} from 'lakutata'
import type {IPCMain} from 'lakutata/adapter/electron'
import {config} from '../config/Config.js'
/**
* The ipcMain of the tests: through it, the actions are invoked as the renderer invokes them, without Electron
*/
const handlers: Map<string, (event: unknown, data: unknown) => Promise<unknown>> = new Map()
const listeners: Map<string, (event: unknown, clientId: string) => void> = new Map()
const ipcMain: IPCMain = {
handle: (channel: string, listener: (event: unknown, data: unknown) => Promise<unknown>): unknown => handlers.set(channel, listener),
removeHandler: (channel: string): unknown => handlers.delete(channel),
on: (channel: string, listener: (event: unknown, clientId: string) => void): unknown => listeners.set(channel, listener),
removeAllListeners: (channel: string): unknown => listeners.delete(channel)
}
//The web contents of the renderer, which collects the events pushed to it
const received: unknown[][] = []
const sender = {once: (): void => undefined, off: (): void => undefined, send: (...args: unknown[]): unknown => received.push(args)}
/**
* Invoke an action as the renderer does (window.ipc.invoke)
* @param data
*/
function invoke(data: Record<string, unknown>): Promise<any> {
return (handlers.get('lakutata:service') as (event: unknown, data: unknown, clientId: string) => Promise<unknown>)({sender: sender}, data, 'page')
}
/**
* Wait until a condition holds, 5 seconds at most
* @param condition
*/
async function until(condition: () => boolean): Promise<void> {
for (let waited: number = 0; !condition(); waited += 50) {
if (waited >= 5000) throw new Error('Waited too long')
await new Promise((resolve: (value: unknown) => void): unknown => setTimeout(resolve, 50))
}
}
describe('notes', (): void => {
//The data directory of the application, and the notes folder that its settings remember
const data: string = mkdtempSync(path.join(tmpdir(), 'notes-'))
const folder: string = path.join(data, 'My notes')
let app: Application
before(async (): Promise<void> => {
mkdirSync(folder)
writeFileSync(path.join(data, 'settings.json'), JSON.stringify({folder: folder}))
app = await new Promise<Application>((resolve: (app: Application) => void): void => {
Application.run(config({settings: path.join(data, 'settings.json'), window: false, ipcMain: ipcMain})).onLaunched((launched: Application): void => resolve(launched))
})
})
after(async (): Promise<void> => {
await app?.dispose()
rmSync(data, {recursive: true, force: true})
})
it('gives the folder remembered by the settings', async (): Promise<void> => {
assert.deepStrictEqual(await invoke({cmd: 'folder.get'}), {success: true, error: null, payload: folder})
})
it('pushes notes.changed to the renderer when the files of the folder change', async (): Promise<void> => {
//The page subscribes to the events
listeners.get('lakutata:service:subscribe')!({sender: sender}, 'page')
//Another program writes a note
writeFileSync(path.join(folder, 'Ideas.md'), '# Ideas\n')
await until((): boolean => received.length > 0)
assert.deepStrictEqual(received[0], ['lakutata:service:event', 'notes.changed', []])
})
it('saves, lists, reads and deletes the notes', async (): Promise<void> => {
const saved = await invoke({cmd: 'notes.save', name: 'Shopping', text: '- bread\n- tea\n'})
assert.strictEqual(saved.payload.name, 'Shopping')
assert.strictEqual(readFileSync(path.join(folder, 'Shopping.md'), 'utf8'), '- bread\n- tea\n')
const listed = await invoke({cmd: 'notes.list'})
assert.deepStrictEqual(listed.payload.map((note: { name: string }): string => note.name), ['Shopping', 'Ideas'])
assert.deepStrictEqual((await invoke({cmd: 'notes.read', name: 'Shopping'})).payload, {name: 'Shopping', text: '- bread\n- tea\n'})
assert.strictEqual((await invoke({cmd: 'notes.delete', name: 'Shopping'})).success, true)
assert.strictEqual(existsSync(path.join(folder, 'Shopping.md')), false)
})
it('refuses a note that does not exist', async (): Promise<void> => {
const response = await invoke({cmd: 'notes.read', name: 'Missing'})
assert.strictEqual(response.success, false)
assert.strictEqual(response.error.errno, 'E_NOTE_NOT_FOUND')
})
it('refuses a name that is not a file name', async (): Promise<void> => {
const response = await invoke({cmd: 'notes.save', name: '../outside', text: ''})
assert.strictEqual(response.success, false)
assert.match(response.error.message, /file name/)
assert.strictEqual(existsSync(path.join(data, 'outside.md')), false)
})
})npm testapp │ ✔ gives the folder remembered by the settings
app │ ✔ pushes notes.changed to the renderer when the files of the folder change
app │ ✔ saves, lists, reads and deletes the notes
app │ ✔ refuses a note that does not exist
app │ ✔ refuses a name that is not a file name
app │ ℹ pass 5
✔ test ran in appnpm test builds the packages, the renderer included, then runs the tests. The two actions that use Electron, the dialog and the file manager, are tried out in the window.
11. Package it
npm run package:hostbuilds the packages, then makes the installers for the platform it runs on, indist/installer: admgon macOS, adebon Linux, annsisinstaller and a portable executable on Windows.npm run package:linuxmakes the Linux ones in Docker, from any platform. They are made by electron-builder, fromlakutata.installerinpackages/app/package.json(Installers, section 8).- The main process is bundled with its dependencies. The built renderer and the preload script go into the resources of Electron (
lakutata.resourcesinpackages/app/package.json), whereresourcesPath()finds them (Packaging, section 6). - The packaged application has no HTTP entrypoint (
devPortis given only whenapp.isPackagedis false), and keeps its settings in the same data directory.