Sukrara (alpha)

13 — macOS widgets (desktop + Notification Center)

2026-10-16 · investigation report · macOS 27.0.1 (26A434), Command Line Tools only (Swift 6.4, MacOSX27.sdk), no Xcode

Goal

WidgetKit widgets for the Mac (desktop and Notification Center) that show glucose:

  1. Current value: number, a trend arrow at a continuous angle, the reading’s age, colour by range, and a stale state.
  2. Graph: Swift Charts, last 3 h (more on bigger widgets), 70–180 target band.
  3. Graph + value combined.

The key question: can this be built and run with only the Command Line Tools, because installing Xcode needs the user’s Apple ID?

Result

Question Answer
Compile a WidgetKit + Swift Charts extension with swiftc (CLT only) Yes. About 11 s (-Onone)
Assemble .app + PlugIns/*.appex by hand, sign ad hoc, register Yes. pluginkit -m lists it and chronod sees it (“Added extensions”)
macOS loads it (widget gallery, desktop) No. chronod drops it within 7 ms: “Extension is not entitled to run in the App Sandbox”, then “purging instead because we shouldn’t cache it”
Can we get past that without an Apple-issued certificate? No. Eight signing and install variants all fail the same way (table below)
Widget views Done, rendered to PNG with ImageRenderer from the same SwiftUI code (evidence)
Data sharing host → sandboxed widget, ad hoc localhost HTTP, a /Users/Shared file, and the widget’s own container all work. The App Group does not
Refresh cadence the system honours Not measurable while chronod refuses the extension. The app’s reloads reach chronod within 1–3 ms

So the CLI-only route stops at one wall, and it is signing, not tooling. Everything else (compiler, SDK, Swift Charts, bundle layout, Info.plist, entitlements, registration) works without Xcode. To make it run you need an Apple-issued code-signing certificate with a Team ID:

With such a certificate, SIGN_ID=<sha1> ./build.sh install is the only change.

Widgets (rendered with ImageRenderer)

The same GlucoseWidgetView is used in the extension and in the renderer, with the container imitated (16 pt margins, rounded background). Widget sizes are the macOS points: small 170×170, medium 364×170, large 364×382, extra large 746×382.

   
Combined, medium
Value small / Combined medium, low
Stale (> 5 min): strikethrough, grey, dot instead of the arrow
Accented rendering mode (tinted desktop)
Combined, large (6 h, time in range)
Combined, extra large (12 h)
Trend arrow, −4 … +4 mg/dL/min

All families × light/dark: value-{small,medium}, graph-{small,medium,large,xl}, combined-{small,medium,large,xl} -{light,dark}.png, plus state-{low,high,flat,stale,nodata}.png.

What the widgets do:

How it is built (no Xcode)

spikes/widgets-macos/build.sh:

swiftc -parse-as-library -application-extension  Shared/*.swift Widget/GlucoseWidgets.swift  → GlucoseWidget.appex/Contents/MacOS/GlucoseWidget
swiftc  Shared/GlucoseData.swift Host/main.swift                                              → GlucoseWidgets.app/Contents/MacOS/GlucoseWidgets
cp Info.plists;  codesign --sign - --options runtime --entitlements …  (appex first, then app)
lsregister -f -R ~/Applications/GlucoseWidgets.app;  pluginkit -a …/GlucoseWidget.appex

What blocks it

evidence/chronod-rejection.log:

chronod [com.apple.extensionkit:discovery] Extension is not entitled to run in the App Sandbox : <_EXExtensionIdentity: …>
chronod [com.apple.chrono:extensions-verbose] Added extensions: [<WidgetExtension - identifier: dev.spike.GlucoseWidgets::dev.spike.GlucoseWidgets.widget, … isApple: false>]
chronod [com.apple.chrono:descriptor] Requested to add extension, but purging instead because we shouldn't cache it: <WidgetExtension …> - isApple? false
chronod [com.apple.chrono:descriptor] Purging descriptors for extensions: [dev.spike.GlucoseWidgets::dev.spike.GlucoseWidgets.widget]
chronod [com.apple.chrono:extensions] Ignoring restricted or unknown extension dev.spike.GlucoseWidgets      ← every reloadAllTimelines()

chronod never asks the extension for its widget descriptors, so nothing appears in the gallery and the extension process is never launched.

Variant Result
Ad hoc, hardened runtime, full entitlements rejected
Ad hoc, minimal entitlements (app-sandbox + network.client only) rejected
Ad hoc without hardened runtime rejected
Self-signed code-signing certificate (temporary keychain) rejected
Self-signed, CN/OU styled Developer ID Application: … (SPIKE12345) rejected (codesign still sets TeamIdentifier=not set)
Ad hoc + fake com.apple.developer.team-identifier / application-identifier entitlements rejected
pluginkit -e use (explicitly elected, shows +) rejected
Installed in /Applications instead of ~/Applications rejected (then removed again)

Why. The in-process check passes. ExtensionFoundation’s own _EXExtensionIdentity, built from our LaunchServices record, says hasSandboxEntitlement = true and app-sandbox = 1 (evidence/exid.txt, Diag/exid.swift). So chronod is not reading a missing entitlement; it does not trust entitlements from this signature. The one difference LaunchServices shows between our extension and the two working third-party widgets on this Mac (CodexBar Y5PE65HELJ, BetterTouchTool DAFVSXZ82P) is teamIdentifier = nil. A Team ID only comes from an Apple-issued certificate, so that is the requirement. (We can’t read chronod’s code, so the exact check is inferred; every variant without a Team ID failed.)

Data sharing (host app → sandboxed widget)

Tested with Probe/: the widget’s exact loader (Shared/Loader.swift) in a CLI. It is signed ad hoc with the widget’s entitlements and runs inside the App Sandbox. With PROBE_ID=dev.spike.GlucoseWidgets.widget it runs as the widget and gets the same container (evidence/probe-sandbox.txt).

Transport Ad hoc result Notes
App Group container (group.dev.spike.glucosewidgets) ✗ containerURL(forSecurityApplicationGroupIdentifier:) returns a path, but writes fail with 513 (EPERM) from both the unsandboxed host and the sandboxed widget. The directory then can’t be listed (“Operation not permitted”). Since macOS 15, group containers are protected and need a group the signing team is authorised for: TEAMID.… on macOS, or group.… with a provisioning profile
Widget’s own container (~/Library/Containers/<widget-id>/Data/…), written by the host ✓ The unsandboxed host can write it and the widget reads it. It relies on the host not being sandboxed, and may hit “access data from other apps” prompts in future macOS
/Users/Shared/GlucoseWidgets/glucose.json + temporary-exception.files.absolute-path.read-only ✓ Temporary-exception entitlements are honoured with ad hoc signing. Mac App Store review would reject them
http://127.0.0.1:17591/glucose.json served by the host, widget has network.client ✓ Always fresh, and needs no file location or container. The widget gets nothing while the host isn’t running, so it falls back to the files

Pick: localhost HTTP first, falling back to the newest file. The loader tries every source and uses the newest written timestamp. In production, with a Team ID, this becomes a team-prefixed App Group (TEAMID.dev.…). That is the standard way: no profile needed on macOS, and no server needed. CodexBar does exactly this (Y5PE65HELJ.com.steipete.codexbar). Keep HTTP as the live path while the host app runs.

Refresh cadence

Integration notes

Files

spikes/widgets-macos/:

Uninstall: pkill -f GlucoseWidgets.app; lsregister -u ~/Applications/GlucoseWidgets.app; rm -rf ~/Applications/GlucoseWidgets.app /Users/Shared/GlucoseWidgets.