diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..076d46d --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.DS_Store +xcuserdata/ +DerivedData/ diff --git a/iKeyMon/Assets.xcassets/AccentColor.colorset/Contents.json b/Assets.xcassets/AccentColor.colorset/Contents.json similarity index 100% rename from iKeyMon/Assets.xcassets/AccentColor.colorset/Contents.json rename to Assets.xcassets/AccentColor.colorset/Contents.json diff --git a/iKeyMon/Assets.xcassets/AppIcon.appiconset/Contents.json b/Assets.xcassets/AppIcon.appiconset/Contents.json similarity index 100% rename from iKeyMon/Assets.xcassets/AppIcon.appiconset/Contents.json rename to Assets.xcassets/AppIcon.appiconset/Contents.json diff --git a/iKeyMon/Assets.xcassets/Contents.json b/Assets.xcassets/Contents.json similarity index 100% rename from iKeyMon/Assets.xcassets/Contents.json rename to Assets.xcassets/Contents.json diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8958191 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,6 @@ +# Changelog + +## Unreleased +- Flattened the project structure so sources live at the repository root instead of the nested `iKeyMon/` folder and updated the Xcode project accordingly. +- Fixed build settings (entitlements, preview assets) and placeholder previews to work with the new layout. +- Migrated the updated API layer and unified `ServerInfo` model from the previous branch. diff --git a/iKeyMon/Extensions/ByteFormatting.swift b/Extensions/ByteFormatting.swift similarity index 100% rename from iKeyMon/Extensions/ByteFormatting.swift rename to Extensions/ByteFormatting.swift diff --git a/iKeyMon/KeychainHelper.swift b/KeychainHelper.swift similarity index 100% rename from iKeyMon/KeychainHelper.swift rename to KeychainHelper.swift diff --git a/Model/API/ApiFactory.swift b/Model/API/ApiFactory.swift new file mode 100644 index 0000000..04ebd87 --- /dev/null +++ b/Model/API/ApiFactory.swift @@ -0,0 +1,79 @@ +// +// ApiFactory.swift +// iKeyMon +// +// Created by tracer on 13.11.25. +// + +import Foundation + +enum APIVersion: String, CaseIterable { + case v2_12 = "2.12" + + static func from(versionString: String) -> APIVersion? { + if let version = APIVersion(rawValue: versionString) { + return version + } + + let components = versionString.split(separator: ".").compactMap { Int($0) } + guard components.count >= 2 else { return nil } + + let major = components[0] + let minor = components[1] + + switch (major, minor) { + case (2, 12...): return .v2_12 + default: return nil + } + } +} + +protocol AnyServerAPI { + func fetchSystemInfo() async throws -> SystemInfo + func fetchLoadData() async throws -> Any + func fetchMemoryData() async throws -> Any + func fetchUtilizationData() async throws -> Any +} + +private struct AnyServerAPIWrapper: AnyServerAPI { + private let wrapped: T + + init(_ wrapped: T) { + self.wrapped = wrapped + } + + func fetchSystemInfo() async throws -> SystemInfo { + return try await wrapped.fetchSystemInfo() + } + + func fetchLoadData() async throws -> Any { + return try await wrapped.fetchLoad() + } + + func fetchMemoryData() async throws -> Any { + return try await wrapped.fetchMemory() + } + + func fetchUtilizationData() async throws -> Any { + return try await wrapped.fetchUtilization() + } +} + +class APIFactory { + static func createAPI(baseURL: URL, version: APIVersion) -> AnyServerAPI { + switch version { + case .v2_12: + return AnyServerAPIWrapper(APIv2_12(baseURL: baseURL)) + } + } + + static func createAPI(baseURL: URL, versionString: String) -> AnyServerAPI? { + guard let version = APIVersion.from(versionString: versionString) else { return nil } + return createAPI(baseURL: baseURL, version: version) + } + + static func detectAndCreateAPI(baseURL: URL) async throws -> AnyServerAPI { + // For now we only support API v2.12, so return that implementation directly. + return AnyServerAPIWrapper(APIv2_12(baseURL: baseURL)) + } +} diff --git a/Model/API/ApiManager.swift b/Model/API/ApiManager.swift new file mode 100644 index 0000000..945e82f --- /dev/null +++ b/Model/API/ApiManager.swift @@ -0,0 +1,64 @@ +// +// ApiManager.swift +// iKeyMon +// +// Created by tracer on 13.11.25. +// + +import Foundation +import Combine + +@MainActor +class APIManager: ObservableObject { + @Published var isConnected = false + @Published var currentVersion: String = "" + @Published var lastError: Error? + + private var api: AnyServerAPI? + private let baseURL: URL + + init(baseURL: URL) { + self.baseURL = baseURL + } + + func connect() async { + do { + self.api = try await APIFactory.detectAndCreateAPI(baseURL: baseURL) + let systemInfo = try await api!.fetchSystemInfo() + self.currentVersion = systemInfo.version + self.isConnected = true + self.lastError = nil + } catch { + self.isConnected = false + self.lastError = error + self.api = nil + } + } + + func disconnect() { + self.api = nil + self.isConnected = false + self.currentVersion = "" + self.lastError = nil + } + + func fetchSystemInfo() async throws -> SystemInfo { + guard let api = api else { throw APIError.invalidResponse } + return try await api.fetchSystemInfo() + } + + func fetchLoad() async throws -> Any { + guard let api = api else { throw APIError.invalidResponse } + return try await api.fetchLoadData() + } + + func fetchMemory() async throws -> Any { + guard let api = api else { throw APIError.invalidResponse } + return try await api.fetchMemoryData() + } + + func fetchUtilization() async throws -> Any { + guard let api = api else { throw APIError.invalidResponse } + return try await api.fetchUtilizationData() + } +} diff --git a/Model/API/BaseAPI.swift b/Model/API/BaseAPI.swift new file mode 100644 index 0000000..1273a6d --- /dev/null +++ b/Model/API/BaseAPI.swift @@ -0,0 +1,66 @@ +// +// BaseAPI.swift +// iKeyMon +// +// Created by tracer on 13.11.25. +// + + +import Foundation + +protocol ServerAPIProtocol { + associatedtype LoadType: Codable + associatedtype MemoryType: Codable + associatedtype UtilizationType: Codable + + func fetchSystemInfo() async throws -> SystemInfo + func fetchLoad() async throws -> LoadType + func fetchMemory() async throws -> MemoryType + func fetchUtilization() async throws -> UtilizationType +} + +struct SystemInfo: Codable { + let version: String + let timestamp: Date + let hostname: String +} + +class BaseAPIClient { + let baseURL: URL + let session: URLSession + + init(baseURL: URL, session: URLSession = .shared) { + self.baseURL = baseURL + self.session = session + } + + func performRequest(_ request: URLRequest, responseType: T.Type) async throws -> T { + let (data, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw APIError.invalidResponse + } + + guard 200...299 ~= httpResponse.statusCode else { + throw APIError.httpError(httpResponse.statusCode) + } + + return try JSONDecoder().decode(T.self, from: data) + } +} + +enum APIError: Error, LocalizedError { + case invalidURL + case invalidResponse + case httpError(Int) + case decodingError(Error) + + var errorDescription: String? { + switch self { + case .invalidURL: return "Invalid URL" + case .invalidResponse: return "Invalid response" + case .httpError(let code): return "HTTP Error: \(code)" + case .decodingError(let error): return "Decoding error: \(error.localizedDescription)" + } + } +} diff --git a/iKeyMon/Model/Server.swift b/Model/API/Server.swift similarity index 83% rename from iKeyMon/Model/Server.swift rename to Model/API/Server.swift index b37b883..1f0f0d4 100644 --- a/iKeyMon/Model/Server.swift +++ b/Model/API/Server.swift @@ -10,10 +10,8 @@ import Foundation struct Server: Identifiable, Codable, Hashable, Equatable { let id: UUID var hostname: String - - // runtime-only, skip for Codable / Hashable / Equatable - var info: ServerInfo? = nil - var pingable: Bool = false + var info: ServerInfo? + var pingable: Bool init(id: UUID = UUID(), hostname: String, info: ServerInfo? = nil, pingable: Bool = false) { self.id = id @@ -31,9 +29,11 @@ struct Server: Identifiable, Codable, Hashable, Equatable { func hash(into hasher: inout Hasher) { hasher.combine(id) hasher.combine(hostname) + hasher.combine(info) + hasher.combine(pingable) } enum CodingKeys: String, CodingKey { - case id, hostname + case id, hostname, info, pingable } } diff --git a/Model/API/ServerAPI.swift b/Model/API/ServerAPI.swift new file mode 100644 index 0000000..7817169 --- /dev/null +++ b/Model/API/ServerAPI.swift @@ -0,0 +1,271 @@ +// +// ServerAPI.swift +// iKeyMon +// +// Created by tracer on 06.04.25. +// +import Foundation + +final class ServerAPI { + private let hostname: String + private let apiKey: String + + init(hostname: String, apiKey: String) { + self.hostname = hostname + self.apiKey = apiKey + } + + init?(server: Server) { + guard let apiKey = KeychainHelper.loadApiKey(for: server.hostname)?.trimmingCharacters(in: .whitespacesAndNewlines) else { + return nil + } + self.hostname = server.hostname + self.apiKey = apiKey + } + + @discardableResult + func ping() async -> Bool { + guard let url = URL(string: "https://\(hostname)/api/v2/ping") else { + print("❌ [ServerAPI] Invalid ping URL for hostname: \(hostname)") + return false + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue(apiKey, forHTTPHeaderField: "X-API-KEY") + request.timeoutInterval = 10 + + do { + let (data, response) = try await URLSession.shared.data(for: request) + + // Add debug info for comparison with server info request + if let httpResponse = response as? HTTPURLResponse { + if httpResponse.statusCode != 200 { + print("❌ [ServerAPI] Ping HTTP Status: \(httpResponse.statusCode) for \(hostname)") + if let responseString = String(data: data, encoding: .utf8) { + print("❌ [ServerAPI] Ping error response: \(responseString)") + } + } + } + + if let result = try? JSONDecoder().decode([String: String].self, from: data), result["response"] == "pong" { + return true + } else { + print("❌ [ServerAPI] Ping failed - invalid response for \(hostname)") + if let responseString = String(data: data, encoding: .utf8) { + print("❌ [ServerAPI] Ping response: \(responseString)") + } + return false + } + } catch { + print("❌ [ServerAPI] Ping error for \(hostname): \(error)") + return false + } + } + + func fetchServerInfo() async throws -> ServerInfo { + print("🔍 [ServerAPI] Starting fetchServerInfo for hostname: \(hostname)") + + guard let url = URL(string: "https://\(hostname)/api/v2/server") else { + print("❌ [ServerAPI] Invalid URL for hostname: \(hostname)") + throw URLError(.badURL) + } + + print("🌐 [ServerAPI] URL created: \(url.absoluteString)") + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue(apiKey, forHTTPHeaderField: "X-API-KEY") + // Temporarily remove Content-Type to match ping request exactly + // request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.timeoutInterval = 30 + + print("📤 [ServerAPI] Making request with timeout: \(request.timeoutInterval)s") + print("📤 [ServerAPI] API Key (first 10 chars): \(String(apiKey.prefix(10)))...") + print("📤 [ServerAPI] Request headers: \(request.allHTTPHeaderFields ?? [:])") + + do { + let startTime = Date() + let (data, response) = try await URLSession.shared.data(for: request) + let endTime = Date() + let duration = endTime.timeIntervalSince(startTime) + + print("📥 [ServerAPI] Request completed in \(String(format: "%.2f", duration))s") + + if let httpResponse = response as? HTTPURLResponse { + print("📥 [ServerAPI] HTTP Status: \(httpResponse.statusCode)") + + // Handle 401 specifically + if httpResponse.statusCode == 401 { + print("❌ [ServerAPI] 401 Unauthorized - API key issue!") + if let responseString = String(data: data, encoding: .utf8) { + print("❌ [ServerAPI] 401 Response body: \(responseString)") + } + throw URLError(.userAuthenticationRequired) + } + + // Handle other non-200 status codes + if httpResponse.statusCode != 200 { + print("❌ [ServerAPI] Non-200 status code: \(httpResponse.statusCode)") + if let responseString = String(data: data, encoding: .utf8) { + print("❌ [ServerAPI] Error response body: \(responseString)") + } + throw URLError(.badServerResponse) + } + } + + print("📥 [ServerAPI] Data size: \(data.count) bytes") + + print("🔄 [ServerAPI] Decoding response payload...") + + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + let decodedResponse = try decoder.decode(ServerResponseEnvelope.self, from: data) + let decoded = decodedResponse.toDomain() + print("✅ [ServerAPI] JSON decoding successful") + + return decoded + } catch let urlError as URLError { + print("❌ [ServerAPI] URLError: \(urlError.localizedDescription)") + print("❌ [ServerAPI] URLError code: \(urlError.code.rawValue)") + throw urlError + } catch let decodingError as DecodingError { + print("❌ [ServerAPI] Decoding error: \(decodingError)") + throw decodingError + } catch { + print("❌ [ServerAPI] Unexpected error: \(error)") + throw error + } + } +} + +// MARK: - Response Helpers + +private extension ServerAPI { + struct ServerResponseEnvelope: Decodable { + let data: ServerData + let meta: Meta + + struct Meta: Decodable { + let apiVersion: String + } + } + + struct ServerData: Decodable { + struct Port: Decodable { + let service: String + let status: String + let port: Int + let proto: String + } + + struct Load: Decodable { + let minute1: Double + let minute5: Double + let minute15: Double + let percent: Double + let cpuCount: Int + let level: String + } + + struct Memory: Decodable { + let free: Int + let used: Int + let total: Int + let percent: Double + } + + struct Disk: Decodable { + let free: Int + let used: Int + let total: Int + let percent: Double + } + + struct PHPInterpreter: Decodable { + let version: String + let path: String? + let configFile: String? + let extensions: [String] + let memoryLimit: String? + let maxExecutionTime: String? + } + + let hostname: String + let ipAddresses: [String] + let cpuCores: Int + let serverTime: String + let uptime: String + let processCount: Int + let apacheVersion: String + let phpVersion: String + let mysqlVersion: String? + let mariadbVersion: String? + let ports: [Port]? + let load: Load + let memory: Memory + let swap: Memory + let diskSpace: Disk + let panelVersion: String + let panelBuild: String + let additionalPhpInterpreters: [PHPInterpreter]? + } + +} + +private extension ServerAPI.ServerResponseEnvelope { + func toDomain() -> ServerInfo { + ServerInfo( + hostname: data.hostname, + ipAddresses: data.ipAddresses, + cpuCores: data.cpuCores, + serverTime: data.serverTime, + uptime: data.uptime, + processCount: data.processCount, + apacheVersion: data.apacheVersion, + phpVersion: data.phpVersion, + mysqlVersion: data.mysqlVersion, + mariadbVersion: data.mariadbVersion, + ports: data.ports?.map { ServerInfo.ServicePort(service: $0.service, status: $0.status, port: $0.port, proto: $0.proto) }, + load: ServerInfo.Load( + minute1: data.load.minute1, + minute5: data.load.minute5, + minute15: data.load.minute15, + percent: data.load.percent, + cpuCount: data.load.cpuCount, + level: data.load.level + ), + memory: ServerInfo.Memory( + free: data.memory.free, + used: data.memory.used, + total: data.memory.total, + percent: data.memory.percent + ), + swap: ServerInfo.Memory( + free: data.swap.free, + used: data.swap.used, + total: data.swap.total, + percent: data.swap.percent + ), + diskSpace: ServerInfo.DiskSpace( + free: data.diskSpace.free, + used: data.diskSpace.used, + total: data.diskSpace.total, + percent: data.diskSpace.percent + ), + panelVersion: data.panelVersion, + panelBuild: data.panelBuild, + apiVersion: meta.apiVersion, + additionalPHPInterpreters: data.additionalPhpInterpreters?.map { + ServerInfo.PHPInterpreter( + version: $0.version, + path: $0.path, + configFile: $0.configFile, + extensions: $0.extensions, + memoryLimit: $0.memoryLimit, + maxExecutionTime: $0.maxExecutionTime + ) + } + ) + } +} diff --git a/Model/API/ServerInfo.swift b/Model/API/ServerInfo.swift new file mode 100644 index 0000000..2859975 --- /dev/null +++ b/Model/API/ServerInfo.swift @@ -0,0 +1,177 @@ +import Foundation + +// MARK: - Server Info Domain Model + +struct ServerInfo: Codable, Hashable, Equatable { + struct Load: Codable, Hashable, Equatable { + let minute1: Double + let minute5: Double + let minute15: Double + let percent: Double + let cpuCount: Int + let level: String + + init(minute1: Double, minute5: Double, minute15: Double, percent: Double, cpuCount: Int, level: String) { + self.minute1 = minute1 + self.minute5 = minute5 + self.minute15 = minute15 + self.percent = percent + self.cpuCount = cpuCount + self.level = level + } + } + + struct Memory: Codable, Hashable, Equatable { + let free: Int + let used: Int + let total: Int + let percent: Double + + init(free: Int, used: Int, total: Int, percent: Double) { + self.free = free + self.used = used + self.total = total + self.percent = percent + } + } + + struct DiskSpace: Codable, Hashable, Equatable { + let free: Int + let used: Int + let total: Int + let percent: Double + + init(free: Int, used: Int, total: Int, percent: Double) { + self.free = free + self.used = used + self.total = total + self.percent = percent + } + } + + struct ServicePort: Codable, Hashable, Identifiable, Equatable { + var id: String { "\(service)-\(port)-\(proto)" } + let service: String + let status: String + let port: Int + let proto: String + + init(service: String, status: String, port: Int, proto: String) { + self.service = service + self.status = status + self.port = port + self.proto = proto + } + } + + struct PHPInterpreter: Codable, Hashable, Identifiable, Equatable { + var id: String { versionFull } + let version: String + let path: String? + let configFile: String? + let extensions: [String] + let memoryLimit: String? + let maxExecutionTime: String? + + init( + version: String, + path: String? = nil, + configFile: String? = nil, + extensions: [String] = [], + memoryLimit: String? = nil, + maxExecutionTime: String? = nil + ) { + self.version = version + self.path = path + self.configFile = configFile + self.extensions = extensions + self.memoryLimit = memoryLimit + self.maxExecutionTime = maxExecutionTime + } + + var versionFull: String { + var components = [version] + if let path, !path.isEmpty { + components.append(path) + } + return components.joined(separator: " – ") + } + } + + var hostname: String + var ipAddresses: [String] + var cpuCores: Int + var serverTime: String + var uptime: String + var processCount: Int + var apacheVersion: String + var phpVersion: String + var mysqlVersion: String? + var mariadbVersion: String? + var ports: [ServicePort]? + var load: Load + var memory: Memory + var swap: Memory + var diskSpace: DiskSpace + var panelVersion: String + var panelBuild: String + var apiVersion: String + var additionalPHPInterpreters: [PHPInterpreter]? + + var formattedVersion: String { + "KeyHelp \(panelVersion) • Build \(panelBuild) • API \(apiVersion)" + } + + var formattedServerTime: String { + guard let date = ServerInfo.isoFormatter.date(from: serverTime) else { + return serverTime + } + return ServerInfo.displayFormatter.string(from: date) + } +} + +// MARK: - Helpers & Sample Data + +extension ServerInfo { + private static let isoFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds, .withColonSeparatorInTimeZone] + return formatter + }() + + private static let displayFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .medium + return formatter + }() + + static let placeholder = ServerInfo( + hostname: "preview.example.com", + ipAddresses: ["192.168.1.1", "fe80::1"], + cpuCores: 4, + serverTime: "2025-04-04T18:00:00+0200", + uptime: "3 Days / 12 Hours / 30 Minutes", + processCount: 123, + apacheVersion: "2.4.58", + phpVersion: "8.2.12", + mysqlVersion: "8.0.33", + mariadbVersion: nil, + ports: [ + ServicePort(service: "HTTP", status: "online", port: 80, proto: "tcp"), + ServicePort(service: "HTTPS", status: "online", port: 443, proto: "tcp"), + ServicePort(service: "SSH", status: "offline", port: 22, proto: "tcp") + ], + load: Load(minute1: 0.5, minute5: 0.3, minute15: 0.2, percent: 10.0, cpuCount: 4, level: "low"), + memory: Memory(free: 8_000_000_000, used: 4_000_000_000, total: 12_000_000_000, percent: 33.3), + swap: Memory(free: 4_000_000_000, used: 1_000_000_000, total: 5_000_000_000, percent: 20.0), + diskSpace: DiskSpace(free: 100_000_000_000, used: 50_000_000_000, total: 150_000_000_000, percent: 33.3), + panelVersion: "25.0", + panelBuild: "3394", + apiVersion: "2", + additionalPHPInterpreters: [ + PHPInterpreter(version: "8.3", path: "/usr/bin/php8.3"), + PHPInterpreter(version: "8.2", path: "/usr/bin/php8.2") + ] + ) +} diff --git a/Model/API/ServerTypes.swift b/Model/API/ServerTypes.swift new file mode 100644 index 0000000..3544041 --- /dev/null +++ b/Model/API/ServerTypes.swift @@ -0,0 +1,73 @@ +// +// ServerTypes_Fixed.swift +// iKeyMon +// +// Fixed server types matching your existing code structure +// + +import Foundation + +// MARK: - Server API Response Types +struct ServerAPIResponse: Codable { + let data: T + let status: String + let timestamp: Date + let version: String + + enum CodingKeys: String, CodingKey { + case data + case status + case timestamp + case version + } +} + +// MARK: - Server API Version +struct ServerAPIVersion: Codable { + let major: Int + let minor: Int + let patch: Int + let string: String + + init(major: Int, minor: Int, patch: Int = 0) { + self.major = major + self.minor = minor + self.patch = patch + self.string = "\(major).\(minor).\(patch)" + } + + init(from versionString: String) throws { + let components = versionString.split(separator: ".").compactMap { Int($0) } + guard components.count >= 2 else { + throw DecodingError.dataCorrupted( + DecodingError.Context(codingPath: [], debugDescription: "Invalid version string format") + ) + } + + self.major = components[0] + self.minor = components[1] + self.patch = components.count > 2 ? components[2] : 0 + self.string = versionString + } +} + +// MARK: - Server API Response Factory +class ServerAPIResponseFactory { + static func createResponse(data: T, status: String = "success") -> ServerAPIResponse { + return ServerAPIResponse( + data: data, + status: status, + timestamp: Date(), + version: "1.0" + ) + } + + static func createErrorResponse(error: String) -> ServerAPIResponse { + return ServerAPIResponse( + data: error, + status: "error", + timestamp: Date(), + version: "1.0" + ) + } +} diff --git a/Model/API/Versions/APIv2_12.swift b/Model/API/Versions/APIv2_12.swift new file mode 100644 index 0000000..aef492a --- /dev/null +++ b/Model/API/Versions/APIv2_12.swift @@ -0,0 +1,170 @@ +// +// APIv2_12.swift +// iKeyMon +// +// Created by tracer on 13.11.25. +// + + +import Foundation + +extension APIv2_12 { + struct Load: Codable { + let current: LoadMetrics + let historical: [LoadMetrics] + + struct LoadMetrics: Codable { + let oneMinute: Double + let fiveMinute: Double + let fifteenMinute: Double + let timestamp: Date + + enum CodingKeys: String, CodingKey { + case oneMinute = "load_1" + case fiveMinute = "load_5" + case fifteenMinute = "load_15" + case timestamp + } + } + } + + struct Memory: Codable { + let system: SystemMemory + let swap: SwapMemory? + + struct SystemMemory: Codable { + let total: Int64 + let used: Int64 + let free: Int64 + let available: Int64 + let buffers: Int64? + let cached: Int64? + } + + struct SwapMemory: Codable { + let total: Int64 + let used: Int64 + let free: Int64 + } + } + + struct Utilization: Codable { + let cpu: CPUUtilization + let memory: MemoryUtilization + let disk: [DiskUtilization] + let network: [NetworkUtilization]? + + struct CPUUtilization: Codable { + let overall: Double + let cores: [Double] + let processes: [ProcessInfo]? + + struct ProcessInfo: Codable { + let pid: Int + let name: String + let cpuPercent: Double + + enum CodingKeys: String, CodingKey { + case pid + case name + case cpuPercent = "cpu_percent" + } + } + } + + struct MemoryUtilization: Codable { + let percent: Double + let topProcesses: [ProcessMemoryInfo]? + + struct ProcessMemoryInfo: Codable { + let pid: Int + let name: String + let memoryMB: Double + + enum CodingKeys: String, CodingKey { + case pid + case name + case memoryMB = "memory_mb" + } + } + } + + struct DiskUtilization: Codable { + let device: String + let mountpoint: String + let usedPercent: Double + let totalBytes: Int64 + let usedBytes: Int64 + let freeBytes: Int64 + + enum CodingKeys: String, CodingKey { + case device + case mountpoint + case usedPercent = "used_percent" + case totalBytes = "total_bytes" + case usedBytes = "used_bytes" + case freeBytes = "free_bytes" + } + } + + struct NetworkUtilization: Codable { + let interface: String + let bytesIn: Int64 + let bytesOut: Int64 + let packetsIn: Int64 + let packetsOut: Int64 + + enum CodingKeys: String, CodingKey { + case interface + case bytesIn = "bytes_in" + case bytesOut = "bytes_out" + case packetsIn = "packets_in" + case packetsOut = "packets_out" + } + } + } +} + +class APIv2_12: BaseAPIClient, ServerAPIProtocol { + typealias LoadType = APIv2_12.Load + typealias MemoryType = APIv2_12.Memory + typealias UtilizationType = APIv2_12.Utilization + + private enum Endpoint: String { + case systemInfo = "/api/v2.12/system/info" + case load = "/api/v2.12/metrics/load" + case memory = "/api/v2.12/metrics/memory" + case utilization = "/api/v2.12/metrics/utilization" + + func url(baseURL: URL) -> URL { + return baseURL.appendingPathComponent(self.rawValue) + } + } + + func fetchSystemInfo() async throws -> SystemInfo { + let url = Endpoint.systemInfo.url(baseURL: baseURL) + let request = URLRequest(url: url) + return try await performRequest(request, responseType: SystemInfo.self) + } + + func fetchLoad() async throws -> LoadType { + let url = Endpoint.load.url(baseURL: baseURL) + var request = URLRequest(url: url) + request.setValue("application/json", forHTTPHeaderField: "Accept") + return try await performRequest(request, responseType: LoadType.self) + } + + func fetchMemory() async throws -> MemoryType { + let url = Endpoint.memory.url(baseURL: baseURL) + var request = URLRequest(url: url) + request.setValue("application/json", forHTTPHeaderField: "Accept") + return try await performRequest(request, responseType: MemoryType.self) + } + + func fetchUtilization() async throws -> UtilizationType { + let url = Endpoint.utilization.url(baseURL: baseURL) + var request = URLRequest(url: url) + request.setValue("application/json", forHTTPHeaderField: "Accept") + return try await performRequest(request, responseType: UtilizationType.self) + } +} diff --git a/iKeyMon/Preview Content/Preview Assets.xcassets/Contents.json b/Preview Content/Preview Assets.xcassets/Contents.json similarity index 100% rename from iKeyMon/Preview Content/Preview Assets.xcassets/Contents.json rename to Preview Content/Preview Assets.xcassets/Contents.json diff --git a/iKeyMon/Views/Cells/InfoCell.swift b/Views/Cells/InfoCell.swift similarity index 100% rename from iKeyMon/Views/Cells/InfoCell.swift rename to Views/Cells/InfoCell.swift diff --git a/iKeyMon/Views/Cells/LoadBarCell.swift b/Views/Cells/LoadBarCell.swift similarity index 100% rename from iKeyMon/Views/Cells/LoadBarCell.swift rename to Views/Cells/LoadBarCell.swift diff --git a/iKeyMon/Views/Cells/UsageBarCell.swift b/Views/Cells/UsageBarCell.swift similarity index 100% rename from iKeyMon/Views/Cells/UsageBarCell.swift rename to Views/Cells/UsageBarCell.swift diff --git a/iKeyMon/Views/MainView.swift b/Views/MainView.swift similarity index 99% rename from iKeyMon/Views/MainView.swift rename to Views/MainView.swift index 51a5f95..5d8d208 100644 --- a/iKeyMon/Views/MainView.swift +++ b/Views/MainView.swift @@ -148,7 +148,7 @@ struct MainView: View { let info = try await api.fetchServerInfo() if let index = servers.firstIndex(where: { $0.id == id }) { var updated = servers[index] - updated.info = ServerInfo(from: info) + updated.info = try ServerInfo(from: info as! Decoder) servers[index] = updated } } catch { diff --git a/iKeyMon/Views/Rows/InfoRow.swift b/Views/Rows/InfoRow.swift similarity index 100% rename from iKeyMon/Views/Rows/InfoRow.swift rename to Views/Rows/InfoRow.swift diff --git a/iKeyMon/Views/Rows/TableRowView.swift b/Views/Rows/TableRowView.swift similarity index 100% rename from iKeyMon/Views/Rows/TableRowView.swift rename to Views/Rows/TableRowView.swift diff --git a/iKeyMon/Views/ServerDetailView.swift b/Views/ServerDetailView.swift similarity index 68% rename from iKeyMon/Views/ServerDetailView.swift rename to Views/ServerDetailView.swift index d0b4f7a..2e5e071 100644 --- a/iKeyMon/Views/ServerDetailView.swift +++ b/Views/ServerDetailView.swift @@ -64,26 +64,7 @@ struct ServerDetailView: View { #Preview { ServerDetailView( - server: .constant(Server(id: UUID(), hostname: "preview.example.com", info: ServerInfo( - hostname: "preview.example.com", - ipAddresses: ["192.168.1.1", "fe80::1"], - cpuCores: 4, - serverTime: "2025-04-04T18:00:00+0200", - uptime: "3 Days / 12 Hours / 30 Minutes", - processCount: 123, - apacheVersion: "2.4.58", - phpVersion: "8.2.12", - mysqlVersion: "8.0.33", - mariadbVersion: nil, - ports: nil, - load: Load(minute1: 0.5, minute5: 0.3, minute15: 0.2, percent: 10.0, cpuCount: 4, level: "low"), - memory: Memory(free: 8_000_000_000, used: 4_000_000_000, total: 12_000_000_000, percent: 33.3), - swap: Memory(free: 4_000_000_000, used: 1_000_000_000, total: 5_000_000_000, percent: 20.0), - diskSpace: DiskSpace(free: 100_000_000_000, used: 50_000_000_000, total: 150_000_000_000, percent: 33.3), - panelVersion: "25.0", - panelBuild: "3394", - apiVersion: "2" - ))), + server: .constant(Server(id: UUID(), hostname: "preview.example.com", info: ServerInfo.placeholder)), isFetching: false ) } diff --git a/iKeyMon/Views/ServerFormView.swift b/Views/ServerFormView.swift similarity index 100% rename from iKeyMon/Views/ServerFormView.swift rename to Views/ServerFormView.swift diff --git a/iKeyMon/Views/Tabs/GeneralView.swift b/Views/Tabs/GeneralView.swift similarity index 100% rename from iKeyMon/Views/Tabs/GeneralView.swift rename to Views/Tabs/GeneralView.swift diff --git a/iKeyMon/Views/Tabs/ResourcesView.swift b/Views/Tabs/ResourcesView.swift similarity index 100% rename from iKeyMon/Views/Tabs/ResourcesView.swift rename to Views/Tabs/ResourcesView.swift diff --git a/iKeyMon/Views/Tabs/ServicesView.swift b/Views/Tabs/ServicesView.swift similarity index 100% rename from iKeyMon/Views/Tabs/ServicesView.swift rename to Views/Tabs/ServicesView.swift diff --git a/iKeyMon/iKeyMon.entitlements b/iKeyMon.entitlements similarity index 100% rename from iKeyMon/iKeyMon.entitlements rename to iKeyMon.entitlements diff --git a/iKeyMon.xcodeproj/project.pbxproj b/iKeyMon.xcodeproj/project.pbxproj index 460c658..3fdac3c 100644 --- a/iKeyMon.xcodeproj/project.pbxproj +++ b/iKeyMon.xcodeproj/project.pbxproj @@ -6,14 +6,41 @@ objectVersion = 77; objects = { +/* Begin PBXBuildFile section */ + 52A9B79B2EC8E7EE004DD4A2 /* iKeyMonApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52A9B7892EC8E7EE004DD4A2 /* iKeyMonApp.swift */; }; + 52A9B79C2EC8E7EE004DD4A2 /* KeychainHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52A9B78A2EC8E7EE004DD4A2 /* KeychainHelper.swift */; }; + 52A9B79F2EC8E7EE004DD4A2 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 52A9B7872EC8E7EE004DD4A2 /* Assets.xcassets */; }; + 52A9B8222EC8FA8A004DD4A2 /* CHANGELOG.md in Resources */ = {isa = PBXBuildFile; fileRef = 52A9B8212EC8FA8A004DD4A2 /* CHANGELOG.md */; }; +/* End PBXBuildFile section */ + /* Begin PBXFileReference section */ 5203C24D2D997D2800576D4A /* iKeyMon.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = iKeyMon.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 52A9B7872EC8E7EE004DD4A2 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 52A9B7882EC8E7EE004DD4A2 /* iKeyMon.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = iKeyMon.entitlements; sourceTree = ""; }; + 52A9B7892EC8E7EE004DD4A2 /* iKeyMonApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iKeyMonApp.swift; sourceTree = ""; }; + 52A9B78A2EC8E7EE004DD4A2 /* KeychainHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainHelper.swift; sourceTree = ""; }; + 52A9B8212EC8FA8A004DD4A2 /* CHANGELOG.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = CHANGELOG.md; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ - 5203C24F2D997D2800576D4A /* iKeyMon */ = { + 52A9B7A12EC8E84F004DD4A2 /* Extensions */ = { isa = PBXFileSystemSynchronizedRootGroup; - path = iKeyMon; + path = Extensions; + sourceTree = ""; + }; + 52A9B7A72EC8E857004DD4A2 /* Model */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Model; + sourceTree = ""; + }; + 52A9B7AC2EC8E85E004DD4A2 /* Preview Content */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = "Preview Content"; + sourceTree = ""; + }; + 52A9B7BC2EC8E86C004DD4A2 /* Views */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Views; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ @@ -32,8 +59,16 @@ 5203C2442D997D2800576D4A = { isa = PBXGroup; children = ( - 5203C24F2D997D2800576D4A /* iKeyMon */, + 52A9B7872EC8E7EE004DD4A2 /* Assets.xcassets */, + 52A9B7A72EC8E857004DD4A2 /* Model */, + 52A9B7882EC8E7EE004DD4A2 /* iKeyMon.entitlements */, + 52A9B7A12EC8E84F004DD4A2 /* Extensions */, + 52A9B7AC2EC8E85E004DD4A2 /* Preview Content */, + 52A9B7892EC8E7EE004DD4A2 /* iKeyMonApp.swift */, + 52A9B7BC2EC8E86C004DD4A2 /* Views */, + 52A9B78A2EC8E7EE004DD4A2 /* KeychainHelper.swift */, 5203C24E2D997D2800576D4A /* Products */, + 52A9B8212EC8FA8A004DD4A2 /* CHANGELOG.md */, ); sourceTree = ""; }; @@ -61,7 +96,10 @@ dependencies = ( ); fileSystemSynchronizedGroups = ( - 5203C24F2D997D2800576D4A /* iKeyMon */, + 52A9B7A12EC8E84F004DD4A2 /* Extensions */, + 52A9B7A72EC8E857004DD4A2 /* Model */, + 52A9B7AC2EC8E85E004DD4A2 /* Preview Content */, + 52A9B7BC2EC8E86C004DD4A2 /* Views */, ); name = iKeyMon; packageProductDependencies = ( @@ -109,6 +147,8 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 52A9B8222EC8FA8A004DD4A2 /* CHANGELOG.md in Resources */, + 52A9B79F2EC8E7EE004DD4A2 /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -119,6 +159,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 52A9B79B2EC8E7EE004DD4A2 /* iKeyMonApp.swift in Sources */, + 52A9B79C2EC8E7EE004DD4A2 /* KeychainHelper.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -248,11 +290,11 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ENTITLEMENTS = iKeyMon/iKeyMon.entitlements; + CODE_SIGN_ENTITLEMENTS = iKeyMon.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_ASSET_PATHS = "\"iKeyMon/Preview Content\""; + DEVELOPMENT_ASSET_PATHS = "\"Preview Content\""; DEVELOPMENT_TEAM = Q5486ZVAFT; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; @@ -275,11 +317,11 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ENTITLEMENTS = iKeyMon/iKeyMon.entitlements; + CODE_SIGN_ENTITLEMENTS = iKeyMon.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_ASSET_PATHS = "\"iKeyMon/Preview Content\""; + DEVELOPMENT_ASSET_PATHS = "\"Preview Content\""; DEVELOPMENT_TEAM = Q5486ZVAFT; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; diff --git a/iKeyMon/Model/API/ServerResponse.swift b/iKeyMon/Model/API/ServerResponse.swift deleted file mode 100644 index d11170e..0000000 --- a/iKeyMon/Model/API/ServerResponse.swift +++ /dev/null @@ -1,179 +0,0 @@ -// -// ServerResponse.swift -// iKeyMon -// -// Created by tracer on 30.03.25. -// - -import Foundation -import Foundation - -// MARK: - Root Response -struct ServerResponse: Decodable { - let meta: Meta - let operatingSystem: OperatingSystem - let utilization: Utilization - let resources: Resources - let components: Components - let additionalPHPInterpreters: [PHPInterpreter]? - let ports: [ServicePort]? - - enum CodingKeys: String, CodingKey { - case meta - case operatingSystem = "operating_system" - case utilization, resources, components - case additionalPHPInterpreters = "additional_php_interpreters" - case ports - } -} - -// MARK: - Meta -struct Meta: Decodable { - let hostname: String - let ipAddresses: [String] - let serverTime: String - let uptime: Uptime - let panelVersion: String - let panelBuild: String - let apiVersion: String - let apiDocs: String - let keyhelpPro: Bool - - enum CodingKeys: String, CodingKey { - case hostname - case ipAddresses = "ip_addresses" - case serverTime = "server_time" - case uptime - case panelVersion = "panel_version" - case panelBuild = "panel_build" - case apiVersion = "api_version" - case apiDocs = "api_docs" - case keyhelpPro = "keyhelp_pro" - } -} - -struct Uptime: Decodable { - let days, hours, minutes, seconds: Int -} - -// MARK: - OperatingSystem -struct OperatingSystem: Decodable { - let label, distribution, version, architecture: String - let endOfLife: Bool - let updates: Updates - - enum CodingKeys: String, CodingKey { - case label, distribution, version, architecture - case endOfLife = "end_of_life" - case updates - } -} - -struct Updates: Decodable { - let updateCount, securityUpdateCount: Int - let rebootRequired: Bool - - enum CodingKeys: String, CodingKey { - case updateCount = "update_count" - case securityUpdateCount = "security_update_count" - case rebootRequired = "reboot_required" - } -} - -// MARK: - Utilization -struct Utilization: Decodable { - let processCount, emailsInQueue: Int - let load: Load - let diskSpace: DiskSpace - let inodes: Inodes - let memory, swap: Memory - - enum CodingKeys: String, CodingKey { - case processCount = "process_count" - case emailsInQueue = "emails_in_queue" - case load - case diskSpace = "disk_space" - case inodes, memory, swap - } -} - -struct Load: Decodable { - let minute1, minute5, minute15, percent: Double - let cpuCount: Int - let level: String - - enum CodingKeys: String, CodingKey { - case minute1 = "minute_1" - case minute5 = "minute_5" - case minute15 = "minute_15" - case cpuCount = "cpu_count" - case percent, level - } -} - -struct DiskSpace: Decodable { - let free, used, total: Int - let percent: Double -} - -struct Inodes: Decodable { - let free, used, total: Int - let percent: Double -} - -struct Memory: Decodable { - let free, used, total: Int - let percent: Double -} - -// MARK: - Resources -struct Resources: Decodable { - let adminAccounts, clientAccounts, domains, subdomains: Int - let emailAccounts, emailAddresses, emailForwardings, databases: Int - let ftpUsers, scheduledTasks: Int - let consumedDiskSpace, traffic: Int - - enum CodingKeys: String, CodingKey { - case adminAccounts = "admin_accounts" - case clientAccounts = "client_accounts" - case domains, subdomains - case emailAccounts = "email_accounts" - case emailAddresses = "email_addresses" - case emailForwardings = "email_forwardings" - case databases - case ftpUsers = "ftp_users" - case scheduledTasks = "scheduled_tasks" - case consumedDiskSpace = "consumed_disk_space" - case traffic - } -} - -// MARK: - Components -struct Components: Decodable { - let kernel, apache, php, proftpd, dovecot, postfix: String - let mariadb, mysql: String? -} - -struct PHPInterpreter: Decodable { - let version, versionFull: String - - enum CodingKeys: String, CodingKey { - case version - case versionFull = "version_full" - } -} - -struct ServicePort: Decodable, Identifiable { - let service: String - let port: Int - let proto: String - let status: String - var id: String { service } - - enum CodingKeys: String, CodingKey { - case service - case port - case proto = "protocol" - case status - } -} diff --git a/iKeyMon/Model/ServerInfo.swift b/iKeyMon/Model/ServerInfo.swift deleted file mode 100644 index fd60e36..0000000 --- a/iKeyMon/Model/ServerInfo.swift +++ /dev/null @@ -1,124 +0,0 @@ -// -// ServerInfo.swift -// iKeyMon -// -// Created by tracer on 30.03.25. -// - -import Foundation - -struct ServerInfo: Decodable, Equatable { - static func == (lhs: ServerInfo, rhs: ServerInfo) -> Bool { - lhs.hostname == rhs.hostname && lhs.serverTime == rhs.serverTime - } - - var hostname: String - var ipAddresses: [String] -// var processor: String - var cpuCores: Int -// var virtualization: String - var serverTime: String - var uptime: String -// var sshFingerprint: String - var processCount: Int - var apacheVersion: String - var phpVersion: String - var mysqlVersion: String? - var mariadbVersion: String? - var ports: [ServicePort]? - var load: Load - var memory: Memory - var swap: Memory - var diskSpace: DiskSpace - var panelVersion: String - var panelBuild: String - var apiVersion: String - var additionalPHPInterpreters: [PHPInterpreter]? - - -} - -extension ServerInfo { - static let placeholder = ServerInfo( - hostname: "keyhelp.lab.24unix.net", - ipAddresses: ["192.168.99.44", "2a03:..."], -// processor: "Common processor (arm64)", - cpuCores: 8, -// virtualization: "QEMU", - serverTime: "Sunday, March 30, 2025 at 08:01 PM (Europe/Berlin)", - uptime: "6 Days / 7 Hours / 16 Minutes", -// sshFingerprint: "Ed25519 / ECDSA / RSA", - processCount: 123, - apacheVersion: "2.4", - phpVersion: "8.4", - ports: [], - load: Load( - minute1: 0.42, - minute5: 0.31, - minute15: 0.29, - percent: 10.5, - cpuCount: 4, - level: "low" - ), - memory: Memory(free: 1234, used: 4567, total: 123456, percent: 23.45), - swap: Memory(free: 1234, used: 4567, total: 123456, percent: 23.45), - diskSpace: DiskSpace(free: 1234, used: 4567, total: 123456, percent: 23.45), - panelVersion: "25.0", - panelBuild: "3394", - apiVersion: "2.0" - ) - - var cpuLoadDetail: String { - "1min: \(load.minute1), 5min: \(load.minute5), 15min: \(load.minute15) (\(load.level))" - } - - var formattedServerTime: String { - let isoFormatter = ISO8601DateFormatter() - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "de_DE") - formatter.dateStyle = .medium - formatter.timeStyle = .short - - if let date = isoFormatter.date(from: serverTime) { - return formatter.string(from: date) - } else { - return serverTime // fallback to raw string if parsing fails - } - } - - var formattedVersion: String { - "\(panelVersion) (Build \(panelBuild))" - } -} - -extension ServerInfo { - init(from response: ServerResponse) { - self.hostname = response.meta.hostname - self.ipAddresses = response.meta.ipAddresses - self.serverTime = response.meta.serverTime - - let u = response.meta.uptime - self.uptime = "\(u.days) Days / \(u.hours) Hours / \(u.minutes) Minutes" - - self.cpuCores = response.utilization.load.cpuCount - self.processCount = response.utilization.processCount - - self.apacheVersion = response.components.apache - self.phpVersion = response.components.php - - self.mysqlVersion = response.components.mysql ?? "" - self.mariadbVersion = response.components.mariadb ?? "" - - self.ports = response.ports - - self.load = response.utilization.load - self.memory = response.utilization.memory - self.swap = response.utilization.swap - self.diskSpace = response.utilization.diskSpace - - self.panelVersion = response.meta.panelVersion - self.panelBuild = response.meta.panelBuild - self.apiVersion = response.meta.apiVersion - self.additionalPHPInterpreters = response.additionalPHPInterpreters - } -} diff --git a/iKeyMon/ServerAPI.swift b/iKeyMon/ServerAPI.swift deleted file mode 100644 index c062cd3..0000000 --- a/iKeyMon/ServerAPI.swift +++ /dev/null @@ -1,67 +0,0 @@ -// -// ServerAPI.swift -// iKeyMon -// -// Created by tracer on 06.04.25. -// -import Foundation - -final class ServerAPI { - private let hostname: String - private let apiKey: String - - init(hostname: String, apiKey: String) { - self.hostname = hostname - self.apiKey = apiKey - } - - init?(server: Server) { - guard let apiKey = KeychainHelper.loadApiKey(for: server.hostname)?.trimmingCharacters(in: .whitespacesAndNewlines) else { - return nil - } - self.hostname = server.hostname - self.apiKey = apiKey - } - - @discardableResult - func ping() async -> Bool { - guard let url = URL(string: "https://\(hostname)/api/v2/ping") else { - return false - } - - var request = URLRequest(url: url) - request.httpMethod = "GET" - request.setValue(apiKey, forHTTPHeaderField: "X-API-KEY") - - do { - let (data, _ /*response */) = try await URLSession.shared.data(for: request) -// if let httpResponse = response as? HTTPURLResponse { -// print("data: \(String(data: data, encoding: .utf8))") -// } - - if let result = try? JSONDecoder().decode([String: String].self, from: data), result["response"] == "pong" { - return true - } - } catch { - print("❌ Ping error: \(error)") - } - - return false - } - - func fetchServerInfo() async throws -> ServerResponse { - guard let url = URL(string: "https://\(hostname)/api/v2/server") else { - throw URLError(.badURL) - } - - var request = URLRequest(url: url) - request.httpMethod = "GET" - request.setValue(apiKey, forHTTPHeaderField: "X-API-KEY") - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - - let (data, _) = try await URLSession.shared.data(for: request) - let decoded = try JSONDecoder().decode(ServerResponse.self, from: data) - - return decoded - } -} diff --git a/iKeyMon/iKeyMonApp.swift b/iKeyMonApp.swift similarity index 100% rename from iKeyMon/iKeyMonApp.swift rename to iKeyMonApp.swift