2

我在(Big Sur)Mac Mini上作为 LaunchDaemon 运行Home Assistant Core(Python 服务器)。OSX 11.6我正在尝试为其构建一个插件,以直接访问连接到机器的摄像头。这需要 OSX 相机权限。

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

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

在 Mac OSX 中运行 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 的UserNameand拥有GroupNamehomeassistant:homeassistant并且它Contents/MacOS/Automator Application Stub+x. 我尝试为应用程序提供全盘访问权限。我在 system.log 中看不到任何有用的东西;只是守护进程正在崩溃循环。

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

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

4

2 回答 2

1

现在有些旧且不再更新的技术说明 TN2083守护进程和代理状态:

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

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

适当命名的危险生活部分还描述了当使用不是守护进程安全的框架时,某些事情完全有可能在某种程度上可能工作或可能不工作。

特别是,以下陈述非常具有启发性:

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

它还说:

这样做的结果是,如果你的守护进程链接到一个不是守护进程安全的框架,你就无法预测它的一般行为方式。它可能在您的机器上工作,但在其他用户的机器上失败,或者在未来的系统版本上失败,或者因不同的输入数据而失败。你活得很危险!

根据具体要求,使用 LaunchAgent 可能是一种替代方法。当然,缺点是 LaunchAgent 仅在用户登录到图形会话时才被调用。在下面的小例子中可以自己测试,访问相机没有问题,正如预期的那样。

启动代理

一个没有故事板的小型、独立示例的实验,即使使用 AppKit(用于图像转换)以及 AVFoundation,并将照片保存为 .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
    }

}

main.swift

import AppKit

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

除了com.apple.security.device.camera权限之外,还设置了LSUIElementin并添加了带有文本的键。Info.plisttrueNSCameraUsageDescription

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

~/Library/LaunchAgents 中的 com.software7.camera.plist

这里应用程序每 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

拆分为 Daemon 和 Agent 组件

如果您编写这样的 LaunchAgent,您可以将其链接到任何框架,如上面使用 AppKit 的示例所示。

Apple 的技术说明中也有一个很好的建议,如果完全没有守护程序就无法完成,则可以拆分代码。苹果对此写道:

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

一些测试

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

使用 4 个变体运行测试,所有变体都在调用它们时将条目写入同一个日志文件/tmp/daemonlog.txt,然后退出:

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

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

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

日志文件中的黄色突出显示区域(=用户已注销)可以很容易地看到这一点:

日志条目

测试源代码

作家.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
    }

}

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

守护进程脚本.sh

对于案例 3 daemonscript.sh,由 直接调用launchd

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

自动化配置

在 Automator 中,Run Shell Script使用了一个动作,如下所示:

echo "`date`: run shell script via Automator app"  >> /tmp/daemonlog.txt
于 2022-03-02T17:15:28.910 回答
1

虽然这可能不是您想听到的答案,但似乎通过 LaunchDaemon 访问相机实际上已经不可能了,至少根据Apple 工作人员“爱斯基摩人”在 Apple 自己的开发者论坛上给出的答案:

很抱歉,没有支持的方式来完成这项工作,因为相机访问是基于一系列不安全的框架。

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

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

于 2022-03-02T10:10:17.147 回答