Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Desktop Electron Package (@lapis-notes/desktop-electron)

The Electron desktop package is the first-party native desktop shell for Lapis Notes. It lives at packages/desktop-electron and mounts the shared renderer from @lapis-notes/workspace.

See ADR-003 for the decision rationale.

Responsibilities

packages/desktop-electron owns:

  • Electron app lifecycle (main process, BrowserWindow creation, app events)
  • Native window behaviour (title bar overlay, traffic-light positioning on macOS)
  • Native drag affordances for tab/header regions that need to initiate window dragging from the shared workspace shell
  • Native menus (File, Edit, View, Window plus debug entries)
  • IPC command handlers for folder picking, revealing the selected vault root in the host file manager, vault-scoped file operations, scoped vault resource URLs, generated-state persistence, native file watching, native file actions, app URL launches, and native OS notification mirroring
  • Native notebook DuckDB sidecar process management for Electron notebook query execution, timeouts, crash recovery, and app-table, virtual-file, and validated vault-file registration
  • Native language-service Node sidecar (markdownlint + embedded TypeScript language service) routed through main-process IPC (desktop_ls_*) for renderer-backed LanguageServiceProvider registrations with bounded JSON payloads
  • Desktop-only runtime adapters (NativeDesktopBridge registration), platform/capability metadata, and shell metrics that tune renderer titlebar spacing
  • Package-local Vite config, TypeScript config, build/dev scripts, and Electron packaging metadata

Shared app behaviour remains in packages/workspace (renderer shell), packages/api (runtime kernel), and first-party plugins. Desktop-specific code belongs in this package only when it requires Electron main-process access.

Package Layout

PathResponsibility
package.jsonpnpm scripts for dev, build, preview, check, Electron packaging, and local desktop release publication
index.htmlDesktop host document with #app, title, theme metadata
src/main.tsRenderer entry: registers native desktop bridge, applies shell-provided CSS metrics, imports and calls mountWorkspaceApp()
src-electron/main.tsElectron main process: app lifecycle, BrowserWindow, IPC handlers, native menus, shell metrics passed to preload
src-electron/notebook-duckdb-sidecar*.tsNative notebook DuckDB sidecar manager and child process used for Electron notebook query execution
src-electron/language-service-sidecar*.tsNative Markdown/TypeScript language-service sidecar manager and forked Node child implementing markdownlint synchronous lint and embedded typescript language-service diagnostics, completion, hover, and definition
src-electron/preload.tsContext-isolated preload: exposes window.__LAPIS_NATIVE_DESKTOP__ bridge, native platform/capabilities, shell metrics, and native notification forwarding
e2e/Playwright Electron smoke helpers, startup-shell regression coverage, Plugin registry settings coverage, and shared-shell grouped-sidebar rendering/menu/resizing coverage; launch helpers isolate user data and strip ELECTRON_RUN_AS_NODE before starting Electron
playwright.config.tsPackage-local Playwright config for Electron automation
electron-builder.config.cjsPackaging identifiers, release-channel metadata, macOS/Linux distribution targets, and release artifact naming
build/entitlements.mac.plistHardened-runtime entitlements used for packaged macOS builds
build/icon.pngDefault packaged desktop app icon with a rounded-square background derived from the shared workspace branding asset
build/icon-light.png / build/icon-dark.pngAppearance-specific macOS dock icon variants selected at runtime so the running app matches light and dark system themes
vite.config.tsElectron renderer Vite config, dev port 1421, first-party source resolver, plugin-host import-map injection, cross-origin isolation headers
tsconfig*.jsonPackage-local TypeScript configuration
scripts/dev.mjsDev orchestrator: starts Vite, watches src-electron, restarts Electron after successful main/preload rebuilds
scripts/generate-build-icons.mjsPackaging preflight: regenerates build/icon.png, build/icon-light.png, build/icon-dark.png, and macOS build/icon.icns from packages/workspace/src/assets/lapis.png before Electron Builder runs
scripts/notarize.cjsEnvironment-gated notarisation hook for signed macOS distributions
../../scripts/release-desktop-local.mjsRepo-root local release orchestrator that verifies, builds selected desktop targets, stages checksums, creates the remote Forgejo tag/release, and uploads assets
spec.mdPackage-local spec summary

