为什么 Automator 应用程序在作为 LaunchDaemon 运行时会出现权限错误?

发布于 2025-01-09 19:32:54 字数 1998 浏览 0 评论 0原文

我有 Home Assistant Core(Python 服务器)在 OSX 11.6< 上作为 LaunchDaemon 运行/code> (大苏尔)Mac Mini。我正在尝试为其构建一个插件,以直接访问连接到机器的相机。这需要 OSX 相机权限。

不幸的是,无法将任意二进制文件(例如服务器 virtualenv 中的 python)添加到相机权限;与其他权限一样,没有 + 图标。当我从终端运行代码时,我收到相机提示,它将 Terminal.app (或 iTerm2.app 或 sshd-keygen-wrapper)添加到相机权限中,一切正常。但由于这些都不是 launchd 根进程,因此在 Home Assistant 守护进程下运行时会失败。

我发现这个问题,其接受的答案建议围绕二进制文件包装一个 Automator 应用程序:

在 Mac OSX launchd 权限问题中运行 python 脚本

我创建了该应用程序,当我使用 /usr/bin/open -a 从终端运行它时,我得到了相机权限提示和.app 已完全按照需要添加到相机权限列表中。但是,当我修改 LaunchDaemon .plist 以运行(通过 ProgramArguments/usr/bin/open -a /opt/homeassistant/bin/hass 时。 app 我收到此错误:

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}}}}}

我验证了 hass.app 及其中的所有内容均归 LaunchDaemon 的 UserNameGroupName 所有, homeassistant:homeassistant,并且其 Contents/MacOS/Automator Application Stub 具有 +x。我尝试授予应用程序完全磁盘访问权限。我在 system.log 中没有看到任何有用的内容;只是守护进程正在崩溃循环。

我发现了有关类似权限问题的问题,其答案建议重新签名应用程序、删除隔离 xattrs 等,但这不是这里的问题,因为它从终端运行得很好。

是什么导致了此权限错误?如何解决?

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?

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。

评论(2

不打扰别人 2025-01-16 19:32:54

虽然这可能不是您想听到的答案,但看来通过 LaunchDaemon 访问相机实际上不再可能了,至少根据 Apple 员工“eskimo”在 Apple 自己的开发者论坛上给出的答案:

很抱歉,没有受支持的方法来完成这项工作,因为相机访问基于一系列非守护程序安全的框架。

请注意,由于我不确切知道 Apple 如何禁止相机访问,因此仍然可以通过 LaunchDaemon 中的外部框架运行外部相机 - 上面的帖子是响应访问内部摄像头。

我担心您可能不会在这里得到更好的答案,至少没有一些可以使用的示例(即该社区可以尝试重现您的错误的一些代码)。

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).

茶花眉 2025-01-16 19:32:54

现在有些旧且不再更新技术说明 TN2083 守护进程代理人声明:

Apple 针对这个问题的解决方案是分层:我们将框架划分为多个层,并为每个层决定该层是否支持全局引导命名空间中的操作。基本规则是 CoreServices 及以下的所有内容(包括 System、IOKit、System Configuration、Foundation)都应该在任何引导命名空间(这些是守护进程安全框架)中工作,而 CoreServices 之上的所有内容(包括 ApplicationServices、Carbon 和 AppKit)都需要每个会话的 GUI 引导命名空间。

这与 Asmus 在 Apple 自己的开发者论坛中关于对非守护程序安全框架的支持的有趣发现相符。

适当命名的部分

