313 lines
12 KiB
Swift
313 lines
12 KiB
Swift
//
|
||
// MainView.swift
|
||
// iKeyMon
|
||
//
|
||
// Created by tracer on 30.03.25.
|
||
//
|
||
|
||
import SwiftUI
|
||
|
||
struct MainView: View {
|
||
|
||
private static let serverOrderKeyStatic = "serverOrder"
|
||
private static let storedServersKeyStatic = "storedServers"
|
||
|
||
@EnvironmentObject private var updateViewModel: UpdateViewModel
|
||
@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
|
||
@State private var refreshTimer = Timer.publish(every: 60, on: .main, in: .common).autoconnect()
|
||
@State private var progress: Double = 0
|
||
@State private var lastRefresh = Date()
|
||
@State private var pingTimer: Timer?
|
||
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")
|
||
}
|
||
ToolbarItem {
|
||
Button {
|
||
updateViewModel.checkForUpdates(userInitiated: true)
|
||
} label: {
|
||
if updateViewModel.isChecking {
|
||
ProgressView()
|
||
.scaleEffect(0.6)
|
||
} else {
|
||
Image(systemName: "square.and.arrow.down")
|
||
}
|
||
}
|
||
.help("Check for Updates")
|
||
}
|
||
}
|
||
.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) {}
|
||
}
|
||
.onReceive(refreshTimer) { _ in
|
||
for server in servers {
|
||
fetchServerInfo(for: server.id)
|
||
}
|
||
}
|
||
.onAppear {
|
||
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)
|
||
}
|
||
}
|
||
pingAllServers()
|
||
pingTimer = Timer.scheduledTimer(withTimeInterval: 10.0, repeats: true) { _ in
|
||
pingAllServers()
|
||
}
|
||
updateViewModel.startAutomaticCheckIfNeeded()
|
||
}
|
||
.frame(minWidth: 800, minHeight: 450)
|
||
.alert(item: availableReleaseBinding) { release in
|
||
Alert(
|
||
title: Text("Update Available"),
|
||
message: Text("iKeyMon \(release.versionString) is available."),
|
||
primaryButton: .default(Text("Download")) {
|
||
updateViewModel.downloadLatest()
|
||
},
|
||
secondaryButton: .cancel(Text("Later"))
|
||
)
|
||
}
|
||
.alert(item: statusAlertBinding) { alert in
|
||
Alert(
|
||
title: Text(alert.title),
|
||
message: Text(alert.message),
|
||
dismissButton: .default(Text("OK"))
|
||
)
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|
||
}
|
||
} 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)
|
||
await MainActor.run {
|
||
servers[index].pingable = pingable
|
||
}
|
||
if !pingable {
|
||
print("📶 [MainView] Ping \(server.hostname): offline")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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 var availableReleaseBinding: Binding<ReleaseInfo?> {
|
||
Binding(
|
||
get: { updateViewModel.availableRelease },
|
||
set: { updateViewModel.availableRelease = $0 }
|
||
)
|
||
}
|
||
|
||
private var statusAlertBinding: Binding<UpdateViewModel.StatusAlert?> {
|
||
Binding(
|
||
get: { updateViewModel.statusAlert },
|
||
set: { updateViewModel.statusAlert = $0 }
|
||
)
|
||
}
|
||
}
|
||
|
||
#Preview {
|
||
MainView()
|
||
.environmentObject(UpdateViewModel())
|
||
}
|