Native IPC Commands

The Electron shell exposes this native command surface through the shared API bridge:

  • desktop_pick_vault_folder — native folder-picker dialog (dialog.showOpenDialog)
  • desktop_create_vault_folder — native create-or-pick dialog for Create New Vault; returns the selected folder after ensuring it exists
  • desktop_open_demo_vault — resolves a stable Electron-owned userData/demo-workspace folder for the shared Open Demo Workspace chooser action and creates it on demand before the renderer seeds bundled fixture files into it
  • desktop_move_vault_folder — native choose-destination flow for moving an existing vault folder; moves the folder and relocates the per-vault generated-state database to the new vaultId
  • desktop_reveal_vault_folder — opens the active vault root in the host file manager so users can copy or back up canonical files outside the renderer
  • desktop_fs_resolve_path — resolves a vault-relative file or folder path to an absolute host path for copy-path actions
  • desktop_fs_to_vault_path — maps an absolute host path back to a vault-relative path when it is under the active vault root
  • desktop_fs_open_path — opens a vault-relative file or folder through the OS default app
  • desktop_fs_reveal_path — reveals a vault-relative file or folder in the host file manager
  • desktop_fs_exists / desktop_fs_stat — path existence and stat queries
  • desktop_fs_read_text / desktop_fs_read_binary — vault-scoped file reads
  • desktop_fs_get_resource_url — returns a scoped lapis-vault-resource:// URL for read-only vault media and embeds
  • desktop_plugin_assets_register — registers a verified installed plugin asset context for the active renderer window so the main process can serve lapis-plugin://[vault-id]/[plugin-id]/[version]/[sha256]/[path] module URLs without exposing arbitrary vault files
  • desktop_fs_write_text / desktop_fs_write_binary — vault-scoped file writes
  • desktop_fs_list — directory listing
  • desktop_fs_mkdir / desktop_fs_rmdir / desktop_fs_remove — directory and file removal
  • desktop_fs_copy / desktop_fs_rename — file copy and rename
  • desktop_fs_watch_start / desktop_fs_watch_stop — host-native directory watching bridged into the shared vault watcher
  • desktop_db_load_state / desktop_db_save_state — native SQLite-backed generated-state persistence outside the vault; load probes return null without creating a SQLite file when neither SQLite nor legacy JSON state exists
  • desktop_vault_bootstrap_kv_get / desktop_vault_bootstrap_kv_set / desktop_vault_bootstrap_kv_set_many / desktop_vault_bootstrap_kv_get_many / desktop_vault_bootstrap_kv_del / desktop_vault_bootstrap_kv_keys / desktop_vault_bootstrap_kv_is_empty / desktop_vault_bootstrap_kv_import_if_empty — main-process JSON persistence for vault profile pointers, saved recent profiles, and desktop-global bootstrap settings such as chooser appearance so startup does not depend on renderer IndexedDB when the Chromium profile misbehaves
  • desktop_notifications_show — validates and mirrors selected durable app notifications through Electron’s OS notification API
  • desktop_ls_capabilities — returns { markdown, typescript } feature flags once the preload protocolVersion matches Electron main expectations (no sidecar spawn)
  • desktop_ls_update_document — pushes a sanitized VirtualDocument plus globals into the forked Node sidecar (document priming parity with worker document/update)
  • desktop_ls_diagnostics — returns diagnostics arrays for Markdown (markdownlint) or TypeScript/JavaScript (embedded LS)
  • desktop_ls_completions / desktop_ls_hover / desktop_ls_definition — position-based intelligence for TS/JS hosts
  • desktop_ls_code_actions — presently returns []; reserved while browser workers also return empty payloads

All file operations are scoped to the selected vault root. .., root, and absolute-subpath escape attempts are rejected. The resource URL command uses the same path validation and serves files through an Electron-registered lapis-vault-resource:// protocol instead of exposing arbitrary raw file:// paths to renderer-owned media views.

