Skip to content

The desktop app ​

hypercal ships as an Electron desktop app that carries its own backend and its own database, and works with no server at all. The same app can instead be pointed at a self-hosted hypercal server, chosen on first launch.

How it works ​

Where Android bundles the backend into a Web Worker and runs it against WASM SQLite on OPFS, the desktop has Node, so it does the obvious thing instead: the Electron main process runs the real backend, the same one the container runs, against a real SQLite file.

┌─ Electron main (Node) ────────────┐        ┌─ BrowserWindow ─────────────────┐
│ startHypercal({ db, host, port }) │        │                                 │
│   createApp()        ← core/app.ts│ ◄HTTP► │ local  → http://localhost:<port>│
│   better-sqlite3 on a real file   │        │          preload attached       │
│   subscription / caldav /         │        │                                 │
│   reminder / backup schedulers    │        │ remote → https://your-server/   │
└───────────────────────────────────┘        │          no preload, no IPC     │
                                             └─────────────────────────────────┘

The frontend is not just unchanged, it is the same file: the desktop serves web/dist from the ordinary npm run build, byte for byte what the container serves. There is no third HYPERCAL_TARGET. The shell attaches a preload script, and web/src/lib/desktop.ts treats the presence of what it exposes as "I am in the shell". That is the whole detection mechanism.

So local mode is, quite literally, the hosted app with the server in the same process — which is why it needs none of the standalone machinery: no bridge, no worker, no cookie jar, no synthesised Response. isEmbedded() is false and every request is a plain same-origin fetch.

Why localhost and not 127.0.0.1 ​

WebAuthn refuses an IP literal as an RP id, so passkeys would silently be unavailable in local mode on the numeric form. The listener binds the hostname localhost too, so the address family Node binds and the one Chromium resolves cannot disagree, which on a dual-stack host is a real failure mode.

localhost is a secure context, so crypto.subtle, WebAuthn and the SameSite=Lax session cookie all behave exactly as on a hosted instance.

The port is allocated once and then kept ​

desktop/src/state.ts persists it in <userData>/desktop.json, and that is not an optimisation. Three things are keyed to the origin, and the origin contains the port: the session cookie, everything in the renderer's storage, and every registered passkey. A fresh port each launch would sign the user out and strand their passkeys every time. loadEnv() also refuses to boot without WEBAUTHN_ORIGIN, which has to be known before the listener binds, so "bind port 0 and see what we got" is not available either.

A stable port is also what makes /dav usable: a CalDAV client on the same machine can be configured once.

Boot order is load-bearing ​

core/src/lib/logger.ts and core/src/lib/security/webauthn.ts snapshot isProdLike() at module scope, and server/src/host.ts builds nodeHost at module scope by calling loadEnv(). So anything read after the first import of core is read too late.

Two consequences, both deliberate:

  • desktop/electron.cjs is not bundled. It is the package's entry point and its only job is to set NODE_ENV, DATABASE_PATH and BACKUP_PATH before requiring dist/main.cjs.
  • startHypercal takes host as a required option rather than defaulting to nodeHost, so importing the boot module does not drag the environment check along with it. The shell imports the host itself, after it knows its port.

"Connect to a server" mode ​

The window navigates to the user's own instance, which serves its own frontend. No proxy and no CORS shim, unlike Android — and that is the point: it is the only arrangement in which the origin the browser presents is the origin the server validates against, so it is the only one where passkeys work.

Four things follow from it:

  • The remote window never gets the local preload. It gets desktop/src/preload-remote.ts, which exposes one thing: the queue of files the user opened with the app (see Opening .ics files). No data path, no mode switch, no file manager. Handing a server's page a file the user chose to open while connected to it sends nothing the import would not send anyway. It lives on a global of its own, hypercalOpenedFiles, so the page never mistakes itself for the local window. The origin and main-frame checks in desktop/src/ipc.ts are the second layer. The window also gets its own session partition, so a remote instance's cookies can never be read from the loopback origin.
  • The address is checked before anything is stored. Choosing a server asks its /api/health first, through the remote window's session. No answer, a 5xx or a 404 is an error shown where the address was typed: the first-run chooser, or Settings ▸ Calendar storage. desktop.json only changes once a server has answered. A redirect to a sign-on page or a 401 passes, because that is a proxy in front of hypercal doing its job.
  • A server that stops answering gets a dialog, not only a page. Any main-frame load failure, or a 5xx from a proxy in front of a stopped server, loads the recovery page and raises a native dialog: Use the calendar on this computer, Try again or Close. The shell draws it, so it works whatever the page is, and wherever the desktop puts the application menu (under a KDE global menu, not in the window at all).
  • The application menu is the other way back. The server's frontend has no idea the shell exists, so hypercal ▸ Calendar storage ▸ On this computer is drawn whatever the window is showing.
  • The shell cannot be detected by the preload there, and the page may be an older release the server shipped. It marks the user agent instead, which is enough to label a bug report and deliberately not enough to call anything.

