2

I have Home Assistant Core (a Python server) running as a LaunchDaemon on an OSX 11.6 (Big Sur) Mac Mini. I am trying to build a plugin for it that directly accesses a camera attached to the machine. This requires OSX Camera permissions.

Unfortunately there is no way to add an arbitrary binary (e.g. python from the server's virtualenv) to Camera permissions; there is no + icon as with other permissions. When I run my code from a terminal I get the camera prompt, which adds Terminal.app (or iTerm2.app, or sshd-keygen-wrapper) to Camera permissions, and everything works. But since none of these is the launchd root process, it fails when running under the Home Assistant daemon.

I found this question whose accepted answer suggests wrapping an Automator app around the binary:

Running python script in Mac OSX launchd permission issue

I created the app, and when I use /usr/bin/open -a to run it from a terminal, I get the Camera permissions prompt and the .app is added to the Camera permissions list, exactly as desired. However, when I then modify the LaunchDaemon .plist to run (via ProgramArguments) /usr/bin/open -a /opt/homeassistant/bin/hass.app I get this error:

The application /opt/homeassistant/bin/hass.app cannot be opened for an unexpected reason, error=Error Domain=NSOSStatusErrorDomain Code=-10826 "kLSNoLaunchPermissionErr: User doesn't have permission to launch the app (managed networks)" UserInfo={_LSFunction=_LSLaunchWithRunningboard, _LSLine=2488, NSUnderlyingError=0x126309f40 {Error Domain=RBSRequestErrorDomain Code=5 "Launch failed." UserInfo={NSLocalizedFailureReason=Launch failed., NSUnderlyingError=0x12630b350 {Error Domain=OSLaunchdErrorDomain Code=125 "Domain does not support specified action" UserInfo={NSLocalizedFailureReason=Domain does not support specified action}}}}}

I verified that hass.app and everything within it is owned by the LaunchDaemon's UserName and GroupName, homeassistant:homeassistant, and that its Contents/MacOS/Automator Application Stub has +x. I tried giving the app Full Disk Access. I don't see anything useful in the system.log; just that the daemon is crash-looping.

I found questions about similar permissions issues whose answers suggested re-signing the app, removing quarantine xattrs, etc. but that's not the issue here, since it runs just fine from the terminal.

What is causing this permissions error, and how can I resolve it?

Damini Suthar
  • 1,470
  • 2
  • 14
  • 43
DNS
  • 37,249
  • 18
  • 95
  • 132
  • I see that there's a vote to close, maybe because this seems like a question for apple.stackexchange? I posted it here because the solution seems likely to involve changes to the Automator app (app development) or launchd config, whose tag has hundreds of other questions. – DNS Feb 25 '22 at 05:50
  • I am not completely sure from where your error stems, but I'd suggest reading through [this related q&a](https://stackoverflow.com/questions/61114738/). It seems to me that either (a) your venv python is not codesigned at all (test with `codesign -vvv $(which python)` *inside* the venv) or rather (b) the code that actually accesses the camera has a different code signature, hence your code can not launch it, at least without removing the "other" signature. Be aware that [Hardened Runtime](https://developer.apple.com/documentation/security/hardened_runtime?language=objc) exists for a reason. – Asmus Mar 01 '22 at 14:18
  • Possibly related: [this unsolved question](https://stackoverflow.com/questions/54780874/) – Asmus Mar 01 '22 at 14:26
  • @Asmus The error message mentions hass.app, though, so unless that message is propagated from further down it implies that the app hasn't even got to the point of running the Python script. I suppose I could confirm this by creating an empty app that does nothing at all; let me try that. – DNS Mar 02 '22 at 04:54
  • An observation: this must somehow be possible, because IIUC the SSH server runs as a LaunchDaemon, and `sshd-keygen-wrapper` *is* able to acquire Camera permissions, and it's possible to successfully access the camera via SSH. So one work-around that I considered was to create a LaunchDaemon that SSHes to localhost and then starts the server, but it's just too messy to set up password-less login and keep a connection open like that. – DNS Mar 02 '22 at 04:59
  • I confirmed that this isn't related to the camera permission. An Automator app that simply runs the shell command `echo 'hello world'` fails in exactly the same way. – DNS Mar 03 '22 at 05:31
  • hm, `otool -L '/Contents/MacOS/Automator Application Stub'` shows a dependency on AppKit which is not daemon-safe, maybe that's the problem? – Stephan Schlecht Mar 03 '22 at 11:47

2 Answers2

1

While this probably isn't the answer you wanted to hear, it appears that accessing the camera through a LaunchDaemon is actually not possible anymore, at least according to this answer given by Apple staff member "eskimo" over at Apple's own developer forums:

I’m sorry to so that there’s no supported way to make this work, because camera access is based on an array of frameworks that are not daemon safe.

Note that since I don't know precisely how Apple is prohibiting camera access, it might still be possible to run external cameras through external frameworks within a LaunchDaemon - the post above is in repsonse to accessing the internal camera.

I fear you'll likely not get a better answer here, at least without some example to work with (i.e. some code this community could try to reproduce your error with).

Asmus
  • 5,117
  • 1
  • 16
  • 21
1

The now somewhat older and no longer updated Technical Note TN2083 Daemons and Agents states:

Apple's solution to this problem is layering: we divide our frameworks into layers and decide for each layer whether that layer supports operations in the global bootstrap namespace. The basic rule is that everything in CoreServices and below (including System, IOKit, System Configuration, Foundation) should work in any bootstrap namespace (these are daemon-safe frameworks), while everything above CoreServices (including ApplicationServices, Carbon, and AppKit) requires a GUI-per-session bootstrap namespace.

Which lines up with Asmus' interesting find from Apple's own developer forums regarding support for non-daemon-safe frameworks.

The appropriately named section Living Dangerously also describes that when using frameworks that are not daemon-safe, it is entirely possible that certain things may or may not work to some degree.

In particular, the following statements are very revealing:

  • Some frameworks fail at load time. That is, the framework has an initialization routine that assumes it's running in a per-session context and fails if it's not. This problem is rare on current systems because most frameworks are initialized lazily. If the framework doesn't fail at load time, you may still encounter problems as you call various routines from that framework.
  • A routine might fail benignly. For example, the routine might fail silently, or print a message to stderr, or perhaps return a meaningful error code.
  • A routine might fail hostilely. For example, it's quite common for the GUI frameworks to call abort if they're run by a daemon!
  • A routine might work even though its framework is not officially daemon-safe.
  • A routine might behave differently depending on its input parameters. For example, an image decompression routine might work for some types of images and fail for others. The behavior of any given framework, and the routines within that framework, can change from release-to-release.

Also it says:

The upshot of this is that, if your daemon links with a framework that's not daemon-safe, you can't predict how it will behave in general. It might work on your machine, but fail on some other user's machine, or fail on a future system release, or fail for different input data. You are living dangerously!

Depending on the exact requirements, using a LaunchAgent might be an alternative. The downside, of course, is that LaunchAgents are only invoked when the user logs into a graphical session. As one can test for oneself in the following small example, accessing the camera is no problem, as expected.

Launch Agent

An experiment with a small, self-contained example without a storyboard, even using AppKit (for image conversion) in addition to AVFoundation, and taking and saving a photo as a .png, might look like this:

Camera.swift

import AVFoundation
import AppKit

enum CameraError: Error {
    case notFound
    case noVideInput
    case noValidImageData
    case fetchImage
    case imageRepresentation
    case pngCreation
}

class Camera: NSObject, AVCapturePhotoCaptureDelegate {
    private var completion: (Result<Void, Error>) -> Void = { _ in }
    private var targetURL: URL?
    private var cameraDevice: AVCaptureDevice?
    private var captureSession: AVCaptureSession?
    private var photoOutput: AVCapturePhotoOutput?
    
    func prepare() -> Result<Void, Error> {
        let deviceDiscoverySession = AVCaptureDevice.DiscoverySession(deviceTypes: [AVCaptureDevice.DeviceType.builtInWideAngleCamera],
                                                                      mediaType: AVMediaType.video,
                                                                      position: AVCaptureDevice.Position.front)
        guard let cameraDevice = deviceDiscoverySession.devices.first else {
            return .failure(CameraError.notFound)
        }
        self.cameraDevice = cameraDevice
        guard let videoInput = try? AVCaptureDeviceInput(device: cameraDevice) else {
            return .failure(CameraError.notFound)
        }
        let captureSession = AVCaptureSession()
        self.captureSession = captureSession
        captureSession.sessionPreset = AVCaptureSession.Preset.photo
        captureSession.beginConfiguration()
        if captureSession.canAddInput(videoInput) {
            captureSession.addInput(videoInput)
        }
        let photoOutput = AVCapturePhotoOutput()
        self.photoOutput = photoOutput
        if captureSession.canAddOutput(photoOutput) {
            captureSession.addOutput(photoOutput)
        }
        _ = AVCaptureConnection(inputPorts: videoInput.ports, output: photoOutput)
        captureSession.commitConfiguration()
        captureSession.startRunning()
        return .success(Void())
    }
    
    func savePhoto(after: TimeInterval, at targetURL: URL, completion: @escaping (Result<Void, Error>) -> Void) {
        self.completion = completion
        self.targetURL = targetURL
        DispatchQueue.main.asyncAfter(deadline: .now() + after) {
            self.photoOutput?.capturePhoto(with: AVCapturePhotoSettings(), delegate: self)
        }
    }
    
    // MARK: - AVCapturePhotoCaptureDelegate
    
    internal func photoOutput(_ output: AVCapturePhotoOutput,
                              didFinishProcessingPhoto photo: AVCapturePhoto,
                              error: Error?) {
        if let error = error {
            completion(.failure(error))
            return
        }
        guard let captureSession = captureSession,
              let imageData = photo.fileDataRepresentation(),
              let targetURL = targetURL else {
                  completion(.failure(CameraError.fetchImage))
                  return
              }
        captureSession.stopRunning()
        completion(Self.writePNG(imageData, to: targetURL))
    }
    
    // MARK: - Private
    
    private static func writePNG(_ imageData: Data, to url: URL) -> Result<Void, Error> {
        guard let image = NSImage(data: imageData) else { return .failure(CameraError.noValidImageData) }
        guard let bitmapImageRep = image.representations[0] as? NSBitmapImageRep else { return .failure(CameraError.imageRepresentation) }
        guard let pngData = bitmapImageRep.representation(using: .png, properties: [:]) else { return .failure(CameraError.pngCreation) }
        do {
            try pngData.write(to: url)
        } catch {
            return .failure(error as Error)
        }
        return .success(Void())
    }
    
}

AppDelegate.swift

import AppKit

class AppDelegate: NSObject, NSApplicationDelegate {

    private let camera = Camera()

    func applicationDidFinishLaunching(_ aNotification: Notification) {
        let imageUrl = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("test.png")
        switch camera.prepare() {
        case .success():
            self.camera.savePhoto(after: 1, at: imageUrl, completion: { result in
                switch result {
                case .success():
                    NSLog("success")
                    exit(0)
                case .failure(let error):
                    NSLog("error: \(error)")
                    exit(1)
                }
            })
        case .failure(let error):
            NSLog("error: \(error)")
            exit(1);
        }
    }

    func applicationWillTerminate(_ aNotification: Notification) {
    }

    func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool {
        return true
    }

}

main.swift:

import AppKit

let delegate = AppDelegate()
NSApplication.shared.delegate = delegate
_ = NSApplicationMain(CommandLine.argc, CommandLine.unsafeArgv)

In addition to the com.apple.security.device.camera permission, LSUIElement in Info.plist is set to true and a NSCameraUsageDescription key with text added.

This is certainly not a productively applicable generic solution, but should at least allow experiments with lower overall complexity.

com.software7.camera.plist in ~/Library/LaunchAgents:

Here the app is triggered every 30 seconds:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.software7.camera</string>
    <key>ProgramArguments</key>
    <array>
<string>/Users/stephan/test/WebcamPhoto.app/Contents/MacOS/WebcamPhoto</string>
    </array>
    <key>StartInterval</key>
    <integer>30</integer>
</dict>
</plist>

Assuming id -u for the target user is 503, the setup is done with:

launchctl bootstrap gui/503 ~/Library/LaunchAgents/com.software7.camera.plist

and could be removed again with

launchctl bootout gui/503 ~/Library/LaunchAgents/com.software7.camera.plist

Splitting into Daemon and Agent component

If you write such a LaunchAgent, you can link it to any framework, as shown in the example above with AppKit.

There is also a good suggestion in Apple's Technical Note that it is possible to split the code if it is not possible to do without a daemon completely. Apple writes about this:

If you're writing a daemon and you must link with a framework that's not daemon-safe, consider splitting your code into a daemon component and an agent component. If that's not possible, be aware of the potential issues associated with linking a daemon to unsafe frameworks ...

Some Tests

I would not consider the following as proof, rather as a strong indication that applications using AppKit should not be used for LaunchDemons as recommended by Apple:

A test was run with 4 variants, all of which write an entry to the same log file named /tmp/daemonlog.txt when they are called, and then exit:

  1. Application with AppKit
  2. Application without AppKit
  3. Shell script called directly from launchd
  4. Shell script called by automator application

In /Library/LaunchDaemons the variants were set up with startup intervals between 25 and 35 seconds.

Observation: As long as the user is logged in, all 4 variants write out their messages periodically according to their specified start interval. As soon as the user logs out, the log entries should continue to be created. However, only variants 2) and 3), which do not use AppKit, do this. Variants 1) and 4) no longer work. In the Activity Monitor you can see that both applications are hanging, but actually they are programmed to quit immediately after writing the log output. When both applications are terminated manually, they start working normally again, but only as long as the user remains logged in.

