← back to Exo
app/EXO/EXO/EXOApp.swift
289 lines
//
// EXOApp.swift
// EXO
//
// Created by Sami Khan on 2025-11-22.
//
import AppKit
import CoreImage
import CoreImage.CIFilterBuiltins
import ServiceManagement
import Sparkle
import SwiftUI
import UserNotifications
import os.log
struct EXOApp: App {
@StateObject private var controller: ExoProcessController
@StateObject private var stateService: ClusterStateService
@StateObject private var networkStatusService: NetworkStatusService
@StateObject private var localNetworkChecker: LocalNetworkChecker
@StateObject private var updater: SparkleUpdater
@StateObject private var thunderboltBridgeService: ThunderboltBridgeService
@StateObject private var settingsWindowController: SettingsWindowController
@StateObject private var bugReportWindowController: BugReportWindowController
private let terminationObserver: TerminationObserver
private let firstLaunchPopout = FirstLaunchPopout()
private let ciContext = CIContext(options: nil)
init() {
let controller = ExoProcessController()
let updater = SparkleUpdater(processController: controller)
terminationObserver = TerminationObserver {
Task { @MainActor in
controller.cancelPendingLaunch()
controller.stop()
}
}
_controller = StateObject(wrappedValue: controller)
let service = ClusterStateService()
_stateService = StateObject(wrappedValue: service)
let networkStatus = NetworkStatusService()
_networkStatusService = StateObject(wrappedValue: networkStatus)
let localNetwork = LocalNetworkChecker()
_localNetworkChecker = StateObject(wrappedValue: localNetwork)
_updater = StateObject(wrappedValue: updater)
let thunderboltBridge = ThunderboltBridgeService(clusterStateService: service)
_thunderboltBridgeService = StateObject(wrappedValue: thunderboltBridge)
_settingsWindowController = StateObject(wrappedValue: SettingsWindowController())
_bugReportWindowController = StateObject(wrappedValue: BugReportWindowController())
enableLaunchAtLoginIfNeeded()
// Install LaunchDaemon to disable Thunderbolt Bridge on startup (prevents network loops)
NetworkSetupHelper.promptAndInstallIfNeeded()
// Check local network access periodically (warning disappears when user grants permission)
localNetwork.startPeriodicChecking(interval: 10)
controller.scheduleLaunch(after: 5)
service.startPolling()
networkStatus.startPolling()
}
var body: some Scene {
MenuBarExtra {
ContentView()
.environmentObject(controller)
.environmentObject(stateService)
.environmentObject(networkStatusService)
.environmentObject(localNetworkChecker)
.environmentObject(updater)
.environmentObject(thunderboltBridgeService)
.environmentObject(settingsWindowController)
.environmentObject(bugReportWindowController)
} label: {
menuBarIcon
.onReceive(controller.$isFirstLaunchReady) { ready in
if ready {
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
self.firstLaunchPopout.onComplete = { [weak controller] in
controller?.markOnboardingCompleted()
}
self.firstLaunchPopout.show()
}
}
}
}
.menuBarExtraStyle(.window)
}
private var menuBarIcon: some View {
let baseImage = resizedMenuBarIcon(named: "menubar-icon", size: 26)
let iconImage: NSImage
if controller.status == .stopped, let grey = greyscale(image: baseImage) {
iconImage = grey
} else {
iconImage = baseImage ?? NSImage(named: "menubar-icon") ?? NSImage()
}
return Image(nsImage: iconImage)
.accessibilityLabel("EXO")
}
private func resizedMenuBarIcon(named: String, size: CGFloat) -> NSImage? {
guard let original = NSImage(named: named) else {
print("Failed to load image named: \(named)")
return nil
}
let targetSize = NSSize(width: size, height: size)
let resized = NSImage(size: targetSize)
resized.lockFocus()
defer { resized.unlockFocus() }
NSGraphicsContext.current?.imageInterpolation = .high
original.draw(
in: NSRect(origin: .zero, size: targetSize),
from: NSRect(origin: .zero, size: original.size),
operation: .copy,
fraction: 1.0
)
return resized
}
private func greyscale(image: NSImage?) -> NSImage? {
guard
let image,
let tiff = image.tiffRepresentation,
let bitmap = NSBitmapImageRep(data: tiff),
let cgImage = bitmap.cgImage
else {
return nil
}
let ciImage = CIImage(cgImage: cgImage)
let filter = CIFilter.colorControls()
filter.inputImage = ciImage
filter.saturation = 0
filter.brightness = -0.2
filter.contrast = 0.9
guard let output = filter.outputImage,
let rendered = ciContext.createCGImage(output, from: output.extent)
else {
return nil
}
return NSImage(cgImage: rendered, size: image.size)
}
private func enableLaunchAtLoginIfNeeded() {
guard SMAppService.mainApp.status != .enabled else { return }
do {
try SMAppService.mainApp.register()
} catch {
Logger().error(
"Failed to register EXO for launch at login: \(error.localizedDescription)")
}
}
}
/// Helper for managing EXO's launch-at-login registration
enum LaunchAtLoginHelper {
private static let logger = Logger(subsystem: "io.exo.EXO", category: "LaunchAtLogin")
/// Unregisters EXO from launching at login
static func disable() {
guard SMAppService.mainApp.status == .enabled else { return }
do {
try SMAppService.mainApp.unregister()
logger.info("Unregistered EXO from launch at login")
} catch {
logger.error(
"Failed to unregister EXO from launch at login: \(error.localizedDescription, privacy: .public)"
)
}
}
}
final class SparkleUpdater: NSObject, ObservableObject {
private let controller: SPUStandardUpdaterController
private let delegateProxy: ExoUpdaterDelegate
private let notificationDelegate = ExoNotificationDelegate()
private var periodicCheckTask: Task<Void, Never>?
init(processController: ExoProcessController) {
let proxy = ExoUpdaterDelegate(processController: processController)
delegateProxy = proxy
controller = SPUStandardUpdaterController(
startingUpdater: true,
updaterDelegate: proxy,
userDriverDelegate: nil
)
super.init()
let center = UNUserNotificationCenter.current()
center.delegate = notificationDelegate
center.requestAuthorization(options: [.alert, .sound]) { _, _ in }
controller.updater.automaticallyChecksForUpdates = true
controller.updater.automaticallyDownloadsUpdates = false
controller.updater.updateCheckInterval = 900 // 15 minutes
DispatchQueue.main.asyncAfter(deadline: .now() + 5) { [weak controller] in
controller?.updater.checkForUpdatesInBackground()
}
let updater = controller.updater
let intervalSeconds = max(60.0, controller.updater.updateCheckInterval)
let intervalNanos = UInt64(intervalSeconds * 1_000_000_000)
periodicCheckTask = Task {
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: intervalNanos)
await MainActor.run {
updater.checkForUpdatesInBackground()
}
}
}
}
deinit {
periodicCheckTask?.cancel()
}
@MainActor
func checkForUpdates() {
controller.checkForUpdates(nil)
}
}
private final class ExoUpdaterDelegate: NSObject, SPUUpdaterDelegate {
private weak var processController: ExoProcessController?
init(processController: ExoProcessController) {
self.processController = processController
}
nonisolated func updater(_ updater: SPUUpdater, didFindValidUpdate item: SUAppcastItem) {
showNotification(
title: "Update available",
body: "EXO \(item.displayVersionString) is ready to install."
)
}
nonisolated func updaterWillRelaunchApplication(_ updater: SPUUpdater) {
Task { @MainActor in
guard let controller = self.processController else { return }
controller.cancelPendingLaunch()
controller.stop()
}
}
nonisolated private func showNotification(title: String, body: String) {
let center = UNUserNotificationCenter.current()
let content = UNMutableNotificationContent()
content.title = title
content.body = body
let request = UNNotificationRequest(
identifier: "exo-update-\(UUID().uuidString)",
content: content,
trigger: nil
)
center.add(request, withCompletionHandler: nil)
}
}
private final class ExoNotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) ->
Void
) {
completionHandler([.banner, .list, .sound])
}
}
@MainActor
private final class TerminationObserver {
private var token: NSObjectProtocol?
init(onTerminate: @escaping () -> Void) {
token = NotificationCenter.default.addObserver(
forName: NSApplication.willTerminateNotification,
object: nil,
queue: .main
) { _ in
onTerminate()
}
}
deinit {
if let token {
NotificationCenter.default.removeObserver(token)
}
}
}