What differs from the hosted app ​

AreaDesktop behaviour
Backups, DB snapshotsWork, unlike Android: a real host with a real filesystem. They live beside the database, under BACKUP_PATH.
CalDAV server (/dav)Reachable, unlike Android, by any client on the same machine. This is what the persisted port buys.
Published calendar feedsReported unavailable. The URL would be a loopback address carrying a bearer token and resolving nowhere for whoever it was shared with.
Service workerNot registered. A packaged app has its assets already, and a second update path would fight whatever installed it.
UpdatesNo updater. A dismissible toast says a newer release exists and links to it; Flatpak, deb, rpm and the AUR do the updating.
RemindersNot delivered. startReminderScheduler is gated on VAPID keys, so local mode schedules nothing, and an Electron window has no push service for remote mode either. See below.
Device calendarsAndroid only. A desktop equivalent would go through Evolution Data Server.

Opening .ics files ​

Every package declares MimeType=text/calendar;, so hypercal is offered under "Open With" and can be made the default:

sh
xdg-mime default hypercal.desktop text/calendar                 # deb, rpm, tar.gz, AUR
xdg-mime default dev.hypercalendar.Desktop.desktop text/calendar # Flatpak

The deb, rpm, AppImage and Flatpak get the key from linux.mimeTypes in electron-builder.yml. The tar.gz, and so the AUR package, ships build/hypercal.desktop, which states it by hand. It is mimeTypes rather than fileAssociations on purpose: a file association also installs a MIME definition, and text/calendar already has one from shared-mime-info.

The file arrives on the command line as a path or a file:// URI. src/openFiles.ts picks out .ics and .hypercalendar files and reads them, up to 10 MB each. A launch while the app is already running reaches it through the single-instance lock's second-instance event instead. The shell queues the files, and the calendar page takes them over IPC once someone is signed in, then asks before importing. It always asks, even though dropping a file onto the window does not: a double-clicked mail attachment is a less deliberate act than a drag.

Some cases behave differently:

  • Flatpak: no filesystem permission is needed. Because the desktop entry passes %U, flatpak build-export adds --file-forwarding, and the app gets a document-portal URI it is allowed to read.
  • Remote mode: the file goes to the server the app is connected to. The server's page takes it through the remote preload and asks first, like the local window. This needs the server to run a release that has it: its page announces itself at boot, and when a page has loaded without announcing, the shell says the server needs updating instead of holding the file.
  • AppImage: it appears under "Open With" only after it has been integrated with the desktop, by appimaged or Gear Lever for example. That is how AppImages work, not something the build controls.

Building ​

sh
npm run build              # the hosted bundle and the server, as usual
npm run build -w desktop   # esbuild the main process and the preload
cd desktop && npx electron .

A terminal inside VS Code exports ELECTRON_RUN_AS_NODE=1, which makes Electron run as plain Node: app is undefined and npm run test:desktop fails with "Process failed to launch". Unset it first, e.g. env -u ELECTRON_RUN_AS_NODE npm run test:desktop.

The profile — the database, its backups, desktop.json and Chromium's own state — lives in Electron's user-data directory, which on Linux is ~/.config/hypercal/. The name comes from productName and is set explicitly in electron.cjs as well: left to default it would be taken from the npm workspace name and land in ~/.config/@hyper-calendar/desktop/.

HYPERCAL_USER_DATA overrides that directory, which is how a test gets a throwaway calendar and how you can keep several side by side.

Almost everything is bundled into dist/main.cjs, which is what keeps packaging small. Four packages stay external, each for a concrete reason listed in desktop/build.mjs: better-sqlite3 (the addon is found by walking __dirname), pino (worker transports), @touch4it/ical-timezones (reads .ics files relative to __dirname) and @modelcontextprotocol/sdk.

No native rebuild step, and why that took a version bump ​

There is deliberately no electron-rebuild here, and that is worth explaining, because every other Electron app carrying a native module needs one.

better-sqlite3 12.10.0, which this repository pinned, is built on V8 APIs directly, and does not compile against Electron 42, 43 or 44 — V8 dropped the no-argument v8::External::Value() and made SetNativeDataProperty ambiguous. Electron 41 compiles, and is outside the three-major support window, which is not a trade worth making for a browser engine that renders other people's calendar data. (12.11.1 does compile against 44, so that was the cheap fix.)