The desktop package registers lapis:// and the compatibility lapis-notes:// scheme with the OS. URLs received during cold start, macOS open-url, or Windows/Linux second-instance launches are queued in the main process until the renderer has mounted and can dispatch them through app.urls. The first supported action is open, including vault/file, desktop path, shorthand vault URLs, and pane-type hints where the shared workspace supports them.

Bootstrap Sequence

  1. Electron main process creates BrowserWindow with contextIsolation: true, nodeIntegration: false, and the context-isolated preload script.
  2. Preload exposes window.__LAPIS_NATIVE_DESKTOP__ with the IPC command stubs, native platform details, capability registry, and shell metrics via contextBridge.exposeInMainWorld.
  3. Renderer entry (src/main.ts) reads the preload bridge, applies shell-owned CSS metrics such as the macOS leading safe-area inset, calls setNativeDesktopBridge(bridge) on the shared API runtime, migrates any legacy vault profile keys from renderer IndexedDB into a main-process JSON store when that store is still empty, registers that main-process store via setDefaultVaultStateStore, and calls mountWorkspaceApp(). Before mounting the shared workspace shell, the renderer also reads the desktop-global bootstrap appearance setting from that same store and applies the matching light/dark document classes so the chooser can render in the expected theme before vault configuration becomes available.
  4. VaultBootstrap.svelte detects the native bridge and routes to the desktop-folder vault selection flow.
  5. VaultSession resolves to NativeDesktopAppDatabase, which persists the serializable AppDatabase state shape in a per-vault SQLite file under Electron app.getPath('userData') outside the selected vault folder. Metadata cache snapshots are additionally mirrored to .lapis/cache/metadata-cache.json as rebuildable vault-local generated state so copied/imported vaults can hydrate the app database when the host-owned SQLite file is absent.

Vault profile records (last-opened vault / saved profiles) persist under app.getPath('userData')/vault-bootstrap-kv.json in the Electron main process. The same file now also carries desktop-global chooser settings such as the bootstrap appearance mode. The renderer runs a best-effort, time-bounded migration from the legacy IndexedDB store (lapis-notes-vault-state) only while that file is still empty, so Chromium storage or service-worker database errors in the renderer profile are less likely to strand the shell on the “Opening vault” bootstrap screen.

The shared chooser now presents a desktop-first landing page on Electron: branded header, Create New Vault, Open Demo Workspace, Open Vault, inline recent projects, command-style recent-project search, and a settings link that edits only bootstrap-relevant appearance state. Create New Vault uses desktop_create_vault_folder, Open Demo Workspace uses desktop_open_demo_vault to resolve a stable Electron-owned demo folder before the shared renderer seeds its bundled fixture files, and Open Vault continues to use desktop_pick_vault_folder. On a first launch with no saved or recoverable vaults, the shared chooser defaults to the same create-first landing used by the browser host, promoting Create New Vault and switching the hero copy to Create a vault; once recent vaults exist it returns to the normal Open a vault landing. Recent projects remain the saved vault-profile records in the bootstrap KV store, ordered by most recent updatedAt. Inline recent-project rows now expose actions for copying the saved vault id, renaming the stored vault alias used by app.urls, revealing the vault root, removing the saved profile, and moving the vault folder. The move flow updates the saved profile root path and migrates Electron’s host-owned generated-state files from the old path-derived vaultId to the new one so search and app state continue to follow the moved vault.

Because Electron mounts the shared workspace bootstrap with the browser popout host enabled, openPopoutLeaf(), moveLeafToPopout(), and paneType=window requests now open a real secondary Electron popup window rather than falling back to an in-window floating pane. Electron main applies the same BrowserWindow shell policy to those window.open('about:blank') popouts that it uses for the initial app window, so secondary windows inherit the same hidden macOS title bar, preload bridge, and shell metrics before the shared workspace renderer mirrors document classes plus stylesheet and plugin CSS changes into the popup document, mounts the same recursive WorkspaceWindow -> TabsSplit shell there, and removes the popout from persisted layout state when the popup closes. The popout window itself does not mount a second top-level App; the owning app-host window keeps the live runtime, tracks focused popout state inside app.workspace, and receives native menu-open-vault events plus queued lapis:// app URLs on behalf of the focused popout.

