niacin 1.8 — when activate fails, you finally know about it
1.7 made the sleep-prevention engine bulletproof by dropping the caffeinate subprocess and calling IOPMAssertionCreateWithName directly. 1.8 makes the rest of the app catch up to that bar. The headline change is small but it’s the one nobody should ever have shipped without: if the kernel refuses your assertion request, niacin now tells you, instead of quietly pretending it succeeded. That, six new app-level integration tests, a Swift 6 concurrency fix, and a logging guide for the IT admins who’ve been asking for one.
The user-visible part: errors that aren’t silence
Until 1.8, if IOPMAssertionCreateWithName returned anything other than success, niacin’s response was approximately: nothing. The menu bar icon didn’t change, the tooltip didn’t change, and from the user’s perspective “Activate” was a button that did nothing. There’s no good failure mode for an app called niacin where you click a thing and it just doesn’t do its job.
SleepPreventer now carries a lastError property that captures the IOReturn code from any failed assertion attempt. It’s cleared on the next successful activate() or on any deactivate(), so it can’t go stale. AppState.activate reads that value after every attempt and surfaces it three ways:
- The menu bar icon swaps to
exclamationmark.triangle.fill— visually distinct from both the empty cup (inactive) and the filled cup (active), so you can spot the failure state at a glance. - The hover tooltip becomes “Niacin — Error:
%@”, with theIOReturncode interpolated so you have something concrete to search or paste into an issue. - The same string flows into the unified log under the
sleep-preventercategory, so an IT admin can pull it without ever touching the user’s machine (see the next section).
The new “Niacin — Error: %@” string is translated into French, German, Spanish, and Italian alongside the existing locales, so the failure path holds the same locale parity as the rest of the UI.
Logging & Diagnostics: a doc for the people who actually need it
niacin has had structured os.Logger output since 1.1, but the README never told IT admins how to get at it. 1.8 fixes that with a new Logging & Diagnostics section in the project README, sitting between the MDM section and Settings. It covers:
log show --predicate ‘subsystem == "com.oldsalt.niacin"’for after-the-fact extraction.log streamfor live tailing while you reproduce a behaviour on a managed Mac.log collectfor shipping a sysdiagnose-style archive when a user can’t describe what they’re seeing.- The category table —
policy,policy-watcher,managed-prefs,sleep-preventer— so you can narrow the predicate to the subsystem you care about instead of swimming through everything.
For the 1.8 release specifically, the sleep-preventer category is where the new IOReturn error strings land. If you’re triaging a fleet report of “niacin won’t activate on this one machine,” log show --predicate ‘subsystem == "com.oldsalt.niacin" AND category == "sleep-preventer"’ is the one command.
Six new tests that exercise the app, not just the engine
1.7’s test suite proved the IOKit engine works — pmset confirmed the kernel actually held the assertion. What it didn’t prove was that AppState uses the engine correctly. 1.8 adds six AppStateIntegrationTests that close that gap end-to-end:
activateThroughAppStateHoldsAssertion—AppState.activate()down through topmsetconfirming the kernel holds the assertion. The full path.userDeactivateIsBlockedWhenPolicyForbidsIt— theallowUserToDisable=falsemanaged-policy guard actually guards.activateBlockedWhenPolicyDisablesApp— same, but for theenabled=falsekill switch.reloadPolicyDeactivatesWhenAppBecomesDisabled— the live policy-reload path that 1.1 introduced still does what 1.1 promised.reloadPolicyLeavesCompatibleSessionRunning— reload doesn’t gratuitously interrupt a session that’s still within the new policy bounds.reloadPolicyDeactivatesWhenSessionExceedsNewMax— themaxDurationSecondspath correctly terminates an over-budget session on reload.
The suite is @Suite(.serialized) — the tests mutate AppState.shared and the kernel’s real power-assertion state, so they can’t run in parallel without lying to each other. Each test has a defer that resets the preventer, so a single failure doesn’t cascade through the rest of the suite. 36 tests in total, all green.
Swift 6: the concurrency landmine we hadn’t stepped on yet
PolicyWatcher.swift:31 had been emitting a Swift concurrency warning since the project moved to Swift 6 build settings: a @MainActor-isolated closure was being assigned inside a queue.async block, and the compiler was quietly stripping the global actor annotation off the captured reference. It worked in Swift 5 language mode. In Swift 6 it would have been a hard error the day we flipped the language version, and the kind of crash that only surfaces under specific scheduling.
1.8 reshapes the assignment to happen outside queue.async, with the property and parameter both annotated @MainActor @Sendable. The warning is gone, the behaviour is identical, and the Swift 6 transition no longer has a landmine waiting in this file.
Upgrading
On 1.3 or later from a direct download? Sparkle will offer 1.8 the next time it polls the appcast, or right now via Settings → Software Update → Check Now. After install the orphan-caffeinate sweep from 1.7 stays in place — harmless if your machine has nothing to sweep, useful if it still does.
On 1.0–1.2 from a GitHub release? Download the 1.8 .zip from the releases page, replace the app in /Applications, and relaunch — from there forward you’re on the auto-update track. Mac App Store users stay on the App Store channel; the engine and error-surfacing changes are dormant in those builds, since the MAS builds don’t carry the Sparkle plumbing in the first place.
Source, release notes, and the new Logging & Diagnostics section are on GitHub at github.com/just-an-oldsalt/niacin. If 1.8 surfaces an IOReturn code you can’t make sense of, paste the unified-log line into a new issue or email niacin@dort.zone — we’ll track down what that code means on your hardware.