13.0.0 moved the whole addon to N-API, which is ABI-stable across runtimes. One prebuilt binary works on every Node version and every Electron version, so the package ships prebuilds/ for eight platform/arch pairs and the shell loads one straight into Electron with nothing built locally. Three things follow:

  • No node-gyp, no C++ toolchain, and no install script anywhere in the desktop packaging job.
  • No ABI hazard. Rebuilding a hoisted better-sqlite3 for Electron used to break npm test, npm run dev -w server and the e2e/enc suites in the same checkout, because they all load that one copy. That situation cannot arise now.
  • The bump dropped 24 transitive dependencies, the whole prebuild-install download stack (tar-fs, tunnel-agent, simple-get, rc, node-abi and friends).

The cost is on disk: eight prebuilds is 17 MB, and npm ci --omit=dev installs all of them, so the container image grew from 438 MB to 459 MB. If that matters, delete the seven the runtime stage cannot use.

The status indicator ​

The shell puts an icon in the panel. Clicking it, or its menu, hides and shows the window; hiding keeps the backend, its pollers and the CalDAV listener up, so another app on this machine can still reach /dav with the window put away.

Closing the window quits. Hiding to the indicator on close is what a tray app usually does, and it was tried here first. It is not safe, for a specific reason: Electron's new Tray() succeeds even when the item never reaches the panel. Inside a Flatpak it does exactly that — the library is present, the icon resolves, the bus name is permitted, and no icon appears — so there is no reliable signal for "an indicator the user can actually see". Hiding on that assumption leaves a running process with no window and no icon, recoverable only from a task manager. So hiding is something you ask for, and closing means closing.

Two more details worth knowing:

  • AppIndicator has no click event. On GNOME and KDE the panel opens the context menu on any click and never delivers click to the app, so hiding has to be a menu item as well or it does not exist on the desktops most people run. The click handler is for the platforms that do deliver it.
  • The indicator is created before the first window, and before-quit is registered before that. The ordering is not cosmetic: registering the quit handler late means a quit arriving in between is silently cancelled, and the app cannot be stopped at all.

Shutting down forces connections closed after a grace period (shutdownGraceMs, 0 in the shell). The app holds an SSE stream open per client for change notifications, and an SSE connection is never idle, so server.close() on its own waits for something that will not end. That affects the container too: without it a SIGTERM waits for Docker's SIGKILL.

Known: no indicator inside the Flatpak ​

The icon does not appear when the app runs as a Flatpak, and the cause is not yet found. The runtime carries libappindicator3.so.1, the icon resolves at /app/lib/dev.hypercalendar.Desktop/resources/icons/256x256.png, and neither --own-name nor a full --socket=session-bus makes it appear. The same packaged build registers fine outside the sandbox, so it is specific to Flatpak rather than to packaging.

Nothing else is affected: the calendar, the backend and the window all work in the sandbox. And because closing quits rather than hides, a missing indicator costs a feature rather than stranding anyone.

Reminders are not delivered yet ​

core/src/lib/reminderScheduler.ts returns a no-op unless pushConfigured(), because its one delivery path is Web Push. That is right for a server and wrong for a desktop app: there are no VAPID keys to configure, and nothing for a push service to push to.

Giving the shell native notifications therefore is not a matter of wiring up new Notification() in main — it needs a delivery seam in core, so a host can say how a due reminder is surfaced, the way Host already lets a host say how backups are stored. That is a core change with the Android build to consider too (it schedules Android notifications through its own path), so it is deliberately not bundled into the desktop work.

Until then the desktop app shows reminders the way the hosted app does with no VAPID keys set: not at all.

Distribution ​

ChannelBuilt byUpdated by
AppImagebuild-desktop, on every v* tagYou, from the release page
tar.gzbuild-desktopYou, or the Arch package below
deb, rpmbuild-desktopapt / dnf, once installed
Flatpakbuild-flatpakflatpak update, from Pages
Archbuild-archpacman -U, or an AUR helper

All build-desktop artifacts go to the public generic package registry, not to job artifacts: artifacts sit behind builds_access_level, which is members-only here, so a release asset pointing at one would 404 for everybody else. The job asserts on their exact filenames, because the release job links to those URLs literally.

build-flatpak is separate and allow_failure: true. It pulls the Freedesktop runtime, its SDK and the Electron base app from Flathub — on the order of a gigabyte — and a Flathub hiccup should not take the other four packages down with it. A release with four Linux packages and no Flatpak is a worse release, not a broken one.

The Flatpak has no --filesystem=host: the calendar lives in the sandbox's own XDG config directory, and a calendar app has no business reading your home directory. It does hold the StatusNotifier bus permissions the indicator asks for, --talk-name and --own-name both, though they are not sufficient on their own: see the known issue above.

Flatpak repository ​