The Electron native app database also mirrors prepared search documents into the same per-vault SQLite store and uses FTS5 for candidate selection on plain lexical searches. desktop_db_load_state checks for an existing SQLite file before opening the database so probing for missing generated state does not leave an empty vault-state/<vaultId>.sqlite3 file behind. If only legacy JSON state exists, load returns that JSON and migration waits until a later save. When a platform-compatible sqlite-vec extension is available, ready chunk embeddings are mirrored into a native vec0 table and vector/hybrid searches use that table for candidate selection before the shared evaluator builds final results. If sqlite-vec cannot be loaded or dimensions do not match, vector and hybrid searches fall back to the shared in-memory vector scorer over the mirrored documents.

The Electron notebook capability is available through a native DuckDB sidecar. Electron main forks a Node child process backed by @duckdb/node-api, routes notebook query and registration requests over IPC, enforces a 30-second request timeout, kills and restarts the sidecar after crashes or timeouts, and shuts it down during app quit. The sidecar keeps its own temporary working directory for registered DuckDB file aliases so existing notebook SQL such as read_csv_auto('alias') continues to work without syntax changes. Vault-backed CSV, Parquet, and JSON registrations pass the resolved notebook path and active native vault root to Electron main, which validates the path under the selected vault before the sidecar links or copies the file into that working directory. The notebook plugin injects this provider only when the preload capability registry advertises notebook as available; browser/PWA notebooks continue to use DuckDB-Wasm and byte-buffer registration.

The Electron plugin-sidecar capability is available as electron-plugin-sidecar with the desktop_plugin_host_* protocol family and an Electron-main-owned child-process boundary. Electron main now owns a trusted per-plugin sidecar keyed by vault/window context plus plugin ID, so crashes, timeouts, restart budgets, and cooldown failures are isolated per hosted plugin instead of shared across every trusted desktop plugin in the window. Each sidecar can prepare, evaluate bundled-only CommonJS Lapis desktop entries, activate/deactivate them, and route the initial parent-brokered capability set for vault I/O, plugin data, commands, notices, settings snapshots, metadata snapshots, event subscriptions, and logging. The sidecar CommonJS v1 resolver exposes only lapis and @lapis-notes/api from the generated host-module catalogue; local require() calls fail with a bundle-required diagnostic, and renderer-only dependencies such as obsidian, Svelte, DOM/CodeMirror view modules, and @lapis-notes/ui fail with explicit unsupported-sidecar dependency messages. The shared API runtime selects this host only for desktop/sidecar-aware community plugins or plugins that request brokered capabilities; baseline renderer-compatible community plugins continue through the renderer execution host. The durable design is documented in Community Plugin Host Boundary.

The Electron plugin-asset capability is available through preload as plugin-assets with provider electron-plugin-protocol. Renderer bootstrap installs a PluginAssetServer for native vault sessions; it reads verified installed-plugin metadata from the active vault, verifies the requested entry file once through the shared adapter path, registers the plugin/version context through desktop_plugin_assets_register, and returns a version/hash lapis-plugin:// URL. Electron main registers lapis-plugin before app readiness, tracks registered contexts by owning window, rejects unregistered vault/plugin/version/hash/path requests, and verifies the URL hash segment, file size, and SHA-256 against installed metadata before returning a JavaScript, CSS, JSON, WASM, or image response. Window cleanup removes owned plugin-asset contexts so the protocol cannot outlive the renderer vault session that registered it.

The Electron notifications capability is available through preload as electron-notification. The required notifications plugin still listens to app.notifications in the renderer, but only mirrors records that were persisted into generated app state. Preload forwards those payloads over desktop_notifications_show; Electron main validates the bounded payload, suppresses duplicate IDs for the window session, and calls Electron’s native Notification API when the host supports OS notifications.

