// // MainView.swift // iKeyMon // // Created by tracer on 30.03.25. // import SwiftUI import Combine import UserNotifications struct MainView: View { private static let serverOrderKeyStatic = "serverOrder" private static let storedServersKeyStatic = "storedServers" @State var showAddServerSheet: Bool = false @State private var serverBeingEdited: Server? @State private var serverToDelete: Server? @State private var showDeleteConfirmation = false @State private var isFetchingInfo: Bool = false @AppStorage("pingInterval") private var pingInterval: Int = 10 @AppStorage("refreshInterval") private var refreshInterval: Int = 60 @AppStorage("enableStatusNotifications") private var enableStatusNotifications: Bool = true @AppStorage("enableAlertNotifications") private var enableAlertNotifications: Bool = true @State private var refreshTimer: Timer.TimerPublisher? @State private var refreshSubscription: AnyCancellable? @State private var pingTimer: Timer? @State private var lastRefreshInterval: Int? @State private var previousServiceStates: [String: String] = [:] private let serverOrderKey = MainView.serverOrderKeyStatic private let storedServersKey = MainView.storedServersKeyStatic @State private var servers: [Server] = MainView.loadStoredServers() // @State private var selectedServer: Server? @State private var selectedServerID: UUID? var body: some View { var mainContent: some View { NavigationSplitView { List(selection: $selectedServerID) { ForEach(servers) { server in HStack { Image(systemName: "dot.circle.fill") .foregroundColor(server.pingable ? .green : .red) Text(server.hostname) } .tag(server) .contextMenu { Button("Edit") { print("Editing:", server.hostname) serverBeingEdited = server } Divider() Button("Delete", role: .destructive) { serverToDelete = server showDeleteConfirmation = true } } } .onMove(perform: moveServer) } .toolbar { ToolbarItem(placement: .primaryAction) { Button(action: { showAddServerSheet = true }) { Image(systemName: "plus") } .help("Add Host") } } .navigationTitle("Servers") .onChange(of: selectedServerID) { if let selectedServerID { UserDefaults.standard.set(selectedServerID.uuidString, forKey: "selectedServerID") fetchServerInfo(for: selectedServerID) } } } detail: { if let selectedServerID, let index = servers.firstIndex(where: { selectedServerID == $0.id }) { ServerDetailView(server: $servers[index], isFetching: isFetchingInfo) } else { ContentUnavailableView("No Server Selected", systemImage: "server.rack") } } } return mainContent .sheet(isPresented: $showAddServerSheet) { ServerFormView( mode: .add, servers: $servers, dismiss: { showAddServerSheet = false } ) } .sheet(item: $serverBeingEdited) { server in ServerFormView( mode: .edit(server), servers: $servers, dismiss: { serverBeingEdited = nil } ) } .alert("Are you sure you want to delete this server?", isPresented: $showDeleteConfirmation, presenting: serverToDelete) { server in Button("Delete", role: .destructive) { ServerFormView.delete(server: server, from: &servers) } Button("Cancel", role: .cancel) {} } .onAppear { requestNotificationPermissions() let initialID: UUID? if let storedID = UserDefaults.standard.string(forKey: "selectedServerID"), let uuid = UUID(uuidString: storedID), servers.contains(where: { $0.id == uuid }) { print("✅ [MainView] Restored selected server \(uuid)") initialID = uuid } else if let first = servers.first { print("✅ [MainView] Selecting first server \(first.hostname)") initialID = first.id } else { print("ℹ️ [MainView] No stored selection") initialID = nil } selectedServerID = initialID if let initialID { Task { await prefetchOtherServers(activeID: initialID) } } setupTimers() } .onChange(of: pingInterval) { _, _ in setupPingTimer() } .onChange(of: refreshInterval) { oldValue, newValue in if oldValue != newValue { setupRefreshTimer() } } .frame(minWidth: 800, minHeight: 450) } private func fetchServerInfo(for id: UUID) { guard let server = servers.first(where: { $0.id == id }) else { print("❌ [MainView] fetchServerInfo: server not found for id \(id)") return } guard let apiKey = KeychainHelper.loadApiKey(for: server.hostname)?.trimmingCharacters(in: .whitespacesAndNewlines), !apiKey.isEmpty else { print("❌ [MainView] fetchServerInfo: missing API key for \(server.hostname)") return } guard let baseURL = URL(string: "https://\(server.hostname)") else { print("❌ [MainView] Invalid base URL for \(server.hostname)") return } isFetchingInfo = true Task { defer { isFetchingInfo = false } do { let api = try await APIFactory.detectAndCreateAPI(baseURL: baseURL, apiKey: apiKey) let info = try await api.fetchServerSummary(apiKey: apiKey) await MainActor.run { if let index = servers.firstIndex(where: { $0.id == id }) { var updated = servers[index] updated.info = info servers[index] = updated checkServiceStatusChanges(for: server.hostname, newInfo: info) } } } catch { print("❌ Failed to fetch server data: \(error)") } } } private func prefetchOtherServers(activeID: UUID) async { let others = servers.filter { $0.id != activeID } await withTaskGroup(of: Void.self) { group in for server in others { group.addTask { await fetchServerInfoAsync(for: server.id) } } } } private func fetchServerInfoAsync(for id: UUID) async { guard let server = servers.first(where: { $0.id == id }) else { return } guard let apiKey = KeychainHelper.loadApiKey(for: server.hostname)?.trimmingCharacters(in: .whitespacesAndNewlines), !apiKey.isEmpty, let baseURL = URL(string: "https://\(server.hostname)") else { return } do { let api = try await APIFactory.detectAndCreateAPI(baseURL: baseURL, apiKey: apiKey) let info = try await api.fetchServerSummary(apiKey: apiKey) await MainActor.run { if let index = servers.firstIndex(where: { $0.id == id }) { var updated = servers[index] updated.info = info servers[index] = updated } } } catch { print("❌ Prefetch failed for \(server.hostname): \(error)") } } private func moveServer(from source: IndexSet, to destination: Int) { servers.move(fromOffsets: source, toOffset: destination) saveServerOrder() } private func saveServerOrder() { let ids = servers.map { $0.id.uuidString } UserDefaults.standard.set(ids, forKey: serverOrderKey) print("💾 [MainView] Saved server order with \(ids.count) entries") } private struct PingResponse: Codable { let response: String } func pingAllServers() { for (index, server) in servers.enumerated() { Task { let apiKey = KeychainHelper.loadApiKey(for: server.hostname)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let pingable = await PingService.ping(hostname: server.hostname, apiKey: apiKey, notificationsEnabled: enableStatusNotifications) await MainActor.run { servers[index].pingable = pingable } if !pingable { print("📶 [MainView] Ping \(server.hostname): offline") } } } } private func setupTimers() { setupPingTimer() setupRefreshTimer() } private func setupPingTimer() { pingTimer?.invalidate() pingAllServers() pingTimer = Timer.scheduledTimer(withTimeInterval: Double(pingInterval), repeats: true) { _ in pingAllServers() } } private func setupRefreshTimer() { refreshSubscription?.cancel() refreshSubscription = nil refreshTimer = Timer.publish(every: Double(refreshInterval), on: .main, in: .common) refreshSubscription = refreshTimer?.autoconnect().sink { _ in for server in servers { fetchServerInfo(for: server.id) } } } private static func loadStoredServers() -> [Server] { let defaults = UserDefaults.standard guard let data = defaults.data(forKey: storedServersKeyStatic) else { print("ℹ️ [MainView] No storedServers data found") return [] } do { let saved = try JSONDecoder().decode([Server].self, from: data) print("📦 [MainView] Loaded \(saved.count) servers from UserDefaults") if let order = defaults.stringArray(forKey: serverOrderKeyStatic) { let idMap = order.compactMap(UUID.init) let sorted = saved.sorted { a, b in guard let i1 = idMap.firstIndex(of: a.id), let i2 = idMap.firstIndex(of: b.id) else { return false } return i1 < i2 } return sorted } return saved } catch { print("❌ [MainView] Failed to decode stored servers: \(error)") return [] } } private func requestNotificationPermissions() { Task { do { try await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) } catch { print("❌ [MainView] Failed to request notification permissions: \(error)") } } } private func checkServiceStatusChanges(for hostname: String, newInfo: ServerInfo) { guard let ports = newInfo.ports else { return } for port in ports { let key = "\(hostname)-\(port.id)" let previousStatus = previousServiceStates[key] let currentStatus = port.status previousServiceStates[key] = currentStatus if let previousStatus, previousStatus != currentStatus { if currentStatus == "offline" && enableStatusNotifications { sendServiceNotification(service: port.service, hostname: hostname, status: "offline") } else if currentStatus == "online" && previousStatus == "offline" && enableStatusNotifications { sendServiceNotification(service: port.service, hostname: hostname, status: "online") } } } } private func sendServiceNotification(service: String, hostname: String, status: String) { let content = UNMutableNotificationContent() content.title = "\(service) \(status.uppercased())" content.body = "\(service) on \(hostname) is \(status)" content.sound = .default let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) UNUserNotificationCenter.current().add(request) } } #Preview { MainView() .environmentObject(SparkleUpdater()) }