Pages serves a signed Flatpak repository at /flatpak/, and Install on Linux with Flatpak is the user-facing page for it. The flatpak-repo job builds it on every Pages run, from the published bundle rather than from build-flatpak's artifact, the same way /fdroid/ is built from published APKs.

It holds the newest stable release only. A bundle's commit has no parent in the repository it is imported into, so older bundles would add objects that no ref points at, and nothing a client could install. One release is under 700 files, far below the 200,000 entries Pages allows. Prereleases are skipped.

The repository is rebuilt from scratch each time. That is safe because a bundle carries its commit: importing the same bundle again gives the same commit ID, so clients see an update only when there is a new release. The ref is app/dev.hypercalendar.Desktop/x86_64/master, and master is electron-builder's default branch. The script refuses any other ref, because a changed ref is a different app to every installed copy.

The signing key is the dedicated Flatpak key in Signing keys. Its fingerprint is pinned in scripts/build-flatpak-repo.sh, and the job fails if the CI variable holds any other key. Before anything reaches Pages, the script adds the new repository to a throwaway installation and reads it back, so a signature that does not verify fails the job, not a user's update.

To build it locally, fetch the bundle and run the script against a throwaway key. FLATPAK_REPO_FINGERPRINT overrides the pin:

sh
python3 scripts/fetch-published-flatpak.py /tmp/fp/bundles
FLATPAK_GPG_KEY_BASE64=... FLATPAK_GPG_PASSPHRASE=... FLATPAK_REPO_FINGERPRINT=... \
FLATPAK_REPO_URL=http://localhost:8000 \
  scripts/build-flatpak-repo.sh /tmp/fp/bundles/*.flatpak /tmp/fp/site
(cd /tmp/fp/site && python3 -m http.server)

Arch ​

packaging/aur/ holds a hypercal-bin PKGBUILD that installs the published tarball, and build-arch builds it on every tag. Two things come out of that job:

  • hypercal-bin-<version>-1-x86_64.pkg.tar.zst, published to the package registry like every other desktop artifact. pacman -U installs it directly, with no AUR helper and no build step. The -1 is pkgrel, not a typo.
  • The rendered PKGBUILD and .SRCINFO, with the real sha256sums filled in, published beside it. These are what gets pushed to the AUR.

Pushing to the AUR is still manual, and deliberately so: the AUR is a separate git repository pushed over SSH, and a release cadence of a few per year does not justify giving a pipeline a publishing key. What changed is that the maintainer no longer renders those files themselves — they are on the release page, already correct. See packaging/aur/README.md.

The job's other half is a check. A PKGBUILD that nothing builds rots quietly: a depends entry is just a string, and a renamed or dropped Arch package still parses. Before build-arch existed this one had been stale for three releases and listed libappindicator-gtk3, which is not in the Arch repositories at all. So the job also verifies every depends and optdepends name resolves, reading them back out of the PKGBUILD rather than repeating them.

For what it is worth, the appindicator entry was wrong in a more interesting way than being AUR-only: Electron 44 does not use appindicator. It dlopens libdbusmenu-glib.so.4 and registers org.kde.StatusNotifierItem over D-Bus itself. strings against the shipped binary settles this; re-check it before changing the optdepend.

build-arch is allow_failure: true for build-flatpak's reason — pacman mirrors are not ours, and a release with five Linux packages and no Arch one is a worse release, not a broken one. It retries on script failure so that a mirror hiccup does not read the same as a real defect.

make arch-package runs the whole thing locally against a tarball in desktop/release/, which is the fastest way to test a PKGBUILD change.

Still to do ​

  • Native reminders, which need the delivery seam described above.

  • Windows and macOS, both out of scope for v1. The blocker is the signing certificate, and on Windows it is now the only blocker — the cross-build itself turns out to be close to free from the Linux runner we already have:

    • desktop/src/ has no platform branching at all; the only process.platform match in the shell is a version regex in update.ts. core, server and shared have no POSIX path or /tmp assumptions, and electron.cjs derives everything from app.getPath("userData").
    • better-sqlite3 13 ships prebuilds/win32-x64.node inside the npm tarball. It is N-API, so there is no rebuild and no @electron/rebuild: a Linux runner already has the Windows binary after npm ci.
    • electron-builder 26 patches the .exe icon and version strings with resedit, which is pure JavaScript, and makensis runs natively on Linux. Wine is needed for one step — generating the NSIS uninstaller — and 26.x downloads its own pinned Wine toolset rather than needing a system one. A zip target needs no wine at all.

    What remains is that an unsigned installer gets a SmartScreen "unrecognized app" interstitial, which is a worse first impression than no Windows build.

Nothing here blocks a Linux release.