Files

23 KiB

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

# 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:

// 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).