特别是以下言论非常具有启发性:

  • 某些框架在加载时失败。也就是说,框架有一个初始化例程,假设它在每个会话上下文中运行,如果不是,则失败。
    这个问题在当前系统上很少见,因为大多数框架都是延迟初始化的。
    如果框架在加载时没有失败,当您从该框架调用各种例程时,您可能仍然会遇到问题。
  • 例程可能会正常失败。例如,例程可能会默默地失败,或者向 stderr 打印一条消息,或者可能返回一个有意义的错误代码。
  • 例程可能会恶意失败。例如,如果 GUI 框架由守护进程运行,则调用 abort 是很常见的!
  • 即使其框架不是正式的守护程序安全例程,也可能会正常工作。
  • 例程的行为可能会有所不同,具体取决于其输入参数。例如,图像解压缩例程可能对某些类型的图像有效,而对其他类型的图像则失败。
    任何给定框架的行为以及该框架内的例程都可能因版本而异。

它还说:

这样做的结果是,如果您的守护程序与非守护程序安全的框架链接,您将无法预测它的一般行为。它可能在您的计算机上工作,但在其他用户的计算机上失败,或者在未来的系统版本上失败,或者对于不同的输入数据失败。你的生活很危险!

根据具体要求,使用 LaunchAgent 可能是一种替代方案。当然,缺点是只有当用户登录到图形会话时才会调用 LaunchAgents。大家可以通过下面的小例子自行测试一下,访问摄像头是没有问题的,正如预期的那样。

启动代理

一个没有故事板的小型独立示例的实验,甚至除了 AVFoundation 之外还使用 AppKit(用于图像转换),并将照片保存为 .png,可能如下所示:

相机。 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
    }

}

ma​​in.swift

import AppKit

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

除了 com.apple.security.device.camera 权限之外,LSUIElementInfo.plist 设置为 true 并添加了一个 NSCameraUsageDescription 键并添加了文本。

这当然不是一个有效适用的通用解决方案,但至少应该允许以较低的总体复杂性进行实验。

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

这里应用程序每 30 秒触发一次:

<?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>

假设目标用户的 id -u 为 503,则设置完成:

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

并且可以通过

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

拆分为守护进程和代理组件

再次删除如果您编写这样的LaunchAgent,您可以将其链接到任何框架,如上面的示例所示应用程序套件。

还有一个

如果您正在编写守护程序并且必须链接到非守护程序安全的框架,请考虑将代码拆分为守护程序组件和代理组件。如果这是不可能的,请注意与将守护程序链接到不安全框架相关的潜在问题......

一些测试

我不会将以下内容视为证据,而是强烈表明使用 AppKit 的应用程序应该不能按照 Apple 的建议用于 LaunchDemons:

使用 4 个变体运行测试,所有变体在调用时都会向名为 /tmp/daemonlog.txt 的同一日志文件写入一个条目,然后exit:

  1. 带有AppKit
  2. 应用程序 的应用程序没有 AppKit
  3. 直接从 launchd 调用的 Shell 脚本
  4. 由自动化应用程序调用的 Shell 脚本

/Library/LaunchDaemons 中,变体设置为启动间隔在 25 到 35 秒之间。

观察:
只要用户登录,所有 4 个变体就会根据指定的开始间隔定期写出消息。用户注销后,应继续创建日志条目。但是,只有不使用 AppKit 的变体 2) 和 3) 才会执行此操作。变体 1) 和 4) 不再起作用。在活动监视器中,您可以看到两个应用程序都挂起,但实际上它们被编程为在写入日志输出后立即退出。当两个应用程序都手动终止时,它们会再次开始正常工作,但前提是用户保持登录状态。

这可以通过日志文件中的黄色突出显示区域(=用户已注销)轻松看出:

日志条目

测试源代码

Writer.swift

Writer.swift 由 1) 和 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

案例 1 中的应用程序使用一个AppDelegate。使用此 AppDelegate 的相应 main.swift 看起来像上面所示的,这里不再重复:

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
    }

}

ma​​in.swift

没有 AppKit 的应用程序是情况 2,看起来像这样:

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< /strong>

对于情况 3,daemonscript.shlaunchd 直接调用。

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

Automator 配置

在 Automator 中,使用 Run Shell Script 操作,如下所示:

echo "`date`: run shell script via Automator app"  >> /tmp/daemonlog.txt

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
~没有更多了~
我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
原文