2026-10-16 · investigation report · macOS 27.0.1 (26A434), Command Line Tools only (Swift 6.4, MacOSX27.sdk), no Xcode
WidgetKit widgets for the Mac (desktop and Notification Center) that show glucose:
The key question: can this be built and run with only the Command Line Tools, because installing Xcode needs the user’s Apple ID?
| 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.
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:
TrendArrow: 0 mg/dL/min points right,
±3 mg/dL/min is vertical, linear in between, and a second chevron at ≥ 3. Here it is a SwiftUI Shape, rotated.
The rate comes from the source, or from a least-squares fit over the last 5 min.Text(.currentDate, format: .reference(to: reading, allowedFields: [.hour, .minute])), which renders
“1 minute ago”. The system keeps it ticking, so it needs no timeline reloads.containerBackground(.fill.tertiary, for: .widget), widgetAccentable() on the
value/arrow, a monochrome chart in accented/vibrant mode, and
widgetURL(glucosewidgets://open?from=<kind>). The host app registers that URL scheme; tested with open, and
the host logs opened via URL glucosewidgets://open?from=combined.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
@main struct GlucoseWidgets: WidgetBundle. WidgetBundle.main() (WidgetKit) is the real
entry, so no -e _NSExtensionMain is needed. Launched by hand, the binary comes up sandboxed and WidgetKit logs
main [WidgetBundle] and WidgetHost - Optional(WidgetKit.ResolvedWidgetBundleHost). The binary is fine.CFBundlePackageType = XPC! and
NSExtension.NSExtensionPointIdentifier = com.apple.widgetkit-extension. It has the same keys as a shipping
third-party widget built with Xcode (CodexBar), minus the DT* build-info keys.app-sandbox, network.client, application-groups, and a read-only
temporary exception for /Users/Shared/GlucoseWidgets/.LSUIElement), not sandboxed. Once a minute it writes a synthetic 12 h history to every
transport and calls WidgetCenter.shared.reloadAllTimelines().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.)
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.
reloadAllTimelines() from the headless host, once a minute, reaches chronod in 1–3 ms every
time (evidence/reload-delivery.log). Reload IPC works
from an ad hoc, unsandboxed LSUIElement app.getTimeline
never runs. The widget logs every getTimeline call: log show --predicate 'subsystem == "dev.spike.GlucoseWidgets"',
plus ~/Library/Containers/dev.spike.GlucoseWidgets.widget/Data/Library/Caches/timeline.log. Once it is signed
with a Team ID, run it for an hour and count. This is the first follow-up..reference(to:)), so “x min ago” is always correct..after(latest + 65 s) to ask for the next reading, and the host calls reloadAllTimelines()
on every new reading.Worst case, the widget shows an older value marked stale, never a wrong “fresh” one.
GlucoseWidget.appex in GlucoseBar.app/Contents/PlugIns/. The extension bundle id must be prefixed by
the app’s (dev.spike.GlucoseBar.widget).GlucoseBar sign the appex first, then the app, with the same Team ID certificate.GlucoseSnapshot (Shared/GlucoseData.swift:
samples + rate) to the team App Group and call WidgetCenter.shared.reloadAllTimelines().The widget code needs no changes beyond the group ID and bundle IDs.
GlucoseRange). Put the user’s unit and
thresholds in the snapshot so the menubar settings drive the widget.GlucoseWidgetView can also serve iOS widgets later (WidgetKit API is the same; sizes differ).SIGN_ID=<sha1> ./build.sh installTEAMID.dev.spike.glucosewidgets in both entitlements files and SharedLocation.appGroup.getTimeline calls for an hour to measure the budget.spikes/widgets-macos/:
Shared/GlucoseData.swift: model, synthetic generator, thresholds, paths.Shared/GlucoseViews.swift: all widget SwiftUI/Charts views.Shared/Loader.swift: the transports.Widget/: WidgetBundle, providers, Info.plist and entitlements.Host/: headless host app (synthetic data, HTTP, file publishing, reloads, URL scheme).Render/: the ImageRenderer CLI that writes the PNGs.Probe/: sandbox data-sharing probe.Diag/: exid.swift (ExtensionKit identity check) and chronolog.sh (chronod log filter).build.sh modes: (none), install, render, probe.Uninstall: pkill -f GlucoseWidgets.app; lsregister -u ~/Applications/GlucoseWidgets.app; rm -rf ~/Applications/GlucoseWidgets.app /Users/Shared/GlucoseWidgets.