* feat(desktop): follow OS language in packaged builds
Packaged Electron currently shows Open Design in en-US regardless of
the OS language setting, because the renderer's i18n picks its locale
from `navigator.language` and Chromium hard-codes that to en-US unless
the host process intervenes. Browser users and `tools-dev` users are
unaffected because their `navigator.language` already reflects the
OS / browser preference.
This change:
- Adds `applyOsLocaleSwitch(app)` in `@open-design/desktop/main`. It
reads `app.getPreferredSystemLanguages()[0]` and (when called before
Electron's `ready` event) points Chromium's `--lang` flag at it, so
the renderer's `navigator.language` follows the OS. Safe to call
more than once: `appendSwitch` is a no-op once `app.isReady()`.
- Calls the helper from both Electron entries: `apps/packaged` before
its own `whenReady`, and `runDesktopMain` for tools-dev parity.
- Forwards the resolved locale through
`BrowserWindow.webPreferences.additionalArguments` as
`--od-os-locale=<bcp-47>`, parsed by the preload and exposed at
`window.__od__.client.osLocale`. The host bridge type
(`OpenDesignHostClient.osLocale`) is extended accordingly.
- Updates `detectInitialLocale` in `apps/web/src/i18n/index.tsx` to
read that field as a new step between the existing localStorage and
navigator fallbacks. Browser/web continues to fall through to
`navigator.languages` unchanged.
The explicit `osLocale` channel exists in addition to `--lang` because
some `app.getPreferredSystemLanguages()` strings (e.g. `zh-Hant-TW`,
`pt-PT`) need to round-trip through `resolveSystemLocale` to land on
the right supported locale, which Chromium's `navigator.language`
cannot do on its own.
* fix(web): route OS locale read through getOpenDesignHost
The first cut of detectInitialLocale read `window.__od__.client.osLocale`
directly, which trips `tests/host-boundary.test.ts` — that guard test
keeps web source from referencing preload globals by name so the
boundary stays single-source. Switch to `getOpenDesignHost()` from
`@open-design/host`, and rewrite the i18n test to install the host via
`installMockOpenDesignHost` instead of poking the global directly.
* fix(tools-pack): unblock mac packaged build on pnpm workspaces
Two independent issues prevented `pnpm tools-pack mac build --to all`
from completing on a clean macOS workspace, both unrelated to the
desktop OS-locale change in this PR but bundled here because verifying
that change end-to-end required the packaged pipeline to actually
finish.
1. `apps/web/.next/standalone/node_modules/.pnpm/node_modules/<pkg>`
contained dangling symlinks left by Next's nft trace (e.g. a
`semver -> ../semver@5.7.2/node_modules/semver` link to a
`.pnpm/<pkg>@<ver>` directory pnpm never created). The downstream
`cp { dereference: true }` aborted the whole packaged pipeline
with ENOENT. Walk every artifact tree before copy and unlink
symlinks whose target doesn't resolve. Targets that *do* resolve
stay untouched.
2. Next 16's standalone build under pnpm workspaces does not hoist
peer-dep packages (react, react-dom, styled-jsx) into
`<standalone>/apps/web/node_modules`. The downstream
`web-standalone-after-pack.cjs` audit then does
`createRequire(server.js).resolve('react/package.json')`, whose
module walk falls out of the standalone tree and aborts the
electron-builder phase. Add a `hoistStandaloneNextPeerDeps` step
for the web standalone artifact only: it locates the
`<pkg>@<version>` (not peer-resolved sibling) directory under
`.pnpm` and symlinks it into `apps/web/node_modules/<pkg>`. The
subsequent `cp { dereference: true }` then writes the real
directory into the cache so the packaged tree stays self-contained.
Verified by `pnpm tools-pack mac build --to all` succeeding end-to-end
(zip + dmg + app), then `pnpm tools-pack mac install` and
`pnpm exec tools-pack mac inspect --expr` reading the desired
`__od__.client.osLocale` from the packaged renderer.
* feat(desktop): fold encodeURIComponent + manual locale source + pet window from #2554
Three defensive improvements lifted from @Eli-tangerine's parallel
implementation on #2554, kept consistent with the OS-locale chain
already on this branch:
- The argv value crossing main → preload is now wrapped with
encodeURIComponent / decodeURIComponent so a locale string with `;`,
`=`, or any other Chromium argv special char round-trips cleanly.
BCP-47 region tags don't carry those today, but the renderer parser
no longer has to assume it.
- `setLocale` now also writes `open-design:locale-source = "manual"`
to localStorage, and `detectInitialLocale` only treats the stored
locale as winning when that marker is present. An untagged value
(left over from a future auto-write path, or a stale install) no
longer pins the app to an old language once the host injects a
fresh OS locale. Today `setLocale` is the only writer so the marker
has no behaviour difference yet — this is a defensive net.
- `createDesktopPetWindow` now receives `osLocale` and forwards the
same `additionalArguments` as the main `BrowserWindow`, so the
pet renderer's `__od__.client.osLocale` is consistent with the main
window's instead of being silently undefined.
Co-authored idea credit: changes mirror the locale-piece of
@Eli-tangerine on #2554 — that PR is closing in favour of this one.
Tests: detect-initial-locale gets a new "untagged localStorage value
loses to host locale" case. desktop 62/62, host 13/13, web i18n +
host-boundary 15/15 stay green.
* feat(web): fold onboarding view styles from #2554
Pulls the 747-line addition to `apps/web/src/styles/home/entry-layout.css`
from @Eli-tangerine's #2554 — the visual layer for the global onboarding
flow (`/onboarding` view, Connect / About-you / Design-system steps).
The view itself was already plumbed through `EntryShell.tsx`; this adds
the styling that makes it shippable on v0.8.0.
#2554 is closing in favour of this branch, so the CSS lands here so the
onboarding work doesn't get dropped on the floor.
Co-authored idea credit: @Eli-tangerine — original styling on #2554.
* fix(tools-pack): make hoistStandaloneNextPeerDeps idempotent across builds
Addresses non-blocking review by @PerishCode on #2560: the previous
`if (await pathExists(linkPath)) continue;` guard uses `access()`,
which follows symlinks. A stale symlink from a previous build whose
`.pnpm/<pkg>@<version>` target moved (e.g. after a react/react-dom
version bump that invalidates the workspace-build cache key and forces
a re-run) reports as missing through `pathExists`, then `symlink()`
rejects with EEXIST and the unhandled rejection aborts the packaged
build.
Switch to `lstat` (which does not follow the link) so we can tell
"genuinely empty slot", "real directory left by Next" and "stale
symlink" apart, then unlink stale entries before re-creating. Also
move `stripBrokenSymlinks` ahead of `hoistStandaloneNextPeerDeps` in
`copyWorkspaceBuildArtifactsToCache` so any leftover dangling links
that survived a previous run are cleared before hoist tries to write.
|
||
|---|---|---|
| .. | ||
| bin | ||
| helm/open-design | ||
| resources | ||
| src | ||
| tests | ||
| AGENTS.md | ||
| docker-compose.yml | ||
| esbuild.config.mjs | ||
| package.json | ||
| README.md | ||
| tsconfig.json | ||
| tsconfig.tests.json | ||
tools/pack
Local packaging control plane for Open Design.
The active slice is mac-first local packaging and smoke lifecycle control:
tools-pack mac build --to alltools-pack mac build --to app|dmg|ziptools-pack mac build --to all --signedtools-pack mac build --to all --portablefor release artifacts that must not bake local tools-pack runtime pathstools-pack mac installtools-pack mac starttools-pack mac stoptools-pack mac logstools-pack mac uninstalltools-pack mac cleanup
Build artifacts are namespace-scoped under .tmp/tools-pack/out/mac/namespaces/<namespace>/.
Release artifacts keep the canonical Open Design.app bundle shape; local tools-pack install copies it as
Open Design.<namespace>.app so developer namespaces can coexist without affecting runtime data/log/cache paths.
Packaged runtime state is namespace-scoped under .tmp/tools-pack/runtime/mac/namespaces/<namespace>/:
data/is the daemon-managed data root passed to the daemon through the packaged sidecar launch environment.logs/contains packaged process logs fordesktop,web, anddaemon.runtime/is the sidecar runtime base used by the packaged desktop/web/daemon process group.cache/is reserved for namespace-local packaged cache state.user-data/is the Electron/ChromiumuserDataroot, withuser-data/session/used forsessionData.
Finder/manual launches cannot carry argv stamps on the root desktop process. To keep process fallback safe,
apps/packaged writes runtime/desktop-root.json with the desktop stamp, PID, executable path, app path, and log path.
tools-pack mac stop trusts that marker only when namespace/stamp/PID/command validation passes; otherwise it reports the
unmanaged/not-owned reason instead of killing unknown processes.
tools-pack mac stop validation
- If the marker is absent, stop reports
not-running. - If the marker PID is gone, stop reports
not-runningand clears the stale marker. - If the marker PID was reused by an unrelated process, stop reports
unmanaged. - If the marker namespace, stamp, runtime root, or command does not match the current namespace, stop reports
unmanaged.
This keeps stop from killing processes outside the current namespace.
Packaged desktop also writes main-process lifecycle logs to logs/desktop/latest.log so Finder/manual launches are
diagnosable. This log is intentionally scoped to packaged desktop startup/shutdown/process errors and does not capture
web/renderer console output.
The packaged daemon path contract is explicit: tools-pack writes namespace/base config, apps/packaged resolves
namespace paths, and the packaged sidecar launcher passes daemon managed paths via launch env. The daemon may keep its
own default fallback for non-packaged launches, but packaged runtime must not rely on fallback inference from Electron
userData, app bundle names, or ports.
Packaged desktop can check the release metadata feed, download a verified mac DMG or Windows installer, and expose update actions through desktop IPC. This runtime updater phase still opens the downloaded installer for manual replacement instead of applying an in-place update.
Electron-builder resources live under tools/pack/resources/mac/. The current logo is staged there as the mac icon/DMG
placeholder so future design-provided assets can replace the resource files without changing packaging code.
Local developer artifacts bake the tools-pack namespace runtime root so tools-pack mac start/stop/logs/cleanup can manage
them from the repo. Release artifacts use --portable so the installed app resolves namespace data/log/runtime/user-data
from the user's Electron userData root instead of the build machine's .tmp path.
macOS compatibility notes
tools-pack mac build --portable --to zipis the safest manual-install artifact for Intel Macs. This path was smoke-tested on macOS 12.7.6 Monterey on a 2015 Intel iMac and the app launched successfully from/Applications.- Finder/manual launches on macOS may not inherit your shell-managed
PATH. If packaged Open Design cannot detect agent CLIs that work in Terminal, expose those binaries to the GUI login environment or launch the packaged app from a shell session that already sees them.
Windows
Local lifecycle commands:
tools-pack win build --to dirfor fast unpacked smoke builds.tools-pack win build --to nsisfor installer builds.tools-pack win build --to allfor both outputs.tools-pack win installtools-pack win starttools-pack win inspect --expr "document.title"tools-pack win logstools-pack win stoptools-pack win cleanuptools-pack win listtools-pack win reset
Build artifacts are namespace-scoped under .tmp/tools-pack/out/win/namespaces/<namespace>/.
Packaged runtime state is namespace-scoped under .tmp/tools-pack/runtime/win/namespaces/<namespace>/.
--to dir may point built-app.json at an immutable cached win-unpacked executable while keeping
namespace-local config and runtime paths outside that cache entry.
Linux
Local lifecycle commands:
tools-pack linux build --to all(default; produces AppImage)tools-pack linux build --to appimage(explicit AppImage)tools-pack linux build --to dir(unpacked output for fast iteration)tools-pack linux build --containerized(run electron-builder insideelectronuserland/builder:baseDocker for a wider glibc compatibility target — requires Docker)tools-pack linux build --to all --portable(release artifacts that must not bake local tools-pack runtime paths)tools-pack linux installtools-pack linux install --headless(install the headless launcher script instead of the AppImage)tools-pack linux starttools-pack linux start --headless(start the headless entry — daemon + web, no Electron)tools-pack linux stoptools-pack linux stop --headless(stop a running headless process)tools-pack linux inspect(desktop status, eval, and screenshot for AppImage mode)tools-pack linux inspect --headless(status only)tools-pack linux logstools-pack linux uninstalltools-pack linux uninstall --headlesstools-pack linux cleanuptools-pack linux cleanup --headless
Build artifacts are namespace-scoped under .tmp/tools-pack/out/linux/namespaces/<namespace>/. Packaged runtime state is namespace-scoped under .tmp/tools-pack/runtime/linux/namespaces/<namespace>/{data,logs,runtime,cache,user-data}/. Containerized build cache lives under .tmp/tools-pack/.docker-cache/{electron,electron-builder}/.
Local installs use XDG paths:
- AppImage:
~/.local/bin/Open-Design.<namespace>.AppImage - Menu entry:
~/.local/share/applications/open-design-<namespace>.desktop - Icon:
~/.local/share/icons/hicolor/512x512/apps/open-design-<namespace>.png
The <namespace> suffix is unconditional so multiple developer namespaces can coexist on the same desktop. The .desktop file registers the od:// scheme via MimeType=x-scheme-handler/od; and pre-sets OD_PACKAGED_NAMESPACE on the Exec= line so menu launches identify the correct namespace.
Headless mode (--headless)
Headless mode targets environments without a display (WSL2, headless servers, CI) where Electron can't run. If you have a desktop, use the AppImage; if you're SSH'd into a machine or in WSL, use headless.
--headless makes install, start, stop, uninstall, and cleanup operate on the headless entry (@open-design/packaged/dist/headless.mjs) instead of the AppImage. Headless mode runs daemon + web without Electron.
install --headlesswrites a shell launcher at~/.local/bin/open-design-headless-<namespace>that bakes in the namespace and resource paths. The launcher is self-contained, but the assembled app directory at those paths must remain in place — don't move it after install.start --headlessspawns the headless process directly, redirects stdout/stderr tologs/desktop/latest.log, and waits up to 95s (35s for identity marker + 60s for web URL) before returning.stop --headlessreads the sameruntime/desktop-root.jsonidentity marker as the AppImage path, validatesstamp.source === PACKAGED, sends a graceful SHUTDOWN over IPC, then terminates the process tree. It does not perform the AppImage-specific process-command check.inspect --headlessreturns status only. Eval and screenshot require AppImage mode because there is no Electron renderer in headless mode.uninstall --headlessremoves the headless launcher after a safe stop.cleanup --headlessstops the headless process before removing namespace output/runtime roots.
logs always reads logs/desktop/latest.log regardless of mode, so headless output is visible via tools-pack linux logs.
AppImage launch mode (FUSE caveat)
tools-pack linux start always spawns the AppImage with --appimage-extract-and-run. Smoke testing on Ubuntu 24.04 and Arch Linux showed that direct FUSE-mounted AppImage launches make Node module loads (Express, better-sqlite3, etc.) slow enough that the daemon sidecar consistently failed to clear apps/packaged's 35-second startup timeout. Extract-and-run unpacks the AppImage into /tmp/appimage_extracted_<hex>/ and exec's the inner Electron from there, bypassing FUSE and getting daemon boot in under 5 seconds — roughly an order-of-magnitude improvement.
Implication for end-users: if launching the installed AppImage manually (not via tools-pack linux start), pass --appimage-extract-and-run yourself, or rely on a desktop launcher / appimage-launcher daemon that handles extract-and-run automatically.
Optional system tools
tools-pack linux install and tools-pack linux uninstall invoke update-desktop-database and gtk-update-icon-cache as best-effort post-hooks. Either tool being absent (iconCache: "missing" in the output) is harmless — the icon and menu entry still work, the cache just isn't refreshed. Install via your distro:
- Arch / CachyOS:
sudo pacman -S desktop-file-utils gtk-update-icon-cache - Debian / Ubuntu:
sudo apt install desktop-file-utils gtk-update-icon-cache - Fedora:
sudo dnf install desktop-file-utils gtk-update-icon-cache
libfuse2 is needed for FUSE-mounted AppImage launch (the default mode when running an AppImage directly without --appimage-extract-and-run). tools-pack linux start always uses extract-and-run and bypasses FUSE entirely, so it does not need libfuse2. Most modern distros ship libfuse2 by default; older Ubuntu LTS hosts may need sudo apt install libfuse2t64 (or libfuse2 on pre-24.04).
Sandbox / chrome-sandbox
Electron 41 on Linux requires kernel.unprivileged_userns_clone=1 (default on Arch, Ubuntu 24+, Debian 12+) or AppImage's --no-sandbox fallback. Most modern distros need no extra setup.
Distro compatibility target
AppImages built natively on a rolling distro (e.g., Arch / CachyOS) link against recent glibc and may not run on stable distros (Ubuntu 22.04, Debian 12). Use --containerized to build against the electronuserland/builder:base baseline (Ubuntu 18.04 / glibc 2.27), which is the compatibility target for release AppImages rather than a guarantee for every Linux distribution.
Verified smoke coverage in this repository currently includes:
- PR lane: Ubuntu GitHub-hosted runner, headless Linux runtime.
- Release lane: Ubuntu GitHub-hosted runner, containerized AppImage build plus Xvfb AppImage runtime smoke when the Linux release lane is enabled.
- Manual AppImage behavior used to choose
--appimage-extract-and-run: Ubuntu 24.04 and Arch Linux.
Format choice: why AppImage first
Linux desktop apps in this space split across formats: VS Code ships .deb + .rpm + Snap; Discord ships AppImage + .deb; Slack ships .deb + .rpm; Cursor and Obsidian ship AppImage. We start with AppImage because one artifact can cover the widest glibc-compatible target without distro repositories, store packaging, signing infrastructure, or per-format install scripts, and it integrates cleanly with the namespace-scoped install layout. .deb / .rpm / Snap / Flatpak can land incrementally when user demand justifies the extra release ownership.
Out of scope (later phases)
- AppImage signing (
--signed) — deferred pending a GPG key infrastructure decision and a user-facing verification flow design (no ETA). - AppImage auto-update feed (
latest-linux.yml) — the linux electron-builder config has nopublishblock wired, so a generated feed would point users at a feed that never updates. Tracked alongside signing. - Additional package formats:
.deb,.rpm, Snap, Flatpak — deferred until there is demand and an owner for per-distro metadata, signing/store/repository plumbing, install/remove hooks, and release validation. - Full Linux AppImage PR smoke remains release-lane only; PR validation runs the Linux headless packaged smoke because it does not require a display server.
--to dmg is manual-install DMG output only. Any builder-generated updater metadata such as latest-mac.yml or
.blockmap files is treated as scratch and cleaned from the builder directory; release-beta generates the authoritative
latest-mac.yml feed during release asset preparation, pointing at the update ZIP.