Files
Adobe-Downloader/Adobe Downloader/HelperManager/SMJobBlessHelperManager.swift

799 lines
30 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// SMJobBlessHelperManager.swift
// Adobe Downloader
//
// Created by X1a0He on 2025/08/17.
//
import AppKit
import Cocoa
import ServiceManagement
@objc enum CommandType: Int {
case install
case uninstall
case moveFile
case setPermissions
case shellCommand
}
@objc protocol HelperToolProtocol {
@objc(executeCommand:path1:path2:permissions:withReply:)
func executeCommand(type: CommandType, path1: String, path2: String, permissions: Int, withReply reply: @escaping (String) -> Void)
func getInstallationOutput(withReply reply: @escaping (String) -> Void)
}
@objcMembers
class SMJobBlessHelperManager: NSObject, ObservableObject {
enum HelperStatus {
case installed
case noFound
case needUpdate
}
static let shared = SMJobBlessHelperManager()
static let machServiceName = "com.x1a0he.macOS.Adobe-Downloader.helper"
var connectionSuccessBlock: (() -> Void)?
private var useLegacyInstall = false
private var connection: NSXPCConnection?
private var isInitializing = false
private var shouldAutoReconnect = true
@Published private(set) var connectionState: ConnectionState = .disconnected {
didSet {
if oldValue != connectionState {
if connectionState == .disconnected {
connection?.invalidate()
connection = nil
}
}
}
}
enum ConnectionState {
case connected
case disconnected
case connecting
var description: String {
switch self {
case .connected:
return String(localized: "已连接")
case .disconnected:
return String(localized: "未连接")
case .connecting:
return String(localized: "正在连接")
}
}
}
private let connectionQueue = DispatchQueue(label: "com.x1a0he.helper.connection")
override init() {
super.init()
initAuthorizationRef()
NotificationCenter.default.addObserver(self,
selector: #selector(handleConnectionInvalidation),
name: .NSXPCConnectionInvalid,
object: nil)
}
@objc private func handleConnectionInvalidation() {
DispatchQueue.main.async { [weak self] in
self?.connectionState = .disconnected
self?.connection?.invalidate()
self?.connection = nil
}
}
private func setupAutoReconnect() {
Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in
guard let self = self else { return }
if self.connectionState == .disconnected && self.shouldAutoReconnect {
_ = self.connectToHelper()
}
}
}
func checkInstall() {
if isInitializing {
return
}
if let currentBuild = Bundle.main.infoDictionary?["CFBundleVersion"] as? String,
let installedBuild = UserDefaults.standard.string(forKey: "InstalledHelperBuild") {
if currentBuild != installedBuild {
notifyInstall()
return
}
}
getHelperStatus { [weak self] status in
guard let self = self else { return }
switch status {
case .noFound:
if Thread.isMainThread {
self.notifyInstall()
} else {
DispatchQueue.main.async {
self.notifyInstall()
}
}
case .needUpdate:
if Thread.isMainThread {
self.notifyInstall()
} else {
DispatchQueue.main.async {
self.notifyInstall()
}
}
case .installed:
if self.connectionState != .connected {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
if let connection = self.connectToHelper() {
self.connection = connection
self.connectionState = .connected
self.connectionSuccessBlock?()
print("连接到已安装的Helper成功")
} else {
print("Helper已安装但连接失败")
}
}
} else {
self.connectionSuccessBlock?()
}
}
}
}
private func initAuthorizationRef() {
var authRef: AuthorizationRef?
let status = AuthorizationCreate(nil, nil, AuthorizationFlags(), &authRef)
if status != OSStatus(errAuthorizationSuccess) {
return
}
}
private func installHelperDaemon() -> DaemonInstallResult {
var authRef: AuthorizationRef?
var authStatus = AuthorizationCreate(nil, nil, [], &authRef)
guard authStatus == errAuthorizationSuccess else {
return .authorizationFail
}
var authItem = AuthorizationItem(name: (kSMRightBlessPrivilegedHelper as NSString).utf8String!, valueLength: 0, value: nil, flags: 0)
var authRights = withUnsafeMutablePointer(to: &authItem) { pointer in
AuthorizationRights(count: 1, items: pointer)
}
let flags: AuthorizationFlags = [[], .interactionAllowed, .extendRights, .preAuthorize]
authStatus = AuthorizationCreate(&authRights, nil, flags, &authRef)
defer {
if let ref = authRef {
AuthorizationFree(ref, [])
}
}
guard authStatus == errAuthorizationSuccess else {
return .getAdminFail
}
var error: Unmanaged<CFError>?
if SMJobBless(kSMDomainSystemLaunchd, SMJobBlessHelperManager.machServiceName as CFString, authRef, &error) == false {
if let blessError = error?.takeRetainedValue() {
let nsError = blessError as Error as NSError
print("SMJobBless failed with error: \(blessError)")
print("Error domain: \(nsError.domain)")
print("Error code: \(nsError.code)")
print("Error description: \(nsError.localizedDescription)")
return .blessError(nsError.code)
}
return .blessError(-1)
}
if let currentBuild = Bundle.main.infoDictionary?["CFBundleVersion"] as? String {
UserDefaults.standard.set(currentBuild, forKey: "InstalledHelperBuild")
}
return .success
}
func getHelperStatus(callback: @escaping ((HelperStatus) -> Void)) {
var called = false
let reply: ((HelperStatus) -> Void) = {
status in
if called { return }
called = true
callback(status)
}
let helperURL = Bundle.main.bundleURL.appendingPathComponent("Contents/Library/LaunchServices/" + SMJobBlessHelperManager.machServiceName)
guard
CFBundleCopyInfoDictionaryForURL(helperURL as CFURL) != nil else {
reply(.noFound)
return
}
let helperFileExists = FileManager.default.fileExists(atPath: "/Library/PrivilegedHelperTools/\(SMJobBlessHelperManager.machServiceName)")
if !helperFileExists {
reply(.noFound)
return
}
if let currentBuild = Bundle.main.infoDictionary?["CFBundleVersion"] as? String,
let installedBuild = UserDefaults.standard.string(forKey: "InstalledHelperBuild"),
currentBuild != installedBuild {
reply(.needUpdate)
return
}
reply(.installed)
}
static var getHelperStatus: Bool {
if let currentBuild = Bundle.main.infoDictionary?["CFBundleVersion"] as? String,
let installedBuild = UserDefaults.standard.string(forKey: "InstalledHelperBuild"),
currentBuild != installedBuild {
return false
}
let helperURL = Bundle.main.bundleURL.appendingPathComponent("Contents/Library/LaunchServices/" + machServiceName)
guard CFBundleCopyInfoDictionaryForURL(helperURL as CFURL) != nil else { return false }
return FileManager.default.fileExists(atPath: "/Library/PrivilegedHelperTools/\(machServiceName)")
}
func reinstallHelper(completion: @escaping (Bool, String) -> Void) {
shouldAutoReconnect = false
uninstallHelperViaTerminal { [weak self] success, message in
guard let self = self else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
let result = self.installHelperDaemon()
switch result {
case .success:
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
guard let self = self else { return }
self.shouldAutoReconnect = true
self.tryConnect(retryCount: 3, delay: 1, completion: completion)
}
case .authorizationFail:
self.shouldAutoReconnect = true
completion(false, String(localized: "获取授权失败"))
case .getAdminFail:
self.shouldAutoReconnect = true
completion(false, String(localized: "获取管理员权限失败"))
case .blessError(_):
self.shouldAutoReconnect = true
completion(false, String(localized: "安装失败: \(result.alertContent)"))
}
}
}
}
private func tryConnect(retryCount: Int, delay: TimeInterval = 2.0, completion: @escaping (Bool, String) -> Void) {
struct Static {
static var currentAttempt = 0
}
if retryCount == 3 {
Static.currentAttempt = 0
}
Static.currentAttempt += 1
guard retryCount > 0 else {
completion(false, String(localized: "多次尝试连接失败"))
return
}
guard let connection = connectToHelper() else {
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
self?.tryConnect(retryCount: retryCount - 1, delay: delay * 1, completion: completion)
}
return
}
guard let helper = connection.remoteObjectProxy as? HelperToolProtocol else {
completion(false, String(localized: "无法获取Helper代理"))
return
}
helper.executeCommand(type: .shellCommand, path1: "whoami", path2: "", permissions: 0) { result in
let trimmedResult = result.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmedResult == "root" {
completion(true, String(localized: "Helper 重新安装成功"))
} else if !trimmedResult.isEmpty && !trimmedResult.starts(with: "Error:") {
completion(true, String(localized: "Helper 重新安装成功但权限不是root: \(trimmedResult)"))
} else {
print("Helper验证失败返回结果: \(result)")
completion(false, String(localized: "Helper 安装失败: \(result)"))
}
}
}
func removeInstallHelper(completion: ((Bool) -> Void)? = nil) {
if FileManager.default.fileExists(atPath: "/Library/LaunchDaemons/\(SMJobBlessHelperManager.machServiceName).plist") {
try? FileManager.default.removeItem(atPath: "/Library/LaunchDaemons/\(SMJobBlessHelperManager.machServiceName).plist")
}
if FileManager.default.fileExists(atPath: "/Library/PrivilegedHelperTools/\(SMJobBlessHelperManager.machServiceName)") {
try? FileManager.default.removeItem(atPath: "/Library/PrivilegedHelperTools/\(SMJobBlessHelperManager.machServiceName)")
}
completion?(true)
}
func connectToHelper() -> NSXPCConnection? {
return connectionQueue.sync {
return createConnection()
}
}
private func createConnection() -> NSXPCConnection? {
DispatchQueue.main.async {
self.connectionState = .connecting
}
if let existingConnection = connection {
existingConnection.invalidate()
connection = nil
}
let newConnection = NSXPCConnection(machServiceName: SMJobBlessHelperManager.machServiceName,
options: .privileged)
let interface = NSXPCInterface(with: HelperToolProtocol.self)
interface.setClasses(NSSet(array: [NSString.self, NSNumber.self]) as! Set<AnyHashable>,
for: #selector(HelperToolProtocol.executeCommand(type:path1:path2:permissions:withReply:)),
argumentIndex: 1,
ofReply: false)
newConnection.remoteObjectInterface = interface
newConnection.interruptionHandler = { [weak self] in
DispatchQueue.main.async {
self?.connectionState = .disconnected
self?.connection = nil
}
}
newConnection.invalidationHandler = { [weak self] in
DispatchQueue.main.async {
self?.connectionState = .disconnected
self?.connection = nil
}
}
newConnection.resume()
let semaphore = DispatchSemaphore(value: 0)
var isConnected = false
if let helper = newConnection.remoteObjectProxy as? HelperToolProtocol {
helper.executeCommand(type: .shellCommand, path1: "whoami", path2: "", permissions: 0) { [weak self] result in
let trimmedResult = result.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmedResult == "root" {
isConnected = true
DispatchQueue.main.async {
self?.connection = newConnection
self?.connectionState = .connected
}
} else if !trimmedResult.isEmpty && !trimmedResult.starts(with: "Error:") {
isConnected = true
DispatchQueue.main.async {
self?.connection = newConnection
self?.connectionState = .connected
}
}
semaphore.signal()
}
_ = semaphore.wait(timeout: .now() + 1.0)
}
if !isConnected {
newConnection.invalidate()
DispatchQueue.main.async {
self.connectionState = .disconnected
}
return nil
}
return newConnection
}
func executeCommand(_ command: String, completion: @escaping (String) -> Void) {
do {
let helper = try getHelperProxy()
if command.contains("perl") || command.contains("codesign") || command.contains("xattr") {
helper.executeCommand(type: .shellCommand, path1: command, path2: "", permissions: 0) { [weak self] result in
DispatchQueue.main.async {
if result.starts(with: "Error:") {
self?.connectionState = .disconnected
} else {
self?.connectionState = .connected
}
completion(result)
}
}
return
}
let (type, path1, path2, permissions) = parseCommand(command)
helper.executeCommand(type: type, path1: path1, path2: path2, permissions: permissions) { [weak self] result in
DispatchQueue.main.async {
if result.starts(with: "Error:") {
self?.connectionState = .disconnected
} else {
self?.connectionState = .connected
}
completion(result)
}
}
} catch {
connectionState = .disconnected
completion("Error: \(error.localizedDescription)")
}
}
private func parseCommand(_ command: String) -> (CommandType, String, String, Int) {
let components = command.split(separator: " ", omittingEmptySubsequences: true).map(String.init)
if command.hasPrefix("installer -pkg") {
return (.install, components[2], "", 0)
} else if command.hasPrefix("rm -rf") {
let path = components.dropFirst(2).joined(separator: " ")
return (.uninstall, path, "", 0)
} else if command.hasPrefix("mv") || command.hasPrefix("cp") {
let paths = components.dropFirst(1)
let sourcePath = String(paths.first ?? "")
let destPath = paths.dropFirst().joined(separator: " ")
return (.moveFile, sourcePath, destPath, 0)
} else if command.hasPrefix("chmod") {
return (.setPermissions,
components.dropFirst(2).joined(separator: " "),
"",
Int(components[1]) ?? 0)
}
return (.shellCommand, command, "", 0)
}
func reconnectHelper(completion: @escaping (Bool, String) -> Void) {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.connectionState = .disconnected
self.connection?.invalidate()
self.connection = nil
self.shouldAutoReconnect = true
DispatchQueue.main.asyncAfter(deadline: .now()) {
do {
let helper = try self.getHelperProxy()
helper.executeCommand(type: .shellCommand, path1: "whoami", path2: "", permissions: 0) { result in
DispatchQueue.main.async {
let trimmedResult = result.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmedResult == "root" {
self.connectionState = .connected
completion(true, String(localized: "Helper 重新连接成功"))
} else if !trimmedResult.isEmpty && !trimmedResult.starts(with: "Error:") {
self.connectionState = .connected
completion(true, String(localized: "Helper 重新连接成功但权限不是root: \(trimmedResult)"))
} else {
self.connectionState = .disconnected
completion(false, String(localized: "Helper 响应异常: \(result)"))
}
}
}
} catch HelperError.connectionFailed {
completion(false, String(localized: "无法连接到 Helper"))
} catch HelperError.proxyError {
completion(false, String(localized: "无法获取 Helper 代理"))
} catch {
completion(false, String(localized: "连接出现错误: \(error.localizedDescription)"))
}
}
}
}
func executeInstallation(_ command: String, progress: @escaping (String) -> Void) async throws {
let helper: HelperToolProtocol = try connectionQueue.sync {
if let existingConnection = connection,
let proxy = existingConnection.remoteObjectProxy as? HelperToolProtocol {
return proxy
}
guard let newConnection = createConnection() else {
throw HelperError.connectionFailed
}
connection = newConnection
guard let proxy = newConnection.remoteObjectProxy as? HelperToolProtocol else {
throw HelperError.proxyError
}
return proxy
}
let (type, path1, path2, permissions) = parseCommand(command)
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
helper.executeCommand(type: type, path1: path1, path2: path2, permissions: permissions) { result in
if result == "Started" || result == "Success" {
continuation.resume()
} else {
continuation.resume(throwing: HelperError.installationFailed(result))
}
}
}
while true {
let output = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<String, Error>) in
helper.getInstallationOutput { result in
continuation.resume(returning: result)
}
}
if !output.isEmpty {
progress(output)
}
if output.contains("Exit Code:") || output.range(of: "Progress: \\d+/\\d+", options: .regularExpression) != nil {
if output.range(of: "Progress: \\d+/\\d+", options: .regularExpression) != nil {
progress("Exit Code: 0")
}
break
}
try await Task.sleep(nanoseconds: 100_000_000)
}
}
func forceCleanAndReinstallHelper(completion: @escaping (Bool, String) -> Void) {
uninstallHelperViaTerminal { [weak self] success, message in
guard let self = self else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
UserDefaults.standard.removeObject(forKey: "InstalledHelperBuild")
self.isInitializing = false
self.shouldAutoReconnect = true
let result = self.installHelperDaemon()
switch result {
case .success:
completion(true, String(localized: "Helper 重新安装成功"))
case .authorizationFail:
completion(false, String(localized: "获取授权失败"))
case .getAdminFail:
completion(false, String(localized: "获取管理员权限失败"))
case .blessError(let code):
completion(false, String(localized: "安装失败: \(result.alertContent)"))
}
}
}
}
func disconnectHelper() {
connectionQueue.sync {
shouldAutoReconnect = false
if let existingConnection = connection {
existingConnection.invalidate()
}
connection = nil
connectionState = .disconnected
}
}
func uninstallHelperViaTerminal(completion: @escaping (Bool, String) -> Void) {
disconnectHelper()
let script = """
#!/bin/bash
sudo /bin/launchctl unload /Library/LaunchDaemons/\(SMJobBlessHelperManager.machServiceName).plist
sudo /bin/rm -f /Library/LaunchDaemons/\(SMJobBlessHelperManager.machServiceName).plist
sudo /bin/rm -f /Library/PrivilegedHelperTools/\(SMJobBlessHelperManager.machServiceName)
sudo /usr/bin/killall -u root -9 \(SMJobBlessHelperManager.machServiceName)
exit 0
"""
let tempDir = FileManager.default.temporaryDirectory
let scriptURL = tempDir.appendingPathComponent("uninstall_helper.sh")
do {
try script.write(to: scriptURL, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: scriptURL.path)
let task = Process()
task.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
task.arguments = ["-e", "do shell script \"\(scriptURL.path)\" with administrator privileges"]
let outputPipe = Pipe()
let errorPipe = Pipe()
task.standardOutput = outputPipe
task.standardError = errorPipe
do {
try task.run()
task.waitUntilExit()
if task.terminationStatus == 0 {
UserDefaults.standard.removeObject(forKey: "InstalledHelperBuild")
connectionState = .disconnected
connection = nil
completion(true, String(localized: "Helper 已完全卸载"))
} else {
let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile()
let errorString = String(data: errorData, encoding: .utf8) ?? "未知错误"
completion(false, String(localized: "卸载失败: \(errorString)"))
}
self.shouldAutoReconnect = true
} catch {
self.shouldAutoReconnect = true
completion(false, String(localized: "执行卸载脚本失败: \(error.localizedDescription)"))
}
try? FileManager.default.removeItem(at: scriptURL)
} catch {
self.shouldAutoReconnect = true
completion(false, String(localized: "准备卸载脚本失败: \(error.localizedDescription)"))
}
}
}
extension SMJobBlessHelperManager {
private func notifyInstall() {
guard !isInitializing else { return }
let result = installHelperDaemon()
if case .success = result {
shouldAutoReconnect = true
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
guard let self = self else { return }
if let connection = self.connectToHelper() {
self.connection = connection
self.connectionState = .connected
self.connectionSuccessBlock?()
}
}
return
}
result.alertAction()
let ret = result.shouldRetryLegacyWay()
useLegacyInstall = ret.0
let isCancle = ret.1
if !isCancle, useLegacyInstall {
shouldAutoReconnect = true
} else if isCancle, !useLegacyInstall {
shouldAutoReconnect = true
NSAlert.alert(with: String(localized: "获取管理员授权失败,用户主动取消授权!"))
} else {
shouldAutoReconnect = true
}
}
}
private enum DaemonInstallResult {
case success
case authorizationFail
case getAdminFail
case blessError(Int)
var alertContent: String {
switch self {
case .success:
return ""
case .authorizationFail: return "Failed to create authorization!"
case .getAdminFail: return "The user actively cancels the authorization, Failed to get admin authorization! "
case let .blessError(code):
switch code {
case kSMErrorInternalFailure: return "blessError: kSMErrorInternalFailure"
case kSMErrorInvalidSignature: return "blessError: kSMErrorInvalidSignature"
case kSMErrorAuthorizationFailure: return "blessError: kSMErrorAuthorizationFailure"
case kSMErrorToolNotValid: return "blessError: kSMErrorToolNotValid"
case kSMErrorJobNotFound: return "blessError: kSMErrorJobNotFound"
case kSMErrorServiceUnavailable: return "blessError: kSMErrorServiceUnavailable"
case kSMErrorJobMustBeEnabled: return "Adobe Downloader Helper is disabled by other process. Please run \"sudo launchctl enable system/\(SMJobBlessHelperManager.machServiceName)\" in your terminal. The command has been copied to your pasteboard"
case kSMErrorInvalidPlist: return "blessError: kSMErrorInvalidPlist"
default:
return "bless unknown error:\(code)"
}
}
}
func shouldRetryLegacyWay() -> (Bool, Bool) {
switch self {
case .success: return (false, false)
case let .blessError(code):
switch code {
case kSMErrorJobMustBeEnabled:
return (false, false)
default:
return (true, false)
}
case .authorizationFail:
return (true, false)
case .getAdminFail:
return (false, true)
}
}
func alertAction() {
switch self {
case let .blessError(code):
switch code {
case kSMErrorJobMustBeEnabled:
NSPasteboard.general.clearContents()
NSPasteboard.general.setString("sudo launchctl enable system/\(SMJobBlessHelperManager.machServiceName)", forType: .string)
default:
break
}
default:
break
}
}
}
extension NSAlert {
static func alert(with text: String) {
let alert = NSAlert()
alert.messageText = text
alert.alertStyle = .warning
alert.addButton(withTitle: NSLocalizedString("OK", comment: ""))
alert.runModal()
}
}
enum HelperError: LocalizedError {
case connectionFailed
case proxyError
case authorizationFailed
case installationFailed(String)
var errorDescription: String? {
switch self {
case .connectionFailed:
return String(localized: "无法连接到 Helper")
case .proxyError:
return String(localized: "无法获取 Helper 代理")
case .authorizationFailed:
return String(localized: "获取授权失败")
case .installationFailed(let reason):
return String(localized: "安装失败: \(reason)")
}
}
}
extension SMJobBlessHelperManager {
public func getHelperProxy() throws -> HelperToolProtocol {
if connectionState != .connected {
guard let newConnection = connectToHelper() else {
throw HelperError.connectionFailed
}
connection = newConnection
}
guard let helper = connection?.remoteObjectProxyWithErrorHandler({ [weak self] error in
self?.connectionState = .disconnected
}) as? HelperToolProtocol else {
throw HelperError.proxyError
}
return helper
}
}
extension Notification.Name {
static let NSXPCConnectionInvalid = Notification.Name("NSXPCConnectionInvalidNotification")
}