Official electron build of draw.io
# CLAUDE.md - AI Assistant Guide for draw.io Desktop
## Project Overview
Draw.io Desktop is an Electron-based desktop application that wraps the core draw.io diagramming editor (included as a git submodule). It enables creating flowcharts, UML diagrams, and more, with a security-first design that isolates diagram data from the internet.
**Repository:** https://github.com/jgraph/drawio-desktop
**License:** GPL v3 only since v31.5.2, Apache 2.0 before it (`GPL-3.0-only` in `package.json`, which electron-builder writes into the deb/rpm `License:` field; the `drawio` core submodule stays Apache 2.0)
**Version:** see `drawio/VERSION` (stamped into `package.json` by `npm run sync`)
## Quick Reference
```bash
# Clone (MUST be recursive for submodule)
git clone --recursive https://github.com/jgraph/drawio-desktop.git
# Install dependencies
npm install
# Run application
npm start
# Run with DevTools enabled
DRAWIO_ENV=dev npm start
# Sync version before building (required)
npm run sync
# Build for specific platforms
npm run release-win # Windows x64
npm run release-linux # Linux (AppImage, deb, rpm)
npm run release-appx # Windows Store
```
## Project Structure
```
drawio-desktop/
โโโ src/main/
โ โโโ electron.js # Main Electron process (3,700+ lines)
โ โโโ electron-preload.js # IPC bridge with contextBridge
โ โโโ args.js # CLI argument definitions and parser
โ โโโ export-files.js # CLI export folder scan and output open (no symlink following)
โ โโโ backup-file.js # GUI backups written by exclusive temp file and rename
โ โโโ progress-bar.js # Progress bar for long-running operations
โ โโโ system-path.js # Absolute paths for Windows system programs (no bare names)
โ โโโ file-watcher.js # Per-window fs.watchFile registry (change events reach the window that asked)
โ โโโ config-paths.js # Per-window grants for config-declared local paths (read once at load, user-allowed)
โ โโโ update-channel.js # Windows arm64 update channel (latest-arm64.yml)
โ โโโ update-mode.js # Per-install update mode: auto, notify (release page link) or off
โ โโโ window-bounds.js # Restores saved window bounds against the current displays
โ โโโ print-options.js # webContents.print() options: printer paper in portrait order + landscape flag
โ โโโ url-params.js # urlParams.json locations (install dir, userData; cwd only in dev) and merge
โ โโโ disableUpdate.js # Generated by sync script
โโโ src/test/
โ โโโ cli-args.test.js # CLI argument parsing tests (npm test)
โ โโโ export-files.test.js # CLI export symlink handling tests (npm test)
โ โโโ backup-file.test.js # GUI path authorisation and backup symlink regression tests
โ โโโ system-path.test.js # System32 path helper tests (npm test)
โ โโโ config-paths.test.js # Config-declared path collection and per-window grant tests (npm test)
โ โโโ file-watcher.test.js # Per-window file watch registry tests (npm test)
โ โโโ update-channel.test.js # Windows arm64 update channel tests (npm test)
โ โโโ update-mode.test.js # Per-install update mode tests (npm test)
โ โโโ window-bounds.test.js # Window bounds restore tests (npm test)
โ โโโ print-options.test.js # Print paper size, orientation and scale tests (npm test)
โ โโโ url-params.test.js # urlParams.json location and merge tests (npm test)
โ โโโ msi-project-created.test.js # MSI shortcut icon hook tests (npm test)
โโโ drawio/ # Git submodule - core draw.io editor
โ โโโ src/main/webapp/ # Web application loaded in Electron
โโโ build/ # Build resources
โ โโโ notarize.mjs # macOS Quick Look setup, signing + notarization
โ โโโ sign-trusted.mjs # Windows signing hook (Azure Trusted Signing)
โ โโโ fuses.mjs # Electron security fuses
โ โโโ msi-project-created.mjs # msiProjectCreated hook: MSI shortcuts use exe icon, not the C:\Windows\Installer icon cache
โ โโโ dmg-hidden-files.mjs # beforePack hook: parks hidden DMG support files outside the installer window
โ โโโ quicklook-preview.html # Quick Look preview page (viewer-static.min.js)
โ โโโ quicklook-entitlements.plist # Sandbox entitlements for .appex
โ โโโ entitlements.mac.plist
โโโ doc/
โ โโโ RELEASE_PROCESS.md # Release workflow documentation
โ โโโ BUILDING_FOR_PERSONAL_USE.md # Guide for unsigned fork/personal builds
โโโ electron-builder-*.json # Platform-specific build configs
โโโ sync.cjs # Version sync script
โโโ package.json
```
## Tech Stack
- **Runtime:** Node.js 22.12+ (`engines` in `package.json`; CI builds on Node 24)
- **Framework:** Electron (version pinned in `package.json`)
- **Language:** JavaScript (ES6 modules)
- **Build Tool:** electron-builder
- **Package Manager:** npm
## Key Files
| File | Purpose |
|------|---------|
| `src/main/electron.js` | Main process: window management, IPC handlers, menus, auto-update |
| `src/main/electron-preload.js` | Secure IPC bridge between renderer and main process |
| `src/main/args.js` | CLI option definitions and argument parser (used by CLI export) |
| `sync.cjs` | Pre-build script that syncs version from `drawio/VERSION` |
| `electron-builder-*.json` | Platform-specific build configurations |
| `build/sign-trusted.mjs` | electron-builder Windows signing hook (Azure Trusted Signing) |
## Code Style
- **ES6 modules** with `import`/`export`
- **Tab indentation**
- **Allman brace style** (opening brace on new line)
- **camelCase** for variables, **PascalCase** for classes
- No ESLint/Prettier - manual style consistency
- Sparse comments; code clarity preferred
## Git Conventions
### Branches
- `dev` - Main development branch (PR target)
- `releases/v*.*.*` - One per release, pushed by the `prepare-release` workflow with a PR into `dev`; the `vX.Y.Z` tag goes on its head (`doc/RELEASE_PROCESS.md`). There is no long-lived `release` branch in this repo (retired after v30.4.1); the `ref: release` in the build workflows is drawio-dev's
### Commit Messages
- Lowercase sentence style without period
- Issue references: `[jgraph/drawio-desktop#XXXX]`
- Examples:
- `Fixes paste error`
- `Adds buffer as dependency [jgraph/drawio-desktop#2301]`
- `Prepare release v29.3.0`
### Version Tags
Format: `v{MAJOR}.{MINOR}.{PATCH}` (e.g., `v29.3.0`)
Tags trigger CI/CD build workflows.
## Build Process
1. **Sync version:** `npm run sync` reads `drawio/VERSION` and updates `package.json`
2. **Install:** `npm ci` for clean install
3. **Build:** `electron-builder` with platform-specific config
4. **Post-build:** Security fuses applied, Quick Look extension assembled (macOS), notarization (macOS)
### Packaged File Set
- The `files` arrays in the five `electron-builder-*.json` configs leave out what the packaged app never loads (about 80 MB of `app.asar`): `stencils/**/*.xml` (embedded in `js/stencils.min.js`, which overrides `mxStencilRegistry.loadStencil`), `shapes/**` (compiled into `js/shapes-14-6-5.min.js`; `mxStencilRegistry.allowEval` is false on desktop), the unminified editor sources `js/diagramly/**`, `js/grapheditor/**`, `mxgraph/src/**` and `mxgraph/mxClient.js` (only loaded with `dev=1`), the `cjs`/`dist`/`src`/`ts3.4` trees of `@cantoo/pdf-lib` (the ESM main process resolves the `es` build) and all `*.map` files. `js/diagramly/ElectronApp.js` and `DesktopLibrary.js` stay because `bootstrap.js` loads them uncompiled. Keep the five configs in sync
- `DRAWIO_ENV=dev` on a packaged build therefore runs the minified bundles: `electron.js` only passes `dev=1` when `js/diagramly/Devel.js` exists. DevTools and main-process logging still work
### Code Signing
- **Windows:** Azure Trusted Signing via the `signtoolOptions.sign` hook `build/sign-trusted.mjs` (configured in `electron-builder-win*.json`, not CSC_LINK certificates). CI (`electron-builder-win.yml`) downloads the signing dlib, locates `signtool.exe`, and authenticates with `AZURE_TENANT_ID`/`AZURE_CLIENT_ID`/`AZURE_CLIENT_SECRET` secrets. `win.signExts: [".dll"]` signs the bundled Electron DLLs too โ unsigned ffmpeg.dll etc. trip Defender's ASR ransomware rule after each release (#2509). The portable-zip step must pass `--config electron-builder-win.json`; a bare `--dir` run loads no config and ships unsigned, unfused binaries
- **macOS:** Apple Developer certificate + notarization in `build/notarize.mjs`
- **Unsigned builds:** `DRAWIO_UNSIGNED=true` skips signing (Windows) and notarization (macOS) for personal/fork builds
### Microsoft Store
- The `.appx` is never a GitHub release asset โ `release-appx` runs `--publish never` and the `microsoft-store` job in `electron-builder-win.yml` submits it to Partner Center with the msstore CLI (`msstore publish <pkg> -id 9MVVSZK43QQW`), which commits the submission into certification
- The appx is copied to a `.msix` name first: msstore's `MSIXProjectPublisher` only matches `.msix`/`.msixbundle`/`.msixupload`, and pointing it at the project root instead would send it down the Electron configurator path, which runs `npm install`. Same container, but Partner Center holds a `.msix` to the MSIX floor and fails the commit if `MinVersion` is 10.0.17134.0 or lower, so `electron-builder-appx.json` sets `appx.minVersion` to 10.0.17763.0 (Windows 10 1809, the first build the Store installs MSIX on; electron-builder's x64 default 10.0.14316.0 only passed as a hand-uploaded `.appx`). `maxVersionTested` is kept equal to it on purpose: it sets which OS behaviours the packaged app runs with, and a raise cannot be tested before the job commits into certification
- Needs `MS_STORE_TENANT_ID`/`MS_STORE_SELLER_ID`/`MS_STORE_CLIENT_ID`/`MS_STORE_CLIENT_SECRET` secrets from an Entra app registration holding the Manager role in Partner Center; the client secret expires and must be rotated
### Personal / Fork Builds
- `doc/BUILDING_FOR_PERSONAL_USE.md` documents building unsigned from a fork (the project is closed to contributions but GPL v3 licensed)
- Set `DRAWIO_UNSIGNED=true` and run `electron-builder` directly with `--publish never`; use `npm run sync -- disableUpdate` so auto-update doesn't replace the custom build
- `.github/workflows/personal-build.yml` is a manual (`workflow_dispatch`) workflow that builds unsigned installers on a fork with no secrets and attaches them as run artifacts
> **CI override:** The release build workflows check out the private `jgraph/drawio-dev` repo at its `release` branch, copy the built `*.min.js` **and** `VERSION` into the public `drawio/` submodule tree, then run `npm run sync` as normal. This lets CI ship from an internal release that is ahead of the public `drawio` tag without any change to `sync.cjs`. Out-of-tree builders (who have no access to `drawio-dev`) fall through to the public submodule's `VERSION` as before.
### Platform Build Commands
| Command | Target |
|---------|--------|
| `npm run release-win` | Windows x64 (NSIS + MSI) |
| `npm run release-win-arm64` | Windows ARM64 |
| `npm run release-linux` | Linux (AppImage, deb, rpm) |
| `npm run release-appx` | Windows Store (`--publish never`; the Store job submits it) |
| `npm run release-snap` | Snap package |
## Architecture Notes
### Security Model
- **Content Security Policy** prevents remote script execution
- **contextBridge** exposes only specific APIs to renderer
- **validateSender()** ensures IPC calls originate from local draw.io
- **Path authorisation** - `validateSender` alone is not enough (a renderer XSS passes it, and diagram content reaches some IPC handlers on its own), so file IPC also checks the path: writes against `assertWritablePath` (paths blessed through OS chrome - file picker, file association, argv), reads/stat/watch against `assertReadablePath` (the same set plus local paths declared in the requesting window's configuration, for #1278 libraries/templates/fonts). Both realpath-canonicalise first, so symlinks cannot escape. Blessing and checking must use the same native realpath (`fs.realpathSync.native`, `fs.promises.realpath`): Node's JS walker `fs.realpathSync` keeps mapped and subst drive letters and the case as given, and mixing the two refused every file on a mapped network drive in 31.5.2 (#2559)
- **Config-declared paths** - the configuration is renderer state (`Editor.config`, from localStorage `.configuration`) that any page script can rewrite, so `src/main/config-paths.js` reads it once per window, at that window's first `app-load-finished`, and never again; read replies to a window wait for that read, so no file content is in the page when it happens. A path in it is readable only once allowed in a main-process `dialog.showMessageBox` (persisted as `allowedConfigPaths`, refusals last the session), because a page script can also write `.configuration` and open a window that boots from it (`newfile`). The first launch with this adopts the existing configuration without asking (`allowedConfigPathsMigrated`), like the recents/libraries migrations. Only this window's configured paths count, and only windows from `createWindow` get any
- **CLI export symlinks** - paths the user did not type are not followed (GHSA-2w35-fgjm-2vvh, `src/main/export-files.js`): folder scans skip every symbolic link, even ones inside the folder, and never stat their targets (a `.csv` link renders any text file into the export); output names made up from an input name refuse a link at that name, dangling or not (lstat, plus `O_NOFOLLOW` where defined; Windows has lstat only). Typed inputs and `-o` paths are followed like any CLI tool
- **GUI backup symlinks** - lexical filenames cannot override an unauthorised canonical target, and unresolved final symlinks are rejected (GHSA-36x5-vw5q-29rv). Backups use an exclusively created temporary file followed by rename (`src/main/backup-file.js`), so a link planted after authorisation cannot redirect the backup write. An unsafe or failed backup is skipped while the diagram save continues
- **Child processes** - Windows system programs are started by absolute path (`getSystem32Path` in `src/main/system-path.js`) with no shell, never by bare name: Windows searches the working directory first, and opening a diagram from Explorer while draw.io is closed makes the diagram's folder the working directory (GHSA-qg46-52fx-h7p8). `NoDefaultCurrentDirectoryInExePath` is set at startup on Windows for dependencies that still start programs by name (electron-updater's signature check)
- **urlParams.json** - extra editor URL params (e.g. `math-font`, #298) come from `urlParams.json` in the install folder, then userData, and the working directory only with `DRAWIO_ENV=dev` (`src/main/url-params.js`): opening a diagram from Explorer makes its folder the working directory, so a file shipped next to it reconfigured every window (`embed=1`, `dev=1`, `chrome=0`). The file never overrides the params that mirror main-process settings (`disableUpdate`, `enableSpellCheck`, `enableStoreBkp`, `isGoogleFontsEnabled`, `appLang`, `autoUpdate`): the Extras checkmarks read them and main flips its own copy, so a mismatch stored the opposite setting
- **Navigation** - `will-navigate` cancels every renderer-initiated main-frame navigation (including reloads), but Chromium commits `about:` URLs without a network request, so they never reach it (nor `will-frame-navigate`); `contents.stop()` in `did-start-navigation` crashes Electron synchronously and is too late when deferred. A Navigation API `navigate` listener in the preload's isolated world cancels them instead (`src/main/electron-preload.js`)
- No external transmission of diagram data
- **Built-in plugins only** - external/third-party plugins were removed (07/2026). The `isPluginsEnabled` IPC action is retained, hardcoded `false`, so an older bundled webapp degrades to the "plugins disabled" dialog rather than failing
### IPC Pattern
The preload script uses a request/response pattern with unique IDs:
```javascript
// Renderer sends request
electron.request({action: 'save', data: ...}, callbackId);
// Main process handles and responds via IPC
ipcMain.on('request', (e, data) => { ... });
```
### macOS Quick Look Preview
- Pressing Space in Finder shows a rendered preview of `.drawio` files
- Uses `quicklookjs` to embed a Quick Look App Extension (`.appex`) in the app bundle
- The `.appex` loads `viewer-static.min.js` (with embedded shapes) in a WKWebView
- **Build flow:** `afterPack` (fuses.mjs) applies security fuses, then `afterSign` (notarize.mjs) assembles the `.appex`, signs it with sandbox entitlements, re-signs the outer `.app`, and notarizes
- The `.appex` is inserted in `afterSign` (not `afterPack`) so it is never present unsigned during electron-builder's signing verification
- Quick Look extensions require `app-sandbox`, but Electron helpers must not be sandboxed โ so the `.appex` gets different entitlements than `entitlementsInherit`
- The UTI `com.jgraph.drawio` is declared via `extendInfo` in `electron-builder-linux-mac.json`
- `viewer-static.min.js` is saved to `build/` during CI before the cleanup step removes it from the drawio submodule; for local dev, it's read from the submodule directly
### Auto-Update
- **Mode per install** (`getUpdateMode` in `src/main/update-mode.js`, decided at runtime, #2569): `auto` for NSIS installs (`Uninstall draw.io.exe` next to the exe), macOS and AppImage; `notify` (a message and a link to the release page, no in-app install) for MSI, the zip, the arm64 portable exe (`PORTABLE_EXECUTABLE_FILE`) and deb/rpm (electron-builder's `resources/package-type`); `off` for the Store (`process.windowsStore`), Snap, Flatpak and `DRAWIO_DISABLE_UPDATE=true` / `--disable-update` / `sync -- disableUpdate`. Only the NSIS installer may replace an install: `latest.yml` lists only the NSIS exe, and electron-updater would otherwise run it over MSI and portable installs, or run `pkexec dpkg -i` at quit on deb
- **Flow:** checks when the app starts and hourly after that, whenever `updateCheckIntervalHours` (default 24) has passed since `lastUpdateCheck`. `auto` downloads in the background, then the main process sends `updateState` to the editor windows (renderer pulls it with the `getUpdateState` request at load). The editor shows a banner and Help > Restart to Update, which send `installUpdate`; the macOS app menu swaps in its own Restart to Update item. A downloaded update otherwise installs at quit (`autoInstallOnAppQuit`, silent, UAC prompt on the per-machine NSIS install)
- **Restart to Update** quits through the normal window close (unsaved changes prompt; cancelling keeps the update for the next quit) and installs from `window-all-closed` with `quitAndInstall(true, true)`: the assisted NSIS installer only relaunches the app when silent
- Background check and download errors are logged only; dialogs are for checks the user started
- The "Check for Updates Automatically" setting is `dontCheckUpdates` in the store, toggled from the macOS app menu or Extras > Automatic Updates (`toggleAutoUpdate`, initial state in the `autoUpdate` URL param). `DRAWIO_NO_SILENT_UPDATE=true` / `--no-silent-update` asks before downloading
- The banner, Help and Extras items live in core (`js/desktop/ElectronApp.js`, `Menus.js`, resource keys `autoUpdate`, `restartToUpdate`, `updateReady`, `updateAvailable`); with an older webapp the update still installs at quit, and Help > Check for Updates offers Restart Now
- Windows arm64 builds publish and fetch `latest-arm64.yml` (`publish.channel` in `electron-builder-win-arm64.json`, matched by `src/main/update-channel.js`) because electron-builder gives Windows manifests no arch suffix; the x64 build keeps `latest.yml`. `npm test` pins the two names together
### Data Storage
- **macOS:** `~/Library/Application Support/draw.io`
- **Windows:** `%APPDATA%\draw.io\`
- Uses `electron-store` for persistent settings
## Testing
`npm test` runs the unit tests in `src/test/` (CLI argument parsing, CLI export symlink handling, GUI backups and path authorisation, config-declared readable paths, per-window file watching, window bounds restore, print paper size and orientation, urlParams.json locations, Windows arm64 update channel, per-install update mode, MSI shortcut icon hook, System32 path helper; Node's built-in test runner). The `test` script in `package.json` names each file, so a new test file must be added there too. Everything else is manual testing, documented in `doc/RELEASE_PROCESS.md`:
- Launch, create diagram, add shapes, save, open
- Export (PNG, PDF, SVG)
- Undo/redo functionality
- About dialog verification
## CI/CD Workflows
| Workflow | Trigger | Purpose |
|----------|---------|---------|
| `electron-builder.yml` | Version tag | macOS/Linux builds |
| `electron-builder-win.yml` | Version tag | Windows builds (Azure Trusted Signing) + Microsoft Store submission |
| `prepare-release.yml` | Manual | Automated release prep |
| `hash-gen.yml` | Release publish, daily cron, manual | Upload checksums to releases missing them (release events are dropped by GitHub since Dec 2024, hence the cron) |
| `store-credentials-check.yml` | Manual | Read-only check of the Partner Center credentials (`msstore apps get`) |
| `personal-build.yml` | Manual | Unsigned fork builds, artifacts only (no secrets, no publish) |
| `stale.yml` | Schedule | Mark stale issues/PRs |
## Important Constraints
1. **Recursive clone required** - drawio submodule must be initialized
2. **Run `npm run sync` before building** - Updates version from submodule
3. **Version source of truth** - `drawio/VERSION` for public builds; `drawio-dev/VERSION` is copied over `drawio/VERSION` at CI time so the internal release number wins for packaged builds
4. **Closed to contributions** - PRs not accepted; maintained by JGraph (forks for personal use are fine, see `doc/BUILDING_FOR_PERSONAL_USE.md`)
5. **Node 22.12+ required** - see `engines` in `package.json`
6. **Windows x64/arm64 and macOS 13+ only** - Electron 44 dropped ia32 binaries and macOS 12, so the 32-bit Windows build was removed (09/2026)
## Development Tips
- Set `DRAWIO_ENV=dev` to auto-open DevTools (the unminified `dev=1` sources only load from a source checkout, packaged builds omit them)
- Use `npm start --enable-logging` for verbose output
- If using symlink instead of submodule, also symlink `node_modules`
- Main process logs to console; check terminal for errors
## Key Dependencies
| Package | Purpose |
|---------|---------|
| `electron` | Desktop app framework |
| `electron-builder` | Build/package tool |
| `electron-updater` | Auto-update mechanism |
| `electron-store` | Persistent storage |
| `electron-log` | Logging |
| `@cantoo/pdf-lib` | PDF export |
| `quicklookjs` | macOS Quick Look preview extension (dev) |
CLI argument parsing is hand-rolled in `src/main/args.js` (no `commander`).