1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// KAApp.swift — point d'entrée : fenêtre principale (Accueil, Recherche,3// Carte, 12 univers, Favoris, Historique, Alertes, KA Agent, Statut),4// MenuBarExtra « Ka », menus natifs (⌘1..9 univers, ⌘F/⌘K recherche, ⌘M5// carte), raccourci global ⌘⇧K, apparence clair/sombre/système, pouls 5 min.6// Spec des données : app iOS KA (~/Desktop/KA).7import SwiftUI89// MARK: - Navigation1011enum SidebarSelection: Hashable {12 case home, search, map, agent, status13 case favorites, history, alerts14 case universe(String)15}1617@MainActor18final class AppState: ObservableObject {19 static let shared = AppState()20 @Published var selection: SidebarSelection? = .home21 /// Incrémenté pour redonner le focus au champ de recherche universel.22 @Published var searchFocusToken = 023 /// Pré-remplissage d'un explorateur d'univers (depuis Accueil/Alertes).24 @Published var prefill: (universeID: String, query: String, params: [String: String])?25 /// Ouvre (ou ramène) la fenêtre principale — capturé depuis la vue racine.26 var openMain: (() -> Void)?2728 func openUniversalSearch() {29 selection = .search30 searchFocusToken += 131 NSApp.activate(ignoringOtherApps: true)32 openMain?()33 }3435 func openUniverse(_ id: String, query: String = "", params: [String: String] = [:]) {36 if !query.isEmpty || !params.isEmpty {37 prefill = (id, query, params)38 }39 selection = .universe(id)40 }41}4243// MARK: - Apparence (clair par défaut, sombre complet, ou système)4445enum KAAppearance: String, CaseIterable, Identifiable {46 case clair, sombre, systeme47 var id: String { rawValue }48 var label: String {49 switch self {50 case .clair: return "Clair"51 case .sombre: return "Sombre"52 case .systeme: return "Système"53 }54 }55 var colorScheme: ColorScheme? {56 switch self {57 case .clair: return .light58 case .sombre: return .dark59 case .systeme: return nil60 }61 }62}6364// MARK: - Le pouls de l'écosystème (compteurs + statut, rafraîchi 5 min)6566@MainActor67final class EcosystemPulse: ObservableObject {68 static let shared = EcosystemPulse()69 @Published var totals: [String: Int] = [:]70 @Published var health: [ServiceHealth] = StatusService.allServices71 @Published var lastRefresh: Date?72 @Published var refreshing = false73 private var loop: Task<Void, Never>?7475 func start() {76 guard loop == nil else { return }77 loop = Task { [weak self] in78 while !Task.isCancelled {79 await self?.refresh()80 try? await Task.sleep(for: .seconds(300)) // 5 minutes81 }82 }83 }8485 func refresh() async {86 refreshing = true87 // Compteurs des univers (fan-out parallèle)88 await withTaskGroup(of: (String, Int?).self) { group in89 for u in Ecosystem.all {90 group.addTask { (u.id, await UniverseService.liveTotal(u)) }91 }92 for await (id, n) in group {93 if let n { totals[id] = n }94 }95 }96 // Statut des 13 services (HEAD + latence)97 var checked = health98 await withTaskGroup(of: (String, Bool, Int).self) { group in99 for s in checked {100 group.addTask {101 let r = await StatusService.check(domain: s.id)102 return (s.id, r.up, r.latencyMs)103 }104 }105 for await (id, up, ms) in group {106 if let i = checked.firstIndex(where: { $0.id == id }) {107 checked[i].up = up108 checked[i].latencyMs = ms109 checked[i].checkedAt = Date()110 }111 }112 }113 health = checked114 lastRefresh = Date()115 refreshing = false116 }117118 var servicesUp: Int { health.filter { $0.up == true }.count }119}120121// MARK: - Délégué (raccourci global + démarrage du pouls + alertes)122123final class AppDelegate: NSObject, NSApplicationDelegate {124 private let hotKey = HotKeyManager()125126 func applicationDidFinishLaunching(_ notification: Notification) {127 hotKey.onHotKey = { Task { @MainActor in AppState.shared.openUniversalSearch() } }128 hotKey.register()129 Task { @MainActor in130 EcosystemPulse.shared.start()131 await RecentsStore.shared.refreshAlerts() // deltas réels des recherches sauvegardées132 }133 }134}135136// MARK: - App137138@main139struct KAApp: App {140 @NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate141 @StateObject private var pulse = EcosystemPulse.shared142 @StateObject private var state = AppState.shared143 @StateObject private var favorites = FavoritesStore.shared144 @StateObject private var recents = RecentsStore.shared145 @AppStorage("ka.appearance") private var appearance = KAAppearance.clair.rawValue146147 private var scheme: ColorScheme? { KAAppearance(rawValue: appearance)?.colorScheme }148149 var body: some Scene {150 WindowGroup("KA", id: "main") {151 MainWindowView()152 .environmentObject(pulse)153 .environmentObject(state)154 .environmentObject(favorites)155 .environmentObject(recents)156 .preferredColorScheme(scheme)157 }158 .defaultSize(width: 1280, height: 820)159 .commands {160 CommandGroup(replacing: .appInfo) { AboutCommand() }161 CommandGroup(after: .sidebar) {162 Divider()163 Picker("Apparence", selection: $appearance) {164 ForEach(KAAppearance.allCases) { a in165 Text(a.label).tag(a.rawValue)166 }167 }168 Divider()169 NavCommand(label: "Accueil", to: .home, key: "0")170 NavCommand(label: "Recherche universelle", to: .search, key: "f")171 NavCommand(label: "Carte", to: .map, key: "m")172 NavCommand(label: "KA Agent", to: .agent, key: "g")173 NavCommand(label: "Favoris", to: .favorites, key: "d")174 NavCommand(label: "Statut des plateformes", to: .status, key: "t")175 }176 CommandGroup(after: .sidebar) { SearchCommands() }177 CommandMenu("Univers") {178 ForEach(Array(Ecosystem.all.enumerated()), id: \.element.id) { i, u in179 UniverseCommand(universe: u, index: i)180 }181 }182 }183184 Window("À propos de KA", id: "about") {185 AboutView()186 .preferredColorScheme(scheme)187 }188 .windowResizability(.contentSize)189 .defaultPosition(.center)190191 MenuBarExtra {192 MenuBarView()193 .environmentObject(pulse)194 .environmentObject(state)195 .environmentObject(favorites)196 .environmentObject(recents)197 .preferredColorScheme(scheme)198 } label: {199 Text("Ka").font(.system(size: 13, weight: .heavy, design: .rounded))200 }201 .menuBarExtraStyle(.window)202 }203}204205/// Ramène la fenêtre principale existante au premier plan — n'en crée une206/// nouvelle QUE s'il n'y en a aucune (évite l'empilement de fenêtres).207@MainActor208func focusMainWindow(_ openWindow: OpenWindowAction) {209 NSApp.activate(ignoringOtherApps: true)210 if let w = NSApp.windows.first(where: { ($0.identifier?.rawValue.hasPrefix("main") ?? false) && $0.canBecomeKey }) {211 w.makeKeyAndOrderFront(nil)212 } else {213 openWindow(id: "main")214 }215}216217/// « À propos de KA » (remplace l'élément standard du menu application).218private struct AboutCommand: View {219 @Environment(\.openWindow) private var openWindow220 var body: some View {221 Button("À propos de KA") { openWindow(id: "about") }222 }223}224225/// Navigation menu « Présentation » (⌘0 accueil, ⌘F recherche, ⌘M carte…).226private struct NavCommand: View {227 let label: String228 let to: SidebarSelection229 let key: KeyEquivalent230 @Environment(\.openWindow) private var openWindow231 var body: some View {232 Button(label) {233 focusMainWindow(openWindow)234 if to == .search { AppState.shared.openUniversalSearch() }235 else { AppState.shared.selection = to }236 }237 .keyboardShortcut(key, modifiers: .command)238 }239}240241/// Recherche universelle — ⌘K et ⌘⇧K (aussi raccourci GLOBAL Carbon).242private struct SearchCommands: View {243 @Environment(\.openWindow) private var openWindow244 var body: some View {245 Button("Recherche rapide") {246 focusMainWindow(openWindow)247 AppState.shared.openUniversalSearch()248 }249 .keyboardShortcut("k", modifiers: .command)250 Button("Recherche universelle (globale)") {251 focusMainWindow(openWindow)252 AppState.shared.openUniversalSearch()253 }254 .keyboardShortcut("k", modifiers: [.command, .shift])255 }256}257258/// Menu « Univers » : ⌘1..⌘9 pour les 9 premiers.259private struct UniverseCommand: View {260 let universe: Universe261 let index: Int262 @Environment(\.openWindow) private var openWindow263 var body: some View {264 let btn = Button(universe.name) {265 focusMainWindow(openWindow)266 AppState.shared.selection = .universe(universe.id)267 }268 if index < 9 {269 btn.keyboardShortcut(KeyEquivalent(Character("\(index + 1)")), modifiers: .command)270 } else {271 btn272 }273 }274}275