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:
Jordan Montgomery
2026-06-15 10:18:09 -04:00
committed by GitHub
co-authored by Copilot Autofix powered by AI
parent 19f0df6e3e
commit 3f6ce1e4bf
12 changed files with 1844 additions and 0 deletions
@@ -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
+5
View File
@@ -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

+164
View File
@@ -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).
+183
View File
@@ -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
+51
View File
@@ -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\""
+1
View File
@@ -392,4 +392,5 @@ ignore (
./handbook
./it-and-security
./node_modules
./apps
)