Add probo-agent binary, installer, and CI
Signed-off-by: Ludovic Vielle <ludovic@probo.com>
This commit is contained in:
2
cmd/probo-agent/installer/macos/enroll-ui/.gitignore
vendored
Normal file
2
cmd/probo-agent/installer/macos/enroll-ui/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
.build/
|
||||
.swiftpm/
|
||||
45
cmd/probo-agent/installer/macos/enroll-ui/Info.plist.tmpl
Normal file
45
cmd/probo-agent/installer/macos/enroll-ui/Info.plist.tmpl
Normal file
@@ -0,0 +1,45 @@
|
||||
<?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">
|
||||
<!--
|
||||
Info.plist for the Probo Agent URL handler app bundle.
|
||||
|
||||
Placeholders are substituted by build-app.sh:
|
||||
|
||||
@@VERSION@@ agent version, e.g. 0.1.0
|
||||
-->
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>probo-agent-url-handler</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.getprobo.agent.url-handler</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Probo Agent</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Probo Agent</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>@@VERSION@@</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>@@VERSION@@</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>11.0</string>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>Probo Enrollment</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>probo</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
15
cmd/probo-agent/installer/macos/enroll-ui/Package.swift
Normal file
15
cmd/probo-agent/installer/macos/enroll-ui/Package.swift
Normal file
@@ -0,0 +1,15 @@
|
||||
// swift-tools-version: 5.9
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "probo-agent-url-handler",
|
||||
platforms: [
|
||||
.macOS(.v11),
|
||||
],
|
||||
targets: [
|
||||
.executableTarget(
|
||||
name: "probo-agent-url-handler",
|
||||
path: "URLHandlerSources"
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,201 @@
|
||||
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])
|
||||
}
|
||||
}
|
||||
|
||||
private final class URLHandlerApp: NSObject, NSApplicationDelegate {
|
||||
private var didReceiveURL = false
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
|
||||
NSAppleEventManager.shared().setEventHandler(
|
||||
self,
|
||||
andSelector: #selector(handleGetURLEvent(_:withReplyEvent:)),
|
||||
forEventClass: AEEventClass(kInternetEventClass),
|
||||
andEventID: AEEventID(kAEGetURL)
|
||||
)
|
||||
}
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
Timer.scheduledTimer(withTimeInterval: 10, repeats: false) { _ in
|
||||
if !self.didReceiveURL {
|
||||
NSApp.terminate(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func handleGetURLEvent(
|
||||
_ event: NSAppleEventDescriptor,
|
||||
withReplyEvent replyEvent: NSAppleEventDescriptor
|
||||
) {
|
||||
guard let rawURL = event.paramDescriptor(forKeyword: keyDirectObject)?.stringValue else {
|
||||
reportFailure("Enrollment link is missing.")
|
||||
return
|
||||
}
|
||||
|
||||
guard !didReceiveURL else { return }
|
||||
|
||||
didReceiveURL = true
|
||||
runEnrollment(for: rawURL)
|
||||
}
|
||||
|
||||
private func runEnrollment(for rawURL: String) {
|
||||
let shouldNotifyWizard = EnrollmentCallbackStore.isWizardRunning()
|
||||
|
||||
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()
|
||||
return
|
||||
}
|
||||
outputData.append(chunk)
|
||||
}
|
||||
|
||||
defer { readHandle.readabilityHandler = nil }
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
} catch {
|
||||
readDone.signal()
|
||||
DispatchQueue.main.async {
|
||||
self.reportFailure(
|
||||
self.sanitizedFailureMessage(error.localizedDescription),
|
||||
shouldNotifyWizard: shouldNotifyWizard
|
||||
)
|
||||
}
|
||||
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 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
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
alert.runModal()
|
||||
NSApp.terminate(nil)
|
||||
}
|
||||
}
|
||||
|
||||
let app = NSApplication.shared
|
||||
private let delegate = URLHandlerApp()
|
||||
app.delegate = delegate
|
||||
app.setActivationPolicy(.accessory)
|
||||
app.run()
|
||||
97
cmd/probo-agent/installer/macos/enroll-ui/build-app.sh
Executable file
97
cmd/probo-agent/installer/macos/enroll-ui/build-app.sh
Executable file
@@ -0,0 +1,97 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Build Probo Agent.app — a headless macOS app bundle that registers
|
||||
# the probo:// URL scheme and forwards enrollment links to probo-agent.
|
||||
#
|
||||
# Required arguments:
|
||||
# --arch amd64 or arm64
|
||||
# --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).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
ARCH=""
|
||||
VERSION=""
|
||||
OUTPUT=""
|
||||
APP_NAME="Probo Agent.app"
|
||||
EXECUTABLE_NAME="probo-agent-url-handler"
|
||||
|
||||
usage() {
|
||||
sed -ne '/^#/!q; s/^# \{0,1\}//; 2,$ p' < "$0"
|
||||
}
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--arch) ARCH="$2"; shift 2 ;;
|
||||
--version) VERSION="$2"; shift 2 ;;
|
||||
--output) OUTPUT="$2"; shift 2 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "unknown flag: $1" >&2; usage >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -z "${ARCH}" ]; then
|
||||
echo "error: --arch (amd64|arm64) is required" >&2
|
||||
exit 2
|
||||
fi
|
||||
case "${ARCH}" in
|
||||
amd64) SWIFT_ARCH="x86_64" ;;
|
||||
arm64) SWIFT_ARCH="arm64" ;;
|
||||
*) echo "error: unsupported --arch '${ARCH}' (want amd64 or arm64)" >&2; exit 2 ;;
|
||||
esac
|
||||
if [ -z "${VERSION}" ]; then
|
||||
echo "error: --version is required" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [ -z "${OUTPUT}" ]; then
|
||||
echo "error: --output is required" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
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
|
||||
|
||||
BUILD_DIR="$(mktemp -d -t probo-agent-url-handler-build)"
|
||||
trap 'rm -rf "${BUILD_DIR}"' EXIT
|
||||
|
||||
pushd "${SCRIPT_DIR}" >/dev/null
|
||||
swift build -c release --arch "${SWIFT_ARCH}" --scratch-path "${BUILD_DIR}"
|
||||
BIN_DIR="$(swift build -c release --arch "${SWIFT_ARCH}" --scratch-path "${BUILD_DIR}" --show-bin-path)"
|
||||
BINARY="${BIN_DIR}/${EXECUTABLE_NAME}"
|
||||
popd >/dev/null
|
||||
|
||||
if [ ! -x "${BINARY}" ]; then
|
||||
echo "error: release binary not found at ${BINARY}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
APP_ROOT="${OUTPUT}/${APP_NAME}"
|
||||
CONTENTS="${APP_ROOT}/Contents"
|
||||
MACOS="${CONTENTS}/MacOS"
|
||||
PLIST="${CONTENTS}/Info.plist"
|
||||
|
||||
rm -rf "${APP_ROOT}"
|
||||
mkdir -p "${MACOS}"
|
||||
|
||||
install -m 0755 "${BINARY}" "${MACOS}/${EXECUTABLE_NAME}"
|
||||
|
||||
sed \
|
||||
-e "s|@@VERSION@@|${VERSION}|g" \
|
||||
"${SCRIPT_DIR}/Info.plist.tmpl" > "${PLIST}"
|
||||
|
||||
if ! plutil -lint "${PLIST}" >/dev/null; then
|
||||
echo "error: rendered Info.plist failed plutil -lint" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q '<string>probo</string>' "${PLIST}"; then
|
||||
echo "error: Info.plist is missing probo URL scheme" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Built ${APP_ROOT}"
|
||||
Reference in New Issue
Block a user