Desktop and mobile clients
The repo ships two clients alongside the browser. apps/desktop runs the same web build inside Electron and adds what a browser can’t do: silent printing, local hardware through native helper processes, a refresh token held by the main process, and auto-update. Each GitHub Release carries installers for Windows, macOS and Linux. apps/mobile is a native Expo / React Native app with its own screens, not a WebView around the web pages. It shares the API client and translations with the web app.
Where do I download the desktop app?
Section titled “Where do I download the desktop app?”From GitHub Releases. Each release has:
| Platform | File | Notes |
|---|---|---|
| Windows 10/11 x64 | admin-desktop-<version>-setup.exe | NSIS installer, per-user install into %LOCALAPPDATA%\Programs, no admin rights |
| macOS Apple Silicon / Intel | admin-desktop-<version>-arm64.dmg / -x64.dmg | the matching .zip files are for the updater |
| Linux x64 | admin-desktop-<version>-x86_64.AppImage | chmod +x and run |
The .blockmap and latest*.yml files are for the updater; you don’t download them yourself.
What does the desktop shell add?
Section titled “What does the desktop shell add?”The shell (Electron 42) contains no business code; the pages are the same apps/web build. The renderer talks to the main process through window.desktop, which exposes six namespaces (app, config, auth, print, devices, updater). The IPC contract lives in packages/platform/src/desktop/contract.ts.
- Silent printing.
print.html()renders HTML in a hidden window and callswebContents.print({ silent: true }), so no dialog appears. It uses the printer named in config or the system default. This works for HTML only, not for an existing PDF, so labels are built as HTML with CSS@page;buildLabelHtml()helps with that.print.htmlToPdf()runs the same pipeline to a PDF for checking layouts without a printer. A failed print returns{ ok: false, reason }, never a false success. Silent printing is the reason recorded inapps/desktop/README.mdfor choosing Electron. - Local hardware. Device adapters start a native helper process and stream its events to
devices.on(). The shell ships an ID-card-reader adapter. When the vendor’s helper.exeisn’t inresources/, the adapter runs in stub mode anddevices.simulate()fires fake events, so the read → fetch → print flow can be developed on macOS. - Credentials. The renderer is served from
app://local, so the browser’s refresh cookie (SameSite=Lax) isn’t sent to the backend. The main process therefore keeps the refresh token, encrypted with ElectronsafeStorage(DPAPI on Windows, Keychain on macOS), and hands only the access token to the page. This path has passed type checks but not yet an end-to-end test in a packaged build. - Runtime config.
config.jsonin the app’s user-data directory holdsserverUrl,printerName,kiosk,autoLaunchandupdateUrl. One installer can serve every environment; nothing is compiled in.
import { isDesktop, desktop } from "@admin/platform/desktop"
if (isDesktop()) { const res = await desktop().print.html(html, { pageSize: { width: 60000, height: 40000 } }) // microns if (!res.ok) showError(res.reason)}Measured while building the shell: print() takes pageSize in microns, while printToPDF() takes inches. With preferCSSPageSize and an @page rule, a 60 × 40 mm label came out at 59.944 × 39.878 mm (0.09% off).
How does the desktop app update itself?
Section titled “How does the desktop app update itself?”It uses electron-updater. The feed is updateUrl from config.json if set (a “generic” source: any static HTTP directory, e.g. on a customer’s intranet). Otherwise it is the app-update.yml baked in at build time, which points at this repo’s GitHub Releases. Updates download automatically and install when the app quits. quitAndInstall(true, true) installs silently and relaunches; without those flags the NSIS wizard opens and waits for a click. The renderer drives it through desktop().updater.check(), .on() and .quitAndInstall(). The web app doesn’t call check() by itself, so add a button or a startup call in your app.
| Platform | Auto-update |
|---|---|
| Windows (NSIS) | works; per-user install, so updates need no UAC prompt |
| Linux (AppImage) | works |
| macOS | does not work while builds are unsigned: electron-updater checks the running app’s code signature |
For an intranet mirror, publish <version>.exe, its .blockmap and latest.yml, uploading latest.yml last and serving it without caching. The .blockmap enables differential downloads, but the first update usually falls back to a full download.
How are the installers built?
Section titled “How are the installers built?”Pushing a v* tag runs .github/workflows/release.yml. A prepare job checks that the tag matches both apps/web/package.json and the backend’s __version__, then creates one draft release. A matrix then builds on windows-latest, macos-latest and ubuntu-latest, each platform on its own OS. electron-builder runs with --publish never and gh release upload attaches the files, because electron-builder’s own publisher once raced into two drafts. A person tests the draft before publishing it.
Locally: pnpm --filter web build, then pnpm --filter @admin/desktop package. The desktop build does not rebuild the web app, so skipping the first step packages a stale UI without any error. For development, run the web dev server on :8888 and then pnpm desktop:dev.
What is the mobile app?
Section titled “What is the mobile app?”apps/mobile uses Expo SDK 57, React Native 0.86 and Expo Router, styled with uniwind (Tailwind CSS v4 for React Native) and react-native-reusables components. It targets Android and iOS only.
- Native screens, not a WebView. Tabs for Home, Apps and Me, plus notifications, profile and password, settings, a users list → detail → delete flow written as the pattern to copy, and notice detail with attachments. Lists use
FlatListwith infinite queries. Notice bodies (HTML from the web’s Tiptap editor) are turned into native components through an allow-list, not rendered in a WebView. - Shared with web:
@admin/api(envelope handling, single-flight 401 refresh, request types inferred from the generatedschema.d.ts) and@admin/i18n. It does not depend on@admin/platform, which is web-shaped. - Sign-in: password or SMS code, both behind the image captcha. The access token is stored in
expo-secure-store(Keystore / Keychain), neverAsyncStorage. The refresh token stays in the httpOnly cookie; on Android, React Native’s cookie jar sends it back automatically and keeps it across cold starts. - Server address is set in the app and stored on the device. Before saving, the app probes
/auth/captchato confirm the address is a reachable backend. The sameoklch()colour tokens as the web app render pixel-identical on Android.
What doesn’t the mobile app do yet?
Section titled “What doesn’t the mobile app do yet?”- Verified by hand on Android only (sign-in → permission gating → paging → detail → delete confirmation → toast). Newer screens, such as SMS sign-in and notice attachments, have so far passed only
tsc, ESLint and the bundle build. iOS has never been run. - There are no automated mobile tests. The CI gate is
pnpm build, which includesexpo export --platform androidand catches bundle-time failures only. - No real-time updates (no socket.io; screens refresh on open and pull-to-refresh). System push, QR scanning and silent printing are out of scope.
- No published APK.
pnpm --filter @admin/mobile apkhas only been run as far as native module compilation, and it signs with the template’s debug keystore; generate your own keystore before shipping. - Changing phone number or email needs a verification-code flow that exists only on the web.
To run it: pnpm --filter api dev:host (backend on 0.0.0.0:8088), then pnpm mobile:dev, and open the printed exp://…:8800 address in Expo Go for SDK 57.
Where it lives in the code
Section titled “Where it lives in the code”apps/desktop/:src/main/(window,app://protocol,print.ts,updater.ts,auth.ts,devices/),electron-builder.ymlpackages/platform/src/desktop/: bridge contract,isDesktop()/desktop(), label templateapps/desktop/AGENTS.md: packaging, signing and update pitfalls found in real buildsapps/mobile/:src/app/(screens),src/lib/(session, API, permissions), andAGENTS.md