This can be easily seen by the yellow highlighted area (=user logged out) in the log file:

log entries

Test Source Codes

Writer.swift

Writer.swift is used by 1) and 2):

import Foundation


extension String {

    func append(to url: URL) throws {
        let line = self + "\n"
        if let fh = FileHandle(forWritingAtPath: url.path) {
            fh.seekToEndOfFile()
            fh.write(line.data(using: .utf8)!)
            fh.closeFile()
        }
        else {
            try line.write(to: url, atomically: true, encoding: .utf8)
        }
    }

}


extension Date {

    func logDate() -> String {
        let dateFormatter = DateFormatter()
        dateFormatter.dateFormat = "EEE MMM dd HH:mm:ss z yyyy"
        return dateFormatter.string(from: self)
    }

}

AppDelegate.swift

The application in case 1 uses an AppDelegate. The corresponding main.swift that uses this AppDelegate looks like the one shown above and is not repeated here:

import AppKit

class AppDelegate: NSObject, NSApplicationDelegate {

    func applicationDidFinishLaunching(_ aNotification: Notification) {
        let url = URL(string: "file:///tmp/daemonlog.txt")!
        do {
            try "\(Date().logDate()): AppKit application called from launchd".append(to: url)
        } catch {
            print("error: \(error)")
        }
        exit(0)
    }

    func applicationWillTerminate(_ aNotification: Notification) {
    }

    func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool {
        return true
    }

}

main.swift

The application without AppKit is case 2 and looks like this:

import Foundation

let url = URL(string: "file:///tmp/daemonlog.txt")!
do {
    try "\(Date().logDate()): application without AppKit called from launchd".append(to: url)
} catch {
    print("error: \(error)")
}
exit(0)

daemonscript.sh

For case 3 daemonscript.sh is called directly by launchd.

#!/bin/sh
echo "`date`: shell script directly called from launchd"  >> /tmp/daemonlog.txt

Automator Config

In Automator, a Run Shell Script action is used, which looks like this:

echo "`date`: run shell script via Automator app"  >> /tmp/daemonlog.txt
Stephan Schlecht
  • 26,556
  • 1
  • 33
  • 47
  • 1
    In order to accept this answer I think I'd need to see an example of a trivial `.app` that *doesn't* link against a disallowed framework, and works as a LaunchDaemon. That would prove that it's a framework causing the problem. In the absence of that proof, this is still definitely the most well-researched and plausible answer, and even includes code. So I believe this at least deserves the bounty; thank you! – DNS Mar 04 '22 at 05:44