How to Run sudo pmset From a Swift App (No Deprecated APIs)

How to Run sudo pmset From a Swift App (No Deprecated APIs)

Written by

in

Quick answer: Reading the power schedule with pmset -g sched needs no privileges, so a plain Process call is enough. Writing it needs root: run the command through NSAppleScript using do shell script "..." with administrator privileges, which shows the standard macOS authentication dialog. For repeated use, install a privileged helper with SMAppService instead.

Why this happens

pmset splits its work in two. Reading the current schedule (pmset -g sched) is unprivileged, so a plain Process call works fine. Writing it — pmset repeat ... or pmset repeat cancel — changes system power state and requires root.

The old escape hatch, AuthorizationExecuteWithPrivileges, has been deprecated for years and is not usable from a modern Swift target. The obvious replacement, launching /usr/bin/sudo from Process, fails because there is no terminal to read the password from:

sudo: a terminal is required to read the password; either use the -S option to read from standard input or configure an askpass helper

If the app is sandboxed, the sandbox blocks the privileged shell as well, so the first thing to check is that App Sandbox is off for a locally distributed tool.

That leaves two workable designs: do shell script ... with administrator privileges through NSAppleScript (a few lines, ideal for a debug build you run on your own Macs), or a privileged helper installed with SMAppService (the supported long-term design).

How to fix it

1. Read the schedule with a plain Process

No privileges needed. Read the pipe before waitUntilExit() so a large output can’t fill the pipe buffer and deadlock the child.

import Foundation

func readSchedule() throws -> String {
    let task = Process()
    let pipe = Pipe()
    task.executableURL = URL(fileURLWithPath: "/usr/bin/pmset")
    task.arguments = ["-g", "sched"]
    task.standardOutput = pipe
    task.standardError = pipe

    try task.run()
    let data = pipe.fileHandleForReading.readDataToEndOfFile()
    task.waitUntilExit()
    return String(decoding: data, as: UTF8.self)
}

When nothing is scheduled, pmset prints a short “no scheduled power events” line instead of a table, so treat the output as text and parse defensively rather than assuming a fixed layout.

2. Write with do shell script ... with administrator privileges

NSAppleScript compiles the source, do shell script runs it through /bin/sh, and the with administrator privileges clause makes SecurityAgent show the standard user/password dialog at the moment of the call. No helper, no installer, no sudo in Terminal.

import Foundation

enum PmsetError: Error {
    case cancelled
    case scriptFailed(String)
}

@discardableResult
func runPrivileged(_ command: String) throws -> String {
    let escaped = command
        .replacingOccurrences(of: "\\", with: "\\\\")
        .replacingOccurrences(of: "\"", with: "\\\"")

    let source = "do shell script \"\(escaped)\" with administrator privileges"
    guard let script = NSAppleScript(source: source) else {
        throw PmsetError.scriptFailed("could not compile script")
    }

    var errorInfo: NSDictionary?
    let result = script.executeAndReturnError(&errorInfo)

    if let errorInfo {
        let code = errorInfo["NSAppleScriptErrorNumber"] as? Int ?? 0
        if code == -128 { throw PmsetError.cancelled }   // user pressed Cancel
        let message = errorInfo["NSAppleScriptErrorMessage"] as? String ?? "unknown"
        throw PmsetError.scriptFailed(message)
    }

    return result.stringValue ?? ""
}

With that in place, setting and clearing the schedule is just string building:

func setSchedule(wakeDays: String?, wakeTime: String?,
                 sleepDays: String?, sleepTime: String?) throws {
    var parts = ["pmset repeat"]
    if let d = wakeDays, let t = wakeTime {
        parts.append("wakeorpoweron \(d) \(t)")
    }
    if let d = sleepDays, let t = sleepTime {
        parts.append("shutdown \(d) \(t)")
    }
    guard parts.count > 1 else { throw PmsetError.scriptFailed("nothing to set") }
    try runPrivileged(parts.joined(separator: " "))
}