The Electron language-service capability advertises language-service in the preload capability registry as electron-language-service-sidecar. Electron main forks a Node child modeled on the DuckDB manager (30-second request timeouts, restart after crashes/timeouts, shutdown on quit) and validates every payload for protocol version mismatches plus bounded document/global text lengths before forwarding work. Renderer entry (desktop-electron/src/main.ts) now registers only the native TypeScript provider during host bootstrap; the external official Markdown Lint plugin consumes the same desktop_ls_* capability through the shared native bridge and registers native markdownlint itself when installed and enabled, so markdown diagnostics ownership stays with a first-party official plugin rather than with the host bootstrap path. Browser worker providers remain registered as fallback parity, and LanguageServiceManager continues to merge diagnostics/code actions only among the highest priority tier while completions, hover, and definition already stop at the first successful provider sorted by descending priority.

Read-only vault resources requested through Vault.getResourceUrl() are delegated from preload to the main process, which returns cache-busted lapis-vault-resource:// URLs. Markdown image views, PDF views and embeds, and Bases card images consume that URL path so Electron avoids renderer-side full-file blob copies for those assets while the browser adapters keep their blob URL fallback.

When the bridge advertises native watch support, NativeDesktopVaultAdapter forwards watch requests through preload to Electron main-process chokidar watchers so the workspace can react to external file changes without falling back to polling.

The shared workspace shell uses data-desktop-drag-region markers on non-interactive chrome. In Electron, native window dragging comes from real -webkit-app-region drag surfaces on the non-interactive header regions such as tab spacers and the page-title header container, while interactive descendants stay opt-out no-drag regions so clicks, renames, and tab drag/drop keep working.

Dev and Build

# Start the Electron dev host (renderer Vite + Electron main)
pnpm dev:desktop
# or from the package
pnpm --filter @lapis-notes/desktop-electron dev

# Build the renderer bundle (also builds bundled workspace plugin dist outputs first)
pnpm --filter @lapis-notes/desktop-electron build

# Run checks
pnpm --filter @lapis-notes/desktop-electron check

# Validate the packaged app layout
pnpm --filter @lapis-notes/desktop-electron package:dir

# Build release artifacts
pnpm --filter @lapis-notes/desktop-electron dist:mac:all
pnpm --filter @lapis-notes/desktop-electron dist:linux

# Verify, build, tag, and upload a local desktop release (set FORGEJO_TOKEN in .env or export it)
pnpm release:desktop:local -- --version <version-without-v> --targets all

Dev port: 1421 (distinct from the workspace dev port 5173).

pnpm dev:desktop and pnpm --filter @lapis-notes/desktop-electron dev must always load the renderer from the live Vite source server via LAPIS_DESKTOP_DEV_SERVER_URL. The root Turbo dev task explicitly passes through LAPIS_DESKTOP_* variables so test-vault, user-data, devtools, and diagnostic launch controls reach the package dev launcher. The Electron dev host must not fall back to dist/index.html when running those dev flows, and the dev launcher strips inherited ELECTRON_RUN_AS_NODE before spawning Electron so package-manager or test environments cannot accidentally run the desktop main process as plain Node. Unless LAPIS_DESKTOP_USER_DATA_DIR is set explicitly, the dev launcher assigns a repo-scoped temporary user-data directory so a packaged Lapis Notes app running on the same machine cannot steal the single-instance lock or receive the dev launch as a second-instance event. In dev-server mode, closing the last Electron window quits the app even on macOS so the dev shell can shut down Vite and TypeScript watchers cleanly.

