Magic Apps

Documentation

macmagic.app

Widgets

Both apps ship WidgetKit extensions, and both reach them the same way. A widget runs in an OS-hosted sandbox with no channel to the app, so everything it draws passes through an App Group snapshot file and every timeline reload has to originate in the Electron main process. The families, layouts, choice of calendars and deep links are each app's own: Magimail's are in ../magimail/widgets.md, Magical's in ../magical/widgets.md.

Where a widget runs, and how it gets its data

A widget is a standalone macOS app extension bundle in Contents/PlugIns/. chronod launches it when it feels like rendering, long after the poll that produced the data, so there is no live channel to answer on. The app leaves a snapshot where the extension can find it and pokes WidgetKit to come and re-read it:

sequenceDiagram
    autonumber
    participant Poller as Poller
    participant Main as Electron Main
    participant Addon as Native Addon (dylib)
    participant Chrono as chronod (WidgetKit host)
    participant Ext as Widgets.appex

    Poller->>Main: poll result
    Main->>Main: write snapshot (temp file then rename)
    Main->>Addon: reloadNativeWidgets()
    Addon->>Chrono: WidgetCenter.shared.reloadAllTimelines()
    Chrono->>Ext: getTimeline(in:completion:)
    Ext->>Ext: load snapshot from the App Group container
    Ext-->>Chrono: Timeline(entries:policy: .after(+15 min))

The snapshot is written through a temporary file and fs.renameSync, so the extension never reads partial JSON. Every field the extension reads is optional on its side: a snapshot written by an older build is missing whatever that build did not write, and the extension reads an absent field as its default rather than failing to decode the file.

App Group container

The snapshot lives at ~/Library/Group Containers/<TEAM_ID>.group.com.alexhaslam.<app>/widget-cache.json, in a container both sides are entitled for. A sandbox temporary exception over the whole of ~/Library/Application Support/<App>/ would reach the same file, but it would also hand the extension the session cookies in Partitions/ so it can read one JSON file; the App Group is the channel that does not do that.

The container resolution, the team-prefix rule and the signing story are in process-model-and-ipc.md and building-and-signing.md.

Timeline reload must come from the main process

WidgetCenter asks macOS chronod which widgets to reload, and chronod accepts the call only from the process whose executable matches CFBundleExecutable, so it goes through the native addon in the main process rather than the Swift helper. When the host app is idle, a 15-minute policy on the timeline refreshes the widget anyway, which is the backstop for when the app is not running to poke it. process-model-and-ipc.md has the rule and the failure it prevents.

The reload fires only when the snapshot changed: the writer compares everything a widget draws against the last thing written and reports whether it wrote, with the update timestamp excluded because it moves every poll and changes nothing on screen. Focus state is part of the comparison, so a Focus transition triggers a reload even when no event changed.

Building and verifying an extension

An .appex is built standalone (its own xcodebuild or swiftc), stamped with the version and the build number, and signed before it is bundled into Contents/PlugIns/ through electron-builder.json's extraFiles. scripts/build-app.sh builds every extension and refuses to package without it, because electron-builder only warns when a bundled artefact is missing, and an app installed without its .appex shows no widgets in the gallery at all. The build-number stamping, and why macOS needs it, are in process-model-and-ipc.md's Extension build number.

Brand mark and the shared tokens

Each extension compiles the brand mark in by relative path from the app's swift/Sources/…Lib/, so the widget, the popover and the menu bar cannot draw different marks. The mark, its box and the rule that keeps it sharp across the extension boundary are in brand-and-icons.md.

Values come from Views.swift in Magimail's extension, so the two apps' widgets sit next to each other in Notification Center without reading as two products:

Token Value
Card fill Color.primary.opacity(0.06)
Hairline Color.primary.opacity(0.08), 1pt border
Card radius 7pt
Accent bar 3pt capsule, 4pt inset from top and bottom
Title 12.5pt semibold, 11.5pt in a compact card
Metadata 10.5pt secondary
Eyebrow 9.5pt bold, 1pt letter spacing
Background .thinMaterial

A feed takes a divided group and a discrete object takes a card. Magimail draws a thread list as one inset group divided by hairlines and draws an account as its own tile; Magical draws a day as a set of cards with gaps between them rather than a feed. The accent bar is the only coloured thing on a card; tinting the border and a fill as well turns a column of cards into a column of colour washes. Both apps use .font(.system(size:weight:)) throughout, with no bundled webfonts, and system SF Symbols rather than branded icons.