Initial move of fleet desktop app (#47181)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #46937 Moves fleet desktop standalone app from https://github.com/allenhouchins/fleet-desktop into the monorepo # Checklist for submitter If some of the following don't apply, delete the relevant line. No changes file. Do we want to create a new changes directory and changelog for this? Unclear - [ ] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. - [x] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements), JS inline code is prevented especially for url redirects, and untrusted data interpolated into shell scripts/commands is validated against shell metacharacters. - [x] Timeouts are implemented and retries are limited to avoid infinite loops - [x] If paths of existing endpoints are modified without backwards compatibility, checked the frontend/CLI for any necessary changes ## Testing - [x] Added/updated automated tests - [x] Where appropriate, [automated tests simulate multiple hosts and test for host isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing) (updates to one hosts's records do not affect another) - [x] QA'd all new/changed functionality manually Downloaded build from CI, tested locally and installed via Fleet on a mac mini. Launched and tested there - all looks good <img width="1485" height="544" alt="Screenshot 2026-06-09 at 4 27 59 PM" src="https://github.com/user-attachments/assets/48611808-2265-43b7-a514-afc4f578a9f8" /> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Launched Fleet Desktop macOS app with web-based self-service UI. * Added fleet:// URL handling to trigger in-app actions (refetch, update all). * Dock badge shows failing policies count in real time. * Built-in token refresh, retries, and navigation error handling for reliability. * MDM configuration support for enterprise deployment. * Automated macOS packaging with code signing, notarization, and artifact upload. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
co-authored by
Copilot Autofix powered by AI
parent
19f0df6e3e
commit
3f6ce1e4bf
@@ -0,0 +1,167 @@
|
||||
name: Build Fleet Desktop (macOS)
|
||||
|
||||
# Builds the native macOS Fleet Desktop app (apps/fleet-desktop-macos/), code signs
|
||||
# and notarizes it with Fleet's Developer ID certificates, and uploads the signed
|
||||
# .pkg as a workflow artifact. No GitHub Release is created.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'apps/fleet-desktop-macos/**'
|
||||
- '.github/workflows/fleet-desktop-macos-build.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'apps/fleet-desktop-macos/**'
|
||||
- '.github/workflows/fleet-desktop-macos-build.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
# Cancel superseded runs on the same ref.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
defaults:
|
||||
run:
|
||||
# fail-fast using bash -eo pipefail.
|
||||
shell: bash
|
||||
working-directory: apps/fleet-desktop-macos
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
# Fleet's Developer ID certificate identities (SHA-1). Same team as the rest
|
||||
# of Fleet's macOS artifacts (orbit Fleet Desktop, fleetd-base.pkg).
|
||||
APPLICATION_SIGNING_IDENTITY_SHA1: 604D877399AAEB7630A78B84F288E2D28A2EDE42
|
||||
INSTALLER_SIGNING_IDENTITY_SHA1: 4608F71FB42E1845C7FC9B2D2B6A7A8D11BBD940
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build, sign, and notarize Fleet Desktop (macOS)
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Build app and create pkg
|
||||
run: |
|
||||
chmod +x build.sh build-pkg.sh
|
||||
./build-pkg.sh
|
||||
|
||||
- name: Import Developer ID certificates
|
||||
env:
|
||||
APPLE_APPLICATION_CERTIFICATE: ${{ secrets.APPLE_APPLICATION_CERTIFICATE }}
|
||||
APPLE_APPLICATION_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_APPLICATION_CERTIFICATE_PASSWORD }}
|
||||
APPLE_INSTALLER_CERTIFICATE: ${{ secrets.APPLE_INSTALLER_CERTIFICATE }}
|
||||
APPLE_INSTALLER_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_INSTALLER_CERTIFICATE_PASSWORD }}
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
run: |
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
|
||||
security default-keychain -s build.keychain
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
|
||||
|
||||
# Developer ID Application certificate — signs the .app (codesign).
|
||||
echo "$APPLE_APPLICATION_CERTIFICATE" | base64 --decode > application.p12
|
||||
security import application.p12 -k build.keychain -P "$APPLE_APPLICATION_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
|
||||
rm application.p12
|
||||
|
||||
# Developer ID Installer certificate — signs the .pkg (productsign).
|
||||
echo "$APPLE_INSTALLER_CERTIFICATE" | base64 --decode > installer.p12
|
||||
security import installer.p12 -k build.keychain -P "$APPLE_INSTALLER_CERTIFICATE_PASSWORD" -T /usr/bin/productsign
|
||||
rm installer.p12
|
||||
|
||||
security set-key-partition-list -S apple-tool:,apple:,codesign:,productsign: -s -k "$KEYCHAIN_PASSWORD" build.keychain
|
||||
security find-identity -vv
|
||||
|
||||
- name: Code sign app
|
||||
run: |
|
||||
BINARY_PATH="build/Fleet Desktop.app/Contents/MacOS/FleetDesktop"
|
||||
|
||||
# Sign the universal binary first, then the bundle (no --deep).
|
||||
codesign --force --sign "$APPLICATION_SIGNING_IDENTITY_SHA1" \
|
||||
--options runtime --timestamp "$BINARY_PATH"
|
||||
codesign --verify --verbose "$BINARY_PATH"
|
||||
|
||||
codesign --force --sign "$APPLICATION_SIGNING_IDENTITY_SHA1" \
|
||||
--options runtime --timestamp "build/Fleet Desktop.app"
|
||||
|
||||
codesign --verify --deep --strict --verbose=2 "build/Fleet Desktop.app"
|
||||
codesign --display --verbose=4 "build/Fleet Desktop.app"
|
||||
|
||||
- name: Rebuild pkg with signed app
|
||||
run: |
|
||||
# build-pkg.sh reuses the already-signed app (ditto preserves the signature).
|
||||
./build-pkg.sh
|
||||
|
||||
- name: Sign pkg
|
||||
run: |
|
||||
VERSION=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "FleetDesktop/Info.plist")
|
||||
UNSIGNED_PKG="build/dist/fleet_desktop-v${VERSION}.pkg"
|
||||
SIGNED_PKG="build/dist/fleet_desktop-v${VERSION}-signed.pkg"
|
||||
|
||||
if [ ! -f "$UNSIGNED_PKG" ]; then
|
||||
echo "Error: package not found: $UNSIGNED_PKG"
|
||||
ls -la build/dist/ || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
productsign --sign "$INSTALLER_SIGNING_IDENTITY_SHA1" --timestamp \
|
||||
"$UNSIGNED_PKG" "$SIGNED_PKG"
|
||||
mv "$SIGNED_PKG" "$UNSIGNED_PKG"
|
||||
pkgutil --check-signature "$UNSIGNED_PKG"
|
||||
|
||||
- name: Notarize pkg
|
||||
env:
|
||||
AC_USERNAME: ${{ secrets.APPLE_USERNAME }}
|
||||
AC_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
AC_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: |
|
||||
VERSION=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "FleetDesktop/Info.plist")
|
||||
PKG_PATH="build/dist/fleet_desktop-v${VERSION}.pkg"
|
||||
|
||||
SUBMISSION_OUTPUT=$(xcrun notarytool submit "$PKG_PATH" \
|
||||
--apple-id "$AC_USERNAME" \
|
||||
--password "$AC_PASSWORD" \
|
||||
--team-id "$AC_TEAM_ID" \
|
||||
--wait --timeout 30m 2>&1) || NOTARIZATION_FAILED=true
|
||||
echo "$SUBMISSION_OUTPUT"
|
||||
|
||||
SUBMISSION_ID=$(echo "$SUBMISSION_OUTPUT" | grep -i "id:" | head -1 | awk '{print $NF}' | tr -d ',' || echo "")
|
||||
STATUS=$(echo "$SUBMISSION_OUTPUT" | grep -i "status:" | tail -1 | awk '{print $NF}' || echo "")
|
||||
|
||||
# Fail closed: only an explicit "Accepted" passes. notarytool statuses
|
||||
# are Accepted / In Progress / Invalid / Rejected — a broad grep for
|
||||
# "failed|error" would let a "Rejected" submission slip through as
|
||||
# success, and notarytool can exit 0 even on a rejected package.
|
||||
if [ "${NOTARIZATION_FAILED:-false}" = "true" ] || [ "$STATUS" != "Accepted" ]; then
|
||||
echo "::error::Notarization failed (status: ${STATUS:-unknown})"
|
||||
if [ -n "$SUBMISSION_ID" ]; then
|
||||
xcrun notarytool log "$SUBMISSION_ID" \
|
||||
--apple-id "$AC_USERNAME" --password "$AC_PASSWORD" --team-id "$AC_TEAM_ID" || true
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
xcrun stapler staple "$PKG_PATH"
|
||||
xcrun stapler validate "$PKG_PATH"
|
||||
spctl --assess --type install --verbose "$PKG_PATH"
|
||||
|
||||
- name: Cleanup keychain
|
||||
run: security delete-keychain build.keychain || true
|
||||
|
||||
- name: Upload pkg artifact
|
||||
uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0
|
||||
with:
|
||||
name: fleet_desktop-pkg
|
||||
path: ./apps/fleet-desktop-macos/build/dist/fleet_desktop-v*.pkg
|
||||
retention-days: 30
|
||||
if-no-files-found: error
|
||||
@@ -0,0 +1,5 @@
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# Build output
|
||||
build/
|
||||
Binary file not shown.
@@ -0,0 +1,517 @@
|
||||
import AppKit
|
||||
import WebKit
|
||||
|
||||
/// A standalone browser window with an embedded WKWebView.
|
||||
/// Scoped to the Fleet server — external links open in the default browser.
|
||||
///
|
||||
/// Supports preloading: call `preload(url:)` at app launch to start loading the page
|
||||
/// in the background, then call `show()` to display the window instantly.
|
||||
/// The WebView is kept alive when the window is closed, so reopening is instant.
|
||||
final class BrowserWindow: NSObject, NSWindowDelegate {
|
||||
private var window: NSWindow?
|
||||
private var webView: WKWebView?
|
||||
private var container: NSView?
|
||||
private var fleetHost: String?
|
||||
private var homeURL: URL?
|
||||
private var loadingOverlay: NSView?
|
||||
private var pageLoaded = false
|
||||
|
||||
/// JavaScript to run on the next `didFinish` navigation. Consumed once.
|
||||
/// Used by `fleet://update_all` to click the in-page "Update all" button.
|
||||
private var pendingPostLoadJS: String?
|
||||
|
||||
/// Tracks whether an SSO/auth flow is in progress. When true, external IdP
|
||||
/// redirects are kept in the WebView so the full redirect chain completes in-app.
|
||||
private var ssoFlowActive = false
|
||||
|
||||
/// The window title used throughout the app.
|
||||
static let windowTitle = "Fleet Desktop"
|
||||
|
||||
/// File extensions that should be downloaded rather than displayed.
|
||||
private static let downloadableExtensions: Set<String> = [
|
||||
"mobileconfig", "pkg", "dmg", "zip", "tar", "gz", "pdf"
|
||||
]
|
||||
|
||||
/// MIME types that should be downloaded rather than displayed.
|
||||
private static let downloadableMIMETypes: Set<String> = [
|
||||
"application/x-apple-aspen-config",
|
||||
"application/octet-stream",
|
||||
"application/zip",
|
||||
"application/x-tar",
|
||||
"application/gzip",
|
||||
"application/pdf",
|
||||
"application/vnd.apple.installer+xml",
|
||||
]
|
||||
|
||||
/// URL schemes that are safe to open externally.
|
||||
private static let allowedExternalSchemes: Set<String> = ["https", "http", "mailto"]
|
||||
|
||||
/// Called when a navigation error occurs (e.g., expired token returns 401/403)
|
||||
/// or when the page content indicates an error (e.g., "Something went wrong").
|
||||
var onNavigationError: (() -> Void)?
|
||||
|
||||
/// Called when the window is closed (allows the owner to react to the UI closing).
|
||||
var onWindowClose: (() -> Void)?
|
||||
|
||||
/// Called when the window is shown (so the timer can be resumed).
|
||||
var onWindowShow: (() -> Void)?
|
||||
|
||||
/// Preload the WebView and start loading the URL without showing a window.
|
||||
/// Call `show()` later to display the window.
|
||||
func preload(url: URL) {
|
||||
fleetHost = url.host
|
||||
homeURL = url
|
||||
|
||||
// Configure WKWebView — non-persistent data store so no cookies/cache persist
|
||||
let config = WKWebViewConfiguration()
|
||||
config.websiteDataStore = .nonPersistent()
|
||||
let wv = WKWebView(frame: .zero, configuration: config)
|
||||
wv.navigationDelegate = self
|
||||
wv.uiDelegate = self
|
||||
wv.translatesAutoresizingMaskIntoConstraints = false
|
||||
webView = wv
|
||||
|
||||
let cont = NSView()
|
||||
cont.addSubview(wv)
|
||||
container = cont
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
wv.topAnchor.constraint(equalTo: cont.topAnchor),
|
||||
wv.leadingAnchor.constraint(equalTo: cont.leadingAnchor),
|
||||
wv.trailingAnchor.constraint(equalTo: cont.trailingAnchor),
|
||||
wv.bottomAnchor.constraint(equalTo: cont.bottomAnchor),
|
||||
])
|
||||
|
||||
wv.load(URLRequest(url: url))
|
||||
}
|
||||
|
||||
/// Show the browser window. If the page hasn't finished loading yet,
|
||||
/// a loading overlay with the Fleet logo is displayed until it does.
|
||||
func show(title: String = BrowserWindow.windowTitle) {
|
||||
guard webView != nil, let container = container else { return }
|
||||
|
||||
// If window already exists, just bring it forward
|
||||
if let win = window {
|
||||
win.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
return
|
||||
}
|
||||
|
||||
// Add loading overlay if page isn't loaded yet
|
||||
if !pageLoaded {
|
||||
addLoadingOverlay()
|
||||
}
|
||||
|
||||
// Default window size — centered on screen
|
||||
let screenFrame = NSScreen.main?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1200, height: 800)
|
||||
let windowWidth: CGFloat = min(1100, screenFrame.width * 0.8)
|
||||
let windowHeight: CGFloat = min(750, screenFrame.height * 0.8)
|
||||
let windowRect = NSRect(
|
||||
x: screenFrame.midX - windowWidth / 2,
|
||||
y: screenFrame.midY - windowHeight / 2,
|
||||
width: windowWidth,
|
||||
height: windowHeight
|
||||
)
|
||||
|
||||
let win = NSWindow(
|
||||
contentRect: windowRect,
|
||||
styleMask: [.titled, .closable, .resizable, .miniaturizable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
win.title = title
|
||||
win.isReleasedWhenClosed = false
|
||||
win.tabbingMode = .disallowed
|
||||
win.representedURL = nil
|
||||
win.standardWindowButton(.documentIconButton)?.isHidden = true
|
||||
win.contentView = container
|
||||
win.delegate = self
|
||||
win.minSize = NSSize(width: 480, height: 360)
|
||||
|
||||
// Restore previous window position and size (persisted by macOS automatically)
|
||||
win.setFrameAutosaveName("FleetDesktopMainWindow")
|
||||
|
||||
centerTitleTextField(in: win)
|
||||
|
||||
win.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
self.window = win
|
||||
|
||||
// Clear any web element focus so nothing appears selected on open
|
||||
webView?.evaluateJavaScript("document.activeElement?.blur()", completionHandler: nil)
|
||||
|
||||
onWindowShow?()
|
||||
}
|
||||
|
||||
/// Whether the WebView has been created (preloaded or opened).
|
||||
var isAvailable: Bool {
|
||||
return webView != nil
|
||||
}
|
||||
|
||||
/// Whether the browser window exists and is on-screen (not used for preloaded-only state).
|
||||
var isWindowVisible: Bool {
|
||||
window.map { $0.isVisible } ?? false
|
||||
}
|
||||
|
||||
/// Reload the current page in the web view (e.g., Cmd+R).
|
||||
func reloadCurrent() {
|
||||
webView?.reload()
|
||||
}
|
||||
|
||||
/// Navigate the existing web view to a new URL (e.g., after token refresh).
|
||||
func reload(url: URL) {
|
||||
fleetHost = url.host
|
||||
homeURL = url
|
||||
webView?.load(URLRequest(url: url))
|
||||
}
|
||||
|
||||
/// Queue JavaScript to run once, the next time a navigation finishes loading.
|
||||
/// Set this *before* calling `preload(url:)` or `reload(url:)`.
|
||||
func runOnNextLoad(_ js: String) {
|
||||
pendingPostLoadJS = js
|
||||
}
|
||||
|
||||
// MARK: - Loading Overlay
|
||||
|
||||
private func addLoadingOverlay() {
|
||||
guard loadingOverlay == nil, let container = container else { return }
|
||||
|
||||
let overlay = LoadingOverlayView()
|
||||
overlay.translatesAutoresizingMaskIntoConstraints = false
|
||||
container.addSubview(overlay)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
overlay.topAnchor.constraint(equalTo: container.topAnchor),
|
||||
overlay.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
||||
overlay.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
||||
overlay.bottomAnchor.constraint(equalTo: container.bottomAnchor),
|
||||
])
|
||||
|
||||
// Fleet logo centered in overlay
|
||||
let iconView = NSImageView()
|
||||
iconView.translatesAutoresizingMaskIntoConstraints = false
|
||||
if let logoPath = Bundle.main.path(forResource: "fleet-logo", ofType: "png"),
|
||||
let logo = NSImage(contentsOfFile: logoPath) {
|
||||
iconView.image = logo
|
||||
} else {
|
||||
iconView.image = NSApp.applicationIconImage
|
||||
}
|
||||
iconView.imageScaling = .scaleProportionallyUpOrDown
|
||||
overlay.addSubview(iconView)
|
||||
|
||||
// Spinner below the icon
|
||||
let spinner = NSProgressIndicator()
|
||||
spinner.translatesAutoresizingMaskIntoConstraints = false
|
||||
spinner.style = .spinning
|
||||
spinner.controlSize = .regular
|
||||
spinner.startAnimation(nil)
|
||||
overlay.addSubview(spinner)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
iconView.centerXAnchor.constraint(equalTo: overlay.centerXAnchor),
|
||||
iconView.centerYAnchor.constraint(equalTo: overlay.centerYAnchor, constant: -20),
|
||||
iconView.widthAnchor.constraint(equalToConstant: 64),
|
||||
iconView.heightAnchor.constraint(equalToConstant: 64),
|
||||
spinner.centerXAnchor.constraint(equalTo: overlay.centerXAnchor),
|
||||
spinner.topAnchor.constraint(equalTo: iconView.bottomAnchor, constant: 16),
|
||||
])
|
||||
|
||||
self.loadingOverlay = overlay
|
||||
}
|
||||
|
||||
private func dismissLoadingOverlay() {
|
||||
guard let overlay = loadingOverlay else { return }
|
||||
NSAnimationContext.runAnimationGroup({ context in
|
||||
context.duration = 0.3
|
||||
overlay.animator().alphaValue = 0
|
||||
}, completionHandler: { [weak self] in
|
||||
overlay.removeFromSuperview()
|
||||
self?.loadingOverlay = nil
|
||||
})
|
||||
}
|
||||
|
||||
// MARK: - SSO Flow Detection
|
||||
|
||||
/// Resets SSO state. Called on window close, navigation errors, and
|
||||
/// when navigation returns to the Fleet host from an SSO flow.
|
||||
private func resetSSOFlow() {
|
||||
ssoFlowActive = false
|
||||
}
|
||||
|
||||
// MARK: - External URL Safety
|
||||
|
||||
/// Opens a URL externally only if it uses a safe scheme (https, http, mailto).
|
||||
private func openExternalURL(_ url: URL) {
|
||||
guard let scheme = url.scheme?.lowercased(),
|
||||
Self.allowedExternalSchemes.contains(scheme) else {
|
||||
return
|
||||
}
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
|
||||
// MARK: - Title Centering
|
||||
|
||||
private func centerTitleTextField(in window: NSWindow) {
|
||||
guard let titlebarView = window.standardWindowButton(.closeButton)?.superview else { return }
|
||||
|
||||
for subview in titlebarView.subviews {
|
||||
if let textField = subview as? NSTextField {
|
||||
textField.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
textField.centerXAnchor.constraint(equalTo: titlebarView.centerXAnchor),
|
||||
textField.centerYAnchor.constraint(equalTo: titlebarView.centerYAnchor),
|
||||
])
|
||||
} else if !(subview is NSButton) {
|
||||
subview.isHidden = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - NSWindowDelegate
|
||||
|
||||
func windowWillClose(_ notification: Notification) {
|
||||
// Keep the WebView alive — just detach the window
|
||||
window = nil
|
||||
loadingOverlay?.removeFromSuperview()
|
||||
loadingOverlay = nil
|
||||
pendingPostLoadJS = nil
|
||||
resetSSOFlow()
|
||||
onWindowClose?()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - WKNavigationDelegate
|
||||
|
||||
extension BrowserWindow: WKNavigationDelegate {
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
pageLoaded = true
|
||||
window?.title = Self.windowTitle
|
||||
dismissLoadingOverlay()
|
||||
|
||||
// If an SSO flow was active and we've finished loading a Fleet-host page,
|
||||
// the SSO callback is complete — reset the flow.
|
||||
if ssoFlowActive, webView.url?.host == fleetHost {
|
||||
resetSSOFlow()
|
||||
}
|
||||
|
||||
// Check if the page content indicates an error (Fleet returns 200 with error HTML
|
||||
// when the token is expired, rather than a 401/403 status code)
|
||||
checkPageForErrors(webView)
|
||||
|
||||
// Only run queued JS on Fleet-host pages — avoids injecting into IdP
|
||||
// pages during SSO redirects and avoids consuming the slot on an
|
||||
// intermediate redirect before the real target finishes loading.
|
||||
if let js = pendingPostLoadJS, webView.url?.host == fleetHost {
|
||||
pendingPostLoadJS = nil
|
||||
webView.evaluateJavaScript(js, completionHandler: nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// Inspects the page DOM for error indicators that suggest the device token has expired.
|
||||
/// Fleet returns HTTP 200 with specific error HTML when tokens expire, so we check for
|
||||
/// a combination of error phrases to reduce false positives from legitimate page content.
|
||||
private func checkPageForErrors(_ webView: WKWebView) {
|
||||
let js = """
|
||||
(function() {
|
||||
var body = document.body ? document.body.innerText : '';
|
||||
var errors = 0;
|
||||
if (body.indexOf('Something went wrong') !== -1) errors++;
|
||||
if (body.indexOf('Error loading software') !== -1) errors++;
|
||||
if (body.indexOf('Please contact your IT admin') !== -1) errors++;
|
||||
return errors >= 2 ? 'error' : 'ok';
|
||||
})();
|
||||
"""
|
||||
webView.evaluateJavaScript(js) { [weak self] result, _ in
|
||||
if let status = result as? String, status == "error" {
|
||||
self?.onNavigationError?()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||||
// Ignore cancelled navigations (e.g., user clicked a new link while loading)
|
||||
if (error as NSError).code == NSURLErrorCancelled { return }
|
||||
resetSSOFlow()
|
||||
onNavigationError?()
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
||||
if (error as NSError).code == NSURLErrorCancelled { return }
|
||||
resetSSOFlow()
|
||||
onNavigationError?()
|
||||
}
|
||||
|
||||
func webView(
|
||||
_ webView: WKWebView,
|
||||
decidePolicyFor navigationResponse: WKNavigationResponse,
|
||||
decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void
|
||||
) {
|
||||
if let httpResponse = navigationResponse.response as? HTTPURLResponse,
|
||||
httpResponse.statusCode == 401 || httpResponse.statusCode == 403 {
|
||||
decisionHandler(.cancel)
|
||||
onNavigationError?()
|
||||
return
|
||||
}
|
||||
|
||||
// Check if this response should be downloaded instead of displayed
|
||||
let shouldDownload: Bool = {
|
||||
let mimeType = navigationResponse.response.mimeType ?? ""
|
||||
let urlExtension = navigationResponse.response.url?.pathExtension.lowercased() ?? ""
|
||||
|
||||
if Self.downloadableMIMETypes.contains(mimeType) { return true }
|
||||
if Self.downloadableExtensions.contains(urlExtension) { return true }
|
||||
if !navigationResponse.canShowMIMEType { return true }
|
||||
return false
|
||||
}()
|
||||
|
||||
if shouldDownload {
|
||||
decisionHandler(.download)
|
||||
return
|
||||
}
|
||||
|
||||
decisionHandler(.allow)
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, navigationResponse: WKNavigationResponse, didBecome download: WKDownload) {
|
||||
download.delegate = self
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) {
|
||||
download.delegate = self
|
||||
}
|
||||
|
||||
func webView(
|
||||
_ webView: WKWebView,
|
||||
decidePolicyFor navigationAction: WKNavigationAction,
|
||||
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
|
||||
) {
|
||||
guard let requestURL = navigationAction.request.url else {
|
||||
decisionHandler(.cancel)
|
||||
return
|
||||
}
|
||||
|
||||
// Always allow same-host and about: URLs
|
||||
if requestURL.host == fleetHost || requestURL.scheme == "about" {
|
||||
decisionHandler(.allow)
|
||||
return
|
||||
}
|
||||
|
||||
// During an active SSO flow, allow external IdP redirects in the WebView
|
||||
// but only over HTTPS to protect credentials in transit
|
||||
if ssoFlowActive {
|
||||
if requestURL.scheme?.lowercased() == "https" {
|
||||
decisionHandler(.allow)
|
||||
} else {
|
||||
resetSSOFlow()
|
||||
decisionHandler(.cancel)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Detect SSO: if the Fleet server redirected us to an external host
|
||||
// (server redirect or form submission from Fleet page), start SSO flow.
|
||||
// This covers all SSO scenarios: MDM enrollment, IdP login, etc.
|
||||
if navigationAction.navigationType == .other || navigationAction.navigationType == .formSubmitted {
|
||||
if navigationAction.sourceFrame.request.url?.host == fleetHost,
|
||||
requestURL.scheme?.lowercased() == "https" {
|
||||
ssoFlowActive = true
|
||||
decisionHandler(.allow)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// External links — open in default browser (scheme-validated)
|
||||
openExternalURL(requestURL)
|
||||
decisionHandler(.cancel)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - WKUIDelegate
|
||||
|
||||
extension BrowserWindow: WKUIDelegate {
|
||||
/// Handle links that request a new window (target="_blank", window.open, etc.).
|
||||
/// Same-host links are loaded in the current WebView; external links open in the default browser.
|
||||
func webView(
|
||||
_ webView: WKWebView,
|
||||
createWebViewWith configuration: WKWebViewConfiguration,
|
||||
for navigationAction: WKNavigationAction,
|
||||
windowFeatures: WKWindowFeatures
|
||||
) -> WKWebView? {
|
||||
if let url = navigationAction.request.url {
|
||||
if url.host == fleetHost || ssoFlowActive {
|
||||
webView.load(URLRequest(url: url))
|
||||
} else {
|
||||
openExternalURL(url)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - WKDownloadDelegate
|
||||
|
||||
extension BrowserWindow: WKDownloadDelegate {
|
||||
func download(
|
||||
_ download: WKDownload,
|
||||
decideDestinationUsing response: URLResponse,
|
||||
suggestedFilename: String,
|
||||
completionHandler: @escaping (URL?) -> Void
|
||||
) {
|
||||
let downloadsDir = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first!
|
||||
var destination = downloadsDir.appendingPathComponent(suggestedFilename)
|
||||
|
||||
// Avoid overwriting existing files — append a number if needed (max 999)
|
||||
var counter = 1
|
||||
let baseName = destination.deletingPathExtension().lastPathComponent
|
||||
let ext = destination.pathExtension
|
||||
while FileManager.default.fileExists(atPath: destination.path), counter < 1000 {
|
||||
let newName = ext.isEmpty ? "\(baseName) (\(counter))" : "\(baseName) (\(counter)).\(ext)"
|
||||
destination = downloadsDir.appendingPathComponent(newName)
|
||||
counter += 1
|
||||
}
|
||||
|
||||
completionHandler(destination)
|
||||
}
|
||||
|
||||
func downloadDidFinish(_ download: WKDownload) {
|
||||
guard let url = download.progress.fileURL else { return }
|
||||
|
||||
// Only auto-open .mobileconfig files (MDM enrollment profiles).
|
||||
// All other file types are saved to Downloads without opening,
|
||||
// to avoid automatically executing potentially unsafe files.
|
||||
if url.pathExtension.lowercased() == "mobileconfig" {
|
||||
NSWorkspace.shared.open(url)
|
||||
|
||||
// Navigate back to the Fleet self-service homepage
|
||||
if let homeURL = homeURL {
|
||||
webView?.load(URLRequest(url: homeURL))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func download(_ download: WKDownload, didFailWithError error: Error, resumeData: Data?) {
|
||||
NSLog("Fleet Desktop: Download failed: %@", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Loading Overlay
|
||||
|
||||
/// Draws the window background color, automatically adapting when the
|
||||
/// user switches between dark and light mode.
|
||||
private final class LoadingOverlayView: NSView {
|
||||
override var wantsUpdateLayer: Bool { true }
|
||||
|
||||
override init(frame frameRect: NSRect) {
|
||||
super.init(frame: frameRect)
|
||||
wantsLayer = true
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
wantsLayer = true
|
||||
}
|
||||
|
||||
override func updateLayer() {
|
||||
layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import AppKit
|
||||
|
||||
@main
|
||||
struct FleetDesktopMain {
|
||||
static func main() {
|
||||
let app = NSApplication.shared
|
||||
let delegate = AppDelegate()
|
||||
app.delegate = delegate
|
||||
app.run()
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure AppKit app delegate — no SwiftUI status window.
|
||||
/// Runs FleetService on launch and opens the browser window directly.
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
private let fleetService = FleetService()
|
||||
|
||||
func applicationWillFinishLaunching(_ notification: Notification) {
|
||||
// Register handler for fleet:// URLs before the system delivers them.
|
||||
// On a cold launch via URL, macOS delivers the Apple Event between
|
||||
// willFinishLaunching and didFinishLaunching — registering here ensures
|
||||
// the event is captured and pending state is set before run() is called.
|
||||
NSAppleEventManager.shared().setEventHandler(
|
||||
self,
|
||||
andSelector: #selector(handleURLEvent(_:withReply:)),
|
||||
forEventClass: AEEventClass(kInternetEventClass),
|
||||
andEventID: AEEventID(kAEGetURL)
|
||||
)
|
||||
}
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
setupMainMenu()
|
||||
fleetService.run()
|
||||
}
|
||||
|
||||
func applicationDidBecomeActive(_ notification: Notification) {
|
||||
fleetService.onApplicationDidBecomeActive()
|
||||
}
|
||||
|
||||
@objc private func handleURLEvent(_ event: NSAppleEventDescriptor, withReply reply: NSAppleEventDescriptor) {
|
||||
guard let urlString = event.paramDescriptor(forKeyword: AEKeyword(keyDirectObject))?.stringValue,
|
||||
let url = URL(string: urlString),
|
||||
url.scheme?.lowercased() == "fleet" else {
|
||||
return
|
||||
}
|
||||
fleetService.handleFleetURL(url)
|
||||
}
|
||||
|
||||
// MARK: - Main Menu
|
||||
|
||||
private func setupMainMenu() {
|
||||
let mainMenu = NSMenu()
|
||||
|
||||
// App menu (Fleet Desktop)
|
||||
let appMenuItem = NSMenuItem()
|
||||
let appMenu = NSMenu()
|
||||
appMenu.addItem(withTitle: "About Fleet Desktop", action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), keyEquivalent: "")
|
||||
appMenu.addItem(.separator())
|
||||
appMenu.addItem(withTitle: "Hide Fleet Desktop", action: #selector(NSApplication.hide(_:)), keyEquivalent: "h")
|
||||
let hideOthersItem = appMenu.addItem(withTitle: "Hide Others", action: #selector(NSApplication.hideOtherApplications(_:)), keyEquivalent: "h")
|
||||
hideOthersItem.keyEquivalentModifierMask = [.command, .option]
|
||||
appMenu.addItem(withTitle: "Show All", action: #selector(NSApplication.unhideAllApplications(_:)), keyEquivalent: "")
|
||||
appMenu.addItem(.separator())
|
||||
appMenu.addItem(withTitle: "Quit Fleet Desktop", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
|
||||
appMenuItem.submenu = appMenu
|
||||
mainMenu.addItem(appMenuItem)
|
||||
|
||||
// Edit menu (enables copy/paste/select-all in the web view)
|
||||
let editMenuItem = NSMenuItem()
|
||||
let editMenu = NSMenu(title: "Edit")
|
||||
editMenu.addItem(withTitle: "Undo", action: NSSelectorFromString("undo:"), keyEquivalent: "z")
|
||||
editMenu.addItem(withTitle: "Redo", action: NSSelectorFromString("redo:"), keyEquivalent: "Z")
|
||||
editMenu.addItem(.separator())
|
||||
editMenu.addItem(withTitle: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x")
|
||||
editMenu.addItem(withTitle: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c")
|
||||
editMenu.addItem(withTitle: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v")
|
||||
editMenu.addItem(withTitle: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a")
|
||||
editMenuItem.submenu = editMenu
|
||||
mainMenu.addItem(editMenuItem)
|
||||
|
||||
// View menu
|
||||
let viewMenuItem = NSMenuItem()
|
||||
let viewMenu = NSMenu(title: "View")
|
||||
viewMenu.addItem(withTitle: "Reload Page", action: #selector(reloadPage(_:)), keyEquivalent: "r")
|
||||
viewMenuItem.submenu = viewMenu
|
||||
mainMenu.addItem(viewMenuItem)
|
||||
|
||||
// Window menu
|
||||
let windowMenuItem = NSMenuItem()
|
||||
let windowMenu = NSMenu(title: "Window")
|
||||
windowMenu.addItem(withTitle: "Minimize", action: #selector(NSWindow.performMiniaturize(_:)), keyEquivalent: "m")
|
||||
windowMenu.addItem(withTitle: "Close", action: #selector(NSWindow.performClose(_:)), keyEquivalent: "w")
|
||||
windowMenuItem.submenu = windowMenu
|
||||
mainMenu.addItem(windowMenuItem)
|
||||
|
||||
NSApp.mainMenu = mainMenu
|
||||
NSApp.windowsMenu = windowMenu
|
||||
}
|
||||
|
||||
@objc private func reloadPage(_ sender: Any?) {
|
||||
fleetService.reloadCurrentPage()
|
||||
}
|
||||
|
||||
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
|
||||
// Re-open the browser window when the user clicks the Dock icon
|
||||
if !flag {
|
||||
fleetService.run()
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,604 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
|
||||
/// Core service that reads the Fleet URL from MDM managed preferences and
|
||||
/// the device token from orbit, then opens the self-service portal in a
|
||||
/// browser window. Only MDM-managed machines are supported.
|
||||
///
|
||||
/// The WebView is kept alive when the window is closed, so reopening is instant.
|
||||
/// The token is checked every 60 seconds (and on navigation errors) to handle hourly
|
||||
/// rotation and keep the Dock badge current even when the window is closed.
|
||||
final class FleetService {
|
||||
private var browserWindow: BrowserWindow?
|
||||
|
||||
private let tokenFile: String
|
||||
|
||||
/// Serial queue protecting mutable state (currentToken, retryCount, isSettingUp).
|
||||
private let stateQueue = DispatchQueue(label: "com.fleetdm.fleet-desktop.state")
|
||||
|
||||
/// The base Fleet URL (set once during setup, never changes afterward).
|
||||
/// Access only from stateQueue.
|
||||
private var _baseURL: String?
|
||||
|
||||
/// Current device token (rotates hourly). Access only from stateQueue.
|
||||
private var _currentToken: String?
|
||||
|
||||
/// Guards against concurrent setup calls (e.g., rapid Dock clicks during launch).
|
||||
/// Access only from stateQueue.
|
||||
private var _isSettingUp = false
|
||||
|
||||
/// Timer that periodically checks for token rotation and refreshes the Dock badge.
|
||||
/// Runs for the lifetime of the service (not stopped when the window closes) so the
|
||||
/// badge keeps updating even when the app is Dock-only.
|
||||
private var refreshTimer: Timer?
|
||||
|
||||
/// Activity token that prevents App Nap from throttling the refresh timer when no
|
||||
/// window is visible. Held for the lifetime of the service.
|
||||
private var activityToken: NSObjectProtocol?
|
||||
|
||||
/// How often (in seconds) to check for a new token and refresh the badge.
|
||||
private static let tokenRefreshInterval: TimeInterval = 60
|
||||
|
||||
/// Delay before retrying a token refresh after a navigation error.
|
||||
private static let tokenRetryDelay: TimeInterval = 5
|
||||
|
||||
/// Maximum number of consecutive retry attempts on navigation error.
|
||||
private static let maxRetryAttempts = 3
|
||||
|
||||
/// Current retry count for navigation-error-triggered refreshes. Access only from stateQueue.
|
||||
private var _retryCount = 0
|
||||
|
||||
/// Page requested via fleet:// URL before setup completed. Consumed by setup().
|
||||
/// Access only from stateQueue.
|
||||
private var _pendingPage: String?
|
||||
|
||||
/// Whether a refetch was requested via fleet://refetch before setup completed.
|
||||
/// Access only from stateQueue.
|
||||
private var _pendingRefetch = false
|
||||
|
||||
/// Whether an update-all was requested via fleet://update_all before setup completed.
|
||||
/// Access only from stateQueue.
|
||||
private var _pendingUpdateAll = false
|
||||
|
||||
/// Set when a `fleet://` open needs the browser UI as soon as setup completes (cold launch or still starting).
|
||||
/// Access only from stateQueue.
|
||||
private var _userRequestedFleetUI = false
|
||||
|
||||
/// True after setup if we intentionally skipped the first window show (login item / `open -j`).
|
||||
/// Used to present once when the user foregrounds the app. Main thread only.
|
||||
private var deferredPresentationFromHeadlessLaunch = false
|
||||
|
||||
/// Most recent `failing_policies_count` from the desktop API.
|
||||
/// Access only from stateQueue.
|
||||
private var _lastBadgeCount: Int?
|
||||
|
||||
/// The `failing_policies_count` reflected by the currently loaded web page.
|
||||
/// Compared to `_lastBadgeCount` when the window is shown to detect a stale
|
||||
/// Policies tab (e.g. badge dropped to 0 while the window was closed).
|
||||
/// Access only from stateQueue.
|
||||
private var _pageBadgeCount: Int?
|
||||
|
||||
/// Characters to trim from file contents (leading/trailing only).
|
||||
private static let trimCharacters = CharacterSet(charactersIn: "\n\r ")
|
||||
|
||||
/// Path to the managed preferences plist (MDM-managed machines).
|
||||
private static let managedPrefsPlistPath = "/Library/Managed Preferences/com.fleetdm.fleetd.config.plist"
|
||||
|
||||
init() {
|
||||
let root = ProcessInfo.processInfo.environment["ORBIT_ROOT_DIR"] ?? "/opt/orbit"
|
||||
self.tokenFile = "\(root)/identifier"
|
||||
}
|
||||
|
||||
deinit {
|
||||
refreshTimer?.invalidate()
|
||||
if let token = activityToken {
|
||||
ProcessInfo.processInfo.endActivity(token)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
/// Called when the user wants to see the window (app launch, Dock click, etc.).
|
||||
/// On first call, creates the WebView; the window is shown unless launch was headless
|
||||
/// (`open -j`, hidden login item) and there was no `fleet://` cold open.
|
||||
/// On subsequent calls, brings the existing window forward.
|
||||
func run() {
|
||||
// If already set up, just show the window
|
||||
if let browser = browserWindow, browser.isAvailable {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.deferredPresentationFromHeadlessLaunch = false
|
||||
browser.show()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Prevent concurrent setup calls (thread-safe check)
|
||||
let shouldSetup: Bool = stateQueue.sync {
|
||||
guard !_isSettingUp else { return false }
|
||||
_isSettingUp = true
|
||||
return true
|
||||
}
|
||||
guard shouldSetup else { return }
|
||||
|
||||
// First time — resolve config and set up
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||
self?.setup()
|
||||
}
|
||||
}
|
||||
|
||||
/// Reloads the current page in the browser window (e.g., Cmd+R).
|
||||
func reloadCurrentPage() {
|
||||
guard let browser = browserWindow else { return }
|
||||
DispatchQueue.main.async {
|
||||
browser.reloadCurrent()
|
||||
}
|
||||
}
|
||||
|
||||
/// Pages that can be opened via fleet:// URLs.
|
||||
/// Unrecognized URLs simply bring the app to the foreground.
|
||||
private static let validPages: Set<String> = ["self-service", "policies", "software"]
|
||||
|
||||
/// Handles an incoming fleet:// URL by navigating to the corresponding page.
|
||||
/// e.g. fleet://self-service → self-service tab, fleet://policies → policies tab.
|
||||
/// fleet://refetch triggers a device refetch and opens the app.
|
||||
/// Unrecognized URLs just bring the app to the foreground.
|
||||
func handleFleetURL(_ url: URL) {
|
||||
let browserReady: Bool = stateQueue.sync {
|
||||
guard let b = browserWindow else { return false }
|
||||
return b.isAvailable
|
||||
}
|
||||
if !browserReady {
|
||||
stateQueue.sync { _userRequestedFleetUI = true }
|
||||
}
|
||||
|
||||
let host = url.host?.lowercased()
|
||||
|
||||
// fleet://refetch — fire the refetch POST and bring the app forward
|
||||
if host == "refetch" {
|
||||
let hasConfig: Bool = stateQueue.sync { _baseURL != nil }
|
||||
if hasConfig {
|
||||
performRefetch()
|
||||
} else {
|
||||
stateQueue.sync { _pendingRefetch = true }
|
||||
}
|
||||
run()
|
||||
return
|
||||
}
|
||||
|
||||
// fleet://update_all (or fleet://update-all) — open the self-service page
|
||||
// and click its "Update all" button via the WebView so the install logic
|
||||
// stays defined by Fleet's UI rather than duplicated here.
|
||||
if host == "update_all" || host == "update-all" {
|
||||
let ready: Bool = stateQueue.sync {
|
||||
guard let b = browserWindow else { return false }
|
||||
return b.isAvailable
|
||||
}
|
||||
if ready {
|
||||
triggerUpdateAll()
|
||||
} else {
|
||||
stateQueue.sync { _pendingUpdateAll = true }
|
||||
run()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let page: String? = {
|
||||
guard let host = host, Self.validPages.contains(host) else { return nil }
|
||||
return host
|
||||
}()
|
||||
|
||||
// If the browser is already set up, navigate (or just show) the window
|
||||
if let browser = browserWindow, browser.isAvailable {
|
||||
if let page = page, let target = deviceURL(page: page) {
|
||||
stateQueue.sync { _pageBadgeCount = _lastBadgeCount }
|
||||
DispatchQueue.main.async {
|
||||
browser.reload(url: target)
|
||||
browser.show()
|
||||
}
|
||||
} else {
|
||||
DispatchQueue.main.async {
|
||||
browser.show()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Not yet set up — store the requested page (if valid) and run setup
|
||||
stateQueue.sync { _pendingPage = page }
|
||||
run()
|
||||
}
|
||||
|
||||
/// Sends a POST to the Fleet refetch API endpoint for this device.
|
||||
/// Runs asynchronously; failures are logged but not surfaced to the user.
|
||||
private func performRefetch() {
|
||||
let (base, token): (String?, String?) = stateQueue.sync { (_baseURL, _currentToken) }
|
||||
guard let baseURL = base,
|
||||
let tok = token,
|
||||
let encoded = tok.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed),
|
||||
let url = URL(string: "\(baseURL)/api/v1/fleet/device/\(encoded)/refetch") else {
|
||||
NSLog("Fleet Desktop: Unable to construct refetch URL")
|
||||
return
|
||||
}
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
URLSession.shared.dataTask(with: request) { [weak self] _, response, error in
|
||||
if let error = error {
|
||||
NSLog("Fleet Desktop: Refetch failed: %@", error.localizedDescription)
|
||||
return
|
||||
}
|
||||
if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
|
||||
NSLog("Fleet Desktop: Refetch returned HTTP %d", http.statusCode)
|
||||
return
|
||||
}
|
||||
// Refetch succeeded — poll the badge soon to catch policy changes
|
||||
// (e.g., an app install that causes a policy to pass).
|
||||
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 15) {
|
||||
self?.fetchDesktopData()
|
||||
}
|
||||
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 30) {
|
||||
self?.fetchDesktopData()
|
||||
}
|
||||
}.resume()
|
||||
}
|
||||
|
||||
/// Navigates to the self-service page and clicks its "Update all" button,
|
||||
/// reusing the Fleet UI's own filter/install logic. Called when fleet://update_all
|
||||
/// arrives after the browser has been set up.
|
||||
private func triggerUpdateAll() {
|
||||
guard let target = deviceURL(page: "self-service"),
|
||||
let browser = browserWindow else { return }
|
||||
DispatchQueue.main.async {
|
||||
browser.runOnNextLoad(Self.updateAllJS)
|
||||
browser.reload(url: target)
|
||||
browser.show()
|
||||
}
|
||||
}
|
||||
|
||||
/// JS injected into the self-service page to click its "Update all" button.
|
||||
/// Retries for a few seconds because the React UI mounts asynchronously after
|
||||
/// `didFinish`. Matching on visible button text keeps the install logic owned
|
||||
/// by Fleet's UI rather than duplicated in this app.
|
||||
private static let updateAllJS = """
|
||||
(function() {
|
||||
var attempts = 0;
|
||||
var maxAttempts = 60; // ~30s at 500ms
|
||||
function tryClick() {
|
||||
var btns = document.querySelectorAll('button');
|
||||
for (var i = 0; i < btns.length; i++) {
|
||||
var label = (btns[i].textContent || '').trim();
|
||||
if (label === 'Update all' && !btns[i].disabled) {
|
||||
btns[i].click();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (++attempts < maxAttempts) {
|
||||
setTimeout(tryClick, 500);
|
||||
}
|
||||
}
|
||||
tryClick();
|
||||
})();
|
||||
"""
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
/// Builds a device page URL from the base URL, current token, and page name.
|
||||
/// The token is percent-encoded to handle any special characters safely.
|
||||
/// Defaults to "self-service" if no page is specified.
|
||||
private func deviceURL(page: String = "self-service") -> URL? {
|
||||
let (base, token): (String?, String?) = stateQueue.sync { (_baseURL, _currentToken) }
|
||||
guard let baseURL = base,
|
||||
let tok = token,
|
||||
let encoded = tok.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else {
|
||||
return nil
|
||||
}
|
||||
let encodedPage = page.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? page
|
||||
return URL(string: "\(baseURL)/device/\(encoded)/\(encodedPage)")
|
||||
}
|
||||
|
||||
/// Reads config, creates the BrowserWindow, loads the URL, optionally shows the window,
|
||||
/// and starts the refresh timer.
|
||||
private func setup() {
|
||||
guard resolveConfig() else {
|
||||
stateQueue.sync { _isSettingUp = false }
|
||||
return
|
||||
}
|
||||
|
||||
// Consume pending state on the main queue. handleFleetURL() always runs
|
||||
// on the main thread, so by the time this block executes, any fleet://
|
||||
// URL event that triggered the launch will have already set
|
||||
// _pendingPage / _pendingRefetch.
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
let (requestedPage, shouldRefetch, shouldUpdateAll): (String, Bool, Bool) = self.stateQueue.sync {
|
||||
let p = self._pendingPage ?? "self-service"
|
||||
self._pendingPage = nil
|
||||
let r = self._pendingRefetch
|
||||
self._pendingRefetch = false
|
||||
let u = self._pendingUpdateAll
|
||||
self._pendingUpdateAll = false
|
||||
return (p, r, u)
|
||||
}
|
||||
if shouldRefetch {
|
||||
self.performRefetch()
|
||||
}
|
||||
// Update-all requires the self-service page so the button is in the DOM.
|
||||
let page = shouldUpdateAll ? "self-service" : requestedPage
|
||||
guard let url = self.deviceURL(page: page) else {
|
||||
self.stateQueue.sync { self._isSettingUp = false }
|
||||
self.showError("Unable to construct self-service URL. Check Fleet configuration.")
|
||||
return
|
||||
}
|
||||
|
||||
let browser = BrowserWindow()
|
||||
self.browserWindow = browser
|
||||
|
||||
browser.onNavigationError = { [weak self] in
|
||||
self?.handleNavigationError()
|
||||
}
|
||||
browser.onWindowShow = { [weak self] in
|
||||
self?.refreshTokenIfNeeded()
|
||||
self?.reloadIfPoliciesStale()
|
||||
}
|
||||
|
||||
if shouldUpdateAll {
|
||||
browser.runOnNextLoad(Self.updateAllJS)
|
||||
}
|
||||
browser.preload(url: url)
|
||||
self.startRefreshTimer()
|
||||
|
||||
// Defer the show decision one turn so `NSApp.isActive` reflects hidden login / `open -j`.
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
let userWantsFleetWindow: Bool = self.stateQueue.sync {
|
||||
let v = self._userRequestedFleetUI
|
||||
self._userRequestedFleetUI = false
|
||||
return v
|
||||
}
|
||||
let showNow = NSApp.isActive || userWantsFleetWindow
|
||||
if showNow {
|
||||
browser.show()
|
||||
self.deferredPresentationFromHeadlessLaunch = false
|
||||
} else {
|
||||
self.deferredPresentationFromHeadlessLaunch = true
|
||||
}
|
||||
self.stateQueue.sync { self._isSettingUp = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// After a headless launch, present the window the first time the user foregrounds the app
|
||||
/// (e.g. Cmd-Tab). Dock clicks use `applicationShouldHandleReopen` → `run()` instead.
|
||||
func onApplicationDidBecomeActive() {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
guard self.deferredPresentationFromHeadlessLaunch, NSApp.isActive else { return }
|
||||
guard let browser = self.browserWindow, browser.isAvailable, !browser.isWindowVisible else {
|
||||
self.deferredPresentationFromHeadlessLaunch = false
|
||||
return
|
||||
}
|
||||
browser.show()
|
||||
self.deferredPresentationFromHeadlessLaunch = false
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the Fleet URL and device token. Returns true if successful.
|
||||
private func resolveConfig() -> Bool {
|
||||
guard let fleetURL = readFleetURL() else {
|
||||
showError("This app is currently only supported on MDM-enabled Macs. Please contact your administrator for assistance.")
|
||||
return false
|
||||
}
|
||||
|
||||
stateQueue.sync { _baseURL = fleetURL.hasSuffix("/") ? String(fleetURL.dropLast()) : fleetURL }
|
||||
|
||||
guard let token = readToken() else {
|
||||
showError("Device token not found or could not be read at \(tokenFile).\nEnsure orbit is enrolled and the identifier file exists.")
|
||||
return false
|
||||
}
|
||||
|
||||
stateQueue.sync { _currentToken = token }
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Token Refresh
|
||||
|
||||
/// Starts the refresh timer and declares an ongoing activity so App Nap
|
||||
/// doesn't throttle the timer when the window is closed. Called once at
|
||||
/// setup time; the timer runs for the lifetime of the service.
|
||||
private func startRefreshTimer() {
|
||||
refreshTimer?.invalidate()
|
||||
let timer = Timer.scheduledTimer(withTimeInterval: Self.tokenRefreshInterval, repeats: true) { [weak self] _ in
|
||||
self?.refreshTokenIfNeeded()
|
||||
self?.fetchDesktopData()
|
||||
}
|
||||
timer.tolerance = 5 // Allow system to coalesce for energy efficiency
|
||||
refreshTimer = timer
|
||||
|
||||
// Prevent App Nap so the timer keeps firing (and the Dock badge stays
|
||||
// current) when the window is closed.
|
||||
if activityToken == nil {
|
||||
activityToken = ProcessInfo.processInfo.beginActivity(
|
||||
options: .userInitiatedAllowingIdleSystemSleep,
|
||||
reason: "Fleet Desktop badge polling"
|
||||
)
|
||||
}
|
||||
|
||||
// Fetch the badge count immediately so the first update doesn't wait
|
||||
// for the full 60-second interval.
|
||||
fetchDesktopData()
|
||||
}
|
||||
|
||||
/// Re-reads the token file. If the token has changed, silently reloads the browser with the new URL.
|
||||
private func refreshTokenIfNeeded() {
|
||||
guard let newToken = readToken(), let browser = browserWindow else { return }
|
||||
|
||||
let changed: Bool = stateQueue.sync {
|
||||
guard newToken != _currentToken else { return false }
|
||||
_currentToken = newToken
|
||||
_retryCount = 0
|
||||
return true
|
||||
}
|
||||
guard changed else { return }
|
||||
guard let url = deviceURL() else { return }
|
||||
|
||||
stateQueue.sync { _pageBadgeCount = _lastBadgeCount }
|
||||
DispatchQueue.main.async {
|
||||
browser.reload(url: url)
|
||||
}
|
||||
}
|
||||
|
||||
/// Called when the browser encounters a navigation error (e.g., expired token).
|
||||
/// Attempts to refresh the token, with retry logic if the file hasn't changed yet.
|
||||
private func handleNavigationError() {
|
||||
let oldToken: String? = stateQueue.sync { _currentToken }
|
||||
|
||||
// First, try an immediate refresh
|
||||
if let newToken = readToken(), newToken != oldToken {
|
||||
stateQueue.sync {
|
||||
_currentToken = newToken
|
||||
_retryCount = 0
|
||||
}
|
||||
if let url = deviceURL(), let browser = browserWindow {
|
||||
DispatchQueue.main.async { browser.reload(url: url) }
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Token hasn't changed yet — retry with delay (up to maxRetryAttempts)
|
||||
let shouldRetry: Bool = stateQueue.sync {
|
||||
guard _retryCount < Self.maxRetryAttempts else {
|
||||
_retryCount = 0
|
||||
return false
|
||||
}
|
||||
_retryCount += 1
|
||||
return true
|
||||
}
|
||||
guard shouldRetry else { return }
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + Self.tokenRetryDelay) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
// Re-read token; if it changed, refreshTokenIfNeeded will reload
|
||||
self.refreshTokenIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Badge Polling
|
||||
|
||||
/// Fetches the desktop API endpoint and updates the Dock badge.
|
||||
private func fetchDesktopData() {
|
||||
let (base, token): (String?, String?) = stateQueue.sync { (_baseURL, _currentToken) }
|
||||
guard let baseURL = base,
|
||||
let tok = token,
|
||||
let encoded = tok.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed),
|
||||
let url = URL(string: "\(baseURL)/api/v1/fleet/device/\(encoded)/desktop") else {
|
||||
return
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "GET"
|
||||
URLSession.shared.dataTask(with: request) { [weak self] data, response, error in
|
||||
if let error = error {
|
||||
NSLog("Fleet Desktop: Badge poll failed: %@", error.localizedDescription)
|
||||
return
|
||||
}
|
||||
if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
|
||||
if http.statusCode != 401 && http.statusCode != 403 {
|
||||
NSLog("Fleet Desktop: Badge poll returned HTTP %d", http.statusCode)
|
||||
}
|
||||
return
|
||||
}
|
||||
guard let data = data else { return }
|
||||
self?.updateBadge(from: data)
|
||||
}.resume()
|
||||
}
|
||||
|
||||
/// Parses the desktop API response and sets the Dock badge label.
|
||||
private func updateBadge(from data: Data) {
|
||||
struct DesktopResponse: Decodable {
|
||||
let failing_policies_count: Int
|
||||
}
|
||||
|
||||
do {
|
||||
let response = try JSONDecoder().decode(DesktopResponse.self, from: data)
|
||||
let count = response.failing_policies_count
|
||||
let label: String? = count > 0 ? "\(count)" : nil
|
||||
stateQueue.sync {
|
||||
_lastBadgeCount = count
|
||||
// On the first successful poll, seed the page-state count too —
|
||||
// the loaded web page reflects Fleet state at this same moment.
|
||||
if _pageBadgeCount == nil {
|
||||
_pageBadgeCount = count
|
||||
}
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
NSApp.dockTile.badgeLabel = label
|
||||
}
|
||||
} catch {
|
||||
NSLog("Fleet Desktop: Failed to decode desktop response: %@", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// Reloads the web view when the badge count differs from what the currently
|
||||
/// loaded page is showing — e.g. user closed the window with 1 failing policy,
|
||||
/// the badge later dropped to 0, and they're reopening to see the change.
|
||||
private func reloadIfPoliciesStale() {
|
||||
let (current, rendered): (Int?, Int?) = stateQueue.sync {
|
||||
(_lastBadgeCount, _pageBadgeCount)
|
||||
}
|
||||
guard let current = current,
|
||||
let rendered = rendered,
|
||||
current != rendered,
|
||||
let browser = browserWindow else { return }
|
||||
stateQueue.sync { _pageBadgeCount = current }
|
||||
DispatchQueue.main.async {
|
||||
browser.reloadCurrent()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - File Reading
|
||||
|
||||
/// Reads the Fleet URL from managed preferences (MDM).
|
||||
/// Only MDM-managed machines are supported.
|
||||
private func readFleetURL() -> String? {
|
||||
guard let plist = NSDictionary(contentsOfFile: Self.managedPrefsPlistPath),
|
||||
let url = plist["FleetURL"] as? String else {
|
||||
return nil
|
||||
}
|
||||
let trimmed = url.trimmingCharacters(in: Self.trimCharacters)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private func readToken() -> String? {
|
||||
return readFileTrimmed(path: tokenFile)
|
||||
}
|
||||
|
||||
private func readFileTrimmed(path: String) -> String? {
|
||||
guard let data = FileManager.default.contents(atPath: path),
|
||||
let raw = String(data: data, encoding: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
let trimmed = raw.trimmingCharacters(in: Self.trimCharacters)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
// MARK: - Error Display
|
||||
|
||||
private func showError(_ message: String) {
|
||||
let work = {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = BrowserWindow.windowTitle
|
||||
alert.informativeText = message
|
||||
alert.alertStyle = .critical
|
||||
alert.addButton(withTitle: "Quit")
|
||||
alert.runModal()
|
||||
NSApp.terminate(nil)
|
||||
}
|
||||
|
||||
if Thread.isMainThread {
|
||||
work()
|
||||
} else {
|
||||
DispatchQueue.main.sync { work() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?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>CFBundleName</key>
|
||||
<string>Fleet Desktop</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Fleet Desktop</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.fleetdm.fleet-desktop</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>6</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.3.1</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>FleetDesktop</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>AppIcon</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>13.0</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.fleetdm.fleet-desktop.url</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>fleet</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,164 @@
|
||||
# Fleet Desktop (macOS)
|
||||
|
||||
A native macOS application that provides end users with a self-service portal for [Fleet](https://fleetdm.com). It integrates with Fleet's [orbit](https://fleetdm.com/docs/get-started/anatomy#orbit) agent to give users direct access to device management features in a native window instead of a browser.
|
||||
|
||||
> **Heads up — two things named "Fleet Desktop":** Fleet's agent already ships a tray/menu-bar component called Fleet Desktop (bundle ID `com.fleetdm.desktop`, built from `orbit/cmd/desktop`). This is a separate, standalone native app (bundle ID `com.fleetdm.fleet-desktop`) distributed as its own `.pkg`. They use different bundle IDs and can coexist.
|
||||
|
||||
## Features
|
||||
|
||||
- **Native macOS app** built with Swift and AppKit
|
||||
- **Universal binary** supporting Apple Silicon (arm64) and Intel (x86_64)
|
||||
- **Self-service portal** embedded in a native window via WKWebView
|
||||
- **Automatic token refresh** handles hourly token rotation transparently
|
||||
- **Loading screen** with Fleet logo while the portal loads
|
||||
- **File download support** for `.mobileconfig` profiles and other files served by Fleet
|
||||
- **Dark/light mode** respects the user's system appearance
|
||||
- **`fleet://` URL scheme** for deep linking to Self-service, Policies, and triggering refetches
|
||||
- **MDM required** — both the app and installer enforce MDM enrollment
|
||||
- **Code signed and notarized** for secure distribution via `.pkg` installer
|
||||
|
||||
## Requirements
|
||||
|
||||
- macOS 13.0 (Ventura) or later
|
||||
- MDM-enabled Mac with Fleet's managed preferences profile installed
|
||||
- Fleet's orbit agent installed and enrolled
|
||||
- The orbit identifier file must exist at `/opt/orbit/identifier`
|
||||
|
||||
## Installation
|
||||
|
||||
The signed, notarized `.pkg` is produced by CI (see [CI/CD](#cicd)) and uploaded as a workflow artifact. To deploy:
|
||||
|
||||
- **Via Fleet (Software):** upload the `.pkg` to Fleet as a software installer. Fleet Desktop will appear in the software catalog for deployment.
|
||||
- **Manually:** double-click the `.pkg` and follow the installer.
|
||||
|
||||
The installer requires an MDM-enabled Mac. It checks for the Fleet managed preferences profile before proceeding — if the profile is not found, the installer displays an error and aborts. The app is placed in `/Applications` with `root:admin` ownership and `755` permissions. On upgrades, the installer gracefully quits Fleet Desktop before installing and automatically relaunches it afterward.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Reads the Fleet URL** from MDM managed preferences (see [Configuration Sources](#configuration-sources))
|
||||
2. **Reads the device token** from `/opt/orbit/identifier` (managed by orbit, rotates hourly)
|
||||
3. **Opens the self-service portal** at `{FleetURL}/device/{token}/self-service` in an embedded browser window
|
||||
|
||||
### Token Rotation
|
||||
|
||||
The device token in `/opt/orbit/identifier` rotates every hour. Fleet Desktop handles this automatically:
|
||||
|
||||
- A background timer checks the identifier file every 60 seconds (and keeps the Dock badge current even when the window is closed)
|
||||
- On HTTP 401/403 errors or error page detection, the app immediately checks for a new token and retries (up to 3 attempts with 5-second delays)
|
||||
- Token refreshes are invisible to the user — the page silently reloads with the new token
|
||||
|
||||
### File Downloads
|
||||
|
||||
When Fleet serves downloadable content (e.g., MDM enrollment profiles):
|
||||
|
||||
- `.mobileconfig` files are downloaded and automatically opened for installation
|
||||
- All other file types (`.pkg`, `.dmg`, `.zip`, etc.) are saved to `~/Downloads`
|
||||
|
||||
### Security
|
||||
|
||||
- App Transport Security (ATS) is enforced for the in-app WebView — the embedded portal requires HTTPS
|
||||
- External links are restricted to `https`, `http`, and `mailto` schemes
|
||||
- Device tokens are percent-encoded and not exposed in error messages
|
||||
- Downloaded files are only auto-opened if they are `.mobileconfig` profiles
|
||||
- The WebView uses a non-persistent data store (no cookies or cache persist between sessions)
|
||||
- Mutable state is protected by a serial dispatch queue for thread safety
|
||||
|
||||
## Development
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
apps/fleet-desktop-macos/
|
||||
├── FleetDesktop/
|
||||
│ ├── FleetDesktopApp.swift # App delegate, main menu, entry point
|
||||
│ ├── FleetService.swift # Config reading, token management, refresh timer
|
||||
│ ├── BrowserWindow.swift # WKWebView window, loading overlay, downloads
|
||||
│ ├── Info.plist # App bundle metadata
|
||||
│ ├── AppIcon.icns # App icon
|
||||
│ └── fleet-logo.png # Fleet logo for loading screen
|
||||
├── build.sh # Compiles universal binary
|
||||
└── build-pkg.sh # Creates the .pkg installer
|
||||
```
|
||||
|
||||
The CI workflow lives at [`.github/workflows/fleet-desktop-macos-build.yml`](../../.github/workflows/fleet-desktop-macos-build.yml).
|
||||
|
||||
### Building Locally
|
||||
|
||||
```bash
|
||||
# Build the app
|
||||
./build.sh
|
||||
|
||||
# Run
|
||||
open "build/Fleet Desktop.app"
|
||||
|
||||
# Build the (unsigned) .pkg installer
|
||||
./build-pkg.sh
|
||||
```
|
||||
|
||||
Local builds are unsigned. Signing and notarization happen in CI with Fleet's Developer ID certificates.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `ORBIT_ROOT_DIR` | `/opt/orbit` | Override the orbit directory (changes where the identifier file is read from) |
|
||||
|
||||
### Configuration Sources
|
||||
|
||||
| File | Key | Purpose |
|
||||
|------|-----|---------|
|
||||
| `/Library/Managed Preferences/com.fleetdm.fleetd.config.plist` | `FleetURL` | Fleet server URL (delivered via MDM profile) |
|
||||
| `/opt/orbit/identifier` | — | Device authentication token (rotates hourly) |
|
||||
|
||||
> **Note:** Fleet Desktop only supports MDM-enabled Macs. If the managed preferences file is not present, the app displays an error and the installer refuses to proceed.
|
||||
|
||||
### URL Scheme
|
||||
|
||||
Fleet Desktop registers the `fleet://` URL scheme, allowing other tools and scripts to open specific pages:
|
||||
|
||||
| URL | Action |
|
||||
|-----|--------|
|
||||
| `fleet://self-service` | Opens the Self-service tab |
|
||||
| `fleet://software` | Opens the Software tab |
|
||||
| `fleet://policies` | Opens the Policies tab |
|
||||
| `fleet://refetch` | Triggers a device refetch and opens the app |
|
||||
| `fleet://update_all` | Opens Self-service and clicks "Update all" |
|
||||
| `fleet://anything-else` | Brings the app to the foreground |
|
||||
|
||||
Example usage from a script or terminal:
|
||||
|
||||
```bash
|
||||
open fleet://self-service
|
||||
open fleet://refetch
|
||||
```
|
||||
|
||||
## CI/CD
|
||||
|
||||
[`.github/workflows/fleet-desktop-macos-build.yml`](../../.github/workflows/fleet-desktop-macos-build.yml) runs on pull requests touching `apps/fleet-desktop-macos/**`, on push to `main`, and via manual dispatch. It:
|
||||
|
||||
1. Compiles a universal binary (arm64 + x86_64)
|
||||
2. Code signs the app with Fleet's Developer ID Application certificate
|
||||
3. Packages into a `.pkg` installer with a custom distribution XML
|
||||
4. Signs the `.pkg` with Fleet's Developer ID Installer certificate
|
||||
5. Notarizes with Apple and staples the ticket
|
||||
6. Uploads the signed `.pkg` as a workflow artifact (retained for 30 days)
|
||||
|
||||
Pull requests (including from forks) only run step 1 — they verify the app compiles and packages, but skip signing/notarization, which require secrets unavailable to forks.
|
||||
|
||||
### Signing secrets
|
||||
|
||||
The workflow reuses the same repository secrets already used by Fleet's other macOS build workflows — **no new secrets are required**:
|
||||
|
||||
| Secret | Purpose |
|
||||
|--------|---------|
|
||||
| `APPLE_APPLICATION_CERTIFICATE` / `..._PASSWORD` | Developer ID Application certificate (.p12, base64) + password |
|
||||
| `APPLE_INSTALLER_CERTIFICATE` / `..._PASSWORD` | Developer ID Installer certificate (.p12, base64) + password |
|
||||
| `APPLE_USERNAME` / `APPLE_PASSWORD` | Apple ID + app-specific password for notarization |
|
||||
| `APPLE_TEAM_ID` | Apple Developer Team ID |
|
||||
| `KEYCHAIN_PASSWORD` | Temporary CI keychain password |
|
||||
|
||||
The Developer ID certificate identities (SHA-1) are pinned in the workflow `env` block, matching the identities used by Fleet's orbit and fleetd-base builds.
|
||||
|
||||
## License
|
||||
|
||||
Licensed under the MIT Expat license via the repository [root LICENSE](../LICENSE).
|
||||
Executable
+183
@@ -0,0 +1,183 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
BUILD_DIR="$SCRIPT_DIR/build"
|
||||
APP_DIR="$BUILD_DIR/Fleet Desktop.app"
|
||||
PKG_DIR="$BUILD_DIR/pkg"
|
||||
DIST_DIR="$BUILD_DIR/dist"
|
||||
|
||||
# Only build if app doesn't exist or if FORCE_REBUILD is set
|
||||
if [ ! -d "$APP_DIR" ] || [ "${FORCE_REBUILD:-}" = "1" ]; then
|
||||
echo "Building Fleet Desktop app..."
|
||||
bash "$SCRIPT_DIR/build.sh"
|
||||
else
|
||||
echo "Using existing app at $APP_DIR (skip rebuild)"
|
||||
# Verify the app is signed if it exists
|
||||
if codesign --verify "$APP_DIR" &>/dev/null; then
|
||||
echo "App is already signed, using as-is"
|
||||
else
|
||||
echo "Warning: App exists but is not signed"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Preparing package structure..."
|
||||
rm -rf "$PKG_DIR" "$DIST_DIR"
|
||||
mkdir -p "$PKG_DIR/Applications"
|
||||
# Use ditto to preserve extended attributes and signatures
|
||||
ditto "$APP_DIR" "$PKG_DIR/Applications/Fleet Desktop.app"
|
||||
|
||||
# Create preinstall script to check MDM and quit the app if running
|
||||
cat > "$PKG_DIR/preinstall" << 'PREINSTALL_EOF'
|
||||
#!/bin/bash
|
||||
# Preinstall script: verify MDM enrollment, gracefully quit Fleet Desktop
|
||||
# if it is running, and track its state so postinstall can relaunch it.
|
||||
|
||||
MDM_PLIST="/Library/Managed Preferences/com.fleetdm.fleetd.config.plist"
|
||||
if [ ! -f "$MDM_PLIST" ]; then
|
||||
echo "ERROR: Fleet Desktop requires an MDM-enabled Mac." >&2
|
||||
echo "The managed preferences file was not found at: $MDM_PLIST" >&2
|
||||
echo "Please enroll this device via MDM before installing Fleet Desktop." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BUNDLE_ID="com.fleetdm.fleet-desktop"
|
||||
# Root-owned, not world-writable, so it isn't open to the symlink/TOCTOU races
|
||||
# that /tmp would be. Cleared at boot, which is fine — the flag only needs to
|
||||
# survive between preinstall and postinstall of a single installer run.
|
||||
RUNNING_FLAG="/var/run/.fleet_desktop_was_running"
|
||||
|
||||
# Clean up any stale flag from a previous install
|
||||
rm -f "$RUNNING_FLAG"
|
||||
|
||||
# Check if a GUI user is logged in (osascript won't work otherwise)
|
||||
console_user=$(stat -f "%Su" /dev/console 2>/dev/null || echo "root")
|
||||
if [[ "$console_user" == "root" || "$console_user" == "loginwindow" ]]; then
|
||||
# No GUI session — nothing to quit or relaunch
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check if the app is running
|
||||
if osascript -e "application id \"$BUNDLE_ID\" is running" 2>/dev/null | grep -qi "true"; then
|
||||
# Mark that it was running so postinstall can relaunch
|
||||
touch "$RUNNING_FLAG"
|
||||
|
||||
# Attempt graceful quit
|
||||
osascript -e "tell application id \"$BUNDLE_ID\" to quit" 2>/dev/null || true
|
||||
|
||||
# Wait up to 10 seconds for the process to exit
|
||||
for i in $(seq 1 10); do
|
||||
if ! pgrep -f "$BUNDLE_ID" >/dev/null 2>&1 && ! pgrep -x "FleetDesktop" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Force kill if still running
|
||||
if pgrep -x "FleetDesktop" >/dev/null 2>&1; then
|
||||
pkill -x "FleetDesktop" 2>/dev/null || true
|
||||
sleep 1
|
||||
fi
|
||||
fi
|
||||
|
||||
exit 0
|
||||
PREINSTALL_EOF
|
||||
|
||||
chmod +x "$PKG_DIR/preinstall"
|
||||
|
||||
# Create postinstall script to set ownership/permissions and relaunch if needed
|
||||
cat > "$PKG_DIR/postinstall" << 'POSTINSTALL_EOF'
|
||||
#!/bin/bash
|
||||
# Postinstall script: set ownership/permissions and relaunch if the app was running
|
||||
|
||||
APP_PATH="/Applications/Fleet Desktop.app"
|
||||
BUNDLE_ID="com.fleetdm.fleet-desktop"
|
||||
RUNNING_FLAG="/var/run/.fleet_desktop_was_running"
|
||||
|
||||
# Set ownership to root:admin
|
||||
chown -R root:admin "$APP_PATH"
|
||||
|
||||
# Set permissions to 755
|
||||
chmod -R 755 "$APP_PATH"
|
||||
|
||||
# Ensure the executable has proper permissions
|
||||
chmod +x "$APP_PATH/Contents/MacOS/FleetDesktop"
|
||||
|
||||
# Relaunch the app if it was running before the install
|
||||
if [ -f "$RUNNING_FLAG" ]; then
|
||||
rm -f "$RUNNING_FLAG"
|
||||
|
||||
# Check if a GUI user is logged in
|
||||
console_user=$(stat -f "%Su" /dev/console 2>/dev/null || echo "root")
|
||||
if [[ "$console_user" != "root" && "$console_user" != "loginwindow" ]]; then
|
||||
# Open the app as the console user (not as root)
|
||||
sudo -u "$console_user" open "$APP_PATH" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
exit 0
|
||||
POSTINSTALL_EOF
|
||||
|
||||
chmod +x "$PKG_DIR/postinstall"
|
||||
|
||||
# Extract version from Info.plist
|
||||
VERSION=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "$APP_DIR/Contents/Info.plist")
|
||||
PKG_NAME="fleet_desktop-v${VERSION}.pkg"
|
||||
|
||||
echo "Building component package..."
|
||||
mkdir -p "$DIST_DIR"
|
||||
COMPONENT_PKG="$BUILD_DIR/fleet-desktop-component.pkg"
|
||||
pkgbuild \
|
||||
--root "$PKG_DIR/Applications" \
|
||||
--scripts "$PKG_DIR" \
|
||||
--identifier com.fleetdm.fleet-desktop \
|
||||
--version "${VERSION}" \
|
||||
--install-location /Applications \
|
||||
"$COMPONENT_PKG"
|
||||
|
||||
# Create distribution XML for custom installer title
|
||||
DIST_XML="$BUILD_DIR/distribution.xml"
|
||||
cat > "$DIST_XML" << DIST_EOF
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<installer-gui-script minSpecVersion="2">
|
||||
<title>Fleet Desktop v${VERSION}</title>
|
||||
<options customize="never" require-scripts="false" hostArchitectures="x86_64,arm64"/>
|
||||
<installation-check script="mdm_check()"/>
|
||||
<script>
|
||||
function mdm_check() {
|
||||
if (system.files.fileExistsAtPath('/Library/Managed Preferences/com.fleetdm.fleetd.config.plist')) {
|
||||
return true;
|
||||
}
|
||||
my.result.title = 'Installation Failed';
|
||||
my.result.message = 'Fleet Desktop requires an MDM-enabled Mac. Please enroll this device via MDM before installing Fleet Desktop.';
|
||||
my.result.type = 'Fatal';
|
||||
return false;
|
||||
}
|
||||
</script>
|
||||
<choices-outline>
|
||||
<line choice="default"/>
|
||||
</choices-outline>
|
||||
<choice id="default" title="Fleet Desktop">
|
||||
<pkg-ref id="com.fleetdm.fleet-desktop"/>
|
||||
</choice>
|
||||
<pkg-ref id="com.fleetdm.fleet-desktop" version="${VERSION}" onConclusion="none">fleet-desktop-component.pkg</pkg-ref>
|
||||
</installer-gui-script>
|
||||
DIST_EOF
|
||||
|
||||
echo "Building product package with custom installer title..."
|
||||
productbuild \
|
||||
--distribution "$DIST_XML" \
|
||||
--package-path "$BUILD_DIR" \
|
||||
"$DIST_DIR/$PKG_NAME"
|
||||
|
||||
# Clean up component package
|
||||
rm -f "$COMPONENT_PKG"
|
||||
|
||||
echo "Package created: $DIST_DIR/$PKG_NAME"
|
||||
|
||||
# Output for GitHub Actions (if running in CI)
|
||||
if [ -n "${GITHUB_OUTPUT:-}" ]; then
|
||||
echo "PKG_PATH=$DIST_DIR/$PKG_NAME" >> "$GITHUB_OUTPUT"
|
||||
echo "PKG_NAME=$PKG_NAME" >> "$GITHUB_OUTPUT"
|
||||
echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
Executable
+51
@@ -0,0 +1,51 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
SRC_DIR="$SCRIPT_DIR/FleetDesktop"
|
||||
BUILD_DIR="$SCRIPT_DIR/build"
|
||||
APP_DIR="$BUILD_DIR/Fleet Desktop.app"
|
||||
CONTENTS_DIR="$APP_DIR/Contents"
|
||||
MACOS_DIR="$CONTENTS_DIR/MacOS"
|
||||
|
||||
echo "Building Fleet Desktop..."
|
||||
|
||||
rm -rf "$BUILD_DIR"
|
||||
mkdir -p "$MACOS_DIR"
|
||||
|
||||
SOURCES=(
|
||||
"$SRC_DIR/FleetService.swift"
|
||||
"$SRC_DIR/BrowserWindow.swift"
|
||||
"$SRC_DIR/FleetDesktopApp.swift"
|
||||
)
|
||||
SDK="$(xcrun --show-sdk-path)"
|
||||
SWIFT_FLAGS=(-sdk "$SDK" -parse-as-library -O)
|
||||
|
||||
# Build for arm64
|
||||
swiftc -target arm64-apple-macos13 "${SWIFT_FLAGS[@]}" \
|
||||
-o "$BUILD_DIR/FleetDesktop-arm64" "${SOURCES[@]}"
|
||||
|
||||
# Build for x86_64
|
||||
swiftc -target x86_64-apple-macos13 "${SWIFT_FLAGS[@]}" \
|
||||
-o "$BUILD_DIR/FleetDesktop-x86_64" "${SOURCES[@]}"
|
||||
|
||||
# Create universal binary
|
||||
lipo -create \
|
||||
"$BUILD_DIR/FleetDesktop-arm64" \
|
||||
"$BUILD_DIR/FleetDesktop-x86_64" \
|
||||
-output "$MACOS_DIR/FleetDesktop"
|
||||
|
||||
rm "$BUILD_DIR/FleetDesktop-arm64" "$BUILD_DIR/FleetDesktop-x86_64"
|
||||
|
||||
# Copy Info.plist
|
||||
cp "$SRC_DIR/Info.plist" "$CONTENTS_DIR/Info.plist"
|
||||
|
||||
# Copy app icon and Fleet logo into Resources
|
||||
mkdir -p "$CONTENTS_DIR/Resources"
|
||||
cp "$SRC_DIR/AppIcon.icns" "$CONTENTS_DIR/Resources/AppIcon.icns"
|
||||
if [ -f "$SRC_DIR/fleet-logo.png" ]; then
|
||||
cp "$SRC_DIR/fleet-logo.png" "$CONTENTS_DIR/Resources/fleet-logo.png"
|
||||
fi
|
||||
|
||||
echo "Build complete: $APP_DIR"
|
||||
echo "Run with: open \"$APP_DIR\""
|
||||
Reference in New Issue
Block a user