diff --git a/.github/workflows/fleet-desktop-macos-build.yml b/.github/workflows/fleet-desktop-macos-build.yml new file mode 100644 index 0000000000..8c04d8a554 --- /dev/null +++ b/.github/workflows/fleet-desktop-macos-build.yml @@ -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 diff --git a/apps/fleet-desktop-macos/.gitignore b/apps/fleet-desktop-macos/.gitignore new file mode 100644 index 0000000000..c2b64c8946 --- /dev/null +++ b/apps/fleet-desktop-macos/.gitignore @@ -0,0 +1,5 @@ +# macOS +.DS_Store + +# Build output +build/ diff --git a/apps/fleet-desktop-macos/FleetDesktop/AppIcon.icns b/apps/fleet-desktop-macos/FleetDesktop/AppIcon.icns new file mode 100644 index 0000000000..fa725ecea6 Binary files /dev/null and b/apps/fleet-desktop-macos/FleetDesktop/AppIcon.icns differ diff --git a/apps/fleet-desktop-macos/FleetDesktop/BrowserWindow.swift b/apps/fleet-desktop-macos/FleetDesktop/BrowserWindow.swift new file mode 100644 index 0000000000..0ea0ae3905 --- /dev/null +++ b/apps/fleet-desktop-macos/FleetDesktop/BrowserWindow.swift @@ -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 = [ + "mobileconfig", "pkg", "dmg", "zip", "tar", "gz", "pdf" + ] + + /// MIME types that should be downloaded rather than displayed. + private static let downloadableMIMETypes: Set = [ + "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 = ["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 + } +} diff --git a/apps/fleet-desktop-macos/FleetDesktop/FleetDesktopApp.swift b/apps/fleet-desktop-macos/FleetDesktop/FleetDesktopApp.swift new file mode 100644 index 0000000000..c755aed454 --- /dev/null +++ b/apps/fleet-desktop-macos/FleetDesktop/FleetDesktopApp.swift @@ -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 + } +} diff --git a/apps/fleet-desktop-macos/FleetDesktop/FleetService.swift b/apps/fleet-desktop-macos/FleetDesktop/FleetService.swift new file mode 100644 index 0000000000..1d52779bea --- /dev/null +++ b/apps/fleet-desktop-macos/FleetDesktop/FleetService.swift @@ -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 = ["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() } + } + } +} diff --git a/apps/fleet-desktop-macos/FleetDesktop/Info.plist b/apps/fleet-desktop-macos/FleetDesktop/Info.plist new file mode 100644 index 0000000000..87dc536012 --- /dev/null +++ b/apps/fleet-desktop-macos/FleetDesktop/Info.plist @@ -0,0 +1,37 @@ + + + + + CFBundleName + Fleet Desktop + CFBundleDisplayName + Fleet Desktop + CFBundleIdentifier + com.fleetdm.fleet-desktop + CFBundleVersion + 6 + CFBundleShortVersionString + 1.3.1 + CFBundlePackageType + APPL + CFBundleExecutable + FleetDesktop + CFBundleIconFile + AppIcon + LSMinimumSystemVersion + 13.0 + NSHighResolutionCapable + + CFBundleURLTypes + + + CFBundleURLName + com.fleetdm.fleet-desktop.url + CFBundleURLSchemes + + fleet + + + + + diff --git a/apps/fleet-desktop-macos/FleetDesktop/fleet-logo.png b/apps/fleet-desktop-macos/FleetDesktop/fleet-logo.png new file mode 100644 index 0000000000..4201265553 Binary files /dev/null and b/apps/fleet-desktop-macos/FleetDesktop/fleet-logo.png differ diff --git a/apps/fleet-desktop-macos/README.md b/apps/fleet-desktop-macos/README.md new file mode 100644 index 0000000000..54deceb221 --- /dev/null +++ b/apps/fleet-desktop-macos/README.md @@ -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). diff --git a/apps/fleet-desktop-macos/build-pkg.sh b/apps/fleet-desktop-macos/build-pkg.sh new file mode 100755 index 0000000000..4b8f201847 --- /dev/null +++ b/apps/fleet-desktop-macos/build-pkg.sh @@ -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 + + + Fleet Desktop v${VERSION} + + + + + + + + + + fleet-desktop-component.pkg + +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 diff --git a/apps/fleet-desktop-macos/build.sh b/apps/fleet-desktop-macos/build.sh new file mode 100755 index 0000000000..b42c9c5274 --- /dev/null +++ b/apps/fleet-desktop-macos/build.sh @@ -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\"" diff --git a/go.mod b/go.mod index 0b9adf6681..9524d8f294 100644 --- a/go.mod +++ b/go.mod @@ -392,4 +392,5 @@ ignore ( ./handbook ./it-and-security ./node_modules + ./apps )