Packaged Electron builds use the in-package build/icon.png, a rounded-square app tile derived from the shared workspace branding asset at packages/workspace/src/assets/lapis.png, so the desktop shell ships an icon that reads like a native app icon instead of a floating transparent mark. Before package:dir / dist:* invoke Electron Builder, scripts/generate-build-icons.mjs regenerates build/icon.png, build/icon-light.png, build/icon-dark.png, and macOS build/icon.icns (on macOS) so x64/arm64 packaging does not fall back to the default Electron icon when generated build resources are missing. On macOS, Electron main also switches the running app dock icon between build/icon-light.png and build/icon-dark.png based on the current system appearance during both packaged runs and pnpm dev:desktop, while build/icon.png remains the packaged bundle fallback. The package metadata now also declares productName: "Lapis Notes", and Electron main still calls app.setName("Lapis Notes") at startup, so dev, e2e, and unpackaged runs use the branded menu and dock label instead of the default Electron binary name.

Electron main now also owns a small native app-info surface for branding and diagnostics. The shared renderer About Lapis Notes command calls desktop_app_info_get to read the desktop app version and build timestamp when the native bridge is available, while Electron’s native About panel is configured through app.setAboutPanelOptions() with the same name, version, icon, and a credits line that shows the build timestamp when the packaged or built bundle exposes one.

Release artifact names are stable and hyphenated for Forgejo downloads and Homebrew tap updates:

  • Lapis-Notes-<version>-mac-arm64.dmg
  • Lapis-Notes-<version>-mac-x64.dmg
  • Lapis-Notes-<version>-mac-arm64.zip
  • Lapis-Notes-<version>-mac-x64.zip
  • Lapis-Notes-<version>-linux-x64.tar.gz
  • Lapis-Notes-<version>-linux-x64.AppImage

pnpm release:desktop:local -- --version <version> --targets all is the local release path for a macOS developer machine. It checks that the input version matches packages/desktop-electron/package.json, refuses to release from a dirty JJ working copy unless --allow-dirty is passed, runs the repo checks plus smoke tests by default, builds macOS and Linux artifacts, writes SHA256SUMS.txt, and uses the Forgejo API to create/update v<version> plus release assets. On non-macOS machines, --targets current resolves to Linux and macOS targets are rejected.

The Forgejo desktop release workflow builds Linux desktop assets on ubuntu-latest, builds macOS arm64/x64 DMG and ZIP assets on macos-arm64, combines checksums, and publishes one release containing every desktop asset. The local macOS command remains available for developer-run releases and dry-runs.

The Electron Builder package scripts pass --publish never and set CI=1 plus ELECTRON_BUILDER_DISABLE_UPDATE_CHECK=true; Forgejo tag creation, release creation, checksums, and uploads are handled by repo release scripts rather than Electron Builder’s publishing integration or update checks.

During development, renderer source changes reload through Vite, while src-electron main/preload changes trigger a TypeScript watch rebuild followed by an Electron process restart.

Validation

  • pnpm --filter @lapis-notes/api build
  • pnpm --filter @lapis-notes/desktop-electron check
  • pnpm --filter @lapis-notes/desktop-electron build
  • pnpm --filter @lapis-notes/desktop-electron test:e2e:smoke (local dev renderer; CI uses full build)
  • pnpm --filter @lapis-notes/desktop-electron dist:linux
  • pnpm --filter @lapis-notes/desktop-electron dist:mac:all on macOS release machines
  • pnpm release:desktop:local -- --version <version-without-v> --targets all --skip-upload for local release dry-runs (FORGEJO_TOKEN in .env or exported)
  • pnpm --filter @lapis-notes/desktop-electron exec playwright test -c ./playwright.config.ts e2e/grouped-sidebar.spec.ts
  • pnpm --filter @lapis-notes/desktop-electron exec playwright test -c ./playwright.config.ts e2e/language-service-markdown.spec.ts
  • pnpm --filter @lapis-notes/desktop-electron package:dir
  • pnpm --filter @lapis-notes/workspace exec playwright test e2e/workspace-shell-visual.spec.ts -g "electron macOS sidebar header subtracts the ribbon width from the traffic-light inset"
  • Manual smoke against ~/Documents/vault-copy

Open Design Items

The Electron community-plugin host boundary is now designed in Community Plugin Host Boundary. Remaining implementation slices are documented on the owning package and contract pages.