Add probo-agent binary, installer, and CI

Signed-off-by: Ludovic Vielle <ludovic@probo.com>
This commit is contained in:
Ludovic Vielle
2026-07-14 20:40:18 +02:00
parent b442e1ed76
commit d0dd87c6c7
24 changed files with 1597 additions and 75 deletions

View File

@@ -0,0 +1,2 @@
.build/
.swiftpm/

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

View 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"
),
]
)

View File

@@ -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()

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