func clearSchedule() throws {
    try runPrivileged("pmset repeat cancel")
}

The day string uses M T W R F S U (R is Thursday, U is Sunday), so a weekday wake at half past seven is setSchedule(wakeDays: "MTWRF", wakeTime: "07:30:00", sleepDays: nil, sleepTime: nil).

3. Validate everything you interpolate

The command travels through an AppleScript string literal and then through /bin/sh, so two layers of quoting sit between your Swift string and pmset. Never pass user-typed text straight in. Check the shape first:

func isValidDays(_ s: String) -> Bool {
    !s.isEmpty && s.allSatisfy { "MTWRFSU".contains($0) }
}

func isValidTime(_ s: String) -> Bool {
    s.range(of: #"^\d{1,2}:\d{2}(:\d{2})?$"#, options: .regularExpression) != nil
}

If validation fails, stop before building the script. That keeps the escaping in runPrivileged a safety net rather than the only defence.

4. Convert the 12-hour output to 24-hour

pmset -g sched prints times like 7:30AM, and the exact wording shifts between macOS releases. Convert every match in place, working backwards so earlier ranges stay valid:

func to24Hour(_ text: String) -> String {
    let pattern = #"(\d{1,2}:\d{2})(AM|PM)"#
    guard let regex = try? NSRegularExpression(pattern: pattern) else { return text }

    let input = DateFormatter()
    input.dateFormat = "h:mma"
    input.locale = Locale(identifier: "en_US_POSIX")

    let output = DateFormatter()
    output.dateFormat = "HH:mm"

    let mutable = NSMutableString(string: text)
    let matches = regex.matches(in: text,
                                range: NSRange(location: 0, length: mutable.length))

    for match in matches.reversed() {
        let chunk = mutable.substring(with: match.range)
        guard let date = input.date(from: chunk) else { continue }
        mutable.replaceCharacters(in: match.range, with: output.string(from: date))
    }
    return mutable as String
}

en_US_POSIX matters: with a locale that uses a 24-hour clock, h:mma parsing can misbehave.

5. For repeated privileged work, use a privileged helper

If the app will call pmset often, prompting every time gets old. The supported design is a small launch daemon that runs as root, registered from the app:

import ServiceManagement

let helper = SMAppService.daemon(plistName: "com.example.powerhelper.plist")
try helper.register()

The daemon’s launchd property list ships inside Contents/Library/LaunchDaemons, the helper executable lives in Contents/MacOS, and the app talks to it over XPC. The helper is the only piece that runs pmset as root; the app itself stays a normal user process. This is more setup than the AppleScript route, but it prompts once at registration instead of on every change.

How to prevent it

  • Never launch /usr/bin/sudo from Process — there is no TTY, and the failure message is unhelpful to end users.
  • Keep reads unprivileged; only the write path needs the authentication dialog.
  • Validate day and time strings before they reach the shell, and escape backslashes and quotes on the way into AppleScript.
  • Turn App Sandbox off for a local tool, or move the privileged work into a helper if you need to ship it.
  • Read the pipe before waitUntilExit() in every Process call to avoid buffer deadlocks.
  • Handle error -128 explicitly so a cancelled dialog is not reported as a failure.

FAQ

Why does sudo pmset fail when I run it from Process?

sudo needs a terminal to read the password. A Process has no TTY, so it exits with sudo: a terminal is required to read the password. The -S flag reads from stdin, but that means storing the password in your app, which is worse than the problem.

Does do shell script ... with administrator privileges work in a sandboxed app?

No. The sandbox blocks the privileged shell, and NSAppleScript cannot hand off to SecurityAgent. Disable App Sandbox for a locally distributed build, or move the command into a privileged helper registered with SMAppService.

Can I avoid the password dialog on every change?

Not reliably with the AppleScript approach — treat each call as a possible prompt. Batch related changes into a single pmset repeat invocation, or install a privileged helper so authorisation happens once at registration time.

Sources & credits

Parts of this guide are adapted from content licensed under CC BY-SA 4.0; this article is shared under the same license.