Skip to content

Commit 89d5fd1

Browse files
iamdadzillaclaude
andcommitted
Merge SMAppService privileged helper (replaces sudoers)
Root helper daemon + XPC replaces the sudoers/first-run-admin-prompt model. Security-reviewed; live-tested: happy path (register→approve→toggle sleep) works and an unauthorized client is rejected by the helper's code-signing requirement. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2 parents 69428f1 + b13fdf1 commit 89d5fd1

13 files changed

Lines changed: 226 additions & 202 deletions

File tree

Package.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ let package = Package(
1010
name: "Macsomnia",
1111
dependencies: ["MacsomniaCore"]
1212
),
13+
.executableTarget(
14+
name: "MacsomniaHelper",
15+
dependencies: ["MacsomniaCore"]
16+
),
1317
.testTarget(
1418
name: "MacsomniaCoreTests",
1519
dependencies: ["MacsomniaCore"]

README.md

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,11 @@ closed and no external cooling can overheat the machine. Use the auto-off timer.
1515
1. Build the app: `./make-app.sh`, then move `Macsomnia.app` to `/Applications`.
1616
2. Open Macsomnia. On first launch it shows a one-time danger/liability warning
1717
you must accept. A `zzz` icon then appears in the menu bar.
18-
3. The first time you enable it, macOS asks you to authorize one-time
19-
administrator permission (it installs a rule allowing password-free `pmset`).
20-
21-
Prefer to set that up yourself instead of the in-app prompt? Run
22-
`./install-sudoers.sh` once before first use. Remove the permission any time
23-
with `sudo rm /etc/sudoers.d/macsomnia`.
18+
3. The first time you enable it, Macsomnia registers a small privileged helper
19+
(an `SMAppService` daemon) that runs `pmset` as root. macOS may ask you to
20+
allow Macsomnia's background item in **System Settings → General → Login
21+
Items & Extensions** (under "Allow in the Background"). Approve it, then
22+
enable again — after that it works without any further prompts.
2423

2524
## Use
2625

@@ -33,5 +32,7 @@ with `sudo rm /etc/sudoers.d/macsomnia`.
3332

3433
## What it runs
3534

36-
- Enable: `sudo pmset -b sleep 0 ; sudo pmset -b disablesleep 1`
37-
- Disable: `sudo pmset -b sleep 5 ; sudo pmset -b disablesleep 0`
35+
The privileged helper daemon runs these as root (reached over XPC):
36+
37+
- Enable: `pmset -b sleep 0 ; pmset -b disablesleep 1`
38+
- Disable: `pmset -b sleep 5 ; pmset -b disablesleep 0`

Sources/Macsomnia/AppDelegate.swift

Lines changed: 40 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import UserNotifications
44
import MacsomniaCore
55

66
final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
7-
let power: PowerControlling = RealPowerController()
7+
let power: PowerControlling = XPCPowerController()
88
lazy var appState = AppState(power: power)
99
private let overlay = RedStripOverlay()
1010
private var monitor: StateMonitor!
@@ -110,7 +110,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
110110
// MARK: - User actions (from the menu)
111111

112112
func enable(_ duration: MacsomniaDuration) {
113-
if !power.hasPasswordlessAccess(), !ensurePrivilege() { return }
113+
if !HelperManager.isEnabled, !ensureHelper() { return }
114114
do {
115115
try appState.enable(duration)
116116
tick = Date()
@@ -119,36 +119,47 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
119119
}
120120
}
121121

122-
/// First-run: ask permission, then install the sudoers rule via the native
123-
/// admin dialog. Returns true only if password-free pmset now works.
124-
private func ensurePrivilege() -> Bool {
122+
/// First-run: register the privileged helper daemon via SMAppService. macOS
123+
/// may require the user to approve Macsomnia's background item in System
124+
/// Settings → Login Items before it becomes active. Returns true only once
125+
/// the helper is enabled and ready to serve XPC requests.
126+
private func ensureHelper() -> Bool {
127+
switch HelperManager.status {
128+
case .enabled:
129+
return true
130+
case .requiresApproval:
131+
presentApprovalNeeded()
132+
return false
133+
default:
134+
do {
135+
try HelperManager.register()
136+
} catch {
137+
presentFailure(error)
138+
return false
139+
}
140+
if HelperManager.isEnabled { return true }
141+
presentApprovalNeeded()
142+
return false
143+
}
144+
}
145+
146+
/// Directs the user to approve Macsomnia's background item so the helper can run.
147+
private func presentApprovalNeeded() {
125148
let alert = NSAlert()
126-
alert.messageText = "Macsomnia needs permission to control sleep"
149+
alert.messageText = "Macsomnia needs you to allow its background item"
127150
alert.informativeText = """
128-
To disable sleep, Macsomnia runs the system pmset tool, which requires \
129-
administrator rights. macOS will ask you to authorize this once. It \
130-
installs a rule allowing password-free pmset, which you can remove any \
131-
time with: sudo rm /etc/sudoers.d/macsomnia
151+
To control sleep, Macsomnia installs a small privileged helper that runs \
152+
the system pmset tool. macOS requires you to approve it once.
153+
154+
Open System Settings → General → Login Items & Extensions, then enable \
155+
Macsomnia under "Allow in the Background". Then try again.
132156
"""
133-
alert.addButton(withTitle: "Grant Permission…")
157+
alert.addButton(withTitle: "Open Login Items Settings")
134158
alert.addButton(withTitle: "Cancel")
135159
NSApp.activate(ignoringOtherApps: true)
136-
guard alert.runModal() == .alertFirstButtonReturn else { return false }
137-
138-
do {
139-
try PrivilegeProvisioner.installSudoersRule()
140-
} catch PrivilegeProvisioner.ProvisionError.cancelled {
141-
return false
142-
} catch {
143-
presentFailure(error)
144-
return false
145-
}
146-
147-
guard power.hasPasswordlessAccess() else {
148-
presentFailure(PrivilegeProvisioner.ProvisionError.failed("permission was not granted"))
149-
return false
160+
if alert.runModal() == .alertFirstButtonReturn {
161+
HelperManager.openLoginItemsSettings()
150162
}
151-
return true
152163
}
153164

154165
func disable() {
@@ -209,9 +220,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
209220
alert.informativeText = """
210221
\(error)
211222
212-
Try enabling again and authorize the permission prompt. If it keeps \
213-
failing, you can grant access manually by running install-sudoers.sh \
214-
from the Macsomnia source.
223+
Try enabling again. If it keeps failing, make sure Macsomnia's background \
224+
item is allowed in System Settings → General → Login Items & Extensions \
225+
(under "Allow in the Background").
215226
"""
216227
alert.alertStyle = .warning
217228
alert.runModal()
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import Foundation
2+
import ServiceManagement
3+
import MacsomniaCore
4+
5+
enum HelperManager {
6+
static var service: SMAppService { SMAppService.daemon(plistName: MacsomniaHelperInfo.daemonPlistName) }
7+
static var status: SMAppService.Status { service.status }
8+
static var isEnabled: Bool { status == .enabled }
9+
static func register() throws { try service.register() }
10+
static func openLoginItemsSettings() { SMAppService.openSystemSettingsLoginItems() }
11+
}

Sources/Macsomnia/PrivilegeProvisioner.swift

Lines changed: 0 additions & 64 deletions
This file was deleted.
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import Foundation
2+
import MacsomniaCore
3+
4+
/// `PowerControlling` backed by the privileged helper daemon over XPC. The
5+
/// protocol is synchronous, so each call bridges the async XPC reply with a
6+
/// semaphore — bounded by a timeout so a stalled/unreachable helper throws
7+
/// instead of hanging the calling (main) thread forever.
8+
final class XPCPowerController: PowerControlling {
9+
/// Generous ceiling; a real round-trip + pmset exec is sub-millisecond.
10+
private static let callTimeout: DispatchTimeInterval = .seconds(10)
11+
12+
func enable() throws { try setSleepDisabled(true) }
13+
func disable() throws { try setSleepDisabled(false) }
14+
15+
func readSleepDisabled() throws -> Bool {
16+
let conn = makeConnection(); defer { conn.invalidate() }
17+
var value = false, found = false, connErr: Error?
18+
let sem = DispatchSemaphore(value: 0)
19+
guard let proxy = conn.remoteObjectProxyWithErrorHandler({ connErr = $0; sem.signal() })
20+
as? MacsomniaHelperProtocol else {
21+
throw PmsetError(code: -1, message: "helper unavailable")
22+
}
23+
proxy.readSleepDisabled { f, v in found = f; value = v; sem.signal() }
24+
if sem.wait(timeout: .now() + Self.callTimeout) == .timedOut {
25+
throw PmsetError(code: -1, message: "helper timed out")
26+
}
27+
if let connErr { throw connErr }
28+
guard found else { throw PmsetError(code: -1, message: "SleepDisabled not found") }
29+
return value
30+
}
31+
32+
private func setSleepDisabled(_ disabled: Bool) throws {
33+
let conn = makeConnection(); defer { conn.invalidate() }
34+
var failure: String?, connErr: Error?
35+
let sem = DispatchSemaphore(value: 0)
36+
guard let proxy = conn.remoteObjectProxyWithErrorHandler({ connErr = $0; sem.signal() })
37+
as? MacsomniaHelperProtocol else {
38+
throw PmsetError(code: -1, message: "helper unavailable")
39+
}
40+
proxy.setSleepDisabled(disabled) { ok, msg in if !ok { failure = msg ?? "unknown" }; sem.signal() }
41+
if sem.wait(timeout: .now() + Self.callTimeout) == .timedOut {
42+
throw PmsetError(code: -1, message: "helper timed out")
43+
}
44+
if let connErr { throw connErr }
45+
if let failure { throw PmsetError(code: -1, message: failure) }
46+
}
47+
48+
private func makeConnection() -> NSXPCConnection {
49+
let c = NSXPCConnection(machServiceName: MacsomniaHelperInfo.machServiceName, options: .privileged)
50+
c.remoteObjectInterface = NSXPCInterface(with: MacsomniaHelperProtocol.self)
51+
c.resume()
52+
return c
53+
}
54+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import Foundation
2+
3+
@objc public protocol MacsomniaHelperProtocol {
4+
/// Set disablesleep (and the -b sleep timer). reply: (ok, errorMessage?)
5+
func setSleepDisabled(_ disabled: Bool, reply: @escaping (Bool, String?) -> Void)
6+
/// Read SleepDisabled. reply: (found, value)
7+
func readSleepDisabled(reply: @escaping (Bool, Bool) -> Void)
8+
}
9+
10+
public enum MacsomniaHelperInfo {
11+
public static let machServiceName = "net.jperry.Macsomnia.helper"
12+
public static let daemonPlistName = "net.jperry.Macsomnia.helper.plist"
13+
}

Sources/MacsomniaCore/PowerController.swift

Lines changed: 4 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -4,75 +4,14 @@ public protocol PowerControlling {
44
func enable() throws // sleep 0 ; disablesleep 1
55
func disable() throws // sleep 5 ; disablesleep 0
66
func readSleepDisabled() throws -> Bool
7-
/// True when `pmset` can be run as root without a password (the sudoers rule
8-
/// is installed). Used to decide whether to trigger first-run provisioning.
9-
func hasPasswordlessAccess() -> Bool
107
}
118

129
public struct PmsetError: Error, CustomStringConvertible {
1310
public let code: Int32
1411
public let message: String
15-
public var description: String { "pmset failed (\(code)): \(message)" }
16-
}
17-
18-
/// Runs the real `pmset` binary. Writes require root (via the sudoers rule
19-
/// installed by install-sudoers.sh); reads do not.
20-
public final class RealPowerController: PowerControlling {
21-
public init() {}
22-
23-
public func enable() throws {
24-
try runSudo(["-b", "sleep", "0"])
25-
try runSudo(["-b", "disablesleep", "1"])
26-
}
27-
28-
public func disable() throws {
29-
try runSudo(["-b", "sleep", "5"])
30-
try runSudo(["-b", "disablesleep", "0"])
31-
}
32-
33-
public func readSleepDisabled() throws -> Bool {
34-
let output = try capture("/usr/bin/pmset", ["-g"])
35-
guard let value = SleepStateParser.sleepDisabled(fromPmsetOutput: output) else {
36-
throw PmsetError(code: -1, message: "SleepDisabled not found in pmset -g")
37-
}
38-
return value
39-
}
40-
41-
public func hasPasswordlessAccess() -> Bool {
42-
// Grep the user's sudo rules for an actual NOPASSWD pmset grant. This
43-
// reflects the installed rule regardless of sudo's credential-cache
44-
// state — a plain `sudo -n pmset` would also succeed on a cached
45-
// timestamp (e.g. the user ran sudo recently), giving a false positive
46-
// and skipping first-run provisioning.
47-
guard let listing = try? capture("/usr/bin/sudo", ["-n", "-l"]) else { return false }
48-
return listing.range(of: "NOPASSWD:[^\n]*pmset", options: .regularExpression) != nil
49-
}
50-
51-
// MARK: - Process helpers
52-
53-
private func runSudo(_ pmsetArgs: [String]) throws {
54-
_ = try capture("/usr/bin/sudo", ["-n", "/usr/bin/pmset"] + pmsetArgs)
55-
}
56-
57-
@discardableResult
58-
private func capture(_ launchPath: String, _ args: [String]) throws -> String {
59-
let process = Process()
60-
process.executableURL = URL(fileURLWithPath: launchPath)
61-
process.arguments = args
62-
let stdout = Pipe()
63-
let stderr = Pipe()
64-
process.standardOutput = stdout
65-
process.standardError = stderr
66-
try process.run()
67-
// Drain both pipes to EOF before waiting, so a child that writes more
68-
// than the OS pipe buffer can't block and deadlock waitUntilExit().
69-
let outData = stdout.fileHandleForReading.readDataToEndOfFile()
70-
let errData = stderr.fileHandleForReading.readDataToEndOfFile()
71-
process.waitUntilExit()
72-
guard process.terminationStatus == 0 else {
73-
let message = String(data: errData, encoding: .utf8) ?? "unknown error"
74-
throw PmsetError(code: process.terminationStatus, message: message)
75-
}
76-
return String(data: outData, encoding: .utf8) ?? ""
12+
public init(code: Int32, message: String) {
13+
self.code = code
14+
self.message = message
7715
}
16+
public var description: String { "pmset failed (\(code)): \(message)" }
7817
}

0 commit comments

Comments
 (0)