Install macOS helper from PKG for XPC enroll
Browser enrollment used osascript on every elevate. Ship a signed privileged helper installed at PKG time so probo:// can enroll over XPC with no second admin prompt. Add make install/uninstall/clean for local PKG test loops, and show alerts only on failure. Mirror the Go lint path for the macOS SPM package: Make targets, root configs, and a Linux CI job. Keep checks syntax-only so they do not need a macOS SDK. Format the existing sources so the new gates start clean. Signed-off-by: Ludovic Vielle <ludovic@probo.com>
This commit is contained in:
@@ -1,2 +1,6 @@
|
||||
# SwiftPM
|
||||
.build/
|
||||
.swiftpm/
|
||||
|
||||
# Rendered by build-app.sh from *.tmpl (do not commit)
|
||||
Shared/*.generated.swift
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import Foundation
|
||||
import ProboAgentShared
|
||||
|
||||
public struct EnrollPreflightResult: Decodable {
|
||||
public let server: String
|
||||
public let token: String
|
||||
public let alreadyEnrolled: Bool
|
||||
public let configDir: String
|
||||
}
|
||||
|
||||
public enum EnrollmentFlow {
|
||||
private static let agentExecutablePath = ProboAgentHelperConstants.agentExecutablePath
|
||||
|
||||
public static func runPreflight(rawURL: String) throws -> EnrollPreflightResult {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: agentExecutablePath)
|
||||
process.arguments = ["enroll-url", "--preflight", rawURL]
|
||||
|
||||
let output = Pipe()
|
||||
process.standardOutput = output
|
||||
process.standardError = output
|
||||
|
||||
try process.run()
|
||||
let data = try output.fileHandleForReading.readToEnd() ?? Data()
|
||||
process.waitUntilExit()
|
||||
|
||||
guard process.terminationStatus == 0 else {
|
||||
let message = String(data: data, encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
throw HelperClientError.operationFailed(process.terminationStatus, message)
|
||||
}
|
||||
|
||||
return try JSONDecoder().decode(EnrollPreflightResult.self, from: data)
|
||||
}
|
||||
|
||||
public static func installViaHelper(preflight: EnrollPreflightResult) throws {
|
||||
try HelperClient.shared.install(
|
||||
serverURL: preflight.server,
|
||||
enrollmentToken: preflight.token,
|
||||
configDir: preflight.configDir
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import Foundation
|
||||
import ProboAgentShared
|
||||
|
||||
public enum HelperClientError: LocalizedError {
|
||||
case helperNotInstalled
|
||||
case connectionFailed(String)
|
||||
case operationFailed(Int32, String?)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .helperNotInstalled:
|
||||
return """
|
||||
Privileged helper is not installed. Reinstall the Probo Agent \
|
||||
package, then try enrollment again.
|
||||
"""
|
||||
case .connectionFailed(let message):
|
||||
return "Cannot connect to privileged helper: \(message)"
|
||||
case .operationFailed(let code, let message):
|
||||
if let message, !message.isEmpty {
|
||||
return message
|
||||
}
|
||||
return "Privileged operation failed (exit \(code))."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Once-only sync bridge for an in-flight XPC call. Reply and proxy error
|
||||
/// handlers both finish here so disconnects unblock waiters immediately.
|
||||
private final class XPCCallCompletion: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private let semaphore = DispatchSemaphore(value: 0)
|
||||
private var finished = false
|
||||
private var error: Error?
|
||||
|
||||
func succeed() {
|
||||
complete(nil)
|
||||
}
|
||||
|
||||
func fail(_ error: Error) {
|
||||
complete(error)
|
||||
}
|
||||
|
||||
private func complete(_ error: Error?) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
guard !finished else { return }
|
||||
finished = true
|
||||
self.error = error
|
||||
semaphore.signal()
|
||||
}
|
||||
|
||||
/// Waits for succeed/fail. Throws the stored error, or `timedOut` on timeout.
|
||||
func wait(timeout: TimeInterval, timedOut: @autoclosure () -> Error) throws {
|
||||
if semaphore.wait(timeout: .now() + timeout) == .timedOut {
|
||||
throw timedOut()
|
||||
}
|
||||
if let error {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/// Waits for succeed/fail. Returns `false` on timeout; throws the stored error.
|
||||
func wait(timeout: TimeInterval) throws -> Bool {
|
||||
if semaphore.wait(timeout: .now() + timeout) == .timedOut {
|
||||
return false
|
||||
}
|
||||
if let error {
|
||||
throw error
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
final public class HelperClient {
|
||||
public static let shared = HelperClient()
|
||||
|
||||
/// Serializes public install within this process so privileged helper
|
||||
/// work never overlaps. Does not coordinate across processes.
|
||||
private let operationLock = NSLock()
|
||||
|
||||
private init() {}
|
||||
|
||||
public func install(
|
||||
serverURL: String,
|
||||
enrollmentToken: String,
|
||||
configDir: String = ProboAgentHelperConstants.defaultConfigDir
|
||||
) throws {
|
||||
operationLock.lock()
|
||||
defer { operationLock.unlock() }
|
||||
|
||||
try ensureHelperReady()
|
||||
try withRemoteProxy { proxy, completion in
|
||||
proxy.install(
|
||||
serverURL: serverURL,
|
||||
enrollmentToken: enrollmentToken,
|
||||
configDir: configDir
|
||||
) { exitCode, output in
|
||||
if exitCode != 0 {
|
||||
completion.fail(HelperClientError.operationFailed(exitCode, output))
|
||||
} else {
|
||||
completion.succeed()
|
||||
}
|
||||
}
|
||||
|
||||
// Headroom over probo-agent install's 60s deadline plus local
|
||||
// service/tray setup so we report the command's real outcome.
|
||||
try completion.wait(
|
||||
timeout: 120,
|
||||
timedOut: HelperClientError.connectionFailed(
|
||||
"install timed out waiting for privileged helper"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// The helper is installed by the PKG postinstall (as root). Enrollment
|
||||
/// never calls SMJobBless — no admin prompt on the browser path.
|
||||
private func ensureHelperReady() throws {
|
||||
guard isHelperInstalled() else {
|
||||
throw HelperClientError.helperNotInstalled
|
||||
}
|
||||
|
||||
let installedVersion = try installedHelperVersion()
|
||||
if installedVersion == nil {
|
||||
throw HelperClientError.connectionFailed(
|
||||
"helper is installed but not responding; reinstall the Probo Agent package"
|
||||
)
|
||||
}
|
||||
|
||||
if installedVersion != ProboAgentHelperConstants.helperVersion {
|
||||
NSLog(
|
||||
"probo-agent helper client: version mismatch (installed=%@ expected=%@)",
|
||||
installedVersion ?? "nil",
|
||||
ProboAgentHelperConstants.helperVersion
|
||||
)
|
||||
}
|
||||
|
||||
try verifyHelperResponds()
|
||||
}
|
||||
|
||||
private func isHelperInstalled() -> Bool {
|
||||
FileManager.default.fileExists(
|
||||
atPath: "/Library/PrivilegedHelperTools/\(ProboAgentHelperConstants.helperLabel)"
|
||||
)
|
||||
}
|
||||
|
||||
private func installedHelperVersion() throws -> String? {
|
||||
try withRemoteProxy { proxy, completion in
|
||||
var version: String?
|
||||
|
||||
proxy.getVersion { value in
|
||||
version = value
|
||||
completion.succeed()
|
||||
}
|
||||
|
||||
guard try completion.wait(timeout: 10) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return version
|
||||
}
|
||||
}
|
||||
|
||||
private func verifyHelperResponds() throws {
|
||||
try withRemoteProxy { proxy, completion in
|
||||
var ok = false
|
||||
|
||||
proxy.ping { value in
|
||||
ok = value
|
||||
completion.succeed()
|
||||
}
|
||||
|
||||
try completion.wait(
|
||||
timeout: 10,
|
||||
timedOut: HelperClientError.connectionFailed(
|
||||
"helper did not respond to ping (timeout)")
|
||||
)
|
||||
if !ok {
|
||||
throw HelperClientError.connectionFailed("helper did not respond to ping")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a dedicated XPC connection for the duration of `body`, then
|
||||
/// invalidates it. Callers must finish waiting for replies inside `body`
|
||||
/// so the connection outlives the reply.
|
||||
private func withRemoteProxy<T>(
|
||||
_ body: (ProboAgentHelperProtocol, XPCCallCompletion) throws -> T
|
||||
) throws -> T {
|
||||
let connection = NSXPCConnection(
|
||||
machServiceName: ProboAgentHelperConstants.machServiceName,
|
||||
options: .privileged
|
||||
)
|
||||
connection.remoteObjectInterface = NSXPCInterface(with: ProboAgentHelperProtocol.self)
|
||||
connection.resume()
|
||||
defer { connection.invalidate() }
|
||||
|
||||
let completion = XPCCallCompletion()
|
||||
guard
|
||||
let proxy = connection.remoteObjectProxyWithErrorHandler({ error in
|
||||
NSLog(
|
||||
"probo-agent helper XPC error: %@",
|
||||
error.localizedDescription
|
||||
)
|
||||
completion.fail(
|
||||
HelperClientError.connectionFailed(error.localizedDescription)
|
||||
)
|
||||
}) as? ProboAgentHelperProtocol
|
||||
else {
|
||||
throw HelperClientError.connectionFailed("cannot create remote proxy")
|
||||
}
|
||||
|
||||
return try body(proxy, completion)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import Foundation
|
||||
import ProboAgentShared
|
||||
import os
|
||||
|
||||
final class Helper: NSObject, ProboAgentHelperProtocol, NSXPCListenerDelegate {
|
||||
private static let log = Logger(
|
||||
subsystem: "com.probo.agent.helper",
|
||||
category: "Helper"
|
||||
)
|
||||
|
||||
func getVersion(withReply reply: @escaping (String) -> Void) {
|
||||
reply(ProboAgentHelperConstants.helperVersion)
|
||||
}
|
||||
|
||||
func ping(withReply reply: @escaping (Bool) -> Void) {
|
||||
Self.log.info("ping")
|
||||
reply(true)
|
||||
}
|
||||
|
||||
func install(
|
||||
serverURL: String,
|
||||
enrollmentToken: String,
|
||||
configDir: String,
|
||||
withReply reply: @escaping (Int32, String?) -> Void
|
||||
) {
|
||||
let trimmedServer = serverURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmedToken = enrollmentToken.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let dir = configDir.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
guard !trimmedServer.isEmpty, !trimmedToken.isEmpty else {
|
||||
reply(1, "server URL and enrollment token are required")
|
||||
return
|
||||
}
|
||||
|
||||
// Pass the token via env rather than argv so it does not show up in
|
||||
// process listings (ps / Activity Monitor). Install already accepts
|
||||
// PROBO_ENROLLMENT_TOKEN when --enrollment-token is omitted.
|
||||
var args = [
|
||||
"install",
|
||||
"--server", trimmedServer,
|
||||
]
|
||||
if !dir.isEmpty {
|
||||
args.append(contentsOf: ["--dir", dir])
|
||||
}
|
||||
|
||||
var environment = ProcessInfo.processInfo.environment
|
||||
environment["PROBO_ENROLLMENT_TOKEN"] = trimmedToken
|
||||
|
||||
let result = runAgent(args: args, environment: environment)
|
||||
reply(result.exitCode, result.output)
|
||||
}
|
||||
|
||||
func listener(_ listener: NSXPCListener, shouldAcceptNewConnection connection: NSXPCConnection)
|
||||
-> Bool
|
||||
{
|
||||
guard ClientAuth.accepts(connection: connection) else {
|
||||
Self.log.error(
|
||||
"refused XPC connection pid=\(connection.processIdentifier, privacy: .public)")
|
||||
return false
|
||||
}
|
||||
|
||||
Self.log.info(
|
||||
"accepted XPC connection pid=\(connection.processIdentifier, privacy: .public)")
|
||||
connection.exportedInterface = NSXPCInterface(with: ProboAgentHelperProtocol.self)
|
||||
connection.exportedObject = self
|
||||
connection.resume()
|
||||
return true
|
||||
}
|
||||
|
||||
private struct CommandResult {
|
||||
let exitCode: Int32
|
||||
let output: String?
|
||||
}
|
||||
|
||||
private func runAgent(args: [String], environment: [String: String]? = nil) -> CommandResult {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: ProboAgentHelperConstants.agentExecutablePath)
|
||||
process.arguments = args
|
||||
if let environment {
|
||||
process.environment = environment
|
||||
}
|
||||
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = pipe
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
let data = try pipe.fileHandleForReading.readToEnd() ?? Data()
|
||||
process.waitUntilExit()
|
||||
let text = String(data: data, encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
return CommandResult(
|
||||
exitCode: process.terminationStatus,
|
||||
output: text?.isEmpty == false ? text : nil
|
||||
)
|
||||
} catch {
|
||||
return CommandResult(exitCode: 1, output: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?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>CFBundleIdentifier</key>
|
||||
<string>com.probo.agent.helper</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Probo Agent Helper</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>@@VERSION@@</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>@@VERSION@@</string>
|
||||
<key>SMAuthorizedClients</key>
|
||||
<array>
|
||||
<string>@@CLIENT_DESIGNATED_REQUIREMENT@@</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?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.probo.agent.helper</string>
|
||||
<key>MachServices</key>
|
||||
<dict>
|
||||
<key>com.probo.agent.helper</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>AssociatedBundleIdentifiers</key>
|
||||
<array>
|
||||
<string>com.probo.agent.url-handler</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,13 @@
|
||||
import Foundation
|
||||
import ProboAgentShared
|
||||
import os
|
||||
|
||||
private let log = Logger(subsystem: "com.probo.agent.helper", category: "main")
|
||||
|
||||
let helper = Helper()
|
||||
let listener = NSXPCListener(machServiceName: ProboAgentHelperConstants.machServiceName)
|
||||
listener.delegate = helper
|
||||
listener.resume()
|
||||
log.info("listening on \(ProboAgentHelperConstants.machServiceName, privacy: .public)")
|
||||
|
||||
RunLoop.main.run()
|
||||
@@ -6,7 +6,8 @@
|
||||
|
||||
Placeholders are substituted by build-app.sh:
|
||||
|
||||
@@VERSION@@ agent version, e.g. 0.1.0
|
||||
@@VERSION@@ agent version, e.g. 0.1.0
|
||||
@@HELPER_DESIGNATED_REQUIREMENT@@ codesign requirement for embedded helper
|
||||
-->
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
@@ -15,7 +16,7 @@
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>probo-agent-url-handler</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.getprobo.agent.url-handler</string>
|
||||
<string>com.probo.agent.url-handler</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Probo Agent</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
@@ -41,5 +42,10 @@
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>SMPrivilegedExecutables</key>
|
||||
<dict>
|
||||
<key>com.probo.agent.helper</key>
|
||||
<string>@@HELPER_DESIGNATED_REQUIREMENT@@</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -4,11 +4,43 @@ import PackageDescription
|
||||
let package = Package(
|
||||
name: "probo-agent-url-handler",
|
||||
platforms: [
|
||||
.macOS(.v11),
|
||||
.macOS(.v11)
|
||||
],
|
||||
products: [
|
||||
.library(name: "ProboAgentShared", targets: ["ProboAgentShared"]),
|
||||
.library(name: "HelperClient", targets: ["HelperClient"]),
|
||||
.executable(name: "com.probo.agent.helper", targets: ["com.probo.agent.helper"]),
|
||||
.executable(name: "probo-agent-url-handler", targets: ["probo-agent-url-handler"]),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "ProboAgentShared",
|
||||
path: "Shared",
|
||||
exclude: [
|
||||
"HelperVersion.generated.swift.tmpl",
|
||||
"SigningConstants.generated.swift.tmpl",
|
||||
],
|
||||
linkerSettings: [
|
||||
.linkedFramework("Security")
|
||||
]
|
||||
),
|
||||
.target(
|
||||
name: "HelperClient",
|
||||
dependencies: ["ProboAgentShared"],
|
||||
path: "HelperClient"
|
||||
),
|
||||
.executableTarget(
|
||||
name: "com.probo.agent.helper",
|
||||
dependencies: ["ProboAgentShared"],
|
||||
path: "HelperTool",
|
||||
exclude: ["Info.plist.tmpl", "Launchd.plist.tmpl"],
|
||||
linkerSettings: [
|
||||
.linkedFramework("Security")
|
||||
]
|
||||
),
|
||||
.executableTarget(
|
||||
name: "probo-agent-url-handler",
|
||||
dependencies: ["HelperClient", "ProboAgentShared"],
|
||||
path: "URLHandlerSources"
|
||||
),
|
||||
]
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import Foundation
|
||||
import Security
|
||||
import os
|
||||
|
||||
public enum ClientAuth {
|
||||
private static let log = Logger(
|
||||
subsystem: "com.probo.agent.helper",
|
||||
category: "ClientAuth"
|
||||
)
|
||||
|
||||
/// Validates an incoming XPC connection from the Probo Agent URL handler app.
|
||||
public static func accepts(connection: NSXPCConnection) -> Bool {
|
||||
guard let code = copyGuestCode(for: connection) else {
|
||||
return false
|
||||
}
|
||||
|
||||
// Developer ID + hardened runtime sets CS_RUNTIME on the code directory
|
||||
// (kSecCodeInfoFlags). kSecCodeInfoStatus is only present for some
|
||||
// dynamic queries and was missing here, which rejected every client.
|
||||
if !hasAcceptableCodeStatus(code) {
|
||||
return false
|
||||
}
|
||||
|
||||
guard let requirement = clientRequirement() else {
|
||||
log.error("reject XPC client (missing client requirement)")
|
||||
return false
|
||||
}
|
||||
|
||||
var secRequirement: SecRequirement?
|
||||
guard
|
||||
SecRequirementCreateWithString(
|
||||
requirement as CFString,
|
||||
SecCSFlags(),
|
||||
&secRequirement
|
||||
) == errSecSuccess, let secRequirement
|
||||
else {
|
||||
log.error("reject XPC client (invalid requirement string)")
|
||||
return false
|
||||
}
|
||||
|
||||
let check = SecCodeCheckValidity(code, SecCSFlags(), secRequirement)
|
||||
if check != errSecSuccess {
|
||||
log.error("reject XPC client (requirement not satisfied, status=\(check))")
|
||||
return false
|
||||
}
|
||||
|
||||
log.info("accepted XPC client pid=\(connection.processIdentifier, privacy: .public)")
|
||||
return true
|
||||
}
|
||||
|
||||
private static func copyGuestCode(for connection: NSXPCConnection) -> SecCode? {
|
||||
if let token = auditToken(from: connection) {
|
||||
var mutableToken = token
|
||||
let tokenData = withUnsafeBytes(of: &mutableToken) { Data($0) }
|
||||
if let code = copyGuestCode(attributes: [
|
||||
kSecGuestAttributeAudit as String: tokenData
|
||||
]) {
|
||||
return code
|
||||
}
|
||||
log.info("audit-token guest lookup failed; trying pid")
|
||||
} else {
|
||||
log.info("no audit token on XPC connection; trying pid")
|
||||
}
|
||||
|
||||
let pid = connection.processIdentifier
|
||||
guard pid > 0 else {
|
||||
log.error("reject XPC client (invalid pid)")
|
||||
return nil
|
||||
}
|
||||
|
||||
return copyGuestCode(attributes: [
|
||||
kSecGuestAttributePid as String: NSNumber(value: pid)
|
||||
])
|
||||
}
|
||||
|
||||
private static func copyGuestCode(attributes: [String: Any]) -> SecCode? {
|
||||
var code: SecCode?
|
||||
let status = SecCodeCopyGuestWithAttributes(
|
||||
nil,
|
||||
attributes as CFDictionary,
|
||||
SecCSFlags(),
|
||||
&code
|
||||
)
|
||||
guard status == errSecSuccess else {
|
||||
log.error("reject XPC client (SecCodeCopyGuestWithAttributes=\(status))")
|
||||
return nil
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
private static func hasAcceptableCodeStatus(_ code: SecCode) -> Bool {
|
||||
var staticCode: SecStaticCode?
|
||||
guard SecCodeCopyStaticCode(code, SecCSFlags(), &staticCode) == errSecSuccess,
|
||||
let staticCode
|
||||
else {
|
||||
log.error("reject XPC client (cannot copy static code)")
|
||||
return false
|
||||
}
|
||||
|
||||
var csInfo: CFDictionary?
|
||||
guard
|
||||
SecCodeCopySigningInformation(
|
||||
staticCode,
|
||||
SecCSFlags(rawValue: kSecCSDynamicInformation),
|
||||
&csInfo
|
||||
) == errSecSuccess,
|
||||
let info = csInfo as? [String: Any]
|
||||
else {
|
||||
log.error("reject XPC client (cannot read signing information)")
|
||||
return false
|
||||
}
|
||||
|
||||
// Prefer dynamic status when present; fall back to code-directory flags
|
||||
// (where hardened-runtime CS_RUNTIME lives for Developer ID binaries).
|
||||
let statusValue = uint32Value(info[kSecCodeInfoStatus as String])
|
||||
let flagsValue = uint32Value(info[kSecCodeInfoFlags as String])
|
||||
let bits = statusValue ?? flagsValue
|
||||
|
||||
guard let bits else {
|
||||
log.error("reject XPC client (no status/flags in signing info)")
|
||||
return false
|
||||
}
|
||||
|
||||
// Accept hardened-runtime clients (Developer ID + --options runtime sets
|
||||
// CS_RUNTIME). Also accept the older CS_HARD|CS_KILL pair.
|
||||
let csHard: UInt32 = 0x100
|
||||
let csKill: UInt32 = 0x200
|
||||
let csRuntime: UInt32 = 0x10000
|
||||
let hasHardKill = (bits & (csHard | csKill)) == (csHard | csKill)
|
||||
let hasRuntime = (bits & csRuntime) == csRuntime
|
||||
if !hasHardKill && !hasRuntime {
|
||||
log.error(
|
||||
"reject XPC client (bits=0x\(String(bits, radix: 16)), need runtime or hard|kill)"
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private static func uint32Value(_ value: Any?) -> UInt32? {
|
||||
switch value {
|
||||
case let number as NSNumber:
|
||||
return number.uint32Value
|
||||
case let value as UInt32:
|
||||
return value
|
||||
case let value as Int:
|
||||
return UInt32(truncatingIfNeeded: value)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads NSXPCConnection.auditToken across SDK/runtime differences.
|
||||
/// Modern macOS exposes it as an ObjC property; KVC may return Data or NSValue.
|
||||
private static func auditToken(from connection: NSXPCConnection) -> audit_token_t? {
|
||||
if let data = connection.value(forKey: "auditToken") as? Data,
|
||||
data.count == MemoryLayout<audit_token_t>.size
|
||||
{
|
||||
return data.withUnsafeBytes { raw in
|
||||
raw.load(as: audit_token_t.self)
|
||||
}
|
||||
}
|
||||
|
||||
if let value = connection.value(forKey: "auditToken") as? NSValue {
|
||||
var token = audit_token_t()
|
||||
value.getValue(&token)
|
||||
return token
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func clientRequirement() -> String? {
|
||||
guard let teamID = ProboAgentSigningConstants.teamID, !teamID.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
return """
|
||||
anchor apple generic and identifier "\(ProboAgentHelperConstants.clientBundleID)" \
|
||||
and certificate leaf[subject.OU] = "\(teamID)"
|
||||
"""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import Foundation
|
||||
|
||||
@objc(ProboAgentHelperProtocol)
|
||||
public protocol ProboAgentHelperProtocol: NSObjectProtocol {
|
||||
func getVersion(withReply reply: @escaping (String) -> Void)
|
||||
func ping(withReply reply: @escaping (Bool) -> Void)
|
||||
func install(
|
||||
serverURL: String,
|
||||
enrollmentToken: String,
|
||||
configDir: String,
|
||||
withReply reply: @escaping (Int32, String?) -> Void
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Generated by build-app.sh — do not edit.
|
||||
enum ProboAgentHelperVersion {
|
||||
static let value = "@@VERSION@@"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import Foundation
|
||||
|
||||
public enum ProboAgentHelperConstants {
|
||||
public static let machServiceName = "com.probo.agent.helper"
|
||||
public static let helperLabel = "com.probo.agent.helper"
|
||||
public static let clientBundleID = "com.probo.agent.url-handler"
|
||||
public static let agentExecutablePath = "/usr/local/bin/probo-agent"
|
||||
public static let defaultConfigDir = "/var/lib/probo-agent"
|
||||
public static let enrolledMarkerPath = "/var/run/probo-agent/enrolled"
|
||||
|
||||
public static let helperVersion = ProboAgentHelperVersion.value
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Generated by build-app.sh — do not edit.
|
||||
enum ProboAgentSigningConstants {
|
||||
static let teamID: String? = @@TEAM_ID_OPTION@@
|
||||
}
|
||||
@@ -1,61 +1,10 @@
|
||||
import AppKit
|
||||
import Darwin
|
||||
import Foundation
|
||||
|
||||
// Fixed install location written by the macOS PKG postinstall script
|
||||
// (cmd/probo-agent/installer/macos/scripts/postinstall, BINARY).
|
||||
private let agentExecutablePath = "/usr/local/bin/probo-agent"
|
||||
|
||||
private enum EnrollmentCallbackState: String, Codable {
|
||||
case success
|
||||
case failure
|
||||
}
|
||||
|
||||
private struct EnrollmentCallbackPayload: Codable {
|
||||
let state: EnrollmentCallbackState
|
||||
let message: String?
|
||||
}
|
||||
|
||||
private enum EnrollmentCallbackStore {
|
||||
static var statusFileURL: URL {
|
||||
FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("probo-agent-enrollment-status.json")
|
||||
}
|
||||
|
||||
static var lockFileURL: URL {
|
||||
FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("probo-agent-enrollment-ui.lock")
|
||||
}
|
||||
|
||||
static func isWizardRunning() -> Bool {
|
||||
guard
|
||||
let data = try? Data(contentsOf: lockFileURL),
|
||||
let pidText = String(data: data, encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
let pid = Int32(pidText),
|
||||
pid > 0
|
||||
else {
|
||||
return false
|
||||
}
|
||||
|
||||
return kill(pid, 0) == 0
|
||||
}
|
||||
|
||||
static func writeStatus(
|
||||
state: EnrollmentCallbackState,
|
||||
message: String?
|
||||
) {
|
||||
let payload = EnrollmentCallbackPayload(state: state, message: message)
|
||||
guard let data = try? JSONEncoder().encode(payload) else {
|
||||
return
|
||||
}
|
||||
|
||||
try? data.write(to: statusFileURL, options: [.atomic])
|
||||
}
|
||||
}
|
||||
import HelperClient
|
||||
|
||||
private final class URLHandlerApp: NSObject, NSApplicationDelegate {
|
||||
private var didReceiveURL = false
|
||||
private var idleTimer: Timer?
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
@@ -69,127 +18,89 @@ private final class URLHandlerApp: NSObject, NSApplicationDelegate {
|
||||
}
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
Timer.scheduledTimer(withTimeInterval: 10, repeats: false) { _ in
|
||||
if !self.didReceiveURL {
|
||||
NSApp.terminate(nil)
|
||||
}
|
||||
NSLog("probo-agent url-handler: launched")
|
||||
idleTimer = Timer.scheduledTimer(withTimeInterval: 15, repeats: false) { [weak self] _ in
|
||||
guard let self, !self.didReceiveURL else { return }
|
||||
NSLog("probo-agent url-handler: no URL received; exiting")
|
||||
NSApp.terminate(nil)
|
||||
}
|
||||
}
|
||||
|
||||
func application(_ application: NSApplication, open urls: [URL]) {
|
||||
guard let rawURL = urls.first?.absoluteString else { return }
|
||||
beginEnrollmentIfNeeded(rawURL: rawURL)
|
||||
}
|
||||
|
||||
@objc private func handleGetURLEvent(
|
||||
_ event: NSAppleEventDescriptor,
|
||||
withReplyEvent replyEvent: NSAppleEventDescriptor
|
||||
) {
|
||||
guard let rawURL = event.paramDescriptor(forKeyword: keyDirectObject)?.stringValue else {
|
||||
reportFailure("Enrollment link is missing.")
|
||||
presentFailure("Enrollment link is missing.")
|
||||
return
|
||||
}
|
||||
|
||||
guard !didReceiveURL else { return }
|
||||
beginEnrollmentIfNeeded(rawURL: rawURL)
|
||||
}
|
||||
|
||||
private func beginEnrollmentIfNeeded(rawURL: String) {
|
||||
guard !didReceiveURL else { return }
|
||||
didReceiveURL = true
|
||||
idleTimer?.invalidate()
|
||||
idleTimer = nil
|
||||
runEnrollment(for: rawURL)
|
||||
}
|
||||
|
||||
private func runEnrollment(for rawURL: String) {
|
||||
let shouldNotifyWizard = EnrollmentCallbackStore.isWizardRunning()
|
||||
NSLog("probo-agent url-handler: starting enrollment")
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: agentExecutablePath)
|
||||
process.arguments = ["enroll-url", rawURL]
|
||||
|
||||
let output = Pipe()
|
||||
process.standardOutput = output
|
||||
process.standardError = output
|
||||
|
||||
var outputData = Data()
|
||||
let readHandle = output.fileHandleForReading
|
||||
let readDone = DispatchSemaphore(value: 0)
|
||||
|
||||
readHandle.readabilityHandler = { handle in
|
||||
let chunk = handle.availableData
|
||||
if chunk.isEmpty {
|
||||
handle.readabilityHandler = nil
|
||||
readDone.signal()
|
||||
do {
|
||||
NSLog("probo-agent url-handler: preflight…")
|
||||
let preflight = try EnrollmentFlow.runPreflight(rawURL: rawURL)
|
||||
if preflight.alreadyEnrolled {
|
||||
NSLog("probo-agent url-handler: already enrolled")
|
||||
DispatchQueue.main.async {
|
||||
NSApp.terminate(nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
outputData.append(chunk)
|
||||
}
|
||||
|
||||
defer { readHandle.readabilityHandler = nil }
|
||||
NSLog("probo-agent url-handler: install via helper…")
|
||||
try EnrollmentFlow.installViaHelper(preflight: preflight)
|
||||
NSLog("probo-agent url-handler: install completed")
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
DispatchQueue.main.async {
|
||||
NSApp.terminate(nil)
|
||||
}
|
||||
} catch {
|
||||
readDone.signal()
|
||||
NSLog(
|
||||
"probo-agent url-handler: enrollment failed: %@",
|
||||
error.localizedDescription
|
||||
)
|
||||
DispatchQueue.main.async {
|
||||
self.reportFailure(
|
||||
self.sanitizedFailureMessage(error.localizedDescription),
|
||||
shouldNotifyWizard: shouldNotifyWizard
|
||||
)
|
||||
self.presentFailure(error.localizedDescription)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
process.waitUntilExit()
|
||||
readDone.wait()
|
||||
|
||||
guard process.terminationStatus == 0 else {
|
||||
let message = String(data: outputData, encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.reportFailure(
|
||||
self.sanitizedFailureMessage(message),
|
||||
shouldNotifyWizard: shouldNotifyWizard
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
if shouldNotifyWizard {
|
||||
EnrollmentCallbackStore.writeStatus(state: .success, message: nil)
|
||||
}
|
||||
NSApp.terminate(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func reportFailure(_ message: String, shouldNotifyWizard: Bool = EnrollmentCallbackStore.isWizardRunning()) {
|
||||
if shouldNotifyWizard {
|
||||
EnrollmentCallbackStore.writeStatus(state: .failure, message: message)
|
||||
NSApp.terminate(nil)
|
||||
return
|
||||
}
|
||||
|
||||
showError(message)
|
||||
private func presentFailure(_ message: String) {
|
||||
presentAlert(title: "Enrollment failed", message: message, style: .warning)
|
||||
}
|
||||
|
||||
private func sanitizedFailureMessage(_ raw: String?) -> String {
|
||||
guard let raw else {
|
||||
return "Enrollment failed. Please try again."
|
||||
}
|
||||
|
||||
let normalized = raw.lowercased()
|
||||
if normalized.contains("already enrolled") {
|
||||
return "This device is already enrolled."
|
||||
}
|
||||
if normalized.contains("key") && normalized.contains("missing") {
|
||||
return "Device API key is missing or invalid."
|
||||
}
|
||||
|
||||
return "Enrollment failed. Please try again."
|
||||
}
|
||||
|
||||
private func showError(_ message: String) {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Enrollment failed"
|
||||
alert.informativeText = message
|
||||
alert.alertStyle = .warning
|
||||
private func presentAlert(title: String, message: String, style: NSAlert.Style) {
|
||||
// LSUIElement / .accessory apps otherwise show alerts that never appear.
|
||||
NSApp.setActivationPolicy(.regular)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
|
||||
let alert = NSAlert()
|
||||
alert.messageText = title
|
||||
alert.informativeText = message
|
||||
alert.alertStyle = style
|
||||
alert.runModal()
|
||||
|
||||
NSApp.setActivationPolicy(.accessory)
|
||||
NSApp.terminate(nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Build Probo Agent.app — a headless macOS app bundle that registers
|
||||
# the probo:// URL scheme and forwards enrollment links to probo-agent.
|
||||
# Build Probo Agent.app — headless macOS app bundle with probo:// handler
|
||||
# and embedded privileged helper (installed by PKG postinstall).
|
||||
#
|
||||
# Required arguments:
|
||||
# --arch amd64, arm64, or universal
|
||||
# --version Agent version, e.g. 0.1.0
|
||||
# --output Parent directory; creates "Probo Agent.app" inside it
|
||||
#
|
||||
# Must run on macOS with the Swift toolchain (swift build).
|
||||
# Required environment variables:
|
||||
# CODESIGN_IDENTITY Developer ID Application identity. Signs the
|
||||
# embedded helper (for SMPrivilegedExecutables DR),
|
||||
# URL handler, and app bundle.
|
||||
# APPLE_TEAM_ID Apple Developer Team ID for SMAuthorizedClients.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -18,7 +22,10 @@ ARCH=""
|
||||
VERSION=""
|
||||
OUTPUT=""
|
||||
APP_NAME="Probo Agent.app"
|
||||
EXECUTABLE_NAME="probo-agent-url-handler"
|
||||
URL_HANDLER_NAME="probo-agent-url-handler"
|
||||
HELPER_LABEL="com.probo.agent.helper"
|
||||
CODESIGN_IDENTITY="${CODESIGN_IDENTITY:-}"
|
||||
APPLE_TEAM_ID="${APPLE_TEAM_ID:-}"
|
||||
|
||||
usage() {
|
||||
sed -ne '/^#/!q; s/^# \{0,1\}//; 2,$ p' < "$0"
|
||||
@@ -60,33 +67,123 @@ if ! command -v swift >/dev/null 2>&1; then
|
||||
echo "error: swift is required (run on macOS with Xcode or Swift toolchain)" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${CODESIGN_IDENTITY}" ]; then
|
||||
echo "error: CODESIGN_IDENTITY is required (privileged helper must be signed)" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [ -z "${APPLE_TEAM_ID}" ]; then
|
||||
echo "error: APPLE_TEAM_ID is required (SMAuthorizedClients team requirement)" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
BUILD_DIR="$(mktemp -d -t probo-agent-url-handler-build)"
|
||||
BUILD_DIR="$(mktemp -d -t probo-agent-enroll-ui-build)"
|
||||
RENDER_DIR="${BUILD_DIR}/rendered"
|
||||
trap 'rm -rf "${BUILD_DIR}"' EXIT
|
||||
mkdir -p "${RENDER_DIR}"
|
||||
|
||||
client_requirement() {
|
||||
if [ -z "${APPLE_TEAM_ID}" ]; then
|
||||
echo "error: APPLE_TEAM_ID is required (client designated requirement)" >&2
|
||||
exit 2
|
||||
fi
|
||||
printf 'anchor apple generic and identifier "com.probo.agent.url-handler" and certificate leaf[subject.OU] = "%s"' "${APPLE_TEAM_ID}"
|
||||
}
|
||||
|
||||
team_id_option() {
|
||||
if [ -n "${APPLE_TEAM_ID}" ]; then
|
||||
printf '"%s"' "${APPLE_TEAM_ID}"
|
||||
return
|
||||
fi
|
||||
printf 'nil'
|
||||
}
|
||||
|
||||
sed \
|
||||
-e "s|@@VERSION@@|${VERSION}|g" \
|
||||
"${SCRIPT_DIR}/Shared/HelperVersion.generated.swift.tmpl" \
|
||||
> "${SCRIPT_DIR}/Shared/HelperVersion.generated.swift"
|
||||
|
||||
sed \
|
||||
-e "s|@@TEAM_ID_OPTION@@|$(team_id_option)|g" \
|
||||
"${SCRIPT_DIR}/Shared/SigningConstants.generated.swift.tmpl" \
|
||||
> "${SCRIPT_DIR}/Shared/SigningConstants.generated.swift"
|
||||
|
||||
HELPER_INFO_PLIST="${RENDER_DIR}/helper-info.plist"
|
||||
HELPER_LAUNCHD_PLIST="${RENDER_DIR}/helper-launchd.plist"
|
||||
|
||||
sed \
|
||||
-e "s|@@VERSION@@|${VERSION}|g" \
|
||||
-e "s|@@CLIENT_DESIGNATED_REQUIREMENT@@|$(client_requirement)|g" \
|
||||
"${SCRIPT_DIR}/HelperTool/Info.plist.tmpl" > "${HELPER_INFO_PLIST}"
|
||||
|
||||
cp "${SCRIPT_DIR}/HelperTool/Launchd.plist.tmpl" "${HELPER_LAUNCHD_PLIST}"
|
||||
|
||||
HELPER_LINKER_FLAGS=(
|
||||
-Xlinker -sectcreate -Xlinker __TEXT -Xlinker __info_plist
|
||||
-Xlinker "${HELPER_INFO_PLIST}"
|
||||
-Xlinker -sectcreate -Xlinker __TEXT -Xlinker __launchd_plist
|
||||
-Xlinker "${HELPER_LAUNCHD_PLIST}"
|
||||
)
|
||||
|
||||
pushd "${SCRIPT_DIR}" >/dev/null
|
||||
swift build -c release "${SWIFT_ARCH_ARGS[@]}" --scratch-path "${BUILD_DIR}"
|
||||
BIN_DIR="$(swift build -c release "${SWIFT_ARCH_ARGS[@]}" --scratch-path "${BUILD_DIR}" --show-bin-path)"
|
||||
BINARY="${BIN_DIR}/${EXECUTABLE_NAME}"
|
||||
swift build -c release "${SWIFT_ARCH_ARGS[@]}" \
|
||||
--scratch-path "${BUILD_DIR}/swift" \
|
||||
--product "${HELPER_LABEL}" \
|
||||
"${HELPER_LINKER_FLAGS[@]}"
|
||||
|
||||
swift build -c release "${SWIFT_ARCH_ARGS[@]}" \
|
||||
--scratch-path "${BUILD_DIR}/swift" \
|
||||
--product "${URL_HANDLER_NAME}"
|
||||
|
||||
BIN_DIR="$(swift build -c release "${SWIFT_ARCH_ARGS[@]}" \
|
||||
--scratch-path "${BUILD_DIR}/swift" --show-bin-path)"
|
||||
HELPER_BINARY="${BIN_DIR}/${HELPER_LABEL}"
|
||||
URL_HANDLER_BINARY="${BIN_DIR}/${URL_HANDLER_NAME}"
|
||||
popd >/dev/null
|
||||
|
||||
if [ ! -x "${BINARY}" ]; then
|
||||
echo "error: release binary not found at ${BINARY}" >&2
|
||||
if [ ! -x "${HELPER_BINARY}" ] || [ ! -x "${URL_HANDLER_BINARY}" ]; then
|
||||
echo "error: expected release binaries were not produced" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
APP_ROOT="${OUTPUT}/${APP_NAME}"
|
||||
CONTENTS="${APP_ROOT}/Contents"
|
||||
MACOS="${CONTENTS}/MacOS"
|
||||
LAUNCH_SERVICES="${CONTENTS}/Library/LaunchServices"
|
||||
LAUNCH_DAEMONS="${CONTENTS}/Library/LaunchDaemons"
|
||||
PLIST="${CONTENTS}/Info.plist"
|
||||
EMBEDDED_HELPER="${LAUNCH_SERVICES}/${HELPER_LABEL}"
|
||||
EMBEDDED_LAUNCHD="${LAUNCH_DAEMONS}/${HELPER_LABEL}.plist"
|
||||
|
||||
rm -rf "${APP_ROOT}"
|
||||
mkdir -p "${MACOS}"
|
||||
mkdir -p "${MACOS}" "${LAUNCH_SERVICES}" "${LAUNCH_DAEMONS}"
|
||||
|
||||
install -m 0755 "${BINARY}" "${MACOS}/${EXECUTABLE_NAME}"
|
||||
install -m 0755 "${URL_HANDLER_BINARY}" "${MACOS}/${URL_HANDLER_NAME}"
|
||||
install -m 0755 "${HELPER_BINARY}" "${EMBEDDED_HELPER}"
|
||||
install -m 0644 "${HELPER_LAUNCHD_PLIST}" "${EMBEDDED_LAUNCHD}"
|
||||
|
||||
codesign \
|
||||
--force \
|
||||
--options runtime \
|
||||
--timestamp \
|
||||
--sign "${CODESIGN_IDENTITY}" \
|
||||
"${EMBEDDED_HELPER}"
|
||||
codesign --verify --verbose=2 "${EMBEDDED_HELPER}"
|
||||
# codesign prints "Executable=…" on stderr and either
|
||||
# "# designated => …" (modern) or "designated => …" (older) on stdout.
|
||||
HELPER_REQUIREMENT="$(
|
||||
codesign -d -r- "${EMBEDDED_HELPER}" 2>&1 \
|
||||
| sed -n -e 's/^# designated => //p' -e 's/^designated => //p'
|
||||
)"
|
||||
if [ -z "${HELPER_REQUIREMENT}" ]; then
|
||||
echo "error: cannot extract designated requirement from signed helper" >&2
|
||||
codesign -d -r- "${EMBEDDED_HELPER}" 2>&1 >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
echo "Helper designated requirement: ${HELPER_REQUIREMENT}"
|
||||
|
||||
sed \
|
||||
-e "s|@@VERSION@@|${VERSION}|g" \
|
||||
-e "s|@@HELPER_DESIGNATED_REQUIREMENT@@|${HELPER_REQUIREMENT}|g" \
|
||||
"${SCRIPT_DIR}/Info.plist.tmpl" > "${PLIST}"
|
||||
|
||||
if ! plutil -lint "${PLIST}" >/dev/null; then
|
||||
@@ -98,4 +195,18 @@ if ! grep -q '<string>probo</string>' "${PLIST}"; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
codesign \
|
||||
--force \
|
||||
--options runtime \
|
||||
--timestamp \
|
||||
--sign "${CODESIGN_IDENTITY}" \
|
||||
"${MACOS}/${URL_HANDLER_NAME}"
|
||||
codesign \
|
||||
--force \
|
||||
--options runtime \
|
||||
--timestamp \
|
||||
--sign "${CODESIGN_IDENTITY}" \
|
||||
"${APP_ROOT}"
|
||||
codesign --verify --verbose=2 "${APP_ROOT}"
|
||||
|
||||
echo "Built ${APP_ROOT}"
|
||||
|
||||
Reference in New Issue
Block a user