Skip to content

refactor(helper): migrate to SMAppService for macOS 13+ helper install#3194

Draft
marxo126 wants to merge 1 commit intoexelban:masterfrom
marxo126:refactor/helper-smappservice
Draft

refactor(helper): migrate to SMAppService for macOS 13+ helper install#3194
marxo126 wants to merge 1 commit intoexelban:masterfrom
marxo126:refactor/helper-smappservice

Conversation

@marxo126
Copy link
Copy Markdown

@marxo126 marxo126 commented May 7, 2026

Why

SMJobBless was deprecated in macOS 13. As of macOS 26.4.1 the OS-level smd daemon now rejects SMJobBless installs that previously worked, silently breaking fan control for end users on the latest macOS:

```
smd: App "eu.exelban.Stats" did not pass helper check "eu.exelban.Stats.SMC.Helper".
```

Reproduces consistently on a fresh macOS 26.4.1 install — clicking Install fan helper silently fails, no helper files land at `/Library/PrivilegedHelperTools/`, and the popup keeps showing the placeholder forever. Affects every user who upgrades to macOS 26.

Change

Migrate the install/uninstall flow to the modern `SMAppService` API (macOS 13+) while keeping the existing SMJobBless code path intact as a fallback for macOS 11/12.

  • New `SMC/Helper/eu.exelban.Stats.SMC.Helper.plist` — bundled launchd plist with `Label`, `MachServices`, and `BundleProgram` pointing to the helper binary at its existing `Contents/Library/LaunchServices/...` path (binary location unchanged).
  • New `PBXCopyFilesBuildPhase` in the Stats target copies the plist to `Contents/Library/LaunchDaemons/` in the app bundle — where `SMAppService` daemons must live.
  • `Kit/helpers.swift`:
    • `install(completion:)` branches on `#available(macOS 13.0, *)`. macOS 13+ calls `SMAppService.daemon(plistName:).register()`. Older macOS keeps the existing SMJobBless + `AuthorizationCreate` flow.
    • `uninstall()` branches to `service.unregister()` on 13+, helper-side XPC uninstall on older.
    • `isInstalled` queries `service.status` on 13+.
    • New `requiresApproval` computed property surfaces the SMAppService `.requiresApproval` status so the popup can show "approve in System Settings → Login Items & Extensions" guidance instead of the generic Install button. (UI wiring not in this PR — kept minimal; happy to add as a follow-up if you'd like the UX in the same change.)
  • Helper code (`SMC/Helper/main.swift`) and existing `SMC/Helper/Launchd.plist` left untouched. The legacy plist is still consumed by the SMJobBless path.

User flow on macOS 13+

  1. User clicks Install fan helper in the popup
  2. `SMAppService.daemon(plistName:).register()` is called
  3. macOS adds an entry to System Settings → Login Items & Extensions → Background Items, status `.requiresApproval`
  4. User approves the toggle in System Settings
  5. Daemon bootstraps, helper goes `.enabled`, fan control comes alive

Net diff

```
3 files changed, +89 lines net
SMC/Helper/eu.exelban.Stats.SMC.Helper.plist | +14 (new)
Kit/helpers.swift | +55
Stats.xcodeproj/project.pbxproj | +14 (PBXFileReference, PBXBuildFile, PBXCopyFilesBuildPhase, group children, build phase wiring)
```

Testing — important caveat

Compile-verified (`BUILD SUCCEEDED`, plist lands at `Contents/Library/LaunchDaemons/eu.exelban.Stats.SMC.Helper.plist` in the built bundle). Direct API probe of `SMAppService.daemon(plistName:).register()` against the dev build returns `SMAppServiceErrorDomain code 3 — Codesigning failure loading plist (-67028)`, which is the expected rejection of unsigned dev builds — `SMAppService` strictly requires a Developer ID signature.

Behavioural verification therefore requires a Developer ID-signed build, which I don't have locally. The PR is filed as Draft so you can build with your signing cert, register the daemon once, and confirm the user-side flow (System Settings prompt → approval → helper bootstraps).

If the registration call succeeds and the System Settings approval prompt appears, the migration is good. If `register()` throws on a properly-signed build, the most likely culprits are:

  • Plist filename mismatch — the file in the bundle must be exactly `eu.exelban.Stats.SMC.Helper.plist` (the file is named that way in the source and the Copy Files phase preserves it).
  • Helper not actually present at the `BundleProgram` path inside the bundle.
  • Notarization needs to include the new plist (no extra resource, but worth a sanity check on the first signed build).

Happy to iterate on any of the above.

Pre-13 fallback

`installSMJobBless()` and the older uninstall path are unchanged — verbatim from main. Any users on macOS 11/12 see no behavioural difference.

SMJobBless was deprecated in macOS 13. As of macOS 26.4.1 the OS-level
smd daemon now rejects SMJobBless installs that previously worked,
silently breaking fan control for end users on the latest macOS:

  smd: App "eu.exelban.Stats" did not pass helper check
       "eu.exelban.Stats.SMC.Helper".

Migrate to the modern SMAppService API for macOS 13+, keeping the
existing SMJobBless flow as a fallback for macOS 11/12.

Changes:
- New `SMC/Helper/eu.exelban.Stats.SMC.Helper.plist` declaring
  `Label`, `MachServices`, and `BundleProgram` pointing to the helper
  binary at `Contents/Library/LaunchServices/...` (its existing path,
  unchanged).
- New Copy Files build phase in `Stats.xcodeproj/project.pbxproj`
  copies the plist to `Contents/Library/LaunchDaemons/` in the app
  bundle, where SMAppService daemons are loaded from.
- `Kit/helpers.swift`:
  - `install(completion:)` branches on macOS version. macOS 13+ calls
    `SMAppService.daemon(plistName:).register()`. Older macOS keeps
    the existing SMJobBless + AuthorizationCreate flow intact.
  - `uninstall()` similarly branches to `service.unregister()` on 13+.
  - `isInstalled` now queries `service.status` on 13+.
  - New `requiresApproval` property surfaces the
    `.requiresApproval` SMAppService status so callers can show
    "approve in System Settings → Login Items & Extensions"
    guidance. (Not yet wired into popup UI — separate UI patch.)
- Helper code (`SMC/Helper/main.swift`) and existing
  `SMC/Helper/Launchd.plist` left untouched. The legacy plist is
  still consumed by the Helper target itself for the SMJobBless path.

Note on testing: the changes are compile-verified and the bundled
plist lands at the expected `Contents/Library/LaunchDaemons/...`
path. SMAppService specifically requires a Developer ID-signed app to
register a daemon (unlike SMJobBless which permitted unsigned testing
within reason), so behavioral verification needs to happen against a
properly-signed build of Stats.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant