From 3f6ce1e4bf17c830ad7c5ce29b62e12c40dacd13 Mon Sep 17 00:00:00 2001 From: Jordan Montgomery Date: Mon, 15 Jun 2026 10:18:09 -0400 Subject: [PATCH] Initial move of fleet desktop app (#47181) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **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 Screenshot 2026-06-09 at 4 27 59 PM ## 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. --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../workflows/fleet-desktop-macos-build.yml | 167 +++++ apps/fleet-desktop-macos/.gitignore | 5 + .../FleetDesktop/AppIcon.icns | Bin 0 -> 124028 bytes .../FleetDesktop/BrowserWindow.swift | 517 +++++++++++++++ .../FleetDesktop/FleetDesktopApp.swift | 115 ++++ .../FleetDesktop/FleetService.swift | 604 ++++++++++++++++++ .../FleetDesktop/Info.plist | 37 ++ .../FleetDesktop/fleet-logo.png | Bin 0 -> 1767 bytes apps/fleet-desktop-macos/README.md | 164 +++++ apps/fleet-desktop-macos/build-pkg.sh | 183 ++++++ apps/fleet-desktop-macos/build.sh | 51 ++ go.mod | 1 + 12 files changed, 1844 insertions(+) create mode 100644 .github/workflows/fleet-desktop-macos-build.yml create mode 100644 apps/fleet-desktop-macos/.gitignore create mode 100644 apps/fleet-desktop-macos/FleetDesktop/AppIcon.icns create mode 100644 apps/fleet-desktop-macos/FleetDesktop/BrowserWindow.swift create mode 100644 apps/fleet-desktop-macos/FleetDesktop/FleetDesktopApp.swift create mode 100644 apps/fleet-desktop-macos/FleetDesktop/FleetService.swift create mode 100644 apps/fleet-desktop-macos/FleetDesktop/Info.plist create mode 100644 apps/fleet-desktop-macos/FleetDesktop/fleet-logo.png create mode 100644 apps/fleet-desktop-macos/README.md create mode 100755 apps/fleet-desktop-macos/build-pkg.sh create mode 100755 apps/fleet-desktop-macos/build.sh 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 0000000000000000000000000000000000000000..fa725ecea6095b2acff5c5a22c91f66c96cd884e GIT binary patch literal 124028 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..42012655532b08b8e92ea56849b9131f5011dde3 GIT binary patch literal 1767 zcmaJ?dpOgJAO13TQY`mN6HeL5Az^ZvhplWmR)-LdEs>cl*ABVNT{)e(g>=X*$u%TK zRB~w+LJ84YD2;0v&zrTOK_j%s;eLnB=`Rjc?&+|#Xg0VRwp)3Iaz!5YGX@BT3 z|4>ZiP&?orJ%=V9fN}{207>va6afCtQ#=d>g6(arffsbumBT>@Z)Il%0ClO7`yRpo zAQFj2T3rnjU`^WjgKZQ=+59S&P#UzWppc%9RmMTBao3{b(m+{d&~%gqBtgh&*iE~3 z=u#Hc2+m0aK~u(|S>ccaA-@X~D3->|kme$I6e_1x7y7(k5uxGR+ERbk48wb8E7og{ zHB7~SdyH3roc#Y7(J@(!KQjHO()GR}oz~?j5?x*^Osoq%PFq*T3n}D z<*+`y_Sw9Vgs`FOgL7JaD@aWAP2+2j2>+VQ7U=U~|%8DhHVgx6Zu6+7G-Y{8gxsPii)+o$8* zs}#)fOIu@$TUR%zUCBcF@%(=2H6L;_Qf+?fIOb`#p?EKoyVs-{{i{6gOI=rS=j9wB zZ}tgpMEUFqy=>oKv|>d?%`-zuv+rRgL=RC)hVfBE6`(U~J3-RByKm#6cW0k^Fx9z1 z)!{0&RqZOrhvI#MHX3p_t@JC%E}I@oxS_X}FqOZ2^{;If>DJ(eF{E8|jOqd$^pS~+ z(RR7)xd+YCeycD6vmZBwzvNgznoq?HmY32M2s?Fl2A$kvyLIGOFH`io*ER9G{Qwjp z$Q&DK`t6%2ib{1JaFK0_%Uj@UT=8rzNEptUL!95ZLwDlY0s~3fF7U=1Dulfq)G-{s zi6j$!;F-;rDrBGYTR}##<|=)vHjFY48r0Gz!G-V0rNai0s2}RCfqh z+i#qHZ<)GLN1#ZRy>R|UNvl6xb(DNPjsWR&14gdmbm89H`Df(H=WKrB?d}A}R*dyA zOz%W8mb^CWje{0DF-*$yxKgEo5|(4ahdzeYIl|sLz3=Zv9T6SheRj#^lQmIW1c%bi z&J8;^hNyJFSHpm&pM4rpKl8ckL3OeFKAkKc3m+g`7P#8%(Dp_UYHL6*i-*ra@Li7|xzuqW(+4$eFC{$448QeAtFGPb>`gB@&ema|*`BP#Ow}`W_F_7yrF{HM zJQ%vSfQN9V3UsfL5tr_C6$oasz1=0>)0Ja(c>{MCUY2D6PY<0jqvjo*%}# z@vJb*aer`Q#ie01Z#kpJq*+hEV8<3^Y4V}>c0y$!x%H#l%M`v3%$QdaT%_eE&?w7M zn-ud^Aq6~Knpw1VWQX-sI3~R$wmkMMHrdo|8G8JBO0_#jvpRd;k|e^nNUS<2@wRlekYK=N{)L(gCOc7u zgm*}}LAgh*qrSFk4%agF#u_ilGDDVNV-LUwjtB zZcK@;^^A~6biI=AA8!zRQlOU^A;!46W*{zoAAHYBM|8T3bIf$3X$yT=QI63G`L0Kc z=i1$#w7^e;6}*Af!Bw%Z10$@|T#$HS)TkMV)|sdTv0d3J(wk6+Dab;4M2^Dl83Yxb znf!r`Txgs7e9E17b7oAPLR#)=r0Q~oaxT^gx0nTZom)o!2=WiNS`$B(dCUE1`1zZ# z9Jo1zMo)gyhU@Vc6-GWlaf&dZc14sG5UuxdOZ?2r1@M1M`k(qKeN(T+O$T}wDxZJ&mjLty4DyBb^|=24eaA<( literal 0 HcmV?d00001 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 )